From e66b4095cf343765eb935e31fe028f997b00e91d Mon Sep 17 00:00:00 2001 From: Brion Date: Fri, 30 Oct 2026 17:22:57 +0530 Subject: [PATCH] BFF: send API calls through the backend's proxy, and drop apiBaseUrl In backend mode the backend maps its proxy to the API from its own configuration, so the app doesn't need the API's address. endpoints.backend.apiBaseUrl and resolveApiBaseUrl() are removed. - The SDK builds its own API requests, such as /users/me and the management APIs, on the backend's proxy, resolved against baseUrl: resolveRequestBaseUrl() returns it in backend mode, and baseUrl otherwise. The React and Vue context baseUrl, which the account and management hooks build on, follows it. - BackendClient.toProxyUrl sends a URL already on the proxy as it is, as a path or a full URL, with no second prefix. Any other path goes under the proxy, and any other absolute URL is refused. The bypass is unchanged. Signed-off-by: Brion --- packages/browser/src/BackendClient.ts | 30 +++++++------ .../browser/src/ThunderIDBrowserClient.ts | 6 +-- .../src/__tests__/BackendClient.test.ts | 39 +++++++++++----- .../ThunderIDBrowserClient.backend.test.ts | 16 +++---- packages/javascript/src/index.ts | 2 +- packages/javascript/src/models/backend.ts | 16 +++---- .../__tests__/resolveBackendEndpoints.test.ts | 44 ++++++++----------- .../javascript/src/utils/resolveApiBaseUrl.ts | 32 -------------- .../src/utils/resolveBackendEndpoints.ts | 15 +++---- .../src/utils/resolveRequestBaseUrl.ts | 31 +++++++++++++ .../contexts/ThunderID/ThunderIDContext.ts | 4 +- .../contexts/ThunderID/ThunderIDProvider.tsx | 17 ++++--- .../__tests__/ThunderIDProvider.test.tsx | 6 +-- packages/vue/src/ThunderIDVueClient.ts | 6 +-- packages/vue/src/models/contexts.ts | 4 +- .../vue/src/providers/ThunderIDProvider.ts | 13 +++--- 16 files changed, 143 insertions(+), 138 deletions(-) delete mode 100644 packages/javascript/src/utils/resolveApiBaseUrl.ts create mode 100644 packages/javascript/src/utils/resolveRequestBaseUrl.ts diff --git a/packages/browser/src/BackendClient.ts b/packages/browser/src/BackendClient.ts index 81ee5d66..00b5742b 100644 --- a/packages/browser/src/BackendClient.ts +++ b/packages/browser/src/BackendClient.ts @@ -208,24 +208,28 @@ class BackendClient { } /** - * Maps an API URL to its backend proxy URL. + * Maps an API URL to its backend proxy URL. A URL already on the proxy is sent as it is, and any + * other path (`/users`) goes under the proxy. The backend, not the app, knows the API's address. * - * @returns The proxy URL, or `undefined` when the URL is not under the API base and so cannot be - * sent with the session's credentials. + * @returns The proxy URL, or `undefined` for an absolute URL that isn't on the proxy and so cannot + * be sent with the session's credentials. */ toProxyUrl(url: string): string | undefined { - const {apiBaseUrl, proxy} = this.endpoints; - let rest: string; - - if (url === apiBaseUrl || url.startsWith(`${apiBaseUrl}/`) || url.startsWith(`${apiBaseUrl}?`)) { - rest = url.slice(apiBaseUrl.length); - } else if (url.startsWith('/') && !url.startsWith('//')) { - rest = url; - } else { - return undefined; + const {proxy} = this.endpoints; + const origin: string = typeof window !== 'undefined' ? window.location.origin : ''; + const isPath = (value: string): boolean => value.startsWith('/') && !value.startsWith('//'); + const isUnder = (base: string): boolean => url === base || url.startsWith(`${base}/`) || url.startsWith(`${base}?`); + // The proxy as a full URL and, when it's on this origin, as a path too. + const absoluteProxy: string = isPath(proxy) ? `${origin}${proxy}` : proxy; + const proxyPath: string | undefined = absoluteProxy.startsWith(`${origin}/`) + ? absoluteProxy.slice(origin.length) + : undefined; + + if (isUnder(proxy) || isUnder(absoluteProxy) || (proxyPath !== undefined && isUnder(proxyPath))) { + return url; } - return `${proxy}${rest.startsWith('/') ? rest : `/${rest}`}`; + return isPath(url) ? `${proxy}${url}` : undefined; } /** diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 6d3ecdb4..9f9ddef0 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -26,7 +26,7 @@ import { generateFlattenedUserProfile, getTokenPermissions, getUsersMe, - resolveApiBaseUrl, + resolveRequestBaseUrl, resolveBackendEndpoints, resolveResourceEndpoint, } from '@thunderid/javascript'; @@ -603,8 +603,8 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC const configData = await this.getStorageManager().getConfigData(); const profile: User = await getUsersMe({ - // In backend mode `baseUrl` is the backend; the API is under `endpoints.backend.apiBaseUrl`. - baseUrl: options?.baseUrl ?? resolveApiBaseUrl(configData as any), + // In backend mode the API is reached through the backend's proxy. + baseUrl: options?.baseUrl ?? resolveRequestBaseUrl(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 83df7237..8219dd41 100644 --- a/packages/browser/src/__tests__/BackendClient.test.ts +++ b/packages/browser/src/__tests__/BackendClient.test.ts @@ -8,7 +8,6 @@ import BackendClient, {BACKEND_REQUEST_HEADER} from '../BackendClient'; import BackendHttpClient from '../BackendHttpClient'; const ENDPOINTS: ResolvedBackendEndpoints = { - apiBaseUrl: 'https://auth.example.com', bypass: {attachToken: true, urls: ['https://dp.example.com/api']}, checkSession: '/api/auth/checksession', proxy: '/api/auth/proxy', @@ -95,16 +94,36 @@ describe('BackendClient', () => { expect((await backend.getSession()).isSignedIn).toBe(false); }); - it('maps API URLs to the proxy and refuses other origins', () => { + it('sends paths through the proxy and refuses other absolute URLs', () => { const backend = new BackendClient(ENDPOINTS); - expect(backend.toProxyUrl('https://auth.example.com/users?limit=5')).toBe('/api/auth/proxy/users?limit=5'); - expect(backend.toProxyUrl('https://auth.example.com')).toBe('/api/auth/proxy/'); expect(backend.toProxyUrl('/applications')).toBe('/api/auth/proxy/applications'); - expect(backend.toProxyUrl('https://auth.example.com.evil.com/users')).toBeUndefined(); + expect(backend.toProxyUrl('/users?limit=5')).toBe('/api/auth/proxy/users?limit=5'); + expect(backend.toProxyUrl('https://auth.example.com/users')).toBeUndefined(); expect(backend.toProxyUrl('https://other.example.com/users')).toBeUndefined(); expect(backend.toProxyUrl('//other.example.com/users')).toBeUndefined(); }); + + it('sends a URL already on the proxy as it is, without a second prefix', () => { + const backend = new BackendClient(ENDPOINTS); + const {origin} = window.location; + + expect(backend.toProxyUrl('/api/auth/proxy/users')).toBe('/api/auth/proxy/users'); + expect(backend.toProxyUrl('/api/auth/proxy')).toBe('/api/auth/proxy'); + expect(backend.toProxyUrl(`${origin}/api/auth/proxy/users?x=1`)).toBe(`${origin}/api/auth/proxy/users?x=1`); + // Only the proxy itself, not a path that merely starts with the same characters. + expect(backend.toProxyUrl('/api/auth/proxyish/users')).toBe('/api/auth/proxy/api/auth/proxyish/users'); + }); + + it('recognises an absolute proxy on the backend, by its full URL', () => { + const backend = new BackendClient({...ENDPOINTS, proxy: 'https://bff.example.com/gateway/api'}); + + expect(backend.toProxyUrl('https://bff.example.com/gateway/api/users')).toBe( + 'https://bff.example.com/gateway/api/users', + ); + expect(backend.toProxyUrl('/users')).toBe('https://bff.example.com/gateway/api/users'); + expect(backend.toProxyUrl('https://bff.example.com/other')).toBeUndefined(); + }); }); describe('BackendHttpClient', () => { @@ -127,7 +146,7 @@ describe('BackendHttpClient', () => { data: {name: 'a'}, headers: {'Content-Type': 'application/json'}, method: 'POST', - url: 'https://auth.example.com/users', + url: '/users', }); expect(response.status).toBe(201); @@ -147,7 +166,7 @@ describe('BackendHttpClient', () => { .mockResolvedValueOnce(json(200, {ok: true})); const client = new BackendHttpClient(new BackendClient(ENDPOINTS), noToken); - const response = await client.request({url: 'https://auth.example.com/users'}); + const response = await client.request({url: '/users'}); expect(response.data).toEqual({ok: true}); expect(fetchMock).toHaveBeenCalledTimes(3); @@ -157,7 +176,7 @@ describe('BackendHttpClient', () => { fetchMock.mockResolvedValueOnce(json(401, {code: 'unauthorized'})); const client = new BackendHttpClient(new BackendClient(ENDPOINTS), noToken); - await expect(client.request({url: 'https://auth.example.com/users'})).rejects.toMatchObject({ + await expect(client.request({url: '/users'})).rejects.toMatchObject({ response: {status: 401}, }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -167,12 +186,12 @@ describe('BackendHttpClient', () => { fetchMock.mockResolvedValueOnce(json(401, {error: 'session_expired'})).mockResolvedValueOnce(json(401, {})); const client = new BackendHttpClient(new BackendClient(ENDPOINTS), noToken); - await expect(client.request({url: 'https://auth.example.com/users'})).rejects.toMatchObject({ + await expect(client.request({url: '/users'})).rejects.toMatchObject({ response: {status: 401}, }); }); - it('refuses URLs outside the API base', async () => { + it('refuses absolute URLs that are not on the proxy', async () => { const client = new BackendHttpClient(new BackendClient(ENDPOINTS), noToken); await expect(client.request({url: 'https://other.example.com/x'})).rejects.toMatchObject({ diff --git a/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts b/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts index 2b006afc..4a111951 100644 --- a/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts +++ b/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts @@ -10,14 +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`. +// In backend mode `baseUrl` is the backend, and the API is reached through its proxy, which the +// backend maps to the API from its own configuration. const BFF = 'https://app.example.com'; const CONFIG = { baseUrl: BFF, clientId: 'my-app', - endpoints: {backend: {apiBaseUrl: 'https://auth.example.com', bypass: {urls: ['https://dp.example.com']}}}, + endpoints: {backend: {bypass: {urls: ['https://dp.example.com']}}}, storage: 'browserMemory', }; @@ -68,7 +68,7 @@ describe('ThunderIDBrowserClient with sign-in on the backend', () => { await client.on('sign-out', onSignOut); fetchMock.mockResolvedValueOnce(json(401, {error: 'login_required'})); - await expect(client.httpRequest({url: 'https://auth.example.com/users'})).rejects.toBeDefined(); + await expect(client.httpRequest({url: '/users'})).rejects.toBeDefined(); expect(onSignOut).toHaveBeenCalledTimes(1); expect(await client.isSignedIn()).toBe(false); @@ -82,7 +82,7 @@ describe('ThunderIDBrowserClient with sign-in on the backend', () => { it('sends httpRequest through the proxy', async () => { fetchMock.mockResolvedValue(json(200, {users: []})); - const response = await client.httpRequest({method: 'GET', url: 'https://auth.example.com/users'}); + const response = await client.httpRequest({method: 'GET', url: '/users'}); expect(response?.data).toEqual({users: []}); expect(fetchMock.mock.calls[0][0]).toBe(`${BFF}/api/auth/proxy/users`); @@ -118,7 +118,7 @@ describe('ThunderIDBrowserClient with sign-in on the backend', () => { FetchHttpClient.destroyInstance(8); }); - it('builds /users/me on the API base and sends it through the proxy', async () => { + it('builds /users/me on the proxy, resolved against baseUrl, without a second prefix', async () => { fetchMock.mockResolvedValueOnce(json(200, {id: 'u1', username: 'ada'})); await client.getUserProfile(); @@ -167,7 +167,7 @@ describe('ThunderIDBrowserClient with sign-in on the backend', () => { .mockResolvedValueOnce(json(200, {access_token: 't2', expires_in: 300})); expect(await client.getAccessToken()).toBe('t1'); - await expect(client.httpRequest({url: 'https://auth.example.com/users'})).rejects.toBeDefined(); + await expect(client.httpRequest({url: '/users'})).rejects.toBeDefined(); await vi.waitFor(async () => expect(await client.getAccessToken()).toBe('t2')); }); @@ -176,7 +176,7 @@ describe('ThunderIDBrowserClient with sign-in on the backend', () => { const onError = vi.fn(); await client.on('http-request-error', onError); - await expect(client.httpRequest({url: 'https://auth.example.com/users'})).rejects.toBeDefined(); + await expect(client.httpRequest({url: '/users'})).rejects.toBeDefined(); expect(onError).toHaveBeenCalledTimes(1); }); diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index cc4a9145..6e941c57 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -912,7 +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 resolveRequestBaseUrl} from './utils/resolveRequestBaseUrl'; 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 55cecdee..9880f81c 100644 --- a/packages/javascript/src/models/backend.ts +++ b/packages/javascript/src/models/backend.ts @@ -16,7 +16,7 @@ * * @example * baseUrl: 'https://app.example.com', - * endpoints: {backend: {signIn: '/api/auth/signin', apiBaseUrl: 'https://api.example.com'}} + * endpoints: {backend: {signIn: '/api/auth/signin', proxy: '/api/auth/proxy'}} */ export interface BackendEndpoints { /** Starts sign-in. Defaults to `/api/auth/signin`. */ @@ -33,16 +33,12 @@ export interface BackendEndpoints { /** Ends the session. Defaults to `/api/auth/signout`. */ signOut?: string; /** - * Prefix of the API proxy. Requests under `apiBaseUrl` are sent through it. Defaults to - * `/api/auth/proxy`. + * The backend's API proxy, which forwards to the API with the access token. The backend maps it to + * the API from its own configuration, so the app never needs the API's address. The SDK builds its + * API requests, such as `/users/me` and the management APIs, on it, and sends a relative request + * (`/users`) to 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`. @@ -70,7 +66,7 @@ export interface BackendBypass { } /** - * The backend endpoints, fully resolved, with the API base whose requests go through the proxy. + * The backend endpoints, fully resolved. */ export type ResolvedBackendEndpoints = Required> & { bypass: Required; diff --git a/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts b/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts index 54dc006e..04693d0b 100644 --- a/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts +++ b/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts @@ -4,8 +4,8 @@ /* 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'; +import resolveRequestBaseUrl from '../resolveRequestBaseUrl'; const ORIGIN = 'https://app.example.com'; @@ -15,9 +15,8 @@ describe('resolveBackendEndpoints', () => { expect(resolveBackendEndpoints(undefined, ORIGIN)).toBeUndefined(); }); - it('defaults every unset endpoint under /api/auth and the API base to the app origin', () => { + it('defaults every unset endpoint under /api/auth', () => { expect(resolveBackendEndpoints({endpoints: {backend: {}}}, ORIGIN)).toEqual({ - apiBaseUrl: ORIGIN, bypass: {attachToken: true, urls: []}, checkSession: '/api/auth/checksession', proxy: '/api/auth/proxy', @@ -29,7 +28,7 @@ describe('resolveBackendEndpoints', () => { }); }); - it('resolves endpoint paths against baseUrl, the backend, and uses it as the API base by default', () => { + it('resolves endpoint paths, the proxy included, against baseUrl, the backend', () => { const resolved = resolveBackendEndpoints( { baseUrl: 'https://bff.example.com/', @@ -42,7 +41,6 @@ describe('resolveBackendEndpoints', () => { 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', () => { @@ -63,17 +61,7 @@ describe('resolveBackendEndpoints', () => { const resolved = resolveBackendEndpoints({baseUrl: '', endpoints: {backend: {session: '/gateway/me'}}}, ORIGIN); expect(resolved?.session).toBe('/gateway/me'); - 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'); + expect(resolved?.proxy).toBe('/api/auth/proxy'); }); it('applies the token endpoint and the bypass, trimming its URLs', () => { @@ -91,22 +79,26 @@ describe('resolveBackendEndpoints', () => { }); }); -describe('resolveApiBaseUrl', () => { - it('is endpoints.backend.apiBaseUrl in backend mode, falling back to baseUrl', () => { +describe('resolveRequestBaseUrl', () => { + it("is the backend's proxy, resolved against baseUrl, in backend mode", () => { + expect( + resolveRequestBaseUrl({baseUrl: 'https://bff.example.com', endpoints: {backend: {proxy: '/gateway/api'}}}), + ).toBe('https://bff.example.com/gateway/api'); + expect(resolveRequestBaseUrl({baseUrl: 'https://bff.example.com', endpoints: {backend: {}}})).toBe( + 'https://bff.example.com/api/auth/proxy', + ); + expect(resolveRequestBaseUrl({endpoints: {backend: {proxy: '/gateway/api'}}})).toBe('/gateway/api'); expect( - resolveApiBaseUrl({ + resolveRequestBaseUrl({ baseUrl: 'https://bff.example.com', - endpoints: {backend: {apiBaseUrl: 'https://api.example.com'}}, + endpoints: {backend: {proxy: 'https://api.example.com/p'}}, }), - ).toBe('https://api.example.com'); - expect(resolveApiBaseUrl({baseUrl: 'https://bff.example.com', endpoints: {backend: {}}})).toBe( - 'https://bff.example.com', - ); + ).toBe('https://api.example.com/p'); }); it('is baseUrl outside backend mode', () => { - expect(resolveApiBaseUrl({baseUrl: 'https://auth.example.com'})).toBe('https://auth.example.com'); - expect(resolveApiBaseUrl(undefined)).toBeUndefined(); + expect(resolveRequestBaseUrl({baseUrl: 'https://auth.example.com'})).toBe('https://auth.example.com'); + expect(resolveRequestBaseUrl(undefined)).toBeUndefined(); }); }); diff --git a/packages/javascript/src/utils/resolveApiBaseUrl.ts b/packages/javascript/src/utils/resolveApiBaseUrl.ts deleted file mode 100644 index 07c6fd31..00000000 --- a/packages/javascript/src/utils/resolveApiBaseUrl.ts +++ /dev/null @@ -1,32 +0,0 @@ -// 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 e1d822d5..a3b255e9 100644 --- a/packages/javascript/src/utils/resolveBackendEndpoints.ts +++ b/packages/javascript/src/utils/resolveBackendEndpoints.ts @@ -1,7 +1,6 @@ // 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'; @@ -20,22 +19,23 @@ export interface BackendEndpointConfig { * * 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. + * relative to the app's origin. The API's own address is the backend's to know: the app reaches it + * only through `proxy`. * * @param config - The client config. - * @param origin - The app's origin, the API base when neither `apiBaseUrl` nor `baseUrl` is set. + * @param _origin - Unused; kept so existing callers don't change. * @returns The resolved endpoints, or `undefined` when `endpoints.backend` is not set. * * @example * ```typescript * resolveBackendEndpoints({baseUrl: 'https://bff.example.com', endpoints: {backend: {}}}, window.location.origin); - * // {signIn: 'https://bff.example.com/api/auth/signin', ..., apiBaseUrl: 'https://bff.example.com'} + * // {signIn: 'https://bff.example.com/api/auth/signin', ..., proxy: 'https://bff.example.com/api/auth/proxy'} * ``` */ const resolveBackendEndpoints = ( config: BackendEndpointConfig | undefined, - origin: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _origin?: string, ): ResolvedBackendEndpoints | undefined => { const endpoints: BackendEndpoints | undefined = config?.endpoints?.backend; @@ -51,9 +51,6 @@ const resolveBackendEndpoints = ( }; return { - // `||`, 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: (resolveApiBaseUrl(config) || origin).replace(/\/+$/, ''), bypass: { attachToken: endpoints.bypass?.attachToken ?? true, urls: (endpoints.bypass?.urls ?? []).map((url: string) => url.replace(/\/+$/, '')), diff --git a/packages/javascript/src/utils/resolveRequestBaseUrl.ts b/packages/javascript/src/utils/resolveRequestBaseUrl.ts new file mode 100644 index 00000000..bdaee716 --- /dev/null +++ b/packages/javascript/src/utils/resolveRequestBaseUrl.ts @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import resolveBackendEndpoints, {type BackendEndpointConfig} from './resolveBackendEndpoints'; + +/** + * Resolves the base URL the SDK builds its own API requests on, such as `/users/me` and the + * management APIs. + * + * When sign-in runs on a backend (`endpoints.backend`), that's the backend's proxy, resolved against + * `baseUrl` (the backend): the backend maps it to the API from its own configuration, so the app never + * needs the API's address. Otherwise it's `baseUrl`. + * + * @param config - The client config. + * @returns The base URL, or `undefined` when none is configured. + * + * @example + * ```typescript + * resolveRequestBaseUrl({baseUrl: 'https://bff.example.com', endpoints: {backend: {proxy: '/api'}}}); + * // 'https://bff.example.com/api' + * ``` + */ +const resolveRequestBaseUrl = (config: BackendEndpointConfig | undefined): string | undefined => { + if (config?.endpoints?.backend) { + return resolveBackendEndpoints(config, '')?.proxy; + } + + return config?.baseUrl; +}; + +export default resolveRequestBaseUrl; diff --git a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts index 7117b0c6..276eff3f 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts +++ b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts @@ -30,8 +30,8 @@ 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). + * The base URL the SDK's API requests are built on: the ThunderID tenant, or in backend mode the + * backend's proxy (`endpoints.backend.proxy`, resolved against `baseUrl`). */ 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 5b53b5a3..c98828c2 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -17,7 +17,7 @@ import { EmbeddedRecoveryFlowResponse, createPackageComponentLogger, getVendorPrefix, - resolveApiBaseUrl, + resolveRequestBaseUrl, resolveResourceEndpoint, AttributeSchema, SPAUtils, @@ -87,10 +87,9 @@ const ThunderIDProvider: FC> = ({ const [userProfile, setUserProfile] = useState(null); const [userSchema, setUserSchema] = useState | null>(null); - // 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); + // The base the SDK's API requests are built on. In backend mode it's the backend's proxy. + const requestBaseUrl: string = resolveRequestBaseUrl({baseUrl: initialBaseUrl, endpoints: rest.endpoints}) ?? ''; + const [baseUrl, setBaseUrl] = useState(requestBaseUrl); const [config, setConfig] = useState({ afterSignInUrl: afterSignInUrl ?? window.location.origin, afterSignOutUrl: afterSignOutUrl ?? window.location.origin, @@ -116,8 +115,8 @@ const ThunderIDProvider: FC> = ({ const pendingSessionUpdateRef: RefObject | null> = useRef | null>(null); useEffect(() => { - setBaseUrl(apiBaseUrl); - }, [apiBaseUrl]); + setBaseUrl(requestBaseUrl); + }, [requestBaseUrl]); useEffect(() => { (async (): Promise => { @@ -140,7 +139,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 = `${resolveApiBaseUrl(await client.getConfiguration()) ?? ''}/o`; + resolvedBaseUrl = `${resolveRequestBaseUrl(await client.getConfiguration()) ?? ''}/o`; setBaseUrl(resolvedBaseUrl); } @@ -500,7 +499,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(resolveApiBaseUrl(reinitializedConfig) ?? ''); + setBaseUrl(resolveRequestBaseUrl(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 7ca5b604..c31a5fab 100644 --- a/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx +++ b/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx @@ -253,7 +253,7 @@ describe('ThunderIDProvider updateSession single-flight', () => { }); describe('ThunderIDProvider in backend mode', () => { - it('initializes the client with the backend as baseUrl and builds API requests on apiBaseUrl', async () => { + it('initializes the client with the backend as baseUrl and builds API requests on its proxy', async () => { function ReadBaseUrl(): ReactElement { return
{useThunderID().baseUrl}
; } @@ -262,7 +262,7 @@ describe('ThunderIDProvider in backend mode', () => { @@ -271,6 +271,6 @@ describe('ThunderIDProvider in backend mode', () => { 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'); + expect(screen.getByTestId('base-url').textContent).toBe('https://app.example.com/gateway/api'); }); }); diff --git a/packages/vue/src/ThunderIDVueClient.ts b/packages/vue/src/ThunderIDVueClient.ts index 2d21e38c..aebb113a 100644 --- a/packages/vue/src/ThunderIDVueClient.ts +++ b/packages/vue/src/ThunderIDVueClient.ts @@ -22,7 +22,7 @@ import { EmbeddedSignUpFlowStatus, StorageManager, AuthClientConfig, - resolveApiBaseUrl, + resolveRequestBaseUrl, resolveResourceEndpoint, } from '@thunderid/browser'; import getUsersMe from './api/account/getUsersMe'; @@ -90,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 ?? resolveApiBaseUrl(configData); + const baseUrl: string | undefined = opts?.baseUrl ?? resolveRequestBaseUrl(configData); const profile: User = await getUsersMe({ baseUrl, @@ -115,7 +115,7 @@ class ThunderIDVueClient exte return this.withLoading(async () => { try { const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); - const baseUrl: string | undefined = options?.baseUrl ?? resolveApiBaseUrl(configData); + const baseUrl: string | undefined = options?.baseUrl ?? resolveRequestBaseUrl(configData); const profile: User = await getUsersMe({ baseUrl, diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts index 2c8e6a72..958db8ad 100644 --- a/packages/vue/src/models/contexts.ts +++ b/packages/vue/src/models/contexts.ts @@ -35,8 +35,8 @@ export interface ThunderIDContext { /** The ThunderID application ID from the config. */ 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). + * The base URL the SDK's API requests are built on: the ThunderID tenant, or in backend mode the + * backend's proxy (`endpoints.backend.proxy`, resolved against `baseUrl`). */ baseUrl: string | undefined; /** diff --git a/packages/vue/src/providers/ThunderIDProvider.ts b/packages/vue/src/providers/ThunderIDProvider.ts index 4748a0f3..dc34ca90 100644 --- a/packages/vue/src/providers/ThunderIDProvider.ts +++ b/packages/vue/src/providers/ThunderIDProvider.ts @@ -19,7 +19,7 @@ import { EmbeddedSignInFlowResponse, Preferences, getVendorPrefix, - resolveApiBaseUrl, + resolveRequestBaseUrl, resolveResourceEndpoint, SPAUtils, } from '@thunderid/browser'; @@ -217,10 +217,9 @@ const ThunderIDProvider: Component = defineComponent({ const user: ShallowRef = shallowRef(null); const userProfile: ShallowRef = shallowRef(null); const userSchema: Ref | null> = ref | null>(null); - // 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); + // The base the SDK's API requests are built on. In backend mode it's the backend's proxy. + const requestBaseUrl: string = resolveRequestBaseUrl({baseUrl: props.baseUrl, endpoints: props.endpoints}) ?? ''; + const resolvedBaseUrl: Ref = ref(requestBaseUrl); let isUpdatingSession = false; let signInCheckInterval: ReturnType | undefined; @@ -258,7 +257,7 @@ const ThunderIDProvider: Component = defineComponent({ const decodedToken: IdToken = await client.getDecodedIdToken(); if (decodedToken?.['user_org']) { - baseUrl = `${resolveApiBaseUrl(await client.getConfiguration()) ?? ''}/o`; + baseUrl = `${resolveRequestBaseUrl(await client.getConfiguration()) ?? ''}/o`; resolvedBaseUrl.value = baseUrl; } @@ -374,7 +373,7 @@ const ThunderIDProvider: Component = defineComponent({ const context: ThunderIDContext = { afterSignInUrl: props.afterSignInUrl, applicationId: props.applicationId, - baseUrl: apiBaseUrl, + baseUrl: requestBaseUrl, endpoints: props.endpoints, clearSession: async (...args: any[]): Promise => { await client.clearSession(...args);