From cbc83a019b7057ab52a7f409ebcba0ac716b94f0 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 18 Mar 2026 15:23:02 +0200 Subject: [PATCH 1/2] feat(clerk-js): Send previous session token on /tokens requests Send the current session JWT as `token` in the POST body when requesting a token refresh. This lets the FAPI Proxy forward it to Session Minter for claim cloning without a DB read. Uses conditional spread so the key is absent (not `token=`) when there's no previous token (first mint). --- .changeset/session-minter-send-token.md | 5 +++++ packages/clerk-js/src/core/resources/Session.ts | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/session-minter-send-token.md 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/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 => { From 388086f99c7ab3efd3373304918e4bb6399c7bd5 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 18 Mar 2026 15:56:59 +0200 Subject: [PATCH 2/2] test(clerk-js): Add tests for sending previous token in /tokens body Unit tests verify the token param is present when lastActiveToken exists, absent on first mint, absent for template requests, and matches getRawString() exactly. E2e test verifies token refresh still works with the new param in the POST body. --- integration/tests/resiliency.test.ts | 41 ++++++ .../core/resources/__tests__/Session.test.ts | 120 ++++++++++++++++++ 2 files changed, 161 insertions(+) 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/__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;