diff --git a/.changeset/evil-paws-learn.md b/.changeset/evil-paws-learn.md new file mode 100644 index 00000000000..8f1b41ab272 --- /dev/null +++ b/.changeset/evil-paws-learn.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': minor +'@clerk/types': minor +--- + +Add support for `expiresInSeconds` parameter in session token generation. This allows setting custom expiration times for tokens both with and without templates via the backend API. + diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index 9c78fd7dd1c..1c3967fdf4a 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -50,6 +50,8 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "types/reverification-config.mdx", "types/saml-strategy.mdx", "types/sdk-metadata.mdx", + "types/server-get-token-options.mdx", + "types/server-get-token.mdx", "types/session-resource.mdx", "types/session-status-claim.mdx", "types/session-verification-level.mdx", diff --git a/packages/backend/src/api/__tests__/SessionApi.test.ts b/packages/backend/src/api/__tests__/SessionApi.test.ts new file mode 100644 index 00000000000..36e7075f3c1 --- /dev/null +++ b/packages/backend/src/api/__tests__/SessionApi.test.ts @@ -0,0 +1,88 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server, validateHeaders } from '../../mock-server'; +import { createBackendApiClient } from '../factory'; + +describe('SessionAPI', () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'deadbeef', + }); + + const sessionId = 'sess_123'; + const mockTokenResponse = { + object: 'token', + jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.token', + }; + + describe('getToken', () => { + it('creates a session token without template', async () => { + server.use( + http.post( + `https://api.clerk.test/v1/sessions/${sessionId}/tokens`, + validateHeaders(async ({ request }) => { + const body = await request.text(); + expect(body).toBe(''); + return HttpResponse.json(mockTokenResponse); + }), + ), + ); + + const response = await apiClient.sessions.getToken(sessionId, ''); + expect(response.jwt).toBe(mockTokenResponse.jwt); + }); + + it('creates a session token with template', async () => { + const template = 'custom-template'; + server.use( + http.post( + `https://api.clerk.test/v1/sessions/${sessionId}/tokens/${template}`, + validateHeaders(async ({ request }) => { + const body = await request.text(); + expect(body).toBe(''); + return HttpResponse.json(mockTokenResponse); + }), + ), + ); + + const response = await apiClient.sessions.getToken(sessionId, template); + expect(response.jwt).toBe(mockTokenResponse.jwt); + }); + + it('creates a session token without template and with expiresInSeconds', async () => { + const expiresInSeconds = 3600; + server.use( + http.post( + `https://api.clerk.test/v1/sessions/${sessionId}/tokens`, + validateHeaders(async ({ request }) => { + const body = await request.json(); + expect(body).toEqual({ expires_in_seconds: expiresInSeconds }); + return HttpResponse.json(mockTokenResponse); + }), + ), + ); + + const response = await apiClient.sessions.getToken(sessionId, '', expiresInSeconds); + expect(response.jwt).toBe(mockTokenResponse.jwt); + }); + + it('creates a session token with template and expiresInSeconds', async () => { + const template = 'custom-template'; + const expiresInSeconds = 3600; + server.use( + http.post( + `https://api.clerk.test/v1/sessions/${sessionId}/tokens/${template}`, + validateHeaders(async ({ request }) => { + const body = await request.json(); + expect(body).toEqual({ expires_in_seconds: expiresInSeconds }); + return HttpResponse.json(mockTokenResponse); + }), + ), + ); + + const response = await apiClient.sessions.getToken(sessionId, template, expiresInSeconds); + expect(response.jwt).toBe(mockTokenResponse.jwt); + }); + }); +}); diff --git a/packages/backend/src/api/endpoints/SessionApi.ts b/packages/backend/src/api/endpoints/SessionApi.ts index 339a19b851f..b2180f7d126 100644 --- a/packages/backend/src/api/endpoints/SessionApi.ts +++ b/packages/backend/src/api/endpoints/SessionApi.ts @@ -71,12 +71,35 @@ export class SessionAPI extends AbstractAPI { }); } - public async getToken(sessionId: string, template: string) { + /** + * Retrieves a session token or generates a JWT using a specified template. + * + * @param sessionId - The ID of the session for which to generate the token + * @param template - Optional name of the JWT template configured in the Clerk Dashboard. + * @param expiresInSeconds - Optional expiration time for the token in seconds. + * If not provided, uses the default expiration. + * + * @returns A promise that resolves to the generated token + * + * @throws {Error} When sessionId is invalid or empty + */ + public async getToken(sessionId: string, template?: string, expiresInSeconds?: number) { this.requireId(sessionId); - return this.request({ + + const path = template + ? joinPaths(basePath, sessionId, 'tokens', template) + : joinPaths(basePath, sessionId, 'tokens'); + + const requestOptions: any = { method: 'POST', - path: joinPaths(basePath, sessionId, 'tokens', template || ''), - }); + path, + }; + + if (expiresInSeconds !== undefined) { + requestOptions.bodyParams = { expires_in_seconds: expiresInSeconds }; + } + + return this.request(requestOptions); } public async refreshSession(sessionId: string, params: RefreshTokenParams & { format: 'token' }): Promise; diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index b521e82a8b4..64bfce39818 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -1,6 +1,7 @@ import type { JwtPayload } from '@clerk/types'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { createBackendApiClient } from '../../api/factory'; import { mockTokens, mockVerificationResults } from '../../fixtures/machine'; import type { AuthenticateContext } from '../authenticateContext'; import type { InvalidTokenAuthObject, UnauthenticatedMachineObject } from '../authObjects'; @@ -13,6 +14,10 @@ import { unauthenticatedMachineObject, } from '../authObjects'; +vi.mock('../../api/factory', () => ({ + createBackendApiClient: vi.fn(), +})); + describe('makeAuthObjectSerializable', () => { it('removes non-serializable props', () => { const authObject = signedOutAuthObject(); @@ -432,3 +437,80 @@ describe('getAuthObjectForAcceptedToken', () => { expect((result as UnauthenticatedMachineObject<'machine_token'>).id).toBeNull(); }); }); + +describe('getToken with expiresInSeconds support', () => { + it('calls fetcher with expiresInSeconds when template is provided', async () => { + const mockGetToken = vi.fn().mockResolvedValue({ jwt: 'mocked-jwt-token' }); + const mockApiClient = { + sessions: { + getToken: mockGetToken, + }, + }; + + vi.mocked(createBackendApiClient).mockReturnValue(mockApiClient as any); + + const mockAuthenticateContext = { + secretKey: 'sk_test_123', + } as AuthenticateContext; + + const authObject = signedInAuthObject(mockAuthenticateContext, 'raw-session-token', { + sid: 'sess_123', + sub: 'user_123', + } as unknown as JwtPayload); + + const result = await authObject.getToken({ template: 'custom-template', expiresInSeconds: 3600 }); + + expect(mockGetToken).toHaveBeenCalledWith('sess_123', 'custom-template', 3600); + expect(result).toBe('mocked-jwt-token'); + }); + + it('calls fetcher without expiresInSeconds when template is provided but expiresInSeconds is undefined', async () => { + const mockGetToken = vi.fn().mockResolvedValue({ jwt: 'mocked-jwt-token' }); + const mockApiClient = { + sessions: { + getToken: mockGetToken, + }, + }; + + vi.mocked(createBackendApiClient).mockReturnValue(mockApiClient as any); + + const mockAuthenticateContext = { + secretKey: 'sk_test_123', + } as AuthenticateContext; + + const authObject = signedInAuthObject(mockAuthenticateContext, 'raw-session-token', { + sid: 'sess_123', + sub: 'user_123', + } as unknown as JwtPayload); + + const result = await authObject.getToken({ template: 'custom-template' }); + + expect(mockGetToken).toHaveBeenCalledWith('sess_123', 'custom-template', undefined); + expect(result).toBe('mocked-jwt-token'); + }); + + it('returns raw session token when no template is provided', async () => { + const mockGetToken = vi.fn(); + const mockApiClient = { + sessions: { + getToken: mockGetToken, + }, + }; + + vi.mocked(createBackendApiClient).mockReturnValue(mockApiClient as any); + + const mockAuthenticateContext = { + secretKey: 'sk_test_123', + } as AuthenticateContext; + + const authObject = signedInAuthObject(mockAuthenticateContext, 'raw-session-token', { + sid: 'sess_123', + sub: 'user_123', + } as unknown as JwtPayload); + + const result = await authObject.getToken({}); + + expect(mockGetToken).not.toHaveBeenCalled(); + expect(result).toBe('raw-session-token'); + }); +}); diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 9c5682c3e01..9e80332b61a 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -189,7 +189,8 @@ export function signedInAuthObject( const getToken = createGetToken({ sessionId, sessionToken, - fetcher: async (...args) => (await apiClient.sessions.getToken(...args)).jwt, + fetcher: async (sessionId, template, expiresInSeconds) => + (await apiClient.sessions.getToken(sessionId, template || '', expiresInSeconds)).jwt, }); return { tokenType: TokenType.SessionToken, @@ -387,10 +388,37 @@ export const makeAuthObjectSerializable = >(ob return rest as unknown as T; }; -type TokenFetcher = (sessionId: string, template: string) => Promise; +/** + * A function that fetches a session token from the Clerk API. + * + * @param sessionId - The ID of the session + * @param template - The JWT template name to use for token generation + * @param expiresInSeconds - Optional expiration time in seconds for the token + * @returns A promise that resolves to the token string + */ +type TokenFetcher = (sessionId: string, template?: string, expiresInSeconds?: number) => Promise; +/** + * Factory function type that creates a getToken function for auth objects. + * + * @param params - Configuration object containing session information and token fetcher + * @returns A ServerGetToken function that can be used to retrieve tokens + */ type CreateGetToken = (params: { sessionId: string; sessionToken: string; fetcher: TokenFetcher }) => ServerGetToken; +/** + * Creates a token retrieval function for authenticated sessions. + * + * This factory function returns a getToken function that can either return the raw session token + * or generate a JWT using a specified template with optional custom expiration. + * + * @param params - Configuration object + * @param params.sessionId - The session ID for token generation + * @param params.sessionToken - The raw session token to return when no template is specified + * @param params.fetcher - Function to fetch tokens from the Clerk API + * + * @returns A function that retrieves tokens based on the provided options + */ const createGetToken: CreateGetToken = params => { const { fetcher, sessionToken, sessionId } = params || {}; @@ -399,8 +427,8 @@ const createGetToken: CreateGetToken = params => { return null; } - if (options.template) { - return fetcher(sessionId, options.template); + if (options.template || options.expiresInSeconds !== undefined) { + return fetcher(sessionId, options.template, options.expiresInSeconds); } return sessionToken; diff --git a/packages/types/src/ssr.ts b/packages/types/src/ssr.ts index 3f03b0da63a..da9815deebd 100644 --- a/packages/types/src/ssr.ts +++ b/packages/types/src/ssr.ts @@ -5,7 +5,30 @@ import type { SessionResource } from './session'; import type { UserResource } from './user'; import type { Serializable } from './utils'; -export type ServerGetTokenOptions = { template?: string }; +/** + * Options for retrieving a session token. + */ +export type ServerGetTokenOptions = { + /** + * The name of a JWT template configured in the Clerk Dashboard. + * If provided, a JWT will be generated using the specified template. + * If not provided, the raw session token will be returned. + */ + template?: string; + /** + * The expiration time for the token in seconds. + * If provided, the token will expire after the specified number of seconds. + * Must be a positive integer. + */ + expiresInSeconds?: number; +}; + +/** + * A function that retrieves a session token or JWT template. + * + * @param options - Configuration options for token retrieval + * @returns A promise that resolves to the token string, or null if no session exists + */ export type ServerGetToken = (options?: ServerGetTokenOptions) => Promise; export type InitialState = Serializable<{