Skip to content

feat(transport): bearer transport on the auth routes and POST /refresh - #164

Merged
Bccorb merged 4 commits into
mainfrom
feat/bearer-transport
Sep 13, 2026
Merged

Bccorb merged 4 commits into
mainfrom
feat/bearer-transport

Conversation

@Bccorb

@Bccorb Bccorb commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #162 (merge that first; this PR's base will then be retargeted to main). Closes #163. Part of the mobile track fells-code/seamless-templates#40.

Every proxied /auth/* route assumed cookies. A native client has no cookie jar, so it could not sign in through the adapter, and going around it to the auth API directly would bypass message delivery (adopter templates), client IP / user agent forwarding, and the service token, and would need the auth API exposed.

A request that carries x-seamless-auth-transport: bearer now gets a bearer contract on the same routes:

  • The client presents the token a route needs in Authorization: Bearer (the ephemeral token /login or /registration/register returned on pre-auth routes, the access token on access routes). The adapter forwards it to the auth API as-is; the auth API decides whether it is the right kind for the route.
  • Session-issuing responses come back whole, token and refreshToken included, with no Set-Cookie. The auth API's RS256 signature on the access token is still verified before the body goes out (sessionResult).
  • ensureCookies is skipped. A route that needs an identity and gets no bearer token answers 401 with the same error a missing cookie gets.
  • The header, not the presence of Authorization, selects the transport, because the first request of a flow carries no token in either. Requests without the header are served exactly as before (the whole existing suite passes untouched).

POST /refresh is new in both adapters:

  • Bearer: Authorization: Bearer <refreshToken> in, rotated pair out. The auth API's failure body passes through so a client can tell refresh_token_reused (chain revoked, sign in again) from a transient error. Concurrent rotations of the same token are collapsed into one upstream call and a straggler within 5s gets the same pair, since the auth API treats a replayed refresh token as theft.
  • Cookie: rotates the refresh cookie into fresh session cookies via the existing refreshAccessToken dedupe.

Core surface

AuthTransport, resolveAuthTransport, AUTH_TRANSPORT_HEADER, BEARER_TRANSPORT, sessionResult, transport option on loginHandler, registerHandler, finishLoginHandler, OTP verify handlers, pollMagicLinkConfirmationHandler, switchOrganizationHandler, finishOAuthLoginHandler; transport + authorization on checkProxyIdentity; transport on applyResult; refreshBearerSession; refreshHandler.

Security notes

  • Bearer mode writes no cookies at all, including clears, so a browser cannot be talked into a cookie-less state by a header.
  • Upstream session bodies are verified (signature, issuer, audience, subject match) before passthrough, same check as cookie minting.
  • Failed refreshes are never cached; successes are cached 5s keyed by the raw refresh token (memory only, pruned and capped like the cookie path).
  • originGuard is unchanged; it is inert for native clients (no Origin / Sec-Fetch-Site).

Test plan

  • pnpm -r test: core 304, express 183, fastify 100 passing
  • New: core/tests/transport.test.js (transport resolution, identity check, applyResult, sessionResult, refreshBearerSession dedupe and failure passthrough), fastify/tests/bearerTransport.parity.test.js (full bearer sign-in through both adapters: login, OTP verify, me, refresh, reuse failure, logout, proxied route, cookie jar untouched; both adapters held to the same answer), express/tests/refreshRoute.test.js (cookie-mode /refresh)
  • Existing cookie-path tests unchanged and passing

Follow-ups

  • @seamless-auth/client bearer transport in the React SDK monorepo consumes this contract.
  • seamless-templates API starters pass authServerUrl/audience into requireAuth.

requireAuth and getSeamlessUser read req.cookies and nothing else, so a native
client, which has no cookie jar and holds the auth API's own tokens, was
rejected on every request to an adopter's routes.

requireAuth takes an optional authServerUrl + audience pair. With both set it
also accepts Authorization: Bearer <access token>, verified against the auth
API's JWKS and required to carry typ "access", so a sign-in flow's ephemeral
token is refused even though the same key signs it. The cookie wins when both
are present, and leaving the pair out keeps the guard cookie-only.

getSeamlessUser resolves a bearer session too. The router options already carry
the audience, so a request with no cookie but a valid bearer token is verified
the same way and forwarded to GET /users/me, while a request with neither, or a
token that fails verification, returns null without an upstream call.

Core exports verifyAccessToken, extractBearerToken, authenticateBearer and
authenticateRequest. The JWKS memo moves to a shared module so both verifiers
share one instance per auth server. Fastify gains direct guard tests, which it
had only through the parity suite.

Refs #147.
jose skips the claim check for an empty expected value, so a blank audience
would have verified against any audience. verifyAccessToken returns null for
an empty issuer or audience, and requireAuth refuses the pair at setup.
…/refresh

Every proxied route assumed cookies: identity came from the cookie payload,
session responses were stripped of their tokens and minted into cookies, and
silent refresh ran on the refresh cookie. A native app has no cookie jar, so
it could not sign in through the adapter, and going around it to the auth API
would bypass message delivery, client IP forwarding and the service token.

A request carrying x-seamless-auth-transport: bearer gets a bearer contract on
the same routes. The client presents the token a route needs in Authorization
and the adapter forwards it as-is. Session-issuing responses come back whole,
tokens included, with no Set-Cookie, and the access token's signature is still
verified before the body goes out. ensureCookies is skipped. Requests without
the header are served exactly as before.

POST /refresh is new in both adapters. In bearer transport it rotates from
Authorization: Bearer <refreshToken> and passes the auth API's failure body
through, collapsing concurrent rotations of one token into a single upstream
call. In cookie transport it rotates the refresh cookie into fresh cookies.

Closes #163.
@Bccorb
Bccorb merged commit 0222376 into main Sep 13, 2026
2 checks passed
@Bccorb
Bccorb deleted the feat/bearer-transport branch September 13, 2026 15:41
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.

feat(transport): bearer transport mode for the auth proxy

1 participant