feat: extend start/retrieve idp intent for Zitadel provider (#12424)

# Which Problems Are Solved

This PR extends `StartIdentityProviderIntent` and
`RetrieveIdentityProviderIntent` for the new Zitadel provider.

# How the Problems Are Solved
- Added `domain.IDPTypeZitadel` cases to both the instance and org
switches in `NewAllIDPWriteModel` to return Zitadel provider when
`GetProvider` is called
- Added Zitadel provider to RetrieveIdentityProviderIntent's provider
type-switch
- Added new `zitadel.go` provider, an OIDC wrapper with forced PKCE
- Added unit/integration tests

# Additional Changes


# Additional Context
- Closes  https://github.com/zitadel/zitadel/issues/12050

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
This commit is contained in:
gayathri
2026-07-17 13:24:18 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 53d7920349
commit b91221143b
8 changed files with 755 additions and 0 deletions
@@ -324,6 +324,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
oauthIdpID := Instance.AddGenericOAuthProvider(IamCTX, integration.IDPName()).GetId()
azureIdpID := Instance.AddAzureADProvider(IamCTX, integration.IDPName()).GetId()
oidcIdpID := Instance.AddGenericOIDCProvider(IamCTX, integration.IDPName()).GetId()
zitadelIdpID := Instance.AddZitadelProvider(IamCTX, integration.IDPName()).GetId()
samlIdpID := Instance.AddSAMLPostProvider(IamCTX)
ldapIdpID := Instance.AddLDAPProvider(IamCTX)
jwtIdPID := Instance.AddJWTProvider(IamCTX)
@@ -360,6 +361,12 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
oidcSuccessfulWithUserID, oidcWithUserIDToken, oidcWithUserIDChangeDate, oidcWithUserIDSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), oidcIdpID, "id", "user", expiry)
require.NoError(t, err)
// the ZITADEL provider reuses the OIDC user mapper
zitadelSuccessful, zitadelToken, zitadelChangeDate, zitadelSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), zitadelIdpID, "id", "", expiry)
require.NoError(t, err)
zitadelSuccessfulWithUserID, zitadelWithUserIDToken, zitadelWithUserIDChangeDate, zitadelWithUserIDSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), zitadelIdpID, "id", "user", expiry)
require.NoError(t, err)
ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), ldapIdpID, "id", "")
require.NoError(t, err)
ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), ldapIdpID, "id", "user")
@@ -841,6 +848,134 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
},
wantErr: false,
},
{
name: "retrieve successful zitadel intent",
args: args{
OrgCTX,
&user.RetrieveIdentityProviderIntentRequest{
IdpIntentId: zitadelSuccessful,
IdpIntentToken: zitadelToken,
},
},
want: &user.RetrieveIdentityProviderIntentResponse{
Details: &object.Details{
ChangeDate: timestamppb.New(zitadelChangeDate),
ResourceOwner: Instance.ID(),
Sequence: zitadelSequence,
},
UserId: "",
IdpInformation: &user.IDPInformation{
Access: &user.IDPInformation_Oauth{
Oauth: &user.IDPOAuthAccessInformation{
AccessToken: "accessToken",
RefreshToken: gu.Ptr("refreshToken"),
IdToken: gu.Ptr("idToken"),
},
},
IdpId: zitadelIdpID,
UserId: "id",
UserName: "username",
RawInformation: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"sub": "id",
"preferred_username": "username",
})
require.NoError(t, err)
return s
}(),
},
AddHumanUser: &user.AddHumanUserRequest{
Username: gu.Ptr("username"),
Profile: &user.SetHumanProfile{
PreferredLanguage: gu.Ptr("und"),
},
IdpLinks: []*user.IDPLink{
{IdpId: zitadelIdpID, UserId: "id", UserName: "username"},
},
Email: &user.SetHumanEmail{
Verification: &user.SetHumanEmail_SendCode{SendCode: &user.SendEmailVerificationCode{}},
},
},
UserAction: &user.RetrieveIdentityProviderIntentResponse_CreateUser{
CreateUser: &user.CreateUserRequest{
Username: gu.Ptr("username"),
UserType: &user.CreateUserRequest_Human_{
Human: &user.CreateUserRequest_Human{
Profile: &user.SetHumanProfile{
PreferredLanguage: gu.Ptr("und"),
},
IdpLinks: []*user.IDPLink{
{IdpId: zitadelIdpID, UserId: "id", UserName: "username"},
},
Email: &user.SetHumanEmail{
Verification: &user.SetHumanEmail_SendCode{SendCode: &user.SendEmailVerificationCode{}},
},
},
},
},
},
},
wantErr: false,
},
{
name: "retrieve successful zitadel intent with linked user",
args: args{
OrgCTX,
&user.RetrieveIdentityProviderIntentRequest{
IdpIntentId: zitadelSuccessfulWithUserID,
IdpIntentToken: zitadelWithUserIDToken,
},
},
want: &user.RetrieveIdentityProviderIntentResponse{
Details: &object.Details{
ChangeDate: timestamppb.New(zitadelWithUserIDChangeDate),
ResourceOwner: Instance.ID(),
Sequence: zitadelWithUserIDSequence,
},
UserId: "user",
IdpInformation: &user.IDPInformation{
Access: &user.IDPInformation_Oauth{
Oauth: &user.IDPOAuthAccessInformation{
AccessToken: "accessToken",
RefreshToken: gu.Ptr("refreshToken"),
IdToken: gu.Ptr("idToken"),
},
},
IdpId: zitadelIdpID,
UserId: "id",
UserName: "username",
RawInformation: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"sub": "id",
"preferred_username": "username",
})
require.NoError(t, err)
return s
}(),
},
UpdateHumanUser: &user.UpdateHumanUserRequest{
Username: gu.Ptr("username"),
UserId: "user",
Profile: &user.SetHumanProfile{
PreferredLanguage: gu.Ptr("und"),
},
},
UserAction: &user.RetrieveIdentityProviderIntentResponse_UpdateUser{
UpdateUser: &user.UpdateUserRequest{
Username: gu.Ptr("username"),
UserId: "user",
UserType: &user.UpdateUserRequest_Human_{
Human: &user.UpdateUserRequest_Human{
Profile: &user.UpdateUserRequest_Human_Profile{
PreferredLanguage: gu.Ptr("und"),
},
},
},
},
},
},
wantErr: false,
},
{
name: "retrieve successful ldap intent",
args: args{
+3
View File
@@ -27,6 +27,7 @@ import (
"github.com/zitadel/zitadel/internal/idp/providers/oauth"
"github.com/zitadel/zitadel/internal/idp/providers/oidc"
"github.com/zitadel/zitadel/internal/idp/providers/saml"
"github.com/zitadel/zitadel/internal/idp/providers/zitadel"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2"
@@ -181,6 +182,8 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *connec
idpUser, err = unmarshalRawIdpUser(intent.IDPUser, p.User())
case *oidc.Provider:
idpUser, err = unmarshalIdpUser(intent.IDPUser, oidc.InitUser())
case *zitadel.Provider:
idpUser, err = unmarshalIdpUser(intent.IDPUser, oidc.InitUser())
case *jwt.Provider:
idpUser, err = unmarshalIdpUser(intent.IDPUser, jwt.InitUser())
case *azuread.Provider:
@@ -2213,6 +2213,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) {
func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
oauthIdpID := Instance.AddGenericOAuthProvider(IamCTX, integration.IDPName()).GetId()
oidcIdpID := Instance.AddGenericOIDCProvider(IamCTX, integration.IDPName()).GetId()
zitadelIdpID := Instance.AddZitadelProvider(IamCTX, integration.IDPName()).GetId()
samlIdpID := Instance.AddSAMLPostProvider(IamCTX)
ldapIdpID := Instance.AddLDAPProvider(IamCTX)
authURL, err := url.Parse(Instance.CreateIntent(CTX, oauthIdpID).GetAuthUrl())
@@ -2241,6 +2242,11 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
require.NoError(t, err)
oidcSuccessfulWithUserID, oidcWithUserIDToken, oidcWithUserIDChangeDate, oidcWithUserIDSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), oidcIdpID, "id", "user", expiry)
require.NoError(t, err)
// the ZITADEL provider reuses the OIDC user mapper
zitadelSuccessful, zitadelToken, zitadelChangeDate, zitadelSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), zitadelIdpID, "id", "", expiry)
require.NoError(t, err)
zitadelSuccessfulWithUserID, zitadelWithUserIDToken, zitadelWithUserIDChangeDate, zitadelWithUserIDSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), zitadelIdpID, "id", "user", expiry)
require.NoError(t, err)
ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), ldapIdpID, "id", "")
require.NoError(t, err)
ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), ldapIdpID, "id", "user")
@@ -2462,6 +2468,84 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
},
wantErr: false,
},
{
name: "retrieve successful zitadel intent",
args: args{
CTX,
&user.RetrieveIdentityProviderIntentRequest{
IdpIntentId: zitadelSuccessful,
IdpIntentToken: zitadelToken,
},
},
want: &user.RetrieveIdentityProviderIntentResponse{
Details: &object.Details{
ChangeDate: timestamppb.New(zitadelChangeDate),
ResourceOwner: Instance.ID(),
Sequence: zitadelSequence,
},
UserId: "",
IdpInformation: &user.IDPInformation{
Access: &user.IDPInformation_Oauth{
Oauth: &user.IDPOAuthAccessInformation{
AccessToken: "accessToken",
RefreshToken: gu.Ptr("refreshToken"),
IdToken: gu.Ptr("idToken"),
},
},
IdpId: zitadelIdpID,
UserId: "id",
UserName: "username",
RawInformation: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"sub": "id",
"preferred_username": "username",
})
require.NoError(t, err)
return s
}(),
},
},
wantErr: false,
},
{
name: "retrieve successful zitadel intent with linked user",
args: args{
CTX,
&user.RetrieveIdentityProviderIntentRequest{
IdpIntentId: zitadelSuccessfulWithUserID,
IdpIntentToken: zitadelWithUserIDToken,
},
},
want: &user.RetrieveIdentityProviderIntentResponse{
Details: &object.Details{
ChangeDate: timestamppb.New(zitadelWithUserIDChangeDate),
ResourceOwner: Instance.ID(),
Sequence: zitadelWithUserIDSequence,
},
UserId: "user",
IdpInformation: &user.IDPInformation{
Access: &user.IDPInformation_Oauth{
Oauth: &user.IDPOAuthAccessInformation{
AccessToken: "accessToken",
RefreshToken: gu.Ptr("refreshToken"),
IdToken: gu.Ptr("idToken"),
},
},
IdpId: zitadelIdpID,
UserId: "id",
UserName: "username",
RawInformation: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"sub": "id",
"preferred_username": "username",
})
require.NoError(t, err)
return s
}(),
},
},
wantErr: false,
},
{
name: "retrieve successful ldap intent",
args: args{
+39
View File
@@ -26,6 +26,7 @@ import (
"github.com/zitadel/zitadel/internal/idp/providers/oidc"
saml2 "github.com/zitadel/zitadel/internal/idp/providers/saml"
"github.com/zitadel/zitadel/internal/idp/providers/saml/requesttracker"
"github.com/zitadel/zitadel/internal/idp/providers/zitadel"
"github.com/zitadel/zitadel/internal/repository/idp"
"github.com/zitadel/zitadel/internal/repository/idpconfig"
"github.com/zitadel/zitadel/internal/repository/instance"
@@ -2279,6 +2280,8 @@ func NewAllIDPWriteModel(resourceOwner string, instanceBool bool, id string, idp
writeModel.model = NewGoogleInstanceIDPWriteModel(resourceOwner, id)
case domain.IDPTypeApple:
writeModel.model = NewAppleInstanceIDPWriteModel(resourceOwner, id)
case domain.IDPTypeZitadel:
writeModel.model = NewInstanceZitadelIDPWriteModel(resourceOwner, id)
case domain.IDPTypeSAML:
writeModel.samlModel = NewSAMLInstanceIDPWriteModel(resourceOwner, id)
case domain.IDPTypeUnspecified:
@@ -2310,6 +2313,8 @@ func NewAllIDPWriteModel(resourceOwner string, instanceBool bool, id string, idp
writeModel.model = NewGoogleOrgIDPWriteModel(resourceOwner, id)
case domain.IDPTypeApple:
writeModel.model = NewAppleOrgIDPWriteModel(resourceOwner, id)
case domain.IDPTypeZitadel:
writeModel.model = NewZitadelOrgIDPWriteModel(resourceOwner, id)
case domain.IDPTypeSAML:
writeModel.samlModel = NewSAMLOrgIDPWriteModel(resourceOwner, id)
case domain.IDPTypeUnspecified:
@@ -2481,3 +2486,37 @@ func (wm *ZitadelIDPWriteModel) NewChanges(
}
return changes, nil
}
func (wm *ZitadelIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) {
secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg)
if err != nil {
return nil, err
}
opts := make([]oidc.ProviderOpts, 1, 7)
opts[0] = oidc.WithSelectAccount()
if wm.IsCreationAllowed {
opts = append(opts, oidc.WithCreationAllowed())
}
if wm.IsLinkingAllowed {
opts = append(opts, oidc.WithLinkingAllowed())
}
if wm.IsAutoCreation {
opts = append(opts, oidc.WithAutoCreation())
}
if wm.IsAutoUpdate {
opts = append(opts, oidc.WithAutoUpdate())
}
return zitadel.New(
wm.Issuer,
wm.ClientID,
secret,
callbackURL,
wm.Scopes,
httpClient,
opts...,
)
}
func (wm *ZitadelIDPWriteModel) GetProviderOptions() idp.Options {
return wm.Options
}
+69
View File
@@ -17,6 +17,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
providers "github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/oauth"
"github.com/zitadel/zitadel/internal/idp/providers/zitadel"
"github.com/zitadel/zitadel/internal/zerrors"
)
@@ -167,6 +168,19 @@ func TestCommands_AllIDPWriteModel(t *testing.T) {
err: nil,
},
},
{
name: "writemodel instance zitadel",
args: args{
resourceOwner: "owner",
instanceBool: true,
id: "id",
idpType: domain.IDPTypeZitadel,
},
res: res{
writeModelType: &InstanceZitadelIDPWriteModel{},
err: nil,
},
},
{
name: "writemodel instance saml",
args: args{
@@ -322,6 +336,19 @@ func TestCommands_AllIDPWriteModel(t *testing.T) {
err: nil,
},
},
{
name: "writemodel org zitadel",
args: args{
resourceOwner: "owner",
instanceBool: false,
id: "id",
idpType: domain.IDPTypeZitadel,
},
res: res{
writeModelType: &OrgZitadelIDPWriteModel{},
err: nil,
},
},
{
name: "writemodel org saml",
args: args{
@@ -424,6 +451,48 @@ func TestOIDCIDPWriteModel_ToProvider_WithPKCE(t *testing.T) {
assertProviderUsesPKCE(t, provider)
}
func TestZitadelIDPWriteModel_ToProvider(t *testing.T) {
var (
issuer string
discoveryRequested atomic.Bool
)
handler := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != oidc_pkg.DiscoveryEndpoint {
http.NotFound(w, r)
return
}
discoveryRequested.Store(true)
w.Header().Set("Content-Type", "application/json")
// assert, not require: FailNow must not be called from the server's handler goroutine
assert.NoError(t, json.NewEncoder(w).Encode(&oidc_pkg.DiscoveryConfiguration{
Issuer: issuer,
AuthorizationEndpoint: issuer + "/authorize",
TokenEndpoint: issuer + "/token",
UserinfoEndpoint: issuer + "/userinfo",
}))
}
server := httptest.NewServer(http.HandlerFunc(handler))
t.Cleanup(server.Close)
issuer = server.URL
wm := &ZitadelIDPWriteModel{
Name: "ZITADEL",
Issuer: issuer,
ClientID: "clientID",
ClientSecret: &crypto.CryptoValue{Algorithm: "plain", Crypted: []byte("clientSecret"), KeyID: "keyID"},
Scopes: []string{"openid"},
}
provider, err := wm.ToProvider("https://zitadel.example.com/idps/callback", plainTextEncryption{}, http.DefaultClient)
require.NoError(t, err)
require.IsType(t, &zitadel.Provider{}, provider)
require.True(t, discoveryRequested.Load(), "expected OIDC discovery to be requested")
// the ZITADEL provider always enforces PKCE
assertProviderUsesPKCE(t, provider)
}
func assertProviderUsesPKCE(t *testing.T, provider providers.Provider) {
t.Helper()
@@ -0,0 +1,262 @@
package zitadel
import (
"context"
"encoding/json"
"errors"
"net/http"
"testing"
"time"
"github.com/h2non/gock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/rp"
openid "github.com/zitadel/oidc/v3/pkg/oidc"
"golang.org/x/oauth2"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/idp/providers/oidc"
)
func TestSession_FetchUser(t *testing.T) {
type fields struct {
httpMock func()
authURL string
code string
tokens *openid.Tokens[*openid.IDTokenClaims]
}
type want struct {
err error
id string
firstName string
lastName string
displayName string
nickName string
preferredUsername string
email string
isEmailVerified bool
phone string
isPhoneVerified bool
preferredLanguage language.Tag
rawClaims map[string]any
}
const rolesClaim = "urn:zitadel:iam:org:project:roles"
projectRoles := map[string]any{
"admin": map[string]any{"orgID": "orgDomain"},
"viewer": map[string]any{"orgID": "orgDomain"},
}
tests := []struct {
name string
fields fields
want want
}{
{
name: "unauthenticated session, error",
fields: fields{
httpMock: func() {
discoveryMock(issuer)
},
authURL: issuer + "/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState",
tokens: nil,
},
want: want{
err: oidc.ErrCodeMissing,
},
},
{
name: "userinfo error, subject not matching",
fields: fields{
httpMock: func() {
discoveryMock(issuer)
gock.New(issuer).
Get("/userinfo").
Reply(200).
JSON(userinfo())
},
authURL: issuer + "/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState",
tokens: &openid.Tokens[*openid.IDTokenClaims]{
Token: &oauth2.Token{
AccessToken: "accessToken",
TokenType: openid.BearerToken,
},
IDTokenClaims: openid.NewIDTokenClaims(
issuer,
"sub2",
[]string{"clientID"},
time.Now().Add(1*time.Hour),
time.Now().Add(-1*time.Second),
"nonce",
"",
nil,
"clientID",
0,
),
},
},
want: want{
err: rp.ErrUserInfoSubNotMatching,
},
},
{
name: "successful fetch",
fields: fields{
httpMock: func() {
discoveryMock(issuer)
gock.New(issuer).
Get("/userinfo").
Reply(200).
JSON(userinfo())
},
authURL: issuer + "/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState",
tokens: &openid.Tokens[*openid.IDTokenClaims]{
Token: &oauth2.Token{
AccessToken: "accessToken",
TokenType: openid.BearerToken,
},
IDTokenClaims: openid.NewIDTokenClaims(
issuer,
"sub",
[]string{"clientID"},
time.Now().Add(1*time.Hour),
time.Now().Add(-1*time.Second),
"nonce",
"",
nil,
"clientID",
0,
),
},
},
want: want{
id: "sub",
firstName: "firstname",
lastName: "lastname",
displayName: "firstname lastname",
nickName: "nickname",
preferredUsername: "username",
email: "email",
isEmailVerified: true,
phone: "phone",
isPhoneVerified: true,
preferredLanguage: language.English,
},
},
{
name: "successful fetch with project roles",
fields: fields{
httpMock: func() {
discoveryMock(issuer)
info := userinfo()
info.AppendClaims(rolesClaim, projectRoles)
gock.New(issuer).
Get("/userinfo").
Reply(200).
JSON(info)
},
authURL: issuer + "/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState",
tokens: &openid.Tokens[*openid.IDTokenClaims]{
Token: &oauth2.Token{
AccessToken: "accessToken",
TokenType: openid.BearerToken,
},
IDTokenClaims: openid.NewIDTokenClaims(
issuer,
"sub",
[]string{"clientID"},
time.Now().Add(1*time.Hour),
time.Now().Add(-1*time.Second),
"nonce",
"",
nil,
"clientID",
0,
),
},
},
want: want{
id: "sub",
firstName: "firstname",
lastName: "lastname",
displayName: "firstname lastname",
nickName: "nickname",
preferredUsername: "username",
email: "email",
isEmailVerified: true,
phone: "phone",
isPhoneVerified: true,
preferredLanguage: language.English,
rawClaims: map[string]any{rolesClaim: projectRoles},
},
},
}
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(issuer, "clientID", "clientSecret", "redirectURI", []string{"openid"}, http.DefaultClient)
require.NoError(t, err)
session := &oidc.Session{
Provider: provider.Provider,
AuthURL: tt.fields.authURL,
Code: tt.fields.code,
Tokens: tt.fields.tokens,
}
user, err := session.FetchUser(context.Background())
if tt.want.err != nil && !errors.Is(err, tt.want.err) {
a.Fail("invalid error", "expected %v, got %v", tt.want.err, err)
}
if tt.want.err == nil {
a.NoError(err)
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(domain.EmailAddress(tt.want.email), user.GetEmail())
a.Equal(tt.want.isEmailVerified, user.IsEmailVerified())
a.Equal(domain.PhoneNumber(tt.want.phone), user.GetPhone())
a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified())
a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage())
if tt.want.rawClaims != nil {
idpInfo, err := json.Marshal(user)
require.NoError(t, err)
var rawInfo map[string]any
require.NoError(t, json.Unmarshal(idpInfo, &rawInfo))
for claim, value := range tt.want.rawClaims {
a.Equal(value, rawInfo[claim])
}
}
}
})
}
}
func userinfo() *openid.UserInfo {
return &openid.UserInfo{
Subject: "sub",
UserInfoProfile: openid.UserInfoProfile{
GivenName: "firstname",
FamilyName: "lastname",
Name: "firstname lastname",
Nickname: "nickname",
PreferredUsername: "username",
Locale: openid.NewLocale(language.English),
},
UserInfoEmail: openid.UserInfoEmail{
Email: "email",
EmailVerified: openid.Bool(true),
},
UserInfoPhone: openid.UserInfoPhone{
PhoneNumber: "phone",
PhoneNumberVerified: true,
},
}
}
+33
View File
@@ -0,0 +1,33 @@
package zitadel
import (
"net/http"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/oidc"
)
const (
name = "ZITADEL"
)
var _ idp.Provider = (*Provider)(nil)
// Provider is the [idp.Provider] implementation for ZITADEL
type Provider struct {
*oidc.Provider
}
func New(issuer, clientID, clientSecret, redirectURI string, scopes []string, httpClient *http.Client, options ...oidc.ProviderOpts) (*Provider, error) {
// PKCE is used by default
options = append(options, oidc.WithRelyingPartyOption(rp.WithPKCE(nil)))
provider, err := oidc.New(name, issuer, clientID, clientSecret, redirectURI, scopes, oidc.DefaultMapper, httpClient, options...)
if err != nil {
return nil, err
}
return &Provider{
Provider: provider,
}, nil
}
@@ -0,0 +1,130 @@
package zitadel
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"github.com/h2non/gock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
openid "github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/oauth"
"github.com/zitadel/zitadel/internal/idp/providers/oidc"
)
const issuer = "https://issuer.com"
func discoveryMock(issuer string) {
gock.New(issuer).
Get(openid.DiscoveryEndpoint).
Reply(200).
JSON(&openid.DiscoveryConfiguration{
Issuer: issuer,
AuthorizationEndpoint: issuer + "/authorize",
TokenEndpoint: issuer + "/token",
UserinfoEndpoint: issuer + "/userinfo",
})
}
func TestProvider_BeginAuth(t *testing.T) {
defer gock.Off()
discoveryMock(issuer)
provider, err := New(issuer, "clientID", "clientSecret", "redirectURI", []string{"openid"}, http.DefaultClient, oidc.WithSelectAccount())
require.NoError(t, err)
session, err := provider.BeginAuth(context.Background(), "testState")
require.NoError(t, err)
auth, err := session.GetAuth(context.Background())
require.NoError(t, err)
redirect, ok := auth.(*idp.RedirectAuth)
require.True(t, ok)
require.True(t, strings.HasPrefix(redirect.RedirectURL, issuer+"/authorize"), "unexpected auth url %q", redirect.RedirectURL)
authURL, err := url.Parse(redirect.RedirectURL)
require.NoError(t, err)
query := authURL.Query()
assert.Equal(t, "clientID", query.Get("client_id"))
assert.Equal(t, "redirectURI", query.Get("redirect_uri"))
assert.Equal(t, "code", query.Get("response_type"))
assert.Equal(t, "openid", query.Get("scope"))
assert.Equal(t, "testState", query.Get("state"))
assert.Equal(t, "select_account", query.Get("prompt"))
// the ZITADEL provider always enforces PKCE
assert.NotEmpty(t, query.Get("code_challenge"))
assert.Equal(t, "S256", query.Get("code_challenge_method"))
assert.NotEmpty(t, session.PersistentParameters()[oauth.CodeVerifier])
}
func TestProvider_Options(t *testing.T) {
type fields struct {
opts []oidc.ProviderOpts
}
type want struct {
name string
linkingAllowed bool
creationAllowed bool
autoCreation bool
autoUpdate bool
}
tests := []struct {
name string
fields fields
want want
}{
{
name: "default",
fields: fields{
opts: nil,
},
want: want{
name: "ZITADEL",
linkingAllowed: false,
creationAllowed: false,
autoCreation: false,
autoUpdate: false,
},
},
{
name: "all true",
fields: fields{
opts: []oidc.ProviderOpts{
oidc.WithLinkingAllowed(),
oidc.WithCreationAllowed(),
oidc.WithAutoCreation(),
oidc.WithAutoUpdate(),
},
},
want: want{
name: "ZITADEL",
linkingAllowed: true,
creationAllowed: true,
autoCreation: true,
autoUpdate: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer gock.Off()
discoveryMock(issuer)
a := assert.New(t)
provider, err := New(issuer, "clientID", "clientSecret", "redirectURI", []string{"openid"}, http.DefaultClient, tt.fields.opts...)
require.NoError(t, err)
a.Equal(tt.want.name, provider.Name())
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())
})
}
}