Skip to content

Commit a4a4318

Browse files
fix(nextjs): keep the organization claims when the essential ID token claims overflow the cookie
Adds a narrower fallback tier between "essential claims" and "no claims" so that `getCurrentOrganization()` keeps working on every server instance unless the cookie is within a hundred bytes or so of the browser limit. The session stays cookie-only by design; there is no server-side store to fall back to. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 723d02f commit a4a4318

3 files changed

Lines changed: 91 additions & 12 deletions

File tree

‎.changeset/nextjs-cookie-backed-session.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@asgardeo/nextjs': patch
33
---
44

5-
Organization switching, the current organization and the ID-token fallback of the user profile no longer depend on the in-memory session of the underlying Node client, which is empty after a server restart, on another serverless instance, or after the middleware refreshed the tokens in the Edge runtime. The claims of the ID token are now kept in the session cookie (single-use protocol claims such as `at_hash` and `nonce` are dropped), `getDecodedIdToken()` reads them from there, and the `organization_switch` exchange uses the access token from the cookie. When the claims would push the session cookie over the 4 KB browser limit, only the organization and basic identity claims are kept (or none, if even those do not fit) and a warning is logged.
5+
Organization switching, the current organization and the ID-token fallback of the user profile no longer depend on the in-memory session of the underlying Node client, which is empty after a server restart, on another serverless instance, or after the middleware refreshed the tokens in the Edge runtime. The claims of the ID token are now kept in the session cookie (single-use protocol claims such as `at_hash` and `nonce` are dropped), `getDecodedIdToken()` reads them from there, and the `organization_switch` exchange uses the access token from the cookie. When the claims would push the session cookie over the 4 KB browser limit, they are narrowed step by step (essential identity and organization claims, then the organization claims alone, then none) and a warning is logged.

‎packages/nextjs/src/utils/SessionManager.ts‎

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -204,16 +204,41 @@ class SessionManager {
204204
return this.MAX_COOKIE_BYTES - this.getSessionCookieName().length - this.COOKIE_ATTRIBUTES_HEADROOM_BYTES;
205205
}
206206

207+
/**
208+
* The organization claims behind `getCurrentOrganization()`: the smallest claim set worth keeping
209+
* when not even the essential claims fit into the session cookie.
210+
*/
211+
private static readonly ORGANIZATION_ID_TOKEN_CLAIMS: string[] = [
212+
'org_handle',
213+
'org_id',
214+
'org_name',
215+
'sub',
216+
'user_org',
217+
];
218+
219+
private static pickIdTokenClaims(
220+
idTokenClaims: Record<string, unknown>,
221+
claimNames: string[],
222+
): Record<string, unknown> {
223+
return Object.fromEntries(
224+
Object.entries(idTokenClaims).filter(([claim]: [string, unknown]) => claimNames.includes(claim)),
225+
);
226+
}
227+
207228
/**
208229
* Reduces persisted ID token claims to the ones the SDK itself reads
209230
* (see {@link SessionManager.ESSENTIAL_ID_TOKEN_CLAIMS}).
210231
*/
211232
static toEssentialIdTokenClaims(idTokenClaims: Record<string, unknown>): Record<string, unknown> {
212-
return Object.fromEntries(
213-
Object.entries(idTokenClaims).filter(([claim]: [string, unknown]) =>
214-
this.ESSENTIAL_ID_TOKEN_CLAIMS.includes(claim),
215-
),
216-
);
233+
return this.pickIdTokenClaims(idTokenClaims, this.ESSENTIAL_ID_TOKEN_CLAIMS);
234+
}
235+
236+
/**
237+
* Reduces persisted ID token claims to the organization claims only
238+
* (see {@link SessionManager.ORGANIZATION_ID_TOKEN_CLAIMS}).
239+
*/
240+
static toOrganizationIdTokenClaims(idTokenClaims: Record<string, unknown>): Record<string, unknown> {
241+
return this.pickIdTokenClaims(idTokenClaims, this.ORGANIZATION_ID_TOKEN_CLAIMS);
217242
}
218243

