Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
22 changes: 22 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion internal/verifier/httpserver/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() },

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The registerAuth field is initialized with a pass-through handler here at line 64, but is unconditionally overwritten at line 90 by the result of NewRegistrationAuthMiddleware. This default is dead code. If the intent is a safety net in case NewRegistrationAuthMiddleware isn't called (defensive programming), that path currently can't happen since the overwrite always executes before registerAuth is used. Consider removing this default initialization to avoid confusion, or adding a comment explaining the defensive intent.

Suggested change
registerAuth: func(c *gin.Context) { c.Next() },

Copilot uses AI. Check for mistakes.
sessionsOptions: sessions.Options{
Path: "/",
Domain: "",
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
212 changes: 212 additions & 0 deletions internal/verifier/middleware/registration_auth.go
Original file line number Diff line number Diff line change
@@ -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 &registrationAuthError{
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
}
Loading
Loading