Skip to content
Draft
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
9 changes: 6 additions & 3 deletions packages/browser/src/ThunderIDBrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import {
AuthorizeRequestParamValue,
ThunderIDJavaScriptClient,
ThunderIDAuthException,
User,
Expand Down Expand Up @@ -316,7 +317,7 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> extends ThunderIDJavaScriptC
}

public override async signInSilently(
additionalParams?: Record<string, string | boolean>,
additionalParams?: Record<string, AuthorizeRequestParamValue>,
tokenRequestConfig?: {params: Record<string, unknown>},
): Promise<User | boolean | undefined> {
await this.isInitialized();
Expand All @@ -335,7 +336,7 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> extends ThunderIDJavaScriptC
}

private async _signInSilentlyInternal(
additionalParams?: Record<string, string | boolean>,
additionalParams?: Record<string, AuthorizeRequestParamValue>,
tokenRequestConfig?: {params: Record<string, unknown>},
): Promise<User | boolean> {
const {session} = await this.getStorageManager().getConfigData();
Expand Down Expand Up @@ -717,7 +718,9 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> extends ThunderIDJavaScriptC
}
}

private async _constructSilentSignInUrl(additionalParams: Record<string, string | boolean> = {}): Promise<string> {
private async _constructSilentSignInUrl(
additionalParams: Record<string, AuthorizeRequestParamValue> = {},
): Promise<string> {
const sm = this.getStorageManager();
const config = await (sm as any).getConfigData();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {AuthorizationDetail, ExtendedAuthorizeRequestUrlParams, StorageManager} from '@thunderid/javascript';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {BrowserAuthConfig} from '../models/BrowserConfig';
import ThunderIDBrowserClient from '../ThunderIDBrowserClient';
import SPAUtils from '../utils/SPAUtils';

const AUTHORIZE = 'https://example.com/oauth2/authorize';
const PAYMENT: AuthorizationDetail = {actions: ['initiate'], instructedAmount: {amount: '20'}, type: 'payment'};
const ACCOUNT: AuthorizationDetail = {type: 'account_information'};

/** Records every authorize URL the client builds, since signing in navigates away from the page. */
class RecordingClient extends ThunderIDBrowserClient {
public signInUrls: string[] = [];

public storage(): StorageManager<BrowserAuthConfig> {
return this.getStorageManager();
}

protected override async getSignInUrl(
requestConfig?: ExtendedAuthorizeRequestUrlParams,
userId?: string,
): Promise<string> {
const url: string = await super.getSignInUrl(requestConfig, userId);
this.signInUrls.push(url);

return url;
}
}

const createClient = async (config: Partial<BrowserAuthConfig> = {}): Promise<RecordingClient> => {
const client = new RecordingClient();
await client.initialize({baseUrl: 'https://example.com', clientId: 'test-client', ...config});
await client.storage().setOIDCProviderMetaData({
authorization_endpoint: AUTHORIZE,
token_endpoint: 'https://example.com/token',
});
await client.storage().setTemporaryDataParameter('op_config_initiated', true);

return client;
};

const searchParamsOf = (client: RecordingClient): URLSearchParams => new URL(client.signInUrls[0]).searchParams;

const authorizationDetailsOf = (client: RecordingClient): unknown =>
JSON.parse(searchParamsOf(client).get('authorization_details') ?? 'null');

describe('ThunderIDBrowserClient authorization details', () => {
beforeEach(() => {
vi.spyOn(SPAUtils, 'waitTillPageRedirect').mockResolvedValue();
// jsdom does not implement the navigation that signIn starts.
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
sessionStorage.clear();
document.body.innerHTML = '';
});

it('sends the authorization details of a sign-in call on the authorize request', async () => {
const client = await createClient({authorizationDetails: [PAYMENT]});

await client.signIn({authorizationDetails: [ACCOUNT]});

expect(authorizationDetailsOf(client)).toEqual([ACCOUNT]);
expect(searchParamsOf(client).has('authorizationDetails')).toBe(false);
expect(searchParamsOf(client).get('code_challenge')).toBeTruthy();
});

it('sends the configured authorization details on the authorize request', async () => {
const client = await createClient({authorizationDetails: [PAYMENT]});

await client.signIn();

expect(authorizationDetailsOf(client)).toEqual([PAYMENT]);
});

it('sends the authorization details of a silent sign-in call on its authorize request', async () => {
const client = await createClient({
authorizationDetails: [PAYMENT],
session: {silentSignIn: {timeout: 1}},
});

await expect(client.signInSilently({authorizationDetails: [ACCOUNT]})).resolves.toBe(false);

expect(authorizationDetailsOf(client)).toEqual([ACCOUNT]);
expect(searchParamsOf(client).get('prompt')).toBe('none');
});
});
5 changes: 3 additions & 2 deletions packages/browser/src/utils/AuthenticationHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import {
AuthorizeRequestParamValue,
ThunderIDAuthException,
User,
IsomorphicCrypto,
Expand Down Expand Up @@ -432,13 +433,13 @@ class AuthenticationHelper<T> {
* @returns The user session, or `false` if the user is not signed in.
*/
public async signInSilently(
constructSilentSignInUrl: (additionalParams?: Record<string, string | boolean>) => Promise<string>,
constructSilentSignInUrl: (additionalParams?: Record<string, AuthorizeRequestParamValue>) => Promise<string>,
requestAccessToken: (
authzCode: string,
state: string,
tokenRequestConfig?: {params: Record<string, unknown>},
) => Promise<User>,
additionalParams?: Record<string, string | boolean>,
additionalParams?: Record<string, AuthorizeRequestParamValue>,
tokenRequestConfig?: {params: Record<string, unknown>},
timeout: number = SILENT_SIGN_IN_TIMEOUT,
): Promise<User | boolean> {
Expand Down
3 changes: 2 additions & 1 deletion packages/express/src/ThunderIDExpressClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
ThunderIDNodeClient,
ThunderIDAuthException,
ExtendedAuthorizeRequestUrlParams,
Storage,
TokenResponse,
User,
Expand Down Expand Up @@ -59,7 +60,7 @@ class ThunderIDExpressClient<T extends ExpressClientConfig = ExpressClientConfig
req: express.Request,
res: express.Response,
next?: express.NextFunction,
signInConfig?: Record<string, string | boolean>,
signInConfig?: ExtendedAuthorizeRequestUrlParams,
): Promise<TokenResponse> {
if (hasErrorInURL(req.originalUrl)) {
return Promise.reject(
Expand Down
144 changes: 144 additions & 0 deletions packages/express/src/__tests__/authorizationDetails.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {AuthorizationDetail, TokenResponse} from '@thunderid/node';
import express from 'express';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {handleSignIn, thunderID} from '../middleware/authentication';
import {ThunderIDExpressConfig} from '../models/config';
import ThunderIDExpressClient from '../ThunderIDExpressClient';

const BASE_URL = 'https://auth.example.com';

const AUTHORIZATION_DETAILS: AuthorizationDetail[] = [
{actions: ['read'], locations: ['https://api.example.com/accounts'], type: 'account'},
];

const encode = (part: Record<string, unknown>): string => Buffer.from(JSON.stringify(part)).toString('base64url');

const ID_TOKEN = `${encode({alg: 'none'})}.${encode({sid: 'server-session-1', sub: 'user-1'})}.`;

interface MockResponse {
cookie: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
redirect: ReturnType<typeof vi.fn>;
status: ReturnType<typeof vi.fn>;
}

describe('authorization details through the Express middleware', () => {
let fetchMock: ReturnType<typeof vi.fn>;
let client: ThunderIDExpressClient;

const newResponse = (): MockResponse => {
const res: MockResponse = {cookie: vi.fn(), end: vi.fn(), redirect: vi.fn(), status: vi.fn()};

res.status.mockReturnValue(res);

return res;
};

const newRequest = (query: Record<string, string> = {}, cookies: Record<string, string> = {}): express.Request =>
({
cookies,
get: () => 'app.example.com',
originalUrl: '/login',
protocol: 'https',
query,
}) as unknown as express.Request;

// Mounts `thunderID()` and then `handleSignIn()` for one request, as an Express app would.
const signIn = async (
config: Partial<ThunderIDExpressConfig>,
req: express.Request,
res: MockResponse,
): Promise<void> => {
const middleware: express.RequestHandler = thunderID({
afterSignInUrl: 'https://app.example.com/login',
baseUrl: BASE_URL,
clientId: 'test-client',
clientSecret: 'test-secret',
discovery: {wellKnown: {enabled: false}},
tokenValidation: {idToken: {validate: false}},
...config,
} as ThunderIDExpressConfig);

await middleware(req, res as unknown as express.Response, vi.fn());

client = (req as express.Request & {thunderIDAuth: ThunderIDExpressClient}).thunderIDAuth;

await handleSignIn()(req, res as unknown as express.Response, vi.fn());
};

const redirectedTo = (res: MockResponse): URL => new URL(res.redirect.mock.calls[0][0] as string);

beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('sends the authorization details of the sign-in options on the authorize request', async () => {
const res = newResponse();

await signIn({signInOptions: {authorizationDetails: AUTHORIZATION_DETAILS}}, newRequest(), res);

const authorizeUrl = redirectedTo(res);

expect(authorizeUrl.origin + authorizeUrl.pathname).toBe(`${BASE_URL}/oauth2/authorize`);
expect(JSON.parse(authorizeUrl.searchParams.get('authorization_details')!)).toEqual(AUTHORIZATION_DETAILS);
});

it('sends the configured authorization details on the authorize request', async () => {
const res = newResponse();

await signIn({authorizationDetails: AUTHORIZATION_DETAILS}, newRequest(), res);

expect(JSON.parse(redirectedTo(res).searchParams.get('authorization_details')!)).toEqual(AUTHORIZATION_DETAILS);
});

it('hands the granted authorization details to onSignIn', async () => {
const onSignIn = vi.fn();
const config: Partial<ThunderIDExpressConfig> = {
onSignIn,
signInOptions: {authorizationDetails: AUTHORIZATION_DETAILS},
};
const start = newResponse();

await signIn(config, newRequest(), start);

const [cookieName, sessionId] = start.cookie.mock.calls[0] as [string, string];
const state = redirectedTo(start).searchParams.get('state')!;

fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'access-1',
authorization_details: AUTHORIZATION_DETAILS,
expires_in: '3600',
id_token: ID_TOKEN,
scope: 'openid',
token_type: 'Bearer',
}),
{headers: {'Content-Type': 'application/json'}, status: 200},
),
);

const callback = newResponse();

await handleSignIn()(
Object.assign(newRequest({code: 'auth-code', state}, {[cookieName]: sessionId}), {thunderIDAuth: client}),
callback as unknown as express.Response,
vi.fn(),
);

expect(onSignIn).toHaveBeenCalledTimes(1);

const tokenResponse = onSignIn.mock.calls[0][1] as TokenResponse;

expect(tokenResponse.accessToken).toBe('access-1');
expect(tokenResponse.authorizationDetails).toEqual(AUTHORIZATION_DETAILS);
});
});
10 changes: 10 additions & 0 deletions packages/javascript/src/ThunderIDJavaScriptClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
const authRequestConfig: ExtendedAuthorizeRequestUrlParams = {...requestConfig};

delete authRequestConfig?.forceInit;
delete authRequestConfig?.authorizationDetails;

const buildSignInUrl = async (): Promise<string> => {
const authorizeEndpoint: string = (await this.storageManager.getOIDCProviderMetaDataParameter(
Expand Down Expand Up @@ -593,6 +594,7 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
const authorizeRequestParams: Map<string, string> = getAuthorizeRequestUrlParams(
Object.fromEntries(
Object.entries({
authorizationDetails: requestConfig?.authorizationDetails ?? configData.authorizationDetails,
clientId: configData.clientId ?? '',
codeChallenge,
codeChallengeMethod: PKCEConstants.DEFAULT_CODE_CHALLENGE_METHOD,
Expand Down Expand Up @@ -843,6 +845,7 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
const sessionData: SessionData = await this.storageManager.getSessionData(userId);

return {
authorizationDetails: sessionData?.authorization_details,
scopes: sessionData?.scope?.split(' '),
};
}
Expand Down Expand Up @@ -950,6 +953,10 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
body.set('scope', scope);
}

if (configData.authorizationDetails?.length) {
body.set('authorization_details', JSON.stringify(configData.authorizationDetails));
}

if (configData.tokenRequest?.params) {
Object.entries(configData.tokenRequest.params).forEach(([key, value]: [string, unknown]) => {
body.set(key, value as string);
Expand Down Expand Up @@ -998,6 +1005,7 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {

return {
accessToken: parsedResponse.access_token,
authorizationDetails: parsedResponse.authorization_details,
createdAt: parsedResponse.created_at,
expiresIn: parsedResponse.expires_in,
idToken: parsedResponse.id_token ?? '',
Expand Down Expand Up @@ -1135,6 +1143,8 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
if (options.bindingMessage) body.set('binding_message', options.bindingMessage);
if (options.acrValues) body.set('acr_values', options.acrValues);
if (options.requestedExpiry !== undefined) body.set('requested_expiry', String(options.requestedExpiry));
const authorizationDetails = options.authorizationDetails ?? configData.authorizationDetails;
if (authorizationDetails?.length) body.set('authorization_details', JSON.stringify(authorizationDetails));

const headers: Record<string, string> = {
Accept: 'application/json',
Expand Down
Loading
Loading