From 552a0de9062d111c9d5ccd09e2d016803d00655d Mon Sep 17 00:00:00 2001 From: Vasily Tsybenko Date: Thu, 19 Dec 2024 15:31:58 +0200 Subject: [PATCH] Validate token's exp and aud claims after introspection --- auth.go | 2 + auth_test.go | 17 ++- idptoken/introspector.go | 56 +++++++--- idptoken/introspector_test.go | 199 +++++++++++++++++++++++++-------- internal/metrics/metrics.go | 1 + jwt/audience_validator.go | 58 ++++++++++ jwt/audience_validator_test.go | 102 +++++++++++++++++ jwt/caching_parser.go | 2 +- jwt/parser.go | 58 +++------- middleware.go | 22 +++- 10 files changed, 398 insertions(+), 119 deletions(-) create mode 100644 jwt/audience_validator.go create mode 100644 jwt/audience_validator_test.go diff --git a/auth.go b/auth.go index d387096..8e6607d 100644 --- a/auth.go +++ b/auth.go @@ -194,6 +194,8 @@ func NewTokenIntrospector( MaxEntries: cfg.Introspection.NegativeCache.MaxEntries, TTL: cfg.Introspection.NegativeCache.TTL, }, + RequireAudience: cfg.JWT.RequireAudience, + ExpectedAudience: cfg.JWT.ExpectedAudience, } introspector, err := idptoken.NewIntrospectorWithOpts(tokenProvider, introspectorOpts) if err != nil { diff --git a/auth_test.go b/auth_test.go index 527b502..f137ddb 100644 --- a/auth_test.go +++ b/auth_test.go @@ -214,10 +214,19 @@ func TestNewTokenIntrospector(t *gotesting.T) { Role: "admin", ResourcePath: "resource-" + uuid.NewString(), }} + opaqueTokenRegClaims := jwtgo.RegisteredClaims{ + ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)), + } httpServerIntrospector.SetResultForToken(opaqueToken, &idptoken.DefaultIntrospectionResult{ - Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{Scope: opaqueTokenScope}}) + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueTokenRegClaims, Scope: opaqueTokenScope}, + }) grpcServerIntrospector.SetResultForToken(opaqueToken, &pb.IntrospectTokenResponse{ - Active: true, TokenType: idputil.TokenTypeBearer, Scope: []*pb.AccessTokenScope{ + Active: true, + TokenType: idputil.TokenTypeBearer, + Exp: opaqueTokenRegClaims.ExpiresAt.Unix(), + Scope: []*pb.AccessTokenScope{ { TenantUuid: opaqueTokenScope[0].TenantUUID, ResourceNamespace: opaqueTokenScope[0].ResourceNamespace, @@ -308,7 +317,7 @@ func TestNewTokenIntrospector(t *gotesting.T) { expectedResult: &idptoken.DefaultIntrospectionResult{ Active: true, TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{Scope: opaqueTokenScope}, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueTokenRegClaims, Scope: opaqueTokenScope}, }, checkCacheFn: func(t *gotesting.T, introspector *idptoken.Introspector) { require.Equal(t, 1, introspector.ClaimsCache.Len(context.Background())) @@ -334,7 +343,7 @@ func TestNewTokenIntrospector(t *gotesting.T) { expectedResult: &idptoken.DefaultIntrospectionResult{ Active: true, TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{Scope: opaqueTokenScope}, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueTokenRegClaims, Scope: opaqueTokenScope}, }, checkCacheFn: func(t *gotesting.T, introspector *idptoken.Introspector) { require.Empty(t, introspector.ClaimsCache.Len(context.Background())) diff --git a/idptoken/introspector.go b/idptoken/introspector.go index 7f77113..673beba 100644 --- a/idptoken/introspector.go +++ b/idptoken/introspector.go @@ -61,6 +61,10 @@ var ErrTokenNotIntrospectable = errors.New("token is not introspectable") // (i.e., it already contains all necessary information). var ErrTokenIntrospectionNotNeeded = errors.New("token introspection is not needed") +// ErrTokenIntrospectionInvalidClaims is returned when introspection response claims are invalid. +// (e.g., audience is not valid) +var ErrTokenIntrospectionInvalidClaims = errors.New("introspection response claims are invalid") + // ErrUnauthenticated is returned when a request is unauthenticated. var ErrUnauthenticated = errors.New("request is unauthenticated") @@ -142,6 +146,15 @@ type IntrospectorOpts struct { // that will be used instead of DefaultIntrospectionResult for unmarshalling introspection response. // It must implement IntrospectionResult interface. ResultTemplate IntrospectionResult + + // RequireAudience specifies whether audience should be required. + // If true, "aud" field must be present in the introspection response. + RequireAudience bool + + // ExpectedAudience is a list of expected audience values. + // It's allowed to use glob patterns (*.my-service.com) for audience matching. + // If it's not empty, "aud" field in the introspection response must match at least one of the patterns. + ExpectedAudience []string } // IntrospectorCacheOpts is a configuration of how cache will be used. @@ -193,6 +206,9 @@ type Introspector struct { claimsCacheTTL time.Duration negativeCacheTTL time.Duration endpointDiscoveryCacheTTL time.Duration + + claimsValidator *jwtgo.Validator + audienceValidator *jwt.AudienceValidator } // DefaultIntrospectionResult is a default implementation of IntrospectionResult. @@ -291,6 +307,8 @@ func NewIntrospectorWithOpts(accessTokenProvider IntrospectionTokenProvider, opt negativeCacheTTL: opts.NegativeCache.TTL, EndpointDiscoveryCache: endpointDiscoveryCache, endpointDiscoveryCacheTTL: opts.EndpointDiscoveryCache.TTL, + claimsValidator: jwtgo.NewValidator(jwtgo.WithExpirationRequired()), + audienceValidator: jwt.NewAudienceValidator(opts.RequireAudience, opts.ExpectedAudience), }, nil } @@ -300,15 +318,11 @@ func (i *Introspector) IntrospectToken(ctx context.Context, token string) (Intro unsafe.Slice(unsafe.StringData(token), len(token))) // nolint:gosec // prevent redundant slice copying if cachedItem, ok := i.ClaimsCache.Get(ctx, cacheKey); ok { - now := time.Now() - if cachedItem.CreatedAt.Add(i.claimsCacheTTL).After(now) { - cachedClaimsExpiresAt, err := cachedItem.IntrospectionResult.GetClaims().GetExpirationTime() - if err != nil { - return nil, fmt.Errorf("get expiration time from cached claims: %w", err) - } - if cachedClaimsExpiresAt == nil || cachedClaimsExpiresAt.Time.After(now) { - return cachedItem.IntrospectionResult.Clone(), nil + if cachedItem.CreatedAt.Add(i.claimsCacheTTL).After(time.Now()) { + if err := i.validateClaims(cachedItem.IntrospectionResult.GetClaims()); err != nil { + return nil, err } + return cachedItem.IntrospectionResult.Clone(), nil } } else if cachedItem, ok = i.NegativeCache.Get(ctx, cacheKey); ok { if cachedItem.CreatedAt.Add(i.negativeCacheTTL).After(time.Now()) { @@ -320,15 +334,15 @@ func (i *Introspector) IntrospectToken(ctx context.Context, token string) (Intro if err != nil { return nil, err } - if introspectionResult.IsActive() { - introspectionResult.GetClaims().ApplyScopeFilter(i.scopeFilter) - i.ClaimsCache.Add(ctx, cacheKey, IntrospectionCacheItem{ - IntrospectionResult: introspectionResult.Clone(), CreatedAt: time.Now()}) - } else { - i.NegativeCache.Add(ctx, cacheKey, IntrospectionCacheItem{ - IntrospectionResult: introspectionResult.Clone(), CreatedAt: time.Now()}) + if !introspectionResult.IsActive() { + i.NegativeCache.Add(ctx, cacheKey, IntrospectionCacheItem{IntrospectionResult: introspectionResult.Clone(), CreatedAt: time.Now()}) + return introspectionResult, nil + } + introspectionResult.GetClaims().ApplyScopeFilter(i.scopeFilter) + i.ClaimsCache.Add(ctx, cacheKey, IntrospectionCacheItem{IntrospectionResult: introspectionResult.Clone(), CreatedAt: time.Now()}) + if err = i.validateClaims(introspectionResult.GetClaims()); err != nil { + return nil, err } - return introspectionResult, nil } @@ -558,6 +572,16 @@ func (i *Introspector) getURLForIssuerWithCallback(ctx context.Context, issuer s return i.trustedIssuerNotFoundFallback(ctx, i, issuer) } +func (i *Introspector) validateClaims(claims jwt.Claims) error { + if err := i.claimsValidator.Validate(claims); err != nil { + return fmt.Errorf("%w: %w", ErrTokenIntrospectionInvalidClaims, err) + } + if err := i.audienceValidator.Validate(claims); err != nil { + return fmt.Errorf("%w: %w", ErrTokenIntrospectionInvalidClaims, err) + } + return nil +} + func makeTokenNotIntrospectableError(inner error) error { if inner != nil { return fmt.Errorf("%w: %w", ErrTokenNotIntrospectable, inner) diff --git a/idptoken/introspector_test.go b/idptoken/introspector_test.go index 9054328..12cdf5d 100644 --- a/idptoken/introspector_test.go +++ b/idptoken/introspector_test.go @@ -64,6 +64,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { expiredJWT := idptest.MustMakeTokenStringSignedWithTestKey(&jwt.DefaultClaims{ RegisteredClaims: jwtgo.RegisteredClaims{ Issuer: httpIDPSrv.URL(), + Audience: jwtgo.ClaimStrings{"https://rs.example.com"}, Subject: uuid.NewString(), ID: uuid.NewString(), ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(-time.Hour)), @@ -78,20 +79,28 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { Role: "account_viewer", ResourcePath: "resource-" + uuid.NewString(), }} - validJWTClaims := jwtgo.RegisteredClaims{ + validJWTRegClaims := jwtgo.RegisteredClaims{ Issuer: httpIDPSrv.URL(), + Audience: jwtgo.ClaimStrings{"https://rs.example.com"}, Subject: uuid.NewString(), ID: uuid.NewString(), ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)), } - validJWT := idptest.MustMakeTokenStringSignedWithTestKey(&jwt.DefaultClaims{RegisteredClaims: validJWTClaims}) + validJWT := idptest.MustMakeTokenStringSignedWithTestKey(&jwt.DefaultClaims{RegisteredClaims: validJWTRegClaims}) httpServerIntrospector.SetResultForToken(validJWT, &idptoken.DefaultIntrospectionResult{Active: true, - TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTClaims, Scope: validJWTScope}}) + TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTRegClaims, Scope: validJWTScope}}) validJWTWithAppTyp := idptest.MustMakeTokenStringWithHeader(&jwt.DefaultClaims{ - RegisteredClaims: validJWTClaims, + RegisteredClaims: validJWTRegClaims, }, idptest.TestKeyID, idptest.GetTestRSAPrivateKey(), map[string]interface{}{"typ": idputil.JWTTypeAppAccessToken}) httpServerIntrospector.SetResultForToken(validJWTWithAppTyp, &idptoken.DefaultIntrospectionResult{Active: true, - TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTClaims, Scope: validJWTScope}}) + TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTRegClaims, Scope: validJWTScope}}) + grpcServerIntrospector.SetResultForToken(validJWT, &pb.IntrospectTokenResponse{ + Active: true, + TokenType: idputil.TokenTypeBearer, + Aud: validJWTRegClaims.Audience, + Exp: validJWTRegClaims.ExpiresAt.Unix(), + Scope: jwtScopeToGRPC(validJWTScope), + }) // Valid JWT with scope and custom claims fields customFieldVal := uuid.NewString() @@ -106,19 +115,20 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { Role: "publisher", ResourcePath: "resource-1", }} - validCustomJWTClaims := jwtgo.RegisteredClaims{ + validCustomJWTRegClaims := jwtgo.RegisteredClaims{ Issuer: httpIDPSrv.URL(), + Audience: jwtgo.ClaimStrings{"https://rs.example.com"}, Subject: uuid.NewString(), ID: uuid.NewString(), ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)), } validCustomJWT := idptest.MustMakeTokenStringSignedWithTestKey(&CustomClaims{ - DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validCustomJWTClaims}, CustomField: customFieldVal}) + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validCustomJWTRegClaims}, CustomField: customFieldVal}) httpServerIntrospector.SetResultForToken(validCustomJWT, &CustomIntrospectionResult{ Active: true, TokenType: idputil.TokenTypeBearer, CustomClaims: CustomClaims{ - DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validCustomJWTClaims, Scope: validCustomJWTScope}, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validCustomJWTRegClaims, Scope: validCustomJWTScope}, CustomField: customFieldVal, }, }) @@ -131,10 +141,21 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { Role: "admin", ResourcePath: "resource-" + uuid.NewString(), }} + opaqueTokenRegClaims := jwtgo.RegisteredClaims{ + ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)), + } httpServerIntrospector.SetResultForToken(opaqueToken, &idptoken.DefaultIntrospectionResult{ - Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{Scope: opaqueTokenScope}}) - grpcServerIntrospector.SetResultForToken(opaqueToken, &pb.IntrospectTokenResponse{Active: true, - TokenType: idputil.TokenTypeBearer, Scope: jwtScopeToGRPC(opaqueTokenScope)}) + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueTokenRegClaims, Scope: opaqueTokenScope}, + }) + grpcServerIntrospector.SetResultForToken(opaqueToken, &pb.IntrospectTokenResponse{ + Active: true, + TokenType: idputil.TokenTypeBearer, + Aud: opaqueTokenRegClaims.Audience, + Exp: opaqueTokenRegClaims.ExpiresAt.Unix(), + Scope: jwtScopeToGRPC(opaqueTokenScope), + }) tests := []struct { name string @@ -175,7 +196,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { { name: `error, dynamic introspection endpoint, invalid "typ" field in JWT header`, tokenToIntrospect: idptest.MustMakeTokenStringWithHeader(&jwt.DefaultClaims{ - RegisteredClaims: validJWTClaims, + RegisteredClaims: validJWTRegClaims, }, idptest.TestKeyID, idptest.GetTestRSAPrivateKey(), map[string]interface{}{"typ": "invalid"}), checkError: func(t *gotesting.T, err error) { require.ErrorIs(t, err, idptoken.ErrTokenNotIntrospectable) @@ -250,7 +271,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { expectedResult: &idptoken.DefaultIntrospectionResult{ Active: true, TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTClaims, Scope: validJWTScope}, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTRegClaims, Scope: validJWTScope}, }, expectedHTTPSrvCalled: true, }, @@ -260,7 +281,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { expectedResult: &idptoken.DefaultIntrospectionResult{ Active: true, TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTClaims, Scope: validJWTScope}, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTRegClaims, Scope: validJWTScope}, }, expectedHTTPSrvCalled: true, }, @@ -275,7 +296,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { Active: true, TokenType: idputil.TokenTypeBearer, CustomClaims: CustomClaims{ - DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validCustomJWTClaims, Scope: jwt.Scope{validCustomJWTScope[1]}}, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validCustomJWTRegClaims, Scope: jwt.Scope{validCustomJWTScope[1]}}, CustomField: customFieldVal, }, }, @@ -286,20 +307,23 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, }, { - name: "ok, static http introspection endpoint, introspected token is opaque", + name: "ok, static http introspection endpoint, opaque token", introspectorOpts: idptoken.IntrospectorOpts{ HTTPEndpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, }, tokenToIntrospect: opaqueToken, expectedResult: &idptoken.DefaultIntrospectionResult{ - Active: true, - TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{Scope: opaqueTokenScope}, + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{ + RegisteredClaims: jwtgo.RegisteredClaims{ExpiresAt: opaqueTokenRegClaims.ExpiresAt}, + Scope: opaqueTokenScope, + }, }, expectedHTTPSrvCalled: true, }, { - name: "ok, static http introspection endpoint, introspected token is opaque, filter scope by resource namespace", + name: "ok, static http introspection endpoint, opaque token, filter scope by resource namespace", introspectorOpts: idptoken.IntrospectorOpts{ HTTPEndpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, ScopeFilter: jwt.ScopeFilter{ @@ -309,9 +333,12 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, tokenToIntrospect: opaqueToken, expectedResult: &idptoken.DefaultIntrospectionResult{ - Active: true, - TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{Scope: opaqueTokenScope}, + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{ + RegisteredClaims: jwtgo.RegisteredClaims{ExpiresAt: opaqueTokenRegClaims.ExpiresAt}, + Scope: opaqueTokenScope, + }, }, expectedHTTPSrvCalled: true, expectedHTTPFormVals: url.Values{ @@ -321,7 +348,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, }, { - name: "error, grpc introspection endpoint, unauthenticated", + name: "error, static http introspection endpoint, opaque token, unauthenticated", introspectorOpts: idptoken.IntrospectorOpts{ HTTPEndpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, }, @@ -333,7 +360,37 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { expectedHTTPSrvCalled: true, }, { - name: "ok, grpc introspection endpoint", + name: "error, static http introspection endpoint, opaque token, audience is missing", + introspectorOpts: idptoken.IntrospectorOpts{ + HTTPEndpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, + RequireAudience: true, + ExpectedAudience: []string{"https://rs.my-service.com"}, + }, + tokenToIntrospect: opaqueToken, + checkError: func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrTokenIntrospectionInvalidClaims) + require.ErrorIs(t, err, jwtgo.ErrTokenRequiredClaimMissing) + var audMissingErr *jwt.AudienceMissingError + require.ErrorAs(t, err, &audMissingErr) + }, + expectedHTTPSrvCalled: true, + }, + { + name: "error, static http introspection endpoint, jwt token, invalid audience", + introspectorOpts: idptoken.IntrospectorOpts{ + HTTPEndpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, + RequireAudience: true, + ExpectedAudience: []string{"https://rs.my-service.com"}, + }, + tokenToIntrospect: validJWT, + checkError: func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrTokenIntrospectionInvalidClaims) + require.ErrorIs(t, err, jwtgo.ErrTokenInvalidAudience) + }, + expectedHTTPSrvCalled: true, + }, + { + name: "ok, grpc introspection endpoint, opaque token", introspectorOpts: idptoken.IntrospectorOpts{ GRPCClient: grpcClient, ScopeFilter: jwt.ScopeFilter{ @@ -343,9 +400,12 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, tokenToIntrospect: opaqueToken, expectedResult: &idptoken.DefaultIntrospectionResult{ - Active: true, - TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{Scope: opaqueTokenScope}, + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{ + RegisteredClaims: jwtgo.RegisteredClaims{ExpiresAt: opaqueTokenRegClaims.ExpiresAt}, + Scope: opaqueTokenScope, + }, }, expectedGRPCSrvCalled: true, expectedGRPCScopeFilter: []*pb.IntrospectionScopeFilter{ @@ -354,7 +414,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, }, { - name: "error, grpc introspection endpoint, unauthenticated", + name: "error, grpc introspection endpoint, opaque token, unauthenticated", introspectorOpts: idptoken.IntrospectorOpts{ GRPCClient: grpcClient, }, @@ -365,14 +425,43 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, expectedGRPCSrvCalled: true, }, + { + name: "error, grpc introspection endpoint, jwt token, invalid audience", + introspectorOpts: idptoken.IntrospectorOpts{ + GRPCClient: grpcClient, + RequireAudience: true, + ExpectedAudience: []string{"https://rs.my-service.com"}, + }, + tokenToIntrospect: validJWT, + checkError: func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrTokenIntrospectionInvalidClaims) + require.ErrorIs(t, err, jwtgo.ErrTokenInvalidAudience) + }, + expectedGRPCSrvCalled: true, + }, + { + name: "error, grpc introspection endpoint, opaque token, audience is missing", + introspectorOpts: idptoken.IntrospectorOpts{ + GRPCClient: grpcClient, + RequireAudience: true, + ExpectedAudience: []string{"https://rs.my-service.com"}, + }, + tokenToIntrospect: opaqueToken, + checkError: func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrTokenIntrospectionInvalidClaims) + require.ErrorIs(t, err, jwtgo.ErrTokenRequiredClaimMissing) + var audMissingErr *jwt.AudienceMissingError + require.ErrorAs(t, err, &audMissingErr) + }, + expectedGRPCSrvCalled: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *gotesting.T) { if tt.accessToken == "" { tt.accessToken = validAccessToken } - introspector, err := idptoken.NewIntrospectorWithOpts( - idptest.NewSimpleTokenProvider(tt.accessToken), tt.introspectorOpts) + introspector, err := idptoken.NewIntrospectorWithOpts(idptest.NewSimpleTokenProvider(tt.accessToken), tt.introspectorOpts) require.NoError(t, err) require.NoError(t, introspector.AddTrustedIssuerURL(httpIDPSrv.URL())) @@ -435,30 +524,30 @@ func TestCachingIntrospector_IntrospectTokenWithCache(t *gotesting.T) { Role: "account_viewer", ResourcePath: "resource-" + uuid.NewString(), }} - validJWT1Claims := jwtgo.RegisteredClaims{ + validJWT1RegClaims := jwtgo.RegisteredClaims{ Issuer: idpSrv.URL(), Subject: uuid.NewString(), ID: uuid.NewString(), ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(2 * time.Hour)), } - valid1JWT := idptest.MustMakeTokenStringSignedWithTestKey(&jwt.DefaultClaims{RegisteredClaims: validJWT1Claims}) + valid1JWT := idptest.MustMakeTokenStringSignedWithTestKey(&jwt.DefaultClaims{RegisteredClaims: validJWT1RegClaims}) serverIntrospector.SetResultForToken(valid1JWT, &idptoken.DefaultIntrospectionResult{Active: true, - TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT1Claims, Scope: validJWT1Scope}}) + TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT1RegClaims, Scope: validJWT1Scope}}) validJWT2Scope := []jwt.AccessPolicy{{ TenantUUID: uuid.NewString(), ResourceNamespace: "account-server", Role: "account_viewer", ResourcePath: "resource-" + uuid.NewString(), }} - validJWT2Claims := jwtgo.RegisteredClaims{ + validJWT2RegClaims := jwtgo.RegisteredClaims{ Issuer: idpSrv.URL(), Subject: uuid.NewString(), ID: uuid.NewString(), ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)), } - valid2JWT := idptest.MustMakeTokenStringSignedWithTestKey(&jwt.DefaultClaims{RegisteredClaims: validJWT2Claims}) + valid2JWT := idptest.MustMakeTokenStringSignedWithTestKey(&jwt.DefaultClaims{RegisteredClaims: validJWT2RegClaims}) serverIntrospector.SetResultForToken(valid2JWT, &idptoken.DefaultIntrospectionResult{Active: true, - TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT2Claims, Scope: validJWT2Scope}}) + TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT2RegClaims, Scope: validJWT2Scope}}) // Opaque tokens opaqueToken1 := "opaque-token-" + uuid.NewString() @@ -470,16 +559,28 @@ func TestCachingIntrospector_IntrospectTokenWithCache(t *gotesting.T) { Role: "admin", ResourcePath: "resource-" + uuid.NewString(), }} + opaqueToken1RegClaims := jwtgo.RegisteredClaims{ + ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)), + } opaqueToken2Scope := []jwt.AccessPolicy{{ TenantUUID: uuid.NewString(), ResourceNamespace: "event-manager", Role: "admin", ResourcePath: "resource-" + uuid.NewString(), }} + opaqueToken2RegClaims := jwtgo.RegisteredClaims{ + ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)), + } serverIntrospector.SetResultForToken(opaqueToken1, &idptoken.DefaultIntrospectionResult{ - Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{Scope: opaqueToken1Scope}}) + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueToken1RegClaims, Scope: opaqueToken1Scope}, + }) serverIntrospector.SetResultForToken(opaqueToken2, &idptoken.DefaultIntrospectionResult{ - Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{Scope: opaqueToken2Scope}}) + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueToken2RegClaims, Scope: opaqueToken2Scope}, + }) serverIntrospector.SetResultForToken(opaqueToken3, &idptoken.DefaultIntrospectionResult{Active: false}) tests := []struct { @@ -553,22 +654,22 @@ func TestCachingIntrospector_IntrospectTokenWithCache(t *gotesting.T) { { Active: true, TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT1Claims, Scope: validJWT1Scope}, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT1RegClaims, Scope: validJWT1Scope}, }, { Active: true, TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT1Claims, Scope: validJWT1Scope}, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT1RegClaims, Scope: validJWT1Scope}, }, { Active: true, TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT2Claims, Scope: validJWT2Scope}, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT2RegClaims, Scope: validJWT2Scope}, }, { Active: true, TokenType: idputil.TokenTypeBearer, - DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT2Claims, Scope: validJWT2Scope}, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWT2RegClaims, Scope: validJWT2Scope}, }, }, checkIntrospector: func(t *gotesting.T, introspector *idptoken.Introspector) { @@ -595,10 +696,10 @@ func TestCachingIntrospector_IntrospectTokenWithCache(t *gotesting.T) { {idptest.TokenIntrospectionEndpointPath: 0}, }, expectedResult: []*idptoken.DefaultIntrospectionResult{ - {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{Scope: opaqueToken1Scope}}, - {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{Scope: opaqueToken1Scope}}, - {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{Scope: opaqueToken2Scope}}, - {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{Scope: opaqueToken2Scope}}, + {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueToken1RegClaims, Scope: opaqueToken1Scope}}, + {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueToken1RegClaims, Scope: opaqueToken1Scope}}, + {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueToken2RegClaims, Scope: opaqueToken2Scope}}, + {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueToken2RegClaims, Scope: opaqueToken2Scope}}, {Active: false}, {Active: false}, }, @@ -624,8 +725,8 @@ func TestCachingIntrospector_IntrospectTokenWithCache(t *gotesting.T) { {idptest.TokenIntrospectionEndpointPath: 1}, }, expectedResult: []*idptoken.DefaultIntrospectionResult{ - {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{Scope: opaqueToken1Scope}}, - {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{Scope: opaqueToken1Scope}}, + {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueToken1RegClaims, Scope: opaqueToken1Scope}}, + {Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: opaqueToken1RegClaims, Scope: opaqueToken1Scope}}, {Active: false}, {Active: false}, }, diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 8303d5f..519012a 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -52,6 +52,7 @@ const ( TokenIntrospectionStatusNotActive = "not_active" TokenIntrospectionStatusNotNeeded = "not_needed" TokenIntrospectionStatusNotIntrospectable = "not_introspectable" + TokenIntrospectionStatusInvalidClaims = "invalid_claims" TokenIntrospectionStatusError = "error" ) diff --git a/jwt/audience_validator.go b/jwt/audience_validator.go new file mode 100644 index 0000000..3dc4f93 --- /dev/null +++ b/jwt/audience_validator.go @@ -0,0 +1,58 @@ +/* +Copyright © 2024 Acronis International GmbH. + +Released under MIT license. +*/ + +package jwt + +import ( + "fmt" + + jwtgo "github.com/golang-jwt/jwt/v5" + "github.com/vasayxtx/go-glob" +) + +// AudienceValidator is a validator that checks if the audience claim ("aud") of the token is expected. +// It validates that the audience claim is presented and/or matches one of the expected glob patterns (e.g. "*.my-service.com"). +type AudienceValidator struct { + requireAudience bool + audienceMatchers []func(aud string) bool +} + +// NewAudienceValidator creates a new AudienceValidator. +// If requireAudience is true, the audience claim must be presented in the token. +// If audiencePatterns is not empty, the audience claim must match at least one of the patterns. +func NewAudienceValidator(requireAudience bool, audiencePatterns []string) *AudienceValidator { + var audienceMatchers []func(aud string) bool + for i := range audiencePatterns { + audienceMatchers = append(audienceMatchers, glob.Compile(audiencePatterns[i])) + } + return &AudienceValidator{requireAudience: requireAudience, audienceMatchers: audienceMatchers} +} + +// Validate checks if the audience claim of the token is expected. +func (av *AudienceValidator) Validate(claims Claims) error { + audience, err := claims.GetAudience() + if err != nil { + return err + } + if len(audience) == 0 { + if av.requireAudience { + return fmt.Errorf("%w: %w", jwtgo.ErrTokenRequiredClaimMissing, &AudienceMissingError{claims}) + } + return nil + } + + if len(av.audienceMatchers) == 0 { + return nil + } + for i := range av.audienceMatchers { + for j := range audience { + if av.audienceMatchers[i](audience[j]) { + return nil + } + } + } + return fmt.Errorf("%w: %w", jwtgo.ErrTokenInvalidAudience, &AudienceNotExpectedError{claims, audience}) +} diff --git a/jwt/audience_validator_test.go b/jwt/audience_validator_test.go new file mode 100644 index 0000000..110a980 --- /dev/null +++ b/jwt/audience_validator_test.go @@ -0,0 +1,102 @@ +/* +Copyright © 2024 Acronis International GmbH. + +Released under MIT license. +*/ + +package jwt + +import ( + "testing" + + jwtgo "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +func TestAudienceValidator_Validate(t *testing.T) { + tests := []struct { + name string + requireAudience bool + audiencePatterns []string + claims Claims + checkError func(t *testing.T, err error, claims Claims) + }{ + { + name: "no audience required, no audience in claims", + requireAudience: false, + audiencePatterns: nil, + claims: &DefaultClaims{RegisteredClaims: jwtgo.RegisteredClaims{Audience: nil}}, + }, + { + name: "audience required, no audience in claims", + requireAudience: true, + audiencePatterns: nil, + claims: &DefaultClaims{RegisteredClaims: jwtgo.RegisteredClaims{Audience: nil}}, + checkError: func(t *testing.T, err error, claims Claims) { + require.ErrorIs(t, err, jwtgo.ErrTokenRequiredClaimMissing) + var audMissingErr *AudienceMissingError + require.ErrorAs(t, err, &audMissingErr) + require.Equal(t, claims, audMissingErr.Claims) + }, + }, + { + name: "audience not required, audience in claims", + requireAudience: false, + audiencePatterns: nil, + claims: &DefaultClaims{RegisteredClaims: jwtgo.RegisteredClaims{Audience: jwtgo.ClaimStrings{"rs.example.com"}}}, + }, + { + name: "audience required, audience in claims", + requireAudience: true, + audiencePatterns: nil, + claims: &DefaultClaims{RegisteredClaims: jwtgo.RegisteredClaims{Audience: jwtgo.ClaimStrings{"rs.example.com"}}}, + }, + { + name: "audience matches pattern with wildcard in the beginning", + requireAudience: false, + audiencePatterns: []string{"*.example.com"}, + claims: &DefaultClaims{RegisteredClaims: jwtgo.RegisteredClaims{Audience: jwtgo.ClaimStrings{"rs.example.com"}}}, + }, + { + name: "audience matches pattern with wildcard in the end", + requireAudience: false, + audiencePatterns: []string{"rs-*"}, + claims: &DefaultClaims{RegisteredClaims: jwtgo.RegisteredClaims{Audience: jwtgo.ClaimStrings{"rs-2ae8e60e-abf9-41d7-a9cb-e083a1814518"}}}, + }, + { + name: "audience matches pattern with wildcard in the middle", + requireAudience: false, + audiencePatterns: []string{"https://*.example.com"}, + claims: &DefaultClaims{RegisteredClaims: jwtgo.RegisteredClaims{Audience: jwtgo.ClaimStrings{"https://rs.example.com"}}}, + }, + { + name: "audience matches pattern without wildcard, multiple patterns", + requireAudience: false, + audiencePatterns: []string{"rs1.example.com", "rs2.example.com", "rs3.example.com"}, + claims: &DefaultClaims{RegisteredClaims: jwtgo.RegisteredClaims{Audience: jwtgo.ClaimStrings{"rs2.example.com"}}}, + }, + { + name: "audience does not match pattern", + requireAudience: false, + audiencePatterns: []string{"*.example.com"}, + claims: &DefaultClaims{RegisteredClaims: jwtgo.RegisteredClaims{Audience: jwtgo.ClaimStrings{"service.other.com"}}}, + checkError: func(t *testing.T, err error, claims Claims) { + require.ErrorIs(t, err, jwtgo.ErrTokenInvalidAudience) + var audNotExpectedErr *AudienceNotExpectedError + require.ErrorAs(t, err, &audNotExpectedErr) + require.Equal(t, claims, audNotExpectedErr.Claims) + require.Equal(t, jwtgo.ClaimStrings{"service.other.com"}, audNotExpectedErr.Audience) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NewAudienceValidator(tt.requireAudience, tt.audiencePatterns).Validate(tt.claims) + if tt.checkError != nil { + tt.checkError(t, err, tt.claims) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/jwt/caching_parser.go b/jwt/caching_parser.go index 5d6bcd6..126080d 100644 --- a/jwt/caching_parser.go +++ b/jwt/caching_parser.go @@ -101,7 +101,7 @@ func (cp *CachingParser) getFromCacheAndValidateIfNeeded(key [sha256.Size]byte) if err = cp.claimsValidator.Validate(cachedClaims); err != nil { return nil, true, fmt.Errorf("%w: %w", jwtgo.ErrTokenInvalidClaims, err) } - if err = cp.Parser.customValidator(cachedClaims); err != nil { + if err = cp.Parser.audienceValidator.Validate(cachedClaims); err != nil { return nil, true, fmt.Errorf("%w: %w", jwtgo.ErrTokenInvalidClaims, err) } } diff --git a/jwt/parser.go b/jwt/parser.go index 319c6b3..fa200cc 100644 --- a/jwt/parser.go +++ b/jwt/parser.go @@ -13,7 +13,6 @@ import ( "github.com/acronis/go-appkit/log" jwtgo "github.com/golang-jwt/jwt/v5" - "github.com/vasayxtx/go-glob" "github.com/acronis/go-authkit/internal/idputil" ) @@ -32,25 +31,31 @@ type CachingKeysProvider interface { // ParserOpts additional options for parser. type ParserOpts struct { - // SkipClaimsValidation is a flag that indicates whether claims validation (e.g. checking expiration time) should be skipped. + // SkipClaimsValidation allows skipping claims validation (e.g., checking expiration time). + // Use it with caution, and only if you know what you are doing. + // For example, if you want to validate claims by yourself. // It doesn't affect signature verification. SkipClaimsValidation bool - // RequireAudience is a flag that indicates whether audience should be required. + // RequireAudience specifies whether audience should be required. If true, "aud" claim must be present in the token. 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 is a list of expected audience values. + // It's allowed to use glob patterns (*.my-service.com) for audience matching. + // If it's not empty, "aud" JWT claim must match at least one of the patterns. ExpectedAudience []string // TrustedIssuerNotFoundFallback is a function called when given issuer is not found in the list of trusted ones. + // For example, it could be analyzed, fetched from the external source + // and then added to the list by calling AddTrustedIssuerURL method. TrustedIssuerNotFoundFallback TrustedIssNotFoundFallback // 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 is a template for claims object. + // It usually used for the custom claims object that implements Claims interface. + // If it's not specified, DefaultClaims will be used. ClaimsTemplate Claims // ScopeFilter is a filter that will be applied to access policies in JWT scope after parsing. @@ -59,8 +64,6 @@ type ParserOpts struct { ScopeFilter ScopeFilter } -type audienceMatcher func(aud string) bool - // TrustedIssNotFoundFallback is a function called when given issuer is not found in the list of trusted ones. // For example, it could be analyzed and then added to the list by calling AddTrustedIssuerURL method. type TrustedIssNotFoundFallback func(ctx context.Context, p *Parser, iss string) (issURL string, issFound bool) @@ -69,7 +72,7 @@ type TrustedIssNotFoundFallback func(ctx context.Context, p *Parser, iss string) type Parser struct { parser *jwtgo.Parser claimsTemplate Claims - customValidator func(claims Claims) error + audienceValidator *AudienceValidator skipClaimsValidation bool keysProvider KeysProvider @@ -88,10 +91,6 @@ func NewParser(keysProvider KeysProvider) *Parser { // NewParserWithOpts creates new JWT parser with specified keys provider and additional options. func NewParserWithOpts(keysProvider KeysProvider, opts ParserOpts) *Parser { - var audienceMatchers []audienceMatcher - for _, audPattern := range opts.ExpectedAudience { - audienceMatchers = append(audienceMatchers, glob.Compile(audPattern)) - } parserOpts := []jwtgo.ParserOption{jwtgo.WithExpirationRequired()} if opts.SkipClaimsValidation { parserOpts = append(parserOpts, jwtgo.WithoutClaimsValidation()) @@ -102,7 +101,7 @@ func NewParserWithOpts(keysProvider KeysProvider, opts ParserOpts) *Parser { } return &Parser{ parser: jwtgo.NewParser(parserOpts...), - customValidator: makeCustomAudienceValidator(opts.RequireAudience, audienceMatchers), + audienceValidator: NewAudienceValidator(opts.RequireAudience, opts.ExpectedAudience), skipClaimsValidation: opts.SkipClaimsValidation, keysProvider: keysProvider, trustedIssuerStore: idputil.NewTrustedIssuerStore(), @@ -165,7 +164,7 @@ func (p *Parser) Parse(ctx context.Context, token string) (Claims, error) { } if !p.skipClaimsValidation { - if err := p.customValidator(claims); err != nil { + if err := p.audienceValidator.Validate(claims); err != nil { return nil, fmt.Errorf("%w: %w", jwtgo.ErrTokenInvalidClaims, err) } } @@ -220,30 +219,3 @@ func (p *Parser) getURLForIssuerWithCallback(ctx context.Context, issuer string) } return p.trustedIssuerNotFoundFallback(ctx, p, issuer) } - -func makeCustomAudienceValidator(requireAudience bool, audienceMatchers []audienceMatcher) func(c Claims) error { - return func(c Claims) error { - audience, err := c.GetAudience() - if err != nil { - return err - } - if len(audience) == 0 { - if requireAudience { - return fmt.Errorf("%w: %w", jwtgo.ErrTokenRequiredClaimMissing, &AudienceMissingError{c}) - } - return nil - } - - if len(audienceMatchers) == 0 { - return nil - } - for i := range audienceMatchers { - for j := range audience { - if audienceMatchers[i](audience[j]) { - return nil - } - } - } - return fmt.Errorf("%w: %w", jwtgo.ErrTokenInvalidAudience, &AudienceNotExpectedError{c, audience}) - } -} diff --git a/middleware.go b/middleware.go index df444a2..0b81ac2 100644 --- a/middleware.go +++ b/middleware.go @@ -160,25 +160,31 @@ func (h *jwtAuthHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { logFunc("token's introspection is not needed") }) h.promMetrics.IncTokenIntrospectionsTotal(metrics.TokenIntrospectionStatusNotNeeded) + case errors.Is(err, idptoken.ErrTokenNotIntrospectable): // Token is not introspectable by some reason. // In this case, we will parse it as JWT and use it for authZ. logger.Warn("token is not introspectable, it will be used for authentication and authorization as is", log.Error(err)) h.promMetrics.IncTokenIntrospectionsTotal(metrics.TokenIntrospectionStatusNotIntrospectable) + + case errors.Is(err, idptoken.ErrTokenIntrospectionInvalidClaims): + logger.Error("token's introspection failed because of invalid claims", log.Error(err)) + h.promMetrics.IncTokenIntrospectionsTotal(metrics.TokenIntrospectionStatusInvalidClaims) + h.respondAuthNFailedError(rw, logger) + return + default: logger.Error("token's introspection failed", log.Error(err)) h.promMetrics.IncTokenIntrospectionsTotal(metrics.TokenIntrospectionStatusError) - apiErr := restapi.NewError(h.errorDomain, ErrCodeAuthenticationFailed, ErrMessageAuthenticationFailed) - restapi.RespondError(rw, http.StatusUnauthorized, apiErr, logger) + h.respondAuthNFailedError(rw, logger) return } } else { if !introspectionResult.IsActive() { logger.Warn("token was successfully introspected, but it is not active") h.promMetrics.IncTokenIntrospectionsTotal(metrics.TokenIntrospectionStatusNotActive) - apiErr := restapi.NewError(h.errorDomain, ErrCodeAuthenticationFailed, ErrMessageAuthenticationFailed) - restapi.RespondError(rw, http.StatusUnauthorized, apiErr, logger) + h.respondAuthNFailedError(rw, logger) return } jwtClaims = introspectionResult.GetClaims() @@ -193,8 +199,7 @@ func (h *jwtAuthHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { var err error if jwtClaims, err = h.jwtParser.Parse(r.Context(), bearerToken); err != nil { logger.Error("authentication failed", log.Error(err)) - apiErr := restapi.NewError(h.errorDomain, ErrCodeAuthenticationFailed, ErrMessageAuthenticationFailed) - restapi.RespondError(rw, http.StatusUnauthorized, apiErr, logger) + h.respondAuthNFailedError(rw, logger) return } } @@ -214,6 +219,11 @@ func (h *jwtAuthHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { h.next.ServeHTTP(rw, r) } +func (h *jwtAuthHandler) respondAuthNFailedError(rw http.ResponseWriter, logger log.FieldLogger) { + apiErr := restapi.NewError(h.errorDomain, ErrCodeAuthenticationFailed, ErrMessageAuthenticationFailed) + restapi.RespondError(rw, http.StatusUnauthorized, apiErr, logger) +} + // GetBearerTokenFromRequest extracts jwt token from request headers. func GetBearerTokenFromRequest(r *http.Request) string { authHeader := strings.TrimSpace(r.Header.Get(HeaderAuthorization))