Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/browser/src/ThunderIDBrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
TokenConstants,
createPackageComponentLogger,
generateFlattenedUserProfile,
getTokenPermissions,
getUsersMe,
resolveBackendEndpoints,
resolveResourceEndpoint,
Expand Down Expand Up @@ -620,6 +621,26 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> 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<string[]> {
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>(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}));

Expand Down
5 changes: 5 additions & 0 deletions packages/javascript/src/models/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,9 @@ export interface BackendSession {
user?: Record<string, unknown>;
/** 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@ const ThunderIDClientProvider: FC<PropsWithChildren<ThunderIDClientProviderProps
exchangeToken: unsupported('exchangeToken'),
getAccessToken: unsupported('getAccessToken'),
getDecodedIdToken: unsupported('getDecodedIdToken'),
// The Next.js useGrantedPermissions reads them through a server action instead.
getGrantedPermissions: unsupported('getGrantedPermissions'),
getIdToken: unsupported('getIdToken'),
getStorageManager: () => Promise.resolve(null),
http: {
Expand Down
5 changes: 4 additions & 1 deletion packages/nuxt/src/runtime/plugins/thunderid.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -209,6 +209,8 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => {
}
};

const getGrantedPermissions = async (): Promise<string[]> => getTokenPermissions(await getAccessToken());

const noop = async (): Promise<any> => undefined;

// ── 4. Provide THUNDERID_KEY at the app level ────────────────────────────
Expand All @@ -222,6 +224,7 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => {
endpoints: publicConfig.endpoints,
exchangeToken: noop,
getAccessToken,
getGrantedPermissions,
getDecodedIdToken: noop,
getIdToken: noop,
http: {
Expand Down
7 changes: 7 additions & 0 deletions packages/react/src/contexts/ThunderID/ThunderIDContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ export type ThunderIDContextProps = {
* @returns A promise that resolves to the access token.
*/
getAccessToken: () => Promise<string>;
/**
* 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<string[]>;
/**
* Function to retrieve the decoded ID token.
* This function decodes the ID token and returns its payload.
Expand Down Expand Up @@ -247,6 +253,7 @@ const ThunderIDContext: Context<ThunderIDContextProps | null> = createContext<nu
},
exchangeToken: null as unknown as ThunderIDContextProps['exchangeToken'],
getAccessToken: null as unknown as ThunderIDContextProps['getAccessToken'],
getGrantedPermissions: null as unknown as ThunderIDContextProps['getGrantedPermissions'],
getDecodedIdToken: null as unknown as ThunderIDContextProps['getDecodedIdToken'],
getIdToken: null as unknown as ThunderIDContextProps['getIdToken'],
getStorageManager: () => Promise.resolve(null),
Expand Down
7 changes: 7 additions & 0 deletions packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,11 @@ const ThunderIDProvider: FC<PropsWithChildren<ThunderIDProviderProps>> = ({
[client],
);

const getGrantedPermissions: () => Promise<string[]> = useCallback(
async (): Promise<string[]> => client.getGrantedPermissions(),
[client],
);

const getStorageManager: ThunderIDContextProps['getStorageManager'] = useCallback(async () => {
const storageManager: Awaited<ReturnType<ThunderIDContextProps['getStorageManager']>> =
storageManagerRef.current ?? (await client.getStorageManager());
Expand Down Expand Up @@ -514,6 +519,7 @@ const ThunderIDProvider: FC<PropsWithChildren<ThunderIDProviderProps>> = ({
exchangeToken,
getAccessToken,
getDecodedIdToken,
getGrantedPermissions,
getIdToken,
getStorageManager,
http: {
Expand Down Expand Up @@ -570,6 +576,7 @@ const ThunderIDProvider: FC<PropsWithChildren<ThunderIDProviderProps>> = ({
exchangeToken,
httpOptions?.fetcher,
getAccessToken,
getGrantedPermissions,
getStorageManager,
instanceId,
invalidator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThunderIDContextProps>): ((props: {children: ReactNode}) => ReactElement) =>
function Wrapper({children}: {children: ReactNode}): ReactElement {
return (
Expand All @@ -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);
Expand All @@ -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));
Expand Down
16 changes: 8 additions & 8 deletions packages/react/src/hooks/management/useGrantedPermissions.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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.
*
Expand All @@ -32,7 +33,7 @@ export interface GrantedPermissions {
* ```
*/
const useGrantedPermissions = (): GrantedPermissions => {
const {getAccessToken, isSignedIn} = useThunderID();
const {getGrantedPermissions, isSignedIn} = useThunderID();
const [granted, setGranted] = useState<string[] | null>(null);

useEffect(() => {
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
});
});
9 changes: 8 additions & 1 deletion packages/vue/src/__tests__/helpers/managementContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ export const translate = (key: string, params?: Record<string, string | number>)
export interface ManagementTestContext {
fetcher: MockFetcher;
provide: Record<symbol, unknown>;
thunderID: {getAccessToken: Mock<() => Promise<string>>; invalidator: ResourceInvalidator};
thunderID: {
getAccessToken: Mock<() => Promise<string>>;
getGrantedPermissions: Mock<() => Promise<string[]>>;
invalidator: ResourceInvalidator;
};
}

/**
Expand All @@ -43,16 +47,19 @@ export const createManagementContext = ({
fetcher = vi.fn<(url: string, init: RequestInit) => Promise<Response>>(),
isSignedIn = true,
accessToken = 'token',
grantedPermissions = [],
}: {
accessToken?: string;
endpoints?: Record<string, string>;
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(),
Expand Down
14 changes: 7 additions & 7 deletions packages/vue/src/composables/management/useGrantedPermissions.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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.
*
Expand All @@ -37,7 +38,7 @@ export interface GrantedPermissions {
* ```
*/
const useGrantedPermissions = (): GrantedPermissions => {
const {getAccessToken, isSignedIn} = useThunderID();
const {getGrantedPermissions, isSignedIn} = useThunderID();
const granted: Ref<string[] | null> = shallowRef<string[] | null>(null);
let generation = 0;

Expand All @@ -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;
}
});
},
Expand Down
2 changes: 2 additions & 0 deletions packages/vue/src/models/contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface ThunderIDContext {
exchangeToken: (config: TokenExchangeRequestConfig) => Promise<TokenResponse | Response>;
// ── Token ──
getAccessToken: () => Promise<string>;
/** The permissions the signed-in user's access token grants; in backend mode, from the backend's session. */
getGrantedPermissions: () => Promise<string[]>;
getDecodedIdToken: () => Promise<IdToken>;
getIdToken: () => Promise<string>;
getStorageManager: () => StorageManager<any>;
Expand Down
1 change: 1 addition & 0 deletions packages/vue/src/providers/ThunderIDProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ const ThunderIDProvider: Component = defineComponent({
scopes: props.scopes,
exchangeToken: (config: any): Promise<TokenResponse | Response> => client.exchangeToken(config),
getAccessToken: (): Promise<string> => client.getAccessToken(),
getGrantedPermissions: (): Promise<string[]> => client.getGrantedPermissions(),
getDecodedIdToken: (): Promise<IdToken> => client.getDecodedIdToken(),
getIdToken: (): Promise<string> => client.getIdToken(),
getStorageManager: () => client.getStorageManager(),
Expand Down
Loading