Files
hanko/backend/thirdparty/provider_microsoft.go
Frederic Jahn 7fe0862369 PKCE OAuth flow (#2266)
* 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
2025-10-13 14:28:42 +02:00

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 &microsoftProvider{
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
}