Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .claude/conventions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# oauth-mcp-proxy conventions

adapter_pattern:
description: "SDK-agnostic core with SDK-specific adapters"
rules:
- "Core package (root) contains all OAuth logic and MUST NOT import any MCP SDK"
- "mark3labs/ adapter imports github.com/mark3labs/mcp-go and wraps core as ServerOption"
- "mcp/ adapter imports github.com/modelcontextprotocol/go-sdk and wraps core as http.Handler"
- "Adapters call oauth.NewServer(), RegisterHandlers(), ValidateTokenCached() — never re-implement validation"
- "New SDK adapters go in their own top-level directory with a single WithOAuth() entry point"

provider_abstraction:
description: "TokenValidator interface with pluggable provider implementations"
rules:
- "All providers implement provider.TokenValidator (ValidateToken + Initialize)"
- "HMACValidator for dev/testing — validates JWT with shared secret, no network I/O"
- "OIDCValidator for prod — validates via OIDC discovery and JWKS (Okta, Google, Azure)"
- "createValidator() in config.go is the only factory — switch on cfg.Provider"
- "New providers: add to provider/provider.go, update createValidator() switch, add docs/providers/*.md"

context_propagation:
description: "Two-phase context: HTTP layer adds token, middleware validates and adds User"
rules:
- "Phase 1: CreateHTTPContextFunc() extracts Bearer token -> WithOAuthToken(ctx, token)"
- "Phase 2: Middleware calls GetOAuthToken(ctx) -> ValidateTokenCached() -> WithUser(ctx, user)"
- "Tool handlers access user via GetUserFromContext(ctx) — never parse tokens directly"
- "Context keys are unexported typed constants (contextKey string type)"
- "Never use context.WithValue with string keys — always use the typed helpers"

token_caching:
description: "5-minute TTL cache with SHA-256 hash keys and sync.RWMutex"
rules:
- "Cache key is full SHA-256 hex of the raw token — never store raw tokens"
- "TTL is 5 minutes (hardcoded in ValidateTokenCached and Middleware)"
- "TokenCache uses sync.RWMutex — RLock for reads, Lock for writes"
- "Expired tokens cleaned lazily via goroutine in getCachedToken (deleteExpiredToken)"
- "Each Server instance has its own cache — no global shared state"

configuration:
description: "Config struct with ConfigBuilder fluent API and FromEnv() factory"
rules:
- "Config struct for direct construction, ConfigBuilder for fluent API"
- "Build() validates config via Config.Validate() before returning"
- "FromEnv() reads env vars: OAUTH_MODE, OAUTH_PROVIDER, OIDC_ISSUER, OIDC_AUDIENCE, etc."
- "Mode auto-detected: ClientID present -> proxy, absent -> native"
- "Audience is always required regardless of provider"

security:
description: "Security-first design for OAuth token handling"
rules:
- "Never log raw tokens — only log SHA-256 hash prefix (tokenHash[:16])"
- "401 responses follow RFC 6750: WWW-Authenticate Bearer with error codes"
- "Two error codes: invalid_request (missing token) and invalid_token (validation failed)"
- "Audience validation is explicit in both HMAC and OIDC validators"
- "Redirect URI validation requires explicit allowlist (RedirectURIs config)"
- "OIDC requires TLS 1.2+ (MinVersion: tls.VersionTLS12)"

testing:
description: "oauth-mcp-proxy specific test organization"
rules:
- "Mock validators implement provider.TokenValidator interface"
- "Test files: api_test.go, security_test.go, integration_test.go, config_test.go"
- "Provider tests in provider/provider_test.go"
47 changes: 47 additions & 0 deletions .claude/knowledge/adrs/001-sdk-agnostic-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
title: "ADR-001: SDK-Agnostic Core Package"
status: accepted
date: 2024-12-01
---

## Context

oauth-mcp-proxy needs to support multiple MCP SDK implementations: the community `mark3labs/mcp-go`
(v0.41.1) and the official `modelcontextprotocol/go-sdk` (v1.0.0). These SDKs have different APIs:
mark3labs uses `server.ToolHandlerFunc` middleware and `ServerOption`, while the official SDK uses
standard `http.Handler` wrapping with `mcp.NewStreamableHTTPHandler`.

