diff --git a/router-tests/authentication_test.go b/router-tests/authentication_test.go index fd0c4bfdc9..12bfcec7d7 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 ( @@ -599,13 +604,13 @@ 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, + Name: jwksName, + HeaderSourcePrefixes: map[string][]string{ + headerName: {headerValuePrefix}, + }, + TokenDecoder: tokenDecoder, } authenticator, err := authentication.NewHttpHeaderAuthenticator(authOptions) require.NoError(t, err) @@ -734,23 +739,25 @@ 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, + Name: "1", + HeaderSourcePrefixes: map[string][]string{ + "Authorization": authenticator1HeaderValuePrefixes, + }, + 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, + Name: "2", + HeaderSourcePrefixes: map[string][]string{ + "Authorization": authenticator2HeaderValuePrefixes, + }, + TokenDecoder: tokenDecoder2, }) require.NoError(t, err) authenticators := []authentication.Authenticator{authenticator1, authenticator2} @@ -835,6 +842,761 @@ func TestAuthenticationMultipleProviders(t *testing.T) { require.JSONEq(t, unauthorizedExpectedData, string(data)) }) }) + +} + +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() + + 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() + + 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) + return string(data) + } + + 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, allowedAlgorithms...)}) + 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) { + 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) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + + 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) { + 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) + }) + }) + }) + }) + + 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) { @@ -844,10 +1606,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 +1642,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/cmd/jwks-server/main.go b/router-tests/cmd/jwks-server/main.go new file mode 100644 index 0000000000..852500f7ec --- /dev/null +++ b/router-tests/cmd/jwks-server/main.go @@ -0,0 +1,251 @@ +/* +[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. + +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) 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-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/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/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) diff --git a/router-tests/utils.go b/router-tests/utils.go index 9fda3aebce..5e87291c9b 100644 --- a/router-tests/utils.go +++ b/router-tests/utils.go @@ -45,10 +45,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 4828613370..c5280d4f0b 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{ @@ -267,6 +229,85 @@ 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 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{}) + + for _, s := range jwtConf.HeaderSources { + if s.Type != "header" { + continue + } + + for _, prefix := range s.ValuePrefixes { + headerSourceMap[s.Name] = append(headerSourceMap[s.Name], prefix) + prefixSet[prefix] = struct{}{} + } + + } + + opts := authentication.HttpHeaderAuthenticatorOptions{ + Name: "jwks", + HeaderSourcePrefixes: headerSourceMap, + 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 { + 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, + 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..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,14 +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 - // 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 - // 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 } @@ -76,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 } diff --git a/router/pkg/authentication/jwks_token_decoder.go b/router/pkg/authentication/jwks_token_decoder.go index e845805b97..6b521f8071 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 { @@ -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: NewValidationStore(logger, nil, c.AllowedAlgorithms), + } + + 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/authentication/validation_store.go b/router/pkg/authentication/validation_store.go new file mode 100644 index 0000000000..b74b4949b7 --- /dev/null +++ b/router/pkg/authentication/validation_store.go @@ -0,0 +1,140 @@ +package authentication + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/MicahParks/jwkset" + "go.uber.org/zap" +) + +var _ jwkset.Storage = (*validationStore)(nil) + +type validationStore struct { + logger *zap.Logger + 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(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 store + } + + for _, alg := range algs { + if _, ok := supportedAlgorithms[alg]; !ok { + logger.Warn("Unsupported algorithm", zap.String("algorithm", alg)) + continue + } + algSet[alg] = struct{}{} + } + + store.algs = algSet + return store +} + +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 { + // 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) +} + +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) +} diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 45457e36eb..2322a14bfb 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -398,20 +398,27 @@ 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"` +type HeaderSource struct { + Type string `yaml:"type"` + Name string `yaml:"name"` + ValuePrefixes []string `yaml:"value_prefixes"` +} + +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 8ee6c27f62..a9c2b35163 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -1456,17 +1456,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": { @@ -1475,20 +1472,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": { + "algorithms": { "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"], + "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" - } - }, - "header_value_prefixes": { - "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"], - "items": { - "type": "string" + "type": "string", + "enum": ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] } }, "refresh_interval": { @@ -1503,7 +1492,45 @@ "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 only currently supported type is 'header'.", + "enum": ["header"] + }, + "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_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 7b38221ab7..c543ab8568 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -214,15 +214,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_prefixes: [Bearer, Token] + - type: header + 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 b58010dca5..3d353b379b 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -180,7 +180,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 3beff6cec7..e99cb1719b 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -341,21 +341,47 @@ "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", + "ValuePrefixes": [ + "Bearer", + "Token" + ] + }, + { + "Type": "header", + "Name": "authz", + "ValuePrefixes": null + } + ] + } }, "Authorization": { "RequireAuthentication": false,