mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-28 06:37:57 +08:00
* feat: add auth prompt config option * feat: add pkce oauth flow When the oauth flow is initialized with a `code_verifier` the state cookie is optional and on hanko_token exchange the client must also send the `code_verifier` in addition to the `hanko_token`. * fix: fix runtime errors & tests
249 lines
7.3 KiB
Go
249 lines
7.3 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/v2/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, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
|
return p.oauthConfig.Exchange(context.Background(), code, opts...)
|
|
}
|
|
|
|
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) GetPromptParam() string {
|
|
if p.config.Prompt != "" {
|
|
return p.config.Prompt
|
|
}
|
|
return "consent"
|
|
}
|
|
|
|
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
|
|
}
|