fix: added login_hint to the idp intent (#11552)

# Which Problems Are Solved

In login V2, when the user started the login flow of an external IDP,
there was no login_hint passed to the external provider. This required
the user to enter their username again. Whilst they already entered it
in Zitadel. By adding the `login_hint` parameter, the username should
already be filled in on the external idp.

# How the Problems Are Solved

Pass the `login_hint` parameter to the external idp.

# Additional Changes

Also added the `login_hint` to the event-store for audit trailing.

# Additional Context


- Closes #11392

---------

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Wim Van Laer
2026-02-13 14:34:32 +00:00
committed by GitHub
co-authored by Copilot
parent 2a2d5392a3
commit ca6bd9d6a2
11 changed files with 225 additions and 19 deletions
@@ -1038,3 +1038,109 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
})
}
}
func TestServer_StartIdentityProviderIntent_WithLoginHint(t *testing.T) {
oauthIdpResp := Instance.AddGenericOAuthProvider(IamCTX, integration.IDPName())
type args struct {
ctx context.Context
req *user.StartIdentityProviderIntentRequest
}
type want struct {
url string
parametersExisting []string
parametersEqual map[string]string
}
tests := []struct {
name string
args args
want want
wantErr bool
}{
{
name: "OAuth with login_hint",
args: args{
OrgCTX,
&user.StartIdentityProviderIntentRequest{
IdpId: oauthIdpResp.Id,
Content: &user.StartIdentityProviderIntentRequest_Urls{
Urls: &user.RedirectURLs{
SuccessUrl: "https://example.com/success",
FailureUrl: "https://example.com/failure",
LoginHint: "[email protected]",
},
},
},
},
want: want{
url: "https://example.com/oauth/v2/authorize",
parametersEqual: map[string]string{
"client_id": "clientID",
"redirect_uri": "http://" + Instance.Domain + ":8082/idps/callback",
"response_type": "code",
"scope": "openid profile email",
"login_hint": "[email protected]",
},
parametersExisting: []string{"state"},
},
wantErr: false,
},
{
name: "Without login_hint",
args: args{
OrgCTX,
&user.StartIdentityProviderIntentRequest{
IdpId: oauthIdpResp.Id,
Content: &user.StartIdentityProviderIntentRequest_Urls{
Urls: &user.RedirectURLs{
SuccessUrl: "https://example.com/success",
FailureUrl: "https://example.com/failure",
// No LoginHint provided
},
},
},
},
want: want{
url: "https://example.com/oauth/v2/authorize",
parametersEqual: map[string]string{
"client_id": "clientID",
"prompt": "select_account",
"redirect_uri": "http://" + Instance.Domain + ":8082/idps/callback",
"response_type": "code",
"scope": "openid profile email",
},
parametersExisting: []string{"state"},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.StartIdentityProviderIntent(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
authUrl, err := url.Parse(got.GetAuthUrl())
require.NoError(t, err)
assert.Equal(t, tt.want.url, authUrl.Scheme+"://"+authUrl.Host+authUrl.Path)
for _, param := range tt.want.parametersExisting {
assert.True(t, authUrl.Query().Has(param), "Expected parameter %s to exist", param)
}
for key, expectedValue := range tt.want.parametersEqual {
actualValue := authUrl.Query().Get(key)
assert.Equal(t, expectedValue, actualValue, "Parameter %s should have value %s but got %s", key, expectedValue, actualValue)
}
if tt.args.req.GetContent().(*user.StartIdentityProviderIntentRequest_Urls).Urls.LoginHint == "" {
assert.False(t, authUrl.Query().Has("login_hint"), "login_hint should not be in URL when not provided or empty")
}
})
}
}
+5 -5
View File
@@ -36,7 +36,7 @@ import (
func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *connect.Request[user.StartIdentityProviderIntentRequest]) (_ *connect.Response[user.StartIdentityProviderIntentResponse], err error) {
switch t := req.Msg.GetContent().(type) {
case *user.StartIdentityProviderIntentRequest_Urls:
return s.startIDPIntent(ctx, req.Msg.GetIdpId(), t.Urls)
return s.startIDPIntent(ctx, req.Msg.GetIdpId(), t.Urls, t.Urls.LoginHint)
case *user.StartIdentityProviderIntentRequest_Ldap:
return s.startLDAPIntent(ctx, req.Msg.GetIdpId(), t.Ldap)
default:
@@ -44,12 +44,12 @@ func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *connect.R
}
}
func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*connect.Response[user.StartIdentityProviderIntentResponse], error) {
state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID))
func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs, loginHint string) (*connect.Response[user.StartIdentityProviderIntentResponse], error) {
state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID), loginHint)
if err != nil {
return nil, err
}
_, details, err := s.command.CreateIntent(ctx, state, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetInstance(ctx).InstanceID(), session.PersistentParameters())
_, details, err := s.command.CreateIntent(ctx, state, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetInstance(ctx).InstanceID(), loginHint, session.PersistentParameters())
if err != nil {
return nil, err
}
@@ -78,7 +78,7 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re
}
func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*connect.Response[user.StartIdentityProviderIntentResponse], error) {
intentWriteModel, details, err := s.command.CreateIntent(ctx, "", idpID, "", "", authz.GetInstance(ctx).InstanceID(), nil)
intentWriteModel, details, err := s.command.CreateIntent(ctx, "", idpID, "", "", authz.GetInstance(ctx).InstanceID(), "", nil)
if err != nil {
return nil, err
}
+5 -5
View File
@@ -368,7 +368,7 @@ func userGrantsToIDs(userGrants []*query.UserGrant) []string {
func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *connect.Request[user.StartIdentityProviderIntentRequest]) (_ *connect.Response[user.StartIdentityProviderIntentResponse], err error) {
switch t := req.Msg.GetContent().(type) {
case *user.StartIdentityProviderIntentRequest_Urls:
return s.startIDPIntent(ctx, req.Msg.GetIdpId(), t.Urls)
return s.startIDPIntent(ctx, req.Msg.GetIdpId(), t.Urls, t.Urls.LoginHint)
case *user.StartIdentityProviderIntentRequest_Ldap:
return s.startLDAPIntent(ctx, req.Msg.GetIdpId(), t.Ldap)
default:
@@ -376,12 +376,12 @@ func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *connect.R
}
}
func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*connect.Response[user.StartIdentityProviderIntentResponse], error) {
state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID))
func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs, loginHint string) (*connect.Response[user.StartIdentityProviderIntentResponse], error) {
state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID), loginHint)
if err != nil {
return nil, err
}
_, details, err := s.command.CreateIntent(ctx, state, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetInstance(ctx).InstanceID(), session.PersistentParameters())
_, details, err := s.command.CreateIntent(ctx, state, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetInstance(ctx).InstanceID(), loginHint, session.PersistentParameters())
if err != nil {
return nil, err
}
@@ -410,7 +410,7 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re
}
func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*connect.Response[user.StartIdentityProviderIntentResponse], error) {
intentWriteModel, details, err := s.command.CreateIntent(ctx, "", idpID, "", "", authz.GetInstance(ctx).InstanceID(), nil)
intentWriteModel, details, err := s.command.CreateIntent(ctx, "", idpID, "", "", authz.GetInstance(ctx).InstanceID(), "", nil)
if err != nil {
return nil, err
}
+10 -5
View File
@@ -27,7 +27,7 @@ import (
"github.com/zitadel/zitadel/internal/zerrors"
)
func (c *Commands) prepareCreateIntent(writeModel *IDPIntentWriteModel, idpID, successURL, failureURL string, idpArguments map[string]any) preparation.Validation {
func (c *Commands) prepareCreateIntent(writeModel *IDPIntentWriteModel, idpID, successURL, failureURL string, loginHint string, idpArguments map[string]any) preparation.Validation {
return func() (_ preparation.CreateCommands, err error) {
if idpID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-x8j2bk", "Errors.Intent.IDPMissing")
@@ -55,6 +55,7 @@ func (c *Commands) prepareCreateIntent(writeModel *IDPIntentWriteModel, idpID, s
successURL,
failureURL,
idpID,
loginHint,
idpArguments,
),
}, nil
@@ -62,7 +63,7 @@ func (c *Commands) prepareCreateIntent(writeModel *IDPIntentWriteModel, idpID, s
}
}
func (c *Commands) CreateIntent(ctx context.Context, intentID, idpID, successURL, failureURL, resourceOwner string, idpArguments map[string]any) (*IDPIntentWriteModel, *domain.ObjectDetails, error) {
func (c *Commands) CreateIntent(ctx context.Context, intentID, idpID, successURL, failureURL, resourceOwner, loginHint string, idpArguments map[string]any) (*IDPIntentWriteModel, *domain.ObjectDetails, error) {
if intentID == "" {
var err error
intentID, err = c.idGenerator.Next()
@@ -73,7 +74,7 @@ func (c *Commands) CreateIntent(ctx context.Context, intentID, idpID, successURL
writeModel := NewIDPIntentWriteModel(intentID, resourceOwner, c.maxIdPIntentLifetime)
//nolint: staticcheck
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareCreateIntent(writeModel, idpID, successURL, failureURL, idpArguments))
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareCreateIntent(writeModel, idpID, successURL, failureURL, loginHint, idpArguments))
if err != nil {
return nil, nil, err
}
@@ -135,7 +136,7 @@ func (c *Commands) GetActiveIntent(ctx context.Context, intentID string) (*IDPIn
return intent, nil
}
func (c *Commands) AuthFromProvider(ctx context.Context, idpID, idpCallback, samlRootURL string) (state string, session idp.Session, err error) {
func (c *Commands) AuthFromProvider(ctx context.Context, idpID, idpCallback, samlRootURL string, loginHint string) (state string, session idp.Session, err error) {
state, err = c.idGenerator.Next()
if err != nil {
return "", nil, err
@@ -144,7 +145,11 @@ func (c *Commands) AuthFromProvider(ctx context.Context, idpID, idpCallback, sam
if err != nil {
return "", nil, err
}
session, err = provider.BeginAuth(ctx, state)
var params []idp.Parameter
if loginHint != "" {
params = append(params, idp.LoginHintParam(loginHint))
}
session, err = provider.BeginAuth(ctx, state, params...)
return state, session, err
}
+2
View File
@@ -21,6 +21,7 @@ type IDPIntentWriteModel struct {
IDPUserID string
IDPUserName string
UserID string
LoginHint string
IDPAccessToken *crypto.CryptoValue
IDPIDToken string
@@ -97,6 +98,7 @@ func (wm *IDPIntentWriteModel) reduceStartedEvent(e *idpintent.StartedEvent) {
wm.SuccessURL = e.SuccessURL
wm.FailureURL = e.FailureURL
wm.IDPID = e.IDPID
wm.LoginHint = e.LoginHint
wm.IDPArguments = e.IDPArguments
wm.State = domain.IDPIntentStateStarted
}
+76 -3
View File
@@ -49,6 +49,7 @@ func TestCommands_CreateIntent(t *testing.T) {
successURL string
failureURL string
instanceID string
loginHint string
idpArguments map[string]any
}
type res struct {
@@ -202,6 +203,7 @@ func TestCommands_CreateIntent(t *testing.T) {
success,
failure,
"idp",
"",
nil,
)
}(),
@@ -257,6 +259,7 @@ func TestCommands_CreateIntent(t *testing.T) {
success,
failure,
"idp",
"",
map[string]interface{}{
"verifier": "pkceOAuthVerifier",
},
@@ -317,6 +320,7 @@ func TestCommands_CreateIntent(t *testing.T) {
success,
failure,
"idp",
"",
map[string]interface{}{
"verifier": "pkceOAuthVerifier",
},
@@ -377,6 +381,7 @@ func TestCommands_CreateIntent(t *testing.T) {
success,
failure,
"idp",
"",
map[string]interface{}{
"verifier": "pkceOAuthVerifier",
},
@@ -408,7 +413,7 @@ func TestCommands_CreateIntent(t *testing.T) {
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
}
intentWriteModel, details, err := c.CreateIntent(tt.args.ctx, tt.args.intentID, tt.args.idpID, tt.args.successURL, tt.args.failureURL, tt.args.instanceID, tt.args.idpArguments)
intentWriteModel, details, err := c.CreateIntent(tt.args.ctx, tt.args.intentID, tt.args.idpID, tt.args.successURL, tt.args.failureURL, tt.args.instanceID, tt.args.loginHint, tt.args.idpArguments)
require.ErrorIs(t, err, tt.res.err)
if intentWriteModel != nil {
assert.Equal(t, tt.res.intentID, intentWriteModel.AggregateID)
@@ -429,6 +434,7 @@ func TestCommands_AuthFromProvider(t *testing.T) {
type args struct {
ctx context.Context
idpID string
loginHint string
callbackURL string
samlRootURL string
}
@@ -582,6 +588,68 @@ func TestCommands_AuthFromProvider(t *testing.T) {
auth: &idp.RedirectAuth{RedirectURL: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=id"},
},
},
{
"oauth auth redirect with login_hint",
fields{
secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
eventstore: expectEventstore(
expectFilter(
eventFromEventPusherWithInstanceID(
"instance",
instance.NewOAuthIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate,
"idp",
"name",
"clientID",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("clientSecret"),
},
"auth",
"token",
"user",
"idAttribute",
nil,
true,
rep_idp.Options{},
)),
),
expectFilter(
eventFromEventPusherWithInstanceID(
"instance",
instance.NewOAuthIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate,
"idp",
"name",
"clientID",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("clientSecret"),
},
"auth",
"token",
"user",
"idAttribute",
nil,
true,
rep_idp.Options{},
)),
),
),
idGenerator: mock.ExpectID(t, "id"),
},
args{
ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}),
idpID: "idp",
callbackURL: "url",
loginHint: "[email protected]",
},
res{
auth: &idp.RedirectAuth{RedirectURL: "auth?client_id=clientID&login_hint=user%40example.com&redirect_uri=url&response_type=code&state=id"},
},
},
{
"migrated and push",
fields{
@@ -681,7 +749,7 @@ func TestCommands_AuthFromProvider(t *testing.T) {
idpConfigEncryption: tt.fields.secretCrypto,
idGenerator: tt.fields.idGenerator,
}
_, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL)
_, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL, tt.args.loginHint)
require.ErrorIs(t, err, tt.res.err)
var got idp.Auth
@@ -705,6 +773,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) {
idpID string
callbackURL string
samlRootURL string
loginHint string
}
type res struct {
url string
@@ -779,6 +848,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) {
success,
failure,
"idp",
"[email protected]",
nil,
)
}(),
@@ -801,6 +871,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) {
idpID: "idp",
callbackURL: "url",
samlRootURL: "samlurl",
loginHint: "[email protected]",
},
res{
url: "http://localhost:8000/sso",
@@ -872,6 +943,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) {
success,
failure,
"idp",
"user123",
nil,
)
}(),
@@ -894,6 +966,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) {
idpID: "idp",
callbackURL: "url",
samlRootURL: "samlurl",
loginHint: "user123",
},
res{
url: "http://localhost:8000/sso",
@@ -911,7 +984,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) {
idpConfigEncryption: tt.fields.secretCrypto,
idGenerator: tt.fields.idGenerator,
}
_, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL)
_, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL, tt.args.loginHint)
require.ErrorIs(t, err, tt.res.err)
auth, err := session.GetAuth(tt.args.ctx)
+1
View File
@@ -993,6 +993,7 @@ func TestCommands_updateSession(t *testing.T) {
nil,
nil,
"idpID",
"",
nil,
),
),
+1 -1
View File
@@ -413,7 +413,7 @@ func successfulIntentHandler(cmd *command.Commands, createIntent func(ctx contex
}
func createIntent(ctx context.Context, cmd *command.Commands, instanceID, idpID string) (string, error) {
writeModel, _, err := cmd.CreateIntent(ctx, "", idpID, "https://example.com/success", "https://example.com/failure", instanceID, nil)
writeModel, _, err := cmd.CreateIntent(ctx, "", idpID, "https://example.com/success", "https://example.com/failure", instanceID, "", nil)
if err != nil {
return "", err
}
+3
View File
@@ -26,6 +26,7 @@ type StartedEvent struct {
SuccessURL *url.URL `json:"successURL"`
FailureURL *url.URL `json:"failureURL"`
IDPID string `json:"idpId"`
LoginHint string `json:"loginHint"`
IDPArguments map[string]any `json:"idpArguments,omitempty"`
}
@@ -35,6 +36,7 @@ func NewStartedEvent(
successURL,
failureURL *url.URL,
idpID string,
loginHint string,
idpArguments map[string]any,
) *StartedEvent {
return &StartedEvent{
@@ -46,6 +48,7 @@ func NewStartedEvent(
SuccessURL: successURL,
FailureURL: failureURL,
IDPID: idpID,
LoginHint: loginHint,
IDPArguments: idpArguments,
}
}
+8
View File
@@ -49,6 +49,14 @@ message RedirectURLs {
example: "\"https://custom.com/login/idp/fail\"";
}
];
string login_hint = 3 [
(validate.rules).string = {max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "login_hint hints to the login page which user wants to log in."
max_length: 200;
example: "\"someone@example.com\"";
}
];
}
message IDPIntent {
+8
View File
@@ -49,6 +49,14 @@ message RedirectURLs {
example: "\"https://custom.com/login/idp/fail\"";
}
];
string login_hint = 3 [
(validate.rules).string = {max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "login_hint hints to the login page which user wants to log in."
max_length: 200;
example: "\"someone@example.com\"";
}
];
}
message IDPIntent {