diff --git a/api/package.json b/api/package.json index 747215e..9d23a7e 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "stellar-streaming-api", - "version": "1.0.0", + "version": "1.1.0", "description": "Stellar Streaming API built with NestJS", "main": "dist/main.js", "scripts": { @@ -57,6 +57,7 @@ "@nestjs/testing": "^10.4.22", "@types/bcrypt": "^6.0.0", "@types/compression": "^1.8.1", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.6", "@types/jest": "^29.5.14", "@types/node": "^20.10.0", diff --git a/api/src/auth/auth.controller.spec.ts b/api/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..9c76eef --- /dev/null +++ b/api/src/auth/auth.controller.spec.ts @@ -0,0 +1,96 @@ +import { UnauthorizedException } from "@nestjs/common" +import { AuthController } from "./auth.controller" +import { AuthResponse, AuthService } from "./auth.service" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface MockAuthService { + refresh: jest.Mock> +} + +function mockAuthService(): MockAuthService { + return { + refresh: jest.fn(), + } +} + +function makeController(service: MockAuthService): AuthController { + return new AuthController(service as unknown as AuthService) +} + +function authResponse(): AuthResponse { + return { + user: { + id: 1, + username: "testuser", + email: "test@example.com", + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + accessToken: "access.token", + refreshToken: "refresh.token", + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("AuthController", () => { + let service: MockAuthService + let controller: AuthController + + beforeEach(() => { + service = mockAuthService() + controller = makeController(service) + jest.clearAllMocks() + }) + + describe("refresh", () => { + it("forwards the body refresh token to the service", async () => { + service.refresh.mockResolvedValue(authResponse()) + + const result = await controller.refresh("body.token", undefined) + + expect(service.refresh).toHaveBeenCalledWith("body.token") + expect(result).toEqual(authResponse()) + }) + + it("falls back to the refresh_token cookie when no body token is present", async () => { + service.refresh.mockResolvedValue(authResponse()) + + const result = await controller.refresh(undefined, { + cookies: { refresh_token: "cookie.token" }, + }) + + expect(service.refresh).toHaveBeenCalledWith("cookie.token") + expect(result).toEqual(authResponse()) + }) + + it("prefers the body token over the cookie when both are present", async () => { + service.refresh.mockResolvedValue(authResponse()) + + await controller.refresh("body.token", { + cookies: { refresh_token: "cookie.token" }, + }) + + expect(service.refresh).toHaveBeenCalledWith("body.token") + expect(service.refresh).not.toHaveBeenCalledWith("cookie.token") + }) + + it("throws UnauthorizedException when neither body token nor cookie is provided", () => { + expect(() => controller.refresh(undefined, undefined)).toThrow( + UnauthorizedException, + ) + expect(service.refresh).not.toHaveBeenCalled() + }) + + it("throws UnauthorizedException when the cookie is empty", () => { + expect(() => controller.refresh(undefined, { cookies: {} })).toThrow( + UnauthorizedException, + ) + expect(service.refresh).not.toHaveBeenCalled() + }) + }) +}) diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 3af5af7..a4ce928 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -159,4 +159,27 @@ export class AuthController { message: "Password has been reset successfully.", } } + + @Post("refresh") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Refresh an expired access token", + description: + "Accepts a refresh token via the request body (`refreshToken`) or via " + + "the `refresh_token` httpOnly cookie. Returns a fresh access token, " + + "refresh token, and the user profile.", + }) + @ApiOkResponse({ + description: "Token refresh successful. New token pair returned.", + }) + refresh( + @Body("refreshToken") bodyToken?: string, + @Req() req?: { cookies?: Record }, + ): Promise { + const token = bodyToken ?? req?.cookies?.refresh_token + if (!token) { + throw new UnauthorizedException("refresh token is required") + } + return this.authService.refresh(token) + } } diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index 75a6375..81d227e 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -586,4 +586,73 @@ describe("AuthService", () => { await expect(service.refresh(req)).rejects.toThrow(UnauthorizedException) }) }) + + // -- refresh ----------------------------------------------------------- + + describe("refresh", () => { + it("returns new token pair for a valid refresh token", async () => { + const user = dummyUser() + jwt.verify.mockReturnValue({ sub: user.id }) + users.findById.mockResolvedValue(user) + jwt.sign.mockReturnValueOnce("new.access.token").mockReturnValueOnce("new.refresh.token") + + const result = await service.refresh("valid.refresh.token") + + expect(jwt.verify).toHaveBeenCalledWith("valid.refresh.token") + expect(users.findById).toHaveBeenCalledWith(user.id) + expect(result.accessToken).toBe("new.access.token") + expect(result.refreshToken).toBe("new.refresh.token") + expect(result.user).toEqual({ + id: user.id, + username: user.username, + email: user.email, + createdAt: user.created_at, + }) + }) + + it("throws UnauthorizedException when the refresh token is invalid or expired", async () => { + jwt.verify.mockImplementation(() => { + throw new Error("jwt expired") + }) + + await expect(service.refresh("expired.token")).rejects.toThrow( + UnauthorizedException, + ) + expect(users.findById).not.toHaveBeenCalled() + }) + + it("throws UnauthorizedException when the user no longer exists", async () => { + jwt.verify.mockReturnValue({ sub: 999 }) + users.findById.mockResolvedValue(null) + + await expect(service.refresh("valid.for.deleted.user")).rejects.toThrow( + UnauthorizedException, + ) + expect(jwt.sign).not.toHaveBeenCalled() + }) + + it("signs the access token with the standard short-lived payload", async () => { + const user = dummyUser() + jwt.verify.mockReturnValue({ sub: user.id }) + users.findById.mockResolvedValue(user) + jwt.sign + .mockReturnValueOnce("access") + .mockReturnValueOnce("refresh") + + await service.refresh("token") + + // First call: access token (short-lived, full claims) + expect(jwt.sign).toHaveBeenNthCalledWith(1, { + sub: user.id, + email: user.email, + username: user.username, + }) + // Second call: refresh token (long-lived, sub only) + expect(jwt.sign).toHaveBeenNthCalledWith( + 2, + { sub: user.id }, + { expiresIn: "7d" }, + ) + }) + }) }) diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 9dd22d7..0d03ec8 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -329,6 +329,14 @@ export class AuthService { jti: randomUUID(), }) } + + /** Create a long-lived JWT refresh token for the given user. */ + private signRefreshToken(user: User): string { + return this.jwtService.sign( + { sub: user.id }, + { expiresIn: "7d" }, + ) + } } /** Strip the password hash from a user row before returning to clients. */ diff --git a/api/src/auth/users.repository.ts b/api/src/auth/users.repository.ts index 531ae4b..a8ef4bb 100644 --- a/api/src/auth/users.repository.ts +++ b/api/src/auth/users.repository.ts @@ -32,6 +32,14 @@ export class UsersRepository { return rows[0] ?? null } + async findById(id: number): Promise { + const { rows } = await this.pool.query( + "SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1", + [id], + ) + return rows[0] ?? null + } + async findByUsername(username: string): Promise { const { rows } = await this.pool.query( "SELECT id, username, email, password_hash, created_at, password_changed_at, is_admin FROM users WHERE username = $1", diff --git a/api/src/main.ts b/api/src/main.ts index 32e4478..7aeecf2 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -3,6 +3,7 @@ import { ValidationPipe } from "@nestjs/common" import { HttpAdapterHost, NestFactory } from "@nestjs/core" import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger" import compression from "compression" +import cookieParser from "cookie-parser" import helmet from "helmet" import * as cookieParser from "cookie-parser" import { AppModule } from "./app.module" @@ -27,6 +28,9 @@ async function bootstrap() { const app = await NestFactory.create(AppModule) + // Parse cookies for refresh-token handling. + app.use(cookieParser()) + // Issue #89: Apply Helmet middleware globally for secure HTTP headers app.use( helmet({ diff --git a/package-lock.json b/package-lock.json index e4d1a27..22bcd01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "@nestjs/testing": "^10.4.22", "@types/bcrypt": "^6.0.0", "@types/compression": "^1.8.1", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.6", "@types/jest": "^29.5.14", "@types/node": "^20.10.0", diff --git a/xstreamroll-sdk/README.md b/xstreamroll-sdk/README.md index 8e14893..338c29f 100644 --- a/xstreamroll-sdk/README.md +++ b/xstreamroll-sdk/README.md @@ -79,10 +79,11 @@ const client = new StreamingClient({ }) // 1. Log in -const { accessToken, refreshToken } = await client.login( +const { user, accessToken, refreshToken } = await client.login( "alice@example.com", "super-secret-password", ) +// user: { id, email, displayName, role, createdAt, updatedAt } // 2. Publish an event to an existing stream. // `clientId` is auto-filled with a stable per-instance id; pass @@ -150,6 +151,11 @@ const registerTokens = await client.register({ password: "super-secret-password", displayName: "Alice", }) +// tokens: { user, accessToken, refreshToken } + +// Refresh an expired access token +const freshTokens = await client.refreshToken() +// freshTokens: { user, accessToken, refreshToken } ``` `StreamingClient` keeps the active tokens on the instance and: diff --git a/xstreamroll-sdk/__tests__/client.test.ts b/xstreamroll-sdk/__tests__/client.test.ts index 053565f..aadded1 100644 --- a/xstreamroll-sdk/__tests__/client.test.ts +++ b/xstreamroll-sdk/__tests__/client.test.ts @@ -1,11 +1,63 @@ +import axios from "axios" import { StreamingClient } from "../src/client" +import type { AuthResponse } from "../src/types" + +jest.mock("axios") +const mockedAxios = axios as jest.Mocked // Helper to read the private apiUrl field for test assertions. function getApiUrl(client: StreamingClient): string { return (client as unknown as { apiUrl: string }).apiUrl } +// Helper to access the private tokens field. +function getTokens(client: StreamingClient): AuthResponse | null { + return (client as unknown as { tokens: AuthResponse | null }).tokens +} + +function setTokens(client: StreamingClient, tokens: AuthResponse): void { + ;(client as unknown as { tokens: AuthResponse }).tokens = tokens +} + +// Create a mock axios instance with interceptors and post/get methods. +// Axios instances are callable functions with properties. +function mockAxiosInstance() { + const fn = jest.fn() + const instance = Object.assign(fn, { + get: jest.fn(), + post: jest.fn(), + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + }) + return instance +} + +function mockAuthResponse(overrides: Partial = {}): AuthResponse { + return { + user: { + id: "1", + email: "test@example.com", + displayName: "Test User", + role: "viewer", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + accessToken: "access.token.here", + refreshToken: "refresh.token.here", + ...overrides, + } +} + +// ── Env Preset Tests ──────────────────────────────────────────────────────── + describe("StreamingClient env presets", () => { + beforeEach(() => { + jest.clearAllMocks() + mockedAxios.create.mockReturnValue(mockAxiosInstance() as never) + }) + it("defaults to development URL when no config given", () => { const client = new StreamingClient({}) expect(getApiUrl(client)).toBe("http://localhost:3001") @@ -47,3 +99,253 @@ describe("StreamingClient env presets", () => { expect(http.constructor.name).toBe("HttpClient") }) }) + +// ── Auth Tests ────────────────────────────────────────────────────────────── + +describe("StreamingClient auth", () => { + let httpInstance: ReturnType + + beforeEach(() => { + jest.clearAllMocks() + httpInstance = mockAxiosInstance() + mockedAxios.create.mockReturnValue(httpInstance as never) + }) + + // -- login ------------------------------------------------------------- + + describe("login", () => { + it("returns AuthResponse with user, accessToken, and refreshToken", async () => { + const authResp = mockAuthResponse() + httpInstance.post.mockResolvedValue({ data: authResp }) + + const client = new StreamingClient({}) + const result = await client.login("alice@example.com", "password") + + expect(httpInstance.post).toHaveBeenCalledWith("/auth/login", { + email: "alice@example.com", + password: "password", + }) + expect(result).toEqual(authResp) + expect(result.user).toBeDefined() + expect(result.user.email).toBe("test@example.com") + expect(result.accessToken).toBe("access.token.here") + expect(result.refreshToken).toBe("refresh.token.here") + }) + + it("stores tokens on the instance after login", async () => { + const authResp = mockAuthResponse() + httpInstance.post.mockResolvedValue({ data: authResp }) + + const client = new StreamingClient({}) + await client.login("alice@example.com", "password") + + expect(getTokens(client)).toEqual(authResp) + }) + + it("does not include expiresIn in the response shape", async () => { + const authResp = mockAuthResponse() + httpInstance.post.mockResolvedValue({ data: authResp }) + + const client = new StreamingClient({}) + const result = await client.login("alice@example.com", "password") + + expect((result as unknown as Record).expiresIn).toBeUndefined() + }) + }) + + // -- register ---------------------------------------------------------- + + describe("register", () => { + it("returns AuthResponse with user, accessToken, and refreshToken", async () => { + const authResp = mockAuthResponse() + httpInstance.post.mockResolvedValue({ data: authResp }) + + const client = new StreamingClient({}) + const dto = { + email: "new@example.com", + password: "password", + displayName: "New User", + } + const result = await client.register(dto) + + expect(httpInstance.post).toHaveBeenCalledWith("/auth/register", dto) + expect(result).toEqual(authResp) + expect(result.user).toBeDefined() + expect(result.accessToken).toBe("access.token.here") + expect(result.refreshToken).toBe("refresh.token.here") + }) + + it("stores tokens on the instance after register", async () => { + const authResp = mockAuthResponse() + httpInstance.post.mockResolvedValue({ data: authResp }) + + const client = new StreamingClient({}) + await client.register({ + email: "new@example.com", + password: "password", + displayName: "New User", + }) + + expect(getTokens(client)).toEqual(authResp) + }) + }) + + // -- refreshToken ------------------------------------------------------ + + describe("refreshToken", () => { + it("sends the stored refresh token in the request body", async () => { + const freshResp = mockAuthResponse({ + accessToken: "new.access.token", + refreshToken: "new.refresh.token", + }) + httpInstance.post.mockResolvedValue({ data: freshResp }) + + const client = new StreamingClient({}) + setTokens(client, mockAuthResponse()) + + const result = await client.refreshToken() + + expect(httpInstance.post).toHaveBeenCalledWith("/auth/refresh", { + refreshToken: "refresh.token.here", + }) + expect(result).toEqual(freshResp) + expect(getTokens(client)).toEqual(freshResp) + }) + + it("throws when no refresh token is available", async () => { + const client = new StreamingClient({}) + await expect(client.refreshToken()).rejects.toThrow( + "No refresh token available" + ) + }) + + it("updates stored tokens on successful refresh", async () => { + const freshResp = mockAuthResponse({ + accessToken: "new.access.token", + refreshToken: "new.refresh.token", + }) + httpInstance.post.mockResolvedValue({ data: freshResp }) + + const client = new StreamingClient({}) + setTokens(client, mockAuthResponse()) + + const result = await client.refreshToken() + + expect(httpInstance.post).toHaveBeenCalledWith("/auth/refresh", { + refreshToken: "refresh.token.here", + }) + expect(result).toEqual(freshResp) + expect(getTokens(client)).toEqual(freshResp) + }) + }) + + // -- logout ------------------------------------------------------------ + + describe("logout", () => { + it("clears stored tokens", async () => { + httpInstance.post.mockResolvedValue({}) + + const client = new StreamingClient({}) + setTokens(client, mockAuthResponse()) + + await client.logout() + + expect(getTokens(client)).toBeNull() + }) + + it("does not throw when no tokens are stored", async () => { + httpInstance.post.mockResolvedValue({}) + + const client = new StreamingClient({}) + await expect(client.logout()).resolves.toBeUndefined() + }) + }) + + // -- auto-refresh on 401 ---------------------------------------------- + + describe("401 auto-refresh interceptor", () => { + it("refreshes and retries the original request on 401", async () => { + // Capture the response error handler + let responseErrorHandler: ((error: unknown) => unknown) | null = null + const instance = Object.assign(jest.fn(), { + get: jest.fn(), + post: jest.fn(), + interceptors: { + request: { use: jest.fn() }, + response: { + use: jest.fn((_onFulfilled: unknown, onRejected: unknown) => { + responseErrorHandler = onRejected as (error: unknown) => unknown + }), + }, + }, + }) + mockedAxios.create.mockReturnValue(instance as never) + + const client = new StreamingClient({}) + const tokens = mockAuthResponse() + setTokens(client, tokens) + + // Setup refresh success + const freshTokens = mockAuthResponse({ + accessToken: "fresh.access", + refreshToken: "fresh.refresh", + }) + instance.post.mockResolvedValue({ data: freshTokens }) + + // Simulate a 401 error + const originalConfig = { + url: "/streams/123", + headers: {} as Record, + _retry: undefined as boolean | undefined, + } + const error = { + response: { status: 401 }, + config: originalConfig, + } + + // The retry calls the axios instance (callable) with the original config + instance.mockResolvedValue({ data: { id: "123", name: "test" } }) + + // Trigger the error handler + const resultPromise = responseErrorHandler!(error) as Promise + await resultPromise + + // Should have called refresh + expect(instance.post).toHaveBeenCalledWith("/auth/refresh", { + refreshToken: tokens.refreshToken, + }) + // Original config should be marked as retry + expect(originalConfig._retry).toBe(true) + // Authorization header should be updated with new token + expect(originalConfig.headers.Authorization).toBe("Bearer fresh.access") + }) + + it("does not retry when no refresh token is stored", async () => { + let responseErrorHandler: ((error: unknown) => unknown) | null = null + const instance = Object.assign(jest.fn(), { + get: jest.fn(), + post: jest.fn(), + interceptors: { + request: { use: jest.fn() }, + response: { + use: jest.fn((_onFulfilled: unknown, onRejected: unknown) => { + responseErrorHandler = onRejected as (error: unknown) => unknown + }), + }, + }, + }) + mockedAxios.create.mockReturnValue(instance as never) + + new StreamingClient({}) // provision instance, no tokens + // No tokens set + + const error = { + response: { status: 401 }, + config: { url: "/streams/123", headers: {} }, + } + + const result = responseErrorHandler!(error) + await expect(result).rejects.toEqual(error) + }) + }) +}) \ No newline at end of file diff --git a/xstreamroll-sdk/package.json b/xstreamroll-sdk/package.json index b48c86c..6ee2a08 100644 --- a/xstreamroll-sdk/package.json +++ b/xstreamroll-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@stellar/streaming-sdk", - "version": "1.0.0", + "version": "1.1.0", "description": "Stellar Streaming SDK for TypeScript", "main": "dist/index.js", "module": "dist-esm/index.js", diff --git a/xstreamroll-sdk/src/index.ts b/xstreamroll-sdk/src/index.ts index b59bb88..0d48726 100644 --- a/xstreamroll-sdk/src/index.ts +++ b/xstreamroll-sdk/src/index.ts @@ -14,6 +14,7 @@ export type { UpdateUserDto, // Auth AuthTokens, + AuthResponse, // Stream StreamStatus, StreamVisibility, diff --git a/xstreamroll-sdk/src/types.ts b/xstreamroll-sdk/src/types.ts index b9914eb..9094ea0 100644 --- a/xstreamroll-sdk/src/types.ts +++ b/xstreamroll-sdk/src/types.ts @@ -85,10 +85,10 @@ export interface UpdateUserDto { // ─── Auth ───────────────────────────────────────────────────────────────────── /** Response returned after a successful login or token refresh. */ -export interface AuthTokens { +export interface AuthResponse { + user: User accessToken: string refreshToken: string - expiresIn: number } // ─── Webhooks ─────────────────────────────────────────────────────────────────