Skip to content

Commit 8c68eac

Browse files
ivan-digitalivan-digital
andauthored
feat: add jwtValidationKeys filter for JWT validation with direct JWKS URL (#3922)
## Summary - Add new `jwtValidationKeys` filter that verifies JWT Bearer tokens using a JWKS URL directly, without requiring OIDC discovery via `.well-known/openid-configuration` - Reuses existing `jwtValidationFilter` — the new spec only provides an alternative entry point that skips OIDC discovery - Claims validation delegated to `oidcClaimsQuery` as per existing convention - Registered alongside `jwtValidation` in skipper.go ## Motivation The existing `jwtValidation` filter only supports JWKS discovery via `.well-known/openid-configuration`. Services like Google Chat bots sign webhook requests with JWTs but publish their public keys at non-standard JWKS endpoints without OIDC discovery support, making it impossible to verify these tokens with the current filter. ## Usage ``` jwtValidationKeys("https://www.googleapis.com/service_accounts/v1/jwk/chat@system.gserviceaccount.com") -> oidcClaimsQuery("/:@_:iss==\"chat@system.gserviceaccount.com\"") -> oidcClaimsQuery("/:@_:aud==\"123456789\"") ``` Closes #3921 ## Test plan - [x] Spec validation (missing args, too many args, non-string args) - [x] Valid token, expired token, missing sub claim - [x] Missing/empty/malformed Bearer tokens - [x] Algorithm none rejected - [x] Existing jwtValidation tests still pass --------- Signed-off-by: ivan-digital <root@ivan.digital> Co-authored-by: ivan-digital <root@ivan.digital>
1 parent c94ddc1 commit 8c68eac

5 files changed

Lines changed: 212 additions & 5 deletions

File tree

docs/reference/filters.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,6 +1681,30 @@ Examples:
16811681
jwtValidation("https://login.microsoftonline.com/{tenantId}/v2.0")
16821682
```
16831683

1684+
#### jwtValidationKeys
1685+
1686+
The filter works like [jwtValidation](#jwtvalidation) but takes a JWKS URL directly instead of
1687+
discovering it via `/.well-known/openid-configuration`. This is useful for services that publish
1688+
JWKS keys at non-standard endpoints, such as Google Chat service accounts.
1689+
Unlike `jwtValidation`, the `sub` claim is not required — tokens without `sub` are accepted.
1690+
1691+
The filter stores token claims into the state bag where they can be used by [oidcClaimsQuery](#oidcclaimsquery), [forwardToken](#forwardtoken) or [forwardTokenField](#forwardtokenfield) filters.
1692+
1693+
Examples:
1694+
1695+
```
1696+
jwtValidationKeys("https://www.googleapis.com/service_accounts/v1/jwk/chat@system.gserviceaccount.com")
1697+
```
1698+
1699+
To also validate specific claims like `iss` or `aud`, chain with [oidcClaimsQuery](#oidcclaimsquery).
1700+
Note that queries within a single `oidcClaimsQuery` argument are OR-matched, so use separate filters for AND logic:
1701+
1702+
```
1703+
jwtValidationKeys("https://www.googleapis.com/service_accounts/v1/jwk/chat@system.gserviceaccount.com")
1704+
-> oidcClaimsQuery("/:@_:iss==\"chat@system.gserviceaccount.com\"")
1705+
-> oidcClaimsQuery("/:@_:aud==\"123456789\"")
1706+
```
1707+
16841708
#### jwtMetrics
16851709

16861710
> This filter is experimental and may change in the future, please see tests for example usage.

filters/auth/jwt_validation.go

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ type (
2121
options TokenintrospectionOptions
2222
}
2323

24+
jwtValidationKeysSpec struct{}
25+
2426
jwtValidationFilter struct {
25-
jwksUri string
27+
jwksUri string
28+
requireSub bool
2629
}
2730
)
2831

@@ -69,7 +72,8 @@ func (s *jwtValidationSpec) CreateFilter(args []interface{}) (filters.Filter, er
6972
}
7073

7174
f := &jwtValidationFilter{
72-
jwksUri: cfg.JwksURI,
75+
jwksUri: cfg.JwksURI,
76+
requireSub: true,
7377
}
7478

7579
return f, nil
@@ -146,18 +150,65 @@ func (f *jwtValidationFilter) Request(ctx filters.FilterContext) {
146150
}
147151

148152
sub, ok := info.Claims["sub"].(string)
149-
if !ok {
150-
unauthorized(ctx, sub, invalidSub, "", "")
151-
return
153+
if !ok || sub == "" {
154+
if f.requireSub {
155+
unauthorized(ctx, "", invalidSub, "", "")
156+
return
157+
}
158+
sub = AuthUnknown
159+
info.Claims["sub"] = sub
152160
}
153161

162+
info.Subject = sub
154163
authorized(ctx, sub)
155164

156165
ctx.StateBag()[oidcClaimsCacheKey] = info
157166
}
158167

159168
func (f *jwtValidationFilter) Response(filters.FilterContext) {}
160169

170+
// NewJwtValidationKeys creates a filter spec for JWT validation using a direct JWKS URL.
171+
//
172+
// Unlike jwtValidation which discovers JWKS via .well-known/openid-configuration,
173+
// this filter takes the JWKS URL directly. This is useful for services that publish
174+
// JWKS keys at non-standard endpoints (e.g. Google Chat service accounts).
175+
//
176+
// The filter stores token claims into the state bag where they can be used by
177+
// oidcClaimsQuery, forwardToken or forwardTokenField filters.
178+
//
179+
// Usage:
180+
//
181+
// jwtValidationKeys("https://www.googleapis.com/service_accounts/v1/jwk/chat@system.gserviceaccount.com")
182+
func NewJwtValidationKeys() filters.Spec {
183+
return &jwtValidationKeysSpec{}
184+
}
185+
186+
func (s *jwtValidationKeysSpec) Name() string {
187+
return filters.JwtValidationKeysName
188+
}
189+
190+
func (s *jwtValidationKeysSpec) CreateFilter(args []interface{}) (filters.Filter, error) {
191+
if len(args) != 1 {
192+
return nil, filters.ErrInvalidFilterParameters
193+
}
194+
195+
sargs, err := getStrings(args)
196+
if err != nil {
197+
return nil, err
198+
}
199+
200+
jwksURL := sargs[0]
201+
202+
if err := registerKeyFunction(jwksURL); err != nil {
203+
return nil, err
204+
}
205+
206+
return &jwtValidationFilter{
207+
jwksUri: jwksURL,
208+
requireSub: false,
209+
}, nil
210+
}
211+
161212
func parseToken(token string, jwksUri string) (map[string]interface{}, error) {
162213
jwks := getKeyFunction(jwksUri)
163214

filters/auth/jwt_validation_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,136 @@ func TestJWTValidation(t *testing.T) {
146146
}
147147
}
148148

149+
func createTokenWithKey(t *testing.T, key *rsa.PrivateKey, claims jwt.MapClaims) string {
150+
t.Helper()
151+
152+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
153+
token.Header["kid"] = kid
154+
155+
s, err := token.SignedString(key)
156+
if err != nil {
157+
t.Fatalf("Failed to sign token: %v", err)
158+
}
159+
return s
160+
}
161+
162+
func setupJWKSServer(t *testing.T, key *rsa.PrivateKey) *httptest.Server {
163+
t.Helper()
164+
165+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
166+
fmt.Fprintf(w, `{"keys":[{"kty":"RSA", "alg":"RS256", "kid": "%s", "n":"%s","e":"AQAB"}]}`,
167+
kid, base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()))
168+
}))
169+
}
170+
171+
func TestJwtValidationKeysSpec(t *testing.T) {
172+
spec := NewJwtValidationKeys()
173+
174+
if spec.Name() != filters.JwtValidationKeysName {
175+
t.Errorf("unexpected name: %s", spec.Name())
176+
}
177+
178+
// No arguments
179+
_, err := spec.CreateFilter([]interface{}{})
180+
if err == nil {
181+
t.Error("expected error with no arguments")
182+
}
183+
184+
// Too many arguments
185+
_, err = spec.CreateFilter([]interface{}{"url1", "url2"})
186+
if err == nil {
187+
t.Error("expected error with too many arguments")
188+
}
189+
190+
// Non-string argument
191+
_, err = spec.CreateFilter([]interface{}{123})
192+
if err == nil {
193+
t.Error("expected error with non-string argument")
194+
}
195+
}
196+
197+
func TestJwtValidationKeys(t *testing.T) {
198+
key, err := rsa.GenerateKey(rand.Reader, 2048)
199+
if err != nil {
200+
t.Fatalf("Failed to generate key: %v", err)
201+
}
202+
203+
jwksServer := setupJWKSServer(t, key)
204+
defer jwksServer.Close()
205+
206+
cli := net.NewClient(net.Options{
207+
IdleConnTimeout: 2 * time.Second,
208+
})
209+
defer cli.Close()
210+
211+
backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
212+
defer backend.Close()
213+
214+
spec := NewJwtValidationKeys()
215+
fr := make(filters.Registry)
216+
fr.Register(spec)
217+
218+
r := &eskip.Route{
219+
Filters: []*eskip.Filter{{Name: spec.Name(), Args: []interface{}{jwksServer.URL}}},
220+
Backend: backend.URL,
221+
}
222+
223+
proxy := proxytest.New(fr, r)
224+
defer proxy.Close()
225+
226+
for _, ti := range []struct {
227+
name string
228+
auth string
229+
expected int
230+
}{{
231+
name: "valid token",
232+
auth: authHeaderPrefix + createTokenWithKey(t, key, jwt.MapClaims{"sub": "user1", "exp": jwt.NewNumericDate(time.Now().Add(time.Hour)).Unix()}),
233+
expected: http.StatusOK,
234+
}, {
235+
name: "expired token",
236+
auth: authHeaderPrefix + createTokenWithKey(t, key, jwt.MapClaims{"sub": "user1", "exp": jwt.NewNumericDate(time.Now().Add(-time.Hour)).Unix()}),
237+
expected: http.StatusUnauthorized,
238+
}, {
239+
name: "missing sub claim accepted",
240+
auth: authHeaderPrefix + createTokenWithKey(t, key, jwt.MapClaims{"iss": "test", "exp": jwt.NewNumericDate(time.Now().Add(time.Hour)).Unix()}),
241+
expected: http.StatusOK,
242+
}, {
243+
name: "no authorization header",
244+
auth: "",
245+
expected: http.StatusUnauthorized,
246+
}, {
247+
name: "empty bearer token",
248+
auth: authHeaderPrefix,
249+
expected: http.StatusUnauthorized,
250+
}, {
251+
name: "invalid token format",
252+
auth: authHeaderPrefix + "not-a-jwt",
253+
expected: http.StatusUnauthorized,
254+
}, {
255+
name: "algorithm none rejected",
256+
auth: authHeaderPrefix + createToken(t, jwt.SigningMethodNone),
257+
expected: http.StatusUnauthorized,
258+
}} {
259+
t.Run(ti.name, func(t *testing.T) {
260+
reqURL, _ := url.Parse(proxy.URL)
261+
req, _ := http.NewRequest("GET", reqURL.String(), nil)
262+
if ti.auth != "" {
263+
req.Header.Set(authHeaderName, ti.auth)
264+
}
265+
266+
rsp, err := cli.Do(req)
267+
if err != nil {
268+
t.Fatalf("failed to get response: %v", err)
269+
}
270+
defer rsp.Body.Close()
271+
272+
if rsp.StatusCode != ti.expected {
273+
t.Errorf("unexpected status code: %v != %v", rsp.StatusCode, ti.expected)
274+
}
275+
})
276+
}
277+
}
278+
149279
func TestJWTValidationJwksError(t *testing.T) {
150280
testOidcConfig := getTestOidcConfig()
151281

filters/filters.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ const (
315315
GrantLogoutName = "grantLogout"
316316
GrantClaimsQueryName = "grantClaimsQuery"
317317
JwtValidationName = "jwtValidation"
318+
JwtValidationKeysName = "jwtValidationKeys"
318319
JwtMetricsName = "jwtMetrics"
319320
OAuthOidcUserInfoName = "oauthOidcUserInfo"
320321
OAuthOidcAnyClaimsName = "oauthOidcAnyClaims"

skipper.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,6 +1875,7 @@ func run(o Options, sig chan os.Signal, idleConnsCH chan struct{}) error {
18751875
auth.NewBearerInjector(sp),
18761876
auth.NewSetRequestHeaderFromSecret(sp),
18771877
auth.NewJwtValidationWithOptions(tio),
1878+
auth.NewJwtValidationKeys(),
18781879
auth.NewJwtMetrics(),
18791880
auth.TokenintrospectionWithOptions(auth.NewOAuthTokenintrospectionAnyClaims, tio),
18801881
auth.TokenintrospectionWithOptions(auth.NewOAuthTokenintrospectionAllClaims, tio),

0 commit comments

Comments
 (0)