diff --git a/README.md b/README.md index 3b5d47bf5..eafedfee4 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,42 @@ determined by `auth_method` in the credential configuration. - OpenTelemetry distributed tracing - Static Linux/amd64 binaries for containerized deployment +## Verifier Dynamic Client Registration Authorization + +The verifier exposes OAuth 2.0 Dynamic Client Registration at `POST /register`. +You can protect this endpoint with an initial access token policy. + +Supported modes: + +- `open` (default): registration is open (rate-limited) +- `static`: requires a fixed bearer token loaded from local file +- `jwt`: requires a signed JWT bearer token validated with configured `issuer`, `audience`, and `jwks_uri` + +Example (`config.yaml`): + +```yaml +verifier: + oidc: + dynamic_registration_auth: + mode: "static" + static_bearer_token_file: "/run/secrets/verifier_dcr_initial_access_token" +``` + +```yaml +verifier: + oidc: + dynamic_registration_auth: + mode: "jwt" + jwt: + jwks_uri: "https://auth.example.com/.well-known/jwks.json" + issuer: "https://auth.example.com" + audience: "vc-verifier-register" + allowed_signing_algs: ["RS256", "ES256"] + clock_skew_seconds: 60 +``` + +Note: `introspection` is reserved for future implementation and is not enabled yet. + ## Docker release version `latest` tracks the latest tag available and is built from branch `main`. diff --git a/config.yaml b/config.yaml index 141d5bf56..90c58ace7 100644 --- a/config.yaml +++ b/config.yaml @@ -191,6 +191,28 @@ verifier: # response_types: # Optional, default: ["code"] # - "code" # client_name: "Example Static Client" # Optional + + # Dynamic Client Registration authorization policy for POST /register + # Modes: + # - open : no additional authorization (default behavior) + # - static : require a fixed bearer token from local file + # - jwt : require signed JWT bearer token validated via JWKS + # - introspection: reserved for future implementation (not yet supported) + # + # Example 1: static token from file + # dynamic_registration_auth: + # mode: "static" + # static_bearer_token_file: "/run/secrets/verifier_dcr_initial_access_token" + # + # Example 2: JWT bearer token validation + # dynamic_registration_auth: + # mode: "jwt" + # jwt: + # jwks_uri: "https://auth.example.com/.well-known/jwks.json" + # issuer: "https://auth.example.com" + # audience: "vc-verifier-register" + # allowed_signing_algs: ["RS256", "ES256"] + # clock_skew_seconds: 60 openid4vp: presentation_timeout: 300 # Template-based presentation requests (optional) diff --git a/internal/verifier/httpserver/service.go b/internal/verifier/httpserver/service.go index 215a338bd..7b94db5de 100644 --- a/internal/verifier/httpserver/service.go +++ b/internal/verifier/httpserver/service.go @@ -39,6 +39,7 @@ type Service struct { tokenLimiter *middleware.RateLimiter authorizeLimiter *middleware.RateLimiter registerLimiter *middleware.RateLimiter + registerAuth gin.HandlerFunc } // New creates a new httpserver service @@ -60,6 +61,7 @@ func New(ctx context.Context, cfg *model.Cfg, apiv1 *apiv1.Client, notify *notif tokenLimiter: middleware.NewRateLimiter(rateLimitConfig.TokenRequestsPerMinute, rateLimitConfig.TokenBurst), authorizeLimiter: middleware.NewRateLimiter(rateLimitConfig.AuthorizeRequestsPerMinute, rateLimitConfig.AuthorizeBurst), registerLimiter: middleware.NewRateLimiter(rateLimitConfig.RegisterRequestsPerMinute, rateLimitConfig.RegisterBurst), + registerAuth: func(c *gin.Context) { c.Next() }, sessionsOptions: sessions.Options{ Path: "/", Domain: "", @@ -85,6 +87,11 @@ func New(ctx context.Context, cfg *model.Cfg, apiv1 *apiv1.Client, notify *notif return nil, err } + s.registerAuth, err = middleware.NewRegistrationAuthMiddleware(s.cfg, s.log.New("registration_auth")) + if err != nil { + return nil, err + } + rgRoot, err := s.httpHelpers.Server.Default(ctx, s.server, s.gin, s.cfg.Verifier.APIServer.Addr) if err != nil { return nil, err @@ -153,7 +160,7 @@ func New(ctx context.Context, cfg *model.Cfg, apiv1 *apiv1.Client, notify *notif }) // Dynamic Client Registration (RFC 7591/7592) with rate limiting - rgRoot.POST("register", s.registerLimiter.Middleware(), func(c *gin.Context) { + rgRoot.POST("register", s.registerLimiter.Middleware(), s.registerAuth, func(c *gin.Context) { response, err := s.endpointRegisterClient(ctx, c) if err != nil { s.handleOAuthError(c, err) diff --git a/internal/verifier/middleware/registration_auth.go b/internal/verifier/middleware/registration_auth.go new file mode 100644 index 000000000..48ff87752 --- /dev/null +++ b/internal/verifier/middleware/registration_auth.go @@ -0,0 +1,212 @@ +package middleware + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + "vc/pkg/logger" + "vc/pkg/model" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" +) + +const ( + errCodeInvalidToken = "invalid_token" + errDescInvalidRegistrationAuthorizationToken = "invalid registration authorization token" + errDescMissingOrInvalidBearerToken = "missing or invalid bearer token" +) + +// RegistrationAuthValidator validates initial access tokens for dynamic client registration. +type RegistrationAuthValidator interface { + Validate(ctx context.Context, token string) error +} + +type registrationAuthError struct { + status int + errorCode string + description string +} + +func (e *registrationAuthError) Error() string { + return e.errorCode + ": " + e.description +} + +func unauthorizedRegistrationError(description string) *registrationAuthError { + return ®istrationAuthError{ + status: http.StatusUnauthorized, + errorCode: errCodeInvalidToken, + description: description, + } +} + +// NewRegistrationAuthMiddleware creates middleware for protecting POST /register. +// +// Modes: +// - open: no auth required +// - static: expects a fixed bearer token loaded from file +// - jwt: expects a signed JWT validated against configured issuer/audience/JWKS +// +// Future option (not implemented): external introspection. +func NewRegistrationAuthMiddleware(cfg *model.Cfg, log *logger.Log) (gin.HandlerFunc, error) { + passThrough := func(c *gin.Context) { c.Next() } + authCfg := getDynamicRegistrationAuthConfig(cfg) + if authCfg == nil { + return passThrough, nil + } + + mode := strings.ToLower(strings.TrimSpace(authCfg.Mode)) + if mode == "" || mode == "open" { + return passThrough, nil + } + + validator, err := buildRegistrationAuthValidator(mode, authCfg) + if err != nil { + return nil, err + } + + if log != nil { + log.Info("Dynamic registration authorization enabled", "mode", mode) + } + + return func(c *gin.Context) { + token, err := extractBearerToken(c.GetHeader("Authorization")) + if err != nil { + writeRegistrationAuthError(c, unauthorizedRegistrationError(errDescMissingOrInvalidBearerToken)) + return + } + + if err := validator.Validate(c.Request.Context(), token); err != nil { + var authErr *registrationAuthError + if errors.As(err, &authErr) { + writeRegistrationAuthError(c, authErr) + return + } + + writeRegistrationAuthError(c, unauthorizedRegistrationError(errDescInvalidRegistrationAuthorizationToken)) + return + } + + c.Next() + }, nil +} + +func getDynamicRegistrationAuthConfig(cfg *model.Cfg) *model.DynamicRegistrationAuthConfig { + if cfg == nil || cfg.Verifier == nil || cfg.Verifier.OIDC == nil { + return nil + } + + return cfg.Verifier.OIDC.DynamicRegistrationAuth +} + +func buildRegistrationAuthValidator(mode string, authCfg *model.DynamicRegistrationAuthConfig) (RegistrationAuthValidator, error) { + switch mode { + case "static": + return newStaticBearerValidator(authCfg.StaticBearerTokenFile) + case "jwt": + return newJWTBearerValidator(authCfg.JWT) + case "introspection": + return nil, fmt.Errorf("verifier OIDC dynamic registration auth mode 'introspection' is not implemented yet") + default: + return nil, fmt.Errorf("unsupported verifier OIDC dynamic registration auth mode: %s", mode) + } +} + +func writeRegistrationAuthError(c *gin.Context, authErr *registrationAuthError) { + c.Header("WWW-Authenticate", fmt.Sprintf("Bearer error=\"%s\"", authErr.errorCode)) + c.JSON(authErr.status, gin.H{ + "error": authErr.errorCode, + "error_description": authErr.description, + }) + c.Abort() +} + +func extractBearerToken(authHeader string) (string, error) { + parts := strings.SplitN(strings.TrimSpace(authHeader), " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "", fmt.Errorf("invalid authorization header") + } + + token := strings.TrimSpace(parts[1]) + if token == "" { + return "", fmt.Errorf("empty bearer token") + } + + return token, nil +} + +type staticBearerValidator struct { + token string +} + +func newStaticBearerValidator(tokenFilePath string) (*staticBearerValidator, error) { + if strings.TrimSpace(tokenFilePath) == "" { + return nil, fmt.Errorf("static mode requires dynamic_registration_auth.static_bearer_token_file") + } + + content, err := os.ReadFile(filepath.Clean(tokenFilePath)) + if err != nil { + return nil, fmt.Errorf("failed to read static bearer token file: %w", err) + } + + token := strings.TrimSpace(string(content)) + if token == "" { + return nil, fmt.Errorf("static bearer token file is empty") + } + + return &staticBearerValidator{token: token}, nil +} + +func (v *staticBearerValidator) Validate(_ context.Context, token string) error { + if subtle.ConstantTimeCompare([]byte(token), []byte(v.token)) != 1 { + return unauthorizedRegistrationError(errDescInvalidRegistrationAuthorizationToken) + } + + return nil +} + +type jwtBearerValidator struct { + verifier *oidc.IDTokenVerifier +} + +func newJWTBearerValidator(cfg *model.DynamicRegistrationJWTAuthConfig) (*jwtBearerValidator, error) { + if cfg == nil { + return nil, fmt.Errorf("jwt mode requires dynamic_registration_auth.jwt configuration") + } + + if strings.TrimSpace(cfg.JWKSURI) == "" || strings.TrimSpace(cfg.Issuer) == "" || strings.TrimSpace(cfg.Audience) == "" { + return nil, fmt.Errorf("jwt mode requires jwks_uri, issuer, and audience") + } + + algs := cfg.AllowedSigningAlgs + if len(algs) == 0 { + algs = []string{"RS256", "ES256"} + } + + // NOTE: Introspection mode is intentionally deferred. + // JWT mode validates token signature and claims locally against JWKS. + keySet := oidc.NewRemoteKeySet(context.Background(), cfg.JWKSURI) + verifier := oidc.NewVerifier(cfg.Issuer, keySet, &oidc.Config{ + ClientID: cfg.Audience, + SupportedSigningAlgs: algs, + }) + + return &jwtBearerValidator{verifier: verifier}, nil +} + +func (v *jwtBearerValidator) Validate(ctx context.Context, token string) error { + verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if _, err := v.verifier.Verify(verifyCtx, token); err != nil { + return unauthorizedRegistrationError(errDescInvalidRegistrationAuthorizationToken) + } + + return nil +} diff --git a/internal/verifier/middleware/registration_auth_test.go b/internal/verifier/middleware/registration_auth_test.go new file mode 100644 index 000000000..746a63ee2 --- /dev/null +++ b/internal/verifier/middleware/registration_auth_test.go @@ -0,0 +1,269 @@ +package middleware + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + "vc/pkg/logger" + "vc/pkg/model" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + registerPath = "/register" + issuerExampleURL = "https://issuer.example.com" + registerAudience = "vc-verifier-register" + jwtKid = "kid-1" +) + +func TestRegistrationAuthMiddlewareOpenMode(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &model.Cfg{ + Verifier: &model.Verifier{ + OIDC: &model.OIDCConfig{ + DynamicRegistrationAuth: &model.DynamicRegistrationAuthConfig{Mode: "open"}, + }, + }, + } + + mw, err := NewRegistrationAuthMiddleware(cfg, logger.NewSimple("test")) + require.NoError(t, err) + + r := gin.New() + r.POST(registerPath, mw, func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodPost, registerPath, nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNoContent, resp.Code) +} + +func TestRegistrationAuthMiddlewareStaticMode(t *testing.T) { + gin.SetMode(gin.TestMode) + + tokenFile := filepath.Join(t.TempDir(), "registration.token") + require.NoError(t, os.WriteFile(tokenFile, []byte("test-static-token\n"), 0o600)) + + cfg := &model.Cfg{ + Verifier: &model.Verifier{ + OIDC: &model.OIDCConfig{ + DynamicRegistrationAuth: &model.DynamicRegistrationAuthConfig{ + Mode: "static", + StaticBearerTokenFile: tokenFile, + }, + }, + }, + } + + mw, err := NewRegistrationAuthMiddleware(cfg, logger.NewSimple("test")) + require.NoError(t, err) + + r := gin.New() + r.POST(registerPath, mw, func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + t.Run("valid token", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, registerPath, nil) + req.Header.Set("Authorization", "Bearer test-static-token") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusNoContent, resp.Code) + }) + + t.Run("missing token", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, registerPath, nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusUnauthorized, resp.Code) + }) + + t.Run("invalid token", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, registerPath, nil) + req.Header.Set("Authorization", "Bearer wrong-token") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusUnauthorized, resp.Code) + }) +} + +func TestRegistrationAuthMiddlewareStaticModeRequiresFile(t *testing.T) { + cfg := &model.Cfg{ + Verifier: &model.Verifier{ + OIDC: &model.OIDCConfig{ + DynamicRegistrationAuth: &model.DynamicRegistrationAuthConfig{Mode: "static"}, + }, + }, + } + + _, err := NewRegistrationAuthMiddleware(cfg, logger.NewSimple("test")) + require.Error(t, err) + assert.Contains(t, err.Error(), "static_bearer_token_file") +} + +func TestRegistrationAuthMiddlewareJWTMode(t *testing.T) { + gin.SetMode(gin.TestMode) + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + key, err := jwk.Import(privateKey.Public()) + require.NoError(t, err) + require.NoError(t, key.Set(jwk.KeyIDKey, jwtKid)) + + set := jwk.NewSet() + require.NoError(t, set.AddKey(key)) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(set)) + })) + defer jwksServer.Close() + + cfg := &model.Cfg{ + Verifier: &model.Verifier{ + OIDC: &model.OIDCConfig{ + DynamicRegistrationAuth: &model.DynamicRegistrationAuthConfig{ + Mode: "jwt", + JWT: &model.DynamicRegistrationJWTAuthConfig{ + JWKSURI: jwksServer.URL, + Issuer: issuerExampleURL, + Audience: registerAudience, + AllowedSigningAlgs: []string{"RS256"}, + }, + }, + }, + }, + } + + mw, err := NewRegistrationAuthMiddleware(cfg, logger.NewSimple("test")) + require.NoError(t, err) + + r := gin.New() + r.POST(registerPath, mw, func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + validClaims := jwt.MapClaims{ + "iss": issuerExampleURL, + "aud": registerAudience, + "sub": "client-reg-admin", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + } + validToken := jwt.NewWithClaims(jwt.SigningMethodRS256, validClaims) + validToken.Header["kid"] = jwtKid + validTokenString, err := validToken.SignedString(privateKey) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, registerPath, nil) + req.Header.Set("Authorization", "Bearer "+validTokenString) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusNoContent, resp.Code) + + invalidClaims := jwt.MapClaims{ + "iss": issuerExampleURL, + "aud": "wrong-audience", + "sub": "client-reg-admin", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + } + invalidToken := jwt.NewWithClaims(jwt.SigningMethodRS256, invalidClaims) + invalidToken.Header["kid"] = jwtKid + invalidTokenString, err := invalidToken.SignedString(privateKey) + require.NoError(t, err) + + req2 := httptest.NewRequest(http.MethodPost, registerPath, nil) + req2.Header.Set("Authorization", "Bearer "+invalidTokenString) + resp2 := httptest.NewRecorder() + r.ServeHTTP(resp2, req2) + assert.Equal(t, http.StatusUnauthorized, resp2.Code) +} + +func TestRegistrationAuthMiddlewareIntrospectionModeNotImplemented(t *testing.T) { + cfg := &model.Cfg{ + Verifier: &model.Verifier{ + OIDC: &model.OIDCConfig{ + DynamicRegistrationAuth: &model.DynamicRegistrationAuthConfig{Mode: "introspection"}, + }, + }, + } + + _, err := NewRegistrationAuthMiddleware(cfg, logger.NewSimple("test")) + require.Error(t, err) + assert.Contains(t, err.Error(), "not implemented") +} + +func TestStaticBearerValidatorConstantTimeComparison(t *testing.T) { + validator := &staticBearerValidator{token: "expected-token"} + + err := validator.Validate(t.Context(), "expected-token") + require.NoError(t, err) + + err = validator.Validate(t.Context(), "wrong-token") + require.Error(t, err) + var authErr *registrationAuthError + require.ErrorAs(t, err, &authErr) + assert.Equal(t, "invalid_token", authErr.errorCode) +} + +func TestJWTBearerValidatorRequiresConfig(t *testing.T) { + _, err := newJWTBearerValidator(nil) + require.Error(t, err) + + _, err = newJWTBearerValidator(&model.DynamicRegistrationJWTAuthConfig{}) + require.Error(t, err) + + _, err = newJWTBearerValidator(&model.DynamicRegistrationJWTAuthConfig{ + JWKSURI: issuerExampleURL + "/jwks", + Issuer: issuerExampleURL, + Audience: registerAudience, + }) + assert.NoError(t, err) +} + +func TestExtractBearerToken(t *testing.T) { + token, err := extractBearerToken("Bearer abc123") + require.NoError(t, err) + assert.Equal(t, "abc123", token) + + _, err = extractBearerToken("") + require.Error(t, err) + + _, err = extractBearerToken("Basic abc123") + require.Error(t, err) +} + +func TestExtractBearerTokenCaseInsensitiveScheme(t *testing.T) { + token, err := extractBearerToken("bearer token-value") + require.NoError(t, err) + assert.Equal(t, "token-value", token) + + token, err = extractBearerToken("BEARER token-value") + require.NoError(t, err) + assert.Equal(t, "token-value", token) +} + +func TestRSAExponentEncodingSanity(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + assert.True(t, privateKey.PublicKey.E > 0) + assert.True(t, privateKey.PublicKey.N.Cmp(big.NewInt(0)) > 0) +} diff --git a/pkg/model/config.go b/pkg/model/config.go index ea27fc8ee..ed8b5da11 100644 --- a/pkg/model/config.go +++ b/pkg/model/config.go @@ -561,6 +561,49 @@ type OIDCConfig struct { // StaticClients is a list of pre-configured OIDC clients // These clients are checked in addition to dynamically registered clients StaticClients []StaticOIDCClient `yaml:"static_clients,omitempty"` + + // DynamicRegistrationAuth configures authorization for POST /register (RFC 7591). + // Modes: + // - open: no authorization required (default) + // - static: require a bearer token loaded from a local file + // - jwt: require a signed JWT validated against configured JWKS/issuer/audience + // + // Future option (not implemented yet): introspection via external authorization server. + DynamicRegistrationAuth *DynamicRegistrationAuthConfig `yaml:"dynamic_registration_auth,omitempty" validate:"omitempty"` +} + +// DynamicRegistrationAuthConfig configures how the verifier authorizes dynamic client registration requests. +type DynamicRegistrationAuthConfig struct { + // Mode controls registration authorization behavior. + // Supported values: open, static, jwt. + // + // Future option (not implemented yet): introspection. + Mode string `yaml:"mode,omitempty" default:"open" validate:"omitempty,oneof=open static jwt introspection"` + + // StaticBearerTokenFile points to a file containing the expected bearer token (single line). + // Required when Mode=static. + StaticBearerTokenFile string `yaml:"static_bearer_token_file,omitempty" validate:"required_if=Mode static"` + + // JWT config for Mode=jwt. + JWT *DynamicRegistrationJWTAuthConfig `yaml:"jwt,omitempty" validate:"required_if=Mode jwt"` +} + +// DynamicRegistrationJWTAuthConfig configures JWT verification for registration authorization. +type DynamicRegistrationJWTAuthConfig struct { + // JWKSURI is the URL to fetch signing keys from. + JWKSURI string `yaml:"jwks_uri" validate:"required,httpurl"` + + // Issuer is the required issuer claim (iss). + Issuer string `yaml:"issuer" validate:"required,httpurl"` + + // Audience is the required audience claim (aud). + Audience string `yaml:"audience" validate:"required"` + + // AllowedSigningAlgs restricts accepted JWT signing algorithms. + AllowedSigningAlgs []string `yaml:"allowed_signing_algs,omitempty" default:"[\"RS256\",\"ES256\"]"` + + // ClockSkewSeconds configures tolerated clock skew for exp/nbf/iat validation. + ClockSkewSeconds int `yaml:"clock_skew_seconds,omitempty" default:"60"` } // OpenID4VPConfig holds OpenID4VP-specific configuration