Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
ba7534f
fix: jwt validation blocks on multiple requests
SkArchon Sep 15, 2025
7aecf6a
Merge branch 'main' into milinda/eng-8149-jwk-refreshunknownkid-causi…
SkArchon Sep 15, 2025
79adec8
fix: review comments
SkArchon Sep 17, 2025
ca0aaac
fix: cleanup
SkArchon Sep 17, 2025
f56cae5
Merge branch 'main' into milinda/eng-8149-jwk-refreshunknownkid-causi…
SkArchon Sep 17, 2025
e0f1e53
fix: require equals
SkArchon Sep 17, 2025
3166aa8
Revert "fix: require equals"
SkArchon Sep 17, 2025
3e9ff2a
fix: tests
SkArchon Sep 17, 2025
eaf3c13
Merge branch 'main' into milinda/eng-8149-jwk-refreshunknownkid-causi…
SkArchon Sep 17, 2025
6978e0d
fix: tests
SkArchon Sep 18, 2025
dac1b99
Merge branch 'main' into milinda/eng-8149-jwk-refreshunknownkid-causi…
SkArchon Sep 18, 2025
c45b3be
Merge branch 'main' into milinda/eng-8149-jwk-refreshunknownkid-causi…
SkArchon Sep 18, 2025
a04b953
fix: make rate limit values configurable
SkArchon Sep 21, 2025
74ac0c5
fix: changes
SkArchon Sep 21, 2025
fd38a25
fix: tests
SkArchon Sep 21, 2025
24025e4
fix: default values and the comments
SkArchon Sep 22, 2025
2a3c31d
feat: allow algorithm be unspecified in jwks
SkArchon Sep 17, 2025
c4aa09e
fix: current
SkArchon Sep 17, 2025
62e7023
fix: updates
SkArchon Sep 17, 2025
7ed09a8
fix: cleanup
SkArchon Sep 17, 2025
2ac5999
fix: add schema
SkArchon Sep 17, 2025
47816e2
fix: refactoring
SkArchon Sep 17, 2025
3974969
fix: refactor comment
SkArchon Sep 17, 2025
3b93c05
fix: bug resolving
SkArchon Sep 17, 2025
fb6a32e
fix: review comments
SkArchon Sep 17, 2025
cea65d4
fix: audience
SkArchon Sep 17, 2025
c28dd2b
fix: initial validation store unit test
SkArchon Sep 18, 2025
c2e55b6
fix: compilation
SkArchon Sep 18, 2025
c7ca05c
fix: update validation store unit tests
SkArchon Sep 18, 2025
b81cd3c
fix: test cleanup
SkArchon Sep 18, 2025
747e23a
fix: cleanup
SkArchon Sep 18, 2025
482d572
fix: cleanup validation store
SkArchon Sep 22, 2025
1d571d0
fix: cleanup
SkArchon Sep 22, 2025
b45a263
fix: cleanup
SkArchon Sep 22, 2025
eda41f5
fix: cleanup
SkArchon Sep 22, 2025
ab80adb
fix: external dependency
SkArchon Sep 22, 2025
b3a4a4a
fix: go mod tidy
SkArchon Sep 22, 2025
8b4f77c
fix: update dependency
SkArchon Sep 22, 2025
4ec706d
fix: tests
SkArchon Sep 22, 2025
6c6b130
fix: tests
SkArchon Sep 22, 2025
04e1449
fix: schema
SkArchon Sep 22, 2025
2ff8405
fix: add tests
SkArchon Sep 23, 2025
8d767c4
fix: tests
SkArchon Sep 23, 2025
9cc810a
fix: updates
SkArchon Sep 30, 2025
688c884
Merge remote-tracking branch 'origin/main' into milinda/eng-8150-jwk-…
SkArchon Sep 30, 2025
a2d6bae
fix: comments
SkArchon Oct 1, 2025
25bad88
fix: using continue
SkArchon Oct 1, 2025
0c3286f
fix: tests
SkArchon Oct 1, 2025
113a61f
fix: tests
SkArchon Oct 1, 2025
59323d9
fix: go.mod updates
SkArchon Oct 1, 2025
54ad4f3
fix: imports
SkArchon Oct 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 272 additions & 1 deletion router-tests/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"bytes"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"net/http"
Expand Down Expand Up @@ -2228,7 +2230,6 @@ func TestSupportedAlgorithms(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) {
Expand Down Expand Up @@ -2790,7 +2791,105 @@ func TestAudienceValidation(t *testing.T) {
require.NoError(t, err)
require.JSONEq(t, unauthorizedExpectedData, string(data))
})
})

