Skip to content

Commit 24e964c

Browse files
committed
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
1 parent b4f7782 commit 24e964c

File tree

3 files changed

+34
-24
lines changed

3 files changed

+34
-24
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ systemctl start headscale
6969
[#2656](https://github.com/juanfont/headscale/pull/2656)
7070
- Adds `/robots.txt` endpoint to avoid crawlers
7171
[#2643](https://github.com/juanfont/headscale/pull/2643)
72+
- OIDC: Use group claim from UserInfo
73+
[#2663](https://github.com/juanfont/headscale/pull/2663)
74+
- OIDC: Update user with claims from UserInfo *before* comparing with allowed
75+
groups, email and domain [#2663](https://github.com/juanfont/headscale/pull/2663)
7276

7377
## 0.26.1 (2025-06-06)
7478

hscontrol/oidc.go

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,35 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
254254
return
255255
}
256256

257+
// Fetch user information (email, groups, name, etc) from the userinfo endpoint
258+
// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
259+
var userinfo *oidc.UserInfo
260+
userinfo, err = a.oidcProvider.UserInfo(req.Context(), oauth2.StaticTokenSource(oauth2Token))
261+
if err != nil {
262+
util.LogErr(err, "could not get userinfo; only using claims from id token")
263+
}
264+
265+
// The oidc.UserInfo type only decodes some fields (Subject, Profile, Email, EmailVerified).
266+
// We are interested in other fields too (e.g. groups are required for allowedGroups) so we
267+
// decode into our own OIDCUserInfo type using the underlying claims struct.
268+
var userinfo2 types.OIDCUserInfo
269+
if userinfo != nil && userinfo.Claims(&userinfo2) == nil && userinfo2.Sub == claims.Sub {
270+
// Update the user with the userinfo claims (with id token claims as fallback).
271+
// TODO(kradalby): there might be more interesting fields here that we have not found yet.
272+
claims.Email = cmp.Or(userinfo2.Email, claims.Email)
273+
claims.EmailVerified = cmp.Or(userinfo2.EmailVerified, claims.EmailVerified)
274+
claims.Username = cmp.Or(userinfo2.PreferredUsername, claims.Username)
275+
claims.Name = cmp.Or(userinfo2.Name, claims.Name)
276+
claims.ProfilePictureURL = cmp.Or(userinfo2.Picture, claims.ProfilePictureURL)
277+
if userinfo2.Groups != nil {
278+
claims.Groups = userinfo2.Groups
279+
}
280+
} else {
281+
util.LogErr(err, "could not get userinfo; only using claims from id token")
282+
}
283+
284+
// The user claims are now updated from the the userinfo endpoint so we can verify the user a
285+
// against allowed emails, email domains, and groups.
257286
if err := validateOIDCAllowedDomains(a.cfg.AllowedDomains, &claims); err != nil {
258287
httpError(writer, err)
259288
return
@@ -269,30 +298,6 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
269298
return
270299
}
271300

272-
var userinfo *oidc.UserInfo
273-
userinfo, err = a.oidcProvider.UserInfo(req.Context(), oauth2.StaticTokenSource(oauth2Token))
274-
if err != nil {
275-
util.LogErr(err, "could not get userinfo; only checking claim")
276-
}
277-
278-
// If the userinfo is available, we can check if the subject matches the
279-
// claims, then use some of the userinfo fields to update the user.
280-
// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
281-
if userinfo != nil && userinfo.Subject == claims.Sub {
282-
claims.Email = cmp.Or(claims.Email, userinfo.Email)
283-
claims.EmailVerified = cmp.Or(claims.EmailVerified, types.FlexibleBoolean(userinfo.EmailVerified))
284-
285-
// The userinfo has some extra fields that we can use to update the user but they are only
286-
// available in the underlying claims struct.
287-
// TODO(kradalby): there might be more interesting fields here that we have not found yet.
288-
var userinfo2 types.OIDCUserInfo
289-
if err := userinfo.Claims(&userinfo2); err == nil {
290-
claims.Username = cmp.Or(claims.Username, userinfo2.PreferredUsername)
291-
claims.Name = cmp.Or(claims.Name, userinfo2.Name)
292-
claims.ProfilePictureURL = cmp.Or(claims.ProfilePictureURL, userinfo2.Picture)
293-
}
294-
}
295-
296301
user, policyChanged, err := a.createOrUpdateUserFromClaim(&claims)
297302
if err != nil {
298303
log.Error().

hscontrol/types/users.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ type OIDCUserInfo struct {
310310
PreferredUsername string `json:"preferred_username"`
311311
Email string `json:"email"`
312312
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
313+
Groups []string `json:"groups"`
313314
Picture string `json:"picture"`
314315
}
315316

0 commit comments

Comments
 (0)