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
30 changes: 17 additions & 13 deletions packages/browser/src/BackendClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/browser/src/ThunderIDBrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
generateFlattenedUserProfile,
getTokenPermissions,
getUsersMe,
resolveApiBaseUrl,
resolveRequestBaseUrl,
resolveBackendEndpoints,
resolveResourceEndpoint,
} from '@thunderid/javascript';
Expand Down Expand Up @@ -603,8 +603,8 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> 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),
});
Expand Down
39 changes: 29 additions & 10 deletions packages/browser/src/__tests__/BackendClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};

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

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

Expand Down
2 changes: 1 addition & 1 deletion packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
16 changes: 6 additions & 10 deletions packages/javascript/src/models/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand All @@ -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`.
Expand Down Expand Up @@ -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<Omit<BackendEndpoints, 'bypass'>> & {
bypass: Required<BackendBypass>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand All @@ -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/',
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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();
});
});

Expand Down
32 changes: 0 additions & 32 deletions packages/javascript/src/utils/resolveApiBaseUrl.ts

This file was deleted.

Loading
Loading