package provider import ( "context" "fmt" "strconv" "strings" "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt/v5" ) type ParseIDTokenOptions struct { SkipAccessTokenCheck bool AccessToken string } // OverrideVerifiers can be used to set a custom verifier for an OIDC provider // (identified by the provider's Endpoint().AuthURL string). Should only be // used in tests. var OverrideVerifiers = make(map[string]func(context.Context, *oidc.Config) *oidc.IDTokenVerifier) // OverrideClock can be used to set a custom clock function to be used when // parsing ID tokens. Should only be used in tests. var OverrideClock func() time.Time func ParseIDToken(ctx context.Context, provider *oidc.Provider, config *oidc.Config, idToken string, options ParseIDTokenOptions) (*oidc.IDToken, *UserProvidedData, error) { if config == nil { config = &oidc.Config{ // aud claim check to be performed by other flows SkipClientIDCheck: true, } } if OverrideClock != nil { clonedConfig := *config clonedConfig.Now = OverrideClock config = &clonedConfig } verifier := provider.VerifierContext(ctx, config) overrideVerifier, ok := OverrideVerifiers[provider.Endpoint().AuthURL] if ok && overrideVerifier != nil { verifier = overrideVerifier(ctx, config) } token, err := verifier.Verify(ctx, idToken) if err != nil { return nil, nil, err } var data *UserProvidedData switch token.Issuer { case IssuerGoogle: token, data, err = parseGoogleIDToken(token) case IssuerApple: token, data, err = parseAppleIDToken(token) case IssuerLinkedin: token, data, err = parseLinkedinIDToken(token) case IssuerKakao: token, data, err = parseKakaoIDToken(token) case IssuerVercelMarketplace: token, data, err = parseVercelMarketplaceIDToken(token) default: if IsAzureIssuer(token.Issuer) { token, data, err = parseAzureIDToken(token) } else { token, data, err = parseGenericIDToken(token) } } if err != nil { return nil, nil, err } if !options.SkipAccessTokenCheck && token.AccessTokenHash != "" { if err := token.VerifyAccessToken(options.AccessToken); err != nil { return nil, nil, err } } return token, data, nil } func parseGoogleIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) { var claims googleUser if err := token.Claims(&claims); err != nil { return nil, nil, err } var data UserProvidedData if claims.Email != "" { data.Emails = append(data.Emails, Email{ Email: claims.Email, Verified: claims.IsEmailVerified(), Primary: true, }) } data.Metadata = &Claims{ Issuer: claims.Issuer, Subject: claims.Subject, Name: claims.Name, Picture: claims.AvatarURL, // To be deprecated AvatarURL: claims.AvatarURL, FullName: claims.Name, ProviderId: claims.Subject, } if claims.HostedDomain != "" { data.Metadata.CustomClaims = map[string]any{ "hd": claims.HostedDomain, } } return token, &data, nil } type AppleIDTokenClaims struct { jwt.RegisteredClaims Email string `json:"email"` AuthTime *float64 `json:"auth_time"` IsPrivateEmail *IsPrivateEmail `json:"is_private_email"` } func parseAppleIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) { var claims AppleIDTokenClaims if err := token.Claims(&claims); err != nil { return nil, nil, err } var data UserProvidedData data.Emails = append(data.Emails, Email{ Email: claims.Email, Verified: true, Primary: true, }) data.Metadata = &Claims{ Issuer: token.Issuer, Subject: token.Subject, ProviderId: token.Subject, CustomClaims: make(map[string]any), } if claims.IsPrivateEmail != nil { data.Metadata.CustomClaims["is_private_email"] = *claims.IsPrivateEmail } if claims.AuthTime != nil { data.Metadata.CustomClaims["auth_time"] = *claims.AuthTime } if len(data.Metadata.CustomClaims) < 1 { data.Metadata.CustomClaims = nil } return token, &data, nil } type LinkedinIDTokenClaims struct { jwt.RegisteredClaims Email string `json:"email"` EmailVerified string `json:"email_verified"` FamilyName string `json:"family_name"` GivenName string `json:"given_name"` Locale string `json:"locale"` Picture string `json:"picture"` } func parseLinkedinIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) { var claims LinkedinIDTokenClaims if err := token.Claims(&claims); err != nil { return nil, nil, err } var data UserProvidedData emailVerified, err := strconv.ParseBool(claims.EmailVerified) if err != nil { return nil, nil, err } if claims.Email != "" { data.Emails = append(data.Emails, Email{ Email: claims.Email, Verified: emailVerified, Primary: true, }) } data.Metadata = &Claims{ Issuer: token.Issuer, Subject: token.Subject, Name: strings.TrimSpace(claims.GivenName + " " + claims.FamilyName), GivenName: claims.GivenName, FamilyName: claims.FamilyName, Locale: claims.Locale, Picture: claims.Picture, ProviderId: token.Subject, } return token, &data, nil } type AzureIDTokenClaims struct { jwt.RegisteredClaims Email string `json:"email"` Name string `json:"name"` PreferredUsername string `json:"preferred_username"` XMicrosoftEmailDomainOwnerVerified any `json:"xms_edov"` } func (c *AzureIDTokenClaims) IsEmailVerified() bool { emailVerified := false edov := c.XMicrosoftEmailDomainOwnerVerified // If xms_edov is not set, and an email is present or xms_edov is true, // only then is the email regarded as verified. // https://learn.microsoft.com/en-us/azure/active-directory/develop/migrate-off-email-claim-authorization#using-the-xms_edov-optional-claim-to-determine-email-verification-status-and-migrate-users if edov == nil { // An email is provided, but xms_edov is not -- probably not // configured, so we must assume the email is verified as Azure // will only send out a potentially unverified email address in // single-tenanat apps. emailVerified = c.Email != "" } else { edovBool := false // Azure can't be trusted with how they encode the xms_edov // claim. Sometimes it's "xms_edov": "1", sometimes "xms_edov": true. switch v := edov.(type) { case bool: edovBool = v case string: edovBool = v == "1" || v == "true" default: edovBool = false } emailVerified = c.Email != "" && edovBool } return emailVerified } // removeAzureClaimsFromCustomClaims contains the list of claims to be removed // from the CustomClaims map. See: // https://learn.microsoft.com/en-us/azure/active-directory/develop/id-token-claims-reference var removeAzureClaimsFromCustomClaims = []string{ "aud", "iss", "iat", "nbf", "exp", "c_hash", "at_hash", "aio", "nonce", "rh", "uti", "jti", "ver", "sub", "name", "preferred_username", } func parseAzureIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) { var data UserProvidedData var azureClaims AzureIDTokenClaims if err := token.Claims(&azureClaims); err != nil { return nil, nil, err } data.Metadata = &Claims{ Issuer: token.Issuer, Subject: token.Subject, ProviderId: token.Subject, PreferredUsername: azureClaims.PreferredUsername, FullName: azureClaims.Name, CustomClaims: make(map[string]any), } if azureClaims.Email != "" { data.Emails = []Email{{ Email: azureClaims.Email, Verified: azureClaims.IsEmailVerified(), Primary: true, }} } if err := token.Claims(&data.Metadata.CustomClaims); err != nil { return nil, nil, err } if data.Metadata.CustomClaims != nil { for _, claim := range removeAzureClaimsFromCustomClaims { delete(data.Metadata.CustomClaims, claim) } } return token, &data, nil } type KakaoIDTokenClaims struct { jwt.RegisteredClaims Email string `json:"email"` Nickname string `json:"nickname"` Picture string `json:"picture"` } func parseKakaoIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) { var claims KakaoIDTokenClaims if err := token.Claims(&claims); err != nil { return nil, nil, err } var data UserProvidedData if claims.Email != "" { data.Emails = append(data.Emails, Email{ Email: claims.Email, Verified: true, Primary: true, }) } data.Metadata = &Claims{ Issuer: token.Issuer, Subject: token.Subject, Name: claims.Nickname, PreferredUsername: claims.Nickname, ProviderId: token.Subject, Picture: claims.Picture, } return token, &data, nil } type VercelMarketplaceIDTokenClaims struct { jwt.RegisteredClaims UserEmail string `json:"user_email"` UserName string `json:"user_name"` UserAvatarUrl string `json:"user_avatar_url"` } func parseVercelMarketplaceIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) { var claims VercelMarketplaceIDTokenClaims if err := token.Claims(&claims); err != nil { return nil, nil, err } var data UserProvidedData data.Emails = append(data.Emails, Email{ Email: claims.UserEmail, Verified: true, Primary: true, }) data.Metadata = &Claims{ Issuer: token.Issuer, Subject: token.Subject, ProviderId: token.Subject, Name: claims.UserName, Picture: claims.UserAvatarUrl, } return token, &data, nil } func parseGenericIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) { var data UserProvidedData if err := token.Claims(&data.Metadata); err != nil { return nil, nil, err } if data.Metadata.Email != "" { data.Emails = append(data.Emails, Email{ Email: data.Metadata.Email, Verified: data.Metadata.EmailVerified, Primary: true, }) } if len(data.Emails) <= 0 { return nil, nil, fmt.Errorf("provider: Generic OIDC ID token from issuer %q must contain an email address", token.Issuer) } return token, &data, nil }