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(),