diff --git a/oauth/get_token_context.go b/oauth/get_token_context.go index 5ed23a30..6caea2fd 100644 --- a/oauth/get_token_context.go +++ b/oauth/get_token_context.go @@ -21,6 +21,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 +57,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 +90,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 +102,4 @@ func sendAccTokenReq( return nil, nil, openapi.ReportError("server no response") } } + diff --git a/oauth/oauth.go b/oauth/oauth.go index 9a9827fb..708bf711 100644 --- a/oauth/oauth.go +++ b/oauth/oauth.go @@ -16,8 +16,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/pkg/errors" - - "github.com/free5gc/openapi/models" ) type CCAClaims struct { @@ -26,6 +24,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 +70,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 +91,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 +311,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)) } + diff --git a/oauth/oauth_hardening_test.go b/oauth/oauth_hardening_test.go new file mode 100644 index 00000000..e8c2d592 --- /dev/null +++ b/oauth/oauth_hardening_test.go @@ -0,0 +1,55 @@ +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") + } +} +