Skip to content
Open
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
17 changes: 14 additions & 3 deletions packages/browser/src/ThunderIDBrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,14 +327,25 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> extends ThunderIDJavaScriptC
return this._exchangeCodeForTokens(resolvedAuthorizationCode, resolvedState, resolvedTokenRequestConfig);
}

// The configured signInOptions are the authorize request parameters to send when the caller
// passes none, such as the RFC 8707 resource indicator a server needs to pick the resource server.
const redirectSignInConfig: SignInConfig | undefined =
signInConfig && Object.keys(signInConfig).length > 0
? signInConfig
: (config as BrowserAuthConfig | undefined)?.signInOptions;

// Opt-in: look for an existing ThunderID SSO session before leaving the app. Skipped inside an
// iframe, and when the caller asks for a specific prompt (login, consent, ...), which a silent
// request cannot honour.
const sessionConfig: SessionConfig | undefined = (config as BrowserAuthConfig | undefined)?.session;

if (sessionConfig?.silentSignIn?.beforeRedirect && window.self === window.top && !signInConfig?.['prompt']) {
if (
sessionConfig?.silentSignIn?.beforeRedirect &&
window.self === window.top &&
!redirectSignInConfig?.['prompt']
) {
try {
const silentUser: User | boolean = await this._signInSilentlyInternal(signInConfig, tokenRequestConfig);
const silentUser: User | boolean = await this._signInSilentlyInternal(redirectSignInConfig, tokenRequestConfig);

if (silentUser && typeof silentUser === 'object') {
return silentUser;
Expand All @@ -344,7 +355,7 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> extends ThunderIDJavaScriptC
}
}

return this.getSignInUrl(signInConfig as any).then(async (url: string) => {
return this.getSignInUrl(redirectSignInConfig as any).then(async (url: string) => {
if (this._storage === BrowserStorage.BrowserMemory && config.enablePKCE) {
const pkceKey: string = extractPkceStorageKeyFromState(resolvedState);
SPAUtils.setPKCE(pkceKey, await this.getPKCECode(resolvedState));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {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 RESOURCE = 'https://api.example.com';

/** 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;

describe('ThunderIDBrowserClient configured sign-in options', () => {
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 configured sign-in options on the authorize request of a sign-in call without options', async () => {
const client = await createClient({signInOptions: {prompt: 'login', resource: RESOURCE}});

await client.signIn();

expect(searchParamsOf(client).get('resource')).toBe(RESOURCE);
expect(searchParamsOf(client).get('prompt')).toBe('login');
});

it('treats an empty options object as no options', async () => {
const client = await createClient({signInOptions: {resource: RESOURCE}});

await client.signIn({});

expect(searchParamsOf(client).get('resource')).toBe(RESOURCE);
});

it('sends the options of a sign-in call in place of the configured ones', async () => {
const client = await createClient({signInOptions: {prompt: 'login', resource: RESOURCE}});

await client.signIn({resource: 'https://other.example.com'});

expect(searchParamsOf(client).get('resource')).toBe('https://other.example.com');
expect(searchParamsOf(client).has('prompt')).toBe(false);
});

it('sends no extra parameters when none are configured', async () => {
const client = await createClient();

await client.signIn();

expect(searchParamsOf(client).has('resource')).toBe(false);
expect(searchParamsOf(client).get('client_id')).toBe('test-client');
});
});
5 changes: 5 additions & 0 deletions playgrounds/react/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ VITE_THUNDERID_AFTER_SIGN_OUT_URL=http://localhost:5173
# Space separated scopes requested at sign-in.
VITE_THUNDERID_SCOPES=openid profile email

# Optional: resource server identifier (RFC 8707 resource indicator) the access token is issued for.
# Needed when the server has no default resource server; sign-in otherwise fails with invalid_target.
# For a local ThunderID server, the System resource server is https://localhost:8090/mcp.
VITE_THUNDERID_RESOURCE=

# Optional: token storage. One of sessionStorage, localStorage, browserMemory. Unset uses the SDK default.
VITE_THUNDERID_STORAGE=

Expand Down
19 changes: 10 additions & 9 deletions playgrounds/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,16 @@ pnpm --filter @thunderid/playground-react dev
Open http://localhost:5173. After changing SDK source, rebuild that package (for example
`pnpm --filter @thunderid/react build`) and reload.

| Variable | Purpose |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VITE_THUNDERID_BASE_URL` | ThunderID server. Defaults to `https://localhost:8090`. |
| `VITE_THUNDERID_CLIENT_ID` | Client ID of the SPA application. Required for sign-in. |
| `VITE_THUNDERID_APPLICATION_ID` | Application ID. Needed by `<SignIn />`, `<SignUp />`, `<Recovery />` and their checks. |
| `VITE_THUNDERID_AFTER_SIGN_IN_URL`, `VITE_THUNDERID_AFTER_SIGN_OUT_URL` | Redirect targets. Default to the app origin. |
| `VITE_THUNDERID_SCOPES` | Space separated scopes. Defaults to `openid profile email`. |
| `VITE_THUNDERID_STORAGE` | `sessionStorage`, `localStorage`, or `browserMemory`. Unset uses the SDK default. |
| `VITE_THUNDERID_ENDPOINT_<KEY>` | Optional per-collection override (full URL), passed to `<ThunderIDProvider endpoints />`. `<KEY>` is `USERS`, `USER_TYPES`, `GROUPS`, `ROLES`, `AGENTS`, `AGENT_TYPES`, `APPLICATIONS`, or `FLOWS`. |
| Variable | Purpose |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VITE_THUNDERID_BASE_URL` | ThunderID server. Defaults to `https://localhost:8090`. |
| `VITE_THUNDERID_CLIENT_ID` | Client ID of the SPA application. Required for sign-in. |
| `VITE_THUNDERID_APPLICATION_ID` | Application ID. Needed by `<SignIn />`, `<SignUp />`, `<Recovery />` and their checks. |
| `VITE_THUNDERID_AFTER_SIGN_IN_URL`, `VITE_THUNDERID_AFTER_SIGN_OUT_URL` | Redirect targets. Default to the app origin. |
| `VITE_THUNDERID_SCOPES` | Space separated scopes. Defaults to `openid profile email`. |
| `VITE_THUNDERID_RESOURCE` | Resource server identifier sent at sign-in as the RFC 8707 `resource` indicator. Needed when the server has no default resource server, where sign-in otherwise fails with `invalid_target`. For a local ThunderID server, the System resource server is `https://localhost:8090/mcp`. Unset uses the server default. |
| `VITE_THUNDERID_STORAGE` | `sessionStorage`, `localStorage`, or `browserMemory`. Unset uses the SDK default. |
| `VITE_THUNDERID_ENDPOINT_<KEY>` | Optional per-collection override (full URL), passed to `<ThunderIDProvider endpoints />`. `<KEY>` is `USERS`, `USER_TYPES`, `GROUPS`, `ROLES`, `AGENTS`, `AGENT_TYPES`, `APPLICATIONS`, or `FLOWS`. |

Unset, each management collection resolves to `VITE_THUNDERID_BASE_URL/<collection>`.

Expand Down
6 changes: 6 additions & 0 deletions playgrounds/react/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface PlaygroundConfig {
clientId: string;
/** Management collection overrides passed to the provider's `endpoints`, or undefined for none. */
endpoints?: ManagementEndpoints;
/**
* RFC 8707 resource indicator sent at sign-in, binding the access token to that resource server. Needed
* when the server has no default resource server, or to target a different one.
*/
resource?: string;
scopes: string[];
storage?: StorageMode;
}
Expand Down Expand Up @@ -57,6 +62,7 @@ const playgroundConfig: PlaygroundConfig = {
baseUrl: (read('VITE_THUNDERID_BASE_URL') ?? 'https://localhost:8090').replace(/\/+$/, ''),
clientId: read('VITE_THUNDERID_CLIENT_ID') ?? '',
endpoints: readEndpoints(),
resource: read('VITE_THUNDERID_RESOURCE'),
scopes: (read('VITE_THUNDERID_SCOPES') ?? 'openid profile email').split(/\s+/).filter(Boolean),
storage: readStorage(read('VITE_THUNDERID_STORAGE')),
};
Expand Down
1 change: 1 addition & 0 deletions playgrounds/react/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ createRoot(document.getElementById('root')!).render(
clientId={playgroundConfig.clientId}
endpoints={playgroundConfig.endpoints}
scopes={playgroundConfig.scopes}
signInOptions={playgroundConfig.resource ? {resource: playgroundConfig.resource} : undefined}
storage={playgroundConfig.storage}
>
<App />
Expand Down
1 change: 1 addition & 0 deletions playgrounds/react/src/pages/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ const Overview = (): ReactElement => {
['afterSignIn', sdk.afterSignInUrl ?? playgroundConfig.afterSignInUrl],
['afterSignOut', playgroundConfig.afterSignOutUrl],
['scopes', Array.isArray(sdk.scopes) ? sdk.scopes.join(' ') : String(sdk.scopes ?? '')],
['resource', playgroundConfig.resource ?? "server's default resource server"],
['storage', playgroundConfig.storage ?? 'SDK default'],
];

Expand Down
Loading