From d6fbf83c56e80cba0c9eb7bda3b5aaf7a1e587ad Mon Sep 17 00:00:00 2001 From: Fredrik Ekre Date: Fri, 27 Jun 2025 12:10:14 +0000 Subject: [PATCH 1/2] OIDC: Query userinfo endpoint before verifying user This patch includes some changes to the OIDC integration in particular: - Make sure that userinfo claims are queried *before* comparing the user with the configured allowed groups, email and email domain. - Update user with group claim from the userinfo endpoint which is required for allowed groups to work correctly. This is essentially a continuation of #2545. - Let userinfo claims take precedence over id token claims. With these changes I have verified that Headscale works as expected together with Authelia without the documented escape hatch [0], i.e. everything works even if the id token only contain the iss and sub claims. [0]: https://www.authelia.com/integration/openid-connect/headscale/#configuration-escape-hatch --- hscontrol/oidc.go | 53 ++++++++++++++++++++++------------------ hscontrol/types/users.go | 1 + 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 1f08adf8..ca73bdd5 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -262,6 +262,35 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( return } + // Fetch user information (email, groups, name, etc) from the userinfo endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + var userinfo *oidc.UserInfo + userinfo, err = a.oidcProvider.UserInfo(req.Context(), oauth2.StaticTokenSource(oauth2Token)) + if err != nil { + util.LogErr(err, "could not get userinfo; only using claims from id token") + } + + // The oidc.UserInfo type only decodes some fields (Subject, Profile, Email, EmailVerified). + // We are interested in other fields too (e.g. groups are required for allowedGroups) so we + // decode into our own OIDCUserInfo type using the underlying claims struct. + var userinfo2 types.OIDCUserInfo + if userinfo != nil && userinfo.Claims(&userinfo2) == nil && userinfo2.Sub == claims.Sub { + // Update the user with the userinfo claims (with id token claims as fallback). + // TODO(kradalby): there might be more interesting fields here that we have not found yet. + claims.Email = cmp.Or(userinfo2.Email, claims.Email) + claims.EmailVerified = cmp.Or(userinfo2.EmailVerified, claims.EmailVerified) + claims.Username = cmp.Or(userinfo2.PreferredUsername, claims.Username) + claims.Name = cmp.Or(userinfo2.Name, claims.Name) + claims.ProfilePictureURL = cmp.Or(userinfo2.Picture, claims.ProfilePictureURL) + if userinfo2.Groups != nil { + claims.Groups = userinfo2.Groups + } + } else { + util.LogErr(err, "could not get userinfo; only using claims from id token") + } + + // The user claims are now updated from the the userinfo endpoint so we can verify the user a + // against allowed emails, email domains, and groups. if err := validateOIDCAllowedDomains(a.cfg.AllowedDomains, &claims); err != nil { httpError(writer, err) return @@ -277,30 +306,6 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( return } - var userinfo *oidc.UserInfo - userinfo, err = a.oidcProvider.UserInfo(req.Context(), oauth2.StaticTokenSource(oauth2Token)) - if err != nil { - util.LogErr(err, "could not get userinfo; only checking claim") - } - - // If the userinfo is available, we can check if the subject matches the - // claims, then use some of the userinfo fields to update the user. - // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo - if userinfo != nil && userinfo.Subject == claims.Sub { - claims.Email = cmp.Or(claims.Email, userinfo.Email) - claims.EmailVerified = cmp.Or(claims.EmailVerified, types.FlexibleBoolean(userinfo.EmailVerified)) - - // The userinfo has some extra fields that we can use to update the user but they are only - // available in the underlying claims struct. - // TODO(kradalby): there might be more interesting fields here that we have not found yet. - var userinfo2 types.OIDCUserInfo - if err := userinfo.Claims(&userinfo2); err == nil { - claims.Username = cmp.Or(claims.Username, userinfo2.PreferredUsername) - claims.Name = cmp.Or(claims.Name, userinfo2.Name) - claims.ProfilePictureURL = cmp.Or(claims.ProfilePictureURL, userinfo2.Picture) - } - } - user, policyChanged, err := a.createOrUpdateUserFromClaim(&claims) if err != nil { log.Error(). diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index 6cd2c41a..0e434f71 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -308,6 +308,7 @@ type OIDCUserInfo struct { PreferredUsername string `json:"preferred_username"` Email string `json:"email"` EmailVerified FlexibleBoolean `json:"email_verified,omitempty"` + Groups []string `json:"groups"` Picture string `json:"picture"` } From 9b32bbcd8586cfa834b84b7bd71065d1e3ac141b Mon Sep 17 00:00:00 2001 From: Fredrik Ekre Date: Fri, 27 Jun 2025 14:17:31 +0000 Subject: [PATCH 2/2] Add changelog entries --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf62ae3..ef388005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ [#2614](https://github.com/juanfont/headscale/pull/2614) - Support client verify for DERP [#2046](https://github.com/juanfont/headscale/pull/2046) +- OIDC: Update user with claims from UserInfo *before* comparing with allowed + groups, email and domain [#2663](https://github.com/juanfont/headscale/pull/2663) +- OIDC: Use group claim from UserInfo + [#2663](https://github.com/juanfont/headscale/pull/2663) ## 0.26.1 (2025-06-06)