mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-27 22:27:23 +08:00
Rename identities table columns for more clarity. Rename parameters,
arguments etc. to accommodate these changes.
Change that the SAML provider domain is persisted in the identities
table as the provider ID. Use the SAML Entity ID/Issuer ID of the
IdP instead.
Introduce saml identity entity (including migrations and a persister)
as a specialization of an identity to allow for determining the
correct provider name to return to the client/frontend and for assisting
in determining whether an identity is a SAML identity (i.e. SAML
identities should have a corresponding SAML Identity instance while
OAuth/OIDC entities do not).
242 lines
7.1 KiB
Go
242 lines
7.1 KiB
Go
package thirdparty
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
|
"github.com/lestrrat-go/jwx/v2/jws"
|
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
"github.com/mitchellh/mapstructure"
|
|
"github.com/teamhanko/hanko/backend/config"
|
|
"golang.org/x/oauth2"
|
|
"net/mail"
|
|
"regexp"
|
|
)
|
|
|
|
const (
|
|
MicrosoftAuthBase = "https://login.microsoftonline.com/common"
|
|
MicrosoftKeysEndpoint = "https://login.microsoftonline.com/common/discovery/v2.0/keys"
|
|
MicrosoftOAuthAuthEndpoint = MicrosoftAuthBase + "/oauth2/v2.0/authorize"
|
|
MicrosoftOAuthTokenEndpoint = MicrosoftAuthBase + "/oauth2/v2.0/token"
|
|
)
|
|
|
|
var DefaultScopes = []string{
|
|
"openid",
|
|
"profile",
|
|
"email",
|
|
}
|
|
|
|
type microsoftProvider struct {
|
|
config config.ThirdPartyProvider
|
|
oauthConfig *oauth2.Config
|
|
}
|
|
|
|
type MicrosoftUser struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"displayName"`
|
|
Email string `json:"mail"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
UserPrincipalName string `json:"user_principal_name"`
|
|
}
|
|
|
|
// NewMicrosoftProvider creates a Microsoft third party provider.
|
|
func NewMicrosoftProvider(config config.ThirdPartyProvider, redirectURL string) (OAuthProvider, error) {
|
|
if !config.Enabled {
|
|
return nil, errors.New("microsoft provider is disabled")
|
|
}
|
|
|
|
return µsoftProvider{
|
|
config: config,
|
|
oauthConfig: &oauth2.Config{
|
|
ClientID: config.ClientID,
|
|
ClientSecret: config.Secret,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: MicrosoftOAuthAuthEndpoint,
|
|
TokenURL: MicrosoftOAuthTokenEndpoint,
|
|
},
|
|
Scopes: DefaultScopes,
|
|
RedirectURL: redirectURL,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (p microsoftProvider) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
|
return p.oauthConfig.AuthCodeURL(state, opts...)
|
|
}
|
|
|
|
func (p microsoftProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
|
|
return p.oauthConfig.Exchange(context.Background(), code)
|
|
}
|
|
|
|
func (p microsoftProvider) GetUserData(token *oauth2.Token) (*UserData, error) {
|
|
rawIDToken, ok := token.Extra("id_token").(string)
|
|
if !ok {
|
|
return nil, errors.New("id_token missing")
|
|
}
|
|
|
|
jwks, err := jwk.Fetch(context.Background(), MicrosoftKeysEndpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parsedIDToken, err := jwt.Parse(
|
|
[]byte(rawIDToken),
|
|
// JWKs of the JWKS (see 'MicrosoftKeysEndpoint') do not contain an 'alg' field. jws.WithKeySet expects this
|
|
// field to be present per default, hence usage of the extra option jws.WithInferAlgorithmFromKey.
|
|
// See the jwt.WithKeySet documentation.
|
|
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true)),
|
|
jwt.WithAudience(p.oauthConfig.ClientID),
|
|
jwt.WithValidator(p.issuerValidator()),
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not parse id token: %w", err)
|
|
}
|
|
|
|
if parsedIDToken == nil {
|
|
return nil, errors.New("could not parse id token")
|
|
}
|
|
|
|
idTokenClaims, err := p.getIdTokenClaims(parsedIDToken.PrivateClaims())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not extract claims from id token: %w", err)
|
|
}
|
|
|
|
if idTokenClaims == nil {
|
|
return nil, errors.New("id token claims must not be nil")
|
|
}
|
|
|
|
var email *Email
|
|
if idTokenClaims.UserPrincipalName != "" {
|
|
// Should be an email address, sanity check just to make sure.
|
|
if address, err := mail.ParseAddress(idTokenClaims.UserPrincipalName); err == nil {
|
|
email = &Email{
|
|
Email: address.Address,
|
|
// Assume it is verified because it looks like UPN suffixes cannot be set to unverified domains.
|
|
Verified: true,
|
|
Primary: true,
|
|
}
|
|
}
|
|
} else {
|
|
emailIsVerified, emailVerificationError := idTokenClaims.IsEmailVerified()
|
|
|
|
if emailVerificationError != nil {
|
|
return nil, emailVerificationError
|
|
}
|
|
|
|
if emailIsVerified {
|
|
email = &Email{
|
|
Email: idTokenClaims.Email,
|
|
Verified: true,
|
|
Primary: true,
|
|
}
|
|
} else {
|
|
email = &Email{
|
|
Email: idTokenClaims.Email,
|
|
Verified: false,
|
|
Primary: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
if email == nil {
|
|
return nil, errors.New("unable to find email with Microsoft provider")
|
|
}
|
|
|
|
data := &UserData{}
|
|
data.Emails = append(data.Emails, *email)
|
|
|
|
data.Metadata = &Claims{
|
|
Issuer: parsedIDToken.Issuer(),
|
|
Subject: parsedIDToken.Subject(),
|
|
Name: idTokenClaims.Name,
|
|
PreferredUsername: idTokenClaims.PreferredUsername,
|
|
Email: email.Email,
|
|
EmailVerified: email.Verified,
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (p microsoftProvider) ID() string {
|
|
return p.config.ID
|
|
}
|
|
|
|
func (p microsoftProvider) issuerValidator() jwt.ValidatorFunc {
|
|
var microsoftIssuerRegexp = regexp.MustCompile("^https://login[.]microsoftonline[.]com/([^/]+)/v2[.]0/?$")
|
|
validator := jwt.ValidatorFunc(func(_ context.Context, t jwt.Token) jwt.ValidationError {
|
|
if !microsoftIssuerRegexp.MatchString(t.Issuer()) {
|
|
return jwt.NewValidationError(fmt.Errorf(`%s is not a valid microsoft issuer`, t.Issuer()))
|
|
}
|
|
return nil
|
|
})
|
|
return validator
|
|
}
|
|
|
|
type microsoftIdTokenClaims struct {
|
|
Email string `mapstructure:"email"`
|
|
Name string `mapstructure:"name"`
|
|
PreferredUsername string `mapstructure:"preferred_username"`
|
|
UserPrincipalName string `mapstructure:"upn"`
|
|
XMicrosoftEmailDomainOwnerVerified any `mapstructure:"xms_edov"`
|
|
}
|
|
|
|
// IsEmailVerified checks if the email used is verified. Functionality mainly derived from Supabase's GoTrue fork
|
|
// See: https://github.com/supabase/gotrue/blob/master/internal/api/provider/oidc.go#L221
|
|
// See also: https://www.descope.com/blog/post/noauth
|
|
func (c *microsoftIdTokenClaims) IsEmailVerified() (bool, error) {
|
|
address, err := mail.ParseAddress(c.Email)
|
|
|
|
if err != nil {
|
|
return false, fmt.Errorf("could not parse email from email claim: %w", err)
|
|
}
|
|
|
|
if address == nil {
|
|
return false, errors.New("could not extract email from email claim")
|
|
}
|
|
|
|
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-tenant apps (which we do not support - only the multi-tenant
|
|
// + public account type).
|
|
emailVerified = true
|
|
} 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 = edovBool
|
|
}
|
|
|
|
return emailVerified, nil
|
|
}
|
|
|
|
func (p microsoftProvider) getIdTokenClaims(privateClaims map[string]interface{}) (*microsoftIdTokenClaims, error) {
|
|
var claims microsoftIdTokenClaims
|
|
err := mapstructure.Decode(privateClaims, &claims)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode claims: %w", err)
|
|
}
|
|
|
|
return &claims, nil
|
|
}
|