Skip to content

security(oauth): OAuth 2.1 / MCP authorization-server hardening (#516)#693

Merged
lakhansamani merged 4 commits into
mainfrom
security/oauth21-mcp-hardening
Jul 21, 2026
Merged

security(oauth): OAuth 2.1 / MCP authorization-server hardening (#516)#693
lakhansamani merged 4 commits into
mainfrom
security/oauth21-mcp-hardening

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Summary

Closes #516 (RFC: OAuth 2.1 Authorization Server for MCP), scoped to what the current MCP spec (2025-11-25) and codebase actually require rather than the issue's original text, which was written against an older spec revision.

  • Refresh-token reuse detection. Rotation already retired the old token on refresh; this adds the missing OAuth 2.1 breach response — replaying an already-rotated refresh token now revokes the user's session family instead of a no-op invalid_grant.
  • RFC 8707 resource on the primary authorization_code flow. Previously only existed inside the token-exchange/delegation path. /authorize now accepts and binds resource; /oauth/token requires it be echoed and matched, and sets the access token's aud to it (id_token/refresh_token audiences stay the client, per OIDC/RFC 6749 §6).
  • RFC 8414 GET /.well-known/oauth-authorization-server — thin alias of the existing OIDC discovery document (not a compliance gap; MCP clients probe this path first).
  • --oauth21-strict flag, default off. Gates implicit-grant removal and PKCE-plain removal behind opt-in — zero behavior change for existing deployments unless explicitly enabled.

Explicitly out of scope (see PR discussion / linked issue for citations): RFC 7591 Dynamic Client Registration (downgraded to MAY in the current MCP spec), OAuth Client ID Metadata Documents (early WG draft), RFC 9728 Protected Resource Metadata (that's the MCP Resource Server's responsibility, not the AS's — examples/with-mcp already demonstrates the boundary correctly).

Judgment calls flagged for reviewer attention

  • Refresh-token-reuse revocation is coarse: it revokes all of that user's sessions (every login method), not just the compromised lineage — there's no per-rotation family ID to scope tighter without a larger schema change. Matches the existing blast radius of reset_password/deactivate_account revocation.
  • Replaying a genuinely expired (not stolen) refresh token also triggers revocation, since the code can't distinguish the two without a family ID. Conservative-but-real UX tradeoff.
  • RFC 8707 resource binding does not survive a refresh_token grant — reverts to client-id audience after the first refresh. Noted as a deferral, not a silent gap.

Test plan

  • New internal/integration_tests/oauth21_mcp_test.go (5 tests / 10 subtests) — resource/aud binding, mismatch rejection, refresh-reuse-revokes-family, strict-mode gating (default-off regression + on-behavior), AS metadata.
  • Full existing suite unaffected (token_audience, refresh_token_client_binding, oidc_hybrid, oauth_standards_compliance regressions all pass).
  • make test-all-db — all 7 DB backends (couchbase, postgres, sqlite, mongodb, arangodb, scylladb, dynamodb), 31 packages, 0 failures.
  • go vet ./... clean, golangci-lint run 0 issues on touched packages, gofmt -s applied.

- Refresh-token reuse response (OAuth 2.1 §6.1 / RFC 9700 §4.14.2):
  a genuine, already-rotated refresh token replayed is now detected
  and revokes the whole live session family, not a no-op invalid_grant.
  Rotation itself already retired the old token; this adds the breach
  response. Reuse is only flagged for a signed, client-bound refresh
  token whose session record is gone, so a forged/foreign token can't
  force a revocation.
- RFC 8707 resource indicator on the authorization_code flow: optional
  single `resource` at /authorize is bound to the code, must be echoed
  and matched at /oauth/token (invalid_grant on mismatch), and sets the
  access token `aud`. id_token / refresh_token audiences stay the client.
- RFC 8414: /.well-known/oauth-authorization-server alias of the OIDC
  discovery handler; advertise resource_indicators_supported.
- --oauth21-strict flag (default false, opt-in). When on, reject the
  implicit/hybrid-with-token response types and PKCE plain (S256 only),
  reflected in code_challenge_methods_supported. Off is unchanged.

Refs #516
…mily

Fix four findings from the adversarial review of PR #693.

Fix 1 (HIGH) — refresh-token reuse was an unauthenticated forced-logout DoS.
On reuse detection the token endpoint called DeleteAllUserSessions(userID),
wiping every session of the user across every login method. Since the refresh
grant needs no client secret, anyone holding any retired refresh token (logs,
history, backups) could keep a victim perpetually logged out.

Approach taken: full family-scoping via a per-lineage family record — NOT the
grace-window+cooldown fallback. Investigating the memory_store backends showed
the subkey-encoding sketch (refresh_token_<family>_<nonce>) would force
family_id into access/session tokens + validation and ~40 Set/Get/Delete call
sites — far more invasive than described. Instead:
  - Refresh tokens carry a stable family_id claim, minted once at first
    issuance and carried forward unchanged across every rotation (nonce still
    rotates). Generation lives in CreateRefreshToken, so every issuance path
    gets one for free; family_id is reserved against custom-script forgery.
  - On rotation the handler writes a family index record
    ("refresh_family_<familyID>" -> {live, prev, rotated_at}) via the existing
    SetUserSession — no new interface method, works uniformly across the
    in-memory, redis and db backends.
  - On reuse, revocation deletes only that family's live session via the
    existing DeleteUserSession(sessionKey, liveNonce); other lineages and
    login-methods of the same user are untouched. A family-less (legacy) or
    live-lineage-absent replay revokes nothing — never the whole user.
  - Grace window (10s): a replay of the immediate predecessor within the
    window is a benign double-submit (multi-tab/retry), not a breach — the
    live session is not revoked. Any older nonce, or a predecessor replayed
    after the window, is genuine reuse and revokes the family.

Fix 2 (MEDIUM) — strict mode now rejects every response_type delivering a
bearer token into the fragment. The check is component-exact on the canonical
response_type, catching the front-channel-token hybrids "code token" and
"code id_token token" alongside "token"/"id_token token", while leaving pure
"id_token" (substring only) untouched.

Fix 3 (LOW) — the /authorize resource parameter is validated per RFC 8707 §2
(absolute URI, no fragment); an invalid value is rejected with invalid_target.

Fix 4 (LOW/MEDIUM) — ValidateAccessToken rejects a token whose aud is a
resource indicator (absolute URI other than the default audience) at
Authorizer's own protected endpoints (/userinfo, GraphQL, gRPC). Introspection
keeps its own ParseJWTToken path and is unaffected, so an external RS can still
validate resource-bound tokens.

Tests: family-scoped revocation kills only its lineage (unrelated same-user
session survives); grace window benign within / breach after; strict-mode
rejection of the two hybrids plus pure-id_token allowance; resource validation;
and the aud restriction at /userinfo. All pre-existing OAuth 2.1 tests updated
and passing.
"oauth21" reads ambiguously (looks like a made-up version 21); the dash
between "2" and "1" makes it unambiguously OAuth 2.1. Go field name
(OAuth21Strict) and test file names are unaffected - Go identifiers can't
carry dots/hyphens anyway.
Found in a fresh adversarial re-review of the family-scoped reuse fix.
handleRefreshTokenReuse couldn't tell "this nonce was genuinely rotated
away" from "the store had a transient blip looking up the CURRENT live
token's own session entry" - GetUserSession collapses both to a plain
error. Without a guard, a transient lookup failure on a still-legitimate
live token fell straight into the genuine-reuse branch and revoked the
user's own session over what should have been a retryable fault.

Fix: if the presented nonce equals the family record's LiveNonce, this
can't be a rotated-away token by definition - skip revocation and let it
fail as an ordinary invalid_grant instead.

Regression test simulates the failure by expiring just the refresh_token_
<nonce> store entry (not via DeleteUserSession, which cascades to the
sibling session/access token entries for that nonce and would mask the
bug) and asserts the sibling access_token_<nonce> entry survives.
Verified the test actually catches the regression: temporarily disabled
the guard, confirmed the test fails (access token wiped), restored it.
@lakhansamani
lakhansamani merged commit c51414d into main Jul 21, 2026
4 checks passed
pull Bot pushed a commit to bhardwajRahul/authorizer that referenced this pull request Jul 22, 2026
Added to README:
- WebAuthn / passkey login
- Enterprise SSO (SAML 2.0 IdP & SP, OIDC broker, verified domains, home realm discovery)
- SCIM 2.0 provisioning
- Multi-tenant / org-scoped admin
- Migration guides (planned)

Added to CHANGELOG [Unreleased]:
- Service accounts as FGA subjects (authorizerdev#665)
- SAML ACS CSRF exemption fix (authorizerdev#666)
- Trusted-issuer token review config (authorizerdev#667)
- Session revocation on password reset (authorizerdev#669, authorizerdev#673)
- Multi-tenant SSO phases (authorizerdev#672, authorizerdev#674, authorizerdev#675)
- OIDC discovery caching + Twitter PKCE fix (authorizerdev#668)
- WebAuthn passkey support (authorizerdev#671)
- Per-user TOTP lockout + hashed recovery codes (authorizerdev#670)
- Server-side user search + org membership (authorizerdev#678, authorizerdev#680)
- Per-method MFA availability signals (authorizerdev#681)
- Consolidated MFA redesign narrative (authorizerdev#682-686)
- SAML 2.0 IdP role (authorizerdev#691)
- OAuth 2.1 / MCP hardening (authorizerdev#693)
- SCIM group provisioning + FGA + SAML assertions (authorizerdev#694)
- Provider template parity (authorizerdev#695)
- Recent fixes (async tracking, AGENTS.md rules, security fixes, storage parity, error typing, panics, frontend fixes) (authorizerdev#696-702)
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.

RFC: OAuth 2.1 Authorization Server for MCP

1 participant