diff --git a/packages/browser/src/BackendClient.ts b/packages/browser/src/BackendClient.ts index c37945cc..81ee5d66 100644 --- a/packages/browser/src/BackendClient.ts +++ b/packages/browser/src/BackendClient.ts @@ -274,12 +274,13 @@ class BackendClient { } /** - * The headers and credentials mode every backend request is sent with. + * The headers and credentials mode every backend request is sent with. The cookies go with every + * request, so a backend on another origin works too when its CORS allows credentials. */ static requestInit(headers: Record = {}): Pick & { headers: Record; } { - return {credentials: 'same-origin', headers: {...headers, [BACKEND_REQUEST_HEADER]: BACKEND_REQUEST_HEADER_VALUE}}; + return {credentials: 'include', headers: {...headers, [BACKEND_REQUEST_HEADER]: BACKEND_REQUEST_HEADER_VALUE}}; } private async loadSession(): Promise { diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 6bba7691..6d3ecdb4 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -26,6 +26,7 @@ import { generateFlattenedUserProfile, getTokenPermissions, getUsersMe, + resolveApiBaseUrl, resolveBackendEndpoints, resolveResourceEndpoint, } from '@thunderid/javascript'; @@ -602,7 +603,8 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC const configData = await this.getStorageManager().getConfigData(); const profile: User = await getUsersMe({ - baseUrl: options?.baseUrl ?? (configData as any)?.baseUrl, + // In backend mode `baseUrl` is the backend; the API is under `endpoints.backend.apiBaseUrl`. + baseUrl: options?.baseUrl ?? resolveApiBaseUrl(configData as any), fetcher: createHttpClientFetcher(this._browserInstanceId), url: resolveResourceEndpoint('usersMe', configData as any, options?.url), }); diff --git a/packages/browser/src/__tests__/BackendClient.test.ts b/packages/browser/src/__tests__/BackendClient.test.ts index 73fad7b3..83df7237 100644 --- a/packages/browser/src/__tests__/BackendClient.test.ts +++ b/packages/browser/src/__tests__/BackendClient.test.ts @@ -38,7 +38,7 @@ describe('BackendClient', () => { vi.unstubAllGlobals(); }); - it('reads the session with the CSRF header and same-origin credentials, and caches it', async () => { + it('reads the session with the CSRF header and credentials included, and caches it', async () => { fetchMock.mockResolvedValue(json(200, {isSignedIn: true, user: {sub: 'u1'}})); const backend = new BackendClient(ENDPOINTS); @@ -48,7 +48,7 @@ describe('BackendClient', () => { expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('/api/auth/session'); - expect(init.credentials).toBe('same-origin'); + expect(init.credentials).toBe('include'); expect(init.headers[BACKEND_REQUEST_HEADER]).toBe('XMLHttpRequest'); }); @@ -134,7 +134,7 @@ describe('BackendHttpClient', () => { expect(response.data).toEqual({id: 'u1'}); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('/api/auth/proxy/users'); - expect(init.credentials).toBe('same-origin'); + expect(init.credentials).toBe('include'); expect(init.headers[BACKEND_REQUEST_HEADER]).toBe('XMLHttpRequest'); expect(init.headers.Authorization).toBeUndefined(); expect(init.body).toBe('{"name":"a"}'); @@ -307,7 +307,7 @@ describe('BackendClient bypass', () => { expect(backend.isBypassed('/orders')).toBe(false); }); - it('requests the access token with the CSRF header and same-origin credentials', async () => { + it('requests the access token with the CSRF header and credentials included', async () => { fetchMock.mockResolvedValueOnce(json(200, {access_token: 't1', expires_in: 300, token_type: 'Bearer'})); expect(await new BackendClient(ENDPOINTS).requestAccessToken()).toEqual({ @@ -318,7 +318,7 @@ describe('BackendClient bypass', () => { const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('/api/auth/token'); expect(init.method).toBe('POST'); - expect(init.credentials).toBe('same-origin'); + expect(init.credentials).toBe('include'); expect(init.headers[BACKEND_REQUEST_HEADER]).toBe('XMLHttpRequest'); }); diff --git a/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts b/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts index 9c1830c8..2b006afc 100644 --- a/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts +++ b/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts @@ -10,10 +10,14 @@ import ThunderIDBrowserClient from '../ThunderIDBrowserClient'; const json = (status: number, body: unknown): Response => new Response(JSON.stringify(body), {headers: {'Content-Type': 'application/json'}, status}); +// In backend mode `baseUrl` is the backend, and the API whose requests go through its proxy is +// `endpoints.backend.apiBaseUrl`. +const BFF = 'https://app.example.com'; + const CONFIG = { - baseUrl: 'https://auth.example.com', + baseUrl: BFF, clientId: 'my-app', - endpoints: {backend: {bypass: {urls: ['https://dp.example.com']}}}, + endpoints: {backend: {apiBaseUrl: 'https://auth.example.com', bypass: {urls: ['https://dp.example.com']}}}, storage: 'browserMemory', }; @@ -81,7 +85,7 @@ describe('ThunderIDBrowserClient with sign-in on the backend', () => { const response = await client.httpRequest({method: 'GET', url: 'https://auth.example.com/users'}); expect(response?.data).toEqual({users: []}); - expect(fetchMock.mock.calls[0][0]).toBe('/api/auth/proxy/users'); + expect(fetchMock.mock.calls[0][0]).toBe(`${BFF}/api/auth/proxy/users`); }); it('gets the access token from the backend and stores it like a client credentials token', async () => { @@ -91,7 +95,7 @@ describe('ThunderIDBrowserClient with sign-in on the backend', () => { expect(await client.getAccessToken()).toBe('t1'); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0][0]).toBe('/api/auth/token'); + expect(fetchMock.mock.calls[0][0]).toBe(`${BFF}/api/auth/token`); }); it('requests a new access token when the stored one is about to expire', async () => { @@ -110,10 +114,28 @@ describe('ThunderIDBrowserClient with sign-in on the backend', () => { 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'); + expect(fetchMock.mock.calls[0][0]).toBe(`${BFF}/api/auth/session`); FetchHttpClient.destroyInstance(8); }); + it('builds /users/me on the API base and sends it through the proxy', async () => { + fetchMock.mockResolvedValueOnce(json(200, {id: 'u1', username: 'ada'})); + + await client.getUserProfile(); + + expect(fetchMock.mock.calls[0][0]).toBe(`${BFF}/api/auth/proxy/users/me`); + }); + + it('does not run OIDC discovery against the backend', async () => { + fetchMock.mockResolvedValueOnce(json(200, SIGNED_IN)); + const fresh = new ThunderIDBrowserClient(9); + await fresh.initialize(CONFIG); + await fresh.isSignedIn(); + + expect(fetchMock.mock.calls.map((call: unknown[]) => String(call[0]))).toEqual([`${BFF}/api/auth/session`]); + FetchHttpClient.destroyInstance(9); + }); + 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(); @@ -166,7 +188,7 @@ describe('ThunderIDBrowserClient with sign-in on the backend', () => { await vi.waitFor(() => expect(afterSignOut).toHaveBeenCalled()); expect(afterSignOut).toHaveBeenCalledWith('https://auth.example.com/oauth2/logout?client_id=my-app'); - expect(fetchMock.mock.calls[0][0]).toBe('/api/auth/signout'); + expect(fetchMock.mock.calls[0][0]).toBe(`${BFF}/api/auth/signout`); }); }); diff --git a/packages/javascript/src/ThunderIDJavaScriptClient.ts b/packages/javascript/src/ThunderIDJavaScriptClient.ts index 35a58e99..a5cda667 100644 --- a/packages/javascript/src/ThunderIDJavaScriptClient.ts +++ b/packages/javascript/src/ThunderIDJavaScriptClient.ts @@ -430,11 +430,15 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { const {discovery, baseUrl, endpoints} = configData as any; const wellKnownOverride: string | undefined = endpoints?.wellKnown; + const isBackendMode = Boolean((configData as {endpoints?: {backend?: unknown}}).endpoints?.backend); - // Discovery is enabled by default; only skip if explicitly disabled. + // Discovery is enabled by default; only skip if explicitly disabled. In backend mode `baseUrl` is + // the backend, which runs OAuth itself, so there is nothing to discover there. const resolvedWellKnownEndpoint: string | undefined = wellKnownOverride || - (discovery?.wellKnown?.enabled !== false && baseUrl ? `${baseUrl}${WELL_KNOWN_PATH}` : undefined); + (discovery?.wellKnown?.enabled !== false && baseUrl && !isBackendMode + ? `${baseUrl}${WELL_KNOWN_PATH}` + : undefined); if (resolvedWellKnownEndpoint) { let response: Response | undefined; diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index fb1ae711..cc4a9145 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -912,6 +912,7 @@ export { export {default as removeTrailingSlash} from './utils/removeTrailingSlash'; export {default as resolveFieldName} from './utils/resolveFieldName'; export {default as resolveResourceEndpoint} from './utils/resolveResourceEndpoint'; +export {default as resolveApiBaseUrl} from './utils/resolveApiBaseUrl'; export {default as resolveBackendEndpoints} from './utils/resolveBackendEndpoints'; export type {BackendEndpointConfig} from './utils/resolveBackendEndpoints'; export type {ResourceEndpointKey, ResourceEndpointConfig} from './utils/resolveResourceEndpoint'; diff --git a/packages/javascript/src/models/backend.ts b/packages/javascript/src/models/backend.ts index 31c2c936..55cecdee 100644 --- a/packages/javascript/src/models/backend.ts +++ b/packages/javascript/src/models/backend.ts @@ -3,14 +3,20 @@ /** * The app backend's auth endpoints, set under `endpoints.backend`. Setting them hands sign-in to the - * app's backend (the Backend for Frontend pattern): the backend, on the app's own origin, runs the - * OAuth flow and keeps the tokens. The browser only holds an `HttpOnly` cookie it cannot read, and - * API calls go through the backend, which attaches the access token. + * app's backend (the Backend for Frontend pattern): the backend runs the OAuth flow and keeps the + * tokens. The browser only holds an `HttpOnly` cookie it cannot read, and API calls go through the + * backend, which attaches the access token. * - * Any endpoint left unset defaults to a fixed path under `/api/auth`. + * In this mode the top-level `baseUrl` is the backend, and defaults to the app's origin. An endpoint + * given as a path is resolved against it; an absolute URL is used as it is. Any endpoint left unset + * defaults to a fixed path under `/api/auth`. + * + * The backend belongs on the app's own origin, as RFC 10017 recommends. A backend on another origin + * has to allow it with CORS, credentials included, and its cookies have to reach it. * * @example - * endpoints: {backend: {signIn: '/api/auth/signin'}} + * baseUrl: 'https://app.example.com', + * endpoints: {backend: {signIn: '/api/auth/signin', apiBaseUrl: 'https://api.example.com'}} */ export interface BackendEndpoints { /** Starts sign-in. Defaults to `/api/auth/signin`. */ @@ -27,10 +33,16 @@ export interface BackendEndpoints { /** Ends the session. Defaults to `/api/auth/signout`. */ signOut?: string; /** - * Prefix of the API proxy. Requests under `baseUrl`, or under the app's origin when `baseUrl` is - * not set, are sent through it. Defaults to `/api/auth/proxy`. + * Prefix of the API proxy. Requests under `apiBaseUrl` are sent through it. Defaults to + * `/api/auth/proxy`. */ proxy?: string; + /** + * Base URL of the API behind the proxy: requests under it are sent through `proxy`, and the SDK + * builds its API requests, such as `/users/me` and the management APIs, on it. Defaults to + * `baseUrl`, then to the app's origin. + */ + apiBaseUrl?: string; /** * Issues the access token for `bypass`, as an OAuth token response without a refresh token. * Defaults to `/api/auth/token`. @@ -61,7 +73,6 @@ export interface BackendBypass { * The backend endpoints, fully resolved, with the API base whose requests go through the proxy. */ export type ResolvedBackendEndpoints = Required> & { - apiBaseUrl: string; bypass: Required; }; diff --git a/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts b/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts index ff834ef2..54dc006e 100644 --- a/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts +++ b/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts @@ -4,6 +4,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/require-await */ import {describe, expect, it} from 'vitest'; import AuthenticationHelper from '../AuthenticationHelper'; +import resolveApiBaseUrl from '../resolveApiBaseUrl'; import resolveBackendEndpoints from '../resolveBackendEndpoints'; const ORIGIN = 'https://app.example.com'; @@ -28,20 +29,51 @@ describe('resolveBackendEndpoints', () => { }); }); - it('applies the configured endpoints and uses baseUrl as the API base', () => { + it('resolves endpoint paths against baseUrl, the backend, and uses it as the API base by default', () => { const resolved = resolveBackendEndpoints( { - baseUrl: 'https://auth.example.com/', + baseUrl: 'https://bff.example.com/', endpoints: {backend: {proxy: '/gateway/api/', session: '/gateway/me', signIn: '/gateway/signin'}}, }, ORIGIN, ); - expect(resolved?.signIn).toBe('/gateway/signin'); + expect(resolved?.signIn).toBe('https://bff.example.com/gateway/signin'); + expect(resolved?.session).toBe('https://bff.example.com/gateway/me'); + expect(resolved?.proxy).toBe('https://bff.example.com/gateway/api'); + expect(resolved?.signOut).toBe('https://bff.example.com/api/auth/signout'); + expect(resolved?.apiBaseUrl).toBe('https://bff.example.com'); + }); + + it('uses absolute endpoint URLs as they are', () => { + const resolved = resolveBackendEndpoints( + { + baseUrl: 'https://bff.example.com', + endpoints: {backend: {checkSession: '//auth.example.com/check', session: 'https://auth.example.com/me'}}, + }, + ORIGIN, + ); + + expect(resolved?.session).toBe('https://auth.example.com/me'); + expect(resolved?.checkSession).toBe('//auth.example.com/check'); + expect(resolved?.refresh).toBe('https://bff.example.com/api/auth/refresh'); + }); + + it('keeps endpoint paths relative to the app origin without baseUrl', () => { + const resolved = resolveBackendEndpoints({baseUrl: '', endpoints: {backend: {session: '/gateway/me'}}}, ORIGIN); + expect(resolved?.session).toBe('/gateway/me'); - expect(resolved?.proxy).toBe('/gateway/api'); - expect(resolved?.signOut).toBe('/api/auth/signout'); - expect(resolved?.apiBaseUrl).toBe('https://auth.example.com'); + expect(resolved?.apiBaseUrl).toBe(ORIGIN); + }); + + it('uses endpoints.backend.apiBaseUrl as the API base when set', () => { + const resolved = resolveBackendEndpoints( + {baseUrl: 'https://bff.example.com', endpoints: {backend: {apiBaseUrl: 'https://api.example.com/v1/'}}}, + ORIGIN, + ); + + expect(resolved?.apiBaseUrl).toBe('https://api.example.com/v1'); + expect(resolved?.proxy).toBe('https://bff.example.com/api/auth/proxy'); }); it('applies the token endpoint and the bypass, trimming its URLs', () => { @@ -59,6 +91,25 @@ describe('resolveBackendEndpoints', () => { }); }); +describe('resolveApiBaseUrl', () => { + it('is endpoints.backend.apiBaseUrl in backend mode, falling back to baseUrl', () => { + expect( + resolveApiBaseUrl({ + baseUrl: 'https://bff.example.com', + endpoints: {backend: {apiBaseUrl: 'https://api.example.com'}}, + }), + ).toBe('https://api.example.com'); + expect(resolveApiBaseUrl({baseUrl: 'https://bff.example.com', endpoints: {backend: {}}})).toBe( + 'https://bff.example.com', + ); + }); + + it('is baseUrl outside backend mode', () => { + expect(resolveApiBaseUrl({baseUrl: 'https://auth.example.com'})).toBe('https://auth.example.com'); + expect(resolveApiBaseUrl(undefined)).toBeUndefined(); + }); +}); + describe('AuthenticationHelper OIDC endpoint overrides', () => { it('keeps the backend overrides out of the OIDC provider metadata', async () => { const storageManager = { diff --git a/packages/javascript/src/utils/resolveApiBaseUrl.ts b/packages/javascript/src/utils/resolveApiBaseUrl.ts new file mode 100644 index 00000000..07c6fd31 --- /dev/null +++ b/packages/javascript/src/utils/resolveApiBaseUrl.ts @@ -0,0 +1,32 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {BackendEndpointConfig} from './resolveBackendEndpoints'; + +/** + * Resolves the base URL the SDK builds API requests on, such as `/users/me` and the management APIs. + * + * When sign-in runs on a backend (`endpoints.backend`), `baseUrl` is the backend itself, so the API + * base is `endpoints.backend.apiBaseUrl`, falling back to `baseUrl`. Requests under it are sent + * through the backend's proxy. Otherwise the API base is `baseUrl`. + * + * @param config - The client config. + * @returns The API base URL, or `undefined` when none is configured. + * + * @example + * ```typescript + * resolveApiBaseUrl({baseUrl: 'https://bff.example.com', endpoints: {backend: {apiBaseUrl: 'https://api.example.com'}}}); + * // 'https://api.example.com' + * ``` + */ +const resolveApiBaseUrl = (config: BackendEndpointConfig | undefined): string | undefined => { + if (config?.endpoints?.backend) { + // `||`, not `??`: an empty value falls back, as an empty baseUrl does elsewhere. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return config.endpoints.backend.apiBaseUrl || config.baseUrl; + } + + return config?.baseUrl; +}; + +export default resolveApiBaseUrl; diff --git a/packages/javascript/src/utils/resolveBackendEndpoints.ts b/packages/javascript/src/utils/resolveBackendEndpoints.ts index b9494c48..e1d822d5 100644 --- a/packages/javascript/src/utils/resolveBackendEndpoints.ts +++ b/packages/javascript/src/utils/resolveBackendEndpoints.ts @@ -1,6 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import resolveApiBaseUrl from './resolveApiBaseUrl'; import {BackendEndpoints, ResolvedBackendEndpoints} from '../models/backend'; const DEFAULT_BACKEND_BASE_PATH = '/api/auth'; @@ -17,14 +18,19 @@ export interface BackendEndpointConfig { * Resolves the backend endpoints from `endpoints.backend`, or returns `undefined` when it is not set * and sign-in does not run on a backend. * + * In backend mode `baseUrl` is the backend. Each endpoint given as a path (starting with `/`) is + * resolved against it, and an absolute URL is used as it is. Without `baseUrl` the paths stay + * relative to the app's origin. The API base, whose requests go through the proxy, is + * `endpoints.backend.apiBaseUrl`, falling back to `baseUrl` and then to the app's origin. + * * @param config - The client config. - * @param origin - The app's origin, used as the API base when `baseUrl` is not set. + * @param origin - The app's origin, the API base when neither `apiBaseUrl` nor `baseUrl` is set. * @returns The resolved endpoints, or `undefined` when `endpoints.backend` is not set. * * @example * ```typescript - * resolveBackendEndpoints({endpoints: {backend: {signIn: '/auth/signin'}}}, window.location.origin); - * // {signIn: '/auth/signin', session: '/api/auth/session', ..., apiBaseUrl: 'https://...'} + * resolveBackendEndpoints({baseUrl: 'https://bff.example.com', endpoints: {backend: {}}}, window.location.origin); + * // {signIn: 'https://bff.example.com/api/auth/signin', ..., apiBaseUrl: 'https://bff.example.com'} * ``` */ const resolveBackendEndpoints = ( @@ -37,21 +43,28 @@ const resolveBackendEndpoints = ( return undefined; } + const backendBase: string = (config?.baseUrl ?? '').replace(/\/+$/, ''); + const resolve = (endpoint: string | undefined, fallback: string): string => { + const value: string = endpoint ?? `${DEFAULT_BACKEND_BASE_PATH}/${fallback}`; + + return value.startsWith('/') && !value.startsWith('//') ? `${backendBase}${value}` : value; + }; + return { - // `||`, not `??`: an empty baseUrl, which the providers default to, falls back to the origin too. + // `||`, not `??`: an empty base, which the providers default to, falls back to the origin too. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - apiBaseUrl: (config?.baseUrl || origin).replace(/\/+$/, ''), + apiBaseUrl: (resolveApiBaseUrl(config) || origin).replace(/\/+$/, ''), bypass: { attachToken: endpoints.bypass?.attachToken ?? true, urls: (endpoints.bypass?.urls ?? []).map((url: string) => url.replace(/\/+$/, '')), }, - checkSession: endpoints.checkSession ?? `${DEFAULT_BACKEND_BASE_PATH}/checksession`, - proxy: (endpoints.proxy ?? `${DEFAULT_BACKEND_BASE_PATH}/proxy`).replace(/\/+$/, ''), - refresh: endpoints.refresh ?? `${DEFAULT_BACKEND_BASE_PATH}/refresh`, - session: endpoints.session ?? `${DEFAULT_BACKEND_BASE_PATH}/session`, - signIn: endpoints.signIn ?? `${DEFAULT_BACKEND_BASE_PATH}/signin`, - signOut: endpoints.signOut ?? `${DEFAULT_BACKEND_BASE_PATH}/signout`, - token: endpoints.token ?? `${DEFAULT_BACKEND_BASE_PATH}/token`, + checkSession: resolve(endpoints.checkSession, 'checksession'), + proxy: resolve(endpoints.proxy, 'proxy').replace(/\/+$/, ''), + refresh: resolve(endpoints.refresh, 'refresh'), + session: resolve(endpoints.session, 'session'), + signIn: resolve(endpoints.signIn, 'signin'), + signOut: resolve(endpoints.signOut, 'signout'), + token: resolve(endpoints.token, 'token'), }; }; diff --git a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts index 77cb8f50..7117b0c6 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts +++ b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts @@ -29,6 +29,10 @@ import ThunderIDReactClient from '../../ThunderIDReactClient'; export type ThunderIDContextProps = { afterSignInUrl: string | undefined; applicationId: string | undefined; + /** + * The base URL the SDK's API requests are built on: the ThunderID tenant, or in backend mode + * `endpoints.backend.apiBaseUrl` (falling back to `baseUrl`, the backend). + */ baseUrl: string | undefined; clientId: string | undefined; /** diff --git a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx index 91955bff..5b53b5a3 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -17,6 +17,7 @@ import { EmbeddedRecoveryFlowResponse, createPackageComponentLogger, getVendorPrefix, + resolveApiBaseUrl, resolveResourceEndpoint, AttributeSchema, SPAUtils, @@ -86,12 +87,15 @@ const ThunderIDProvider: FC> = ({ const [userProfile, setUserProfile] = useState(null); const [userSchema, setUserSchema] = useState | null>(null); - const [baseUrl, setBaseUrl] = useState(initialBaseUrl ?? ''); + // The base the SDK's API requests are built on. In backend mode `baseUrl` is the backend, and the + // API is under `endpoints.backend.apiBaseUrl`. + const apiBaseUrl: string = resolveApiBaseUrl({baseUrl: initialBaseUrl, endpoints: rest.endpoints}) ?? ''; + const [baseUrl, setBaseUrl] = useState(apiBaseUrl); const [config, setConfig] = useState({ afterSignInUrl: afterSignInUrl ?? window.location.origin, afterSignOutUrl: afterSignOutUrl ?? window.location.origin, applicationId, - baseUrl, + baseUrl: initialBaseUrl ?? '', clientId, organizationChain, organizationHandle, @@ -112,8 +116,8 @@ const ThunderIDProvider: FC> = ({ const pendingSessionUpdateRef: RefObject | null> = useRef | null>(null); useEffect(() => { - setBaseUrl(initialBaseUrl ?? ''); - }, [initialBaseUrl]); + setBaseUrl(apiBaseUrl); + }, [apiBaseUrl]); useEffect(() => { (async (): Promise => { @@ -136,7 +140,7 @@ const ThunderIDProvider: FC> = ({ // If there's a `user_org` claim in the ID token, // Treat this login as a organization login. if (decodedToken?.['user_org']) { - resolvedBaseUrl = `${(await client.getConfiguration()).baseUrl}/o`; + resolvedBaseUrl = `${resolveApiBaseUrl(await client.getConfiguration()) ?? ''}/o`; setBaseUrl(resolvedBaseUrl); } @@ -496,7 +500,7 @@ const ThunderIDProvider: FC> = ({ // baseUrl, discovery) reflects the new configuration instead of the pre-reinitialization state. const reinitializedConfig: ThunderIDReactConfig = await client.getConfiguration(); setConfig(reinitializedConfig); - setBaseUrl(reinitializedConfig.baseUrl ?? ''); + setBaseUrl(resolveApiBaseUrl(reinitializedConfig) ?? ''); setWellKnown(await client.getDiscoveryResponse()); return result; diff --git a/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx b/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx index 3cc1cff4..7ca5b604 100644 --- a/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx +++ b/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx @@ -1,9 +1,11 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {cleanup, render, waitFor} from '@testing-library/react'; +import {cleanup, render, screen, waitFor} from '@testing-library/react'; +import {ReactElement} from 'react'; import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import ThunderIDProvider from '../ThunderIDProvider'; +import useThunderID from '../useThunderID'; type Noop = Mock<() => void>; type SignInHookHandler = () => Promise; @@ -249,3 +251,26 @@ describe('ThunderIDProvider updateSession single-flight', () => { expect(mocks.getUsersMe).toHaveBeenCalledTimes(2); }); }); + +describe('ThunderIDProvider in backend mode', () => { + it('initializes the client with the backend as baseUrl and builds API requests on apiBaseUrl', async () => { + function ReadBaseUrl(): ReactElement { + return
{useThunderID().baseUrl}
; + } + + render( + + + , + ); + + await waitFor(() => expect(mocks.client.initialize).toHaveBeenCalled()); + expect(mocks.client.initialize.mock.calls[0][0]).toMatchObject({baseUrl: 'https://app.example.com'}); + expect(screen.getByTestId('base-url').textContent).toBe('https://api.example.com'); + }); +}); diff --git a/packages/vue/src/ThunderIDVueClient.ts b/packages/vue/src/ThunderIDVueClient.ts index bf6d1baa..2d21e38c 100644 --- a/packages/vue/src/ThunderIDVueClient.ts +++ b/packages/vue/src/ThunderIDVueClient.ts @@ -22,6 +22,7 @@ import { EmbeddedSignUpFlowStatus, StorageManager, AuthClientConfig, + resolveApiBaseUrl, resolveResourceEndpoint, } from '@thunderid/browser'; import getUsersMe from './api/account/getUsersMe'; @@ -89,7 +90,7 @@ class ThunderIDVueClient exte const opts: {baseUrl?: string; url?: string} | undefined = typeof options === 'object' && options !== null ? options : undefined; const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); - const baseUrl: string | undefined = opts?.baseUrl ?? configData?.baseUrl; + const baseUrl: string | undefined = opts?.baseUrl ?? resolveApiBaseUrl(configData); const profile: User = await getUsersMe({ baseUrl, @@ -114,7 +115,7 @@ class ThunderIDVueClient exte return this.withLoading(async () => { try { const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); - const baseUrl: string | undefined = options?.baseUrl ?? configData?.baseUrl; + const baseUrl: string | undefined = options?.baseUrl ?? resolveApiBaseUrl(configData); const profile: User = await getUsersMe({ baseUrl, diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts index a86328c5..2c8e6a72 100644 --- a/packages/vue/src/models/contexts.ts +++ b/packages/vue/src/models/contexts.ts @@ -34,7 +34,10 @@ export interface ThunderIDContext { afterSignInUrl: string | undefined; /** The ThunderID application ID from the config. */ applicationId: string | undefined; - /** The base URL of the ThunderID tenant. */ + /** + * The base URL the SDK's API requests are built on: the ThunderID tenant, or in backend mode + * `endpoints.backend.apiBaseUrl` (falling back to `baseUrl`, the backend). + */ baseUrl: string | undefined; /** * Optional endpoint URL overrides from the config. Used to resolve resource-server endpoints diff --git a/packages/vue/src/providers/ThunderIDProvider.ts b/packages/vue/src/providers/ThunderIDProvider.ts index 4918a2ff..4748a0f3 100644 --- a/packages/vue/src/providers/ThunderIDProvider.ts +++ b/packages/vue/src/providers/ThunderIDProvider.ts @@ -19,6 +19,7 @@ import { EmbeddedSignInFlowResponse, Preferences, getVendorPrefix, + resolveApiBaseUrl, resolveResourceEndpoint, SPAUtils, } from '@thunderid/browser'; @@ -216,7 +217,10 @@ const ThunderIDProvider: Component = defineComponent({ const user: ShallowRef = shallowRef(null); const userProfile: ShallowRef = shallowRef(null); const userSchema: Ref | null> = ref | null>(null); - const resolvedBaseUrl: Ref = ref(props.baseUrl); + // The base the SDK's API requests are built on. In backend mode `baseUrl` is the backend, and the + // API is under `endpoints.backend.apiBaseUrl`. + const apiBaseUrl: string = resolveApiBaseUrl({baseUrl: props.baseUrl, endpoints: props.endpoints}) ?? ''; + const resolvedBaseUrl: Ref = ref(apiBaseUrl); let isUpdatingSession = false; let signInCheckInterval: ReturnType | undefined; @@ -254,7 +258,7 @@ const ThunderIDProvider: Component = defineComponent({ const decodedToken: IdToken = await client.getDecodedIdToken(); if (decodedToken?.['user_org']) { - baseUrl = `${(await client.getConfiguration()).baseUrl}/o`; + baseUrl = `${resolveApiBaseUrl(await client.getConfiguration()) ?? ''}/o`; resolvedBaseUrl.value = baseUrl; } @@ -370,7 +374,7 @@ const ThunderIDProvider: Component = defineComponent({ const context: ThunderIDContext = { afterSignInUrl: props.afterSignInUrl, applicationId: props.applicationId, - baseUrl: props.baseUrl, + baseUrl: apiBaseUrl, endpoints: props.endpoints, clearSession: async (...args: any[]): Promise => { await client.clearSession(...args);