From 67e8b49255a4f04a3821b0185a5b460f2c652a83 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Tue, 2 Jun 2026 14:45:07 -0700 Subject: [PATCH] feat(oauth): emit group names + ids and accept offline_access scope - Token group claims now carry both readable names and stable IDs: groups is the list of group names (what RBAC policies key on, e.g. ArgoCD), and group_ids is the list of ULIDs for rename-safe references. Applies to the access token, ID token, and UserInfo via the shared SetGroupClaims. - Add offline_access as an accepted scope so OIDC clients (e.g. ArgoCD) can request a refresh token without the request being rejected. Sentinel still always issues a refresh token; this just stops valid requests from failing. Note: the groups claim previously contained group IDs and now contains names. Consumers needing IDs should read group_ids. --- oauth/model/scope.go | 1 + oauth/service/oidc.go | 4 +-- oauth/service/token_claims.go | 48 +++++++++++++++++++++++++---------- web/src/lib/scopes.ts | 6 +++++ 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/oauth/model/scope.go b/oauth/model/scope.go index 2831050..9f47644 100644 --- a/oauth/model/scope.go +++ b/oauth/model/scope.go @@ -4,6 +4,7 @@ var ValidScopes = map[string]string{ "openid": "Authenticate you and issue an ID token", "profile": "Read your basic profile (name, username, picture)", "email": "Read your email address", + "offline_access": "Stay signed in without re-authenticating (refresh token)", "user:read": "Read user and entity profile information", "user:write": "Update user profile information", "groups:read": "Read group memberships", diff --git a/oauth/service/oidc.go b/oauth/service/oidc.go index c8c4eb1..ef78dad 100644 --- a/oauth/service/oidc.go +++ b/oauth/service/oidc.go @@ -82,7 +82,7 @@ func BuildIDTokenClaims(entityID string, clientID string, scope string, nonce st e, _ := fetchOIDCEntity(entityID) claims := identityClaims(e, scope) if GroupsClaimAllowed(scope) { - claims["groups"] = FilteredGroups(entityID, clientID) + SetGroupClaims(claims, FilteredGroups(entityID, clientID)) } claims["auth_time"] = authTime if nonce != "" { @@ -104,7 +104,7 @@ func BuildUserInfoClaims(entityID string, clientID string, scope string) (map[st } claims := identityClaims(e, scope) if GroupsClaimAllowed(scope) { - claims["groups"] = FilteredGroups(entityID, clientID) + SetGroupClaims(claims, FilteredGroups(entityID, clientID)) } claims["sub"] = entityID return claims, nil diff --git a/oauth/service/token_claims.go b/oauth/service/token_claims.go index d963217..d9f817e 100644 --- a/oauth/service/token_claims.go +++ b/oauth/service/token_claims.go @@ -25,7 +25,15 @@ type idHolder struct { } type groupResponse struct { - ID string `json:"id"` + ID string `json:"id"` + Name string `json:"name"` +} + +// GroupRef is a group's stable ID and its human-readable name, carried +// together so token claims can expose both. +type GroupRef struct { + ID string + Name string } type applicationResponse struct { @@ -67,12 +75,26 @@ func BuildTokenClaims(entityID string, clientID string, scope string) map[string } if GroupsClaimAllowed(scope) { - claims["groups"] = FilteredGroups(entityID, clientID) + SetGroupClaims(claims, FilteredGroups(entityID, clientID)) } return claims } +// SetGroupClaims writes the group claims onto a claim map: `groups` holds the +// human-readable names (what RBAC policies key on) and `group_ids` holds the +// stable ULIDs (for consumers that need rename-safe references). +func SetGroupClaims(claims map[string]interface{}, groups []GroupRef) { + names := make([]string, 0, len(groups)) + ids := make([]string, 0, len(groups)) + for _, g := range groups { + names = append(names, g.Name) + ids = append(ids, g.ID) + } + claims["groups"] = names + claims["group_ids"] = ids +} + // GroupsClaimAllowed reports whether a token carrying the given scope should // include the groups claim — granted by the first-party sentinel:all scope or // the explicit groups:read scope. @@ -80,13 +102,13 @@ func GroupsClaimAllowed(scope string) bool { return ScopesContain(scope, "sentinel:all") || ScopesContain(scope, "groups:read") } -// FilteredGroups resolves the group IDs an entity should expose to a given +// FilteredGroups resolves the groups an entity should expose to a given // client, applying the same per-client visibility rules described on // BuildTokenClaims: the Sentinel client sees all of the user's groups; any // other client sees the user's groups intersected with the union of the // client's linked groups and Sentinel's linked groups (the global default). -func FilteredGroups(entityID string, clientID string) []string { - userGroups := getEntityGroupIDs(entityID) +func FilteredGroups(entityID string, clientID string) []GroupRef { + userGroups := getEntityGroups(entityID) if isSentinelClient(clientID) { return userGroups @@ -99,9 +121,9 @@ func FilteredGroups(entityID string, clientID string) []string { for _, link := range getSentinelGroupLinks() { allowed[link.GroupID] = struct{}{} } - filtered := make([]string, 0, len(userGroups)) + filtered := make([]GroupRef, 0, len(userGroups)) for _, g := range userGroups { - if _, ok := allowed[g]; ok { + if _, ok := allowed[g.ID]; ok { filtered = append(filtered, g) } } @@ -123,10 +145,10 @@ func CheckAccessGate(entityID, clientID string) error { if len(required) == 0 { return nil } - userGroups := getEntityGroupIDs(entityID) + userGroups := getEntityGroups(entityID) user := make(map[string]struct{}, len(userGroups)) for _, g := range userGroups { - user[g] = struct{}{} + user[g.ID] = struct{}{} } for _, g := range required { if _, ok := user[g]; ok { @@ -140,17 +162,17 @@ func isSentinelClient(clientID string) bool { return clientID == config.SentinelClientID } -func getEntityGroupIDs(entityID string) []string { +func getEntityGroups(entityID string) []GroupRef { var groups []groupResponse if err := sentinel.Get("/core/entity/"+entityID+"/groups", &groups); err != nil { logger.SugarLogger.Errorf("Failed to load groups for entity %s: %v", entityID, err) return nil } - ids := make([]string, 0, len(groups)) + refs := make([]GroupRef, 0, len(groups)) for _, g := range groups { - ids = append(ids, g.ID) + refs = append(refs, GroupRef{ID: g.ID, Name: g.Name}) } - return ids + return refs } func getAppGroupLinks(clientID string) []applicationGroupLink { diff --git a/web/src/lib/scopes.ts b/web/src/lib/scopes.ts index 8ef8276..7e5296e 100644 --- a/web/src/lib/scopes.ts +++ b/web/src/lib/scopes.ts @@ -1,5 +1,6 @@ import { AppWindow, + Clock, IdCard, KeyRound, Mail, @@ -32,6 +33,11 @@ const SCOPES: Record = { description: "See the email address on your account.", icon: Mail, }, + offline_access: { + label: "Stay signed in", + description: "Keep you signed in without re-authenticating.", + icon: Clock, + }, "user:read": { label: "Read your profile", description: "See your name, email, entity ID, and basic account info.",