feat(client): platform ports and a bearer transport with token custody - #150
Merged
Conversation
4 tasks
13 tasks
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().
Bccorb
force-pushed
the
feat/client-ports
branch
from
September 13, 2026 14:17
026e86e to
a190e84
Compare
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 andwindow.locationreached 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/clientsrc/transport.ts, behind the existingcreateFetchWithAuthseam). Cookie transport is byte-for-byte what the client did.{ mode: 'bearer', tokenStorage }:x-seamless-auth-transport: bearerand nocredentials;/loginor/registration/registerreturned, kept in memory only (never persisted);TokenStoragePort(read-through cached); a rotation that reissues only the access token (organization switch) keeps the refresh token;POST /refreshwith 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/deleteclear the session on success.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 withPasskeyCeremonyError(or any error with a DOMExceptionnameand a stringcode, 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).OAuthRedirectPortwithcreateBrowserOAuthRedirect(); a port that receives the callback itself resolves{ type: 'callback', code, state }.TokenStoragePortwithcreateMemoryTokenStorage().createAuthSessionaccepts the client options (or a ready-madeclient) and exposessession.client.@seamless-auth/reactAuthProvidergainstransportandportsprops (memoised on their contents, so inline objects do not recreate the session), and exposesclientandportson 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;OAuthProviderButtonsopens the provider through the redirect port and finishes the login itself when the port hands the callback back.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.92client/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, rewrittenuseAuthClientandusePasskeySupporttestsuseAuthnow carriesclientandports)Follow-ups
@seamless-auth/react-native(headless) on top of these ports.