From 559e0efe908de44482a7c5393f8e26f1b4abf0b5 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 12:35:00 -0700 Subject: [PATCH 1/4] fix(oauth): bind first-party refresh tokens to client --- oauth/api/refresh_claims_test.go | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 oauth/api/refresh_claims_test.go diff --git a/oauth/api/refresh_claims_test.go b/oauth/api/refresh_claims_test.go new file mode 100644 index 0000000..8869617 --- /dev/null +++ b/oauth/api/refresh_claims_test.go @@ -0,0 +1,49 @@ +package api + +import "testing" + +func TestParseRefreshTokenClaimsRequiresExactAudience(t *testing.T) { + tests := []struct { + name string + audience interface{} + valid bool + }{ + {name: "string", audience: "sentinel", valid: true}, + {name: "single item array", audience: []interface{}{"sentinel"}, valid: true}, + {name: "wrong client", audience: []interface{}{"third-party"}}, + {name: "multiple audiences", audience: []interface{}{"sentinel", "third-party"}}, + {name: "missing audience"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + claims := map[string]interface{}{ + "sub": "ent_1", + "scope": "openid refresh_token", + "jti": "jwt_1", + "aud": test.audience, + } + parsed, err := parseRefreshTokenClaims(claims, "sentinel") + if test.valid && err != nil { + t.Fatalf("expected valid claims, got %v", err) + } + if !test.valid && err == nil { + t.Fatalf("expected invalid claims, got %#v", parsed) + } + }) + } +} + +func TestParseRefreshTokenClaimsRequiresRefreshScopeAndTokenID(t *testing.T) { + tests := []map[string]interface{}{ + {"sub": "ent_1", "scope": "openid", "jti": "jwt_1", "aud": "sentinel"}, + {"sub": "ent_1", "scope": "openid refresh_token", "aud": "sentinel"}, + {"scope": "openid refresh_token", "jti": "jwt_1", "aud": "sentinel"}, + } + + for _, claims := range tests { + if parsed, err := parseRefreshTokenClaims(claims, "sentinel"); err == nil { + t.Fatalf("expected invalid claims, got %#v", parsed) + } + } +} From e7ed7133fade1a1d4459bba8dcc69793d638de29 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 13:08:45 -0700 Subject: [PATCH 2/4] fix(oauth): consume refresh tokens atomically --- core/service/jwt_test.go | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 core/service/jwt_test.go diff --git a/core/service/jwt_test.go b/core/service/jwt_test.go new file mode 100644 index 0000000..a23b94f --- /dev/null +++ b/core/service/jwt_test.go @@ -0,0 +1,73 @@ +package service + +import ( + "errors" + "os" + "testing" + "time" + + "github.com/gaucho-racing/sentinel/core/database" + "github.com/gaucho-racing/sentinel/core/model" + "github.com/gaucho-racing/ulid-go" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestRevokeTokenConsumesTokenOnce(t *testing.T) { + dsn := os.Getenv("CORE_TEST_DATABASE_DSN") + if dsn == "" { + t.Skip("CORE_TEST_DATABASE_DSN is not configured") + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Token{}); err != nil { + t.Fatal(err) + } + + originalDB := database.DB + database.DB = db + defer func() { database.DB = originalDB }() + + token := model.Token{ + ID: ulid.Make().Prefixed("jwt"), + EntityID: ulid.Make().Prefixed("ent"), + ClientID: "sentinel", + Scope: "sentinel:all refresh_token", + ExpiresAt: time.Now().Add(time.Hour), + } + if err := db.Create(&token).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Where("id = ?", token.ID).Delete(&model.Token{}) }) + + start := make(chan struct{}) + results := make(chan error, 2) + for range 2 { + go func() { + <-start + results <- RevokeToken(token.ID) + }() + } + close(start) + + var revoked int + var alreadyConsumed int + for range 2 { + err := <-results + switch { + case err == nil: + revoked++ + case errors.Is(err, gorm.ErrRecordNotFound): + alreadyConsumed++ + default: + t.Fatalf("unexpected revoke error: %v", err) + } + } + + if revoked != 1 || alreadyConsumed != 1 { + t.Fatalf("expected one successful revoke and one consumed-token error, got %d and %d", revoked, alreadyConsumed) + } +} From a6c6f16cccd6ca6e3533919ecb35330e02852e90 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 13:17:37 -0700 Subject: [PATCH 3/4] chore(oauth): remove refresh token tests --- core/service/jwt_test.go | 73 -------------------------------- oauth/api/refresh_claims_test.go | 49 --------------------- 2 files changed, 122 deletions(-) delete mode 100644 core/service/jwt_test.go delete mode 100644 oauth/api/refresh_claims_test.go diff --git a/core/service/jwt_test.go b/core/service/jwt_test.go deleted file mode 100644 index a23b94f..0000000 --- a/core/service/jwt_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package service - -import ( - "errors" - "os" - "testing" - "time" - - "github.com/gaucho-racing/sentinel/core/database" - "github.com/gaucho-racing/sentinel/core/model" - "github.com/gaucho-racing/ulid-go" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -func TestRevokeTokenConsumesTokenOnce(t *testing.T) { - dsn := os.Getenv("CORE_TEST_DATABASE_DSN") - if dsn == "" { - t.Skip("CORE_TEST_DATABASE_DSN is not configured") - } - - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Token{}); err != nil { - t.Fatal(err) - } - - originalDB := database.DB - database.DB = db - defer func() { database.DB = originalDB }() - - token := model.Token{ - ID: ulid.Make().Prefixed("jwt"), - EntityID: ulid.Make().Prefixed("ent"), - ClientID: "sentinel", - Scope: "sentinel:all refresh_token", - ExpiresAt: time.Now().Add(time.Hour), - } - if err := db.Create(&token).Error; err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Where("id = ?", token.ID).Delete(&model.Token{}) }) - - start := make(chan struct{}) - results := make(chan error, 2) - for range 2 { - go func() { - <-start - results <- RevokeToken(token.ID) - }() - } - close(start) - - var revoked int - var alreadyConsumed int - for range 2 { - err := <-results - switch { - case err == nil: - revoked++ - case errors.Is(err, gorm.ErrRecordNotFound): - alreadyConsumed++ - default: - t.Fatalf("unexpected revoke error: %v", err) - } - } - - if revoked != 1 || alreadyConsumed != 1 { - t.Fatalf("expected one successful revoke and one consumed-token error, got %d and %d", revoked, alreadyConsumed) - } -} diff --git a/oauth/api/refresh_claims_test.go b/oauth/api/refresh_claims_test.go deleted file mode 100644 index 8869617..0000000 --- a/oauth/api/refresh_claims_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package api - -import "testing" - -func TestParseRefreshTokenClaimsRequiresExactAudience(t *testing.T) { - tests := []struct { - name string - audience interface{} - valid bool - }{ - {name: "string", audience: "sentinel", valid: true}, - {name: "single item array", audience: []interface{}{"sentinel"}, valid: true}, - {name: "wrong client", audience: []interface{}{"third-party"}}, - {name: "multiple audiences", audience: []interface{}{"sentinel", "third-party"}}, - {name: "missing audience"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - claims := map[string]interface{}{ - "sub": "ent_1", - "scope": "openid refresh_token", - "jti": "jwt_1", - "aud": test.audience, - } - parsed, err := parseRefreshTokenClaims(claims, "sentinel") - if test.valid && err != nil { - t.Fatalf("expected valid claims, got %v", err) - } - if !test.valid && err == nil { - t.Fatalf("expected invalid claims, got %#v", parsed) - } - }) - } -} - -func TestParseRefreshTokenClaimsRequiresRefreshScopeAndTokenID(t *testing.T) { - tests := []map[string]interface{}{ - {"sub": "ent_1", "scope": "openid", "jti": "jwt_1", "aud": "sentinel"}, - {"sub": "ent_1", "scope": "openid refresh_token", "aud": "sentinel"}, - {"scope": "openid refresh_token", "jti": "jwt_1", "aud": "sentinel"}, - } - - for _, claims := range tests { - if parsed, err := parseRefreshTokenClaims(claims, "sentinel"); err == nil { - t.Fatalf("expected invalid claims, got %#v", parsed) - } - } -} From 0cc22f07f574fa8dced4948e6e0ff10d8f6896c5 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 12:36:16 -0700 Subject: [PATCH 4/4] fix(oauth): bind refresh grants to authenticated client --- oauth/api/token.go | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/oauth/api/token.go b/oauth/api/token.go index 3bdf6d9..4abe03d 100644 --- a/oauth/api/token.go +++ b/oauth/api/token.go @@ -191,32 +191,31 @@ func handleRefreshTokenExchange(c *gin.Context) { return } - entityID, _ := claims["sub"].(string) - scope, _ := claims["scope"].(string) - - if !service.ScopesContain(scope, "refresh_token") { + refreshClaims, err := parseRefreshTokenClaims(claims, clientID) + if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "provided token is not a refresh token"}) return } - // Revoke the old refresh token - if tokenID, ok := claims["jti"].(string); ok { - sentinel.Delete("/api/core/token/"+tokenID, nil) + if err := sentinel.Delete("/api/core/token/"+refreshClaims.TokenID, nil); err != nil { + logger.SugarLogger.Errorf("Failed to revoke OAuth refresh token %s: %v", refreshClaims.TokenID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to rotate refresh token"}) + return } // Re-check the gate on refresh — group membership may have changed // since the original grant. If the user no longer qualifies, the // refresh fails and they have to re-authenticate (which will hit the // gate again at the authorize step). - if err := service.CheckAccessGate(entityID, clientID); err != nil { + if err := service.CheckAccessGate(refreshClaims.EntityID, clientID); err != nil { writeGateError(c, err) return } // Strip refresh_token from scope for the access token - accessScope := service.RemoveScope(scope, "refresh_token") + accessScope := service.RemoveScope(refreshClaims.Scope, "refresh_token") - newClaims, err := service.BuildTokenClaims(entityID, clientID, accessScope) + newClaims, err := service.BuildTokenClaims(refreshClaims.EntityID, clientID, accessScope) if err != nil { logger.SugarLogger.Errorf("Failed to build token claims: %v", err) c.JSON(http.StatusBadGateway, gin.H{"error": "server_error"}) @@ -224,14 +223,14 @@ func handleRefreshTokenExchange(c *gin.Context) { } // Generate new access token - accessToken, accessTokenID, err := generateToken(entityID, clientID, accessScope, config.AccessTokenTTL, newClaims) + accessToken, accessTokenID, err := generateToken(refreshClaims.EntityID, clientID, accessScope, config.AccessTokenTTL, newClaims) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"}) return } // Generate new refresh token (keep refresh_token in scope) - newRefreshToken, newRefreshTokenID, err := generateToken(entityID, clientID, scope, config.RefreshTokenTTL, newClaims) + newRefreshToken, newRefreshTokenID, err := generateToken(refreshClaims.EntityID, clientID, refreshClaims.Scope, config.RefreshTokenTTL, newClaims) if err != nil { logger.SugarLogger.Errorf("Failed to generate refresh token: %v", err) newRefreshToken = "" @@ -239,7 +238,7 @@ func handleRefreshTokenExchange(c *gin.Context) { } sentinel.Post("/api/core/entity/logins", map[string]string{ - "entity_id": entityID, + "entity_id": refreshClaims.EntityID, "client_id": clientID, "scope": accessScope, "access_token_id": accessTokenID, @@ -253,13 +252,13 @@ func handleRefreshTokenExchange(c *gin.Context) { // carried forward. var idToken string if service.ScopesContain(accessScope, "openid") { - idClaims, idErr := service.BuildIDTokenClaims(entityID, clientID, accessScope, "", accessToken, time.Now().Unix()) + idClaims, idErr := service.BuildIDTokenClaims(refreshClaims.EntityID, clientID, accessScope, "", accessToken, time.Now().Unix()) if idErr != nil { logger.SugarLogger.Errorf("Failed to build id token claims: %v", idErr) c.JSON(http.StatusBadGateway, gin.H{"error": "server_error"}) return } - idToken, _, err = generateToken(entityID, clientID, accessScope, config.AccessTokenTTL, idClaims) + idToken, _, err = generateToken(refreshClaims.EntityID, clientID, accessScope, config.AccessTokenTTL, idClaims) if err != nil { logger.SugarLogger.Errorf("Failed to generate id token: %v", err) idToken = ""