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:
adam jones 2025-08-27 18:59:49 +01:00 • committed by GitHub
commit 36fcdff044
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 704 additions and 6 deletions

View file

@ -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)
}

View 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
}

View 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