From 154b3327b947d5ef083904918967be254a11b3f6 Mon Sep 17 00:00:00 2001 From: Brion Date: Fri, 6 Nov 2026 12:04:28 +0530 Subject: [PATCH] Send the configured signInOptions when signIn() is called without options The signInOptions configuration is documented as the extra parameters for the authorize request, but a redirect sign-in only sent the options passed to that call. A bare signIn() therefore dropped them, so an RFC 8707 resource indicator set there never reached a server that has no default resource server, and sign-in failed with invalid_target. ProtectedRoute and SignInButton worked around it by passing the context's signInOptions themselves. A redirect sign-in called without options, or with an empty options object, now sends the configured signInOptions, including on the silent sign-in tried before the redirect. Options passed to the call still take their place. The React playground gains VITE_THUNDERID_RESOURCE, passed as signInOptions.resource and shown in the Overview configuration, so it can sign in to a server without a default resource server. Signed-off-by: Brion --- .../browser/src/ThunderIDBrowserClient.ts | 17 +++- ...underIDBrowserClient.signInOptions.test.ts | 93 +++++++++++++++++++ playgrounds/react/.env.example | 5 + playgrounds/react/README.md | 19 ++-- playgrounds/react/src/config.ts | 6 ++ playgrounds/react/src/main.tsx | 1 + playgrounds/react/src/pages/Overview.tsx | 1 + 7 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 packages/browser/src/__tests__/ThunderIDBrowserClient.signInOptions.test.ts diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 9f9ddef0..3f927e85 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -327,14 +327,25 @@ class ThunderIDBrowserClient 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; @@ -344,7 +355,7 @@ class ThunderIDBrowserClient 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)); diff --git a/packages/browser/src/__tests__/ThunderIDBrowserClient.signInOptions.test.ts b/packages/browser/src/__tests__/ThunderIDBrowserClient.signInOptions.test.ts new file mode 100644 index 00000000..37587c8a --- /dev/null +++ b/packages/browser/src/__tests__/ThunderIDBrowserClient.signInOptions.test.ts @@ -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 { + return this.getStorageManager(); + } + + protected override async getSignInUrl( + requestConfig?: ExtendedAuthorizeRequestUrlParams, + userId?: string, + ): Promise { + const url: string = await super.getSignInUrl(requestConfig, userId); + this.signInUrls.push(url); + + return url; + } +} + +const createClient = async (config: Partial = {}): Promise => { + 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'); + }); +}); diff --git a/playgrounds/react/.env.example b/playgrounds/react/.env.example index 26f281b3..7d98a9a1 100644 --- a/playgrounds/react/.env.example +++ b/playgrounds/react/.env.example @@ -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= diff --git a/playgrounds/react/README.md b/playgrounds/react/README.md index a8d3f496..c3e15a48 100644 --- a/playgrounds/react/README.md +++ b/playgrounds/react/README.md @@ -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 ``, ``, `` 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_` | Optional per-collection override (full URL), passed to ``. `` 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 ``, ``, `` 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_` | Optional per-collection override (full URL), passed to ``. `` is `USERS`, `USER_TYPES`, `GROUPS`, `ROLES`, `AGENTS`, `AGENT_TYPES`, `APPLICATIONS`, or `FLOWS`. | Unset, each management collection resolves to `VITE_THUNDERID_BASE_URL/`. diff --git a/playgrounds/react/src/config.ts b/playgrounds/react/src/config.ts index 63c16230..ceb59557 100644 --- a/playgrounds/react/src/config.ts +++ b/playgrounds/react/src/config.ts @@ -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; } @@ -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')), }; diff --git a/playgrounds/react/src/main.tsx b/playgrounds/react/src/main.tsx index f570a5bc..b6fb1c16 100644 --- a/playgrounds/react/src/main.tsx +++ b/playgrounds/react/src/main.tsx @@ -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} > diff --git a/playgrounds/react/src/pages/Overview.tsx b/playgrounds/react/src/pages/Overview.tsx index 205fa14a..a6664c77 100644 --- a/playgrounds/react/src/pages/Overview.tsx +++ b/playgrounds/react/src/pages/Overview.tsx @@ -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'], ];