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/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ea5e796dbb8..45204e08b5c 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -480,7 +480,12 @@ 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 params: Record = template + ? {} + : { + organizationId: organizationId ?? null, + ...(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__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 4ccae5510e2..1fa05a66012 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,126 @@ 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() 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;