From ebd58fcfdb02ec3d079122810cd7ea5c9e7136ba Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Mon, 12 May 2025 11:14:21 +0200 Subject: [PATCH] Introduce a the auth-api service * primitive implementation to demonstrate how it could work, still to be considered WIP at best * add new dependency: MicahParks/jwkset and MicahParks/keyfunc to retrieve the JWK set from KeyCloak to verify the signature of the JWTs sent as part of Bearer authentication in the /auth API * (minor) opencloud/.../service.go: clean up a logging statement that was introduced earlier to hunt down why the auth-api service was not being started --- .../opencloud_full/debug-opencloud-paused.yml | 7 + go.mod | 2 + go.sum | 4 + services/auth-api/pkg/config/config.go | 1 + .../pkg/config/defaults/defaultconfig.go | 3 + services/auth-api/pkg/metrics/metrics.go | 52 +- services/auth-api/pkg/metrics/options.go | 31 ++ services/auth-api/pkg/server/http/server.go | 4 +- .../auth-api/pkg/service/http/v0/option.go | 14 + .../auth-api/pkg/service/http/v0/service.go | 233 ++++++-- .../pkg/service/http/v0/service_test.go | 17 + .../github.com/MicahParks/jwkset/.gitignore | 2 + vendor/github.com/MicahParks/jwkset/LICENSE | 201 +++++++ vendor/github.com/MicahParks/jwkset/README.md | 133 +++++ .../github.com/MicahParks/jwkset/constants.go | 167 ++++++ vendor/github.com/MicahParks/jwkset/http.go | 276 ++++++++++ vendor/github.com/MicahParks/jwkset/jwk.go | 494 +++++++++++++++++ .../github.com/MicahParks/jwkset/marshal.go | 511 ++++++++++++++++++ .../github.com/MicahParks/jwkset/storage.go | 314 +++++++++++ vendor/github.com/MicahParks/jwkset/x509.go | 125 +++++ .../github.com/MicahParks/jwkset/x509_gen.sh | 15 + .../github.com/MicahParks/keyfunc/v3/LICENSE | 201 +++++++ .../MicahParks/keyfunc/v3/README.md | 81 +++ .../MicahParks/keyfunc/v3/keyfunc.go | 177 ++++++ vendor/modules.txt | 6 + 25 files changed, 3012 insertions(+), 59 deletions(-) create mode 100644 devtools/deployments/opencloud_full/debug-opencloud-paused.yml create mode 100644 services/auth-api/pkg/metrics/options.go create mode 100644 services/auth-api/pkg/service/http/v0/service_test.go create mode 100644 vendor/github.com/MicahParks/jwkset/.gitignore create mode 100644 vendor/github.com/MicahParks/jwkset/LICENSE create mode 100644 vendor/github.com/MicahParks/jwkset/README.md create mode 100644 vendor/github.com/MicahParks/jwkset/constants.go create mode 100644 vendor/github.com/MicahParks/jwkset/http.go create mode 100644 vendor/github.com/MicahParks/jwkset/jwk.go create mode 100644 vendor/github.com/MicahParks/jwkset/marshal.go create mode 100644 vendor/github.com/MicahParks/jwkset/storage.go create mode 100644 vendor/github.com/MicahParks/jwkset/x509.go create mode 100644 vendor/github.com/MicahParks/jwkset/x509_gen.sh create mode 100644 vendor/github.com/MicahParks/keyfunc/v3/LICENSE create mode 100644 vendor/github.com/MicahParks/keyfunc/v3/README.md create mode 100644 vendor/github.com/MicahParks/keyfunc/v3/keyfunc.go diff --git a/devtools/deployments/opencloud_full/debug-opencloud-paused.yml b/devtools/deployments/opencloud_full/debug-opencloud-paused.yml new file mode 100644 index 000000000..acd010de7 --- /dev/null +++ b/devtools/deployments/opencloud_full/debug-opencloud-paused.yml @@ -0,0 +1,7 @@ +--- +services: + + opencloud: + command: [ "-c", "opencloud init || true; dlv --listen=:40000 --headless=true --check-go-version=false --api-version=2 --accept-multiclient exec /usr/bin/opencloud server" ] + ports: + - 40000:40000 diff --git a/go.mod b/go.mod index f4dd437f0..c6a3eefd6 100644 --- a/go.mod +++ b/go.mod @@ -127,6 +127,8 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible // indirect + github.com/MicahParks/jwkset v0.8.0 // indirect + github.com/MicahParks/keyfunc/v3 v3.3.11 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.5 // indirect github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect diff --git a/go.sum b/go.sum index 75e411b26..e3db88ac6 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/MicahParks/jwkset v0.8.0 h1:jHtclI38Gibmu17XMI6+6/UB59srp58pQVxePHRK5o8= +github.com/MicahParks/jwkset v0.8.0/go.mod h1:fVrj6TmG1aKlJEeceAz7JsXGTXEn72zP1px3us53JrA= github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= +github.com/MicahParks/keyfunc/v3 v3.3.11 h1:eA6wNltwdSRX2gtpTwZseBCC9nGeBkI9KxHtTyZbDbo= +github.com/MicahParks/keyfunc/v3 v3.3.11/go.mod h1:y6Ed3dMgNKTcpxbaQHD8mmrYDUZWJAxteddA6OQj+ag= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= diff --git a/services/auth-api/pkg/config/config.go b/services/auth-api/pkg/config/config.go index 68519b9ce..b371c200c 100644 --- a/services/auth-api/pkg/config/config.go +++ b/services/auth-api/pkg/config/config.go @@ -24,4 +24,5 @@ type Config struct { } type AuthenticationAPI struct { + JwkEndpoint string `yaml:"jwk_endpoint"` } diff --git a/services/auth-api/pkg/config/defaults/defaultconfig.go b/services/auth-api/pkg/config/defaults/defaultconfig.go index 5ea474813..6be01b217 100644 --- a/services/auth-api/pkg/config/defaults/defaultconfig.go +++ b/services/auth-api/pkg/config/defaults/defaultconfig.go @@ -31,6 +31,9 @@ func DefaultConfig() *config.Config { Service: config.Service{ Name: "auth-api", }, + Authentication: config.AuthenticationAPI{ + JwkEndpoint: "https://keycloak.opencloud.test/realms/openCloud/protocol/openid-connect/certs", + }, } } diff --git a/services/auth-api/pkg/metrics/metrics.go b/services/auth-api/pkg/metrics/metrics.go index 7f2c82866..c684316cd 100644 --- a/services/auth-api/pkg/metrics/metrics.go +++ b/services/auth-api/pkg/metrics/metrics.go @@ -7,16 +7,30 @@ var ( Namespace = "opencloud" // Subsystem defines the subsystem for the defines metrics. - Subsystem = "authentication-api" + Subsystem = "authapi" ) // Metrics defines the available metrics of this service. type Metrics struct { BuildInfo *prometheus.GaugeVec + Duration *prometheus.HistogramVec + Attempts *prometheus.CounterVec } +const ( + TypeLabel = "type" + BasicType = "basic" + BearerType = "bearer" + UnsupportedType = "unsupported" + OutcomeLabel = "outcome" + AttemptSuccessOutcome = "success" + AttemptFailureOutcome = "failure" +) + // New initializes the available metrics. -func New() *Metrics { +func New(opts ...Option) *Metrics { + options := newOptions(opts...) + m := &Metrics{ BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: Namespace, @@ -24,11 +38,37 @@ func New() *Metrics { Name: "build_info", Help: "Build information", }, []string{"version"}), + Duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "authentication_duration_seconds", + Help: "Authentication processing time in seconds", + }, []string{"type"}), + Attempts: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "athentication_attempts_total", + Help: "How many authentication attempts were processed", + }, []string{"outcome"}), } - _ = prometheus.Register( - m.BuildInfo, - ) - + if err := prometheus.Register(m.BuildInfo); err != nil { + options.Logger.Error(). + Err(err). + Str("metric", "BuildInfo"). + Msg("Failed to register prometheus metric") + } + if err := prometheus.Register(m.Duration); err != nil { + options.Logger.Error(). + Err(err). + Str("metric", "Duration"). + Msg("Failed to register prometheus metric") + } + if err := prometheus.Register(m.Attempts); err != nil { + options.Logger.Error(). + Err(err). + Str("metric", "Attempts"). + Msg("Failed to register prometheus metric") + } return m } diff --git a/services/auth-api/pkg/metrics/options.go b/services/auth-api/pkg/metrics/options.go new file mode 100644 index 000000000..304456e7d --- /dev/null +++ b/services/auth-api/pkg/metrics/options.go @@ -0,0 +1,31 @@ +package metrics + +import ( + "github.com/opencloud-eu/opencloud/pkg/log" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Logger log.Logger +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} diff --git a/services/auth-api/pkg/server/http/server.go b/services/auth-api/pkg/server/http/server.go index 0237b55f5..33141d1fd 100644 --- a/services/auth-api/pkg/server/http/server.go +++ b/services/auth-api/pkg/server/http/server.go @@ -15,8 +15,6 @@ import ( func Server(opts ...Option) (http.Service, error) { options := newOptions(opts...) - fmt.Printf("===== HTTP addr: %v\n", options.Config.HTTP.Addr) - service, err := http.NewService( http.TLSConfig(options.Config.HTTP.TLS), http.Logger(options.Logger), @@ -37,6 +35,8 @@ func Server(opts ...Option) (http.Service, error) { handle := svc.NewService( svc.Logger(options.Logger), svc.Config(options.Config), + svc.Metrics(options.Metrics), + svc.TraceProvider(options.TraceProvider), svc.Middleware( middleware.RealIP, middleware.RequestID, diff --git a/services/auth-api/pkg/service/http/v0/option.go b/services/auth-api/pkg/service/http/v0/option.go index 581a7b663..cb0e6615f 100644 --- a/services/auth-api/pkg/service/http/v0/option.go +++ b/services/auth-api/pkg/service/http/v0/option.go @@ -5,6 +5,7 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/services/auth-api/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics" "go.opentelemetry.io/otel/trace" ) @@ -16,6 +17,7 @@ type Options struct { Logger log.Logger Config *config.Config Middleware []func(http.Handler) http.Handler + Metrics *metrics.Metrics TraceProvider trace.TracerProvider } @@ -50,3 +52,15 @@ func Middleware(val ...func(http.Handler) http.Handler) Option { o.Middleware = val } } + +func TraceProvider(tp trace.TracerProvider) Option { + return func(o *Options) { + o.TraceProvider = tp + } +} + +func Metrics(m *metrics.Metrics) Option { + return func(o *Options) { + o.Metrics = m + } +} diff --git a/services/auth-api/pkg/service/http/v0/service.go b/services/auth-api/pkg/service/http/v0/service.go index a779d33a2..cd3b90d98 100644 --- a/services/auth-api/pkg/service/http/v0/service.go +++ b/services/auth-api/pkg/service/http/v0/service.go @@ -1,18 +1,26 @@ package svc import ( + "context" + "crypto/tls" "net/http" "regexp" "time" + "github.com/MicahParks/jwkset" + "github.com/MicahParks/keyfunc/v3" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/golang-jwt/jwt/v5" + "github.com/riandyrn/otelchi" + "go.opentelemetry.io/otel/attribute" + oteltrace "go.opentelemetry.io/otel/trace" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/tracing" "github.com/opencloud-eu/opencloud/services/auth-api/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics" ) // Service defines the service handlers. @@ -33,13 +41,16 @@ func NewService(opts ...Option) Service { otelchi.WithChiRoutes(m), otelchi.WithTracerProvider(options.TraceProvider), otelchi.WithPropagators(tracing.GetPropagator()), + otelchi.WithTraceResponseHeaders(otelchi.TraceHeaderConfig{}), ), ) - svc := NewAuthenticationApi(options.Config, &options.Logger, m) + svc, err := NewAuthenticationApi(options.Config, &options.Logger, options.TraceProvider, m) + if err != nil { + panic(err) // TODO p.bleser what to do when we encounter an error in a NewService() ? + } m.Route(options.Config.HTTP.Root, func(r chi.Router) { - r.Get("/", svc.Authenticate) r.Post("/", svc.Authenticate) }) @@ -52,84 +63,204 @@ func NewService(opts ...Option) Service { } type AuthenticationApi struct { - config *config.Config - logger *log.Logger - mux *chi.Mux + config *config.Config + logger *log.Logger + metrics *metrics.Metrics + tracer oteltrace.Tracer + mux *chi.Mux + refreshCtx context.Context + jwksFunc keyfunc.Keyfunc } -func NewAuthenticationApi(config *config.Config, logger *log.Logger, mux *chi.Mux) *AuthenticationApi { - return &AuthenticationApi{ - config: config, - mux: mux, - logger: logger, +func NewAuthenticationApi( + config *config.Config, + logger *log.Logger, + metrics *metrics.Metrics, + tracerProvider oteltrace.TracerProvider, + mux *chi.Mux, +) (*AuthenticationApi, error) { + + tracer := tracerProvider.Tracer("instrumentation/" + config.HTTP.Namespace + "/" + config.Service.Name) + + var httpClient *http.Client + { + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.ResponseHeaderTimeout = time.Duration(10) * time.Second + tlsConfig := &tls.Config{InsecureSkipVerify: true} + tr.TLSClientConfig = tlsConfig + h := http.DefaultClient + h.Transport = tr + httpClient = h } + + refreshCtx := context.Background() + + storage, err := jwkset.NewStorageFromHTTP(config.Authentication.JwkEndpoint, jwkset.HTTPClientStorageOptions{ + Client: httpClient, + Ctx: refreshCtx, + HTTPExpectedStatus: http.StatusOK, + HTTPMethod: http.MethodGet, + HTTPTimeout: time.Duration(10) * time.Second, + NoErrorReturnFirstHTTPReq: true, + RefreshInterval: time.Duration(10) * time.Minute, + RefreshErrorHandler: func(ctx context.Context, err error) { + logger.Error().Err(err).Ctx(ctx).Str("url", config.Authentication.JwkEndpoint).Msg("failed to refresh JWK Set from IDP") + }, + //ValidateOptions: jwkset.JWKValidateOptions{}, + }) + if err != nil { + return nil, err + } + + jwksFunc, err := keyfunc.New(keyfunc.Options{ + Ctx: refreshCtx, + UseWhitelist: []jwkset.USE{jwkset.UseSig}, + Storage: storage, + }) + if err != nil { + return nil, err + } + + return &AuthenticationApi{ + config: config, + mux: mux, + logger: logger, + metrics: metrics, + tracer: tracer, + refreshCtx: refreshCtx, + jwksFunc: jwksFunc, + }, nil } func (a AuthenticationApi) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.mux.ServeHTTP(w, r) } -type AuthResponse struct { - Subject string +type SuccessfulAuthResponse struct { + Subject string `json:"subject"` + Roles []string `json:"roles,omitempty"` } -func (AuthResponse) Render(w http.ResponseWriter, r *http.Request) error { +func (SuccessfulAuthResponse) Render(w http.ResponseWriter, r *http.Request) error { return nil } -var authRegex = regexp.MustCompile("^(i:Basic|Bearer)\\s+(.+)$") +type FailedAuthResponse struct { + Reason string `json:"reason,omitempty"` +} + +func (FailedAuthResponse) Render(w http.ResponseWriter, r *http.Request) error { + return nil +} + +type CustomClaims struct { + Roles []string `json:"roles,omitempty"` + AuthorizedParties jwt.ClaimStrings `json:"azp,omitempty"` + SessionId string `json:"sid,omitempty"` + AuthenticationContextClassReference string `json:"acr,omitempty"` + Scope jwt.ClaimStrings `json:"scope,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` + Name string `json:"name,omitempty"` + Groups jwt.ClaimStrings `json:"groups,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` + Uuid string `json:"uuid,omitempty"` + Email string `json:"email,omitempty"` + + jwt.RegisteredClaims +} + +var authRegex = regexp.MustCompile(`(?i)^(Basic|Bearer)\s+(.+)$`) + +func (a AuthenticationApi) failedAuth() { + a.metrics.Attempts.WithLabelValues(metrics.OutcomeLabel, metrics.AttemptFailureOutcome).Inc() +} +func (a AuthenticationApi) succeededAuth() { + a.metrics.Attempts.WithLabelValues(metrics.OutcomeLabel, metrics.AttemptSuccessOutcome).Inc() +} func (a AuthenticationApi) Authenticate(w http.ResponseWriter, r *http.Request) { + _, span := a.tracer.Start(r.Context(), "authenticate") + defer span.End() + auth := r.Header.Get("Authorization") if auth == "" { + a.logger.Warn().Msg("missing Authorization header") w.WriteHeader(http.StatusBadRequest) // authentication header is missing altogether + _ = render.Render(w, r, FailedAuthResponse{Reason: "Missing Authorization header"}) + a.failedAuth() return } - matches := authRegex.FindAllString(auth, 2) - if matches == nil { + matches := authRegex.FindStringSubmatch(auth) + if matches == nil || len(matches) != 3 { + a.logger.Warn().Msg("unsupported Authorization header") w.WriteHeader(http.StatusBadRequest) // authentication header is unsupported + _ = render.Render(w, r, FailedAuthResponse{Reason: "Unsupported Authorization header"}) + a.failedAuth() return } - if matches[0] == "Basic" { + if matches[1] == "Basic" { + span.SetAttributes(attribute.String("authenticate.scheme", "basic")) + a.metrics.Attempts.WithLabelValues(metrics.TypeLabel, metrics.BasicType).Inc() + username, password, ok := r.BasicAuth() if !ok { + a.logger.Warn().Msg("failed to decode basic credentials") w.WriteHeader(http.StatusBadRequest) // failed to decode the basic credentials - } - if password == "secret" { - _ = render.Render(w, r, AuthResponse{Subject: username}) - } else { - w.WriteHeader(http.StatusUnauthorized) - } - } else if matches[0] == "Bearer" { - claims := jwt.MapClaims{} - publicKey := nil - tokenString := matches[1] - token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { - token.Header["kid"] - return publicKey, nil - }, jwt.WithExpirationRequired(), jwt.WithLeeway(5*time.Second)) - if err != nil { - w.WriteHeader(http.StatusBadRequest) // failed to parse bearer token - } - sub, err := token.Claims.GetSubject() - if err != nil { - w.WriteHeader(http.StatusBadRequest) // failed to extract sub claim from bearer token - } - _ = render.Render(w, r, AuthResponse{Subject: sub}) - } else { - w.WriteHeader(http.StatusBadRequest) // authentication header is unsupported - return - } - - // TODO - - /* - if err != nil { - w.WriteHeader(http.StatusInternalServerError) + _ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to decode basic credentials"}) + a.failedAuth() return } - */ + if password == "secret" { + _ = render.Render(w, r, SuccessfulAuthResponse{Subject: username}) + a.succeededAuth() + } else { + a.logger.Info().Str("username", username).Msg("authentication failed") + w.WriteHeader(http.StatusUnauthorized) + _ = render.Render(w, r, FailedAuthResponse{Reason: "Unauthorized credentials"}) + a.failedAuth() + return + } + } else if matches[1] == "Bearer" { + span.SetAttributes(attribute.String("authenticate.scheme", "bearer")) + a.metrics.Attempts.WithLabelValues(metrics.TypeLabel, metrics.BearerType).Inc() - _ = render.Render(w, r, AuthResponse{Subject: "todo"}) + claims := &CustomClaims{} + tokenString := matches[2] + token, err := jwt.ParseWithClaims(tokenString, claims, a.jwksFunc.Keyfunc, jwt.WithExpirationRequired(), jwt.WithLeeway(5*time.Second)) + if err != nil { + a.logger.Warn().Err(err).Msg("failed to parse bearer token") + w.WriteHeader(http.StatusBadRequest) // failed to parse bearer token + _ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to parse bearer token"}) + return + } + + a.logger.Info().Str("type", matches[1]).Interface("header", token.Header).Interface("claims", token.Claims).Bool("valid", token.Valid).Msgf("successfully parsed token") + + if typedClaims, ok := token.Claims.(*CustomClaims); ok && token.Valid { + sub := typedClaims.PreferredUsername + if sub == "" { + sub, err = typedClaims.GetSubject() + if err != nil { + a.logger.Warn().Err(err).Msg("failed to retrieve sub claim from token") + w.WriteHeader(http.StatusBadRequest) // failed to extract sub claim from bearer token + _ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to extract sub claim from bearer token"}) + return + } + } + _ = render.Render(w, r, SuccessfulAuthResponse{Subject: sub, Roles: claims.Roles}) + } else { + w.WriteHeader(http.StatusBadRequest) // failed to extract sub claim from bearer token + _ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to parse bearer token"}) + return + } + } else { + a.metrics.Attempts.WithLabelValues(metrics.TypeLabel, metrics.UnsupportedType).Inc() + + w.WriteHeader(http.StatusBadRequest) // authentication header is unsupported + _ = render.Render(w, r, FailedAuthResponse{Reason: "Unsupported Authorization type"}) + return + } } diff --git a/services/auth-api/pkg/service/http/v0/service_test.go b/services/auth-api/pkg/service/http/v0/service_test.go new file mode 100644 index 000000000..23f3520ce --- /dev/null +++ b/services/auth-api/pkg/service/http/v0/service_test.go @@ -0,0 +1,17 @@ +package svc + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRegex(t *testing.T) { + require := require.New(t) + + matches := authRegex.FindStringSubmatch("Basic abc") + require.NotNil(matches) + require.Len(matches, 3) + require.Equal("Basic", matches[1]) + require.Equal("abc", matches[2]) +} diff --git a/vendor/github.com/MicahParks/jwkset/.gitignore b/vendor/github.com/MicahParks/jwkset/.gitignore new file mode 100644 index 000000000..040ac50a4 --- /dev/null +++ b/vendor/github.com/MicahParks/jwkset/.gitignore @@ -0,0 +1,2 @@ +config.*json +node_modules diff --git a/vendor/github.com/MicahParks/jwkset/LICENSE b/vendor/github.com/MicahParks/jwkset/LICENSE new file mode 100644 index 000000000..05f2ccbd2 --- /dev/null +++ b/vendor/github.com/MicahParks/jwkset/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Micah Parks + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/MicahParks/jwkset/README.md b/vendor/github.com/MicahParks/jwkset/README.md new file mode 100644 index 000000000..e9d511eb3 --- /dev/null +++ b/vendor/github.com/MicahParks/jwkset/README.md @@ -0,0 +1,133 @@ +[![Go Reference](https://pkg.go.dev/badge/github.com/MicahParks/jwkset.svg)](https://pkg.go.dev/github.com/MicahParks/jwkset) + +# JWK Set (JSON Web Key Set) + +This is a JWK Set (JSON Web Key Set) implementation written in Golang. + +The goal of this project is to provide a complete implementation of JWK and JWK Sets within the constraints of the +Golang standard library, without implementing any cryptographic algorithms. For example, `Ed25519` is supported, but +`Ed448` is not, because the Go standard library does not have a high level implementation of `Ed448`. + +If you would like to generate or validate a JWK without writing any Golang code, please visit +the [Generate a JWK Set](#generate-a-jwk-set) section. + +If you would like to have a JWK Set client to help verify JWTs without writing any Golang code, you can use the +[JWK Set Client Proxy (JCP) project](https://github.com/MicahParks/jcp) perform JWK Set client operations in the +language of your choice using an OpenAPI interface. + +# Generate a JWK Set + +If you would like to generate a JWK Set without writing Golang code, this project publishes utilities to generate a JWK +Set from: + +* PEM encoded X.509 Certificates +* PEM encoded public keys +* PEM encoded private keys + +The PEM block type is used to infer which key type to decode. Reference the [Supported keys](#supported-keys) section +for a list of supported cryptographic key types. + +## Website + +Visit [https://jwkset.com](https://jwkset.com) to use the web interface for this project. You can self-host this website +by following the instructions in the `README.md` in +the [website](https://github.com/MicahParks/jwkset/tree/master/website) directory. + +## Command line + +Gather your PEM encoded keys or certificates and use the `cmd/jwksetinfer` command line tool to generate a JWK Set. + +**Install** + +``` +go install github.com/MicahParks/jwkset/cmd/jwksetinfer@latest +``` + +**Usage** + +``` +jwksetinfer mykey.pem mycert.crt +``` + +## Custom server + +This project can be used in creating a custom JWK Set server. A good place to start is `examples/http_server/main.go`. + +# Golang JWK Set client + +If you are using [`github.com/golang-jwt/jwt/v5`](https://github.com/golang-jwt/jwt) take a look +at [`github.com/MicahParks/keyfunc/v3`](https://github.com/MicahParks/keyfunc). + +This project can be used to create JWK Set clients. An HTTP client is provided. See a snippet of the usage +from `examples/default_http_client/main.go` below. + +## Create a JWK Set client from the server's HTTP URL. + +```go +jwks, err := jwkset.NewDefaultHTTPClient([]string{server.URL}) +if err != nil { + log.Fatalf("Failed to create client JWK set. Error: %s", err) +} +``` + +## Read a key from the client. + +```go +jwk, err = jwks.KeyRead(ctx, myKeyID) +if err != nil { + log.Fatalf("Failed to read key from client JWK set. Error: %s", err) +} +``` + +# Supported keys + +This project supports the following key types: + +* [Edwards-curve Digital Signature Algorithm (EdDSA)](https://en.wikipedia.org/wiki/EdDSA) (Ed25519 only) + * Go Types: `ed25519.PrivateKey` and `ed25519.PublicKey` +* [Elliptic-curve Diffie–Hellman (ECDH)](https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman) (X25519 + only) + * Go Types: `*ecdh.PrivateKey` and `*ecdh.PublicKey` +* [Elliptic Curve Digital Signature Algorithm (ECDSA)](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm) + * Go Types: `*ecdsa.PrivateKey` and `*ecdsa.PublicKey` +* [Rivest–Shamir–Adleman (RSA)](https://en.wikipedia.org/wiki/RSA_(cryptosystem)) + * Go Types: `*rsa.PrivateKey` and `*rsa.PublicKey` +* [HMAC](https://en.wikipedia.org/wiki/HMAC), [AES Key Wrap](https://en.wikipedia.org/wiki/Key_Wrap), and other + symmetric keys + * Go Type: `[]byte` + +Cryptographic keys can be added, deleted, and read from the JWK Set. A JSON representation of the JWK Set can be created +for hosting via HTTPS. This project includes an in-memory storage implementation, but an interface is provided for more +advanced use cases. + +# Notes + +This project aims to implement the relevant RFCs to the fullest extent possible using the Go standard library, but does +not implement any cryptographic algorithms itself. + +* RFC 8037 adds support for `Ed448`, `X448`, and `secp256k1`, but there is no Golang standard library support for these + key types. +* In order to be compatible with non-RFC compliant JWK Set providers, this project does not strictly enforce JWK + parameters that are integers and have extra or missing leading padding. See the release notes + of [`v0.5.15`](https://github.com/MicahParks/jwkset/releases/tag/v0.5.15) for details. +* `Base64url Encoding` requires that all trailing `=` characters be removed. This project automatically strips any + trailing `=` characters in an attempt to be compatible with improper implementations of JWK. +* This project does not currently support JWK Set encryption using JWE. This would involve implementing the relevant JWE + specifications. It may be implemented in the future if there is interest. Open a GitHub issue to express interest. + +# Related projects + +## [`github.com/MicahParks/keyfunc`](https://github.com/MicahParks/keyfunc) + +A JWK Set client for the [`github.com/golang-jwt/jwt/v5`](https://github.com/golang-jwt/jwt) project. + +## [`github.com/MicahParks/jcp`](https://github.com/MicahParks/jcp) + +A JWK Set client proxy. JCP for short. This project is a standalone service that uses keyfunc under the hood. It +primarily exists for these use cases: + +The language or shell a program is written in does not have an adequate JWK Set client. Validate JWTs with curl? Why +not? +Restrictive networking policies prevent a program from accessing the remote JWK Set directly. +Many co-located services need to validate JWTs that were signed by a key that lives in a remote JWK Set. +If you can integrate keyfunc directly into your program, you likely don't need JCP. diff --git a/vendor/github.com/MicahParks/jwkset/constants.go b/vendor/github.com/MicahParks/jwkset/constants.go new file mode 100644 index 000000000..15e219e0d --- /dev/null +++ b/vendor/github.com/MicahParks/jwkset/constants.go @@ -0,0 +1,167 @@ +package jwkset + +const ( + // HeaderKID is a JWT header for the key ID. + HeaderKID = "kid" +) + +// These are string constants set in https://www.iana.org/assignments/jose/jose.xhtml +// See their respective types for more information. +const ( + AlgHS256 ALG = "HS256" + AlgHS384 ALG = "HS384" + AlgHS512 ALG = "HS512" + AlgRS256 ALG = "RS256" + AlgRS384 ALG = "RS384" + AlgRS512 ALG = "RS512" + AlgES256 ALG = "ES256" + AlgES384 ALG = "ES384" + AlgES512 ALG = "ES512" + AlgPS256 ALG = "PS256" + AlgPS384 ALG = "PS384" + AlgPS512 ALG = "PS512" + AlgNone ALG = "none" + AlgRSA1_5 ALG = "RSA1_5" + AlgRSAOAEP ALG = "RSA-OAEP" + AlgRSAOAEP256 ALG = "RSA-OAEP-256" + AlgA128KW ALG = "A128KW" + AlgA192KW ALG = "A192KW" + AlgA256KW ALG = "A256KW" + AlgDir ALG = "dir" + AlgECDHES ALG = "ECDH-ES" + AlgECDHESA128KW ALG = "ECDH-ES+A128KW" + AlgECDHESA192KW ALG = "ECDH-ES+A192KW" + AlgECDHESA256KW ALG = "ECDH-ES+A256KW" + AlgA128GCMKW ALG = "A128GCMKW" + AlgA192GCMKW ALG = "A192GCMKW" + AlgA256GCMKW ALG = "A256GCMKW" + AlgPBES2HS256A128KW ALG = "PBES2-HS256+A128KW" + AlgPBES2HS384A192KW ALG = "PBES2-HS384+A192KW" + AlgPBES2HS512A256KW ALG = "PBES2-HS512+A256KW" + AlgA128CBCHS256 ALG = "A128CBC-HS256" + AlgA192CBCHS384 ALG = "A192CBC-HS384" + AlgA256CBCHS512 ALG = "A256CBC-HS512" + AlgA128GCM ALG = "A128GCM" + AlgA192GCM ALG = "A192GCM" + AlgA256GCM ALG = "A256GCM" + AlgEdDSA ALG = "EdDSA" + AlgRS1 ALG = "RS1" // Prohibited. + AlgRSAOAEP384 ALG = "RSA-OAEP-384" + AlgRSAOAEP512 ALG = "RSA-OAEP-512" + AlgA128CBC ALG = "A128CBC" // Prohibited. + AlgA192CBC ALG = "A192CBC" // Prohibited. + AlgA256CBC ALG = "A256CBC" // Prohibited. + AlgA128CTR ALG = "A128CTR" // Prohibited. + AlgA192CTR ALG = "A192CTR" // Prohibited. + AlgA256CTR ALG = "A256CTR" // Prohibited. + AlgHS1 ALG = "HS1" // Prohibited. + AlgES256K ALG = "ES256K" + + CrvP256 CRV = "P-256" + CrvP384 CRV = "P-384" + CrvP521 CRV = "P-521" + CrvEd25519 CRV = "Ed25519" + CrvEd448 CRV = "Ed448" + CrvX25519 CRV = "X25519" + CrvX448 CRV = "X448" + CrvSECP256K1 CRV = "secp256k1" + + KeyOpsSign KEYOPS = "sign" + KeyOpsVerify KEYOPS = "verify" + KeyOpsEncrypt KEYOPS = "encrypt" + KeyOpsDecrypt KEYOPS = "decrypt" + KeyOpsWrapKey KEYOPS = "wrapKey" + KeyOpsUnwrapKey KEYOPS = "unwrapKey" + KeyOpsDeriveKey KEYOPS = "deriveKey" + KeyOpsDeriveBits KEYOPS = "deriveBits" + + KtyEC KTY = "EC" + KtyOKP KTY = "OKP" + KtyRSA KTY = "RSA" + KtyOct KTY = "oct" + + UseEnc USE = "enc" + UseSig USE = "sig" +) + +// ALG is a set of "JSON Web Signature and Encryption Algorithms" types from +// https://www.iana.org/assignments/jose/jose.xhtml as defined in +// https://www.rfc-editor.org/rfc/rfc7518#section-7.1 +type ALG string + +func (alg ALG) IANARegistered() bool { + switch alg { + case AlgHS256, AlgHS384, AlgHS512, AlgRS256, AlgRS384, AlgRS512, AlgES256, AlgES384, AlgES512, AlgPS256, AlgPS384, + AlgPS512, AlgNone, AlgRSA1_5, AlgRSAOAEP, AlgRSAOAEP256, AlgA128KW, AlgA192KW, AlgA256KW, AlgDir, AlgECDHES, + AlgECDHESA128KW, AlgECDHESA192KW, AlgECDHESA256KW, AlgA128GCMKW, AlgA192GCMKW, AlgA256GCMKW, + AlgPBES2HS256A128KW, AlgPBES2HS384A192KW, AlgPBES2HS512A256KW, AlgA128CBCHS256, AlgA192CBCHS384, + AlgA256CBCHS512, AlgA128GCM, AlgA192GCM, AlgA256GCM, AlgEdDSA, AlgRS1, AlgRSAOAEP384, AlgRSAOAEP512, AlgA128CBC, + AlgA192CBC, AlgA256CBC, AlgA128CTR, AlgA192CTR, AlgA256CTR, AlgHS1, AlgES256K, "": + return true + } + return false +} +func (alg ALG) String() string { + return string(alg) +} + +// CRV is a set of "JSON Web Key Elliptic Curve" types from https://www.iana.org/assignments/jose/jose.xhtml as +// mentioned in https://www.rfc-editor.org/rfc/rfc7518.html#section-6.2.1.1 +type CRV string + +func (crv CRV) IANARegistered() bool { + switch crv { + case CrvP256, CrvP384, CrvP521, CrvEd25519, CrvEd448, CrvX25519, CrvX448, CrvSECP256K1, "": + return true + } + return false +} +func (crv CRV) String() string { + return string(crv) +} + +// KEYOPS is a set of "JSON Web Key Operations" from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in +// https://www.rfc-editor.org/rfc/rfc7517#section-4.3 +type KEYOPS string + +func (keyopts KEYOPS) IANARegistered() bool { + switch keyopts { + case KeyOpsSign, KeyOpsVerify, KeyOpsEncrypt, KeyOpsDecrypt, KeyOpsWrapKey, KeyOpsUnwrapKey, KeyOpsDeriveKey, + KeyOpsDeriveBits: + return true + } + return false +} +func (keyopts KEYOPS) String() string { + return string(keyopts) +} + +// KTY is a set of "JSON Web Key Types" from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in +// https://www.rfc-editor.org/rfc/rfc7517#section-4.1 +type KTY string + +func (kty KTY) IANARegistered() bool { + switch kty { + case KtyEC, KtyOKP, KtyRSA, KtyOct: + return true + } + return false +} +func (kty KTY) String() string { + return string(kty) +} + +// USE is a set of "JSON Web Key Use" types from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in +// https://www.rfc-editor.org/rfc/rfc7517#section-4.2 +type USE string + +func (use USE) IANARegistered() bool { + switch use { + case UseEnc, UseSig, "": + return true + } + return false +} +func (use USE) String() string { + return string(use) +} diff --git a/vendor/github.com/MicahParks/jwkset/http.go b/vendor/github.com/MicahParks/jwkset/http.go new file mode 100644 index 000000000..36f151c24 --- /dev/null +++ b/vendor/github.com/MicahParks/jwkset/http.go @@ -0,0 +1,276 @@ +package jwkset + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "golang.org/x/time/rate" +) + +var ( + // ErrNewClient fails to create a new JWK Set client. + ErrNewClient = errors.New("failed to create new JWK Set client") +) + +// HTTPClientOptions are options for creating a new JWK Set client. +type HTTPClientOptions struct { + // Given contains keys known from outside HTTP URLs. + Given Storage + // HTTPURLs are a mapping of HTTP URLs to JWK Set endpoints to storage implementations for the keys located at the + // URL. If empty, HTTP will not be used. + HTTPURLs map[string]Storage + // PrioritizeHTTP is a flag that indicates whether keys from the HTTP URL should be prioritized over keys from the + // given storage. + PrioritizeHTTP bool + // RateLimitWaitMax is the timeout for waiting for rate limiting to end. + RateLimitWaitMax time.Duration + // RefreshUnknownKID is non-nil to indicate that remote HTTP resources should be refreshed if a key with an unknown + // key ID is trying to be read. This makes reading methods block until the context is over, a key with the matching + // key ID is found in a refreshed remote resource, or all refreshes complete. + RefreshUnknownKID *rate.Limiter +} + +// Client is a JWK Set client. +type httpClient struct { + given Storage + httpURLs map[string]Storage + prioritizeHTTP bool + rateLimitWaitMax time.Duration + refreshUnknownKID *rate.Limiter +} + +// NewHTTPClient creates a new JWK Set client from remote HTTP resources. +func NewHTTPClient(options HTTPClientOptions) (Storage, error) { + if options.Given == nil && len(options.HTTPURLs) == 0 { + return nil, fmt.Errorf("%w: no given keys or HTTP URLs", ErrNewClient) + } + for u, store := range options.HTTPURLs { + if store == nil { + var err error + options.HTTPURLs[u], err = NewStorageFromHTTP(u, HTTPClientStorageOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client storage for %q: %w", u, errors.Join(err, ErrNewClient)) + } + } + } + given := options.Given + if given == nil { + given = NewMemoryStorage() + } + c := httpClient{ + given: given, + httpURLs: options.HTTPURLs, + prioritizeHTTP: options.PrioritizeHTTP, + rateLimitWaitMax: options.RateLimitWaitMax, + refreshUnknownKID: options.RefreshUnknownKID, + } + return c, nil +} + +// NewDefaultHTTPClient creates a new JWK Set client with default options from remote HTTP resources. +// +// The default behavior is to: +// 1. Refresh remote HTTP resources every hour. +// 2. Prioritize keys from remote HTTP resources over keys from the given storage. +// 3. Refresh remote HTTP resources if a key with an unknown key ID is trying to be read, with a rate limit of 5 minutes. +// 4. Log to slog.Default() if a refresh fails. +func NewDefaultHTTPClient(urls []string) (Storage, error) { + return NewDefaultHTTPClientCtx(context.Background(), urls) +} + +// NewDefaultHTTPClientCtx is the same as NewDefaultHTTPClient, but with a context that can end the refresh goroutine. +func NewDefaultHTTPClientCtx(ctx context.Context, urls []string) (Storage, error) { + clientOptions := HTTPClientOptions{ + HTTPURLs: make(map[string]Storage), + RateLimitWaitMax: time.Minute, + RefreshUnknownKID: rate.NewLimiter(rate.Every(5*time.Minute), 1), + } + for _, u := range urls { + refreshErrorHandler := func(ctx context.Context, err error) { + slog.Default().ErrorContext(ctx, "Failed to refresh HTTP JWK Set from remote HTTP resource.", + "error", err, + "url", u, + ) + } + options := HTTPClientStorageOptions{ + Ctx: ctx, + NoErrorReturnFirstHTTPReq: true, + RefreshErrorHandler: refreshErrorHandler, + RefreshInterval: time.Hour, + } + c, err := NewStorageFromHTTP(u, options) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client storage for %q: %w", u, errors.Join(err, ErrNewClient)) + } + clientOptions.HTTPURLs[u] = c + } + return NewHTTPClient(clientOptions) +} + +func (c httpClient) KeyDelete(ctx context.Context, keyID string) (ok bool, err error) { + ok, err = c.given.KeyDelete(ctx, keyID) + if err != nil && !errors.Is(err, ErrKeyNotFound) { + return false, fmt.Errorf("failed to delete key with ID %q from given storage due to error: %w", keyID, err) + } + if ok { + return true, nil + } + for _, store := range c.httpURLs { + ok, err = store.KeyDelete(ctx, keyID) + if err != nil && !errors.Is(err, ErrKeyNotFound) { + return false, fmt.Errorf("failed to delete key with ID %q from HTTP storage due to error: %w", keyID, err) + } + if ok { + return true, nil + } + } + return false, nil +} +func (c httpClient) KeyRead(ctx context.Context, keyID string) (jwk JWK, err error) { + if !c.prioritizeHTTP { + jwk, err = c.given.KeyRead(ctx, keyID) + switch { + case errors.Is(err, ErrKeyNotFound): + // Do nothing. + case err != nil: + return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in given storage due to error: %w", keyID, err) + default: + return jwk, nil + } + } + for _, store := range c.httpURLs { + jwk, err = store.KeyRead(ctx, keyID) + switch { + case errors.Is(err, ErrKeyNotFound): + continue + case err != nil: + return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in HTTP storage due to error: %w", keyID, err) + default: + return jwk, nil + } + } + if c.prioritizeHTTP { + jwk, err = c.given.KeyRead(ctx, keyID) + switch { + case errors.Is(err, ErrKeyNotFound): + // Do nothing. + case err != nil: + return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in given storage due to error: %w", keyID, err) + default: + return jwk, nil + } + } + if c.refreshUnknownKID != nil { + var cancel context.CancelFunc = func() {} + if c.rateLimitWaitMax > 0 { + ctx, cancel = context.WithTimeout(ctx, c.rateLimitWaitMax) + } + defer cancel() + err = c.refreshUnknownKID.Wait(ctx) + if err != nil { + return JWK{}, fmt.Errorf("failed to wait for JWK Set refresh rate limiter due to error: %w", err) + } + for _, store := range c.httpURLs { + s, ok := store.(httpStorage) + if !ok { + continue + } + err = s.refresh(ctx) + if err != nil { + if s.options.RefreshErrorHandler != nil { + s.options.RefreshErrorHandler(ctx, err) + } + continue + } + jwk, err = store.KeyRead(ctx, keyID) + switch { + case errors.Is(err, ErrKeyNotFound): + // Do nothing. + case err != nil: + return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in HTTP storage due to error: %w", keyID, err) + default: + return jwk, nil + } + } + } + return JWK{}, fmt.Errorf("%w %q", ErrKeyNotFound, keyID) +} +func (c httpClient) KeyReadAll(ctx context.Context) ([]JWK, error) { + jwks, err := c.given.KeyReadAll(ctx) + if err != nil { + return nil, fmt.Errorf("failed to snapshot given keys due to error: %w", err) + } + for u, store := range c.httpURLs { + j, err := store.KeyReadAll(ctx) + if err != nil { + return nil, fmt.Errorf("failed to snapshot HTTP keys from %q due to error: %w", u, err) + } + jwks = append(jwks, j...) + } + return jwks, nil +} +func (c httpClient) KeyWrite(ctx context.Context, jwk JWK) error { + return c.given.KeyWrite(ctx, jwk) +} + +func (c httpClient) JSON(ctx context.Context) (json.RawMessage, error) { + m, err := c.combineStorage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to combine storage due to error: %w", err) + } + return m.JSON(ctx) +} +func (c httpClient) JSONPublic(ctx context.Context) (json.RawMessage, error) { + m, err := c.combineStorage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to combine storage due to error: %w", err) + } + return m.JSONPublic(ctx) +} +func (c httpClient) JSONPrivate(ctx context.Context) (json.RawMessage, error) { + m, err := c.combineStorage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to combine storage due to error: %w", err) + } + return m.JSONPrivate(ctx) +} +func (c httpClient) JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error) { + m, err := c.combineStorage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to combine storage due to error: %w", err) + } + return m.JSONWithOptions(ctx, marshalOptions, validationOptions) +} +func (c httpClient) Marshal(ctx context.Context) (JWKSMarshal, error) { + m, err := c.combineStorage(ctx) + if err != nil { + return JWKSMarshal{}, fmt.Errorf("failed to combine storage due to error: %w", err) + } + return m.Marshal(ctx) +} +func (c httpClient) MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error) { + m, err := c.combineStorage(ctx) + if err != nil { + return JWKSMarshal{}, fmt.Errorf("failed to combine storage due to error: %w", err) + } + return m.MarshalWithOptions(ctx, marshalOptions, validationOptions) +} + +func (c httpClient) combineStorage(ctx context.Context) (Storage, error) { + jwks, err := c.KeyReadAll(ctx) + if err != nil { + return nil, fmt.Errorf("failed to snapshot keys due to error: %w", err) + } + m := NewMemoryStorage() + for _, jwk := range jwks { + err = m.KeyWrite(ctx, jwk) + if err != nil { + return nil, fmt.Errorf("failed to write key to memory storage due to error: %w", err) + } + } + return m, nil +} diff --git a/vendor/github.com/MicahParks/jwkset/jwk.go b/vendor/github.com/MicahParks/jwkset/jwk.go new file mode 100644 index 000000000..9fd55580d --- /dev/null +++ b/vendor/github.com/MicahParks/jwkset/jwk.go @@ -0,0 +1,494 @@ +package jwkset + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "slices" + "time" +) + +var ( + // ErrPadding indicates that there is invalid padding. + ErrPadding = errors.New("padding error") +) + +// JWK represents a JSON Web Key. +type JWK struct { + key any + marshal JWKMarshal + options JWKOptions +} + +// JWKMarshalOptions are used to specify options for JSON marshaling a JWK. +type JWKMarshalOptions struct { + // Private is used to indicate that the JWK's private key material should be JSON marshaled and unmarshalled. This + // includes symmetric and asymmetric keys. Setting this to true is the only way to marshal and unmarshal symmetric + // keys. + Private bool +} + +// JWKX509Options holds the X.509 certificate information for a JWK. This data structure is not used for JSON marshaling. +type JWKX509Options struct { + // X5C contains a chain of one or more PKIX certificates. The PKIX certificate containing the key value MUST be the + // first certificate. + X5C []*x509.Certificate // The PKIX certificate containing the key value MUST be the first certificate. + + // X5T is calculated automatically. + // X5TS256 is calculated automatically. + + // X5U Is a URI that refers to a resource for an X.509 public key certificate or certificate chain. + X5U string // https://www.rfc-editor.org/rfc/rfc7517#section-4.6 +} + +// JWKValidateOptions are used to specify options for validating a JWK. +type JWKValidateOptions struct { + /* + This package intentionally does not confirm if certificate's usage or compare that to the JWK's use parameter. + Please open a GitHub issue if you think this should be an option. + */ + // CheckX509ValidTime is used to indicate that the X.509 certificate's valid time should be checked. + CheckX509ValidTime bool + // GetX5U is used to get and validate the X.509 certificate from the X5U URI. Use DefaultGetX5U for the default + // behavior. + GetX5U func(x5u *url.URL) ([]*x509.Certificate, error) + // SkipAll is used to skip all validation. + SkipAll bool + // SkipKeyOps is used to skip validation of the key operations (key_ops). + SkipKeyOps bool + // SkipMetadata skips checking if the JWKMetadataOptions match the JWKMarshal. + SkipMetadata bool + // SkipUse is used to skip validation of the key use (use). + SkipUse bool + // SkipX5UScheme is used to skip checking if the X5U URI scheme is https. + SkipX5UScheme bool + // StrictPadding is used to indicate that the JWK should be validated with strict padding. + StrictPadding bool +} + +// JWKMetadataOptions are direct passthroughs into the JWKMarshal. +type JWKMetadataOptions struct { + // ALG is the algorithm (alg). + ALG ALG + // KID is the key ID (kid). + KID string + // KEYOPS is the key operations (key_ops). + KEYOPS []KEYOPS + // USE is the key use (use). + USE USE +} + +// JWKOptions are used to specify options for marshaling a JSON Web Key. +type JWKOptions struct { + Marshal JWKMarshalOptions + Metadata JWKMetadataOptions + Validate JWKValidateOptions + X509 JWKX509Options +} + +// NewJWKFromKey uses the given key and options to create a JWK. It is possible to provide a private key with an X.509 +// certificate, which will be validated to contain the correct public key. +func NewJWKFromKey(key any, options JWKOptions) (JWK, error) { + marshal, err := keyMarshal(key, options) + if err != nil { + return JWK{}, fmt.Errorf("failed to marshal JSON Web Key: %w", err) + } + switch key.(type) { + case ed25519.PrivateKey, ed25519.PublicKey: + if options.Metadata.ALG == "" { + options.Metadata.ALG = AlgEdDSA + } else if options.Metadata.ALG != AlgEdDSA { + return JWK{}, fmt.Errorf("%w: invalid ALG for Ed25519 key: %q", ErrOptions, options.Metadata.ALG) + } + } + j := JWK{ + key: key, + marshal: marshal, + options: options, + } + err = j.Validate() + if err != nil { + return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err) + } + return j, nil +} + +// NewJWKFromRawJSON uses the given raw JSON to create a JWK. +func NewJWKFromRawJSON(j json.RawMessage, marshalOptions JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) { + marshal := JWKMarshal{} + err := json.Unmarshal(j, &marshal) + if err != nil { + return JWK{}, fmt.Errorf("failed to unmarshal JSON Web Key: %w", err) + } + return NewJWKFromMarshal(marshal, marshalOptions, validateOptions) +} + +// NewJWKFromMarshal transforms a JWKMarshal into a JWK. +func NewJWKFromMarshal(marshal JWKMarshal, marshalOptions JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) { + j, err := keyUnmarshal(marshal, marshalOptions, validateOptions) + if err != nil { + return JWK{}, fmt.Errorf("failed to unmarshal JSON Web Key: %w", err) + } + err = j.Validate() + if err != nil { + return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err) + } + return j, nil +} + +// NewJWKFromX5C uses the X.509 X5C information in the options to create a JWK. +func NewJWKFromX5C(options JWKOptions) (JWK, error) { + if len(options.X509.X5C) == 0 { + return JWK{}, fmt.Errorf("%w: no X.509 certificates provided", ErrOptions) + } + cert := options.X509.X5C[0] + marshal, err := keyMarshal(cert.PublicKey, options) + if err != nil { + return JWK{}, fmt.Errorf("failed to marshal JSON Web Key: %w", err) + } + + if cert.PublicKeyAlgorithm == x509.Ed25519 { + if options.Metadata.ALG != "" && options.Metadata.ALG != AlgEdDSA { + return JWK{}, fmt.Errorf("%w: ALG in metadata does not match ALG in X.509 certificate", errors.Join(ErrOptions, ErrX509Mismatch)) + } + options.Metadata.ALG = AlgEdDSA + } + + j := JWK{ + key: options.X509.X5C[0].PublicKey, + marshal: marshal, + options: options, + } + err = j.Validate() + if err != nil { + return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err) + } + return j, nil +} + +// NewJWKFromX5U uses the X.509 X5U information in the options to create a JWK. +func NewJWKFromX5U(options JWKOptions) (JWK, error) { + if options.X509.X5U == "" { + return JWK{}, fmt.Errorf("%w: no X.509 URI provided", ErrOptions) + } + u, err := url.ParseRequestURI(options.X509.X5U) + if err != nil { + return JWK{}, fmt.Errorf("failed to parse X5U URI: %w", errors.Join(ErrOptions, err)) + } + if !options.Validate.SkipX5UScheme && u.Scheme != "https" { + return JWK{}, fmt.Errorf("%w: X5U URI scheme must be https", errors.Join(ErrOptions)) + } + get := options.Validate.GetX5U + if get == nil { + get = DefaultGetX5U + } + certs, err := get(u) + if err != nil { + return JWK{}, fmt.Errorf("failed to get X5U URI: %w", err) + } + options.X509.X5C = certs + jwk, err := NewJWKFromX5C(options) + if err != nil { + return JWK{}, fmt.Errorf("failed to create JWK from fetched X5U assets: %w", err) + } + return jwk, nil +} + +// Key returns the public or private cryptographic key associated with the JWK. +func (j JWK) Key() any { + return j.key +} + +// Marshal returns Go type that can be marshalled into JSON. +func (j JWK) Marshal() JWKMarshal { + return j.marshal +} + +// X509 returns the X.509 certificate information for the JWK. +func (j JWK) X509() JWKX509Options { + return j.options.X509 +} + +// Validate validates the JWK. The JWK is automatically validated when created from a function in this package. +func (j JWK) Validate() error { + if j.options.Validate.SkipAll { + return nil + } + if !j.marshal.KTY.IANARegistered() { + return fmt.Errorf("%w: invalid or unsupported key type %q", ErrJWKValidation, j.marshal.KTY) + } + + if !j.options.Validate.SkipUse && !j.marshal.USE.IANARegistered() { + return fmt.Errorf("%w: invalid or unsupported key use %q", ErrJWKValidation, j.marshal.USE) + } + + if !j.options.Validate.SkipKeyOps { + for _, o := range j.marshal.KEYOPS { + if !o.IANARegistered() { + return fmt.Errorf("%w: invalid or unsupported key_opt %q", ErrJWKValidation, o) + } + } + } + + if !j.options.Validate.SkipMetadata { + if j.marshal.ALG != j.options.Metadata.ALG { + return fmt.Errorf("%w: ALG in marshal does not match ALG in options", errors.Join(ErrJWKValidation, ErrOptions)) + } + if j.marshal.KID != j.options.Metadata.KID { + return fmt.Errorf("%w: KID in marshal does not match KID in options", errors.Join(ErrJWKValidation, ErrOptions)) + } + if !slices.Equal(j.marshal.KEYOPS, j.options.Metadata.KEYOPS) { + return fmt.Errorf("%w: KEYOPS in marshal does not match KEYOPS in options", errors.Join(ErrJWKValidation, ErrOptions)) + } + if j.marshal.USE != j.options.Metadata.USE { + return fmt.Errorf("%w: USE in marshal does not match USE in options", errors.Join(ErrJWKValidation, ErrOptions)) + } + } + + if len(j.options.X509.X5C) > 0 { + cert := j.options.X509.X5C[0] + i := cert.PublicKey + switch k := j.key.(type) { + // ECDH keys are not used to sign certificates. + case *ecdsa.PublicKey: + pub, ok := i.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("%w: Golang key is type *ecdsa.Public but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i) + } + if !k.Equal(pub) { + return fmt.Errorf("%w: Golang *ecdsa.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch)) + } + case ed25519.PublicKey: + pub, ok := i.(ed25519.PublicKey) + if !ok { + return fmt.Errorf("%w: Golang key is type ed25519.PublicKey but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i) + } + if !bytes.Equal(k, pub) { + return fmt.Errorf("%w: Golang ed25519.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch)) + } + case *rsa.PublicKey: + pub, ok := i.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("%w: Golang key is type *rsa.PublicKey but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i) + } + if !k.Equal(pub) { + return fmt.Errorf("%w: Golang *rsa.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch)) + } + default: + return fmt.Errorf("%w: Golang key is type %T, which is not supported, so it cannot be compared to given X.509 certificates", errors.Join(ErrJWKValidation, ErrUnsupportedKey, ErrX509Mismatch), j.key) + } + if cert.PublicKeyAlgorithm == x509.Ed25519 { + if j.marshal.ALG != AlgEdDSA { + return fmt.Errorf("%w: ALG in marshal does not match ALG in X.509 certificate", errors.Join(ErrJWKValidation, ErrX509Mismatch)) + } + } + if j.options.Validate.CheckX509ValidTime { + now := time.Now() + if now.Before(cert.NotBefore) { + return fmt.Errorf("%w: X.509 certificate is not yet valid", ErrJWKValidation) + } + if now.After(cert.NotAfter) { + return fmt.Errorf("%w: X.509 certificate is expired", ErrJWKValidation) + } + } + } + + marshalled, err := keyMarshal(j.key, j.options) + if err != nil { + return fmt.Errorf("failed to marshal JSON Web Key: %w", errors.Join(ErrJWKValidation, err)) + } + + // Remove automatically computed thumbprints if not set in given JWK. + if j.marshal.X5T == "" { + marshalled.X5T = "" + } + if j.marshal.X5TS256 == "" { + marshalled.X5TS256 = "" + } + + canComputeThumbprint := len(j.marshal.X5C) > 0 + if j.marshal.X5T != marshalled.X5T && canComputeThumbprint { + return fmt.Errorf("%w: X5T in marshal does not match X5T in marshalled", ErrJWKValidation) + } + if j.marshal.X5TS256 != marshalled.X5TS256 && canComputeThumbprint { + return fmt.Errorf("%w: X5TS256 in marshal does not match X5TS256 in marshalled", ErrJWKValidation) + } + if j.marshal.CRV != marshalled.CRV { + return fmt.Errorf("%w: CRV in marshal does not match CRV in marshalled", ErrJWKValidation) + } + switch j.marshal.KTY { + case KtyEC: + err = cmpBase64Int(j.marshal.X, marshalled.X, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: X in marshal does not match X in marshalled", errors.Join(ErrJWKValidation, err)) + } + err = cmpBase64Int(j.marshal.Y, marshalled.Y, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: Y in marshal does not match Y in marshalled", errors.Join(ErrJWKValidation, err)) + } + err = cmpBase64Int(j.marshal.D, marshalled.D, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: D in marshal does not match D in marshalled", errors.Join(ErrJWKValidation, err)) + } + case KtyOKP: + if j.marshal.X != marshalled.X { + return fmt.Errorf("%w: X in marshal does not match X in marshalled", ErrJWKValidation) + } + if j.marshal.D != marshalled.D { + return fmt.Errorf("%w: D in marshal does not match D in marshalled", ErrJWKValidation) + } + case KtyRSA: + err = cmpBase64Int(j.marshal.D, marshalled.D, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: D in marshal does not match D in marshalled", errors.Join(ErrJWKValidation, err)) + } + err = cmpBase64Int(j.marshal.N, marshalled.N, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: N in marshal does not match N in marshalled", errors.Join(ErrJWKValidation, err)) + } + err = cmpBase64Int(j.marshal.E, marshalled.E, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: E in marshal does not match E in marshalled", errors.Join(ErrJWKValidation, err)) + } + err = cmpBase64Int(j.marshal.P, marshalled.P, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: P in marshal does not match P in marshalled", errors.Join(ErrJWKValidation, err)) + } + err = cmpBase64Int(j.marshal.Q, marshalled.Q, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: Q in marshal does not match Q in marshalled", errors.Join(ErrJWKValidation, err)) + } + err = cmpBase64Int(j.marshal.DP, marshalled.DP, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: DP in marshal does not match DP in marshalled", errors.Join(ErrJWKValidation, err)) + } + err = cmpBase64Int(j.marshal.DQ, marshalled.DQ, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: DQ in marshal does not match DQ in marshalled", errors.Join(ErrJWKValidation, err)) + } + if len(j.marshal.OTH) != len(marshalled.OTH) { + return fmt.Errorf("%w: OTH in marshal does not match OTH in marshalled", ErrJWKValidation) + } + for i, o := range j.marshal.OTH { + err = cmpBase64Int(o.R, marshalled.OTH[i].R, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: OTH index %d in marshal does not match OTH in marshalled", errors.Join(ErrJWKValidation, err), i) + } + err = cmpBase64Int(o.D, marshalled.OTH[i].D, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: OTH index %d in marshal does not match OTH in marshalled", errors.Join(ErrJWKValidation, err), i) + } + err = cmpBase64Int(o.T, marshalled.OTH[i].T, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: OTH index %d in marshal does not match OTH in marshalled", errors.Join(ErrJWKValidation, err), i) + } + } + case KtyOct: + err = cmpBase64Int(j.marshal.K, marshalled.K, j.options.Validate.StrictPadding) + if err != nil { + return fmt.Errorf("%w: K in marshal does not match K in marshalled", errors.Join(ErrJWKValidation, err)) + } + default: + return fmt.Errorf("%w: invalid or unsupported key type %q", ErrJWKValidation, j.marshal.KTY) + } + + // Saved for last because it may involve a network request. + if j.marshal.X5U != "" || j.options.X509.X5U != "" { + if j.marshal.X5U != j.options.X509.X5U { + return fmt.Errorf("%w: X5U in marshal does not match X5U in options", errors.Join(ErrJWKValidation, ErrOptions)) + } + u, err := url.ParseRequestURI(j.marshal.X5U) + if err != nil { + return fmt.Errorf("failed to parse X5U URI: %w", errors.Join(ErrJWKValidation, ErrOptions, err)) + } + if !j.options.Validate.SkipX5UScheme && u.Scheme != "https" { + return fmt.Errorf("%w: X5U URI scheme must be https", errors.Join(ErrJWKValidation, ErrOptions)) + } + if j.options.Validate.GetX5U != nil { + certs, err := j.options.Validate.GetX5U(u) + if err != nil { + return fmt.Errorf("failed to get X5U URI: %w", errors.Join(ErrJWKValidation, ErrOptions, err)) + } + if len(certs) == 0 { + return fmt.Errorf("%w: X5U URI did not return any certificates", errors.Join(ErrJWKValidation, ErrOptions)) + } + larger := certs + smaller := j.options.X509.X5C + if len(j.options.X509.X5C) > len(certs) { + larger = j.options.X509.X5C + smaller = certs + } + for i, c := range smaller { + if !c.Equal(larger[i]) { + return fmt.Errorf("%w: the X5C and X5U (remote resource) parameters are not a full or partial match", errors.Join(ErrJWKValidation, ErrOptions)) + } + } + } + } + + return nil +} + +// DefaultGetX5U is the default implementation of the GetX5U field for JWKValidateOptions. +func DefaultGetX5U(u *url.URL) ([]*x509.Certificate, error) { + timeout := time.Minute + ctx, cancel := context.WithTimeoutCause(context.Background(), timeout, fmt.Errorf("%w: timeout of %s reached", ErrGetX5U, timeout.String())) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create X5U request: %w", errors.Join(ErrGetX5U, err)) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to do X5U request: %w", errors.Join(ErrGetX5U, err)) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: X5U request returned status code %d", ErrGetX5U, resp.StatusCode) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read X5U response body: %w", errors.Join(ErrGetX5U, err)) + } + certs, err := LoadCertificates(b) + if err != nil { + return nil, fmt.Errorf("failed to parse X5U response body: %w", errors.Join(ErrGetX5U, err)) + } + return certs, nil +} + +func cmpBase64Int(first, second string, strictPadding bool) error { + if first == second { + return nil + } + b, err := base64.RawURLEncoding.DecodeString(first) + if err != nil { + return fmt.Errorf("failed to decode Base64 raw URL decode first string: %w", err) + } + fLen := len(b) + f := new(big.Int).SetBytes(b) + b, err = base64.RawURLEncoding.DecodeString(second) + if err != nil { + return fmt.Errorf("failed to decode Base64 raw URL decode second string: %w", err) + } + sLen := len(b) + s := new(big.Int).SetBytes(b) + if f.Cmp(s) != 0 { + return fmt.Errorf("%w: the parsed integers do not match", ErrJWKValidation) + } + if strictPadding && fLen != sLen { + return fmt.Errorf("%w: the Base64 raw URL inputs do not have matching padding", errors.Join(ErrJWKValidation, ErrPadding)) + } + return nil +} diff --git a/vendor/github.com/MicahParks/jwkset/marshal.go b/vendor/github.com/MicahParks/jwkset/marshal.go new file mode 100644 index 000000000..c604e22df --- /dev/null +++ b/vendor/github.com/MicahParks/jwkset/marshal.go @@ -0,0 +1,511 @@ +package jwkset + +import ( + "context" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "math" + "math/big" + "slices" + "strings" +) + +var ( + // ErrGetX5U indicates there was an error getting the X5U remote resource. + ErrGetX5U = errors.New("failed to get X5U via given URI") + // ErrJWKValidation indicates that a JWK failed to validate. + ErrJWKValidation = errors.New("failed to validate JWK") + // ErrKeyUnmarshalParameter indicates that a JWK's attributes are invalid and cannot be unmarshaled. + ErrKeyUnmarshalParameter = errors.New("unable to unmarshal JWK due to invalid attributes") + // ErrOptions indicates that the given options caused an error. + ErrOptions = errors.New("the given options caused an error") + // ErrUnsupportedKey indicates a key is not supported. + ErrUnsupportedKey = errors.New("unsupported key") + // ErrX509Mismatch indicates that the X.509 certificate does not match the key. + ErrX509Mismatch = errors.New("the X.509 certificate does not match Golang key type") +) + +// OtherPrimes is for RSA private keys that have more than 2 primes. +// https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7 +type OtherPrimes struct { + R string `json:"r,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7.1 + D string `json:"d,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7.2 + T string `json:"t,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7.3 +} + +// JWKMarshal is used to marshal or unmarshal a JSON Web Key. +// https://www.rfc-editor.org/rfc/rfc7517 +// https://www.rfc-editor.org/rfc/rfc7518 +// https://www.rfc-editor.org/rfc/rfc8037 +// +// You can find the full list at https://www.iana.org/assignments/jose/jose.xhtml under "JSON Web Key Parameters". +type JWKMarshal struct { + KTY KTY `json:"kty,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.1 + USE USE `json:"use,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.2 + KEYOPS []KEYOPS `json:"key_ops,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.3 + ALG ALG `json:"alg,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.4 and https://www.rfc-editor.org/rfc/rfc7518#section-4.1 + KID string `json:"kid,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.5 + X5U string `json:"x5u,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.6 + X5C []string `json:"x5c,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.7 + X5T string `json:"x5t,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.8 + X5TS256 string `json:"x5t#S256,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.9 + CRV CRV `json:"crv,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.1 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2 + X string `json:"x,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.2 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2 + Y string `json:"y,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.3 + D string `json:"d,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.1 and https://www.rfc-editor.org/rfc/rfc7518#section-6.2.2.1 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2 + N string `json:"n,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.1.1 + E string `json:"e,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.1.2 + P string `json:"p,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.2 + Q string `json:"q,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.3 + DP string `json:"dp,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.4 + DQ string `json:"dq,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.5 + QI string `json:"qi,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.6 + OTH []OtherPrimes `json:"oth,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7 + K string `json:"k,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.4.1 +} + +// JWKSMarshal is used to marshal or unmarshal a JSON Web Key Set. +type JWKSMarshal struct { + Keys []JWKMarshal `json:"keys"` +} + +// JWKSlice converts the JWKSMarshal to a []JWK. +func (j JWKSMarshal) JWKSlice() ([]JWK, error) { + slice := make([]JWK, len(j.Keys)) + for i, key := range j.Keys { + marshalOptions := JWKMarshalOptions{ + Private: true, + } + jwk, err := keyUnmarshal(key, marshalOptions, JWKValidateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal JWK: %w", err) + } + slice[i] = jwk + } + return slice, nil +} + +// ToStorage converts the JWKSMarshal to a Storage. +func (j JWKSMarshal) ToStorage() (Storage, error) { + m := NewMemoryStorage() + jwks, err := j.JWKSlice() + if err != nil { + return nil, fmt.Errorf("failed to create a slice of JWK from JWKSMarshal: %w", err) + } + for _, jwk := range jwks { + err = m.KeyWrite(context.Background(), jwk) + if err != nil { + return nil, fmt.Errorf("failed to write JWK to storage: %w", err) + } + } + return m, nil +} + +func keyMarshal(key any, options JWKOptions) (JWKMarshal, error) { + m := JWKMarshal{} + m.ALG = options.Metadata.ALG + switch key := key.(type) { + case *ecdh.PublicKey: + pub := key.Bytes() + m.CRV = CrvX25519 + m.X = base64.RawURLEncoding.EncodeToString(pub) + m.KTY = KtyOKP + case *ecdh.PrivateKey: + pub := key.PublicKey().Bytes() + m.CRV = CrvX25519 + m.X = base64.RawURLEncoding.EncodeToString(pub) + m.KTY = KtyOKP + if options.Marshal.Private { + priv := key.Bytes() + m.D = base64.RawURLEncoding.EncodeToString(priv) + } + case *ecdsa.PrivateKey: + pub := key.PublicKey + m.CRV = CRV(pub.Curve.Params().Name) + l := uint(pub.Curve.Params().BitSize / 8) + if pub.Curve.Params().BitSize%8 != 0 { + l++ + } + m.X = bigIntToBase64RawURL(pub.X, l) + m.Y = bigIntToBase64RawURL(pub.Y, l) + m.KTY = KtyEC + if options.Marshal.Private { + params := key.Curve.Params() + f, _ := params.N.Float64() + l = uint(math.Ceil(math.Log2(f) / 8)) + m.D = bigIntToBase64RawURL(key.D, l) + } + case *ecdsa.PublicKey: + l := uint(key.Curve.Params().BitSize / 8) + if key.Curve.Params().BitSize%8 != 0 { + l++ + } + m.CRV = CRV(key.Curve.Params().Name) + m.X = bigIntToBase64RawURL(key.X, l) + m.Y = bigIntToBase64RawURL(key.Y, l) + m.KTY = KtyEC + case ed25519.PrivateKey: + pub := key.Public().(ed25519.PublicKey) + m.ALG = AlgEdDSA + m.CRV = CrvEd25519 + m.X = base64.RawURLEncoding.EncodeToString(pub) + m.KTY = KtyOKP + if options.Marshal.Private { + m.D = base64.RawURLEncoding.EncodeToString(key[:32]) + } + case ed25519.PublicKey: + m.ALG = AlgEdDSA + m.CRV = CrvEd25519 + m.X = base64.RawURLEncoding.EncodeToString(key) + m.KTY = KtyOKP + case *rsa.PrivateKey: + pub := key.PublicKey + m.E = bigIntToBase64RawURL(big.NewInt(int64(pub.E)), 0) + m.N = bigIntToBase64RawURL(pub.N, 0) + m.KTY = KtyRSA + if options.Marshal.Private { + m.D = bigIntToBase64RawURL(key.D, 0) + m.P = bigIntToBase64RawURL(key.Primes[0], 0) + m.Q = bigIntToBase64RawURL(key.Primes[1], 0) + m.DP = bigIntToBase64RawURL(key.Precomputed.Dp, 0) + m.DQ = bigIntToBase64RawURL(key.Precomputed.Dq, 0) + m.QI = bigIntToBase64RawURL(key.Precomputed.Qinv, 0) + if len(key.Precomputed.CRTValues) > 0 { + m.OTH = make([]OtherPrimes, len(key.Precomputed.CRTValues)) + for i := 0; i < len(key.Precomputed.CRTValues); i++ { + m.OTH[i] = OtherPrimes{ + D: bigIntToBase64RawURL(key.Precomputed.CRTValues[i].Exp, 0), + T: bigIntToBase64RawURL(key.Precomputed.CRTValues[i].Coeff, 0), + R: bigIntToBase64RawURL(key.Primes[i+2], 0), + } + } + } + } + case *rsa.PublicKey: + m.E = bigIntToBase64RawURL(big.NewInt(int64(key.E)), 0) + m.N = bigIntToBase64RawURL(key.N, 0) + m.KTY = KtyRSA + case []byte: + if options.Marshal.Private { + m.KTY = KtyOct + m.K = base64.RawURLEncoding.EncodeToString(key) + } else { + return JWKMarshal{}, fmt.Errorf("%w: incorrect options to marshal symmetric key (oct)", ErrOptions) + } + default: + return JWKMarshal{}, fmt.Errorf("%w: %T", ErrUnsupportedKey, key) + } + haveX5C := len(options.X509.X5C) > 0 + if haveX5C { + for i, cert := range options.X509.X5C { + m.X5C = append(m.X5C, base64.StdEncoding.EncodeToString(cert.Raw)) + if i == 0 { + h1 := sha1.Sum(cert.Raw) + m.X5T = base64.RawURLEncoding.EncodeToString(h1[:]) + h256 := sha256.Sum256(cert.Raw) + m.X5TS256 = base64.RawURLEncoding.EncodeToString(h256[:]) + } + } + } + m.KID = options.Metadata.KID + m.KEYOPS = options.Metadata.KEYOPS + m.USE = options.Metadata.USE + m.X5U = options.X509.X5U + return m, nil +} + +func keyUnmarshal(marshal JWKMarshal, options JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) { + marshalCopy := JWKMarshal{} + var key any + switch marshal.KTY { + case KtyEC: + if marshal.CRV == "" || marshal.X == "" || marshal.Y == "" { + return JWK{}, fmt.Errorf(`%w: %s requires parameters "crv", "x", and "y"`, ErrKeyUnmarshalParameter, KtyEC) + } + x, err := base64urlTrailingPadding(marshal.X) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "x": %w`, KtyEC, err) + } + y, err := base64urlTrailingPadding(marshal.Y) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "y": %w`, KtyEC, err) + } + publicKey := &ecdsa.PublicKey{ + X: new(big.Int).SetBytes(x), + Y: new(big.Int).SetBytes(y), + } + switch marshal.CRV { + case CrvP256: + publicKey.Curve = elliptic.P256() + case CrvP384: + publicKey.Curve = elliptic.P384() + case CrvP521: + publicKey.Curve = elliptic.P521() + default: + return JWK{}, fmt.Errorf("%w: unsupported curve type %q", ErrKeyUnmarshalParameter, marshal.CRV) + } + marshalCopy.CRV = marshal.CRV + marshalCopy.X = marshal.X + marshalCopy.Y = marshal.Y + if options.Private && marshal.D != "" { + d, err := base64urlTrailingPadding(marshal.D) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyEC, err) + } + privateKey := &ecdsa.PrivateKey{ + PublicKey: *publicKey, + D: new(big.Int).SetBytes(d), + } + key = privateKey + marshalCopy.D = marshal.D + } else { + key = publicKey + } + case KtyOKP: + if marshal.CRV == "" || marshal.X == "" { + return JWK{}, fmt.Errorf(`%w: %s requires parameters "crv" and "x"`, ErrKeyUnmarshalParameter, KtyOKP) + } + public, err := base64urlTrailingPadding(marshal.X) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "x": %w`, KtyOKP, err) + } + marshalCopy.CRV = marshal.CRV + marshalCopy.X = marshal.X + var private []byte + if options.Private && marshal.D != "" { + private, err = base64urlTrailingPadding(marshal.D) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyOKP, err) + } + } + switch marshal.CRV { + case CrvEd25519: + if len(public) != ed25519.PublicKeySize { + return JWK{}, fmt.Errorf("%w: %s key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, ed25519.PublicKeySize) + } + if options.Private && marshal.D != "" { + private = append(private, public...) + if len(private) != ed25519.PrivateKeySize { + return JWK{}, fmt.Errorf("%w: %s key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, ed25519.PrivateKeySize) + } + key = ed25519.PrivateKey(private) + marshalCopy.D = marshal.D + } else { + key = ed25519.PublicKey(public) + } + case CrvX25519: + const x25519PublicKeySize = 32 + if len(public) != x25519PublicKeySize { + return JWK{}, fmt.Errorf("%w: %s with curve %s public key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, CrvEd25519, x25519PublicKeySize) + } + if options.Private && marshal.D != "" { + const x25519PrivateKeySize = 32 + if len(private) != x25519PrivateKeySize { + return JWK{}, fmt.Errorf("%w: %s with curve %s private key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, CrvEd25519, x25519PrivateKeySize) + } + key, err = ecdh.X25519().NewPrivateKey(private) + if err != nil { + return JWK{}, fmt.Errorf("failed to create X25519 private key: %w", err) + } + marshalCopy.D = marshal.D + } else { + key, err = ecdh.X25519().NewPublicKey(public) + if err != nil { + return JWK{}, fmt.Errorf("failed to create X25519 public key: %w", err) + } + } + default: + return JWK{}, fmt.Errorf("%w: unsupported curve type %q", ErrKeyUnmarshalParameter, marshal.CRV) + } + case KtyRSA: + if marshal.N == "" || marshal.E == "" { + return JWK{}, fmt.Errorf(`%w: %s requires parameters "n" and "e"`, ErrKeyUnmarshalParameter, KtyRSA) + } + n, err := base64urlTrailingPadding(marshal.N) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "n": %w`, KtyRSA, err) + } + e, err := base64urlTrailingPadding(marshal.E) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "e": %w`, KtyRSA, err) + } + publicKey := rsa.PublicKey{ + N: new(big.Int).SetBytes(n), + E: int(new(big.Int).SetBytes(e).Uint64()), + } + marshalCopy.N = marshal.N + marshalCopy.E = marshal.E + if options.Private && marshal.D != "" && marshal.P != "" && marshal.Q != "" && marshal.DP != "" && marshal.DQ != "" && marshal.QI != "" { // TODO Only "d" is required, but if one of the others is present, they all must be. + d, err := base64urlTrailingPadding(marshal.D) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyRSA, err) + } + p, err := base64urlTrailingPadding(marshal.P) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "p": %w`, KtyRSA, err) + } + q, err := base64urlTrailingPadding(marshal.Q) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "q": %w`, KtyRSA, err) + } + dp, err := base64urlTrailingPadding(marshal.DP) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "dp": %w`, KtyRSA, err) + } + dq, err := base64urlTrailingPadding(marshal.DQ) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "dq": %w`, KtyRSA, err) + } + qi, err := base64urlTrailingPadding(marshal.QI) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "qi": %w`, KtyRSA, err) + } + var oth []rsa.CRTValue + primes := []*big.Int{ + new(big.Int).SetBytes(p), + new(big.Int).SetBytes(q), + } + if len(marshal.OTH) > 0 { + oth = make([]rsa.CRTValue, len(marshal.OTH)) + for i, otherPrimes := range marshal.OTH { + if otherPrimes.R == "" || otherPrimes.D == "" || otherPrimes.T == "" { + return JWK{}, fmt.Errorf(`%w: %s requires parameters "r", "d", and "t" for each "oth"`, ErrKeyUnmarshalParameter, KtyRSA) + } + othD, err := base64urlTrailingPadding(otherPrimes.D) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyRSA, err) + } + othT, err := base64urlTrailingPadding(otherPrimes.T) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "t": %w`, KtyRSA, err) + } + othR, err := base64urlTrailingPadding(otherPrimes.R) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "r": %w`, KtyRSA, err) + } + primes = append(primes, new(big.Int).SetBytes(othR)) + oth[i] = rsa.CRTValue{ + Exp: new(big.Int).SetBytes(othD), + Coeff: new(big.Int).SetBytes(othT), + R: new(big.Int).SetBytes(othR), + } + } + } + privateKey := &rsa.PrivateKey{ + PublicKey: publicKey, + D: new(big.Int).SetBytes(d), + Primes: primes, + Precomputed: rsa.PrecomputedValues{ + Dp: new(big.Int).SetBytes(dp), + Dq: new(big.Int).SetBytes(dq), + Qinv: new(big.Int).SetBytes(qi), + CRTValues: oth, + }, + } + err = privateKey.Validate() + if err != nil { + return JWK{}, fmt.Errorf(`failed to validate %s key: %w`, KtyRSA, err) + } + key = privateKey + marshalCopy.D = marshal.D + marshalCopy.P = marshal.P + marshalCopy.Q = marshal.Q + marshalCopy.DP = marshal.DP + marshalCopy.DQ = marshal.DQ + marshalCopy.QI = marshal.QI + marshalCopy.OTH = slices.Clone(marshal.OTH) + } else { + key = &publicKey + } + case KtyOct: + if options.Private { + if marshal.K == "" { + return JWK{}, fmt.Errorf(`%w: %s requires parameter "k"`, ErrKeyUnmarshalParameter, KtyOct) + } + k, err := base64urlTrailingPadding(marshal.K) + if err != nil { + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "k": %w`, KtyOct, err) + } + key = k + marshalCopy.K = marshal.K + } else { + return JWK{}, fmt.Errorf("%w: incorrect options to unmarshal symmetric key (%s)", ErrOptions, KtyOct) + } + default: + return JWK{}, fmt.Errorf("%w: %s (kty)", ErrUnsupportedKey, marshal.KTY) + } + marshalCopy.KTY = marshal.KTY + x5c := make([]*x509.Certificate, len(marshal.X5C)) + for i, cert := range marshal.X5C { + raw, err := base64.StdEncoding.DecodeString(cert) + if err != nil { + return JWK{}, fmt.Errorf("failed to Base64 decode X.509 certificate: %w", err) + } + x5c[i], err = x509.ParseCertificate(raw) + if err != nil { + return JWK{}, fmt.Errorf("failed to parse X.509 certificate: %w", err) + } + } + jwkX509 := JWKX509Options{ + X5C: x5c, + X5U: marshal.X5U, + } + marshalCopy.X5C = slices.Clone(marshal.X5C) + marshalCopy.X5T = marshal.X5T + marshalCopy.X5TS256 = marshal.X5TS256 + marshalCopy.X5U = marshal.X5U + metadata := JWKMetadataOptions{ + ALG: marshal.ALG, + KID: marshal.KID, + KEYOPS: slices.Clone(marshal.KEYOPS), + USE: marshal.USE, + } + marshalCopy.ALG = marshal.ALG + marshalCopy.KID = marshal.KID + marshalCopy.KEYOPS = slices.Clone(marshal.KEYOPS) + marshalCopy.USE = marshal.USE + opts := JWKOptions{ + Metadata: metadata, + Marshal: options, + Validate: validateOptions, + X509: jwkX509, + } + j := JWK{ + key: key, + marshal: marshalCopy, + options: opts, + } + return j, nil +} + +// base64urlTrailingPadding removes trailing padding before decoding a string from base64url. Some non-RFC compliant +// JWKS contain padding at the end values for base64url encoded public keys. +// +// Trailing padding is required to be removed from base64url encoded keys. +// RFC 7517 defines base64url the same as RFC 7515 Section 2: +// https://datatracker.ietf.org/doc/html/rfc7517#section-1.1 +// https://datatracker.ietf.org/doc/html/rfc7515#section-2 +func base64urlTrailingPadding(s string) ([]byte, error) { + s = strings.TrimRight(s, "=") + return base64.RawURLEncoding.DecodeString(s) +} + +func bigIntToBase64RawURL(i *big.Int, l uint) string { + var b []byte + if l != 0 { + b = make([]byte, l) + i.FillBytes(b) + } else { + b = i.Bytes() + } + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/vendor/github.com/MicahParks/jwkset/storage.go b/vendor/github.com/MicahParks/jwkset/storage.go new file mode 100644 index 000000000..e66efe9bd --- /dev/null +++ b/vendor/github.com/MicahParks/jwkset/storage.go @@ -0,0 +1,314 @@ +package jwkset + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "sync" + "time" +) + +var ( + // ErrKeyNotFound is returned by a Storage implementation when a key is not found. + ErrKeyNotFound = errors.New("key not found") + // ErrInvalidHTTPStatusCode is returned when the HTTP status code is invalid. + ErrInvalidHTTPStatusCode = errors.New("invalid HTTP status code") +) + +// Storage handles storage operations for a JWKSet. +type Storage interface { + // KeyDelete deletes a key from the storage. It will return ok as true if the key was present for deletion. + KeyDelete(ctx context.Context, keyID string) (ok bool, err error) + // KeyRead reads a key from the storage. If the key is not present, it returns ErrKeyNotFound. Any pointers returned + // should be considered read-only. + KeyRead(ctx context.Context, keyID string) (JWK, error) + // KeyReadAll reads a snapshot of all keys from storage. As with ReadKey, any pointers returned should be + // considered read-only. + KeyReadAll(ctx context.Context) ([]JWK, error) + // KeyWrite writes a key to the storage. If the key already exists, it will be overwritten. After writing a key, + // any pointers written should be considered owned by the underlying storage. + KeyWrite(ctx context.Context, jwk JWK) error + + // JSON creates the JSON representation of the JWKSet. + JSON(ctx context.Context) (json.RawMessage, error) + // JSONPublic creates the JSON representation of the public keys in JWKSet. + JSONPublic(ctx context.Context) (json.RawMessage, error) + // JSONPrivate creates the JSON representation of the JWKSet public and private key material. + JSONPrivate(ctx context.Context) (json.RawMessage, error) + // JSONWithOptions creates the JSON representation of the JWKSet with the given options. These options override whatever + // options are set on the individual JWKs. + JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error) + // Marshal transforms the JWK Set's current state into a Go type that can be marshaled into JSON. + Marshal(ctx context.Context) (JWKSMarshal, error) + // MarshalWithOptions transforms the JWK Set's current state into a Go type that can be marshaled into JSON with the + // given options. These options override whatever options are set on the individual JWKs. + MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error) +} + +var _ Storage = &MemoryJWKSet{} + +type MemoryJWKSet struct { + set []JWK + mux sync.RWMutex +} + +// NewMemoryStorage creates a new in-memory Storage implementation. +func NewMemoryStorage() *MemoryJWKSet { + return &MemoryJWKSet{} +} + +func (m *MemoryJWKSet) KeyDelete(_ context.Context, keyID string) (ok bool, err error) { + m.mux.Lock() + defer m.mux.Unlock() + for i, jwk := range m.set { + if jwk.Marshal().KID == keyID { + m.set = append(m.set[:i], m.set[i+1:]...) + return true, nil + } + } + return ok, nil +} +func (m *MemoryJWKSet) KeyRead(_ context.Context, keyID string) (JWK, error) { + m.mux.RLock() + defer m.mux.RUnlock() + for _, jwk := range m.set { + if jwk.Marshal().KID == keyID { + return jwk, nil + } + } + return JWK{}, fmt.Errorf("%w: kid %q", ErrKeyNotFound, keyID) +} +func (m *MemoryJWKSet) KeyReadAll(_ context.Context) ([]JWK, error) { + m.mux.RLock() + defer m.mux.RUnlock() + return slices.Clone(m.set), nil +} +func (m *MemoryJWKSet) KeyWrite(_ context.Context, jwk JWK) error { + m.mux.Lock() + defer m.mux.Unlock() + m.set = append(m.set, jwk) + return nil +} +func (m *MemoryJWKSet) JSON(ctx context.Context) (json.RawMessage, error) { + jwks, err := m.Marshal(ctx) + if err != nil { + return nil, fmt.Errorf("failed to marshal JWK Set: %w", err) + } + return json.Marshal(jwks) +} +func (m *MemoryJWKSet) JSONPublic(ctx context.Context) (json.RawMessage, error) { + return m.JSONWithOptions(ctx, JWKMarshalOptions{}, JWKValidateOptions{}) +} +func (m *MemoryJWKSet) JSONPrivate(ctx context.Context) (json.RawMessage, error) { + marshalOptions := JWKMarshalOptions{ + Private: true, + } + return m.JSONWithOptions(ctx, marshalOptions, JWKValidateOptions{}) +} +func (m *MemoryJWKSet) JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error) { + jwks, err := m.MarshalWithOptions(ctx, marshalOptions, validationOptions) + if err != nil { + return nil, fmt.Errorf("failed to marshal JWK Set with options: %w", err) + } + return json.Marshal(jwks) +} +func (m *MemoryJWKSet) Marshal(ctx context.Context) (JWKSMarshal, error) { + keys, err := m.KeyReadAll(ctx) + if err != nil { + return JWKSMarshal{}, fmt.Errorf("failed to read snapshot of all keys from storage: %w", err) + } + jwks := JWKSMarshal{} + for _, key := range keys { + jwks.Keys = append(jwks.Keys, key.Marshal()) + } + return jwks, nil +} +func (m *MemoryJWKSet) MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error) { + jwks := JWKSMarshal{} + + keys, err := m.KeyReadAll(ctx) + if err != nil { + return JWKSMarshal{}, fmt.Errorf("failed to read snapshot of all keys from storage: %w", err) + } + + for _, key := range keys { + options := key.options + options.Marshal = marshalOptions + options.Validate = validationOptions + marshal, err := keyMarshal(key.Key(), options) + if err != nil { + if errors.Is(err, ErrOptions) { + continue + } + return JWKSMarshal{}, fmt.Errorf("failed to marshal key: %w", err) + } + jwks.Keys = append(jwks.Keys, marshal) + } + + return jwks, nil +} + +// HTTPClientStorageOptions are used to configure the behavior of NewStorageFromHTTP. +type HTTPClientStorageOptions struct { + // Client is the HTTP client to use for requests. + // + // This defaults to http.DefaultClient. + Client *http.Client + + // Ctx is used when performing HTTP requests. It is also used to end the refresh goroutine when it's no longer + // needed. + // + // This defaults to context.Background(). + Ctx context.Context + + // HTTPExpectedStatus is the expected HTTP status code for the HTTP request. + // + // This defaults to http.StatusOK. + HTTPExpectedStatus int + + // HTTPMethod is the HTTP method to use for the HTTP request. + // + // This defaults to http.MethodGet. + HTTPMethod string + + // HTTPTimeout is the timeout for the HTTP request. When the Ctx option is also provided, this value is used for a + // child context. + // + // This defaults to time.Minute. + HTTPTimeout time.Duration + + // NoErrorReturnFirstHTTPReq will create the Storage without error if the first HTTP request fails. + NoErrorReturnFirstHTTPReq bool + + // RefreshErrorHandler is a function that consumes errors that happen during an HTTP refresh. This is only effectual + // if RefreshInterval is set. + // + // If NoErrorReturnFirstHTTPReq is set, this function will be called when if the first HTTP request fails. + RefreshErrorHandler func(ctx context.Context, err error) + + // RefreshInterval is the interval at which the HTTP URL is refreshed and the JWK Set is processed. This option will + // launch a "refresh goroutine" to refresh the remote HTTP resource at the given interval. + // + // Provide the Ctx option to end the goroutine when it's no longer needed. + RefreshInterval time.Duration + + // ValidateOptions are the options to use when validating the JWKs. + ValidateOptions JWKValidateOptions +} + +type httpStorage struct { + options HTTPClientStorageOptions + refresh func(ctx context.Context) error + Storage +} + +// NewStorageFromHTTP creates a new Storage implementation that processes a remote HTTP resource for a JWK Set. If +// the RefreshInterval option is not set, the remote HTTP resource will be requested and processed before returning. If +// the RefreshInterval option is set, a background goroutine will be launched to refresh the remote HTTP resource and +// not block the return of this function. +func NewStorageFromHTTP(remoteJWKSetURL string, options HTTPClientStorageOptions) (Storage, error) { + if options.Client == nil { + fmt.Println("***** USING DEFAULT HTTP CLIENT *****") + options.Client = http.DefaultClient + } else { + fmt.Println("***** USING PROVIDED HTTP CLIENT *****") + } + if options.Ctx == nil { + options.Ctx = context.Background() + } + if options.HTTPExpectedStatus == 0 { + options.HTTPExpectedStatus = http.StatusOK + } + if options.HTTPTimeout == 0 { + options.HTTPTimeout = time.Minute + } + if options.HTTPMethod == "" { + options.HTTPMethod = http.MethodGet + } + store := NewMemoryStorage() + _, err := url.ParseRequestURI(remoteJWKSetURL) + if err != nil { + return nil, fmt.Errorf("failed to parse given URL %q: %w", remoteJWKSetURL, err) + } + + refresh := func(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, options.HTTPMethod, remoteJWKSetURL, nil) + if err != nil { + return fmt.Errorf("failed to create HTTP request for JWK Set refresh: %w", err) + } + resp, err := options.Client.Do(req) + if err != nil { + return fmt.Errorf("failed to perform HTTP request for JWK Set refresh: %w", err) + } + //goland:noinspection GoUnhandledErrorResult + defer resp.Body.Close() + if resp.StatusCode != options.HTTPExpectedStatus { + return fmt.Errorf("%w: %d", ErrInvalidHTTPStatusCode, resp.StatusCode) + } + var jwks JWKSMarshal + err = json.NewDecoder(resp.Body).Decode(&jwks) + if err != nil { + return fmt.Errorf("failed to decode JWK Set response: %w", err) + } + store.mux.Lock() + defer store.mux.Unlock() + store.set = make([]JWK, len(jwks.Keys)) // Clear local cache in case of key revocation. + for i, marshal := range jwks.Keys { + marshalOptions := JWKMarshalOptions{ + Private: true, + } + jwk, err := NewJWKFromMarshal(marshal, marshalOptions, options.ValidateOptions) + if err != nil { + return fmt.Errorf("failed to create JWK from JWK Marshal: %w", err) + } + store.set[i] = jwk + } + return nil + } + + if options.RefreshInterval != 0 { + go func() { // Refresh goroutine. + ticker := time.NewTicker(options.RefreshInterval) + defer ticker.Stop() + for { + select { + case <-options.Ctx.Done(): + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(options.Ctx, options.HTTPTimeout) + err := refresh(ctx) + cancel() + if err != nil && options.RefreshErrorHandler != nil { + options.RefreshErrorHandler(ctx, err) + } + } + } + }() + } + + s := httpStorage{ + options: options, + refresh: refresh, + Storage: store, + } + + ctx, cancel := context.WithTimeout(options.Ctx, options.HTTPTimeout) + defer cancel() + err = refresh(ctx) + cancel() + if err != nil { + if options.NoErrorReturnFirstHTTPReq { + if options.RefreshErrorHandler != nil { + options.RefreshErrorHandler(ctx, err) + } + return s, nil + } + return nil, fmt.Errorf("failed to perform first HTTP request for JWK Set: %w", err) + } + + return s, nil +} diff --git a/vendor/github.com/MicahParks/jwkset/x509.go b/vendor/github.com/MicahParks/jwkset/x509.go new file mode 100644 index 000000000..b89a3a6ea --- /dev/null +++ b/vendor/github.com/MicahParks/jwkset/x509.go @@ -0,0 +1,125 @@ +package jwkset + +import ( + "crypto/ecdh" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" +) + +var ( + // ErrX509Infer is returned when the key type cannot be inferred from the PEM block type. + ErrX509Infer = errors.New("failed to infer X509 key type") +) + +// LoadCertificate loads an X509 certificate from a PEM block. +func LoadCertificate(pemBlock []byte) (*x509.Certificate, error) { + cert, err := x509.ParseCertificate(pemBlock) + if err != nil { + return nil, fmt.Errorf("failed to parse certificates: %w", err) + } + switch cert.PublicKey.(type) { + case *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey: + default: + return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, cert.PublicKey) + } + return cert, nil +} + +// LoadCertificates loads X509 certificates from raw PEM data. It can be useful in loading X5U remote resources. +func LoadCertificates(rawPEM []byte) ([]*x509.Certificate, error) { + b := make([]byte, 0) + for { + block, rest := pem.Decode(rawPEM) + if block == nil { + break + } + rawPEM = rest + if block.Type == "CERTIFICATE" { + b = append(b, block.Bytes...) + } + } + certs, err := x509.ParseCertificates(b) + if err != nil { + return nil, fmt.Errorf("failed to parse certificates: %w", err) + } + for _, cert := range certs { + switch cert.PublicKey.(type) { + case *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey: + default: + return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, cert.PublicKey) + } + } + return certs, nil +} + +// LoadX509KeyInfer loads an X509 key from a PEM block. +func LoadX509KeyInfer(pemBlock *pem.Block) (key any, err error) { + switch pemBlock.Type { + case "EC PRIVATE KEY": + key, err = loadECPrivate(pemBlock) + case "RSA PRIVATE KEY": + key, err = loadPKCS1Private(pemBlock) + case "RSA PUBLIC KEY": + key, err = loadPKCS1Public(pemBlock) + case "PRIVATE KEY": + key, err = loadPKCS8Private(pemBlock) + case "PUBLIC KEY": + key, err = loadPKIXPublic(pemBlock) + default: + return nil, ErrX509Infer + } + if err != nil { + return nil, fmt.Errorf("failed to load key from inferred format %q: %w", key, err) + } + return key, nil +} +func loadECPrivate(pemBlock *pem.Block) (priv *ecdsa.PrivateKey, err error) { + priv, err = x509.ParseECPrivateKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse EC private key: %w", err) + } + return priv, nil +} +func loadPKCS1Public(pemBlock *pem.Block) (pub *rsa.PublicKey, err error) { + pub, err = x509.ParsePKCS1PublicKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS1 public key: %w", err) + } + return pub, nil +} +func loadPKCS1Private(pemBlock *pem.Block) (priv *rsa.PrivateKey, err error) { + priv, err = x509.ParsePKCS1PrivateKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS1 private key: %w", err) + } + return priv, nil +} +func loadPKCS8Private(pemBlock *pem.Block) (priv any, err error) { + priv, err = x509.ParsePKCS8PrivateKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS8 private key: %w", err) + } + switch priv.(type) { + case *ecdh.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, *rsa.PrivateKey: + default: + return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, priv) + } + return priv, nil +} +func loadPKIXPublic(pemBlock *pem.Block) (pub any, err error) { + pub, err = x509.ParsePKIXPublicKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKIX public key: %w", err) + } + switch pub.(type) { + case *ecdh.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey: + default: + return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, pub) + } + return pub, nil +} diff --git a/vendor/github.com/MicahParks/jwkset/x509_gen.sh b/vendor/github.com/MicahParks/jwkset/x509_gen.sh new file mode 100644 index 000000000..79cc315c1 --- /dev/null +++ b/vendor/github.com/MicahParks/jwkset/x509_gen.sh @@ -0,0 +1,15 @@ +# OpenSSL 3.0.10 1 Aug 2023 (Library: OpenSSL 3.0.10 1 Aug 2023) +openssl req -newkey EC -pkeyopt ec_paramgen_curve:P-521 -noenc -keyout ec521.pem -x509 -out ec521.crt -subj "/C=US/ST=Virginia/L=Richmond/O=Micah Parks/OU=Self/CN=example.com" +openssl req -newkey ED25519 -noenc -keyout ed25519.pem -x509 -out ed25519.crt -subj "/C=US/ST=Virginia/L=Richmond/O=Micah Parks/OU=Self/CN=example.com" +openssl req -newkey RSA:4096 -noenc -keyout rsa4096.pem -x509 -out rsa4096.crt -subj "/C=US/ST=Virginia/L=Richmond/O=Micah Parks/OU=Self/CN=example.com" + +openssl pkey -in ec521.pem -pubout -out ec521pub.pem +openssl pkey -in ed25519.pem -pubout -out ed25519pub.pem +openssl pkey -in rsa4096.pem -pubout -out rsa4096pub.pem + +# For the "RSA PRIVATE KEY" (PKCS#1) and "EC PRIVATE KEY" (SEC1) formats, the PEM files are generated using the +# cmd/gen_pkcs1 and cmd/gen_ec Golang programs, respectively. + +openssl dsaparam -out dsaparam.pem 2048 +openssl gendsa -out dsa.pem dsaparam.pem +openssl dsa -in dsa.pem -pubout -out dsa_pub.pem diff --git a/vendor/github.com/MicahParks/keyfunc/v3/LICENSE b/vendor/github.com/MicahParks/keyfunc/v3/LICENSE new file mode 100644 index 000000000..06dd4f210 --- /dev/null +++ b/vendor/github.com/MicahParks/keyfunc/v3/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Micah Parks + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/MicahParks/keyfunc/v3/README.md b/vendor/github.com/MicahParks/keyfunc/v3/README.md new file mode 100644 index 000000000..e6e304dc3 --- /dev/null +++ b/vendor/github.com/MicahParks/keyfunc/v3/README.md @@ -0,0 +1,81 @@ +[![Go Reference](https://pkg.go.dev/badge/github.com/MicahParks/keyfunc/v3.svg)](https://pkg.go.dev/github.com/MicahParks/keyfunc/v3) + +# keyfunc + +The purpose of this package is to provide a +[`jwt.Keyfunc`](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#Keyfunc) for the +[github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) package using a JSON Web Key Set (JWK Set) for parsing +and verifying JSON Web Tokens (JWTs). + +It's common for an identity providers, particularly those +using [OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) +or [OpenID Connect](https://openid.net/developers/how-connect-works/), such +as [Keycloak](https://github.com/MicahParks/keyfunc/blob/master/examples/keycloak/main.go) +or [Amazon Cognito (AWS)](https://github.com/MicahParks/keyfunc/blob/master/examples/aws_cognito/main.go) to expose a +JWK Set via an HTTPS endpoint. This package has the ability to consume that JWK Set and produce a +[`jwt.Keyfunc`](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#Keyfunc). It is important that a JWK Set endpoint is +using HTTPS to ensure the keys are from the correct trusted source. + +## Basic usage + +For complete examples, please see the `examples` directory. + +```go +import "github.com/MicahParks/keyfunc/v3" +``` + +### Step 1: Create the `keyfunc.Keyfunc` + +The below example is for a remote HTTP resource. +See [`examples/json/main.go`](https://github.com/MicahParks/keyfunc/blob/master/examples/json/main.go) for a JSON +example. + +```go +// Create the keyfunc.Keyfunc. +k, err := keyfunc.NewDefaultCtx(ctx, []string{server.URL}) // Context is used to end the refresh goroutine. +if err != nil { + log.Fatalf("Failed to create a keyfunc.Keyfunc from the server's URL.\nError: %s", err) +} +``` + +When using the `keyfunc.NewDefault` function, the JWK Set will be automatically refreshed using +[`jwkset.NewDefaultHTTPClient`](https://pkg.go.dev/github.com/MicahParks/jwkset#NewHTTPClient). This does launch a " +refresh goroutine". If you want the ability to end this goroutine, use the `keyfunc.NewDefaultCtx` function. + +It is also possible to create a `keyfunc.Keyfunc` from given keys like HMAC shared secrets. See `examples/hmac/main.go`. + +### Step 2: Use the `keyfunc.Keyfunc` to parse and verify JWTs + +```go +// Parse the JWT. +parsed, err := jwt.Parse(signed, k.Keyfunc) +if err != nil { + log.Fatalf("Failed to parse the JWT.\nError: %s", err) +} +``` + +## Additional features + +This project's primary purpose is to provide a [`jwt.Keyfunc`](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#Keyfunc) +implementation for JWK Sets. + +Since version `3.X.X`, this project has become a thin wrapper +around [github.com/MicahParks/jwkset](https://github.com/MicahParks/jwkset). Newer versions contain a superset of +features available in versions `2.X.X` and earlier, but some of the deep customization has been moved to the `jwkset` +project. The intention behind this is to make `keyfunc` easier to use for most use cases. + +Access the [`jwkset.Storage`](https://pkg.go.dev/github.com/MicahParks/jwkset#Storage) from a `keyfunc.Keyfunc` via +the `.Storage()` method. Using the [github.com/MicahParks/jwkset](https://github.com/MicahParks/jwkset) package +provides the below features, and more: + +* An HTTP client that automatically updates one or more remote JWK Set resources. +* An automatic refresh of remote HTTP resources when an unknown key ID (`kid`) is encountered. +* X.509 URIs or embedded [certificate chains](https://pkg.go.dev/crypto/x509#Certificate), when a JWK contains them. +* Support for private asymmetric keys. +* Specified key operations and usage. + +## Related projects + +### [`github.com/MicahParks/jwkset`](https://github.com/MicahParks/jwkset): + +A JWK Set implementation. The `keyfunc` project is a wrapper around this project. diff --git a/vendor/github.com/MicahParks/keyfunc/v3/keyfunc.go b/vendor/github.com/MicahParks/keyfunc/v3/keyfunc.go new file mode 100644 index 000000000..1725731d6 --- /dev/null +++ b/vendor/github.com/MicahParks/keyfunc/v3/keyfunc.go @@ -0,0 +1,177 @@ +package keyfunc + +import ( + "context" + "crypto" + "encoding/json" + "errors" + "fmt" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" +) + +var ( + // ErrKeyfunc is returned when a keyfunc error occurs. + ErrKeyfunc = errors.New("failed keyfunc") +) + +// Keyfunc is meant to be used as the jwt.Keyfunc function for github.com/golang-jwt/jwt/v5. It uses +// github.com/MicahParks/jwkset as a JWK Set storage. +type Keyfunc interface { + Keyfunc(token *jwt.Token) (any, error) + KeyfuncCtx(ctx context.Context) jwt.Keyfunc + Storage() jwkset.Storage +} + +// Options are used to create a new Keyfunc. +type Options struct { + Ctx context.Context + Storage jwkset.Storage + UseWhitelist []jwkset.USE +} + +type keyfunc struct { + ctx context.Context + storage jwkset.Storage + useWhitelist []jwkset.USE +} + +// New creates a new Keyfunc. +func New(options Options) (Keyfunc, error) { + ctx := options.Ctx + if ctx == nil { + ctx = context.Background() + } + if options.Storage == nil { + return nil, fmt.Errorf("%w: no JWK Set storage given in options", ErrKeyfunc) + } + k := keyfunc{ + ctx: ctx, + storage: options.Storage, + useWhitelist: options.UseWhitelist, + } + return k, nil +} + +// NewDefault creates a new Keyfunc with a default JWK Set storage and options. +// +// This will launch "refresh goroutine" to automatically refresh the remote HTTP resources. +func NewDefault(urls []string) (Keyfunc, error) { + return NewDefaultCtx(context.Background(), urls) +} + +// NewDefaultCtx creates a new Keyfunc with a default JWK Set storage and options. The context is used to end the +// "refresh goroutine". +// +// This will launch "refresh goroutine" to automatically refresh the remote HTTP resources. +func NewDefaultCtx(ctx context.Context, urls []string) (Keyfunc, error) { + client, err := jwkset.NewDefaultHTTPClientCtx(ctx, urls) + if err != nil { + return nil, err + } + options := Options{ + Storage: client, + } + return New(options) +} + +// NewJWKJSON creates a new Keyfunc from raw JWK JSON. +func NewJWKJSON(raw json.RawMessage) (Keyfunc, error) { + marshalOptions := jwkset.JWKMarshalOptions{ + Private: true, + } + jwk, err := jwkset.NewJWKFromRawJSON(raw, marshalOptions, jwkset.JWKValidateOptions{}) + if err != nil { + return nil, fmt.Errorf("%w: could not create JWK from raw JSON", errors.Join(err, ErrKeyfunc)) + } + store := jwkset.NewMemoryStorage() + err = store.KeyWrite(context.Background(), jwk) + if err != nil { + return nil, fmt.Errorf("%w: could not write JWK to storage", errors.Join(err, ErrKeyfunc)) + } + options := Options{ + Storage: store, + } + return New(options) +} + +// NewJWKSetJSON creates a new Keyfunc from raw JWK Set JSON. +func NewJWKSetJSON(raw json.RawMessage) (Keyfunc, error) { + var jwks jwkset.JWKSMarshal + err := json.Unmarshal(raw, &jwks) + if err != nil { + return nil, fmt.Errorf("%w: could not unmarshal raw JWK Set JSON", errors.Join(err, ErrKeyfunc)) + } + store, err := jwks.ToStorage() + if err != nil { + return nil, fmt.Errorf("%w: could not create JWK Set storage", errors.Join(err, ErrKeyfunc)) + } + options := Options{ + Storage: store, + } + return New(options) +} + +func (k keyfunc) KeyfuncCtx(ctx context.Context) jwt.Keyfunc { + return func(token *jwt.Token) (any, error) { + kidInter, ok := token.Header[jwkset.HeaderKID] + if !ok { + return nil, fmt.Errorf("%w: could not find kid in JWT header", ErrKeyfunc) + } + kid, ok := kidInter.(string) + if !ok { + return nil, fmt.Errorf("%w: could not convert kid in JWT header to string", ErrKeyfunc) + } + algInter, ok := token.Header["alg"] + if !ok { + return nil, fmt.Errorf("%w: could not find alg in JWT header", ErrKeyfunc) + } + alg, ok := algInter.(string) + if !ok { + // For test coverage purposes, this should be impossible to reach because the JWT package rejects a token + // without an alg parameter in the header before calling jwt.Keyfunc. + return nil, fmt.Errorf(`%w: the JWT header did not contain the "alg" parameter, which is required by RFC 7515 section 4.1.1`, ErrKeyfunc) + } + + jwk, err := k.storage.KeyRead(ctx, kid) + if err != nil { + return nil, fmt.Errorf("%w: could not read JWK from storage", errors.Join(err, ErrKeyfunc)) + } + + if a := jwk.Marshal().ALG.String(); a != "" && a != alg { + return nil, fmt.Errorf(`%w: JWK "alg" parameter value %q does not match token "alg" parameter value %q`, ErrKeyfunc, a, alg) + } + if len(k.useWhitelist) > 0 { + found := false + for _, u := range k.useWhitelist { + if jwk.Marshal().USE == u { + found = true + break + } + } + if !found { + return nil, fmt.Errorf(`%w: JWK "use" parameter value %q is not in whitelist`, ErrKeyfunc, jwk.Marshal().USE) + } + } + + type publicKeyer interface { + Public() crypto.PublicKey + } + + key := jwk.Key() + pk, ok := key.(publicKeyer) + if ok { + key = pk.Public() + } + + return key, nil + } +} +func (k keyfunc) Keyfunc(token *jwt.Token) (any, error) { + keyF := k.KeyfuncCtx(k.ctx) + return keyF(token) +} +func (k keyfunc) Storage() jwkset.Storage { + return k.storage +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 9c4068de1..129388b5c 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -37,9 +37,15 @@ github.com/Masterminds/semver/v3 # github.com/Masterminds/sprig v2.22.0+incompatible ## explicit github.com/Masterminds/sprig +# github.com/MicahParks/jwkset v0.8.0 +## explicit; go 1.21 +github.com/MicahParks/jwkset # github.com/MicahParks/keyfunc/v2 v2.1.0 ## explicit; go 1.18 github.com/MicahParks/keyfunc/v2 +# github.com/MicahParks/keyfunc/v3 v3.3.11 +## explicit; go 1.21 +github.com/MicahParks/keyfunc/v3 # github.com/Microsoft/go-winio v0.6.2 ## explicit; go 1.21 github.com/Microsoft/go-winio