OAuth 2.1 Implementation
Seed implements a complete OAuth 2.1 authorization server with PKCE support, token exchange, token revocation, and dynamic client registration (DCR). It acts as an OAuth proxy, allowing dynamically registered clients while delegating actual authentication to an upstream identity provider.
Overview
The OAuth implementation provides:
- Authorization Code Flow with PKCE (RFC 7636)
- Refresh Token Grant (RFC 6749 Section 6)
- Token Revocation (RFC 7009) - ✅ IMPLEMENTED (2026-01-06)
- Dynamic Client Registration (RFC 7591)
- OAuth 2.0 Discovery (RFC 8414)
- Protected Resource Metadata (RFC 9728)
Architecture Pattern
Seed uses an OAuth Proxy Pattern:
- Seed exposes OAuth endpoints to Claude clients
- Dynamically registered clients are stored in Redis
- Client IDs are mapped to a static upstream IdP client
- Actual authentication is delegated to the upstream IdP (e.g., Authentik)
This pattern gives Seed control over the OAuth flow while the IdP handles authentication.
Token Exchange Flow
Endpoint: POST /oauth/token
File: src/routes/oauth-token.ts
Public endpoint (no authentication required)
Supported Grant Types
1. Authorization Code Grant
Exchange an authorization code for access and refresh tokens.
Request:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https://app.example.com/callback
&client_id=seed-abc123
&code_verifier=PKCE_VERIFIERRequired Parameters:
grant_type: Must be"authorization_code"code: Authorization code from /oauth/authorizeredirect_uri: Must match registered redirect URIclient_id: Client identifier from registrationcode_verifier: PKCE verifier (plain text that hashes to code_challenge)
Response (Success):
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"scope": "openid profile email"
}2. Refresh Token Grant
Exchange a refresh token for a new access token.
Request:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=REFRESH_TOKEN
&client_id=seed-abc123Required Parameters:
grant_type: Must be"refresh_token"refresh_token: Refresh token from previous token responseclient_id: Client identifier
Response (Success):
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"scope": "openid profile email"
}Dynamic Client Registration Flow
For clients with IDs starting with the configured prefix (default: seed-):
Lookup client in Redis:
typescriptconst client = await clientStore.get(params.client_id); if (!client) { return res.status(401).json({ error: "invalid_client", error_description: "Client not found or expired" }); }Validate redirect_uri (authorization code grant only):
typescriptif (!client.redirect_uris.includes(params.redirect_uri)) { return res.status(400).json({ error: "invalid_request", error_description: "redirect_uri does not match registered URIs" }); }Replace client_id with static IdP client:
typescriptproxyParams.client_id = config.oidc.audience; // Static IdP client IDProxy to upstream token endpoint:
typescriptconst response = await fetch(config.oidc.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(proxyParams), });Return upstream response with same status code
Error Responses
OAuth error responses follow RFC 6749 Section 5.2:
{
"error": "invalid_request",
"error_description": "Missing required parameter: code"
}Error Codes:
invalid_request: Missing or invalid parametersinvalid_client: Client authentication failedinvalid_grant: Authorization code or refresh token invalidunsupported_grant_type: Grant type not supportedserver_error: Internal server error
Authorization Endpoint
Endpoint: GET /oauth/authorize
File: src/routes/oauth-authorize.ts
Public endpoint (no authentication required)
Request Parameters
GET /oauth/authorize
?response_type=code
&client_id=seed-abc123
&redirect_uri=https://app.example.com/callback
&scope=openid%20profile%20email
&state=random-csrf-token
&code_challenge=BASE64URL(SHA256(code_verifier))
&code_challenge_method=S256Required Parameters:
response_type: Must be"code"client_id: Client identifierredirect_uri: Callback URL (must match registered URI for DCR clients)scope: Requested scopes (e.g., "openid profile")state: CSRF protection token (recommended)code_challenge: PKCE challenge (base64url-encoded SHA-256 hash)code_challenge_method: Must be"S256"
Flow
Validate client (for DCR clients):
typescriptconst client = await clientStore.get(clientId); if (!client) { return res.redirect(`${redirectUri}?error=invalid_client&state=${state}`); } if (!client.redirect_uris.includes(redirectUri)) { return res.redirect(`${redirectUri}?error=invalid_request&error_description=invalid_redirect_uri&state=${state}`); }Replace client_id (for DCR clients):
typescriptparams.client_id = config.oidc.audience; // Static IdP clientRedirect to upstream authorization endpoint:
typescriptconst authUrl = `${config.oidc.authorizationUrl}?${new URLSearchParams(params)}`; res.redirect(302, authUrl);
Response
The endpoint returns a 302 redirect to the upstream IdP's authorization page. After authentication, the IdP redirects back to the redirect_uri with an authorization code:
HTTP/1.1 302 Found
Location: https://app.example.com/callback?code=AUTHORIZATION_CODE&state=random-csrf-tokenDynamic Client Registration
Endpoint: POST /oauth/register
File: src/routes/oauth-register.ts
Public endpoint (no authentication required)
RFC 7591 Compliance
Implements OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) with:
- Client metadata validation
- Redirect URI security checks
- Client ID generation
- TTL-based storage in Redis
Request
POST /oauth/register
Content-Type: application/json
{
"redirect_uris": [
"https://app.example.com/callback",
"http://localhost:3000/callback"
],
"client_name": "My Application",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"software_id": "my-app",
"software_version": "1.0.0"
}Required Fields:
redirect_uris: Array of callback URLs (1-10 URIs)
Optional Fields:
client_name: Human-readable name (default: "OAuth Client")grant_types: Array of grant types (default:["authorization_code"])- Allowed:
["authorization_code", "refresh_token"]
- Allowed:
response_types: Array of response types (default:["code"])- Allowed:
["code"]
- Allowed:
token_endpoint_auth_method: Auth method (default:"none")- Allowed:
["none", "client_secret_post", "client_secret_basic"]
- Allowed:
software_id: Software identifiersoftware_version: Software version
Redirect URI Validation
Security Rules:
- Must be a valid URL
- Must use
https://(exception:localhostcan usehttp://) - Must not contain a fragment (
#) - Maximum 10 redirect URIs per client
Examples:
// Valid
"https://app.example.com/callback"
"http://localhost:3000/callback"
"http://127.0.0.1:8080/auth"
// Invalid
"http://app.example.com/callback" // Not localhost, must use https
"https://app.example.com#fragment" // Contains fragment
"not-a-url" // Invalid URL formatClient ID Generation
Client IDs are generated using cryptographically secure random characters:
function generateClientId(): string {
const prefix = config.dcr.clientIdPrefix; // "seed-"
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
const length = 12;
const randomPart = Array.from({ length }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join("");
return `${prefix}${randomPart}`;
}
// Example: "seed-a1b2c3d4e5f6"Properties:
- Prefix: Configurable via
DCR_CLIENT_ID_PREFIX(default:"seed-") - Length: 12 random characters (lowercase alphanumeric)
- Character set:
a-zand0-9(36 characters) - Entropy: ~62 bits (36^12 combinations)
- Collision resistance: Extremely low probability with 30-day TTL
Response (Success)
Status: 201 Created
{
"client_id": "seed-a1b2c3d4e5f6",
"client_name": "My Application",
"redirect_uris": [
"https://app.example.com/callback",
"http://localhost:3000/callback"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"client_id_issued_at": 1702857600,
"client_secret_expires_at": 0,
"software_id": "my-app",
"software_version": "1.0.0"
}Notes:
client_secret_expires_at: Always 0 (public client, no secret)client_id_issued_at: Unix timestamp of registration- Client stored in Redis with 30-day TTL (configurable)
Response (Error)
{
"error": "invalid_redirect_uri",
"error_description": "redirect_uri must use https (except for localhost)"
}Error Codes:
invalid_redirect_uri: Redirect URI validation failedinvalid_client_metadata: Other validation failuresserver_error: Storage or processing error
Rate Limiting
The Dynamic Client Registration endpoint is protected by distributed rate limiting (10 registrations/hour per IP, 1,000 globally).
See Also: Rate Limiting for comprehensive documentation including configuration, sliding window implementation, and observability.
Token Revocation
✅ IMPLEMENTED (2026-01-06) - RFC 7009 compliant token revocation endpoint for access and refresh tokens.
Endpoint: POST /oauth/revoke
File: src/routes/oauth-revoke.ts
Public endpoint (no authentication required)
Request
POST /oauth/revoke
Content-Type: application/x-www-form-urlencoded
token=REFRESH_TOKEN_OR_ACCESS_TOKEN
&client_id=seed-abc123
&token_type_hint=refresh_tokenRequired Parameters:
token: The token to revoke (access token or refresh token)client_id: Client identifier
Optional Parameters:
token_type_hint: Hint about token type (access_tokenorrefresh_token)
Behavior
Refresh Token Revocation:
- Token proxied to upstream IdP's revocation endpoint
- IdP immediately invalidates the refresh token
- Cannot be used to obtain new access tokens
Access Token Revocation:
- Token added to revocation cache in Redis (5-minute TTL)
- Authentication middleware checks revocation cache before validating token
- Rejected with 401 if found in revocation cache
- Does not affect upstream IdP (access tokens are typically short-lived)
Implementation Details:
// src/routes/oauth-revoke.ts
export const revocationCache = {
keyPrefix: "revoked:token:",
ttlSeconds: 300, // 5 minutes (covers typical access token lifetime)
};
// Store revoked access token
await redis.setex(
`${revocationCache.keyPrefix}${tokenHash}`,
revocationCache.ttlSeconds,
"1"
);Response
Success (200 OK):
{}RFC 7009 requires successful revocation to return 200 OK with empty response, regardless of whether the token was valid.
Error (400 Bad Request):
{
"error": "invalid_request",
"error_description": "Missing required parameter: token"
}Security Considerations
Token Hashing:
- Access tokens hashed (SHA-256) before storage in revocation cache
- Prevents token leakage from cache inspection
TTL-Based Cleanup:
- Revoked access tokens auto-expire after 5 minutes
- Matches typical access token lifetime
- Prevents unbounded cache growth
Upstream Delegation:
- Refresh token revocation delegated to IdP
- IdP handles actual invalidation and security
- Seed acts as transparent proxy
Use Cases
- User Logout: Revoke refresh token to prevent new access tokens
- Security Incident: Immediately revoke compromised tokens
- Token Rotation: Revoke old tokens during rotation flows
- Session Termination: Revoke tokens when closing MCP session
See Also: API Documentation - Token Revocation for request examples and integration guidance.
Observability & Monitoring
OAuth endpoints include comprehensive logging and metrics for monitoring and debugging.
See Also: Observability for comprehensive documentation including structured logging, Prometheus metrics, and monitoring best practices.
Configuration
Environment Variables
# OAuth Endpoints
OAUTH_AUTHORIZATION_URL=https://auth.example.com/application/o/authorize/
OAUTH_TOKEN_URL=https://auth.example.com/application/o/token/
# OIDC Configuration
OIDC_ISSUER=https://auth.example.com/application/o/my-app/
OIDC_AUDIENCE=my-static-client-id
# Dynamic Client Registration
REDIS_URL=redis://redis:6379
DCR_CLIENT_TTL=2592000 # 30 days
DCR_RATE_LIMIT_WINDOW_MS=3600000 # 1 hour
DCR_RATE_LIMIT_MAX=10 # 10 requests per window
# Server
BASE_URL=https://seed.example.comSecurity Considerations
Redirect URI Security
- HTTPS required (except localhost)
- No fragments allowed
- Exact match validation
- Maximum 10 URIs per client
Client ID Security
- Cryptographically random generation
- Prefix-based identification (
seed-) - TTL-based expiration
- Redis storage with expiry
PKCE Requirements
- S256 method required (SHA-256)
- Code verifier: 43-128 characters
- Code challenge: Base64url-encoded SHA-256
Implementation Files
- Token Exchange:
src/routes/oauth-token.ts- Authorization code and refresh token grants - Authorization:
src/routes/oauth-authorize.ts- Authorization endpoint with PKCE - Token Revocation:
src/routes/oauth-revoke.ts- RFC 7009 token revocation endpoint - Registration:
src/routes/oauth-register.ts- Dynamic Client Registration with rate limiting - Client Store:
src/services/client-store.ts- Redis-backed client metadata storage - Logger Service:
src/services/logger.js- Winston logger with structured logging - Metrics Service:
src/services/metrics.ts- Prometheus metrics definitions - Rate Limiting:
src/middleware/distributed-rate-limit.ts- Redis-backed rate limiter - Config:
src/config/oidc.ts,src/config/dcr.ts,src/config/rate-limit.ts
Related Documentation
- Authentication Flow - JWT validation
- Session Management - MCP session tracking
- Configuration System - Environment configuration