Skip to content

feat(guards): accept the auth API's access token as a bearer credential - #162

Merged
Bccorb merged 2 commits into
mainfrom
feat/bearer-guards
Sep 13, 2026
Merged

Bccorb merged 2 commits into
mainfrom
feat/bearer-guards

Conversation

@Bccorb

@Bccorb Bccorb commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

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. This is the first PR of the mobile track (fells-code/seamless-templates#40) and closes #147.

  • requireAuth (Express and Fastify) takes an optional authServerUrl + audience pair. With both configured it also accepts Authorization: Bearer <access token>, verified against the auth API's JWKS (issuer, audience, expiry) 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. Leaving the pair out keeps the guard cookie-only, which is what every earlier version did. Half of the pair throws at setup.
  • getSeamlessUser resolves a bearer session too. The router options already carry authServerUrl and audience, so no new option is needed there. A request with no access cookie but a valid bearer token is verified the same way and forwarded to GET /users/me; a request with neither, or a token that fails verification, returns null without an upstream call.
  • On the bearer path req.user.email and req.user.phone are unset (the access token does not carry them); getSeamlessUser hydrates the profile.
  • Core exports verifyAccessToken, extractBearerToken, authenticateBearer, authenticateRequest. The JWKS memo that verifySignedAuthResponse used moves to jwks.ts so both verifiers share one instance per auth server.
  • Fastify gains direct tests for requireAuth, requireRole and getSeamlessUser, which until now were covered only through the plugin parity suite.

Security notes

  • Bearer verification is RS256 only, checks iss, aud, exp and typ === "access", and fails closed on any error including a JWKS fetch failure.
  • A token that is not a JWT fails before any network call. jose's remote JWKS applies a refetch cooldown, so unknown kids cannot be used to hammer the auth API.
  • No token value is logged.

Test plan

  • pnpm -r test: core 285, express 180, fastify 81 passing
  • New: core/tests/verifyAccessToken.test.js, core/tests/authenticateBearer.test.js, core/tests/getSeamlessUser.bearer.test.js, express/tests/requireAuth.bearer.test.js, fastify/tests/guards.test.js
  • Existing cookie-path tests unchanged and passing

Follow-ups

  • Bearer transport mode for the /auth proxy (the client-facing half; issue to follow), then the 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.
@Bccorb

Bccorb commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Security review (adapter branch, both commits plus the transport PR stacked on top) found no exploitable issue. Two below-threshold notes, both addressed or documented:

  • An empty audience string would have made jose skip the aud check. verifyAccessToken now returns null for an empty issuer or audience, and requireAuth refuses the pair at setup (6dc3b04).
  • Access tokens carry aud = ISSUER for every adopter of one auth API, so a raw access token is a valid bearer credential at any backend sharing that API. That is the shared-user-pool design and matches what the cookie path already trusted upstream, but it is a boundary worth knowing about when enabling bearer on requireAuth.

@Bccorb
Bccorb merged commit 297db22 into main Sep 13, 2026
2 checks passed
@Bccorb
Bccorb deleted the feat/bearer-guards branch September 13, 2026 02:59
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(guards): accept bearer tokens in requireAuth and getSeamlessUser

1 participant