diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bda04ed..c5d5f36c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,10 @@ The new policy can be used by setting the environment variable - It is now possible to inspect running goroutines and take profiles - View of config, policy, filter, ssh policy per node, connected nodes and DERPmap +- OIDC: Fetch UserInfo to get EmailVerified if necessary + [#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. ## 0.25.1 (2025-02-25) diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index a1807717..85566d0f 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -234,7 +234,14 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( return } - idToken, err := a.extractIDToken(req.Context(), code, state) + oauth2Token, err := a.getOauth2Token(req.Context(), code, state) + + if err != nil { + httpError(writer, err) + return + } + + idToken, err := a.extractIDToken(req.Context(), oauth2Token) if err != nil { httpError(writer, err) return @@ -273,6 +280,16 @@ 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") + } + claims.EmailVerified = types.FlexibleBoolean(userinfo.EmailVerified) + } + user, err := a.createOrUpdateUserFromClaim(&claims) if err != nil { httpError(writer, err) @@ -333,13 +350,12 @@ func extractCodeAndStateParamFromRequest( return code, state, nil } -// extractIDToken takes the code parameter from the callback -// and extracts the ID token from the oauth2 token. -func (a *AuthProviderOIDC) extractIDToken( +// getOauth2Token exchanges the code from the callback for an oauth2 token. +func (a *AuthProviderOIDC) getOauth2Token( ctx context.Context, code string, state string, -) (*oidc.IDToken, error) { +) (*oauth2.Token, error) { var exchangeOpts []oauth2.AuthCodeOption if a.cfg.PKCE.Enabled { @@ -356,7 +372,14 @@ func (a *AuthProviderOIDC) extractIDToken( if err != nil { return nil, NewHTTPError(http.StatusForbidden, "invalid code", fmt.Errorf("could not exchange code for token: %w", err)) } + return oauth2Token, err +} +// extractIDToken extracts the ID token from the oauth2 token. +func (a *AuthProviderOIDC) extractIDToken( + ctx context.Context, + oauth2Token *oauth2.Token, +) (*oidc.IDToken, error) { rawIDToken, ok := oauth2Token.Extra("id_token").(string) if !ok { return nil, NewHTTPError(http.StatusBadRequest, "no id_token", errNoOIDCIDToken)