Skip to content

feat(client): platform ports and a bearer transport with token custody - #150

Merged
Bccorb merged 2 commits into
mainfrom
feat/client-ports
Sep 13, 2026
Merged

feat(client): platform ports and a bearer transport with token custody#150
Bccorb merged 2 commits into
mainfrom
feat/client-ports

Conversation

@Bccorb

@Bccorb Bccorb commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #149 (merge that first; this PR's base then retargets to main). Covers #124, #125, #127, #128 and the transport/token-custody gap the mobile epic (fells-code/seamless-templates#40) did not file. Pairs with the adapter's bearer transport in fells-code/seamless-auth-server#164.

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. Everything that differs by platform is now a port a binding supplies, with the browser implementations as the defaults, so a web application configures nothing and behaves exactly as before.

@seamless-auth/client

  • Transport (src/transport.ts, behind the existing createFetchWithAuth seam). Cookie transport is byte-for-byte what the client did. { mode: 'bearer', tokenStorage }:
    • every request carries x-seamless-auth-transport: bearer and no credentials;
    • pre-auth routes carry the ephemeral token /login or /registration/register returned, kept in memory only (never persisted);
    • signed-in routes carry the access token; the pair a sign-in returns is written through TokenStoragePort (read-through cached); a rotation that reissues only the access token (organization switch) keeps the refresh token;
    • a 401 on a signed-in route triggers one POST /refresh with the refresh token and one retry; concurrent 401s share a single refresh (the auth API revokes the chain on a replayed refresh token); a refused refresh clears the session and returns the 401;
    • /logout, /logout/all, /users/delete clear the session on success.
    • Which routes take which token is one table, ROUTE_RULES, mirroring the server adapter's own map, instead of 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, the shape SimpleWebAuthn throws), which the client maps to the same result as before. The PRF helpers no longer depend on SimpleWebAuthn at runtime (local base64url).
  • OAuthRedirectPort with createBrowserOAuthRedirect(); a port that receives the callback itself resolves { type: 'callback', code, state }.
  • TokenStoragePort with createMemoryTokenStorage().
  • createAuthSession accepts the client options (or a ready-made client) and exposes session.client.

@seamless-auth/react

  • AuthProvider gains transport and ports props (memoised on their contents, so inline objects do not recreate the session), and exposes client and ports on the context.
  • useAuthClient() returns the session's client rather than building a second one. Bearer transport needs this: the client holds the sign-in in flight.
  • usePasskeySupport() reads the passkey port; OAuthProviderButtons opens the provider through the redirect port and finishes the login itself when the port hands the callback back.
  • Port types re-exported for bindings.

Test plan

  • npm run lint, npm run typecheck, npm run build, npx prettier --check .
  • npm test: 382 passing, coverage 90.81 / 81.30 / 90.62 / 90.92
  • New: client/tests/transport.node.test.ts (route table, cookie transport unchanged, bearer: header, mount path, login → pre-auth → stored session, ephemeral never persisted, cold start from storage, refresh+retry, concurrent 401s collapse to one refresh, refused refresh clears, no refresh on pre-auth/public/no-session, logout clears, failed logout keeps, access-only rotation keeps refresh token, clearTokens, non-JSON body), client/tests/ports.test.ts, session client-ownership cases, provider port/transport/memoisation cases, OAuth buttons port cases, rewritten useAuthClient and usePasskeySupport tests
  • Existing 327 tests pass unchanged apart from the provider-contract mocks (useAuth now carries client and ports)

Follow-ups

  • @seamless-auth/react-native (headless) on top of these ports.

Base automatically changed from chore/client-workspace to main September 13, 2026 03:02
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.
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().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant