-
Notifications
You must be signed in to change notification settings - Fork 1
[HOTE-1077] feat: session token service + interfaces #353
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| })); | ||
|
|
||
| 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", | ||
| ); | ||
| }); | ||
| }); | ||
| 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
|
||
|
|
||
| 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); | ||
| } | ||
| } | ||
| 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
|
||||||||||||||||||||||||||
| export interface IAccessTokenPayload { | |
| sessionId: string; | |
| sessionCreatedAt: string; | |
| } | |
| export interface IRefreshTokenPayload { | |
| export interface ISessionAccessTokenPayload { | |
| sessionId: string; | |
| sessionCreatedAt: string; | |
| } | |
| export interface ISessionRefreshTokenPayload { |
There was a problem hiding this comment.
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
SessionTokenServiceimport, so the module will be loaded beforejsonwebtoken/auth-utilsare mocked (unlike the existing auth-token tests), which is likely to call the realjwt.signand make this test fail.Move the
import { SessionTokenService ... }to after thejest.mock(...)calls (or convert tojest.unstable_mockModuleif you switch these tests to ESM mocking).