From b55d6689002daa35d9019bf657d62a461e61c6c0 Mon Sep 17 00:00:00 2001 From: Vasily Tsybenko Date: Fri, 13 Dec 2024 15:16:01 +0200 Subject: [PATCH] Add scope filtering support for jwt.Parser/jwt.CachingParser --- auth.go | 12 ++++++++++++ auth_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++-- jwt/parser.go | 34 +++++++++++++++++++++++++++++----- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/auth.go b/auth.go index f90ba86..d387096 100644 --- a/auth.go +++ b/auth.go @@ -62,6 +62,7 @@ func NewJWTParser(cfg *Config, opts ...JWTParserOption) (JWTParser, error) { TrustedIssuerNotFoundFallback: options.trustedIssuerNotFoundFallback, LoggerProvider: options.loggerProvider, ClaimsTemplate: options.claimsTemplate, + ScopeFilter: options.scopeFilter, } if cfg.JWT.ClaimsCache.Enabled { @@ -90,6 +91,7 @@ type jwtParserOptions struct { prometheusLibInstanceLabel string trustedIssuerNotFoundFallback jwt.TrustedIssNotFoundFallback claimsTemplate jwt.Claims + scopeFilter jwt.ScopeFilter } // JWTParserOption is an option for creating JWTParser. @@ -123,6 +125,16 @@ func WithJWTParserClaimsTemplate(claimsTemplate jwt.Claims) JWTParserOption { } } +// WithJWTParserScopeFilter sets the scope filter for JWTParser. +// If it's used, then only access policies in scope that match at least one of the filtering policies will be returned. +// It's useful when the claims cache is used (cfg.JWT.ClaimsCache.Enabled is true), +// and we want to store only some of the access policies in the cache to reduce memory usage. +func WithJWTParserScopeFilter(scopeFilter jwt.ScopeFilter) JWTParserOption { + return func(options *jwtParserOptions) { + options.scopeFilter = scopeFilter + } +} + // NewTokenIntrospector creates a new TokenIntrospector with the given configuration, token provider and scope filter. // If cfg.Introspection.ClaimsCache.Enabled or cfg.Introspection.NegativeCache.Enabled is true, // then idptoken.CachingIntrospector created, otherwise - idptoken.Introspector. diff --git a/auth_test.go b/auth_test.go index e00fb89..527b502 100644 --- a/auth_test.go +++ b/auth_test.go @@ -49,7 +49,12 @@ func TestNewJWTParser(t *gotesting.T) { Issuer: idpSrv.URL(), ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(10 * time.Second)), }, - Scope: []jwt.AccessPolicy{{ResourceNamespace: "my-service", Role: "ro_admin"}}, + Scope: []jwt.AccessPolicy{ + {ResourceNamespace: "my-service", Role: "ro_admin"}, + {ResourceNamespace: "other-service", Role: "ro_admin"}, + {ResourceNamespace: "my-service", Role: "admin"}, + {ResourceNamespace: "other-service", Role: "admin"}, + }, } token := idptest.MustMakeTokenStringSignedWithTestKey(claims) @@ -66,6 +71,7 @@ func TestNewJWTParser(t *gotesting.T) { name string token string cfg *Config + opts []JWTParserOption expectedClaims jwt.Claims checkFn func(t *gotesting.T, jwtParser JWTParser) }{ @@ -109,10 +115,44 @@ func TestNewJWTParser(t *gotesting.T) { require.Equal(t, 1, cachingParser.ClaimsCache.Len()) }, }, + { + name: "new jwt parser with scope filter", + cfg: &Config{JWT: JWTConfig{TrustedIssuerURLs: []string{idpSrv.URL()}}}, + token: token, + expectedClaims: &jwt.DefaultClaims{ + RegisteredClaims: claims.RegisteredClaims, + Scope: []jwt.AccessPolicy{ + {ResourceNamespace: "my-service", Role: "ro_admin"}, + {ResourceNamespace: "my-service", Role: "admin"}, + }, + }, + opts: []JWTParserOption{WithJWTParserScopeFilter(jwt.ScopeFilter{{ResourceNamespace: "my-service"}})}, + checkFn: func(t *gotesting.T, jwtParser JWTParser) { + require.IsType(t, &jwt.Parser{}, jwtParser) + }, + }, + { + name: "new caching jwt parser with scope filter", + cfg: &Config{JWT: JWTConfig{TrustedIssuerURLs: []string{idpSrv.URL()}, ClaimsCache: ClaimsCacheConfig{Enabled: true}}}, + token: token, + expectedClaims: &jwt.DefaultClaims{ + RegisteredClaims: claims.RegisteredClaims, + Scope: []jwt.AccessPolicy{ + {ResourceNamespace: "other-service", Role: "ro_admin"}, + {ResourceNamespace: "other-service", Role: "admin"}, + }, + }, + opts: []JWTParserOption{WithJWTParserScopeFilter(jwt.ScopeFilter{{ResourceNamespace: "other-service"}})}, + checkFn: func(t *gotesting.T, jwtParser JWTParser) { + require.IsType(t, &jwt.CachingParser{}, jwtParser) + cachingParser := jwtParser.(*jwt.CachingParser) + require.Equal(t, 1, cachingParser.ClaimsCache.Len()) + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *gotesting.T) { - jwtParser, err := NewJWTParser(tt.cfg) + jwtParser, err := NewJWTParser(tt.cfg, tt.opts...) require.NoError(t, err) parsedClaims, err := jwtParser.Parse(context.Background(), tt.token) diff --git a/jwt/parser.go b/jwt/parser.go index 1ea430b..319c6b3 100644 --- a/jwt/parser.go +++ b/jwt/parser.go @@ -32,12 +32,31 @@ type CachingKeysProvider interface { // ParserOpts additional options for parser. type ParserOpts struct { - SkipClaimsValidation bool - RequireAudience bool - ExpectedAudience []string + // SkipClaimsValidation is a flag that indicates whether claims validation (e.g. checking expiration time) should be skipped. + // It doesn't affect signature verification. + SkipClaimsValidation bool + + // RequireAudience is a flag that indicates whether audience should be required. + RequireAudience bool + + // ExpectedAudience is a list of expected audience patterns. + // If it's set, then only tokens with audience that matches at least one of the patterns will be accepted. + ExpectedAudience []string + + // TrustedIssuerNotFoundFallback is a function called when given issuer is not found in the list of trusted ones. TrustedIssuerNotFoundFallback TrustedIssNotFoundFallback - LoggerProvider func(ctx context.Context) log.FieldLogger - ClaimsTemplate Claims + + // LoggerProvider is a function that provides a logger for the Parser. + LoggerProvider func(ctx context.Context) log.FieldLogger + + // ClaimsTemplate is a template for claims object that will be used for unmarshalling JWT. + // By default, DefaultClaims is used. + ClaimsTemplate Claims + + // ScopeFilter is a filter that will be applied to access policies in JWT scope after parsing. + // If it's set, then only access policies in scope that match at least one of the filtering policies will be returned. + // It's useful when the CachingParser is used, and we want to store only some of the access policies in the cache to reduce memory usage. + ScopeFilter ScopeFilter } type audienceMatcher func(aud string) bool @@ -58,6 +77,8 @@ type Parser struct { trustedIssuerNotFoundFallback TrustedIssNotFoundFallback loggerProvider func(ctx context.Context) log.FieldLogger + + scopeFilter ScopeFilter } // NewParser creates new JWT parser with specified keys provider. @@ -88,6 +109,7 @@ func NewParserWithOpts(keysProvider KeysProvider, opts ParserOpts) *Parser { trustedIssuerNotFoundFallback: opts.TrustedIssuerNotFoundFallback, loggerProvider: opts.LoggerProvider, claimsTemplate: claimsTemplate, + scopeFilter: opts.ScopeFilter, } } @@ -148,6 +170,8 @@ func (p *Parser) Parse(ctx context.Context, token string) (Claims, error) { } } + claims.ApplyScopeFilter(p.scopeFilter) + return claims, nil }