Coupling OAuth logic to a single SDK would require duplication when supporting the other, and would
create vendor lock-in that prevents users from migrating between SDKs.

## Decision

The core package (root `oauth` package) contains all OAuth logic and is SDK-agnostic. It must not
import any MCP SDK. SDK-specific integration lives in adapter packages:

- **`mark3labs/`** — Imports `mark3labs/mcp-go`, provides `WithOAuth(mux, cfg) -> (*Server, ServerOption, error)`.
Uses `server.WithToolHandlerMiddleware()` to inject the authentication middleware.
- **`mcp/`** — Imports `modelcontextprotocol/go-sdk`, provides `WithOAuth(mux, cfg, mcpServer) -> (*Server, http.Handler, error)`.
Wraps `mcp.NewStreamableHTTPHandler` with Bearer token validation.

Core types used by adapters:
- `oauth.Server` — Created via `NewServer(cfg)`, holds validator + cache + handler
- `oauth.ValidateTokenCached(ctx, token)` — Cache-aware token validation
- `oauth.WithOAuthToken(ctx, token)` / `oauth.WithUser(ctx, user)` — Context helpers
- `oauth.RegisterHandlers(mux)` — Registers OAuth HTTP endpoints

Note: The root package `oauth.go` does import `mark3labs/mcp-go` for the legacy `WithOAuth()` and
`Middleware()` functions. The `mark3labs/` adapter is the recommended path for new code.

## Consequences

**Positive:**
- Users choose their SDK without changing OAuth config or learning a different API.
- Core logic is tested once, not per-SDK. Adapter tests focus on SDK wiring.
- Adding a third SDK adapter requires only a new directory, no core changes.

**Negative:**
- Adapters duplicate some wiring code (Bearer extraction, 401 responses). The `mcp/oauth.go` adapter
re-implements Bearer validation inline rather than reusing `WrapHandler()` because it needs to
control the full HTTP lifecycle for the official SDK's handler.
- The root package still has a legacy mark3labs dependency via `Middleware()` and `GetHTTPServerOptions()`.
60 changes: 60 additions & 0 deletions .claude/knowledge/adrs/002-dual-mode-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
title: "ADR-002: Dual-Mode Authentication (Native vs Proxy)"
status: accepted
date: 2024-12-01
---

## Context

MCP clients have varying OAuth capabilities. Some clients (like Claude Desktop) can perform their own
OAuth flows and send Bearer tokens directly. Others lack built-in OAuth support and need the server to
act as an OAuth proxy, handling the authorization code flow on their behalf.

The library needs to support both scenarios without requiring separate server configurations.

## Decision

Two authentication modes, auto-detected from configuration:

**Native mode** (token validation only):
- Client obtains token from OAuth provider independently.
- Server validates Bearer token via configured provider (HMAC or OIDC).
- Auto-selected when `ClientID` is empty.
- Only `/.well-known/*` metadata endpoints are registered.

**Proxy mode** (server-driven OAuth flow):
- Server proxies the full OAuth 2.1 authorization code flow.
- Registers additional endpoints: `/oauth/authorize`, `/oauth/callback`, `/oauth/token`, `/oauth/register`.
- Requires `ClientID`, `ClientSecret`, `ServerURL`, and `RedirectURIs` in config.
- Auto-selected when `ClientID` is present.

Auto-detection in `Config.Validate()` (config.go:39-45):
```go
if c.Mode == "" {
if c.ClientID != "" {
c.Mode = "proxy"
} else {
c.Mode = "native"
}
}
```

Proxy mode validates additional requirements (config.go:77-87): ClientID, ServerURL, and RedirectURIs
must all be set.

## Consequences

**Positive:**
- Zero-config mode detection: users set credentials for their scenario and mode is inferred.
- Native mode has minimal surface area (no proxy endpoints exposed).
- Both modes share the same token validation and caching infrastructure.

**Negative:**
- Mode auto-detection can surprise users who set ClientID for a different reason.
- Proxy mode requires more config validation, making error messages harder to understand.

## Alternatives Considered

