feat: add basic structure of idp templates (#5053)

add basic structure and implement first providers for IDP templates to be able to manage and use them in the future
This commit is contained in:
Livio Spring
2023-01-23 08:11:40 +01:00
committed by GitHub
parent 7b5135e637
commit 598a4d2d4b
29 changed files with 3907 additions and 54 deletions

View File

@@ -0,0 +1,205 @@
package azuread
import (
"fmt"
"github.com/zitadel/oidc/v2/pkg/oidc"
"golang.org/x/oauth2"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/oauth"
)
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"
)
// TenantType are the well known tenant types to scope the users that can authenticate. TenantType is not an
// exclusive list of Azure Tenants which can be used. A consumer can also use their own Tenant ID to scope
// authentication to their specific Tenant either through the Tenant ID or the friendly domain name.
//
// see also https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints
type TenantType string
const (
// CommonTenant allows users with both personal Microsoft accounts and work/school accounts from Azure Active
// Directory to sign in to the application.
CommonTenant TenantType = "common"
// OrganizationsTenant allows only users with work/school accounts from Azure Active Directory to sign in to the application.
OrganizationsTenant TenantType = "organizations"
// ConsumersTenant allows only users with personal Microsoft accounts (MSA) to sign in to the application.
ConsumersTenant TenantType = "consumers"
)
var _ idp.Provider = (*Provider)(nil)
// Provider is the [idp.Provider] implementation for AzureAD (V2 Endpoints)
type Provider struct {
*oauth.Provider
tenant TenantType
emailVerified bool
options []oauth.ProviderOpts
}
type ProviderOptions func(*Provider)
// WithTenant allows to set a [TenantType] (can also be a Tenant ID)
// default is CommonTenant
func WithTenant(tenantType TenantType) ProviderOptions {
return func(p *Provider) {
p.tenant = tenantType
}
}
// WithEmailVerified allows to set every email received as verified
func WithEmailVerified() ProviderOptions {
return func(p *Provider) {
p.emailVerified = true
}
}
// WithOAuthOptions allows to specify [oauth.ProviderOpts] like [oauth.WithLinkingAllowed]
func WithOAuthOptions(opts ...oauth.ProviderOpts) ProviderOptions {
return func(p *Provider) {
p.options = append(p.options, opts...)
}
}
// 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) {
provider := &Provider{
tenant: CommonTenant,
options: make([]oauth.ProviderOpts, 0),
}
for _, opt := range opts {
opt(provider)
}
config := newConfig(provider.tenant, clientID, clientSecret, redirectURI, []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail})
rp, err := oauth.New(
config,
name,
userinfoURL,
func() idp.User {
return &User{isEmailVerified: provider.emailVerified}
},
provider.options...,
)
if err != nil {
return nil, err
}
provider.Provider = rp
return provider, nil
}
func newConfig(tenant TenantType, clientID, secret, callbackURL string, scopes []string) *oauth2.Config {
c := &oauth2.Config{
ClientID: clientID,
ClientSecret: secret,
RedirectURL: callbackURL,
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf(authURLTemplate, tenant),
TokenURL: fmt.Sprintf(tokenURLTemplate, tenant),
},
Scopes: []string{oidc.ScopeOpenID},
}
if len(scopes) > 0 {
c.Scopes = scopes
}
return c
}
// User represents the structure return on the userinfo endpoint and implements the [idp.User] interface
//
// 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 string `json:"email"`
Picture string `json:"picture"`
isEmailVerified bool
}
// GetID is an implementation of the [idp.User] interface.
func (u *User) GetID() string {
return u.Sub
}
// GetFirstName is an implementation of the [idp.User] interface.
func (u *User) GetFirstName() string {
return u.GivenName
}
// GetLastName is an implementation of the [idp.User] interface.
func (u *User) GetLastName() string {
return u.FamilyName
}
// GetDisplayName is an implementation of the [idp.User] interface.
func (u *User) GetDisplayName() string {
return u.Name
}
// GetNickname is an implementation of the [idp.User] interface.
// It returns an empty string because AzureAD does not provide the user's nickname.
func (u *User) GetNickname() string {
return ""
}
// GetPreferredUsername is an implementation of the [idp.User] interface.
func (u *User) GetPreferredUsername() string {
return u.PreferredUsername
}
// GetEmail is an implementation of the [idp.User] interface.
func (u *User) GetEmail() string {
return u.Email
}
// IsEmailVerified is an implementation of the [idp.User] interface
// returning the value specified in the creation of the [Provider].
// Default is false because AzureAD does not return an `email_verified` claim.
// The verification can be automatically activated on the provider ([WithEmailVerified]).
func (u *User) IsEmailVerified() bool {
return u.isEmailVerified
}
// GetPhone is an implementation of the [idp.User] interface.
// It returns an empty string because AzureAD does not provide the user's phone.
func (u *User) GetPhone() string {
return ""
}
// IsPhoneVerified is an implementation of the [idp.User] interface.
// It returns false because AzureAD does not provide the user's phone.
func (u *User) IsPhoneVerified() bool {
return false
}
// 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
}
// GetProfile is an implementation of the [idp.User] interface.
// It returns an empty string because AzureAD does not provide the user's profile page.
func (u *User) GetProfile() string {
return ""
}
// GetAvatarURL is an implementation of the [idp.User] interface.
func (u *User) GetAvatarURL() string {
return u.Picture
}

