Skip to content

OAuth 2.1 Implementation

Seed implements a complete OAuth 2.1 authorization server with PKCE support, token exchange, 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)
  • 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.

mermaid
sequenceDiagram
    participant Client as Claude Client
    participant Seed as Seed OAuth Proxy
    participant Redis as Redis (DCR Store)
    participant IdP as Identity Provider

    Client->>Seed: POST /oauth/register
    Seed->>Redis: Store client metadata
    Redis-->>Seed: OK
    Seed-->>Client: client_id: seed-abc123

    Client->>Seed: GET /oauth/authorize?client_id=seed-abc123
    Seed->>Redis: Lookup client
    Redis-->>Seed: Client metadata
    Seed->>IdP: Redirect with static client_id
    IdP-->>Client: Authorization code

    Client->>Seed: POST /oauth/token (code + code_verifier)
    Seed->>Redis: Lookup client
    Seed->>IdP: Exchange code (with static client_id)
    IdP-->>Seed: Access token + refresh token
    Seed-->>Client: Access token + refresh token

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:

http
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_VERIFIER

Required Parameters:

  • grant_type: Must be "authorization_code"
  • code: Authorization code from /oauth/authorize
  • redirect_uri: Must match registered redirect URI
  • client_id: Client identifier from registration
  • code_verifier: PKCE verifier (plain text that hashes to code_challenge)

Response (Success):

json
{
  "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:

http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=REFRESH_TOKEN
&client_id=seed-abc123

Required Parameters:

  • grant_type: Must be "refresh_token"
  • refresh_token: Refresh token from previous token response
  • client_id: Client identifier

Response (Success):

json
{
  "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-):

  1. Lookup client in Redis:

    typescript
    const client = await clientStore.get(params.client_id);
    if (!client) {
      return res.status(401).json({
        error: "invalid_client",
        error_description: "Client not found or expired"
      });
    }
  2. Validate redirect_uri (authorization code grant only):

    typescript
    if (!client.redirect_uris.includes(params.redirect_uri)) {
      return res.status(400).json({
        error: "invalid_request",
        error_description: "redirect_uri does not match registered URIs"
      });
    }
  3. Replace client_id with static IdP client:

    typescript
    proxyParams.client_id = config.oidc.audience; // Static IdP client ID
  4. Proxy to upstream token endpoint:

    typescript
    const response = await fetch(config.oidc.tokenUrl, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams(proxyParams),
    });
  5. Return upstream response with same status code

Error Responses

OAuth error responses follow RFC 6749 Section 5.2:

json
{
  "error": "invalid_request",
  "error_description": "Missing required parameter: code"
}

Error Codes:

  • invalid_request: Missing or invalid parameters
  • invalid_client: Client authentication failed
  • invalid_grant: Authorization code or refresh token invalid
  • unsupported_grant_type: Grant type not supported
  • server_error: Internal server error

Authorization Endpoint

Endpoint: GET /oauth/authorize

File: src/routes/oauth-authorize.ts

Public endpoint (no authentication required)

Request Parameters

http
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=S256

Required Parameters:

  • response_type: Must be "code"
  • client_id: Client identifier
  • redirect_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

  1. Validate client (for DCR clients):

    typescript
    const 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}`);
    }
  2. Replace client_id (for DCR clients):

    typescript
    params.client_id = config.oidc.audience; // Static IdP client
  3. Redirect to upstream authorization endpoint:

    typescript
    const 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
HTTP/1.1 302 Found
Location: https://app.example.com/callback?code=AUTHORIZATION_CODE&state=random-csrf-token

Dynamic 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

http
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"]
  • response_types: Array of response types (default: ["code"])
    • Allowed: ["code"]
  • token_endpoint_auth_method: Auth method (default: "none")
    • Allowed: ["none", "client_secret_post", "client_secret_basic"]
  • software_id: Software identifier
  • software_version: Software version

Redirect URI Validation

Security Rules:

  1. Must be a valid URL
  2. Must use https:// (exception: localhost can use http://)
  3. Must not contain a fragment (#)
  4. Maximum 10 redirect URIs per client

Examples:

typescript
// 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 format

Client ID Generation

typescript
function generateClientId(): string {
  const prefix = config.dcr.clientIdPrefix; // "seed-"
  const randomPart = randomBytes(6).toString("base64url"); // 12 chars
  return `${prefix}${randomPart}`;
}

// Example: "seed-a1b2c3d4e5f6"

Response (Success)

Status: 201 Created

json
{
  "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)

json
{
  "error": "invalid_redirect_uri",
  "error_description": "redirect_uri must use https (except for localhost)"
}

Error Codes:

  • invalid_redirect_uri: Redirect URI validation failed
  • invalid_client_metadata: Other validation failures
  • server_error: Storage or processing error

Configuration

Environment Variables

bash
# 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.com

Security 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: src/routes/oauth-authorize.ts
  • Registration: src/routes/oauth-register.ts
  • Client Store: src/services/client-store.ts
  • Config: src/config/oidc.ts, src/config/dcr.ts

Released under the MIT License.