From e4802f6d26e7bc6a4c1b2a33d5ce982f97d7412d Mon Sep 17 00:00:00 2001 From: lukegranto23 Date: Thu, 30 Jul 2026 01:23:46 -0400 Subject: [PATCH 1/6] fix(oauth): isolate cached tokens by NF identity --- oauth/get_token_context.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/oauth/get_token_context.go b/oauth/get_token_context.go index 5ed23a30..ad8ac18d 100644 --- a/oauth/get_token_context.go +++ b/oauth/get_token_context.go @@ -1,3 +1,6 @@ +Exit code: 0 +Wall time: 0.3 seconds +Output: package oauth import ( @@ -21,6 +24,14 @@ type cachedToken struct { var tokenMap sync.Map var clientMap sync.Map +type tokenCacheKey struct { + NFType models.NrfNfManagementNfType + TargetNF models.NrfNfManagementNfType + NFID string + NRFURI string + Scope string +} + func GetTokenCtx( nfType, targetNF models.NrfNfManagementNfType, nfId, nrfUri, scope string, @@ -49,7 +60,10 @@ func sendAccTokenReq( } // Check if we have a valid cached token - if val, ok := tokenMap.Load(scope); ok { + cacheKey := tokenCacheKey{ + NFType: nfType, TargetNF: targetNF, NFID: nfId, NRFURI: nrfUri, Scope: scope, + } + if val, ok := tokenMap.Load(cacheKey); ok { cached := val.(cachedToken) // Compare current time with absolute expiry timestamp if time.Now().Unix() < cached.ExpiryTime { @@ -79,7 +93,7 @@ func sendAccTokenReq( Response: res.NrfAccessTokenAccessTokenRsp, ExpiryTime: expiryTime, } - tokenMap.Store(scope, cached) + tokenMap.Store(cacheKey, cached) token := &oauth2.Token{ AccessToken: res.NrfAccessTokenAccessTokenRsp.AccessToken, @@ -91,3 +105,4 @@ func sendAccTokenReq( return nil, nil, openapi.ReportError("server no response") } } + From 5047211f00d0369ad8668f1875835ac0a6dc6ce1 Mon Sep 17 00:00:00 2001 From: lukegranto23 Date: Thu, 30 Jul 2026 01:23:53 -0400 Subject: [PATCH 2/6] fix(oauth): validate standard JWT claims and bearer scheme --- oauth/oauth.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/oauth/oauth.go b/oauth/oauth.go index 9a9827fb..5144129a 100644 --- a/oauth/oauth.go +++ b/oauth/oauth.go @@ -1,3 +1,6 @@ +Exit code: 0 +Wall time: 0.4 seconds +Output: package oauth import ( @@ -16,8 +19,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/pkg/errors" - - "github.com/free5gc/openapi/models" ) type CCAClaims struct { @@ -26,6 +27,15 @@ type CCAClaims struct { jwt.RegisteredClaims } +// accessTokenClaims deliberately uses only jwt.RegisteredClaims for the +// standard JWT fields. The generated free5GC model exposes duplicate iss, +// sub, aud, and exp fields alongside RegisteredClaims; decoding into that +// model leaves the validator's embedded fields empty. +type accessTokenClaims struct { + Scope string `json:"scope,omitempty"` + jwt.RegisteredClaims +} + func GenerateClientCredentialAssertion( sub, aud, keyPath string, ) (string, error) { @@ -63,14 +73,14 @@ func VerifyOAuth( } auth_fields := strings.Fields(authorization) - if len(auth_fields) < 2 { + if len(auth_fields) != 2 || !strings.EqualFold(auth_fields[0], "Bearer") { return errors.Errorf("verify OAuth Authorization header invalid") } access_token := auth_fields[1] token, err := jwt.ParseWithClaims( access_token, - &models.NrfAccessTokenAccessTokenClaims{}, + &accessTokenClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, errors.Wrapf(err, "Unexpected signing method") @@ -84,7 +94,7 @@ func VerifyOAuth( return errors.Wrapf(err, "verify OAuth parse") } - if !verifyScope(token.Claims.(*models.NrfAccessTokenAccessTokenClaims).Scope, serviceName) { + if !verifyScope(token.Claims.(*accessTokenClaims).Scope, serviceName) { return errors.New("OAuth scope verification failed: insufficient permissions") } return nil @@ -304,3 +314,4 @@ func GetNFCertPath(base, nfType, nfId string) string { // Note: NF's cert should be put in the same base path return filepath.Join(base, GetNFCertFileName(nfType, nfId)) } + From 6c7d8d0a7d3581c6c9f9fc75107878f8807aff81 Mon Sep 17 00:00:00 2001 From: lukegranto23 Date: Thu, 30 Jul 2026 01:23:58 -0400 Subject: [PATCH 3/6] test(oauth): cover claim expiry and auth scheme validation --- oauth/oauth_hardening_test.go | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 oauth/oauth_hardening_test.go diff --git a/oauth/oauth_hardening_test.go b/oauth/oauth_hardening_test.go new file mode 100644 index 00000000..b648692e --- /dev/null +++ b/oauth/oauth_hardening_test.go @@ -0,0 +1,58 @@ +Exit code: 0 +Wall time: 0.4 seconds +Output: +package oauth + +import ( + "testing" + "time" + + "github.com/free5gc/openapi/models" + "github.com/golang-jwt/jwt/v5" +) + +func TestVerifyOAuthRejectsExpiredGeneratedModelToken(t *testing.T) { + dir := t.TempDir() + pubPath, privPath := dir+"/public.pem", dir+"/private.pem" + key, err := GenerateRSAKeyPair(pubPath, privPath) + if err != nil { + t.Fatal(err) + } + claims := models.AccessTokenClaims{ + Scope: "svc", + Exp: int32(time.Now().Add(-time.Minute).Unix()), + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "issuer", + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodRS512, claims) + signed, err := tok.SignedString(key) + if err != nil { + t.Fatal(err) + } + if err := VerifyOAuth("Bearer "+signed, "svc", pubPath); err == nil { + t.Fatal("expired token was accepted") + } +} + +func TestVerifyOAuthRequiresBearerScheme(t *testing.T) { + dir := t.TempDir() + pubPath, privPath := dir+"/public.pem", dir+"/private.pem" + key, err := GenerateRSAKeyPair(pubPath, privPath) + if err != nil { + t.Fatal(err) + } + claims := models.AccessTokenClaims{ + Scope: "svc", + Exp: int32(time.Now().Add(time.Minute).Unix()), + } + tok := jwt.NewWithClaims(jwt.SigningMethodRS512, claims) + signed, err := tok.SignedString(key) + if err != nil { + t.Fatal(err) + } + if err := VerifyOAuth("Basic "+signed, "svc", pubPath); err == nil { + t.Fatal("non-Bearer authorization scheme was accepted") + } +} + From 10d6270b84626fcbc881b173c8491125ce3e5f05 Mon Sep 17 00:00:00 2001 From: lukegranto23 Date: Thu, 30 Jul 2026 01:25:06 -0400 Subject: [PATCH 4/6] fix(oauth): isolate cached tokens by NF identity --- oauth/get_token_context.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/oauth/get_token_context.go b/oauth/get_token_context.go index ad8ac18d..6caea2fd 100644 --- a/oauth/get_token_context.go +++ b/oauth/get_token_context.go @@ -1,6 +1,3 @@ -Exit code: 0 -Wall time: 0.3 seconds -Output: package oauth import ( From 6a49eeda73376f0dd185fe98fa33091dc0734b6a Mon Sep 17 00:00:00 2001 From: lukegranto23 Date: Thu, 30 Jul 2026 01:25:13 -0400 Subject: [PATCH 5/6] fix(oauth): validate standard JWT claims and bearer scheme --- oauth/oauth.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/oauth/oauth.go b/oauth/oauth.go index 5144129a..708bf711 100644 --- a/oauth/oauth.go +++ b/oauth/oauth.go @@ -1,6 +1,3 @@ -Exit code: 0 -Wall time: 0.4 seconds -Output: package oauth import ( From 1158da642a0250024e22f5da833a1e6e79086640 Mon Sep 17 00:00:00 2001 From: lukegranto23 Date: Thu, 30 Jul 2026 01:25:19 -0400 Subject: [PATCH 6/6] test(oauth): cover claim expiry and auth scheme validation --- oauth/oauth_hardening_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/oauth/oauth_hardening_test.go b/oauth/oauth_hardening_test.go index b648692e..e8c2d592 100644 --- a/oauth/oauth_hardening_test.go +++ b/oauth/oauth_hardening_test.go @@ -1,6 +1,3 @@ -Exit code: 0 -Wall time: 0.4 seconds -Output: package oauth import (