From c4d48710f338b85790b0c8c92283a9b219c7eda9 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Fri, 6 Mar 2026 10:58:38 +0100 Subject: [PATCH 1/2] feat(verifier): protect dynamic client registration with static/JWT auth - Add verifier OIDC dynamic_registration_auth config - Support modes: open, static(file bearer token), jwt(JWKS validation) - Wire auth middleware to POST /register endpoint - Keep introspection as reserved/not implemented - Add middleware tests for auth modes and bearer handling - Add config examples in config.yaml and README - Regenerate docs/CONFIGURATION.md --- README.md | 36 +++ config.yaml | 22 ++ internal/verifier/httpserver/service.go | 9 +- .../verifier/middleware/registration_auth.go | 201 ++++++++++++++ .../middleware/registration_auth_test.go | 262 ++++++++++++++++++ pkg/model/config.go | 43 +++ 6 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 internal/verifier/middleware/registration_auth.go create mode 100644 internal/verifier/middleware/registration_auth_test.go 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..f9db39562 --- /dev/null +++ b/internal/verifier/middleware/registration_auth.go @@ -0,0 +1,201 @@ +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" +) + +// 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: "invalid_token", + 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() } + + if cfg == nil || cfg.Verifier == nil || cfg.Verifier.OIDC == nil || cfg.Verifier.OIDC.DynamicRegistrationAuth == nil { + return passThrough, nil + } + + authCfg := cfg.Verifier.OIDC.DynamicRegistrationAuth + mode := strings.ToLower(strings.TrimSpace(authCfg.Mode)) + if mode == "" || mode == "open" { + return passThrough, nil + } + + var validator RegistrationAuthValidator + switch mode { + case "static": + v, err := newStaticBearerValidator(authCfg.StaticBearerTokenFile) + if err != nil { + return nil, err + } + validator = v + case "jwt": + v, err := newJWTBearerValidator(authCfg.JWT) + if err != nil { + return nil, err + } + validator = v + 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) + } + + 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("missing or invalid bearer token")) + 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("invalid registration authorization token")) + return + } + + c.Next() + }, nil +} + +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("invalid registration authorization token") + } + + 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("invalid registration authorization token") + } + + 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..980a2fb77 --- /dev/null +++ b/internal/verifier/middleware/registration_auth_test.go @@ -0,0 +1,262 @@ +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" +) + +func TestRegistrationAuthMiddleware_OpenMode(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("/register", mw, func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodPost, "/register", nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNoContent, resp.Code) +} + +func TestRegistrationAuthMiddleware_StaticMode(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("/register", mw, func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + t.Run("valid token", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/register", 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, "/register", 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, "/register", nil) + req.Header.Set("Authorization", "Bearer wrong-token") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusUnauthorized, resp.Code) + }) +} + +func TestRegistrationAuthMiddleware_StaticModeRequiresFile(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 TestRegistrationAuthMiddleware_JWTMode(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, "kid-1")) + + 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: "https://issuer.example.com", + Audience: "vc-verifier-register", + AllowedSigningAlgs: []string{"RS256"}, + }, + }, + }, + }, + } + + mw, err := NewRegistrationAuthMiddleware(cfg, logger.NewSimple("test")) + require.NoError(t, err) + + r := gin.New() + r.POST("/register", mw, func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + validClaims := jwt.MapClaims{ + "iss": "https://issuer.example.com", + "aud": "vc-verifier-register", + "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"] = "kid-1" + validTokenString, err := validToken.SignedString(privateKey) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/register", 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": "https://issuer.example.com", + "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"] = "kid-1" + invalidTokenString, err := invalidToken.SignedString(privateKey) + require.NoError(t, err) + + req2 := httptest.NewRequest(http.MethodPost, "/register", nil) + req2.Header.Set("Authorization", "Bearer "+invalidTokenString) + resp2 := httptest.NewRecorder() + r.ServeHTTP(resp2, req2) + assert.Equal(t, http.StatusUnauthorized, resp2.Code) +} + +func TestRegistrationAuthMiddleware_IntrospectionModeNotImplemented(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 TestStaticBearerValidator_ConstantTimeComparison(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 TestJWTBearerValidator_RequiresConfig(t *testing.T) { + _, err := newJWTBearerValidator(nil) + require.Error(t, err) + + _, err = newJWTBearerValidator(&model.DynamicRegistrationJWTAuthConfig{}) + require.Error(t, err) + + _, err = newJWTBearerValidator(&model.DynamicRegistrationJWTAuthConfig{ + JWKSURI: "https://issuer.example.com/jwks", + Issuer: "https://issuer.example.com", + Audience: "vc-verifier-register", + }) + 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 TestExtractBearerToken_CaseInsensitiveScheme(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 From 59ee210827ca41c3257556e9aab9cadcdd03fdb1 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Fri, 6 Mar 2026 11:59:53 +0100 Subject: [PATCH 2/2] chore(verifier): address sonarcloud nits in registration auth - Reduce cognitive complexity in NewRegistrationAuthMiddleware - Deduplicate repeated string literals - Rename tests to satisfy Sonar naming rule --- .../verifier/middleware/registration_auth.go | 63 +++++++++++-------- .../middleware/registration_auth_test.go | 63 ++++++++++--------- 2 files changed, 72 insertions(+), 54 deletions(-) diff --git a/internal/verifier/middleware/registration_auth.go b/internal/verifier/middleware/registration_auth.go index f9db39562..48ff87752 100644 --- a/internal/verifier/middleware/registration_auth.go +++ b/internal/verifier/middleware/registration_auth.go @@ -17,6 +17,12 @@ import ( "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 @@ -35,7 +41,7 @@ func (e *registrationAuthError) Error() string { func unauthorizedRegistrationError(description string) *registrationAuthError { return ®istrationAuthError{ status: http.StatusUnauthorized, - errorCode: "invalid_token", + errorCode: errCodeInvalidToken, description: description, } } @@ -50,35 +56,19 @@ func unauthorizedRegistrationError(description string) *registrationAuthError { // Future option (not implemented): external introspection. func NewRegistrationAuthMiddleware(cfg *model.Cfg, log *logger.Log) (gin.HandlerFunc, error) { passThrough := func(c *gin.Context) { c.Next() } - - if cfg == nil || cfg.Verifier == nil || cfg.Verifier.OIDC == nil || cfg.Verifier.OIDC.DynamicRegistrationAuth == nil { + authCfg := getDynamicRegistrationAuthConfig(cfg) + if authCfg == nil { return passThrough, nil } - authCfg := cfg.Verifier.OIDC.DynamicRegistrationAuth mode := strings.ToLower(strings.TrimSpace(authCfg.Mode)) if mode == "" || mode == "open" { return passThrough, nil } - var validator RegistrationAuthValidator - switch mode { - case "static": - v, err := newStaticBearerValidator(authCfg.StaticBearerTokenFile) - if err != nil { - return nil, err - } - validator = v - case "jwt": - v, err := newJWTBearerValidator(authCfg.JWT) - if err != nil { - return nil, err - } - validator = v - 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) + validator, err := buildRegistrationAuthValidator(mode, authCfg) + if err != nil { + return nil, err } if log != nil { @@ -88,7 +78,7 @@ func NewRegistrationAuthMiddleware(cfg *model.Cfg, log *logger.Log) (gin.Handler return func(c *gin.Context) { token, err := extractBearerToken(c.GetHeader("Authorization")) if err != nil { - writeRegistrationAuthError(c, unauthorizedRegistrationError("missing or invalid bearer token")) + writeRegistrationAuthError(c, unauthorizedRegistrationError(errDescMissingOrInvalidBearerToken)) return } @@ -99,7 +89,7 @@ func NewRegistrationAuthMiddleware(cfg *model.Cfg, log *logger.Log) (gin.Handler return } - writeRegistrationAuthError(c, unauthorizedRegistrationError("invalid registration authorization token")) + writeRegistrationAuthError(c, unauthorizedRegistrationError(errDescInvalidRegistrationAuthorizationToken)) return } @@ -107,6 +97,27 @@ func NewRegistrationAuthMiddleware(cfg *model.Cfg, log *logger.Log) (gin.Handler }, 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{ @@ -154,7 +165,7 @@ func newStaticBearerValidator(tokenFilePath string) (*staticBearerValidator, err func (v *staticBearerValidator) Validate(_ context.Context, token string) error { if subtle.ConstantTimeCompare([]byte(token), []byte(v.token)) != 1 { - return unauthorizedRegistrationError("invalid registration authorization token") + return unauthorizedRegistrationError(errDescInvalidRegistrationAuthorizationToken) } return nil @@ -194,7 +205,7 @@ func (v *jwtBearerValidator) Validate(ctx context.Context, token string) error { defer cancel() if _, err := v.verifier.Verify(verifyCtx, token); err != nil { - return unauthorizedRegistrationError("invalid registration authorization token") + return unauthorizedRegistrationError(errDescInvalidRegistrationAuthorizationToken) } return nil diff --git a/internal/verifier/middleware/registration_auth_test.go b/internal/verifier/middleware/registration_auth_test.go index 980a2fb77..746a63ee2 100644 --- a/internal/verifier/middleware/registration_auth_test.go +++ b/internal/verifier/middleware/registration_auth_test.go @@ -21,7 +21,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestRegistrationAuthMiddleware_OpenMode(t *testing.T) { +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{ @@ -36,18 +43,18 @@ func TestRegistrationAuthMiddleware_OpenMode(t *testing.T) { require.NoError(t, err) r := gin.New() - r.POST("/register", mw, func(c *gin.Context) { + r.POST(registerPath, mw, func(c *gin.Context) { c.Status(http.StatusNoContent) }) - req := httptest.NewRequest(http.MethodPost, "/register", nil) + req := httptest.NewRequest(http.MethodPost, registerPath, nil) resp := httptest.NewRecorder() r.ServeHTTP(resp, req) assert.Equal(t, http.StatusNoContent, resp.Code) } -func TestRegistrationAuthMiddleware_StaticMode(t *testing.T) { +func TestRegistrationAuthMiddlewareStaticMode(t *testing.T) { gin.SetMode(gin.TestMode) tokenFile := filepath.Join(t.TempDir(), "registration.token") @@ -68,12 +75,12 @@ func TestRegistrationAuthMiddleware_StaticMode(t *testing.T) { require.NoError(t, err) r := gin.New() - r.POST("/register", mw, func(c *gin.Context) { + 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, "/register", nil) + req := httptest.NewRequest(http.MethodPost, registerPath, nil) req.Header.Set("Authorization", "Bearer test-static-token") resp := httptest.NewRecorder() r.ServeHTTP(resp, req) @@ -81,14 +88,14 @@ func TestRegistrationAuthMiddleware_StaticMode(t *testing.T) { }) t.Run("missing token", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/register", nil) + 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, "/register", nil) + req := httptest.NewRequest(http.MethodPost, registerPath, nil) req.Header.Set("Authorization", "Bearer wrong-token") resp := httptest.NewRecorder() r.ServeHTTP(resp, req) @@ -96,7 +103,7 @@ func TestRegistrationAuthMiddleware_StaticMode(t *testing.T) { }) } -func TestRegistrationAuthMiddleware_StaticModeRequiresFile(t *testing.T) { +func TestRegistrationAuthMiddlewareStaticModeRequiresFile(t *testing.T) { cfg := &model.Cfg{ Verifier: &model.Verifier{ OIDC: &model.OIDCConfig{ @@ -110,7 +117,7 @@ func TestRegistrationAuthMiddleware_StaticModeRequiresFile(t *testing.T) { assert.Contains(t, err.Error(), "static_bearer_token_file") } -func TestRegistrationAuthMiddleware_JWTMode(t *testing.T) { +func TestRegistrationAuthMiddlewareJWTMode(t *testing.T) { gin.SetMode(gin.TestMode) privateKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -119,7 +126,7 @@ func TestRegistrationAuthMiddleware_JWTMode(t *testing.T) { 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, "kid-1")) + require.NoError(t, key.Set(jwk.KeyIDKey, jwtKid)) set := jwk.NewSet() require.NoError(t, set.AddKey(key)) @@ -136,8 +143,8 @@ func TestRegistrationAuthMiddleware_JWTMode(t *testing.T) { Mode: "jwt", JWT: &model.DynamicRegistrationJWTAuthConfig{ JWKSURI: jwksServer.URL, - Issuer: "https://issuer.example.com", - Audience: "vc-verifier-register", + Issuer: issuerExampleURL, + Audience: registerAudience, AllowedSigningAlgs: []string{"RS256"}, }, }, @@ -149,48 +156,48 @@ func TestRegistrationAuthMiddleware_JWTMode(t *testing.T) { require.NoError(t, err) r := gin.New() - r.POST("/register", mw, func(c *gin.Context) { + r.POST(registerPath, mw, func(c *gin.Context) { c.Status(http.StatusNoContent) }) validClaims := jwt.MapClaims{ - "iss": "https://issuer.example.com", - "aud": "vc-verifier-register", + "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"] = "kid-1" + validToken.Header["kid"] = jwtKid validTokenString, err := validToken.SignedString(privateKey) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/register", nil) + 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": "https://issuer.example.com", + "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"] = "kid-1" + invalidToken.Header["kid"] = jwtKid invalidTokenString, err := invalidToken.SignedString(privateKey) require.NoError(t, err) - req2 := httptest.NewRequest(http.MethodPost, "/register", nil) + 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 TestRegistrationAuthMiddleware_IntrospectionModeNotImplemented(t *testing.T) { +func TestRegistrationAuthMiddlewareIntrospectionModeNotImplemented(t *testing.T) { cfg := &model.Cfg{ Verifier: &model.Verifier{ OIDC: &model.OIDCConfig{ @@ -204,7 +211,7 @@ func TestRegistrationAuthMiddleware_IntrospectionModeNotImplemented(t *testing.T assert.Contains(t, err.Error(), "not implemented") } -func TestStaticBearerValidator_ConstantTimeComparison(t *testing.T) { +func TestStaticBearerValidatorConstantTimeComparison(t *testing.T) { validator := &staticBearerValidator{token: "expected-token"} err := validator.Validate(t.Context(), "expected-token") @@ -217,7 +224,7 @@ func TestStaticBearerValidator_ConstantTimeComparison(t *testing.T) { assert.Equal(t, "invalid_token", authErr.errorCode) } -func TestJWTBearerValidator_RequiresConfig(t *testing.T) { +func TestJWTBearerValidatorRequiresConfig(t *testing.T) { _, err := newJWTBearerValidator(nil) require.Error(t, err) @@ -225,9 +232,9 @@ func TestJWTBearerValidator_RequiresConfig(t *testing.T) { require.Error(t, err) _, err = newJWTBearerValidator(&model.DynamicRegistrationJWTAuthConfig{ - JWKSURI: "https://issuer.example.com/jwks", - Issuer: "https://issuer.example.com", - Audience: "vc-verifier-register", + JWKSURI: issuerExampleURL + "/jwks", + Issuer: issuerExampleURL, + Audience: registerAudience, }) assert.NoError(t, err) } @@ -244,7 +251,7 @@ func TestExtractBearerToken(t *testing.T) { require.Error(t, err) } -func TestExtractBearerToken_CaseInsensitiveScheme(t *testing.T) { +func TestExtractBearerTokenCaseInsensitiveScheme(t *testing.T) { token, err := extractBearerToken("bearer token-value") require.NoError(t, err) assert.Equal(t, "token-value", token)