From 418a04a132f2667ba6e9f674501ae12af9f3322c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 12 Sep 2026 19:39:55 -0700 Subject: [PATCH 1/2] feat(client): platform ports and a bearer transport with token custody The client spoke one contract: cookies to a server adapter at /auth, with the browser's WebAuthn API and window.location reached directly. A native binding has none of those, so the pieces that differ by platform are now ports a binding supplies, with the browser implementations as the defaults. A web application configures nothing and behaves as before. transport on createSeamlessAuthClient and AuthProvider: cookie transport is unchanged; mode bearer makes the client hold the auth API's own tokens. Every request carries x-seamless-auth-transport: bearer; pre-auth routes carry the ephemeral token /login returned, kept in memory only; signed-in routes carry the access token; the pair a sign-in returns goes through a TokenStoragePort; a 401 on a signed-in route triggers one POST /refresh and one retry, with at most one refresh in flight because the auth API revokes the chain on a replayed refresh token. Which routes take which token is one table mirroring the server adapter's, not an annotation at each of forty call sites. PasskeyPort replaces the direct SimpleWebAuthn calls at the four ceremony sites and OAuthRedirectPort replaces window.location.assign in the provider buttons. createAuthSession takes the client options or a ready-made client and exposes the one it drives; useAuthClient returns that same instance, which bearer transport needs because the client holds the sign-in in flight. Refs #124, #125, #127, #128. --- .../client-ports-and-bearer-transport.md | 37 ++ AGENTS.md | 26 +- eslint.config.mjs | 2 +- packages/client/README.md | 35 +- .../src/client/createSeamlessAuthClient.ts | 45 +- packages/client/src/client/webauthnPrf.ts | 30 +- packages/client/src/fetchWithAuth.ts | 48 +-- packages/client/src/index.ts | 5 + packages/client/src/ports/browserPasskeys.ts | 26 ++ packages/client/src/ports/oauthRedirect.ts | 39 ++ packages/client/src/ports/passkeys.ts | 69 +++ packages/client/src/ports/tokenStorage.ts | 45 ++ .../client/src/session/createAuthSession.ts | 26 +- packages/client/src/transport.ts | 313 ++++++++++++++ packages/client/tests/authSession.test.ts | 50 +++ packages/client/tests/ports.test.ts | 104 +++++ packages/client/tests/transport.node.test.ts | 408 ++++++++++++++++++ packages/react/README.md | 36 +- packages/react/src/AuthProvider.tsx | 72 +++- .../src/components/OAuthProviderButtons.tsx | 25 +- packages/react/src/hooks/useAuthClient.ts | 19 +- packages/react/src/hooks/usePasskeySupport.ts | 9 +- packages/react/src/index.ts | 17 +- .../react/tests/OAuthProviderButtons.test.tsx | 60 ++- packages/react/tests/authProvider.test.tsx | 115 ++++- .../react/tests/magicLinkDestination.test.tsx | 41 +- packages/react/tests/useAuthClient.test.tsx | 26 +- .../react/tests/usePasskeySupport.test.tsx | 48 ++- 28 files changed, 1637 insertions(+), 139 deletions(-) create mode 100644 .changeset/client-ports-and-bearer-transport.md create mode 100644 packages/client/src/ports/browserPasskeys.ts create mode 100644 packages/client/src/ports/oauthRedirect.ts create mode 100644 packages/client/src/ports/passkeys.ts create mode 100644 packages/client/src/ports/tokenStorage.ts create mode 100644 packages/client/src/transport.ts create mode 100644 packages/client/tests/ports.test.ts create mode 100644 packages/client/tests/transport.node.test.ts diff --git a/.changeset/client-ports-and-bearer-transport.md b/.changeset/client-ports-and-bearer-transport.md new file mode 100644 index 0000000..11267c6 --- /dev/null +++ b/.changeset/client-ports-and-bearer-transport.md @@ -0,0 +1,37 @@ +--- +'@seamless-auth/client': minor +'@seamless-auth/react': minor +--- + +Put the platform behind ports, and add a bearer transport with token custody to the client core. + +The client spoke one contract: cookies to a server adapter at `/auth`, with the browser's WebAuthn +API and `window.location` reached directly. A native binding has none of those, so the pieces that +differ by platform are now ports a binding supplies, with the browser implementations as the +defaults. A web application configures nothing and behaves exactly as before. + +- `transport` on `createSeamlessAuthClient` (and `AuthProvider`): cookie transport is unchanged; + `{ mode: 'bearer', tokenStorage }` makes the client hold the auth API's own tokens. Every request + carries `x-seamless-auth-transport: bearer`; pre-auth routes carry the ephemeral token that + `/login` or `/registration/register` returned, kept in memory only; signed-in routes carry the + access token; the pair a sign-in returns is written through a `TokenStoragePort`; a 401 on a + signed-in route triggers one `POST /refresh` and one retry, with at most one refresh in flight + because the auth API revokes the chain on a replayed refresh token. Which routes take which + token is one table, mirroring the server adapter's, rather than an annotation at each of the + forty call sites. +- `PasskeyPort` (`isSupported`, `isPlatformAuthenticatorAvailable`, `create`, `get`) replaces the + direct SimpleWebAuthn calls at the four ceremony sites. `createBrowserPasskeyPort()` is the + default. A port reports an authenticator refusal with `PasskeyCeremonyError`, or any error with a + DOMException `name` and a string `code`, which the client turns into the same result a browser + failure gave. The PRF helpers no longer depend on SimpleWebAuthn at runtime. +- `OAuthRedirectPort` replaces `window.location.assign` in the built-in provider buttons. + `createBrowserOAuthRedirect()` is the default; a port that receives the callback itself resolves + with `code` and `state`, and the buttons finish the login on the spot. +- `TokenStoragePort` with `createMemoryTokenStorage()`. +- `createAuthSession` accepts the client options (or a ready-made `client`) and exposes the client + it drives as `session.client`. `useAuthClient()` returns that same instance rather than building a + second one, which bearer transport needs: the client holds the sign-in in flight. +- `AuthProvider` gains `transport` and `ports` props and exposes `client` and `ports` on the + context. `usePasskeySupport()` reads the passkey port. + +Tracks fells-code/seamless-auth-react#124, #125, #127 and #128. diff --git a/AGENTS.md b/AGENTS.md index f863936..e00cb02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,12 +86,26 @@ Common usage patterns: ## Runtime Model -This package assumes a Seamless Auth-compatible backend mounted under `/auth`. - -`createFetchWithAuth()` is the shared request helper: - -- it always sends `credentials: "include"` -- it targets `${authHost}/auth/...` +This package assumes a Seamless Auth-compatible backend (a server adapter) +mounted under `/auth`. + +`createTransport()` in `@seamless-auth/client` is the shared request layer, and +`createFetchWithAuth()` is the seam the client is built on: + +- cookie transport (the default, and what every browser application uses) sends + `credentials: "include"` to `${apiHost}/auth/...` and holds no tokens +- bearer transport (`mode: 'bearer'`, for native bindings) marks every request + with `x-seamless-auth-transport: bearer`, attaches the ephemeral token on + pre-auth routes and the access token on signed-in routes, stores the pair a + session-issuing response returns through a `TokenStoragePort`, and refreshes + once through `POST /refresh` on a 401 with at most one refresh in flight +- which routes take which token is one table, `ROUTE_RULES` in + `packages/client/src/transport.ts`, mirroring the server adapter's own map + +Platform differences sit behind ports the binding supplies: `PasskeyPort` (who +runs the WebAuthn ceremonies), `OAuthRedirectPort` (how the provider is opened), +`TokenStoragePort` (where a bearer session lives). The browser implementations +are the defaults, so a web application configures nothing. Important implication: diff --git a/eslint.config.mjs b/eslint.config.mjs index 371fda5..44837ca 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -50,7 +50,7 @@ export default [ // Jest only reads the `@jest-environment` pragma from the first comment in a // file, so these suites carry it alongside the license text in one block. // The license header is still present, it just is not a byte-for-byte match. - files: ['**/*.ssr.test.ts', '**/*.ssr.test.tsx'], + files: ['**/*.ssr.test.ts', '**/*.ssr.test.tsx', '**/*.node.test.ts'], rules: { 'license-header/header': 'off', }, diff --git a/packages/client/README.md b/packages/client/README.md index cc2ec42..3396dff 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -37,9 +37,38 @@ than redeclaring shapes. ## Transport -Today the client speaks the cookie contract: requests go to `${apiHost}/auth/*` -with `credentials: 'include'` and the server adapter holds the tokens. A bearer -transport for native clients is the next change to this package. +`createSeamlessAuthClient` takes a `transport` option: + +- cookie transport, the default: requests go to `${apiHost}/auth/*` with + `credentials: 'include'` and the server adapter holds the tokens. The browser + contract. +- bearer transport (`{ mode: 'bearer', tokenStorage }`): the client holds the auth + API's own tokens. Every request carries `x-seamless-auth-transport: bearer`; + pre-auth routes carry the ephemeral token `/login` or `/registration/register` + returned (kept in memory only), signed-in routes carry the access token; the + pair a sign-in returns is written through the `TokenStoragePort`; a 401 on a + signed-in route triggers one `POST /refresh` and one retry, with at most one + refresh in flight. The native contract. + +Which routes take which token is one table, `ROUTE_RULES` in `src/transport.ts`, +mirroring the server adapter's own map. + +## Ports + +- `PasskeyPort`: who runs the WebAuthn ceremonies. `createBrowserPasskeyPort()` + (SimpleWebAuthn) is the default. A port throws `PasskeyCeremonyError` (or an + error with a DOMException `name` and a string `code`) when the authenticator + refuses, which the client turns into the same result a browser failure gives. +- `TokenStoragePort`: where a bearer session lives. `createMemoryTokenStorage()` + is the default and does not survive a restart; a native binding supplies one + over the platform keystore. +- `OAuthRedirectPort`: how the provider is opened. `createBrowserOAuthRedirect()` + navigates the page; a native port opens an in-app browser session and resolves + with the callback's `code` and `state`. + +`createAuthSession` accepts the same client options, or a ready-made `client`, +and exposes the client it drives as `session.client` so a binding hands out one +instance rather than two. ## License diff --git a/packages/client/src/client/createSeamlessAuthClient.ts b/packages/client/src/client/createSeamlessAuthClient.ts index 82b3861..1cf76e4 100644 --- a/packages/client/src/client/createSeamlessAuthClient.ts +++ b/packages/client/src/client/createSeamlessAuthClient.ts @@ -4,14 +4,11 @@ * See LICENSE file in the project root for full license information */ -import { - startAuthentication, - startRegistration, - type AuthenticationResponseJSON, - type PublicKeyCredentialCreationOptionsJSON, - type PublicKeyCredentialRequestOptionsJSON, - type RegistrationResponseJSON, - WebAuthnError, +import type { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, } from '@simplewebauthn/browser'; import type { @@ -44,6 +41,9 @@ import type { } from '@seamless-auth/types'; import { createFetchWithAuth } from '../fetchWithAuth'; +import { createBrowserPasskeyPort } from '../ports/browserPasskeys'; +import { isPasskeyCeremonyError, type PasskeyPort } from '../ports/passkeys'; +import type { TransportOptions } from '../transport'; import { getWebAuthnErrorDetail } from './errors'; import { NETWORK_ERROR_STATUS, @@ -56,7 +56,6 @@ import { createPrfRequestBody, extractPasskeyPrfResult, getRegistrationPrfCapable, - isPasskeyPrfSupported, PasskeyPrfInput, PasskeyPrfResult, preparePrfRequestOptions, @@ -71,6 +70,14 @@ export interface SeamlessAuthClientOptions { * destination as the send it repeats. Omit it to keep the deployment's. */ magicLinkRedirectUri?: string; + /** + * How the session travels. Defaults to cookie transport, the browser + * contract. A native binding sets `mode: 'bearer'` and supplies a + * `tokenStorage` backed by the platform keystore. + */ + transport?: Omit; + /** Who runs the passkey ceremonies. Defaults to the browser. */ + passkeys?: PasskeyPort; } export interface LoginInput { @@ -423,8 +430,10 @@ export const createSeamlessAuthClient = ( opts: SeamlessAuthClientOptions ): SeamlessAuthClient => { const fetchWithAuth = createFetchWithAuth({ + ...opts.transport, authHost: opts.apiHost, }); + const passkeys = opts.passkeys ?? createBrowserPasskeyPort(); return { getCurrentUser: () => @@ -458,9 +467,7 @@ export const createSeamlessAuthClient = ( let assertionResponse: AuthenticationResponseJSON; try { - const credential = (await startAuthentication({ - optionsJSON: preparePrfRequestOptions(started.data), - })) as AuthenticationResponseJSON; + const credential = await passkeys.get(preparePrfRequestOptions(started.data)); prf = extractPasskeyPrfResult(credential); assertionResponse = stripPrfResultsFromAssertion(credential); } catch (error) { @@ -685,9 +692,9 @@ export const createSeamlessAuthClient = ( let attestationResponse: RegistrationResponseJSON; try { - attestationResponse = await startRegistration({ optionsJSON: challenge.data }); + attestationResponse = await passkeys.create(challenge.data); } catch (error) { - if (error instanceof WebAuthnError) { + if (isPasskeyCeremonyError(error)) { // The authenticator name is the useful detail here, for example // InvalidStateError when the passkey already exists. return resultError(error.name, NETWORK_ERROR_STATUS, undefined, error); @@ -720,7 +727,7 @@ export const createSeamlessAuthClient = ( return resultOf({ credentialId: attestationResponse.id, prfCapable }); }, - isPasskeyPrfSupported, + isPasskeyPrfSupported: async () => passkeys.isSupported(), getStepUpStatus: () => requestResult( @@ -741,9 +748,7 @@ export const createSeamlessAuthClient = ( let assertionResponse: AuthenticationResponseJSON; try { - const credential = (await startAuthentication({ - optionsJSON: preparePrfRequestOptions(started.data), - })) as AuthenticationResponseJSON; + const credential = await passkeys.get(preparePrfRequestOptions(started.data)); assertionResponse = stripPrfResultsFromAssertion(credential); } catch (error) { return webAuthnFailure( @@ -784,9 +789,7 @@ export const createSeamlessAuthClient = ( let assertionResponse: AuthenticationResponseJSON; try { - const credential = (await startAuthentication({ - optionsJSON: preparePrfRequestOptions(started.data), - })) as AuthenticationResponseJSON; + const credential = await passkeys.get(preparePrfRequestOptions(started.data)); prf = extractPasskeyPrfResult(credential); assertionResponse = stripPrfResultsFromAssertion(credential); } catch (error) { diff --git a/packages/client/src/client/webauthnPrf.ts b/packages/client/src/client/webauthnPrf.ts index adeb5eb..5bedb6a 100644 --- a/packages/client/src/client/webauthnPrf.ts +++ b/packages/client/src/client/webauthnPrf.ts @@ -4,15 +4,35 @@ * See LICENSE file in the project root for full license information */ -import { - base64URLStringToBuffer, - bufferToBase64URLString, - type AuthenticationResponseJSON, - type PublicKeyCredentialRequestOptionsJSON, +import type { + AuthenticationResponseJSON, + PublicKeyCredentialRequestOptionsJSON, } from '@simplewebauthn/browser'; import { isWebAuthnAvailable } from './webauthnSupport'; +// Local rather than SimpleWebAuthn's, so the PRF logic has no runtime +// dependency on the browser package and a native binding can use it as-is. +function bufferToBase64URLString(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function base64URLStringToBuffer(value: string): ArrayBuffer { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + export type PasskeyPrfSalt = ArrayBuffer | ArrayBufferView | string; export interface PasskeyPrfInput { diff --git a/packages/client/src/fetchWithAuth.ts b/packages/client/src/fetchWithAuth.ts index 3afb41d..a8f9c1d 100644 --- a/packages/client/src/fetchWithAuth.ts +++ b/packages/client/src/fetchWithAuth.ts @@ -4,35 +4,29 @@ * See LICENSE file in the project root for full license information */ -interface FetchWithAuthOptions { +import { + createTransport, + type FetchWithAuth, + type Transport, + type TransportOptions, +} from './transport'; + +export interface FetchWithAuthOptions extends Omit { authHost?: string; } -export const createFetchWithAuth = (opts: FetchWithAuthOptions) => { - const { authHost } = opts; - - return async function fetchWithAuth( - input: string, - init?: RequestInit - ): Promise { - const host = authHost?.replace(/\/+$/, '') ?? ''; - const path = input.startsWith('/') ? input : `/${input}`; - - const url = `${host}/auth${path}`; - - // Only declare a JSON content type when a body is actually sent. Some - // proxies reject a bodyless GET that advertises a request content type. - const hasBody = init?.body != null; - - const requestInit: RequestInit = { - ...init, - credentials: 'include', - headers: { - ...(hasBody ? { 'Content-Type': 'application/json' } : {}), - ...init?.headers, - }, - }; +/** + * The fetch every client method goes through. + * + * Kept as the seam the client is built on (and tests replace) while the work + * moved into `createTransport`: cookie transport is what this always did, and + * bearer transport is the same call with `mode: 'bearer'`. + */ +export const createFetchWithAuth = (opts: FetchWithAuthOptions): FetchWithAuth => { + return createFetchTransport(opts).fetch; +}; - return fetch(url, requestInit); - }; +export const createFetchTransport = (opts: FetchWithAuthOptions): Transport => { + const { authHost, ...transport } = opts; + return createTransport({ ...transport, apiHost: authHost ?? '' }); }; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 2811583..6607332 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -10,7 +10,12 @@ export * from './client/result'; export * from './client/webauthnPrf'; export * from './client/webauthnSupport'; export * from './fetchWithAuth'; +export * from './ports/browserPasskeys'; +export * from './ports/oauthRedirect'; +export * from './ports/passkeys'; +export * from './ports/tokenStorage'; export * from './scopedRoles'; export * from './session/createAuthSession'; export * from './session/storage'; +export * from './transport'; export * from './types'; diff --git a/packages/client/src/ports/browserPasskeys.ts b/packages/client/src/ports/browserPasskeys.ts new file mode 100644 index 0000000..8719dfa --- /dev/null +++ b/packages/client/src/ports/browserPasskeys.ts @@ -0,0 +1,26 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; + +import { + isPlatformAuthenticatorAvailable, + isWebAuthnAvailable, +} from '../client/webauthnSupport'; +import type { PasskeyPort } from './passkeys'; + +/** + * The browser's passkey ceremonies, over SimpleWebAuthn. This is the default + * port, so a web application configures nothing. + */ +export function createBrowserPasskeyPort(): PasskeyPort { + return { + isSupported: () => isWebAuthnAvailable(), + isPlatformAuthenticatorAvailable, + create: optionsJSON => startRegistration({ optionsJSON }), + get: optionsJSON => startAuthentication({ optionsJSON }), + }; +} diff --git a/packages/client/src/ports/oauthRedirect.ts b/packages/client/src/ports/oauthRedirect.ts new file mode 100644 index 0000000..05c416e --- /dev/null +++ b/packages/client/src/ports/oauthRedirect.ts @@ -0,0 +1,39 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +/** + * What happened after the provider was opened. + * + * On the web the page navigates away, so nothing comes back through this port: + * the provider returns the user to the callback route, which finishes the + * login. A native binding opens an in-app browser session and receives the + * callback URL directly, so it resolves with the `code` and `state` to finish + * the login with, or `cancelled` when the user dismissed it. + */ +export type OAuthRedirectOutcome = + | { type: 'navigated' } + | { type: 'callback'; code: string; state: string } + | { type: 'cancelled' }; + +export interface OAuthRedirectPort { + open(authorizationUrl: string, redirectUri: string): Promise; +} + +/** + * The browser's redirect: a full navigation to the provider. The default port + * for web applications. `navigate` exists for tests, since jsdom's + * `window.location` cannot be stubbed. + */ +export function createBrowserOAuthRedirect( + navigate: (url: string) => void = url => window.location.assign(url) +): OAuthRedirectPort { + return { + async open(authorizationUrl) { + navigate(authorizationUrl); + return { type: 'navigated' }; + }, + }; +} diff --git a/packages/client/src/ports/passkeys.ts b/packages/client/src/ports/passkeys.ts new file mode 100644 index 0000000..848d29d --- /dev/null +++ b/packages/client/src/ports/passkeys.ts @@ -0,0 +1,69 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import type { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/browser'; + +/** + * The passkey ceremonies, as the platform runs them. + * + * The auth API speaks WebAuthn JSON on both sides of a ceremony, so the only + * thing that differs between a browser and a native app is who prompts the + * user. A binding supplies this and the client keeps the flow logic. + */ +export interface PasskeyPort { + /** Whether this platform can run WebAuthn ceremonies at all. */ + isSupported(): boolean; + /** + * Whether a user-verifying platform authenticator (Touch ID, Face ID, + * Windows Hello, Android biometrics) is available for a new credential. + */ + isPlatformAuthenticatorAvailable(): Promise; + create( + optionsJSON: PublicKeyCredentialCreationOptionsJSON + ): Promise; + get( + optionsJSON: PublicKeyCredentialRequestOptionsJSON + ): Promise; +} + +/** + * What a port throws when the authenticator itself refuses or fails, as + * opposed to a network or programming error. `name` is the DOMException name + * (`NotAllowedError`, `InvalidStateError`, `SecurityError`), which is what the + * error readers and the built-in screens key off; `code` is a finer reason + * when the platform offers one. + */ +export class PasskeyCeremonyError extends Error { + readonly code: string; + readonly cause?: unknown; + + constructor(name: string, message: string, code = name, cause?: unknown) { + super(message); + this.name = name; + this.code = code; + this.cause = cause; + } +} + +/** + * True for a `PasskeyCeremonyError` and for the shape SimpleWebAuthn's own + * `WebAuthnError` has (a DOMException `name` plus a string `code`), so the + * browser port can pass the library's errors through unchanged. + */ +export function isPasskeyCeremonyError( + error: unknown +): error is Error & { code: string } { + return ( + error instanceof Error && + typeof (error as { code?: unknown }).code === 'string' && + error.name !== 'Error' + ); +} diff --git a/packages/client/src/ports/tokenStorage.ts b/packages/client/src/ports/tokenStorage.ts new file mode 100644 index 0000000..2b1e987 --- /dev/null +++ b/packages/client/src/ports/tokenStorage.ts @@ -0,0 +1,45 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +export interface StoredTokens { + accessToken: string; + refreshToken: string; +} + +/** + * Where a bearer-transport client keeps the session that outlives the process. + * + * Only the access and refresh tokens are stored. The ephemeral token of a + * sign-in in flight stays in memory: persisting it would widen its exposure + * without making any flow resumable, since the flow it belongs to is gone + * once the process is. + * + * Every platform keystore is asynchronous, so the port is. Implementations + * must not throw: a locked or unavailable keystore is a signed-out session, + * not a failed request. + */ +export interface TokenStoragePort { + get(): Promise; + set(tokens: StoredTokens): Promise; + remove(): Promise; +} + +/** Holds the session for the life of the process only. */ +export function createMemoryTokenStorage(): TokenStoragePort { + let tokens: StoredTokens | null = null; + + return { + async get() { + return tokens; + }, + async set(next) { + tokens = next; + }, + async remove() { + tokens = null; + }, + }; +} diff --git a/packages/client/src/session/createAuthSession.ts b/packages/client/src/session/createAuthSession.ts index 8f0a1e0..a019776 100644 --- a/packages/client/src/session/createAuthSession.ts +++ b/packages/client/src/session/createAuthSession.ts @@ -17,6 +17,8 @@ import { PasskeyMetadata, PasskeyRegistrationData, RegisterPasskeyOptions, + SeamlessAuthClient, + SeamlessAuthClientOptions, StartOAuthLoginInput, StartOAuthLoginResult, StepUpPrfData, @@ -89,11 +91,19 @@ export interface AuthSession { getState: () => AuthSessionState; subscribe: (listener: () => void) => () => void; actions: AuthSessionActions; + /** + * The client the store drives. Bindings hand this same instance to custom + * UI rather than building a second one: in bearer transport the client + * holds the sign-in in flight, and two clients would not see each other's. + */ + client: SeamlessAuthClient; destroy: () => void; } -export interface AuthSessionOptions { +export interface AuthSessionOptions extends Omit { apiHost: string; + /** A ready-made client. When given, the other client options are ignored. */ + client?: SeamlessAuthClient; storage?: SessionStoragePort; /** * When false, a previous sign-in is still recorded but never surfaced, so a UI @@ -112,9 +122,16 @@ const SIGNED_OUT = { } satisfies Partial; export function createAuthSession(options: AuthSessionOptions): AuthSession { - const { apiHost, detectPreviousSignIn = true } = options; - const client = createSeamlessAuthClient({ apiHost }); - const storage = options.storage ?? createDefaultStorage(); + const { + apiHost, + detectPreviousSignIn = true, + client: providedClient, + storage: providedStorage, + ...clientOptions + } = options; + const client = + providedClient ?? createSeamlessAuthClient({ ...clientOptions, apiHost }); + const storage = providedStorage ?? createDefaultStorage(); const listeners = new Set<() => void>(); let destroyed = false; @@ -364,6 +381,7 @@ export function createAuthSession(options: AuthSessionOptions): AuthSession { }; }, actions, + client, destroy: () => { destroyed = true; listeners.clear(); diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts new file mode 100644 index 0000000..bde9ad3 --- /dev/null +++ b/packages/client/src/transport.ts @@ -0,0 +1,313 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { + createMemoryTokenStorage, + type StoredTokens, + type TokenStoragePort, +} from './ports/tokenStorage'; + +/** + * How the client carries its session to the server adapter. + * + * `cookie` is the browser contract: the adapter holds the tokens in `httpOnly` + * cookies and this client never sees them. `bearer` is the native contract: + * this client holds the auth API's own tokens, presents the one a route needs + * in `Authorization`, and stores the ones a response issues. The adapter + * serves both on the same routes; the header below selects the second. + */ +export type AuthTransportMode = 'cookie' | 'bearer'; + +export const AUTH_TRANSPORT_HEADER = 'x-seamless-auth-transport'; + +export interface TransportOptions { + apiHost: string; + /** Where the server adapter is mounted. Defaults to `/auth`. */ + basePath?: string; + mode?: AuthTransportMode; + /** Required for bearer transport. Defaults to memory, which does not survive a restart. */ + tokenStorage?: TokenStoragePort; + /** The fetch to use. Defaults to the global one. */ + fetch?: typeof fetch; +} + +export type FetchWithAuth = (input: string, init?: RequestInit) => Promise; + +export interface Transport { + fetch: FetchWithAuth; + mode: AuthTransportMode; + /** Forgets the held session without calling the server. Bearer transport only; a no-op otherwise. */ + clearTokens(): Promise; +} + +/** + * Which token a route needs. `none` is a public route, `preAuth` continues a + * sign-in with the ephemeral token `/login` or `/registration/register` + * returned, and `access` is a signed-in route. + */ +type RequestIdentity = 'none' | 'preAuth' | 'access'; + +/** + * What a successful response does to the held session. `ephemeral` starts a + * flow, `issue` signs in (or rotates) and `end` signs out. + */ +type SessionEffect = 'ephemeral' | 'issue' | 'end'; + +interface RouteRule { + match: RegExp; + identity: RequestIdentity; + effect?: SessionEffect; +} + +/** + * The same map the server adapter keeps of which routes take which session. + * Kept here, in one place, rather than annotated at every call site in the + * client: a route that moves between identities upstream is a one-line change + * that cannot be missed at one of forty call sites. + */ +const ROUTE_RULES: readonly RouteRule[] = [ + { match: /^\/login$/, identity: 'none', effect: 'ephemeral' }, + { match: /^\/registration\/register$/, identity: 'none', effect: 'ephemeral' }, + { match: /^\/oauth\/providers$/, identity: 'none' }, + { match: /^\/oauth\/[^/]+\/start$/, identity: 'none' }, + { match: /^\/oauth\/[^/]+\/callback$/, identity: 'none', effect: 'issue' }, + { match: /^\/system-config\/public$/, identity: 'none' }, + { match: /^\/magic-link\/verify\/[^/]+$/, identity: 'none' }, + { match: /^\/magic-link$/, identity: 'preAuth' }, + { match: /^\/magic-link\/check$/, identity: 'preAuth', effect: 'issue' }, + { match: /^\/otp\/generate-/, identity: 'preAuth' }, + { match: /^\/otp\/verify-/, identity: 'preAuth', effect: 'issue' }, + { match: /^\/webAuthn\/login\/start$/, identity: 'preAuth' }, + { match: /^\/webAuthn\/login\/finish$/, identity: 'preAuth', effect: 'issue' }, + { match: /^\/refresh$/, identity: 'none', effect: 'issue' }, + { match: /^\/logout(\/all)?$/, identity: 'access', effect: 'end' }, + { match: /^\/users\/delete$/, identity: 'access', effect: 'end' }, + { match: /^\/organizations\/[^/]+\/switch$/, identity: 'access', effect: 'issue' }, +]; + +const DEFAULT_RULE: RouteRule = { match: /.*/, identity: 'access' }; + +export function resolveRouteRule(path: string): RouteRule { + return ROUTE_RULES.find(rule => rule.match.test(path)) ?? DEFAULT_RULE; +} + +function normalizePath(input: string): string { + return input.startsWith('/') ? input : `/${input}`; +} + +function buildUrl(apiHost: string, basePath: string, path: string): string { + const host = apiHost.replace(/\/+$/, ''); + const mount = basePath === '' ? '' : `/${basePath.replace(/^\/+|\/+$/g, '')}`; + return `${host}${mount}${path}`; +} + +function withHeaders( + init: RequestInit | undefined, + extra: Record +): RequestInit { + // Only declare a JSON content type when a body is actually sent. Some + // proxies reject a bodyless GET that advertises a request content type. + const hasBody = init?.body != null; + + return { + ...init, + headers: { + ...(hasBody ? { 'Content-Type': 'application/json' } : {}), + ...extra, + ...init?.headers, + }, + }; +} + +interface SessionBody { + token?: unknown; + refreshToken?: unknown; +} + +async function readSessionBody(response: Response): Promise { + try { + // Cloned so the caller can still read the body it was handed. + const data: unknown = await response.clone().json(); + return data && typeof data === 'object' ? (data as SessionBody) : null; + } catch { + return null; + } +} + +export function createTransport(options: TransportOptions): Transport { + const mode = options.mode ?? 'cookie'; + const basePath = options.basePath ?? '/auth'; + const fetchImpl = + options.fetch ?? ((...args: Parameters) => fetch(...args)); + + if (mode === 'cookie') { + return { + mode, + fetch: (input, init) => + fetchImpl( + buildUrl(options.apiHost, basePath, normalizePath(input)), + withHeaders({ ...init, credentials: 'include' }, {}) + ), + clearTokens: async () => undefined, + }; + } + + const storage = options.tokenStorage ?? createMemoryTokenStorage(); + + // The ephemeral token lives only here: it belongs to the sign-in in flight + // and nothing else, so it is never written to the keystore. + let ephemeralToken: string | undefined; + + // Read-through cache over the keystore, so a request does not pay for a + // keystore read and the pair is consistent within the process. + let cached: StoredTokens | null | undefined; + + async function readTokens(): Promise { + if (cached === undefined) { + cached = await storage.get(); + } + return cached; + } + + async function writeTokens(tokens: StoredTokens): Promise { + cached = tokens; + await storage.set(tokens); + } + + async function clearTokens(): Promise { + cached = null; + ephemeralToken = undefined; + await storage.remove(); + } + + async function authorizationFor( + identity: RequestIdentity + ): Promise { + if (identity === 'preAuth') { + return ephemeralToken ? `Bearer ${ephemeralToken}` : undefined; + } + + if (identity === 'access') { + const tokens = await readTokens(); + return tokens ? `Bearer ${tokens.accessToken}` : undefined; + } + + return undefined; + } + + async function applyEffect(effect: SessionEffect, response: Response): Promise { + if (effect === 'end') { + await clearTokens(); + return; + } + + const body = await readSessionBody(response); + if (!body || typeof body.token !== 'string') { + // A registration step that did not complete, or a poll that found + // nothing yet. Nothing to hold. + return; + } + + if (effect === 'ephemeral') { + ephemeralToken = body.token; + return; + } + + // A rotation that reissues only the access token (an organization switch) + // keeps the refresh token the session already has. + const refreshToken = + typeof body.refreshToken === 'string' + ? body.refreshToken + : (await readTokens())?.refreshToken; + + if (!refreshToken) { + return; + } + + await writeTokens({ accessToken: body.token, refreshToken }); + ephemeralToken = undefined; + } + + // One refresh at a time. The auth API rotates refresh tokens and treats a + // replayed one as theft, revoking the whole chain, so two requests that hit a + // 401 together must share a single rotation rather than each send the token. + let refreshing: Promise | null = null; + + function refreshOnce(): Promise { + if (!refreshing) { + refreshing = (async () => { + const tokens = await readTokens(); + if (!tokens) return false; + + const response = await fetchImpl( + buildUrl(options.apiHost, basePath, '/refresh'), + withHeaders( + { method: 'POST' }, + { + [AUTH_TRANSPORT_HEADER]: 'bearer', + Authorization: `Bearer ${tokens.refreshToken}`, + } + ) + ); + + if (!response.ok) { + // Whether the chain was revoked or the token merely expired, there is + // no session left to hold. + await clearTokens(); + return false; + } + + await applyEffect('issue', response); + return true; + })().finally(() => { + refreshing = null; + }); + } + + return refreshing; + } + + async function send( + path: string, + init: RequestInit | undefined, + identity: RequestIdentity + ) { + const authorization = await authorizationFor(identity); + + return fetchImpl( + buildUrl(options.apiHost, basePath, path), + withHeaders(init, { + [AUTH_TRANSPORT_HEADER]: 'bearer', + ...(authorization ? { Authorization: authorization } : {}), + }) + ); + } + + return { + mode, + clearTokens, + fetch: async (input, init) => { + const path = normalizePath(input); + const rule = resolveRouteRule(path); + + let response = await send(path, init, rule.identity); + + // An expired access token is the one 401 this layer can do something + // about. Retried once; a second 401 is the caller's to handle. + if (response.status === 401 && rule.identity === 'access' && (await readTokens())) { + if (await refreshOnce()) { + response = await send(path, init, rule.identity); + } + } + + if (response.ok && rule.effect) { + await applyEffect(rule.effect, response); + } + + return response; + }, + }; +} diff --git a/packages/client/tests/authSession.test.ts b/packages/client/tests/authSession.test.ts index f9c7134..f465b95 100644 --- a/packages/client/tests/authSession.test.ts +++ b/packages/client/tests/authSession.test.ts @@ -350,4 +350,54 @@ describe('createAuthSession', () => { expect(session.actions.hasScopedRole('admin:read')).toBe(true); }); }); + + describe('client ownership', () => { + it('exposes the client it drives so a binding can hand out the same instance', () => { + const session = buildSession(); + + expect(typeof session.client.login).toBe('function'); + expect(session.client).toBe(session.client); + }); + + it('drives an injected client instead of building one', async () => { + const getCurrentUser = jest.fn().mockResolvedValue({ + data: { user, credentials: [] }, + error: null, + }); + const client = { getCurrentUser } as never; + + const session = createAuthSession({ + apiHost, + client, + storage: createMemoryStorage(), + }); + await session.actions.refreshSession(); + + expect(session.client).toBe(client); + expect(getCurrentUser).toHaveBeenCalledTimes(1); + expect(session.getState().user).toEqual(user); + }); + + it('passes client options through when it builds the client itself', async () => { + const session = createAuthSession({ + apiHost, + magicLinkRedirectUri: 'https://app.example.com/magic', + transport: { basePath: '/identity' }, + storage: createMemoryStorage(), + }); + + mockFetchWithAuth.mockResolvedValueOnce(okResponse({ message: 'sent' })); + await session.client.requestMagicLink(); + + expect(createFetchWithAuth).toHaveBeenCalledWith( + expect.objectContaining({ authHost: apiHost, basePath: '/identity' }) + ); + expect(mockFetchWithAuth).toHaveBeenCalledWith( + '/magic-link', + expect.objectContaining({ + body: JSON.stringify({ redirectUri: 'https://app.example.com/magic' }), + }) + ); + }); + }); }); diff --git a/packages/client/tests/ports.test.ts b/packages/client/tests/ports.test.ts new file mode 100644 index 0000000..fddb4ef --- /dev/null +++ b/packages/client/tests/ports.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; + +import { createBrowserPasskeyPort } from '../src/ports/browserPasskeys'; +import { createBrowserOAuthRedirect } from '../src/ports/oauthRedirect'; +import { isPasskeyCeremonyError, PasskeyCeremonyError } from '../src/ports/passkeys'; +import { createMemoryTokenStorage } from '../src/ports/tokenStorage'; + +jest.mock('@simplewebauthn/browser', () => ({ + startAuthentication: jest.fn(), + startRegistration: jest.fn(), + browserSupportsWebAuthn: jest.fn(() => true), +})); + +describe('createBrowserPasskeyPort', () => { + it('runs the ceremonies through SimpleWebAuthn with the JSON it was handed', async () => { + (startRegistration as jest.Mock).mockResolvedValue({ id: 'cred-1' }); + (startAuthentication as jest.Mock).mockResolvedValue({ id: 'cred-1', response: {} }); + const port = createBrowserPasskeyPort(); + + const creation = { challenge: 'c', rp: { name: 'x' } } as never; + const request = { challenge: 'c' } as never; + + await expect(port.create(creation)).resolves.toEqual({ id: 'cred-1' }); + await expect(port.get(request)).resolves.toMatchObject({ id: 'cred-1' }); + expect(startRegistration).toHaveBeenCalledWith({ optionsJSON: creation }); + expect(startAuthentication).toHaveBeenCalledWith({ optionsJSON: request }); + }); + + it('reports support from the browser capability checks', () => { + const port = createBrowserPasskeyPort(); + expect(typeof port.isSupported()).toBe('boolean'); + }); +}); + +describe('PasskeyCeremonyError', () => { + it('carries the DOMException name, a code, and the cause', () => { + const cause = new Error('underlying'); + const error = new PasskeyCeremonyError( + 'NotAllowedError', + 'dismissed', + 'USER_CANCELLED', + cause + ); + + expect(error.name).toBe('NotAllowedError'); + expect(error.code).toBe('USER_CANCELLED'); + expect(error.message).toBe('dismissed'); + expect(error.cause).toBe(cause); + expect(isPasskeyCeremonyError(error)).toBe(true); + }); + + it('defaults the code to the name', () => { + expect(new PasskeyCeremonyError('InvalidStateError', 'exists').code).toBe( + 'InvalidStateError' + ); + }); + + it('recognises the shape SimpleWebAuthn throws and nothing looser', () => { + const webauthnShaped = Object.assign(new Error('x'), { + code: 'ERROR_CEREMONY_ABORTED', + }); + webauthnShaped.name = 'AbortError'; + expect(isPasskeyCeremonyError(webauthnShaped)).toBe(true); + + expect(isPasskeyCeremonyError(new Error('plain'))).toBe(false); + expect(isPasskeyCeremonyError({ name: 'NotAllowedError', code: 'x' })).toBe(false); + expect(isPasskeyCeremonyError(Object.assign(new Error('x'), { code: 'y' }))).toBe( + false + ); + expect(isPasskeyCeremonyError(null)).toBe(false); + }); +}); + +describe('createMemoryTokenStorage', () => { + it('holds a pair until removed', async () => { + const storage = createMemoryTokenStorage(); + + await expect(storage.get()).resolves.toBeNull(); + await storage.set({ accessToken: 'a', refreshToken: 'r' }); + await expect(storage.get()).resolves.toEqual({ accessToken: 'a', refreshToken: 'r' }); + await storage.remove(); + await expect(storage.get()).resolves.toBeNull(); + }); +}); + +describe('createBrowserOAuthRedirect', () => { + it('navigates the page to the provider and reports that it did', async () => { + const navigate = jest.fn(); + + const outcome = await createBrowserOAuthRedirect(navigate).open( + 'https://idp.example.com/authorize?state=s', + 'https://app.example.com/oauth/callback' + ); + + expect(navigate).toHaveBeenCalledWith('https://idp.example.com/authorize?state=s'); + expect(outcome).toEqual({ type: 'navigated' }); + }); +}); diff --git a/packages/client/tests/transport.node.test.ts b/packages/client/tests/transport.node.test.ts new file mode 100644 index 0000000..a1edd35 --- /dev/null +++ b/packages/client/tests/transport.node.test.ts @@ -0,0 +1,408 @@ +/** + * @jest-environment node + * + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + * + * Node rather than jsdom: the transport builds and reads real `Response` + * objects, which the jsdom environment does not provide. + */ +import { + createMemoryTokenStorage, + type TokenStoragePort, +} from '../src/ports/tokenStorage'; +import { + AUTH_TRANSPORT_HEADER, + createTransport, + resolveRouteRule, +} from '../src/transport'; + +const API = 'https://api.example.com'; + +type Call = { url: string; init: RequestInit }; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** A fetch that answers from a script and records what it was asked. */ +function scriptedFetch( + script: (call: Call, index: number) => Response | Promise +) { + const calls: Call[] = []; + const fetchImpl = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const call = { url: String(input), init: init ?? {} }; + calls.push(call); + return script(call, calls.length - 1); + }); + return { calls, fetchImpl: fetchImpl as unknown as typeof fetch }; +} + +function headersOf(call: Call): Record { + return (call.init.headers ?? {}) as Record; +} + +describe('resolveRouteRule', () => { + it.each([ + ['/login', 'none', 'ephemeral'], + ['/registration/register', 'none', 'ephemeral'], + ['/oauth/providers', 'none', undefined], + ['/oauth/google/start', 'none', undefined], + ['/oauth/google/callback', 'none', 'issue'], + ['/system-config/public', 'none', undefined], + ['/magic-link/verify/abc', 'none', undefined], + ['/magic-link', 'preAuth', undefined], + ['/magic-link/check', 'preAuth', 'issue'], + ['/otp/generate-login-email-otp', 'preAuth', undefined], + ['/otp/verify-email-otp', 'preAuth', 'issue'], + ['/webAuthn/login/start', 'preAuth', undefined], + ['/webAuthn/login/finish', 'preAuth', 'issue'], + ['/refresh', 'none', 'issue'], + ['/logout', 'access', 'end'], + ['/logout/all', 'access', 'end'], + ['/users/delete', 'access', 'end'], + ['/organizations/org-1/switch', 'access', 'issue'], + ['/users/me', 'access', undefined], + ['/webAuthn/register/start', 'access', undefined], + ['/step-up/status', 'access', undefined], + ['/organizations', 'access', undefined], + ])('%s takes the %s identity', (path, identity, effect) => { + const rule = resolveRouteRule(path); + expect(rule.identity).toBe(identity); + expect(rule.effect).toBe(effect); + }); +}); + +describe('cookie transport', () => { + it('sends credentials to the /auth mount and nothing else', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = createTransport({ apiHost: `${API}/`, fetch: fetchImpl }); + + await transport.fetch('users/me', { method: 'GET' }); + await transport.fetch('/login', { method: 'POST', body: '{}' }); + + expect(calls[0].url).toBe(`${API}/auth/users/me`); + expect(calls[0].init.credentials).toBe('include'); + expect(headersOf(calls[0])).toEqual({}); + + expect(calls[1].url).toBe(`${API}/auth/login`); + expect(headersOf(calls[1])).toEqual({ 'Content-Type': 'application/json' }); + expect(transport.mode).toBe('cookie'); + }); + + it('never touches token storage', async () => { + const storage: TokenStoragePort = { + get: jest.fn(async () => null), + set: jest.fn(async () => undefined), + remove: jest.fn(async () => undefined), + }; + const { fetchImpl } = scriptedFetch(() => + jsonResponse(200, { token: 'a', refreshToken: 'r' }) + ); + const transport = createTransport({ + apiHost: API, + fetch: fetchImpl, + tokenStorage: storage, + }); + + await transport.fetch('/otp/verify-login-email-otp', { method: 'POST', body: '{}' }); + await transport.clearTokens(); + + expect(storage.get).not.toHaveBeenCalled(); + expect(storage.set).not.toHaveBeenCalled(); + expect(storage.remove).not.toHaveBeenCalled(); + }); +}); + +describe('bearer transport', () => { + const bearer = (fetchImpl: typeof fetch, tokenStorage = createMemoryTokenStorage()) => + createTransport({ apiHost: API, mode: 'bearer', fetch: fetchImpl, tokenStorage }); + + it('marks every request with the transport header and omits credentials', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + + await bearer(fetchImpl).fetch('/system-config/public', { method: 'GET' }); + + expect(calls[0].init.credentials).toBeUndefined(); + expect(headersOf(calls[0])).toEqual({ [AUTH_TRANSPORT_HEADER]: 'bearer' }); + }); + + it('honours a custom mount path', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = createTransport({ + apiHost: API, + mode: 'bearer', + basePath: 'identity/', + fetch: fetchImpl, + }); + + await transport.fetch('/login', { method: 'POST', body: '{}' }); + + expect(calls[0].url).toBe(`${API}/identity/login`); + }); + + it('carries a sign-in from /login through a pre-auth step into a stored session', async () => { + const storage = createMemoryTokenStorage(); + const { calls, fetchImpl } = scriptedFetch(call => { + if (call.url.endsWith('/login')) { + return jsonResponse(200, { + message: 'Login continued', + token: 'ephemeral-1', + sub: 'u', + }); + } + if (call.url.endsWith('/otp/generate-login-email-otp')) { + return jsonResponse(200, { message: 'sent' }); + } + if (call.url.endsWith('/otp/verify-login-email-otp')) { + return jsonResponse(200, { + message: 'Success', + token: 'access-1', + refreshToken: 'refresh-1', + }); + } + return jsonResponse(200, { user: { id: 'u' } }); + }); + const transport = bearer(fetchImpl, storage); + + const started = await transport.fetch('/login', { method: 'POST', body: '{}' }); + // The caller can still read the body the transport peeked at. + expect(await started.json()).toMatchObject({ token: 'ephemeral-1' }); + expect(headersOf(calls[0]).Authorization).toBeUndefined(); + + await transport.fetch('/otp/generate-login-email-otp', { + method: 'POST', + body: '{}', + }); + expect(headersOf(calls[1]).Authorization).toBe('Bearer ephemeral-1'); + + const verified = await transport.fetch('/otp/verify-login-email-otp', { + method: 'POST', + body: '{}', + }); + expect(await verified.json()).toMatchObject({ token: 'access-1' }); + expect(await storage.get()).toEqual({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + }); + + await transport.fetch('/users/me', { method: 'GET' }); + expect(headersOf(calls[3]).Authorization).toBe('Bearer access-1'); + + // The ephemeral token is spent once a session exists. + await transport.fetch('/otp/generate-login-email-otp', { + method: 'POST', + body: '{}', + }); + expect(headersOf(calls[4]).Authorization).toBeUndefined(); + }); + + it('holds nothing when a pre-auth step returns no session yet', async () => { + const storage = createMemoryTokenStorage(); + const { fetchImpl } = scriptedFetch(() => + jsonResponse(200, { message: 'phone verified' }) + ); + + await bearer(fetchImpl, storage).fetch('/otp/verify-phone-otp', { + method: 'POST', + body: '{}', + }); + + expect(await storage.get()).toBeNull(); + }); + + it('never persists the ephemeral token', async () => { + const storage = createMemoryTokenStorage(); + const { fetchImpl } = scriptedFetch(() => + jsonResponse(200, { token: 'ephemeral-1', sub: 'u', ttl: 300 }) + ); + + await bearer(fetchImpl, storage).fetch('/registration/register', { + method: 'POST', + body: '{}', + }); + + expect(await storage.get()).toBeNull(); + }); + + it('attaches a session restored from storage on the first request', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access-cold', refreshToken: 'refresh-cold' }); + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, { user: {} })); + + await bearer(fetchImpl, storage).fetch('/users/me', { method: 'GET' }); + + expect(headersOf(calls[0]).Authorization).toBe('Bearer access-cold'); + }); + + it('refreshes once on a 401 and retries the request with the new token', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access-old', refreshToken: 'refresh-old' }); + const { calls, fetchImpl } = scriptedFetch(call => { + if (call.url.endsWith('/refresh')) { + return jsonResponse(200, { token: 'access-new', refreshToken: 'refresh-new' }); + } + return headersOf(call).Authorization === 'Bearer access-new' + ? jsonResponse(200, { user: { id: 'u' } }) + : jsonResponse(401, { error: 'unauthenticated' }); + }); + + const response = await bearer(fetchImpl, storage).fetch('/users/me', { + method: 'GET', + }); + + expect(response.status).toBe(200); + expect(calls.map(c => c.url.replace(`${API}/auth`, ''))).toEqual([ + '/users/me', + '/refresh', + '/users/me', + ]); + expect(headersOf(calls[1])).toMatchObject({ + Authorization: 'Bearer refresh-old', + [AUTH_TRANSPORT_HEADER]: 'bearer', + }); + expect(calls[1].init.method).toBe('POST'); + expect(await storage.get()).toEqual({ + accessToken: 'access-new', + refreshToken: 'refresh-new', + }); + }); + + it('collapses concurrent 401s into a single refresh', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access-old', refreshToken: 'refresh-old' }); + + let releaseRefresh: () => void = () => undefined; + const refreshGate = new Promise(resolve => { + releaseRefresh = resolve; + }); + + const { calls, fetchImpl } = scriptedFetch(async call => { + if (call.url.endsWith('/refresh')) { + await refreshGate; + return jsonResponse(200, { token: 'access-new', refreshToken: 'refresh-new' }); + } + return headersOf(call).Authorization === 'Bearer access-new' + ? jsonResponse(200, {}) + : jsonResponse(401, {}); + }); + const transport = bearer(fetchImpl, storage); + + const first = transport.fetch('/users/me', { method: 'GET' }); + const second = transport.fetch('/organizations', { method: 'GET' }); + // Both 401s have landed and both are parked on the one refresh. + await new Promise(resolve => setTimeout(resolve, 0)); + releaseRefresh(); + + const [a, b] = await Promise.all([first, second]); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + expect(calls.filter(c => c.url.endsWith('/refresh'))).toHaveLength(1); + }); + + it('clears the session and returns the 401 when the refresh is refused', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access-old', refreshToken: 'refresh-spent' }); + const { calls, fetchImpl } = scriptedFetch(call => + call.url.endsWith('/refresh') + ? jsonResponse(401, { error: 'refresh_token_reused' }) + : jsonResponse(401, { error: 'unauthenticated' }) + ); + + const response = await bearer(fetchImpl, storage).fetch('/users/me', { + method: 'GET', + }); + + expect(response.status).toBe(401); + expect(await storage.get()).toBeNull(); + // No retry without a session to retry with. + expect(calls).toHaveLength(2); + }); + + it('does not refresh a 401 on a pre-auth or public route', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access', refreshToken: 'refresh' }); + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(401, {})); + const transport = bearer(fetchImpl, storage); + + await transport.fetch('/otp/verify-login-email-otp', { method: 'POST', body: '{}' }); + await transport.fetch('/login', { method: 'POST', body: '{}' }); + + expect(calls.filter(c => c.url.endsWith('/refresh'))).toHaveLength(0); + }); + + it('does not refresh a 401 when there is no session to refresh', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(401, {})); + + await bearer(fetchImpl).fetch('/users/me', { method: 'GET' }); + + expect(calls).toHaveLength(1); + }); + + it('forgets the session when the user signs out', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access', refreshToken: 'refresh' }); + const { calls, fetchImpl } = scriptedFetch(() => new Response(null, { status: 204 })); + + await bearer(fetchImpl, storage).fetch('/logout', { method: 'DELETE' }); + + expect(headersOf(calls[0]).Authorization).toBe('Bearer access'); + expect(await storage.get()).toBeNull(); + }); + + it('keeps the session when sign-out fails upstream', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access', refreshToken: 'refresh' }); + const { fetchImpl } = scriptedFetch(() => + jsonResponse(503, { error: 'unavailable' }) + ); + + await bearer(fetchImpl, storage).fetch('/logout', { method: 'DELETE' }); + + expect(await storage.get()).not.toBeNull(); + }); + + it('keeps the refresh token when a rotation reissues only the access token', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access-1', refreshToken: 'refresh-1' }); + const { fetchImpl } = scriptedFetch(() => + jsonResponse(200, { token: 'access-org', sub: 'u', organizationId: 'org-1' }) + ); + + await bearer(fetchImpl, storage).fetch('/organizations/org-1/switch', { + method: 'POST', + }); + + expect(await storage.get()).toEqual({ + accessToken: 'access-org', + refreshToken: 'refresh-1', + }); + }); + + it('clearTokens drops the held session without a network call', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access', refreshToken: 'refresh' }); + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = bearer(fetchImpl, storage); + + await transport.clearTokens(); + await transport.fetch('/users/me', { method: 'GET' }); + + expect(await storage.get()).toBeNull(); + expect(headersOf(calls[0]).Authorization).toBeUndefined(); + }); + + it('tolerates a non-JSON success body on a session route', async () => { + const storage = createMemoryTokenStorage(); + const { fetchImpl } = scriptedFetch(() => new Response('ok', { status: 200 })); + + await expect( + bearer(fetchImpl, storage).fetch('/magic-link/check', { method: 'GET' }) + ).resolves.toBeInstanceOf(Response); + expect(await storage.get()).toBeNull(); + }); +}); diff --git a/packages/react/README.md b/packages/react/README.md index 2a06f95..34e9274 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -197,6 +197,38 @@ result. Custom UIs get the same default through `useAuthClient()`, and can still override a single send with `requestMagicLink(uri)`. +### Platform ports and transport + +Everything that differs between a browser and another platform sits behind a port on +`AuthProvider`. A web application configures nothing; the browser implementations are the +defaults. A binding for another platform (React Native is the first) supplies its own: + +```tsx + + + +``` + +- `transport` selects how the session travels. Cookie transport, the default, is the browser + contract: the server adapter holds the tokens in `httpOnly` cookies and the client never sees + them. Bearer transport is the native contract: the client holds the auth API's own tokens, + presents the one a route needs in `Authorization`, stores the pair a sign-in returns through + `tokenStorage`, and refreshes once through `POST /auth/refresh` when a request answers 401. At + most one refresh is in flight at a time, because the auth API treats a replayed refresh token as + theft and revokes the chain. +- `ports.passkeys` runs the WebAuthn ceremonies (`create`, `get`, and the two support checks). + `usePasskeySupport()` and every passkey flow go through it. +- `ports.oauthRedirect` opens the provider. The browser navigates away and the callback route + finishes the login; a port that receives the callback itself (an in-app browser session) + resolves with the `code` and `state`, and the built-in buttons finish the login on the spot. + +`useAuthClient()` returns the same client instance the provider's session drives. In bearer +transport that matters: the client holds the sign-in in flight, and a second client would not see it. + ### Scoped roles `hasRole(role)` remains an exact role check. Use `hasScopedRole(role)` for colon-separated scoped @@ -1059,8 +1091,10 @@ This package assumes a Seamless Auth-compatible backend with the auth adapter mo - Requests target `${apiHost}/auth/...` - `apiHost` may be provided with or without a trailing slash -- Requests are sent with `credentials: 'include'` +- In cookie transport (the default) requests are sent with `credentials: 'include'`; in bearer + transport they carry `x-seamless-auth-transport: bearer` and an `Authorization` header instead - `AuthProvider` validates the current session by calling `/users/me` on load +- Bearer transport additionally uses `POST /refresh` The built-in flows assume compatible endpoints for: diff --git a/packages/react/src/AuthProvider.tsx b/packages/react/src/AuthProvider.tsx index 4951d98..746bdaa 100644 --- a/packages/react/src/AuthProvider.tsx +++ b/packages/react/src/AuthProvider.tsx @@ -23,7 +23,15 @@ import { } from '@seamless-auth/client'; import type { SeamlessAuthResult } from '@seamless-auth/client'; import { PasskeyPrfInput } from '@seamless-auth/client'; -import { createAuthSession } from '@seamless-auth/client'; +import { + createAuthSession, + createBrowserOAuthRedirect, + createBrowserPasskeyPort, + type OAuthRedirectPort, + type PasskeyPort, + type SeamlessAuthClient, + type TransportOptions, +} from '@seamless-auth/client'; import { Credential, Organization, User } from '@seamless-auth/client'; import React, { createContext, @@ -78,6 +86,19 @@ export interface AuthContextType { ) => Promise>; verifyStepUpWithTotp: (code: string) => Promise>; loading: boolean; + /** The client behind the session. `useAuthClient()` returns this same instance. */ + client: SeamlessAuthClient; + /** The platform ports the built-in screens and hooks go through. */ + ports: AuthPorts; +} + +/** + * What a binding plugs in for its platform. The browser defaults cover a web + * application; a native binding supplies its own. + */ +export interface AuthPorts { + passkeys: PasskeyPort; + oauthRedirect: OAuthRedirectPort; } const AuthContext = createContext(undefined); @@ -103,6 +124,13 @@ interface AuthProviderProps { * send and a resend read it from here, so the two cannot drift apart. */ magicLinkRedirectUri?: string; + /** + * How the session travels to the server adapter. Defaults to cookie + * transport, which is what a browser application wants. + */ + transport?: Omit; + /** Platform ports. Each one left out falls back to the browser's. */ + ports?: Partial; } export const AuthProvider: React.FC = ({ @@ -110,14 +138,43 @@ export const AuthProvider: React.FC = ({ apiHost, autoDetectPreviousSignin = true, magicLinkRedirectUri, + transport, + ports: providedPorts, }) => { + // Memoised on what is inside the objects, not on the objects. Callers write + // these props inline, and a fresh session per render would sign the user out + // on every paint. + const ports = useMemo( + () => ({ + passkeys: providedPorts?.passkeys ?? createBrowserPasskeyPort(), + oauthRedirect: providedPorts?.oauthRedirect ?? createBrowserOAuthRedirect(), + }), + [providedPorts?.passkeys, providedPorts?.oauthRedirect] + ); + + const { mode, basePath, tokenStorage, fetch: fetchImpl } = transport ?? {}; + const stableTransport = useMemo( + () => (transport ? { mode, basePath, tokenStorage, fetch: fetchImpl } : undefined), + // eslint-disable-next-line react-hooks/exhaustive-deps -- `transport` itself is deliberately not a dependency + [mode, basePath, tokenStorage, fetchImpl] + ); + const session = useMemo( () => createAuthSession({ apiHost, + magicLinkRedirectUri, + transport: stableTransport, + passkeys: ports.passkeys, detectPreviousSignIn: autoDetectPreviousSignin, }), - [apiHost, autoDetectPreviousSignin] + [ + apiHost, + magicLinkRedirectUri, + stableTransport, + ports.passkeys, + autoDetectPreviousSignin, + ] ); // The store is the source of truth; React only reads snapshots from it. The @@ -145,8 +202,15 @@ export const AuthProvider: React.FC = ({ }, [session]); const value = useMemo( - () => ({ ...state, ...session.actions, apiHost, magicLinkRedirectUri }), - [state, session, apiHost, magicLinkRedirectUri] + () => ({ + ...state, + ...session.actions, + apiHost, + magicLinkRedirectUri, + client: session.client, + ports, + }), + [state, session, apiHost, magicLinkRedirectUri, ports] ); return {children}; diff --git a/packages/react/src/components/OAuthProviderButtons.tsx b/packages/react/src/components/OAuthProviderButtons.tsx index c1b7b48..d77139c 100644 --- a/packages/react/src/components/OAuthProviderButtons.tsx +++ b/packages/react/src/components/OAuthProviderButtons.tsx @@ -14,7 +14,7 @@ import styles from '../styles/login.module.css'; export const OAUTH_PROVIDER_STORAGE_KEY = 'seamless:oauth:provider'; const OAuthProviderButtons: React.FC = () => { - const { listOAuthProviders, startOAuthLogin } = useAuth(); + const { listOAuthProviders, startOAuthLogin, finishOAuthLogin, ports } = useAuth(); // useHref applies the router basename, so the callback URL stays correct for // apps mounted under a non-root basename (for example /app/oauth/callback). const callbackHref = useHref('/oauth/callback'); @@ -43,17 +43,30 @@ const OAuthProviderButtons: React.FC = () => { // The callback route reads this to know which provider to finish with. sessionStorage.setItem(OAUTH_PROVIDER_STORAGE_KEY, providerId); - const { data, error } = await startOAuthLogin({ - providerId, - redirectUri: new URL(callbackHref, window.location.origin).toString(), - }); + const redirectUri = new URL(callbackHref, window.location.origin).toString(); + const { data, error } = await startOAuthLogin({ providerId, redirectUri }); if (error) { setError('Could not start sign-in with this provider.'); return; } - window.location.assign(data.authorizationUrl); + // In a browser this navigates away and the callback route finishes the + // login. A port that hands the callback straight back (an in-app browser + // session) finishes it here instead. + const outcome = await ports.oauthRedirect.open(data.authorizationUrl, redirectUri); + + if (outcome.type === 'callback') { + const finished = await finishOAuthLogin({ + providerId, + code: outcome.code, + state: outcome.state, + }); + + if (finished.error) { + setError('Could not finish sign-in with this provider.'); + } + } }; return ( diff --git a/packages/react/src/hooks/useAuthClient.ts b/packages/react/src/hooks/useAuthClient.ts index 1e9bb17..59319df 100644 --- a/packages/react/src/hooks/useAuthClient.ts +++ b/packages/react/src/hooks/useAuthClient.ts @@ -4,20 +4,13 @@ * See LICENSE file in the project root for full license information */ -import { useMemo } from 'react'; - import { useAuth } from '@/AuthProvider'; -import { createSeamlessAuthClient } from '@seamless-auth/client'; +/** + * The client behind the provider's session. The same instance the store + * drives, not a second one: in bearer transport the client holds the sign-in + * in flight, and a second client would not see it. + */ export const useAuthClient = () => { - const { apiHost, magicLinkRedirectUri } = useAuth(); - - return useMemo( - () => - createSeamlessAuthClient({ - apiHost, - magicLinkRedirectUri, - }), - [apiHost, magicLinkRedirectUri] - ); + return useAuth().client; }; diff --git a/packages/react/src/hooks/usePasskeySupport.ts b/packages/react/src/hooks/usePasskeySupport.ts index c9b440b..05a7a71 100644 --- a/packages/react/src/hooks/usePasskeySupport.ts +++ b/packages/react/src/hooks/usePasskeySupport.ts @@ -6,9 +6,10 @@ import { useEffect, useState } from 'react'; -import { isPasskeySupported } from '@/utils'; +import { useAuth } from '@/AuthProvider'; export const usePasskeySupport = () => { + const { ports } = useAuth(); const [passkeySupported, setPasskeySupported] = useState(false); const [loading, setLoading] = useState(true); @@ -17,7 +18,9 @@ export const usePasskeySupport = () => { const checkSupport = async () => { try { - const supported = await isPasskeySupported(); + const supported = + ports.passkeys.isSupported() && + (await ports.passkeys.isPlatformAuthenticatorAvailable()); if (active) { setPasskeySupported(supported); } @@ -37,7 +40,7 @@ export const usePasskeySupport = () => { return () => { active = false; }; - }, []); + }, [ports.passkeys]); return { passkeySupported, loading }; }; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 3b72e12..71e29a2 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,7 +4,7 @@ * See LICENSE file in the project root for full license information */ -import { AuthContextType, AuthProvider, useAuth } from '@/AuthProvider'; +import { AuthContextType, AuthPorts, AuthProvider, useAuth } from '@/AuthProvider'; import { AuthRoutes } from '@/AuthRoutes'; import { createSeamlessAuthClient, @@ -72,6 +72,14 @@ import { OrganizationMembership, User, } from '@seamless-auth/client'; +import type { + OAuthRedirectOutcome, + OAuthRedirectPort, + PasskeyPort, + StoredTokens, + TokenStoragePort, + TransportOptions, +} from '@seamless-auth/client'; export { AuthProvider, @@ -96,6 +104,7 @@ export { export type { CredentialUpdateResult, AuthContextType, + AuthPorts, Credential, CreateOrganizationInput, CurrentUserResult, @@ -108,6 +117,8 @@ export type { OAuthErrorCode, OAuthProvider, OAuthProvidersResult, + OAuthRedirectOutcome, + OAuthRedirectPort, Organization, OrganizationMemberInput, OrganizationMembership, @@ -121,6 +132,7 @@ export type { PasskeyAttachment, PasskeyMetadata, PasskeyPolicyErrorCode, + PasskeyPort, PasskeyPrfInput, PasskeyPrfResult, PasskeyRegistrationData, @@ -135,8 +147,11 @@ export type { StepUpMethod, StepUpStatus, StepUpPrfData, + StoredTokens, + TokenStoragePort, TotpEnrollmentStartResult, TotpStatus, + TransportOptions, UpdateOrganizationInput, User, WebAuthnErrorDetail, diff --git a/packages/react/tests/OAuthProviderButtons.test.tsx b/packages/react/tests/OAuthProviderButtons.test.tsx index fcf8eab..5901817 100644 --- a/packages/react/tests/OAuthProviderButtons.test.tsx +++ b/packages/react/tests/OAuthProviderButtons.test.tsx @@ -22,11 +22,19 @@ const renderInRouter = (basename?: string) => describe('OAuthProviderButtons', () => { const listOAuthProviders = jest.fn(); const startOAuthLogin = jest.fn(); + const finishOAuthLogin = jest.fn(); + const open = jest.fn(); beforeEach(() => { jest.clearAllMocks(); window.sessionStorage.clear(); - (useAuth as jest.Mock).mockReturnValue({ listOAuthProviders, startOAuthLogin }); + open.mockResolvedValue({ type: 'navigated' }); + (useAuth as jest.Mock).mockReturnValue({ + listOAuthProviders, + startOAuthLogin, + finishOAuthLogin, + ports: { oauthRedirect: { open } }, + }); }); test('renders nothing when no providers are configured', async () => { @@ -84,4 +92,54 @@ describe('OAuthProviderButtons', () => { }) ); }); + + test('opens the provider through the redirect port', async () => { + listOAuthProviders.mockResolvedValue({ + data: { providers: [{ id: 'mock', name: 'Mock OIDC', scopes: [] }] }, + error: null, + }); + startOAuthLogin.mockResolvedValue({ + data: { authorizationUrl: 'http://idp.test/authorize' }, + error: null, + }); + + renderInRouter(); + fireEvent.click( + await screen.findByRole('button', { name: /Continue with Mock OIDC/ }) + ); + + await waitFor(() => + expect(open).toHaveBeenCalledWith( + 'http://idp.test/authorize', + `${window.location.origin}/oauth/callback` + ) + ); + expect(finishOAuthLogin).not.toHaveBeenCalled(); + }); + + test('finishes the login itself when the port hands the callback back', async () => { + listOAuthProviders.mockResolvedValue({ + data: { providers: [{ id: 'mock', name: 'Mock OIDC', scopes: [] }] }, + error: null, + }); + startOAuthLogin.mockResolvedValue({ + data: { authorizationUrl: 'http://idp.test/authorize' }, + error: null, + }); + open.mockResolvedValue({ type: 'callback', code: 'c-1', state: 's-1' }); + finishOAuthLogin.mockResolvedValue({ data: {}, error: null }); + + renderInRouter(); + fireEvent.click( + await screen.findByRole('button', { name: /Continue with Mock OIDC/ }) + ); + + await waitFor(() => + expect(finishOAuthLogin).toHaveBeenCalledWith({ + providerId: 'mock', + code: 'c-1', + state: 's-1', + }) + ); + }); }); diff --git a/packages/react/tests/authProvider.test.tsx b/packages/react/tests/authProvider.test.tsx index 9bb7452..235c465 100644 --- a/packages/react/tests/authProvider.test.tsx +++ b/packages/react/tests/authProvider.test.tsx @@ -5,7 +5,7 @@ */ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { StrictMode } from 'react'; +import React, { StrictMode } from 'react'; import { AuthProvider, useAuth } from '../src/AuthProvider'; import { createFetchWithAuth } from '../../client/src/fetchWithAuth'; @@ -469,6 +469,119 @@ describe('AuthProvider', () => { // store, so a provider that tore the store down on cleanup came back holding a // store that refused every update and never left `loading`. The templates ship // StrictMode, so this is the default path for a new app, not an edge case. + describe('ports and transport', () => { + const signedIn = () => + ({ + ok: true, + json: async () => ({ + user: { id: '1', email: 'test@example.com', phone: '', roles: [] }, + credentials: [], + }), + }) as any; + + it('hands the same client to useAuthClient that the session drives', async () => { + mockFetchWithAuthImpl.mockResolvedValue(signedIn()); + const seen: unknown[] = []; + + const Probe = () => { + const auth = useAuth(); + seen.push(auth.client); + return null; + }; + + await act(async () => { + render( + + + + ); + }); + + expect(seen.length).toBeGreaterThan(0); + expect(new Set(seen).size).toBe(1); + expect(typeof (seen[0] as { login: unknown }).login).toBe('function'); + }); + + it('passes transport and passkey ports through to the client', async () => { + mockFetchWithAuthImpl.mockResolvedValue(signedIn()); + const tokenStorage = { + get: jest.fn(async () => null), + set: jest.fn(async () => undefined), + remove: jest.fn(async () => undefined), + }; + const passkeys = { + isSupported: () => true, + isPlatformAuthenticatorAvailable: async () => true, + create: jest.fn(), + get: jest.fn(), + }; + let ports: unknown; + + const Probe = () => { + ports = useAuth().ports; + return null; + }; + + await act(async () => { + render( + + + + ); + }); + + expect(createFetchWithAuth).toHaveBeenCalledWith( + expect.objectContaining({ authHost: apiHost, mode: 'bearer', tokenStorage }) + ); + expect((ports as { passkeys: unknown }).passkeys).toBe(passkeys); + }); + + it('keeps one session across re-renders that pass fresh but equal prop objects', async () => { + mockFetchWithAuthImpl.mockResolvedValue(signedIn()); + const tokenStorage = { + get: jest.fn(async () => null), + set: jest.fn(async () => undefined), + remove: jest.fn(async () => undefined), + }; + const passkeys = { + isSupported: () => true, + isPlatformAuthenticatorAvailable: async () => true, + create: jest.fn(), + get: jest.fn(), + }; + + const Harness = ({ tick }: { tick: number }) => ( + + + {tick} + + ); + + let rerender: (ui: React.ReactElement) => void = () => undefined; + await act(async () => { + ({ rerender } = render()); + }); + await act(async () => { + rerender(); + }); + await act(async () => { + rerender(); + }); + + // One session, so one client, so one session read on mount. + expect(createFetchWithAuth).toHaveBeenCalledTimes(1); + expect(mockFetchWithAuthImpl).toHaveBeenCalledTimes(1); + }); + }); + describe('StrictMode remount', () => { it('settles a signed-out session instead of loading forever', async () => { // The adapter answers a missing access cookie with 400, which is the diff --git a/packages/react/tests/magicLinkDestination.test.tsx b/packages/react/tests/magicLinkDestination.test.tsx index 7b03df0..c9f4987 100644 --- a/packages/react/tests/magicLinkDestination.test.tsx +++ b/packages/react/tests/magicLinkDestination.test.tsx @@ -10,10 +10,13 @@ import Login from '@/views/Login'; import MagicLinkSent from '@/components/MagicLinkSent'; import { useAuth } from '@/AuthProvider'; import { createFetchWithAuth } from '../../client/src/fetchWithAuth'; +import { createSeamlessAuthClient } from '@seamless-auth/client'; import { useNavigate, useLocation } from 'react-router-dom'; // `useAuthClient` and the client itself stay real here: the whole point is that // the destination survives the trip from provider config into the request body. +// The provider is mocked, so the client it would have built is built here from +// the same config. jest.mock('@/AuthProvider'); jest.mock('../../client/src/fetchWithAuth'); jest.mock('@/utils', () => ({ @@ -32,6 +35,29 @@ jest.mock('@/components/AuthFallbackOptions', () => (props: any) => ( const REDIRECT_URI = 'https://app.example.com/auth/magic'; +const noPasskeys = { + isSupported: () => false, + isPlatformAuthenticatorAvailable: async () => false, + create: jest.fn(), + get: jest.fn(), +}; + +const authContext = (magicLinkRedirectUri: string | undefined) => ({ + apiHost: 'https://api.example.com', + magicLinkRedirectUri, + client: createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + magicLinkRedirectUri, + passkeys: noPasskeys, + }), + ports: { passkeys: noPasskeys }, + hasSignedInBefore: true, + refreshSession: jest.fn(), + listOAuthProviders: jest.fn().mockResolvedValue({ providers: [] }), + login: jest.fn().mockResolvedValue({ data: {}, error: null }), + handlePasskeyLogin: jest.fn().mockResolvedValue(false), +}); + const mockFetchWithAuth = jest.fn(); /** The body of every POST the client made to /magic-link, in order. */ @@ -53,15 +79,7 @@ describe('magic link destination in the bundled views', () => { state: { identifier: 'test@example.com' }, }); - (useAuth as jest.Mock).mockReturnValue({ - apiHost: 'https://api.example.com', - magicLinkRedirectUri: REDIRECT_URI, - hasSignedInBefore: true, - refreshSession: jest.fn(), - listOAuthProviders: jest.fn().mockResolvedValue({ providers: [] }), - login: jest.fn().mockResolvedValue({ data: {}, error: null }), - handlePasskeyLogin: jest.fn().mockResolvedValue(false), - }); + (useAuth as jest.Mock).mockReturnValue(authContext(REDIRECT_URI)); }); afterEach(() => { @@ -121,10 +139,7 @@ describe('magic link destination in the bundled views', () => { }); it('falls back to the deployment destination when none is configured', async () => { - (useAuth as jest.Mock).mockReturnValue({ - ...(useAuth as jest.Mock)(), - magicLinkRedirectUri: undefined, - }); + (useAuth as jest.Mock).mockReturnValue(authContext(undefined)); await sendFromLogin(); await resendFromMagicLinkSent(); diff --git a/packages/react/tests/useAuthClient.test.tsx b/packages/react/tests/useAuthClient.test.tsx index 22e0944..66db9c1 100644 --- a/packages/react/tests/useAuthClient.test.tsx +++ b/packages/react/tests/useAuthClient.test.tsx @@ -7,40 +7,22 @@ import { renderHook } from '@testing-library/react'; import { useAuth } from '@/AuthProvider'; -import { createSeamlessAuthClient } from '../../client/src/client/createSeamlessAuthClient'; import { useAuthClient } from '@/hooks/useAuthClient'; jest.mock('@/AuthProvider'); -jest.mock('../../client/src/client/createSeamlessAuthClient'); describe('useAuthClient', () => { - it('creates a client from the current auth config', () => { + it('returns the client the provider session already drives', () => { const client = { login: jest.fn() }; (useAuth as jest.Mock).mockReturnValue({ apiHost: 'https://api.example.com', + client, }); - (createSeamlessAuthClient as jest.Mock).mockReturnValue(client); const { result } = renderHook(() => useAuthClient()); - expect(createSeamlessAuthClient).toHaveBeenCalledWith({ - apiHost: 'https://api.example.com', - }); + // The same instance, not a copy: in bearer transport the client holds the + // sign-in in flight, and a second client would not see it. expect(result.current).toBe(client); }); - - it('passes the magic link destination through to the client', () => { - (useAuth as jest.Mock).mockReturnValue({ - apiHost: 'https://api.example.com', - magicLinkRedirectUri: 'https://app.example.com/magic', - }); - (createSeamlessAuthClient as jest.Mock).mockReturnValue({}); - - renderHook(() => useAuthClient()); - - expect(createSeamlessAuthClient).toHaveBeenCalledWith({ - apiHost: 'https://api.example.com', - magicLinkRedirectUri: 'https://app.example.com/magic', - }); - }); }); diff --git a/packages/react/tests/usePasskeySupport.test.tsx b/packages/react/tests/usePasskeySupport.test.tsx index ea4c5da..28a7cb3 100644 --- a/packages/react/tests/usePasskeySupport.test.tsx +++ b/packages/react/tests/usePasskeySupport.test.tsx @@ -6,20 +6,35 @@ import { renderHook, waitFor } from '@testing-library/react'; +import { useAuth } from '@/AuthProvider'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; -import { isPasskeySupported } from '@/utils'; -jest.mock('@/utils', () => ({ - isPasskeySupported: jest.fn(), -})); +jest.mock('@/AuthProvider'); + +function mockPorts(passkeys: { + isSupported?: () => boolean; + isPlatformAuthenticatorAvailable?: () => Promise; +}) { + (useAuth as jest.Mock).mockReturnValue({ + ports: { + passkeys: { + isSupported: passkeys.isSupported ?? (() => true), + isPlatformAuthenticatorAvailable: + passkeys.isPlatformAuthenticatorAvailable ?? (async () => true), + create: jest.fn(), + get: jest.fn(), + }, + }, + }); +} describe('usePasskeySupport', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('reports support when passkeys are available', async () => { - (isPasskeySupported as jest.Mock).mockResolvedValue(true); + it('reports support when the port has a platform authenticator', async () => { + mockPorts({}); const { result } = renderHook(() => usePasskeySupport()); @@ -30,8 +45,27 @@ describe('usePasskeySupport', () => { expect(result.current.passkeySupported).toBe(true); }); + it('reports unsupported when the platform cannot run WebAuthn at all', async () => { + const isPlatformAuthenticatorAvailable = jest.fn(async () => true); + mockPorts({ isSupported: () => false, isPlatformAuthenticatorAvailable }); + + const { result } = renderHook(() => usePasskeySupport()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.passkeySupported).toBe(false); + // No point asking for an authenticator on a platform without WebAuthn. + expect(isPlatformAuthenticatorAvailable).not.toHaveBeenCalled(); + }); + it('reports unsupported when the capability check fails', async () => { - (isPasskeySupported as jest.Mock).mockRejectedValue(new Error('unsupported')); + mockPorts({ + isPlatformAuthenticatorAvailable: async () => { + throw new Error('unsupported'); + }, + }); const { result } = renderHook(() => usePasskeySupport()); From a190e844f572f57527923c442f6927c27524fc1d Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 12 Sep 2026 19:59:12 -0700 Subject: [PATCH 2/2] feat(client): authorizedFetch for an application's own API A native app calls its backend's routes behind requireAuth with the access token, and needs the same refresh-and-retry the auth routes get. The transport gains authorizedFetch for any URL: credentials in cookie transport, the bearer token with one refresh on a 401 in bearer transport, and no token capture from the response since that body is the application's. Exposed as client.authorizedFetch (a path resolves on apiHost) and useAuthorizedFetch(). --- .../client-ports-and-bearer-transport.md | 4 + packages/client/README.md | 5 ++ .../src/client/createSeamlessAuthClient.ts | 23 +++++- packages/client/src/transport.ts | 56 ++++++++++---- packages/client/tests/authSession.test.ts | 16 +++- .../tests/createSeamlessAuthClient.test.ts | 41 ++++++++++- .../client/tests/sessionStorage.ssr.test.ts | 6 ++ packages/client/tests/transport.node.test.ts | 73 +++++++++++++++++++ packages/react/README.md | 9 +++ .../react/src/hooks/useAuthorizedFetch.ts | 14 ++++ packages/react/src/index.ts | 2 + packages/react/tests/authProvider.test.tsx | 15 +++- .../react/tests/magicLinkDestination.test.tsx | 11 ++- 13 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 packages/react/src/hooks/useAuthorizedFetch.ts diff --git a/.changeset/client-ports-and-bearer-transport.md b/.changeset/client-ports-and-bearer-transport.md index 11267c6..e73f45a 100644 --- a/.changeset/client-ports-and-bearer-transport.md +++ b/.changeset/client-ports-and-bearer-transport.md @@ -28,6 +28,10 @@ defaults. A web application configures nothing and behaves exactly as before. `createBrowserOAuthRedirect()` is the default; a port that receives the callback itself resolves with `code` and `state`, and the buttons finish the login on the spot. - `TokenStoragePort` with `createMemoryTokenStorage()`. +- `client.authorizedFetch(input, init)` and `useAuthorizedFetch()`: a fetch for the application's + own API that carries the session the way the transport does (cookies, or the access token with + one refresh-and-retry on a 401). A path resolves on `apiHost`. This is what a native app uses to + call routes behind `requireAuth`. - `createAuthSession` accepts the client options (or a ready-made `client`) and exposes the client it drives as `session.client`. `useAuthClient()` returns that same instance rather than building a second one, which bearer transport needs: the client holds the sign-in in flight. diff --git a/packages/client/README.md b/packages/client/README.md index 3396dff..74d529b 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -53,6 +53,11 @@ than redeclaring shapes. Which routes take which token is one table, `ROUTE_RULES` in `src/transport.ts`, mirroring the server adapter's own map. +`client.authorizedFetch(input, init)` is a fetch for the application's own API +that carries the session the same way: cookies in cookie transport, the access +token with one refresh-and-retry on a 401 in bearer transport. It never reads +tokens out of the response, since that body is the application's. + ## Ports - `PasskeyPort`: who runs the WebAuthn ceremonies. `createBrowserPasskeyPort()` diff --git a/packages/client/src/client/createSeamlessAuthClient.ts b/packages/client/src/client/createSeamlessAuthClient.ts index 1cf76e4..47d9426 100644 --- a/packages/client/src/client/createSeamlessAuthClient.ts +++ b/packages/client/src/client/createSeamlessAuthClient.ts @@ -40,7 +40,7 @@ import type { UpdateOrganizationRequest, } from '@seamless-auth/types'; -import { createFetchWithAuth } from '../fetchWithAuth'; +import { createFetchTransport } from '../fetchWithAuth'; import { createBrowserPasskeyPort } from '../ports/browserPasskeys'; import { isPasskeyCeremonyError, type PasskeyPort } from '../ports/passkeys'; import type { TransportOptions } from '../transport'; @@ -244,9 +244,17 @@ export interface LogoutOptions { * Every request resolves to a `SeamlessAuthResult`: check `error` first, then * read `data`. Nothing here throws for an HTTP or transport failure. * `isPasskeyPrfSupported` is the one exception, since it is a local capability - * check rather than a request. + * check rather than a request, and `authorizedFetch` returns the raw `Response` + * because the body is the application's, not this client's. */ export interface SeamlessAuthClient { + /** + * A request to the application's own API, carrying the session the way the + * transport does: cookies in cookie transport, the access token (refreshed + * once on a 401) in bearer transport. `input` is a full URL or a path on + * `apiHost`. + */ + authorizedFetch: (input: string | URL, init?: RequestInit) => Promise; getCurrentUser: () => Promise>; login: (input: LoginInput) => Promise>; loginWithPasskey: ( @@ -429,13 +437,22 @@ function webAuthnFailure( export const createSeamlessAuthClient = ( opts: SeamlessAuthClientOptions ): SeamlessAuthClient => { - const fetchWithAuth = createFetchWithAuth({ + const transport = createFetchTransport({ ...opts.transport, authHost: opts.apiHost, }); + const fetchWithAuth = transport.fetch; const passkeys = opts.passkeys ?? createBrowserPasskeyPort(); + const host = opts.apiHost.replace(/\/+$/, ''); + return { + authorizedFetch: (input, init) => + transport.authorizedFetch( + typeof input === 'string' && input.startsWith('/') ? `${host}${input}` : input, + init + ), + getCurrentUser: () => requestResult( fetchWithAuth(`users/me`, { method: 'GET' }), diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts index bde9ad3..74e42a9 100644 --- a/packages/client/src/transport.ts +++ b/packages/client/src/transport.ts @@ -37,7 +37,14 @@ export interface TransportOptions { export type FetchWithAuth = (input: string, init?: RequestInit) => Promise; export interface Transport { + /** A request to the server adapter's auth routes, by path under the mount. */ fetch: FetchWithAuth; + /** + * A request to any URL, carrying the session the way this transport does: + * cookies in cookie transport, the access token (refreshed once on a 401) in + * bearer transport. For an application's own API behind `requireAuth`. + */ + authorizedFetch: (input: string | URL, init?: RequestInit) => Promise; mode: AuthTransportMode; /** Forgets the held session without calling the server. Bearer transport only; a no-op otherwise. */ clearTokens(): Promise; @@ -151,6 +158,8 @@ export function createTransport(options: TransportOptions): Transport { buildUrl(options.apiHost, basePath, normalizePath(input)), withHeaders({ ...init, credentials: 'include' }, {}) ), + authorizedFetch: (input, init) => + fetchImpl(String(input), withHeaders({ ...init, credentials: 'include' }, {})), clearTokens: async () => undefined, }; } @@ -271,21 +280,41 @@ export function createTransport(options: TransportOptions): Transport { } async function send( - path: string, + url: string, init: RequestInit | undefined, - identity: RequestIdentity + identity: RequestIdentity, + markTransport: boolean ) { const authorization = await authorizationFor(identity); return fetchImpl( - buildUrl(options.apiHost, basePath, path), + url, withHeaders(init, { - [AUTH_TRANSPORT_HEADER]: 'bearer', + ...(markTransport ? { [AUTH_TRANSPORT_HEADER]: 'bearer' } : {}), ...(authorization ? { Authorization: authorization } : {}), }) ); } + // An expired access token is the one 401 this layer can do something about. + // Retried once; a second 401 is the caller's to handle. + async function sendWithRefresh( + url: string, + init: RequestInit | undefined, + identity: RequestIdentity, + markTransport: boolean + ) { + let response = await send(url, init, identity, markTransport); + + if (response.status === 401 && identity === 'access' && (await readTokens())) { + if (await refreshOnce()) { + response = await send(url, init, identity, markTransport); + } + } + + return response; + } + return { mode, clearTokens, @@ -293,15 +322,12 @@ export function createTransport(options: TransportOptions): Transport { const path = normalizePath(input); const rule = resolveRouteRule(path); - let response = await send(path, init, rule.identity); - - // An expired access token is the one 401 this layer can do something - // about. Retried once; a second 401 is the caller's to handle. - if (response.status === 401 && rule.identity === 'access' && (await readTokens())) { - if (await refreshOnce()) { - response = await send(path, init, rule.identity); - } - } + const response = await sendWithRefresh( + buildUrl(options.apiHost, basePath, path), + init, + rule.identity, + true + ); if (response.ok && rule.effect) { await applyEffect(rule.effect, response); @@ -309,5 +335,9 @@ export function createTransport(options: TransportOptions): Transport { return response; }, + // The transport header is the adapter's; an application's own API only + // needs the bearer token, which requireAuth reads. + authorizedFetch: (input, init) => + sendWithRefresh(String(input), init, 'access', false), }; } diff --git a/packages/client/tests/authSession.test.ts b/packages/client/tests/authSession.test.ts index f465b95..f49e7bd 100644 --- a/packages/client/tests/authSession.test.ts +++ b/packages/client/tests/authSession.test.ts @@ -6,7 +6,7 @@ import { createAuthSession } from '../src/session/createAuthSession'; import { createMemoryStorage, SessionStoragePort } from '../src/session/storage'; -import { createFetchWithAuth } from '../src/fetchWithAuth'; +import { createFetchTransport, createFetchWithAuth } from '../src/fetchWithAuth'; import { startRegistration } from '@simplewebauthn/browser'; jest.mock('../src/fetchWithAuth'); @@ -19,6 +19,12 @@ jest.mock('@simplewebauthn/browser', () => ({ const mockFetchWithAuth = jest.fn(); (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth); +(createFetchTransport as jest.Mock).mockImplementation(() => ({ + fetch: mockFetchWithAuth, + authorizedFetch: jest.fn(), + mode: 'cookie', + clearTokens: jest.fn(), +})); const apiHost = 'https://api.example.com'; @@ -44,6 +50,12 @@ describe('createAuthSession', () => { beforeEach(() => { jest.clearAllMocks(); (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth); + (createFetchTransport as jest.Mock).mockImplementation(() => ({ + fetch: mockFetchWithAuth, + authorizedFetch: jest.fn(), + mode: 'cookie', + clearTokens: jest.fn(), + })); }); it('starts signed out and loading, before anything is requested', () => { @@ -389,7 +401,7 @@ describe('createAuthSession', () => { mockFetchWithAuth.mockResolvedValueOnce(okResponse({ message: 'sent' })); await session.client.requestMagicLink(); - expect(createFetchWithAuth).toHaveBeenCalledWith( + expect(createFetchTransport).toHaveBeenCalledWith( expect.objectContaining({ authHost: apiHost, basePath: '/identity' }) ); expect(mockFetchWithAuth).toHaveBeenCalledWith( diff --git a/packages/client/tests/createSeamlessAuthClient.test.ts b/packages/client/tests/createSeamlessAuthClient.test.ts index b604bef..b9bdefd 100644 --- a/packages/client/tests/createSeamlessAuthClient.test.ts +++ b/packages/client/tests/createSeamlessAuthClient.test.ts @@ -5,7 +5,7 @@ */ import { createSeamlessAuthClient } from '../src/client/createSeamlessAuthClient'; -import { createFetchWithAuth } from '../src/fetchWithAuth'; +import { createFetchTransport, createFetchWithAuth } from '../src/fetchWithAuth'; import { startAuthentication, startRegistration, @@ -56,6 +56,12 @@ jest.mock('@simplewebauthn/browser', () => ({ const mockFetchWithAuth = jest.fn(); (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth); +(createFetchTransport as jest.Mock).mockImplementation(() => ({ + fetch: mockFetchWithAuth, + authorizedFetch: jest.fn(), + mode: 'cookie', + clearTokens: jest.fn(), +})); describe('createSeamlessAuthClient', () => { beforeEach(() => { @@ -64,6 +70,39 @@ describe('createSeamlessAuthClient', () => { (startAuthentication as jest.Mock).mockReset(); (startRegistration as jest.Mock).mockReset(); (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth); + (createFetchTransport as jest.Mock).mockImplementation(() => ({ + fetch: mockFetchWithAuth, + authorizedFetch: jest.fn(), + mode: 'cookie', + clearTokens: jest.fn(), + })); + }); + + it('authorizedFetch resolves a path on apiHost and passes a full URL through', async () => { + const authorizedFetch = jest.fn().mockResolvedValue({ ok: true }); + (createFetchTransport as jest.Mock).mockImplementation(() => ({ + fetch: mockFetchWithAuth, + authorizedFetch, + mode: 'bearer', + clearTokens: jest.fn(), + })); + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com/' }); + + await client.authorizedFetch('/api/plan', { method: 'GET' }); + await client.authorizedFetch('https://other.example.com/x'); + + expect(authorizedFetch).toHaveBeenNthCalledWith( + 1, + 'https://api.example.com/api/plan', + { + method: 'GET', + } + ); + expect(authorizedFetch).toHaveBeenNthCalledWith( + 2, + 'https://other.example.com/x', + undefined + ); }); it('forwards login requests through the shared auth fetch helper', async () => { diff --git a/packages/client/tests/sessionStorage.ssr.test.ts b/packages/client/tests/sessionStorage.ssr.test.ts index 874445a..abe73e7 100644 --- a/packages/client/tests/sessionStorage.ssr.test.ts +++ b/packages/client/tests/sessionStorage.ssr.test.ts @@ -11,6 +11,12 @@ import { createDefaultStorage } from '@/session/storage'; jest.mock('@/fetchWithAuth', () => ({ createFetchWithAuth: () => jest.fn(), + createFetchTransport: () => ({ + fetch: jest.fn(), + authorizedFetch: jest.fn(), + mode: 'cookie', + clearTokens: jest.fn(), + }), })); // Runs under the node environment, where `localStorage` genuinely does not diff --git a/packages/client/tests/transport.node.test.ts b/packages/client/tests/transport.node.test.ts index a1edd35..a365042 100644 --- a/packages/client/tests/transport.node.test.ts +++ b/packages/client/tests/transport.node.test.ts @@ -94,6 +94,23 @@ describe('cookie transport', () => { expect(transport.mode).toBe('cookie'); }); + it('authorizedFetch sends credentials to any URL untouched', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = createTransport({ apiHost: API, fetch: fetchImpl }); + + await transport.authorizedFetch(`${API}/api/plan`, { method: 'GET' }); + await transport.authorizedFetch(new URL('https://other.example.com/x'), { + method: 'POST', + body: '{}', + }); + + expect(calls[0].url).toBe(`${API}/api/plan`); + expect(calls[0].init.credentials).toBe('include'); + expect(headersOf(calls[0])).toEqual({}); + expect(calls[1].url).toBe('https://other.example.com/x'); + expect(headersOf(calls[1])).toEqual({ 'Content-Type': 'application/json' }); + }); + it('never touches token storage', async () => { const storage: TokenStoragePort = { get: jest.fn(async () => null), @@ -396,6 +413,62 @@ describe('bearer transport', () => { expect(headersOf(calls[0]).Authorization).toBeUndefined(); }); + it('authorizedFetch carries the access token to any URL without the transport header', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access-1', refreshToken: 'refresh-1' }); + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, { plan: [] })); + + const response = await bearer(fetchImpl, storage).authorizedFetch(`${API}/api/plan`, { + method: 'GET', + }); + + expect(response.status).toBe(200); + expect(calls[0].url).toBe(`${API}/api/plan`); + expect(calls[0].init.credentials).toBeUndefined(); + expect(headersOf(calls[0])).toEqual({ Authorization: 'Bearer access-1' }); + }); + + it('authorizedFetch refreshes once on a 401 and retries', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access-old', refreshToken: 'refresh-old' }); + const { calls, fetchImpl } = scriptedFetch(call => { + if (call.url.endsWith('/auth/refresh')) { + return jsonResponse(200, { token: 'access-new', refreshToken: 'refresh-new' }); + } + return headersOf(call).Authorization === 'Bearer access-new' + ? jsonResponse(200, { ok: true }) + : jsonResponse(401, { message: 'Unauthorized' }); + }); + + const response = await bearer(fetchImpl, storage).authorizedFetch(`${API}/api/plan`); + + expect(response.status).toBe(200); + expect(calls.map(c => c.url)).toEqual([ + `${API}/api/plan`, + `${API}/auth/refresh`, + `${API}/api/plan`, + ]); + expect(await storage.get()).toEqual({ + accessToken: 'access-new', + refreshToken: 'refresh-new', + }); + }); + + it('authorizedFetch never captures tokens from an application response', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access-1', refreshToken: 'refresh-1' }); + const { fetchImpl } = scriptedFetch(() => + jsonResponse(200, { token: 'not-ours', refreshToken: 'not-ours-either' }) + ); + + await bearer(fetchImpl, storage).authorizedFetch(`${API}/api/thing`); + + expect(await storage.get()).toEqual({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + }); + }); + it('tolerates a non-JSON success body on a session route', async () => { const storage = createMemoryTokenStorage(); const { fetchImpl } = scriptedFetch(() => new Response('ok', { status: 200 })); diff --git a/packages/react/README.md b/packages/react/README.md index 34e9274..0c62454 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -229,6 +229,15 @@ defaults. A binding for another platform (React Native is the first) supplies it `useAuthClient()` returns the same client instance the provider's session drives. In bearer transport that matters: the client holds the sign-in in flight, and a second client would not see it. +`useAuthorizedFetch()` (or `client.authorizedFetch`) is a fetch for your own API that carries the +session the way the transport does: `credentials: 'include'` in cookie transport, the access token +with one refresh-and-retry on a 401 in bearer transport. A path resolves on `apiHost`. + +```ts +const authorizedFetch = useAuthorizedFetch(); +const plan = await authorizedFetch('/api/plan/mine').then(r => r.json()); +``` + ### Scoped roles `hasRole(role)` remains an exact role check. Use `hasScopedRole(role)` for colon-separated scoped diff --git a/packages/react/src/hooks/useAuthorizedFetch.ts b/packages/react/src/hooks/useAuthorizedFetch.ts new file mode 100644 index 0000000..4bb9b6d --- /dev/null +++ b/packages/react/src/hooks/useAuthorizedFetch.ts @@ -0,0 +1,14 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { useAuth } from '@/AuthProvider'; + +/** + * A fetch for the application's own API that carries the session the way the + * provider's transport does: cookies for a web application, the access token + * (refreshed once on a 401) for a native one. A path resolves on `apiHost`. + */ +export const useAuthorizedFetch = () => useAuth().client.authorizedFetch; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 71e29a2..c1a4909 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -63,6 +63,7 @@ import { PasskeyPrfResult, } from '@seamless-auth/client'; import { useAuthClient } from '@/hooks/useAuthClient'; +import { useAuthorizedFetch } from '@/hooks/useAuthorizedFetch'; import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; import { hasScopedRole, roleGrantsAccess } from '@seamless-auth/client'; @@ -98,6 +99,7 @@ export { SeamlessAuthError, useAuth, useAuthClient, + useAuthorizedFetch, useLoginMethods, usePasskeySupport, }; diff --git a/packages/react/tests/authProvider.test.tsx b/packages/react/tests/authProvider.test.tsx index 235c465..0d44386 100644 --- a/packages/react/tests/authProvider.test.tsx +++ b/packages/react/tests/authProvider.test.tsx @@ -7,7 +7,10 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import React, { StrictMode } from 'react'; import { AuthProvider, useAuth } from '../src/AuthProvider'; -import { createFetchWithAuth } from '../../client/src/fetchWithAuth'; +import { + createFetchTransport, + createFetchWithAuth, +} from '../../client/src/fetchWithAuth'; jest.mock('../../client/src/fetchWithAuth'); @@ -15,6 +18,12 @@ jest.mock('../../client/src/fetchWithAuth'); const mockFetchWithAuthImpl = jest.fn(); // make createFetchWithAuth return our mock function (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuthImpl); +(createFetchTransport as jest.Mock).mockImplementation(() => ({ + fetch: mockFetchWithAuthImpl, + authorizedFetch: jest.fn(), + mode: 'cookie', + clearTokens: jest.fn(), +})); const Consumer = () => { const auth = useAuth(); @@ -534,7 +543,7 @@ describe('AuthProvider', () => { ); }); - expect(createFetchWithAuth).toHaveBeenCalledWith( + expect(createFetchTransport).toHaveBeenCalledWith( expect.objectContaining({ authHost: apiHost, mode: 'bearer', tokenStorage }) ); expect((ports as { passkeys: unknown }).passkeys).toBe(passkeys); @@ -577,7 +586,7 @@ describe('AuthProvider', () => { }); // One session, so one client, so one session read on mount. - expect(createFetchWithAuth).toHaveBeenCalledTimes(1); + expect(createFetchTransport).toHaveBeenCalledTimes(1); expect(mockFetchWithAuthImpl).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/react/tests/magicLinkDestination.test.tsx b/packages/react/tests/magicLinkDestination.test.tsx index c9f4987..410e6de 100644 --- a/packages/react/tests/magicLinkDestination.test.tsx +++ b/packages/react/tests/magicLinkDestination.test.tsx @@ -9,7 +9,10 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import Login from '@/views/Login'; import MagicLinkSent from '@/components/MagicLinkSent'; import { useAuth } from '@/AuthProvider'; -import { createFetchWithAuth } from '../../client/src/fetchWithAuth'; +import { + createFetchTransport, + createFetchWithAuth, +} from '../../client/src/fetchWithAuth'; import { createSeamlessAuthClient } from '@seamless-auth/client'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -69,6 +72,12 @@ const magicLinkBodies = (): string[] => describe('magic link destination in the bundled views', () => { beforeEach(() => { (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth); + (createFetchTransport as jest.Mock).mockImplementation(() => ({ + fetch: mockFetchWithAuth, + authorizedFetch: jest.fn(), + mode: 'cookie', + clearTokens: jest.fn(), + })); mockFetchWithAuth.mockResolvedValue({ ok: true, json: async () => ({ message: 'Success' }),