oidc: merge userinfo claims; tick P4-05 in tasks.md

Authelia (and many other IdPs) only put `sub` in the ID token by
default, surfacing `preferred_username`/`email`/`groups` from the
userinfo endpoint. Fetch userinfo after id_token verification and
fold its claims into the parsed claim map; the id_token claims
remain authoritative on conflict so the signed assertion still
wins.

Live sweep against https://auth.dcglab.co.uk verified all four
flows: rm-admin → admin JIT, rm-operator → operator JIT (RBAC
denies admin pages), rm-viewer → viewer JIT (RBAC denies operator
pages), rm-other → no_role_match banner with no row created.
Returning rm-admin sign-in resolves to the same row by sub.
Screenshots in _diag/p4-05-sweep/.
This commit is contained in:
2026-05-05 14:06:28 +01:00
parent e0989e1cef
commit 2e1961beee
2 changed files with 18 additions and 1 deletions
+14
View File
@@ -101,6 +101,20 @@ func (c *Client) Exchange(ctx context.Context, code, verifier string) (*Claims,
if err := idTok.Claims(&raw); err != nil {
return nil, "", fmt.Errorf("oidc: claims: %w", err)
}
// Many IdPs (Authelia among them) only return minimal claims in
// the ID token and put profile/email/groups on /userinfo. Fetch
// userinfo and merge — id_token claims win on conflict so the
// signed assertion remains authoritative.
if ui, err := c.provider.UserInfo(ctx, oauth2.StaticTokenSource(tok)); err == nil {
var uiClaims map[string]any
if err := ui.Claims(&uiClaims); err == nil {
for k, v := range uiClaims {
if _, present := raw[k]; !present {
raw[k] = v
}
}
}
}
return parseClaims(raw, c.cfg.RoleClaim), rawID, nil
}