Skip to content

Commit 6f711ca

Browse files
committed
feat: Implement OAuth 2.1 Resource Server for JWT authentication, including JWKS caching, discovery endpoints, and configuration updates.
1 parent 69ff109 commit 6f711ca

13 files changed

Lines changed: 642 additions & 30 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ vendor/
1717
# User Specific configuration
1818
agentgate.yaml
1919
.env
20+
chinook.db

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,22 @@ Relying on "system prompts" for security is a guaranteed way to get your databas
4949

5050
## 🔥 Core Features
5151

52-
### 1. Semantic RBAC & Parameter Sandbox 🔒
52+
### 1. Centralized OAuth 2.1 Resource Server 🛡️
53+
Stop writing custom OAuth logic for every single MCP tool you build! AgentGate acts as a spec-compliant OAuth 2.1 Resource Server. It validates JWTs, fetches JWKS keys (with background rotation), and bounces unauthenticated AI clients with `WWW-Authenticate` headers — completely decoupling auth from your business logic.
54+
55+
### 2. Semantic RBAC & Parameter Sandbox 🔒
5356
Whitelist exactly which tools an agent can use. Go deeper with **regex rules on the parameters themselves** — e.g., the agent can only read files ending in `.log`, or can only `SELECT` but never `DELETE`.
5457

55-
### 2. Human-in-the-Loop (HITL) ⏸️
58+
### 3. Human-in-the-Loop (HITL) ⏸️
5659
Automatically pause high-risk tool execution. AgentGate intercepts the request, pings your **Slack** or a CLI webhook, and physically holds the HTTP connection open until a human clicks **Approve** or **Deny**.
5760

58-
### 3. Runaway Loop Breaker (Rate Limiting) ⏱️
61+
### 4. Runaway Loop Breaker (Rate Limiting) ⏱️
5962
Defeat hallucination loops. Cap tool executions per minute globally or **per MCP server**. If an agent spams a function, it instantly receives `HTTP 429`.
6063

61-
### 4. The IPC Panic Button 🛑
64+
### 5. The IPC Panic Button 🛑
6265
If an agent goes completely rogue, type `agentgate service pause` in your terminal. This uses an isolated Unix Domain Socket to **instantly sever all autonomous tool execution** with a `503`, without exposing an admin endpoint to the network.
6366

64-
### 5. stdio → HTTP Bridge 🌉
67+
### 6. stdio → HTTP Bridge 🌉
6568
MCP natively uses local `stdio`. AgentGate translates this to standard **HTTP/SSE**, letting you run tools in an isolated container or VPC while the LLM client stays local.
6669

6770
---

auth/context.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package auth
2+
3+
import "context"
4+
5+
// ctxKey is an unexported type for context keys in this package.
6+
// Using a package-local type prevents collisions with other packages
7+
// that also use context.WithValue.
8+
type ctxKey int
9+
10+
const (
11+
ctxKeySub ctxKey = iota // JWT "sub" claim (user/client identity)
12+
ctxKeyScopes // JWT "scope" claim (space-delimited, split into []string)
13+
)
14+
15+
// ContextWithClaims returns a new context enriched with the JWT subject and scopes.
16+
func ContextWithClaims(ctx context.Context, sub string, scopes []string) context.Context {
17+
ctx = context.WithValue(ctx, ctxKeySub, sub)
18+
ctx = context.WithValue(ctx, ctxKeyScopes, scopes)
19+
return ctx
20+
}
21+
22+
// SubFromContext extracts the JWT subject (sub) from a request context.
23+
// Returns an empty string if not present (i.e., OAuth2 is disabled).
24+
func SubFromContext(ctx context.Context) string {
25+
v, _ := ctx.Value(ctxKeySub).(string)
26+
return v
27+
}
28+
29+
// ScopesFromContext extracts the JWT scopes from a request context.
30+
// Returns nil if not present.
31+
func ScopesFromContext(ctx context.Context) []string {
32+
v, _ := ctx.Value(ctxKeyScopes).([]string)
33+
return v
34+
}
35+
36+
// HasScope returns true if the given scope is present in the request context.
37+
func HasScope(ctx context.Context, required string) bool {
38+
for _, s := range ScopesFromContext(ctx) {
39+
if s == required {
40+
return true
41+
}
42+
}
43+
return false
44+
}

