Skip to content

Support multiple OAuth Identity Providers with provider selection page #74

@BorisTyshkevich

Description

@BorisTyshkevich

Problem

The current OAuth implementation supports exactly one upstream Identity Provider at a time. All config fields (issuer, client_id, client_secret, auth_url, token_url, etc.) are single-valued. Organizations often need multiple IdPs simultaneously — e.g., Google for corporate users, GitHub for developers, and Keycloak for on-prem users.

Goal

Configure multiple IdPs. When a user hits /oauth/authorize, show a provider selection page listing all configured providers. After selection, continue the normal OAuth redirect flow with that provider's endpoints and credentials.

Current Architecture (Single IdP)

/oauth/authorize → redirect to single upstream auth_url → /oauth/callback → token exchange → mint gating token

Key single-IdP assumptions baked into the code:

Location Single-IdP assumption
OAuthConfig (pkg/config/config.go:76-179) All fields single-valued: Issuer, ClientID, ClientSecret, AuthURL, TokenURL, etc.
handleOAuthAuthorize (oauth_server.go:792-856) Calls resolveUpstreamAuthURL() → returns one URL; uses one ClientID
handleOAuthCallback (oauth_server.go:858-995) Uses single ClientID/ClientSecret for token exchange; validates against single issuer
oauthPendingAuth struct (oauth_server.go:48-56) No field to track which IdP was chosen
resolveUpstreamAuthURL/TokenURL (oauth_server.go:623-651) Return single URL from single config
validateOAuthClaims (server.go:453-509) Checks against single Issuer and Audience
Metadata endpoints (oauth_server.go:653-714) Advertise single set of scopes
mintGatingTokenResponse (oauth_server.go:1008-1069) Uses single issuer for self-issued tokens

Proposed Design

Config: Provider Array

server:
  oauth:
    enabled: true
    mode: "gating"
    gating_secret_key: "<SECRET>"
    
    # Shared identity policy (applies to all providers)
    require_email_verified: true
    allowed_email_domains: ["yourcompany.com"]
    
    # Multiple providers
    providers:
      - id: "google"
        name: "Google"
        issuer: "https://accounts.google.com"
        client_id: "<GOOGLE_CLIENT_ID>"
        client_secret: "<GOOGLE_CLIENT_SECRET>"
        scopes: ["openid", "email", "profile"]
        # auth_url, token_url auto-discovered via OIDC

      - id: "github"
        name: "GitHub"
        issuer: "https://github.com"
        client_id: "<GITHUB_CLIENT_ID>"
        client_secret: "<GITHUB_CLIENT_SECRET>"
        auth_url: "https://github.com/login/oauth/authorize"
        token_url: "https://github.com/login/oauth/access_token"
        userinfo_url: "https://api.github.com/user"
        userinfo_email_url: "https://api.github.com/user/emails"
        scopes: ["user:email"]
        userinfo_claims_mapping:
          id: "sub"
          login: "preferred_username"

      - id: "keycloak"
        name: "Keycloak"
        issuer: "https://keycloak.example.com/realms/myrealm"
        client_id: "<KC_CLIENT_ID>"
        client_secret: "<KC_CLIENT_SECRET>"
        scopes: ["openid", "email"]

Backward compatibility: If the legacy single-provider fields (issuer, client_id, etc.) are set and providers is empty, synthesize a single-entry providers array internally. Zero breaking changes.

Authorization Flow: Provider Selection Page

/oauth/authorize
    ↓
[provider param present?]
    ├── yes → store provider ID in pending state → redirect to that IdP
    └── no  → render provider selection HTML page
                ↓ (user clicks provider)
              /oauth/authorize?...&provider=google
                ↓
              store provider ID in pending state → redirect to Google

The selection page is a minimal, self-contained HTML page (no external dependencies) rendered by the server. Lists each configured provider with name and optional icon.

Callback: Provider-Aware Token Exchange

type oauthPendingAuth struct {
    // ... existing fields ...
    ProviderID string  // NEW: which IdP was chosen
}

In handleOAuthCallback:

  1. Consume pending auth → get ProviderID
  2. Look up provider config by ID → get that provider's TokenURL, ClientID, ClientSecret
  3. Exchange code with correct provider endpoints
  4. Validate identity token/userinfo against provider-specific issuer

Token Validation: Multi-Issuer

In gating mode, the MCP server is the token issuer (self-issued). No change needed — gating tokens are validated against gating_secret_key, not the upstream IdP.

In forward mode with multiple IdPs, validateOAuthClaims needs to accept any of the configured provider issuers (loop over providers[].issuer).

Metadata Endpoints

scopes_supported in authorization server metadata becomes the union of all providers' scopes.

Key Implementation Areas

New/modified files:

File Changes
pkg/config/config.go New OAuthProvider struct; Providers []OAuthProvider field; backward-compat migration of legacy fields
cmd/altinity-mcp/oauth_server.go Provider selection page; provider-aware authorize/callback/token handlers; per-provider URL resolution
pkg/server/server.go Multi-issuer validation in forward mode
cmd/altinity-mcp/oauth_server_test.go Tests for multi-provider flows
docs/oauth_authorization.md Multi-provider configuration docs

Provider selection page

Embedded HTML (Go embed or inline string). Minimal design:

  • List of provider buttons (name + optional icon)
  • Each button links to /oauth/authorize?...&provider={id}
  • Preserves all original query params (client_id, redirect_uri, code_challenge, state, etc.)
  • No JavaScript required — pure HTML form/links

Dependencies

  • Add GitHub as OAuth Identity Provider #73 (GitHub IdP support) — the userinfo_claims_mapping, userinfo_email_url, and accept header config fields from that issue should be per-provider fields in the OAuthProvider struct rather than top-level OAuthConfig fields.

Open Questions

  • Should identity policy (allowed_email_domains, require_email_verified) be configurable per-provider, or always global?
  • Should mode (forward/gating) be per-provider or global? (Per-provider seems over-complex initially.)
  • Should the provider selection page be customizable (logo, colors, custom CSS)?
  • How should the provider ID be tracked for MCP protocol clients that may not render HTML? (e.g., pass provider as a query param in the initial authorize request)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions