From 4b21aa37f6813a7e1abe74f5e74f9679529944ca Mon Sep 17 00:00:00 2001 From: Brion Date: Fri, 30 Oct 2026 14:24:26 +0530 Subject: [PATCH] BFF: baseUrl is the backend In backend mode (endpoints.backend) the top-level baseUrl is now the backend, the BFF. It defaults to the page's origin. Every endpoint in endpoints.backend given as a path is resolved against it, and an absolute URL is used as it is, so pointing an app at a BFF is one setting. The API behind the proxy moves to the new endpoints.backend.apiBaseUrl, which defaults to baseUrl. Requests under it are sent through the proxy, and the SDK builds its own API requests on it (resolveApiBaseUrl). That covers the browser client's and the Vue client's /users/me, and the baseUrl the React and Vue providers put in their context, which the account and management components and hooks build their URLs on. The client itself is still initialized with the real baseUrl. Backend requests send cookies with credentials 'include', so a BFF on another origin works when its CORS allows it, though RFC 10017 recommends the app's own origin. OIDC discovery is skipped in backend mode unless endpoints.wellKnown is set, since the backend runs OAuth itself. Signed-off-by: Brion --- packages/browser/src/BackendClient.ts | 5 +- .../browser/src/ThunderIDBrowserClient.ts | 4 +- .../src/__tests__/BackendClient.test.ts | 10 +-- .../ThunderIDBrowserClient.backend.test.ts | 34 ++++++++-- .../src/ThunderIDJavaScriptClient.ts | 8 ++- packages/javascript/src/index.ts | 1 + packages/javascript/src/models/backend.ts | 27 +++++--- .../__tests__/resolveBackendEndpoints.test.ts | 63 +++++++++++++++++-- .../javascript/src/utils/resolveApiBaseUrl.ts | 32 ++++++++++ .../src/utils/resolveBackendEndpoints.ts | 37 +++++++---- .../contexts/ThunderID/ThunderIDContext.ts | 4 ++ .../contexts/ThunderID/ThunderIDProvider.tsx | 16 +++-- .../__tests__/ThunderIDProvider.test.tsx | 27 +++++++- packages/vue/src/ThunderIDVueClient.ts | 5 +- packages/vue/src/models/contexts.ts | 5 +- .../vue/src/providers/ThunderIDProvider.ts | 10 ++- 16 files changed, 233 insertions(+), 55 deletions(-) create mode 100644 packages/javascript/src/utils/resolveApiBaseUrl.ts 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);