From 7b233436a2e979305f839bfe547e0234ad52a955 Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Tue, 7 Jan 2025 16:22:22 +0100 Subject: [PATCH 01/12] refactor(router): redesign authentication logic for JWKS --- router-tests/authentication_test.go | 20 ++-- router-tests/modules/set_scopes_test.go | 8 +- router-tests/utils.go | 9 +- router-tests/websocket_test.go | 18 ++- router/cmd/instance.go | 113 +++++++++++------- .../http_header_authenticator.go | 2 - .../pkg/authentication/jwks_token_decoder.go | 58 +++++---- router/pkg/config/config.go | 26 ++-- router/pkg/config/config.schema.json | 72 +++++++---- router/pkg/config/fixtures/full.yaml | 24 ++-- .../pkg/config/testdata/config_defaults.json | 7 +- router/pkg/config/testdata/config_full.json | 45 +++++-- 12 files changed, 258 insertions(+), 144 deletions(-) diff --git a/router-tests/authentication_test.go b/router-tests/authentication_test.go index fd0c4bfdc9..adf3581228 100644 --- a/router-tests/authentication_test.go +++ b/router-tests/authentication_test.go @@ -599,10 +599,9 @@ func TestAuthenticationWithCustomHeaders(t *testing.T) { require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) authOptions := authentication.HttpHeaderAuthenticatorOptions{ Name: jwksName, - URL: authServer.JWKSURL(), HeaderNames: []string{headerName}, HeaderValuePrefixes: []string{headerValuePrefix}, TokenDecoder: tokenDecoder, @@ -734,22 +733,20 @@ func TestAuthenticationMultipleProviders(t *testing.T) { require.NoError(t, err) t.Cleanup(authServer2.Close) - tokenDecoder1, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer1.JWKSURL(), time.Second*5) + tokenDecoder1, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer1.JWKSURL(), time.Second*5)}) authenticator1HeaderValuePrefixes := []string{"Provider1"} authenticator1, err := authentication.NewHttpHeaderAuthenticator(authentication.HttpHeaderAuthenticatorOptions{ Name: "1", HeaderValuePrefixes: authenticator1HeaderValuePrefixes, - URL: authServer1.JWKSURL(), TokenDecoder: tokenDecoder1, }) require.NoError(t, err) - tokenDecoder2, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer2.JWKSURL(), time.Second*5) + tokenDecoder2, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer2.JWKSURL(), time.Second*5)}) authenticator2HeaderValuePrefixes := []string{"", "Provider2"} authenticator2, err := authentication.NewHttpHeaderAuthenticator(authentication.HttpHeaderAuthenticatorOptions{ Name: "2", HeaderValuePrefixes: authenticator2HeaderValuePrefixes, - URL: authServer2.JWKSURL(), TokenDecoder: tokenDecoder2, }) require.NoError(t, err) @@ -844,10 +841,9 @@ func TestAuthenticationOverWebsocket(t *testing.T) { require.NoError(t, err) defer authServer.Close() - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) jwksOpts := authentication.HttpHeaderAuthenticatorOptions{ Name: jwksName, - URL: authServer.JWKSURL(), TokenDecoder: tokenDecoder, } @@ -881,3 +877,11 @@ func TestAuthenticationOverWebsocket(t *testing.T) { require.Equal(t, http.StatusSwitchingProtocols, res.StatusCode) }) } + +func toJWKSConfig(url string, refresh time.Duration, allowedAlgorithms ...string) authentication.JWKSConfig { + return authentication.JWKSConfig{ + URL: url, + RefreshInterval: refresh, + AllowedAlgorithms: allowedAlgorithms, + } +} diff --git a/router-tests/modules/set_scopes_test.go b/router-tests/modules/set_scopes_test.go index 278ba4f2e9..a36a3cbe82 100644 --- a/router-tests/modules/set_scopes_test.go +++ b/router-tests/modules/set_scopes_test.go @@ -28,10 +28,14 @@ func configureAuth(t *testing.T) ([]authentication.Authenticator, *jwks.Server) authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(integration.NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(integration.NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{ + { + URL: authServer.JWKSURL(), + RefreshInterval: time.Second * 5, + }, + }) authOptions := authentication.HttpHeaderAuthenticatorOptions{ Name: jwksName, - URL: authServer.JWKSURL(), TokenDecoder: tokenDecoder, } authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) diff --git a/router-tests/utils.go b/router-tests/utils.go index fa06efc2ab..7fd880ea95 100644 --- a/router-tests/utils.go +++ b/router-tests/utils.go @@ -43,10 +43,15 @@ func configureAuth(t *testing.T) ([]authentication.Authenticator, *jwks.Server) authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{ + { + URL: authServer.JWKSURL(), + RefreshInterval: time.Second * 5, + }, + }) + authOptions := authentication.HttpHeaderAuthenticatorOptions{ Name: jwksName, - URL: authServer.JWKSURL(), TokenDecoder: tokenDecoder, } authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) diff --git a/router-tests/websocket_test.go b/router-tests/websocket_test.go index eb82b4d8ca..e3d174a812 100644 --- a/router-tests/websocket_test.go +++ b/router-tests/websocket_test.go @@ -75,10 +75,9 @@ func TestWebSockets(t *testing.T) { authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) authOptions := authentication.HttpHeaderAuthenticatorOptions{ Name: jwksName, - URL: authServer.JWKSURL(), TokenDecoder: tokenDecoder, } authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) @@ -125,10 +124,9 @@ func TestWebSockets(t *testing.T) { authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) authOptions := authentication.HttpHeaderAuthenticatorOptions{ Name: jwksName, - URL: authServer.JWKSURL(), TokenDecoder: tokenDecoder, } authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) @@ -175,10 +173,9 @@ func TestWebSockets(t *testing.T) { authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) authOptions := authentication.HttpHeaderAuthenticatorOptions{ Name: jwksName, - URL: authServer.JWKSURL(), TokenDecoder: tokenDecoder, } authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) @@ -235,10 +232,9 @@ func TestWebSockets(t *testing.T) { authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) authOptions := authentication.HttpHeaderAuthenticatorOptions{ Name: jwksName, - URL: authServer.JWKSURL(), TokenDecoder: tokenDecoder, } authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) @@ -294,7 +290,7 @@ func TestWebSockets(t *testing.T) { authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) authOptions := authentication.WebsocketInitialPayloadAuthenticatorOptions{ TokenDecoder: tokenDecoder, Key: "Authorization", @@ -356,7 +352,7 @@ func TestWebSockets(t *testing.T) { authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) authOptions := authentication.WebsocketInitialPayloadAuthenticatorOptions{ TokenDecoder: tokenDecoder, Key: "Authorization", @@ -405,7 +401,7 @@ func TestWebSockets(t *testing.T) { authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) authOptions := authentication.WebsocketInitialPayloadAuthenticatorOptions{ TokenDecoder: tokenDecoder, Key: "Authorization", diff --git a/router/cmd/instance.go b/router/cmd/instance.go index c18af92004..2a4b71fa14 100644 --- a/router/cmd/instance.go +++ b/router/cmd/instance.go @@ -55,47 +55,9 @@ func NewRouter(ctx context.Context, params Params, additionalOptions ...core.Opt cfg := params.Config logger := params.Logger - var authenticators []authentication.Authenticator - for i, auth := range cfg.Authentication.Providers { - if auth.JWKS != nil { - name := auth.Name - if name == "" { - name = fmt.Sprintf("jwks-#%d", i) - } - providerLogger := logger.With(zap.String("provider_name", name)) - tokenDecoder, err := authentication.NewJwksTokenDecoder(ctx, providerLogger, auth.JWKS.URL, auth.JWKS.RefreshInterval) - if err != nil { - providerLogger.Error("Could not create JWKS token decoder", zap.Error(err)) - return nil, err - } - opts := authentication.HttpHeaderAuthenticatorOptions{ - Name: name, - URL: auth.JWKS.URL, - HeaderNames: auth.JWKS.HeaderNames, - HeaderValuePrefixes: auth.JWKS.HeaderValuePrefixes, - TokenDecoder: tokenDecoder, - } - authenticator, err := authentication.NewHttpHeaderAuthenticator(opts) - if err != nil { - providerLogger.Error("Could not create HttpHeader authenticator", zap.Error(err)) - return nil, err - } - authenticators = append(authenticators, authenticator) - - if cfg.WebSocket.Authentication.FromInitialPayload.Enabled { - opts := authentication.WebsocketInitialPayloadAuthenticatorOptions{ - TokenDecoder: tokenDecoder, - Key: cfg.WebSocket.Authentication.FromInitialPayload.Key, - HeaderValuePrefixes: auth.JWKS.HeaderValuePrefixes, - } - authenticator, err = authentication.NewWebsocketInitialPayloadAuthenticator(opts) - if err != nil { - providerLogger.Error("Could not create WebsocketInitialPayload authenticator", zap.Error(err)) - return nil, err - } - authenticators = append(authenticators, authenticator) - } - } + authenticators, err := setupAuthenticators(ctx, logger, cfg) + if err != nil { + return nil, fmt.Errorf("could not setup authenticators: %w", err) } options := []core.Option{ @@ -266,6 +228,75 @@ func NewRouter(ctx context.Context, params Params, additionalOptions ...core.Opt return core.NewRouter(options...) } +func setupAuthenticators(ctx context.Context, logger *zap.Logger, cfg *config.Config) ([]authentication.Authenticator, error) { + jwtConf := cfg.Authentication.JWT + if len(jwtConf.JWKS) == 0 { + // No JWT authenticators configured + return nil, nil + } + + var authenticators []authentication.Authenticator + configs := make([]authentication.JWKSConfig, 0, len(jwtConf.JWKS)) + + for _, jwks := range cfg.Authentication.JWT.JWKS { + configs = append(configs, authentication.JWKSConfig{ + URL: jwks.URL, + RefreshInterval: jwks.RefreshInterval, + AllowedAlgorithms: jwks.Algorithms, + }) + } + + tokenDecoder, err := authentication.NewJwksTokenDecoder(ctx, logger, configs) + if err != nil { + return nil, err + } + + // create a list of header names to parse + + headerSources := []string{jwtConf.HeaderName} + headerPrefixes := []string{jwtConf.HeaderValuePrefix} + for _, s := range jwtConf.HeaderSources { + if s.Type != "header" { + continue + } + + headerSources = append(headerSources, s.Name) + headerPrefixes = append(headerPrefixes, s.ValuePrefix) + } + + // TODO we might want to have Cookies as well. + opts := authentication.HttpHeaderAuthenticatorOptions{ + Name: "jwks", + HeaderNames: headerSources, + HeaderValuePrefixes: headerPrefixes, + TokenDecoder: tokenDecoder, + } + + authenticator, err := authentication.NewHttpHeaderAuthenticator(opts) + if err != nil { + logger.Error("Could not create HttpHeader authenticator", zap.Error(err)) + return nil, err + } + + authenticators = append(authenticators, authenticator) + + if cfg.WebSocket.Authentication.FromInitialPayload.Enabled { + opts := authentication.WebsocketInitialPayloadAuthenticatorOptions{ + TokenDecoder: tokenDecoder, + Key: cfg.WebSocket.Authentication.FromInitialPayload.Key, + HeaderValuePrefixes: headerPrefixes, + } + authenticator, err = authentication.NewWebsocketInitialPayloadAuthenticator(opts) + if err != nil { + logger.Error("Could not create WebsocketInitialPayload authenticator", zap.Error(err)) + return nil, err + } + authenticators = append(authenticators, authenticator) + } + + return authenticators, nil +} + func hasProxyConfigured() bool { _, httpProxy := os.LookupEnv("HTTP_PROXY") _, httpsProxy := os.LookupEnv("HTTPS_PROXY") diff --git a/router/pkg/authentication/http_header_authenticator.go b/router/pkg/authentication/http_header_authenticator.go index 7fbd13b45c..4c87f50213 100644 --- a/router/pkg/authentication/http_header_authenticator.go +++ b/router/pkg/authentication/http_header_authenticator.go @@ -53,8 +53,6 @@ func (a *httpHeaderAuthenticator) Authenticate(ctx context.Context, p Provider) type HttpHeaderAuthenticatorOptions struct { // Name is the authenticator name. It cannot be empty. Name string - // URL is the URL of the JWKS endpoint, it is mandatory. - URL string // HeaderNames are the header names to use for retrieving the token. It defaults to // Authorization HeaderNames []string diff --git a/router/pkg/authentication/jwks_token_decoder.go b/router/pkg/authentication/jwks_token_decoder.go index e845805b97..bfbd26f397 100644 --- a/router/pkg/authentication/jwks_token_decoder.go +++ b/router/pkg/authentication/jwks_token_decoder.go @@ -38,42 +38,50 @@ func (j *jwksTokenDecoder) Decode(tokenString string) (Claims, error) { return Claims(claims), nil } -func NewJwksTokenDecoder(ctx context.Context, logger *zap.Logger, u string, refreshInterval time.Duration) (TokenDecoder, error) { +type JWKSConfig struct { + URL string + RefreshInterval time.Duration + AllowedAlgorithms []string +} - logger = logger.With(zap.String("url", u)) +func NewJwksTokenDecoder(ctx context.Context, logger *zap.Logger, configs []JWKSConfig) (TokenDecoder, error) { - // Create the JWK Set HTTP client. remoteJWKSets := make(map[string]jwkset.Storage) - ur, err := url.ParseRequestURI(u) - if err != nil { - return nil, fmt.Errorf("failed to parse given URL %q: %w", u, err) - } - jwksetHTTPStorageOptions := jwkset.HTTPClientStorageOptions{ - Client: httpclient.NewRetryableHTTPClient(logger), - Ctx: ctx, // Used to end background refresh goroutine. - HTTPExpectedStatus: http.StatusOK, - HTTPMethod: http.MethodGet, - HTTPTimeout: 15 * time.Second, - RefreshErrorHandler: func(ctx context.Context, err error) { - logger.Error("Failed to refresh HTTP JWK Set from remote HTTP resource.", zap.Error(err)) - }, - RefreshInterval: refreshInterval, - Storage: nil, - } - store, err := jwkset.NewStorageFromHTTP(ur, jwksetHTTPStorageOptions) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP client storage for JWK provider: %w", err) + for _, c := range configs { + l := logger.With(zap.String("url", c.URL)) + ur, err := url.ParseRequestURI(c.URL) + if err != nil { + return nil, fmt.Errorf("failed to parse given URL %q: %w", c.URL, err) + } + + jwksetHTTPStorageOptions := jwkset.HTTPClientStorageOptions{ + Client: httpclient.NewRetryableHTTPClient(l), + Ctx: ctx, // Used to end background refresh goroutine. + HTTPExpectedStatus: http.StatusOK, + HTTPMethod: http.MethodGet, + HTTPTimeout: 15 * time.Second, + RefreshErrorHandler: func(ctx context.Context, err error) { + l.Error("Failed to refresh HTTP JWK Set from remote HTTP resource.", zap.Error(err)) + }, + RefreshInterval: c.RefreshInterval, + Storage: nil, + } + + store, err := jwkset.NewStorageFromHTTP(ur, jwksetHTTPStorageOptions) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client storage for JWK provider: %w", err) + } + + remoteJWKSets[ur.String()] = store } - remoteJWKSets[ur.String()] = store - - // Create the JWK Set containing HTTP clients and given keys. jwksetHTTPClientOptions := jwkset.HTTPClientOptions{ HTTPURLs: remoteJWKSets, PrioritizeHTTP: false, RefreshUnknownKID: rate.NewLimiter(rate.Every(5*time.Minute), 1), } + combined, err := jwkset.NewHTTPClient(jwksetHTTPClientOptions) if err != nil { return nil, fmt.Errorf("failed to create HTTP client storage for JWK provider: %w", err) diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index ecf0317f99..f39909b46f 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -398,20 +398,28 @@ type OverridesConfiguration struct { Subgraphs map[string]SubgraphOverridesConfiguration `yaml:"subgraphs"` } -type AuthenticationProviderJWKS struct { - URL string `yaml:"url"` - HeaderNames []string `yaml:"header_names"` - HeaderValuePrefixes []string `yaml:"header_value_prefixes"` - RefreshInterval time.Duration `yaml:"refresh_interval" envDefault:"1m"` +type JWKSConfiguration struct { + URL string `yaml:"url"` + Algorithms []string `yaml:"algorithms"` + RefreshInterval time.Duration `yaml:"refresh_interval" envDefault:"1m"` } -type AuthenticationProvider struct { - Name string `yaml:"name"` - JWKS *AuthenticationProviderJWKS `yaml:"jwks"` +// TODO Change name to TokenSource maybe? +type HeaderSource struct { + Type string `yaml:"type"` + Name string `yaml:"name"` + ValuePrefix string `yaml:"value_prefix"` +} + +type JWTAuthenticationConfiguration struct { + JWKS []JWKSConfiguration `yaml:"jwks"` + HeaderName string `yaml:"header_name" envDefault:"Authorization"` + HeaderValuePrefix string `yaml:"header_value_prefix" envDefault:"Bearer"` + HeaderSources []HeaderSource `yaml:"header_sources"` } type AuthenticationConfiguration struct { - Providers []AuthenticationProvider `yaml:"providers"` + JWT JWTAuthenticationConfiguration `yaml:"jwt"` } type AuthorizationConfiguration struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 0219476890..6132062744 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -1428,17 +1428,14 @@ "description": "The configuration for the authentication. The authentication is used to authenticate the incoming requests. We currently support JWK (JSON Web Key) authentication.", "additionalProperties": false, "properties": { - "providers": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "name": { - "type": "string", - "description": "The name of the authentication provider. The name is used to identify the provider in the configuration." - }, - "jwks": { + "jwt": { + "type": "object", + "additionalProperties": false, + "properties": { + "jwks": { + "type": "array", + "additionalProperties": false, + "items": { "type": "object", "additionalProperties": false, "properties": { @@ -1447,20 +1444,12 @@ "description": "The URL of the JWKs. The JWKs are used to verify the JWT (JSON Web Token). The URL is specified as a string with the format 'scheme://host:port'.", "format": "http-url" }, - "header_names": { - "type": "array", - "description": "The names of the headers. The headers are used to extract the token from the request. The default value is 'Authorization'", - "default": ["Authorization"], - "items": { - "type": "string" - } - }, - "header_value_prefixes": { + "algorithms": { "type": "array", - "description": "The prefixes of the header values. The prefixes are used to extract the token from the header value. The default value is 'Bearer'", - "default": ["Bearer"], + "description": "The allowed algorithms for the keys that are retrieved from the JWKs. An empty list means that all algorithms are allowed.", "items": { - "type": "string" + "type": "string", + "enum": ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] } }, "refresh_interval": { @@ -1475,7 +1464,42 @@ "required": ["url"] } }, - "required": ["name"] + "header_name": { + "type": "string", + "description": "The name of the header. The header is used to extract the token from the request. The default value is 'Authorization'.", + "default": "Authorization" + }, + "header_value_prefix": { + "type": "string", + "description": "The prefix of the header value. The prefix is used to extract the token from the header value. The default value is 'Bearer'.", + "default": "Bearer" + }, + "header_sources": { + "type": "array", + "description": "Additional sources for the token. The sources are used to extract the token from the request.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "description": "The type of the source. The supported types are 'header', 'cookie'.", + "enum": ["header", "cookie"] + }, + "name": { + "type": "string", + "description": "The name of the header. The header is used to extract the token from the request.", + "format": "http-header", + "examples": ["X-Authorization"] + }, + "value_prefix": { + "type": "string", + "description": "The prefix of the header value. The prefix is used to extract the token from the header value." + } + }, + "required": ["type", "name"] + } + } } } } diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index a4a4027494..17b5621cfb 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -210,15 +210,23 @@ headers: # Authentication and Authorization # See https://cosmo-docs.wundergraph.com/router/authentication-and-authorization for more information authentication: - providers: - - name: My Auth Provider # Optional, used for error messages and diagnostics - jwks: # JWKS provider configuration - url: https://example.com/.well-known/jwks.json # URL to load the JWKS from + jwt: + jwks: + - url: "https://example.com/.well-known/jwks.json" refresh_interval: 1m - header_names: - - Authorization # Optional - header_value_prefixes: - - Bearer # Optional + algorithms: ["RS256"] + - url: "https://example.com/.well-known/jwks2.json" + refresh_interval: 2m + algorithms: ["RS256", "HS256"] + - url: "https://example.com/.well-known/jwks3.json" + header_name: Authorization + header_value_prefix: Bearer + header_sources: + - type: header + name: X-Authorization + value_prefix: Bearer + - type: cookie + name: authz authorization: require_authentication: false # Set to true to disable requests without authentication diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 8215fe3cf8..e9cd899437 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -175,7 +175,12 @@ "GraphQLPath": "/graphql", "PlaygroundPath": "/", "Authentication": { - "Providers": null + "JWT": { + "JWKS": null, + "HeaderName": "Authorization", + "HeaderValuePrefix": "Bearer", + "HeaderSources": null + } }, "Authorization": { "RequireAuthentication": false, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index dc390712d2..5d73918ac4 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -336,21 +336,44 @@ "GraphQLPath": "/graphql", "PlaygroundPath": "/", "Authentication": { - "Providers": [ - { - "Name": "My Auth Provider", - "JWKS": { + "JWT": { + "JWKS": [ + { "URL": "https://example.com/.well-known/jwks.json", - "HeaderNames": [ - "Authorization" - ], - "HeaderValuePrefixes": [ - "Bearer" + "Algorithms": [ + "RS256" ], "RefreshInterval": 60000000000 + }, + { + "URL": "https://example.com/.well-known/jwks2.json", + "Algorithms": [ + "RS256", + "HS256" + ], + "RefreshInterval": 120000000000 + }, + { + "URL": "https://example.com/.well-known/jwks3.json", + "Algorithms": null, + "RefreshInterval": 0 } - } - ] + ], + "HeaderName": "Authorization", + "HeaderValuePrefix": "Bearer", + "HeaderSources": [ + { + "Type": "header", + "Name": "X-Authorization", + "ValuePrefix": "Bearer" + }, + { + "Type": "cookie", + "Name": "authz", + "ValuePrefix": "" + } + ] + } }, "Authorization": { "RequireAuthentication": false, From 86b87bf1e8e47cc00bd147ec652eaa04aeceebb6 Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Wed, 8 Jan 2025 16:41:57 +0100 Subject: [PATCH 02/12] feat(router): add validation for JWKS --- router-tests/authentication_test.go | 589 +++++++++++++++++- router-tests/jwks/crypto.go | 200 ++++++ router-tests/jwks/jwks.go | 97 +-- router-tests/utils.go | 5 +- .../pkg/authentication/jwks_token_decoder.go | 12 +- router/pkg/authentication/validation_store.go | 125 ++++ 6 files changed, 975 insertions(+), 53 deletions(-) create mode 100644 router-tests/jwks/crypto.go create mode 100644 router/pkg/authentication/validation_store.go diff --git a/router-tests/authentication_test.go b/router-tests/authentication_test.go index adf3581228..e42a8e1629 100644 --- a/router-tests/authentication_test.go +++ b/router-tests/authentication_test.go @@ -2,19 +2,24 @@ package integration import ( "bytes" - "go.uber.org/zap" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "github.com/golang-jwt/jwt/v5" "io" "net/http" "strings" "testing" "time" + "github.com/MicahParks/jwkset" "github.com/stretchr/testify/require" "github.com/wundergraph/cosmo/router-tests/jwks" "github.com/wundergraph/cosmo/router-tests/testenv" "github.com/wundergraph/cosmo/router/core" "github.com/wundergraph/cosmo/router/pkg/authentication" "github.com/wundergraph/cosmo/router/pkg/config" + "go.uber.org/zap" ) const ( @@ -832,6 +837,588 @@ func TestAuthenticationMultipleProviders(t *testing.T) { require.JSONEq(t, unauthorizedExpectedData, string(data)) }) }) + + t.Run("should fail to create TokenDecoder with RSA algorithm when only HS256 is allowed", func(t *testing.T) { + t.Parallel() + + authServer, err := jwks.NewServer(t) + require.NoError(t, err) + t.Cleanup(authServer.Close) + + _, err = authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), + []authentication.JWKSConfig{ + toJWKSConfig(authServer.JWKSURL(), time.Second*5, "HS256"), // Allow only HS256. RSA should be denied + }, + ) + + require.Error(t, err) + }) +} + +func TestAlgorithmMismatch(t *testing.T) { + t.Parallel() + + testSetup := func(t *testing.T, crypto jwks.Crypto) (string, []authentication.Authenticator) { + t.Helper() + + authServer, err := jwks.NewServerWithCrypto(t, crypto) + require.NoError(t, err) + t.Cleanup(authServer.Close) + + tokenDecoder, err := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) + require.NoError(t, err) + + authOptions := authentication.HttpHeaderAuthenticatorOptions{ + Name: jwksName, + TokenDecoder: tokenDecoder, + } + authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) + require.NoError(t, err) + + authenticators := []authentication.Authenticator{authenticator} + + token, err := authServer.TokenForKID(crypto.KID(), nil) + require.NoError(t, err) + + return token, authenticators + } + + t.Run("should prevent access with invalid algorithm", func(t *testing.T) { + // create a crypto for RSA + rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgRS256, 2048) + require.NoError(t, err) + + // We are not using the provided token here as we want to test the algorithm mismatch + _, authenticators := testSetup(t, rsaCrypto) + + // sign a token with an HMAC algorithm using the RSA key in PEM format + // Unlike RSA, HMAC is a symmetric algorithm and the key is the same for signing and verifying + // Therefore we can try to use the public key as the HMAC key to sign a token. + signer := jwt.New(jwt.SigningMethodHS256) + + signer.Header[jwkset.HeaderKID] = rsaCrypto.KID() + + publicKey := rsaCrypto.PrivateKey().(*rsa.PrivateKey).PublicKey + publicKeyPEM := &pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: x509.MarshalPKCS1PublicKey(&publicKey), + } + + token, err := signer.SignedString(pem.EncodeToMemory(publicKeyPEM)) + require.NoError(t, err) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + // Operation with forged token should fail + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQuery)) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) + }) + + t.Run("Should not allow none algorithm", func(t *testing.T) { + t.Parallel() + + rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgRS256, 2048) + require.NoError(t, err) + + // We will create a token with none algorithm + _, authenticators := testSetup(t, rsaCrypto) + + token, err := jwt.New(jwt.SigningMethodNone).SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQuery)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) + }) +} + +func TestMultipleKeys(t *testing.T) { + t.Parallel() + + testAuthentication := func(t *testing.T, xEnv *testenv.Environment, token string) { + t.Helper() + + // Operations with a token should succeed + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQuery)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, jwksName, res.Header.Get(xAuthenticatedByHeader)) + data, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, employeesExpectedData, string(data)) + + // Operation without a token should fail + res, err = xEnv.MakeRequest(http.MethodPost, "/graphql", nil, strings.NewReader(employeesQuery)) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + } + + testSetup := func(t *testing.T, crypto ...jwks.Crypto) (map[string]string, []authentication.Authenticator) { + t.Helper() + + authServer, err := jwks.NewServerWithCrypto(t, crypto...) + require.NoError(t, err) + + t.Cleanup(authServer.Close) + + tokenDecoder, err := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) + require.NoError(t, err) + + authOptions := authentication.HttpHeaderAuthenticatorOptions{ + Name: jwksName, + TokenDecoder: tokenDecoder, + } + authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) + require.NoError(t, err) + + authenticators := []authentication.Authenticator{authenticator} + + tokens := make(map[string]string) + + for _, c := range crypto { + token, err := authServer.TokenForKID(c.KID(), nil) + require.NoError(t, err) + + tokens[c.KID()] = token + } + + return tokens, authenticators + } + + t.Run("Test with multiple asymmetric keys", func(t *testing.T) { + t.Parallel() + + t.Run("Should succeed with multiple RSA keys", func(t *testing.T) { + t.Parallel() + + rsa1, err := jwks.NewRSACrypto("", jwkset.AlgRS256, 2048) + require.NoError(t, err) + + rsa2, err := jwks.NewRSACrypto("", jwkset.AlgRS512, 2048) + require.NoError(t, err) + + tokens, authenticators := testSetup(t, rsa1, rsa2) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + for _, token := range tokens { + testAuthentication(t, xEnv, token) + } + }) + }) + + t.Run("Should succeed with multiple ECDSA keys", func(t *testing.T) { + t.Parallel() + + ec1, err := jwks.NewES256Crypto("") + require.NoError(t, err) + + ec2, err := jwks.NewES384Crypto("") + require.NoError(t, err) + + tokens, authenticators := testSetup(t, ec1, ec2) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + for _, token := range tokens { + testAuthentication(t, xEnv, token) + } + }) + }) + + t.Run("Should succeed with RSA and ECDSA keys", func(t *testing.T) { + t.Parallel() + + rsa, err := jwks.NewRSACrypto("", jwkset.AlgRS256, 2048) + require.NoError(t, err) + + ec, err := jwks.NewES256Crypto("") + require.NoError(t, err) + + tokens, authenticators := testSetup(t, rsa, ec) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + for _, token := range tokens { + testAuthentication(t, xEnv, token) + } + }) + }) + }) + + t.Run("Test with multiple symmetric keys", func(t *testing.T) { + t.Parallel() + + t.Run("Should succeed with multiple HS256 keys", func(t *testing.T) { + t.Parallel() + + hs1, err := jwks.NewHMACCrypto("", jwkset.AlgHS256) + require.NoError(t, err) + + hs2, err := jwks.NewHMACCrypto("", jwkset.AlgHS256) + require.NoError(t, err) + + tokens, authenticators := testSetup(t, hs1, hs2) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + for _, token := range tokens { + testAuthentication(t, xEnv, token) + } + }) + }) + }) + + t.Run("Test with symmetric and asymmetric keys", func(t *testing.T) { + t.Parallel() + + t.Run("Should succeed with RSA and HS256 keys", func(t *testing.T) { + t.Parallel() + + rsa, err := jwks.NewRSACrypto("", jwkset.AlgRS256, 2048) + require.NoError(t, err) + + hs, err := jwks.NewHMACCrypto("", jwkset.AlgHS256) + require.NoError(t, err) + + tokens, authenticators := testSetup(t, rsa, hs) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + for _, token := range tokens { + testAuthentication(t, xEnv, token) + } + }) + }) + }) +} + +func TestSupportedAlgorithms(t *testing.T) { + t.Parallel() + + testAuthentication := func(t *testing.T, xEnv *testenv.Environment, token string) { + t.Helper() + + // Operations with a token should succeed + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQuery)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, jwksName, res.Header.Get(xAuthenticatedByHeader)) + data, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, employeesExpectedData, string(data)) + + // Operation without a token should fail + res, err = xEnv.MakeRequest(http.MethodPost, "/graphql", nil, strings.NewReader(employeesQuery)) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + } + + testSetup := func(t *testing.T, crypto jwks.Crypto) (string, []authentication.Authenticator) { + t.Helper() + + authServer, err := jwks.NewServerWithCrypto(t, crypto) + require.NoError(t, err) + t.Cleanup(authServer.Close) + + tokenDecoder, err := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) + require.NoError(t, err) + + authOptions := authentication.HttpHeaderAuthenticatorOptions{ + Name: jwksName, + TokenDecoder: tokenDecoder, + } + authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) + require.NoError(t, err) + + authenticators := []authentication.Authenticator{authenticator} + + token, err := authServer.TokenForKID(crypto.KID(), nil) + require.NoError(t, err) + + return token, authenticators + } + + t.Run("RSA Tests", func(t *testing.T) { + t.Parallel() + + t.Run("Test authentication with RSA 256", func(t *testing.T) { + t.Parallel() + + rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgRS256, 2048) + require.NoError(t, err) + + token, authenticators := testSetup(t, rsaCrypto) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + + t.Run("Test authentication with RSA 384", func(t *testing.T) { + t.Parallel() + + rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgRS384, 2048) + require.NoError(t, err) + + token, authenticators := testSetup(t, rsaCrypto) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + + t.Run("Test authentication with RSA 512", func(t *testing.T) { + t.Parallel() + + rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgRS512, 2048) + require.NoError(t, err) + + token, authenticators := testSetup(t, rsaCrypto) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + + t.Run("Test authentication with RSA 256 PSS", func(t *testing.T) { + t.Parallel() + + rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgPS256, 2048) + require.NoError(t, err) + + token, authenticators := testSetup(t, rsaCrypto) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + + t.Run("Test authentication with RSA 384 PSS", func(t *testing.T) { + t.Parallel() + + rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgPS384, 2048) + require.NoError(t, err) + + token, authenticators := testSetup(t, rsaCrypto) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + + t.Run("Test authentication with RSA 512 PSS", func(t *testing.T) { + t.Parallel() + + rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgPS512, 2048) + require.NoError(t, err) + + token, authenticators := testSetup(t, rsaCrypto) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + }) + + t.Run("HMAC Tests", func(t *testing.T) { + t.Parallel() + + t.Run("Test authentication with HMAC 256", func(t *testing.T) { + t.Parallel() + + hmac, err := jwks.NewHMACCrypto("", jwkset.AlgHS256) + require.NoError(t, err) + + token, authenticators := testSetup(t, hmac) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + + t.Run("Test authentication with HMAC 384", func(t *testing.T) { + t.Parallel() + + hmac, err := jwks.NewHMACCrypto("", jwkset.AlgHS384) + require.NoError(t, err) + + token, authenticators := testSetup(t, hmac) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + + t.Run("Test authentication with HMAC 512", func(t *testing.T) { + t.Parallel() + + hmac, err := jwks.NewHMACCrypto("", jwkset.AlgHS512) + require.NoError(t, err) + + token, authenticators := testSetup(t, hmac) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + }) + + t.Run("ED25519 Tests", func(t *testing.T) { + t.Parallel() + + t.Run("Test authentication with ED25519", func(t *testing.T) { + t.Parallel() + + ed25519Crypto, err := jwks.NewED25519Crypto("") + require.NoError(t, err) + + token, authenticators := testSetup(t, ed25519Crypto) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + }) + + t.Run("ECDSA Tests", func(t *testing.T) { + t.Parallel() + + t.Run("Test authentication with ES256", func(t *testing.T) { + t.Parallel() + + es256Crypto, err := jwks.NewES256Crypto("") + require.NoError(t, err) + + token, authenticators := testSetup(t, es256Crypto) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + + t.Run("Test authentication with ES384", func(t *testing.T) { + t.Parallel() + + es384Crypto, err := jwks.NewES384Crypto("") + require.NoError(t, err) + + token, authenticators := testSetup(t, es384Crypto) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + + t.Run("Test authentication with ES512", func(t *testing.T) { + t.Parallel() + + es512Crypto, err := jwks.NewES512Crypto("") + require.NoError(t, err) + + token, authenticators := testSetup(t, es512Crypto) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + testAuthentication(t, xEnv, token) + }) + }) + }) + } func TestAuthenticationOverWebsocket(t *testing.T) { diff --git a/router-tests/jwks/crypto.go b/router-tests/jwks/crypto.go new file mode 100644 index 0000000000..0b157a8440 --- /dev/null +++ b/router-tests/jwks/crypto.go @@ -0,0 +1,200 @@ +package jwks + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "fmt" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" +) + +type Crypto interface { + SigningMethod() jwt.SigningMethod + PrivateKey() privateKey + MarshalJWK() (jwkset.JWK, error) + KID() string +} + +type privateKey any + +type baseCrypto struct { + pk privateKey + alg jwkset.ALG + kID string +} + +func (b *baseCrypto) PrivateKey() privateKey { + return b.pk +} + +func (b *baseCrypto) SigningMethod() jwt.SigningMethod { + return jwt.GetSigningMethod(b.alg.String()) +} + +func (b *baseCrypto) MarshalJWK() (jwkset.JWK, error) { + marshalOptions := jwkset.JWKMarshalOptions{ + Private: false, + } + + meta := jwkset.JWKMetadataOptions{ + ALG: b.alg, + KID: b.kID, + USE: jwkset.UseSig, + } + + options := jwkset.JWKOptions{ + Marshal: marshalOptions, + Metadata: meta, + } + + return jwkset.NewJWKFromKey(b.pk, options) +} + +func (b *baseCrypto) KID() string { + return b.kID +} + +type rsaCrypto struct { + baseCrypto +} + +func NewRSACrypto(kID string, alg jwkset.ALG, size int) (Crypto, error) { + pk, err := rsa.GenerateKey(rand.Reader, size) + if err != nil { + return nil, err + } + + if kID == "" { + kID = randomKID() + } + + return &rsaCrypto{ + baseCrypto: baseCrypto{ + pk: pk, + alg: alg, + kID: kID, + }, + }, nil +} + +type hmacCrypto struct { + baseCrypto +} + +func NewHMACCrypto(kID string, alg jwkset.ALG) (Crypto, error) { + secret := make([]byte, 64) + _, err := rand.Read(secret) + if err != nil { + return nil, fmt.Errorf("failed to generate random secret") + } + + h := hmac.New(crypto.SHA256.New, secret) + + s := h.Sum(nil) + + if kID == "" { + kID = randomKID() + } + + return &hmacCrypto{ + baseCrypto: baseCrypto{ + pk: s, + alg: alg, + kID: kID, + }, + }, nil +} + +func (b *hmacCrypto) MarshalJWK() (jwkset.JWK, error) { + marshalOptions := jwkset.JWKMarshalOptions{ + Private: true, + } + + meta := jwkset.JWKMetadataOptions{ + ALG: b.alg, + KID: b.kID, + USE: jwkset.UseSig, + } + + options := jwkset.JWKOptions{ + Marshal: marshalOptions, + Metadata: meta, + } + + return jwkset.NewJWKFromKey(b.pk, options) +} + +type ed25519Crypto struct { + baseCrypto +} + +func NewED25519Crypto(kID string) (Crypto, error) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + + if kID == "" { + kID = randomKID() + } + + return &ed25519Crypto{ + baseCrypto: baseCrypto{ + pk: priv, + alg: jwkset.AlgEdDSA, + kID: kID, + }, + }, nil + +} + +type ecdsaCrypto struct { + baseCrypto +} + +func newESCryptoWithEllipticCurve(kID string, curve elliptic.Curve, alg jwkset.ALG) (Crypto, error) { + priv, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + return nil, err + } + + if kID == "" { + kID = randomKID() + } + return &ecdsaCrypto{ + baseCrypto: baseCrypto{ + pk: priv, + alg: alg, + kID: kID, + }, + }, nil +} + +func NewES256Crypto(kID string) (Crypto, error) { + return newESCryptoWithEllipticCurve(kID, elliptic.P256(), jwkset.AlgES256) +} + +func NewES384Crypto(kID string) (Crypto, error) { + return newESCryptoWithEllipticCurve(kID, elliptic.P384(), jwkset.AlgES384) +} + +func NewES512Crypto(kID string) (Crypto, error) { + return newESCryptoWithEllipticCurve(kID, elliptic.P521(), jwkset.AlgES512) +} + +func randomKID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + + for i := 0; i < len(b); i++ { + b[i] = 'a' + (b[i] % 26) + } + + return string(b) +} diff --git a/router-tests/jwks/jwks.go b/router-tests/jwks/jwks.go index 7ab835ee99..68e31d59d6 100644 --- a/router-tests/jwks/jwks.go +++ b/router-tests/jwks/jwks.go @@ -2,8 +2,6 @@ package jwks import ( "context" - "crypto/rand" - "crypto/rsa" "github.com/MicahParks/jwkset" "log" "net/http" @@ -15,17 +13,11 @@ import ( ) const ( - jwtKeyID = "123456789" - jwksHTTPPath = "/.well-known/jwks.json" ) -var ( - signingMethod = jwt.SigningMethodRS256 -) - type Server struct { - privateKey *rsa.PrivateKey + providers map[string]Crypto httpServer *httptest.Server storage jwkset.Storage } @@ -35,15 +27,33 @@ func (s *Server) Close() { } func (s *Server) Token(claims map[string]any) (string, error) { - token := jwt.NewWithClaims(signingMethod, jwt.MapClaims(claims)) - token.Header[jwkset.HeaderKID] = jwtKeyID - return token.SignedString(s.privateKey) + if len(s.providers) == 0 { + return "", jwt.ErrInvalidKey + } + + for kid, pr := range s.providers { + token := jwt.NewWithClaims(pr.SigningMethod(), jwt.MapClaims(claims)) + token.Header[jwkset.HeaderKID] = kid + return token.SignedString(pr.PrivateKey()) + } + + return "", jwt.ErrInvalidKey +} + +func (s *Server) TokenForKID(kid string, claims map[string]any) (string, error) { + provider, ok := s.providers[kid] + if !ok { + return "", jwt.ErrInvalidKey + } + token := jwt.NewWithClaims(provider.SigningMethod(), jwt.MapClaims(claims)) + token.Header[jwkset.HeaderKID] = kid + return token.SignedString(provider.PrivateKey()) } func (s *Server) jwksJSON(w http.ResponseWriter, r *http.Request) { ctx := context.Background() - rawJWKS, err := s.storage.JSONPublic(ctx) + rawJWKS, err := s.storage.JSON(ctx) if err != nil { log.Fatalf("Failed to get the server's JWKS.\nError: %s", err) } @@ -70,45 +80,34 @@ func (s *Server) waitForServer(ctx context.Context) error { } } -func NewServer(t *testing.T) (*Server, error) { - ctx := context.Background() - - // Create a cryptographic key. - priv, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatalf("Failed to generate given key.\nError: %s", err) +func NewServerWithCrypto(t *testing.T, providers ...Crypto) (*Server, error) { + t.Helper() + if len(providers) == 0 { + t.Fatalf("At least one crypto provider is required.") } - // Turn the key into a JWK. - marshalOptions := jwkset.JWKMarshalOptions{ - Private: true, - } - metadata := jwkset.JWKMetadataOptions{ - ALG: jwkset.AlgRS256, - KID: jwtKeyID, - USE: jwkset.UseSig, - } - options := jwkset.JWKOptions{ - Marshal: marshalOptions, - Metadata: metadata, + s := &Server{ + providers: make(map[string]Crypto), + storage: jwkset.NewMemoryStorage(), } - jwk, err := jwkset.NewJWKFromKey(priv, options) - if err != nil { - t.Fatalf("Failed to create a JWK from the given key.\nError: %s", err) - } + ctx := context.Background() - // Write the JWK to the server's storage. - serverStore := jwkset.NewMemoryStorage() - err = serverStore.KeyWrite(ctx, jwk) - if err != nil { - t.Fatalf("Failed to write the JWK to the server's storage.\nError: %s", err) - } + for _, p := range providers { + kid := p.KID() - s := &Server{ - privateKey: priv, - storage: serverStore, + jwk, err := p.MarshalJWK() + if err != nil { + t.Fatalf("Failed to marshal the JWK.\nError: %s", err) + } + + if err := s.storage.KeyWrite(ctx, jwk); err != nil { + t.Fatalf("Failed to write the JWK to the server's storage.\nError: %s", err) + } + + s.providers[kid] = p } + mux := http.NewServeMux() mux.HandleFunc(jwksHTTPPath, s.jwksJSON) s.httpServer = httptest.NewServer(mux) @@ -119,3 +118,11 @@ func NewServer(t *testing.T) (*Server, error) { } return s, nil } + +func NewServer(t *testing.T) (*Server, error) { + rsaCrypto, err := NewRSACrypto("", jwkset.AlgRS256, 2048) + if err != nil { + t.Fatalf("Failed to create an RSA crypto provider.\nError: %s", err) + } + return NewServerWithCrypto(t, rsaCrypto) +} diff --git a/router-tests/utils.go b/router-tests/utils.go index 7fd880ea95..4b00562ab7 100644 --- a/router-tests/utils.go +++ b/router-tests/utils.go @@ -2,6 +2,7 @@ package integration import ( "context" + "github.com/MicahParks/jwkset" "github.com/stretchr/testify/require" "github.com/wundergraph/cosmo/router-tests/jwks" "github.com/wundergraph/cosmo/router/pkg/authentication" @@ -40,7 +41,9 @@ func RequireSpanWithName(t *testing.T, exporter *tracetest2.InMemoryExporter, na } func configureAuth(t *testing.T) ([]authentication.Authenticator, *jwks.Server) { - authServer, err := jwks.NewServer(t) + rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgRS256, 2048) + require.NoError(t, err) + authServer, err := jwks.NewServerWithCrypto(t, rsaCrypto) require.NoError(t, err) t.Cleanup(authServer.Close) tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{ diff --git a/router/pkg/authentication/jwks_token_decoder.go b/router/pkg/authentication/jwks_token_decoder.go index bfbd26f397..394fe1a3f6 100644 --- a/router/pkg/authentication/jwks_token_decoder.go +++ b/router/pkg/authentication/jwks_token_decoder.go @@ -3,16 +3,16 @@ package authentication import ( "context" "fmt" - "github.com/MicahParks/jwkset" - "github.com/MicahParks/keyfunc/v3" - "github.com/wundergraph/cosmo/router/internal/httpclient" - "go.uber.org/zap" - "golang.org/x/time/rate" "net/http" "net/url" "time" + "github.com/MicahParks/jwkset" + "github.com/MicahParks/keyfunc/v3" "github.com/golang-jwt/jwt/v5" + "github.com/wundergraph/cosmo/router/internal/httpclient" + "go.uber.org/zap" + "golang.org/x/time/rate" ) type TokenDecoder interface { @@ -65,7 +65,7 @@ func NewJwksTokenDecoder(ctx context.Context, logger *zap.Logger, configs []JWKS l.Error("Failed to refresh HTTP JWK Set from remote HTTP resource.", zap.Error(err)) }, RefreshInterval: c.RefreshInterval, - Storage: nil, + Storage: NewValidationStore(nil, c.AllowedAlgorithms), } store, err := jwkset.NewStorageFromHTTP(ur, jwksetHTTPStorageOptions) diff --git a/router/pkg/authentication/validation_store.go b/router/pkg/authentication/validation_store.go new file mode 100644 index 0000000000..62ef422995 --- /dev/null +++ b/router/pkg/authentication/validation_store.go @@ -0,0 +1,125 @@ +package authentication + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/MicahParks/jwkset" +) + +var _ jwkset.Storage = (*validationStore)(nil) + +type validationStore struct { + algs map[string]struct{} + inner jwkset.Storage +} + +var supportedAlgorithms = map[string]struct{}{ + "HS256": {}, + "HS384": {}, + "HS512": {}, + "RS256": {}, + "RS384": {}, + "RS512": {}, + "PS256": {}, + "PS384": {}, + "PS512": {}, + "ES256": {}, + "ES384": {}, + "ES512": {}, + "EdDSA": {}, +} + +func NewValidationStore(inner jwkset.Storage, algs []string) jwkset.Storage { + if inner == nil { + inner = jwkset.NewMemoryStorage() + } + + algSet := make(map[string]struct{}, len(algs)) + + if len(algs) == 0 { + return &validationStore{ + algs: supportedAlgorithms, + inner: inner, + } + } + + for _, alg := range algs { + algSet[alg] = struct{}{} + } + + return &validationStore{ + algs: algSet, + inner: inner, + } +} + +func (v *validationStore) KeyDelete(ctx context.Context, keyID string) (ok bool, err error) { + return v.inner.KeyDelete(ctx, keyID) +} + +func (v *validationStore) KeyRead(ctx context.Context, keyID string) (jwkset.JWK, error) { + key, err := v.inner.KeyRead(ctx, keyID) + if err != nil { + return key, err + } + + m := key.Marshal() + if _, ok := v.algs[m.ALG.String()]; ok { + return key, nil + } + + return jwkset.JWK{}, fmt.Errorf("key with ID %q has an unsupported algorithm %s", keyID, m.ALG.String()) +} + +func (v *validationStore) KeyReadAll(ctx context.Context) ([]jwkset.JWK, error) { + keys, err := v.inner.KeyReadAll(ctx) + if err != nil { + return nil, err + } + + filter := make([]jwkset.JWK, 0, len(keys)) + + for _, k := range keys { + m := k.Marshal() + if _, ok := v.algs[m.ALG.String()]; ok { + filter = append(filter, k) + } + } + + return filter, nil +} + +func (v *validationStore) KeyWrite(ctx context.Context, jwk jwkset.JWK) error { + jwkMarshal := jwk.Marshal() + if _, ok := v.algs[jwkMarshal.ALG.String()]; !ok { + return fmt.Errorf("key with ID %q has an unsupported algorithm %s", jwkMarshal.KID, jwkMarshal.ALG.String()) + } + + return v.inner.KeyWrite(ctx, jwk) +} + +func (v *validationStore) JSON(ctx context.Context) (json.RawMessage, error) { + return v.inner.JSON(ctx) +} + +func (v *validationStore) JSONPublic(ctx context.Context) (json.RawMessage, error) { + return v.inner.JSONPublic(ctx) +} + +func (v *validationStore) JSONPrivate(ctx context.Context) (json.RawMessage, error) { + return v.inner.JSONPrivate(ctx) +} + +func (v *validationStore) JSONWithOptions(ctx context.Context, marshalOptions jwkset.JWKMarshalOptions, validationOptions jwkset.JWKValidateOptions) (json.RawMessage, error) { + return v.inner.JSONWithOptions(ctx, marshalOptions, validationOptions) +} + +func (v *validationStore) Marshal(ctx context.Context) (jwkset.JWKSMarshal, error) { + return v.inner.Marshal(ctx) +} + +func (v *validationStore) MarshalWithOptions(ctx context.Context, marshalOptions jwkset.JWKMarshalOptions, validationOptions jwkset.JWKValidateOptions) (jwkset.JWKSMarshal, error) { + return v.inner.MarshalWithOptions(ctx, marshalOptions, validationOptions) +} From 2ab097ffc6212fdf6c9e3243069109f33966f125 Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Wed, 8 Jan 2025 16:44:57 +0100 Subject: [PATCH 03/12] chore: use NewServer function --- router-tests/utils.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/router-tests/utils.go b/router-tests/utils.go index 4b00562ab7..7fd880ea95 100644 --- a/router-tests/utils.go +++ b/router-tests/utils.go @@ -2,7 +2,6 @@ package integration import ( "context" - "github.com/MicahParks/jwkset" "github.com/stretchr/testify/require" "github.com/wundergraph/cosmo/router-tests/jwks" "github.com/wundergraph/cosmo/router/pkg/authentication" @@ -41,9 +40,7 @@ func RequireSpanWithName(t *testing.T, exporter *tracetest2.InMemoryExporter, na } func configureAuth(t *testing.T) ([]authentication.Authenticator, *jwks.Server) { - rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgRS256, 2048) - require.NoError(t, err) - authServer, err := jwks.NewServerWithCrypto(t, rsaCrypto) + authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{ From b126dd0f699035bf6a5d31c3cb3e1c0094a4cc92 Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Wed, 8 Jan 2025 16:50:42 +0100 Subject: [PATCH 04/12] chore: fix test --- router-tests/ratelimit_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/router-tests/ratelimit_test.go b/router-tests/ratelimit_test.go index c83b2d6f0a..6c2586e71b 100644 --- a/router-tests/ratelimit_test.go +++ b/router-tests/ratelimit_test.go @@ -255,10 +255,14 @@ func TestRateLimit(t *testing.T) { authServer, err := jwks.NewServer(t) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), authServer.JWKSURL(), time.Second*5) + tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{ + { + URL: authServer.JWKSURL(), + RefreshInterval: time.Second * 5, + }, + }) authOptions := authentication.HttpHeaderAuthenticatorOptions{ Name: "my-jwks-server", - URL: authServer.JWKSURL(), TokenDecoder: tokenDecoder, } authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) From 473ef2a8e1b52e83280d4e2fce7ee98817fddbab Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Fri, 10 Jan 2025 21:26:27 +0100 Subject: [PATCH 05/12] chore: map header and prefixes --- router-tests/authentication_test.go | 25 ++++--- router/cmd/instance.go | 30 +++++--- .../http_header_authenticator.go | 72 ++++++++++--------- 3 files changed, 73 insertions(+), 54 deletions(-) diff --git a/router-tests/authentication_test.go b/router-tests/authentication_test.go index e42a8e1629..91e1ccb484 100644 --- a/router-tests/authentication_test.go +++ b/router-tests/authentication_test.go @@ -606,10 +606,11 @@ func TestAuthenticationWithCustomHeaders(t *testing.T) { tokenDecoder, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) authOptions := authentication.HttpHeaderAuthenticatorOptions{ - Name: jwksName, - HeaderNames: []string{headerName}, - HeaderValuePrefixes: []string{headerValuePrefix}, - TokenDecoder: tokenDecoder, + Name: jwksName, + HeaderSourcePrefixes: map[string][]string{ + headerName: {headerValuePrefix}, + }, + TokenDecoder: tokenDecoder, } authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) require.NoError(t, err) @@ -741,18 +742,22 @@ func TestAuthenticationMultipleProviders(t *testing.T) { tokenDecoder1, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer1.JWKSURL(), time.Second*5)}) authenticator1HeaderValuePrefixes := []string{"Provider1"} authenticator1, err := authentication.NewHttpHeaderAuthenticator(authentication.HttpHeaderAuthenticatorOptions{ - Name: "1", - HeaderValuePrefixes: authenticator1HeaderValuePrefixes, - TokenDecoder: tokenDecoder1, + Name: "1", + HeaderSourcePrefixes: map[string][]string{ + "Authorization": authenticator1HeaderValuePrefixes, + }, + TokenDecoder: tokenDecoder1, }) require.NoError(t, err) tokenDecoder2, _ := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer2.JWKSURL(), time.Second*5)}) authenticator2HeaderValuePrefixes := []string{"", "Provider2"} authenticator2, err := authentication.NewHttpHeaderAuthenticator(authentication.HttpHeaderAuthenticatorOptions{ - Name: "2", - HeaderValuePrefixes: authenticator2HeaderValuePrefixes, - TokenDecoder: tokenDecoder2, + Name: "2", + HeaderSourcePrefixes: map[string][]string{ + "Authorization": authenticator2HeaderValuePrefixes, + }, + TokenDecoder: tokenDecoder2, }) require.NoError(t, err) authenticators := []authentication.Authenticator{authenticator1, authenticator2} diff --git a/router/cmd/instance.go b/router/cmd/instance.go index 2a4b71fa14..3ba0bf60a1 100644 --- a/router/cmd/instance.go +++ b/router/cmd/instance.go @@ -251,25 +251,30 @@ func setupAuthenticators(ctx context.Context, logger *zap.Logger, cfg *config.Co return nil, err } - // create a list of header names to parse + // create a map for the `httpHeaderAuthenticator` + headerSourceMap := map[string][]string{ + jwtConf.HeaderName: {jwtConf.HeaderValuePrefix}, + } + + // The `websocketInitialPayloadAuthenticator` has one key and uses a flat list of prefixes + prefixSet := make(map[string]struct{}) - headerSources := []string{jwtConf.HeaderName} - headerPrefixes := []string{jwtConf.HeaderValuePrefix} for _, s := range jwtConf.HeaderSources { if s.Type != "header" { continue } - headerSources = append(headerSources, s.Name) - headerPrefixes = append(headerPrefixes, s.ValuePrefix) + if _, ok := headerSourceMap[s.Name]; !ok { + headerSourceMap[s.Name] = append(headerSourceMap[s.Name], s.ValuePrefix) + } + + prefixSet[s.ValuePrefix] = struct{}{} } - // TODO we might want to have Cookies as well. opts := authentication.HttpHeaderAuthenticatorOptions{ - Name: "jwks", - HeaderNames: headerSources, - HeaderValuePrefixes: headerPrefixes, - TokenDecoder: tokenDecoder, + Name: "jwks", + HeaderSourcePrefixes: headerSourceMap, + TokenDecoder: tokenDecoder, } authenticator, err := authentication.NewHttpHeaderAuthenticator(opts) @@ -281,6 +286,11 @@ func setupAuthenticators(ctx context.Context, logger *zap.Logger, cfg *config.Co authenticators = append(authenticators, authenticator) if cfg.WebSocket.Authentication.FromInitialPayload.Enabled { + headerPrefixes := make([]string, 0, len(prefixSet)) + for prefix := range prefixSet { + headerPrefixes = append(headerPrefixes, prefix) + } + opts := authentication.WebsocketInitialPayloadAuthenticatorOptions{ TokenDecoder: tokenDecoder, Key: cfg.WebSocket.Authentication.FromInitialPayload.Key, diff --git a/router/pkg/authentication/http_header_authenticator.go b/router/pkg/authentication/http_header_authenticator.go index 4c87f50213..5a60c1aaea 100644 --- a/router/pkg/authentication/http_header_authenticator.go +++ b/router/pkg/authentication/http_header_authenticator.go @@ -13,10 +13,9 @@ const ( ) type httpHeaderAuthenticator struct { - tokenDecoder TokenDecoder - name string - headerNames []string - headerValuePrefixes []string + tokenDecoder TokenDecoder + name string + headerSourceMap map[string][]string } func (a *httpHeaderAuthenticator) Name() string { @@ -27,25 +26,37 @@ func (a *httpHeaderAuthenticator) Authenticate(ctx context.Context, p Provider) headers := p.AuthenticationHeaders() var errs error - for _, header := range a.headerNames { + for header, prefixes := range a.headerSourceMap { authorization := headers.Get(header) - for _, prefix := range a.headerValuePrefixes { - if strings.HasPrefix(authorization, prefix) { - tokenString := strings.TrimSpace(authorization[len(prefix):]) - claims, err := a.tokenDecoder.Decode(tokenString) - if err != nil { - errs = errors.Join(errs, fmt.Errorf("could not validate token: %w", err)) + + if len(prefixes) == 0 { + prefixes = []string{""} + } + + for _, prefix := range prefixes { + tokenString := authorization + if prefix != "" { + if !strings.HasPrefix(authorization, prefix) { continue } - // If claims is nil, we should return an empty Claims map to signal that the - // authentication was successful, but no claims were found. - if claims == nil { - claims = make(Claims) - } - return claims, nil + + tokenString = strings.TrimSpace(authorization[len(prefix):]) } + + claims, err := a.tokenDecoder.Decode(tokenString) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("could not validate token: %w", err)) + continue + } + // If claims is nil, we should return an empty Claims map to signal that the + // authentication was successful, but no claims were found. + if claims == nil { + claims = make(Claims) + } + return claims, nil } } + return nil, errs } @@ -53,12 +64,9 @@ func (a *httpHeaderAuthenticator) Authenticate(ctx context.Context, p Provider) type HttpHeaderAuthenticatorOptions struct { // Name is the authenticator name. It cannot be empty. Name string - // HeaderNames are the header names to use for retrieving the token. It defaults to - // Authorization - HeaderNames []string - // HeaderValuePrefixes are the prefixes to use for retrieving the token. It defaults to - // Bearer - HeaderValuePrefixes []string + // HeaderSourcePrefixes are the headers and their prefixes to use for retrieving the token. + // It defaults to Authorization and Bearer + HeaderSourcePrefixes map[string][]string // TokenDecoder is the token decoder to use for decoding the token. It cannot be nil. TokenDecoder TokenDecoder } @@ -74,19 +82,15 @@ func NewHttpHeaderAuthenticator(opts HttpHeaderAuthenticatorOptions) (Authentica return nil, fmt.Errorf("token decoder must be provided") } - headerNames := opts.HeaderNames - if len(headerNames) == 0 { - headerNames = []string{defaultHeaderName} - } - headerValuePrefixes := opts.HeaderValuePrefixes - if len(headerValuePrefixes) == 0 { - headerValuePrefixes = []string{defaultHeaderValuePrefix} + if len(opts.HeaderSourcePrefixes) == 0 { + opts.HeaderSourcePrefixes = map[string][]string{ + defaultHeaderName: {defaultHeaderValuePrefix}, + } } return &httpHeaderAuthenticator{ - tokenDecoder: opts.TokenDecoder, - name: opts.Name, - headerNames: headerNames, - headerValuePrefixes: headerValuePrefixes, + tokenDecoder: opts.TokenDecoder, + name: opts.Name, + headerSourceMap: opts.HeaderSourcePrefixes, }, nil } From ec93e3bdedc0ea645c13ed03a7c1ea17ea780178 Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Fri, 10 Jan 2025 22:44:38 +0100 Subject: [PATCH 06/12] chore: do not return an error --- router-tests/authentication_test.go | 49 ++++++++++++------- .../pkg/authentication/jwks_token_decoder.go | 2 +- router/pkg/authentication/validation_store.go | 41 +++++++++++----- router/pkg/config/config.go | 1 - router/pkg/config/config.schema.json | 2 +- 5 files changed, 62 insertions(+), 33 deletions(-) diff --git a/router-tests/authentication_test.go b/router-tests/authentication_test.go index 91e1ccb484..1e21a85f51 100644 --- a/router-tests/authentication_test.go +++ b/router-tests/authentication_test.go @@ -843,21 +843,6 @@ func TestAuthenticationMultipleProviders(t *testing.T) { }) }) - t.Run("should fail to create TokenDecoder with RSA algorithm when only HS256 is allowed", func(t *testing.T) { - t.Parallel() - - authServer, err := jwks.NewServer(t) - require.NoError(t, err) - t.Cleanup(authServer.Close) - - _, err = authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), - []authentication.JWKSConfig{ - toJWKSConfig(authServer.JWKSURL(), time.Second*5, "HS256"), // Allow only HS256. RSA should be denied - }, - ) - - require.Error(t, err) - }) } func TestAlgorithmMismatch(t *testing.T) { @@ -1162,14 +1147,18 @@ func TestSupportedAlgorithms(t *testing.T) { require.Equal(t, http.StatusUnauthorized, res.StatusCode) } - testSetup := func(t *testing.T, crypto jwks.Crypto) (string, []authentication.Authenticator) { + testSetup := func(t *testing.T, crypto jwks.Crypto, allowedAlgorithms ...string) (string, []authentication.Authenticator) { t.Helper() authServer, err := jwks.NewServerWithCrypto(t, crypto) require.NoError(t, err) t.Cleanup(authServer.Close) - tokenDecoder, err := authentication.NewJwksTokenDecoder(NewContextWithCancel(t), zap.NewNop(), []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}) + tokenDecoder, err := authentication.NewJwksTokenDecoder( + NewContextWithCancel(t), + zap.NewNop(), + []authentication.JWKSConfig{ + toJWKSConfig(authServer.JWKSURL(), time.Second*5, allowedAlgorithms...)}) require.NoError(t, err) authOptions := authentication.HttpHeaderAuthenticatorOptions{ @@ -1424,6 +1413,32 @@ func TestSupportedAlgorithms(t *testing.T) { }) }) + t.Run("Should not be able to add JWKS with an algorithm that was not allowed", func(t *testing.T) { + t.Parallel() + + rsaCrypto, err := jwks.NewRSACrypto("", jwkset.AlgRS256, 2048) + require.NoError(t, err) + + // We are adding an RSA key but only allow HMAC + token, authenticators := testSetup(t, rsaCrypto, jwkset.AlgHS256.String()) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(core.NewAccessController(authenticators, true)), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + // Operations with a token should fail + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQuery)) + require.NoError(t, err) + defer func() { _ = res.Body.Close() }() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) + }) + } func TestAuthenticationOverWebsocket(t *testing.T) { diff --git a/router/pkg/authentication/jwks_token_decoder.go b/router/pkg/authentication/jwks_token_decoder.go index 394fe1a3f6..6b521f8071 100644 --- a/router/pkg/authentication/jwks_token_decoder.go +++ b/router/pkg/authentication/jwks_token_decoder.go @@ -65,7 +65,7 @@ func NewJwksTokenDecoder(ctx context.Context, logger *zap.Logger, configs []JWKS l.Error("Failed to refresh HTTP JWK Set from remote HTTP resource.", zap.Error(err)) }, RefreshInterval: c.RefreshInterval, - Storage: NewValidationStore(nil, c.AllowedAlgorithms), + Storage: NewValidationStore(logger, nil, c.AllowedAlgorithms), } store, err := jwkset.NewStorageFromHTTP(ur, jwksetHTTPStorageOptions) diff --git a/router/pkg/authentication/validation_store.go b/router/pkg/authentication/validation_store.go index 62ef422995..b74b4949b7 100644 --- a/router/pkg/authentication/validation_store.go +++ b/router/pkg/authentication/validation_store.go @@ -4,15 +4,17 @@ import ( "context" "encoding/json" "fmt" - + "github.com/MicahParks/jwkset" + "go.uber.org/zap" ) var _ jwkset.Storage = (*validationStore)(nil) type validationStore struct { - algs map[string]struct{} - inner jwkset.Storage + logger *zap.Logger + algs map[string]struct{} + inner jwkset.Storage } var supportedAlgorithms = map[string]struct{}{ @@ -31,28 +33,37 @@ var supportedAlgorithms = map[string]struct{}{ "EdDSA": {}, } -func NewValidationStore(inner jwkset.Storage, algs []string) jwkset.Storage { +func NewValidationStore(logger *zap.Logger, inner jwkset.Storage, algs []string) jwkset.Storage { if inner == nil { inner = jwkset.NewMemoryStorage() } + if logger == nil { + logger = zap.NewNop() + } + algSet := make(map[string]struct{}, len(algs)) + store := &validationStore{ + logger: logger, + inner: inner, + algs: supportedAlgorithms, + } + if len(algs) == 0 { - return &validationStore{ - algs: supportedAlgorithms, - inner: inner, - } + return store } for _, alg := range algs { + if _, ok := supportedAlgorithms[alg]; !ok { + logger.Warn("Unsupported algorithm", zap.String("algorithm", alg)) + continue + } algSet[alg] = struct{}{} } - return &validationStore{ - algs: algSet, - inner: inner, - } + store.algs = algSet + return store } func (v *validationStore) KeyDelete(ctx context.Context, keyID string) (ok bool, err error) { @@ -94,7 +105,11 @@ func (v *validationStore) KeyReadAll(ctx context.Context) ([]jwkset.JWK, error) func (v *validationStore) KeyWrite(ctx context.Context, jwk jwkset.JWK) error { jwkMarshal := jwk.Marshal() if _, ok := v.algs[jwkMarshal.ALG.String()]; !ok { - return fmt.Errorf("key with ID %q has an unsupported algorithm %s", jwkMarshal.KID, jwkMarshal.ALG.String()) + // We should not return an error here. If JWKS are configured for multiple applications, we should only add the + // supported keys to the token decoder store and not prevent the refresh entirely. + // In case we are receiving a key with an unsupported algorithm we log a warning instead. + v.logger.Warn("Skipping key with unsupported algorithm", zap.String("keyID", jwkMarshal.KID), zap.String("algorithm", jwkMarshal.ALG.String())) + return nil } return v.inner.KeyWrite(ctx, jwk) diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index f39909b46f..b21c04e879 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -404,7 +404,6 @@ type JWKSConfiguration struct { RefreshInterval time.Duration `yaml:"refresh_interval" envDefault:"1m"` } -// TODO Change name to TokenSource maybe? type HeaderSource struct { Type string `yaml:"type"` Name string `yaml:"name"` diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 6132062744..22691eb749 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -1484,7 +1484,7 @@ "type": { "type": "string", "description": "The type of the source. The supported types are 'header', 'cookie'.", - "enum": ["header", "cookie"] + "enum": ["header"] }, "name": { "type": "string", From d9d341be007bf17b76588fb3e7496824ae665af6 Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Tue, 14 Jan 2025 00:14:10 +0100 Subject: [PATCH 07/12] chore: update config schema and test --- router/pkg/config/fixtures/full.yaml | 2 +- router/pkg/config/testdata/config_full.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index 17b5621cfb..70e48b4b72 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -225,7 +225,7 @@ authentication: - type: header name: X-Authorization value_prefix: Bearer - - type: cookie + - type: header name: authz authorization: diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 5d73918ac4..cd57434cc6 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -368,7 +368,7 @@ "ValuePrefix": "Bearer" }, { - "Type": "cookie", + "Type": "header", "Name": "authz", "ValuePrefix": "" } From 92e2733280345ac865fd369fc7cda7a1ee4326bd Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Tue, 21 Jan 2025 13:38:58 +0100 Subject: [PATCH 08/12] feat: add runnable test server to simulate JWKS integration --- router-tests/authentication_test.go | 1 - router-tests/cmd/jwks-server/main.go | 261 ++++++++++++++++++++ router/cmd/instance.go | 6 +- router/pkg/config/config.go | 6 +- router/pkg/config/config.schema.json | 9 +- router/pkg/config/fixtures/full.yaml | 2 +- router/pkg/config/testdata/config_full.json | 7 +- 7 files changed, 279 insertions(+), 13 deletions(-) create mode 100644 router-tests/cmd/jwks-server/main.go diff --git a/router-tests/authentication_test.go b/router-tests/authentication_test.go index 1e21a85f51..eb1f7dcbf3 100644 --- a/router-tests/authentication_test.go +++ b/router-tests/authentication_test.go @@ -1438,7 +1438,6 @@ func TestSupportedAlgorithms(t *testing.T) { require.Equal(t, http.StatusUnauthorized, res.StatusCode) }) }) - } func TestAuthenticationOverWebsocket(t *testing.T) { diff --git a/router-tests/cmd/jwks-server/main.go b/router-tests/cmd/jwks-server/main.go new file mode 100644 index 0000000000..67f793134e --- /dev/null +++ b/router-tests/cmd/jwks-server/main.go @@ -0,0 +1,261 @@ +/* +This simple JWKS server can be used to test the router. +Start the server before running an instance of the router to simulate JWKS integration with the router setup through instance.go. + +Start with adding the following configuration to your router configuration file to use this server: + +authentication: + jwt: + jwks: + # default port is 3344 + - url: "http://localhost:3344/.well-known/jwks.json" + refresh_interval: 1m + # optional list of allowed algorithms per JWKS + algorithms: ["RS256", "EdDSA", "HS256"] + header_name: Authorization # This is the default value + header_value_prefix: Bearer # This is the default value + header_sources: + - type: header + name: X-Auth-Token + value_prefixes: [Token] +authorization: + require_authentication: true + + +Next Steps: +1. Run the JWKS server +2. Run the router +3. Navigate to localhost:3002 in your browser to start the playground +4. Make sure to include your header in the playground. e.g. + * Authorization: Bearer + * X-Auth-Token: Token +*/ + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" + "github.com/wundergraph/cosmo/router-tests/jwks" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +var ( + providers = flag.String("providers", "rsa", "Comma separated list of providers to use, allowed values: rsa, ed25519, hmac") + port = flag.String("port", "3344", "Port to run the server on") + kid = flag.String("kid", "test", "Key ID to use for the providers. Used as a prefix with the provider type to create the key ID. E.g. test_rsa, test_ed25519, test_hmac") +) + +type crypto string + +const ( + rsa crypto = "rsa" + ed25519 crypto = "ed25519" + hmac crypto = "hmac" +) + +func init() { + log.SetFlags(log.Lshortfile) + +} + +func main() { + log.Println("Starting JWKS server") + flag.Parse() + + var providerList []jwks.Crypto + for _, p := range strings.Split(*providers, ",") { + switch crypto(p) { + case rsa: + + rsaID := *kid + "_rsa" + rsa, err := jwks.NewRSACrypto(rsaID, jwkset.AlgRS256, 2048) + if err != nil { + log.Fatalf("Failed to create RSA provider.\nError: %s", err) + } + providerList = append(providerList, rsa) + case ed25519: + edID := *kid + "_ed25519" + ed, err := jwks.NewED25519Crypto(edID) + if err != nil { + log.Fatalf("Failed to create Ed25519 provider.\nError: %s", err) + } + + providerList = append(providerList, ed) + case hmac: + hmID := *kid + "_hmac" + hm, err := jwks.NewHMACCrypto(hmID, jwkset.AlgHS256) + if err != nil { + log.Fatalf("Failed to create HMAC provider.\nError: %s", err) + } + + providerList = append(providerList, hm) + default: + log.Fatalf("Unsupported test provider (for now): %s", p) + } + } + + s, err := NewServerWithCrypto(providerList...) + if err != nil { + log.Fatalf("Failed to create the server.\nError: %s", err) + } + + log.Println("Starting server on port", *port) + + // Create shutdown signal hook + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) + + go func() { + if err := s.httpServer.ListenAndServe(); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("Failed to start the server.\nError: %s", err) + } + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + if err := s.waitForServerRunning(ctx); err != nil { + log.Fatalf("Failed to wait for the server to start.\nError: %s", err) + } + cancel() + + if err := s.printTokensForKeys(map[string]any{"sub": "test"}); err != nil { + log.Fatalf("Failed to print tokens for keys.\nError: %s", err) + } + + <-sigs + + s.close() + +} + +const ( + jwksHTTPPath = "/.well-known/jwks.json" +) + +type server struct { + providers map[string]jwks.Crypto + httpServer *http.Server + storage jwkset.Storage +} + +func (s *server) close() { + s.httpServer.Close() +} + +func (s *server) waitForServerRunning(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + resp, err := http.Get(fmt.Sprintf("http://localhost:%s", *port)) + if err == nil { + _ = resp.Body.Close() + return nil + } + time.Sleep(time.Millisecond * 100) + } +} + +func (s *server) printTokensForKeys(claims map[string]any) error { + var tokens []string + for keyID := range s.providers { + token, err := s.tokenForKID(keyID, claims) + if err != nil { + return err + } + + tokens = append(tokens, fmt.Sprintf("%s Token: %s", keyID, token)) + } + + fmt.Println(strings.Join(tokens, "\n")) + return nil +} + +func (s *server) token(claims map[string]any) (string, error) { + if len(s.providers) == 0 { + return "", jwt.ErrInvalidKey + } + + for kid, pr := range s.providers { + token := jwt.NewWithClaims(pr.SigningMethod(), jwt.MapClaims(claims)) + token.Header[jwkset.HeaderKID] = kid + return token.SignedString(pr.PrivateKey()) + } + + return "", jwt.ErrInvalidKey +} + +func (s *server) tokenForKID(kid string, claims map[string]any) (string, error) { + provider, ok := s.providers[kid] + if !ok { + return "", jwt.ErrInvalidKey + } + token := jwt.NewWithClaims(provider.SigningMethod(), jwt.MapClaims(claims)) + token.Header[jwkset.HeaderKID] = kid + return token.SignedString(provider.PrivateKey()) +} + +func (s *server) jwksJSON(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + + rawJWKS, err := s.storage.JSON(ctx) + if err != nil { + log.Fatalf("Failed to get the server's JWKS.\nError: %s", err) + } + _, _ = w.Write(rawJWKS) +} + +func NewServerWithCrypto(providers ...jwks.Crypto) (*server, error) { + if len(providers) == 0 { + return nil, errors.New("no providers provided") + } + + s := &server{ + providers: make(map[string]jwks.Crypto), + storage: jwkset.NewMemoryStorage(), + } + + ctx := context.Background() + + for _, p := range providers { + kid := p.KID() + + jwk, err := p.MarshalJWK() + if err != nil { + return nil, err + } + + if err := s.storage.KeyWrite(ctx, jwk); err != nil { + return nil, err + } + + s.providers[kid] = p + } + + mux := http.NewServeMux() + mux.HandleFunc(jwksHTTPPath, s.jwksJSON) + + srv := &http.Server{ + Addr: ":3344", + Handler: mux, + } + + s.httpServer = srv + + return s, nil +} diff --git a/router/cmd/instance.go b/router/cmd/instance.go index 3ba0bf60a1..8e9118eb3d 100644 --- a/router/cmd/instance.go +++ b/router/cmd/instance.go @@ -264,11 +264,11 @@ func setupAuthenticators(ctx context.Context, logger *zap.Logger, cfg *config.Co continue } - if _, ok := headerSourceMap[s.Name]; !ok { - headerSourceMap[s.Name] = append(headerSourceMap[s.Name], s.ValuePrefix) + for _, prefix := range s.ValuePrefixes { + headerSourceMap[s.Name] = append(headerSourceMap[s.Name], prefix) + prefixSet[prefix] = struct{}{} } - prefixSet[s.ValuePrefix] = struct{}{} } opts := authentication.HttpHeaderAuthenticatorOptions{ diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index b21c04e879..4a572a1a65 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -405,9 +405,9 @@ type JWKSConfiguration struct { } type HeaderSource struct { - Type string `yaml:"type"` - Name string `yaml:"name"` - ValuePrefix string `yaml:"value_prefix"` + Type string `yaml:"type"` + Name string `yaml:"name"` + ValuePrefixes []string `yaml:"value_prefixes"` } type JWTAuthenticationConfiguration struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 22691eb749..dd4eb21c63 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -1492,9 +1492,12 @@ "format": "http-header", "examples": ["X-Authorization"] }, - "value_prefix": { - "type": "string", - "description": "The prefix of the header value. The prefix is used to extract the token from the header value." + "value_prefixes": { + "type": "array", + "description": "The prefixes of the header value. The prefixes are used to extract the token from the header value.", + "items": { + "type": "string" + } } }, "required": ["type", "name"] diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index 70e48b4b72..d514b6d431 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -224,7 +224,7 @@ authentication: header_sources: - type: header name: X-Authorization - value_prefix: Bearer + value_prefixes: [Bearer, Token] - type: header name: authz diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index cd57434cc6..f99281cf7f 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -365,12 +365,15 @@ { "Type": "header", "Name": "X-Authorization", - "ValuePrefix": "Bearer" + "ValuePrefixes": [ + "Bearer", + "Token" + ] }, { "Type": "header", "Name": "authz", - "ValuePrefix": "" + "ValuePrefixes": null } ] } From d89cad2c6b20404c3aee564b1631cee7c0cff471 Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Tue, 21 Jan 2025 13:42:04 +0100 Subject: [PATCH 09/12] chore: remove unused function --- router-tests/cmd/jwks-server/main.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/router-tests/cmd/jwks-server/main.go b/router-tests/cmd/jwks-server/main.go index 67f793134e..f5bf2131f0 100644 --- a/router-tests/cmd/jwks-server/main.go +++ b/router-tests/cmd/jwks-server/main.go @@ -186,20 +186,6 @@ func (s *server) printTokensForKeys(claims map[string]any) error { return nil } -func (s *server) token(claims map[string]any) (string, error) { - if len(s.providers) == 0 { - return "", jwt.ErrInvalidKey - } - - for kid, pr := range s.providers { - token := jwt.NewWithClaims(pr.SigningMethod(), jwt.MapClaims(claims)) - token.Header[jwkset.HeaderKID] = kid - return token.SignedString(pr.PrivateKey()) - } - - return "", jwt.ErrInvalidKey -} - func (s *server) tokenForKID(kid string, claims map[string]any) (string, error) { provider, ok := s.providers[kid] if !ok { From 27ca2743a0ff7a4dca8cc23818d0e84f9b5c890b Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Tue, 21 Jan 2025 14:49:53 +0100 Subject: [PATCH 10/12] test: make tests more verbose --- router-tests/authentication_test.go | 211 ++++++++++++++++++++++++---- 1 file changed, 185 insertions(+), 26 deletions(-) diff --git a/router-tests/authentication_test.go b/router-tests/authentication_test.go index eb1f7dcbf3..12bfcec7d7 100644 --- a/router-tests/authentication_test.go +++ b/router-tests/authentication_test.go @@ -1125,26 +1125,29 @@ func TestMultipleKeys(t *testing.T) { func TestSupportedAlgorithms(t *testing.T) { t.Parallel() - testAuthentication := func(t *testing.T, xEnv *testenv.Environment, token string) { - t.Helper() - - // Operations with a token should succeed - header := http.Header{ + authHeader := func(token string) http.Header { + return http.Header{ "Authorization": []string{"Bearer " + token}, } + } + + testRequest := func(t *testing.T, xEnv *testenv.Environment, header http.Header, expectSuccess bool) string { + t.Helper() + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQuery)) require.NoError(t, err) defer res.Body.Close() - require.Equal(t, http.StatusOK, res.StatusCode) - require.Equal(t, jwksName, res.Header.Get(xAuthenticatedByHeader)) - data, err := io.ReadAll(res.Body) - require.NoError(t, err) - require.Equal(t, employeesExpectedData, string(data)) - // Operation without a token should fail - res, err = xEnv.MakeRequest(http.MethodPost, "/graphql", nil, strings.NewReader(employeesQuery)) + if expectSuccess { + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, jwksName, res.Header.Get(xAuthenticatedByHeader)) + } else { + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + } + + data, err := io.ReadAll(res.Body) require.NoError(t, err) - require.Equal(t, http.StatusUnauthorized, res.StatusCode) + return string(data) } testSetup := func(t *testing.T, crypto jwks.Crypto, allowedAlgorithms ...string) (string, []authentication.Authenticator) { @@ -1192,7 +1195,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) @@ -1209,7 +1224,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) @@ -1226,7 +1253,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) @@ -1243,7 +1282,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) @@ -1260,7 +1311,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) @@ -1277,7 +1340,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) }) @@ -1298,7 +1373,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) @@ -1315,7 +1402,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) @@ -1332,7 +1431,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) }) @@ -1353,7 +1464,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) }) @@ -1374,7 +1497,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) @@ -1391,7 +1526,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) @@ -1408,7 +1555,19 @@ func TestSupportedAlgorithms(t *testing.T) { core.WithAccessController(core.NewAccessController(authenticators, true)), }, }, func(t *testing.T, xEnv *testenv.Environment) { - testAuthentication(t, xEnv, token) + t.Run("Should succeed when providing token", func(t *testing.T) { + t.Parallel() + body := testRequest(t, xEnv, authHeader(token), true) + require.Equal(t, employeesExpectedData, string(body)) + + }) + + t.Run("Should fail when providing no Token", func(t *testing.T) { + t.Parallel() + + body := testRequest(t, xEnv, nil, false) + require.JSONEq(t, unauthorizedExpectedData, body) + }) }) }) }) From db714d27b0c394990a97fc5fe3dd30e2d8db7680 Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Tue, 21 Jan 2025 15:11:18 +0100 Subject: [PATCH 11/12] chore: reference docs --- router-tests/cmd/jwks-server/main.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/router-tests/cmd/jwks-server/main.go b/router-tests/cmd/jwks-server/main.go index f5bf2131f0..852500f7ec 100644 --- a/router-tests/cmd/jwks-server/main.go +++ b/router-tests/cmd/jwks-server/main.go @@ -1,4 +1,8 @@ /* +[NOTE] +> For mor information on authentication and authorization, see the router documentation at +> https://cosmo-docs.wundergraph.com/router/authentication-and-authorization + This simple JWKS server can be used to test the router. Start the server before running an instance of the router to simulate JWKS integration with the router setup through instance.go. From 93e78c84c582766f6ab5627d8831f0f5197b9b8f Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Wed, 22 Jan 2025 11:27:14 +0100 Subject: [PATCH 12/12] chore: correct schema description --- router/pkg/config/config.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index dd4eb21c63..e5c05f1ecf 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -1483,7 +1483,7 @@ "properties": { "type": { "type": "string", - "description": "The type of the source. The supported types are 'header', 'cookie'.", + "description": "The type of the source. The only currently supported type is 'header'.", "enum": ["header"] }, "name": {