Skip to content

Commit 2837336

Browse files
authored
feat(clerk-js): Send previous session token on /tokens requests (#8105)
1 parent 6105fc0 commit 2837336

File tree

9 files changed

+184
-2
lines changed

9 files changed

+184
-2
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Send previous session token on `/tokens` requests to support Session Minter edge token minting.

integration/tests/resiliency.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,4 +518,45 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc
518518
await u.po.clerk.toBeLoaded();
519519
});
520520
});
521+
522+
test.describe('token refresh with previous token in body', () => {
523+
test('token refresh includes previous token in POST body and succeeds', async ({ page, context }) => {
524+
const u = createTestUtils({ app, page, context });
525+
526+
// Sign in
527+
await u.po.signIn.goTo();
528+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
529+
await u.po.expect.toBeSignedIn();
530+
531+
// Track token request bodies
532+
const tokenRequestBodies: string[] = [];
533+
await context.route('**/v1/client/sessions/*/tokens*', async route => {
534+
const postData = route.request().postData();
535+
if (postData) {
536+
tokenRequestBodies.push(postData);
537+
}
538+
await route.continue();
539+
});
540+
541+
// Force a fresh token fetch (cache miss -> hits /tokens endpoint)
542+
const token = await page.evaluate(async () => {
543+
const clerk = (window as any).Clerk;
544+
await clerk.session?.clearCache();
545+
return await clerk.session?.getToken({ skipCache: true });
546+
});
547+
548+
// Token refresh should succeed (backend ignores the param for now)
549+
expect(token).toBeTruthy();
550+
551+
// Verify token param is present in the POST body (form-urlencoded)
552+
// fapiClient serializes body as form-urlencoded via qs.stringify(camelToSnake(body))
553+
// so "token" stays "token" (no case change) and the body looks like "organization_id=&token=<jwt>"
554+
expect(tokenRequestBodies.length).toBeGreaterThanOrEqual(1);
555+
const lastBody = tokenRequestBodies[tokenRequestBodies.length - 1];
556+
expect(lastBody).toContain('token=');
557+
558+
// User should still be signed in after refresh
559+
await u.po.expect.toBeSignedIn();
560+
});
561+
});
521562
});

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"files": [
33
{ "path": "./dist/clerk.js", "maxSize": "540KB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "66KB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "67KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "108KB" },
66
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" },
77
{ "path": "./dist/clerk.native.js", "maxSize": "66KB" },

packages/clerk-js/src/core/resources/AuthConfig.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
88
reverification: boolean = false;
99
singleSessionMode: boolean = false;
1010
preferredChannels: Record<string, PhoneCodeChannel> | null = null;
11+
sessionMinter: boolean = false;
1112

1213
public constructor(data: Partial<AuthConfigJSON> | null = null) {
1314
super();
@@ -23,6 +24,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
2324
this.reverification = this.withDefault(data.reverification, this.reverification);
2425
this.singleSessionMode = this.withDefault(data.single_session_mode, this.singleSessionMode);
2526
this.preferredChannels = this.withDefault(data.preferred_channels, this.preferredChannels);
27+
this.sessionMinter = this.withDefault(data.session_minter, this.sessionMinter);
2628
return this;
2729
}
2830

@@ -33,6 +35,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
3335
object: 'auth_config',
3436
reverification: this.reverification,
3537
single_session_mode: this.singleSessionMode,
38+
session_minter: this.sessionMinter,
3639
};
3740
}
3841
}

