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