feat: add ldap external idp to login api (#5938)

* fix: handling of ldap login through separate endpoint

* fix: handling of ldap login through separate endpoint

* fix: handling of ldap login through separate endpoint

* fix: successful intent for ldap

* fix: successful intent for ldap

* fix: successful intent for ldap

* fix: add changes from code review

* fix: remove set intent credentials and handle ldap errors

* fix: remove set intent credentials and handle ldap errors

* refactor into separate methods and fix merge

* remove mocks

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Stefan Benz 2023-08-16 13:29:57 +02:00 committed by GitHub
parent 1b923425cd
commit 52f68f8db8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 726 additions and 149 deletions

View File

@ -258,7 +258,7 @@ func TestServer_CreateSession_successfulIntent(t *testing.T) {
require.NoError(t, err)
verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil)
intentID, token, _, _ := Tester.CreateSuccessfulIntent(t, idpID, User.GetUserId(), "id")
intentID, token, _, _ := Tester.CreateSuccessfulOAuthIntent(t, idpID, User.GetUserId(), "id")
updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{
SessionId: createResp.GetSessionId(),
SessionToken: createResp.GetSessionToken(),
@ -289,7 +289,7 @@ func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) {
verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil)
idpUserID := "id"
intentID, token, _, _ := Tester.CreateSuccessfulIntent(t, idpID, "", idpUserID)
intentID, token, _, _ := Tester.CreateSuccessfulOAuthIntent(t, idpID, "", idpUserID)
updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{
SessionId: createResp.GetSessionId(),
SessionToken: createResp.GetSessionToken(),

View File

@ -2,6 +2,7 @@ package user
import (
"context"
errs "errors"
"io"
"golang.org/x/text/language"
@ -14,6 +15,9 @@ import (
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/ldap"
"github.com/zitadel/zitadel/internal/query"
object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
@ -126,11 +130,22 @@ func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_
}
func (s *Server) StartIdentityProviderFlow(ctx context.Context, req *user.StartIdentityProviderFlowRequest) (_ *user.StartIdentityProviderFlowResponse, err error) {
id, details, err := s.command.CreateIntent(ctx, req.GetIdpId(), req.GetSuccessUrl(), req.GetFailureUrl(), authz.GetCtxData(ctx).OrgID)
switch t := req.GetContent().(type) {
case *user.StartIdentityProviderFlowRequest_Urls:
return s.startIDPIntent(ctx, req.GetIdpId(), t.Urls)
case *user.StartIdentityProviderFlowRequest_Ldap:
return s.startLDAPIntent(ctx, req.GetIdpId(), t.Ldap)
default:
return nil, errors.ThrowUnimplementedf(nil, "USERv2-S2g21", "type oneOf %T in method StartIdentityProviderFlow not implemented", t)
}
}
func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderFlowResponse, error) {
intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetCtxData(ctx).OrgID)
if err != nil {
return nil, err
}
authURL, err := s.command.AuthURLFromProvider(ctx, req.GetIdpId(), id, s.idpCallback(ctx))
authURL, err := s.command.AuthURLFromProvider(ctx, idpID, intentWriteModel.AggregateID, s.idpCallback(ctx))
if err != nil {
return nil, err
}
@ -140,6 +155,79 @@ func (s *Server) StartIdentityProviderFlow(ctx context.Context, req *user.StartI
}, nil
}
func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderFlowResponse, error) {
intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, "", "", authz.GetCtxData(ctx).OrgID)
if err != nil {
return nil, err
}
externalUser, userID, attributes, err := s.ldapLogin(ctx, intentWriteModel.IDPID, ldapCredentials.GetUsername(), ldapCredentials.GetPassword())
if err != nil {
if err := s.command.FailIDPIntent(ctx, intentWriteModel, err.Error()); err != nil {
return nil, err
}
return nil, err
}
token, err := s.command.SucceedLDAPIDPIntent(ctx, intentWriteModel, externalUser, userID, attributes)
if err != nil {
return nil, err
}
return &user.StartIdentityProviderFlowResponse{
Details: object.DomainToDetailsPb(details),
NextStep: &user.StartIdentityProviderFlowResponse_Intent{Intent: &user.Intent{IntentId: intentWriteModel.AggregateID, Token: token}},
}, nil
}
func (s *Server) checkLinkedExternalUser(ctx context.Context, idpID, externalUserID string) (string, error) {
idQuery, err := query.NewIDPUserLinkIDPIDSearchQuery(idpID)
if err != nil {
return "", err
}
externalIDQuery, err := query.NewIDPUserLinksExternalIDSearchQuery(externalUserID)
if err != nil {
return "", err
}
queries := []query.SearchQuery{
idQuery, externalIDQuery,
}
links, err := s.query.IDPUserLinks(ctx, &query.IDPUserLinksSearchQuery{Queries: queries}, false)
if err != nil {
return "", err
}
if len(links.Links) == 1 {
return links.Links[0].UserID, nil
}
return "", nil
}
func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string) (idp.User, string, map[string][]string, error) {
provider, err := s.command.GetProvider(ctx, idpID, "")
if err != nil {
return nil, "", nil, err
}
ldapProvider, ok := provider.(*ldap.Provider)
if !ok {
return nil, "", nil, errors.ThrowInvalidArgument(nil, "IDP-9a02j2n2bh", "Errors.ExternalIDP.IDPTypeNotImplemented")
}
session := ldapProvider.GetSession(username, password)
externalUser, err := session.FetchUser(ctx)
if errs.Is(err, ldap.ErrFailedLogin) || errs.Is(err, ldap.ErrNoSingleUser) {
return nil, "", nil, errors.ThrowInvalidArgument(nil, "COMMAND-nzun2i", "Errors.User.ExternalIDP.LoginFailed")
}
if err != nil {
return nil, "", nil, err
}
userID, err := s.checkLinkedExternalUser(ctx, idpID, externalUser.GetID())
if err != nil {
return nil, "", nil, err
}
attributes := make(map[string][]string, 0)
for _, item := range session.Entry.Attributes {
attributes[item.Name] = item.Values
}
return externalUser, userID, attributes, nil
}
func (s *Server) RetrieveIdentityProviderInformation(ctx context.Context, req *user.RetrieveIdentityProviderInformationRequest) (_ *user.RetrieveIdentityProviderInformationResponse, err error) {
intent, err := s.command.GetIntentWriteModel(ctx, req.GetIntentId(), authz.GetCtxData(ctx).OrgID)
if err != nil {
@ -155,41 +243,83 @@ func (s *Server) RetrieveIdentityProviderInformation(ctx context.Context, req *u
}
func intentToIDPInformationPb(intent *command.IDPIntentWriteModel, alg crypto.EncryptionAlgorithm) (_ *user.RetrieveIdentityProviderInformationResponse, err error) {
var idToken *string
if intent.IDPIDToken != "" {
idToken = &intent.IDPIDToken
}
var accessToken string
if intent.IDPAccessToken != nil {
accessToken, err = crypto.DecryptString(intent.IDPAccessToken, alg)
if err != nil {
return nil, err
}
}
rawInformation := new(structpb.Struct)
err = rawInformation.UnmarshalJSON(intent.IDPUser)
if err != nil {
return nil, err
}
return &user.RetrieveIdentityProviderInformationResponse{
Details: &object_pb.Details{
Sequence: intent.ProcessedSequence,
ChangeDate: timestamppb.New(intent.ChangeDate),
ResourceOwner: intent.ResourceOwner,
},
information := &user.RetrieveIdentityProviderInformationResponse{
Details: intentToDetailsPb(intent),
IdpInformation: &user.IDPInformation{
Access: &user.IDPInformation_Oauth{
Oauth: &user.IDPOAuthAccessInformation{
AccessToken: accessToken,
IdToken: idToken,
},
},
IdpId: intent.IDPID,
UserId: intent.IDPUserID,
UserName: intent.IDPUserName,
RawInformation: rawInformation,
},
}
if intent.IDPIDToken != "" || intent.IDPAccessToken != nil {
information.IdpInformation.Access, err = idpOAuthTokensToPb(intent.IDPIDToken, intent.IDPAccessToken, alg)
if err != nil {
return nil, err
}
}
if intent.IDPEntryAttributes != nil {
access, err := IDPEntryAttributesToPb(intent.IDPEntryAttributes)
if err != nil {
return nil, err
}
information.IdpInformation.Access = access
}
return information, nil
}
func idpOAuthTokensToPb(idpIDToken string, idpAccessToken *crypto.CryptoValue, alg crypto.EncryptionAlgorithm) (_ *user.IDPInformation_Oauth, err error) {
var idToken *string
if idpIDToken != "" {
idToken = &idpIDToken
}
var accessToken string
if idpAccessToken != nil {
accessToken, err = crypto.DecryptString(idpAccessToken, alg)
if err != nil {
return nil, err
}
}
return &user.IDPInformation_Oauth{
Oauth: &user.IDPOAuthAccessInformation{
AccessToken: accessToken,
IdToken: idToken,
},
}, nil
}
func intentToDetailsPb(intent *command.IDPIntentWriteModel) *object_pb.Details {
return &object_pb.Details{
Sequence: intent.ProcessedSequence,
ChangeDate: timestamppb.New(intent.ChangeDate),
ResourceOwner: intent.ResourceOwner,
}
}
func IDPEntryAttributesToPb(entryAttributes map[string][]string) (*user.IDPInformation_Ldap, error) {
values := make(map[string]interface{}, 0)
for k, v := range entryAttributes {
intValues := make([]interface{}, len(v))
for i, value := range v {
intValues[i] = value
}
values[k] = intValues
}
attributes, err := structpb.NewStruct(values)
if err != nil {
return nil, err
}
return &user.IDPInformation_Ldap{
Ldap: &user.IDPLDAPAccessInformation{
Attributes: attributes,
},
}, nil
}

View File

@ -651,10 +651,14 @@ func TestServer_StartIdentityProviderFlow(t *testing.T) {
CTX,
&user.StartIdentityProviderFlowRequest{
IdpId: idpID,
Content: &user.StartIdentityProviderFlowRequest_Urls{
Urls: &user.RedirectURLs{
SuccessUrl: "https://example.com/success",
FailureUrl: "https://example.com/failure",
},
},
},
},
want: &user.StartIdentityProviderFlowResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
@ -689,7 +693,8 @@ func TestServer_StartIdentityProviderFlow(t *testing.T) {
func TestServer_RetrieveIdentityProviderInformation(t *testing.T) {
idpID := Tester.AddGenericOAuthProvider(t)
intentID := Tester.CreateIntent(t, idpID)
successfulID, token, changeDate, sequence := Tester.CreateSuccessfulIntent(t, idpID, "", "id")
successfulID, token, changeDate, sequence := Tester.CreateSuccessfulOAuthIntent(t, idpID, "", "id")
ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence := Tester.CreateSuccessfulLDAPIntent(t, idpID, "", "id")
type args struct {
ctx context.Context
req *user.RetrieveIdentityProviderInformationRequest
@ -759,6 +764,51 @@ func TestServer_RetrieveIdentityProviderInformation(t *testing.T) {
},
wantErr: false,
},
{
name: "retrieve successful ldap intent",
args: args{
CTX,
&user.RetrieveIdentityProviderInformationRequest{
IntentId: ldapSuccessfulID,
Token: ldapToken,
},
},
want: &user.RetrieveIdentityProviderInformationResponse{
Details: &object.Details{
ChangeDate: timestamppb.New(ldapChangeDate),
ResourceOwner: Tester.Organisation.ID,
Sequence: ldapSequence,
},
IdpInformation: &user.IDPInformation{
Access: &user.IDPInformation_Ldap{
Ldap: &user.IDPLDAPAccessInformation{
Attributes: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"id": []interface{}{"id"},
"username": []interface{}{"username"},
"language": []interface{}{"en"},
})
require.NoError(t, err)
return s
}(),
},
},
IdpId: idpID,
UserId: "id",
UserName: "username",
RawInformation: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"id": "id",
"preferredUsername": "username",
"preferredLanguage": "en",
})
require.NoError(t, err)
return s
}(),
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -769,7 +819,7 @@ func TestServer_RetrieveIdentityProviderInformation(t *testing.T) {
require.NoError(t, err)
}
grpc.AllFieldsEqual(t, got.ProtoReflect(), tt.want.ProtoReflect(), grpc.CustomMappers)
grpc.AllFieldsEqual(t, tt.want.ProtoReflect(), got.ProtoReflect(), grpc.CustomMappers)
})
}
}

