Skip to content

Commit 52d5e57

Browse files
authored
feat(backend): Support ExpiresInSeconds param (#6150)
1 parent 547f49d commit 52d5e57

File tree

7 files changed

+263
-10
lines changed

7 files changed

+263
-10
lines changed

.changeset/evil-paws-learn.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/backend': minor
3+
'@clerk/types': minor
4+
---
5+
6+
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.
7+

.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
5050
"types/reverification-config.mdx",
5151
"types/saml-strategy.mdx",
5252
"types/sdk-metadata.mdx",
53+
"types/server-get-token-options.mdx",
54+
"types/server-get-token.mdx",
5355
"types/session-resource.mdx",
5456
"types/session-status-claim.mdx",
5557
"types/session-verification-level.mdx",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { http, HttpResponse } from 'msw';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { server, validateHeaders } from '../../mock-server';
5+
import { createBackendApiClient } from '../factory';
6+
7+
describe('SessionAPI', () => {
8+
const apiClient = createBackendApiClient({
9+
apiUrl: 'https://api.clerk.test',
10+
secretKey: 'deadbeef',
11+
});
12+
13+
const sessionId = 'sess_123';
14+
const mockTokenResponse = {
15+
object: 'token',
16+
jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.token',
17+
};
18+
19+
describe('getToken', () => {
20+
it('creates a session token without template', async () => {
21+
server.use(
22+
http.post(
23+
`https://api.clerk.test/v1/sessions/${sessionId}/tokens`,
24+
validateHeaders(async ({ request }) => {
25+
const body = await request.text();
26+
expect(body).toBe('');
27+
return HttpResponse.json(mockTokenResponse);
28+
}),
29+
),
30+
);
31+
32+
const response = await apiClient.sessions.getToken(sessionId, '');
33+
expect(response.jwt).toBe(mockTokenResponse.jwt);
34+
});
35+
36+
it('creates a session token with template', async () => {
37+
const template = 'custom-template';
38+
server.use(
39+
http.post(
40+
`https://api.clerk.test/v1/sessions/${sessionId}/tokens/${template}`,
41+
validateHeaders(async ({ request }) => {
42+
const body = await request.text();
43+
expect(body).toBe('');
44+
return HttpResponse.json(mockTokenResponse);
45+
}),
46+
),
47+
);
48+
49+
const response = await apiClient.sessions.getToken(sessionId, template);
50+
expect(response.jwt).toBe(mockTokenResponse.jwt);
51+
});
52+
53+
it('creates a session token without template and with expiresInSeconds', async () => {
54+
const expiresInSeconds = 3600;
55+
server.use(
56+
http.post(
57+
`https://api.clerk.test/v1/sessions/${sessionId}/tokens`,
58+
validateHeaders(async ({ request }) => {
59+
const body = await request.json();
60+
expect(body).toEqual({ expires_in_seconds: expiresInSeconds });
61+
return HttpResponse.json(mockTokenResponse);
62+
}),
63+
),
64+
);
65+
66+
const response = await apiClient.sessions.getToken(sessionId, '', expiresInSeconds);
67+
expect(response.jwt).toBe(mockTokenResponse.jwt);
68+
});
69+
70+
it('creates a session token with template and expiresInSeconds', async () => {
71+
const template = 'custom-template';
72+
const expiresInSeconds = 3600;
73+
server.use(
74+
http.post(
75+
`https://api.clerk.test/v1/sessions/${sessionId}/tokens/${template}`,
76+
validateHeaders(async ({ request }) => {
77+
const body = await request.json();
78+
expect(body).toEqual({ expires_in_seconds: expiresInSeconds });
79+
return HttpResponse.json(mockTokenResponse);
80+
}),
81+
),
82+
);
83+
84+
const response = await apiClient.sessions.getToken(sessionId, template, expiresInSeconds);
85+
expect(response.jwt).toBe(mockTokenResponse.jwt);
86+
});
87+
});
88+
});

packages/backend/src/api/endpoints/SessionApi.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,35 @@ export class SessionAPI extends AbstractAPI {
7171
});
7272
}
7373

