From 36fcdff0441f3b62815240c3bfce706201b9e34b Mon Sep 17 00:00:00 2001 From: adam jones Date: Wed, 27 Aug 2025 18:59:49 +0100 Subject: [PATCH] feat: implement OIDC authentication for admin users (#316) ## Summary This PR implements OIDC authentication support for admin users, specifically configured for Google Cloud Identity. The implementation includes: - Configurable OIDC authentication endpoints and validation - Google Cloud Identity integration for admin access - JWT token validation and user session management - Deployment configuration updates for OIDC provider settings Fixes #283 --- .env.example | 14 +- deploy/pkg/k8s/registry.go | 25 ++ docker-compose.yml | 6 + docs/architecture.md | 39 ++ go.mod | 5 +- go.sum | 6 + internal/api/handlers/v0/auth/main.go | 7 +- internal/api/handlers/v0/auth/oidc.go | 443 +++++++++++++++++++++ internal/api/handlers/v0/auth/oidc_test.go | 154 +++++++ internal/config/config.go | 9 + internal/model/model.go | 2 + 11 files changed, 704 insertions(+), 6 deletions(-) create mode 100644 internal/api/handlers/v0/auth/oidc.go create mode 100644 internal/api/handlers/v0/auth/oidc_test.go diff --git a/.env.example b/.env.example index 9182a97..675693a 100644 --- a/.env.example +++ b/.env.example @@ -29,4 +29,16 @@ MCP_REGISTRY_JWT_PRIVATE_KEY=bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb # Anonymous authentication for development/testing only # When enabled, allows anyone to get tokens for publishing to io.modelcontextprotocol.anonymous/* namespace # This should be disabled in prod -MCP_REGISTRY_ENABLE_ANONYMOUS_AUTH=false \ No newline at end of file +MCP_REGISTRY_ENABLE_ANONYMOUS_AUTH=false + +# Google Cloud Identity OIDC configuration for admin access +# Enable OIDC authentication for @modelcontextprotocol.io admin accounts +MCP_REGISTRY_OIDC_ENABLED=false +# Example for Google Cloud Identity issuer +MCP_REGISTRY_OIDC_ISSUER=https://accounts.google.com +MCP_REGISTRY_OIDC_CLIENT_ID=1234.apps.googleusercontent.com +# Require @modelcontextprotocol.io Google Workspace domain +MCP_REGISTRY_OIDC_EXTRA_CLAIMS=[{"hd":"modelcontextprotocol.io"}] +# Grant admin permissions to OIDC-authenticated users +MCP_REGISTRY_OIDC_EDIT_PERMISSIONS=* +MCP_REGISTRY_OIDC_PUBLISH_PERMISSIONS=* diff --git a/deploy/pkg/k8s/registry.go b/deploy/pkg/k8s/registry.go index 8de1c84..0a71790 100644 --- a/deploy/pkg/k8s/registry.go +++ b/deploy/pkg/k8s/registry.go @@ -128,6 +128,31 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env }, }, }, + // Google Cloud Identity OIDC for admin access + &corev1.EnvVarArgs{ + Name: pulumi.String("MCP_REGISTRY_OIDC_ENABLED"), + Value: pulumi.String("true"), + }, + &corev1.EnvVarArgs{ + Name: pulumi.String("MCP_REGISTRY_OIDC_ISSUER"), + Value: pulumi.String("https://accounts.google.com"), + }, + &corev1.EnvVarArgs{ + Name: pulumi.String("MCP_REGISTRY_OIDC_CLIENT_ID"), + Value: pulumi.String("32555940559.apps.googleusercontent.com"), + }, + &corev1.EnvVarArgs{ + Name: pulumi.String("MCP_REGISTRY_OIDC_EXTRA_CLAIMS"), + Value: pulumi.String(`[{"hd":"modelcontextprotocol.io"}]`), + }, + &corev1.EnvVarArgs{ + Name: pulumi.String("MCP_REGISTRY_OIDC_EDIT_PERMISSIONS"), + Value: pulumi.String("*"), + }, + &corev1.EnvVarArgs{ + Name: pulumi.String("MCP_REGISTRY_OIDC_PUBLISH_PERMISSIONS"), + Value: pulumi.String("*"), + }, }, LivenessProbe: &corev1.ProbeArgs{ HttpGet: &corev1.HTTPGetActionArgs{ diff --git a/docker-compose.yml b/docker-compose.yml index 8edfb12..d8e63c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,12 @@ services: - MCP_REGISTRY_JWT_PRIVATE_KEY=${MCP_REGISTRY_JWT_PRIVATE_KEY:-8103179d8ef955f6d3de6d6217224a909ec4060529dfeb1d4ca5a994537658cd} - MCP_REGISTRY_ENABLE_ANONYMOUS_AUTH=${MCP_REGISTRY_ENABLE_ANONYMOUS_AUTH:-true} - MCP_REGISTRY_SEED_FROM=${MCP_REGISTRY_SEED_FROM:-data/seed.json} + - MCP_REGISTRY_OIDC_ENABLED=${MCP_REGISTRY_OIDC_ENABLED:-true} + - MCP_REGISTRY_OIDC_ISSUER=${MCP_REGISTRY_OIDC_ISSUER:-https://accounts.google.com} + - MCP_REGISTRY_OIDC_CLIENT_ID=${MCP_REGISTRY_OIDC_CLIENT_ID:-32555940559.apps.googleusercontent.com} + - MCP_REGISTRY_OIDC_EXTRA_CLAIMS=${MCP_REGISTRY_OIDC_EXTRA_CLAIMS:-[{"hd":"modelcontextprotocol.io"}]} + - MCP_REGISTRY_OIDC_EDIT_PERMISSIONS=${MCP_REGISTRY_OIDC_EDIT_PERMISSIONS:-*} + - MCP_REGISTRY_OIDC_PUBLISH_PERMISSIONS=${MCP_REGISTRY_OIDC_PUBLISH_PERMISSIONS:-*} ports: - 8080:8080 restart: "unless-stopped" diff --git a/docs/architecture.md b/docs/architecture.md index 4f12a2c..3bd89ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,6 +44,7 @@ graph TB MKT --> MC AGG --> MC API -.-> |Auth| GH + API -.-> |Admin Auth| GCP[Google Cloud Identity] API -.-> |Verify| DNS API -.-> |Reference| NPM API -.-> |Reference| PYPI @@ -206,4 +207,42 @@ sequenceDiagram API->>DB: Store verification API-->>CLI: Domain verified CLI-->>User: Success! +``` + +### 4. Admin OIDC Authentication Flow + +For registry administration, users with @modelcontextprotocol.io Google Cloud Identity accounts can authenticate using OIDC: + +```mermaid +sequenceDiagram + participant Admin as Admin User + participant CLI as Admin CLI + participant GCP as Google Cloud Identity + participant API as Registry API + + Admin->>CLI: Request admin token + CLI->>GCP: gcloud auth print-identity-token --audiences=mcp-registry + GCP-->>CLI: ID Token (with hd: "modelcontextprotocol.io") + CLI->>API: POST /v0/auth/oidc {"oidc_token": "eyJ..."} + API->>GCP: Verify token signature (JWKS) + API->>API: Validate claims (issuer, audience, hd) + API->>API: Grant admin permissions (edit: *, publish: *) + API-->>CLI: Registry JWT Token + CLI->>API: POST /admin/* (with Registry JWT) + API->>API: Validate JWT + permissions + API-->>CLI: Admin operation success +``` + +**Usage:** +```bash +# Get Google Cloud Identity token +ID_TOKEN=$(gcloud auth print-identity-token) + +# Exchange for Registry JWT token +REGISTRY_TOKEN=$(curl -X POST /v0/auth/oidc \ + -H "Content-Type: application/json" \ + -d '{"oidc_token": "'$ID_TOKEN'"}' | jq -r .registry_token) + +# Use for admin operations +curl -H "Authorization: Bearer $REGISTRY_TOKEN" /v0/... ``` \ No newline at end of file diff --git a/go.mod b/go.mod index 0f9e055..f70ae43 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/caarlos0/env/v11 v11.3.1 + github.com/coreos/go-oidc/v3 v3.15.0 github.com/danielgtaylor/huma/v2 v2.34.1 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 @@ -17,12 +18,15 @@ require ( go.opentelemetry.io/otel/metric v1.37.0 go.opentelemetry.io/otel/sdk v1.37.0 go.opentelemetry.io/otel/sdk/metric v1.37.0 + golang.org/x/mod v0.27.0 + golang.org/x/oauth2 v0.30.0 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect @@ -38,7 +42,6 @@ require ( go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/crypto v0.41.0 // indirect - golang.org/x/mod v0.27.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/go.sum b/go.sum index c8713e9..fa6f80e 100644 --- a/go.sum +++ b/go.sum @@ -4,11 +4,15 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= +github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ= github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -85,6 +89,8 @@ golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= diff --git a/internal/api/handlers/v0/auth/main.go b/internal/api/handlers/v0/auth/main.go index 71e778a..c450c77 100644 --- a/internal/api/handlers/v0/auth/main.go +++ b/internal/api/handlers/v0/auth/main.go @@ -13,6 +13,9 @@ func RegisterAuthEndpoints(api huma.API, cfg *config.Config) { // Register GitHub OIDC authentication endpoint RegisterGitHubOIDCEndpoint(api, cfg) + // Register configurable OIDC authentication endpoints + RegisterOIDCEndpoints(api, cfg) + // Register DNS-based authentication endpoint RegisterDNSEndpoint(api, cfg) @@ -21,8 +24,4 @@ func RegisterAuthEndpoints(api huma.API, cfg *config.Config) { // Register anonymous authentication endpoint RegisterNoneEndpoint(api, cfg) - - // Future auth providers can be registered here: - // RegisterGitLabEndpoint(api, cfg) - // RegisterOIDCEndpoint(api, cfg) } diff --git a/internal/api/handlers/v0/auth/oidc.go b/internal/api/handlers/v0/auth/oidc.go new file mode 100644 index 0000000..6d27df3 --- /dev/null +++ b/internal/api/handlers/v0/auth/oidc.go @@ -0,0 +1,443 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/danielgtaylor/huma/v2" + v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" + "github.com/modelcontextprotocol/registry/internal/auth" + "github.com/modelcontextprotocol/registry/internal/config" + "github.com/modelcontextprotocol/registry/internal/model" + "golang.org/x/oauth2" +) + +// OIDCTokenExchangeInput represents the input for OIDC token exchange +type OIDCTokenExchangeInput struct { + Body struct { + OIDCToken string `json:"oidc_token" doc:"OIDC ID token from any provider" required:"true"` + } +} + +// OIDCStartInput represents the input for OIDC authorization start +type OIDCStartInput struct { + RedirectURI string `query:"redirect_uri" doc:"Optional redirect URI after authentication"` +} + +// OIDCCallbackInput represents the input for OIDC callback +type OIDCCallbackInput struct { + Code string `query:"code" doc:"Authorization code from OIDC provider" required:"true"` + State string `query:"state" doc:"State parameter for CSRF protection" required:"true"` +} + +// OIDCClaims represents the claims we extract from any OIDC token +type OIDCClaims struct { + Subject string `json:"sub"` + Issuer string `json:"iss"` + Audience []string `json:"aud"` + ExtraClaims map[string]any `json:"-"` +} + +// GenericOIDCValidator defines the interface for validating OIDC tokens from any provider +type GenericOIDCValidator interface { + ValidateToken(ctx context.Context, token string) (*OIDCClaims, error) + GetAuthorizationURL(state, nonce string, redirectURI string) string + ExchangeCodeForToken(ctx context.Context, code string, redirectURI string) (string, error) +} + +// StandardOIDCValidator validates OIDC tokens using go-oidc library +type StandardOIDCValidator struct { + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + oauth2Config *oauth2.Config +} + +// NewStandardOIDCValidator creates a new standard OIDC validator using go-oidc +func NewStandardOIDCValidator(issuer, clientID, clientSecret string) (*StandardOIDCValidator, error) { + ctx := context.Background() + + // Initialize the OIDC provider + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err) + } + + // Create ID token verifier + verifierConfig := &oidc.Config{ + ClientID: clientID, + } + verifier := provider.Verifier(verifierConfig) + + // Create OAuth2 config for authorization flow + oauth2Config := &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "email", "profile"}, + } + + return &StandardOIDCValidator{ + provider: provider, + verifier: verifier, + oauth2Config: oauth2Config, + }, nil +} + +// ValidateToken validates an OIDC ID token using go-oidc library +func (v *StandardOIDCValidator) ValidateToken(ctx context.Context, tokenString string) (*OIDCClaims, error) { + // Verify and parse the ID token using go-oidc + idToken, err := v.verifier.Verify(ctx, tokenString) + if err != nil { + return nil, fmt.Errorf("failed to verify ID token: %w", err) + } + + // Extract all claims + var allClaims map[string]any + if err := idToken.Claims(&allClaims); err != nil { + return nil, fmt.Errorf("failed to extract claims: %w", err) + } + + // Build our claims structure + oidcClaims := &OIDCClaims{ + Subject: idToken.Subject, + Issuer: idToken.Issuer, + ExtraClaims: make(map[string]any), + } + + // Extract audience + if aud, ok := allClaims["aud"]; ok { + switch v := aud.(type) { + case string: + oidcClaims.Audience = []string{v} + case []any: + for _, a := range v { + if s, ok := a.(string); ok { + oidcClaims.Audience = append(oidcClaims.Audience, s) + } + } + } + } + + // Store all non-standard claims in ExtraClaims + standardClaims := map[string]bool{ + "iss": true, "sub": true, "aud": true, "exp": true, "nbf": true, "iat": true, "jti": true, + } + + for key, value := range allClaims { + if !standardClaims[key] { + oidcClaims.ExtraClaims[key] = value + } + } + + return oidcClaims, nil +} + +// GetAuthorizationURL constructs the OIDC authorization URL using oauth2 +func (v *StandardOIDCValidator) GetAuthorizationURL(state, nonce string, redirectURI string) string { + // Update redirect URI for this request + config := *v.oauth2Config + config.RedirectURL = redirectURI + + // Add nonce as additional parameter + authURL := config.AuthCodeURL(state, oauth2.SetAuthURLParam("nonce", nonce)) + return authURL +} + +// ExchangeCodeForToken exchanges authorization code for ID token using oauth2 +func (v *StandardOIDCValidator) ExchangeCodeForToken(ctx context.Context, code string, redirectURI string) (string, error) { + // Update redirect URI for this exchange + config := *v.oauth2Config + config.RedirectURL = redirectURI + + // Exchange authorization code for token + token, err := config.Exchange(ctx, code) + if err != nil { + return "", fmt.Errorf("failed to exchange code for token: %w", err) + } + + // Extract ID token from OAuth2 token + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return "", fmt.Errorf("no ID token found in OAuth2 response") + } + + return rawIDToken, nil +} + +// OIDCHandler handles configurable OIDC authentication +type OIDCHandler struct { + config *config.Config + jwtManager *auth.JWTManager + validator GenericOIDCValidator + sessions map[string]OIDCSession // In-memory state storage for now +} + +// OIDCSession stores OIDC flow state +type OIDCSession struct { + State string + Nonce string + RedirectURI string + CreatedAt time.Time +} + +// NewOIDCHandler creates a new OIDC handler +func NewOIDCHandler(cfg *config.Config) *OIDCHandler { + if !cfg.OIDCEnabled { + panic("OIDC is not enabled - should not create OIDC handler") + } + if cfg.OIDCIssuer == "" { + panic("OIDC issuer is required when OIDC is enabled") + } + + validator, err := NewStandardOIDCValidator(cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret) + if err != nil { + panic(fmt.Sprintf("Failed to initialize OIDC validator: %v", err)) + } + + return &OIDCHandler{ + config: cfg, + jwtManager: auth.NewJWTManager(cfg), + validator: validator, + sessions: make(map[string]OIDCSession), + } +} + +// SetValidator sets a custom OIDC validator (used for testing) +func (h *OIDCHandler) SetValidator(validator GenericOIDCValidator) { + h.validator = validator +} + +// RegisterOIDCEndpoints registers all OIDC authentication endpoints +func RegisterOIDCEndpoints(api huma.API, cfg *config.Config) { + if !cfg.OIDCEnabled { + return // Skip registration if OIDC is not enabled + } + + handler := NewOIDCHandler(cfg) + + // Direct token exchange endpoint + huma.Register(api, huma.Operation{ + OperationID: "exchange-oidc-token", + Method: http.MethodPost, + Path: "/v0/auth/oidc", + Summary: "Exchange OIDC ID token for Registry JWT", + Description: "Exchange an OIDC ID token from any configured provider for a short-lived Registry JWT token", + Tags: []string{"auth"}, + }, func(ctx context.Context, input *OIDCTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) { + response, err := handler.ExchangeToken(ctx, input.Body.OIDCToken) + if err != nil { + return nil, huma.Error401Unauthorized("Token exchange failed", err) + } + + return &v0.Response[auth.TokenResponse]{ + Body: *response, + }, nil + }) + + // Authorization start endpoint + huma.Register(api, huma.Operation{ + OperationID: "oidc-auth-start", + Method: http.MethodGet, + Path: "/v0/auth/oidc/start", + Summary: "Start OIDC authorization flow", + Description: "Redirects user to OIDC provider for authentication", + Tags: []string{"auth"}, + }, func(ctx context.Context, input *OIDCStartInput) (*v0.Response[map[string]string], error) { + authURL, err := handler.StartAuth(ctx, input.RedirectURI) + if err != nil { + return nil, huma.Error400BadRequest("Failed to start OIDC flow", err) + } + + return &v0.Response[map[string]string]{ + Body: map[string]string{ + "authorization_url": authURL, + "message": "Visit the authorization URL to complete authentication", + }, + }, nil + }) + + // Authorization callback endpoint + huma.Register(api, huma.Operation{ + OperationID: "oidc-auth-callback", + Method: http.MethodGet, + Path: "/v0/auth/oidc/callback", + Summary: "Handle OIDC authorization callback", + Description: "Handles the callback from OIDC provider after user authorization", + Tags: []string{"auth"}, + }, func(ctx context.Context, input *OIDCCallbackInput) (*v0.Response[auth.TokenResponse], error) { + response, err := handler.HandleCallback(ctx, input.Code, input.State) + if err != nil { + return nil, huma.Error400BadRequest("Failed to handle OIDC callback", err) + } + + return &v0.Response[auth.TokenResponse]{ + Body: *response, + }, nil + }) +} + +// ExchangeToken exchanges an OIDC ID token for a Registry JWT token +func (h *OIDCHandler) ExchangeToken(ctx context.Context, oidcToken string) (*auth.TokenResponse, error) { + // Validate OIDC token + claims, err := h.validator.ValidateToken(ctx, oidcToken) + if err != nil { + return nil, fmt.Errorf("failed to validate OIDC token: %w", err) + } + + // Validate extra claims if configured + if err := h.validateExtraClaims(claims); err != nil { + return nil, fmt.Errorf("extra claims validation failed: %w", err) + } + + // Build permissions based on claims and configuration + permissions := h.buildPermissions(claims) + + // Create JWT claims + jwtClaims := auth.JWTClaims{ + AuthMethod: model.AuthMethodOIDC, + AuthMethodSubject: claims.Subject, + Permissions: permissions, + } + + // Generate Registry JWT token + tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, jwtClaims) + if err != nil { + return nil, fmt.Errorf("failed to generate JWT token: %w", err) + } + + return tokenResponse, nil +} + +// StartAuth initiates the OIDC authorization flow +func (h *OIDCHandler) StartAuth(_ context.Context, redirectURI string) (string, error) { + // Generate state and nonce for security + state, err := generateRandomString(32) + if err != nil { + return "", fmt.Errorf("failed to generate state: %w", err) + } + + nonce, err := generateRandomString(32) + if err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + // Store session for callback validation + session := OIDCSession{ + State: state, + Nonce: nonce, + RedirectURI: redirectURI, + CreatedAt: time.Now(), + } + h.sessions[state] = session + + // Build callback URI - use configured base URL or default + callbackURI := "/v0/auth/oidc/callback" + + // Get authorization URL + authURL := h.validator.GetAuthorizationURL(state, nonce, callbackURI) + + return authURL, nil +} + +// HandleCallback handles the OIDC callback +func (h *OIDCHandler) HandleCallback(ctx context.Context, code, state string) (*auth.TokenResponse, error) { + // Validate state and retrieve session + session, exists := h.sessions[state] + if !exists { + return nil, fmt.Errorf("invalid state parameter") + } + + // Clean up session + delete(h.sessions, state) + + // Check session expiry (5 minutes) + if time.Since(session.CreatedAt) > 5*time.Minute { + return nil, fmt.Errorf("authentication session expired") + } + + // Exchange authorization code for tokens + idToken, err := h.validator.ExchangeCodeForToken(ctx, code, "/v0/auth/oidc/callback") + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + + // Now validate the ID token and generate registry token + return h.ExchangeToken(ctx, idToken) +} + +// validateExtraClaims validates additional claims based on configuration +func (h *OIDCHandler) validateExtraClaims(claims *OIDCClaims) error { + if h.config.OIDCExtraClaims == "" { + return nil // No extra validation required + } + + // Parse extra claims configuration + var extraClaimsRules []map[string]any + if err := json.Unmarshal([]byte(h.config.OIDCExtraClaims), &extraClaimsRules); err != nil { + return fmt.Errorf("invalid extra claims configuration: %w", err) + } + + // Validate each rule + for _, rule := range extraClaimsRules { + for key, expectedValue := range rule { + actualValue, exists := claims.ExtraClaims[key] + if !exists { + return fmt.Errorf("claim validation failed: required claim %s not found", key) + } + + if actualValue != expectedValue { + return fmt.Errorf("claim validation failed: %s expected %v, got %v", key, expectedValue, actualValue) + } + } + } + + return nil +} + +// buildPermissions builds permissions based on OIDC claims and configuration +func (h *OIDCHandler) buildPermissions(_ *OIDCClaims) []auth.Permission { + var permissions []auth.Permission + + // Parse permission patterns from configuration + if h.config.OIDCPublishPerms != "" { + for _, pattern := range strings.Split(h.config.OIDCPublishPerms, ",") { + pattern = strings.TrimSpace(pattern) + if pattern != "" { + permissions = append(permissions, auth.Permission{ + Action: auth.PermissionActionPublish, + ResourcePattern: pattern, + }) + } + } + } + + if h.config.OIDCEditPerms != "" { + for _, pattern := range strings.Split(h.config.OIDCEditPerms, ",") { + pattern = strings.TrimSpace(pattern) + if pattern != "" { + permissions = append(permissions, auth.Permission{ + Action: auth.PermissionActionEdit, + ResourcePattern: pattern, + }) + } + } + } + + return permissions +} + +// generateRandomString generates a cryptographically secure random string +func generateRandomString(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes)[:length], nil +} diff --git a/internal/api/handlers/v0/auth/oidc_test.go b/internal/api/handlers/v0/auth/oidc_test.go new file mode 100644 index 0000000..a903ba9 --- /dev/null +++ b/internal/api/handlers/v0/auth/oidc_test.go @@ -0,0 +1,154 @@ +package auth_test + +import ( + "context" + "fmt" + "testing" + + "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" + "github.com/modelcontextprotocol/registry/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MockGenericOIDCValidator for testing +type MockGenericOIDCValidator struct { + validateFunc func(ctx context.Context, token string) (*auth.OIDCClaims, error) + authURLFunc func(state, nonce, redirectURI string) string + exchangeCodeFunc func(ctx context.Context, code, redirectURI string) (string, error) +} + +func (m *MockGenericOIDCValidator) ValidateToken(ctx context.Context, token string) (*auth.OIDCClaims, error) { + if m.validateFunc != nil { + return m.validateFunc(ctx, token) + } + return nil, fmt.Errorf("not implemented") +} + +func (m *MockGenericOIDCValidator) GetAuthorizationURL(state, nonce, redirectURI string) string { + if m.authURLFunc != nil { + return m.authURLFunc(state, nonce, redirectURI) + } + return "" +} + +func (m *MockGenericOIDCValidator) ExchangeCodeForToken(ctx context.Context, code, redirectURI string) (string, error) { + if m.exchangeCodeFunc != nil { + return m.exchangeCodeFunc(ctx, code, redirectURI) + } + return "", fmt.Errorf("not implemented") +} + +func TestOIDCHandler_ExchangeToken(t *testing.T) { + tests := []struct { + name string + config *config.Config + mockValidator *MockGenericOIDCValidator + token string + expectedError bool + }{ + { + name: "successful token exchange with publish permissions", + config: &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://accounts.google.com", + OIDCClientID: "test-client-id", + OIDCExtraClaims: `[{"hd":"modelcontextprotocol.io"}]`, + OIDCPublishPerms: "*", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", // 32 byte hex + }, + mockValidator: &MockGenericOIDCValidator{ + validateFunc: func(_ context.Context, _ string) (*auth.OIDCClaims, error) { + return &auth.OIDCClaims{ + ExtraClaims: map[string]any{ + "email": "admin@modelcontextprotocol.io", + "email_verified": true, + "hd": "modelcontextprotocol.io", + "preferred_username": "admin", + }, + }, nil + }, + }, + token: "valid-oidc-token", + expectedError: false, + }, + { + name: "failed validation with invalid hosted domain", + config: &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://accounts.google.com", + OIDCClientID: "test-client-id", + OIDCExtraClaims: `[{"hd":"modelcontextprotocol.io"}]`, + OIDCPublishPerms: "*", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, + mockValidator: &MockGenericOIDCValidator{ + validateFunc: func(_ context.Context, _ string) (*auth.OIDCClaims, error) { + return &auth.OIDCClaims{ + ExtraClaims: map[string]any{ + "email": "user@example.com", + "email_verified": true, + "hd": "example.com", // Wrong domain + "preferred_username": "user", + }, + }, nil + }, + }, + token: "invalid-domain-token", + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := auth.NewOIDCHandler(tt.config) + if tt.mockValidator != nil { + handler.SetValidator(tt.mockValidator) + } + + ctx := context.Background() + response, err := handler.ExchangeToken(ctx, tt.token) + + if tt.expectedError { + assert.Error(t, err) + assert.Nil(t, response) + } else { + require.NoError(t, err) + require.NotNil(t, response) + assert.NotEmpty(t, response.RegistryToken) + assert.Greater(t, response.ExpiresAt, 0) + } + }) + } +} + +func TestOIDCHandler_StartAuth(t *testing.T) { + config := &config.Config{ + OIDCEnabled: true, + OIDCIssuer: "https://accounts.google.com", + OIDCClientID: "test-client-id", + OIDCClientSecret: "test-secret", + JWTPrivateKey: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + } + + mockValidator := &MockGenericOIDCValidator{ + authURLFunc: func(state, nonce, redirectURI string) string { + return fmt.Sprintf("https://accounts.google.com/oauth/authorize?client_id=%s&state=%s&nonce=%s&redirect_uri=%s", + config.OIDCClientID, state, nonce, redirectURI) + }, + } + + handler := auth.NewOIDCHandler(config) + handler.SetValidator(mockValidator) + + ctx := context.Background() + authURL, err := handler.StartAuth(ctx, "http://localhost:3000/callback") + + require.NoError(t, err) + assert.Contains(t, authURL, "https://accounts.google.com/oauth/authorize") + assert.Contains(t, authURL, "client_id=test-client-id") + assert.Contains(t, authURL, "state=") + assert.Contains(t, authURL, "nonce=") +} + +// Note: validateExtraClaims and buildPermissions are tested through ExchangeToken integration tests diff --git a/internal/config/config.go b/internal/config/config.go index 7ed7fbb..21a3f47 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,15 @@ type Config struct { GithubClientSecret string `env:"GITHUB_CLIENT_SECRET" envDefault:""` JWTPrivateKey string `env:"JWT_PRIVATE_KEY" envDefault:""` EnableAnonymousAuth bool `env:"ENABLE_ANONYMOUS_AUTH" envDefault:"false"` + + // OIDC Configuration + OIDCEnabled bool `env:"OIDC_ENABLED" envDefault:"false"` + OIDCIssuer string `env:"OIDC_ISSUER" envDefault:""` + OIDCClientID string `env:"OIDC_CLIENT_ID" envDefault:""` + OIDCClientSecret string `env:"OIDC_CLIENT_SECRET" envDefault:""` + OIDCExtraClaims string `env:"OIDC_EXTRA_CLAIMS" envDefault:""` + OIDCEditPerms string `env:"OIDC_EDIT_PERMISSIONS" envDefault:""` + OIDCPublishPerms string `env:"OIDC_PUBLISH_PERMISSIONS" envDefault:""` } // NewConfig creates a new configuration with default values diff --git a/internal/model/model.go b/internal/model/model.go index b6c7752..0298ecb 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -17,6 +17,8 @@ const ( AuthMethodGitHubAT AuthMethod = "github-at" // GitHub Actions OIDC authentication AuthMethodGitHubOIDC AuthMethod = "github-oidc" + // Generic OIDC authentication + AuthMethodOIDC AuthMethod = "oidc" // DNS-based public/private key authentication AuthMethodDNS AuthMethod = "dns" // HTTP-based public/private key authentication