From 746b0a6ffc58ef6b48057dda1a4c9929ba0551d0 Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 14 Oct 2026 11:18:47 +0530 Subject: [PATCH] Sign in through the app's backend in the browser SDKs Setting endpoints.backend hands sign-in to the app's backend (the Backend for Frontend pattern). The browser client then signs in, reads the user, and signs out through the backend, and sends API calls through its proxy with the session cookie, so the browser never holds a token. React and Vue pick it up through the browser client. 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. A session that ends elsewhere fires the sign-out hook, found by a 401 login_required from the proxy, a check when the page regains focus, or polling /checksession when syncSession is on. Signed-off-by: Brion --- packages/browser/src/BackendClient.ts | 279 +++++++++++++++++ packages/browser/src/BackendHttpClient.ts | 57 ++++ packages/browser/src/FetchHttpClient.ts | 4 + .../browser/src/ThunderIDBrowserClient.ts | 161 +++++++++- .../src/__tests__/BackendClient.test.ts | 282 ++++++++++++++++++ .../ThunderIDBrowserClient.backend.test.ts | 142 +++++++++ packages/browser/src/index.ts | 2 + packages/javascript/src/index.ts | 3 + packages/javascript/src/models/backend.ts | 50 ++++ packages/javascript/src/models/config.ts | 7 + .../src/utils/AuthenticationHelper.ts | 6 +- .../__tests__/resolveBackendEndpoints.test.ts | 60 ++++ .../src/utils/resolveBackendEndpoints.ts | 53 ++++ packages/react/src/ThunderIDReactClient.ts | 23 +- .../contexts/ThunderID/ThunderIDProvider.tsx | 7 + .../vue/src/providers/ThunderIDProvider.ts | 7 + 16 files changed, 1137 insertions(+), 6 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..c269fd62 --- /dev/null +++ b/packages/browser/src/BackendClient.ts @@ -0,0 +1,279 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {BackendSession, ResolvedBackendEndpoints, 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) => 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}`}`; + } + + /** + * 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..9c4d8c7e --- /dev/null +++ b/packages/browser/src/BackendHttpClient.ts @@ -0,0 +1,57 @@ +// 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. + */ +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 { + constructor(private readonly backend: BackendClient) { + super(true); + } + + protected override async transport(config: HttpRequestConfig): Promise> { + 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; + } + } +} + +export default BackendHttpClient; diff --git a/packages/browser/src/FetchHttpClient.ts b/packages/browser/src/FetchHttpClient.ts index 5c31fad5..bfa44411 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 0ddc7554..c10292b7 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -16,8 +16,13 @@ import { HttpError, HttpRequestConfig, HttpResponse, + BackendEndpointConfig, + ResolvedBackendEndpoints, createPackageComponentLogger, + resolveBackendEndpoints, } from '@thunderid/javascript'; +import BackendClient from './BackendClient'; +import BackendHttpClient from './BackendHttpClient'; import {REFRESH_ACCESS_TOKEN_ERR0R, SILENT_SIGN_IN_STATE, TOKEN_REQUEST_CONFIG_KEY} from './constants/SPAConstants'; import FetchHttpClient from './FetchHttpClient'; import {BrowserAuthConfig} from './models/BrowserConfig'; @@ -77,6 +82,8 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC private _onCustomGrant = new Map void>(); private _initialized = false; private _startedInitialize = false; + private _backend: BackendClient | undefined; + private _stopBackendWatch: (() => void) | undefined; public constructor(instanceId = 0) { super(undefined, new SPACryptoUtils()); @@ -108,7 +115,38 @@ 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(() => this._onSignOutCallback()); + } + // With syncSession, poll the backend every checkSessionInterval seconds. Checks on focus run + // either way. + this._stopBackendWatch?.(); + const {checkSessionInterval, syncSession} = merged as {checkSessionInterval?: number; syncSession?: boolean}; + this._stopBackendWatch = this._backend.watch(syncSession ? Number(checkSessionInterval) : 0); + this._httpClient = new BackendHttpClient(this._backend); + 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._sessionManagementHelper = await createSessionManagementHelper( async () => this.getSignOutUrl(), @@ -196,7 +234,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( @@ -208,6 +263,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; } @@ -332,6 +398,10 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC ): Promise { await this.isInitialized(); + if (this._backend) { + return (await this.isSignedIn()) ? this.getUser() : false; + } + if (SPAUtils.wasSignInCalled()) { return undefined; } @@ -366,6 +436,15 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC afterSignOut = afterSignOutParam; } + if (this._backend) { + 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(); @@ -437,6 +516,10 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC public async httpRequest(requestConfig: HttpRequestConfig): Promise { await this._validateMethod(false); + if (this._backend) { + return this._backendRequest(requestConfig); + } + return this._authHelper!.httpRequest( this._httpClient!, requestConfig, @@ -449,6 +532,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))); + } + return this._authHelper!.httpRequestAll( configs, this._httpClient!, @@ -458,23 +545,56 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC ); } + private async _backendRequest(requestConfig: HttpRequestConfig): Promise { + try { + return await this._httpClient!.request(requestConfig); + } catch (error) { + if (this._isHttpHandlerEnabled) { + await this._httpErrorCallback?.(error as HttpError); + } + + throw error; + } finally { + if (this._isHttpHandlerEnabled) { + this._httpFinishCallback?.(); + } + } + } + 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); } public override async getAccessToken(sessionId?: string): Promise { + if (this._backend) { + return this._rejectInBackendMode('getAccessToken'); + } + return super.getAccessToken(sessionId); } 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); @@ -507,6 +627,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); } @@ -517,12 +641,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) { @@ -531,6 +673,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(); @@ -545,6 +691,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(); } @@ -582,7 +732,7 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC const result = await super.reInitialize(config); const merged = {...existingConfig, ...config}; - if (merged.syncSession && isCheckSessionIframeDifferent) { + if (merged.syncSession && isCheckSessionIframeDifferent && !this._backend) { this._sessionManagementHelper?.reset(); this._checkSession(); } @@ -593,6 +743,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); } diff --git a/packages/browser/src/__tests__/BackendClient.test.ts b/packages/browser/src/__tests__/BackendClient.test.ts new file mode 100644 index 00000000..80c1978c --- /dev/null +++ b/packages/browser/src/__tests__/BackendClient.test.ts @@ -0,0 +1,282 @@ +// 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', + checkSession: '/api/auth/checksession', + proxy: '/api/auth/proxy', + refresh: '/api/auth/refresh', + session: '/api/auth/session', + signIn: '/api/auth/signin', + signOut: '/api/auth/signout', +}; + +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)); + + 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)); + + 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)); + + 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)); + + 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)); + + 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).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(); + }); +}); 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..34ef573e --- /dev/null +++ b/packages/browser/src/__tests__/ThunderIDBrowserClient.backend.test.ts @@ -0,0 +1,142 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ +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: {}}, + 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 tokens', async () => { + await expect(client.getAccessToken()).rejects.toMatchObject({code: 'SPA-AUTH_CLIENT-BACKEND-NS01'}); + 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('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 4c00bc36..de8d927e 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 2052ba64..2103d7a6 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -232,6 +232,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 {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'; @@ -268,6 +269,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 evaluatePasswordPolicy} from './utils/evaluatePasswordPolicy'; export type {PasswordPolicy, PasswordRuleResult} from './utils/evaluatePasswordPolicy'; diff --git a/packages/javascript/src/models/backend.ts b/packages/javascript/src/models/backend.ts new file mode 100644 index 00000000..2fda524e --- /dev/null +++ b/packages/javascript/src/models/backend.ts @@ -0,0 +1,50 @@ +// 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; +} + +/** + * The backend endpoints, fully resolved, with the API base whose requests go through the proxy. + */ +export type ResolvedBackendEndpoints = Required & {apiBaseUrl: string}; + +/** + * 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 dfad366f..30800e7f 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'; @@ -255,6 +256,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 beae664b..87092fb2 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..32ddb44d --- /dev/null +++ b/packages/javascript/src/utils/__tests__/resolveBackendEndpoints.test.ts @@ -0,0 +1,60 @@ +// 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, + checkSession: '/api/auth/checksession', + proxy: '/api/auth/proxy', + refresh: '/api/auth/refresh', + session: '/api/auth/session', + signIn: '/api/auth/signin', + signOut: '/api/auth/signout', + }); + }); + + 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'); + }); +}); + +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..d8d13c92 --- /dev/null +++ b/packages/javascript/src/utils/resolveBackendEndpoints.ts @@ -0,0 +1,53 @@ +// 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(/\/+$/, ''), + 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`, + }; +}; + +export default resolveBackendEndpoints; diff --git a/packages/react/src/ThunderIDReactClient.ts b/packages/react/src/ThunderIDReactClient.ts index fe36d406..518d674a 100644 --- a/packages/react/src/ThunderIDReactClient.ts +++ b/packages/react/src/ThunderIDReactClient.ts @@ -108,7 +108,7 @@ class ThunderIDReactClient { @@ -148,6 +148,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 ?? ''; @@ -260,6 +264,8 @@ class ThunderIDReactClient { + this.rejectEmbeddedFlowInBackendMode(); + const config: ThunderIDReactConfig = (await this.getStorageManager().getConfigData()) as ThunderIDReactConfig; return executeEmbeddedRecoveryFlow({ @@ -269,6 +275,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 cfcb70b4..a4b0fd8c 100644 --- a/packages/vue/src/providers/ThunderIDProvider.ts +++ b/packages/vue/src/providers/ThunderIDProvider.ts @@ -422,6 +422,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();