mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
co-authored by
Copilot Autofix powered by AI
parent
53d7920349
commit
b91221143b
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user