Skip to content

feat(oauthserver): bind access tokens to RFC 8707 resource indicators - #2894

Open
melcheikh wants to merge 1 commit into
supabase:masterfrom
melcheikh:ccr-3e7991bb-d64gio
Open

melcheikh wants to merge 1 commit into
supabase:masterfrom
melcheikh:ccr-3e7991bb-d64gio

Conversation

@melcheikh

Copy link
Copy Markdown

What

Closes #2610.

The OAuth 2.1 server already accepts, validates and stores the resource parameter on /oauth/authorize, but never uses it: every access token is issued with aud: "authenticated". MCP clients (Claude, ChatGPT) send resource per the MCP authorization spec, and MCP servers that validate the audience reject these tokens.

This PR binds access tokens to the authorized resource:

"aud": ["authenticated", "https://mcp.example.com/mcp"]

Changes

  • Audience binding: the authorized resource is stored on the session (new nullable sessions.resource column) and added to aud on the code exchange and on every refresh. The user's audience stays first, so aud[0] (used by requestAud, /user, etc.) and Data API compatibility are unchanged.
  • Form-encoded token requests: resource was only read from JSON bodies, so the existing "resource must match the authorization" check was skipped for standard application/x-www-form-urlencoded requests. It is now read for both.
  • Refresh: a refresh request may include resource, but only the one granted for the session (RFC 8707 §2.2). Anything else is rejected.
  • Error code: invalid, mismatched or multiple resource values now return invalid_target (RFC 8707 §2) instead of invalid_request / invalid_grant.
  • Length limit: resources longer than 2048 characters are rejected at /oauth/authorize instead of failing on the oauth_authorizations_resource_length constraint with a server_error.
  • Consent screen: GET /oauth/authorizations/{id} returns resource, so a consent UI can show which resource server the client is asking for.
  • Docs: resource documented in openapi.yaml for /oauth/authorize, /oauth/token and the authorization details response.

Compatibility

  • Tokens issued without resource are unchanged (aud stays the string "authenticated").
  • Existing sessions have resource = NULL and keep their current tokens on refresh.
  • The Custom Access Token hook receives the array aud in claims; the hook schema already allows string | array.
  • Only one resource per authorization is supported; several values are rejected with invalid_target rather than silently using the first one.

Migration

20261030120000_add_resource_to_sessions.up.sql adds sessions.resource text null with the same 2048-character check as oauth_authorizations.resource. It is idempotent.

Tests

New internal/api/oauthserver/resource_indicator_test.go (form-encoded requests through OAuthToken / OAuthServerAuthorize):

  • code exchange binds aud, with and without repeating resource; session stores it
  • no resource keeps the default audience
  • mismatched / not-authorized / multiple resources → invalid_target, code not consumed
  • refresh keeps the audience; refresh with a resource not granted → invalid_target
  • authorize: resource stored and returned in authorization details; relative URI, fragment, query, too long and multiple values redirect with error=invalid_target and preserve state

go test ./internal/api/oauthserver/ ./internal/tokens/ ./internal/models/ ./internal/api/ pass locally against Postgres 16 (except tests that need outbound network/DNS, which fail the same way on master in my environment).

🤖 Generated with Claude Code

https://claude.ai/code/session_0187GfsUpzmeh8pDYi3eBwEq

…supabase#2610)

The OAuth 2.1 server accepted and stored the `resource` parameter but
never used it: every access token had `aud: "authenticated"`, so MCP
servers that validate the audience rejected them.

- Store the authorized resource on the session (new `sessions.resource`
  column) and add it to the access token `aud` claim on code exchange
  and every refresh. The user's audience stays first, so `aud[0]` and
  Data API compatibility are unchanged.
- Read `resource` from form-encoded token requests. Before, only JSON
  bodies were checked against the authorized resource.
- Reject a refresh that asks for a resource the session was not granted.
- Use the RFC 8707 `invalid_target` error code for invalid, mismatched
  or multiple resource values, at both endpoints.
- Enforce the 2048 character limit at the authorize endpoint instead of
  failing on the database constraint.
- Return `resource` in the authorization details so consent screens can
  show it, and document the parameter in openapi.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187GfsUpzmeh8pDYi3eBwEq
@melcheikh
melcheikh requested a review from a team as a code owner October 30, 2026 23:38

This branch has not been deployed

No deployments
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.

OAuth 2.1 server: support RFC 8707 Resource Indicators (resource parameter → aud claim)

1 participant