Skip to content

feat(api): accept OAuth 2.1 access tokens on /api/* - #238

Open
Waiel5 wants to merge 1 commit into
choyiny:mainfrom
Waiel5:feat/oauth-bearer-api
Open

feat(api): accept OAuth 2.1 access tokens on /api/*#238
Waiel5 wants to merge 1 commit into
choyiny:mainfrom
Waiel5:feat/oauth-bearer-api

Conversation

@Waiel5

@Waiel5 Waiel5 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

This deployment already issues OAuth 2.1 access tokens, but only /mcp accepted them. /api/* string-matches the Bearer sk_ API-key prefix and rejects anything else, so a client can complete authorization, be granted email:read, and discover that the scope reaches nothing. A third-party or native client has no usable path to the API at all.

This makes tokens a third credential alongside the session cookie and API keys, with scopes enforced per route.

Depends on nothing, but reads best after #237 (which fixes the logged-out authorize leg a token client always takes).

Changes

One resolver, both surfaces. Tokens verify offline against JWKS, so a signature proves only that this deployment minted the token at some point; everything that can revoke a live one — disabling the client, deleting or banning the user, removing their passkey — is in the database and must be re-checked per request. /mcp already did all of it. resolveOAuthPrincipal now holds that logic and both callers use it, so the two bearer surfaces cannot drift. /mcp keeps its narrower audience and its WWW-Authenticate challenge; its handler loses ~2,700 characters of inline validation and behaves identically.

Scopes are the boundary, not the audience. No /api audience is added — see the note below.

The policy classifies on method plus exact path, with anything unclassified denied.

Closed to tokens outright: the credential surface, and a set of admin operations that escalate the principal or open a standing channel.

Plus admin:manage, GET /api/user/me, and Authorization in the CORS allow-list.

Release impact

  • Minor — new backwards-compatible behaviour

  • Feature

Test plan

  • yarn test worker/src/__tests__/oauth-api-access.test.ts — 33 passed
  • MCP suites after the refactor — mcp-oauth + mcp-tools + the new file, 85 passed
  • Wider auth regression — admin-router, api-keys-router, inbox-permissions, both MCP suites — 131 passed
  • prettier --check . clean

Notes for reviewers

On not adding an /api audience. My first draft did, and verified aud === ${BASE_URL}/api at the boundary. That is wrong on this version. @better-auth/oauth-provider@1.6.25 is affected by GHSA-p2fr-6hmx-4528 — the authorization-time resource is dropped, and the token endpoint will mint a token for another allowlisted audience, freshly signed with that audience. A strict aud check accepts it, so a distinct API audience would look like an authorization boundary without being one. The advisory says as much explicitly.

So the audience stays a sanity check and scopes do the real work. Worth flagging separately: the latest stable release is 1.6.26, also affected; the fix exists only in 1.7.0-beta.41.7.0-rc.4, which is presumably why Dependabot has not surfaced it — it does not propose prereleases. That upgrade is a bigger job than a version bump (1.7 removes validAudiences for resources and needs a schema migration), so it seemed like your call to time rather than something to force through a PR. Happy to open one if you want it.

On the three send-traps. POST /api/email-templates/{slug}/send, POST /api/sequences/{id}/enroll and POST /api/outbox/{id}/retry all send mail from under a router whose other routes do not. A prefix-based table files them as email:manage and lets a client that was never granted email:send send mail. That is the whole reason the policy is method-plus-path rather than prefix, and there are tests pinning each.

On the admin denials — the most contestable part. Holding admin:manage does not permit: rewriting inbox assignments, changing a user's role, minting or listing invite tokens, revoking OAuth clients, repointing the webhook, or setting an inbox's forwardTo. The reasoning is that a token is held by software, acts with no human present, and may be compromised without anyone noticing, so operations that escalate the principal or open a standing channel are a different risk from an admin doing them in a browser. Assignments is the sharpest: it replaces inbox_permissions wholesale, so a token could grant its own principal every inbox and then read all of it — turning a scoped grant into unscoped access in one call.

The cost is real and I want to name it: denying PATCH /api/admin/inboxes/{email} for the sake of forwardTo also costs a client the ability to rename an inbox, since the two cannot be separated by path alone. If you would rather split that route or gate on the body, say so and I will.

On what is deliberately not denied. /api/notifications/subscribe lets a caller attach a push endpoint, which is arguably an out-of-band channel — but it is also the entire point of the endpoint, and a client that can already read mail gains nothing by also being pinged about it. What keeps that true is the payload staying a wake-up rather than a copy of the message.

Nothing changes for existing callers. The scope middleware returns immediately unless the request authenticated as oauth; session and API-key callers remain unscoped exactly as before, and there is a test asserting an sk_ key still reaches a route the policy denies to tokens.

Checklist

  • Added or updated a migration (yarn db:generate) if the schema changed — n/a
  • Updated CHANGELOG.md under ## [Unreleased]
  • Updated docs — happy to add an integration section to the README if you want this documented for third-party clients

This deployment already issues OAuth access tokens, but only `/mcp` accepted
them. `/api/*` string-matched the `Bearer sk_` API-key prefix and rejected
anything else, so a client could complete authorization, be granted
`email:read`, and then find that the scope reached nothing. A third-party or
native client had no usable path to the API at all.

Tokens become a third credential alongside the session cookie and API keys.

**One resolver, both surfaces.** Access tokens verify offline against the
JWKS, so a signature proves only that this deployment minted the token at some
point; everything that can revoke a live one — disabling the client, deleting
or banning the user, removing their passkey — lives in the database and must be
re-checked per request. `/mcp` already did all of that. Rather than write it
twice, `resolveOAuthPrincipal` holds it and both callers use it, so the two
bearer surfaces cannot drift apart. `/mcp` keeps its narrower audience and its
`WWW-Authenticate` challenge; its handler loses about 2,700 characters of
inline validation and behaves identically.

**Scopes, not audience, are the boundary.** No `/api` audience is introduced.
`@better-auth/oauth-provider` at the pinned version is subject to
GHSA-p2fr-6hmx-4528, where the authorization-time resource is dropped and the
token endpoint will mint a token for another allowlisted audience — so a
distinct API audience would look like an authorization boundary without being
one, which is worse than not having it. Scopes do the work instead, checked per
route.

**The policy classifies on method plus exact path.** Three routes send mail
from under a router whose other routes do not — template send, sequence enroll
and outbox retry — so a prefix rule would file them as `email:manage` and let a
client that was never granted `email:send` send mail. Anything unclassified is
denied, so adding a route without classifying it breaks an integration rather
than quietly widening every existing token.

**Two things are closed to tokens outright.** The credential surface
(`/api/api-keys`, `/api/user/passkeys`, `/api/auth/*`), because minting an
unscoped API key would convert a narrow mail consent into the user's whole
account and destroy whatever key they already had. And a set of admin
operations that escalate the principal or open a standing channel: rewriting
inbox assignments (a token could grant itself every inbox, then read them),
changing a user's role, minting or listing invite tokens, revoking OAuth
clients, repointing the webhook, and setting an inbox's `forwardTo`. These are
a different risk from an admin doing the same in a browser — a token is held by
software, acts with no human present, and may be compromised without anyone
noticing. `admin:manage` is for operating the deployment, not for rewriting who
may operate it.

Session and API-key callers are untouched and remain unscoped; the scope
middleware returns immediately unless the request authenticated as `oauth`.

Also here because the same clients need them:

- `admin:manage`, never implied and required in addition to `role === "admin"`.
- `GET /api/user/me`, so a client can decide whether to offer admin screens
  without probing an admin route and reading the 403.
- `Authorization` in the CORS allow-list. Hono's default is empty, so a
  cross-origin client that preflighted a bearer request had the header stripped
  and the request blocked.
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