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);