View File

@@ -0,0 +1,162 @@
package azuread
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v2/pkg/client/rp"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/oauth"
"github.com/zitadel/zitadel/internal/idp/providers/oidc"
)
func TestProvider_BeginAuth(t *testing.T) {
type fields struct {
name string
clientID string
clientSecret string
redirectURI string
options []ProviderOptions
}
tests := []struct {
name string
fields fields
want idp.Session
}{
{
name: "default common tenant",
fields: fields{
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
},
want: &oidc.Session{
AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid+profile+email&state=testState",
},
},
{
name: "tenant",
fields: fields{
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
options: []ProviderOptions{
WithTenant(ConsumersTenant),
},
},
want: &oidc.Session{
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",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(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...)
r.NoError(err)
session, err := provider.BeginAuth(context.Background(), "testState")
r.NoError(err)
a.Equal(tt.want.GetAuthURL(), session.GetAuthURL())
})
}
}
func TestProvider_Options(t *testing.T) {
type fields struct {
name string
clientID string
clientSecret string
redirectURI string
options []ProviderOptions
}
type want struct {
name string
tenant TenantType
emailVerified bool
linkingAllowed bool
creationAllowed bool
autoCreation bool
autoUpdate bool
pkce bool
}
tests := []struct {
name string
fields fields
want want
}{
{
name: "default common tenant",
fields: fields{
name: "default common tenant",
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
options: nil,
},
want: want{
name: "default common tenant",
tenant: CommonTenant,
emailVerified: false,
linkingAllowed: false,
creationAllowed: false,
autoCreation: false,
autoUpdate: false,
pkce: false,
},
},
{
name: "all set",
fields: fields{
name: "custom tenant",
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
options: []ProviderOptions{
WithTenant("tenant"),
WithEmailVerified(),
WithOAuthOptions(
oauth.WithLinkingAllowed(),
oauth.WithCreationAllowed(),
oauth.WithAutoCreation(),
oauth.WithAutoUpdate(),
oauth.WithRelyingPartyOption(rp.WithPKCE(nil)),
),
},
},
want: want{
name: "custom tenant",
tenant: "tenant",
emailVerified: true,
linkingAllowed: true,
creationAllowed: true,
autoCreation: true,
autoUpdate: true,
pkce: true,
},
},
}
for _, tt := range tests {
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...)
require.NoError(t, err)
a.Equal(tt.want.name, provider.Name())
a.Equal(tt.want.tenant, provider.tenant)
a.Equal(tt.want.emailVerified, provider.emailVerified)
a.Equal(tt.want.linkingAllowed, provider.IsLinkingAllowed())
a.Equal(tt.want.creationAllowed, provider.IsCreationAllowed())
a.Equal(tt.want.autoCreation, provider.IsAutoCreation())
a.Equal(tt.want.autoUpdate, provider.IsAutoUpdate())
a.Equal(tt.want.pkce, provider.RelyingParty.IsPKCE())
})
}
}

View File