auth/jwks.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package auth
2+
3+
import (
4+
"crypto"
5+
"crypto/ecdsa"
6+
"crypto/elliptic"
7+
"crypto/rsa"
8+
"encoding/base64"
9+
"encoding/json"
10+
"fmt"
11+
"log"
12+
"math/big"
13+
"net/http"
14+
"sync"
15+
"time"
16+
)
17+
18+
// jwksResponse is the raw JSON structure from the /.well-known/jwks.json endpoint.
19+
type jwksResponse struct {
20+
Keys []jwk `json:"keys"`
21+
}
22+
23+
// jwk represents a single JSON Web Key (RSA or EC public key fields).
24+
type jwk struct {
25+
Kid string `json:"kid"` // Key ID
26+
Kty string `json:"kty"` // Key type ("RSA" or "EC")
27+
Alg string `json:"alg"` // Algorithm (e.g. "RS256", "ES256")
28+
29+
// RSA fields:
30+
N string `json:"n"` // RSA modulus (base64url encoded)
31+
E string `json:"e"` // RSA exponent (base64url encoded)
32+
33+
// EC fields:
34+
Crv string `json:"crv"` // Curve (e.g. "P-256")
35+
X string `json:"x"` // EC X coordinate
36+
Y string `json:"y"` // EC Y coordinate
37+
}
38+
39+
// JWKSCache fetches and caches public keys from a JWKS endpoint.
40+
// It refreshes the cache periodically to handle key rotation.
41+
type JWKSCache struct {
42+
jwksURL string
43+
mu sync.RWMutex
44+
keys map[string]crypto.PublicKey // kid → public key
45+
}
46+
47+
// NewJWKSCache creates a JWKSCache and performs an initial key fetch.
48+
// It also spawns a background goroutine that refreshes the cache every
49+
// refreshInterval to transparently handle key rotation at the IdP.
50+
func NewJWKSCache(jwksURL string, refreshInterval time.Duration) (*JWKSCache, error) {
51+
c := &JWKSCache{
52+
jwksURL: jwksURL,
53+
keys: make(map[string]crypto.PublicKey),
54+
}
55+
56+
// Perform the initial fetch synchronously so we fail fast on misconfiguration.
57+
if err := c.refresh(); err != nil {
58+
return nil, fmt.Errorf("initial JWKS fetch from %s failed: %w", jwksURL, err)
59+
}
60+
61+
// Background refresh goroutine — handles key rotation.
62+
go func() {
63+
ticker := time.NewTicker(refreshInterval)
64+
defer ticker.Stop()
65+
for range ticker.C {
66+
if err := c.refresh(); err != nil {
67+
log.Printf("[JWKS] Background refresh failed (will retry): %v", err)
68+
} else {
69+
log.Printf("[JWKS] Key cache refreshed from %s", jwksURL)
70+
}
71+
}
72+
}()
73+
74+
return c, nil
75+
}
76+
77+
// GetKey returns the public key for the given key ID (kid).
78+
// Returns an error if the kid is not found in the cache.
79+
func (c *JWKSCache) GetKey(kid string) (crypto.PublicKey, error) {
80+
c.mu.RLock()
81+
key, ok := c.keys[kid]
82+
c.mu.RUnlock()
83+
84+
if !ok {
85+
// Kid not found — try one eager refresh in case the IdP just rotated keys.
86+
log.Printf("[JWKS] kid %q not in cache — attempting eager refresh", kid)
87+
if err := c.refresh(); err != nil {
88+
return nil, fmt.Errorf("kid %q not found and refresh failed: %w", kid, err)
89+
}
90+
c.mu.RLock()
91+
key, ok = c.keys[kid]
92+
c.mu.RUnlock()
93+
if !ok {
94+
return nil, fmt.Errorf("kid %q not found in JWKS after refresh", kid)
95+
}
96+
}
97+
return key, nil
98+
}
99+
100+
// refresh fetches the JWKS endpoint and atomically replaces the key cache.
101+
func (c *JWKSCache) refresh() error {
102+
resp, err := http.Get(c.jwksURL) //nolint:noctx
103+
if err != nil {
104+
return fmt.Errorf("GET %s: %w", c.jwksURL, err)
105+
}
106+
defer resp.Body.Close()
107+
108+
if resp.StatusCode != http.StatusOK {
109+
return fmt.Errorf("GET %s: unexpected HTTP %d", c.jwksURL, resp.StatusCode)
110+
}
111+
112+
var jwks jwksResponse
113+
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
114+
return fmt.Errorf("decode JWKS: %w", err)
115+
}
116+
117+
newKeys := make(map[string]crypto.PublicKey, len(jwks.Keys))
118+
for _, k := range jwks.Keys {
119+
if k.Kty == "RSA" {
120+
pub, err := parseRSAPublicKey(k)
121+
if err != nil {
122+
log.Printf("[JWKS] Skipping RSA key kid=%q: %v", k.Kid, err)
123+
continue
124+
}
125+
newKeys[k.Kid] = pub
126+
} else if k.Kty == "EC" && k.Crv == "P-256" {
127+
pub, err := parseECPublicKey(k)
128+
if err != nil {
129+
log.Printf("[JWKS] Skipping EC key kid=%q: %v", k.Kid, err)
130+
continue
131+
}
132+
newKeys[k.Kid] = pub
133+
} else {
134+
continue // Skip unsupported key types/curves
135+
}
136+
}
137+
138+
if len(newKeys) == 0 {
139+
return fmt.Errorf("JWKS response contained no supported RSA or EC keys")
140+
}
141+
142+
c.mu.Lock()
143+
c.keys = newKeys
144+
c.mu.Unlock()
145+
146+
log.Printf("[JWKS] Loaded %d key(s) from %s", len(newKeys), c.jwksURL)
147+
return nil
148+
}
149+
150+
// parseECPublicKey converts a JSON Web Key into a Go *ecdsa.PublicKey
151+
// using only the standard library.
152+
func parseECPublicKey(k jwk) (*ecdsa.PublicKey, error) {
153+
xBytes, err := base64.RawURLEncoding.DecodeString(k.X)
154+
if err != nil {
155+
return nil, fmt.Errorf("decode X: %w", err)
156+
}
157+
158+
yBytes, err := base64.RawURLEncoding.DecodeString(k.Y)
159+
if err != nil {
160+
return nil, fmt.Errorf("decode Y: %w", err)
161+
}
162+
163+
return &ecdsa.PublicKey{
164+
Curve: elliptic.P256(),
165+
X: new(big.Int).SetBytes(xBytes),
166+
Y: new(big.Int).SetBytes(yBytes),
167+
}, nil
168+
}
169+
170+
// parseRSAPublicKey converts a JSON Web Key into a Go *rsa.PublicKey
171+
// using only the standard library (no external JWT packages).
172+
func parseRSAPublicKey(k jwk) (*rsa.PublicKey, error) {
173+
// Decode the base64url-encoded modulus (N)
174+
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
175+
if err != nil {
176+
return nil, fmt.Errorf("decode N: %w", err)
177+
}
178+
179+
// Decode the base64url-encoded exponent (E)
180+
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
181+
if err != nil {
182+
return nil, fmt.Errorf("decode E: %w", err)
183+
}
184+
185+
// Convert E bytes to int
186+
var eInt int
187+
for _, b := range eBytes {
188+
eInt = eInt<<8 | int(b)
189+
}
190+
191+
return &rsa.PublicKey{
192+
N: new(big.Int).SetBytes(nBytes),
193+
E: eInt,
194+
}, nil
195+
}

