Skip to content

Commit

Permalink
feat: use OIDC ID token for Azure
Browse files Browse the repository at this point in the history
  • Loading branch information
hf committed Oct 13, 2023
1 parent 71fb156 commit 608284c
Show file tree
Hide file tree
Showing 5 changed files with 317 additions and 47 deletions.
113 changes: 67 additions & 46 deletions internal/api/provider/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package provider

import (
"context"
"errors"
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"strings"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/supabase/gotrue/internal/conf"
"golang.org/x/oauth2"
)
Expand All @@ -13,19 +17,18 @@ const IssuerAzure = "https://login.microsoftonline.com/common/v2.0"

const (
defaultAzureAuthBase = "login.microsoftonline.com/common"
defaultAzureAPIBase = "graph.microsoft.com"
)

type azureProvider struct {
*oauth2.Config
APIPath string

ExpectedIssuer string
}

type azureUser struct {
Name string `json:"name"`
Email string `json:"email"`
Sub string `json:"sub"`
OtherMails []string `json:"otherMails"`
var azureIssuerRegexp = regexp.MustCompile("^https://login[.]microsoftonline[.]com/([^/]+)/v2[.]0/?$")

func IsAzureIssuer(issuer string) bool {
return azureIssuerRegexp.MatchString(issuer)
}

// NewAzureProvider creates a Azure account provider.
Expand All @@ -34,15 +37,19 @@ func NewAzureProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuth
return nil, err
}

authHost := chooseHost(ext.URL, defaultAzureAuthBase)
apiPath := chooseHost(ext.ApiURL, defaultAzureAPIBase)

oauthScopes := []string{"openid"}

if scopes != "" {
oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...)
}

authHost := chooseHost(ext.URL, defaultAzureAuthBase)
expectedIssuer := ""

if ext.URL != "" {
expectedIssuer = authHost + "/v2.0"
}

return &azureProvider{
Config: &oauth2.Config{
ClientID: ext.ClientID[0],
Expand All @@ -54,58 +61,72 @@ func NewAzureProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuth
RedirectURL: ext.RedirectURI,
Scopes: oauthScopes,
},
APIPath: apiPath,
ExpectedIssuer: expectedIssuer,
}, nil
}

func (g azureProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
return g.Exchange(context.Background(), code)
}

func (g azureProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
var u azureUser
if err := makeRequest(ctx, tok, g.Config, g.APIPath+"/oidc/userinfo", &u); err != nil {
return nil, err
func (g azureProvider) detectIDTokenIssuer(ctx context.Context, idToken string) (string, error) {
var payload struct {
Issuer string `json:"iss"`
}

var data UserProvidedData

data.Metadata = &Claims{
Issuer: g.APIPath,
Subject: u.Sub,
Name: u.Name,
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return "", fmt.Errorf("azure: invalid ID token")
}

// To be deprecated
FullName: u.Name,
ProviderId: u.Sub,
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("azure: invalid ID token %w", err)
}

if u.Email != "" {
data.Emails = append(data.Emails, Email{
Email: u.Email,
Verified: true,
})
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return "", fmt.Errorf("azure: invalid ID token %w", err)
}

if u.OtherMails != nil {
for _, mail := range u.OtherMails {
if mail != "" {
data.Emails = append(data.Emails, Email{
Email: mail,
Verified: false,
})
}
return payload.Issuer, nil
}

func (g azureProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
idToken := tok.Extra("id_token")
fmt.Printf("AZURE ID TOKEN %v\n", idToken)

if idToken != nil {
issuer, err := g.detectIDTokenIssuer(ctx, idToken.(string))
if err != nil {
return nil, err
}
}

if len(data.Emails) == 0 {
return nil, errors.New("unable to find email with Azure provider")
}
if !IsAzureIssuer(issuer) {
return nil, fmt.Errorf("azure: ID token issuer not valid %q", issuer)
}

if g.ExpectedIssuer != "" && issuer != g.ExpectedIssuer {
return nil, fmt.Errorf("azure: ID token issuer %q does not match expected issuer %q", issuer, g.ExpectedIssuer)
}

provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, err
}

data.Emails[0].Primary = true
_, data, err := ParseIDToken(ctx, provider, &oidc.Config{
ClientID: g.ClientID,
}, idToken.(string), ParseIDTokenOptions{
AccessToken: tok.AccessToken,
})
if err != nil {
return nil, err
}

return data, nil
}

data.Metadata.Email = data.Emails[0].Email
data.Metadata.EmailVerified = data.Emails[0].Verified
// Only ID tokens supported, UserInfo endpoint has a history of being less secure.

return &data, nil
return nil, fmt.Errorf("azure: no OIDC ID token present in response")
}
29 changes: 29 additions & 0 deletions internal/api/provider/azure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package provider

import "testing"

func TestIsAzureIssuer(t *testing.T) {
positiveExamples := []string{
"https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
"https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0/",
"https://login.microsoftonline.com/common/v2.0",
}

negativeExamples := []string{
"http://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
"https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0?something=else",
"https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0/extra",
}

for _, example := range positiveExamples {
if !IsAzureIssuer(example) {
t.Errorf("Example %q should be treated as a valid Azure issuer", example)
}
}

for _, example := range negativeExamples {
if IsAzureIssuer(example) {
t.Errorf("Example %q should be treated as not a valid Azure issuer", example)
}
}
}
127 changes: 126 additions & 1 deletion internal/api/provider/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ func ParseIDToken(ctx context.Context, provider *oidc.Provider, config *oidc.Con
case IssuerLinkedin:
token, data, err = parseLinkedinIDToken(token)
default:
token, data, err = parseGenericIDToken(token)
if IsAzureIssuer(token.Issuer) {
token, data, err = parseAzureIDToken(token)
} else {
token, data, err = parseGenericIDToken(token)
}
}

if err != nil {
Expand Down Expand Up @@ -200,6 +204,127 @@ func parseLinkedinIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData
return token, &data, nil
}

type AzureIDTokenClaims struct {
jwt.StandardClaims

Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
XMicrosoftEmailDomainOwnerVerified any `json:"xms_edov"`
}

func (c *AzureIDTokenClaims) IsEmailVerified() bool {
// 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
emailVerified := false

edov := c.XMicrosoftEmailDomainOwnerVerified

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 edov.(type) {
case bool:
edovBool = edov.(bool)

case string:
edovBool = edov.(string) == "1" || edov.(string) == "true"

case int:
edovBool = edov.(int) != 0

case int32:
edovBool = edov.(int32) != 0

case int64:
edovBool = edov.(int64) != 0

case float32:
edovBool = edov.(float32) != 0

case float64:
edovBool = edov.(float64) != 0
}

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,
Email: azureClaims.Email,
EmailVerified: azureClaims.IsEmailVerified(),
ProviderId: token.Subject,
PreferredUsername: azureClaims.PreferredUsername,
CustomClaims: make(map[string]any),
}

if data.Metadata.Email != "" {
data.Emails = append(data.Emails, Email{
Email: data.Metadata.Email,
Verified: data.Metadata.EmailVerified,
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)
}
}

if len(data.Emails) <= 0 {
return nil, nil, fmt.Errorf("provider: Azure OIDC ID token from issuer %q must contain an email address", token.Issuer)
}

return token, &data, nil
}

func parseGenericIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) {
var data UserProvidedData

Expand Down
Loading

0 comments on commit 608284c

Please sign in to comment.