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
This commit is contained in:
parent
4dd295a6f1
commit
36fcdff044
11 changed files with 704 additions and 6 deletions
14
.env.example
14
.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
|
||||
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=*
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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/...
|
||||
```
|
||||
5
go.mod
5
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
|
||||
|
|
|
|||
6
go.sum
6
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
443
internal/api/handlers/v0/auth/oidc.go
Normal file
443
internal/api/handlers/v0/auth/oidc.go
Normal file
|
|
@ -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
|
||||
}
|
||||
154
internal/api/handlers/v0/auth/oidc_test.go
Normal file
154
internal/api/handlers/v0/auth/oidc_test.go
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue