diff --git a/cmd/clidoc/main.go b/cmd/clidoc/main.go index a9e96b909922..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(), @@ -144,6 +147,7 @@ func init() { "NewErrorValidationRecoveryStateFailure": text.NewErrorValidationRecoveryStateFailure(), "NewInfoNodeInputEmail": text.NewInfoNodeInputEmail(), "NewInfoNodeResendOTP": text.NewInfoNodeResendOTP(), + "NewInfoNodeLoginAndLinkCredential": text.NewInfoNodeLoginAndLinkCredential(), "NewInfoNodeLabelContinue": text.NewInfoNodeLabelContinue(), "NewInfoSelfServiceSettingsRegisterWebAuthn": text.NewInfoSelfServiceSettingsRegisterWebAuthn(), "NewInfoLoginWebAuthnPasswordless": text.NewInfoLoginWebAuthnPasswordless(), @@ -163,6 +167,7 @@ func init() { "NewInfoSelfServiceLoginCode": text.NewInfoSelfServiceLoginCode(), "NewErrorValidationRegistrationRetrySuccessful": text.NewErrorValidationRegistrationRetrySuccessful(), "NewInfoSelfServiceRegistrationRegisterCode": text.NewInfoSelfServiceRegistrationRegisterCode(), + "NewErrorValidationLoginLinkedCredentialsDoNotMatch": text.NewErrorValidationLoginLinkedCredentialsDoNotMatch(), } } diff --git a/identity/manager.go b/identity/manager.go index 11220ec15f12..9bd02ce7451c 100644 --- a/identity/manager.go +++ b/identity/manager.go @@ -110,12 +110,7 @@ func (m *Manager) Create(ctx context.Context, i *Identity, opts ...ManagerOption return nil } -func (m *Manager) findExistingAuthMethod(ctx context.Context, e error, i *Identity) (err error) { - if !m.r.Config().SelfServiceFlowRegistrationLoginHints(ctx) { - return &ErrDuplicateCredentials{error: e} - } - // First we try to find the conflict in the identifiers table. This is most likely to have a conflict. - var found *Identity +func (m *Manager) ConflictingIdentity(ctx context.Context, i *Identity) (found *Identity, foundConflictAddress string, err error) { for ct, cred := range i.Credentials { for _, id := range cred.Identifiers { found, _, err = m.r.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, ct, id) @@ -125,53 +120,65 @@ func (m *Manager) findExistingAuthMethod(ctx context.Context, e error, i *Identi // FindByCredentialsIdentifier does not expand identity credentials. if err = m.r.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, found, ExpandCredentials); err != nil { - return err + return nil, "", err } + + return found, id, nil } } // If the conflict is not in the identifiers table, it is coming from the verifiable or recovery address. - var foundConflictAddress string - if found == nil { - for _, va := range i.VerifiableAddresses { - conflictingAddress, err := m.r.PrivilegedIdentityPool().FindVerifiableAddressByValue(ctx, va.Via, va.Value) - if errors.Is(err, sqlcon.ErrNoRows) { - continue - } else if err != nil { - return err - } + for _, va := range i.VerifiableAddresses { + conflictingAddress, err := m.r.PrivilegedIdentityPool().FindVerifiableAddressByValue(ctx, va.Via, va.Value) + if errors.Is(err, sqlcon.ErrNoRows) { + continue + } else if err != nil { + return nil, "", err + } - foundConflictAddress = conflictingAddress.Value - found, err = m.r.PrivilegedIdentityPool().GetIdentity(ctx, conflictingAddress.IdentityID, ExpandCredentials) - if err != nil { - return err - } + foundConflictAddress = conflictingAddress.Value + found, err = m.r.PrivilegedIdentityPool().GetIdentity(ctx, conflictingAddress.IdentityID, ExpandCredentials) + if err != nil { + return nil, "", err } + + return found, foundConflictAddress, nil } // Last option: check the recovery address - if found == nil { - for _, va := range i.RecoveryAddresses { - conflictingAddress, err := m.r.PrivilegedIdentityPool().FindRecoveryAddressByValue(ctx, va.Via, va.Value) - if errors.Is(err, sqlcon.ErrNoRows) { - continue - } else if err != nil { - return err - } + for _, va := range i.RecoveryAddresses { + conflictingAddress, err := m.r.PrivilegedIdentityPool().FindRecoveryAddressByValue(ctx, va.Via, va.Value) + if errors.Is(err, sqlcon.ErrNoRows) { + continue + } else if err != nil { + return nil, "", err + } - foundConflictAddress = conflictingAddress.Value - found, err = m.r.PrivilegedIdentityPool().GetIdentity(ctx, conflictingAddress.IdentityID, ExpandCredentials) - if err != nil { - return err - } + foundConflictAddress = conflictingAddress.Value + found, err = m.r.PrivilegedIdentityPool().GetIdentity(ctx, conflictingAddress.IdentityID, ExpandCredentials) + if err != nil { + return nil, "", err } + + return found, foundConflictAddress, nil } - // Still not found? Return generic error. - if found == nil { + return nil, "", sqlcon.ErrNoRows +} + +func (m *Manager) findExistingAuthMethod(ctx context.Context, e error, i *Identity) (err error) { + if !m.r.Config().SelfServiceFlowRegistrationLoginHints(ctx) { return &ErrDuplicateCredentials{error: e} } + found, foundConflictAddress, err := m.ConflictingIdentity(ctx, i) + if err != nil { + if errors.Is(err, sqlcon.ErrNoRows) { + return &ErrDuplicateCredentials{error: e} + } + return err + } + // We need to sort the credentials for the error message to be deterministic. var creds []Credentials for _, cred := range found.Credentials { diff --git a/identity/manager_test.go b/identity/manager_test.go index 800d84590470..81001c9ba9c6 100644 --- a/identity/manager_test.go +++ b/identity/manager_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/ory/x/pointerx" + "github.com/ory/x/sqlcon" "github.com/gofrs/uuid" @@ -556,6 +557,68 @@ func TestManager(t *testing.T) { checkExtensionFields(fromStore, "email-updatetraits-1@ory.sh")(t) }) }) + + t.Run("method=ConflictingIdentity", func(t *testing.T) { + ctx := context.Background() + + conflicOnIdentifier := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + conflicOnIdentifier.Traits = identity.Traits(`{"email":"conflict-on-identifier@example.com"}`) + require.NoError(t, reg.IdentityManager().Create(ctx, conflicOnIdentifier)) + + conflicOnVerifiableAddress := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + conflicOnVerifiableAddress.Traits = identity.Traits(`{"email":"user-va@example.com", "email_verify":"conflict-on-va@example.com"}`) + require.NoError(t, reg.IdentityManager().Create(ctx, conflicOnVerifiableAddress)) + + conflicOnRecoveryAddress := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + conflicOnRecoveryAddress.Traits = identity.Traits(`{"email":"user-ra@example.com", "email_recovery":"conflict-on-ra@example.com"}`) + require.NoError(t, reg.IdentityManager().Create(ctx, conflicOnRecoveryAddress)) + + t.Run("case=returns not found if no conflict", func(t *testing.T) { + found, foundConflictAddress, err := reg.IdentityManager().ConflictingIdentity(ctx, &identity.Identity{ + Credentials: map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypePassword: {Identifiers: []string{"no-conflict@example.com"}}, + }, + }) + assert.ErrorIs(t, err, sqlcon.ErrNoRows) + assert.Nil(t, found) + assert.Empty(t, foundConflictAddress) + }) + + t.Run("case=conflict on identifier", func(t *testing.T) { + found, foundConflictAddress, err := reg.IdentityManager().ConflictingIdentity(ctx, &identity.Identity{ + Credentials: map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypePassword: {Identifiers: []string{"conflict-on-identifier@example.com"}}, + }, + }) + require.NoError(t, err) + assert.Equal(t, conflicOnIdentifier.ID, found.ID) + assert.Equal(t, "conflict-on-identifier@example.com", foundConflictAddress) + }) + + t.Run("case=conflict on verifiable address", func(t *testing.T) { + found, foundConflictAddress, err := reg.IdentityManager().ConflictingIdentity(ctx, &identity.Identity{ + VerifiableAddresses: []identity.VerifiableAddress{{ + Value: "conflict-on-va@example.com", + Via: "email", + }}, + }) + require.NoError(t, err) + assert.Equal(t, conflicOnVerifiableAddress.ID, found.ID) + assert.Equal(t, "conflict-on-va@example.com", foundConflictAddress) + }) + + t.Run("case=conflict on recovery address", func(t *testing.T) { + found, foundConflictAddress, err := reg.IdentityManager().ConflictingIdentity(ctx, &identity.Identity{ + RecoveryAddresses: []identity.RecoveryAddress{{ + Value: "conflict-on-ra@example.com", + Via: "email", + }}, + }) + require.NoError(t, err) + assert.Equal(t, conflicOnRecoveryAddress.ID, found.ID) + assert.Equal(t, "conflict-on-ra@example.com", foundConflictAddress) + }) + }) } func TestManagerNoDefaultNamedSchema(t *testing.T) { diff --git a/schema/errors.go b/schema/errors.go index 55957a10460c..d72e02af7d11 100644 --- a/schema/errors.go +++ b/schema/errors.go @@ -349,3 +349,13 @@ func NewLoginCodeInvalid() error { Messages: new(text.Messages).Add(text.NewErrorValidationLoginCodeInvalidOrAlreadyUsed()), }) } + +func NewLinkedCredentialsDoNotMatch() error { + return errors.WithStack(&ValidationError{ + ValidationError: &jsonschema.ValidationError{ + Message: `linked credentials do not match; please start a new flow`, + InstancePtr: "#/", + }, + Messages: new(text.Messages).Add(text.NewErrorValidationLoginLinkedCredentialsDoNotMatch()), + }) +} diff --git a/selfservice/flow/continue_with.go b/selfservice/flow/continue_with.go index 5d6eb6ff1619..a54af5abef5a 100644 --- a/selfservice/flow/continue_with.go +++ b/selfservice/flow/continue_with.go @@ -17,9 +17,8 @@ type ContinueWith any // swagger:enum ContinueWithActionSetOrySessionToken type ContinueWithActionSetOrySessionToken string -// #nosec G101 -- only a key constant const ( - ContinueWithActionSetOrySessionTokenString ContinueWithActionSetOrySessionToken = "set_ory_session_token" + ContinueWithActionSetOrySessionTokenString ContinueWithActionSetOrySessionToken = "set_ory_session_token" // #nosec G101 -- only a key constant ) var _ ContinueWith = new(ContinueWithSetOrySessionToken) diff --git a/selfservice/flow/duplicate_credentials.go b/selfservice/flow/duplicate_credentials.go new file mode 100644 index 000000000000..ddba57251ed2 --- /dev/null +++ b/selfservice/flow/duplicate_credentials.go @@ -0,0 +1,61 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +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 + SetInternalContext(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.SetInternalContext(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 be486e3723ae..ee9fbba6638d 100644 --- a/selfservice/flow/flow.go +++ b/selfservice/flow/flow.go @@ -8,15 +8,13 @@ import ( "net/http" "net/url" + "github.com/gofrs/uuid" "github.com/pkg/errors" "github.com/ory/herodot" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/ui/container" "github.com/ory/kratos/x" - - "github.com/gofrs/uuid" - "github.com/ory/x/urlx" ) diff --git a/selfservice/flow/login/flow.go b/selfservice/flow/login/flow.go index 1bd95c7b1fad..5dd8422cdb84 100644 --- a/selfservice/flow/login/flow.go +++ b/selfservice/flow/login/flow.go @@ -231,6 +231,14 @@ func (f *Flow) EnsureInternalContext() { } } +func (f *Flow) GetInternalContext() sqlxx.JSONRawMessage { + return f.InternalContext +} + +func (f *Flow) SetInternalContext(bytes sqlxx.JSONRawMessage) { + f.InternalContext = bytes +} + func (f Flow) MarshalJSON() ([]byte, error) { type local Flow f.SetReturnTo() 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/handler.go b/selfservice/flow/login/handler.go index 9efb5f0844db..096a657c0869 100644 --- a/selfservice/flow/login/handler.go +++ b/selfservice/flow/login/handler.go @@ -100,6 +100,12 @@ func WithFlowReturnTo(returnTo string) FlowOption { } } +func WithInternalContext(internalContext []byte) FlowOption { + return func(f *Flow) { + f.InternalContext = internalContext + } +} + func WithFormErrorMessage(messages []text.Message) FlowOption { return func(f *Flow) { for i := range messages { @@ -793,7 +799,7 @@ continueLogin: } method := ss.CompletedAuthenticationMethod(r.Context()) - sess.CompletedLoginFor(method.Method, method.AAL) + sess.CompletedLoginForMethod(method) i = interim break } diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go index 1bff4122ad1f..2af0c3daf1fc 100644 --- a/selfservice/flow/login/hook.go +++ b/selfservice/flow/login/hook.go @@ -9,22 +9,22 @@ import ( "net/http" "time" + "github.com/gofrs/uuid" + "github.com/pkg/errors" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/ory/kratos/x/events" - - "github.com/pkg/errors" - "github.com/ory/kratos/driver/config" "github.com/ory/kratos/hydra" "github.com/ory/kratos/identity" + "github.com/ory/kratos/schema" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/session" "github.com/ory/kratos/ui/container" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" + "github.com/ory/kratos/x/events" "github.com/ory/x/otelx" ) @@ -47,6 +47,7 @@ type ( executorDependencies interface { config.Provider hydra.Provider + identity.PrivilegedPoolProvider session.ManagementProvider session.PersistenceProvider x.CSRFTokenGeneratorProvider @@ -55,7 +56,9 @@ type ( x.TracingProvider sessiontokenexchange.PersistenceProvider + FlowPersistenceProvider HooksProvider + StrategyProvider } HookExecutor struct { d executorDependencies @@ -129,6 +132,10 @@ func (e *HookExecutor) PostLoginHook( r = r.WithContext(ctx) defer otelx.End(span, &err) + if err := e.maybeLinkCredentials(r.Context(), s, i, a); err != nil { + return err + } + if err := s.Activate(r, i, e.d.Config(), time.Now().UTC()); err != nil { return err } @@ -321,3 +328,51 @@ func (e *HookExecutor) PreLoginHook(w http.ResponseWriter, r *http.Request, a *F return nil } + +// maybeLinkCredentials links the identity with the credentials of the inner context of the login flow. +func (e *HookExecutor) maybeLinkCredentials(ctx context.Context, sess *session.Session, ident *identity.Identity, loginFlow *Flow) error { + lc, err := flow.DuplicateCredentials(loginFlow) + if err != nil { + return err + } else if lc == nil { + return nil + } + + if err := e.checkDuplicateCredentialsIdentifierMatch(ctx, ident.ID, lc.DuplicateIdentifier); err != nil { + return err + } + strategy, err := e.d.AllLoginStrategies().Strategy(lc.CredentialsType) + if err != nil { + return err + } + + linkableStrategy, ok := strategy.(LinkableStrategy) + if !ok { + // This should never happen because we check for this in the registration flow. + return errors.Errorf("strategy is not linkable: %T", linkableStrategy) + } + + if err := linkableStrategy.Link(ctx, ident, lc.CredentialsConfig); err != nil { + return err + } + + method := strategy.CompletedAuthenticationMethod(ctx) + sess.CompletedLoginForMethod(method) + + return nil +} + +func (e *HookExecutor) checkDuplicateCredentialsIdentifierMatch(ctx context.Context, identityID uuid.UUID, match string) error { + i, err := e.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, identityID) + if err != nil { + return err + } + for _, credentials := range i.Credentials { + for _, identifier := range credentials.Identifiers { + if identifier == match { + return nil + } + } + } + return schema.NewLinkedCredentialsDoNotMatch() +} diff --git a/selfservice/flow/login/hook_test.go b/selfservice/flow/login/hook_test.go index ea3bd84bac72..052973317ca2 100644 --- a/selfservice/flow/login/hook_test.go +++ b/selfservice/flow/login/hook_test.go @@ -12,14 +12,15 @@ import ( "github.com/stretchr/testify/require" - "github.com/ory/kratos/hydra" - "github.com/ory/kratos/session" - "github.com/gobuffalo/httptest" "github.com/julienschmidt/httprouter" "github.com/stretchr/testify/assert" "github.com/tidwall/gjson" + "github.com/ory/kratos/hydra" + "github.com/ory/kratos/schema" + "github.com/ory/kratos/session" + "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" @@ -256,6 +257,58 @@ func TestLoginExecutor(t *testing.T) { assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "/self-service/login/browser?aal=aal2", "%s", body) }) }) + + }) + t.Run("case=maybe links credential", func(t *testing.T) { + t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) + + email := testhelpers.RandomEmail() + useIdentity := &identity.Identity{Credentials: map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypePassword: { + Type: identity.CredentialsTypePassword, + Config: []byte(`{"hashed_password": "$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`), + Identifiers: []string{email}, + }, + }} + require.NoError(t, reg.Persister().CreateIdentity(context.Background(), useIdentity)) + + credsOIDC, err := identity.NewCredentialsOIDC( + "id-token", + "access-token", + "refresh-token", + "my-provider", + email, + "", + ) + require.NoError(t, err) + + t.Run("sub-case=links matching identity", func(t *testing.T) { + res, _ := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity, func(l *login.Flow) { + require.NoError(t, flow.SetDuplicateCredentials(l, flow.DuplicateCredentialsData{ + CredentialsType: identity.CredentialsTypeOIDC, + CredentialsConfig: credsOIDC.Config, + DuplicateIdentifier: email, + })) + }), false, url.Values{}) + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assert.EqualValues(t, "https://www.ory.sh/", res.Request.URL.String()) + + ident, err := reg.Persister().GetIdentity(ctx, useIdentity.ID, identity.ExpandCredentials) + require.NoError(t, err) + assert.Equal(t, 2, len(ident.Credentials)) + }) + + t.Run("sub-case=errors on non-matching identity", func(t *testing.T) { + res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity, func(l *login.Flow) { + require.NoError(t, flow.SetDuplicateCredentials(l, flow.DuplicateCredentialsData{ + CredentialsType: identity.CredentialsTypeOIDC, + CredentialsConfig: credsOIDC.Config, + DuplicateIdentifier: "wrong@example.com", + })) + }), false, url.Values{}) + assert.EqualValues(t, http.StatusInternalServerError, res.StatusCode) + assert.Equal(t, schema.NewLinkedCredentialsDoNotMatch().Error(), body, "%s", body) + }) }) t.Run("type=api", func(t *testing.T) { diff --git a/selfservice/flow/login/strategy.go b/selfservice/flow/login/strategy.go index 818ecfea9cf0..c8ad84986a55 100644 --- a/selfservice/flow/login/strategy.go +++ b/selfservice/flow/login/strategy.go @@ -8,14 +8,13 @@ import ( "net/http" "github.com/gofrs/uuid" - - "github.com/ory/kratos/session" - "github.com/pkg/errors" "github.com/ory/kratos/identity" + "github.com/ory/kratos/session" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" + "github.com/ory/x/sqlxx" ) type Strategy interface { @@ -29,6 +28,10 @@ type Strategy interface { type Strategies []Strategy +type LinkableStrategy interface { + Link(ctx context.Context, i *identity.Identity, credentials sqlxx.JSONRawMessage) error +} + func (s Strategies) Strategy(id identity.CredentialsType) (Strategy, error) { ids := make([]identity.CredentialsType, len(s)) for k, ss := range s { diff --git a/selfservice/flow/registration/flow.go b/selfservice/flow/registration/flow.go index 494bf72383af..b3dae8a6a1c5 100644 --- a/selfservice/flow/registration/flow.go +++ b/selfservice/flow/registration/flow.go @@ -11,25 +11,19 @@ import ( "time" "github.com/gobuffalo/pop/v6" - + "github.com/gofrs/uuid" + "github.com/pkg/errors" "github.com/tidwall/gjson" - "github.com/ory/x/sqlxx" - hydraclientgo "github.com/ory/hydra-client-go/v2" - "github.com/ory/kratos/driver/config" "github.com/ory/kratos/hydra" - "github.com/ory/kratos/ui/container" - - "github.com/gofrs/uuid" - "github.com/pkg/errors" - - "github.com/ory/x/urlx" - "github.com/ory/kratos/identity" "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/ui/container" "github.com/ory/kratos/x" + "github.com/ory/x/sqlxx" + "github.com/ory/x/urlx" ) // swagger:model registrationFlow @@ -208,6 +202,14 @@ func (f *Flow) EnsureInternalContext() { } } +func (f *Flow) GetInternalContext() sqlxx.JSONRawMessage { + return f.InternalContext +} + +func (f *Flow) SetInternalContext(bytes sqlxx.JSONRawMessage) { + f.InternalContext = bytes +} + func (f Flow) MarshalJSON() ([]byte, error) { type local Flow f.SetReturnTo() diff --git a/selfservice/flow/registration/handler.go b/selfservice/flow/registration/handler.go index 2857fa459ee5..8cfe59e4d6d9 100644 --- a/selfservice/flow/registration/handler.go +++ b/selfservice/flow/registration/handler.go @@ -9,30 +9,25 @@ import ( "time" "github.com/gofrs/uuid" - - "github.com/ory/herodot" - "github.com/ory/kratos/hydra" - "github.com/ory/kratos/selfservice/sessiontokenexchange" - "github.com/ory/kratos/text" - "github.com/ory/nosurf" - - "github.com/ory/kratos/schema" - - "github.com/ory/kratos/identity" - "github.com/ory/kratos/ui/node" - "github.com/julienschmidt/httprouter" "github.com/pkg/errors" - "github.com/ory/x/sqlxx" - "github.com/ory/x/urlx" - + "github.com/ory/herodot" hydraclientgo "github.com/ory/hydra-client-go/v2" "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/hydra" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/schema" "github.com/ory/kratos/selfservice/errorx" "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/session" + "github.com/ory/kratos/text" + "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" + "github.com/ory/nosurf" + "github.com/ory/x/sqlxx" + "github.com/ory/x/urlx" ) const ( diff --git a/selfservice/flow/registration/hook.go b/selfservice/flow/registration/hook.go index f7a7fed1d99f..6a997009c1c5 100644 --- a/selfservice/flow/registration/hook.go +++ b/selfservice/flow/registration/hook.go @@ -9,23 +9,21 @@ import ( "net/http" "time" - "go.opentelemetry.io/otel/attribute" - - "github.com/ory/x/otelx" - "github.com/julienschmidt/httprouter" - - "github.com/ory/kratos/selfservice/sessiontokenexchange" - "github.com/ory/kratos/x/events" - "github.com/pkg/errors" + "go.opentelemetry.io/otel/attribute" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/hydra" "github.com/ory/kratos/identity" "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/session" "github.com/ory/kratos/x" + "github.com/ory/kratos/x/events" + "github.com/ory/x/otelx" + "github.com/ory/x/sqlcon" ) type ( @@ -75,10 +73,14 @@ type ( executorDependencies interface { config.Provider identity.ManagementProvider + identity.PrivilegedPoolProvider identity.ValidationProvider + login.FlowPersistenceProvider + login.StrategyProvider session.PersistenceProvider session.ManagementProvider HooksProvider + FlowPersistenceProvider hydra.Provider x.CSRFTokenGeneratorProvider x.HTTPClientProvider @@ -99,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) @@ -111,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). @@ -135,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). @@ -153,14 +155,36 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque // We're now creating the identity because any of the hooks could trigger a "redirect" or a "session" which // would imply that the identity has to exist already. } else if err := e.d.IdentityManager().Create(r.Context(), i); err != nil { + if errors.Is(err, sqlcon.ErrUniqueViolation) { + strategy, err := e.d.AllLoginStrategies().Strategy(ct) + if err != nil { + return err + } + + if _, ok := strategy.(login.LinkableStrategy); ok { + duplicateIdentifier, err := e.getDuplicateIdentifier(r.Context(), i) + if err != nil { + return err + } + registrationDuplicateCredentials := flow.DuplicateCredentialsData{ + CredentialsType: ct, + CredentialsConfig: i.Credentials[ct].Config, + DuplicateIdentifier: duplicateIdentifier, + } + + if err := flow.SetDuplicateCredentials(registrationFlow, registrationDuplicateCredentials); err != nil { + return err + } + } + } return err } // 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())), @@ -179,7 +203,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() @@ -201,7 +225,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). @@ -230,7 +254,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). @@ -248,13 +272,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 @@ -262,21 +286,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, @@ -287,8 +311,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)) @@ -297,6 +321,14 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque return nil } +func (e *HookExecutor) getDuplicateIdentifier(ctx context.Context, i *identity.Identity) (string, error) { + _, id, err := e.d.IdentityManager().ConflictingIdentity(ctx, i) + if err != nil { + return "", err + } + return id, nil +} + func (e *HookExecutor) PreRegistrationHook(w http.ResponseWriter, r *http.Request, a *Flow) error { for _, executor := range e.d.PreRegistrationHooks(r.Context()) { if err := executor.ExecuteRegistrationPreHook(w, r, a); err != nil { diff --git a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod.json b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod.json index 9a93294a6040..04eba3e43565 100644 --- a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod.json +++ b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod.json @@ -36,6 +36,28 @@ } } }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "secondProvider", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010002, + "text": "Sign in with secondProvider", + "type": "info", + "context": { + "provider": "secondProvider" + } + } + } + }, { "type": "input", "group": "oidc", diff --git a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateSignUpMethod.json b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateSignUpMethod.json index 699d8d10ec62..e9b5dc03f8e6 100644 --- a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateSignUpMethod.json +++ b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateSignUpMethod.json @@ -36,6 +36,28 @@ } } }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "secondProvider", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1040002, + "text": "Sign up with secondProvider", + "type": "info", + "context": { + "provider": "secondProvider" + } + } + } + }, { "type": "input", "group": "oidc", diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index 8031868f993f..2e536c8a35cf 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -11,9 +11,12 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "path/filepath" "strings" + "github.com/ory/x/urlx" + "go.opentelemetry.io/otel/attribute" "golang.org/x/oauth2" @@ -544,13 +547,48 @@ func (s *Strategy) handleError(w http.ResponseWriter, r *http.Request, f flow.Fl // This is kinda hacky and will probably need to be updated at some point. if dup := new(identity.ErrDuplicateCredentials); errors.As(err, &dup) { - rf.UI.Messages.Add(text.NewErrorValidationDuplicateCredentialsOnOIDCLink()) + err = schema.NewDuplicateCredentialsError(dup) + + if validationErr := new(schema.ValidationError); errors.As(err, &validationErr) { + for _, m := range validationErr.Messages { + m := m + rf.UI.Messages.Add(&m) + } + } else { + rf.UI.Messages.Add(text.NewErrorValidationDuplicateCredentialsOnOIDCLink()) + } + lf, err := s.registrationToLogin(w, r, rf, provider) if err != nil { return err } // return a new login flow with the error message embedded in the login flow. - x.AcceptToRedirectOrJSON(w, r, s.d.Writer(), lf, lf.AppendTo(s.d.Config().SelfServiceFlowLoginUI(r.Context())).String()) + redirectURL := lf.AppendTo(s.d.Config().SelfServiceFlowLoginUI(r.Context())) + if dc, err := flow.DuplicateCredentials(lf); err == nil && dc != nil { + redirectURL = urlx.CopyWithQuery(redirectURL, url.Values{"no_org_ui": {"true"}}) + + 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 return flow.ErrCompletedByStrategy } @@ -630,3 +668,40 @@ func (s *Strategy) processIDToken(w http.ResponseWriter, r *http.Request, provid return claims, nil } + +func (s *Strategy) linkCredentials(ctx context.Context, i *identity.Identity, idToken, accessToken, refreshToken, provider, subject, organization string) error { + 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) + if errors.Is(err, herodot.ErrNotFound) { + var err error + if creds, err = identity.NewCredentialsOIDC(idToken, accessToken, refreshToken, provider, subject, organization); err != nil { + return err + } + } else if err != nil { + return err + } else { + creds.Identifiers = append(creds.Identifiers, identity.OIDCUniqueID(provider, subject)) + conf.Providers = append(conf.Providers, identity.CredentialsOIDCProvider{ + Subject: subject, Provider: provider, + InitialAccessToken: accessToken, + InitialRefreshToken: refreshToken, + InitialIDToken: idToken, + Organization: organization, + }) + + creds.Config, err = json.Marshal(conf) + if err != nil { + return err + } + } + + i.Credentials[s.ID()] = *creds + if orgID, err := uuid.FromString(organization); err == nil { + i.OrganizationID = uuid.NullUUID{UUID: orgID, Valid: true} + } + + return nil +} diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go index b6747ef9ad0a..d07e6c6fa8ed 100644 --- a/selfservice/strategy/oidc/strategy_registration.go +++ b/selfservice/strategy/oidc/strategy_registration.go @@ -261,6 +261,8 @@ func (s *Strategy) registrationToLogin(w http.ResponseWriter, r *http.Request, r opts = append(opts, login.WithFormErrorMessage(rf.UI.Messages)) } + opts = append(opts, login.WithInternalContext(rf.InternalContext)) + lf, _, err := s.d.LoginHandler().NewLoginFlow(w, r, rf.Type, opts...) if err != nil { return nil, err diff --git a/selfservice/strategy/oidc/strategy_settings.go b/selfservice/strategy/oidc/strategy_settings.go index 513f5cccdabe..a38e4e0b40d9 100644 --- a/selfservice/strategy/oidc/strategy_settings.go +++ b/selfservice/strategy/oidc/strategy_settings.go @@ -11,6 +11,8 @@ import ( "net/http" "time" + "github.com/ory/x/sqlxx" + "github.com/tidwall/sjson" "golang.org/x/oauth2" @@ -412,31 +414,10 @@ func (s *Strategy) linkProvider(w http.ResponseWriter, r *http.Request, ctxUpdat return s.handleSettingsError(w, r, ctxUpdate, p, err) } - var conf identity.CredentialsOIDC - creds, err := i.ParseCredentials(s.ID(), &conf) - if errors.Is(err, herodot.ErrNotFound) { - var err error - if creds, err = identity.NewCredentialsOIDC(it, cat, crt, provider.Config().ID, claims.Subject, ""); err != nil { - return s.handleSettingsError(w, r, ctxUpdate, p, err) - } - } else if err != nil { + if err := s.linkCredentials(r.Context(), i, it, cat, crt, provider.Config().ID, claims.Subject, provider.Config().OrganizationID); err != nil { return s.handleSettingsError(w, r, ctxUpdate, p, err) - } else { - creds.Identifiers = append(creds.Identifiers, identity.OIDCUniqueID(provider.Config().ID, claims.Subject)) - conf.Providers = append(conf.Providers, identity.CredentialsOIDCProvider{ - Subject: claims.Subject, Provider: provider.Config().ID, - InitialAccessToken: cat, - InitialRefreshToken: crt, - InitialIDToken: it, - }) - - creds.Config, err = json.Marshal(conf) - if err != nil { - return s.handleSettingsError(w, r, ctxUpdate, p, err) - } } - i.Credentials[s.ID()] = *creds if err := s.d.SettingsHookExecutor().PostSettingsHook(w, r, s.SettingsStrategyID(), ctxUpdate, i, settings.WithCallback(func(ctxUpdate *settings.UpdateContext) error { return s.PopulateSettingsMethod(r, ctxUpdate.Session.Identity, ctxUpdate.Flow) })); err != nil { @@ -533,3 +514,34 @@ func (s *Strategy) handleSettingsError(w http.ResponseWriter, r *http.Request, c return err } + +func (s *Strategy) Link(ctx context.Context, i *identity.Identity, credentialsConfig sqlxx.JSONRawMessage) error { + var credentialsOIDCConfig identity.CredentialsOIDC + if err := json.Unmarshal(credentialsConfig, &credentialsOIDCConfig); err != nil { + return err + } + if len(credentialsOIDCConfig.Providers) != 1 { + return errors.New("No oidc provider was set") + } + var credentialsOIDCProvider = credentialsOIDCConfig.Providers[0] + + if err := s.linkCredentials( + ctx, + i, + credentialsOIDCProvider.InitialIDToken, + credentialsOIDCProvider.InitialAccessToken, + credentialsOIDCProvider.InitialRefreshToken, + credentialsOIDCProvider.Provider, + credentialsOIDCProvider.Subject, + credentialsOIDCProvider.Organization, + ); err != nil { + return err + } + + options := []identity.ManagerOption{identity.ManagerAllowWriteProtectedTraits} + if err := s.d.IdentityManager().Update(ctx, i, options...); err != nil { + return err + } + + return nil +} diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index 5d0a542ea7bd..cdbff20e0477 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -13,10 +13,13 @@ import ( "net/http/cookiejar" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" + "github.com/ory/x/sqlxx" + "github.com/ory/kratos/hydra" "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/session" @@ -75,6 +78,7 @@ func TestStrategy(t *testing.T) { t, conf, newOIDCProvider(t, ts, remotePublic, remoteAdmin, "valid"), + newOIDCProvider(t, ts, remotePublic, remoteAdmin, "secondProvider"), oidc.Configuration{ Provider: "generic", ID: "invalid-issuer", @@ -217,8 +221,8 @@ func TestStrategy(t *testing.T) { var assertSystemErrorWithReason = func(t *testing.T, res *http.Response, body []byte, code int, reason string) { require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body) - assert.Equal(t, int64(code), gjson.GetBytes(body, "code").Int(), "%s", body) - assert.Contains(t, gjson.GetBytes(body, "reason").String(), reason, "%s", body) + assert.Equal(t, int64(code), gjson.GetBytes(body, "code").Int(), "%s", prettyJSON(t, body)) + assert.Contains(t, gjson.GetBytes(body, "reason").String(), reason, "%s", prettyJSON(t, body)) } // assert system error (redirect to error endpoint) @@ -232,15 +236,15 @@ func TestStrategy(t *testing.T) { // assert ui error (redirect to login/registration ui endpoint) var assertUIError = func(t *testing.T, res *http.Response, body []byte, reason string) { require.Contains(t, res.Request.URL.String(), uiTS.URL, "status: %d, body: %s", res.StatusCode, body) - assert.Contains(t, gjson.GetBytes(body, "ui.messages.0.text").String(), reason, "%s", body) + assert.Contains(t, gjson.GetBytes(body, "ui.messages.0.text").String(), reason, "%s", prettyJSON(t, body)) } // assert identity (success) var assertIdentity = func(t *testing.T, res *http.Response, body []byte) { - assert.Contains(t, res.Request.URL.String(), returnTS.URL) - assert.Equal(t, subject, gjson.GetBytes(body, "identity.traits.subject").String(), "%s", body) - assert.Equal(t, claims.traits.website, gjson.GetBytes(body, "identity.traits.website").String(), "%s", body) - assert.Equal(t, claims.metadataPublic.picture, gjson.GetBytes(body, "identity.metadata_public.picture").String(), "%s", body) + assert.Contains(t, res.Request.URL.String(), returnTS.URL, "%s", body) + assert.Equal(t, subject, gjson.GetBytes(body, "identity.traits.subject").String(), "%s", prettyJSON(t, body)) + assert.Equal(t, claims.traits.website, gjson.GetBytes(body, "identity.traits.website").String(), "%s", prettyJSON(t, body)) + assert.Equal(t, claims.metadataPublic.picture, gjson.GetBytes(body, "identity.metadata_public.picture").String(), "%s", prettyJSON(t, body)) } var newLoginFlow = func(t *testing.T, requestURL string, exp time.Duration, flowType flow.Type) (req *login.Flow) { @@ -433,23 +437,25 @@ func TestStrategy(t *testing.T) { }) }) + expectTokens := func(t *testing.T, provider string, body []byte) uuid.UUID { + id := uuid.FromStringOrNil(gjson.GetBytes(body, "identity.id").String()) + i, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), id) + require.NoError(t, err) + c := i.Credentials[identity.CredentialsTypeOIDC].Config + assert.NotEmpty(t, gjson.GetBytes(c, "providers.0.initial_access_token").String()) + assertx.EqualAsJSONExcept( + t, + json.RawMessage(fmt.Sprintf(`{"providers": [{"subject":"%s","provider":"%s"}]}`, subject, provider)), + json.RawMessage(c), + []string{"providers.0.initial_id_token", "providers.0.initial_access_token", "providers.0.initial_refresh_token"}, + ) + return id + } + t.Run("case=register and then login", func(t *testing.T) { subject = "register-then-login@ory.sh" scope = []string{"openid", "offline"} - expectTokens := func(t *testing.T, provider string, body []byte) { - i, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "identity.id").String())) - require.NoError(t, err) - c := i.Credentials[identity.CredentialsTypeOIDC].Config - assert.NotEmpty(t, gjson.GetBytes(c, "providers.0.initial_access_token").String()) - assertx.EqualAsJSONExcept( - t, - json.RawMessage(fmt.Sprintf(`{"providers": [{"subject":"%s","provider":"%s"}]}`, subject, provider)), - json.RawMessage(c), - []string{"providers.0.initial_id_token", "providers.0.initial_access_token", "providers.0.initial_refresh_token"}, - ) - } - t.Run("case=should pass registration", func(t *testing.T) { r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) action := assertFormValues(t, r.ID, "valid") @@ -879,7 +885,7 @@ func TestStrategy(t *testing.T) { r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - assertUIError(t, res, body, "An account with the same identifier (email, phone, username, ...) exists already. Please sign in to your existing account and link your social profile in the settings page.") + assertUIError(t, res, body, "An account with the same identifier (email, phone, username, ...) exists already.") require.Contains(t, gjson.GetBytes(body, "ui.action").String(), "/self-service/login") }) @@ -1068,6 +1074,158 @@ func TestStrategy(t *testing.T) { }) }) + t.Run("case=registration should start new login flow if duplicate credentials detected", func(t *testing.T) { + require.NoError(t, reg.Config().Set(ctx, config.ViperKeySelfServiceRegistrationLoginHints, true)) + loginWithOIDC := func(t *testing.T, c *http.Client, flowID uuid.UUID, provider string) (*http.Response, []byte) { + action := assertFormValues(t, flowID, provider) + res, err := c.PostForm(action, url.Values{"provider": {provider}}) + require.NoError(t, err, action) + body, err := io.ReadAll(res.Body) + require.NoError(t, res.Body.Close()) + require.NoError(t, err) + return res, body + } + + 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, 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) + assert.Equal(t, provider, gjson.GetBytes(i.Credentials["oidc"].Config, "providers.0.provider").String(), + "%s", string(i.Credentials["oidc"].Config[:])) + assert.Contains(t, gjson.GetBytes(body, "authentication_methods").String(), "oidc", "%s", body) + } + + t.Run("case=second login is password", func(t *testing.T) { + subject = "new-login-if-email-exist-with-password-strategy@ory.sh" + subject2 := "new-login-subject2@ory.sh" + scope = []string{"openid"} + password := "lwkj52sdkjf" + + var i *identity.Identity + t.Run("step=create password identity", func(t *testing.T) { + i = identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + p, err := reg.Hasher(ctx).Generate(ctx, []byte(password)) + require.NoError(t, err) + i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Identifiers: []string{subject}, + Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`), + }) + i.Traits = identity.Traits(`{"subject":"` + subject + `"}`) + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i)) + + i2 := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + i2.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Identifiers: []string{subject2}, + Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`), + }) + i2.Traits = identity.Traits(`{"subject":"` + subject2 + `"}`) + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i2)) + }) + + client := testhelpers.NewClientWithCookieJar(t, nil, false) + loginFlow := newLoginFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser) + + var linkingLoginFlow struct { + ID string + UIAction string + 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")) + assertUIError(t, res, body, "You tried signing in with new-login-if-email-exist-with-password-strategy@ory.sh which is already in use by another account. You can sign in using your password.") + assert.Equal(t, "password", gjson.GetBytes(body, "ui.messages.#(id==4000028).context.available_credential_types.0").String()) + assert.Equal(t, "new-login-if-email-exist-with-password-strategy@ory.sh", gjson.GetBytes(body, "ui.messages.#(id==4000028).context.credential_identifier_hint").String()) + linkingLoginFlow.ID = gjson.GetBytes(body, "id").String() + linkingLoginFlow.UIAction = gjson.GetBytes(body, "ui.action").String() + linkingLoginFlow.CSRFToken = gjson.GetBytes(body, `ui.nodes.#(attributes.name=="csrf_token").attributes.value`).String() + assert.NotEqual(t, loginFlow.ID.String(), linkingLoginFlow.ID, "should have started a new flow") + }) + + t.Run("step=should fail login if existing identity identifier doesn't match", func(t *testing.T) { + res, err := client.PostForm(linkingLoginFlow.UIAction, url.Values{ + "csrf_token": {linkingLoginFlow.CSRFToken}, + "method": {"password"}, + "identifier": {subject2}, + "password": {password}}) + require.NoError(t, err, linkingLoginFlow.UIAction) + body, err := io.ReadAll(res.Body) + require.NoError(t, res.Body.Close()) + require.NoError(t, err) + assert.Equal(t, + strconv.Itoa(int(text.ErrorValidationLoginLinkedCredentialsDoNotMatch)), + gjson.GetBytes(body, "ui.messages.0.id").String(), + prettyJSON(t, body), + ) + }) + + t.Run("step=should link oidc credentials to existing identity", func(t *testing.T) { + res, err := client.PostForm(linkingLoginFlow.UIAction, url.Values{ + "csrf_token": {linkingLoginFlow.CSRFToken}, + "method": {"password"}, + "identifier": {subject}, + "password": {password}}) + require.NoError(t, err, linkingLoginFlow.UIAction) + body, err := io.ReadAll(res.Body) + require.NoError(t, res.Body.Close()) + require.NoError(t, err) + checkCredentialsLinked(res, body, i.ID, "valid") + }) + }) + + t.Run("case=second login is OIDC", func(t *testing.T) { + email1 := "existing-oidc-identity-1@ory.sh" + email2 := "existing-oidc-identity-2@ory.sh" + scope = []string{"openid", "offline"} + + var identityID uuid.UUID + t.Run("step=create OIDC identity", func(t *testing.T) { + subject = email1 + r := newRegistrationFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser) + action := assertFormValues(t, r.ID, "secondProvider") + res, body := makeRequest(t, "secondProvider", action, url.Values{}) + assertIdentity(t, res, body) + identityID = expectTokens(t, "secondProvider", body) + + subject = email2 + r = newRegistrationFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser) + action = assertFormValues(t, r.ID, "valid") + res, body = makeRequest(t, "valid", action, url.Values{}) + assertIdentity(t, res, body) + expectTokens(t, "valid", body) + }) + + subject = email1 + client := testhelpers.NewClientWithCookieJar(t, nil, false) + loginFlow := newLoginFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser) + var linkingLoginFlow struct{ ID string } + t.Run("step=should fail login and start a new login", func(t *testing.T) { + res, body := loginWithOIDC(t, client, loginFlow.ID, "valid") + assertUIError(t, res, body, "You tried signing in with existing-oidc-identity-1@ory.sh which is already in use by another account. You can sign in using social sign in. You can sign in using one of the following social sign in providers: Secondprovider.") + linkingLoginFlow.ID = gjson.GetBytes(body, "id").String() + assert.NotEqual(t, loginFlow.ID.String(), linkingLoginFlow.ID, "should have started a new flow") + }) + + subject = email2 + t.Run("step=should fail login if existing identity identifier doesn't match", func(t *testing.T) { + res, body := loginWithOIDC(t, client, uuid.Must(uuid.FromString(linkingLoginFlow.ID)), "valid") + assertUIError(t, res, body, "Linked credentials do not match.") + }) + + subject = email1 + t.Run("step=should link oidc credentials to existing identity", func(t *testing.T) { + res, body := loginWithOIDC(t, client, uuid.Must(uuid.FromString(linkingLoginFlow.ID)), "secondProvider") + checkCredentialsLinked(res, body, identityID, "secondProvider") + }) + }) + }) + t.Run("method=TestPopulateSignUpMethod", func(t *testing.T) { conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "https://foo/") @@ -1089,6 +1247,13 @@ func TestStrategy(t *testing.T) { }) } +func prettyJSON(t *testing.T, body []byte) string { + var out bytes.Buffer + require.NoError(t, json.Indent(&out, body, "", "\t")) + + return out.String() +} + func TestCountActiveFirstFactorCredentials(t *testing.T) { _, reg := internal.NewFastRegistryWithMocks(t) strategy := oidc.NewStrategy(reg) diff --git a/selfservice/strategy/password/registration_test.go b/selfservice/strategy/password/registration_test.go index 2311741daad7..ddcf26a20883 100644 --- a/selfservice/strategy/password/registration_test.go +++ b/selfservice/strategy/password/registration_test.go @@ -5,6 +5,7 @@ package password_test import ( "context" + _ "embed" "fmt" "net/http" "net/http/httptest" @@ -31,8 +32,6 @@ import ( "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/x/assertx" - _ "embed" - "github.com/ory/kratos/x" ) diff --git a/session/manager_http.go b/session/manager_http.go index e1c31f94f03f..fbd6574e0b2c 100644 --- a/session/manager_http.go +++ b/session/manager_http.go @@ -364,7 +364,7 @@ func (s *ManagerHTTP) SessionAddAuthenticationMethods(ctx context.Context, sid u return err } for _, m := range ams { - sess.CompletedLoginFor(m.Method, m.AAL) + sess.CompletedLoginForMethod(m) } sess.SetAuthenticatorAssuranceLevel() return s.r.SessionPersister().UpsertSession(ctx, sess) diff --git a/session/session.go b/session/session.go index 74204be4215c..d11a05e3bf05 100644 --- a/session/session.go +++ b/session/session.go @@ -164,15 +164,19 @@ func (s Session) TableName(ctx context.Context) string { return "sessions" } +func (s *Session) CompletedLoginForMethod(method AuthenticationMethod) { + method.CompletedAt = time.Now().UTC() + s.AMR = append(s.AMR, method) +} + func (s *Session) CompletedLoginFor(method identity.CredentialsType, aal identity.AuthenticatorAssuranceLevel) { - s.AMR = append(s.AMR, AuthenticationMethod{Method: method, AAL: aal, CompletedAt: time.Now().UTC()}) + s.CompletedLoginForMethod(AuthenticationMethod{Method: method, AAL: aal}) } func (s *Session) CompletedLoginForWithProvider(method identity.CredentialsType, aal identity.AuthenticatorAssuranceLevel, providerID string, organizationID string) { - s.AMR = append(s.AMR, AuthenticationMethod{ + s.CompletedLoginForMethod(AuthenticationMethod{ Method: method, AAL: aal, - CompletedAt: time.Now().UTC(), Provider: providerID, Organization: organizationID, }) diff --git a/test/e2e/cypress/integration/profiles/oidc/login/success.spec.ts b/test/e2e/cypress/integration/profiles/oidc/login/success.spec.ts index 51ccf8574a6f..866f4344eda6 100644 --- a/test/e2e/cypress/integration/profiles/oidc/login/success.spec.ts +++ b/test/e2e/cypress/integration/profiles/oidc/login/success.spec.ts @@ -1,6 +1,6 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { gen, website } from "../../../../helpers" +import { appPrefix, gen, website } from "../../../../helpers" import { routes as express } from "../../../../helpers/express" import { routes as react } from "../../../../helpers/react" @@ -9,16 +9,18 @@ context("Social Sign In Successes", () => { { login: react.login, registration: react.registration, + settings: react.settings, app: "react" as "react", profile: "spa", }, { login: express.login, registration: express.registration, + settings: express.settings, app: "express" as "express", profile: "oidc", }, - ].forEach(({ login, registration, profile, app }) => { + ].forEach(({ login, registration, profile, app, settings }) => { describe(`for app ${app}`, () => { before(() => { cy.useConfigProfile(profile) @@ -37,6 +39,40 @@ context("Social Sign In Successes", () => { cy.loginOidc({ app, url: login }) }) + it.only("should be able to sign up and link existing account", () => { + const email = gen.email() + const password = gen.password() + + // Create a new account + cy.registerApi({ + email, + password, + fields: { "traits.website": website }, + }) + + // Try to log in with the same identifier through OIDC. This should fail and create a new login flow. + cy.registerOidc({ + app, + email, + website, + expectSession: false, + }) + cy.noSession() + + // Log in with the same identifier through the login flow. This should link the accounts. + cy.get(`${appPrefix(app)}input[name="identifier"]`).type(email) + cy.get('input[name="password"]').type(password) + cy.submitPasswordForm() + cy.location("pathname").should("not.contain", "/login") + cy.getSession() + + // Hydra OIDC should now be linked + cy.visit(settings) + cy.get('[value="hydra"]') + .should("have.attr", "name", "unlink") + .should("contain.text", "Unlink hydra") + }) + it("should be able to sign up with redirects", () => { const email = gen.email() cy.registerOidc({ diff --git a/test/e2e/cypress/integration/profiles/oidc/registration/success.spec.ts b/test/e2e/cypress/integration/profiles/oidc/registration/success.spec.ts index ca2e2700ef7a..fe32cbbf61a2 100644 --- a/test/e2e/cypress/integration/profiles/oidc/registration/success.spec.ts +++ b/test/e2e/cypress/integration/profiles/oidc/registration/success.spec.ts @@ -244,7 +244,7 @@ context("Social Sign Up Successes", () => { route: registration, }) - cy.get('[data-testid="ui/message/4000027"]').should("be.visible") + cy.get('[data-testid="ui/message/1010016"]').should("be.visible") cy.location("href").should("contain", "/login") diff --git a/test/e2e/cypress/integration/profiles/oidc/settings/success.spec.ts b/test/e2e/cypress/integration/profiles/oidc/settings/success.spec.ts index 69f8e0f7d737..dadbc06d9453 100644 --- a/test/e2e/cypress/integration/profiles/oidc/settings/success.spec.ts +++ b/test/e2e/cypress/integration/profiles/oidc/settings/success.spec.ts @@ -42,9 +42,9 @@ context("Social Sign In Settings Success", () => { cy.get('input[name="traits.website"]').clear().type(website) cy.triggerOidc(app, "hydra") - cy.get('[data-testid="ui/message/4000027"]').should( + cy.get('[data-testid="ui/message/1010016"]').should( "contain.text", - "An account with the same identifier", + "Signing in will link your account", ) cy.noSession() diff --git a/test/e2e/cypress/integration/profiles/verification/settings/success.spec.ts b/test/e2e/cypress/integration/profiles/verification/settings/success.spec.ts index bba960f280ae..d17ee11b9dda 100644 --- a/test/e2e/cypress/integration/profiles/verification/settings/success.spec.ts +++ b/test/e2e/cypress/integration/profiles/verification/settings/success.spec.ts @@ -50,12 +50,14 @@ context("Account Verification Settings Success", () => { .clear() .type(email) cy.get('[value="profile"]').click() - cy.expectSettingsSaved() - cy.get('input[name="traits.email"]').should("contain.value", email) - cy.getSession().then( - assertVerifiableAddress({ isVerified: false, email }), - ) + if (app == "express") { + cy.expectSettingsSaved() + cy.get('input[name="traits.email"]').should("contain.value", email) + cy.getSession().then( + assertVerifiableAddress({ isVerified: false, email }), + ) + } cy.verifyEmail({ expect: { email } }) }) diff --git a/text/id.go b/text/id.go index c0274db092ef..fa8184f0ffaf 100644 --- a/text/id.go +++ b/text/id.go @@ -25,6 +25,9 @@ const ( InfoSelfServiceLoginContinue // 1010013 InfoSelfServiceLoginEmailWithCodeSent // 1010014 InfoSelfServiceLoginCode // 1010015 + InfoSelfServiceLoginLink // 1010016 + InfoSelfServiceLoginAndLink // 1010017 + InfoSelfServiceLoginWithAndLink // 1010018 ) const ( @@ -89,6 +92,7 @@ const ( InfoNodeLabelVerificationCode // 1070011 InfoNodeLabelRegistrationCode // 1070012 InfoNodeLabelLoginCode // 1070013 + InfoNodeLabelLoginAndLinkCredential ) const ( @@ -139,15 +143,16 @@ const ( ) const ( - ErrorValidationLogin ID = 4010000 + iota // 4010000 - ErrorValidationLoginFlowExpired // 4010001 - ErrorValidationLoginNoStrategyFound // 4010002 - ErrorValidationRegistrationNoStrategyFound // 4010003 - ErrorValidationSettingsNoStrategyFound // 4010004 - ErrorValidationRecoveryNoStrategyFound // 4010005 - ErrorValidationVerificationNoStrategyFound // 4010006 - ErrorValidationLoginRetrySuccess // 4010007 - ErrorValidationLoginCodeInvalidOrAlreadyUsed // 4010008 + ErrorValidationLogin ID = 4010000 + iota // 4010000 + ErrorValidationLoginFlowExpired // 4010001 + ErrorValidationLoginNoStrategyFound // 4010002 + ErrorValidationRegistrationNoStrategyFound // 4010003 + ErrorValidationSettingsNoStrategyFound // 4010004 + ErrorValidationRecoveryNoStrategyFound // 4010005 + ErrorValidationVerificationNoStrategyFound // 4010006 + ErrorValidationLoginRetrySuccess // 4010007 + ErrorValidationLoginCodeInvalidOrAlreadyUsed // 4010008 + ErrorValidationLoginLinkedCredentialsDoNotMatch // 4010009 ) const ( diff --git a/text/message_login.go b/text/message_login.go index 3afa27da46bf..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, @@ -198,3 +235,11 @@ func NewInfoSelfServiceLoginCode() *Message { Text: "Sign in with code", } } + +func NewErrorValidationLoginLinkedCredentialsDoNotMatch() *Message { + return &Message{ + ID: ErrorValidationLoginLinkedCredentialsDoNotMatch, + Text: "Linked credentials do not match.", + Type: Error, + } +} diff --git a/text/message_node.go b/text/message_node.go index 3af122ae542b..e2dfb7d6dc32 100644 --- a/text/message_node.go +++ b/text/message_node.go @@ -109,3 +109,11 @@ func NewInfoNodeResendOTP() *Message { Type: Info, } } + +func NewInfoNodeLoginAndLinkCredential() *Message { + return &Message{ + ID: InfoNodeLabelLoginAndLinkCredential, + Text: "Login and link credential", + Type: Info, + } +} diff --git a/text/message_validation.go b/text/message_validation.go index 205c27304cdb..28396180c0c5 100644 --- a/text/message_validation.go +++ b/text/message_validation.go @@ -317,8 +317,9 @@ func NewErrorValidationDuplicateCredentialsWithHints(availableCredentialTypes [] func NewErrorValidationDuplicateCredentialsOnOIDCLink() *Message { return &Message{ - ID: ErrorValidationDuplicateCredentialsOnOIDCLink, - Text: "An account with the same identifier (email, phone, username, ...) exists already. Please sign in to your existing account and link your social profile in the settings page.", + ID: ErrorValidationDuplicateCredentialsOnOIDCLink, + Text: "An account with the same identifier (email, phone, username, ...) exists already. " + + "Please sign in to your existing account to link your social profile.", Type: Error, } }