Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-minter-send-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Send previous session token on `/tokens` requests to support Session Minter edge token minting.
41 changes: 41 additions & 0 deletions integration/tests/resiliency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<jwt>"
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();
});
});
});
7 changes: 6 additions & 1 deletion packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,12 @@ export class Session extends BaseResource implements SessionResource {
): Promise<TokenResource> {
const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`;
// TODO: update template endpoint to accept organizationId
const params: Record<string, string | null> = template ? {} : { organizationId: organizationId ?? null };
const params: Record<string, string | null> = 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 => {
Expand Down
120 changes: 120 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,126 @@ describe('Session', () => {
});
});

describe('sends previous token in /tokens request body', () => {
let dispatchSpy: ReturnType<typeof vi.spyOn>;
let fetchSpy: ReturnType<typeof vi.spyOn>;

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<typeof vi.spyOn>;
let fetchSpy: ReturnType<typeof vi.spyOn>;
Expand Down
Loading