From 64c69d05828539fa0e95a688e0a9bcd8c0a9bed6 Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Fri, 11 Sep 2026 15:43:27 +0530 Subject: [PATCH] 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). Review follow-ups: - Keep the session cookie within the browser size limit: when the claims would push it over, narrow them step by step (essential identity and organization claims, then the organization claims alone, then none) and log a warning. - Send the organization_switch request with `redirect: 'error'` and a bounded timeout, and reject a successful response that carries no access_token. CI: - Override smol-toml (pulled in by nx, which pins 1.6.1) to 1.7.2 so the security audit no longer fails on GHSA-7w5x-hrqm-74c2. --- .changeset/nextjs-cookie-backed-session.md | 5 + packages/nextjs/src/AsgardeoNextClient.ts | 127 +++++++-- .../src/__tests__/AsgardeoNextClient.test.ts | 262 ++++++++++++++++++ .../nextjs/src/constants/sessionConstants.ts | 7 + .../actions/handleOAuthCallbackAction.ts | 1 + .../nextjs/src/server/actions/signInAction.ts | 1 + .../src/server/actions/switchOrganization.ts | 1 + packages/nextjs/src/utils/SessionManager.ts | 196 ++++++++++++- .../utils/__tests__/SessionManager.test.ts | 238 ++++++++++++++++ .../__tests__/handleRefreshToken.test.ts | 138 +++++++++ .../nextjs/src/utils/handleRefreshToken.ts | 16 +- pnpm-lock.yaml | 9 +- pnpm-workspace.yaml | 1 + 13 files changed, 965 insertions(+), 37 deletions(-) create mode 100644 .changeset/nextjs-cookie-backed-session.md create mode 100644 packages/nextjs/src/__tests__/AsgardeoNextClient.test.ts create mode 100644 packages/nextjs/src/utils/__tests__/SessionManager.test.ts create mode 100644 packages/nextjs/src/utils/__tests__/handleRefreshToken.test.ts diff --git a/.changeset/nextjs-cookie-backed-session.md b/.changeset/nextjs-cookie-backed-session.md new file mode 100644 index 000000000..588c31775 --- /dev/null +++ b/.changeset/nextjs-cookie-backed-session.md @@ -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. diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 3d4426a3c..19b64cf37 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -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 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 exte `Reason: ${error instanceof Error ? error.message : String(error)}`, ); + const idTokenClaims: Record = 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 exte } override async getCurrentOrganization(userId?: string): Promise { - 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 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 { try { if (!organization.id) { @@ -411,22 +425,82 @@ class AsgardeoNextClient 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 = 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()}`, + ); + } + + const tokenData: Record = (await response.json()) as Record; + 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', }; - 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 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 { await this.ensureInitialized(); - return this.asgardeo.getDecodedIdToken(sessionId as string, idToken); + + if (idToken) { + return this.asgardeo.decodeJwtToken(idToken); + } + + const session: SessionTokenPayload | undefined = await getSessionPayload(); + + if (session?.idTokenClaims) { + return {sub: session.sub, ...session.idTokenClaims} as IdToken; + } + + return this.asgardeo.getDecodedIdToken(sessionId as string); } override getConfiguration(): T { diff --git a/packages/nextjs/src/__tests__/AsgardeoNextClient.test.ts b/packages/nextjs/src/__tests__/AsgardeoNextClient.test.ts new file mode 100644 index 000000000..42dc8ce25 --- /dev/null +++ b/packages/nextjs/src/__tests__/AsgardeoNextClient.test.ts @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {AsgardeoRuntimeError, IdToken, Organization, TokenResponse} from '@asgardeo/node'; +import {afterEach, beforeAll, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import AsgardeoNextClient from '../AsgardeoNextClient'; +import getAccessToken from '../server/actions/getAccessToken'; +import getSessionPayload from '../server/actions/getSessionPayload'; +import {SessionTokenPayload} from '../utils/SessionManager'; + +const {legacyClient, storageManager} = vi.hoisted(() => { + const hoistedStorageManager: {setSessionData: Mock} = {setSessionData: vi.fn()}; + const hoistedLegacyClient: { + decodeJwtToken: Mock; + getConfigData: Mock; + getDecodedIdToken: Mock; + getStorageManager: Mock; + initialize: Mock; + } = { + decodeJwtToken: vi.fn(), + getConfigData: vi.fn(), + getDecodedIdToken: vi.fn(), + getStorageManager: vi.fn(), + initialize: vi.fn(), + }; + + return {legacyClient: hoistedLegacyClient, storageManager: hoistedStorageManager}; +}); + +vi.mock('@asgardeo/node', async (importOriginal: () => Promise>) => ({ + ...(await importOriginal()), + // The SDK instantiates the legacy client with `new`, which an arrow function cannot serve. + // eslint-disable-next-line prefer-arrow-callback + LegacyAsgardeoNodeClient: vi.fn(function LegacyAsgardeoNodeClientMock(): unknown { + return legacyClient; + }), +})); + +vi.mock('../server/actions/getClientOrigin', () => ({default: vi.fn(async () => 'http://localhost:3000')})); +vi.mock('../server/actions/getSessionId', () => ({default: vi.fn(async () => 'session-1')})); +vi.mock('../server/actions/getSessionPayload', () => ({default: vi.fn()})); +vi.mock('../server/actions/getAccessToken', () => ({default: vi.fn()})); + +describe('AsgardeoNextClient', () => { + const config: Record = { + baseUrl: 'https://api.asgardeo.io/t/acme', + clientId: 'client-id', + clientSecret: 'client-secret', + scopes: 'openid profile', + }; + const cookieSession: SessionTokenPayload = { + accessToken: 'cookie-access-token', + exp: 0, + iat: 0, + idTokenClaims: {email: 'jane@example.com', org_handle: 'acme', org_id: 'org-1', org_name: 'Acme'}, + organizationId: 'org-1', + refreshToken: 'refresh-1', + scopes: ['openid'], + sessionId: 'session-1', + sub: 'user-1', + type: 'session', + } as SessionTokenPayload; + + let client: AsgardeoNextClient; + + beforeAll(async () => { + legacyClient.getConfigData.mockResolvedValue(config); + legacyClient.initialize.mockResolvedValue(true); + + client = AsgardeoNextClient.getInstance(); + await client.initialize(config as any); + }); + + beforeEach(() => { + vi.clearAllMocks(); + legacyClient.getConfigData.mockResolvedValue(config); + legacyClient.getStorageManager.mockResolvedValue(storageManager); + (getSessionPayload as unknown as Mock).mockResolvedValue(cookieSession); + (getAccessToken as unknown as Mock).mockResolvedValue('cookie-access-token'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('getDecodedIdToken', () => { + it('decodes the given ID token instead of consulting any session', async () => { + const decoded: IdToken = {aud: 'client-id', iss: 'issuer', sub: 'user-1'}; + + legacyClient.decodeJwtToken.mockResolvedValue(decoded); + + await expect(client.getDecodedIdToken('session-1', 'raw.id.token')).resolves.toEqual(decoded); + + expect(legacyClient.decodeJwtToken).toHaveBeenCalledWith('raw.id.token'); + expect(getSessionPayload).not.toHaveBeenCalled(); + expect(legacyClient.getDecodedIdToken).not.toHaveBeenCalled(); + }); + + it('returns the claims stored in the session cookie without the in-memory session', async () => { + await expect(client.getDecodedIdToken('session-1')).resolves.toEqual({ + email: 'jane@example.com', + org_handle: 'acme', + org_id: 'org-1', + org_name: 'Acme', + sub: 'user-1', + }); + + expect(legacyClient.getDecodedIdToken).not.toHaveBeenCalled(); + }); + + it('falls back to the in-memory session for cookies that carry no claims', async () => { + const decoded: IdToken = {aud: 'client-id', iss: 'issuer', sub: 'user-1'}; + + (getSessionPayload as unknown as Mock).mockResolvedValue({...cookieSession, idTokenClaims: undefined}); + legacyClient.getDecodedIdToken.mockResolvedValue(decoded); + + await expect(client.getDecodedIdToken('session-1')).resolves.toEqual(decoded); + + expect(legacyClient.getDecodedIdToken).toHaveBeenCalledWith('session-1'); + }); + }); + + describe('getCurrentOrganization', () => { + it('reads the organization from the claims in the session cookie', async () => { + await expect(client.getCurrentOrganization('session-1')).resolves.toEqual({ + id: 'org-1', + name: 'Acme', + orgHandle: 'acme', + }); + + expect(legacyClient.getDecodedIdToken).not.toHaveBeenCalled(); + }); + }); + + describe('switchOrganization', () => { + const organization: Organization = {id: 'org-2', name: 'Beta', orgHandle: 'beta'}; + const tokenData: Record = { + access_token: 'switched-access-token', + expires_in: 3600, + id_token: 'switched.id.token', + refresh_token: 'refresh-2', + scope: 'openid profile', + token_type: 'Bearer', + }; + + const mockTokenEndpoint = (response: Partial & {json?: () => Promise}): Mock => { + const fetchMock: Mock = vi.fn().mockResolvedValue({ + json: async (): Promise => tokenData, + ok: true, + status: 200, + text: async (): Promise => '', + ...response, + }); + + vi.stubGlobal('fetch', fetchMock); + + return fetchMock; + }; + + it('exchanges the access token from the session cookie with the organization_switch grant', async () => { + const fetchMock: Mock = mockTokenEndpoint({}); + + const result: TokenResponse | Response = await client.switchOrganization(organization, 'session-1'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init]: [string, RequestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body: URLSearchParams = new URLSearchParams(init.body as string); + + expect(url).toBe('https://api.asgardeo.io/t/acme/oauth2/token'); + expect(init.method).toBe('POST'); + expect(init.redirect).toBe('error'); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(init.signal?.aborted).toBe(false); + expect(body.get('grant_type')).toBe('organization_switch'); + expect(body.get('switching_organization')).toBe('org-2'); + expect(body.get('token')).toBe('cookie-access-token'); + expect(body.get('client_id')).toBe('client-id'); + expect(body.get('client_secret')).toBe('client-secret'); + expect(body.get('scope')).toBe('openid profile'); + + expect(result).toMatchObject({ + accessToken: 'switched-access-token', + expiresIn: '3600', + idToken: 'switched.id.token', + refreshToken: 'refresh-2', + scope: 'openid profile', + tokenType: 'Bearer', + }); + }); + + it('keeps the in-memory session in sync with the switched tokens', async () => { + mockTokenEndpoint({}); + + await client.switchOrganization(organization, 'session-1'); + + expect(storageManager.setSessionData).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'switched-access-token', + expires_in: '3600', + id_token: 'switched.id.token', + refresh_token: 'refresh-2', + }), + 'session-1', + ); + }); + + it('uses HTTP basic authentication when the token request is configured for it', async () => { + legacyClient.getConfigData.mockResolvedValue({...config, tokenRequest: {authMethod: 'client_secret_basic'}}); + + const fetchMock: Mock = mockTokenEndpoint({}); + + await client.switchOrganization(organization, 'session-1'); + + const [, init]: [string, RequestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body: URLSearchParams = new URLSearchParams(init.body as string); + + expect((init.headers as Record)['Authorization']).toBe( + `Basic ${btoa('client-id:client-secret')}`, + ); + expect(body.has('client_secret')).toBe(false); + }); + + it('rejects when the token endpoint refuses the switch', async () => { + mockTokenEndpoint({ok: false, status: 400, text: async (): Promise => '{"error":"invalid_grant"}'}); + + await expect(client.switchOrganization(organization, 'session-1')).rejects.toBeInstanceOf(AsgardeoRuntimeError); + await expect(client.switchOrganization(organization, 'session-1')).rejects.toThrow(/HTTP 400/); + }); + + it('rejects a successful response that carries no access token', async () => { + mockTokenEndpoint({json: async (): Promise => ({token_type: 'Bearer'})}); + + await expect(client.switchOrganization(organization, 'session-1')).rejects.toThrow(/access_token/); + expect(storageManager.setSessionData).not.toHaveBeenCalled(); + }); + + it('rejects when the organization has no ID', async () => { + const fetchMock: Mock = mockTokenEndpoint({}); + + await expect(client.switchOrganization({name: 'Nameless'} as Organization)).rejects.toThrow( + /Organization ID is required/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/nextjs/src/constants/sessionConstants.ts b/packages/nextjs/src/constants/sessionConstants.ts index 4f5eb65ad..4579638a9 100644 --- a/packages/nextjs/src/constants/sessionConstants.ts +++ b/packages/nextjs/src/constants/sessionConstants.ts @@ -23,6 +23,13 @@ */ export const REFRESH_BUFFER_SECONDS: number = 25; +/** + * Upper bound, in milliseconds, for a token request the SDK sends from a server action + * (for example the `organization_switch` grant). Without it a token endpoint that accepts + * the request but never answers keeps the server action pending and ties up request capacity. + */ +export const TOKEN_REQUEST_TIMEOUT_MS: number = 30_000; + /** * Default session cookie lifetime in seconds (24 hours). * diff --git a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts index 37fdc5e4b..b067af81d 100644 --- a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts +++ b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts @@ -127,6 +127,7 @@ const handleOAuthCallbackAction = async ( expiresIn, refreshToken, organizationId, + SessionManager.toIdTokenClaims(idToken), ); cookieStore.set( diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index 82b1a19d8..6a03a20ef 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -148,6 +148,7 @@ const signInAction = async ( expiresIn, refreshToken, organizationId, + SessionManager.toIdTokenClaims(idToken), ); cookieStore.set( diff --git a/packages/nextjs/src/server/actions/switchOrganization.ts b/packages/nextjs/src/server/actions/switchOrganization.ts index 1cfd8139d..6eaa105e9 100644 --- a/packages/nextjs/src/server/actions/switchOrganization.ts +++ b/packages/nextjs/src/server/actions/switchOrganization.ts @@ -63,6 +63,7 @@ const switchOrganization = async ( expiresIn, tokenResponse.refreshToken ?? '', organizationId, + SessionManager.toIdTokenClaims(idToken), ); logger.debug('[switchOrganization] Session token created successfully.'); diff --git a/packages/nextjs/src/utils/SessionManager.ts b/packages/nextjs/src/utils/SessionManager.ts index ba89cd229..a395c2270 100644 --- a/packages/nextjs/src/utils/SessionManager.ts +++ b/packages/nextjs/src/utils/SessionManager.ts @@ -18,6 +18,7 @@ import {AsgardeoRuntimeError, CookieConfig} from '@asgardeo/node'; import {SignJWT, jwtVerify, compactVerify, JWTPayload} from 'jose'; +import logger from './logger'; import {DEFAULT_SESSION_COOKIE_EXPIRY_TIME} from '../constants/sessionConstants'; /** @@ -28,6 +29,14 @@ export interface SessionTokenPayload extends JWTPayload { exp: number; /** Issued at timestamp */ iat: number; + /** + * Claims of the ID token that was issued together with the access token, minus the + * single-use protocol claims (see {@link SessionManager.toIdTokenClaims}). Lets the + * server read the user's organization and identity claims without an in-memory session. + * Reduced to the essential claims, or left out, when the full set would not fit into the + * cookie (see {@link SessionManager.createSessionToken}). + */ + idTokenClaims?: Record; /** Organization ID if applicable */ organizationId?: string; /** The refresh token; empty string if not provided by the auth server */ @@ -115,6 +124,133 @@ class SessionManager { return DEFAULT_SESSION_COOKIE_EXPIRY_TIME; } + /** + * ID token claims that are only meaningful while the token is being validated (hashes, nonce, + * session identifiers). They are dropped before the claims are stored in the session cookie + * to keep the cookie small; everything else, including the organization claims (`org_id`, + * `org_name`, `org_handle`, `user_org`) and the user attributes, is kept. + */ + private static readonly TRANSIENT_ID_TOKEN_CLAIMS: string[] = [ + 'acr', + 'amr', + 'at_hash', + 'azp', + 'c_hash', + 'isk', + 'jti', + 'nbf', + 'nonce', + 'sid', + ]; + + /** + * Reduces a decoded ID token to the claims worth keeping in the session cookie. + * + * @param decodedIdToken - The decoded ID token payload, if one was issued. + * @returns The claims to persist, or `undefined` when there is no ID token. + */ + static toIdTokenClaims(decodedIdToken?: Record | null): Record | undefined { + if (!decodedIdToken || typeof decodedIdToken !== 'object') { + return undefined; + } + + return Object.fromEntries( + Object.entries(decodedIdToken).filter( + ([claim, value]: [string, unknown]) => value !== undefined && !this.TRANSIENT_ID_TOKEN_CLAIMS.includes(claim), + ), + ); + } + + /** + * Browsers store at most 4096 bytes per cookie (name, value and attributes together) and drop + * larger ones, so the session token has to stay within that budget. + */ + private static readonly MAX_COOKIE_BYTES: number = 4096; + + /** + * Room left for the cookie attributes (`Path`, `Max-Age`, `HttpOnly`, `Secure`, `SameSite`) + * that accompany the session token. + */ + private static readonly COOKIE_ATTRIBUTES_HEADROOM_BYTES: number = 128; + + /** + * ID token claims the SDK itself reads: the organization claims behind `getCurrentOrganization()` + * and the basic identity claims behind the ID-token fallback of `getUser()`. When the full claim + * set does not fit into the session cookie, the persisted claims are reduced to these. + */ + private static readonly ESSENTIAL_ID_TOKEN_CLAIMS: string[] = [ + 'aud', + 'email', + 'exp', + 'family_name', + 'given_name', + 'iat', + 'iss', + 'name', + 'org_handle', + 'org_id', + 'org_name', + 'preferred_username', + 'sub', + 'user_org', + 'username', + ]; + + /** + * The largest session token, in bytes, that still fits into a browser cookie together with the + * cookie name and attributes. + */ + static getSessionCookieValueBudget(): number { + return this.MAX_COOKIE_BYTES - this.getSessionCookieName().length - this.COOKIE_ATTRIBUTES_HEADROOM_BYTES; + } + + /** + * The organization claims behind `getCurrentOrganization()`: the smallest claim set worth keeping + * when not even the essential claims fit into the session cookie. + */ + private static readonly ORGANIZATION_ID_TOKEN_CLAIMS: string[] = [ + 'org_handle', + 'org_id', + 'org_name', + 'sub', + 'user_org', + ]; + + private static pickIdTokenClaims( + idTokenClaims: Record, + claimNames: string[], + ): Record { + return Object.fromEntries( + Object.entries(idTokenClaims).filter(([claim]: [string, unknown]) => claimNames.includes(claim)), + ); + } + + /** + * Reduces persisted ID token claims to the ones the SDK itself reads + * (see {@link SessionManager.ESSENTIAL_ID_TOKEN_CLAIMS}). + */ + static toEssentialIdTokenClaims(idTokenClaims: Record): Record { + return this.pickIdTokenClaims(idTokenClaims, this.ESSENTIAL_ID_TOKEN_CLAIMS); + } + + /** + * Reduces persisted ID token claims to the organization claims only + * (see {@link SessionManager.ORGANIZATION_ID_TOKEN_CLAIMS}). + */ + static toOrganizationIdTokenClaims(idTokenClaims: Record): Record { + return this.pickIdTokenClaims(idTokenClaims, this.ORGANIZATION_ID_TOKEN_CLAIMS); + } + + /** + * Creates the signed session token that is stored in the session cookie. + * + * A cookie above the browser limit is dropped silently, which would leave the user without a + * session right after signing in or refreshing. The ID token claims are the only part of the + * payload whose size the SDK controls, so when the token exceeds the cookie budget they are + * narrowed step by step: to the essential identity and organization claims, then to the + * organization claims alone, and finally left out entirely. Any reduction is logged at `warn` + * level. The session stays cookie-only on purpose; there is no server-side store to fall back to. + */ static async createSessionToken( accessToken: string, userId: string, @@ -123,22 +259,56 @@ class SessionManager { accessTokenTtlSeconds: number, refreshToken: string, organizationId?: string, + idTokenClaims?: Record, ): Promise { const secret: Uint8Array = this.getSecret(); + const expirationTime: number = Math.floor(Date.now() / 1000) + accessTokenTtlSeconds; + const budget: number = this.getSessionCookieValueBudget(); - const jwt: string = await new SignJWT({ - accessToken, - organizationId, - refreshToken, - scopes, - sessionId, - type: 'session', - } as Omit) - .setProtectedHeader({alg: 'HS256'}) - .setSubject(userId) - .setIssuedAt() - .setExpirationTime(Math.floor(Date.now() / 1000) + accessTokenTtlSeconds) - .sign(secret); + const sign = (claims?: Record): Promise => + new SignJWT({ + accessToken, + idTokenClaims: claims, + organizationId, + refreshToken, + scopes, + sessionId, + type: 'session', + } as Omit) + .setProtectedHeader({alg: 'HS256'}) + .setSubject(userId) + .setIssuedAt() + .setExpirationTime(expirationTime) + .sign(secret); + + // A compact JWT is ASCII, so its length is its size in bytes. + let jwt: string = await sign(idTokenClaims); + + if (idTokenClaims && jwt.length > budget) { + let keptClaims: string = 'only the essential ID token claims'; + jwt = await sign(this.toEssentialIdTokenClaims(idTokenClaims)); + + if (jwt.length > budget) { + keptClaims = 'only the organization claims of the ID token'; + jwt = await sign(this.toOrganizationIdTokenClaims(idTokenClaims)); + } + + if (jwt.length > budget) { + // Without claims, the ID token fallbacks behave as they did before the claims were persisted. + keptClaims = 'none of the ID token claims'; + jwt = await sign(undefined); + } + + logger.warn( + `[SessionManager] The ID token claims do not fit into the session cookie (budget: ${budget} bytes); ${keptClaims} are kept in the session.`, + ); + } + + if (jwt.length > budget) { + logger.warn( + `[SessionManager] The session cookie value is ${jwt.length} bytes, above the ${budget}-byte budget; the browser may drop the cookie.`, + ); + } return jwt; } diff --git a/packages/nextjs/src/utils/__tests__/SessionManager.test.ts b/packages/nextjs/src/utils/__tests__/SessionManager.test.ts new file mode 100644 index 000000000..96eb15ecf --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/SessionManager.test.ts @@ -0,0 +1,238 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {afterAll, beforeAll, describe, expect, it} from 'vitest'; +import SessionManager, {SessionTokenPayload} from '../SessionManager'; + +describe('SessionManager', () => { + const originalSecret: string | undefined = process.env['ASGARDEO_SECRET']; + + beforeAll(() => { + process.env['ASGARDEO_SECRET'] = 'unit-test-secret'; + }); + + afterAll(() => { + if (originalSecret === undefined) { + delete process.env['ASGARDEO_SECRET']; + } else { + process.env['ASGARDEO_SECRET'] = originalSecret; + } + }); + + describe('toIdTokenClaims', () => { + it('keeps the identity and organization claims and drops the transient protocol claims', () => { + const claims: Record | undefined = SessionManager.toIdTokenClaims({ + at_hash: 'hash', + aud: 'client-id', + c_hash: 'hash', + email: 'jane@example.com', + exp: 1700003600, + iat: 1700000000, + iss: 'https://api.asgardeo.io/t/acme/oauth2/token', + nonce: 'nonce', + org_handle: 'acme', + org_id: 'org-1', + org_name: 'Acme', + sid: 'sid', + sub: 'user-1', + user_org: 'org-1', + }); + + expect(claims).toEqual({ + aud: 'client-id', + email: 'jane@example.com', + exp: 1700003600, + iat: 1700000000, + iss: 'https://api.asgardeo.io/t/acme/oauth2/token', + org_handle: 'acme', + org_id: 'org-1', + org_name: 'Acme', + sub: 'user-1', + user_org: 'org-1', + }); + }); + + it('returns undefined when there is no ID token', () => { + expect(SessionManager.toIdTokenClaims(undefined)).toBeUndefined(); + expect(SessionManager.toIdTokenClaims(null)).toBeUndefined(); + }); + }); + + describe('createSessionToken', () => { + const idTokenClaims: Record = {org_id: 'org-1', org_name: 'Acme', sub: 'user-1'}; + + it('round-trips the ID token claims through the session cookie', async () => { + const token: string = await SessionManager.createSessionToken( + 'access-token', + 'user-1', + 'session-1', + 'openid profile', + 3600, + 'refresh-token', + 'org-1', + idTokenClaims, + ); + + const payload: SessionTokenPayload = await SessionManager.verifySessionToken(token); + + expect(payload.sub).toBe('user-1'); + expect(payload.organizationId).toBe('org-1'); + expect(payload.idTokenClaims).toEqual(idTokenClaims); + + const payloadForRefresh: SessionTokenPayload = await SessionManager.verifySessionTokenForRefresh(token); + + expect(payloadForRefresh.idTokenClaims).toEqual(idTokenClaims); + }); + + it('omits the claims when none are given', async () => { + const token: string = await SessionManager.createSessionToken( + 'access-token', + 'user-1', + 'session-1', + 'openid', + 3600, + '', + ); + + const payload: SessionTokenPayload = await SessionManager.verifySessionToken(token); + + expect('idTokenClaims' in payload).toBe(false); + }); + + it('keeps only the essential claims when the full claim set does not fit into the cookie', async () => { + const oversizedClaims: Record = { + ...idTokenClaims, + email: 'jane@example.com', + groups: Array.from({length: 200}, (): string => 'x'.repeat(30)), + }; + + const token: string = await SessionManager.createSessionToken( + 'access-token', + 'user-1', + 'session-1', + 'openid profile', + 3600, + 'refresh-token', + 'org-1', + oversizedClaims, + ); + + expect(token.length).toBeLessThanOrEqual(SessionManager.getSessionCookieValueBudget()); + + const payload: SessionTokenPayload = await SessionManager.verifySessionToken(token); + + expect(payload.idTokenClaims).toEqual({ + email: 'jane@example.com', + org_id: 'org-1', + org_name: 'Acme', + sub: 'user-1', + }); + }); + + it('keeps only the organization claims when the essential claims do not fit into the cookie either', async () => { + const bulkyEssentialClaims: Record = { + ...idTokenClaims, + email: 'jane@example.com', + given_name: 'g'.repeat(1200), + name: 'n'.repeat(1200), + org_handle: 'acme', + user_org: 'org-1', + }; + + const token: string = await SessionManager.createSessionToken( + 'a'.repeat(2400), + 'user-1', + 'session-1', + 'openid', + 3600, + 'refresh-token', + 'org-1', + bulkyEssentialClaims, + ); + + expect(token.length).toBeLessThanOrEqual(SessionManager.getSessionCookieValueBudget()); + + const payload: SessionTokenPayload = await SessionManager.verifySessionToken(token); + + expect(payload.idTokenClaims).toEqual({ + org_handle: 'acme', + org_id: 'org-1', + org_name: 'Acme', + sub: 'user-1', + user_org: 'org-1', + }); + }); + + it('leaves the claims out when not even the organization claims fit into the cookie', async () => { + const token: string = await SessionManager.createSessionToken( + 'a'.repeat(SessionManager.getSessionCookieValueBudget()), + 'user-1', + 'session-1', + 'openid', + 3600, + 'refresh-token', + 'org-1', + idTokenClaims, + ); + + const payload: SessionTokenPayload = await SessionManager.verifySessionToken(token); + + expect(payload.accessToken).toHaveLength(SessionManager.getSessionCookieValueBudget()); + expect('idTokenClaims' in payload).toBe(false); + }); + }); + + describe('toOrganizationIdTokenClaims', () => { + it('keeps the organization claims and the subject only', () => { + expect( + SessionManager.toOrganizationIdTokenClaims({ + email: 'jane@example.com', + org_handle: 'acme', + org_id: 'org-1', + org_name: 'Acme', + sub: 'user-1', + user_org: 'org-1', + }), + ).toEqual({org_handle: 'acme', org_id: 'org-1', org_name: 'Acme', sub: 'user-1', user_org: 'org-1'}); + }); + }); + + describe('toEssentialIdTokenClaims', () => { + it('keeps the organization and basic identity claims only', () => { + expect( + SessionManager.toEssentialIdTokenClaims({ + email: 'jane@example.com', + groups: ['admins'], + org_handle: 'acme', + org_id: 'org-1', + org_name: 'Acme', + phone_number: '+123', + sub: 'user-1', + user_org: 'org-1', + }), + ).toEqual({ + email: 'jane@example.com', + org_handle: 'acme', + org_id: 'org-1', + org_name: 'Acme', + sub: 'user-1', + user_org: 'org-1', + }); + }); + }); +}); diff --git a/packages/nextjs/src/utils/__tests__/handleRefreshToken.test.ts b/packages/nextjs/src/utils/__tests__/handleRefreshToken.test.ts new file mode 100644 index 000000000..52f4e1a5f --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/handleRefreshToken.test.ts @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {SignJWT} from 'jose'; +import {afterAll, afterEach, beforeAll, describe, expect, it, vi, Mock} from 'vitest'; +import handleRefreshToken, {HandleRefreshTokenResult} from '../handleRefreshToken'; +import SessionManager, {SessionTokenPayload} from '../SessionManager'; + +describe('handleRefreshToken', () => { + const originalSecret: string | undefined = process.env['ASGARDEO_SECRET']; + const config: {baseUrl: string; clientId: string; clientSecret: string} = { + baseUrl: 'https://api.asgardeo.io/t/acme', + clientId: 'client-id', + clientSecret: 'client-secret', + }; + const storedClaims: Record = {org_id: 'org-1', org_name: 'Acme', sub: 'user-1'}; + + const makeSession = (): SessionTokenPayload => + ({ + accessToken: 'old-access-token', + exp: 0, + iat: 0, + idTokenClaims: storedClaims, + organizationId: 'org-1', + refreshToken: 'refresh-1', + scopes: ['openid'], + sessionId: 'session-1', + sub: 'user-1', + type: 'session', + } as SessionTokenPayload); + + const mockTokenEndpoint = (tokenData: Record): Mock => { + const fetchMock: Mock = vi.fn().mockResolvedValue({ + json: async (): Promise> => tokenData, + ok: true, + status: 200, + }); + + vi.stubGlobal('fetch', fetchMock); + + return fetchMock; + }; + + beforeAll(() => { + process.env['ASGARDEO_SECRET'] = 'unit-test-secret'; + }); + + afterAll(() => { + if (originalSecret === undefined) { + delete process.env['ASGARDEO_SECRET']; + } else { + process.env['ASGARDEO_SECRET'] = originalSecret; + } + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends the refresh_token grant to the token endpoint of the base URL', async () => { + const fetchMock: Mock = mockTokenEndpoint({access_token: 'new-access-token', expires_in: 3600}); + + await handleRefreshToken(makeSession(), config); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init]: [string, RequestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body: URLSearchParams = new URLSearchParams(init.body as string); + + expect(url).toBe('https://api.asgardeo.io/t/acme/oauth2/token'); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh-1'); + expect(body.get('client_id')).toBe('client-id'); + expect(body.get('client_secret')).toBe('client-secret'); + }); + + it('stores the claims of the refreshed ID token in the new session', async () => { + const idToken: string = await new SignJWT({ + at_hash: 'hash', + org_handle: 'beta', + org_id: 'org-2', + org_name: 'Beta', + sub: 'user-1', + }) + .setProtectedHeader({alg: 'HS256'}) + .sign(new TextEncoder().encode('identity-server-secret')); + + mockTokenEndpoint({ + access_token: 'new-access-token', + expires_in: 3600, + id_token: idToken, + refresh_token: 'refresh-2', + scope: 'openid profile', + token_type: 'Bearer', + }); + + const result: HandleRefreshTokenResult = await handleRefreshToken(makeSession(), config); + const payload: SessionTokenPayload = await SessionManager.verifySessionToken(result.newSessionToken); + + expect(result.tokenResponse.idToken).toBe(idToken); + expect(payload.refreshToken).toBe('refresh-2'); + expect(payload.idTokenClaims).toEqual({org_handle: 'beta', org_id: 'org-2', org_name: 'Beta', sub: 'user-1'}); + }); + + it('keeps the existing claims when the refresh response has no ID token', async () => { + mockTokenEndpoint({access_token: 'new-access-token', expires_in: 3600}); + + const result: HandleRefreshTokenResult = await handleRefreshToken(makeSession(), config); + const payload: SessionTokenPayload = await SessionManager.verifySessionToken(result.newSessionToken); + + expect(payload.idTokenClaims).toEqual(storedClaims); + expect(payload.refreshToken).toBe('refresh-1'); + }); + + it('keeps the existing claims when the refreshed ID token cannot be decoded', async () => { + mockTokenEndpoint({access_token: 'new-access-token', expires_in: 3600, id_token: 'not-a-jwt'}); + + const result: HandleRefreshTokenResult = await handleRefreshToken(makeSession(), config); + const payload: SessionTokenPayload = await SessionManager.verifySessionToken(result.newSessionToken); + + expect(payload.idTokenClaims).toEqual(storedClaims); + }); +}); diff --git a/packages/nextjs/src/utils/handleRefreshToken.ts b/packages/nextjs/src/utils/handleRefreshToken.ts index e0a6c9368..246daa086 100644 --- a/packages/nextjs/src/utils/handleRefreshToken.ts +++ b/packages/nextjs/src/utils/handleRefreshToken.ts @@ -17,6 +17,7 @@ */ import type {TokenResponse} from '@asgardeo/node'; +import {decodeJwt} from 'jose'; import SessionManager, {SessionTokenPayload} from './SessionManager'; /** @@ -51,7 +52,7 @@ const handleRefreshToken = async ( config: HandleRefreshTokenConfig, ): Promise => { const {baseUrl, clientId, clientSecret, sessionCookieExpiryTime: configuredExpiry} = config; - const {refreshToken: storedRefreshToken, sessionId, sub, scopes, organizationId} = sessionPayload; + const {refreshToken: storedRefreshToken, sessionId, sub, scopes, organizationId, idTokenClaims} = sessionPayload; if (!storedRefreshToken) { throw new Error('No refresh token found in session payload.'); @@ -98,6 +99,18 @@ const handleRefreshToken = async ( const newScopes: string = (tokenData['scope'] as string | undefined) ?? (Array.isArray(scopes) ? scopes.join(' ') : (scopes as string) ?? ''); + const newIdToken: string | undefined = tokenData['id_token'] as string | undefined; + // A refreshed ID token carries the latest claims; when the server did not issue one, keep the existing claims. + let newIdTokenClaims: Record | undefined = idTokenClaims; + + if (newIdToken) { + try { + newIdTokenClaims = SessionManager.toIdTokenClaims(decodeJwt(newIdToken)); + } catch { + // Malformed ID token in the refresh response; the existing claims are still the best we have. + } + } + const resolvedSessionCookieExpiry: number = SessionManager.resolveSessionCookieExpiry(configuredExpiry); const newSessionToken: string = await SessionManager.createSessionToken( @@ -108,6 +121,7 @@ const handleRefreshToken = async ( expiresIn, newRefreshToken, organizationId, + newIdTokenClaims, ); return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f40efb647..72a2d78dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,7 @@ overrides: seroval@<1.5.3: 1.6.4 sharp@<0.35.0: 0.35.4 shell-quote@<1.9.0: 1.10.0 + smol-toml@<1.7.1: 1.7.2 svgo@>=4.0.0 <4.0.2: 4.1.0 undici@>=7.0.0 <7.29.0: 7.29.0 vite@>=6.0.0 <6.4.3: 6.4.3 @@ -7691,8 +7692,8 @@ packages: resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} engines: {node: '>=20.0.0'} - smol-toml@1.6.1: - resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + smol-toml@1.7.2: + resolution: {integrity: sha512-pXFZ9B2WinEPzxWkMmlYE/oYx2BP+qLrE95wP8tCuK901uLSMGdCb6QSr82z+wnhXkG4+cO+OMLbZB2Cn+97zw==} engines: {node: '>= 18'} source-map-js@1.2.1: @@ -15735,7 +15736,7 @@ snapshots: safe-buffer: 5.2.1 semver: 7.7.4 signal-exit: 3.0.7 - smol-toml: 1.6.1 + smol-toml: 1.7.2 string-width: 4.2.3 string_decoder: 1.3.0 strip-ansi: 6.0.1 @@ -16919,7 +16920,7 @@ snapshots: smob@1.6.2: {} - smol-toml@1.6.1: {} + smol-toml@1.7.2: {} source-map-js@1.2.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b98e32f72..c74b70d7b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -78,6 +78,7 @@ overrides: 'seroval@<1.5.3': 1.6.4 'sharp@<0.35.0': 0.35.4 'shell-quote@<1.9.0': 1.10.0 + 'smol-toml@<1.7.1': 1.7.2 'svgo@>=4.0.0 <4.0.2': 4.1.0 'undici@>=7.0.0 <7.29.0': 7.29.0 'vite@>=6.0.0 <6.4.3': 6.4.3