Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions idptoken/introspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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) &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest checking "nri" only if "irn" is missing since the first is deprecated (pls add a comment about it). Otherwise, will be not be able to remove "nri" in the future - this library will not be ready for it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed.

introspectionRequiredByIRNField(jwtHeader, scopeFilter)
}

func introspectionRequiredByNRIField(jwtHeader map[string]interface{}) bool {
notRequiredIntrospection, ok := jwtHeader[idputil.JWTHeaderFieldNRI]
if !ok {
return true
}
Expand All @@ -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
Expand Down
68 changes: 68 additions & 0 deletions idptoken/introspector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions internal/idputil/idp_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down