From 691024bee75bb0a4038c5bf9334c74b8b7f36ee5 Mon Sep 17 00:00:00 2001 From: Brion Date: Fri, 30 Oct 2026 12:30:57 +0530 Subject: [PATCH] Read granted permissions from the backend session in backend mode In backend mode the access token stays on the backend, but useGrantedPermissions read the permissions by decoding the access token. In backend mode that meant asking the backend's token endpoint for one, which a BFF doesn't offer, so an app saw no permissions. The backend's session now carries the granted scopes (BackendSession scope, the token response's scope), and a new ThunderIDBrowserClient.getGrantedPermissions() reads them from the session the client already holds. In backend mode a session without scope grants nothing, and the client never asks for a token. In redirect mode the permissions are still read from the access token. The React and Vue useGrantedPermissions call getGrantedPermissions() through their contexts. Nuxt provides it from its existing token route, so it behaves as before, and Next.js marks it unsupported on its client context, since its hook reads permissions through a server action. Signed-off-by: Brion --- .../browser/src/ThunderIDBrowserClient.ts | 21 +++++++++++++++ .../ThunderIDBrowserClient.backend.test.ts | 16 +++++++++++ packages/javascript/src/models/backend.ts | 5 ++++ .../contexts/ThunderID/ThunderIDProvider.tsx | 2 ++ .../nuxt/src/runtime/plugins/thunderid.ts | 5 +++- .../contexts/ThunderID/ThunderIDContext.ts | 7 +++++ .../contexts/ThunderID/ThunderIDProvider.tsx | 7 +++++ .../__tests__/useGrantedPermissions.test.tsx | 27 +++++++------------ .../hooks/management/useGrantedPermissions.ts | 16 +++++------ .../management/resource-query.test.ts | 13 +++------ .../__tests__/helpers/managementContext.ts | 9 ++++++- .../management/useGrantedPermissions.ts | 14 +++++----- packages/vue/src/models/contexts.ts | 2 ++ .../vue/src/providers/ThunderIDProvider.ts | 1 + 14 files changed, 101 insertions(+), 44 deletions(-) diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 5f31d69c..6bba7691 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -24,6 +24,7 @@ import { TokenConstants, createPackageComponentLogger, generateFlattenedUserProfile, + getTokenPermissions, getUsersMe, resolveBackendEndpoints, resolveResourceEndpoint, @@ -620,6 +621,26 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC return super.getAccessToken(sessionId); } + /** + * The permissions the signed-in user's access token grants, for example to hide a page the user + * cannot use. The server still checks every request. + * + * In backend mode they come only from the backend's session (`scope`): the token stays on the backend, + * so a session without `scope` grants nothing. Otherwise they are read from the access token's `scope` + * or `scopes` claim. + * + * @returns The granted permissions, or an empty list when none can be read. + */ + public async getGrantedPermissions(): Promise { + if (this._backend) { + const {scope} = await this._backend.getSession(); + + return typeof scope === 'string' ? scope.split(' ').filter((entry: string) => entry.length > 0) : []; + } + + return getTokenPermissions(await this.getAccessToken()); + } + /** * The access token for requests under `endpoints.backend.bypass`. Like a client credentials token it * has no refresh token: it is kept in the session storage until it nears expiry, then requested again. diff --git a/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts b/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts index 3c216626..9c1830c8 100644 --- a/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts +++ b/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts @@ -103,6 +103,22 @@ describe('ThunderIDBrowserClient with sign-in on the backend', () => { expect(await client.getAccessToken()).toBe('t2'); }); + it('reads the granted permissions from the session, without the access token', async () => { + fetchMock.mockResolvedValueOnce(json(200, {...SIGNED_IN, scope: 'openid system:user system:group:view'})); + const scoped = new ThunderIDBrowserClient(8); + await scoped.initialize(CONFIG); + + expect(await scoped.getGrantedPermissions()).toEqual(['openid', 'system:user', 'system:group:view']); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('/api/auth/session'); + FetchHttpClient.destroyInstance(8); + }); + + it('grants nothing when the session has no scope, without asking for the access token', async () => { + expect(await client.getGrantedPermissions()).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('shares one token request between concurrent callers', async () => { fetchMock.mockResolvedValueOnce(json(200, {access_token: 't1', expires_in: 300})); diff --git a/packages/javascript/src/models/backend.ts b/packages/javascript/src/models/backend.ts index 607a3579..31c2c936 100644 --- a/packages/javascript/src/models/backend.ts +++ b/packages/javascript/src/models/backend.ts @@ -74,4 +74,9 @@ export interface BackendSession { user?: Record; /** When the access token expires, in seconds since the epoch. */ expiresAt?: number; + /** + * The scopes the access token was granted, space separated, as the token response's `scope` + * (RFC 6749 §5.1). The app reads its permissions from here, so the token can stay on the backend. + */ + scope?: string; } diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index d51fb936..87cd9d19 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -345,6 +345,8 @@ const ThunderIDClientProvider: FC Promise.resolve(null), http: { diff --git a/packages/nuxt/src/runtime/plugins/thunderid.ts b/packages/nuxt/src/runtime/plugins/thunderid.ts index b6298955..4ebe178d 100644 --- a/packages/nuxt/src/runtime/plugins/thunderid.ts +++ b/packages/nuxt/src/runtime/plugins/thunderid.ts @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {getRedirectBasedSignUpUrl} from '@thunderid/browser'; +import {getRedirectBasedSignUpUrl, getTokenPermissions} from '@thunderid/browser'; import {VendorConstants} from '@thunderid/node'; import type {AttributeSchema, FlowMetadataResponse, UserProfile} from '@thunderid/node'; import {ThunderIDPlugin, THUNDERID_KEY} from '@thunderid/vue'; @@ -209,6 +209,8 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { } }; + const getGrantedPermissions = async (): Promise => getTokenPermissions(await getAccessToken()); + const noop = async (): Promise => undefined; // ── 4. Provide THUNDERID_KEY at the app level ──────────────────────────── @@ -222,6 +224,7 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { endpoints: publicConfig.endpoints, exchangeToken: noop, getAccessToken, + getGrantedPermissions, getDecodedIdToken: noop, getIdToken: noop, http: { diff --git a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts index 7cfd1b45..77cb8f50 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts +++ b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts @@ -62,6 +62,12 @@ export type ThunderIDContextProps = { * @returns A promise that resolves to the access token. */ getAccessToken: () => Promise; + /** + * Retrieves the permissions the signed-in user's access token grants. In backend mode they come from + * the backend's session, so the token stays on the backend. + * @returns A promise that resolves to the granted permissions. + */ + getGrantedPermissions: () => Promise; /** * Function to retrieve the decoded ID token. * This function decodes the ID token and returns its payload. @@ -247,6 +253,7 @@ const ThunderIDContext: Context = createContext Promise.resolve(null), diff --git a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx index fbc3c14d..91955bff 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -438,6 +438,11 @@ const ThunderIDProvider: FC> = ({ [client], ); + const getGrantedPermissions: () => Promise = useCallback( + async (): Promise => client.getGrantedPermissions(), + [client], + ); + const getStorageManager: ThunderIDContextProps['getStorageManager'] = useCallback(async () => { const storageManager: Awaited> = storageManagerRef.current ?? (await client.getStorageManager()); @@ -514,6 +519,7 @@ const ThunderIDProvider: FC> = ({ exchangeToken, getAccessToken, getDecodedIdToken, + getGrantedPermissions, getIdToken, getStorageManager, http: { @@ -570,6 +576,7 @@ const ThunderIDProvider: FC> = ({ exchangeToken, httpOptions?.fetcher, getAccessToken, + getGrantedPermissions, getStorageManager, instanceId, invalidator, diff --git a/packages/react/src/hooks/management/__tests__/useGrantedPermissions.test.tsx b/packages/react/src/hooks/management/__tests__/useGrantedPermissions.test.tsx index 140b12f9..13638bf0 100644 --- a/packages/react/src/hooks/management/__tests__/useGrantedPermissions.test.tsx +++ b/packages/react/src/hooks/management/__tests__/useGrantedPermissions.test.tsx @@ -7,13 +7,6 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import ThunderIDContext, {ThunderIDContextProps} from '../../../contexts/ThunderID/ThunderIDContext'; import useGrantedPermissions from '../useGrantedPermissions'; -const tokenWithScope = (scope: string): string => { - const encode = (value: unknown): string => - btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - - return `${encode({alg: 'none'})}.${encode({scope})}.signature`; -}; - const wrapperWith = (value: Partial): ((props: {children: ReactNode}) => ReactElement) => function Wrapper({children}: {children: ReactNode}): ReactElement { return ( @@ -28,11 +21,11 @@ afterEach(() => { }); describe('useGrantedPermissions', () => { - it('reads the permissions the access token grants', async () => { - const getAccessToken = vi.fn().mockResolvedValue(tokenWithScope('openid system:group')); + it('reads the granted permissions from the client', async () => { + const getGrantedPermissions = vi.fn().mockResolvedValue(['openid', 'system:group']); const {result} = renderHook(() => useGrantedPermissions(), { - wrapper: wrapperWith({getAccessToken, isSignedIn: true}), + wrapper: wrapperWith({getGrantedPermissions, isSignedIn: true}), }); expect(result.current.isLoading).toBe(true); @@ -42,23 +35,23 @@ describe('useGrantedPermissions', () => { expect(result.current.can('system:user:view')).toBe(false); }); - it('grants nothing when signed out, without asking for a token', () => { - const getAccessToken = vi.fn(); + it('grants nothing when signed out, without asking the client', () => { + const getGrantedPermissions = vi.fn(); const {result} = renderHook(() => useGrantedPermissions(), { - wrapper: wrapperWith({getAccessToken, isSignedIn: false}), + wrapper: wrapperWith({getGrantedPermissions, isSignedIn: false}), }); expect(result.current.isLoading).toBe(false); expect(result.current.permissions).toEqual([]); - expect(getAccessToken).not.toHaveBeenCalled(); + expect(getGrantedPermissions).not.toHaveBeenCalled(); }); - it('grants nothing when the token cannot be read', async () => { - const getAccessToken = vi.fn().mockRejectedValue(new Error('no session')); + it('grants nothing when the permissions cannot be read', async () => { + const getGrantedPermissions = vi.fn().mockRejectedValue(new Error('no session')); const {result} = renderHook(() => useGrantedPermissions(), { - wrapper: wrapperWith({getAccessToken, isSignedIn: true}), + wrapper: wrapperWith({getGrantedPermissions, isSignedIn: true}), }); await waitFor(() => expect(result.current.isLoading).toBe(false)); diff --git a/packages/react/src/hooks/management/useGrantedPermissions.ts b/packages/react/src/hooks/management/useGrantedPermissions.ts index 7dc18bc7..f45ead22 100644 --- a/packages/react/src/hooks/management/useGrantedPermissions.ts +++ b/packages/react/src/hooks/management/useGrantedPermissions.ts @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {getTokenPermissions, hasPermission} from '@thunderid/browser'; +import {hasPermission} from '@thunderid/browser'; import {useCallback, useEffect, useMemo, useState} from 'react'; import useThunderID from '../../contexts/ThunderID/useThunderID'; @@ -21,7 +21,8 @@ export interface GrantedPermissions { /** * Reads the permissions the signed-in user's access token grants, for example to hide a page the - * user cannot use. + * user cannot use. In backend mode they come from the backend's session, so the token stays on the + * backend. * * @returns The granted permissions and a `can` check. * @@ -32,7 +33,7 @@ export interface GrantedPermissions { * ``` */ const useGrantedPermissions = (): GrantedPermissions => { - const {getAccessToken, isSignedIn} = useThunderID(); + const {getGrantedPermissions, isSignedIn} = useThunderID(); const [granted, setGranted] = useState(null); useEffect(() => { @@ -42,19 +43,18 @@ const useGrantedPermissions = (): GrantedPermissions => { let active = true; - void getAccessToken() - .then((token: string) => getTokenPermissions(token)) + void getGrantedPermissions() .catch((): string[] => []) - .then((tokenPermissions: string[]) => { + .then((grantedPermissions: string[]) => { if (active) { - setGranted(tokenPermissions); + setGranted(grantedPermissions); } }); return (): void => { active = false; }; - }, [getAccessToken, isSignedIn]); + }, [getGrantedPermissions, isSignedIn]); const permissions: string[] = useMemo(() => (isSignedIn ? (granted ?? []) : []), [granted, isSignedIn]); const isLoading: boolean = isSignedIn && granted === null; diff --git a/packages/vue/src/__tests__/composables/management/resource-query.test.ts b/packages/vue/src/__tests__/composables/management/resource-query.test.ts index 976569d3..7689a163 100644 --- a/packages/vue/src/__tests__/composables/management/resource-query.test.ts +++ b/packages/vue/src/__tests__/composables/management/resource-query.test.ts @@ -255,15 +255,8 @@ describe('useGrantedPermissions (vue)', () => { mounted.splice(0).forEach((wrapper) => wrapper.unmount()); }); - const tokenWithScopes = (scope: string): string => { - const encode = (value: object): string => - btoa(JSON.stringify(value)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); - - return `${encode({alg: 'none'})}.${encode({scope, sub: 'u1'})}.signature`; - }; - - it('reads the permissions from the access token and checks them by prefix', async () => { - const context = createManagementContext({accessToken: tokenWithScopes('openid system:user system:group:view')}); + it('reads the granted permissions from the client and checks them by prefix', async () => { + const context = createManagementContext({grantedPermissions: ['openid', 'system:user', 'system:group:view']}); const {result} = withSetup(() => useGrantedPermissions(), context); expect(result.isLoading.value).toBe(true); @@ -280,6 +273,6 @@ describe('useGrantedPermissions (vue)', () => { await nextTick(); expect(result.isLoading.value).toBe(false); expect(result.permissions.value).toEqual([]); - expect(context.thunderID.getAccessToken).not.toHaveBeenCalled(); + expect(context.thunderID.getGrantedPermissions).not.toHaveBeenCalled(); }); }); diff --git a/packages/vue/src/__tests__/helpers/managementContext.ts b/packages/vue/src/__tests__/helpers/managementContext.ts index ed2524bc..b13db20a 100644 --- a/packages/vue/src/__tests__/helpers/managementContext.ts +++ b/packages/vue/src/__tests__/helpers/managementContext.ts @@ -31,7 +31,11 @@ export const translate = (key: string, params?: Record) export interface ManagementTestContext { fetcher: MockFetcher; provide: Record; - thunderID: {getAccessToken: Mock<() => Promise>; invalidator: ResourceInvalidator}; + thunderID: { + getAccessToken: Mock<() => Promise>; + getGrantedPermissions: Mock<() => Promise>; + invalidator: ResourceInvalidator; + }; } /** @@ -43,16 +47,19 @@ export const createManagementContext = ({ fetcher = vi.fn<(url: string, init: RequestInit) => Promise>(), isSignedIn = true, accessToken = 'token', + grantedPermissions = [], }: { accessToken?: string; endpoints?: Record; fetcher?: MockFetcher; + grantedPermissions?: string[]; isSignedIn?: boolean; } = {}): ManagementTestContext => { const thunderID = { baseUrl: 'https://thunder.example', endpoints, getAccessToken: vi.fn(() => Promise.resolve(accessToken)), + getGrantedPermissions: vi.fn(() => Promise.resolve(grantedPermissions)), http: {fetcher, request: vi.fn(), requestAll: vi.fn()}, instanceId: 0, invalidator: createResourceInvalidator(), diff --git a/packages/vue/src/composables/management/useGrantedPermissions.ts b/packages/vue/src/composables/management/useGrantedPermissions.ts index cfadfeec..cbb017c0 100644 --- a/packages/vue/src/composables/management/useGrantedPermissions.ts +++ b/packages/vue/src/composables/management/useGrantedPermissions.ts @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {getTokenPermissions, hasPermission} from '@thunderid/browser'; +import {hasPermission} from '@thunderid/browser'; import {type ComputedRef, type Ref, computed, getCurrentScope, onScopeDispose, shallowRef, watch} from 'vue'; import useThunderID from '../useThunderID'; @@ -21,7 +21,8 @@ export interface GrantedPermissions { /** * Reads the permissions the signed-in user's access token grants, for example to hide a page the - * user cannot use. + * user cannot use. In backend mode they come from the backend's session, so the token stays on the + * backend. * * @returns The granted permissions and a `can` check. * @@ -37,7 +38,7 @@ export interface GrantedPermissions { * ``` */ const useGrantedPermissions = (): GrantedPermissions => { - const {getAccessToken, isSignedIn} = useThunderID(); + const {getGrantedPermissions, isSignedIn} = useThunderID(); const granted: Ref = shallowRef(null); let generation = 0; @@ -53,12 +54,11 @@ const useGrantedPermissions = (): GrantedPermissions => { return; } - void getAccessToken() - .then((token: string) => getTokenPermissions(token)) + void getGrantedPermissions() .catch((): string[] => []) - .then((tokenPermissions: string[]) => { + .then((grantedPermissions: string[]) => { if (current === generation) { - granted.value = tokenPermissions; + granted.value = grantedPermissions; } }); }, diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts index d9a43c88..a86328c5 100644 --- a/packages/vue/src/models/contexts.ts +++ b/packages/vue/src/models/contexts.ts @@ -50,6 +50,8 @@ export interface ThunderIDContext { exchangeToken: (config: TokenExchangeRequestConfig) => Promise; // ── Token ── getAccessToken: () => Promise; + /** The permissions the signed-in user's access token grants; in backend mode, from the backend's session. */ + getGrantedPermissions: () => Promise; getDecodedIdToken: () => Promise; getIdToken: () => Promise; getStorageManager: () => StorageManager; diff --git a/packages/vue/src/providers/ThunderIDProvider.ts b/packages/vue/src/providers/ThunderIDProvider.ts index a7dd5079..4918a2ff 100644 --- a/packages/vue/src/providers/ThunderIDProvider.ts +++ b/packages/vue/src/providers/ThunderIDProvider.ts @@ -379,6 +379,7 @@ const ThunderIDProvider: Component = defineComponent({ scopes: props.scopes, exchangeToken: (config: any): Promise => client.exchangeToken(config), getAccessToken: (): Promise => client.getAccessToken(), + getGrantedPermissions: (): Promise => client.getGrantedPermissions(), getDecodedIdToken: (): Promise => client.getDecodedIdToken(), getIdToken: (): Promise => client.getIdToken(), getStorageManager: () => client.getStorageManager(),