diff --git a/idptoken/introspector.go b/idptoken/introspector.go index 91899ef..f7676c4 100644 --- a/idptoken/introspector.go +++ b/idptoken/introspector.go @@ -489,7 +489,7 @@ func (i *Introspector) makeIntrospectFuncForToken(ctx context.Context, token str if err != nil { return i.getStaticIntrospectFuncOrError(fmt.Errorf("parse JWT header: %w", err)) } - if !checkIntrospectionRequiredByJWTHeader(jwtHeader) { + if !checkIntrospectionRequiredByJWTHeader(jwtHeader, i.scopeFilter) { return nil, ErrTokenIntrospectionNotNeeded } @@ -745,9 +745,16 @@ func parserJWTHeader(b []byte) (map[string]interface{}, error) { } // checkIntrospectionRequiredByJWTHeader checks if introspection is required by JWT header. -// Introspection is required by default. -func checkIntrospectionRequiredByJWTHeader(jwtHeader map[string]interface{}) bool { - notRequiredIntrospection, ok := jwtHeader["nri"] +// Introspection is required when BOTH conditions are true: +// 1. nri field indicates introspection is required (nri absent/false/0) +// 2. irn (Introspectable Resource Namespace) field contains list of Resource Namespaces matching the scopeFilter +func checkIntrospectionRequiredByJWTHeader(jwtHeader map[string]interface{}, scopeFilter jwt.ScopeFilter) bool { + return introspectionRequiredByNRIField(jwtHeader) && + introspectionRequiredByIRNField(jwtHeader, scopeFilter) +} + +func introspectionRequiredByNRIField(jwtHeader map[string]interface{}) bool { + notRequiredIntrospection, ok := jwtHeader[idputil.JWTHeaderFieldNRI] if !ok { return true } @@ -766,6 +773,36 @@ func checkIntrospectionRequiredByJWTHeader(jwtHeader map[string]interface{}) boo return true } +// introspectionRequiredByIRNField checks if current scope filter requires an Access Policy that is only +// visible after token introspection and listed in the "irn" JWT header. In that case introspection is mandatory. +func introspectionRequiredByIRNField(jwtHeader map[string]any, scopeFilter jwt.ScopeFilter) bool { + if len(scopeFilter) == 0 { + return true + } + // Backward compatibility: "irn" JWT header can be absent. + introspectableRNs, ok := jwtHeader[idputil.JWTHeaderFieldIRN] + if !ok { + return true + } + var introspectableRNsArr []any + if introspectableRNsArr, ok = introspectableRNs.([]any); !ok || len(introspectableRNsArr) == 0 { + return false + } + for i := range scopeFilter { + sfRN := strings.TrimSpace(scopeFilter[i].ResourceNamespace) + for j := range introspectableRNsArr { + iRN, ok := introspectableRNsArr[j].(string) + if !ok { + continue + } + if strings.EqualFold(sfRN, strings.TrimSpace(iRN)) { + return true + } + } + } + return false +} + type IntrospectionCacheItem struct { IntrospectionResult IntrospectionResult CreatedAt time.Time diff --git a/idptoken/introspector_test.go b/idptoken/introspector_test.go index 58fe14b..e21f5a1 100644 --- a/idptoken/introspector_test.go +++ b/idptoken/introspector_test.go @@ -81,6 +81,13 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { Role: "account_viewer", ResourcePath: "resource-" + uuid.NewString(), }} + validJWTScopeEmptyRN := []jwt.AccessPolicy{ + { + TenantUUID: uuid.NewString(), + ResourceNamespace: "", + Role: "account_viewer", + ResourcePath: "resource-" + uuid.NewString(), + }} validJWTRegClaims := jwtgo.RegisteredClaims{ Issuer: httpIDPSrv.URL(), Audience: jwtgo.ClaimStrings{"https://rs.example.com"}, @@ -96,6 +103,16 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, idptest.TestKeyID, idptest.GetTestRSAPrivateKey(), map[string]interface{}{"typ": idputil.JWTTypeAppAccessToken}) httpServerIntrospector.SetResultForToken(validJWTWithAppTyp, &idptoken.DefaultIntrospectionResult{Active: true, TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTRegClaims, Scope: validJWTScope}}, nil) + validJWTWithIntrospectableRNs := idptest.MustMakeTokenStringWithHeader(&jwt.DefaultClaims{ + RegisteredClaims: validJWTRegClaims, + }, idptest.TestKeyID, idptest.GetTestRSAPrivateKey(), map[string]interface{}{idputil.JWTHeaderFieldIRN: []string{"account-server", ""}}) + httpServerIntrospector.SetResultForToken(validJWTWithIntrospectableRNs, &idptoken.DefaultIntrospectionResult{Active: true, + TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTRegClaims, Scope: validJWTScope}}, nil) + validJWTWithEmptyIntrospectableRNs := idptest.MustMakeTokenStringWithHeader(&jwt.DefaultClaims{ + RegisteredClaims: validJWTRegClaims, + }, idptest.TestKeyID, idptest.GetTestRSAPrivateKey(), map[string]interface{}{idputil.JWTHeaderFieldIRN: []string{""}}) + httpServerIntrospector.SetResultForToken(validJWTWithEmptyIntrospectableRNs, &idptoken.DefaultIntrospectionResult{Active: true, + TokenType: idputil.TokenTypeBearer, DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTRegClaims, Scope: validJWTScopeEmptyRN}}, nil) grpcServerIntrospector.SetResultForToken(validJWT, &pb.IntrospectTokenResponse{ Active: true, TokenType: idputil.TokenTypeBearer, @@ -317,6 +334,57 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { require.ErrorIs(t, err, idptoken.ErrTokenIntrospectionNotNeeded) }, }, + { + name: "error, dynamic introspection endpoint, nri is false, but irn does not match scope filter", + introspectorOpts: idptoken.IntrospectorOpts{ + ScopeFilter: []jwt.ScopeFilterAccessPolicy{{ResourceNamespace: "event-manager"}}, + }, + tokenToIntrospect: idptest.MustMakeTokenStringWithHeader(&jwt.DefaultClaims{ + RegisteredClaims: jwtgo.RegisteredClaims{ + Subject: uuid.NewString(), + ID: uuid.NewString(), + ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)), + }, + }, idptest.TestKeyID, idptest.GetTestRSAPrivateKey(), map[string]interface{}{"irn": "account-server"}), + checkError: func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrTokenIntrospectionNotNeeded) + }, + }, + { + name: "ok, dynamic introspection endpoint, nri is false, irn match scope filter", + introspectorOpts: idptoken.IntrospectorOpts{ + ScopeFilter: []jwt.ScopeFilterAccessPolicy{{ResourceNamespace: "account-server"}}, + }, + tokenToIntrospect: validJWTWithIntrospectableRNs, + expectedResult: &idptoken.DefaultIntrospectionResult{ + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTRegClaims, Scope: validJWTScope}, + }, + expectedHTTPSrvCalled: true, + expectedHTTPFormVals: url.Values{ + "token": {validJWTWithIntrospectableRNs}, + "scope_filter[0].rn": {"account-server"}, + }, + }, + { + name: "ok, dynamic introspection endpoint, nri is false, irn match scope filter and contains empty string element", + introspectorOpts: idptoken.IntrospectorOpts{ + ScopeFilter: []jwt.ScopeFilterAccessPolicy{{ResourceNamespace: "event-manager"}, {ResourceNamespace: ""}}, + }, + tokenToIntrospect: validJWTWithEmptyIntrospectableRNs, + expectedResult: &idptoken.DefaultIntrospectionResult{ + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{RegisteredClaims: validJWTRegClaims, Scope: validJWTScopeEmptyRN}, + }, + expectedHTTPSrvCalled: true, + expectedHTTPFormVals: url.Values{ + "token": {validJWTWithEmptyIntrospectableRNs}, + "scope_filter[0].rn": {"event-manager"}, + "scope_filter[1].rn": {""}, + }, + }, { name: "ok, dynamic introspection endpoint, introspected token is expired JWT", tokenToIntrospect: expiredJWT, diff --git a/internal/idputil/idp_util.go b/internal/idputil/idp_util.go index e891475..ef7eef0 100644 --- a/internal/idputil/idp_util.go +++ b/internal/idputil/idp_util.go @@ -24,6 +24,15 @@ const JWTTypeAccessToken = "at+jwt" const JWTTypeAppAccessToken = "application/at+jwt" +// "irn" stands for "Introspectable Resource Namespace". +// It contains array of Resource Namespaces for roles available after token introspection only. +const JWTHeaderFieldIRN = "irn" + +// "nri" stands for "Not Required Introspection". +// This header field is absent for all Hybrid tokens and set to 1 for non-hybrid tokens. +// Important: the "nri" field (and related logic) is planned to be deprecated in favor of "irn" header field (PLTFRM-85633). +const JWTHeaderFieldNRI = "nri" + const TokenTypeBearer = "Bearer" const (