diff --git a/internal/api/grpc/user/v2/integration_test/intent_test.go b/internal/api/grpc/user/v2/integration_test/intent_test.go index 7f269ec2b..49dec1090 100644 --- a/internal/api/grpc/user/v2/integration_test/intent_test.go +++ b/internal/api/grpc/user/v2/integration_test/intent_test.go @@ -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{ diff --git a/internal/api/grpc/user/v2/intent.go b/internal/api/grpc/user/v2/intent.go index e0f43c754..27350cc9a 100644 --- a/internal/api/grpc/user/v2/intent.go +++ b/internal/api/grpc/user/v2/intent.go @@ -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: diff --git a/internal/api/grpc/user/v2beta/integration_test/user_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go index c52b91267..061715a5a 100644 --- a/internal/api/grpc/user/v2beta/integration_test/user_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -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{ diff --git a/internal/command/idp_model.go b/internal/command/idp_model.go index 7e21ca959..d34c5a3c8 100644 --- a/internal/command/idp_model.go +++ b/internal/command/idp_model.go @@ -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 +} diff --git a/internal/command/idp_model_test.go b/internal/command/idp_model_test.go index 1c55a95fb..65232974f 100644 --- a/internal/command/idp_model_test.go +++ b/internal/command/idp_model_test.go @@ -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() diff --git a/internal/idp/providers/zitadel/session_test.go b/internal/idp/providers/zitadel/session_test.go new file mode 100644 index 000000000..622faffae --- /dev/null +++ b/internal/idp/providers/zitadel/session_test.go @@ -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, + }, + } +} diff --git a/internal/idp/providers/zitadel/zitadel.go b/internal/idp/providers/zitadel/zitadel.go new file mode 100644 index 000000000..51eff614d --- /dev/null +++ b/internal/idp/providers/zitadel/zitadel.go @@ -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 +} diff --git a/internal/idp/providers/zitadel/zitadel_test.go b/internal/idp/providers/zitadel/zitadel_test.go new file mode 100644 index 000000000..22a121de8 --- /dev/null +++ b/internal/idp/providers/zitadel/zitadel_test.go @@ -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()) + }) + } +}