packages/clerk-js/src/core/resources/Session.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,13 @@ export class Session extends BaseResource implements SessionResource {
483483
): Promise<TokenResource> {
484484
const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`;
485485
// TODO: update template endpoint to accept organizationId
486-
const params: Record<string, string | null> = template ? {} : { organizationId: organizationId ?? null };
486+
const sessionMinterEnabled = Session.clerk?.__internal_environment?.authConfig?.sessionMinter;
487+
const params: Record<string, string | null> = template
488+
? {}
489+
: {
490+
organizationId: organizationId ?? null,
491+
...(sessionMinterEnabled && this.lastActiveToken ? { token: this.lastActiveToken.getRawString() } : {}),
492+
};
487493
const lastActiveToken = this.lastActiveToken?.getRawString();
488494

489495
const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => {

packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ describe('AuthConfig', () => {
4646
id: '',
4747
reverification: true,
4848
single_session_mode: true,
49+
session_minter: false,
4950
});
5051
});
5152
});

packages/clerk-js/src/core/resources/__tests__/Session.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,6 +1574,130 @@ describe('Session', () => {
15741574
});
15751575
});
15761576

1577+
describe('sends previous token in /tokens request body', () => {
1578+
let dispatchSpy: ReturnType<typeof vi.spyOn>;
1579+
let fetchSpy: ReturnType<typeof vi.spyOn>;
1580+
1581+
beforeEach(() => {
1582+
dispatchSpy = vi.spyOn(eventBus, 'emit');
1583+
fetchSpy = vi.spyOn(BaseResource, '_fetch' as any);
1584+
BaseResource.clerk = clerkMock({
1585+
__internal_environment: {
1586+
authConfig: { sessionMinter: true },
1587+
},
1588+
}) as any;
1589+
});
1590+
1591+
afterEach(() => {
1592+
dispatchSpy?.mockRestore();
1593+
fetchSpy?.mockRestore();
1594+
BaseResource.clerk = null as any;
1595+
});
1596+
1597+
it('includes token in request body when lastActiveToken exists', async () => {
1598+
const session = new Session({
1599+
status: 'active',
1600+
id: 'session_1',
1601+
object: 'session',
1602+
user: createUser({}),
1603+
last_active_organization_id: null,
1604+
last_active_token: { object: 'token', jwt: mockJwt },
1605+
actor: null,
1606+
created_at: new Date().getTime(),
1607+
updated_at: new Date().getTime(),
1608+
} as SessionJSON);
1609+
1610+
SessionTokenCache.clear();
1611+
1612+
fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt });
1613+
1614+
await session.getToken();
1615+
1616+
expect(fetchSpy).toHaveBeenCalledTimes(1);
1617+
expect(fetchSpy.mock.calls[0][0]).toMatchObject({
1618+
path: '/client/sessions/session_1/tokens',
1619+
method: 'POST',
1620+
body: { organizationId: null, token: mockJwt },
1621+
});
1622+
});
1623+
1624+
it('does not include token key in request body when lastActiveToken is null (first mint)', async () => {
1625+
const session = new Session({
1626+
status: 'active',
1627+
id: 'session_1',
1628+
object: 'session',
1629+
user: createUser({}),
1630+
last_active_organization_id: null,
1631+
actor: null,
1632+
created_at: new Date().getTime(),
1633+
updated_at: new Date().getTime(),
1634+
} as unknown as SessionJSON);
1635+
1636+
SessionTokenCache.clear();
1637+
1638+
fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt });
1639+
1640+
await session.getToken();
1641+
1642+
expect(fetchSpy).toHaveBeenCalledTimes(1);
1643+
expect(fetchSpy.mock.calls[0][0]).toMatchObject({
1644+
path: '/client/sessions/session_1/tokens',
1645+
method: 'POST',
1646+
body: { organizationId: null },
1647+
});
1648+
expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('token');
1649+
});
1650+
1651+
it('does not include token in request body for template token requests', async () => {
1652+
const session = new Session({
1653+
status: 'active',
1654+
id: 'session_1',
1655+
object: 'session',
1656+
user: createUser({}),
1657+
last_active_organization_id: null,
1658+
last_active_token: { object: 'token', jwt: mockJwt },
1659+
actor: null,
1660+
created_at: new Date().getTime(),
1661+
updated_at: new Date().getTime(),
1662+
} as SessionJSON);
1663+
1664+
SessionTokenCache.clear();
1665+
1666+
fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt });
1667+
1668+
await session.getToken({ template: 'my-template' });
1669+
1670+
expect(fetchSpy).toHaveBeenCalledTimes(1);
1671+
expect(fetchSpy.mock.calls[0][0]).toMatchObject({
1672+
path: '/client/sessions/session_1/tokens/my-template',
1673+
method: 'POST',
1674+
});
1675+
expect(fetchSpy.mock.calls[0][0].body).toEqual({});
1676+
});
1677+
1678+
it('token value matches lastActiveToken.getRawString() exactly', async () => {
1679+
const session = new Session({
1680+
status: 'active',
1681+
id: 'session_1',
1682+
object: 'session',
1683+
user: createUser({}),
1684+
last_active_organization_id: null,
1685+
last_active_token: { object: 'token', jwt: mockJwt },
1686+
actor: null,
1687+
created_at: new Date().getTime(),
1688+
updated_at: new Date().getTime(),
1689+
} as SessionJSON);
1690+
1691+
SessionTokenCache.clear();
1692+
1693+
fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt });
1694+
1695+
await session.getToken();
1696+
1697+
expect(fetchSpy.mock.calls[0][0].body.token).toBe(mockJwt);
1698+
});
1699+
});
1700+
15771701
describe('origin outage mode fallback', () => {
15781702
let dispatchSpy: ReturnType<typeof vi.spyOn>;
15791703
let fetchSpy: ReturnType<typeof vi.spyOn>;

packages/shared/src/types/authConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@ export interface AuthConfigResource extends ClerkResource {
2121
* Preferred channels for phone code providers.
2222
*/
2323
preferredChannels: Record<string, PhoneCodeChannel> | null;
24+
sessionMinter: boolean;
2425
__internal_toSnapshot: () => AuthConfigJSONSnapshot;
2526
}

packages/shared/src/types/json.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ export interface AuthConfigJSON extends ClerkResourceJSON {
332332
claimed_at: number | null;
333333
reverification: boolean;
334334
preferred_channels?: Record<string, PhoneCodeChannel>;
335+
session_minter?: boolean;
335336
}
336337

337338
export interface VerificationJSON extends ClerkResourceJSON {

0 commit comments

Comments
 (0)