diff --git a/.changeset/nextjs-cookie-backed-session.md b/.changeset/nextjs-cookie-backed-session.md new file mode 100644 index 000000000..2810742c6 --- /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. diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 3d4426a3c..fe8e72480 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -55,13 +55,16 @@ import { getScim2Me, getSchemas, initializeEmbeddedSignInFlow, + processOpenIDScopes, updateMeProfile, } from '@asgardeo/node'; 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 +216,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 +264,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 +397,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 +406,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 +424,72 @@ 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', + }); + + 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 tokenResponse: TokenResponse = { + accessToken: tokenData['access_token'] as string, + 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 +537,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..5481f268d --- /dev/null +++ b/packages/nextjs/src/__tests__/AsgardeoNextClient.test.ts @@ -0,0 +1,252 @@ +/** + * 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(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 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/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..c09a92bca 100644 --- a/packages/nextjs/src/utils/SessionManager.ts +++ b/packages/nextjs/src/utils/SessionManager.ts @@ -28,6 +28,12 @@ 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. + */ + idTokenClaims?: Record; /** Organization ID if applicable */ organizationId?: string; /** The refresh token; empty string if not provided by the auth server */ @@ -115,6 +121,43 @@ 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), + ), + ); + } + static async createSessionToken( accessToken: string, userId: string, @@ -123,11 +166,13 @@ class SessionManager { accessTokenTtlSeconds: number, refreshToken: string, organizationId?: string, + idTokenClaims?: Record, ): Promise { const secret: Uint8Array = this.getSecret(); const jwt: string = await new SignJWT({ accessToken, + idTokenClaims, organizationId, refreshToken, scopes, 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..633711069 --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/SessionManager.test.ts @@ -0,0 +1,117 @@ +/** + * 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); + }); + }); +}); 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 {