411 lines
9.8 KiB
Go
411 lines
9.8 KiB
Go
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
|
|
}
|