- **Single mode only**: Rejected because it would force all clients to have OAuth capability.
- **Explicit mode required**: Considered, but auto-detection from ClientID is intuitive and reduces
configuration burden.
67 changes: 67 additions & 0 deletions .claude/knowledge/adrs/003-token-caching-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
title: "ADR-003: Token Caching with 5-Minute TTL and SHA-256 Keys"
status: accepted
date: 2024-12-01
---

## Context

Token validation can be expensive: OIDC validation requires network calls to the provider's JWKS
endpoint (10-second timeout per call). In a high-request environment, validating the same token on
every request would create unacceptable latency and load on the identity provider.

The cache must be thread-safe (concurrent HTTP requests), secure (no raw token storage), and
time-bounded (tokens should not be cached indefinitely after revocation).

## Decision

Each `Server` instance maintains its own `TokenCache` (cache.go). Key design choices:

**SHA-256 hash keys**: Raw tokens are never stored. The cache key is the full hex-encoded SHA-256
hash of the token (oauth.go:114):
```go
tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(token)))
```

**5-minute TTL**: Cached tokens expire after 5 minutes (oauth.go:129):
```go
expiresAt := time.Now().Add(5 * time.Minute)
```

**sync.RWMutex**: Read-heavy workload (most requests hit cache) uses `RLock` for reads and `Lock`
for writes (cache.go:15-16):
```go
type TokenCache struct {
mu sync.RWMutex
cache map[string]*CachedToken
}
```

**Lazy cleanup**: Expired tokens are cleaned up asynchronously. When `getCachedToken` finds an expired
entry, it returns cache-miss and spawns a goroutine to delete it (cache.go:37-38):
```go
go tc.deleteExpiredToken(tokenHash)
```
The `deleteExpiredToken` goroutine re-checks expiry under write lock to prevent race conditions.

**Instance-scoped**: No global cache. Each `Server` creates its own cache in `NewServer()` (oauth.go:63-65).

## Consequences

**Positive:**
- OIDC validation cost amortized across requests with the same token.
- SHA-256 keys mean a cache dump does not leak tokens.
- RWMutex allows concurrent cache reads without contention.
- 5-minute TTL limits exposure window after token revocation.

**Negative:**
- Revoked tokens remain valid for up to 5 minutes.
- No cache size limit — a large number of unique tokens could grow memory (acceptable for MCP servers
which typically have few concurrent users).
- Lazy cleanup means expired entries may persist briefly (not a correctness issue).

## Alternatives Considered

- **No cache**: Rejected because OIDC calls add 100-500ms latency per request.
- **Longer TTL (30min)**: Rejected because it widens the revocation window too much.
- **LRU cache with size limit**: Considered but over-engineered for MCP server use cases.
77 changes: 77 additions & 0 deletions .claude/knowledge/adrs/004-provider-abstraction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
title: "ADR-004: TokenValidator Interface for Provider Abstraction"
status: accepted
date: 2024-12-01
---

## Context

The library needs to support multiple OAuth providers: HMAC (for development/testing), and OIDC-based
providers (Okta, Google, Azure AD) for production. Each provider has fundamentally different validation
mechanisms: HMAC uses local JWT signature verification, while OIDC requires network-based JWKS
discovery and verification.

Tests also need to validate OAuth flows without real provider infrastructure.

## Decision

A `TokenValidator` interface in `provider/provider.go` abstracts all provider differences:

```go
type TokenValidator interface {
ValidateToken(ctx context.Context, token string) (*User, error)
Initialize(cfg *Config) error
}
```

Two implementations:
- **`HMACValidator`** — Local JWT validation using `golang-jwt/jwt/v5`. Uses `sync.Once` for
initialization. No network I/O, making it fast and suitable for dev/testing. Validates HMAC-SHA256
signature, expiry, not-before, issued-at, and audience claims.
- **`OIDCValidator`** — Network-based validation using `coreos/go-oidc/v3`. Initializes with 30-second
timeout for OIDC discovery. Validates tokens via JWKS with 10-second timeout. Supports RS256 and
ES256 signing algorithms. Enforces TLS 1.2+.

Both validators explicitly check the `aud` (audience) claim — this is security-critical and not
delegated to the JWT library alone.

