feat: add azure provider templates (#5441)

Adds possibility to manage and use Microsoft Azure template based providers
This commit is contained in:
Livio Spring
2023-03-15 07:48:37 +01:00
committed by GitHub
parent 93e1fe0056
commit 5a307afe62
30 changed files with 2843 additions and 60 deletions

View File

@@ -15,7 +15,7 @@ import (
const (
authURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize"
tokenURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/token"
userinfoURL string = "https://graph.microsoft.com/oidc/userinfo"
userinfoURL string = "https://graph.microsoft.com/v1.0/me"
)
// TenantType are the well known tenant types to scope the users that can authenticate. TenantType is not an
@@ -73,7 +73,7 @@ func WithOAuthOptions(opts ...oauth.ProviderOpts) ProviderOptions {
// New creates an AzureAD provider using the [oauth.Provider] (OAuth 2.0 generic provider).
// By default, it uses the [CommonTenant] and unverified emails.
func New(name, clientID, clientSecret, redirectURI string, opts ...ProviderOptions) (*Provider, error) {
func New(name, clientID, clientSecret, redirectURI string, scopes []string, opts ...ProviderOptions) (*Provider, error) {
provider := &Provider{
tenant: CommonTenant,
options: make([]oauth.ProviderOpts, 0),
@@ -81,7 +81,7 @@ func New(name, clientID, clientSecret, redirectURI string, opts ...ProviderOptio
for _, opt := range opts {
opt(provider)
}
config := newConfig(provider.tenant, clientID, clientSecret, redirectURI, []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail})
config := newConfig(provider.tenant, clientID, clientSecret, redirectURI, scopes)
rp, err := oauth.New(
config,
name,
@@ -121,34 +121,38 @@ func newConfig(tenant TenantType, clientID, secret, callbackURL string, scopes [
// AzureAD does not return an `email_verified` claim.
// The verification can be automatically activated on the provider ([WithEmailVerified])
type User struct {
Sub string `json:"sub"`
FamilyName string `json:"family_name"`
GivenName string `json:"given_name"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email domain.EmailAddress `json:"email"`
Picture string `json:"picture"`
ID string `json:"id"`
BusinessPhones []domain.PhoneNumber `json:"businessPhones"`
DisplayName string `json:"displayName"`
FirstName string `json:"givenName"`
JobTitle string `json:"jobTitle"`
Email domain.EmailAddress `json:"mail"`
MobilePhone domain.PhoneNumber `json:"mobilePhone"`
OfficeLocation string `json:"officeLocation"`
PreferredLanguage string `json:"preferredLanguage"`
LastName string `json:"surname"`
UserPrincipalName string `json:"userPrincipalName"`
isEmailVerified bool
}
// GetID is an implementation of the [idp.User] interface.
func (u *User) GetID() string {
return u.Sub
return u.ID
}
// GetFirstName is an implementation of the [idp.User] interface.
func (u *User) GetFirstName() string {
return u.GivenName
return u.FirstName
}
// GetLastName is an implementation of the [idp.User] interface.
func (u *User) GetLastName() string {
return u.FamilyName
return u.LastName
}
// GetDisplayName is an implementation of the [idp.User] interface.
func (u *User) GetDisplayName() string {
return u.Name
return u.DisplayName
}
// GetNickname is an implementation of the [idp.User] interface.
@@ -159,11 +163,16 @@ func (u *User) GetNickname() string {
// GetPreferredUsername is an implementation of the [idp.User] interface.
func (u *User) GetPreferredUsername() string {
return u.PreferredUsername
return u.UserPrincipalName
}
// GetEmail is an implementation of the [idp.User] interface.
func (u *User) GetEmail() domain.EmailAddress {
if u.Email == "" {
// if the user used a social login on Azure as well, the email will be empty
// but is used as username
return domain.EmailAddress(u.UserPrincipalName)
}
return u.Email
}
@@ -188,10 +197,8 @@ func (u *User) IsPhoneVerified() bool {
}
// GetPreferredLanguage is an implementation of the [idp.User] interface.
// It returns [language.Und] because AzureAD does not provide the user's language
func (u *User) GetPreferredLanguage() language.Tag {
// AzureAD does not provide the user's language
return language.Und
return language.Make(u.PreferredLanguage)
}
// GetProfile is an implementation of the [idp.User] interface.
@@ -202,5 +209,5 @@ func (u *User) GetProfile() string {
// GetAvatarURL is an implementation of the [idp.User] interface.
func (u *User) GetAvatarURL() string {
return u.Picture
return ""
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v2/pkg/client/rp"
openid "github.com/zitadel/oidc/v2/pkg/oidc"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/oauth"
@@ -19,6 +20,7 @@ func TestProvider_BeginAuth(t *testing.T) {
clientID string
clientSecret string
redirectURI string
scopes []string
options []ProviderOptions
}
tests := []struct {
@@ -34,7 +36,7 @@ func TestProvider_BeginAuth(t *testing.T) {
redirectURI: "redirectURI",
},
want: &oidc.Session{
AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=redirectURI&response_type=code&scope=openid+profile+email&state=testState",
AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState",
},
},
{
@@ -48,7 +50,22 @@ func TestProvider_BeginAuth(t *testing.T) {
},
},
want: &oidc.Session{
AuthURL: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=redirectURI&response_type=code&scope=openid+profile+email&state=testState",
AuthURL: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState",
},
},
{
name: "custom scopes",
fields: fields{
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
scopes: []string{openid.ScopeOpenID, openid.ScopeProfile, "user"},
options: []ProviderOptions{
WithTenant(ConsumersTenant),
},
},
want: &oidc.Session{
AuthURL: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=redirectURI&response_type=code&scope=openid+profile+user&state=testState",
},
},
}
@@ -57,7 +74,7 @@ func TestProvider_BeginAuth(t *testing.T) {
a := assert.New(t)
r := require.New(t)
provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.options...)
provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...)
r.NoError(err)
session, err := provider.BeginAuth(context.Background(), "testState")
@@ -74,6 +91,7 @@ func TestProvider_Options(t *testing.T) {
clientID string
clientSecret string
redirectURI string
scopes []string
options []ProviderOptions
}
type want struct {
@@ -98,6 +116,7 @@ func TestProvider_Options(t *testing.T) {
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
scopes: nil,
options: nil,
},
want: want{
@@ -146,7 +165,7 @@ func TestProvider_Options(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
a := assert.New(t)
provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.options...)
provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...)
require.NoError(t, err)
a.Equal(tt.want.name, provider.Name())

View File

@@ -25,6 +25,7 @@ func TestSession_FetchUser(t *testing.T) {
clientID string
clientSecret string
redirectURI string
scopes []string
httpMock func()
options []ProviderOptions
authURL string
@@ -61,7 +62,7 @@ func TestSession_FetchUser(t *testing.T) {
redirectURI: "redirectURI",
httpMock: func() {
gock.New("https://graph.microsoft.com").
Get("/oidc/userinfo").
Get("/v1.0/me").
Reply(200).
JSON(userinfo())
},
@@ -82,7 +83,7 @@ func TestSession_FetchUser(t *testing.T) {
redirectURI: "redirectURI",
httpMock: func() {
gock.New("https://graph.microsoft.com").
Get("/oidc/userinfo").
Get("/v1.0/me").
Reply(http.StatusInternalServerError)
},
authURL: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid+profile+email&state=testState",
@@ -119,7 +120,7 @@ func TestSession_FetchUser(t *testing.T) {
redirectURI: "redirectURI",
httpMock: func() {
gock.New("https://graph.microsoft.com").
Get("/oidc/userinfo").
Get("/v1.0/me").
Reply(200).
JSON(userinfo())
},
@@ -145,16 +146,20 @@ func TestSession_FetchUser(t *testing.T) {
},
want: want{
user: &User{
Sub: "sub",
FamilyName: "lastname",
GivenName: "firstname",
Name: "firstname lastname",
PreferredUsername: "username",
ID: "id",
BusinessPhones: []domain.PhoneNumber{"phone1", "phone2"},
DisplayName: "firstname lastname",
FirstName: "firstname",
JobTitle: "title",
Email: "email",
Picture: "picture",
MobilePhone: "mobile",
OfficeLocation: "office",
PreferredLanguage: "en",
LastName: "lastname",
UserPrincipalName: "username",
isEmailVerified: false,
},
id: "sub",
id: "id",
firstName: "firstname",
lastName: "lastname",
displayName: "firstname lastname",
@@ -164,8 +169,7 @@ func TestSession_FetchUser(t *testing.T) {
isEmailVerified: false,
phone: "",
isPhoneVerified: false,
preferredLanguage: language.Und,
avatarURL: "picture",
preferredLanguage: language.English,
profile: "",
},
},
@@ -180,7 +184,7 @@ func TestSession_FetchUser(t *testing.T) {
},
httpMock: func() {
gock.New("https://graph.microsoft.com").
Get("/oidc/userinfo").
Get("/v1.0/me").
Reply(200).
JSON(userinfo())
},
@@ -206,16 +210,20 @@ func TestSession_FetchUser(t *testing.T) {
},
want: want{
user: &User{
Sub: "sub",
FamilyName: "lastname",
GivenName: "firstname",
Name: "firstname lastname",
PreferredUsername: "username",
ID: "id",
BusinessPhones: []domain.PhoneNumber{"phone1", "phone2"},
DisplayName: "firstname lastname",
FirstName: "firstname",
JobTitle: "title",
Email: "email",
Picture: "picture",
MobilePhone: "mobile",
OfficeLocation: "office",
PreferredLanguage: "en",
LastName: "lastname",
UserPrincipalName: "username",
isEmailVerified: true,
},
id: "sub",
id: "id",
firstName: "firstname",
lastName: "lastname",
displayName: "firstname lastname",
@@ -225,8 +233,7 @@ func TestSession_FetchUser(t *testing.T) {
isEmailVerified: true,
phone: "",
isPhoneVerified: false,
preferredLanguage: language.Und,
avatarURL: "picture",
preferredLanguage: language.English,
profile: "",
},
},
@@ -237,7 +244,7 @@ func TestSession_FetchUser(t *testing.T) {
tt.fields.httpMock()
a := assert.New(t)
provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.options...)
provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...)
require.NoError(t, err)
session := &oauth.Session{
@@ -272,15 +279,18 @@ func TestSession_FetchUser(t *testing.T) {
}
}
func userinfo() oidc.UserInfoSetter {
userinfo := oidc.NewUserInfo()
userinfo.SetSubject("sub")
userinfo.SetName("firstname lastname")
userinfo.SetPreferredUsername("username")
userinfo.SetNickname("nickname")
userinfo.SetEmail("email", false) // azure add does not send the email_verified claim
userinfo.SetPicture("picture")
userinfo.SetGivenName("firstname")
userinfo.SetFamilyName("lastname")
return userinfo
func userinfo() *User {
return &User{
ID: "id",
BusinessPhones: []domain.PhoneNumber{"phone1", "phone2"},
DisplayName: "firstname lastname",
FirstName: "firstname",
JobTitle: "title",
Email: "email",
MobilePhone: "mobile",
OfficeLocation: "office",
PreferredLanguage: "en",
LastName: "lastname",
UserPrincipalName: "username",
}
}