Skip to content

Commit c69dc5a

Browse files
fix(nextjs): keep the ID token claims in the session cookie and switch organizations without the in-memory session
The Next.js session lives in an HttpOnly JWT cookie, but organization switching, the current-organization lookup and the ID-token fallback of the user profile still went through the legacy Node client, whose session is a process-local memory cache. After a server restart, on another serverless instance, or once the middleware had refreshed the tokens in the Edge runtime, that cache was empty or stale and those operations failed while the user was still signed in. - Store the ID token claims (minus single-use protocol claims) in the session cookie at sign-in, on organization switch and on refresh. - Read them back in getDecodedIdToken(); keep the in-memory session only as a fallback for cookies issued before this change. - Perform the organization_switch grant directly with the access token from the cookie and update the in-memory session best-effort afterwards. - Align the getUser() fallback with the React SDK (ID token claims). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 409ebae commit c69dc5a

10 files changed

Lines changed: 672 additions & 20 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@asgardeo/nextjs': patch
3+
---
4+
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.

‎packages/nextjs/src/AsgardeoNextClient.ts‎

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,16 @@ import {
5555
getScim2Me,
5656
getSchemas,
5757
initializeEmbeddedSignInFlow,
58+
processOpenIDScopes,
5859
updateMeProfile,
5960
} from '@asgardeo/node';
6061
import {AsgardeoNextConfig} from './models/config';
6162
import getClientOrigin from './server/actions/getClientOrigin';
6263
import getSessionId from './server/actions/getSessionId';
64+
import getSessionPayload from './server/actions/getSessionPayload';
6365
import decorateConfigWithNextEnv from './utils/decorateConfigWithNextEnv';
6466
import logger from './utils/logger';
67+
import {SessionTokenPayload} from './utils/SessionManager';
6568