auth/middleware.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package auth
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/agentgate/agentgate/config"
10+
)
11+
12+
// JWTAuthMiddleware is the OAuth 2.1 Resource Server interceptor.
13+
//
14+
// Behavior:
15+
// - If oauth2.enabled = false: passes through immediately (backward compatible).
16+
// - No/invalid Authorization header: HTTP 401 + WWW-Authenticate challenge.
17+
// - Valid JWT: enriches context with sub+scopes, strips Authorization header,
18+
// optionally injects X-AgentGate-User header for the upstream MCP server.
19+
// - Passes to next handler.
20+
func JWTAuthMiddleware(cfg *config.Config, cache *JWKSCache, next http.Handler) http.Handler {
21+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+
// Fast path: OAuth2 not enabled — skip this entire layer.
23+
if !cfg.OAuth2.Enabled {
24+
next.ServeHTTP(w, r)
25+
return
26+
}
27+
28+
// ── Extract Bearer token ──────────────────────────────────────────────
29+
authHeader := r.Header.Get("Authorization")
30+
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
31+
writeWWWAuthenticate(w, cfg.OAuth2.ResourceMetadata, "missing_token", "Authorization: Bearer <token> is required")
32+
return
33+
}
34+
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
35+
36+
// ── Validate JWT ──────────────────────────────────────────────────────
37+
sub, scopes, err := ValidateJWT(tokenString, cache, cfg.OAuth2.Issuer, cfg.OAuth2.Audience)
38+
if err != nil {
39+
log.Printf("[JWTAuth] [ERROR] Token validation failed from %s: %v", r.RemoteAddr, err)
40+
errCode := classifyError(err)
41+
writeWWWAuthenticate(w, cfg.OAuth2.ResourceMetadata, errCode, err.Error())
42+
return
43+
}
44+
45+
log.Printf("[JWTAuth] Token valid — sub=%q scopes=%v from %s", sub, scopes, r.RemoteAddr)
46+
47+
// ── Enrich context with claims ────────────────────────────────────────
48+
ctx := ContextWithClaims(r.Context(), sub, scopes)
49+
r = r.WithContext(ctx)
50+
51+
// ── Strip upstream Authorization header (don't leak the JWT) ─────────
52+
// We replace the original header with a custom user identity header
53+
// so the upstream MCP server knows who made the request without having
54+
// access to the raw credential.
55+
r.Header.Del("Authorization")
56+
if cfg.OAuth2.InjectUserHeader {
57+
r.Header.Set("X-AgentGate-User", sub)
58+
if len(scopes) > 0 {
59+
r.Header.Set("X-AgentGate-Scopes", strings.Join(scopes, " "))
60+
}
61+
}
62+
63+
next.ServeHTTP(w, r)
64+
})
65+
}
66+
67+
// writeWWWAuthenticate sends an RFC 6750-compliant 401 response.
68+
//
69+
// Format per MCP spec:
70+
//
71+
// WWW-Authenticate: Bearer realm="mcp", resource_metadata="<url>",
72+
// error="<code>", error_description="<msg>"
73+
func writeWWWAuthenticate(w http.ResponseWriter, resourceMetadata, errCode, errDesc string) {
74+
challenge := fmt.Sprintf(
75+
`Bearer realm="mcp", resource_metadata="%s", error="%s", error_description="%s"`,
76+
resourceMetadata, errCode, errDesc,
77+
)
78+
w.Header().Set("WWW-Authenticate", challenge)
79+
w.Header().Set("Content-Type", "application/json")
80+
w.WriteHeader(http.StatusUnauthorized)
81+
w.Write([]byte(`{"error":"unauthorized","error_description":"` + errDesc + `"}`))
82+
}
83+
84+
// classifyError maps a validation error to an RFC 6750 error code string.
85+
func classifyError(err error) string {
86+
msg := err.Error()
87+
if strings.Contains(msg, "expired") {
88+
return "invalid_token"
89+
}
90+
if strings.Contains(msg, "signature") {
91+
return "invalid_token"
92+
}
93+
if strings.Contains(msg, "issuer") || strings.Contains(msg, "audience") {
94+
return "invalid_token"
95+
}
96+
if strings.Contains(msg, "malformed") || strings.Contains(msg, "algorithm") {
97+
return "invalid_token"
98+
}
99+
return "invalid_token"
100+
}

0 commit comments

Comments
 (0)