Skip to content

Commit

Permalink
Validate token's exp and aud claims after introspection
Browse files Browse the repository at this point in the history
  • Loading branch information
vasayxtx committed Dec 20, 2024
1 parent 075949b commit 552a0de
Show file tree
Hide file tree
Showing 10 changed files with 398 additions and 119 deletions.
2 changes: 2 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 13 additions & 4 deletions auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()))
Expand All @@ -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()))
Expand Down
56 changes: 40 additions & 16 deletions idptoken/introspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand All @@ -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()) {
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 552a0de

Please sign in to comment.