-
Notifications
You must be signed in to change notification settings - Fork 67
fix(nextjs): keep the ID token claims in the session cookie and switch organizations without the in-memory session #550
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@asgardeo/nextjs': patch | ||
| --- | ||
|
|
||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,13 +55,17 @@ import { | |
| getScim2Me, | ||
| getSchemas, | ||
| initializeEmbeddedSignInFlow, | ||
| processOpenIDScopes, | ||
| updateMeProfile, | ||
| } from '@asgardeo/node'; | ||
| import {TOKEN_REQUEST_TIMEOUT_MS} from './constants/sessionConstants'; | ||
| import {AsgardeoNextConfig} from './models/config'; | ||
| import getClientOrigin from './server/actions/getClientOrigin'; | ||
| import getSessionId from './server/actions/getSessionId'; | ||
| import getSessionPayload from './server/actions/getSessionPayload'; | ||
| import decorateConfigWithNextEnv from './utils/decorateConfigWithNextEnv'; | ||
| import logger from './utils/logger'; | ||
| import {SessionTokenPayload} from './utils/SessionManager'; | ||
|
|
||
| /** | ||
| * Client for mplementing Asgardeo in Next.js applications. | ||
|
|
@@ -213,7 +217,8 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte | |
|
|
||
| return generateUserProfile(profile, flattenUserSchema(schemas)); | ||
| } catch (error) { | ||
| return this.asgardeo.getUser(resolvedSessionId); | ||
| // Same fallback as the React SDK: the claims of the ID token, read from the session cookie. | ||
| return extractUserClaimsFromIdToken(await this.getDecodedIdToken(resolvedSessionId)) as User; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -260,9 +265,11 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte | |
| `Reason: ${error instanceof Error ? error.message : String(error)}`, | ||
| ); | ||
|
|
||
| const idTokenClaims: Record<string, unknown> = extractUserClaimsFromIdToken(await this.getDecodedIdToken(userId)); | ||
|
|
||
| return { | ||
| flattenedProfile: extractUserClaimsFromIdToken(await this.asgardeo.getDecodedIdToken(userId)), | ||
| profile: extractUserClaimsFromIdToken(await this.asgardeo.getDecodedIdToken(userId)), | ||
| flattenedProfile: idTokenClaims, | ||
| profile: idTokenClaims, | ||
| schemas: [], | ||
| }; | ||
| } | ||
|
|
@@ -391,7 +398,7 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte | |
| } | ||
|
|
||
| override async getCurrentOrganization(userId?: string): Promise<Organization | null> { | ||
| const idToken: IdToken = await this.asgardeo.getDecodedIdToken(userId); | ||
| const idToken: IdToken = await this.getDecodedIdToken(userId); | ||
|
|
||
| return { | ||
| id: idToken?.org_id as string, | ||
|
|
@@ -400,6 +407,13 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte | |
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Exchanges the current access token for one scoped to `organization` (the `organization_switch` grant). | ||
| * | ||
| * The current access token is read from the session cookie rather than the legacy in-memory session, so the | ||
| * switch works on any server instance and after the middleware has refreshed the tokens. The in-memory | ||
| * session is updated afterwards, best-effort, for the code paths that still read it. | ||
| */ | ||
| override async switchOrganization(organization: Organization, userId?: string): Promise<TokenResponse | Response> { | ||
| try { | ||
| if (!organization.id) { | ||
|
|
@@ -411,22 +425,82 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte | |
| ); | ||
| } | ||
|
|
||
| const exchangeConfig: TokenExchangeRequestConfig = { | ||
| attachToken: false, | ||
| data: { | ||
| client_id: '{{clientId}}', | ||
| client_secret: '{{clientSecret}}', | ||
| grant_type: 'organization_switch', | ||
| scope: '{{scopes}}', | ||
| switching_organization: organization.id, | ||
| token: '{{accessToken}}', | ||
| const configData: AuthClientConfig<T> = await this.asgardeo.getConfigData(); | ||
| const accessToken: string = await this.getAccessToken(userId); | ||
| const clientId: string = configData?.clientId ?? ''; | ||
| const clientSecret: string | undefined = configData?.clientSecret || undefined; | ||
| const tokenEndpoint: string = configData?.endpoints?.token || `${configData?.baseUrl}/oauth2/token`; | ||
| const useBasicAuth: boolean = !!clientSecret && configData?.tokenRequest?.authMethod === 'client_secret_basic'; | ||
|
|
||
| const body: URLSearchParams = new URLSearchParams({ | ||
| client_id: clientId, | ||
| grant_type: 'organization_switch', | ||
| scope: processOpenIDScopes(configData?.scopes), | ||
| switching_organization: organization.id, | ||
| token: accessToken, | ||
| }); | ||
|
|
||
| if (clientSecret && !useBasicAuth) { | ||
| body.set('client_secret', clientSecret); | ||
| } | ||
|
|
||
| const response: Response = await fetch(tokenEndpoint, { | ||
| body: body.toString(), | ||
| headers: { | ||
| Accept: 'application/json', | ||
| 'Content-Type': 'application/x-www-form-urlencoded', | ||
| ...(useBasicAuth ? {Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`} : {}), | ||
| }, | ||
| id: 'organization-switch', | ||
| returnsSession: true, | ||
| signInRequired: true, | ||
| method: 'POST', | ||
| // The body carries the access token and possibly the client secret; a redirect must never forward them. | ||
| redirect: 'error', | ||
| // Bound the wait so a stalled token endpoint cannot keep the server action pending indefinitely. | ||
| signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error( | ||
| `The token endpoint rejected the organization switch (HTTP ${response.status}): ${await response.text()}`, | ||
| ); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| const tokenData: Record<string, unknown> = (await response.json()) as Record<string, unknown>; | ||
| const switchedAccessToken: unknown = tokenData['access_token']; | ||
|
|
||
| if (typeof switchedAccessToken !== 'string' || !switchedAccessToken) { | ||
| throw new Error('The token endpoint response for the organization switch does not contain an access_token.'); | ||
| } | ||
|
|
||
| const tokenResponse: TokenResponse = { | ||
| accessToken: switchedAccessToken, | ||
| createdAt: Date.now(), | ||
| expiresIn: String(tokenData['expires_in']), | ||
| idToken: (tokenData['id_token'] as string | undefined) ?? '', | ||
| refreshToken: (tokenData['refresh_token'] as string | undefined) ?? '', | ||
| scope: (tokenData['scope'] as string | undefined) ?? '', | ||
| tokenType: (tokenData['token_type'] as string | undefined) ?? 'Bearer', | ||
|
Comment on lines
+477
to
+481
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 427 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- changed hunk ---'
git diff -- packages/nextjs/src/AsgardeoNextClient.ts
printf '%s\n' '--- target context ---'
sed -n '430,510p' packages/nextjs/src/AsgardeoNextClient.ts
printf '%s\n' '--- TokenResponse declarations and relevant consumers ---'
rg -n -g '*.ts' -g '*.tsx' "interface TokenResponse|type TokenResponse|TokenResponse|expiresIn|idToken|refreshToken|tokenType" packages/nextjs packages | head -240Repository: asgardeo/javascript Length of output: 31926 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- file outline ---'
ast-grep outline packages/nextjs/src/AsgardeoNextClient.ts
printf '%s\n' '--- token exchange methods and direct helpers ---'
rg -n -C 12 "tokenData|fetch\\(|redirect:|getDecodedIdToken|sessionId" packages/nextjs/src/AsgardeoNextClient.ts
printf '%s\n' '--- token model files ---'
fd -t f . packages | rg '(^|/)(types?|models?|interfaces?)/|Token|token'Repository: asgardeo/javascript Length of output: 15788 🏁 Script executed: #!/bin/bash
set -e
sed -n '430,510p' packages/nextjs/src/AsgardeoNextClient.ts
rg -n -C 8 "TokenResponse|expiresIn|idToken|refreshToken|tokenType" packages/nextjs/src packages/nextjsRepository: asgardeo/javascript Length of output: 50376 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- target context ---'
sed -n '430,510p' packages/nextjs/src/AsgardeoNextClient.ts
printf '%s\n' '--- direct token contracts and consumers ---'
rg -n -C 10 "TokenResponse|expiresIn|idToken|refreshToken|tokenType" packages/nextjs/src packages/nextjsRepository: asgardeo/javascript Length of output: 50376 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- TokenResponse contract ---'
rg -n -C 8 "export (interface|type) TokenResponse|interface TokenResponse|type TokenResponse" packages/node packages/javascript
printf '%s\n' '--- switch consumer ---'
sed -n '34,78p' packages/nextjs/src/server/actions/switchOrganization.ts
printf '%s\n' '--- session token constructor ---'
rg -n -C 12 "createSessionToken\\(" packages/nextjs/src/utils/SessionManager.ts
printf '%s\n' '--- relevant tests ---'
sed -n '160,220p' packages/nextjs/src/__tests__/AsgardeoNextClient.test.tsRepository: asgardeo/javascript Length of output: 7039 🏁 Script executed: #!/bin/bash
set -e
sed -n '27,115p' packages/javascript/src/models/token.ts
sed -n '254,286p' packages/nextjs/src/utils/SessionManager.tsRepository: asgardeo/javascript Length of output: 3824 Validate the token response before constructing If 🤖 Prompt for AI Agents |
||
| }; | ||
|
|
||
| const tokenResponse: TokenResponse | Response = await this.asgardeo.exchangeToken(exchangeConfig, userId); | ||
| try { | ||
| await this.setSession( | ||
| { | ||
| access_token: tokenResponse.accessToken, | ||
| created_at: tokenResponse.createdAt, | ||
| expires_in: tokenResponse.expiresIn, | ||
| id_token: tokenResponse.idToken, | ||
| refresh_token: tokenResponse.refreshToken, | ||
| scope: tokenResponse.scope, | ||
| token_type: tokenResponse.tokenType, | ||
| }, | ||
| userId, | ||
| ); | ||
| } catch (error) { | ||
| logger.debug( | ||
| `[AsgardeoNextClient] Could not update the in-memory session after the organization switch: ${ | ||
| error instanceof Error ? error.message : String(error) | ||
| }`, | ||
| ); | ||
| } | ||
|
|
||
| return tokenResponse; | ||
| } catch (error) { | ||
|
|
@@ -474,11 +548,26 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte | |
| } | ||
|
|
||
| /** | ||
| * Get the decoded ID token for a session | ||
| * Gets the decoded ID token. | ||
| * | ||
| * When `idToken` is given it is decoded as is. Otherwise the claims kept in the session cookie are | ||
| * returned, so the lookup works on any server instance and after the middleware has refreshed the | ||
| * tokens. The legacy in-memory session is only consulted for sessions that predate the cookie claims. | ||
| */ | ||
| async getDecodedIdToken(sessionId?: string, idToken?: string): Promise<IdToken> { | ||
| await this.ensureInitialized(); | ||
| return this.asgardeo.getDecodedIdToken(sessionId as string, idToken); | ||
|
|
||
| if (idToken) { | ||
| return this.asgardeo.decodeJwtToken<IdToken>(idToken); | ||
| } | ||
|
|
||
| const session: SessionTokenPayload | undefined = await getSessionPayload(); | ||
|
|
||
| if (session?.idTokenClaims) { | ||
| return {sub: session.sub, ...session.idTokenClaims} as IdToken; | ||
|
Comment on lines
+564
to
+567
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
sed -n '360,430p' packages/nextjs/src/AsgardeoNextClient.ts
sed -n '545,580p' packages/nextjs/src/AsgardeoNextClient.ts
sed -n '90,150p' packages/nextjs/src/__tests__/AsgardeoNextClient.test.tsRepository: asgardeo/javascript Length of output: 6373 Information Disclosure Reachability: Internal Honor an explicit When Use cookie claims only when 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| return this.asgardeo.getDecodedIdToken(sessionId as string); | ||
| } | ||
|
|
||
| override getConfiguration(): T { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.