diff --git a/.changeset/nextjs-context-surface.md b/.changeset/nextjs-context-surface.md new file mode 100644 index 000000000..f9f797406 --- /dev/null +++ b/.changeset/nextjs-context-surface.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +`useAsgardeo()` exposes more of what the React SDK's context provides: `organization` (the current organization, which the bundled sample already reads), `isInitialized`, `clientId`, `signInOptions`, `switchOrganization()` (which re-renders the server components once the switch has happened) and `getDecodedIdToken()` (the ID token claims, resolved through a server action so the tokens themselves stay in the HttpOnly cookie). diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts index 65cbd1a33..58f4a752a 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts @@ -18,16 +18,36 @@ 'use client'; +import {IdToken, Organization, TokenResponse} from '@asgardeo/node'; import {AsgardeoContextProps as AsgardeoReactContextProps} from '@asgardeo/react'; import {Context, createContext} from 'react'; import {RefreshResult} from '../../../server/actions/refreshToken'; /** * Props interface of {@link AsgardeoContext} + * + * A subset of the React SDK's context: the raw tokens never reach the browser (they live in the HttpOnly + * session cookie), so `getAccessToken`, `getIdToken` and `exchangeToken` are not available here. Use + * `http.request` for authenticated calls and `getDecodedIdToken` for the ID token claims. */ -export type AsgardeoContextProps = Partial & { +export type AsgardeoContextProps = Partial< + Omit +> & { clearSession?: () => Promise; + /** + * Returns the decoded ID token (its claims) of the signed-in user, resolved through a server action. + */ + getDecodedIdToken?: () => Promise; + /** + * The organization the session belongs to, or `null` while signed out or unknown. + */ + organization?: Organization | null; refreshToken?: () => Promise; + /** + * Switches the session to `organization` and re-renders the server components so the new organization, + * user and organization list are picked up. + */ + switchOrganization?: (organization: Organization) => Promise; }; /** @@ -38,16 +58,21 @@ const AsgardeoContext: Context = createContext Promise.resolve(), + clientId: undefined, + getDecodedIdToken: () => Promise.resolve({} as IdToken), isInitialized: false, isLoading: true, isSignedIn: false, + organization: null, organizationHandle: undefined, refreshToken: () => Promise.resolve({expiresAt: 0}), signIn: () => Promise.resolve({} as any), + signInOptions: {}, signInUrl: undefined, signOut: () => Promise.resolve({} as any), signUp: () => Promise.resolve({} as any), signUpUrl: undefined, + switchOrganization: () => Promise.resolve({} as TokenResponse), user: null, }); diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index de77065f8..5878a786a 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -35,6 +35,7 @@ import { EmbeddedFlowStatus, HttpRequestConfig, HttpResponse, + IdToken, } from '@asgardeo/node'; import { I18nProvider, @@ -66,8 +67,14 @@ export type AsgardeoClientProviderProps = Partial Promise; createOrganization: (payload: CreateOrganizationPayload, sessionId: string) => Promise; - currentOrganization: Organization; + currentOrganization: Organization | null; getAllOrganizations: (options?: any, sessionId?: string) => Promise; + /** + * Server action returning the decoded ID token of the signed-in user. + */ + getDecodedIdToken?: ( + sessionId?: string, + ) => Promise<{data: {idToken?: IdToken}; error: string | null; success: boolean}>; handleOAuthCallback: ( code: string, state: string, @@ -118,6 +125,9 @@ const AsgardeoClientProvider: FC> brandingPreference, afterSignInUrl, httpRequest, + getDecodedIdToken, + signInOptions, + clientId, }: PropsWithChildren) => { const reRenderCheckRef: RefObject = useRef(false); const router: AppRouterInstance = useRouter(); @@ -353,25 +363,69 @@ const AsgardeoClientProvider: FC> const handleHttpRequestAll = async (requestConfigs?: HttpRequestConfig[]): Promise => Promise.all((requestConfigs ?? []).map((requestConfig: HttpRequestConfig) => handleHttpRequest(requestConfig))); + /** + * Returns the decoded ID token through the server action; the raw token stays in the HttpOnly cookie. + */ + const handleGetDecodedIdToken = async (): Promise => { + if (!getDecodedIdToken) { + throw new AsgardeoRuntimeError( + '`getDecodedIdToken` is not available. Make sure the component is rendered inside ``.', + 'AsgardeoClientProvider-handleGetDecodedIdToken-RuntimeError-001', + 'nextjs', + ); + } + + const result: {data: {idToken?: IdToken}; error: string | null; success: boolean} = await getDecodedIdToken(); + + if (!result.success || !result.data.idToken) { + throw new AsgardeoRuntimeError( + result.error ?? 'Failed to get the decoded ID token.', + 'AsgardeoClientProvider-handleGetDecodedIdToken-RuntimeError-002', + 'nextjs', + ); + } + + return result.data.idToken; + }; + + /** + * Switches the session to `organization` and re-renders the server components, which re-read the session + * cookie and hand the new organization, user and organization list down. + */ + const handleSwitchOrganization = async (organization: Organization): Promise => { + const response: TokenResponse | Response = await switchOrganization(organization); + + router.refresh(); + + return response; + }; + const contextValue: AsgardeoContextProps = useMemo( () => ({ afterSignInUrl, applicationId, baseUrl, clearSession, + clientId, + getDecodedIdToken: handleGetDecodedIdToken, http: { request: handleHttpRequest, requestAll: handleHttpRequestAll, }, + // The server provider only renders this provider once the client has been initialized. + isInitialized: true, isLoading, isSignedIn, + organization: currentOrganization, organizationHandle, refreshToken, signIn: handleSignIn, + signInOptions: signInOptions ?? {}, signInUrl, signOut: handleSignOut, signUp: handleSignUp, signUpUrl, + switchOrganization: handleSwitchOrganization, user, }), [ @@ -385,6 +439,11 @@ const AsgardeoClientProvider: FC> organizationHandle, afterSignInUrl, httpRequest, + clientId, + currentOrganization, + signInOptions, + getDecodedIdToken, + switchOrganization, ], ); @@ -413,7 +472,7 @@ const AsgardeoClientProvider: FC> getAllOrganizations={getAllOrganizations} myOrganizations={myOrganizations} currentOrganization={currentOrganization} - onOrganizationSwitch={switchOrganization as any} + onOrganizationSwitch={handleSwitchOrganization} revalidateMyOrganizations={revalidateMyOrganizations as any} > {children} diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index 5015697ac..c71bbfde6 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -26,6 +26,7 @@ import createOrganization from './actions/createOrganization'; import getAllOrganizations from './actions/getAllOrganizations'; import getBrandingPreference from './actions/getBrandingPreference'; import getCurrentOrganizationAction from './actions/getCurrentOrganizationAction'; +import getDecodedIdTokenAction from './actions/getDecodedIdTokenAction'; import getMyOrganizations from './actions/getMyOrganizations'; import getSessionId from './actions/getSessionId'; import getSessionPayload from './actions/getSessionPayload'; @@ -227,6 +228,8 @@ const AsgardeoServerProvider: FC> signUpUrl={config?.signUpUrl} afterSignInUrl={config?.afterSignInUrl} httpRequest={httpRequestAction} + getDecodedIdToken={getDecodedIdTokenAction} + signInOptions={config?.signInOptions} preferences={config?.preferences} clientId={config?.clientId} user={user} diff --git a/packages/nextjs/src/server/actions/__tests__/getDecodedIdTokenAction.test.ts b/packages/nextjs/src/server/actions/__tests__/getDecodedIdTokenAction.test.ts new file mode 100644 index 000000000..b5ab1ab89 --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/getDecodedIdTokenAction.test.ts @@ -0,0 +1,70 @@ +/** + * 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 {beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import AsgardeoNextClient from '../../../AsgardeoNextClient'; +import getDecodedIdTokenAction from '../getDecodedIdTokenAction'; +import getSessionId from '../getSessionId'; + +vi.mock('../../../AsgardeoNextClient', () => ({ + default: { + getInstance: vi.fn(), + }, +})); + +vi.mock('../getSessionId', () => ({ + default: vi.fn(async () => 'session-from-cookie'), +})); + +describe('getDecodedIdTokenAction', () => { + type ActionResult = Awaited>; + + const client: {getDecodedIdToken: Mock} = {getDecodedIdToken: vi.fn()}; + const idToken: Record = {aud: 'client-id', email: 'jane@example.com', iss: 'issuer', sub: 'user-1'}; + + beforeEach(() => { + vi.clearAllMocks(); + (AsgardeoNextClient.getInstance as unknown as Mock).mockReturnValue(client); + (getSessionId as unknown as Mock).mockResolvedValue('session-from-cookie'); + }); + + it('returns the decoded ID token for the given session', async () => { + client.getDecodedIdToken.mockResolvedValue(idToken); + + const result: ActionResult = await getDecodedIdTokenAction('session-1'); + + expect(client.getDecodedIdToken).toHaveBeenCalledWith('session-1'); + expect(result).toEqual({data: {idToken}, error: null, success: true}); + }); + + it('resolves the session from the cookie when no session ID is given', async () => { + client.getDecodedIdToken.mockResolvedValue(idToken); + + await getDecodedIdTokenAction(); + + expect(client.getDecodedIdToken).toHaveBeenCalledWith('session-from-cookie'); + }); + + it('reports the failure reason instead of throwing', async () => { + client.getDecodedIdToken.mockRejectedValue(new Error('No session')); + + const result: ActionResult = await getDecodedIdTokenAction(); + + expect(result).toEqual({data: {}, error: 'No session', success: false}); + }); +}); diff --git a/packages/nextjs/src/server/actions/getDecodedIdTokenAction.ts b/packages/nextjs/src/server/actions/getDecodedIdTokenAction.ts new file mode 100644 index 000000000..60e4f28d6 --- /dev/null +++ b/packages/nextjs/src/server/actions/getDecodedIdTokenAction.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025, 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. + */ + +'use server'; + +import {IdToken} from '@asgardeo/node'; +import getSessionId from './getSessionId'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; + +/** + * Server action that returns the decoded ID token (its claims) of the signed-in user. + * + * Only the decoded claims cross the server boundary; the raw tokens stay in the HttpOnly session cookie. + * Backs `useAsgardeo().getDecodedIdToken()` in Client Components. + * + * @param sessionId - Optional session ID; resolved from the session cookie when omitted. + */ +const getDecodedIdTokenAction = async ( + sessionId?: string, +): Promise<{data: {idToken?: IdToken}; error: string | null; success: boolean}> => { + try { + const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); + const idToken: IdToken = await client.getDecodedIdToken(sessionId ?? (await getSessionId())); + + return {data: {idToken}, error: null, success: true}; + } catch (error) { + return { + data: {}, + error: error instanceof Error ? error.message : String(error), + success: false, + }; + } +}; + +export default getDecodedIdTokenAction;