From d2b8ec220fda46cef40e0395f79e42cb08422df1 Mon Sep 17 00:00:00 2001 From: Brion Date: Fri, 23 Oct 2026 15:24:33 +0530 Subject: [PATCH] Sign in through the app's backend in the browser SDKs Browser apps keep their tokens in browser storage, where any script on the page can read them. Setting `endpoints.backend` hands sign-in to a backend the app owns, on the app's own origin: the backend runs the OAuth flow, keeps the tokens, and proxies the app's API calls, so the browser only ever holds an `HttpOnly` cookie. It follows the Backend for Frontend pattern of RFC 10017. The backend is any server that implements the contract: `signIn`, `session`, `checkSession`, `refresh`, `signOut`, `proxy` and `token`, each defaulting to a path under `/api/auth`. The key stays out of the OIDC discovery metadata. `BackendClient` talks to the backend and `BackendHttpClient` is registered as the instance's HTTP client, so every `http.request` call goes through the proxy with the session cookie and an `X-Requested-With` header, and never with a token. A request to a URL outside the API base is refused rather than sent without credentials. `signIn`, `signOut`, `isSignedIn`, `getUser` and `getDecodedIdToken` go through the backend; `getIdToken`, `exchangeToken` and `revokeAccessToken` reject, because the browser holds no ID or refresh token. Some APIs cannot sit behind the proxy, such as a data plane on another origin. `endpoints.backend.bypass` lists their base URLs in `urls`. A request under one goes straight there with the cookies and, while `attachToken` is on (the default), `Authorization: Bearer`; on a 401 from the API it gets a new token and retries once. The token comes from the backend's `token` endpoint, as an OAuth token response without a refresh token, and is handled like a client credentials token in SPA mode: one per client, kept in the configured session storage with `created_at`, reused until it nears expiry, then requested again, with concurrent callers sharing one request. `getAccessToken` returns it. A `session_expired` from the token endpoint refreshes the session and asks again; `login_required` signs the app out. The stored token is removed on sign-out and when the session ends elsewhere. The client reports loading until the first session read settles, so route guards do not start a sign-in before it is known whether the user is signed in. The SDK refreshes only when the backend answers `401 session_expired`; any other `401` is the API's own answer and is returned as is. A session that ends elsewhere fires the `sign-out` hook: a `401 login_required` from the proxy, a check when the page regains focus, or polling `checkSession` every `session.checkInterval` seconds. The hook now also keeps its callbacks, so an emitted sign-out reaches them, and the React and Vue providers clear the user when it fires. A sign-out the app starts itself does not fire it. Apps that do not set `endpoints.backend` keep working exactly as today. Signed-off-by: Brion --- packages/browser/src/BackendClient.ts | 327 ++++++++++++++ packages/browser/src/BackendHttpClient.ts | 99 +++++ packages/browser/src/FetchHttpClient.ts | 4 + .../browser/src/ThunderIDBrowserClient.ts | 201 ++++++++- .../src/__tests__/BackendClient.test.ts | 412 ++++++++++++++++++ .../ThunderIDBrowserClient.backend.test.ts | 190 ++++++++ packages/browser/src/index.ts | 2 + packages/javascript/src/index.ts | 3 + packages/javascript/src/models/backend.ts | 77 ++++ packages/javascript/src/models/config.ts | 16 + .../src/utils/AuthenticationHelper.ts | 6 +- .../__tests__/resolveBackendEndpoints.test.ts | 76 ++++ .../src/utils/resolveBackendEndpoints.ts | 58 +++ packages/react/src/ThunderIDReactClient.ts | 23 +- .../contexts/ThunderID/ThunderIDProvider.tsx | 7 + .../vue/src/providers/ThunderIDProvider.ts | 7 + 16 files changed, 1503 insertions(+), 5 deletions(-) create mode 100644 packages/browser/src/BackendClient.ts create mode 100644 packages/browser/src/BackendHttpClient.ts create mode 100644 packages/browser/src/__tests__/BackendClient.test.ts create mode 100644 packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts create mode 100644 packages/javascript/src/models/backend.ts create mode 100644 packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts create mode 100644 packages/javascript/src/utils/resolveBackendEndpoints.ts diff --git a/packages/browser/src/BackendClient.ts b/packages/browser/src/BackendClient.ts new file mode 100644 index 00000000..c37945cc --- /dev/null +++ b/packages/browser/src/BackendClient.ts @@ -0,0 +1,327 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {BackendSession, ResolvedBackendEndpoints, SessionData, ThunderIDAuthException} from '@thunderid/javascript'; + +/** + * Header every backend request carries. A cross-origin page cannot set it without a CORS preflight, + * which the backend never answers, so it keeps the cookie from being used cross-site. + */ +export const BACKEND_REQUEST_HEADER = 'X-Requested-With'; + +const BACKEND_REQUEST_HEADER_VALUE = 'XMLHttpRequest'; + +/** A check on focus is skipped when the last one is more recent than this, in milliseconds. */ +const FOCUS_CHECK_THROTTLE = 5000; + +/** + * Talks to the app's backend when sign-in runs there: reads the session, refreshes it, and starts and ends it. The + * browser never holds a token; the backend keeps them behind an `HttpOnly` cookie. + */ +class BackendClient { + private session: Promise | undefined; + + private refreshing: Promise | undefined; + + private hasLoadedSession = false; + + /** Whether the session last seen was signed in, so a later read can tell it has ended. */ + private wasSignedIn = false; + + private lastCheck = 0; + + private readonly signedOutListeners: (() => void)[] = []; + + constructor(private readonly endpoints: ResolvedBackendEndpoints) {} + + /** + * Calls the listener when a signed-in session turns out to have ended without this client signing + * out: another app signed the user out, an administrator ended the session, or it timed out. + */ + onSignedOut(listener: () => void): void { + this.signedOutListeners.push(listener); + } + + /** + * Records that the session has ended, and tells the listeners when it had been signed in. + */ + markSignedOut(): void { + this.session = Promise.resolve({isSignedIn: false}); + this.hasLoadedSession = true; + + if (this.wasSignedIn) { + this.wasSignedIn = false; + this.signedOutListeners.forEach((listener: () => void) => listener()); + } + } + + /** + * Asks the backend whether the session is still live. The check neither refreshes the session nor + * counts as activity, so it can be polled without keeping an idle session alive. + * + * @returns Whether the session is live. A failed check reports the last known state. + */ + async checkSession(): Promise { + this.lastCheck = Date.now(); + + try { + const response: Response = await this.send(this.endpoints.checkSession, 'GET'); + if (!response.ok) { + return this.wasSignedIn; + } + const {isSignedIn} = (await response.json()) as {isSignedIn: boolean}; + if (!isSignedIn) { + this.markSignedOut(); + } + + return isSignedIn; + } catch { + return this.wasSignedIn; + } + } + + /** + * Watches for the session ending while the page is open: checks it whenever the page gets focus or + * becomes visible again, and every `pollSeconds` while it is visible when that is positive. + * + * @returns A function that stops watching. + */ + watch(pollSeconds: number): () => void { + const checkIfSignedIn = (): void => { + if (this.wasSignedIn && document.visibilityState === 'visible') { + void this.checkSession(); + } + }; + const onFocus = (): void => { + if (Date.now() - this.lastCheck >= FOCUS_CHECK_THROTTLE) { + checkIfSignedIn(); + } + }; + + document.addEventListener('visibilitychange', onFocus); + window.addEventListener('focus', onFocus); + const timer: ReturnType | undefined = + pollSeconds > 0 ? setInterval(checkIfSignedIn, pollSeconds * 1000) : undefined; + + return (): void => { + document.removeEventListener('visibilitychange', onFocus); + window.removeEventListener('focus', onFocus); + if (timer) { + clearInterval(timer); + } + }; + } + + /** + * Returns the session. The result is cached until a sign-in, sign-out, or refresh changes it, so + * repeated sign-in checks do not reach the server each time. + * + * @param reload - Read the session from the server even when one is cached. + */ + getSession(reload = false): Promise { + if (reload) { + this.session = undefined; + } + + this.session ??= this.loadSession() + .catch((error: unknown) => { + this.session = undefined; + throw error; + }) + .finally(() => { + this.hasLoadedSession = true; + }); + + return this.session; + } + + /** + * Whether this client talks to exactly these endpoints. + */ + hasEndpoints(endpoints: ResolvedBackendEndpoints): boolean { + return (Object.keys(endpoints) as (keyof ResolvedBackendEndpoints)[]).every( + (key: keyof ResolvedBackendEndpoints) => + key === 'bypass' + ? JSON.stringify(this.endpoints.bypass) === JSON.stringify(endpoints.bypass) + : this.endpoints[key] === endpoints[key], + ); + } + + /** + * Whether the first session read is still in flight, so it is not yet known whether the user is + * signed in. + */ + isLoading(): boolean { + return !this.hasLoadedSession; + } + + /** + * Refreshes the session once, sharing one request between concurrent callers. + * + * @returns Whether the session was refreshed. When it was not, the session is now signed out. + */ + refresh(): Promise { + this.refreshing ??= this.send(this.endpoints.refresh, 'POST') + .then((response: Response) => { + if (response.ok) { + this.session = undefined; + } else { + this.markSignedOut(); + } + + return response.ok; + }) + .finally(() => { + this.refreshing = undefined; + }); + + return this.refreshing; + } + + /** + * The URL that starts sign-in at the backend and comes back to `returnTo`. + */ + getSignInUrl(returnTo: string): string { + return `${this.endpoints.signIn}?${new URLSearchParams({returnTo}).toString()}`; + } + + /** + * Ends the backend session. + * + * @returns The URL the browser visits to end the session at the authorization server. + */ + async signOut(): Promise { + const response: Response = await this.send(this.endpoints.signOut, 'POST'); + // Signing out here is not news to anyone, so the listeners are not told. + this.wasSignedIn = false; + this.session = Promise.resolve({isSignedIn: false}); + + if (!response.ok) { + throw new ThunderIDAuthException( + 'SPA-BACKEND_CLIENT-SO-SE01', + 'Sign out failed.', + `The backend sign-out endpoint returned ${response.status}.`, + ); + } + + return ((await response.json()) as {redirectUrl: string}).redirectUrl; + } + + /** + * Maps an API URL to its backend proxy URL. + * + * @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. + */ + 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; + } + + return `${proxy}${rest.startsWith('/') ? rest : `/${rest}`}`; + } + + /** + * Whether a request to this URL skips the proxy and goes straight from the browser. + */ + isBypassed(url: string): boolean { + return this.endpoints.bypass.urls.some( + (base: string) => url === base || url.startsWith(`${base}/`) || url.startsWith(`${base}?`), + ); + } + + /** + * Whether requests that skip the proxy carry the access token. + */ + attachesTokenOnBypass(): boolean { + return this.endpoints.bypass.attachToken; + } + + /** + * Gets an access token for the requests that skip the proxy from the backend's token endpoint. It comes without a + * refresh token: when it expires, the caller asks again. + * + * @returns The token response, as the backend sent it. + */ + async requestAccessToken(): Promise> { + let response: Response = await this.send(this.endpoints.token, 'POST'); + + if (response.status === 401) { + const {error} = (await response.json().catch(() => ({}))) as {error?: string}; + if (error === 'session_expired' && (await this.refresh())) { + response = await this.send(this.endpoints.token, 'POST'); + } else if (error === 'login_required') { + this.markSignedOut(); + } + } + + if (!response.ok) { + throw new ThunderIDAuthException( + 'SPA-BACKEND_CLIENT-RAT-SE01', + 'Failed to get an access token.', + `The backend token endpoint returned ${response.status}.`, + ); + } + + return (await response.json()) as Partial; + } + + /** + * The headers and credentials mode every backend request is sent with. + */ + static requestInit(headers: Record = {}): Pick & { + headers: Record; + } { + return {credentials: 'same-origin', headers: {...headers, [BACKEND_REQUEST_HEADER]: BACKEND_REQUEST_HEADER_VALUE}}; + } + + private async loadSession(): Promise { + let response: Response = await this.send(this.endpoints.session, 'GET'); + + if (response.status === 401 && (await this.refresh())) { + response = await this.send(this.endpoints.session, 'GET'); + } + + if (response.status === 401) { + return this.signedOutSession(); + } + + if (!response.ok) { + throw new ThunderIDAuthException( + 'SPA-BACKEND_CLIENT-GS-SE01', + 'Failed to read the session.', + `The backend session endpoint returned ${response.status}.`, + ); + } + + const session: BackendSession = (await response.json()) as BackendSession; + if (!session.isSignedIn) { + return this.signedOutSession(); + } + this.wasSignedIn = true; + + return session; + } + + private signedOutSession(): BackendSession { + if (this.wasSignedIn) { + // Defer, so the listeners see this read's result in the cache rather than racing it. + queueMicrotask(() => this.markSignedOut()); + } + + return {isSignedIn: false}; + } + + private send(url: string, method: string): Promise { + return fetch(url, {method, ...BackendClient.requestInit({Accept: 'application/json'})}); + } +} + +export default BackendClient; diff --git a/packages/browser/src/BackendHttpClient.ts b/packages/browser/src/BackendHttpClient.ts new file mode 100644 index 00000000..b4819633 --- /dev/null +++ b/packages/browser/src/BackendHttpClient.ts @@ -0,0 +1,99 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {HttpError, HttpRequestConfig, HttpResponse} from '@thunderid/javascript'; +import BackendClient from './BackendClient'; +import FetchHttpClient from './FetchHttpClient'; + +/** + * The HTTP client used when sign-in runs on the backend. It sends each request through the backend's + * API proxy with the session cookie instead of attaching a token, and refreshes the session once + * when the backend reports + * it expired. Requests under `bypass` go straight there with the cookies and, unless turned off, the + * access token the backend issued. + */ +const hasBackendError = (error: HttpError | undefined, code: string): boolean => + error?.response?.status === 401 && (error.response.data as {error?: string} | undefined)?.error === code; + +const isSessionExpired = (error: HttpError | undefined): boolean => hasBackendError(error, 'session_expired'); + +class BackendHttpClient extends FetchHttpClient { + /** + * @param backend - The backend client. + * @param getAccessToken - Returns the access token for bypassed requests; `renew` asks for a new one. + */ + constructor( + private readonly backend: BackendClient, + private readonly getAccessToken: (renew: boolean) => Promise, + ) { + super(true); + } + + protected override async transport(config: HttpRequestConfig): Promise> { + if (this.backend.isBypassed(config.url ?? '')) { + return this.sendBypassed(config); + } + + const proxyUrl: string | undefined = this.backend.toProxyUrl(config.url ?? ''); + + if (!proxyUrl) { + throw Object.assign(new Error(`"${config.url}" is not under the backend API base, so it cannot be sent.`), { + code: 'BACKEND_UNSUPPORTED_URL', + config, + } as Partial); + } + + const send = (): Promise> => + super.transport({ + ...config, + ...BackendClient.requestInit(config.headers), + url: proxyUrl, + }); + + try { + return await send(); + } catch (error) { + // Only the backend's own `session_expired` calls for a refresh. Any other 401 is the API's answer. + if (isSessionExpired(error as HttpError) && (await this.backend.refresh())) { + return send(); + } + if (hasBackendError(error as HttpError, 'login_required')) { + this.backend.markSignedOut(); + } + + throw error; + } + } + + /** + * Sends a request straight to its URL, skipping the proxy, with the cookies and the access token. + * When the API rejects the token, it gets a new one and retries once. + */ + private async sendBypassed(config: HttpRequestConfig): Promise> { + if (!this.backend.attachesTokenOnBypass()) { + return super.transport({...config, credentials: 'include'}); + } + + const send = async (renew: boolean): Promise> => + super.transport({ + ...config, + credentials: 'include', + headers: { + ...config.headers, + Authorization: `Bearer ${await this.getAccessToken(renew)}`, + }, + }); + + try { + return await send(false); + } catch (error) { + if ((error as HttpError).response?.status === 401) { + return send(true); + } + + throw error; + } + } +} + +export default BackendHttpClient; diff --git a/packages/browser/src/FetchHttpClient.ts b/packages/browser/src/FetchHttpClient.ts index 06deb11a..39161a3d 100644 --- a/packages/browser/src/FetchHttpClient.ts +++ b/packages/browser/src/FetchHttpClient.ts @@ -25,6 +25,10 @@ export class FetchHttpClient extends HttpClient { return this.instances.get(instanceId)!; } + static setInstance(instanceId: number, client: FetchHttpClient): void { + this.instances.set(instanceId, client); + } + static destroyInstance(instanceId = 0): void { this.instances.delete(instanceId); } diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index a15a2c8e..e6115cd1 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -17,11 +17,18 @@ import { HttpRequestConfig, HttpResponse, UserProfile, + BackendEndpointConfig, + ResolvedBackendEndpoints, + SessionData, + TokenConstants, createPackageComponentLogger, generateFlattenedUserProfile, getUsersMe, + resolveBackendEndpoints, resolveResourceEndpoint, } from '@thunderid/javascript'; +import BackendClient from './BackendClient'; +import BackendHttpClient from './BackendHttpClient'; import Hooks from './constants/Hooks'; import {REFRESH_ACCESS_TOKEN_ERR0R, SILENT_SIGN_IN_STATE, TOKEN_REQUEST_CONFIG_KEY} from './constants/SPAConstants'; import FetchHttpClient from './FetchHttpClient'; @@ -73,6 +80,9 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC private _hookCallbacks = new Map>(); private _initialized = false; private _startedInitialize = false; + private _backend: BackendClient | undefined; + private _stopBackendWatch: (() => void) | undefined; + private _backendTokenRequest: Promise | undefined; public constructor(instanceId = 0) { super(undefined, new SPACryptoUtils()); @@ -104,7 +114,41 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC await this._authHelper?.attachTokenToRequestConfig(request); }; - this._httpClient = FetchHttpClient.getInstance(this._browserInstanceId, true, attachToken); + const backendEndpoints: ResolvedBackendEndpoints | undefined = resolveBackendEndpoints( + merged as BackendEndpointConfig, + window.location.origin, + ); + + if (backendEndpoints) { + // The backend holds the tokens, so requests carry the session cookie instead of a token. A + // repeated initialization with the same endpoints, as React's strict mode does in development, + // keeps the client and its session read. + if (!this._backend?.hasEndpoints(backendEndpoints)) { + this._backend = new BackendClient(backendEndpoints); + // A session that ends elsewhere, such as a sign-out in another app, signs the app out too. + this._backend.onSignedOut(() => { + void this.getStorageManager().removeSessionData(); + void this._emitHook(Hooks.SignOut); + }); + } + // Poll the backend every `session.checkInterval` seconds. Checks on focus run either way. + this._stopBackendWatch?.(); + const {session} = merged as {session?: SessionConfig}; + this._stopBackendWatch = this._backend.watch(Number(session?.checkInterval ?? 0)); + this._httpClient = new BackendHttpClient(this._backend, (renew: boolean) => this._getBackendAccessToken(renew)); + FetchHttpClient.setInstance(this._browserInstanceId, this._httpClient); + // Read the session now, so the client reports loading until it is known whether the user is + // signed in. Route guards otherwise see "not loading, not signed in" and start a sign-in. + this._backend.getSession().catch((error: unknown) => { + logger.debug('Could not read the backend session.', error); + }); + } else { + this._stopBackendWatch?.(); + this._stopBackendWatch = undefined; + this._backend = undefined; + this._httpClient = FetchHttpClient.getInstance(this._browserInstanceId, true, attachToken); + } + this._httpClient.setHttpRequestStartCallback((request: HttpRequestConfig) => { void this._emitHook(Hooks.HttpRequestStart, request); }); @@ -187,7 +231,24 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } public override isLoading(): boolean { - return false; + return this._backend?.isLoading() ?? false; + } + + /** + * Whether sign-in runs on the app's backend, which holds the tokens so the browser never does. + */ + public isBackendMode(): boolean { + return this._backend !== undefined; + } + + private _rejectInBackendMode(operation: string): Promise { + return Promise.reject( + new ThunderIDAuthException( + 'SPA-AUTH_CLIENT-BACKEND-NS01', + 'Not supported when sign-in runs on the backend.', + `${operation} is not available when sign-in runs on the backend, because the browser holds no tokens.`, + ), + ); } public override async signIn( @@ -198,6 +259,17 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC ): Promise { await this.isInitialized(); + if (this._backend) { + if (config?.callOnlyOnRedirect) { + return undefined; + } + + location.href = this._backend.getSignInUrl(`${window.location.pathname}${window.location.search}`); + await SPAUtils.waitTillPageRedirect(); + + return undefined; + } + if (!SPAUtils.canContinueSignIn(Boolean(config?.callOnlyOnRedirect), authorizationCode)) { return undefined; } @@ -321,6 +393,10 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC ): Promise { await this.isInitialized(); + if (this._backend) { + return (await this.isSignedIn()) ? this.getUser() : false; + } + if (SPAUtils.wasSignInCalled()) { return undefined; } @@ -364,6 +440,16 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC afterSignOut = afterSignOutParam; } + if (this._backend) { + await this.getStorageManager().removeSessionData(); + const backendSignOutUrl: string = await this._backend.signOut(); + afterSignOut?.(backendSignOutUrl); + location.href = backendSignOutUrl; + await SPAUtils.waitTillPageRedirect(); + + return backendSignOutUrl; + } + const sm = this.getStorageManager(); const config = await (sm as any).getConfigData(); @@ -435,6 +521,10 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC public async httpRequest(requestConfig: HttpRequestConfig): Promise { await this._validateMethod(false); + if (this._backend) { + return this._backendRequest(requestConfig); + } + const response: HttpResponse = await this._authHelper!.httpRequest( this._httpClient!, requestConfig, @@ -453,6 +543,10 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC public async httpRequestAll(configs: HttpRequestConfig[]): Promise { await this._validateMethod(false); + if (this._backend) { + return Promise.all(configs.map((requestConfig: HttpRequestConfig) => this._backendRequest(requestConfig))); + } + const responses: HttpResponse[] | undefined = await this._authHelper!.httpRequestAll( configs, this._httpClient!, @@ -468,9 +562,29 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC return responses; } + private async _backendRequest(requestConfig: HttpRequestConfig): Promise { + try { + return await this._httpClient!.request(requestConfig); + } catch (error) { + if (this._isHttpHandlerEnabled) { + await this._emitHttpError(error as HttpError); + } + + throw error; + } finally { + if (this._isHttpHandlerEnabled) { + this._emitHttpFinish(); + } + } + } + public override async getUser(userId?: string): Promise { await this._validateMethod(); + if (this._backend) { + return ((await this._backend.getSession()).user ?? {}) as User; + } + return super.getUser(userId); } @@ -498,16 +612,61 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } public override async getAccessToken(sessionId?: string): Promise { + if (this._backend) { + return this._getBackendAccessToken(); + } + return super.getAccessToken(sessionId); } + /** + * The access token for requests under `endpoints.backend.bypass`. Like a client credentials token it + * has no refresh token: it is kept in the session storage until it nears expiry, then requested again. + * + * @param renew - Request a new token even when the stored one is still valid. + */ + private async _getBackendAccessToken(renew = false): Promise { + const storageManager = this.getStorageManager(); + const session: Partial | undefined = await storageManager.getSessionData(); + const expiresAt: number = (session?.created_at ?? 0) + Number(session?.expires_in ?? 0) * 1000; + + if ( + !renew && + session?.access_token && + expiresAt - TokenConstants.Lifecycle.CLIENT_CREDENTIALS_REFRESH_MARGIN_MS > Date.now() + ) { + return session.access_token; + } + + this._backendTokenRequest ??= this._backend!.requestAccessToken() + .then(async (response: Partial) => { + await storageManager.setSessionData({...response, created_at: Date.now()}); + + return response.access_token ?? ''; + }) + .finally(() => { + this._backendTokenRequest = undefined; + }); + + return this._backendTokenRequest; + } + public override async getDecodedIdToken(userId?: string, idToken?: string): Promise { await this._validateMethod(); + if (this._backend) { + // The backend keeps the ID token and returns its user claims, which is what callers read from it. + return ((await this._backend.getSession()).user ?? {}) as IdToken; + } + return super.getDecodedIdToken(userId, idToken); } public override async getIdToken(userId?: string): Promise { + if (this._backend) { + return this._rejectInBackendMode('getIdToken'); + } + await this._validateMethod(); return super.getIdToken(userId); @@ -540,6 +699,10 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC public override async isSignedIn(userId?: string): Promise { await this.isInitialized(); + if (this._backend) { + return (await this._backend.getSession()).isSignedIn; + } + return super.isSignedIn(userId); } @@ -550,12 +713,30 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC public async isSessionActive(): Promise { await this.isInitialized(); + if (this._backend) { + return this.isSignedIn(); + } + return (await (this.getStorageManager() as any).getSessionStatus()) === 'true'; } public override async refreshAccessToken(userId?: string): Promise { await this._validateMethod(false); + if (this._backend) { + if (!(await this._backend.refresh())) { + return Promise.reject( + new ThunderIDAuthException( + 'SPA-AUTH_CLIENT-BACKEND-RF01', + 'Refresh failed.', + 'The backend could not refresh the session.', + ), + ); + } + + return this.getUser(); + } + try { return await this._authHelper!.refreshAccessToken((cfg) => this._enableRetrievingSignOutURLFromSession(cfg)); } catch (error) { @@ -564,6 +745,10 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } public override async revokeAccessToken(userId?: string): Promise { + if (this._backend) { + return this._rejectInBackendMode('revokeAccessToken'); + } + await this._validateMethod(); const timer: number = await this._spaHelper!.getRefreshTimeoutTimer(); @@ -577,6 +762,10 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } public override async exchangeToken(config: SPATokenExchangeConfig): Promise { + if (this._backend) { + return this._rejectInBackendMode('exchangeToken'); + } + if (config.signInRequired) { await this._validateMethod(); } @@ -609,6 +798,11 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC public async startAutoRefreshToken(): Promise { await this.isInitialized(); + if (this._backend) { + // The backend refreshes on demand: a request that finds the session expired refreshes it once. + return; + } + await this._spaHelper!.clearRefreshTokenTimeout(); await this._spaHelper!.refreshAccessTokenAutomatically(() => this.refreshAccessToken() as any); } @@ -666,6 +860,9 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC this._addHookCallback(hook, callback); break; case Hooks.SignOut: + // The hook fires on the way back from a sign-out redirect, and later whenever the session + // ends without this app signing out, which only the backend can report. + this._addHookCallback(hook, callback); if ( await SPAUtils.isSignOutSuccessful( ThunderIDBrowserClient.isSignOutSuccessful.bind(ThunderIDBrowserClient), diff --git a/packages/browser/src/__tests__/BackendClient.test.ts b/packages/browser/src/__tests__/BackendClient.test.ts new file mode 100644 index 00000000..73fad7b3 --- /dev/null +++ b/packages/browser/src/__tests__/BackendClient.test.ts @@ -0,0 +1,412 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ +import {ResolvedBackendEndpoints} from '@thunderid/javascript'; +import {afterEach, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +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', + refresh: '/api/auth/refresh', + session: '/api/auth/session', + signIn: '/api/auth/signin', + signOut: '/api/auth/signout', + token: '/api/auth/token', +}; + +const noToken = (): Promise => Promise.reject(new Error('no token expected')); + +const json = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), {headers: {'Content-Type': 'application/json'}, status}); + +const empty = (status: number): Response => new Response(null, {status}); + +describe('BackendClient', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reads the session with the CSRF header and same-origin credentials, and caches it', async () => { + fetchMock.mockResolvedValue(json(200, {isSignedIn: true, user: {sub: 'u1'}})); + const backend = new BackendClient(ENDPOINTS); + + expect(await backend.getSession()).toEqual({isSignedIn: true, user: {sub: 'u1'}}); + await backend.getSession(); + + 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.headers[BACKEND_REQUEST_HEADER]).toBe('XMLHttpRequest'); + }); + + it('refreshes once when the session has expired', async () => { + fetchMock + .mockResolvedValueOnce(json(401, {error: 'session_expired'})) + .mockResolvedValueOnce(empty(204)) + .mockResolvedValueOnce(json(200, {isSignedIn: true})); + + expect((await new BackendClient(ENDPOINTS).getSession()).isSignedIn).toBe(true); + expect(fetchMock.mock.calls.map((call: unknown[]) => call[0])).toEqual([ + '/api/auth/session', + '/api/auth/refresh', + '/api/auth/session', + ]); + }); + + it('reports signed out when the refresh fails', async () => { + fetchMock.mockResolvedValueOnce(json(401, {})).mockResolvedValueOnce(json(401, {})); + + expect(await new BackendClient(ENDPOINTS).getSession()).toEqual({isSignedIn: false}); + }); + + it('shares one refresh between concurrent callers', async () => { + fetchMock.mockResolvedValue(empty(204)); + const backend = new BackendClient(ENDPOINTS); + + expect(await Promise.all([backend.refresh(), backend.refresh()])).toEqual([true, true]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('builds the sign-in URL with the return path', () => { + expect(new BackendClient(ENDPOINTS).getSignInUrl('/app/users?x=1')).toBe( + '/api/auth/signin?returnTo=%2Fapp%2Fusers%3Fx%3D1', + ); + }); + + it('signs out and returns the end-session URL', async () => { + fetchMock.mockResolvedValueOnce(json(200, {redirectUrl: 'https://auth.example.com/oauth2/logout?x'})); + const backend = new BackendClient(ENDPOINTS); + + expect(await backend.signOut()).toBe('https://auth.example.com/oauth2/logout?x'); + expect(fetchMock.mock.calls[0][1].method).toBe('POST'); + expect((await backend.getSession()).isSignedIn).toBe(false); + }); + + it('maps API URLs to the proxy and refuses other origins', () => { + 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('https://other.example.com/users')).toBeUndefined(); + expect(backend.toProxyUrl('//other.example.com/users')).toBeUndefined(); + }); +}); + +describe('BackendHttpClient', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends requests through the proxy without a token', async () => { + fetchMock.mockResolvedValueOnce(json(201, {id: 'u1'})); + const client = new BackendHttpClient(new BackendClient(ENDPOINTS), noToken); + + const response = await client.request({ + data: {name: 'a'}, + headers: {'Content-Type': 'application/json'}, + method: 'POST', + url: 'https://auth.example.com/users', + }); + + expect(response.status).toBe(201); + 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.headers[BACKEND_REQUEST_HEADER]).toBe('XMLHttpRequest'); + expect(init.headers.Authorization).toBeUndefined(); + expect(init.body).toBe('{"name":"a"}'); + }); + + it('refreshes and retries once on 401', async () => { + fetchMock + .mockResolvedValueOnce(json(401, {error: 'session_expired'})) + .mockResolvedValueOnce(empty(204)) + .mockResolvedValueOnce(json(200, {ok: true})); + const client = new BackendHttpClient(new BackendClient(ENDPOINTS), noToken); + + const response = await client.request({url: 'https://auth.example.com/users'}); + + expect(response.data).toEqual({ok: true}); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('returns an API 401 as is, without a refresh', async () => { + 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({ + response: {status: 401}, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('throws the 401 when the refresh fails', async () => { + 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({ + response: {status: 401}, + }); + }); + + it('refuses URLs outside the API base', async () => { + const client = new BackendHttpClient(new BackendClient(ENDPOINTS), noToken); + + await expect(client.request({url: 'https://other.example.com/x'})).rejects.toMatchObject({ + code: 'BACKEND_UNSUPPORTED_URL', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('BackendClient session end', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + const signedInClient = async (): Promise<{backend: BackendClient; onSignedOut: Mock}> => { + fetchMock.mockResolvedValueOnce(json(200, {isSignedIn: true})); + const backend = new BackendClient(ENDPOINTS); + const onSignedOut = vi.fn(); + backend.onSignedOut(onSignedOut); + await backend.getSession(); + + return {backend, onSignedOut}; + }; + + it('tells the listeners when a check finds the session ended', async () => { + const {backend, onSignedOut} = await signedInClient(); + fetchMock.mockResolvedValueOnce(json(200, {isSignedIn: false})); + + expect(await backend.checkSession()).toBe(false); + + expect(fetchMock.mock.calls[1][0]).toBe('/api/auth/checksession'); + expect(onSignedOut).toHaveBeenCalledTimes(1); + expect((await backend.getSession()).isSignedIn).toBe(false); + }); + + it('stays quiet while the session is live, and when a check fails', async () => { + const {backend, onSignedOut} = await signedInClient(); + fetchMock.mockResolvedValueOnce(json(200, {isSignedIn: true})).mockRejectedValueOnce(new Error('offline')); + + expect(await backend.checkSession()).toBe(true); + expect(await backend.checkSession()).toBe(true); + expect(onSignedOut).not.toHaveBeenCalled(); + }); + + it('tells the listeners when the proxy says the session ended', async () => { + const {backend, onSignedOut} = await signedInClient(); + fetchMock.mockResolvedValueOnce(json(401, {error: 'login_required'})); + + await expect(new BackendHttpClient(backend, noToken).request({url: '/users'})).rejects.toMatchObject({ + response: {status: 401}, + }); + expect(onSignedOut).toHaveBeenCalledTimes(1); + }); + + it('tells the listeners when a refresh fails', async () => { + const {backend, onSignedOut} = await signedInClient(); + fetchMock.mockResolvedValueOnce(json(401, {})); + + expect(await backend.refresh()).toBe(false); + expect(onSignedOut).toHaveBeenCalledTimes(1); + }); + + it('does not tell the listeners about its own sign-out', async () => { + const {backend, onSignedOut} = await signedInClient(); + fetchMock.mockResolvedValueOnce(json(200, {redirectUrl: '/bye'})); + + await backend.signOut(); + + expect(onSignedOut).not.toHaveBeenCalled(); + }); + + it('polls while signed in and checks on focus, at most once per throttle window', async () => { + const {backend} = await signedInClient(); + vi.useFakeTimers(); + fetchMock.mockResolvedValue(json(200, {isSignedIn: true})); + const stop = backend.watch(30); + + await vi.advanceTimersByTimeAsync(30_000); + expect(fetchMock).toHaveBeenCalledTimes(2); + + window.dispatchEvent(new Event('focus')); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(5_000); + window.dispatchEvent(new Event('focus')); + expect(fetchMock).toHaveBeenCalledTimes(3); + + stop(); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('does not check before anyone has signed in', async () => { + vi.useFakeTimers(); + const backend = new BackendClient(ENDPOINTS); + const stop = backend.watch(30); + + window.dispatchEvent(new Event('focus')); + await vi.advanceTimersByTimeAsync(60_000); + + expect(fetchMock).not.toHaveBeenCalled(); + stop(); + }); +}); + +describe('BackendClient bypass', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('matches URLs under a bypass URL only', () => { + const backend = new BackendClient(ENDPOINTS); + + expect(backend.isBypassed('https://dp.example.com/api/orders')).toBe(true); + expect(backend.isBypassed('https://dp.example.com/api?x=1')).toBe(true); + expect(backend.isBypassed('https://dp.example.com/apix')).toBe(false); + expect(backend.isBypassed('https://dp.example.com.evil.com/api')).toBe(false); + expect(backend.isBypassed('/orders')).toBe(false); + }); + + it('requests the access token with the CSRF header and same-origin credentials', async () => { + fetchMock.mockResolvedValueOnce(json(200, {access_token: 't1', expires_in: 300, token_type: 'Bearer'})); + + expect(await new BackendClient(ENDPOINTS).requestAccessToken()).toEqual({ + access_token: 't1', + expires_in: 300, + token_type: 'Bearer', + }); + 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.headers[BACKEND_REQUEST_HEADER]).toBe('XMLHttpRequest'); + }); + + it('refreshes the session when the token endpoint says it expired', async () => { + fetchMock + .mockResolvedValueOnce(json(401, {error: 'session_expired'})) + .mockResolvedValueOnce(empty(204)) + .mockResolvedValueOnce(json(200, {access_token: 't1', expires_in: 300})); + + expect((await new BackendClient(ENDPOINTS).requestAccessToken()).access_token).toBe('t1'); + expect(fetchMock.mock.calls.map((call: unknown[]) => call[0])).toEqual([ + '/api/auth/token', + '/api/auth/refresh', + '/api/auth/token', + ]); + }); + + it('fails when the backend will not issue a token', async () => { + fetchMock.mockResolvedValueOnce(json(403, {error: 'access_denied'})); + + await expect(new BackendClient(ENDPOINTS).requestAccessToken()).rejects.toMatchObject({ + code: 'SPA-BACKEND_CLIENT-RAT-SE01', + }); + }); +}); + +describe('BackendHttpClient bypass', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends a bypassed request with the access token and the cookies', async () => { + fetchMock.mockResolvedValueOnce(json(200, {orders: []})); + const client = new BackendHttpClient(new BackendClient(ENDPOINTS), () => Promise.resolve('t1')); + + const response = await client.request({ + headers: {Accept: 'application/json'}, + url: 'https://dp.example.com/api/orders', + }); + + expect(response.data).toEqual({orders: []}); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://dp.example.com/api/orders'); + expect(init.credentials).toBe('include'); + expect(init.headers.Authorization).toBe('Bearer t1'); + expect(init.headers.Accept).toBe('application/json'); + expect(init.headers[BACKEND_REQUEST_HEADER]).toBeUndefined(); + }); + + it('asks for a new token and retries once when the API rejects the token', async () => { + fetchMock.mockResolvedValueOnce(json(401, {})).mockResolvedValueOnce(json(200, {ok: true})); + const getAccessToken = vi.fn((renew: boolean) => Promise.resolve(renew ? 't2' : 't1')); + const client = new BackendHttpClient(new BackendClient(ENDPOINTS), getAccessToken); + + expect((await client.request({url: 'https://dp.example.com/api/a'})).data).toEqual({ok: true}); + expect(getAccessToken.mock.calls).toEqual([[false], [true]]); + expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer t2'); + }); + + it('sends a bypassed request with only the cookies when attachToken is off', async () => { + fetchMock.mockResolvedValueOnce(json(200, {ok: true})); + const getAccessToken = vi.fn(() => Promise.resolve('t1')); + const backend = new BackendClient({...ENDPOINTS, bypass: {...ENDPOINTS.bypass, attachToken: false}}); + const client = new BackendHttpClient(backend, getAccessToken); + + await client.request({url: 'https://dp.example.com/api/a'}); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://dp.example.com/api/a'); + expect(init.credentials).toBe('include'); + expect(init.headers.Authorization).toBeUndefined(); + expect(getAccessToken).not.toHaveBeenCalled(); + }); + + it('returns a second 401 from the API as is', async () => { + fetchMock.mockImplementation(() => Promise.resolve(json(401, {}))); + const client = new BackendHttpClient(new BackendClient(ENDPOINTS), () => Promise.resolve('t1')); + + await expect(client.request({url: 'https://dp.example.com/api/a'})).rejects.toMatchObject({ + response: {status: 401}, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts b/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts new file mode 100644 index 00000000..3c216626 --- /dev/null +++ b/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts @@ -0,0 +1,190 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */ +import {afterEach, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import BackendHttpClient from '../BackendHttpClient'; +import FetchHttpClient from '../FetchHttpClient'; +import ThunderIDBrowserClient from '../ThunderIDBrowserClient'; + +const json = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), {headers: {'Content-Type': 'application/json'}, status}); + +const CONFIG = { + baseUrl: 'https://auth.example.com', + clientId: 'my-app', + endpoints: {backend: {bypass: {urls: ['https://dp.example.com']}}}, + storage: 'browserMemory', +}; + +const SIGNED_IN = {isSignedIn: true, user: {name: 'Ada', sub: 'u1'}}; + +describe('ThunderIDBrowserClient with sign-in on the backend', () => { + let fetchMock: Mock; + let client: ThunderIDBrowserClient; + + beforeEach(async () => { + // Initialization reads the session, so it gets a signed-in session; each test sets its own + // responses after that. + fetchMock = vi.fn().mockResolvedValueOnce(json(200, SIGNED_IN)); + vi.stubGlobal('fetch', fetchMock); + client = new ThunderIDBrowserClient(7); + await client.initialize(CONFIG); + await client.isSignedIn(); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + FetchHttpClient.destroyInstance(7); + }); + + it('registers the backend HTTP client for the instance', () => { + expect(client.isBackendMode()).toBe(true); + expect(FetchHttpClient.getInstance(7)).toBeInstanceOf(BackendHttpClient); + }); + + it('reads sign-in state and the user from the session read at initialization', async () => { + expect(client.isLoading()).toBe(false); + expect(await client.isSignedIn()).toBe(true); + expect(await client.getUser()).toEqual({name: 'Ada', sub: 'u1'}); + expect(await client.getDecodedIdToken()).toEqual({name: 'Ada', sub: 'u1'}); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('keeps the session read across a repeated initialization with the same endpoints', async () => { + await client.initialize(CONFIG); + + expect(await client.isSignedIn()).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('signs the app out when the session ends elsewhere', async () => { + const onSignOut = vi.fn(); + 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(); + + expect(onSignOut).toHaveBeenCalledTimes(1); + expect(await client.isSignedIn()).toBe(false); + }); + + it('refuses to hand out the ID token or exchange tokens', async () => { + await expect(client.getIdToken()).rejects.toMatchObject({code: 'SPA-AUTH_CLIENT-BACKEND-NS01'}); + await expect(client.exchangeToken({id: 'x'} as any)).rejects.toMatchObject({code: 'SPA-AUTH_CLIENT-BACKEND-NS01'}); + }); + + 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'}); + + expect(response?.data).toEqual({users: []}); + expect(fetchMock.mock.calls[0][0]).toBe('/api/auth/proxy/users'); + }); + + it('gets the access token from the backend and stores it like a client credentials token', async () => { + fetchMock.mockResolvedValueOnce(json(200, {access_token: 't1', expires_in: 300, token_type: 'Bearer'})); + + expect(await client.getAccessToken()).toBe('t1'); + expect(await client.getAccessToken()).toBe('t1'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('/api/auth/token'); + }); + + it('requests a new access token when the stored one is about to expire', async () => { + fetchMock + .mockResolvedValueOnce(json(200, {access_token: 't1', expires_in: 10})) + .mockResolvedValueOnce(json(200, {access_token: 't2', expires_in: 300})); + + expect(await client.getAccessToken()).toBe('t1'); + expect(await client.getAccessToken()).toBe('t2'); + }); + + it('shares one token request between concurrent callers', async () => { + fetchMock.mockResolvedValueOnce(json(200, {access_token: 't1', expires_in: 300})); + + expect(await Promise.all([client.getAccessToken(), client.getAccessToken()])).toEqual(['t1', 't1']); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('sends a bypassed httpRequest straight there with the access token', async () => { + fetchMock + .mockResolvedValueOnce(json(200, {access_token: 't1', expires_in: 300})) + .mockResolvedValueOnce(json(200, {orders: []})); + + const response = await client.httpRequest({url: 'https://dp.example.com/orders'}); + + expect(response?.data).toEqual({orders: []}); + expect(fetchMock.mock.calls[1][0]).toBe('https://dp.example.com/orders'); + expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer t1'); + }); + + it('drops the access token when the session ends elsewhere', async () => { + fetchMock + .mockResolvedValueOnce(json(200, {access_token: 't1', expires_in: 300})) + .mockResolvedValueOnce(json(401, {error: 'login_required'})) + .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 vi.waitFor(async () => expect(await client.getAccessToken()).toBe('t2')); + }); + + it('reports request errors to the error hook', async () => { + fetchMock.mockResolvedValue(json(500, {error: 'boom'})); + const onError = vi.fn(); + await client.on('http-request-error', onError); + + await expect(client.httpRequest({url: 'https://auth.example.com/users'})).rejects.toBeDefined(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('signs out through the backend and navigates to the end-session URL', async () => { + fetchMock.mockResolvedValue(json(200, {redirectUrl: 'https://auth.example.com/oauth2/logout?client_id=my-app'})); + const afterSignOut = vi.fn(); + + void client.signOut(undefined, afterSignOut); + 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'); + }); +}); + +describe('ThunderIDBrowserClient loading state with sign-in on the backend', () => { + afterEach(() => { + vi.unstubAllGlobals(); + FetchHttpClient.destroyInstance(8); + }); + + it('reports loading until the first session read settles', async () => { + let respond: (response: Response) => void = () => undefined; + vi.stubGlobal( + 'fetch', + vi.fn( + () => + new Promise((resolve: (response: Response) => void) => { + respond = resolve; + }), + ), + ); + const client = new ThunderIDBrowserClient(8); + await client.initialize(CONFIG); + + expect(client.isLoading()).toBe(true); + + respond(json(200, {isSignedIn: false})); + expect(await client.isSignedIn()).toBe(false); + expect(client.isLoading()).toBe(false); + }); + + it('does not report loading without a backend', async () => { + const client = new ThunderIDBrowserClient(8); + await client.initialize({baseUrl: 'https://auth.example.com', clientId: 'my-app', storage: 'browserMemory'}); + + expect(client.isLoading()).toBe(false); + }); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 80ecbd22..64f1f373 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -8,6 +8,8 @@ // Client export {default as ThunderIDBrowserClient} from './ThunderIDBrowserClient'; export {default as FetchHttpClient} from './FetchHttpClient'; +export {default as BackendClient, BACKEND_REQUEST_HEADER} from './BackendClient'; +export {default as BackendHttpClient} from './BackendHttpClient'; // Constants export {default as Hooks, Hooks as BrowserHooks} from './constants/Hooks'; diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index c093def9..b95f0f13 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -263,6 +263,7 @@ export type {OIDCDiscoveryApiResponse} from './models/oidc-discovery'; export type {Storage, TemporaryStore} from './models/store'; export type {User, UserProfile} from './models/user'; export type {SessionData} from './models/session'; +export type {BackendBypass, BackendEndpoints, BackendSession, ResolvedBackendEndpoints} from './models/backend'; export type {TranslationFn} from './models/translation'; export type {ResolveFlowTemplateLiteralsOptions} from './models/vars'; export type {RecursivePartial} from './models/utility-types'; @@ -299,6 +300,8 @@ 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 resolveBackendEndpoints} from './utils/resolveBackendEndpoints'; +export type {BackendEndpointConfig} from './utils/resolveBackendEndpoints'; export type {ResourceEndpointKey, ResourceEndpointConfig} from './utils/resolveResourceEndpoint'; export {default as computeNextPageOffset} from './utils/computeNextPageOffset'; export {default as isAdvancingPageOffset} from './utils/isAdvancingPageOffset'; diff --git a/packages/javascript/src/models/backend.ts b/packages/javascript/src/models/backend.ts new file mode 100644 index 00000000..607a3579 --- /dev/null +++ b/packages/javascript/src/models/backend.ts @@ -0,0 +1,77 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * 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. + * + * Any endpoint left unset defaults to a fixed path under `/api/auth`. + * + * @example + * endpoints: {backend: {signIn: '/api/auth/signin'}} + */ +export interface BackendEndpoints { + /** Starts sign-in. Defaults to `/api/auth/signin`. */ + signIn?: string; + /** Reports the signed-in user. Defaults to `/api/auth/session`. */ + session?: string; + /** + * Reports only whether the session is still live, without refreshing it or counting as activity, + * so the SDK can poll it. Defaults to `/api/auth/checksession`. + */ + checkSession?: string; + /** Refreshes the session. Defaults to `/api/auth/refresh`. */ + refresh?: string; + /** 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`. + */ + proxy?: string; + /** + * Issues the access token for `bypass`, as an OAuth token response without a refresh token. + * Defaults to `/api/auth/token`. + */ + token?: string; + /** Requests that skip the proxy and go straight from the browser. */ + bypass?: BackendBypass; +} + +/** + * Requests the browser sends directly rather than through the proxy, such as calls to a data plane on + * another origin. They always carry the cookies. + * + * @example + * bypass: {urls: ['https://dp.example.com/api']} + */ +export interface BackendBypass { + /** Base URLs whose requests skip the proxy. A request matches its own base URL or anything under it. */ + urls: string[]; + /** + * Attach the access token from `token` as `Authorization: Bearer`, and get a new one and retry once + * on a 401. Defaults to `true`. With `false`, requests go out with only the cookies. + */ + attachToken?: boolean; +} + +/** + * The backend endpoints, fully resolved, with the API base whose requests go through the proxy. + */ +export type ResolvedBackendEndpoints = Required> & { + apiBaseUrl: string; + bypass: Required; +}; + +/** + * What the backend's session endpoint reports. + */ +export interface BackendSession { + isSignedIn: boolean; + /** The ID token's user claims, without the protocol claims. */ + user?: Record; + /** When the access token expires, in seconds since the epoch. */ + expiresAt?: number; +} diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 943984f5..7668ebeb 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -1,6 +1,7 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import type {BackendEndpoints} from './backend'; import {ComponentsExtensions} from './extensions/components'; import type {OAuthResponseMode} from './oauth-response'; import type {OIDCEndpoints} from './oidc-endpoints'; @@ -46,6 +47,15 @@ export type SignUpOptions = Record; * Options for the ThunderID SSO session. See {@link BaseConfig.session}. */ export interface SessionConfig { + /** + * How often to ask the app's backend whether the session is still live, in seconds. Only applies + * when sign-in runs on a backend (`endpoints.backend`): the backend's check endpoint reports + * liveness without refreshing the session or counting as activity, so polling it does not keep an + * idle session alive. Unset or `0` leaves only the check that runs when the page regains focus. + * @default 0 + */ + checkInterval?: number; + /** * Silent sign-in: a `prompt=none` authorize request in a hidden iframe that signs the user in from * an existing ThunderID SSO session without any interaction. @@ -286,6 +296,12 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, resolved from the well-known discovery document. */ authorization?: string; + /** + * The app backend's auth endpoints. Setting them hands sign-in to the backend: the SDK then signs + * in, reads the session, and sends API calls through the backend, and never holds a token. + * Browser clients only. Unset entries default to a fixed path under `/api/auth`. + */ + backend?: BackendEndpoints; /** * The end-session (logout) endpoint URL. * If not provided, resolved from the well-known discovery document. diff --git a/packages/javascript/src/utils/AuthenticationHelper.ts b/packages/javascript/src/utils/AuthenticationHelper.ts index b4e4acac..c750a224 100644 --- a/packages/javascript/src/utils/AuthenticationHelper.ts +++ b/packages/javascript/src/utils/AuthenticationHelper.ts @@ -61,7 +61,7 @@ class AuthenticationHelper { * Maps the config-defined OIDC endpoint overrides to their discovery-metadata form. * * Endpoint names are converted from camelCase to snake_case, and resource-server endpoints - * (`RESOURCE_ENDPOINT_KEYS`) are excluded — those are resolved separately via + * (`RESOURCE_ENDPOINT_KEYS`) and the backend overrides are excluded — those are resolved separately via * `resolveResourceEndpoint` and must not leak into the OIDC provider metadata. * * @param configData - The resolved auth client config. @@ -75,7 +75,9 @@ class AuthenticationHelper { } Object.keys(configData.endpoints) - .filter((endpointName: string) => !RESOURCE_ENDPOINT_KEYS.includes(endpointName as never)) + .filter( + (endpointName: string) => endpointName !== 'backend' && !RESOURCE_ENDPOINT_KEYS.includes(endpointName as never), + ) .forEach((endpointName: string) => { const snakeCasedName: string = endpointName.replace(/[A-Z]/g, (letter: string) => `_${letter.toLowerCase()}`); diff --git a/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts b/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts new file mode 100644 index 00000000..ff834ef2 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts @@ -0,0 +1,76 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* 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 resolveBackendEndpoints from '../resolveBackendEndpoints'; + +const ORIGIN = 'https://app.example.com'; + +describe('resolveBackendEndpoints', () => { + it('returns undefined when sign-in does not run on a backend', () => { + expect(resolveBackendEndpoints({baseUrl: 'https://auth.example.com'}, ORIGIN)).toBeUndefined(); + expect(resolveBackendEndpoints(undefined, ORIGIN)).toBeUndefined(); + }); + + it('defaults every unset endpoint under /api/auth and the API base to the app origin', () => { + expect(resolveBackendEndpoints({endpoints: {backend: {}}}, ORIGIN)).toEqual({ + apiBaseUrl: ORIGIN, + bypass: {attachToken: true, urls: []}, + checkSession: '/api/auth/checksession', + proxy: '/api/auth/proxy', + refresh: '/api/auth/refresh', + session: '/api/auth/session', + signIn: '/api/auth/signin', + signOut: '/api/auth/signout', + token: '/api/auth/token', + }); + }); + + it('applies the configured endpoints and uses baseUrl as the API base', () => { + const resolved = resolveBackendEndpoints( + { + baseUrl: 'https://auth.example.com/', + endpoints: {backend: {proxy: '/gateway/api/', session: '/gateway/me', signIn: '/gateway/signin'}}, + }, + ORIGIN, + ); + + expect(resolved?.signIn).toBe('/gateway/signin'); + 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'); + }); + + it('applies the token endpoint and the bypass, trimming its URLs', () => { + const resolved = resolveBackendEndpoints( + { + endpoints: { + backend: {bypass: {attachToken: false, urls: ['https://dp.example.com/api/']}, token: '/gateway/token'}, + }, + }, + ORIGIN, + ); + + expect(resolved?.token).toBe('/gateway/token'); + expect(resolved?.bypass).toEqual({attachToken: false, urls: ['https://dp.example.com/api']}); + }); +}); + +describe('AuthenticationHelper OIDC endpoint overrides', () => { + it('keeps the backend overrides out of the OIDC provider metadata', async () => { + const storageManager = { + getConfigData: async (): Promise => ({ + endpoints: {backend: {session: '/x'}, token: 'https://auth.example.com/token'}, + }), + }; + const helper = new AuthenticationHelper(storageManager as any, {} as any); + + const resolved = await helper.resolveEndpoints({}); + + expect(resolved.token).toBe('https://auth.example.com/token'); + expect(resolved).not.toHaveProperty('backend'); + }); +}); diff --git a/packages/javascript/src/utils/resolveBackendEndpoints.ts b/packages/javascript/src/utils/resolveBackendEndpoints.ts new file mode 100644 index 00000000..b9494c48 --- /dev/null +++ b/packages/javascript/src/utils/resolveBackendEndpoints.ts @@ -0,0 +1,58 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {BackendEndpoints, ResolvedBackendEndpoints} from '../models/backend'; + +const DEFAULT_BACKEND_BASE_PATH = '/api/auth'; + +/** + * Minimal shape of the config needed to resolve the backend endpoints. + */ +export interface BackendEndpointConfig { + baseUrl?: string; + endpoints?: {backend?: BackendEndpoints}; +} + +/** + * Resolves the backend endpoints from `endpoints.backend`, or returns `undefined` when it is not set + * and sign-in does not run on a backend. + * + * @param config - The client config. + * @param origin - The app's origin, used as the API base when `baseUrl` is not set. + * @returns The resolved endpoints, or `undefined` when `endpoints.backend` is not set. + * + * @example + * ```typescript + * resolveBackendEndpoints({endpoints: {backend: {signIn: '/auth/signin'}}}, window.location.origin); + * // {signIn: '/auth/signin', session: '/api/auth/session', ..., apiBaseUrl: 'https://...'} + * ``` + */ +const resolveBackendEndpoints = ( + config: BackendEndpointConfig | undefined, + origin: string, +): ResolvedBackendEndpoints | undefined => { + const endpoints: BackendEndpoints | undefined = config?.endpoints?.backend; + + if (!endpoints) { + return undefined; + } + + return { + // `||`, not `??`: an empty baseUrl, which the providers default to, falls back to the origin too. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + apiBaseUrl: (config?.baseUrl || origin).replace(/\/+$/, ''), + bypass: { + attachToken: endpoints.bypass?.attachToken ?? true, + urls: (endpoints.bypass?.urls ?? []).map((url: string) => url.replace(/\/+$/, '')), + }, + checkSession: endpoints.checkSession ?? `${DEFAULT_BACKEND_BASE_PATH}/checksession`, + proxy: (endpoints.proxy ?? `${DEFAULT_BACKEND_BASE_PATH}/proxy`).replace(/\/+$/, ''), + refresh: endpoints.refresh ?? `${DEFAULT_BACKEND_BASE_PATH}/refresh`, + session: endpoints.session ?? `${DEFAULT_BACKEND_BASE_PATH}/session`, + signIn: endpoints.signIn ?? `${DEFAULT_BACKEND_BASE_PATH}/signin`, + signOut: endpoints.signOut ?? `${DEFAULT_BACKEND_BASE_PATH}/signout`, + token: endpoints.token ?? `${DEFAULT_BACKEND_BASE_PATH}/token`, + }; +}; + +export default resolveBackendEndpoints; diff --git a/packages/react/src/ThunderIDReactClient.ts b/packages/react/src/ThunderIDReactClient.ts index 90462732..94627846 100644 --- a/packages/react/src/ThunderIDReactClient.ts +++ b/packages/react/src/ThunderIDReactClient.ts @@ -134,7 +134,7 @@ class ThunderIDReactClient { @@ -162,6 +162,8 @@ class ThunderIDReactClient; override async signUp(payload: any): Promise; override async signUp(...args: any[]): Promise { + this.rejectEmbeddedFlowInBackendMode(); + const config: ThunderIDReactConfig = (await this.getStorageManager().getConfigData()) as ThunderIDReactConfig; const firstArg: any = args[0]; const baseUrl: string = config?.baseUrl ?? ''; @@ -276,6 +280,8 @@ class ThunderIDReactClient { + this.rejectEmbeddedFlowInBackendMode(); + const config: ThunderIDReactConfig = (await this.getStorageManager().getConfigData()) as ThunderIDReactConfig; return executeEmbeddedRecoveryFlow({ @@ -285,6 +291,21 @@ class ThunderIDReactClient> = ({ await updateSession(); }); + // A session that ends elsewhere, such as a sign-out in another app, signs this app out too. + await client.on('sign-out', () => { + setUser(null); + setUserProfile(null); + setIsSignedInSync(false); + }); + // Start auto-refresh with a soft failure. This also covers the case where the access token // expired while the refresh token is still valid — startAutoRefreshToken() calls // refreshAccessToken() immediately when timeUntilRefresh <= 0 — so the sign-in check below diff --git a/packages/vue/src/providers/ThunderIDProvider.ts b/packages/vue/src/providers/ThunderIDProvider.ts index fbca8189..3c346011 100644 --- a/packages/vue/src/providers/ThunderIDProvider.ts +++ b/packages/vue/src/providers/ThunderIDProvider.ts @@ -443,6 +443,13 @@ const ThunderIDProvider: Component = defineComponent({ await updateSession(); }); + // A session that ends elsewhere, such as a sign-out in another app, signs this app out too. + await client.on('sign-out', () => { + user.value = null; + userProfile.value = null; + isSignedIn.value = false; + }); + // 3. Try to sign in if already authenticated or if URL has auth params const alreadySignedIn: boolean = await client.isSignedIn();