diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 30a1848..4cb5f8f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,7 +27,7 @@ jobs: cache: false - name: Run GolangCI-Lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v6 with: - version: v1.56.1 + version: v1.61.0 args: --timeout=5m diff --git a/auth.go b/auth.go index cb2301d..7518941 100644 --- a/auth.go +++ b/auth.go @@ -121,7 +121,7 @@ func NewTokenIntrospector( tokenProvider idptoken.IntrospectionTokenProvider, scopeFilter []idptoken.IntrospectionScopeFilterAccessPolicy, opts ...TokenIntrospectorOption, -) (TokenIntrospector, error) { +) (*idptoken.Introspector, error) { var options tokenIntrospectorOptions for _, opt := range opts { opt(&options) @@ -147,7 +147,7 @@ func NewTokenIntrospector( } introspectorOpts := idptoken.IntrospectorOpts{ - StaticHTTPEndpoint: cfg.Introspection.Endpoint, + HTTPEndpoint: cfg.Introspection.Endpoint, GRPCClient: grpcClient, HTTPClient: idputil.MakeDefaultHTTPClient(cfg.HTTPClient.RequestTimeout, logger), AccessTokenScope: cfg.Introspection.AccessTokenScope, @@ -155,35 +155,26 @@ func NewTokenIntrospector( ScopeFilter: scopeFilter, TrustedIssuerNotFoundFallback: options.trustedIssuerNotFoundFallback, PrometheusLibInstanceLabel: options.prometheusLibInstanceLabel, + ClaimsCache: idptoken.IntrospectorCacheOpts{ + Enabled: cfg.Introspection.ClaimsCache.Enabled, + MaxEntries: cfg.Introspection.ClaimsCache.MaxEntries, + TTL: cfg.Introspection.ClaimsCache.TTL, + }, + NegativeCache: idptoken.IntrospectorCacheOpts{ + Enabled: cfg.Introspection.NegativeCache.Enabled, + MaxEntries: cfg.Introspection.NegativeCache.MaxEntries, + TTL: cfg.Introspection.NegativeCache.TTL, + }, } - - if cfg.Introspection.ClaimsCache.Enabled || cfg.Introspection.NegativeCache.Enabled { - cachingIntrospector, err := idptoken.NewCachingIntrospectorWithOpts(tokenProvider, idptoken.CachingIntrospectorOpts{ - IntrospectorOpts: introspectorOpts, - ClaimsCache: idptoken.CachingIntrospectorCacheOpts{ - Enabled: cfg.Introspection.ClaimsCache.Enabled, - MaxEntries: cfg.Introspection.ClaimsCache.MaxEntries, - TTL: cfg.Introspection.ClaimsCache.TTL, - }, - NegativeCache: idptoken.CachingIntrospectorCacheOpts{ - Enabled: cfg.Introspection.NegativeCache.Enabled, - MaxEntries: cfg.Introspection.NegativeCache.MaxEntries, - TTL: cfg.Introspection.NegativeCache.TTL, - }, - }) - if err != nil { - return nil, fmt.Errorf("new caching introspector: %w", err) - } - if err = addTrustedIssuers(cachingIntrospector, cfg.JWT.TrustedIssuers, cfg.JWT.TrustedIssuerURLs); err != nil { - return nil, err - } - return cachingIntrospector, nil + introspector, err := idptoken.NewIntrospectorWithOpts(tokenProvider, introspectorOpts) + if err != nil { + return nil, err } - introspector := idptoken.NewIntrospectorWithOpts(tokenProvider, introspectorOpts) - if err := addTrustedIssuers(introspector, cfg.JWT.TrustedIssuers, cfg.JWT.TrustedIssuerURLs); err != nil { + if err = addTrustedIssuers(introspector, cfg.JWT.TrustedIssuers, cfg.JWT.TrustedIssuerURLs); err != nil { return nil, err } + return introspector, nil } diff --git a/auth_test.go b/auth_test.go index 4a0a920..f0b4569 100644 --- a/auth_test.go +++ b/auth_test.go @@ -32,6 +32,7 @@ import ( "github.com/acronis/go-authkit/idptest" "github.com/acronis/go-authkit/idptoken" "github.com/acronis/go-authkit/idptoken/pb" + "github.com/acronis/go-authkit/internal/idputil" "github.com/acronis/go-authkit/internal/testing" "github.com/acronis/go-authkit/jwt" ) @@ -126,9 +127,12 @@ func TestNewJWTParser(t *gotesting.T) { func TestNewTokenIntrospector(t *gotesting.T) { const testIss = "test-issuer" + const validAccessToken = "access-token-with-introspection-permission" httpServerIntrospector := testing.NewHTTPServerTokenIntrospectorMock() + httpServerIntrospector.SetAccessTokenForIntrospection(validAccessToken) grpcServerIntrospector := testing.NewGRPCServerTokenIntrospectorMock() + grpcServerIntrospector.SetAccessTokenForIntrospection(validAccessToken) // Start testing HTTP IDP server. httpIDPSrv := idptest.NewHTTPServer(idptest.WithHTTPTokenIntrospector(httpServerIntrospector)) @@ -171,9 +175,9 @@ func TestNewTokenIntrospector(t *gotesting.T) { ResourcePath: "resource-" + uuid.NewString(), }} httpServerIntrospector.SetResultForToken(opaqueToken, idptoken.IntrospectionResult{ - Active: true, TokenType: idptoken.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueTokenScope}}) + Active: true, TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueTokenScope}}) grpcServerIntrospector.SetResultForToken(opaqueToken, &pb.IntrospectTokenResponse{ - Active: true, TokenType: idptoken.TokenTypeBearer, Scope: []*pb.AccessTokenScope{ + Active: true, TokenType: idputil.TokenTypeBearer, Scope: []*pb.AccessTokenScope{ { TenantUuid: opaqueTokenScope[0].TenantUUID, ResourceNamespace: opaqueTokenScope[0].ResourceNamespace, @@ -187,7 +191,7 @@ func TestNewTokenIntrospector(t *gotesting.T) { cfg *Config token string expectedResult idptoken.IntrospectionResult - checkFn func(t *gotesting.T, introspector TokenIntrospector) + checkCacheFn func(t *gotesting.T, introspector *idptoken.Introspector) }{ { name: "new token introspector, dynamic endpoint, trusted issuers map", @@ -198,8 +202,9 @@ func TestNewTokenIntrospector(t *gotesting.T) { TokenType: "bearer", Claims: *claimsWithNamedIssuer, }, - checkFn: func(t *gotesting.T, introspector TokenIntrospector) { - require.IsType(t, &idptoken.Introspector{}, introspector) + checkCacheFn: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Empty(t, introspector.ClaimsCache.Len(context.Background())) + require.Empty(t, introspector.NegativeCache.Len(context.Background())) }, }, { @@ -211,8 +216,9 @@ func TestNewTokenIntrospector(t *gotesting.T) { TokenType: "bearer", Claims: *claims, }, - checkFn: func(t *gotesting.T, introspector TokenIntrospector) { - require.IsType(t, &idptoken.Introspector{}, introspector) + checkCacheFn: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Empty(t, introspector.ClaimsCache.Len(context.Background())) + require.Empty(t, introspector.NegativeCache.Len(context.Background())) }, }, { @@ -227,10 +233,9 @@ func TestNewTokenIntrospector(t *gotesting.T) { TokenType: "bearer", Claims: *claimsWithNamedIssuer, }, - checkFn: func(t *gotesting.T, introspector TokenIntrospector) { - require.IsType(t, &idptoken.CachingIntrospector{}, introspector) - cachingIntrospector := introspector.(*idptoken.CachingIntrospector) - require.Equal(t, 1, cachingIntrospector.ClaimsCache.Len(context.Background())) + checkCacheFn: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Equal(t, 1, introspector.ClaimsCache.Len(context.Background())) + require.Empty(t, introspector.NegativeCache.Len(context.Background())) }, }, { @@ -245,10 +250,9 @@ func TestNewTokenIntrospector(t *gotesting.T) { TokenType: "bearer", Claims: *claims, }, - checkFn: func(t *gotesting.T, introspector TokenIntrospector) { - require.IsType(t, &idptoken.CachingIntrospector{}, introspector) - cachingIntrospector := introspector.(*idptoken.CachingIntrospector) - require.Equal(t, 1, cachingIntrospector.ClaimsCache.Len(context.Background())) + checkCacheFn: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Equal(t, 1, introspector.ClaimsCache.Len(context.Background())) + require.Empty(t, introspector.NegativeCache.Len(context.Background())) }, }, { @@ -266,10 +270,9 @@ func TestNewTokenIntrospector(t *gotesting.T) { TokenType: "bearer", Claims: jwt.Claims{Scope: opaqueTokenScope}, }, - checkFn: func(t *gotesting.T, introspector TokenIntrospector) { - require.IsType(t, &idptoken.CachingIntrospector{}, introspector) - cachingIntrospector := introspector.(*idptoken.CachingIntrospector) - require.Equal(t, 1, cachingIntrospector.ClaimsCache.Len(context.Background())) + checkCacheFn: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Equal(t, 1, introspector.ClaimsCache.Len(context.Background())) + require.Empty(t, introspector.NegativeCache.Len(context.Background())) }, }, { @@ -285,7 +288,6 @@ func TestNewTokenIntrospector(t *gotesting.T) { CACert: certFile, }, }, - Endpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, }, }, token: opaqueToken, @@ -294,8 +296,9 @@ func TestNewTokenIntrospector(t *gotesting.T) { TokenType: "bearer", Claims: jwt.Claims{Scope: opaqueTokenScope}, }, - checkFn: func(t *gotesting.T, introspector TokenIntrospector) { - require.IsType(t, &idptoken.Introspector{}, introspector) + checkCacheFn: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Empty(t, introspector.ClaimsCache.Len(context.Background())) + require.Empty(t, introspector.NegativeCache.Len(context.Background())) }, }, } @@ -306,14 +309,14 @@ func TestNewTokenIntrospector(t *gotesting.T) { httpServerIntrospector.JWTParser = jwtParser grpcServerIntrospector.JWTParser = jwtParser - introspector, err := NewTokenIntrospector(tt.cfg, idptest.NewSimpleTokenProvider("access-token"), nil) + introspector, err := NewTokenIntrospector(tt.cfg, idptest.NewSimpleTokenProvider(validAccessToken), nil) require.NoError(t, err) result, err := introspector.IntrospectToken(context.Background(), tt.token) require.NoError(t, err) require.Equal(t, tt.expectedResult, result) - if tt.checkFn != nil { - tt.checkFn(t, introspector) + if tt.checkCacheFn != nil { + tt.checkCacheFn(t, introspector) } }) } diff --git a/examples/authn-middleware/main.go b/examples/authn-middleware/main.go index 664f721..90d0320 100644 --- a/examples/authn-middleware/main.go +++ b/examples/authn-middleware/main.go @@ -1,3 +1,9 @@ +/* +Copyright © 2024 Acronis International GmbH. + +Released under MIT license. +*/ + package main import ( diff --git a/examples/idp-test-server/main.go b/examples/idp-test-server/main.go index aea6e96..9a0ecbf 100644 --- a/examples/idp-test-server/main.go +++ b/examples/idp-test-server/main.go @@ -1,9 +1,14 @@ +/* +Copyright © 2024 Acronis International GmbH. + +Released under MIT license. +*/ + package main import ( "context" "errors" - "github.com/acronis/go-authkit" golog "log" "net/http" "os" @@ -16,6 +21,7 @@ import ( jwtgo "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + "github.com/acronis/go-authkit" "github.com/acronis/go-authkit/idptest" "github.com/acronis/go-authkit/idptoken" "github.com/acronis/go-authkit/jwks" diff --git a/examples/token-introspection/main.go b/examples/token-introspection/main.go index 67d1050..d4ec2fe 100644 --- a/examples/token-introspection/main.go +++ b/examples/token-introspection/main.go @@ -1,3 +1,9 @@ +/* +Copyright © 2024 Acronis International GmbH. + +Released under MIT license. +*/ + package main import ( @@ -10,9 +16,9 @@ import ( "github.com/acronis/go-appkit/config" "github.com/acronis/go-appkit/httpserver/middleware" "github.com/acronis/go-appkit/log" - "github.com/acronis/go-authkit/idptoken" "github.com/acronis/go-authkit" + "github.com/acronis/go-authkit/idptoken" ) const ( @@ -36,39 +42,51 @@ func runApp() error { logger, loggerClose := log.NewLogger(cfg.Log) defer loggerClose() - // create JWT parser and token introspector + // Create JWT parser. jwtParser, err := authkit.NewJWTParser(cfg.Auth, authkit.WithJWTParserLogger(logger)) if err != nil { return fmt.Errorf("create JWT parser: %w", err) } + + // Create token introspector. introspectionScopeFilter := []idptoken.IntrospectionScopeFilterAccessPolicy{ {ResourceNamespace: serviceAccessPolicy}} tokenIntrospector, err := authkit.NewTokenIntrospector(cfg.Auth, introspectionTokenProvider{}, introspectionScopeFilter, authkit.WithTokenIntrospectorLogger(logger)) + if err != nil { + return fmt.Errorf("create token introspector: %w", err) + } + if tokenIntrospector.GRPCClient != nil { + defer func() { + if closeErr := tokenIntrospector.GRPCClient.Close(); closeErr != nil { + logger.Error("failed to close gRPC client", log.Error(closeErr)) + } + }() + } logMw := middleware.Logging(logger) - // configure JWTAuthMiddleware that performs only authentication via OAuth2 token introspection endpoint + // Configure JWTAuthMiddleware that performs only authentication via OAuth2 token introspection endpoint. authNMw := authkit.JWTAuthMiddleware(serviceErrorDomain, jwtParser, authkit.WithJWTAuthMiddlewareTokenIntrospector(tokenIntrospector)) - // configure JWTAuthMiddleware that performs authentication via token introspection endpoint - // and authorization based on the user's roles + // Configure JWTAuthMiddleware that performs authentication via token introspection endpoint + // and authorization based on the user's roles. authZMw := authkit.JWTAuthMiddleware(serviceErrorDomain, jwtParser, authkit.WithJWTAuthMiddlewareTokenIntrospector(tokenIntrospector), authkit.WithJWTAuthMiddlewareVerifyAccess( authkit.NewVerifyAccessByRolesInJWT(authkit.Role{Namespace: serviceAccessPolicy, Name: "admin"}))) - // create HTTP server and start it + // Create HTTP server and start it. srvMux := http.NewServeMux() - // "/" endpoint will be available for all authenticated users + // "/" endpoint will be available for all authenticated users. srvMux.Handle("/", logMw(authNMw(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { jwtClaims := authkit.GetJWTClaimsFromContext(r.Context()) // get JWT claims from the request context _, _ = rw.Write([]byte(fmt.Sprintf("Hello, %s", jwtClaims.Subject))) })))) - // "/admin" endpoint will be available only for users with the "admin" role + // "/admin" endpoint will be available only for users with the "admin" role. srvMux.Handle("/admin", logMw(authZMw(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - jwtClaims := authkit.GetJWTClaimsFromContext(r.Context()) // get JWT claims from the request context + jwtClaims := authkit.GetJWTClaimsFromContext(r.Context()) // Get JWT claims from the request context. _, _ = rw.Write([]byte(fmt.Sprintf("Hi, %s", jwtClaims.Subject))) })))) if err = http.ListenAndServe(":8080", srvMux); err != nil && !errors.Is(err, http.ErrServerClosed) { diff --git a/idptest/jwt.go b/idptest/jwt.go index 51660b1..886f318 100644 --- a/idptest/jwt.go +++ b/idptest/jwt.go @@ -13,7 +13,7 @@ import ( jwtgo "github.com/golang-jwt/jwt/v5" "github.com/mendsley/gojwk" - "github.com/acronis/go-authkit/idptoken" + "github.com/acronis/go-authkit/internal/idputil" ) // SignToken signs token with key. @@ -36,7 +36,7 @@ func MakeTokenStringWithHeader( claims jwtgo.Claims, kid string, rsaPrivateKey interface{}, header map[string]interface{}, ) (string, error) { token := jwtgo.NewWithClaims(jwtgo.SigningMethodRS256, claims) - token.Header["typ"] = idptoken.JWTTypeAccessToken + token.Header["typ"] = idputil.JWTTypeAccessToken token.Header["kid"] = kid for k, v := range header { token.Header[k] = v diff --git a/idptoken/caching_introspector.go b/idptoken/caching_introspector.go deleted file mode 100644 index e00a807..0000000 --- a/idptoken/caching_introspector.go +++ /dev/null @@ -1,194 +0,0 @@ -/* -Copyright © 2024 Acronis International GmbH. - -Released under MIT license. -*/ - -package idptoken - -import ( - "context" - "crypto/sha256" - "fmt" - "time" - "unsafe" - - "github.com/acronis/go-appkit/lrucache" - - "github.com/acronis/go-authkit/jwt" -) - -const ( - DefaultIntrospectionClaimsCacheMaxEntries = 1000 - DefaultIntrospectionClaimsCacheTTL = 1 * time.Minute - DefaultIntrospectionNegativeCacheMaxEntries = 1000 - DefaultIntrospectionNegativeCacheTTL = 10 * time.Minute -) - -type IntrospectionClaimsCacheItem struct { - Claims *jwt.Claims - TokenType string - CreatedAt time.Time -} - -type IntrospectionClaimsCache interface { - Get(ctx context.Context, key [sha256.Size]byte) (IntrospectionClaimsCacheItem, bool) - Add(ctx context.Context, key [sha256.Size]byte, value IntrospectionClaimsCacheItem) - Purge(ctx context.Context) - Len(ctx context.Context) int -} - -type IntrospectionNegativeCacheItem struct { - CreatedAt time.Time -} - -type IntrospectionNegativeCache interface { - Get(ctx context.Context, key [sha256.Size]byte) (IntrospectionNegativeCacheItem, bool) - Add(ctx context.Context, key [sha256.Size]byte, value IntrospectionNegativeCacheItem) - Purge(ctx context.Context) - Len(ctx context.Context) int -} - -type CachingIntrospectorOpts struct { - IntrospectorOpts - ClaimsCache CachingIntrospectorCacheOpts - NegativeCache CachingIntrospectorCacheOpts -} - -type CachingIntrospectorCacheOpts struct { - Enabled bool - MaxEntries int - TTL time.Duration -} - -type CachingIntrospector struct { - *Introspector - ClaimsCache IntrospectionClaimsCache - NegativeCache IntrospectionNegativeCache - claimsCacheTTL time.Duration - negativeCacheTTL time.Duration -} - -func NewCachingIntrospector(tokenProvider IntrospectionTokenProvider) (*CachingIntrospector, error) { - return NewCachingIntrospectorWithOpts(tokenProvider, CachingIntrospectorOpts{}) -} - -func NewCachingIntrospectorWithOpts( - tokenProvider IntrospectionTokenProvider, opts CachingIntrospectorOpts, -) (*CachingIntrospector, error) { - if !opts.ClaimsCache.Enabled && !opts.NegativeCache.Enabled { - return nil, fmt.Errorf("at least one of claims or negative cache must be enabled") - } - - introspector := NewIntrospectorWithOpts(tokenProvider, opts.IntrospectorOpts) - - // Building claims cache if needed. - var claimsCache IntrospectionClaimsCache = &disabledIntrospectionClaimsCache{} - if opts.ClaimsCache.Enabled { - if opts.ClaimsCache.TTL == 0 { - opts.ClaimsCache.TTL = DefaultIntrospectionClaimsCacheTTL - } - if opts.ClaimsCache.MaxEntries == 0 { - opts.ClaimsCache.MaxEntries = DefaultIntrospectionClaimsCacheMaxEntries - } - cache, err := lrucache.New[[sha256.Size]byte, IntrospectionClaimsCacheItem]( - opts.ClaimsCache.MaxEntries, introspector.promMetrics.TokenClaimsCache) - if err != nil { - return nil, err - } - claimsCache = &introspectionCacheLRUAdapter[[sha256.Size]byte, IntrospectionClaimsCacheItem]{cache} - } - - // Building negative cache if needed. - var negativeCache IntrospectionNegativeCache = &disabledIntrospectionNegativeCache{} - if opts.NegativeCache.Enabled { - if opts.NegativeCache.TTL == 0 { - opts.NegativeCache.TTL = DefaultIntrospectionNegativeCacheTTL - } - if opts.NegativeCache.MaxEntries == 0 { - opts.NegativeCache.MaxEntries = DefaultIntrospectionNegativeCacheMaxEntries - } - cache, err := lrucache.New[[sha256.Size]byte, IntrospectionNegativeCacheItem]( - opts.NegativeCache.MaxEntries, introspector.promMetrics.TokenNegativeCache) - if err != nil { - return nil, err - } - negativeCache = &introspectionCacheLRUAdapter[[sha256.Size]byte, IntrospectionNegativeCacheItem]{cache} - } - - return &CachingIntrospector{ - Introspector: introspector, - ClaimsCache: claimsCache, - NegativeCache: negativeCache, - claimsCacheTTL: opts.ClaimsCache.TTL, - negativeCacheTTL: opts.NegativeCache.TTL, - }, nil -} - -func (i *CachingIntrospector) IntrospectToken(ctx context.Context, token string) (IntrospectionResult, error) { - cacheKey := sha256.Sum256( - unsafe.Slice(unsafe.StringData(token), len(token))) // nolint:gosec // prevent redundant slice copying - - if c, ok := i.ClaimsCache.Get(ctx, cacheKey); ok && c.CreatedAt.Add(i.claimsCacheTTL).After(time.Now()) { - return IntrospectionResult{Active: true, TokenType: c.TokenType, Claims: *c.Claims}, nil - } - if c, ok := i.NegativeCache.Get(ctx, cacheKey); ok && c.CreatedAt.Add(i.negativeCacheTTL).After(time.Now()) { - return IntrospectionResult{Active: false}, nil - } - - introspectionResult, err := i.Introspector.IntrospectToken(ctx, token) - if err != nil { - return IntrospectionResult{}, err - } - if introspectionResult.Active { - i.ClaimsCache.Add(ctx, cacheKey, IntrospectionClaimsCacheItem{ - Claims: &introspectionResult.Claims, - TokenType: introspectionResult.TokenType, - CreatedAt: time.Now(), - }) - } else { - i.NegativeCache.Add(ctx, cacheKey, IntrospectionNegativeCacheItem{CreatedAt: time.Now()}) - } - - return introspectionResult, nil -} - -type introspectionCacheLRUAdapter[K comparable, V any] struct { - cache *lrucache.LRUCache[K, V] -} - -func (a *introspectionCacheLRUAdapter[K, V]) Get(_ context.Context, key K) (V, bool) { - return a.cache.Get(key) -} - -func (a *introspectionCacheLRUAdapter[K, V]) Add(_ context.Context, key K, val V) { - a.cache.Add(key, val) -} - -func (a *introspectionCacheLRUAdapter[K, V]) Purge(ctx context.Context) { - a.cache.Purge() -} - -func (a *introspectionCacheLRUAdapter[K, V]) Len(ctx context.Context) int { - return a.cache.Len() -} - -type disabledIntrospectionClaimsCache struct{} - -func (c *disabledIntrospectionClaimsCache) Get(ctx context.Context, key [sha256.Size]byte) (IntrospectionClaimsCacheItem, bool) { - return IntrospectionClaimsCacheItem{}, false -} -func (c *disabledIntrospectionClaimsCache) Add(ctx context.Context, key [sha256.Size]byte, value IntrospectionClaimsCacheItem) { -} -func (c *disabledIntrospectionClaimsCache) Purge(ctx context.Context) {} -func (c *disabledIntrospectionClaimsCache) Len(ctx context.Context) int { return 0 } - -type disabledIntrospectionNegativeCache struct{} - -func (c *disabledIntrospectionNegativeCache) Get(ctx context.Context, key [sha256.Size]byte) (IntrospectionNegativeCacheItem, bool) { - return IntrospectionNegativeCacheItem{}, false -} -func (c *disabledIntrospectionNegativeCache) Add(ctx context.Context, key [sha256.Size]byte, value IntrospectionNegativeCacheItem) { -} -func (c *disabledIntrospectionNegativeCache) Purge(ctx context.Context) {} -func (c *disabledIntrospectionNegativeCache) Len(ctx context.Context) int { return 0 } diff --git a/idptoken/caching_introspector_test.go b/idptoken/caching_introspector_test.go deleted file mode 100644 index a4d61df..0000000 --- a/idptoken/caching_introspector_test.go +++ /dev/null @@ -1,254 +0,0 @@ -/* -Copyright © 2024 Acronis International GmbH. - -Released under MIT license. -*/ - -package idptoken_test - -import ( - "context" - "net/url" - gotesting "testing" - "time" - - "github.com/acronis/go-appkit/log" - jwtgo "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "github.com/acronis/go-authkit/idptest" - "github.com/acronis/go-authkit/idptoken" - "github.com/acronis/go-authkit/internal/testing" - "github.com/acronis/go-authkit/jwks" - "github.com/acronis/go-authkit/jwt" -) - -func TestCachingIntrospector_IntrospectToken(t *gotesting.T) { - serverIntrospector := testing.NewHTTPServerTokenIntrospectorMock() - - idpSrv := idptest.NewHTTPServer(idptest.WithHTTPTokenIntrospector(serverIntrospector)) - require.NoError(t, idpSrv.StartAndWaitForReady(time.Second)) - defer func() { _ = idpSrv.Shutdown(context.Background()) }() - - const accessToken = "access-token-with-introspection-permission" - tokenProvider := idptest.NewSimpleTokenProvider(accessToken) - - logger := log.NewDisabledLogger() - jwtParser := jwt.NewParser(jwks.NewClient(), logger) - require.NoError(t, jwtParser.AddTrustedIssuerURL(idpSrv.URL())) - serverIntrospector.JWTParser = jwtParser - - jwtExpiresAtInFuture := jwtgo.NewNumericDate(time.Now().Add(time.Hour)) - jwtIssuer := idpSrv.URL() - jwtSubject := uuid.NewString() - jwtID := uuid.NewString() - jwtScope := []jwt.AccessPolicy{{ - TenantUUID: uuid.NewString(), - ResourceNamespace: "account-server", - Role: "account_viewer", - ResourcePath: "resource-" + uuid.NewString(), - }} - - expiredJWT := idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ - RegisteredClaims: jwtgo.RegisteredClaims{ - Issuer: idpSrv.URL(), - Subject: uuid.NewString(), - ID: uuid.NewString(), - ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(-time.Hour)), - }, - }) - activeJWT := idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ - RegisteredClaims: jwtgo.RegisteredClaims{ - Issuer: jwtIssuer, - Subject: jwtSubject, - ID: jwtID, - ExpiresAt: jwtExpiresAtInFuture, - }, - }) - - opaqueToken1 := "opaque-token-" + uuid.NewString() - opaqueToken2 := "opaque-token-" + uuid.NewString() - opaqueToken3 := "opaque-token-" + uuid.NewString() - opaqueToken1Scope := []jwt.AccessPolicy{{ - TenantUUID: uuid.NewString(), - ResourceNamespace: "account-server", - Role: "admin", - ResourcePath: "resource-" + uuid.NewString(), - }} - opaqueToken2Scope := []jwt.AccessPolicy{{ - TenantUUID: uuid.NewString(), - ResourceNamespace: "event-manager", - Role: "admin", - ResourcePath: "resource-" + uuid.NewString(), - }} - - serverIntrospector.SetScopeForJWTID(jwtID, jwtScope) - serverIntrospector.SetResultForToken(opaqueToken1, idptoken.IntrospectionResult{ - Active: true, TokenType: idptoken.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken1Scope}}) - serverIntrospector.SetResultForToken(opaqueToken2, idptoken.IntrospectionResult{ - Active: true, TokenType: idptoken.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken2Scope}}) - serverIntrospector.SetResultForToken(opaqueToken3, idptoken.IntrospectionResult{Active: false}) - - tests := []struct { - name string - introspectorOpts idptoken.CachingIntrospectorOpts - tokens []string - expectedSrvCalled []bool - expectedResult []idptoken.IntrospectionResult - checkError []func(t *gotesting.T, err error) - checkIntrospector func(t *gotesting.T, introspector *idptoken.CachingIntrospector) - delay time.Duration - }{ - { - name: "error, token is not introspectable", - tokens: []string{"", "opaque-token"}, - expectedSrvCalled: []bool{false, false}, - introspectorOpts: idptoken.CachingIntrospectorOpts{ - ClaimsCache: idptoken.CachingIntrospectorCacheOpts{Enabled: true}, - NegativeCache: idptoken.CachingIntrospectorCacheOpts{Enabled: true}, - }, - checkError: []func(t *gotesting.T, err error){ - func(t *gotesting.T, err error) { - require.ErrorIs(t, err, idptoken.ErrTokenNotIntrospectable) - require.ErrorContains(t, err, "token is missing") - }, - func(t *gotesting.T, err error) { - require.ErrorIs(t, err, idptoken.ErrTokenNotIntrospectable) - require.ErrorContains(t, err, "no JWT header found") - }, - }, - checkIntrospector: func(t *gotesting.T, introspector *idptoken.CachingIntrospector) { - require.Equal(t, 0, introspector.ClaimsCache.Len(context.Background())) - require.Equal(t, 0, introspector.NegativeCache.Len(context.Background())) - }, - }, - { - name: "ok, dynamic introspection endpoint, introspected token is expired JWT", - introspectorOpts: idptoken.CachingIntrospectorOpts{ - ClaimsCache: idptoken.CachingIntrospectorCacheOpts{Enabled: true}, - NegativeCache: idptoken.CachingIntrospectorCacheOpts{Enabled: true}, - }, - tokens: repeat(expiredJWT, 2), - expectedSrvCalled: []bool{true, false}, - expectedResult: []idptoken.IntrospectionResult{{Active: false}, {Active: false}}, - checkIntrospector: func(t *gotesting.T, introspector *idptoken.CachingIntrospector) { - require.Equal(t, 0, introspector.ClaimsCache.Len(context.Background())) - require.Equal(t, 1, introspector.NegativeCache.Len(context.Background())) - }, - }, - { - name: "ok, dynamic introspection endpoint, introspected token is JWT", - introspectorOpts: idptoken.CachingIntrospectorOpts{ - ClaimsCache: idptoken.CachingIntrospectorCacheOpts{Enabled: true}, - NegativeCache: idptoken.CachingIntrospectorCacheOpts{Enabled: true}, - }, - tokens: repeat(activeJWT, 2), - expectedSrvCalled: []bool{true, false}, - expectedResult: repeat(idptoken.IntrospectionResult{ - Active: true, - TokenType: idptoken.TokenTypeBearer, - Claims: jwt.Claims{ - RegisteredClaims: jwtgo.RegisteredClaims{ - Issuer: jwtIssuer, - Subject: jwtSubject, - ID: jwtID, - ExpiresAt: jwtExpiresAtInFuture, - }, - Scope: jwtScope, - }, - }, 2), - checkIntrospector: func(t *gotesting.T, introspector *idptoken.CachingIntrospector) { - require.Equal(t, 1, introspector.ClaimsCache.Len(context.Background())) - require.Equal(t, 0, introspector.NegativeCache.Len(context.Background())) - }, - }, - { - name: "ok, static introspection endpoint, introspected token is opaque", - introspectorOpts: idptoken.CachingIntrospectorOpts{ - IntrospectorOpts: idptoken.IntrospectorOpts{ - StaticHTTPEndpoint: idpSrv.URL() + idptest.TokenIntrospectionEndpointPath, - }, - ClaimsCache: idptoken.CachingIntrospectorCacheOpts{Enabled: true}, - NegativeCache: idptoken.CachingIntrospectorCacheOpts{Enabled: true}, - }, - tokens: []string{opaqueToken1, opaqueToken1, opaqueToken2, opaqueToken2, opaqueToken3, opaqueToken3}, - expectedSrvCalled: []bool{true, false, true, false, true, false}, - expectedResult: []idptoken.IntrospectionResult{ - {Active: true, TokenType: idptoken.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken1Scope}}, - {Active: true, TokenType: idptoken.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken1Scope}}, - {Active: true, TokenType: idptoken.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken2Scope}}, - {Active: true, TokenType: idptoken.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken2Scope}}, - {Active: false}, - {Active: false}, - }, - checkIntrospector: func(t *gotesting.T, introspector *idptoken.CachingIntrospector) { - require.Equal(t, 2, introspector.ClaimsCache.Len(context.Background())) - require.Equal(t, 1, introspector.NegativeCache.Len(context.Background())) - }, - }, - { - name: "ok, cache has ttl", - introspectorOpts: idptoken.CachingIntrospectorOpts{ - IntrospectorOpts: idptoken.IntrospectorOpts{ - StaticHTTPEndpoint: idpSrv.URL() + idptest.TokenIntrospectionEndpointPath, - }, - ClaimsCache: idptoken.CachingIntrospectorCacheOpts{Enabled: true, TTL: 100 * time.Millisecond}, - NegativeCache: idptoken.CachingIntrospectorCacheOpts{Enabled: true, TTL: 100 * time.Millisecond}, - }, - tokens: []string{opaqueToken1, opaqueToken1, opaqueToken3, opaqueToken3}, - expectedSrvCalled: []bool{true, true, true, true}, - expectedResult: []idptoken.IntrospectionResult{ - {Active: true, TokenType: idptoken.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken1Scope}}, - {Active: true, TokenType: idptoken.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken1Scope}}, - {Active: false}, - {Active: false}, - }, - checkIntrospector: func(t *gotesting.T, introspector *idptoken.CachingIntrospector) { - require.Equal(t, 1, introspector.ClaimsCache.Len(context.Background())) - require.Equal(t, 1, introspector.NegativeCache.Len(context.Background())) - }, - delay: 200 * time.Millisecond, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *gotesting.T) { - introspector, err := idptoken.NewCachingIntrospectorWithOpts(tokenProvider, tt.introspectorOpts) - require.NoError(t, err) - require.NoError(t, introspector.AddTrustedIssuerURL(idpSrv.URL())) - - for i, token := range tt.tokens { - serverIntrospector.ResetCallsInfo() - - result, introspectErr := introspector.IntrospectToken(context.Background(), token) - if i < len(tt.checkError) { - tt.checkError[i](t, introspectErr) - } else { - require.NoError(t, introspectErr) - require.Equal(t, tt.expectedResult[i], result) - } - - require.Equal(t, tt.expectedSrvCalled[i], serverIntrospector.Called) - if tt.expectedSrvCalled[i] { - require.Equal(t, token, serverIntrospector.LastIntrospectedToken) - require.Equal(t, "Bearer "+accessToken, serverIntrospector.LastAuthorizationHeader) - require.Equal(t, url.Values{"token": {token}}, serverIntrospector.LastFormValues) - } - - time.Sleep(tt.delay) - } - - if tt.checkIntrospector != nil { - tt.checkIntrospector(t, introspector) - } - }) - } -} - -func repeat[V any](v V, n int) []V { - s := make([]V, n) - for i := range s { - s[i] = v - } - return s -} diff --git a/idptoken/grpc_client.go b/idptoken/grpc_client.go index b787d8e..69cd158 100644 --- a/idptoken/grpc_client.go +++ b/idptoken/grpc_client.go @@ -27,8 +27,11 @@ import ( "github.com/acronis/go-authkit/jwt" ) +// DefaultGRPCClientRequestTimeout is a default timeout for the gRPC requests. const DefaultGRPCClientRequestTimeout = time.Second * 30 +const grpcMetaAuthorization = "authorization" + // GRPCClientOpts contains options for the GRPCClient. type GRPCClientOpts struct { // Logger is a logger for the client. @@ -50,8 +53,6 @@ type GRPCClient struct { promMetrics *metrics.PrometheusMetrics } -const grpcMetaAuthorization = "authorization" - // NewGRPCClient creates a new GRPCClient instance that communicates with the IDP token service. func NewGRPCClient( target string, transportCreds credentials.TransportCredentials, @@ -89,6 +90,18 @@ func (c *GRPCClient) Close() error { return c.clientConn.Close() } +// TokenData contains the data of the token issuing response from the IDP service. +type TokenData struct { + // AccessToken is the issued access token. + AccessToken string + + // TokenType is the type of the issued access token. + TokenType string + + // ExpiresIn is the duration of the access token validity. + ExpiresIn time.Duration +} + // IntrospectToken introspects the token using the IDP token service. func (c *GRPCClient) IntrospectToken( ctx context.Context, token string, scopeFilter []IntrospectionScopeFilterAccessPolicy, accessToken string, @@ -103,25 +116,14 @@ func (c *GRPCClient) IntrospectToken( ctx = metadata.AppendToOutgoingContext(ctx, grpcMetaAuthorization, makeBearerToken(accessToken)) - ctx, ctxCancel := context.WithTimeout(ctx, c.reqTimeout) - defer ctxCancel() - - const methodName = "IDPTokenService/IntrospectToken" - startTime := time.Now() - resp, err := c.client.IntrospectToken(ctx, &req) - elapsed := time.Since(startTime) - if err != nil { - var code grpccodes.Code - if st, ok := grpcstatus.FromError(err); ok { - code = st.Code() - } - c.promMetrics.ObserveGRPCClientRequest(methodName, code, elapsed) - if code == grpccodes.Unauthenticated { - return IntrospectionResult{}, ErrTokenIntrospectionUnauthenticated - } - return IntrospectionResult{}, fmt.Errorf("introspect token: %w", err) + var resp *pb.IntrospectTokenResponse + if err := c.do(ctx, "IDPTokenService/IntrospectToken", func(ctx context.Context) error { + var innerErr error + resp, innerErr = c.client.IntrospectToken(ctx, &req) + return innerErr + }); err != nil { + return IntrospectionResult{}, err } - c.promMetrics.ObserveGRPCClientRequest(methodName, grpccodes.OK, elapsed) res := IntrospectionResult{ Active: resp.GetActive(), @@ -157,6 +159,53 @@ func (c *GRPCClient) IntrospectToken( return res, nil } +// ExchangeToken exchanges the token requesting a new token with the specified version. +func (c *GRPCClient) ExchangeToken(ctx context.Context, token string, tokenVersion uint32) (TokenData, error) { + req := pb.CreateTokenRequest{ + GrantType: idputil.GrantTypeJWTBearer, + Assertion: token, + TokenVersion: tokenVersion, + } + + var resp *pb.CreateTokenResponse + if err := c.do(ctx, "IDPTokenService/CreateToken", func(ctx context.Context) error { + var innerErr error + resp, innerErr = c.client.CreateToken(ctx, &req) + return innerErr + }); err != nil { + return TokenData{}, err + } + + return TokenData{ + AccessToken: resp.GetAccessToken(), + TokenType: resp.GetTokenType(), + ExpiresIn: time.Second * time.Duration(resp.GetExpiresIn()), + }, nil +} + +func (c *GRPCClient) do(ctx context.Context, methodName string, call func(ctx context.Context) error) error { + ctx, ctxCancel := context.WithTimeout(ctx, c.reqTimeout) + defer ctxCancel() + + startTime := time.Now() + err := call(ctx) + elapsed := time.Since(startTime) + if err != nil { + var code grpccodes.Code + if st, ok := grpcstatus.FromError(err); ok { + code = st.Code() + } + c.promMetrics.ObserveGRPCClientRequest(methodName, code, elapsed) + if code == grpccodes.Unauthenticated { + return ErrUnauthenticated + } + return err + } + c.promMetrics.ObserveGRPCClientRequest(methodName, grpccodes.OK, elapsed) + + return nil +} + type statsHandler struct { logger log.FieldLogger } diff --git a/idptoken/grpc_client_test.go b/idptoken/grpc_client_test.go new file mode 100644 index 0000000..2311047 --- /dev/null +++ b/idptoken/grpc_client_test.go @@ -0,0 +1,105 @@ +/* +Copyright © 2024 Acronis International GmbH. + +Released under MIT license. +*/ + +package idptoken_test + +import ( + "context" + gotesting "testing" + "time" + + jwtgo "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/credentials/insecure" + + "github.com/acronis/go-authkit/idptest" + "github.com/acronis/go-authkit/idptoken" + "github.com/acronis/go-authkit/idptoken/pb" + "github.com/acronis/go-authkit/internal/idputil" + "github.com/acronis/go-authkit/internal/testing" + "github.com/acronis/go-authkit/jwt" +) + +func TestGRPCClient_ExchangeToken(t *gotesting.T) { + tokenExpiresIn := time.Hour + tokenExpiresAt := time.Now().Add(time.Hour) + tokenV1 := idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ + RegisteredClaims: jwtgo.RegisteredClaims{ + Subject: "test-subject", + ExpiresAt: jwtgo.NewNumericDate(tokenExpiresAt), + }, + Version: 1, + }) + tokenV2 := idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ + RegisteredClaims: jwtgo.RegisteredClaims{ + Subject: "test-subject", + ExpiresAt: jwtgo.NewNumericDate(tokenExpiresAt), + }, + Version: 2, + }) + + grpcServerTokenCreator := testing.NewGRPCServerTokenCreatorMock() + grpcServerTokenCreator.SetResultForToken(tokenV1, &pb.CreateTokenResponse{ + AccessToken: tokenV2, + ExpiresIn: int64(tokenExpiresIn.Seconds()), + TokenType: "Bearer", + }) + + grpcIDPSrv := idptest.NewGRPCServer(idptest.WithGRPCTokenCreator(grpcServerTokenCreator)) + require.NoError(t, grpcIDPSrv.StartAndWaitForReady(time.Second)) + defer func() { grpcIDPSrv.GracefulStop() }() + + grpcClient, err := idptoken.NewGRPCClient(grpcIDPSrv.Addr(), insecure.NewCredentials()) + require.NoError(t, err) + defer func() { require.NoError(t, grpcClient.Close()) }() + + tests := []struct { + name string + assertion string + tokenVersion uint32 + expectedTokenData idptoken.TokenData + expectedRequest *pb.CreateTokenRequest + checkErr func(t *gotesting.T, err error) + }{ + { + name: "invalid assertion", + assertion: "invalid-assertion", + checkErr: func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrUnauthenticated) + }, + }, + { + name: "ok", + assertion: tokenV1, + tokenVersion: 2, + expectedRequest: &pb.CreateTokenRequest{ + GrantType: idputil.GrantTypeJWTBearer, + Assertion: tokenV1, + TokenVersion: 2, + }, + expectedTokenData: idptoken.TokenData{ + AccessToken: tokenV2, + ExpiresIn: tokenExpiresIn, + TokenType: "Bearer", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *gotesting.T) { + tokenData, err := grpcClient.ExchangeToken(context.Background(), tt.assertion, tt.tokenVersion) + if tt.checkErr != nil { + tt.checkErr(t, err) + } else { + require.Equal(t, tt.expectedTokenData, tokenData) + } + if tt.expectedRequest != nil { + require.Equal(t, tt.expectedRequest.GrantType, grpcServerTokenCreator.LastRequest.GrantType) + require.Equal(t, tt.expectedRequest.Assertion, grpcServerTokenCreator.LastRequest.Assertion) + require.Equal(t, tt.expectedRequest.TokenVersion, grpcServerTokenCreator.LastRequest.TokenVersion) + } + }) + } +} diff --git a/idptoken/idp_token.proto b/idptoken/idp_token.proto index 4299e6a..9d0ba7b 100644 --- a/idptoken/idp_token.proto +++ b/idptoken/idp_token.proto @@ -19,7 +19,7 @@ service IDPTokenService { // The token is considered active if // 1) it's not expired; // 2) it's not revoked; - // 3) it has has the valid signature. + // 3) it has a valid signature. rpc IntrospectToken (IntrospectTokenRequest) returns (IntrospectTokenResponse); } diff --git a/idptoken/introspector.go b/idptoken/introspector.go index e8a18f7..c400c51 100644 --- a/idptoken/introspector.go +++ b/idptoken/introspector.go @@ -9,6 +9,7 @@ package idptoken import ( "bytes" "context" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -18,8 +19,10 @@ import ( "strings" "sync/atomic" "time" + "unsafe" "github.com/acronis/go-appkit/log" + "github.com/acronis/go-appkit/lrucache" jwtgo "github.com/golang-jwt/jwt/v5" "github.com/acronis/go-authkit/internal/idputil" @@ -27,14 +30,26 @@ import ( "github.com/acronis/go-authkit/jwt" ) -const JWTTypeAccessToken = "at+jwt" - -const TokenTypeBearer = "bearer" - const minAccessTokenProviderInvalidationInterval = time.Minute const tokenIntrospectorPromSource = "token_introspector" +const ( + // DefaultIntrospectionClaimsCacheMaxEntries is a default maximum number of entries in the claims cache. + // Claims cache is used for storing introspected active tokens. + DefaultIntrospectionClaimsCacheMaxEntries = 1000 + + // DefaultIntrospectionClaimsCacheTTL is a default time-to-live for the claims cache. + DefaultIntrospectionClaimsCacheTTL = 1 * time.Minute + + // DefaultIntrospectionNegativeCacheMaxEntries is a default maximum number of entries in the negative cache. + // Negative cache is used for storing tokens that are not active. + DefaultIntrospectionNegativeCacheMaxEntries = 1000 + + // DefaultIntrospectionNegativeCacheTTL is a default time-to-live for the negative cache. + DefaultIntrospectionNegativeCacheTTL = 10 * time.Minute +) + // ErrTokenNotIntrospectable is returned when token is not introspectable. var ErrTokenNotIntrospectable = errors.New("token is not introspectable") @@ -42,8 +57,8 @@ 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") -// ErrTokenIntrospectionUnauthenticated is returned when token introspection is unauthenticated. -var ErrTokenIntrospectionUnauthenticated = errors.New("token introspection is unauthenticated") +// ErrUnauthenticated is returned when a request is unauthenticated. +var ErrUnauthenticated = errors.New("request is unauthenticated") // 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. @@ -63,16 +78,16 @@ type IntrospectionScopeFilterAccessPolicy struct { // IntrospectorOpts is a set of options for creating Introspector. type IntrospectorOpts struct { - // GRPCClient is a GRPC client for doing introspection. + // GRPCClient is a gRPC client for doing introspection. // If it is set, then introspection will be done using this client. // Otherwise, introspection will be done via HTTP. GRPCClient *GRPCClient - // StaticHTTPEndpoint is a static URL for introspection. + // HTTPEndpoint is a static URL for introspection. // If it is set, then introspection will be done using this endpoint. // Otherwise, introspection will be done using issuer URL (/.well-known/openid-configuration response). // In this case, issuer URL should be present in JWT header or payload. - StaticHTTPEndpoint string + HTTPEndpoint string // HTTPClient is an HTTP client for doing requests to /.well-known/openid-configuration and introspection endpoints. HTTPClient *http.Client @@ -95,6 +110,19 @@ type IntrospectorOpts struct { // PrometheusLibInstanceLabel is a label for Prometheus metrics. // It allows distinguishing metrics from different instances of the same library. PrometheusLibInstanceLabel string + + // ClaimsCache is a configuration of how claims cache will be used. + ClaimsCache IntrospectorCacheOpts + + // NegativeCache is a configuration of how negative cache will be used. + NegativeCache IntrospectorCacheOpts +} + +// IntrospectorCacheOpts is a configuration of how cache will be used. +type IntrospectorCacheOpts struct { + Enabled bool + MaxEntries int + TTL time.Duration } // Introspector is a struct for introspecting tokens. @@ -105,9 +133,10 @@ type Introspector struct { jwtParser *jwtgo.Parser - grpcClient *GRPCClient - staticHTTPURL string - httpClient *http.Client + GRPCClient *GRPCClient + + httpEndpoint string + httpClient *http.Client scopeFilter []IntrospectionScopeFilterAccessPolicy scopeFilterFormURLEncoded string @@ -118,6 +147,11 @@ type Introspector struct { trustedIssuerNotFoundFallback TrustedIssNotFoundFallback promMetrics *metrics.PrometheusMetrics + + ClaimsCache IntrospectionClaimsCache + claimsCacheTTL time.Duration + NegativeCache IntrospectionNegativeCache + negativeCacheTTL time.Duration } // IntrospectionResult is a struct for introspection result. @@ -128,13 +162,13 @@ type IntrospectionResult struct { } // NewIntrospector creates a new Introspector with the given token provider. -func NewIntrospector(tokenProvider IntrospectionTokenProvider) *Introspector { +func NewIntrospector(tokenProvider IntrospectionTokenProvider) (*Introspector, error) { return NewIntrospectorWithOpts(tokenProvider, IntrospectorOpts{}) } // NewIntrospectorWithOpts creates a new Introspector with the given token provider and options. // See IntrospectorOpts for more details. -func NewIntrospectorWithOpts(accessTokenProvider IntrospectionTokenProvider, opts IntrospectorOpts) *Introspector { +func NewIntrospectorWithOpts(accessTokenProvider IntrospectionTokenProvider, opts IntrospectorOpts) (*Introspector, error) { opts.Logger = idputil.PrepareLogger(opts.Logger) if opts.HTTPClient == nil { opts.HTTPClient = idputil.MakeDefaultHTTPClient(idputil.DefaultHTTPRequestTimeout, opts.Logger) @@ -148,24 +182,100 @@ func NewIntrospectorWithOpts(accessTokenProvider IntrospectionTokenProvider, opt promMetrics := metrics.GetPrometheusMetrics(opts.PrometheusLibInstanceLabel, tokenIntrospectorPromSource) + // Building claims cache if needed. + var claimsCache IntrospectionClaimsCache = &disabledIntrospectionClaimsCache{} + if opts.ClaimsCache.Enabled { + if opts.ClaimsCache.TTL == 0 { + opts.ClaimsCache.TTL = DefaultIntrospectionClaimsCacheTTL + } + if opts.ClaimsCache.MaxEntries == 0 { + opts.ClaimsCache.MaxEntries = DefaultIntrospectionClaimsCacheMaxEntries + } + cache, err := lrucache.New[[sha256.Size]byte, IntrospectionClaimsCacheItem]( + opts.ClaimsCache.MaxEntries, promMetrics.TokenClaimsCache) + if err != nil { + return nil, err + } + claimsCache = &IntrospectionLRUCache[[sha256.Size]byte, IntrospectionClaimsCacheItem]{cache} + } + + // Building negative cache if needed. + var negativeCache IntrospectionNegativeCache = &disabledIntrospectionNegativeCache{} + if opts.NegativeCache.Enabled { + if opts.NegativeCache.TTL == 0 { + opts.NegativeCache.TTL = DefaultIntrospectionNegativeCacheTTL + } + if opts.NegativeCache.MaxEntries == 0 { + opts.NegativeCache.MaxEntries = DefaultIntrospectionNegativeCacheMaxEntries + } + cache, err := lrucache.New[[sha256.Size]byte, IntrospectionNegativeCacheItem]( + opts.NegativeCache.MaxEntries, promMetrics.TokenNegativeCache) + if err != nil { + return nil, err + } + negativeCache = &IntrospectionLRUCache[[sha256.Size]byte, IntrospectionNegativeCacheItem]{cache} + } + return &Introspector{ accessTokenProvider: accessTokenProvider, accessTokenScope: opts.AccessTokenScope, jwtParser: jwtgo.NewParser(), logger: opts.Logger, - grpcClient: opts.GRPCClient, + GRPCClient: opts.GRPCClient, httpClient: opts.HTTPClient, + httpEndpoint: opts.HTTPEndpoint, scopeFilterFormURLEncoded: scopeFilterFormURLEncoded, scopeFilter: opts.ScopeFilter, - staticHTTPURL: opts.StaticHTTPEndpoint, trustedIssuerStore: idputil.NewTrustedIssuerStore(), trustedIssuerNotFoundFallback: opts.TrustedIssuerNotFoundFallback, promMetrics: promMetrics, - } + ClaimsCache: claimsCache, + claimsCacheTTL: opts.ClaimsCache.TTL, + NegativeCache: negativeCache, + negativeCacheTTL: opts.NegativeCache.TTL, + }, nil } // IntrospectToken introspects the given token. func (i *Introspector) IntrospectToken(ctx context.Context, token string) (IntrospectionResult, error) { + cacheKey := sha256.Sum256( + unsafe.Slice(unsafe.StringData(token), len(token))) // nolint:gosec // prevent redundant slice copying + + if c, ok := i.ClaimsCache.Get(ctx, cacheKey); ok && c.CreatedAt.Add(i.claimsCacheTTL).After(time.Now()) { + return IntrospectionResult{Active: true, TokenType: c.TokenType, Claims: *c.Claims}, nil + } + if c, ok := i.NegativeCache.Get(ctx, cacheKey); ok && c.CreatedAt.Add(i.negativeCacheTTL).After(time.Now()) { + return IntrospectionResult{Active: false}, nil + } + + introspectionResult, err := i.introspectToken(ctx, token) + if err != nil { + return IntrospectionResult{}, err + } + if introspectionResult.Active { + i.ClaimsCache.Add(ctx, cacheKey, IntrospectionClaimsCacheItem{ + Claims: &introspectionResult.Claims, + TokenType: introspectionResult.TokenType, + CreatedAt: time.Now(), + }) + } else { + i.NegativeCache.Add(ctx, cacheKey, IntrospectionNegativeCacheItem{CreatedAt: time.Now()}) + } + + return introspectionResult, nil +} + +// AddTrustedIssuer adds trusted issuer with specified name and URL. +func (i *Introspector) AddTrustedIssuer(issName, issURL string) { + i.trustedIssuerStore.AddTrustedIssuer(issName, issURL) +} + +// AddTrustedIssuerURL adds trusted issuer URL. +func (i *Introspector) AddTrustedIssuerURL(issURL string) error { + return i.trustedIssuerStore.AddTrustedIssuerURL(issURL) +} + +func (i *Introspector) introspectToken(ctx context.Context, token string) (IntrospectionResult, error) { introspectFn, err := i.makeIntrospectFuncForToken(ctx, token) if err != nil { return IntrospectionResult{}, err @@ -176,7 +286,7 @@ func (i *Introspector) IntrospectToken(ctx context.Context, token string) (Intro return result, nil } - if !errors.Is(err, ErrTokenIntrospectionUnauthenticated) { + if !errors.Is(err, ErrUnauthenticated) { return IntrospectionResult{}, err } @@ -191,23 +301,13 @@ func (i *Introspector) IntrospectToken(ctx context.Context, token string) (Intro return IntrospectionResult{}, err } -// AddTrustedIssuer adds trusted issuer with specified name and URL. -func (i *Introspector) AddTrustedIssuer(issName, issURL string) { - i.trustedIssuerStore.AddTrustedIssuer(issName, issURL) -} - -// AddTrustedIssuerURL adds trusted issuer URL. -func (i *Introspector) AddTrustedIssuerURL(issURL string) error { - return i.trustedIssuerStore.AddTrustedIssuerURL(issURL) -} - type introspectFunc func(ctx context.Context, token string) (IntrospectionResult, error) func (i *Introspector) makeIntrospectFuncForToken(ctx context.Context, token string) (introspectFunc, error) { var err error if token == "" { - return i.makeStaticIntrospectFuncOrError(fmt.Errorf("token is missing")) + return nil, makeTokenNotIntrospectableError(fmt.Errorf("token is missing")) } jwtHeaderEndIdx := strings.IndexByte(token, '.') @@ -224,17 +324,17 @@ func (i *Introspector) makeIntrospectFuncForToken(ctx context.Context, token str if err = headerDecoder.Decode(&jwtHeader); err != nil { return i.makeStaticIntrospectFuncOrError(fmt.Errorf("unmarshal JWT header: %w", err)) } - if typ, ok := jwtHeader["typ"].(string); !ok || !strings.EqualFold(typ, JWTTypeAccessToken) { - return i.makeStaticIntrospectFuncOrError(fmt.Errorf("token type is not %s", JWTTypeAccessToken)) + if typ, ok := jwtHeader["typ"].(string); !ok || !strings.EqualFold(typ, idputil.JWTTypeAccessToken) { + return i.makeStaticIntrospectFuncOrError(fmt.Errorf("token type is not %s", idputil.JWTTypeAccessToken)) } if !checkIntrospectionRequiredByJWTHeader(jwtHeader) { return nil, ErrTokenIntrospectionNotNeeded } - if i.staticHTTPURL != "" { - return i.makeIntrospectFuncHTTP(i.staticHTTPURL), nil + if i.httpEndpoint != "" { + return i.makeIntrospectFuncHTTP(i.httpEndpoint), nil } - if i.grpcClient != nil { + if i.GRPCClient != nil { return i.makeIntrospectFuncGRPC(), nil } @@ -278,11 +378,11 @@ func (i *Introspector) makeIntrospectFuncForToken(ctx context.Context, token str } func (i *Introspector) makeStaticIntrospectFuncOrError(inner error) (introspectFunc, error) { - if i.grpcClient != nil { + if i.GRPCClient != nil { return i.makeIntrospectFuncGRPC(), nil } - if i.staticHTTPURL != "" { - return i.makeIntrospectFuncHTTP(i.staticHTTPURL), nil + if i.httpEndpoint != "" { + return i.makeIntrospectFuncHTTP(i.httpEndpoint), nil } return nil, makeTokenNotIntrospectableError(inner) } @@ -321,7 +421,7 @@ func (i *Introspector) makeIntrospectFuncHTTP(introspectionEndpointURL string) i i.promMetrics.ObserveHTTPClientRequest( http.MethodPost, introspectionEndpointURL, resp.StatusCode, elapsed, metrics.HTTPRequestErrorUnexpectedStatusCode) if resp.StatusCode == http.StatusUnauthorized { - return IntrospectionResult{}, ErrTokenIntrospectionUnauthenticated + return IntrospectionResult{}, ErrUnauthenticated } return IntrospectionResult{}, fmt.Errorf("unexpected HTTP code %d for POST %s", resp.StatusCode, introspectionEndpointURL) } @@ -344,7 +444,7 @@ func (i *Introspector) makeIntrospectFuncGRPC() introspectFunc { if err != nil { return IntrospectionResult{}, fmt.Errorf("get access token for doing introspection: %w", err) } - res, err := i.grpcClient.IntrospectToken(ctx, token, i.scopeFilter, accessToken) + res, err := i.GRPCClient.IntrospectToken(ctx, token, i.scopeFilter, accessToken) if err != nil { return IntrospectionResult{}, fmt.Errorf("introspect token: %w", err) } @@ -408,3 +508,67 @@ func checkIntrospectionRequiredByJWTHeader(jwtHeader map[string]interface{}) boo } return true } + +type IntrospectionClaimsCacheItem struct { + Claims *jwt.Claims + TokenType string + CreatedAt time.Time +} + +type IntrospectionClaimsCache interface { + Get(ctx context.Context, key [sha256.Size]byte) (IntrospectionClaimsCacheItem, bool) + Add(ctx context.Context, key [sha256.Size]byte, value IntrospectionClaimsCacheItem) + Purge(ctx context.Context) + Len(ctx context.Context) int +} + +type IntrospectionNegativeCacheItem struct { + CreatedAt time.Time +} + +type IntrospectionNegativeCache interface { + Get(ctx context.Context, key [sha256.Size]byte) (IntrospectionNegativeCacheItem, bool) + Add(ctx context.Context, key [sha256.Size]byte, value IntrospectionNegativeCacheItem) + Purge(ctx context.Context) + Len(ctx context.Context) int +} + +type IntrospectionLRUCache[K comparable, V any] struct { + cache *lrucache.LRUCache[K, V] +} + +func (a *IntrospectionLRUCache[K, V]) Get(_ context.Context, key K) (V, bool) { + return a.cache.Get(key) +} + +func (a *IntrospectionLRUCache[K, V]) Add(_ context.Context, key K, val V) { + a.cache.Add(key, val) +} + +func (a *IntrospectionLRUCache[K, V]) Purge(ctx context.Context) { + a.cache.Purge() +} + +func (a *IntrospectionLRUCache[K, V]) Len(ctx context.Context) int { + return a.cache.Len() +} + +type disabledIntrospectionClaimsCache struct{} + +func (c *disabledIntrospectionClaimsCache) Get(ctx context.Context, key [sha256.Size]byte) (IntrospectionClaimsCacheItem, bool) { + return IntrospectionClaimsCacheItem{}, false +} +func (c *disabledIntrospectionClaimsCache) Add(ctx context.Context, key [sha256.Size]byte, value IntrospectionClaimsCacheItem) { +} +func (c *disabledIntrospectionClaimsCache) Purge(ctx context.Context) {} +func (c *disabledIntrospectionClaimsCache) Len(ctx context.Context) int { return 0 } + +type disabledIntrospectionNegativeCache struct{} + +func (c *disabledIntrospectionNegativeCache) Get(ctx context.Context, key [sha256.Size]byte) (IntrospectionNegativeCacheItem, bool) { + return IntrospectionNegativeCacheItem{}, false +} +func (c *disabledIntrospectionNegativeCache) Add(ctx context.Context, key [sha256.Size]byte, value IntrospectionNegativeCacheItem) { +} +func (c *disabledIntrospectionNegativeCache) Purge(ctx context.Context) {} +func (c *disabledIntrospectionNegativeCache) Len(ctx context.Context) int { return 0 } diff --git a/idptoken/introspector_test.go b/idptoken/introspector_test.go index 74d7f77..4047d81 100644 --- a/idptoken/introspector_test.go +++ b/idptoken/introspector_test.go @@ -21,14 +21,20 @@ import ( "github.com/acronis/go-authkit/idptest" "github.com/acronis/go-authkit/idptoken" "github.com/acronis/go-authkit/idptoken/pb" + "github.com/acronis/go-authkit/internal/idputil" "github.com/acronis/go-authkit/internal/testing" "github.com/acronis/go-authkit/jwks" "github.com/acronis/go-authkit/jwt" ) func TestIntrospector_IntrospectToken(t *gotesting.T) { + const validAccessToken = "access-token-with-introspection-permission" + httpServerIntrospector := testing.NewHTTPServerTokenIntrospectorMock() + httpServerIntrospector.SetAccessTokenForIntrospection(validAccessToken) + grpcServerIntrospector := testing.NewGRPCServerTokenIntrospectorMock() + grpcServerIntrospector.SetAccessTokenForIntrospection(validAccessToken) httpIDPSrv := idptest.NewHTTPServer(idptest.WithHTTPTokenIntrospector(httpServerIntrospector)) require.NoError(t, httpIDPSrv.StartAndWaitForReady(time.Second)) @@ -38,8 +44,9 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { require.NoError(t, grpcIDPSrv.StartAndWaitForReady(time.Second)) defer func() { grpcIDPSrv.GracefulStop() }() - const accessToken = "access-token-with-introspection-permission" - tokenProvider := idptest.NewSimpleTokenProvider(accessToken) + grpcClient, err := idptoken.NewGRPCClient(grpcIDPSrv.Addr(), insecure.NewCredentials()) + require.NoError(t, err) + defer func() { require.NoError(t, grpcClient.Close()) }() jwtParser := jwt.NewParser(jwks.NewClient(), log.NewDisabledLogger()) require.NoError(t, jwtParser.AddTrustedIssuerURL(httpIDPSrv.URL())) @@ -80,16 +87,16 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { httpServerIntrospector.SetScopeForJWTID(jwtID, jwtScope) httpServerIntrospector.SetResultForToken(opaqueToken, idptoken.IntrospectionResult{ - Active: true, TokenType: idptoken.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueTokenScope}}) + Active: true, TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueTokenScope}}) grpcServerIntrospector.SetScopeForJWTID(jwtID, jwtScopeToGRPC(jwtScope)) grpcServerIntrospector.SetResultForToken(opaqueToken, &pb.IntrospectTokenResponse{ - Active: true, TokenType: idptoken.TokenTypeBearer, Scope: jwtScopeToGRPC(opaqueTokenScope)}) + Active: true, TokenType: idputil.TokenTypeBearer, Scope: jwtScopeToGRPC(opaqueTokenScope)}) tests := []struct { name string introspectorOpts idptoken.IntrospectorOpts - useGRPC bool - token string + tokenToIntrospect string + accessToken string expectedResult idptoken.IntrospectionResult checkError func(t *gotesting.T, err error) expectedHTTPSrvCalled bool @@ -98,24 +105,24 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { expectedGRPCScopeFilter []*pb.IntrospectionScopeFilter }{ { - name: "error, token is missing", - token: "", + name: "error, token is missing", + tokenToIntrospect: "", checkError: func(t *gotesting.T, err error) { require.ErrorIs(t, err, idptoken.ErrTokenNotIntrospectable) require.ErrorContains(t, err, "token is missing") }, }, { - name: "error, dynamic introspection endpoint, no jwt header", - token: "opaque-token", + name: "error, dynamic introspection endpoint, no jwt header", + tokenToIntrospect: "opaque-token", checkError: func(t *gotesting.T, err error) { require.ErrorIs(t, err, idptoken.ErrTokenNotIntrospectable) require.ErrorContains(t, err, "no JWT header found") }, }, { - name: "error, dynamic introspection endpoint, cannot decode jwt header", - token: "$opaque$.$token$", + name: "error, dynamic introspection endpoint, cannot decode jwt header", + tokenToIntrospect: "$opaque$.$token$", checkError: func(t *gotesting.T, err error) { require.ErrorIs(t, err, idptoken.ErrTokenNotIntrospectable) require.ErrorContains(t, err, "decode JWT header") @@ -123,7 +130,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, { name: "error, dynamic introspection endpoint, issuer is not trusted", - token: idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ + tokenToIntrospect: idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ RegisteredClaims: jwtgo.RegisteredClaims{ Issuer: "https://untrusted-issuer.com", Subject: uuid.NewString(), @@ -138,7 +145,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, { name: "error, dynamic introspection endpoint, issuer is missing in JWT header and payload", - token: idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ + tokenToIntrospect: idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ RegisteredClaims: jwtgo.RegisteredClaims{ Subject: uuid.NewString(), ID: uuid.NewString(), @@ -152,7 +159,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, { name: "error, dynamic introspection endpoint, nri is 1", - token: idptest.MustMakeTokenStringWithHeader(jwt.Claims{ + tokenToIntrospect: idptest.MustMakeTokenStringWithHeader(jwt.Claims{ RegisteredClaims: jwtgo.RegisteredClaims{ Subject: uuid.NewString(), ID: uuid.NewString(), @@ -165,7 +172,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, { name: "error, dynamic introspection endpoint, nri is true", - token: idptest.MustMakeTokenStringWithHeader(jwt.Claims{ + tokenToIntrospect: idptest.MustMakeTokenStringWithHeader(jwt.Claims{ RegisteredClaims: jwtgo.RegisteredClaims{ Subject: uuid.NewString(), ID: uuid.NewString(), @@ -178,7 +185,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, { name: "ok, dynamic introspection endpoint, introspected token is expired JWT", - token: idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ + tokenToIntrospect: idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ RegisteredClaims: jwtgo.RegisteredClaims{ Issuer: httpIDPSrv.URL(), Subject: uuid.NewString(), @@ -191,7 +198,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, { name: "ok, dynamic introspection endpoint, introspected token is JWT", - token: idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ + tokenToIntrospect: idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ RegisteredClaims: jwtgo.RegisteredClaims{ Issuer: jwtIssuer, Subject: jwtSubject, @@ -201,7 +208,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }), expectedResult: idptoken.IntrospectionResult{ Active: true, - TokenType: idptoken.TokenTypeBearer, + TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{ RegisteredClaims: jwtgo.RegisteredClaims{ Issuer: jwtIssuer, @@ -215,31 +222,31 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { expectedHTTPSrvCalled: true, }, { - name: "ok, static introspection endpoint, introspected token is opaque", + name: "ok, static http introspection endpoint, introspected token is opaque", introspectorOpts: idptoken.IntrospectorOpts{ - StaticHTTPEndpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, + HTTPEndpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, }, - token: opaqueToken, + tokenToIntrospect: opaqueToken, expectedResult: idptoken.IntrospectionResult{ Active: true, - TokenType: idptoken.TokenTypeBearer, + TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueTokenScope}, }, expectedHTTPSrvCalled: true, }, { - name: "ok, static introspection endpoint, introspected token is opaque, filter scope by resource namespace", + name: "ok, static http introspection endpoint, introspected token is opaque, filter scope by resource namespace", introspectorOpts: idptoken.IntrospectorOpts{ - StaticHTTPEndpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, + HTTPEndpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, ScopeFilter: []idptoken.IntrospectionScopeFilterAccessPolicy{ {ResourceNamespace: "account-server"}, {ResourceNamespace: "tenant-manager"}, }, }, - token: opaqueToken, + tokenToIntrospect: opaqueToken, expectedResult: idptoken.IntrospectionResult{ Active: true, - TokenType: idptoken.TokenTypeBearer, + TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueTokenScope}, }, expectedHTTPSrvCalled: true, @@ -250,18 +257,30 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, }, { - name: "ok, grpc introspection endpoint", - useGRPC: true, + name: "error, grpc introspection endpoint, unauthenticated", + introspectorOpts: idptoken.IntrospectorOpts{ + HTTPEndpoint: httpIDPSrv.URL() + idptest.TokenIntrospectionEndpointPath, + }, + tokenToIntrospect: opaqueToken, + accessToken: "invalid-access-token", + checkError: func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrUnauthenticated) + }, + expectedHTTPSrvCalled: true, + }, + { + name: "ok, grpc introspection endpoint", introspectorOpts: idptoken.IntrospectorOpts{ + GRPCClient: grpcClient, ScopeFilter: []idptoken.IntrospectionScopeFilterAccessPolicy{ {ResourceNamespace: "account-server"}, {ResourceNamespace: "tenant-manager"}, }, }, - token: opaqueToken, + tokenToIntrospect: opaqueToken, expectedResult: idptoken.IntrospectionResult{ Active: true, - TokenType: idptoken.TokenTypeBearer, + TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueTokenScope}, }, expectedGRPCSrvCalled: true, @@ -270,22 +289,33 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { {ResourceNamespace: "tenant-manager"}, }, }, + { + name: "error, grpc introspection endpoint, unauthenticated", + introspectorOpts: idptoken.IntrospectorOpts{ + GRPCClient: grpcClient, + }, + tokenToIntrospect: opaqueToken, + accessToken: "invalid-access-token", + checkError: func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrUnauthenticated) + }, + expectedGRPCSrvCalled: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *gotesting.T) { - if tt.useGRPC { - grpcClient, err := idptoken.NewGRPCClient(grpcIDPSrv.Addr(), insecure.NewCredentials()) - require.NoError(t, err) - defer func() { require.NoError(t, grpcClient.Close()) }() - tt.introspectorOpts.GRPCClient = grpcClient + if tt.accessToken == "" { + tt.accessToken = validAccessToken } - introspector := idptoken.NewIntrospectorWithOpts(tokenProvider, tt.introspectorOpts) + introspector, err := idptoken.NewIntrospectorWithOpts( + idptest.NewSimpleTokenProvider(tt.accessToken), tt.introspectorOpts) + require.NoError(t, err) require.NoError(t, introspector.AddTrustedIssuerURL(httpIDPSrv.URL())) httpServerIntrospector.ResetCallsInfo() grpcServerIntrospector.ResetCallsInfo() - result, err := introspector.IntrospectToken(context.Background(), tt.token) + result, err := introspector.IntrospectToken(context.Background(), tt.tokenToIntrospect) if tt.checkError != nil { tt.checkError(t, err) } else { @@ -295,20 +325,246 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { require.Equal(t, tt.expectedHTTPSrvCalled, httpServerIntrospector.Called) if tt.expectedHTTPSrvCalled { - require.Equal(t, tt.token, httpServerIntrospector.LastIntrospectedToken) - require.Equal(t, "Bearer "+accessToken, httpServerIntrospector.LastAuthorizationHeader) + require.Equal(t, tt.tokenToIntrospect, httpServerIntrospector.LastIntrospectedToken) + require.Equal(t, "Bearer "+tt.accessToken, httpServerIntrospector.LastAuthorizationHeader) if tt.expectedHTTPFormVals == nil { - tt.expectedHTTPFormVals = url.Values{"token": {tt.token}} + tt.expectedHTTPFormVals = url.Values{"token": {tt.tokenToIntrospect}} } require.Equal(t, tt.expectedHTTPFormVals, httpServerIntrospector.LastFormValues) } require.Equal(t, tt.expectedGRPCSrvCalled, grpcServerIntrospector.Called) if tt.expectedGRPCSrvCalled { - require.Equal(t, tt.token, grpcServerIntrospector.LastRequest.Token) + require.Equal(t, tt.tokenToIntrospect, grpcServerIntrospector.LastRequest.Token) require.Equal(t, tt.expectedGRPCScopeFilter, grpcServerIntrospector.LastRequest.GetScopeFilter()) - require.Equal(t, "Bearer "+accessToken, grpcServerIntrospector.LastAuthorizationMeta) + require.Equal(t, "Bearer "+tt.accessToken, grpcServerIntrospector.LastAuthorizationMeta) + } + }) + } +} + +func TestCachingIntrospector_IntrospectTokenWithCache(t *gotesting.T) { + const accessToken = "access-token-with-introspection-permission" + + serverIntrospector := testing.NewHTTPServerTokenIntrospectorMock() + serverIntrospector.SetAccessTokenForIntrospection(accessToken) + + idpSrv := idptest.NewHTTPServer(idptest.WithHTTPTokenIntrospector(serverIntrospector)) + require.NoError(t, idpSrv.StartAndWaitForReady(time.Second)) + defer func() { _ = idpSrv.Shutdown(context.Background()) }() + + logger := log.NewDisabledLogger() + jwtParser := jwt.NewParser(jwks.NewClient(), logger) + require.NoError(t, jwtParser.AddTrustedIssuerURL(idpSrv.URL())) + serverIntrospector.JWTParser = jwtParser + + jwtExpiresAtInFuture := jwtgo.NewNumericDate(time.Now().Add(time.Hour)) + jwtIssuer := idpSrv.URL() + jwtSubject := uuid.NewString() + jwtID := uuid.NewString() + jwtScope := []jwt.AccessPolicy{{ + TenantUUID: uuid.NewString(), + ResourceNamespace: "account-server", + Role: "account_viewer", + ResourcePath: "resource-" + uuid.NewString(), + }} + + expiredJWT := idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ + RegisteredClaims: jwtgo.RegisteredClaims{ + Issuer: idpSrv.URL(), + Subject: uuid.NewString(), + ID: uuid.NewString(), + ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(-time.Hour)), + }, + }) + activeJWT := idptest.MustMakeTokenStringSignedWithTestKey(jwt.Claims{ + RegisteredClaims: jwtgo.RegisteredClaims{ + Issuer: jwtIssuer, + Subject: jwtSubject, + ID: jwtID, + ExpiresAt: jwtExpiresAtInFuture, + }, + }) + + opaqueToken1 := "opaque-token-" + uuid.NewString() + opaqueToken2 := "opaque-token-" + uuid.NewString() + opaqueToken3 := "opaque-token-" + uuid.NewString() + opaqueToken1Scope := []jwt.AccessPolicy{{ + TenantUUID: uuid.NewString(), + ResourceNamespace: "account-server", + Role: "admin", + ResourcePath: "resource-" + uuid.NewString(), + }} + opaqueToken2Scope := []jwt.AccessPolicy{{ + TenantUUID: uuid.NewString(), + ResourceNamespace: "event-manager", + Role: "admin", + ResourcePath: "resource-" + uuid.NewString(), + }} + + serverIntrospector.SetScopeForJWTID(jwtID, jwtScope) + serverIntrospector.SetResultForToken(opaqueToken1, idptoken.IntrospectionResult{ + Active: true, TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken1Scope}}) + serverIntrospector.SetResultForToken(opaqueToken2, idptoken.IntrospectionResult{ + Active: true, TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken2Scope}}) + serverIntrospector.SetResultForToken(opaqueToken3, idptoken.IntrospectionResult{Active: false}) + + tests := []struct { + name string + introspectorOpts idptoken.IntrospectorOpts + tokens []string + expectedSrvCalled []bool + expectedResult []idptoken.IntrospectionResult + checkError []func(t *gotesting.T, err error) + checkIntrospector func(t *gotesting.T, introspector *idptoken.Introspector) + delay time.Duration + }{ + { + name: "error, token is not introspectable", + tokens: []string{"", "opaque-token"}, + expectedSrvCalled: []bool{false, false}, + introspectorOpts: idptoken.IntrospectorOpts{ + ClaimsCache: idptoken.IntrospectorCacheOpts{Enabled: true}, + NegativeCache: idptoken.IntrospectorCacheOpts{Enabled: true}, + }, + checkError: []func(t *gotesting.T, err error){ + func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrTokenNotIntrospectable) + require.ErrorContains(t, err, "token is missing") + }, + func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrTokenNotIntrospectable) + require.ErrorContains(t, err, "no JWT header found") + }, + }, + checkIntrospector: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Equal(t, 0, introspector.ClaimsCache.Len(context.Background())) + require.Equal(t, 0, introspector.NegativeCache.Len(context.Background())) + }, + }, + { + name: "ok, dynamic introspection endpoint, introspected token is expired JWT", + introspectorOpts: idptoken.IntrospectorOpts{ + ClaimsCache: idptoken.IntrospectorCacheOpts{Enabled: true}, + NegativeCache: idptoken.IntrospectorCacheOpts{Enabled: true}, + }, + tokens: repeat(expiredJWT, 2), + expectedSrvCalled: []bool{true, false}, + expectedResult: []idptoken.IntrospectionResult{{Active: false}, {Active: false}}, + checkIntrospector: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Equal(t, 0, introspector.ClaimsCache.Len(context.Background())) + require.Equal(t, 1, introspector.NegativeCache.Len(context.Background())) + }, + }, + { + name: "ok, dynamic introspection endpoint, introspected token is JWT", + introspectorOpts: idptoken.IntrospectorOpts{ + ClaimsCache: idptoken.IntrospectorCacheOpts{Enabled: true}, + NegativeCache: idptoken.IntrospectorCacheOpts{Enabled: true}, + }, + tokens: repeat(activeJWT, 2), + expectedSrvCalled: []bool{true, false}, + expectedResult: repeat(idptoken.IntrospectionResult{ + Active: true, + TokenType: idputil.TokenTypeBearer, + Claims: jwt.Claims{ + RegisteredClaims: jwtgo.RegisteredClaims{ + Issuer: jwtIssuer, + Subject: jwtSubject, + ID: jwtID, + ExpiresAt: jwtExpiresAtInFuture, + }, + Scope: jwtScope, + }, + }, 2), + checkIntrospector: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Equal(t, 1, introspector.ClaimsCache.Len(context.Background())) + require.Equal(t, 0, introspector.NegativeCache.Len(context.Background())) + }, + }, + { + name: "ok, static introspection endpoint, introspected token is opaque", + introspectorOpts: idptoken.IntrospectorOpts{ + HTTPEndpoint: idpSrv.URL() + idptest.TokenIntrospectionEndpointPath, + ClaimsCache: idptoken.IntrospectorCacheOpts{Enabled: true}, + NegativeCache: idptoken.IntrospectorCacheOpts{Enabled: true}, + }, + tokens: []string{opaqueToken1, opaqueToken1, opaqueToken2, opaqueToken2, opaqueToken3, opaqueToken3}, + expectedSrvCalled: []bool{true, false, true, false, true, false}, + expectedResult: []idptoken.IntrospectionResult{ + {Active: true, TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken1Scope}}, + {Active: true, TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken1Scope}}, + {Active: true, TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken2Scope}}, + {Active: true, TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken2Scope}}, + {Active: false}, + {Active: false}, + }, + checkIntrospector: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Equal(t, 2, introspector.ClaimsCache.Len(context.Background())) + require.Equal(t, 1, introspector.NegativeCache.Len(context.Background())) + }, + }, + { + name: "ok, cache has ttl", + introspectorOpts: idptoken.IntrospectorOpts{ + HTTPEndpoint: idpSrv.URL() + idptest.TokenIntrospectionEndpointPath, + ClaimsCache: idptoken.IntrospectorCacheOpts{Enabled: true, TTL: 100 * time.Millisecond}, + NegativeCache: idptoken.IntrospectorCacheOpts{Enabled: true, TTL: 100 * time.Millisecond}, + }, + tokens: []string{opaqueToken1, opaqueToken1, opaqueToken3, opaqueToken3}, + expectedSrvCalled: []bool{true, true, true, true}, + expectedResult: []idptoken.IntrospectionResult{ + {Active: true, TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken1Scope}}, + {Active: true, TokenType: idputil.TokenTypeBearer, Claims: jwt.Claims{Scope: opaqueToken1Scope}}, + {Active: false}, + {Active: false}, + }, + checkIntrospector: func(t *gotesting.T, introspector *idptoken.Introspector) { + require.Equal(t, 1, introspector.ClaimsCache.Len(context.Background())) + require.Equal(t, 1, introspector.NegativeCache.Len(context.Background())) + }, + delay: 200 * time.Millisecond, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *gotesting.T) { + introspector, err := idptoken.NewIntrospectorWithOpts( + idptest.NewSimpleTokenProvider(accessToken), tt.introspectorOpts) + require.NoError(t, err) + require.NoError(t, introspector.AddTrustedIssuerURL(idpSrv.URL())) + + for i, token := range tt.tokens { + serverIntrospector.ResetCallsInfo() + + result, introspectErr := introspector.IntrospectToken(context.Background(), token) + if i < len(tt.checkError) { + tt.checkError[i](t, introspectErr) + } else { + require.NoError(t, introspectErr) + require.Equal(t, tt.expectedResult[i], result) + } + + require.Equal(t, tt.expectedSrvCalled[i], serverIntrospector.Called) + if tt.expectedSrvCalled[i] { + require.Equal(t, token, serverIntrospector.LastIntrospectedToken) + require.Equal(t, "Bearer "+accessToken, serverIntrospector.LastAuthorizationHeader) + require.Equal(t, url.Values{"token": {token}}, serverIntrospector.LastFormValues) + } + + time.Sleep(tt.delay) + } + + if tt.checkIntrospector != nil { + tt.checkIntrospector(t, introspector) } }) } } + +func repeat[V any](v V, n int) []V { + s := make([]V, n) + for i := range s { + s[i] = v + } + return s +} diff --git a/idptoken/pb/idp_token.pb.go b/idptoken/pb/idp_token.pb.go index 0feb757..280d875 100644 --- a/idptoken/pb/idp_token.pb.go +++ b/idptoken/pb/idp_token.pb.go @@ -1,3 +1,8 @@ +// +//Copyright © 2024 Acronis International GmbH. +// +//Released under MIT license. + // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.33.0 diff --git a/idptoken/pb/idp_token_grpc.pb.go b/idptoken/pb/idp_token_grpc.pb.go index a338bb6..e8b2732 100644 --- a/idptoken/pb/idp_token_grpc.pb.go +++ b/idptoken/pb/idp_token_grpc.pb.go @@ -1,3 +1,8 @@ +// +//Copyright © 2024 Acronis International GmbH. +// +//Released under MIT license. + // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 @@ -28,10 +33,13 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type IDPTokenServiceClient interface { // CreateToken creates a new token based on the provided assertion. - // Now only JWT_BEARER (urn:ietf:params:oauth:grant-type:jwt-bearer) grant type is supported. + // Currently only "urn:ietf:params:oauth:grant-type:jwt-bearer" grant type is supported. CreateToken(ctx context.Context, in *CreateTokenRequest, opts ...grpc.CallOption) (*CreateTokenResponse, error) // IntrospectToken returns information about the token including its scopes. - // The token is considered active if it 1) is not expired; 2) is not revoked; 3) has has the valid signature. + // The token is considered active if + // 1. it's not expired; + // 2. it's not revoked; + // 3. it has a valid signature. IntrospectToken(ctx context.Context, in *IntrospectTokenRequest, opts ...grpc.CallOption) (*IntrospectTokenResponse, error) } @@ -66,10 +74,13 @@ func (c *iDPTokenServiceClient) IntrospectToken(ctx context.Context, in *Introsp // for forward compatibility type IDPTokenServiceServer interface { // CreateToken creates a new token based on the provided assertion. - // Now only JWT_BEARER (urn:ietf:params:oauth:grant-type:jwt-bearer) grant type is supported. + // Currently only "urn:ietf:params:oauth:grant-type:jwt-bearer" grant type is supported. CreateToken(context.Context, *CreateTokenRequest) (*CreateTokenResponse, error) // IntrospectToken returns information about the token including its scopes. - // The token is considered active if it 1) is not expired; 2) is not revoked; 3) has has the valid signature. + // The token is considered active if + // 1. it's not expired; + // 2. it's not revoked; + // 3. it has a valid signature. IntrospectToken(context.Context, *IntrospectTokenRequest) (*IntrospectTokenResponse, error) mustEmbedUnimplementedIDPTokenServiceServer() } diff --git a/idptoken/provider.go b/idptoken/provider.go index 0b74818..cbbc995 100644 --- a/idptoken/provider.go +++ b/idptoken/provider.go @@ -35,10 +35,8 @@ const ( wellKnownPath = "/.well-known/openid-configuration" ) -var ( - // ErrSourceNotRegistered is returned if GetToken is requested for the unknown Source - ErrSourceNotRegistered = errors.New("cannot issue token for unknown source") -) +// ErrSourceNotRegistered is returned if GetToken is requested for the unknown Source +var ErrSourceNotRegistered = errors.New("cannot issue token for unknown source") // UnexpectedIDPResponseError is an error representing an unexpected response type UnexpectedIDPResponseError struct { @@ -50,8 +48,8 @@ func (e *UnexpectedIDPResponseError) Error() string { return fmt.Sprintf(`%s responded with unexpected code %d`, e.IssueURL, e.HTTPCode) } -// TokenData represents API-related token information -type TokenData struct { +// tokenData represents API-related token information +type tokenData struct { Data string ClientID string issueURL string @@ -70,7 +68,7 @@ var zeroTime = time.Time{} // TokenDetails represents the data to be stored in TokenCache type TokenDetails struct { - token TokenData + token tokenData requestedScope []string sourceURL string issued time.Time @@ -237,11 +235,11 @@ func (p *MultiSourceProvider) RefreshTokensPeriodically(ctx context.Context) { func (p *MultiSourceProvider) issueToken( ctx context.Context, clientID, sourceURL string, customHeaders map[string]string, scope []string, -) (TokenData, error) { +) (tokenData, error) { issuer, found := p.tokenIssuers[keyForIssuer(clientID, sourceURL)] if !found { - return TokenData{}, ErrSourceNotRegistered + return tokenData{}, ErrSourceNotRegistered } headers := make(map[string]string) @@ -257,7 +255,7 @@ func (p *MultiSourceProvider) issueToken( }) if errEns != nil { p.logger.Error(fmt.Sprintf("(%s, %s): ensure issuer URL", sourceURL, clientID), log.Error(errEns)) - return TokenData{}, errEns + return tokenData{}, errEns } sortedScope := uniqAndSort(scope) @@ -269,10 +267,10 @@ func (p *MultiSourceProvider) issueToken( }) if err != nil { p.logger.Error(fmt.Sprintf("(%s, %s): issuing token", issuer.loadTokenURL(), clientID), log.Error(err)) - return TokenData{}, err + return tokenData{}, err } - return token.(TokenData), nil + return token.(tokenData), nil } func (p *MultiSourceProvider) ensureToken( @@ -292,7 +290,7 @@ func (p *MultiSourceProvider) ensureToken( return token.Data, nil } -func (p *MultiSourceProvider) cacheToken(token TokenData, sourceURL string) { +func (p *MultiSourceProvider) cacheToken(token tokenData, sourceURL string) { issued := time.Now().UTC() randInt, err := rand.Int(rand.Reader, big.NewInt(expiryDeltaMaxOffset)) if err != nil { @@ -328,32 +326,32 @@ func (p *MultiSourceProvider) cacheToken(token TokenData, sourceURL string) { } } -func (p *MultiSourceProvider) getCachedOrInvalidate(clientID, sourceURL string, scope []string) (TokenData, error) { +func (p *MultiSourceProvider) getCachedOrInvalidate(clientID, sourceURL string, scope []string) (tokenData, error) { now := time.Now().UnixNano() issuer, found := p.tokenIssuers[keyForIssuer(clientID, sourceURL)] if !found { - return TokenData{}, fmt.Errorf("(%s, %s): not registered", sourceURL, clientID) + return tokenData{}, fmt.Errorf("(%s, %s): not registered", sourceURL, clientID) } if issuer.loadTokenURL() == "" { - return TokenData{}, fmt.Errorf("(%s, %s): issuer URL not acquired", sourceURL, clientID) + return tokenData{}, fmt.Errorf("(%s, %s): issuer URL not acquired", sourceURL, clientID) } key := keyForCache(clientID, issuer.loadTokenURL(), uniqAndSort(scope)) details := p.cache.Get(key) if details == nil { - return TokenData{}, errors.New("token not found in cache") + return tokenData{}, errors.New("token not found in cache") } if details.token.Expires.UnixNano() < now { p.cache.Delete(key) - return TokenData{}, errors.New("token is expired") + return tokenData{}, errors.New("token is expired") } if details.invalidation.UnixNano() < now { p.cache.Delete(key) - return TokenData{}, errors.New("token needs to be refreshed") + return tokenData{}, errors.New("token needs to be refreshed") } if details.issued.UnixNano() > now { p.cache.Delete(key) - return TokenData{}, errors.New("token's issued time is invalid") + return tokenData{}, errors.New("token's issued time is invalid") } return details.token, nil } @@ -559,10 +557,10 @@ func (ti *oauth2Issuer) ensureTokenURL(ctx context.Context, customHeaders map[st func (ti *oauth2Issuer) issueToken( ctx context.Context, customHeaders map[string]string, scope []string, -) (TokenData, error) { +) (tokenData, error) { tokenURL := ti.loadTokenURL() if tokenURL == "" { - return TokenData{}, fmt.Errorf("token URL is empty") + return tokenData{}, fmt.Errorf("token URL is empty") } values := url.Values{} values.Add("grant_type", "client_credentials") @@ -572,7 +570,7 @@ func (ti *oauth2Issuer) issueToken( } req, reqErr := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(values.Encode())) if reqErr != nil { - return TokenData{}, reqErr + return tokenData{}, reqErr } req = req.WithContext(ctx) req.SetBasicAuth(ti.clientID, ti.clientSecret) @@ -587,7 +585,7 @@ func (ti *oauth2Issuer) issueToken( if err != nil { ti.promMetrics.ObserveHTTPClientRequest(http.MethodPost, tokenURL, 0, elapsed, metrics.HTTPRequestErrorDo) - return TokenData{}, fmt.Errorf("do http request: %w", err) + return tokenData{}, fmt.Errorf("do http request: %w", err) } defer func() { if err := resp.Body.Close(); err != nil { @@ -601,7 +599,7 @@ func (ti *oauth2Issuer) issueToken( if err = json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { ti.promMetrics.ObserveHTTPClientRequest( http.MethodPost, tokenURL, resp.StatusCode, elapsed, metrics.HTTPRequestErrorDecodeBody) - return TokenData{}, fmt.Errorf( + return tokenData{}, fmt.Errorf( "(%s, %s): read and unmarshal IDP response: %w", ti.loadTokenURL(), ti.clientID, err, ) } @@ -609,13 +607,13 @@ func (ti *oauth2Issuer) issueToken( if resp.StatusCode != http.StatusOK { ti.promMetrics.ObserveHTTPClientRequest( http.MethodPost, tokenURL, resp.StatusCode, elapsed, metrics.HTTPRequestErrorUnexpectedStatusCode) - return TokenData{}, &UnexpectedIDPResponseError{HTTPCode: resp.StatusCode, IssueURL: ti.loadTokenURL()} + return tokenData{}, &UnexpectedIDPResponseError{HTTPCode: resp.StatusCode, IssueURL: ti.loadTokenURL()} } ti.promMetrics.ObserveHTTPClientRequest(http.MethodPost, tokenURL, resp.StatusCode, elapsed, "") expires := time.Now().Add(time.Second * time.Duration(tokenResponse.ExpiresIn)) ti.logger.Infof("(%s, %s): issued token, expires on %s", ti.loadTokenURL(), ti.clientID, expires.UTC()) - return TokenData{ + return tokenData{ Data: tokenResponse.AccessToken, Scope: scope, Expires: expires, diff --git a/internal/idputil/idp_util.go b/internal/idputil/idp_util.go index 9774fa4..2de7b89 100644 --- a/internal/idputil/idp_util.go +++ b/internal/idputil/idp_util.go @@ -17,6 +17,12 @@ import ( "github.com/acronis/go-authkit/internal/libinfo" ) +const GrantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer" //nolint: gosec // false positive + +const JWTTypeAccessToken = "at+jwt" + +const TokenTypeBearer = "bearer" + const ( DefaultHTTPRequestTimeout = 30 * time.Second DefaultHTTPRequestMaxRetryAttempts = 3 diff --git a/internal/testing/server_token_introspector_mock.go b/internal/testing/server_token_introspector_mock.go index d2d3c11..98ade72 100644 --- a/internal/testing/server_token_introspector_mock.go +++ b/internal/testing/server_token_introspector_mock.go @@ -12,10 +12,14 @@ import ( "net/http" "net/url" + "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "github.com/acronis/go-authkit/idptest" "github.com/acronis/go-authkit/idptoken" "github.com/acronis/go-authkit/idptoken/pb" + "github.com/acronis/go-authkit/internal/idputil" "github.com/acronis/go-authkit/jwt" ) @@ -29,6 +33,8 @@ type HTTPServerTokenIntrospectorMock struct { introspectionResults map[[sha256.Size]byte]idptoken.IntrospectionResult jwtScopes map[string][]jwt.AccessPolicy + accessTokenForIntrospection string + Called bool LastAuthorizationHeader string LastIntrospectedToken string @@ -50,6 +56,10 @@ func (m *HTTPServerTokenIntrospectorMock) SetScopeForJWTID(jwtID string, scope [ m.jwtScopes[jwtID] = scope } +func (m *HTTPServerTokenIntrospectorMock) SetAccessTokenForIntrospection(accessToken string) { + m.accessTokenForIntrospection = accessToken +} + func (m *HTTPServerTokenIntrospectorMock) IntrospectToken( r *http.Request, token string, ) (idptoken.IntrospectionResult, error) { @@ -58,6 +68,10 @@ func (m *HTTPServerTokenIntrospectorMock) IntrospectToken( m.LastIntrospectedToken = token m.LastFormValues = r.Form + if m.LastAuthorizationHeader != "Bearer "+m.accessTokenForIntrospection { + return idptoken.IntrospectionResult{}, idptest.ErrUnauthorized + } + if result, ok := m.introspectionResults[tokenToKey(token)]; ok { return result, nil } @@ -66,7 +80,7 @@ func (m *HTTPServerTokenIntrospectorMock) IntrospectToken( if err != nil { return idptoken.IntrospectionResult{Active: false}, nil } - result := idptoken.IntrospectionResult{Active: true, TokenType: idptoken.TokenTypeBearer, Claims: *claims} + result := idptoken.IntrospectionResult{Active: true, TokenType: idputil.TokenTypeBearer, Claims: *claims} if scopes, ok := m.jwtScopes[claims.ID]; ok { result.Scope = scopes } @@ -86,6 +100,8 @@ type GRPCServerTokenIntrospectorMock struct { introspectionResults map[[sha256.Size]byte]*pb.IntrospectTokenResponse scopes map[string][]*pb.AccessTokenScope + accessTokenForIntrospection string + Called bool LastAuthorizationMeta string LastRequest *pb.IntrospectTokenRequest @@ -106,6 +122,10 @@ func (m *GRPCServerTokenIntrospectorMock) SetScopeForJWTID(jwtID string, scope [ m.scopes[jwtID] = scope } +func (m *GRPCServerTokenIntrospectorMock) SetAccessTokenForIntrospection(accessToken string) { + m.accessTokenForIntrospection = accessToken +} + func (m *GRPCServerTokenIntrospectorMock) IntrospectToken( ctx context.Context, req *pb.IntrospectTokenRequest, ) (*pb.IntrospectTokenResponse, error) { @@ -117,6 +137,13 @@ func (m *GRPCServerTokenIntrospectorMock) IntrospectToken( } m.LastRequest = req + if m.LastAuthorizationMeta == "" { + return nil, status.Error(codes.Unauthenticated, "Access Token is missing") + } + if m.LastAuthorizationMeta != "Bearer "+m.accessTokenForIntrospection { + return nil, status.Error(codes.Unauthenticated, "Access Token is invalid") + } + if result, ok := m.introspectionResults[tokenToKey(req.Token)]; ok { return result, nil } @@ -127,7 +154,7 @@ func (m *GRPCServerTokenIntrospectorMock) IntrospectToken( } result := &pb.IntrospectTokenResponse{ Active: true, - TokenType: idptoken.TokenTypeBearer, + TokenType: idputil.TokenTypeBearer, Exp: claims.ExpiresAt.Unix(), Aud: claims.Audience, Jti: claims.ID, diff --git a/internal/testing/server_token_mock.go b/internal/testing/server_token_mock.go new file mode 100644 index 0000000..6787483 --- /dev/null +++ b/internal/testing/server_token_mock.go @@ -0,0 +1,59 @@ +/* +Copyright © 2024 Acronis International GmbH. + +Released under MIT license. +*/ + +package testing + +import ( + "context" + "crypto/sha256" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/acronis/go-authkit/idptoken/pb" + "github.com/acronis/go-authkit/internal/idputil" +) + +type GRPCServerTokenCreatorMock struct { + results map[[sha256.Size]byte]*pb.CreateTokenResponse + + Called bool + LastRequest *pb.CreateTokenRequest +} + +func NewGRPCServerTokenCreatorMock() *GRPCServerTokenCreatorMock { + return &GRPCServerTokenCreatorMock{ + results: make(map[[sha256.Size]byte]*pb.CreateTokenResponse), + } +} + +func (m *GRPCServerTokenCreatorMock) SetResultForToken(token string, result *pb.CreateTokenResponse) { + m.results[tokenToKey(token)] = result +} + +func (m *GRPCServerTokenCreatorMock) CreateToken( + ctx context.Context, req *pb.CreateTokenRequest, +) (*pb.CreateTokenResponse, error) { + m.Called = true + m.LastRequest = req + + if req.GrantType != idputil.GrantTypeJWTBearer { + return nil, status.Error(codes.InvalidArgument, "Unsupported GrantType") + } + if req.Assertion == "" { + return nil, status.Error(codes.InvalidArgument, "Assertion is missing") + } + result, ok := m.results[tokenToKey(req.Assertion)] + if !ok { + return nil, status.Error(codes.Unauthenticated, "Invalid assertion") + } + return result, nil +} + +func (m *GRPCServerTokenCreatorMock) ResetCallsInfo() { + m.Called = false + m.LastRequest = nil +}