t.Run("audience validation succeeds even when one audience match fails", func(t *testing.T) {
t.Parallel()

t.Run("with http based configuration", func(t *testing.T) {
t.Parallel()

tokenAudiences := []string{"aud1"}

authServer1, err := jwks.NewServer(t)
require.NoError(t, err)
t.Cleanup(authServer1.Close)

authServer2, err := jwks.NewServer(t)
require.NoError(t, err)
t.Cleanup(authServer2.Close)

token, err := authServer1.Token(map[string]any{"aud": tokenAudiences})
require.NoError(t, err)

authenticators := ConfigureAuthWithJwksConfig(t, []authentication.JWKSConfig{
{
URL: authServer2.JWKSURL(),
RefreshInterval: time.Second * 5,
Audiences: []string{"aud2"},
},
{
URL: authServer1.JWKSURL(),
RefreshInterval: time.Second * 5,
Audiences: []string{"aud1", "aud5"},
},
})

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 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))
})
})

t.Run("with secret based configuration", func(t *testing.T) {
t.Parallel()

matchingAud := "matchingAudience"

secret := "example secret"
kid := "givenKID"
authenticators := ConfigureAuthWithJwksConfig(t, []authentication.JWKSConfig{
{
Secret: "secret",
Algorithm: string(jwkset.AlgHS256),
KeyId: "kid",
Audiences: []string{"aud3"},
},
{
Secret: secret,
Algorithm: string(jwkset.AlgHS256),
KeyId: kid,
Audiences: []string{matchingAud, "aud5"},
},
})

token := generateToken(t, kid, secret, jwt.SigningMethodHS256, jwt.MapClaims{
"aud": matchingAud,
})

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 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))
})
})
})

t.Run("audience validation is ignored when expected aud is not provided", func(t *testing.T) {
Expand Down Expand Up @@ -2831,6 +2930,178 @@ func TestAudienceValidation(t *testing.T) {
require.Equal(t, employeesExpectedData, string(data))
})
})

t.Run("valid token with empty algorithm in JWKS", func(t *testing.T) {
t.Parallel()

rsaCrypto, err := jwks.NewRSACrypto("", "", 2048)
require.NoError(t, err)

authServer, err := jwks.NewServerWithCrypto(t, rsaCrypto)
require.NoError(t, err)
t.Cleanup(authServer.Close)

authenticators := ConfigureAuthWithJwksConfig(t, []authentication.JWKSConfig{
{
URL: authServer.JWKSURL(),
RefreshInterval: time.Second * 5,
},
})

testenv.Run(t, &testenv.Config{
RouterOptions: []core.Option{
core.WithAccessController(core.NewAccessController(authenticators, false)),
},
}, func(t *testing.T, xEnv *testenv.Environment) {
// Operations with a token should succeed
token, err := authServer.TokenWithOpts(nil, jwks.TokenOpts{
AlgOverride: string(jwkset.AlgRS256),
})
require.NoError(t, err)
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))
})
})

t.Run("verify blocking invalid specified algorithm even though token is valid", func(t *testing.T) {
t.Parallel()

rsaCrypto, err := jwks.NewRSACrypto("", "", 2048)
require.NoError(t, err)

authServer, err := jwks.NewServerWithCrypto(t, rsaCrypto)
require.NoError(t, err)
t.Cleanup(authServer.Close)

allowedAlgorithm := jwkset.AlgRS256

authenticators := ConfigureAuthWithJwksConfig(t, []authentication.JWKSConfig{
{
URL: authServer.JWKSURL(),
RefreshInterval: time.Second * 5,
AllowedAlgorithms: []string{string(allowedAlgorithm)},
},
})

testenv.Run(t, &testenv.Config{
RouterOptions: []core.Option{
core.WithAccessController(core.NewAccessController(authenticators, false)),
},
}, func(t *testing.T, xEnv *testenv.Environment) {
// Fail with RS512
token2, err := authServer.TokenWithOpts(nil, jwks.TokenOpts{
AlgOverride: string(jwkset.AlgRS512),
})
require.NoError(t, err)
res2, err := xEnv.MakeRequest(http.MethodPost, "/graphql", http.Header{
"Authorization": []string{"Bearer " + token2},
}, strings.NewReader(employeesQuery))
require.NoError(t, err)
defer func() {
_ = res2.Body.Close()
}()
require.Equal(t, http.StatusUnauthorized, res2.StatusCode)
})
})