6669
/**
6770
* Client for mplementing Asgardeo in Next.js applications.
@@ -213,7 +216,8 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
213216

214217
return generateUserProfile(profile, flattenUserSchema(schemas));
215218
} catch (error) {
216-
return this.asgardeo.getUser(resolvedSessionId);
219+
// Same fallback as the React SDK: the claims of the ID token, read from the session cookie.
220+
return extractUserClaimsFromIdToken(await this.getDecodedIdToken(resolvedSessionId)) as User;
217221
}
218222
}
219223

@@ -260,9 +264,11 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
260264
`Reason: ${error instanceof Error ? error.message : String(error)}`,
261265
);
262266

267+
const idTokenClaims: Record<string, unknown> = extractUserClaimsFromIdToken(await this.getDecodedIdToken(userId));
268+
263269
return {
264-
flattenedProfile: extractUserClaimsFromIdToken(await this.asgardeo.getDecodedIdToken(userId)),
265-
profile: extractUserClaimsFromIdToken(await this.asgardeo.getDecodedIdToken(userId)),
270+
flattenedProfile: idTokenClaims,
271+
profile: idTokenClaims,
266272
schemas: [],
267273
};
268274
}
@@ -391,7 +397,7 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
391397
}
392398

393399
override async getCurrentOrganization(userId?: string): Promise<Organization | null> {
394-
const idToken: IdToken = await this.asgardeo.getDecodedIdToken(userId);
400+
const idToken: IdToken = await this.getDecodedIdToken(userId);
395401

396402
return {
397403
id: idToken?.org_id as string,
@@ -400,6 +406,13 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
400406
};
401407
}
402408

409+
/**
410+
* Exchanges the current access token for one scoped to `organization` (the `organization_switch` grant).
411+
*
412+
* The current access token is read from the session cookie rather than the legacy in-memory session, so the
413+
* switch works on any server instance and after the middleware has refreshed the tokens. The in-memory
414+
* session is updated afterwards, best-effort, for the code paths that still read it.
415+
*/
403416
override async switchOrganization(organization: Organization, userId?: string): Promise<TokenResponse | Response> {
404417
try {
405418
if (!organization.id) {
@@ -411,22 +424,72 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
411424
);
412425
}
413426

414-
const exchangeConfig: TokenExchangeRequestConfig = {
415-
attachToken: false,
416-
data: {
417-
client_id: '{{clientId}}',
418-
client_secret: '{{clientSecret}}',
419-
grant_type: 'organization_switch',
420-
scope: '{{scopes}}',
421-
switching_organization: organization.id,
422-
token: '{{accessToken}}',
427+
const configData: AuthClientConfig<T> = await this.asgardeo.getConfigData();
428+
const accessToken: string = await this.getAccessToken(userId);
429+
const clientId: string = configData?.clientId ?? '';
430+
const clientSecret: string | undefined = configData?.clientSecret || undefined;
431+
const tokenEndpoint: string = configData?.endpoints?.token || `${configData?.baseUrl}/oauth2/token`;
432+
const useBasicAuth: boolean = !!clientSecret && configData?.tokenRequest?.authMethod === 'client_secret_basic';
433+
434+
const body: URLSearchParams = new URLSearchParams({
435+
client_id: clientId,
436+
grant_type: 'organization_switch',
437+
scope: processOpenIDScopes(configData?.scopes),
438+
switching_organization: organization.id,
439+
token: accessToken,
440+
});
441+
442+
if (clientSecret && !useBasicAuth) {
443+
body.set('client_secret', clientSecret);
444+
}
445+
446+
const response: Response = await fetch(tokenEndpoint, {
447+
body: body.toString(),
448+
headers: {
449+
Accept: 'application/json',
450+
'Content-Type': 'application/x-www-form-urlencoded',
451+
...(useBasicAuth ? {Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`} : {}),
423452
},
424-
id: 'organization-switch',
425-
returnsSession: true,
426-
signInRequired: true,
453+
method: 'POST',
454+
});
455+
456+
if (!response.ok) {
457+
throw new Error(
458+
`The token endpoint rejected the organization switch (HTTP ${response.status}): ${await response.text()}`,
459+
);
460+
}
461+
462+
const tokenData: Record<string, unknown> = (await response.json()) as Record<string, unknown>;
463+
const tokenResponse: TokenResponse = {
464+
accessToken: tokenData['access_token'] as string,
465+
createdAt: Date.now(),
466+
expiresIn: String(tokenData['expires_in']),
467+
idToken: (tokenData['id_token'] as string | undefined) ?? '',
468+
refreshToken: (tokenData['refresh_token'] as string | undefined) ?? '',
469+
scope: (tokenData['scope'] as string | undefined) ?? '',
470+
tokenType: (tokenData['token_type'] as string | undefined) ?? 'Bearer',
427471
};
428472

429-
const tokenResponse: TokenResponse | Response = await this.asgardeo.exchangeToken(exchangeConfig, userId);
473+
try {
474+
await this.setSession(
475+
{
476+
access_token: tokenResponse.accessToken,
477+
created_at: tokenResponse.createdAt,
478+
expires_in: tokenResponse.expiresIn,
479+
id_token: tokenResponse.idToken,
480+
refresh_token: tokenResponse.refreshToken,
481+
scope: tokenResponse.scope,
482+
token_type: tokenResponse.tokenType,
483+
},
484+
userId,
485+
);
486+
} catch (error) {
487+
logger.debug(
488+
`[AsgardeoNextClient] Could not update the in-memory session after the organization switch: ${
489+
error instanceof Error ? error.message : String(error)
490+
}`,
491+
);
492+
}
430493

431494
return tokenResponse;
432495
} catch (error) {
@@ -474,11 +537,26 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
474537
}
475538

476539
/**
477-
* Get the decoded ID token for a session
540+
* Gets the decoded ID token.
541+
*
542+
* When `idToken` is given it is decoded as is. Otherwise the claims kept in the session cookie are
543+
* returned, so the lookup works on any server instance and after the middleware has refreshed the
544+
* tokens. The legacy in-memory session is only consulted for sessions that predate the cookie claims.
478545
*/
479546
async getDecodedIdToken(sessionId?: string, idToken?: string): Promise<IdToken> {
480547
await this.ensureInitialized();
481-
return this.asgardeo.getDecodedIdToken(sessionId as string, idToken);
548+
549+
if (idToken) {
550+
return this.asgardeo.decodeJwtToken<IdToken>(idToken);
551+
}
552+
553+
const session: SessionTokenPayload | undefined = await getSessionPayload();
554+
555+
if (session?.idTokenClaims) {
556+
return {sub: session.sub, ...session.idTokenClaims} as IdToken;
557+
}
558+
559+
return this.asgardeo.getDecodedIdToken(sessionId as string);
482560
}
483561

484562
override getConfiguration(): T {

0 commit comments

Comments
 (0)