Skip to content

Commit 01187d1

Browse files
authored
feat: support Azure AD groups claims overflow via Microsoft Graph API (#27397)
Signed-off-by: Christian Artin <gravufo@gmail.com>
1 parent be19446 commit 01187d1

9 files changed

Lines changed: 1110 additions & 23 deletions

File tree

docs/operator-manual/user-management/microsoft.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,93 @@
124124

125125
Refer to [operator-manual/argocd-rbac-cm.yaml](https://github.com/argoproj/argo-cd/blob/master/docs/operator-manual/argocd-rbac-cm.yaml) for all of the available variables.
126126

127+
## Azure AD Groups Overflow Resolution (200+ Groups)
128+
129+
### Overview
130+
131+
Azure AD / Entra ID access tokens can contain a maximum of 200 groups. When a user belongs to more
132+
than 200 groups, Azure AD sets overflow indicators (`_claim_names` and `_claim_sources`) in the ID
133+
token instead of including the `groups` claim. This causes users to have no group-based RBAC
134+
permissions in Argo CD.
135+
136+
Argo CD can automatically detect this overflow and resolve it by calling the Microsoft Graph API to
137+
fetch the complete list of group memberships.
138+
139+
### Prerequisites
140+
141+
1. The Azure AD app registration must have the **User.Read** delegated permission. This is granted by
142+
default when following the [Setup permissions](#setup-permissions-for-entra-id-application) steps
143+
above. Azure AD automatically includes approved permissions in the access token's `scp` claim
144+
without needing to explicitly request them in `requestedScopes`.
145+
146+
### Configuration
147+
148+
Add the following to your `argocd-cm` ConfigMap under `oidc.config`:
149+
150+
```yaml
151+
oidc.config: |
152+
name: Azure
153+
issuer: https://login.microsoftonline.com/{tenant_id}/v2.0
154+
clientID: {client_id}
155+
clientSecret: $oidc.azure.clientSecret
156+
requestedScopes:
157+
- openid
158+
- profile
159+
- email
160+
azure:
161+
enableUserGroupOverageClaim: true
162+
```
163+
164+
### Configuration Options
165+
166+
| Setting | Default | Description |
167+
|---------|---------|-------------|
168+
| `enableUserGroupOverageClaim` | `false` | Enable automatic overflow resolution via Graph API |
169+
| `graphApiEndpoint` | `https://graph.microsoft.com/v1.0` | Graph API base URL (override for sovereign clouds) |
170+
| `userGroupOverageClaimCacheExpiration` | Token expiry | Cache duration for resolved groups (e.g., `10m`) |
171+
172+
#### Sovereign Clouds
173+
174+
For Azure Government, Azure China, or other sovereign clouds, override the Graph API endpoint:
175+
176+
```yaml
177+
azure:
178+
enableUserGroupOverageClaim: true
179+
graphApiEndpoint: https://graph.microsoft.us/v1.0 # Azure Government
180+
```
181+
182+
### How It Works
183+
184+
1. **Detection**: Argo CD checks the ID token for overflow indicators (`_claim_names` and
185+
`_claim_sources`).
186+
2. **Scope Check**: Verifies the access token contains the `User.Read` scope.
187+
3. **Graph API Call**: Calls `POST /me/getMemberGroups` to fetch all group IDs (up to 2048).
188+
Only security-enabled groups are returned (not distribution lists), which is appropriate
189+
for RBAC evaluation.
190+
4. **Caching**: Encrypts and caches the resolved groups for the duration of the token or a
191+
configured expiration.
192+
5. **RBAC Integration**: The resolved group IDs are added to the user's claims for RBAC evaluation.
193+
194+
### Troubleshooting
195+
196+
| Symptom | Cause | Solution |
197+
|---------|-------|----------|
198+
| User still has 0 groups despite 200+ memberships | Feature not enabled | Set `enableUserGroupOverageClaim: true` |
199+
| Logs show "access token missing User.Read scope" | Missing permission | Verify the **User.Read** delegated permission is granted on the app registration in Azure AD |
200+
| Logs show "insufficient permissions for Graph API" | App permission denied | Verify app permissions in Azure AD |
201+
| Logs show "no access token cached" | Token expired | User must re-authenticate |
202+
203+
> [!WARNING]
204+
> This feature depends on the Microsoft Graph API being reachable at authentication time. If the
205+
> Graph API is unavailable (network issues, outage, etc.), users with 200+ group memberships will
206+
> be unable to authenticate until service is restored. Users with fewer than 200 groups are
207+
> unaffected since their groups are included directly in the ID token.
208+
>
209+
> Graph API failures (missing scope, permission denied, network errors) will cause authentication
210+
> to fail with a 401 Unauthorized response. This is consistent with how the UserInfo endpoint
211+
> behaves. Only enable this feature if you need group-based RBAC for users with 200+ groups, and
212+
> ensure the prerequisites above are met to avoid authentication issues.
213+
127214
## Entra ID SAML Enterprise App Auth using Dex
128215
### Configure a new Entra ID Enterprise App
129216

server/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1599,7 +1599,7 @@ func (server *ArgoCDServer) getClaims(ctx context.Context) (jwt.Claims, string,
15991599
finalClaims := claims
16001600
oidcConfig := server.settings.OIDCConfig()
16011601
if oidcConfig != nil || server.settings.IsDexConfigured() {
1602-
updatedClaims, err := server.ssoClientApp.SetGroupsFromUserInfo(ctx, claims, util_session.SessionManagerClaimsIssuer)
1602+
updatedClaims, err := server.ssoClientApp.SetGroupsClaimFromEndpoint(ctx, claims, util_session.SessionManagerClaimsIssuer)
16031603
if err != nil {
16041604
return claims, "", status.Errorf(codes.Unauthenticated, "invalid session: %v", err)
16051605
}

util/oidc/oidc.go

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,15 @@ func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) {
580580
}
581581
// save the accessToken in memory for later use
582582
sub := jwtutil.StringField(claims, "sub")
583+
584+
// Invalidate cached groups from previous sessions so a fresh login picks up updated memberships
585+
if err := a.clientCache.Delete(FormatUserInfoResponseCacheKey(sub)); err != nil {
586+
log.Warnf("failed to invalidate UserInfo cache for %s: %v", sub, err)
587+
}
588+
if err := a.clientCache.Delete(FormatAzureGroupsOverageResponseCacheKey(sub)); err != nil {
589+
log.Warnf("failed to invalidate Azure groups overage cache for %s: %v", sub, err)
590+
}
591+
583592
err = a.SetValueInEncryptedCache(ctx, FormatAccessTokenCacheKey(sub), []byte(token.AccessToken), GetTokenExpiration(claims))
584593
if err != nil {
585594
claimsJSON, _ := json.Marshal(claims)
@@ -882,12 +891,14 @@ func createClaimsAuthenticationRequestParameter(requestedClaims map[string]*oidc
882891
return oauth2.SetAuthURLParam("claims", string(claimsRequestRAW)), nil
883892
}
884893

885-
// SetGroupsFromUserInfo takes a claims object and adds groups claim from userinfo endpoint if available
886-
// This is required by some SSO implementations as they don't provide the groups claim in the ID token
887-
// If querying the UserInfo endpoint fails, we return an error to indicate the session is invalid
888-
// we assume that everywhere in argocd jwt.MapClaims is used as type for interface jwt.Claims
889-
// otherwise this would cause a panic
890-
func (a *ClientApp) SetGroupsFromUserInfo(ctx context.Context, claims jwt.Claims, sessionManagerClaimsIssuer string) (jwt.MapClaims, error) {
894+
// SetGroupsClaimFromEndpoint takes a claims object and adds groups claim from either:
895+
// - the UserInfo endpoint if enabled
896+
// - the Microsoft Graph API if Azure groups overage claim is detected and enabled
897+
// This is required by some SSO implementations as they don't provide the groups claim in the ID token.
898+
// If querying the endpoint fails, we return an error to indicate the session is invalid.
899+
// We assume that everywhere in argocd jwt.MapClaims is used as type for interface jwt.Claims
900+
// otherwise this would cause a panic.
901+
func (a *ClientApp) SetGroupsClaimFromEndpoint(ctx context.Context, claims jwt.Claims, sessionManagerClaimsIssuer string) (jwt.MapClaims, error) {
891902
var groupClaims jwt.MapClaims
892903
var ok bool
893904
if groupClaims, ok = claims.(jwt.MapClaims); !ok {
@@ -897,19 +908,37 @@ func (a *ClientApp) SetGroupsFromUserInfo(ctx context.Context, claims jwt.Claims
897908
}
898909
}
899910
}
911+
900912
iss := jwtutil.StringField(groupClaims, "iss")
901-
if iss != sessionManagerClaimsIssuer && a.settings.UserInfoGroupsEnabled() && a.settings.UserInfoPath() != "" {
902-
userInfo, unauthorized, err := a.GetUserInfo(ctx, groupClaims, a.settings.IssuerURL(), a.settings.UserInfoBaseURL(), a.settings.UserInfoPath())
903-
if unauthorized {
904-
return groupClaims, fmt.Errorf("error while quering userinfo endpoint: %w", err)
905-
}
906-
if err != nil {
907-
return groupClaims, fmt.Errorf("error fetching user info endpoint: %w", err)
913+
if iss != sessionManagerClaimsIssuer {
914+
// Path 1: UserInfo endpoint — mutually exclusive with Path 2 (Azure overage).
915+
// For Entra ID, UserInfo returns the same data as the ID token, so both are never needed.
916+
if a.settings.UserInfoGroupsEnabled() && a.settings.UserInfoPath() != "" {
917+
userInfo, unauthorized, err := a.GetUserInfo(ctx, groupClaims, a.settings.IssuerURL(), a.settings.UserInfoBaseURL(), a.settings.UserInfoPath())
918+
if unauthorized {
919+
return groupClaims, fmt.Errorf("error while querying userinfo endpoint: %w", err)
920+
}
921+
if err != nil {
922+
return groupClaims, fmt.Errorf("error fetching user info endpoint: %w", err)
923+
}
924+
if groupClaims["sub"] != userInfo["sub"] {
925+
return groupClaims, errors.New("subject of claims from user info endpoint didn't match subject of idToken, see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo")
926+
}
927+
groupClaims["groups"] = userInfo["groups"]
928+
return groupClaims, nil
908929
}
909-
if groupClaims["sub"] != userInfo["sub"] {
910-
return groupClaims, errors.New("subject of claims from user info endpoint didn't match subject of idToken, see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo")
930+
931+
// Path 2: Azure AD groups overage claim resolution via Microsoft Graph API
932+
if a.settings.AzureUserGroupOverageClaimEnabled() && a.settings.AzureGraphAPIEndpoint() != "" {
933+
groups, err := a.GetUserGroupsFromAzureOverageClaim(ctx, groupClaims, a.settings.AzureGraphAPIEndpoint())
934+
if err != nil {
935+
return groupClaims, fmt.Errorf("error fetching groups from Azure overage claim: %w", err)
936+
}
937+
if len(groups) > 0 {
938+
groupClaims["groups"] = groups
939+
}
940+
return groupClaims, nil
911941
}
912-
groupClaims["groups"] = userInfo["groups"]
913942
}
914943

915944
return groupClaims, nil

0 commit comments

Comments
 (0)