@@ -0,0 +1,285 @@
package azuread
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/h2non/gock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v2/pkg/oidc"
"golang.org/x/oauth2"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/oauth"
)
func TestSession_FetchUser(t *testing.T) {
type fields struct {
name string
clientID string
clientSecret string
redirectURI string
httpMock func()
options []ProviderOptions
authURL string
code string
tokens *oidc.Tokens
}
type want struct {
err func(error) bool
user idp.User
id string
firstName string
lastName string
displayName string
nickName string
preferredUsername string
email string
isEmailVerified bool
phone string
isPhoneVerified bool
preferredLanguage language.Tag
avatarURL string
profile string
}
tests := []struct {
name string
fields fields
want want
}{
{
name: "unauthenticated session, error",
fields: fields{
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
httpMock: func() {
gock.New("https://graph.microsoft.com").
Get("/oidc/userinfo").
Reply(200).
JSON(userinfo())
},
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",
tokens: nil,
},
want: want{
err: func(err error) bool {
return errors.Is(err, oauth.ErrCodeMissing)
},
},
},
{
name: "user error",
fields: fields{
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
httpMock: func() {
gock.New("https://graph.microsoft.com").
Get("/oidc/userinfo").
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",
tokens: &oidc.Tokens{
Token: &oauth2.Token{
AccessToken: "accessToken",
TokenType: oidc.BearerToken,
},
IDTokenClaims: oidc.NewIDTokenClaims(
"https://login.microsoftonline.com/consumers/oauth2/v2.0",
"sub2",
[]string{"clientID"},
time.Now().Add(1*time.Hour),
time.Now().Add(-1*time.Second),
"nonce",
"",
nil,
"clientID",
0,
),
},
},
want: want{
err: func(err error) bool {
return err.Error() == "http status not ok: 500 Internal Server Error "
},
},
},
{
name: "successful fetch",
fields: fields{
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
httpMock: func() {
gock.New("https://graph.microsoft.com").
Get("/oidc/userinfo").
Reply(200).
JSON(userinfo())
},
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",
tokens: &oidc.Tokens{
Token: &oauth2.Token{
AccessToken: "accessToken",
TokenType: oidc.BearerToken,
},
IDTokenClaims: oidc.NewIDTokenClaims(
"https://login.microsoftonline.com/consumers/oauth2/v2.0",
"sub",
[]string{"clientID"},
time.Now().Add(1*time.Hour),
time.Now().Add(-1*time.Second),
"nonce",
"",
nil,
"clientID",
0,
),
},
},
want: want{
user: &User{
Sub: "sub",
FamilyName: "lastname",
GivenName: "firstname",
Name: "firstname lastname",
PreferredUsername: "username",
Email: "email",
Picture: "picture",
isEmailVerified: false,
},
id: "sub",
firstName: "firstname",
lastName: "lastname",
displayName: "firstname lastname",
nickName: "",
preferredUsername: "username",
email: "email",
isEmailVerified: false,
phone: "",
isPhoneVerified: false,
preferredLanguage: language.Und,
avatarURL: "picture",
profile: "",
},
},
{
name: "successful fetch with email verified",
fields: fields{
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
options: []ProviderOptions{
WithEmailVerified(),
},
httpMock: func() {
gock.New("https://graph.microsoft.com").
Get("/oidc/userinfo").
Reply(200).
JSON(userinfo())
},
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",
tokens: &oidc.Tokens{
Token: &oauth2.Token{
AccessToken: "accessToken",
TokenType: oidc.BearerToken,
},
IDTokenClaims: oidc.NewIDTokenClaims(
"https://login.microsoftonline.com/consumers/oauth2/v2.0",
"sub",
[]string{"clientID"},
time.Now().Add(1*time.Hour),
time.Now().Add(-1*time.Second),
"nonce",
"",
nil,
"clientID",
0,
),
},
},
want: want{
user: &User{
Sub: "sub",
FamilyName: "lastname",
GivenName: "firstname",
Name: "firstname lastname",
PreferredUsername: "username",
Email: "email",
Picture: "picture",
isEmailVerified: true,
},
id: "sub",
firstName: "firstname",
lastName: "lastname",
displayName: "firstname lastname",
nickName: "",
preferredUsername: "username",
email: "email",
isEmailVerified: true,
phone: "",
isPhoneVerified: false,
preferredLanguage: language.Und,
avatarURL: "picture",
profile: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer gock.Off()
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...)
require.NoError(t, err)
session := &oauth.Session{
AuthURL: tt.fields.authURL,
Code: tt.fields.code,
Tokens: tt.fields.tokens,
Provider: provider.Provider,
}
user, err := session.FetchUser(context.Background())
if tt.want.err != nil && !tt.want.err(err) {
a.Fail("invalid error", err)
}
if tt.want.err == nil {
a.NoError(err)
a.Equal(tt.want.user, user)
a.Equal(tt.want.id, user.GetID())
a.Equal(tt.want.firstName, user.GetFirstName())
a.Equal(tt.want.lastName, user.GetLastName())
a.Equal(tt.want.displayName, user.GetDisplayName())
a.Equal(tt.want.nickName, user.GetNickname())
a.Equal(tt.want.preferredUsername, user.GetPreferredUsername())
a.Equal(tt.want.email, user.GetEmail())
a.Equal(tt.want.isEmailVerified, user.IsEmailVerified())
a.Equal(tt.want.phone, user.GetPhone())
a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified())
a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage())
a.Equal(tt.want.avatarURL, user.GetAvatarURL())
a.Equal(tt.want.profile, user.GetProfile())
}
})
}
}
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
}