Skip to content

Commit 26d4dad

Browse files
appleboyclaude
andcommitted
feat(oauth): add MCP / RFC 8707 + RFC 8414 compatibility
- 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>
1 parent 83265e0 commit 26d4dad

38 files changed

Lines changed: 1547 additions & 180 deletions

docs/MCP.md

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# MCP Integration Guide
2+
3+
AuthGate implements the OAuth 2.1 surface required by the
4+
[Model Context Protocol (MCP) authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization),
5+
so it can act as a drop-in authorization server for any MCP deployment.
6+
7+
This guide covers what an MCP server (the resource server) advertises to
8+
clients, what AuthGate provides on each side of the trust boundary, and how
9+
to wire the two together.
10+
11+
## Trust boundary
12+
13+
| Component | Owner | Responsibility |
14+
| -------------------- | -------------------- | -------------------------------------------------------------------- |
15+
| MCP client | The application | Discovers the AS, performs PKCE, sends `resource=<MCP-URL>` |
16+
| MCP server (RS) | Your deployment | Publishes [RFC 9728 Protected Resource Metadata][rfc9728] pointing at AuthGate; verifies token signature, `iss`, `aud` |
17+
| AuthGate (AS) | This service | Issues access/refresh tokens with audience bound to the MCP resource |
18+
19+
AuthGate does **not** publish RFC 9728 Protected Resource Metadata; that
20+
belongs to each MCP server. The PRM document is what tells clients which
21+
AuthGate URL to use.
22+
23+
[rfc9728]: https://datatracker.ietf.org/doc/html/rfc9728
24+
25+
## What to advertise on your MCP server
26+
27+
The MCP server's PRM document (`/.well-known/oauth-protected-resource`) must
28+
advertise the AuthGate base URL as its authorization server. Example:
29+
30+
```json
31+
{
32+
"resource": "https://mcp.example.com",
33+
"authorization_servers": ["https://auth.example.com"],
34+
"bearer_methods_supported": ["header"],
35+
"scopes_supported": ["read", "write"]
36+
}
37+
```
38+
39+
When an MCP client receives a 401 with `WWW-Authenticate: Bearer
40+
resource_metadata="..."`, it fetches the PRM, follows
41+
`authorization_servers[0]`, and asks AuthGate for metadata.
42+
43+
## AuthGate AS metadata
44+
45+
MCP clients try `/.well-known/oauth-authorization-server` (RFC 8414) first,
46+
then fall back to OIDC discovery. AuthGate publishes both:
47+
48+
| URL | Use |
49+
| ---------------------------------------------------- | ----------------------------------------------------- |
50+
| `/.well-known/oauth-authorization-server` | OAuth 2.0 AS metadata — curated, no OIDC-only fields |
51+
| `/.well-known/openid-configuration` | OIDC Provider metadata — unchanged |
52+
| `/.well-known/jwks.json` | Public keys for `RS256`/`ES256` verification |
53+
54+
The OAuth metadata response includes:
55+
56+
- `issuer`, `authorization_endpoint`, `token_endpoint`
57+
- `introspection_endpoint`, `revocation_endpoint`
58+
- `registration_endpoint` — only when `ENABLE_DYNAMIC_CLIENT_REGISTRATION=true`
59+
- `grant_types_supported``authorization_code`, `device_code`,
60+
`refresh_token`, `client_credentials`
61+
- `code_challenge_methods_supported``["S256"]` (PKCE `plain` is rejected)
62+
- `token_endpoint_auth_methods_supported`,
63+
`introspection_endpoint_auth_methods_supported`,
64+
`revocation_endpoint_auth_methods_supported`
65+
66+
Browser-based MCP clients need cross-origin access to these endpoints. The
67+
`/.well-known/*` group respects `CORS_ENABLED` / `CORS_ALLOWED_ORIGINS`
68+
exactly like `/oauth/*`.
69+
70+
## PKCE requirement
71+
72+
MCP requires `code_challenge_method=S256`. AuthGate's behaviour aligns:
73+
74+
- Public clients (no client secret) **must** present an `S256` code challenge.
75+
- `plain` is rejected (returns `invalid_request`).
76+
- Confidential clients may also opt into PKCE; set `PKCE_REQUIRED=true` to
77+
force it across all clients.
78+
79+
## Dynamic Client Registration (RFC 7591)
80+
81+
MCP recommends DCR so clients can self-register without admin intervention.
82+
AuthGate exposes `POST /oauth/register` when
83+
`ENABLE_DYNAMIC_CLIENT_REGISTRATION=true`. An MCP client posts:
84+
85+
```http
86+
POST /oauth/register HTTP/1.1
87+
Content-Type: application/json
88+
89+
{
90+
"client_name": "Acme MCP CLI",
91+
"redirect_uris": ["http://127.0.0.1:1729/callback"],
92+
"grant_types": ["authorization_code", "refresh_token"],
93+
"token_endpoint_auth_method": "none"
94+
}
95+
```
96+
97+
The response contains `client_id` and (for confidential clients) a one-time
98+
`client_secret`. Restrict DCR with `DYNAMIC_CLIENT_REGISTRATION_TOKEN` to
99+
require a pre-shared bearer token for registration.
100+
101+
## Audience binding via Resource Indicators (RFC 8707)
102+
103+
MCP clients send `resource=<MCP-URL>` on both `/authorize` and `/token`. The
104+
issued JWT's `aud` claim is **bound to the requested resource**. AuthGate:
105+
106+
- Validates each `resource` value against RFC 8707 §2.1 (absolute URI, no
107+
fragment) and rejects malformed values with `error=invalid_target`.
108+
- Replaces the static `JWT_AUDIENCE` config for that token. When the caller
109+
does not send `resource`, the existing `JWT_AUDIENCE` is used as before.
110+
- Persists the bound resource on the authorization code and on access/refresh
111+
token rows.
112+
- Enforces RFC 8707 §2.2 on refresh: the caller may narrow the audience but
113+
never widen it. Widening returns 400 `invalid_target`.
114+
- On `authorization_code` token exchange, validates that any token-time
115+
`resource` is a subset of what was bound at `/authorize`.
116+
117+
**Trust model:** the `aud` claim is server-attested. The MCP server must
118+
verify that `aud` matches its own resource identifier before accepting the
119+
token — token replay against a different MCP server with the same
120+
`iss`/signature must fail. Standard verification still applies: check JWT
121+
signature against JWKS, `iss` matches AuthGate, `exp` is in the future.
122+
123+
## curl walkthrough
124+
125+
```bash
126+
# 1. Fetch AS metadata (the MCP-required endpoint).
127+
curl -s http://localhost:8080/.well-known/oauth-authorization-server | jq '
128+
{issuer, authorization_endpoint, token_endpoint,
129+
introspection_endpoint, registration_endpoint,
130+
code_challenge_methods_supported}'
131+
# Expect: code_challenge_methods_supported = ["S256"];
132+
# registration_endpoint present when ENABLE_DYNAMIC_CLIENT_REGISTRATION=true.
133+
134+
# 2. Confirm CORS preflight on the metadata endpoint.
135+
curl -i -H "Origin: https://allowed.example.com" \
136+
http://localhost:8080/.well-known/oauth-authorization-server \
137+
| grep -i access-control-allow-origin
138+
# Expect: Access-Control-Allow-Origin: https://allowed.example.com
139+
140+
# 3. Run the authorization-code flow with a resource indicator.
141+
# (Perform interactive consent in a browser, then exchange the code.)
142+
curl -s -X POST http://localhost:8080/oauth/token \
143+
-d grant_type=authorization_code -d "code=$CODE" -d "redirect_uri=$RURI" \
144+
-d "client_id=$CID" -d "code_verifier=$CV" \
145+
-d "resource=https://mcp.example.com"
146+
# Decode the access_token's payload; "aud" must equal "https://mcp.example.com".
147+
148+
# 4. Refresh requesting a resource outside the original grant — must fail.
149+
curl -X POST http://localhost:8080/oauth/token \
150+
-d grant_type=refresh_token -d "refresh_token=$RT" -d "client_id=$CID" \
151+
-d "resource=https://forbidden.example.com"
152+
# Expect: 400 {"error":"invalid_target",...}
153+
```
154+
155+
## Configuration checklist
156+
157+
For an MCP-ready deployment:
158+
159+
- `BASE_URL=https://auth.example.com` (your AuthGate's public URL)
160+
- `JWT_SIGNING_ALGORITHM=RS256` or `ES256` (asymmetric keys exposed via JWKS)
161+
- `CORS_ENABLED=true` and `CORS_ALLOWED_ORIGINS=<browser MCP client origins>`
162+
- `ENABLE_DYNAMIC_CLIENT_REGISTRATION=true` if you want self-service MCP clients
163+
- `ENABLE_REFRESH_TOKENS=true` (long-running MCP sessions)
164+
- `PKCE_REQUIRED=true` recommended; AuthGate already requires `S256` for public
165+
clients and rejects `plain`.
166+
167+
No new configuration keys are required to support MCP — Resource Indicators
168+
are always-on and backward-compatible: callers that don't send `resource`
169+
keep getting `aud` from `JWT_AUDIENCE`.

internal/bootstrap/router.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,17 @@ func setupAllRoutes(
215215
// OAuth routes (public)
216216
setupOAuthRoutes(r, oauthProviders, h.oauth)
217217

218-
// OIDC Discovery and JWKS (public, no auth required)
219-
r.GET("/.well-known/openid-configuration", h.oidc.Discovery)
220-
r.GET("/.well-known/jwks.json", h.jwks.JWKS)
218+
// OIDC Discovery, OAuth 2.0 AS metadata (RFC 8414), and JWKS (public,
219+
// no auth required). Browser-based MCP clients need cross-origin access
220+
// to these endpoints, so CORS is applied here when enabled — the same
221+
// middleware as /oauth/* uses.
222+
wellKnown := r.Group("/.well-known")
223+
if cfg.CORSEnabled {
224+
wellKnown.Use(middleware.CORSMiddleware(cfg))
225+
}
226+
wellKnown.GET("/openid-configuration", h.oidc.Discovery)
227+
wellKnown.GET("/oauth-authorization-server", h.oidc.OAuthAuthorizationServerMetadata)
228+
wellKnown.GET("/jwks.json", h.jwks.JWKS)
221229

222230
// OAuth API routes (public, called by CLI)
223231
oauth := r.Group("/oauth")
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package bootstrap
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/go-authgate/authgate/internal/config"
9+
"github.com/go-authgate/authgate/internal/middleware"
10+
11+
"github.com/gin-gonic/gin"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// buildWellKnownRouter mirrors the router setup in setupAllRoutes for the
17+
// /.well-known/* group only, so CORS behaviour can be asserted in isolation
18+
// without booting the full bootstrap pipeline.
19+
func buildWellKnownRouter(cfg *config.Config) *gin.Engine {
20+
gin.SetMode(gin.TestMode)
21+
r := gin.New()
22+
wellKnown := r.Group("/.well-known")
23+
if cfg.CORSEnabled {
24+
wellKnown.Use(middleware.CORSMiddleware(cfg))
25+
}
26+
stub := func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }
27+
wellKnown.GET("/openid-configuration", stub)
28+
wellKnown.GET("/oauth-authorization-server", stub)
29+
wellKnown.GET("/jwks.json", stub)
30+
return r
31+
}
32+
33+
func TestWellKnown_CORS_AllowsConfiguredOrigin(t *testing.T) {
34+
cfg := &config.Config{
35+
CORSEnabled: true,
36+
CORSAllowedOrigins: []string{"https://allowed.example.com"},
37+
CORSAllowedMethods: []string{"GET", "OPTIONS"},
38+
CORSAllowedHeaders: []string{"Origin", "Content-Type"},
39+
}
40+
r := buildWellKnownRouter(cfg)
41+
42+
req := httptest.NewRequest(
43+
http.MethodGet,
44+
"/.well-known/oauth-authorization-server",
45+
nil,
46+
)
47+
req.Header.Set("Origin", "https://allowed.example.com")
48+
w := httptest.NewRecorder()
49+
r.ServeHTTP(w, req)
50+
51+
require.Equal(t, http.StatusOK, w.Code)
52+
assert.Equal(
53+
t,
54+
"https://allowed.example.com",
55+
w.Header().Get("Access-Control-Allow-Origin"),
56+
)
57+
}
58+
59+
func TestWellKnown_CORS_RejectsUnconfiguredOrigin(t *testing.T) {
60+
cfg := &config.Config{
61+
CORSEnabled: true,
62+
CORSAllowedOrigins: []string{"https://allowed.example.com"},
63+
CORSAllowedMethods: []string{"GET", "OPTIONS"},
64+
CORSAllowedHeaders: []string{"Origin", "Content-Type"},
65+
}
66+
r := buildWellKnownRouter(cfg)
67+
68+
req := httptest.NewRequest(
69+
http.MethodGet,
70+
"/.well-known/oauth-authorization-server",
71+
nil,
72+
)
73+
req.Header.Set("Origin", "https://evil.example.com")
74+
w := httptest.NewRecorder()
75+
r.ServeHTTP(w, req)
76+
77+
// gin-contrib/cors rejects un-allowed origins; ACAO must be empty so the
78+
// browser refuses to expose the response to JS.
79+
assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
80+
}
81+
82+
func TestWellKnown_CORSDisabled_NoACAOHeader(t *testing.T) {
83+
cfg := &config.Config{CORSEnabled: false}
84+
r := buildWellKnownRouter(cfg)
85+
86+
req := httptest.NewRequest(
87+
http.MethodGet,
88+
"/.well-known/oauth-authorization-server",
89+
nil,
90+
)
91+
req.Header.Set("Origin", "https://anything.example.com")
92+
w := httptest.NewRecorder()
93+
r.ServeHTTP(w, req)
94+
95+
require.Equal(t, http.StatusOK, w.Code)
96+
assert.Empty(
97+
t,
98+
w.Header().Get("Access-Control-Allow-Origin"),
99+
"CORS disabled must not emit Access-Control-Allow-Origin",
100+
)
101+
}

internal/core/token.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,25 @@ type TokenRefreshResult struct {
6363
// extraClaims (when non-empty) is merged into the generated JWT before standard
6464
// claims are set; standard claims (iss, sub, exp, iat, jti, aud, type, scope,
6565
// user_id, client_id) take precedence and cannot be overridden.
66+
//
67+
// audience (when non-empty) overrides the "aud" claim with the supplied values
68+
// (RFC 8707 Resource Indicators). When empty, the provider falls back to its
69+
// default audience configuration. Caller-supplied "aud" inside extraClaims is
70+
// always stripped — audience must be passed explicitly through this parameter.
6671
type TokenProvider interface {
6772
GenerateToken(
6873
ctx context.Context,
6974
userID, clientID, scopes string,
7075
ttl time.Duration,
7176
extraClaims map[string]any,
77+
audience []string,
7278
) (*TokenResult, error)
7379
GenerateRefreshToken(
7480
ctx context.Context,
7581
userID, clientID, scopes string,
7682
ttl time.Duration,
7783
extraClaims map[string]any,
84+
audience []string,
7885
) (*TokenResult, error)
7986
// GenerateClientCredentialsToken generates a token for the client_credentials grant.
8087
// May apply a different expiry or claim set than GenerateToken.
@@ -83,13 +90,15 @@ type TokenProvider interface {
8390
userID, clientID, scopes string,
8491
ttl time.Duration,
8592
extraClaims map[string]any,
93+
audience []string,
8694
) (*TokenResult, error)
8795
ValidateToken(ctx context.Context, tokenString string) (*TokenValidationResult, error)
8896
RefreshAccessToken(
8997
ctx context.Context,
9098
refreshToken string,
9199
accessTTL, refreshTTL time.Duration,
92100
extraClaims map[string]any,
101+
audience []string,
93102
) (*TokenRefreshResult, error)
94103
Name() string
95104
}

0 commit comments

Comments
 (0)