74-
public async getToken(sessionId: string, template: string) {
74+
/**
75+
* Retrieves a session token or generates a JWT using a specified template.
76+
*
77+
* @param sessionId - The ID of the session for which to generate the token
78+
* @param template - Optional name of the JWT template configured in the Clerk Dashboard.
79+
* @param expiresInSeconds - Optional expiration time for the token in seconds.
80+
* If not provided, uses the default expiration.
81+
*
82+
* @returns A promise that resolves to the generated token
83+
*
84+
* @throws {Error} When sessionId is invalid or empty
85+
*/
86+
public async getToken(sessionId: string, template?: string, expiresInSeconds?: number) {
7587
this.requireId(sessionId);
76-
return this.request<Token>({
88+
89+
const path = template
90+
? joinPaths(basePath, sessionId, 'tokens', template)
91+
: joinPaths(basePath, sessionId, 'tokens');
92+
93+
const requestOptions: any = {
7794
method: 'POST',
78-
path: joinPaths(basePath, sessionId, 'tokens', template || ''),
79-
});
95+
path,
96+
};
97+
98+
if (expiresInSeconds !== undefined) {
99+
requestOptions.bodyParams = { expires_in_seconds: expiresInSeconds };
100+
}
101+
102+
return this.request<Token>(requestOptions);
80103
}
81104

82105
public async refreshSession(sessionId: string, params: RefreshTokenParams & { format: 'token' }): Promise<Token>;

packages/backend/src/tokens/__tests__/authObjects.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { JwtPayload } from '@clerk/types';
2-
import { describe, expect, it } from 'vitest';
2+
import { describe, expect, it, vi } from 'vitest';
33

4+
import { createBackendApiClient } from '../../api/factory';
45
import { mockTokens, mockVerificationResults } from '../../fixtures/machine';
56
import type { AuthenticateContext } from '../authenticateContext';
67
import type { InvalidTokenAuthObject, UnauthenticatedMachineObject } from '../authObjects';
@@ -13,6 +14,10 @@ import {
1314
unauthenticatedMachineObject,
1415
} from '../authObjects';
1516

17+
vi.mock('../../api/factory', () => ({
18+
createBackendApiClient: vi.fn(),
19+
}));
20+
1621
describe('makeAuthObjectSerializable', () => {
1722
it('removes non-serializable props', () => {
1823
const authObject = signedOutAuthObject();
@@ -432,3 +437,80 @@ describe('getAuthObjectForAcceptedToken', () => {
432437
expect((result as UnauthenticatedMachineObject<'machine_token'>).id).toBeNull();
433438
});
434439
});
440+
441+
describe('getToken with expiresInSeconds support', () => {
442+
it('calls fetcher with expiresInSeconds when template is provided', async () => {
443+
const mockGetToken = vi.fn().mockResolvedValue({ jwt: 'mocked-jwt-token' });
444+
const mockApiClient = {
445+
sessions: {
446+
getToken: mockGetToken,
447+
},
448+
};
449+
450+
vi.mocked(createBackendApiClient).mockReturnValue(mockApiClient as any);
451+
452+
const mockAuthenticateContext = {
453+
secretKey: 'sk_test_123',
454+
} as AuthenticateContext;
455+
456+
const authObject = signedInAuthObject(mockAuthenticateContext, 'raw-session-token', {
457+
sid: 'sess_123',
458+
sub: 'user_123',
459+
} as unknown as JwtPayload);
460+
461+
const result = await authObject.getToken({ template: 'custom-template', expiresInSeconds: 3600 });
462+
463+
expect(mockGetToken).toHaveBeenCalledWith('sess_123', 'custom-template', 3600);
464+
expect(result).toBe('mocked-jwt-token');
465+
});
466+
467+
it('calls fetcher without expiresInSeconds when template is provided but expiresInSeconds is undefined', async () => {
468+
const mockGetToken = vi.fn().mockResolvedValue({ jwt: 'mocked-jwt-token' });
469+
const mockApiClient = {
470+
sessions: {
471+
getToken: mockGetToken,
472+
},
473+
};
474+
475+
vi.mocked(createBackendApiClient).mockReturnValue(mockApiClient as any);
476+
477+
const mockAuthenticateContext = {
478+
secretKey: 'sk_test_123',
479+
} as AuthenticateContext;
480+
481+
const authObject = signedInAuthObject(mockAuthenticateContext, 'raw-session-token', {
482+
sid: 'sess_123',
483+
sub: 'user_123',
484+
} as unknown as JwtPayload);
485+
486+
const result = await authObject.getToken({ template: 'custom-template' });
487+
488+
expect(mockGetToken).toHaveBeenCalledWith('sess_123', 'custom-template', undefined);
489+
expect(result).toBe('mocked-jwt-token');
490+
});
491+
492+
it('returns raw session token when no template is provided', async () => {
493+
const mockGetToken = vi.fn();
494+
const mockApiClient = {
495+
sessions: {
496+
getToken: mockGetToken,
497+
},
498+
};
499+
500+
vi.mocked(createBackendApiClient).mockReturnValue(mockApiClient as any);
501+
502+
const mockAuthenticateContext = {
503+
secretKey: 'sk_test_123',
504+
} as AuthenticateContext;
505+
506+
const authObject = signedInAuthObject(mockAuthenticateContext, 'raw-session-token', {
507+
sid: 'sess_123',
508+
sub: 'user_123',
509+
} as unknown as JwtPayload);
510+
511+
const result = await authObject.getToken({});
512+
513+
expect(mockGetToken).not.toHaveBeenCalled();
514+
expect(result).toBe('raw-session-token');
515+
});
516+
});

packages/backend/src/tokens/authObjects.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@ export function signedInAuthObject(
189189
const getToken = createGetToken({
190190
sessionId,
191191
sessionToken,
192-
fetcher: async (...args) => (await apiClient.sessions.getToken(...args)).jwt,
192+
fetcher: async (sessionId, template, expiresInSeconds) =>
193+
(await apiClient.sessions.getToken(sessionId, template || '', expiresInSeconds)).jwt,
193194
});
194195
return {
195196
tokenType: TokenType.SessionToken,
@@ -387,10 +388,37 @@ export const makeAuthObjectSerializable = <T extends Record<string, unknown>>(ob
387388
return rest as unknown as T;
388389
};
389390

390-
type TokenFetcher = (sessionId: string, template: string) => Promise<string>;
391+
/**
392+
* A function that fetches a session token from the Clerk API.
393+
*
394+
* @param sessionId - The ID of the session
395+
* @param template - The JWT template name to use for token generation
396+
* @param expiresInSeconds - Optional expiration time in seconds for the token
397+
* @returns A promise that resolves to the token string
398+
*/
399+
type TokenFetcher = (sessionId: string, template?: string, expiresInSeconds?: number) => Promise<string>;
391400

401+
/**
402+
* Factory function type that creates a getToken function for auth objects.
403+
*
404+
* @param params - Configuration object containing session information and token fetcher
405+
* @returns A ServerGetToken function that can be used to retrieve tokens
406+
*/
392407
type CreateGetToken = (params: { sessionId: string; sessionToken: string; fetcher: TokenFetcher }) => ServerGetToken;
393408

409+
/**
410+
* Creates a token retrieval function for authenticated sessions.
411+
*
412+
* This factory function returns a getToken function that can either return the raw session token
413+
* or generate a JWT using a specified template with optional custom expiration.
414+
*
415+
* @param params - Configuration object
416+
* @param params.sessionId - The session ID for token generation
417+
* @param params.sessionToken - The raw session token to return when no template is specified
418+
* @param params.fetcher - Function to fetch tokens from the Clerk API
419+
*
420+
* @returns A function that retrieves tokens based on the provided options
421+
*/
394422
const createGetToken: CreateGetToken = params => {
395423
const { fetcher, sessionToken, sessionId } = params || {};
396424

@@ -399,8 +427,8 @@ const createGetToken: CreateGetToken = params => {
399427
return null;
400428
}
401429

402-
if (options.template) {
403-
return fetcher(sessionId, options.template);
430+
if (options.template || options.expiresInSeconds !== undefined) {
431+
return fetcher(sessionId, options.template, options.expiresInSeconds);
404432
}
405433

406434
return sessionToken;

packages/types/src/ssr.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,30 @@ import type { SessionResource } from './session';
55
import type { UserResource } from './user';
66
import type { Serializable } from './utils';
77

8-
export type ServerGetTokenOptions = { template?: string };
8+
/**
9+
* Options for retrieving a session token.
10+
*/
11+
export type ServerGetTokenOptions = {
12+
/**
13+
* The name of a JWT template configured in the Clerk Dashboard.
14+
* If provided, a JWT will be generated using the specified template.
15+
* If not provided, the raw session token will be returned.
16+
*/
17+
template?: string;
18+
/**
19+
* The expiration time for the token in seconds.
20+
* If provided, the token will expire after the specified number of seconds.
21+
* Must be a positive integer.
22+
*/
23+
expiresInSeconds?: number;
24+
};
25+
26+
/**
27+
* A function that retrieves a session token or JWT template.
28+
*
29+
* @param options - Configuration options for token retrieval
30+
* @returns A promise that resolves to the token string, or null if no session exists
31+
*/
932
export type ServerGetToken = (options?: ServerGetTokenOptions) => Promise<string | null>;
1033

1134
export type InitialState = Serializable<{

0 commit comments

Comments
 (0)