t.Run("verify blocking invalid algorithm", func(t *testing.T) {
t.Parallel()

rsaCrypto, err := jwks.NewRSACrypto("", "R4ND0M", 2048)
require.NoError(t, err)

authServer, err := jwks.NewServerWithCrypto(t, rsaCrypto)
require.NoError(t, err)
t.Cleanup(authServer.Close)

authenticators := ConfigureAuthWithJwksConfig(t, []authentication.JWKSConfig{
toJWKSConfig(authServer.JWKSURL(), time.Second*5),
})

testenv.Run(t, &testenv.Config{
RouterOptions: []core.Option{
core.WithAccessController(core.NewAccessController(authenticators, true)),
},
}, func(t *testing.T, xEnv *testenv.Environment) {
// Manually craft a JWT with an unregistered/unknown alg value
hdr := map[string]any{"alg": "R4ND0M", "typ": "JWT", jwkset.HeaderKID: rsaCrypto.KID()}
pl := map[string]any{}
hBytes, err := json.Marshal(hdr)
require.NoError(t, err)
pBytes, err := json.Marshal(pl)
require.NoError(t, err)
signed := base64.RawURLEncoding.EncodeToString(hBytes) + "." + base64.RawURLEncoding.EncodeToString(pBytes) + ".bogus"

header := http.Header{"Authorization": []string{"Bearer " + signed}}
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)
data, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.JSONEq(t, unauthorizedExpectedData, string(data))
})
})

t.Run("valid token for second entry with empty algorithm in JWKS", func(t *testing.T) {
t.Parallel()

rsaCrypto, err := jwks.NewRSACrypto("", "", 2048)
require.NoError(t, err)

authServer1, err := jwks.NewServerWithCrypto(t, rsaCrypto)
require.NoError(t, err)
t.Cleanup(authServer1.Close)

authServer2, err := jwks.NewServerWithCrypto(t, rsaCrypto)
require.NoError(t, err)
t.Cleanup(authServer2.Close)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
authenticators := ConfigureAuthWithJwksConfig(t, []authentication.JWKSConfig{
{
URL: authServer1.JWKSURL(),
RefreshInterval: time.Second * 5,
AllowedAlgorithms: []string{string(jwkset.AlgRS256)},
},
{
URL: authServer2.JWKSURL(),
RefreshInterval: time.Second * 5,
AllowedAlgorithms: []string{string(jwkset.AlgRS512)},
},
})

testenv.Run(t, &testenv.Config{
RouterOptions: []core.Option{
core.WithAccessController(core.NewAccessController(authenticators, false)),
},
}, func(t *testing.T, xEnv *testenv.Environment) {
// Operations with a token should succeed
token, err := authServer2.TokenWithOpts(nil, jwks.TokenOpts{
AlgOverride: string(jwkset.AlgRS512),
})
require.NoError(t, err)
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))
})
})
}

