feat(api): accept OAuth 2.1 access tokens on /api/* - #238
Open
Waiel5 wants to merge 1 commit into
Open
Conversation
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.
This was referenced Aug 5, 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
This deployment already issues OAuth 2.1 access tokens, but only
/mcpaccepted them./api/*string-matches theBearer sk_API-key prefix and rejects anything else, so a client can complete authorization, be grantedemail: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.
/mcpalready did all of it.resolveOAuthPrincipalnow holds that logic and both callers use it, so the two bearer surfaces cannot drift./mcpkeeps its narrower audience and itsWWW-Authenticatechallenge; its handler loses ~2,700 characters of inline validation and behaves identically.Scopes are the boundary, not the audience. No
/apiaudience 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, andAuthorizationin 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 passedmcp-oauth+mcp-tools+ the new file, 85 passedprettier --check .cleanNotes for reviewers
On not adding an
/apiaudience. My first draft did, and verifiedaud === ${BASE_URL}/apiat the boundary. That is wrong on this version.@better-auth/oauth-provider@1.6.25is 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 strictaudcheck 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.4…1.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 removesvalidAudiencesforresourcesand 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}/enrollandPOST /api/outbox/{id}/retryall send mail from under a router whose other routes do not. A prefix-based table files them asemail:manageand lets a client that was never grantedemail:sendsend 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:managedoes 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'sforwardTo. 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 replacesinbox_permissionswholesale, 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 offorwardToalso 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/subscribelets 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 ansk_key still reaches a route the policy denies to tokens.Checklist
yarn db:generate) if the schema changed — n/aCHANGELOG.mdunder## [Unreleased]