219244
/**
@@ -222,8 +247,9 @@ class SessionManager {
222247
* A cookie above the browser limit is dropped silently, which would leave the user without a
223248
* session right after signing in or refreshing. The ID token claims are the only part of the
224249
* payload whose size the SDK controls, so when the token exceeds the cookie budget they are
225-
* reduced to the essential claims and, if that is still too large, left out entirely. Either
226-
* reduction is logged at `warn` level.
250+
* narrowed step by step: to the essential identity and organization claims, then to the
251+
* organization claims alone, and finally left out entirely. Any reduction is logged at `warn`
252+
* level. The session stays cookie-only on purpose; there is no server-side store to fall back to.
227253
*/
228254
static async createSessionToken(
229255
accessToken: string,
@@ -259,18 +285,22 @@ class SessionManager {
259285
let jwt: string = await sign(idTokenClaims);
260286

261287
if (idTokenClaims && jwt.length > budget) {
288+
let keptClaims: string = 'only the essential ID token claims';
262289
jwt = await sign(this.toEssentialIdTokenClaims(idTokenClaims));
263290

264-
const keptClaims: string =
265-
jwt.length > budget ? 'none of the ID token claims are' : 'only the essential ID token claims are';
291+
if (jwt.length > budget) {
292+
keptClaims = 'only the organization claims of the ID token';
293+
jwt = await sign(this.toOrganizationIdTokenClaims(idTokenClaims));
294+
}
266295

267296
if (jwt.length > budget) {
268297
// Without claims, the ID token fallbacks behave as they did before the claims were persisted.
298+
keptClaims = 'none of the ID token claims';
269299
jwt = await sign(undefined);
270300
}
271301

272302
logger.warn(
273-
`[SessionManager] The ID token claims do not fit into the session cookie (budget: ${budget} bytes); ${keptClaims} kept in the session.`,
303+
`[SessionManager] The ID token claims do not fit into the session cookie (budget: ${budget} bytes); ${keptClaims} are kept in the session.`,
274304
);
275305
}
276306

‎packages/nextjs/src/utils/__tests__/SessionManager.test.ts‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,41 @@ describe('SessionManager', () => {
144144
});
145145
});
146146

147-
it('leaves the claims out when not even the essential claims fit into the cookie', async () => {
147+
it('keeps only the organization claims when the essential claims do not fit into the cookie either', async () => {
148+
const bulkyEssentialClaims: Record<string, unknown> = {
149+
...idTokenClaims,
150+
email: 'jane@example.com',
151+
given_name: 'g'.repeat(1200),
152+
name: 'n'.repeat(1200),
153+
org_handle: 'acme',
154+
user_org: 'org-1',
155+
};
156+
157+
const token: string = await SessionManager.createSessionToken(
158+
'a'.repeat(2400),
159+
'user-1',
160+
'session-1',
161+
'openid',
162+
3600,
163+
'refresh-token',
164+
'org-1',
165+
bulkyEssentialClaims,
166+
);
167+
168+
expect(token.length).toBeLessThanOrEqual(SessionManager.getSessionCookieValueBudget());
169+
170+
const payload: SessionTokenPayload = await SessionManager.verifySessionToken(token);
171+
172+
expect(payload.idTokenClaims).toEqual({
173+
org_handle: 'acme',
174+
org_id: 'org-1',
175+
org_name: 'Acme',
176+
sub: 'user-1',
177+
user_org: 'org-1',
178+
});
179+
});
180+
181+
it('leaves the claims out when not even the organization claims fit into the cookie', async () => {
148182
const token: string = await SessionManager.createSessionToken(
149183
'a'.repeat(SessionManager.getSessionCookieValueBudget()),
150184
'user-1',
@@ -163,6 +197,21 @@ describe('SessionManager', () => {
163197
});
164198
});
165199

200+
describe('toOrganizationIdTokenClaims', () => {
201+
it('keeps the organization claims and the subject only', () => {
202+
expect(
203+
SessionManager.toOrganizationIdTokenClaims({
204+
email: 'jane@example.com',
205+
org_handle: 'acme',
206+
org_id: 'org-1',
207+
org_name: 'Acme',
208+
sub: 'user-1',
209+
user_org: 'org-1',
210+
}),
211+
).toEqual({org_handle: 'acme', org_id: 'org-1', org_name: 'Acme', sub: 'user-1', user_org: 'org-1'});
212+
});
213+
});
214+
166215
describe('toEssentialIdTokenClaims', () => {
167216
it('keeps the organization and basic identity claims only', () => {
168217
expect(

0 commit comments

Comments
 (0)