Factory in `config.go` (createValidator, line 119):
```go
switch cfg.Provider {
case "hmac":
validator = &provider.HMACValidator{}
case "okta", "google", "azure":
validator = &provider.OIDCValidator{}
}
```

The `User` struct (provider/provider.go:17-21) is the common return type:
```go
type User struct {
Username string
Email string
Subject string
}
```

## Consequences

**Positive:**
- Tests use `HMACValidator` or mock implementations without any OIDC infrastructure.
- Adding a new provider (e.g., Auth0) requires only implementing the interface and adding a case
to `createValidator()`.
- Core package and adapters depend on the interface, not concrete implementations.
- `User` struct is re-exported from root package via type alias (`type User = provider.User` in cache.go).

**Negative:**
- `Initialize()` method means validators are not ready at construction time — must be called before use.
- The `ctx` parameter on `HMACValidator.ValidateToken` is unused (accepted for interface compliance).
- Provider-specific config (e.g., OIDC-specific TLS settings) is passed through the generic
`provider.Config` struct.

## Alternatives Considered

- **Single validator with config switches**: Rejected because HMAC and OIDC have fundamentally
different dependencies and initialization patterns.
- **Separate packages per provider**: Considered, but the current single-file approach
(`provider/provider.go`) is appropriate given only two implementations.
75 changes: 75 additions & 0 deletions .claude/knowledge/adrs/005-context-based-user-propagation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
title: "ADR-005: Context-Based User Propagation"
status: accepted
date: 2024-12-01
---

## Context

Authenticated user information must flow from the HTTP layer (where Bearer tokens are extracted)
through the MCP middleware layer (where tokens are validated) to the tool handler (where the user
identity is consumed). Go's MCP SDKs use `context.Context` as the primary mechanism for passing
request-scoped data through handler chains.

The library must avoid global state (multiple Server instances may coexist) and must work with both
mark3labs and official SDK handler signatures.

## Decision

Two-phase context propagation using typed context keys (context.go):

**Phase 1 — Token extraction** (HTTP layer):
`CreateHTTPContextFunc()` (middleware.go:120-142) extracts the Bearer token from the Authorization
header and stores it in context:
```go
ctx = WithOAuthToken(ctx, token) // context.go:14-16
```

**Phase 2 — Token validation and user injection** (middleware layer):
The SDK-specific middleware retrieves the token, validates it, and adds the user:
```go
tokenString, ok := GetOAuthToken(ctx) // context.go:19-22
user, err := s.ValidateTokenCached(ctx, token)
ctx = WithUser(ctx, user) // context.go:25-27
```

**Consumer access** (tool handler):
```go
user, ok := GetUserFromContext(ctx) // context.go:42-44
```

Context keys are typed constants to prevent collisions (context.go:7-11):
```go
type contextKey string
const (
oauthTokenKey contextKey = "oauth_token"
userContextKey contextKey = "user"
)
```

The mark3labs adapter (mark3labs/middleware.go) uses `server.ToolHandlerFunc` signature and calls
`oauth.GetOAuthToken` -> `ValidateTokenCached` -> `oauth.WithUser`. The official SDK adapter
(mcp/oauth.go) does the same but at the HTTP handler level before passing to
`mcp.NewStreamableHTTPHandler`.

## Consequences

**Positive:**
- Request-scoped: each request carries its own user, no global state or goroutine-local storage.
- Works identically with both SDK adapters since both use `context.Context`.
- Type-safe keys prevent collision with other context values.
- Tool handlers have a clean API: `GetUserFromContext(ctx)` returns `(*User, bool)`.

**Negative:**
- Two-phase design means a missing Phase 1 (no `CreateHTTPContextFunc`) silently fails at Phase 2
with "authentication required: missing OAuth token" — not always obvious to debug.
- Context values are not visible in type signatures, so the dependency on `WithOAuthToken` being
called before middleware is implicit.

## Alternatives Considered

- **Global user map keyed by request ID**: Rejected because it introduces global state and cleanup complexity.
- **Custom request struct wrapping context**: Rejected because it would require non-standard handler
signatures incompatible with both SDKs.
- **Single-phase (validate at HTTP level only)**: Rejected because mark3labs SDK needs middleware at
the tool handler level to access `mcp.CallToolRequest`.
Loading
Loading