Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/browser/src/BackendClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}): Pick<RequestInit, 'credentials'> & {
headers: Record<string, string>;
} {
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<BackendSession> {
Expand Down
4 changes: 3 additions & 1 deletion packages/browser/src/ThunderIDBrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
generateFlattenedUserProfile,
getTokenPermissions,
getUsersMe,
resolveApiBaseUrl,
resolveBackendEndpoints,
resolveResourceEndpoint,
} from '@thunderid/javascript';
Expand Down Expand Up @@ -602,7 +603,8 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> 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),
});
Expand Down
10 changes: 5 additions & 5 deletions packages/browser/src/__tests__/BackendClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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');
});

Expand Down Expand Up @@ -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"}');
Expand Down Expand Up @@ -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({
Expand All @@ -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');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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<any>(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();
Expand Down Expand Up @@ -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`);
});
});

Expand Down
8 changes: 6 additions & 2 deletions packages/javascript/src/ThunderIDJavaScriptClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,11 +430,15 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {

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;
Expand Down
1 change: 1 addition & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
27 changes: 19 additions & 8 deletions packages/javascript/src/models/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand All @@ -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`.
Expand Down Expand Up @@ -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<Omit<BackendEndpoints, 'bypass'>> & {
apiBaseUrl: string;
bypass: Required<BackendBypass>;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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', () => {
Expand All @@ -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 = {
Expand Down
32 changes: 32 additions & 0 deletions packages/javascript/src/utils/resolveApiBaseUrl.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading