Skip to content

feat(oauth): add MCP / RFC 8707 + RFC 8414 compatibility#187

Merged
appleboy merged 19 commits into
mainfrom
worktree-oauth
May 17, 2026
Merged

feat(oauth): add MCP / RFC 8707 + RFC 8414 compatibility#187
appleboy merged 19 commits into
mainfrom
worktree-oauth

Conversation

@appleboy
Copy link
Copy Markdown
Member

@appleboy appleboy commented May 14, 2026

Summary

Adds OAuth 2.1 / MCP authorization spec compatibility to AuthGate so it can act
as a drop-in authorization server for any Model Context Protocol
deployment. Three gaps closed: RFC 8707 Resource Indicators (audience binding),
RFC 8414 /.well-known/oauth-authorization-server metadata, and CORS on the
/.well-known/* group for browser-based MCP clients.

No new env vars; behaviour is backward-compatible for OAuth clients that don't
supply resource (JWT_AUDIENCE config remains the fallback). See the
Breaking changes section below for source-level and operational concerns.

Scope note (post-review): during Copilot review, the resource-binding
surface was extended end-to-end to close audience-confusion gaps. In addition
to AuthorizationCode and AccessToken, both DeviceCode and
UserAuthorization are now resource-aware, and the device flow gained a
dedicated consent page. See Post-review revisions below.

Breaking changes

End-user OAuth clients (CLIs, web/mobile apps using the documented HTTP
surface) are unaffected — every new parameter is optional and behaviour
without resource is unchanged. The following items only matter for forks,
custom integrations, and operators with non-default deployments.

Source-level (forks / out-of-tree implementations)

  • core.TokenProvider interface signature change (internal/core/token.go).
    Any out-of-tree implementation will fail to compile:
    • GenerateToken, GenerateRefreshToken, GenerateClientCredentialsToken
      each gain a trailing audience []string parameter. Pass nil for the
      pre-PR behaviour.
    • RefreshAccessToken splits its single audience parameter into
      accessAudience, refreshAudience []string — keeping refresh JWTs from
      carrying a per-request RS aud. Pass nil, nil for the pre-PR
      behaviour.
    • New required method: ValidateRefreshToken(ctx, tokenString) (*TokenValidationResult, error).
    • Mocks regenerated in internal/mocks/mock_token.go; vendored copies
      must be refreshed.
  • Caller-supplied aud inside extra_claims is now unconditionally stripped
    at sign time. Callers that previously smuggled audience via extra_claims
    must move to the new resource parameter.

Database

  • New nullable Resource StringArray JSON column on AccessToken,
    DeviceCode, AuthorizationCode, and UserAuthorization. GORM
    AutoMigrate handles the migration on startup; existing rows have empty
    Resource. No data backfill required.
  • AccessToken.Resource has dual semantics — for access-token rows it
    is the JWT aud snapshot taken at issuance; for refresh-token rows it is
    the original grant's resource set (NOT the refresh JWT's aud). Anything
    reading this column directly (custom analytics, external migrations) must
    branch on TokenCategory. See the field's docstring for the full
    rationale.

Operational — JWT_AUDIENCE semantics

Refresh JWTs are signed with the static JWT_AUDIENCE as their aud claim
(unchanged from pre-PR). The PR explicitly tightens the constraint:
deployments MUST point JWT_AUDIENCE at an AS-only value, or leave it
unset. If a deployment currently uses JWT_AUDIENCE=<resource-server-id>,
change it before upgrading — otherwise a refresh JWT could be silently
accepted as an access token by a resource server that only verifies
signature/iss/exp/aud. Access tokens issued without a per-request resource
will then carry no aud (and resource servers should reject tokens whose
aud does not match their own identifier — RFC 8707 §2.2).

AI Authorship

  • No AI was used in this PR
  • AI was used. Details:
    • Tool / model: Claude Opus 4.7 (1M context) via Claude Code
    • AI-authored files: all 74 changed files
    • Human line-by-line reviewed: pending — author directed implementation
      via a written plan, 10 Copilot review rounds, two /simplify
      cycles
      , one /security-review pass (no high-confidence findings),
      and a three-round documentation update covering 11 docs files. Each
      review's findings were fixed in the same branch before this PR was marked
      ready. Reviewers should still expect to read carefully — see "Reviewer
      guide" below.

Change classification

  • Core code — touches authentication, token signing, JWT audience binding,
    and all four OAuth grant flows. Failure is system-wide.
  • Leaf node

Plan reference

/Users/mtk10671/.claude/plans/authgate-mcp-mutable-nebula.md — scope, allowed
files, three required e2e tests, and done-definition all defined there.

Verification

  • Unit tests — internal/util/resource_test.go, internal/util/slice_test.go
  • Integration tests — internal/services/token_resource_test.go (covers
    all four grants end-to-end with resource binding, subset acceptance on
    refresh, superset rejection, cascade-revoke linkage, and JWT-audience
    snapshot fallback)
  • At least 3 e2e tests (1 happy path + 2 errors) — and many more added
    during review:
    • TestAuthCodeFlow_WithResource_PropagatesToAud (happy path)
    • TestAuthorize_RejectsResourceWithFragment (RFC 8707 §2.1 validation)
    • TestRefresh_RejectsResourceSupersetOfOriginal (RFC 8707 §2.2 widening)
    • TestClientCredentials_WithResource_PropagatesToAud
    • TestDeviceCode_WithResource_PropagatesToAud
    • TestDeviceCode_RejectsResourceSupersetOfGrant
    • TestDeviceCode_RejectsResourceWhenNoneGranted
    • TestDeviceCode_LinksAuthorizationIDForCascadeRevoke
    • TestClientCredentials_NoResource_SnapshotsJWTAudience
    • TestRefresh_NarrowsResource_Subset
    • TestAuthorize_InvalidResource_RedirectsAfterValidation
    • TestAuthorize_UnsupportedResponseType_UnregisteredRedirectURI_NotReflected
    • TestAuthorize_InvalidResource_UnregisteredRedirectURI_NotReflected
    • TestDeviceCodeRequest_WithResource_PersistsOnDeviceCode
    • TestDeviceCodeRequest_InvalidResource_ReturnsInvalidTarget
    • TestIntrospectAudience
  • Discovery regression test pins existing OIDC field set so
    /.well-known/openid-configuration shape cannot drift
    (TestOIDCDiscovery_UnaffectedByOAuthMetadataAddition)
  • CORS tests (allow/reject/disabled) on the .well-known group
  • Stress / soak test: N/A — /.well-known/* responses are cached
    (Cache-Control: public, max-age=3600) and the token endpoint is unchanged
    in hot-path shape

Tester / QA — Manual Verification Guide

Run through these steps to confirm the PR works end-to-end. Time: ~10–15 minutes. The minimal "smoke" subset is steps 1, 2, 4, 5, 7 (≈5 minutes); the rest exercise edge cases and security invariants.

Prerequisites & setup

# 1. Check out the branch
git checkout worktree-oauth
make generate && make lint && make test    # all should pass; lint = 0 issues

# 2. Build and start the server with an AS-only JWT_AUDIENCE
#    (important — see Breaking changes § Operational)
make build
JWT_AUDIENCE=https://authgate.local \
  ./bin/authgate server

On first start, admin credentials and an initial CLIENT_ID are written to authgate-credentials.txt (mode 0600). Log in to http://localhost:8080 as admin and create a test OAuth client for the manual tests:

  • Admin → OAuth Clients → New Client
  • Client Type: Public (so PKCE applies)
  • Grant Types: Authorization Code Flow ✅ + Device Authorization Grant ✅
  • Redirect URIs: http://localhost:9999/callback
  • Scopes: read write

Save the generated client_id — referenced below as $CID.


Step 1: Automated test suite (30 seconds)

make test 2>&1 | tail -3

Expect: all tests PASS, including internal/util/resource_test.go, internal/util/audience_test.go, and internal/services/token_resource_test.go (16+ new tests covering all four grants with resource binding).

Step 2: RFC 8414 — new AS Metadata endpoint

curl -s http://localhost:8080/.well-known/oauth-authorization-server | jq

Expect to see resource_indicators_supported: true, device_authorization_endpoint, code_challenge_methods_supported: ["S256"]. The existing OIDC discovery endpoint should still respond unchanged:

curl -s http://localhost:8080/.well-known/openid-configuration | jq .issuer

Step 3: CORS preflight on /.well-known/* (for browser-based MCP clients)

Restart the server with CORS enabled:

JWT_AUDIENCE=https://authgate.local \
  CORS_ENABLED=true \
  CORS_ALLOWED_ORIGINS=https://app.example.com \
  ./bin/authgate server
# Allowed origin — expect 204 with ACAO header
curl -i -X OPTIONS http://localhost:8080/.well-known/oauth-authorization-server \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET"

# Disallowed origin — expect NO ACAO header
curl -i -X OPTIONS http://localhost:8080/.well-known/oauth-authorization-server \
  -H "Origin: https://evil.example.com" \
  -H "Access-Control-Request-Method: GET"

Step 4: Device Code Flow with resource binding (UI: DeviceConfirmPage)

# Request a device code bound to a resource
curl -s -X POST http://localhost:8080/oauth/device/code \
  -d "client_id=$CID" \
  -d "scope=read" \
  -d "resource=https://api.example.com" | jq
  1. Open the returned verification_uri_complete in a browser.
  2. Log in as a non-admin user (create one in Admin if needed).
  3. Verify: the page shown is the DeviceConfirmPage, NOT the legacy fast-path. It must list https://api.example.com under "Access will be granted to:" with a blue left-border chip and an arrow marker.
  4. Click Confirm and Authorize.
# Poll for token
DEVICE_CODE=...   # from step 4 above
curl -s -X POST http://localhost:8080/oauth/token \
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
  -d "device_code=$DEVICE_CODE" \
  -d "client_id=$CID" | jq

Save access_token and refresh_token for the next steps.

Step 5: Verify the JWT aud claim (the core RFC 8707 invariant)

# Decode access token payload
echo "$ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq

# Decode refresh token payload
echo "$REFRESH_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq

Expect:

Token aud claim type claim
Access token "https://api.example.com" (the per-request resource) "access"
Refresh token "https://authgate.local" (the AS-only JWT_AUDIENCE, NOT https://api.example.com) "refresh"

If the refresh token's aud matches the per-request resource, the audience-separation invariant is broken — fail the PR.

Step 6: Authorization Code Flow with resource (UI: authorize consent page)

Build a PKCE-enabled authorize URL:

VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-43)
CHALLENGE=$(echo -n $VERIFIER | openssl dgst -sha256 -binary | base64 | tr -d "=+/" | tr "/+" "_-")

open "http://localhost:8080/oauth/authorize?response_type=code&client_id=$CID&redirect_uri=http://localhost:9999/callback&scope=read&state=xyz&code_challenge=$CHALLENGE&code_challenge_method=S256&resource=https://api.example.com&resource=https://mcp.example.com"

On the consent page, verify:

  • "Requested Permissions" section shows the scope read with a green ✓ circle
  • "Token will be valid for" section shows the two resources with a blue bullseye marker (◎) in a tinted circle — NOT a green check
  • Each resource has a blue left-border accent (3px)
  • DevTools → Elements → search for <input type="hidden" name="resource"> and confirm two hidden inputs with the resource values

Click Allow Access and observe the redirect to localhost:9999/callback?code=.... Then exchange the code:

curl -s -X POST http://localhost:8080/oauth/token \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "redirect_uri=http://localhost:9999/callback" \
  -d "client_id=$CID" \
  -d "code_verifier=$VERIFIER" | jq

Decode the new access token — aud should be ["https://api.example.com", "https://mcp.example.com"].

Step 7: RFC 8707 §2.2 narrowing rule (subset accept, widen reject)

# Narrowing — subset of granted resources. Expect 200 OK.
curl -s -X POST http://localhost:8080/oauth/token \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$REFRESH_TOKEN" \
  -d "client_id=$CID" \
  -d "resource=https://api.example.com" | jq

# Widening — resource not in original grant. Expect 400 invalid_target.
curl -s -X POST http://localhost:8080/oauth/token \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$REFRESH_TOKEN" \
  -d "client_id=$CID" \
  -d "resource=https://other-api.example.com" | jq

Decode the new access token from the narrowing case — aud should be only "https://api.example.com" (single string, not array).

Step 8: Resource validation rejection (security-critical)

All of these should return 400 invalid_target:

# Non-http(s) scheme
curl -s -X POST http://localhost:8080/oauth/device/code \
  -d "client_id=$CID" -d "resource=javascript:alert(1)" | jq

# urn:-style identifier (not supported per AuthGate's stricter rules)
curl -s -X POST http://localhost:8080/oauth/device/code \
  -d "client_id=$CID" -d "resource=urn:mcp:my-server" | jq

# Fragment component
curl -s -X POST http://localhost:8080/oauth/device/code \
  -d "client_id=$CID" -d "resource=https://api.example.com/#frag" | jq

# Empty host
curl -s -X POST http://localhost:8080/oauth/device/code \
  -d "client_id=$CID" -d "resource=https://:443/path" | jq

Step 9: Remembered consent strict matching

  1. Re-run the Step 6 authorize URL with exactly the same resource set → expect auto-approve (no consent screen, direct redirect).
  2. Change one resource (drop mcp.example.com, keep api.example.com) → expect the consent screen to re-appear.
  3. Add a new resource → expect re-consent again.

This proves IsStringSliceSetEqual is enforced — a previously-approved consent is never silently extended to cover a different audience set.

Step 10: Cascade-revoke for device-code grants

  1. Visit /account/authorizations (logged in as the user from Step 4).
  2. Verify UI: the device-code grant appears with the resource(s) shown as blue <code> chips under "Resource(s):"
  3. Click Revoke Access.
  4. Use the access token from Step 4 against /oauth/tokeninfo:
    curl -s -X GET http://localhost:8080/oauth/tokeninfo \
      -H "Authorization: Bearer $ACCESS_TOKEN" | jq
    Expect invalid_token — before this PR, device-code tokens were orphaned from the consent row and revoke silently failed.

Step 11: Database migration

If upgrading an existing deployment:

# After server start, verify the new resource columns exist
sqlite3 authgate.db ".schema access_tokens" | grep -i resource
sqlite3 authgate.db ".schema device_codes" | grep -i resource
sqlite3 authgate.db ".schema authorization_codes" | grep -i resource
sqlite3 authgate.db ".schema user_authorizations" | grep -i resource

All four tables should now have a JSON resource column. Existing rows have empty resource and continue to work (fall back to JWT_AUDIENCE).

Optional: end-to-end with the demo CLI

git clone https://github.com/go-authgate/device-cli
cd device-cli && cp .env.example .env
# Fill in CLIENT_ID
go run main.go

Walks the device flow and confirms a real CLI client can complete the flow against this branch.


Pass criteria

The PR is verified once all of the following hold:

  • Step 1: make test, make lint pass cleanly
  • Step 2: AS metadata endpoint returns resource_indicators_supported: true
  • Step 3: CORS preflight returns ACAO for allowed origins, omits it for disallowed
  • Step 4: DeviceConfirmPage renders for resource-bound codes; legacy flow still works for non-resource codes
  • Step 5: Access token aud = per-request resource; refresh token aud = JWT_AUDIENCE (separation invariant)
  • Step 6: Authorize consent page distinguishes scopes (green ✓) from resources (blue ◎); resources propagate to issued JWT aud
  • Step 7: Subset narrowing accepted; widening rejected with invalid_target
  • Step 8: All four malformed resource shapes rejected with invalid_target
  • Step 9: Remembered consent re-prompts on resource-set mismatch
  • Step 10: Revoke from /account/authorizations invalidates device-code tokens
  • Step 11: Migration adds resource column to all four tables on existing DBs

Verifiability check

  • Inputs and outputs are documented — docs/MCP.md and inline godoc
  • Reviewer can judge correctness without reading every line — RFC §
    citations appear at every enforcement point
  • Failures will surface in monitoring — invalid_target errors flow through
    the existing OAuth error response path (counted by existing metrics)

Security check

  • No secrets in code
  • All external inputs validated — ValidateResourceIndicators rejects
    non-http(s) schemes (blocks javascript:/data:/file: aud values),
    empty strings, relative URIs, fragments, and caps the list at 10 entries
    to prevent DoS amplification
  • Permission checks tested — RFC 8707 §2.2 subset rule enforced on refresh,
    on authorization_code → token exchange, on device_code → token
    exchange (against the device-time grant), and on client_credentials
    (against JWT_AUDIENCE snapshot when no resource supplied); tested
    with dedicated tests
  • Open-redirect closed — POST deny path on /oauth/authorize and the
    device-flow consent page both honour the same redirect-URI allowlist
    check; invalid response_type / resource on an unregistered
    redirect_uri are not reflected back
  • Rate limits applied — existing per-endpoint limits on /oauth/* unchanged
  • Errors don't leak internals — invalid_target paths use generic RFC error
    descriptions
  • aud cannot be smuggled via extra_claims — explicit delete(claims, "aud") runs before the audience override applies (defense in depth)
  • Refresh aud is bound at issuance — snapshotted onto the access token
    and re-applied on refresh; refresh cannot widen aud beyond the original
    grant
  • Introspection aud is bound — /oauth/tokeninfo reflects the per-token
    aud rather than the static JWT_AUDIENCE config
  • Cascade-revoke linkage — device-code-issued tokens carry the originating
    authorization ID so admin revoke cleanly reaches all descendants
  • Templ auto-escaping on hidden <input name="resource"> rendering prevents
    reflected XSS

Risk & rollback

  • Riskcore.TokenProvider interface gained a trailing audience []string
    parameter on four methods (GenerateToken, GenerateRefreshToken,
    GenerateClientCredentialsToken, RefreshAccessToken). LocalTokenProvider
    is the only in-tree implementer; any external implementation would break
    at compile time. Mocks were updated in lockstep.

  • Risk — Four models gained nullable resource columns:

    • AuthorizationCode — captures the resource from /oauth/authorize
    • AccessToken — snapshots the issued aud so refresh cannot widen it
    • DeviceCode — captures the resource from /oauth/device/code
    • UserAuthorization — per-resource consent grants (one record per
      user+app+resource tuple, so granting two distinct resources to the same
      app no longer collapses into a single consent record)

    GORM AutoMigrate adds these transparently on Postgres and SQLite; no
    backfill required. Existing rows default to NULL/empty and fall back to
    JWT_AUDIENCE.

  • Riskinternal/templates/device_confirm_page.templ is new (device-flow
    consent screen). Existing device-flow users will see one extra confirm step
    on first authorization; subsequent uses re-use the stored UserAuthorization.

  • Rollback — reverting the PR returns aud to the static-config path.
    Existing tokens remain valid (the new columns default to NULL/empty, and
    the audience source falls back to JWT_AUDIENCE). The new templ template
    is embedded — no asset migration needed.

Reviewer guide

  • Read carefully:
    • internal/util/resource.go — RFC 8707 §2.1 validation (security-critical)
    • internal/handlers/authorization.go — POST deny path's redirect-URI
      allowlist check (open-redirect closure)
    • internal/handlers/token.go:handleAuthorizationCodeGrant — subset rule
      between authorize-time and token-time resource
    • internal/services/token_refresh.go — RFC 8707 §2.2 subset on refresh,
      plus aud snapshot re-application
    • internal/services/token_exchange.go — device-code → token resource
      subset check against the device-time grant
    • internal/services/device.go + internal/models/device_code.go — device
      resource persistence and consent-page rendering
    • internal/services/authorization.go + internal/models/user_authorization.go
      — per-resource consent grants (composite key changed)
    • internal/token/local.go:generateJWTaud source precedence (override
      vs config), explicit strip of caller-supplied aud
    • internal/handlers/oidc.go — new oauthASMetadata shape vs
      discoveryMetadata, plus introspection aud fix
  • Spot-check OK:
    • The services/token_*.go files threading resource end-to-end
      (mechanical change; tests cover the wiring)
    • Test-file mechanical updates that just append nil to call sites
    • Mock regeneration in internal/mocks/mock_token.go
    • internal/templates/device_confirm_page.templ rendering (templ
      auto-escaping handles the only user-controlled field)
  • MCP spec reference:

Post-review revisions

The branch went through multiple review phases after the initial implementation
commit. Commits are listed oldest → newest within each phase.

Copilot review rounds — audience invariants, open-redirect, race conditions

  • a4926d3 — first round of Copilot findings
  • b765220 — second round
  • 4e10389 — open-redirect closed, refresh-aud snapshot, audience invariants
  • 66e61d0 — device-code resource binding, introspection aud fix
  • eb92f3aUserAuthorization made resource-aware (composite key change)
  • b634503 — device confirm page, cascade-revoke linkage, swagger/docs
  • e41d8d2 — POST deny redirect, resource validation tighten, race fix,
    client-credentials snapshot aud
  • baf8cc4 — deny PKCE on public clients, refresh aud snapshot for grants
    with no resource, atomic device-consent transaction, tokeninfo aud emission
  • 0a591dc — in-transaction dc.IsExpired() re-check, introspect aud
    fallback, unsupported_response_type redirect path, DCR doc fix, revocation
    endpoint auth-methods metadata
  • 9ab39e9 — revert unsafe introspect aud live-config fallback (snapshot
    purity restored), use JWT-signed aud on refresh for legacy rows,
    handler-layer regression coverage for the device confirm page

Code simplification (/simplify pass)

  • 77cb30b — consolidate three near-duplicate audience helpers into
    util.AudienceClaim + util.AudienceFromClaims; extract narrowResource
    for the triplicated RFC 8707 §2.2 subset-and-fallback pattern across the
    device-code, authorization-code, and refresh-token grants; convert
    parseResourceParam to a free function; replace stringly-typed metric
    labels with models.TokenCategory* constants

UI polish — resource indicator visual treatment

The PR-added resource markup originally rendered with browser-default styles
(unstyled <ul> bullets, untreated <code>) and the authorize page reused
the green scope-permission check circle for resource arrows, creating a
semantic mismatch (audience target ≠ permission grant). Three commits fix this:

  • 8edb5f8 — style .device-resource-* / .authorization-resource-* /
    .device-cancel-link on the device authorization page, device confirm page,
    and account authorizations list
  • 581c0e7 — replace reused green scope-check circle on the authorize consent
    page with a dedicated bullseye SVG marker in a blue tinted circle, so
    audience targets visually read as a different security category from
    permission scopes

Documentation — three rounds covering 11 files

  • 15e18d4 (Round 1, critical surfaces) — README.md advertises MCP / RFC 8707
    / RFC 8414; docs/CONFIGURATION.md adds the JWT_AUDIENCE operational
    constraint and updated CORS scope; docs/JWT_VERIFICATION.md adds the
    Audience Binding (RFC 8707) section with aud + type claim
    verification; docs/MCP.md cross-links and JWT_AUDIENCE callout
  • ce76227 (Round 2, flow guides + security) — resource parameter on all
    three flow guides (AUTHORIZATION_CODE_FLOW.md, DEVICE_CODE_FLOW.md,
    CLIENT_CREDENTIALS_FLOW.md) with examples; the M2M multi-resource-server
    caveat; docs/SECURITY.md adds Token Replay Across RS and Refresh-as-Access
    Token Confusion to the threat model and four new production-checklist items
  • 62ae7d5 (Round 3, depth) — docs/ARCHITECTURE.md documents the four new
    Resource columns with dual access-vs-refresh semantics plus the
    core.TokenProvider interface signature change; docs/USE_CASES.md adds
    the MCP Server Authorization Server and Multi-Resource-Server
    Audience Binding
    scenarios; docs/TROUBLESHOOTING.md adds three new
    entries — invalid_target debug table (11 root causes), refresh-as-access
    token-confusion mitigation, consent re-prompt on resource-set mismatch
  • 9793298, 4d080d2 — markdown formatter normalizations

Security review

A /security-review pass against the post-revision branch found no
high-confidence security vulnerabilities
newly introduced by this PR. The
prior 10 Copilot rounds had already closed the obvious attack vectors
(open-redirect, refresh-as-access token confusion, audience binding leaks,
consent re-prompt strict matching, snapshot purity for introspection). Full
report in the review conversation.

Net delta vs main

  • 74 files changed, +5285 / −548 lines (post-PR description update,
    Round 3 docs landed)
  • Bulk of the delta beyond the initial implementation is test coverage
    (16+ new tests), the device confirm template, the audience-helper
    consolidation, and the three-round documentation update

- Add RFC 8707 Resource Indicators across authorization_code, device_code,
  refresh_token, and client_credentials grants
- Bind issued JWT aud to the requested resource and persist resource on
  auth codes and access/refresh token rows
- Enforce subset rule on refresh and token exchange per RFC 8707 §2.2
- Add /.well-known/oauth-authorization-server endpoint (RFC 8414) with
  curated OAuth-only metadata
- Apply CORS middleware to /.well-known/* for browser-based MCP clients
- Reject non-http(s) schemes and cap resource-list size in the validator
- Add docs/MCP.md integration guide

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 14, 2026 02:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds MCP-oriented OAuth compatibility by supporting RFC 8707 resource indicators, publishing OAuth AS metadata, and enabling CORS for well-known discovery endpoints.

Changes:

  • Threads resource indicators through authorization, token issuance, refresh, and JWT audience generation.
  • Adds OAuth AS metadata and well-known CORS coverage.
  • Adds persistence fields, tests, mocks, and MCP integration documentation.

Reviewed changes

Copilot reviewed 37 out of 38 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
internal/util/slice.go Adds string-slice subset helper for resource narrowing checks.
internal/util/slice_test.go Covers subset helper behavior.
internal/util/resource.go Adds resource indicator validation.
internal/util/resource_test.go Covers resource validation cases.
internal/token/local.go Adds audience override support to JWT generation and refresh.
internal/token/local_test.go Updates token provider call sites for audience parameter.
internal/token/local_extra_claims_test.go Updates extra-claims tests for new provider signature.
internal/templates/props.go Adds resource values to authorize page props.
internal/templates/authorize.templ Preserves resources through consent POST.
internal/services/token.go Persists resource values on issued token pairs.
internal/services/token_uid_test.go Updates service tests for resource parameter.
internal/services/token_test.go Updates existing token tests for resource-aware signatures.
internal/services/token_resource_test.go Adds resource/audience integration tests.
internal/services/token_refresh.go Enforces refresh-time resource subset checks.
internal/services/token_profile_test.go Updates token profile test call sites.
internal/services/token_private_claim_prefix_test.go Updates private-claim tests for new signatures.
internal/services/token_introspect_test.go Updates client credentials issuance call site.
internal/services/token_exchange.go Threads resource through auth-code and device-code exchanges.
internal/services/token_domain_test.go Updates domain-claim tests for resource parameter.
internal/services/token_client_credentials.go Threads resource into client credentials access tokens.
internal/services/token_client_credentials_test.go Updates client credentials tests for resource parameter.
internal/services/token_cache_test.go Updates cache tests for new token provider signature.
internal/services/token_cache_bench_test.go Updates benchmark token generation call.
internal/services/authorization.go Persists authorize-time resources on authorization codes.
internal/services/authorization_test.go Updates authorization request validation tests.
internal/models/token.go Adds persisted token resource field.
internal/models/authorization_code.go Adds persisted authorization-code resource field.
internal/mocks/mock_token.go Regenerates token provider mock signatures.
internal/handlers/token.go Parses resource parameters and applies grant-specific behavior.
internal/handlers/token_introspect_test.go Updates token generation call site.
internal/handlers/oidc.go Adds OAuth AS metadata and shared discovery metadata construction.
internal/handlers/oidc_test.go Adds OAuth metadata and OIDC regression tests.
internal/handlers/authorization.go Validates and preserves authorize-time resource indicators.
internal/handlers/authorization_test.go Adds invalid-target mapping and authorize rejection test.
internal/core/token.go Extends token provider interface with audience parameter.
internal/bootstrap/wellknown_cors_test.go Adds well-known CORS tests.
internal/bootstrap/router.go Groups well-known endpoints and applies CORS when enabled.
docs/MCP.md Documents MCP integration and resource indicator behavior.
Files not reviewed (1)
  • internal/mocks/mock_token.go: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/util/resource.go
Comment thread internal/util/resource.go
Comment thread internal/handlers/token.go Outdated
Comment thread internal/services/token_refresh.go
Comment thread internal/handlers/oidc.go Outdated
Comment thread internal/handlers/oidc.go
Comment thread internal/handlers/oidc.go
Comment thread internal/bootstrap/router.go
Comment thread internal/handlers/token.go
Comment thread docs/MCP.md Outdated
- Cap per-resource URI at 1024 chars and require non-empty host for
  http(s) values
- Move RFC 8707 §2.2 subset check into ExchangeCode so a rejected resource
  does not burn the single-use authorization code
- Preserve the rotated refresh token's audience as the original grant so
  narrowing once does not permanently shrink the refresh token's resource
- Restrict introspection endpoint auth methods to exclude `none` since
  /oauth/introspect rejects unauthenticated requests
- Advertise device_authorization_endpoint and resource_indicators_supported
  in the OAuth AS metadata
- Register OPTIONS handlers on /.well-known/* so browser CORS preflights
  reach the CORS middleware instead of 405-ing first
- Update docs/MCP.md preflight example to use OPTIONS with
  Access-Control-Request-Method

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 38 changed files in this pull request and generated 12 comments.

Files not reviewed (1)
  • internal/mocks/mock_token.go: Language not supported

Comment thread internal/handlers/oidc.go
Comment thread internal/handlers/authorization.go Outdated
Comment thread internal/handlers/oidc_test.go
Comment thread internal/handlers/oidc.go
Comment thread internal/handlers/token.go Outdated
Comment thread internal/handlers/oidc_test.go
Comment thread internal/services/token_client_credentials.go
Comment thread internal/handlers/authorization_test.go Outdated
Comment thread internal/handlers/authorization.go
Comment thread internal/templates/authorize.templ
- Reorder /authorize validation so redirect_uri is proven registered before
  the resource parameter is parsed, closing an open-redirect window where
  an invalid resource on an unregistered redirect_uri could be reflected
- Apply strict RFC 8707 §2.2 subset rule at /token: reject any token-time
  resource when /authorize bound none, mirroring the refresh-grant rule
- Preserve the full /authorize-time grant on the refresh token issued via
  authorization_code so future refreshes can re-narrow against the original
  audience rather than the access token's narrowed set
- Include `aud` in the RFC 7662 introspection response so resource servers
  that authorize via introspection can enforce audience binding
- Skip ConsentRemember when the request includes resource indicators, so a
  user must explicitly approve each new audience their tokens bind to
- Render requested resources visibly on the consent page next to scopes
- Emit `resource_parameter_supported` alongside `resource_indicators_supported`
  in the OAuth AS metadata for both naming conventions in the wild
- Add a client_credentials + resource integration test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 38 changed files in this pull request and generated 9 comments.

Files not reviewed (1)
  • internal/mocks/mock_token.go: Language not supported
Comments suppressed due to low confidence (1)

internal/handlers/token.go:440

  • This emits aud for every active token row, including refresh tokens whose Resource is now persisted. Since the introspection response still reports refresh tokens as active with token_type: "Bearer" and does not expose the access/refresh category, a resource server that relies on RFC 7662 plus aud cannot distinguish a refresh token from an access token. Restrict this audience-bearing response to access tokens or include/enforce the token category so refresh tokens are not accepted as resource-server credentials.
	// Audience binding: prefer the RFC 8707 resource set persisted at
	// issuance; fall back to the static JWTAudience config when no resource
	// was requested. Resource servers rely on this to enforce that a token
	// minted for service A cannot be replayed against service B.
	if aud := introspectAudience(tok, h.config.JWTAudience); aud != nil {
		resp["aud"] = aud

Comment thread internal/handlers/token.go Outdated
Comment thread internal/services/token_exchange.go Outdated
Comment thread internal/handlers/authorization_test.go Outdated
Comment thread internal/services/authorization.go
Comment thread internal/services/token_refresh.go Outdated
Comment thread internal/handlers/token.go Outdated
Comment thread docs/MCP.md Outdated
Comment thread internal/services/token.go Outdated
Comment thread internal/services/token_refresh.go Outdated
…ariants

- Render an error page (not a redirect) when ValidateAuthorizationRequest
  fails with ErrInvalidRedirectURI or ErrUnauthorizedClient, per RFC 6749
  §3.1.2.4. Closes the open-redirect path where an unregistered redirect_uri
  was still reflected as the OAuth error redirect target
- Split provider RefreshAccessToken into accessAudience and refreshAudience;
  refresh tokens no longer carry a resource-server `aud` claim (they go to
  the AS, not the RS, so emitting an RS audience risked confusing JWT
  validators that only check signature/iss/exp/aud)
- Eliminate the wasted second access-token mint in token rotation: with the
  two-audience provider signature the access token is issued with the
  narrowed audience and the refresh token with no resource audience in a
  single round-trip
- Add a service-boundary RFC 8707 §2.2 subset check in
  ExchangeAuthorizationCode so any future call path bypassing the handler
  still enforces the audience invariant
- Drop the JWTAudience config fallback in the introspection response so
  rotating JWT_AUDIENCE cannot diverge introspect `aud` from the JWT
- Document that resource servers MUST reject non-access tokens by checking
  the `type` claim, otherwise a refresh token could be mistaken for one
- Add tests for ExchangeCode subset rule (allowed subset, rejected superset
  with code remaining unconsumed, rejected request against empty grant)
- Add a handler-integration test asserting the open-redirect mitigation:
  invalid resource on an unregistered redirect_uri is NOT reflected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@appleboy appleboy requested a review from Copilot May 14, 2026 06:36
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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.

2 participants