View File

@ -74,6 +74,7 @@ func Test_intentToIDPInformationPb(t *testing.T) {
Crypted: []byte("accessToken"),
},
IDPIDToken: "idToken",
IDPEntryAttributes: map[string][]string{},
UserID: "userID",
State: domain.IDPIntentStateSucceeded,
},
@ -85,7 +86,7 @@ func Test_intentToIDPInformationPb(t *testing.T) {
},
},
{
"successful",
"successful oauth",
args{
intent: &command.IDPIntentWriteModel{
WriteModel: eventstore.WriteModel{
@ -140,16 +141,73 @@ func Test_intentToIDPInformationPb(t *testing.T) {
},
err: nil,
},
}, {
"successful ldap",
args{
intent: &command.IDPIntentWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "intentID",
ProcessedSequence: 123,
ResourceOwner: "ro",
InstanceID: "instanceID",
ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local),
},
IDPID: "idpID",
IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`),
IDPUserID: "idpUserID",
IDPUserName: "username",
IDPEntryAttributes: map[string][]string{
"id": {"idpUserID"},
"firstName": {"firstname1", "firstname2"},
"lastName": {"lastname"},
},
UserID: "userID",
State: domain.IDPIntentStateSucceeded,
},
},
res{
resp: &user.RetrieveIdentityProviderInformationResponse{
Details: &object_pb.Details{
Sequence: 123,
ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)),
ResourceOwner: "ro",
},
IdpInformation: &user.IDPInformation{
Access: &user.IDPInformation_Ldap{
Ldap: &user.IDPLDAPAccessInformation{
Attributes: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"id": []interface{}{"idpUserID"},
"firstName": []interface{}{"firstname1", "firstname2"},
"lastName": []interface{}{"lastname"},
})
require.NoError(t, err)
return s
}(),
},
},
IdpId: "idpID",
UserId: "idpUserID",
UserName: "username",
RawInformation: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"userID": "idpUserID",
"username": "username",
})
require.NoError(t, err)
return s
}(),
},
},
err: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := intentToIDPInformationPb(tt.args.intent, tt.args.alg)
require.ErrorIs(t, err, tt.res.err)
grpc.AllFieldsEqual(t, got.ProtoReflect(), tt.res.resp.ProtoReflect(), grpc.CustomMappers)
if tt.res.resp != nil {
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
}
grpc.AllFieldsEqual(t, tt.res.resp.ProtoReflect(), got.ProtoReflect(), grpc.CustomMappers)
})
}
}

View File

@ -30,6 +30,7 @@ import (
const (
HandlerPrefix = "/idps"
callbackPath = "/callback"
ldapCallbackPath = callbackPath + "/ldap"
paramIntentID = "id"
paramToken = "token"
@ -82,18 +83,22 @@ func NewHandler(
}
func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
data, err := h.parseCallbackRequest(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
intent := h.getActiveIntent(w, r, data.State)
if intent == nil {
// if we didn't get an active intent the error was already handled (either redirected or display directly)
intent, err := h.commands.GetActiveIntent(ctx, data.State)
if err != nil {
if z_errs.IsNotFound(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
redirectToFailureURLErr(w, r, intent, err)
return
}
ctx := r.Context()
// the provider might have returned an error
if data.Error != "" {
cmdErr := h.commands.FailIDPIntent(ctx, intent, reason(data.Error, data.ErrorDescription))

View File

@ -50,32 +50,32 @@ func (c *Commands) prepareCreateIntent(writeModel *IDPIntentWriteModel, idpID st
}
}
func (c *Commands) CreateIntent(ctx context.Context, idpID, successURL, failureURL, resourceOwner string) (string, *domain.ObjectDetails, error) {
func (c *Commands) CreateIntent(ctx context.Context, idpID, successURL, failureURL, resourceOwner string) (*IDPIntentWriteModel, *domain.ObjectDetails, error) {
id, err := c.idGenerator.Next()
if err != nil {
return "", nil, err
return nil, nil, err
}
writeModel := NewIDPIntentWriteModel(id, resourceOwner)
if err != nil {
return "", nil, err
return nil, nil, err
}
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareCreateIntent(writeModel, idpID, successURL, failureURL))
if err != nil {
return "", nil, err
return nil, nil, err
}
pushedEvents, err := c.eventstore.Push(ctx, cmds...)
if err != nil {
return "", nil, err
return nil, nil, err
}
err = AppendAndReduce(writeModel, pushedEvents...)
if err != nil {
return "", nil, err
return nil, nil, err
}
return id, writeModelToObjectDetails(&writeModel.WriteModel), nil
return writeModel, writeModelToObjectDetails(&writeModel.WriteModel), nil
}
func (c *Commands) GetProvider(ctx context.Context, idpID, callbackURL string) (idp.Provider, error) {
func (c *Commands) GetProvider(ctx context.Context, idpID string, callbackURL string) (idp.Provider, error) {
writeModel, err := IDPProviderWriteModel(ctx, c.eventstore.Filter, idpID)
if err != nil {
return nil, err
@ -83,7 +83,21 @@ func (c *Commands) GetProvider(ctx context.Context, idpID, callbackURL string) (
return writeModel.ToProvider(callbackURL, c.idpConfigEncryption)
}
func (c *Commands) AuthURLFromProvider(ctx context.Context, idpID, state, callbackURL string) (string, error) {
func (c *Commands) GetActiveIntent(ctx context.Context, intentID string) (*IDPIntentWriteModel, error) {
intent, err := c.GetIntentWriteModel(ctx, intentID, "")
if err != nil {
return nil, err
}
if intent.State == domain.IDPIntentStateUnspecified {
return nil, errors.ThrowNotFound(nil, "IDP-Hk38e", "Errors.Intent.NotStarted")
}
if intent.State != domain.IDPIntentStateStarted {
return nil, errors.ThrowInvalidArgument(nil, "IDP-Sfrgs", "Errors.Intent.NotStarted")
}
return intent, nil
}
func (c *Commands) AuthURLFromProvider(ctx context.Context, idpID, state string, callbackURL string) (string, error) {
provider, err := c.GetProvider(ctx, idpID, callbackURL)
if err != nil {
return "", err
@ -108,7 +122,7 @@ func getIDPIntentWriteModel(ctx context.Context, writeModel *IDPIntentWriteModel
}
func (c *Commands) SucceedIDPIntent(ctx context.Context, writeModel *IDPIntentWriteModel, idpUser idp.User, idpSession idp.Session, userID string) (string, error) {
token, err := c.idpConfigEncryption.Encrypt([]byte(writeModel.AggregateID))
token, err := c.generateIntentToken(writeModel.AggregateID)
if err != nil {
return "", err
}
@ -134,9 +148,42 @@ func (c *Commands) SucceedIDPIntent(ctx context.Context, writeModel *IDPIntentWr
if err != nil {
return "", err
}
return token, nil
}
func (c *Commands) generateIntentToken(intentID string) (string, error) {
token, err := c.idpConfigEncryption.Encrypt([]byte(intentID))
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(token), nil
}
func (c *Commands) SucceedLDAPIDPIntent(ctx context.Context, writeModel *IDPIntentWriteModel, idpUser idp.User, userID string, attributes map[string][]string) (string, error) {
token, err := c.generateIntentToken(writeModel.AggregateID)
if err != nil {
return "", err
}
idpInfo, err := json.Marshal(idpUser)
if err != nil {
return "", err
}
cmd := idpintent.NewLDAPSucceededEvent(
ctx,
&idpintent.NewAggregate(writeModel.AggregateID, writeModel.ResourceOwner).Aggregate,
idpInfo,
idpUser.GetID(),
idpUser.GetPreferredUsername(),
userID,
attributes,
)
err = c.pushAppendAndReduce(ctx, writeModel, cmd)
if err != nil {
return "", err
}
return token, nil
}
func (c *Commands) FailIDPIntent(ctx context.Context, writeModel *IDPIntentWriteModel, reason string) error {
cmd := idpintent.NewFailedEvent(
ctx,

View File

@ -18,9 +18,12 @@ type IDPIntentWriteModel struct {
IDPUser []byte
IDPUserID string
IDPUserName string
UserID string
IDPAccessToken *crypto.CryptoValue
IDPIDToken string
UserID string
IDPEntryAttributes map[string][]string
State domain.IDPIntentState
aggregate *eventstore.Aggregate
@ -42,7 +45,9 @@ func (wm *IDPIntentWriteModel) Reduce() error {
case *idpintent.StartedEvent:
wm.reduceStartedEvent(e)
case *idpintent.SucceededEvent:
wm.reduceSucceededEvent(e)
wm.reduceOAuthSucceededEvent(e)
case *idpintent.LDAPSucceededEvent:
wm.reduceLDAPSucceededEvent(e)
case *idpintent.FailedEvent:
wm.reduceFailedEvent(e)
}
@ -59,6 +64,7 @@ func (wm *IDPIntentWriteModel) Query() *eventstore.SearchQueryBuilder {
EventTypes(
idpintent.StartedEventType,
idpintent.SucceededEventType,
idpintent.LDAPSucceededEventType,
idpintent.FailedEventType,
).
Builder()
@ -71,7 +77,16 @@ func (wm *IDPIntentWriteModel) reduceStartedEvent(e *idpintent.StartedEvent) {
wm.State = domain.IDPIntentStateStarted
}
func (wm *IDPIntentWriteModel) reduceSucceededEvent(e *idpintent.SucceededEvent) {
func (wm *IDPIntentWriteModel) reduceLDAPSucceededEvent(e *idpintent.LDAPSucceededEvent) {
wm.UserID = e.UserID
wm.IDPUser = e.IDPUser
wm.IDPUserID = e.IDPUserID
wm.IDPUserName = e.IDPUserName
wm.IDPEntryAttributes = e.EntryAttributes
wm.State = domain.IDPIntentStateSucceeded
}
func (wm *IDPIntentWriteModel) reduceOAuthSucceededEvent(e *idpintent.SucceededEvent) {
wm.UserID = e.UserID
wm.IDPUser = e.IDPUser
wm.IDPUserID = e.IDPUserID

View File

@ -10,6 +10,7 @@ import (
"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/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
@ -199,9 +200,13 @@ func TestCommands_CreateIntent(t *testing.T) {
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
}
intentID, details, err := c.CreateIntent(tt.args.ctx, tt.args.idpID, tt.args.successURL, tt.args.failureURL, tt.args.resourceOwner)
intentWriteModel, details, err := c.CreateIntent(tt.args.ctx, tt.args.idpID, tt.args.successURL, tt.args.failureURL, tt.args.resourceOwner)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.intentID, intentID)
if intentWriteModel != nil {
assert.Equal(t, tt.res.intentID, intentWriteModel.AggregateID)
} else {
assert.Equal(t, tt.res.intentID, "")
}
assert.Equal(t, tt.res.details, details)
})
}
@ -580,6 +585,103 @@ func TestCommands_SucceedIDPIntent(t *testing.T) {
}
}
func TestCommands_SucceedLDAPIDPIntent(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
idpConfigEncryption crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
writeModel *IDPIntentWriteModel
idpUser idp.User
userID string
attributes map[string][]string
}
type res struct {
token string
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"encryption fails",
fields{
idpConfigEncryption: func() crypto.EncryptionAlgorithm {
m := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t))
m.EXPECT().Encrypt(gomock.Any()).Return(nil, z_errors.ThrowInternal(nil, "id", "encryption failed"))
return m
}(),
},
args{
ctx: context.Background(),
writeModel: NewIDPIntentWriteModel("id", "ro"),
},
res{
err: z_errors.ThrowInternal(nil, "id", "encryption failed"),
},
},
{
"push",
fields{
idpConfigEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
eventstore: eventstoreExpect(t,
expectPush(
eventPusherToEvents(
idpintent.NewLDAPSucceededEvent(
context.Background(),
&idpintent.NewAggregate("id", "ro").Aggregate,
[]byte(`{"id":"id","preferredUsername":"username","preferredLanguage":"und"}`),
"id",
"username",
"",
map[string][]string{"id": {"id"}},
),
),
),
),
},
args{
ctx: context.Background(),
writeModel: NewIDPIntentWriteModel("id", "ro"),
attributes: map[string][]string{"id": {"id"}},
idpUser: ldap.NewUser(
"id",
"",
"",
"",
"",
"username",
"",
false,
"",
false,
language.Tag{},
"",
"",
),
},
res{
token: "aWQ",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
idpConfigEncryption: tt.fields.idpConfigEncryption,
}
got, err := c.SucceedLDAPIDPIntent(tt.args.ctx, tt.args.writeModel, tt.args.idpUser, tt.args.userID, tt.args.attributes)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.token, got)
})
}
}
func TestCommands_FailIDPIntent(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore

View File

@ -218,6 +218,14 @@ func (p *Provider) BeginAuth(ctx context.Context, state string, params ...any) (
}, nil
}
func (p *Provider) GetSession(username, password string) *Session {
return &Session{
Provider: p,
User: username,
Password: password,
}
}
func (p *Provider) IsLinkingAllowed() bool {
return p.isLinkingAllowed
}

View File

@ -26,6 +26,7 @@ type Session struct {
loginUrl string
User string
Password string
Entry *ldap.Entry
}
func (s *Session) GetAuthURL() string {
@ -57,6 +58,7 @@ func (s *Session) FetchUser(_ context.Context) (_ idp.User, err error) {
if err != nil {
return nil, err
}
s.Entry = user
return mapLDAPEntryToUser(
user,

View File

@ -219,19 +219,19 @@ func TestProvider_mapLDAPEntryToUser(t *testing.T) {
},
want: want{
user: &User{
id: "",
firstName: "",
lastName: "",
displayName: "",
nickName: "",
preferredUsername: "",
email: "",
emailVerified: false,
phone: "",
phoneVerified: false,
preferredLanguage: language.Tag{},
avatarURL: "",
profile: "",
ID: "",
FirstName: "",
LastName: "",
DisplayName: "",
NickName: "",
PreferredUsername: "",
Email: "",
EmailVerified: false,
Phone: "",
PhoneVerified: false,
PreferredLanguage: language.Tag{},
AvatarURL: "",
Profile: "",
},
},
},
@ -351,19 +351,19 @@ func TestProvider_mapLDAPEntryToUser(t *testing.T) {
},
want: want{
user: &User{
id: "id",
firstName: "first",
lastName: "last",
displayName: "display",
nickName: "nick",
preferredUsername: "preferred",
email: "email",
emailVerified: false,
phone: "phone",
phoneVerified: false,
preferredLanguage: language.Make("und"),
avatarURL: "avatar",
profile: "profile",
ID: "id",
FirstName: "first",
LastName: "last",
DisplayName: "display",
NickName: "nick",
PreferredUsername: "preferred",
Email: "email",
EmailVerified: false,
Phone: "phone",
PhoneVerified: false,
PreferredLanguage: language.Make("und"),
AvatarURL: "avatar",
Profile: "profile",
},
},
},

View File

@ -7,19 +7,19 @@ import (
)
type User struct {
id string
firstName string
lastName string
displayName string
nickName string
preferredUsername string
email domain.EmailAddress
emailVerified bool
phone domain.PhoneNumber
phoneVerified bool
preferredLanguage language.Tag
avatarURL string
profile string
ID string `json:"id,omitempty"`
FirstName string `json:"firstName,omitempty"`
LastName string `json:"lastName,omitempty"`
DisplayName string `json:"displayName,omitempty"`
NickName string `json:"nickName,omitempty"`
PreferredUsername string `json:"preferredUsername,omitempty"`
Email domain.EmailAddress `json:"email,omitempty"`
EmailVerified bool `json:"emailVerified,omitempty"`
Phone domain.PhoneNumber `json:"phone,omitempty"`
PhoneVerified bool `json:"phoneVerified,omitempty"`
PreferredLanguage language.Tag `json:"preferredLanguage,omitempty"`
AvatarURL string `json:"avatarURL,omitempty"`
Profile string `json:"profile,omitempty"`
}
func NewUser(
@ -55,41 +55,41 @@ func NewUser(
}
func (u *User) GetID() string {
return u.id
return u.ID
}
func (u *User) GetFirstName() string {
return u.firstName
return u.FirstName
}
func (u *User) GetLastName() string {
return u.lastName
return u.LastName
}
func (u *User) GetDisplayName() string {
return u.displayName
return u.DisplayName
}
func (u *User) GetNickname() string {
return u.nickName
return u.NickName
}
func (u *User) GetPreferredUsername() string {
return u.preferredUsername
return u.PreferredUsername
}
func (u *User) GetEmail() domain.EmailAddress {
return u.email
return u.Email
}
func (u *User) IsEmailVerified() bool {
return u.emailVerified
return u.EmailVerified
}
func (u *User) GetPhone() domain.PhoneNumber {
return u.phone
return u.Phone
}
func (u *User) IsPhoneVerified() bool {
return u.phoneVerified
return u.PhoneVerified
}
func (u *User) GetPreferredLanguage() language.Tag {
return u.preferredLanguage
return u.PreferredLanguage
}
func (u *User) GetAvatarURL() string {
return u.avatarURL
return u.AvatarURL
}
func (u *User) GetProfile() string {
return u.profile
return u.Profile
}

View File

@ -10,10 +10,12 @@ import (
"github.com/zitadel/logging"
"github.com/zitadel/oidc/v2/pkg/oidc"
"golang.org/x/oauth2"
"golang.org/x/text/language"
"google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/idp/providers/ldap"
openid "github.com/zitadel/zitadel/internal/idp/providers/oidc"
"github.com/zitadel/zitadel/internal/repository/idp"
"github.com/zitadel/zitadel/pkg/grpc/admin"
@ -196,12 +198,12 @@ func (s *Tester) AddGenericOAuthProvider(t *testing.T) string {
func (s *Tester) CreateIntent(t *testing.T, idpID string) string {
ctx := authz.WithInstance(context.Background(), s.Instance)
id, _, err := s.Commands.CreateIntent(ctx, idpID, "https://example.com/success", "https://example.com/failure", s.Organisation.ID)
writeModel, _, err := s.Commands.CreateIntent(ctx, idpID, "https://example.com/success", "https://example.com/failure", s.Organisation.ID)
require.NoError(t, err)
return id
return writeModel.AggregateID
}
func (s *Tester) CreateSuccessfulIntent(t *testing.T, idpID, userID, idpUserID string) (string, string, time.Time, uint64) {
func (s *Tester) CreateSuccessfulOAuthIntent(t *testing.T, idpID, userID, idpUserID string) (string, string, time.Time, uint64) {
ctx := authz.WithInstance(context.Background(), s.Instance)
intentID := s.CreateIntent(t, idpID)
writeModel, err := s.Commands.GetIntentWriteModel(ctx, intentID, s.Organisation.ID)
@ -227,6 +229,34 @@ func (s *Tester) CreateSuccessfulIntent(t *testing.T, idpID, userID, idpUserID s
return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence
}
func (s *Tester) CreateSuccessfulLDAPIntent(t *testing.T, idpID, userID, idpUserID string) (string, string, time.Time, uint64) {
ctx := authz.WithInstance(context.Background(), s.Instance)
intentID := s.CreateIntent(t, idpID)
writeModel, err := s.Commands.GetIntentWriteModel(ctx, intentID, s.Organisation.ID)
require.NoError(t, err)
username := "username"
lang := language.Make("en")
idpUser := ldap.NewUser(
idpUserID,
"",
"",
"",
"",
username,
"",
false,
"",
false,
lang,
"",
"",
)
attributes := map[string][]string{"id": {idpUserID}, "username": {username}, "language": {lang.String()}}
token, err := s.Commands.SucceedLDAPIDPIntent(ctx, writeModel, idpUser, userID, attributes)
require.NoError(t, err)
return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence
}
func (s *Tester) CreateVerfiedWebAuthNSession(t *testing.T, ctx context.Context, userID string) (id, token string, start, change time.Time) {
createResp, err := s.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{
Checks: &session.Checks{

View File

@ -7,5 +7,6 @@ import (
func RegisterEventMappers(es *eventstore.Eventstore) {
es.RegisterFilterEventMapper(AggregateType, StartedEventType, StartedEventMapper).
RegisterFilterEventMapper(AggregateType, SucceededEventType, SucceededEventMapper).
RegisterFilterEventMapper(AggregateType, LDAPSucceededEventType, LDAPSucceededEventMapper).
RegisterFilterEventMapper(AggregateType, FailedEventType, FailedEventMapper)
}

View File

@ -14,6 +14,7 @@ import (
const (
StartedEventType = instanceEventTypePrefix + "started"
SucceededEventType = instanceEventTypePrefix + "succeeded"
LDAPSucceededEventType = instanceEventTypePrefix + "ldap.succeeded"
FailedEventType = instanceEventTypePrefix + "failed"
)
@ -72,6 +73,7 @@ type SucceededEvent struct {
IDPUserID string `json:"idpUserId,omitempty"`
IDPUserName string `json:"idpUserName,omitempty"`
UserID string `json:"userId,omitempty"`
IDPAccessToken *crypto.CryptoValue `json:"idpAccessToken,omitempty"`
IDPIDToken string `json:"idpIdToken,omitempty"`
}
@ -122,6 +124,61 @@ func SucceededEventMapper(event *repository.Event) (eventstore.Event, error) {
return e, nil
}
type LDAPSucceededEvent struct {
eventstore.BaseEvent `json:"-"`
IDPUser []byte `json:"idpUser"`
IDPUserID string `json:"idpUserId,omitempty"`
IDPUserName string `json:"idpUserName,omitempty"`
UserID string `json:"userId,omitempty"`
EntryAttributes map[string][]string `json:"user,omitempty"`
}
func NewLDAPSucceededEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
idpUser []byte,
idpUserID,
idpUserName,
userID string,
attributes map[string][]string,
) *LDAPSucceededEvent {
return &LDAPSucceededEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
LDAPSucceededEventType,
),
IDPUser: idpUser,
IDPUserID: idpUserID,
IDPUserName: idpUserName,
UserID: userID,
EntryAttributes: attributes,
}
}
func (e *LDAPSucceededEvent) Data() interface{} {
return e
}
func (e *LDAPSucceededEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func LDAPSucceededEventMapper(event *repository.Event) (eventstore.Event, error) {
e := &LDAPSucceededEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := json.Unmarshal(event.Data, e)
if err != nil {
return nil, errors.ThrowInternal(err, "IDP-HBreq", "unable to unmarshal event")
}
return e, nil
}
type FailedEvent struct {
eventstore.BaseEvent `json:"-"`

View File

@ -139,6 +139,7 @@ Errors:
MinimumExternalIDPNeeded: Трябва да се добави поне един IDP
AlreadyExists: Външен IDP вече е зает
NotFound: Външен IDP не е намерен
LoginFailed: Влизането във Външен IDP е неуспешно
MFA:
OTP:
AlreadyReady: Многофакторният OTP (OneTimePassword) вече е настроен

View File

@ -136,7 +136,8 @@ Errors:
NotAllowed: Externer IDP ist auf dieser Organisation nicht erlaubt.
MinimumExternalIDPNeeded: Mindestens ein IDP muss hinzugefügt werden.
AlreadyExists: External IDP ist bereits vergeben
NotFound: Externe IDP nicht gefunden
NotFound: Externer IDP nicht gefunden
LoginFailed: Externer IDP Login fehlgeschlagen
MFA:
OTP:
AlreadyReady: Multifaktor OTP (OneTimePassword) ist bereits eingerichtet

View File

@ -137,6 +137,7 @@ Errors:
MinimumExternalIDPNeeded: At least one IDP must be added
AlreadyExists: External IDP already taken
NotFound: External IDP not found
LoginFailed: Login at External IDP failed
MFA:
OTP:
AlreadyReady: Multifactor OTP (OneTimePassword) is already set up

View File

@ -137,6 +137,7 @@ Errors:
MinimumExternalIDPNeeded: Al menos de añadirse un IDP
AlreadyExists: IDP externo ya cogido
NotFound: IDP no encontrado
LoginFailed: Error de inicio de sesión en IDP externo
MFA:
OTP:
AlreadyReady: Multifactor OTP (OneTimePassword) ya está configurado

View File

@ -137,6 +137,7 @@ Errors:
MinimumExternalIDPNeeded: Au moins un IDP doit être ajouté
AlreadyExists: External IDP déjà pris
NotFound: IDP externe non trouvé
LoginFailed: Échec de la connexion à l'IDP externe
MFA:
OTP:
AlreadyReady: L'OTP (mot de passe à usage unique) multifactoriel est déjà configuré.

View File

@ -137,6 +137,7 @@ Errors:
MinimumExternalIDPNeeded: Almeno un IDP deve essere aggiunto
AlreadyExists: IDP esterno già preso
NotFound: IDP esterno non trovato
LoginFailed: Accesso all'IDP esterno non riuscito
MFA:
OTP:
AlreadyReady: Multifattore OTP (OneTimePassword) è già impostato

View File

@ -129,6 +129,7 @@ Errors:
MinimumExternalIDPNeeded: 少なくとも1つのIDPを追加する必要があります
AlreadyExists: 外部IDPはすでに使用されています
NotFound: 外部IDPが見つかりません
LoginFailed: 外部IDPでのログインに失敗
MFA:
OTP:
AlreadyReady: 多要素OTPワンタイムパスワードは設定済みです

View File

@ -137,6 +137,7 @@ Errors:
MinimumExternalIDPNeeded: Мора да се додаде најмалку еден надворешен IDP
AlreadyExists: Надворешниот IDP е веќе зафатен
NotFound: Надворешниот IDP не е пронајден
LoginFailed: Пријавувањето на Надворешниот ВРЛ не успеа
MFA:
OTP:
AlreadyReady: Мултифактор OTP (Еднократна Лозинка) e веќе поставен

View File

@ -137,6 +137,7 @@ Errors:
MinimumExternalIDPNeeded: Przynajmniej jeden IDP musi być dodany
AlreadyExists: IDP zewnętrzne już istnieje
NotFound: IDP zewnętrzne nie znaleziony
LoginFailed: Logowanie w zewnętrznym IDP nie powiodło się
MFA:
OTP:
AlreadyReady: Wieloskładnikowe OTP (OneTimePassword) jest już skonfigurowane

View File

@ -137,6 +137,7 @@ Errors:
MinimumExternalIDPNeeded: 必须添加至少一个 IDP
AlreadyExists: 外部 IDP 已存在
NotFound: 未找到外部 IDP
LoginFailed: 外部 IDP 登录失败
MFA:
OTP:
AlreadyReady: OTP (一次性密码) 已经设置好了

View File

@ -9,6 +9,67 @@ import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
message LDAPCredentials {
string username = 1[
(validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Username used to login through LDAP"
min_length: 1;
max_length: 200;
example: "\"username\"";
}
];
string password = 2[
(validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Password used to login through LDAP"
min_length: 1;
max_length: 200;
example: "\"Password1!\"";
}
];
}
message RedirectURLs {
string success_url = 1 [
(validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "URL on which the user will be redirected after a successful login"
min_length: 1;
max_length: 200;
example: "\"https://custom.com/login/idp/success\"";
}
];
string failure_url = 2 [
(validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "URL on which the user will be redirected after a failed login"
min_length: 1;
max_length: 200;
example: "\"https://custom.com/login/idp/fail\"";
}
];
}
message Intent {
string intent_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "ID of the intent"
min_length: 1;
max_length: 200;
example: "\"163840776835432705=\"";
}
];
string token = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "token of the intent"
min_length: 1;
max_length: 200;
example: "\"SJKL3ioIDpo342ioqw98fjp3sdf32wahb=\"";
}
];
}
message IDPInformation{
oneof access{
IDPOAuthAccessInformation oauth = 1 [
@ -16,6 +77,11 @@ message IDPInformation{
description: "OAuth/OIDC access (and id_token) returned by the identity provider"
}
];
IDPLDAPAccessInformation ldap = 6 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "LDAP entity attributes returned by the identity provider"
}
];
}
string idp_id = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
@ -47,6 +113,10 @@ message IDPOAuthAccessInformation{
optional string id_token = 2;
}
message IDPLDAPAccessInformation{
google.protobuf.Struct attributes = 1;
}
message IDPLink {
string idp_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},

View File

@ -1082,24 +1082,11 @@ message StartIdentityProviderFlowRequest{
example: "\"163840776835432705\"";
}
];
string success_url = 2 [
(validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "URL on which the user will be redirected after a successful login"
min_length: 1;
max_length: 200;
example: "\"https://custom.com/login/idp/success\"";
oneof content {
RedirectURLs urls = 2;
LDAPCredentials ldap = 3;
}
];
string failure_url = 3 [
(validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "URL on which the user will be redirected after a failed login"
min_length: 1;
max_length: 200;
example: "\"https://custom.com/login/idp/fail\"";
}
];
}
message StartIdentityProviderFlowResponse{
@ -1111,6 +1098,11 @@ message StartIdentityProviderFlowResponse{
example: "\"https://accounts.google.com/o/oauth2/v2/auth?client_id=clientID&callback=https%3A%2F%2Fzitadel.cloud%2Fidps%2Fcallback\"";
}
];
Intent intent = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Intent information"
}
];
}
}