feat(oauth): add MCP / RFC 8707 + RFC 8414 compatibility#187
Open
appleboy wants to merge 19 commits into
Open
Conversation
- 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>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Contributor
There was a problem hiding this comment.
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
resourceindicators 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.
- 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>
- 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>
Contributor
There was a problem hiding this comment.
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
audfor every active token row, including refresh tokens whoseResourceis now persisted. Since the introspection response still reports refresh tokens as active withtoken_type: "Bearer"and does not expose the access/refresh category, a resource server that relies on RFC 7662 plusaudcannot 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
…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>
- Stop reusing the green scope-check circle for resource indicators so the page no longer reads "permission granted" for an audience target - Replace the arrow glyph with a bullseye SVG icon that conveys the audience-target meaning without depending on font emoji support - Introduce dedicated resource list classes with a blue left-border accent and tinted hover so resources and scopes form two distinct visual categories at a glance - Render the resource URI in a code element so screen readers announce it in monospace voice and long values wrap inside the row
- Advertise MCP, multi-resource-server, RFC 8707 Resource Indicators, and RFC 8414 AS metadata in the readme tagline, Perfect-for list, Key Features, advanced-topics index, endpoints table, and references - Document the JWT_AUDIENCE operational constraint in the configuration guide: refresh JWTs are signed with this value so it MUST be AS-only or unset, never a resource server identifier - Update the CORS section to cover the well-known route group used by browser-based MCP and SPA clients, and add an internal-network caveat - Add an Audience Binding section to the JWT verification guide that walks resource servers through validating aud and type, the snapshot semantics of introspection, and refresh-token-as-access-token confusion mitigation - Add WithAudience to the Go verification example so readers do not ship a sample that skips the aud check - Cross-link the MCP guide to JWT verification and configuration and repeat the JWT_AUDIENCE warning at the end of the checklist
- Add the resource parameter to the authorize and token tables in the authorization code flow guide, with a public-client PKCE example that binds two audiences and a note on the strict consent-match rule - Add the resource parameter to the device code request and poll tables with an MCP example, and explain that resource-bound device codes route through an explicit confirmation page before authorization - Add the resource parameter to the client credentials token request table with a multi-RS example and a callout that the M2M grant has no per-client allowlist, so resource servers must validate the (client_id, sub, aud) tuple against their own policy - Add audience binding and refresh-token-as-access-token mitigation entries to the security threat model and production checklist, including the JWT_AUDIENCE constraint and the type-claim requirement
…source indicators - Add the AS metadata endpoint to the architecture endpoints table, document the new resource column on four tables with the dual access vs refresh semantics, and flag the TokenProvider interface signature change as a breaking surface for out-of-tree implementations - Add two new use cases: AuthGate as the authorization server for an MCP deployment (with the PRM + AS discovery + token verification trifecta) and multi-resource-server audience binding with a side-by-side comparison against scope-only isolation - Add troubleshooting entries for the invalid_target error with a diagnose table covering each rejection reason, the refresh-as-access token-confusion failure mode with both AS-side and RS-side fixes, and the consent-re-prompt-on-resource-set-mismatch behaviour
Apply the project's markdown formatter to the documentation files updated in the recent resource-indicator documentation rounds. The formatter rewrites asterisk-style emphasis to underscore-style for consistency with the rest of the docs tree; no prose, examples, or links change.
Apply the project's markdown formatter to the key-endpoints table. The formatter pads every row to the width of the widest cell so the column borders line up. Pure cosmetic — no endpoint content or wording changes.
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
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-servermetadata, 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_AUDIENCEconfig remains the fallback). See theBreaking changes section below for source-level and operational concerns.
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
resourceis unchanged. The following items only matter for forks,custom integrations, and operators with non-default deployments.
Source-level (forks / out-of-tree implementations)
core.TokenProviderinterface signature change (internal/core/token.go).Any out-of-tree implementation will fail to compile:
GenerateToken,GenerateRefreshToken,GenerateClientCredentialsTokeneach gain a trailing
audience []stringparameter. Passnilfor thepre-PR behaviour.
RefreshAccessTokensplits its single audience parameter intoaccessAudience, refreshAudience []string— keeping refresh JWTs fromcarrying a per-request RS
aud. Passnil, nilfor the pre-PRbehaviour.
ValidateRefreshToken(ctx, tokenString) (*TokenValidationResult, error).internal/mocks/mock_token.go; vendored copiesmust be refreshed.
audinsideextra_claimsis now unconditionally strippedat sign time. Callers that previously smuggled audience via
extra_claimsmust move to the new
resourceparameter.Database
Resource StringArrayJSON column onAccessToken,DeviceCode,AuthorizationCode, andUserAuthorization. GORMAutoMigrate handles the migration on startup; existing rows have empty
Resource. No data backfill required.AccessToken.Resourcehas dual semantics — for access-token rows itis the JWT
audsnapshot taken at issuance; for refresh-token rows it isthe original grant's resource set (NOT the refresh JWT's
aud). Anythingreading this column directly (custom analytics, external migrations) must
branch on
TokenCategory. See the field's docstring for the fullrationale.
Operational —
JWT_AUDIENCEsemanticsRefresh JWTs are signed with the static
JWT_AUDIENCEas theiraudclaim(unchanged from pre-PR). The PR explicitly tightens the constraint:
deployments MUST point
JWT_AUDIENCEat an AS-only value, or leave itunset. 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
resourcewill then carry no
aud(and resource servers should reject tokens whoseauddoes not match their own identifier — RFC 8707 §2.2).AI Authorship
via a written plan, three independent review passes (two
/simplifycycles and one
/security-review), and 7 follow-up Copilot review rounds.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
and all four OAuth grant flows. Failure is system-wide.
Plan reference
/Users/mtk10671/.claude/plans/authgate-mcp-mutable-nebula.md— scope, allowedfiles, three required e2e tests, and done-definition all defined there.
Verification
internal/util/resource_test.go,internal/util/slice_test.gointernal/services/token_resource_test.go(coversall four grants end-to-end with resource binding, subset acceptance on
refresh, superset rejection, cascade-revoke linkage, and JWT-audience
snapshot fallback)
during review:
TestAuthCodeFlow_WithResource_PropagatesToAud(happy path)TestAuthorize_RejectsResourceWithFragment(RFC 8707 §2.1 validation)TestRefresh_RejectsResourceSupersetOfOriginal(RFC 8707 §2.2 widening)TestClientCredentials_WithResource_PropagatesToAudTestDeviceCode_WithResource_PropagatesToAudTestDeviceCode_RejectsResourceSupersetOfGrantTestDeviceCode_RejectsResourceWhenNoneGrantedTestDeviceCode_LinksAuthorizationIDForCascadeRevokeTestClientCredentials_NoResource_SnapshotsJWTAudienceTestRefresh_NarrowsResource_SubsetTestAuthorize_InvalidResource_RedirectsAfterValidationTestAuthorize_UnsupportedResponseType_UnregisteredRedirectURI_NotReflectedTestAuthorize_InvalidResource_UnregisteredRedirectURI_NotReflectedTestDeviceCodeRequest_WithResource_PersistsOnDeviceCodeTestDeviceCodeRequest_InvalidResource_ReturnsInvalidTargetTestIntrospectAudience/.well-known/openid-configurationshape cannot drift(
TestOIDCDiscovery_UnaffectedByOAuthMetadataAddition).well-knowngroup/.well-known/*responses are cached(Cache-Control: public, max-age=3600) and the token endpoint is unchanged
in hot-path shape
./bin/authgate serverthen:Verifiability check
docs/MCP.mdand inline godoccitations appear at every enforcement point
the existing OAuth error response path (counted by existing metrics)
Security check
ValidateResourceIndicatorsrejectsnon-http(s) schemes (blocks
javascript:/data:/file:audvalues),empty strings, relative URIs, fragments, and caps the list at 10 entries
to prevent DoS amplification
on
authorization_code→ token exchange, ondevice_code→ tokenexchange (against the device-time grant), and on
client_credentials(against
JWT_AUDIENCEsnapshot when noresourcesupplied); testedwith dedicated tests
/oauth/authorizeand thedevice-flow consent page both honour the same redirect-URI allowlist
check; invalid
response_type/resourceon an unregisteredredirect_uriare not reflected back/oauth/*unchangeddescriptions
audcannot be smuggled viaextra_claims— explicitdelete(claims, "aud")runs before the audience override applies (defense in depth)audis bound at issuance — snapshotted onto the access tokenand re-applied on refresh; refresh cannot widen
audbeyond the originalgrant
audis bound —/oauth/tokeninforeflects the per-tokenaudrather than the staticJWT_AUDIENCEconfigauthorization ID so admin revoke cleanly reaches all descendants
<input name="resource">rendering preventsreflected XSS
Risk & rollback
Risk —
core.TokenProviderinterface gained a trailingaudience []stringparameter on four methods (
GenerateToken,GenerateRefreshToken,GenerateClientCredentialsToken,RefreshAccessToken).LocalTokenProvideris the only in-tree implementer; any external implementation would break
at compile time. Mocks were updated in lockstep.
Risk — Four models gained nullable
resourcecolumns:AuthorizationCode— captures theresourcefrom/oauth/authorizeAccessToken— snapshots the issuedaudso refresh cannot widen itDeviceCode— captures theresourcefrom/oauth/device/codeUserAuthorization— per-resource consent grants (one record peruser+app+resource tuple, so granting two distinct resources to the same
app no longer collapses into a single consent record)
GORM
AutoMigrateadds these transparently on Postgres and SQLite; nobackfill required. Existing rows default to NULL/empty and fall back to
JWT_AUDIENCE.Risk —
internal/templates/device_confirm_page.templis new (device-flowconsent 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
audto 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 templateis embedded — no asset migration needed.
Reviewer guide
internal/util/resource.go— RFC 8707 §2.1 validation (security-critical)internal/handlers/authorization.go— POST deny path's redirect-URIallowlist check (open-redirect closure)
internal/handlers/token.go:handleAuthorizationCodeGrant— subset rulebetween authorize-time and token-time resource
internal/services/token_refresh.go— RFC 8707 §2.2 subset on refresh,plus
audsnapshot re-applicationinternal/services/token_exchange.go— device-code → token resourcesubset check against the device-time grant
internal/services/device.go+internal/models/device_code.go— deviceresource 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:generateJWT—audsource precedence (overridevs config), explicit strip of caller-supplied
audinternal/handlers/oidc.go— newoauthASMetadatashape vsdiscoveryMetadata, plus introspectionaudfixservices/token_*.gofiles threadingresourceend-to-end(mechanical change; tests cover the wiring)
nilto call sitesinternal/mocks/mock_token.gointernal/templates/device_confirm_page.templrendering (templauto-escaping handles the only user-controlled field)
Post-review revisions
The branch went through 7 Copilot review rounds after the initial implementation
commit. The fixes landed in these commits (oldest → newest):
a4926d3— first round of Copilot findingsb765220— second round4e10389— open-redirect closed, refresh-audsnapshot, audience invariants66e61d0— device-code resource binding, introspectionaudfixeb92f3a—UserAuthorizationmade resource-aware (composite key change)b634503— device confirm page, cascade-revoke linkage, swagger/docse41d8d2— POST deny redirect, resource validation tighten, race fix,client-credentials snapshot
audNet delta vs. initial implementation commit: +13 files, +1776 lines (mostly
test coverage and the device confirm template).