diff --git a/lambdas/src/lib/auth/session-token-service.test.ts b/lambdas/src/lib/auth/session-token-service.test.ts new file mode 100644 index 000000000..fcd9c88a9 --- /dev/null +++ b/lambdas/src/lib/auth/session-token-service.test.ts @@ -0,0 +1,70 @@ +import { SessionTokenService } from "./session-token-service"; + +const mockSign = jest.fn(); +const mockCleanupKey = jest.fn(); + +jest.mock("jsonwebtoken", () => ({ + __esModule: true, + default: { + sign: mockSign, + }, +})); + +jest.mock("./auth-utils", () => ({ + cleanupKey: mockCleanupKey, +})); + +describe("SessionTokenService", () => { + const config = { + privateKey: "raw-private-key", + accessTokenExpiryDurationMinutes: 10, + refreshTokenExpiryDurationMinutes: 60, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSign.mockReturnValue("signed-token"); + mockCleanupKey.mockReturnValue("clean-private-key"); + }); + + describe("signAccessToken", () => { + it("signs with the correct payload, key, and options", () => { + const service = new SessionTokenService(config); + const payload = { + sessionId: "abc-123", + sessionCreatedAt: "2026-04-14T10:00:00.000Z", + }; + + const token = service.signAccessToken(payload); + + expect(mockCleanupKey).toHaveBeenCalledWith("raw-private-key"); + expect(mockSign).toHaveBeenCalledWith(payload, "clean-private-key", { + expiresIn: "10m", + algorithm: "RS512", + }); + expect(token).toBe("signed-token"); + }); + }); + + describe("signRefreshToken", () => { + it("signs with the correct payload, key, and options", () => { + const service = new SessionTokenService(config); + const payload = { refreshTokenId: "def-456" }; + + const token = service.signRefreshToken(payload); + + expect(mockCleanupKey).toHaveBeenCalledWith("raw-private-key"); + expect(mockSign).toHaveBeenCalledWith(payload, "clean-private-key", { + expiresIn: "60m", + algorithm: "RS512", + }); + expect(token).toBe("signed-token"); + }); + }); + + it("throws during construction if privateKey is empty", () => { + expect(() => new SessionTokenService({ ...config, privateKey: "" })).toThrow( + "SessionTokenService requires a non-empty private key", + ); + }); +}); diff --git a/lambdas/src/lib/auth/session-token-service.ts b/lambdas/src/lib/auth/session-token-service.ts new file mode 100644 index 000000000..33177d71b --- /dev/null +++ b/lambdas/src/lib/auth/session-token-service.ts @@ -0,0 +1,53 @@ +import jwt, { type SignOptions } from "jsonwebtoken"; + +import { + type IAccessTokenPayload, + type IRefreshTokenPayload, +} from "../models/auth/session-token-payload"; +import { cleanupKey } from "./auth-utils"; + +export interface ISessionTokenServiceConfig { + privateKey: string; + accessTokenExpiryDurationMinutes: number; + refreshTokenExpiryDurationMinutes: number; +} + +export interface ISessionTokenService { + signAccessToken: (payload: IAccessTokenPayload) => string; + signRefreshToken: (payload: IRefreshTokenPayload) => string; +} + +export class SessionTokenService implements ISessionTokenService { + private readonly config: ISessionTokenServiceConfig; + + constructor(config: ISessionTokenServiceConfig) { + if (!config.privateKey) { + throw new Error("SessionTokenService requires a non-empty private key"); + } + this.config = config; + } + + public signAccessToken(payload: IAccessTokenPayload): string { + return this.signToken(payload, this.config.accessTokenExpiryDurationMinutes); + } + + public signRefreshToken(payload: IRefreshTokenPayload): string { + return this.signToken(payload, this.config.refreshTokenExpiryDurationMinutes); + } + + private signToken( + payload: IAccessTokenPayload | IRefreshTokenPayload, + expiryDurationMinutes: number, + ): string { + // No kid header is set here. The header: { alg, kid } pattern from AuthTokenService + // is omitted intentionally — add it when multi-key rotation support is required. + const options: SignOptions = { + expiresIn: `${expiryDurationMinutes}m`, + algorithm: "RS512", + }; + + const privateKey = cleanupKey(this.config.privateKey) ?? this.config.privateKey; + + return jwt.sign(payload, privateKey, options); + } +} diff --git a/lambdas/src/lib/models/auth/session-token-payload.ts b/lambdas/src/lib/models/auth/session-token-payload.ts new file mode 100644 index 000000000..4fcc9595f --- /dev/null +++ b/lambdas/src/lib/models/auth/session-token-payload.ts @@ -0,0 +1,8 @@ +export interface IAccessTokenPayload { + sessionId: string; + sessionCreatedAt: string; +} + +export interface IRefreshTokenPayload { + refreshTokenId: string; +}