From cfe9bbf829d5d0eede24669f80d4d6482f3cfb3a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 30 Apr 2025 12:54:13 +0300 Subject: [PATCH] oidc: try to get username from userinfo (#2545) * oidc: try to get username from userinfo Signed-off-by: Kristoffer Dalby * changelog Signed-off-by: Kristoffer Dalby --------- Signed-off-by: Kristoffer Dalby --- CHANGELOG.md | 2 ++ hscontrol/oidc.go | 29 ++++++++++++++++++++++------- hscontrol/types/users.go | 13 ++++++++++++- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb98bbd2..18878d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,8 @@ working in v1 and not tested might be broken in v2 (and vice versa). [#2493](https://github.com/juanfont/headscale/pull/2493) - If a OIDC provider doesn't include the `email_verified` claim in its ID tokens, Headscale will attempt to get it from the UserInfo endpoint. +- OIDC: Try to populate name, email and username from UserInfo + [#2545](https://github.com/juanfont/headscale/pull/2545) - Improve performance by only querying relevant nodes from the database for node updates [#2509](https://github.com/juanfont/headscale/pull/2509) - node FQDNs in the netmap will now contain a dot (".") at the end. This aligns diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 85566d0f..e566d64a 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -2,6 +2,7 @@ package hscontrol import ( "bytes" + "cmp" "context" _ "embed" "errors" @@ -280,14 +281,28 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( return } - // If EmailVerified is missing, we can try to get it from UserInfo - if !claims.EmailVerified { - var userinfo *oidc.UserInfo - userinfo, err = a.oidcProvider.UserInfo(req.Context(), oauth2.StaticTokenSource(oauth2Token)) - if err != nil { - util.LogErr(err, "could not get userinfo; email cannot be verified") + 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) } - claims.EmailVerified = types.FlexibleBoolean(userinfo.EmailVerified) } user, err := a.createOrUpdateUserFromClaim(&claims) diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index 96988a0a..471cb1e5 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -157,7 +157,7 @@ func (u *User) Proto() *v1.User { type FlexibleBoolean bool func (bit *FlexibleBoolean) UnmarshalJSON(data []byte) error { - var val interface{} + var val any err := json.Unmarshal(data, &val) if err != nil { return fmt.Errorf("could not unmarshal data: %w", err) @@ -203,6 +203,17 @@ func (c *OIDCClaims) Identifier() string { return c.Iss + "/" + c.Sub } +type OIDCUserInfo struct { + Sub string `json:"sub"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + EmailVerified FlexibleBoolean `json:"email_verified,omitempty"` + Picture string `json:"picture"` +} + // FromClaim overrides a User from OIDC claims. // All fields will be updated, except for the ID. func (u *User) FromClaim(claims *OIDCClaims) {