From 5bff9ef9dda21b27f897a513a0382c10533d1eab Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Mon, 30 Oct 2023 10:15:17 +0100 Subject: [PATCH] add consent label --- cmd/clidoc/main.go | 3 ++ selfservice/flow/duplicate_credentials.go | 57 ++++++++++++++++++++++ selfservice/flow/flow.go | 10 ---- selfservice/flow/login/flow.go | 11 +---- selfservice/flow/login/flow_test.go | 34 +++++++++++++ selfservice/flow/login/hook.go | 2 +- selfservice/flow/registration/flow.go | 4 ++ selfservice/flow/registration/hook.go | 42 ++++++++-------- selfservice/strategy/oidc/strategy.go | 31 +++++++++--- selfservice/strategy/oidc/strategy_test.go | 5 +- text/id.go | 4 +- text/message_login.go | 37 ++++++++++++++ 12 files changed, 189 insertions(+), 51 deletions(-) create mode 100644 selfservice/flow/duplicate_credentials.go diff --git a/cmd/clidoc/main.go b/cmd/clidoc/main.go index 3821f26b0a62..6a9ff0610017 100644 --- a/cmd/clidoc/main.go +++ b/cmd/clidoc/main.go @@ -119,10 +119,13 @@ func init() { "NewInfoLoginTOTPLabel": text.NewInfoLoginTOTPLabel(), "NewInfoLoginLookupLabel": text.NewInfoLoginLookupLabel(), "NewInfoLogin": text.NewInfoLogin(), + "NewInfoLoginAndLink": text.NewInfoLoginAndLink(), + "NewInfoLoginLinkMessage": text.NewInfoLoginLinkMessage("{duplicteIdentifier}", "{provider}", "{newLoginUrl}"), "NewInfoLoginTOTP": text.NewInfoLoginTOTP(), "NewInfoLoginLookup": text.NewInfoLoginLookup(), "NewInfoLoginVerify": text.NewInfoLoginVerify(), "NewInfoLoginWith": text.NewInfoLoginWith("{provider}"), + "NewInfoLoginWithAndLink": text.NewInfoLoginWithAndLink("{provider}"), "NewErrorValidationLoginFlowExpired": text.NewErrorValidationLoginFlowExpired(aSecondAgo), "NewErrorValidationLoginNoStrategyFound": text.NewErrorValidationLoginNoStrategyFound(), "NewErrorValidationRegistrationNoStrategyFound": text.NewErrorValidationRegistrationNoStrategyFound(), diff --git a/selfservice/flow/duplicate_credentials.go b/selfservice/flow/duplicate_credentials.go new file mode 100644 index 000000000000..9249aaacd60e --- /dev/null +++ b/selfservice/flow/duplicate_credentials.go @@ -0,0 +1,57 @@ +package flow + +import ( + "encoding/json" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "github.com/ory/kratos/identity" + "github.com/ory/x/sqlxx" +) + +const internalContextDuplicateCredentialsPath = "registration_duplicate_credentials" + +type DuplicateCredentialsData struct { + CredentialsType identity.CredentialsType + CredentialsConfig sqlxx.JSONRawMessage + DuplicateIdentifier string +} + +type InternalContexter interface { + EnsureInternalContext() + GetInternalContext() *sqlxx.JSONRawMessage +} + +// SetDuplicateCredentials sets the duplicate credentials data in the flow's internal context. +func SetDuplicateCredentials(flow InternalContexter, creds DuplicateCredentialsData) error { + if flow.GetInternalContext() == nil { + flow.EnsureInternalContext() + } + bytes, err := sjson.SetBytes( + *flow.GetInternalContext(), + internalContextDuplicateCredentialsPath, + creds, + ) + if err != nil { + return err + } + *flow.GetInternalContext() = bytes + + return nil +} + +// DuplicateCredentials returns the duplicate credentials data from the flow's internal context. +func DuplicateCredentials(flow InternalContexter) (*DuplicateCredentialsData, error) { + if flow.GetInternalContext() == nil { + flow.EnsureInternalContext() + } + raw := gjson.GetBytes(*flow.GetInternalContext(), internalContextDuplicateCredentialsPath) + if !raw.IsObject() { + return nil, nil + } + var creds DuplicateCredentialsData + err := json.Unmarshal([]byte(raw.Raw), &creds) + + return &creds, err +} diff --git a/selfservice/flow/flow.go b/selfservice/flow/flow.go index 14a51d4dbdc4..ee9fbba6638d 100644 --- a/selfservice/flow/flow.go +++ b/selfservice/flow/flow.go @@ -13,21 +13,11 @@ import ( "github.com/ory/herodot" "github.com/ory/kratos/driver/config" - "github.com/ory/kratos/identity" "github.com/ory/kratos/ui/container" "github.com/ory/kratos/x" - "github.com/ory/x/sqlxx" "github.com/ory/x/urlx" ) -const InternalContextDuplicateCredentialsPath = "registration_duplicate_credentials" - -type RegistrationDuplicateCredentials struct { - CredentialsType identity.CredentialsType - CredentialsConfig sqlxx.JSONRawMessage - DuplicateIdentifier string -} - func AppendFlowTo(src *url.URL, id uuid.UUID) *url.URL { return urlx.CopyWithQuery(src, url.Values{"flow": {id.String()}}) } diff --git a/selfservice/flow/login/flow.go b/selfservice/flow/login/flow.go index bb8fb6d8db7d..bb5441c3b09c 100644 --- a/selfservice/flow/login/flow.go +++ b/selfservice/flow/login/flow.go @@ -231,15 +231,8 @@ func (f *Flow) EnsureInternalContext() { } } -func (f *Flow) DuplicateCredentials() (*flow.RegistrationDuplicateCredentials, error) { - raw := gjson.GetBytes(f.InternalContext, flow.InternalContextDuplicateCredentialsPath) - if !raw.IsObject() { - return nil, nil - } - var creds flow.RegistrationDuplicateCredentials - err := json.Unmarshal([]byte(raw.Raw), &creds) - - return &creds, err +func (f *Flow) GetInternalContext() *sqlxx.JSONRawMessage { + return &f.InternalContext } func (f Flow) MarshalJSON() ([]byte, error) { diff --git a/selfservice/flow/login/flow_test.go b/selfservice/flow/login/flow_test.go index 1c7f1e200a53..c33ac1dc99e3 100644 --- a/selfservice/flow/login/flow_test.go +++ b/selfservice/flow/login/flow_test.go @@ -17,6 +17,7 @@ import ( "github.com/tidwall/gjson" "github.com/ory/x/jsonx" + "github.com/ory/x/sqlxx" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" @@ -35,6 +36,7 @@ import ( ) func TestFakeFlow(t *testing.T) { + t.Parallel() var r login.Flow require.NoError(t, faker.FakeData(&r)) @@ -47,6 +49,7 @@ func TestFakeFlow(t *testing.T) { } func TestNewFlow(t *testing.T) { + t.Parallel() ctx := context.Background() conf, _ := internal.NewFastRegistryWithMocks(t) @@ -130,6 +133,7 @@ func TestNewFlow(t *testing.T) { } func TestFlow(t *testing.T) { + t.Parallel() r := &login.Flow{ID: x.NewUUID()} assert.Equal(t, r.ID, r.GetID()) @@ -154,6 +158,7 @@ func TestFlow(t *testing.T) { } func TestGetType(t *testing.T) { + t.Parallel() for _, ft := range []flow.Type{ flow.TypeAPI, flow.TypeBrowser, @@ -166,18 +171,21 @@ func TestGetType(t *testing.T) { } func TestGetRequestURL(t *testing.T) { + t.Parallel() expectedURL := "http://foo/bar/baz" f := &login.Flow{RequestURL: expectedURL} assert.Equal(t, expectedURL, f.GetRequestURL()) } func TestFlowEncodeJSON(t *testing.T) { + t.Parallel() assert.EqualValues(t, "", gjson.Get(jsonx.TestMarshalJSONString(t, &login.Flow{RequestURL: "https://foo.bar?foo=bar"}), "return_to").String()) assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, &login.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String()) assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, login.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String()) } func TestFlowDontOverrideReturnTo(t *testing.T) { + t.Parallel() f := &login.Flow{ReturnTo: "/foo"} f.SetReturnTo() assert.Equal(t, "/foo", f.ReturnTo) @@ -186,3 +194,29 @@ func TestFlowDontOverrideReturnTo(t *testing.T) { f.SetReturnTo() assert.Equal(t, "/bar", f.ReturnTo) } + +func TestDuplicateCredentials(t *testing.T) { + t.Parallel() + t.Run("case=returns previous data", func(t *testing.T) { + t.Parallel() + f := new(login.Flow) + dc := flow.DuplicateCredentialsData{ + CredentialsType: "foo", + CredentialsConfig: sqlxx.JSONRawMessage(`{"bar":"baz"}`), + DuplicateIdentifier: "bar", + } + + require.NoError(t, flow.SetDuplicateCredentials(f, dc)) + actual, err := flow.DuplicateCredentials(f) + require.NoError(t, err) + assert.Equal(t, dc, *actual) + }) + + t.Run("case=returns nil data", func(t *testing.T) { + t.Parallel() + f := new(login.Flow) + actual, err := flow.DuplicateCredentials(f) + require.NoError(t, err) + assert.Nil(t, actual) + }) +} diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go index 456ee7a9705b..68b27943d0cb 100644 --- a/selfservice/flow/login/hook.go +++ b/selfservice/flow/login/hook.go @@ -331,7 +331,7 @@ func (e *HookExecutor) PreLoginHook(w http.ResponseWriter, r *http.Request, a *F // maybeLinkCredentials links the identity with the credentials of the inner context of the login flow. func (e *HookExecutor) maybeLinkCredentials(r *http.Request, s *session.Session, i *identity.Identity, f *Flow) error { - lc, err := f.DuplicateCredentials() + lc, err := flow.DuplicateCredentials(f) if err != nil { return err } else if lc == nil { diff --git a/selfservice/flow/registration/flow.go b/selfservice/flow/registration/flow.go index 2f1ea3603b40..9a2b7c9cbdc4 100644 --- a/selfservice/flow/registration/flow.go +++ b/selfservice/flow/registration/flow.go @@ -202,6 +202,10 @@ func (f *Flow) EnsureInternalContext() { } } +func (f *Flow) GetInternalContext() *sqlxx.JSONRawMessage { + return &f.InternalContext +} + func (f Flow) MarshalJSON() ([]byte, error) { type local Flow f.SetReturnTo() diff --git a/selfservice/flow/registration/hook.go b/selfservice/flow/registration/hook.go index 9b8c346f4123..85d6078b5594 100644 --- a/selfservice/flow/registration/hook.go +++ b/selfservice/flow/registration/hook.go @@ -11,7 +11,6 @@ import ( "github.com/julienschmidt/httprouter" "github.com/pkg/errors" - "github.com/tidwall/sjson" "go.opentelemetry.io/otel/attribute" "github.com/ory/kratos/driver/config" @@ -102,7 +101,7 @@ func NewHookExecutor(d executorDependencies) *HookExecutor { return &HookExecutor{d: d} } -func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Request, ct identity.CredentialsType, provider string, a *Flow, i *identity.Identity) (err error) { +func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Request, ct identity.CredentialsType, provider string, registrationFlow *Flow, i *identity.Identity) (err error) { ctx := r.Context() ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "HookExecutor.PostRegistrationHook") r = r.WithContext(ctx) @@ -114,7 +113,7 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque WithField("flow_method", ct). Debug("Running PostRegistrationPrePersistHooks.") for k, executor := range e.d.PostRegistrationPrePersistHooks(r.Context(), ct) { - if err := executor.ExecutePostRegistrationPrePersistHook(w, r, a, i); err != nil { + if err := executor.ExecutePostRegistrationPrePersistHook(w, r, registrationFlow, i); err != nil { if errors.Is(err, ErrHookAbortFlow) { e.d.Logger(). WithRequest(r). @@ -138,7 +137,7 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque Error("ExecutePostRegistrationPostPersistHook hook failed with an error.") traits := i.Traits - return flow.HandleHookError(w, r, a, traits, ct.ToUiNodeGroup(), err, e.d, e.d) + return flow.HandleHookError(w, r, registrationFlow, traits, ct.ToUiNodeGroup(), err, e.d, e.d) } e.d.Logger().WithRequest(r). @@ -169,14 +168,13 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque if err != nil { return err } - registrationDuplicateCredentials := flow.RegistrationDuplicateCredentials{ + registrationDuplicateCredentials := flow.DuplicateCredentialsData{ CredentialsType: ct, CredentialsConfig: i.Credentials[ct].Config, DuplicateIdentifier: duplicateIdentifier, } - a.InternalContext, err = sjson.SetBytes(a.InternalContext, flow.InternalContextDuplicateCredentialsPath, - registrationDuplicateCredentials) + err = flow.SetDuplicateCredentials(registrationFlow, registrationDuplicateCredentials) if err != nil { return err } @@ -188,8 +186,8 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque // Verify the redirect URL before we do any other processing. c := e.d.Config() returnTo, err := x.SecureRedirectTo(r, c.SelfServiceBrowserDefaultReturnTo(r.Context()), - x.SecureRedirectReturnTo(a.ReturnTo), - x.SecureRedirectUseSourceURL(a.RequestURL), + x.SecureRedirectReturnTo(registrationFlow.ReturnTo), + x.SecureRedirectUseSourceURL(registrationFlow.RequestURL), x.SecureRedirectAllowURLs(c.SelfServiceBrowserAllowedReturnToDomains(r.Context())), x.SecureRedirectAllowSelfServiceURLs(c.SelfPublicURL(r.Context())), x.SecureRedirectOverrideDefaultReturnTo(c.SelfServiceFlowRegistrationReturnTo(r.Context(), ct.String())), @@ -208,7 +206,7 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque WithField("identity_id", i.ID). Info("A new identity has registered using self-service registration.") - span.AddEvent(events.NewRegistrationSucceeded(r.Context(), i.ID, string(a.Type), a.Active.String(), provider)) + span.AddEvent(events.NewRegistrationSucceeded(r.Context(), i.ID, string(registrationFlow.Type), registrationFlow.Active.String(), provider)) s := session.NewInactiveSession() @@ -230,7 +228,7 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque WithField("flow_method", ct). Debug("Running PostRegistrationPostPersistHooks.") for k, executor := range e.d.PostRegistrationPostPersistHooks(r.Context(), ct) { - if err := executor.ExecutePostRegistrationPostPersistHook(w, r, a, s); err != nil { + if err := executor.ExecutePostRegistrationPostPersistHook(w, r, registrationFlow, s); err != nil { if errors.Is(err, ErrHookAbortFlow) { e.d.Logger(). WithRequest(r). @@ -259,7 +257,7 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque span.SetAttributes(attribute.String("redirect_reason", "hook error"), attribute.String("executor", fmt.Sprintf("%T", executor))) traits := i.Traits - return flow.HandleHookError(w, r, a, traits, ct.ToUiNodeGroup(), err, e.d, e.d) + return flow.HandleHookError(w, r, registrationFlow, traits, ct.ToUiNodeGroup(), err, e.d, e.d) } e.d.Logger().WithRequest(r). @@ -277,13 +275,13 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque WithField("identity_id", i.ID). Debug("Post registration execution hooks completed successfully.") - if a.Type == flow.TypeAPI || x.IsJSONRequest(r) { + if registrationFlow.Type == flow.TypeAPI || x.IsJSONRequest(r) { span.SetAttributes(attribute.String("flow_type", string(flow.TypeAPI))) - if a.IDToken != "" { + if registrationFlow.IDToken != "" { // We don't want to redirect with the code, if the flow was submitted with an ID token. // This is the case for Sign in with native Apple SDK or Google SDK. - } else if handled, err := e.d.SessionManager().MaybeRedirectAPICodeFlow(w, r, a, s.ID, ct.ToUiNodeGroup()); err != nil { + } else if handled, err := e.d.SessionManager().MaybeRedirectAPICodeFlow(w, r, registrationFlow, s.ID, ct.ToUiNodeGroup()); err != nil { return errors.WithStack(err) } else if handled { return nil @@ -291,21 +289,21 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque e.d.Writer().Write(w, r, &APIFlowResponse{ Identity: i, - ContinueWith: a.ContinueWith(), + ContinueWith: registrationFlow.ContinueWith(), }) return nil } finalReturnTo := returnTo.String() - if a.OAuth2LoginChallenge != "" { - if a.ReturnToVerification != "" { + if registrationFlow.OAuth2LoginChallenge != "" { + if registrationFlow.ReturnToVerification != "" { // Special case: If Kratos is used as a login UI *and* we want to show the verification UI, // redirect to the verification URL first and then return to Hydra. - finalReturnTo = a.ReturnToVerification + finalReturnTo = registrationFlow.ReturnToVerification } else { callbackURL, err := e.d.Hydra().AcceptLoginRequest(r.Context(), hydra.AcceptLoginRequestParams{ - LoginChallenge: string(a.OAuth2LoginChallenge), + LoginChallenge: string(registrationFlow.OAuth2LoginChallenge), IdentityID: i.ID.String(), SessionID: s.ID.String(), AuthenticationMethods: s.AMR, @@ -316,8 +314,8 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque finalReturnTo = callbackURL } span.SetAttributes(attribute.String("redirect_reason", "oauth2 login challenge")) - } else if a.ReturnToVerification != "" { - finalReturnTo = a.ReturnToVerification + } else if registrationFlow.ReturnToVerification != "" { + finalReturnTo = registrationFlow.ReturnToVerification span.SetAttributes(attribute.String("redirect_reason", "verification requested")) } span.SetAttributes(attribute.String("return_to", finalReturnTo)) diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index d58f2615ed17..b22e06c6fcc9 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -551,10 +551,31 @@ func (s *Strategy) handleError(w http.ResponseWriter, r *http.Request, f flow.Fl } // return a new login flow with the error message embedded in the login flow. redirectURL := lf.AppendTo(s.d.Config().SelfServiceFlowLoginUI(r.Context())) - if dc, err := lf.DuplicateCredentials(); err == nil && dc != nil { + if dc, err := flow.DuplicateCredentials(lf); err == nil && dc != nil { q := redirectURL.Query() q.Set("no_org_ui", "true") redirectURL.RawQuery = q.Encode() + + for i, n := range lf.UI.Nodes { + if n.Meta == nil || n.Meta.Label == nil { + continue + } + switch n.Meta.Label.ID { + case text.InfoSelfServiceLogin: + lf.UI.Nodes[i].Meta.Label = text.NewInfoLoginAndLink() + case text.InfoSelfServiceLoginWith: + p := gjson.GetBytes(n.Meta.Label.Context, "provider").String() + lf.UI.Nodes[i].Meta.Label = text.NewInfoLoginWithAndLink(p) + } + } + + newLoginURL := s.d.Config().SelfServiceFlowLoginUI(r.Context()).String() + lf.UI.Messages.Add(text.NewInfoLoginLinkMessage(dc.DuplicateIdentifier, provider, newLoginURL)) + + err := s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), lf) + if err != nil { + return err + } } x.AcceptToRedirectOrJSON(w, r, s.d.Writer(), lf, redirectURL.String()) // ensure the function does not continue to execute @@ -638,12 +659,8 @@ func (s *Strategy) processIDToken(w http.ResponseWriter, r *http.Request, provid } func (s *Strategy) linkCredentials(ctx context.Context, i *identity.Identity, idToken, accessToken, refreshToken, provider, subject, organization string) error { - if i.Credentials == nil { - confidential, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, i.ID) - if err != nil { - return err - } - i.Credentials = confidential.Credentials + if err := s.d.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, i, identity.ExpandCredentials); err != nil { + return err } var conf identity.CredentialsOIDC creds, err := i.ParseCredentials(s.ID(), &conf) diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index a39564baf327..a278cd6dcb53 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -1087,7 +1087,7 @@ func TestStrategy(t *testing.T) { checkCredentialsLinked := func(res *http.Response, body []byte, identityID uuid.UUID, provider string) { assert.Contains(t, res.Request.URL.String(), returnTS.URL, "%s", body) - assert.Equal(t, subject, gjson.GetBytes(body, "identity.traits.subject").String(), "%s", body) + assert.Equal(t, strings.ToLower(subject), gjson.GetBytes(body, "identity.traits.subject").String(), "%s", body) i, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, identityID) require.NoError(t, err) assert.NotEmpty(t, i.Credentials["oidc"], "%+v", i.Credentials) @@ -1132,6 +1132,9 @@ func TestStrategy(t *testing.T) { CSRFToken string } + // To test that the subject is normalized properly + subject = strings.ToUpper(subject) + t.Run("step=should fail login and start a new flow", func(t *testing.T) { res, body := loginWithOIDC(t, client, loginFlow.ID, "valid") assert.True(t, res.Request.URL.Query().Has("no_org_ui")) diff --git a/text/id.go b/text/id.go index 24cca62f4a5b..fa8184f0ffaf 100644 --- a/text/id.go +++ b/text/id.go @@ -25,7 +25,9 @@ const ( InfoSelfServiceLoginContinue // 1010013 InfoSelfServiceLoginEmailWithCodeSent // 1010014 InfoSelfServiceLoginCode // 1010015 - InfoSelfServiceLoginLinkCredentials // 1010016 + InfoSelfServiceLoginLink // 1010016 + InfoSelfServiceLoginAndLink // 1010017 + InfoSelfServiceLoginWithAndLink // 1010018 ) const ( diff --git a/text/message_login.go b/text/message_login.go index 5abfab44dfb3..4918cb893701 100644 --- a/text/message_login.go +++ b/text/message_login.go @@ -56,6 +56,31 @@ func NewInfoLogin() *Message { } } +func NewInfoLoginLinkMessage(dupIdentifier, provider, newLoginURL string) *Message { + return &Message{ + ID: InfoSelfServiceLoginLink, + Type: Info, + Text: fmt.Sprintf( + "Signing in will link your account to %q at provider %q. If you do not wish to link that account, please start a new login flow.", + dupIdentifier, + provider, + ), + Context: context(map[string]any{ + "duplicateIdentifier": dupIdentifier, + "provider": provider, + "newLoginUrl": newLoginURL, + }), + } +} + +func NewInfoLoginAndLink() *Message { + return &Message{ + ID: InfoSelfServiceLoginAndLink, + Text: "Sign in and link", + Type: Info, + } +} + func NewInfoLoginTOTP() *Message { return &Message{ ID: InfoLoginTOTP, @@ -91,6 +116,18 @@ func NewInfoLoginWith(provider string) *Message { } } +func NewInfoLoginWithAndLink(provider string) *Message { + + return &Message{ + ID: InfoSelfServiceLoginWithAndLink, + Text: fmt.Sprintf("Sign in with %s and link credential", provider), + Type: Info, + Context: context(map[string]any{ + "provider": provider, + }), + } +} + func NewErrorValidationLoginFlowExpired(expiredAt time.Time) *Message { return &Message{ ID: ErrorValidationLoginFlowExpired,