diff --git a/.changeset/client-ports-and-bearer-transport.md b/.changeset/client-ports-and-bearer-transport.md new file mode 100644 index 0000000..e73f45a --- /dev/null +++ b/.changeset/client-ports-and-bearer-transport.md @@ -0,0 +1,41 @@ +--- +'@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()`. +- `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. +- `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..74d529b 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -37,9 +37,43 @@ 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. + +`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()` + (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..47d9426 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 { @@ -43,7 +40,10 @@ 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'; 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 { @@ -237,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: ( @@ -422,11 +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' }), @@ -458,9 +484,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 +709,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 +744,7 @@ export const createSeamlessAuthClient = ( return resultOf({ credentialId: attestationResponse.id, prfCapable }); }, - isPasskeyPrfSupported, + isPasskeyPrfSupported: async () => passkeys.isSupported(), getStepUpStatus: () => requestResult( @@ -741,9 +765,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 +806,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..74e42a9 --- /dev/null +++ b/packages/client/src/transport.ts @@ -0,0 +1,343 @@ +/* + * 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 { + /** 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; +} + +/** + * 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' }, {}) + ), + authorizedFetch: (input, init) => + fetchImpl(String(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( + url: string, + init: RequestInit | undefined, + identity: RequestIdentity, + markTransport: boolean + ) { + const authorization = await authorizationFor(identity); + + return fetchImpl( + url, + withHeaders(init, { + ...(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, + fetch: async (input, init) => { + const path = normalizePath(input); + const rule = resolveRouteRule(path); + + const response = await sendWithRefresh( + buildUrl(options.apiHost, basePath, path), + init, + rule.identity, + true + ); + + if (response.ok && rule.effect) { + await applyEffect(rule.effect, response); + } + + 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 f9c7134..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', () => { @@ -350,4 +362,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(createFetchTransport).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/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/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/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 new file mode 100644 index 0000000..a365042 --- /dev/null +++ b/packages/client/tests/transport.node.test.ts @@ -0,0 +1,481 @@ +/** + * @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('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), + 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('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 })); + + 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..0c62454 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -197,6 +197,47 @@ 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. + +`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 @@ -1059,8 +1100,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/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/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..c1a4909 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, @@ -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'; @@ -72,6 +73,14 @@ import { OrganizationMembership, User, } from '@seamless-auth/client'; +import type { + OAuthRedirectOutcome, + OAuthRedirectPort, + PasskeyPort, + StoredTokens, + TokenStoragePort, + TransportOptions, +} from '@seamless-auth/client'; export { AuthProvider, @@ -90,12 +99,14 @@ export { SeamlessAuthError, useAuth, useAuthClient, + useAuthorizedFetch, useLoginMethods, usePasskeySupport, }; export type { CredentialUpdateResult, AuthContextType, + AuthPorts, Credential, CreateOrganizationInput, CurrentUserResult, @@ -108,6 +119,8 @@ export type { OAuthErrorCode, OAuthProvider, OAuthProvidersResult, + OAuthRedirectOutcome, + OAuthRedirectPort, Organization, OrganizationMemberInput, OrganizationMembership, @@ -121,6 +134,7 @@ export type { PasskeyAttachment, PasskeyMetadata, PasskeyPolicyErrorCode, + PasskeyPort, PasskeyPrfInput, PasskeyPrfResult, PasskeyRegistrationData, @@ -135,8 +149,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..0d44386 100644 --- a/packages/react/tests/authProvider.test.tsx +++ b/packages/react/tests/authProvider.test.tsx @@ -5,9 +5,12 @@ */ 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'; +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(); @@ -469,6 +478,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(createFetchTransport).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(createFetchTransport).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..410e6de 100644 --- a/packages/react/tests/magicLinkDestination.test.tsx +++ b/packages/react/tests/magicLinkDestination.test.tsx @@ -9,11 +9,17 @@ 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'; // `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 +38,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. */ @@ -43,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' }), @@ -53,15 +88,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 +148,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());