diff --git a/.changeset/session-minter-send-token.md b/.changeset/session-minter-send-token.md new file mode 100644 index 00000000000..0cdd7fbe70d --- /dev/null +++ b/.changeset/session-minter-send-token.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Send previous session token on `/tokens` requests to support Session Minter edge token minting. diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index dbb3dab9ceb..1e90a1d3196 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -518,4 +518,45 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc await u.po.clerk.toBeLoaded(); }); }); + + test.describe('token refresh with previous token in body', () => { + test('token refresh includes previous token in POST body and succeeds', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Sign in + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + // Track token request bodies + const tokenRequestBodies: string[] = []; + await context.route('**/v1/client/sessions/*/tokens*', async route => { + const postData = route.request().postData(); + if (postData) { + tokenRequestBodies.push(postData); + } + await route.continue(); + }); + + // Force a fresh token fetch (cache miss -> hits /tokens endpoint) + const token = await page.evaluate(async () => { + const clerk = (window as any).Clerk; + await clerk.session?.clearCache(); + return await clerk.session?.getToken({ skipCache: true }); + }); + + // Token refresh should succeed (backend ignores the param for now) + expect(token).toBeTruthy(); + + // Verify token param is present in the POST body (form-urlencoded) + // fapiClient serializes body as form-urlencoded via qs.stringify(camelToSnake(body)) + // so "token" stays "token" (no case change) and the body looks like "organization_id=&token=" + expect(tokenRequestBodies.length).toBeGreaterThanOrEqual(1); + const lastBody = tokenRequestBodies[tokenRequestBodies.length - 1]; + expect(lastBody).toContain('token='); + + // User should still be signed in after refresh + await u.po.expect.toBeSignedIn(); + }); + }); }); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index a268b1c02dd..45853fa48ba 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "540KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "67KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "108KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" }, { "path": "./dist/clerk.native.js", "maxSize": "66KB" }, diff --git a/packages/clerk-js/src/core/resources/AuthConfig.ts b/packages/clerk-js/src/core/resources/AuthConfig.ts index b95dfaf5ba2..3bfc61dbacf 100644 --- a/packages/clerk-js/src/core/resources/AuthConfig.ts +++ b/packages/clerk-js/src/core/resources/AuthConfig.ts @@ -8,6 +8,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { reverification: boolean = false; singleSessionMode: boolean = false; preferredChannels: Record | null = null; + sessionMinter: boolean = false; public constructor(data: Partial | null = null) { super(); @@ -23,6 +24,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { this.reverification = this.withDefault(data.reverification, this.reverification); this.singleSessionMode = this.withDefault(data.single_session_mode, this.singleSessionMode); this.preferredChannels = this.withDefault(data.preferred_channels, this.preferredChannels); + this.sessionMinter = this.withDefault(data.session_minter, this.sessionMinter); return this; } @@ -33,6 +35,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { object: 'auth_config', reverification: this.reverification, single_session_mode: this.singleSessionMode, + session_minter: this.sessionMinter, }; } } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ea5e796dbb8..2f1c55eddb4 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -480,7 +480,13 @@ export class Session extends BaseResource implements SessionResource { ): Promise { const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; // TODO: update template endpoint to accept organizationId - const params: Record = template ? {} : { organizationId: organizationId ?? null }; + const sessionMinterEnabled = Session.clerk?.__internal_environment?.authConfig?.sessionMinter; + const params: Record = template + ? {} + : { + organizationId: organizationId ?? null, + ...(sessionMinterEnabled && this.lastActiveToken ? { token: this.lastActiveToken.getRawString() } : {}), + }; const lastActiveToken = this.lastActiveToken?.getRawString(); const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { diff --git a/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts b/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts index bfce1d5c021..3d16c8d430b 100644 --- a/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts @@ -46,6 +46,7 @@ describe('AuthConfig', () => { id: '', reverification: true, single_session_mode: true, + session_minter: false, }); }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 4ccae5510e2..386e25a9758 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1522,6 +1522,130 @@ describe('Session', () => { }); }); + describe('sends previous token in /tokens request body', () => { + let dispatchSpy: ReturnType; + let fetchSpy: ReturnType; + + beforeEach(() => { + dispatchSpy = vi.spyOn(eventBus, 'emit'); + fetchSpy = vi.spyOn(BaseResource, '_fetch' as any); + BaseResource.clerk = clerkMock({ + __internal_environment: { + authConfig: { sessionMinter: true }, + }, + }) as any; + }); + + afterEach(() => { + dispatchSpy?.mockRestore(); + fetchSpy?.mockRestore(); + BaseResource.clerk = null as any; + }); + + it('includes token in request body when lastActiveToken exists', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toMatchObject({ + path: '/client/sessions/session_1/tokens', + method: 'POST', + body: { organizationId: null, token: mockJwt }, + }); + }); + + it('does not include token key in request body when lastActiveToken is null (first mint)', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as unknown as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toMatchObject({ + path: '/client/sessions/session_1/tokens', + method: 'POST', + body: { organizationId: null }, + }); + expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('token'); + }); + + it('does not include token in request body for template token requests', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken({ template: 'my-template' }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toMatchObject({ + path: '/client/sessions/session_1/tokens/my-template', + method: 'POST', + }); + expect(fetchSpy.mock.calls[0][0].body).toEqual({}); + }); + + it('token value matches lastActiveToken.getRawString() exactly', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(fetchSpy.mock.calls[0][0].body.token).toBe(mockJwt); + }); + }); + describe('origin outage mode fallback', () => { let dispatchSpy: ReturnType; let fetchSpy: ReturnType; diff --git a/packages/shared/src/types/authConfig.ts b/packages/shared/src/types/authConfig.ts index 9a1fd5479f0..e0919100434 100644 --- a/packages/shared/src/types/authConfig.ts +++ b/packages/shared/src/types/authConfig.ts @@ -21,5 +21,6 @@ export interface AuthConfigResource extends ClerkResource { * Preferred channels for phone code providers. */ preferredChannels: Record | null; + sessionMinter: boolean; __internal_toSnapshot: () => AuthConfigJSONSnapshot; } diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index c9bf7ddb2b6..31cd716f319 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -332,6 +332,7 @@ export interface AuthConfigJSON extends ClerkResourceJSON { claimed_at: number | null; reverification: boolean; preferred_channels?: Record; + session_minter?: boolean; } export interface VerificationJSON extends ClerkResourceJSON {