Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions lambdas/src/lib/auth/session-token-service.test.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
Comment on lines +1 to +15
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Jest mocks here are defined after the SessionTokenService import, so the module will be loaded before jsonwebtoken/auth-utils are mocked (unlike the existing auth-token tests), which is likely to call the real jwt.sign and make this test fail.
Move the import { SessionTokenService ... } to after the jest.mock(...) calls (or convert to jest.unstable_mockModule if you switch these tests to ESM mocking).

Copilot uses AI. Check for mistakes.

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",
);
});
});
53 changes: 53 additions & 0 deletions lambdas/src/lib/auth/session-token-service.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +9 to +13
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISessionTokenServiceConfig introduces an I*-prefixed config interface, but the existing config type in this folder is AuthTokenVerifierConfig (no I prefix), which makes the auth module’s naming inconsistent.
Consider renaming this to SessionTokenServiceConfig (or aligning all config interfaces in src/lib/auth to the same convention).

Copilot uses AI. Check for mistakes.

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);
}
}
8 changes: 8 additions & 0 deletions lambdas/src/lib/models/auth/session-token-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface IAccessTokenPayload {
sessionId: string;
sessionCreatedAt: string;
}

export interface IRefreshTokenPayload {
Comment on lines +1 to +6
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The payload interface names IAccessTokenPayload / IRefreshTokenPayload are very generic in a codebase that already has multiple “access tokens” (e.g. NHS Login access tokens, auth tokens), which makes imports and usage harder to understand.
Consider renaming them to be domain-specific (e.g. ISessionAccessTokenPayload / ISessionRefreshTokenPayload) and matching the file name (session-token-payload.ts).

Suggested change
export interface IAccessTokenPayload {
sessionId: string;
sessionCreatedAt: string;
}
export interface IRefreshTokenPayload {
export interface ISessionAccessTokenPayload {
sessionId: string;
sessionCreatedAt: string;
}
export interface ISessionRefreshTokenPayload {

Copilot uses AI. Check for mistakes.
refreshTokenId: string;
}
Loading