func toJWKSConfig(url string, refresh time.Duration, allowedAlgorithms ...string) authentication.JWKSConfig {
Expand Down
4 changes: 2 additions & 2 deletions router-tests/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/wundergraph/cosmo/router-tests
go 1.25

require (
github.com/MicahParks/jwkset v0.9.0
github.com/MicahParks/jwkset v0.11.0
github.com/buger/jsonparser v1.1.1
github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0
github.com/golang-jwt/jwt/v5 v5.2.2
Expand Down Expand Up @@ -45,7 +45,7 @@ require (
connectrpc.com/connect v1.16.2 // indirect
github.com/99designs/gqlgen v0.17.76 // indirect
github.com/KimMachineGun/automemlimit v0.6.1 // indirect
github.com/MicahParks/keyfunc/v3 v3.3.5 // indirect
github.com/MicahParks/keyfunc/v3 v3.6.2 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
Expand Down
8 changes: 4 additions & 4 deletions router-tests/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ github.com/99designs/gqlgen v0.17.76/go.mod h1:miiU+PkAnTIDKMQ1BseUOIVeQHoiwYDZG
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/KimMachineGun/automemlimit v0.6.1 h1:ILa9j1onAAMadBsyyUJv5cack8Y1WT26yLj/V+ulKp8=
github.com/KimMachineGun/automemlimit v0.6.1/go.mod h1:T7xYht7B8r6AG/AqFcUdc7fzd2bIdBKmepfP2S1svPY=
github.com/MicahParks/jwkset v0.9.0 h1:xDlGu6mZJdJ+mgAI4mIRqWm2p8Vrx0U98LMgRObw46M=
github.com/MicahParks/jwkset v0.9.0/go.mod h1:fVrj6TmG1aKlJEeceAz7JsXGTXEn72zP1px3us53JrA=
github.com/MicahParks/keyfunc/v3 v3.3.5 h1:7ceAJLUAldnoueHDNzF8Bx06oVcQ5CfJnYwNt1U3YYo=
github.com/MicahParks/keyfunc/v3 v3.3.5/go.mod h1:SdCCyMJn/bYqWDvARspC6nCT8Sk74MjuAY22C7dCST8=
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.6.2 h1:82rre60MKw4r117ew5/T4m1AphgkpCOYry0RPbFUY3w=
github.com/MicahParks/keyfunc/v3 v3.6.2/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
Expand Down
17 changes: 16 additions & 1 deletion router-tests/jwks/jwks.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,28 @@ func (s *Server) Close() {
s.httpServer.Close()
}

type TokenOpts struct {
AlgOverride string
}

func (s *Server) Token(claims map[string]any) (string, error) {
return s.TokenWithOpts(claims, TokenOpts{AlgOverride: ""})
}

func (s *Server) TokenWithOpts(claims map[string]any, tokenOpts TokenOpts) (string, error) {
if len(s.providers) == 0 {
return "", jwt.ErrInvalidKey
}

for kid, pr := range s.providers {
token := jwt.NewWithClaims(pr.SigningMethod(), jwt.MapClaims(claims))
method := pr.SigningMethod()
if tokenOpts.AlgOverride != "" {
method = jwt.GetSigningMethod(tokenOpts.AlgOverride)
if method == nil {
return "", fmt.Errorf("unsupported signing method: %s", tokenOpts.AlgOverride)
}
}
token := jwt.NewWithClaims(method, jwt.MapClaims(claims))
token.Header[jwkset.HeaderKID] = kid
return token.SignedString(pr.PrivateKey())
}
Expand Down
4 changes: 2 additions & 2 deletions router/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ require (

require (
github.com/KimMachineGun/automemlimit v0.6.1
github.com/MicahParks/jwkset v0.9.0
github.com/MicahParks/keyfunc/v3 v3.3.5
github.com/MicahParks/jwkset v0.11.0
github.com/MicahParks/keyfunc/v3 v3.6.2
github.com/alicebob/miniredis/v2 v2.34.0
github.com/caarlos0/env/v11 v11.3.1
github.com/cep21/circuit/v4 v4.0.0
Expand Down
8 changes: 4 additions & 4 deletions router/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ github.com/99designs/gqlgen v0.17.49/go.mod h1:tC8YFVZMed81x7UJ7ORUwXF4Kn6SXuucF
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/KimMachineGun/automemlimit v0.6.1 h1:ILa9j1onAAMadBsyyUJv5cack8Y1WT26yLj/V+ulKp8=
github.com/KimMachineGun/automemlimit v0.6.1/go.mod h1:T7xYht7B8r6AG/AqFcUdc7fzd2bIdBKmepfP2S1svPY=
github.com/MicahParks/jwkset v0.9.0 h1:xDlGu6mZJdJ+mgAI4mIRqWm2p8Vrx0U98LMgRObw46M=
github.com/MicahParks/jwkset v0.9.0/go.mod h1:fVrj6TmG1aKlJEeceAz7JsXGTXEn72zP1px3us53JrA=
github.com/MicahParks/keyfunc/v3 v3.3.5 h1:7ceAJLUAldnoueHDNzF8Bx06oVcQ5CfJnYwNt1U3YYo=
github.com/MicahParks/keyfunc/v3 v3.3.5/go.mod h1:SdCCyMJn/bYqWDvARspC6nCT8Sk74MjuAY22C7dCST8=
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.6.2 h1:82rre60MKw4r117ew5/T4m1AphgkpCOYry0RPbFUY3w=
github.com/MicahParks/keyfunc/v3 v3.6.2/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
Expand Down
Loading
Loading