// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package oidc_test import ( "context" _ "embed" "encoding/json" "fmt" "net/http" "net/url" "slices" "strconv" "testing" "time" "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "github.com/ory/kratos/corpx" "github.com/ory/kratos/driver" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/pkg" kratos "github.com/ory/kratos/pkg/httpclient" "github.com/ory/kratos/pkg/testhelpers" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/settings" "github.com/ory/kratos/selfservice/strategy/oidc" "github.com/ory/kratos/ui/container" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" "github.com/ory/kratos/x/nosurfx" "github.com/ory/x/assertx" "github.com/ory/x/configx" "github.com/ory/x/contextx" "github.com/ory/x/snapshotx" "github.com/ory/x/sqlxx" ) func init() { corpx.RegisterFakes() } func TestSettingsStrategy(t *testing.T) { t.Parallel() normalPrivilegedSessionFor := 5 * time.Minute conf, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/settings.schema.json")), configx.WithValues(map[string]any{ config.ViperKeySelfServiceBrowserDefaultReturnTo: "https://www.ory.com/kratos", config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter: normalPrivilegedSessionFor, }), ) hydraAdmin, hydraPublic := newHydra(t) uiTS := newUI(t, reg) errTS := testhelpers.NewErrorTestServer(t, reg) publicTS, _ := testhelpers.NewKratosServer(t, reg) setProviderConfig(t, conf, newOIDCProvider(t, publicTS, hydraPublic, hydraAdmin, "ory", func(c *oidc.Configuration) { c.Label = "Ory" }), newOIDCProvider(t, publicTS, hydraPublic, hydraAdmin, "ory-sso", func(c *oidc.Configuration) { c.OrganizationID = "org-1" }), newOIDCProvider(t, publicTS, hydraPublic, hydraAdmin, "google"), newOIDCProvider(t, publicTS, hydraPublic, hydraAdmin, "github"), ) type userDataFunc = func() (string, *identity.Identity) passwordIdentity := func() (string, *identity.Identity) { e := testhelpers.RandomEmail() return e, &identity.Identity{ Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, e)), SchemaID: config.DefaultIdentityTraitsSchemaID, Credentials: map[identity.CredentialsType]identity.Credentials{ "password": { Type: "password", Identifiers: []string{e}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$argon2id$iammocked...."}`), }, }, } } singleOIDCIdentity := func() (string, *identity.Identity) { e := testhelpers.RandomEmail() return e, &identity.Identity{ Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, e)), SchemaID: config.DefaultIdentityTraitsSchemaID, Credentials: map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypeOIDC: { Type: identity.CredentialsTypeOIDC, Identifiers: []string{"ory:" + e}, Config: sqlxx.JSONRawMessage(fmt.Sprintf(`{"providers":[{"provider":"ory","subject":"%s"}]}`, e)), }, }, } } multiOIDCIdentity := func() (string, *identity.Identity) { e := testhelpers.RandomEmail() return e, &identity.Identity{ Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, e)), Credentials: map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypeOIDC: { Type: identity.CredentialsTypeOIDC, Identifiers: []string{"ory:" + e, "github:" + e}, Config: sqlxx.JSONRawMessage(fmt.Sprintf(`{"providers":[{"provider":"ory","subject":"%s"},{"provider":"github","subject":"%s"}]}`, e, e)), }, }, SchemaID: config.DefaultIdentityTraitsSchemaID, } } multiCredentialIdentity := func() (string, *identity.Identity) { e := testhelpers.RandomEmail() return e, &identity.Identity{ Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, e)), Credentials: map[identity.CredentialsType]identity.Credentials{ "password": { Type: "password", Identifiers: []string{e}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$argon2id$iammocked...."}`), }, identity.CredentialsTypeOIDC: { Type: identity.CredentialsTypeOIDC, Identifiers: []string{"ory:" + e, "google:" + e}, Config: sqlxx.JSONRawMessage(fmt.Sprintf(`{"providers":[{"provider":"ory","subject":"%s"},{"provider":"google","subject":"%s"}]}`, e, e)), }, }, SchemaID: config.DefaultIdentityTraitsSchemaID, } } newProfileFlow := func(t *testing.T, client *http.Client, redirectTo string, exp time.Duration) *settings.Flow { req, err := reg.SettingsFlowPersister().GetSettingsFlow(t.Context(), x.ParseUUID(testhelpers.InitializeSettingsFlowViaBrowser(t, client, false, publicTS).Id)) require.NoError(t, err) assert.Empty(t, req.Active) if redirectTo != "" { req.RequestURL = redirectTo } req.ExpiresAt = time.Now().Add(exp) require.NoError(t, reg.SettingsFlowPersister().UpdateSettingsFlow(t.Context(), req)) // sanity check got, err := reg.SettingsFlowPersister().GetSettingsFlow(t.Context(), req.ID) require.NoError(t, err) require.Len(t, got.UI.Nodes, len(req.UI.Nodes)) return req } // does the same as new profile request but uses the SDK nprSDK := func(t *testing.T, client *http.Client) *kratos.SettingsFlow { return testhelpers.InitializeSettingsFlowViaBrowser(t, client, false, publicTS) } _, sharedPasswordUser := passwordIdentity() sharedUserID, sharedUserClient := testhelpers.AddAndLoginIdentity(t, reg, sharedPasswordUser) t.Run("case=should not be able to continue a flow with a malformed ID", func(t *testing.T) { body, res := testhelpers.HTTPPostForm(t, sharedUserClient, publicTS.URL+settings.RouteSubmitFlow+"?flow=i-am-not-a-uuid", nil) AssertSystemError(t, errTS, res, body, 400, "malformed") }) t.Run("case=should not be able to continue a flow without the flow query parameter", func(t *testing.T) { body, res := testhelpers.HTTPPostForm(t, sharedUserClient, publicTS.URL+settings.RouteSubmitFlow, nil) AssertSystemError(t, errTS, res, body, 400, "query parameter is missing") }) t.Run("case=should not be able to continue a flow with a non-existing ID", func(t *testing.T) { body, res := testhelpers.HTTPPostForm(t, sharedUserClient, publicTS.URL+settings.RouteSubmitFlow+"?flow="+x.NewUUID().String(), nil) AssertSystemError(t, errTS, res, body, 404, "not be found") }) t.Run("case=should not be able to continue a flow that is expired", func(t *testing.T) { req := newProfileFlow(t, sharedUserClient, "", -time.Hour) body, res := testhelpers.HTTPPostForm(t, sharedUserClient, publicTS.URL+settings.RouteSubmitFlow+"?flow="+req.ID.String(), nil) require.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?flow=") assert.NotContains(t, res.Request.URL.String(), req.ID.String(), "should initialize a new flow") assert.Contains(t, gjson.GetBytes(body, `ui.messages.0.text`).String(), "expired") }) t.Run("case=should not be able to fetch another user's data", func(t *testing.T) { _, otherUserData := passwordIdentity() _, otherUserClient := testhelpers.AddAndLoginIdentity(t, reg, otherUserData) req := newProfileFlow(t, sharedUserClient, "", time.Hour) _, _, err := testhelpers.NewSDKCustomClient(publicTS, otherUserClient).FrontendAPI.GetSettingsFlow(t.Context()).Id(req.ID.String()).Execute() require.Error(t, err) assert.Contains(t, err.Error(), "403") }) t.Run("case=should fetch the settings request and expect data to be set appropriately", func(t *testing.T) { req := newProfileFlow(t, sharedUserClient, "", time.Hour) rs, _, err := testhelpers.NewSDKCustomClient(publicTS, sharedUserClient).FrontendAPI.GetSettingsFlow(t.Context()).Id(req.ID.String()).Execute() require.NoError(t, err) // Check our sanity. Does the SDK relay the same info that we expect and got from the store? assert.Equal(t, publicTS.URL+"/self-service/settings/browser", req.RequestURL) assert.Empty(t, req.Active) assert.NotEmpty(t, req.IssuedAt) assert.EqualValues(t, sharedUserID, req.Identity.ID) assert.EqualValues(t, sharedPasswordUser.Traits, req.Identity.Traits) assert.EqualValues(t, sharedPasswordUser.SchemaID, req.Identity.SchemaID) assert.EqualValues(t, req.ID.String(), rs.Id) assert.EqualValues(t, req.RequestURL, rs.RequestUrl) assert.EqualValues(t, req.Identity.ID.String(), rs.Identity.Id) assert.EqualValues(t, req.IssuedAt, rs.IssuedAt) require.NotNil(t, identity.CredentialsTypeOIDC.String(), rs.Ui) require.EqualValues(t, "POST", rs.Ui.Method) require.EqualValues(t, publicTS.URL+settings.RouteSubmitFlow+"?flow="+req.ID.String(), rs.Ui.Action) }) t.Run("case=should adjust linkable providers based on linked credentials", func(t *testing.T) { _, pw := passwordIdentity() _, ory := singleOIDCIdentity() _, gh := multiOIDCIdentity() _, multi := multiCredentialIdentity() userData := map[string]*identity.Identity{ "password": pw, "oryer": ory, "githuber": gh, "multiuser": multi, } users := testhelpers.AddAndLoginIdentities(t, reg, userData) for name, user := range users { t.Run("agent="+name, func(t *testing.T) { rs := nprSDK(t, user.Client) snapshotx.SnapshotT(t, rs.Ui.Nodes, snapshotx.ExceptPaths("0.attributes.value", "1.attributes.value")) }) } }) checkCredentials := func(t *testing.T, shouldExist bool, iid uuid.UUID, provider, subject string, expectTokens bool) { actual, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), iid) require.NoError(t, err) var cc identity.CredentialsOIDC creds, err := actual.ParseCredentials(identity.CredentialsTypeOIDC, &cc) require.NoError(t, err) if shouldExist { assert.Contains(t, creds.Identifiers, provider+":"+subject) } else { assert.NotContains(t, creds.Identifiers, provider+":"+subject) } var found bool for _, p := range cc.Providers { if p.Provider == provider && p.Subject == subject { found = true if expectTokens { assert.NotEmpty(t, p.InitialIDToken) assert.NotEmpty(t, p.InitialAccessToken) assert.NotEmpty(t, p.InitialRefreshToken) } break } } require.EqualValues(t, shouldExist, found) } t.Run("suite=unlink", func(t *testing.T) { unlink := func(t *testing.T, agent *http.Client, provider string) (body []byte, res *http.Response, req *kratos.SettingsFlow) { req = nprSDK(t, agent) body, res = testhelpers.HTTPPostForm(t, agent, req.Ui.Action, &url.Values{"csrf_token": {nosurfx.FakeCSRFToken}, "unlink": {provider}}) return } unlinkInvalid := func(userData userDataFunc, provider, errorMessage string) func(t *testing.T) { return func(t *testing.T) { _, identityData := userData() _, client := testhelpers.AddAndLoginIdentity(t, reg, identityData) body, res, req := unlink(t, client, provider) assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?flow="+req.Id) // The original options to link google and github are still there t.Run("flow=fetch", func(t *testing.T) { snapshotx.SnapshotT(t, req.Ui.Nodes, snapshotx.ExceptPaths("0.attributes.value", "1.attributes.value")) }) t.Run("flow=json", func(t *testing.T) { snapshotx.SnapshotT(t, json.RawMessage(gjson.GetBytes(body, `ui.nodes`).Raw), snapshotx.ExceptPaths("0.attributes.value", "1.attributes.value")) }) assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), publicTS.URL+settings.RouteSubmitFlow+"?flow=") assert.Contains(t, gjson.GetBytes(body, `ui.messages.0.text`).String(), errorMessage) } } t.Run("case=should not be able to unlink the last remaining connection", unlinkInvalid(singleOIDCIdentity, "ory", "can not unlink OpenID Connect connection because it is the last remaining first factor credential")) t.Run("case=should not be able to unlink an non-existing connection", unlinkInvalid(multiOIDCIdentity, "i-do-not-exist", "can not unlink non-existing OpenID Connect connection")) t.Run("case=should not be able to unlink a connection not yet linked", unlinkInvalid(multiOIDCIdentity, "google", "can not unlink non-existing OpenID Connect connection")) t.Run("case=should unlink a connection", func(t *testing.T) { email, userData := multiOIDCIdentity() // only keep the relevant identity userID, client := testhelpers.AddAndLoginIdentity(t, reg, userData) provider := "github" body, res, req := unlink(t, client, provider) assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?flow="+req.Id) require.Equalf(t, "success", gjson.GetBytes(body, "state").String(), "%s", body) checkCredentials(t, false, userID, provider, email, false) checkCredentials(t, true, userID, "ory", email, false) }) t.Run("case=should not be able to unlink under an org-scoped settings flow", func(t *testing.T) { _, userData := multiOIDCIdentity() _, client := testhelpers.AddAndLoginIdentity(t, reg, userData) req, err := reg.SettingsFlowPersister().GetSettingsFlow(t.Context(), x.ParseUUID(testhelpers.InitializeSettingsFlowViaBrowser(t, client, false, publicTS).Id)) require.NoError(t, err) req.OrganizationID = uuid.NullUUID{UUID: x.NewUUID(), Valid: true} require.NoError(t, reg.SettingsFlowPersister().UpdateSettingsFlow(t.Context(), req)) body, res := testhelpers.HTTPPostForm(t, client, publicTS.URL+settings.RouteSubmitFlow+"?flow="+req.ID.String(), &url.Values{"csrf_token": {nosurfx.FakeCSRFToken}, "unlink": {"github"}}) assert.Equal(t, http.StatusOK, res.StatusCode) assert.Contains(t, gjson.GetBytes(body, `ui.messages.0.text`).String(), "Cannot unlink") }) t.Run("case=should not be able to unlink a connection without a privileged session", func(t *testing.T) { userEmail, userData := multiOIDCIdentity() userID, client := testhelpers.AddAndLoginIdentity(t, reg, userData) provider := "github" runUnauthed := func(t *testing.T) *kratos.SettingsFlow { conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, time.Millisecond) t.Cleanup(func() { conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, normalPrivilegedSessionFor) }) _, res, req := unlink(t, client, provider) assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login") fa := testhelpers.NewSDKCustomClient(publicTS, client).FrontendAPI lf, _, err := fa.GetLoginFlow(t.Context()).Id(res.Request.URL.Query()["flow"][0]).Execute() require.NoError(t, err) for _, n := range lf.Ui.Nodes { if n.Group == "oidc" && n.Attributes.UiNodeInputAttributes.Name == "provider" { assert.Contains(t, []string{"ory", "github"}, n.Attributes.UiNodeInputAttributes.Value) } } rs, _, err := fa.GetSettingsFlow(t.Context()).Id(req.Id).Execute() require.NoError(t, err) require.EqualValues(t, flow.StateShowForm, rs.State) checkCredentials(t, true, userID, provider, userEmail, false) return req } t.Run("subcase=should not update without re-auth", func(t *testing.T) { _ = runUnauthed(t) }) t.Run("subcase=should update after re-auth", func(t *testing.T) { req := runUnauthed(t) // fake login by allowing longer sessions... conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, normalPrivilegedSessionFor) body, res := testhelpers.HTTPPostForm(t, client, req.Ui.Action, &url.Values{"csrf_token": {nosurfx.FakeCSRFToken}, "unlink": {provider}}) assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?flow="+req.Id) assert.Equal(t, "success", gjson.GetBytes(body, "state").String()) checkCredentials(t, false, userID, provider, userEmail, false) checkCredentials(t, true, userID, "ory", userEmail, false) }) }) }) t.Run("suite=link", func(t *testing.T) { userEmail, userData := multiOIDCIdentity() _, client := testhelpers.AddAndLoginIdentity(t, reg, userData) link := func(t *testing.T, client *http.Client, provider string) (body []byte, res *http.Response, req *kratos.SettingsFlow) { req = nprSDK(t, client) body, res = testhelpers.HTTPPostForm(t, client, req.Ui.Action, &url.Values{"csrf_token": {nosurfx.FakeCSRFToken}, "link": {provider}}) return } linkInvalid := func(userData userDataFunc, provider string) func(t *testing.T) { return func(t *testing.T) { _, identityData := userData() _, client := testhelpers.AddAndLoginIdentity(t, reg, identityData) body, res, req := link(t, client, provider) assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?flow="+req.Id) // assert.EqualValues(t, identity.CredentialsTypeOIDC.String(), gjson.GetBytes(body, "active").String()) assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), publicTS.URL+settings.RouteSubmitFlow+"?flow=") // The original options to link google and github are still there snapshotx.SnapshotT(t, json.RawMessage(gjson.GetBytes(body, "ui.nodes").Raw), snapshotx.ExceptPaths("0.attributes.value", "1.attributes.value")) assert.Contains(t, gjson.GetBytes(body, `ui.messages.0.text`).String(), "can not link unknown or already existing OpenID Connect connection") } } t.Run("case=should not be able to link an non-existing connection", linkInvalid(singleOIDCIdentity, "i-do-not-exist")) t.Run("case=should not be able to link a connection which already exists", linkInvalid(multiOIDCIdentity, "github")) t.Run("case=should not be able to link a connection already linked by another identity", func(t *testing.T) { // While this theoretically allows for account enumeration - because we see an error indicator if an // OIDC connection is being linked that exists already - it would require the attacker to already // have control over the social profile, in which case account enumeration is the least of our worries. // Instead of using the OIDC profile for enumeration, the attacker would use it for account takeover. _, otherUserData := singleOIDCIdentity() _, otherClient := testhelpers.AddAndLoginIdentity(t, reg, otherUserData) subject := userEmail scope := []string{"openid"} c := wrapClientForHydraLoginConsent(t, otherClient, hydraAdmin, hydraFlowParams{subject: subject, scope: scope}) body, res, _ := link(t, c, "github") assert.Contains(t, res.Request.URL.String(), uiTS.URL) assert.Containsf(t, gjson.GetBytes(body, "ui.messages.0.text").String(), "An account with the same identifier (email, phone, username, ...) exists already.", "%s", body) }) t.Run("case=should not be able to link a connection which is missing the ID token", func(t *testing.T) { c := wrapClientForHydraLoginConsent(t, client, hydraAdmin, hydraFlowParams{subject: "hackerman+scope-missing", scope: []string{}}) body, res, _ := link(t, c, "google") assert.Contains(t, res.Request.URL.String(), uiTS.URL) assert.Containsf(t, gjson.GetBytes(body, `ui.messages.0.text`).String(), "no id_token was returned", "%s", body) }) t.Run("case=should link a connection", func(t *testing.T) { subject := testhelpers.RandomEmail() scope := []string{"openid", "offline"} provider := "google" _, userData := multiOIDCIdentity() userID, client := testhelpers.AddAndLoginIdentity(t, reg, userData) updatedFlow, res, originalFlow := link(t, wrapClientForHydraLoginConsent(t, client, hydraAdmin, hydraFlowParams{subject: subject, scope: scope}), provider) assert.Contains(t, res.Request.URL.String(), uiTS.URL) updatedFlowSDK, _, err := testhelpers.NewSDKCustomClient(publicTS, client).FrontendAPI.GetSettingsFlow(t.Context()).Id(originalFlow.Id).Execute() require.NoError(t, err) require.EqualValues(t, flow.StateSuccess, updatedFlowSDK.State) t.Run("flow=original", func(t *testing.T) { snapshotx.SnapshotT(t, originalFlow.Ui.Nodes, snapshotx.ExceptPaths("0.attributes.value", "1.attributes.value")) }) t.Run("flow=response", func(t *testing.T) { snapshotx.SnapshotT(t, json.RawMessage(gjson.GetBytes(updatedFlow, "ui.nodes").Raw), snapshotx.ExceptPaths("0.attributes.value", "1.attributes.value")) }) t.Run("flow=fetch", func(t *testing.T) { snapshotx.SnapshotT(t, updatedFlowSDK.Ui.Nodes, snapshotx.ExceptPaths("0.attributes.value", "1.attributes.value")) }) checkCredentials(t, true, userID, provider, subject, true) t.Run("session should have new auth method", func(t *testing.T) { // Get the specific session for this agent using SDK sess, _, err := testhelpers.NewSDKCustomClient(publicTS, client). FrontendAPI. ToSession(t.Context()). Execute() require.NoError(t, err) require.NotNil(t, sess) // Check that the session has the expected auth method found := slices.ContainsFunc(sess.AuthenticationMethods, func(am kratos.SessionAuthenticationMethod) bool { return am.Method != nil && am.Provider != nil && *am.Method == string(identity.CredentialsTypeOIDC) && *am.Provider == provider }) require.Truef(t, found, "session should contain OIDC auth method for provider %s", provider) }) }) t.Run("case=should link a connection even if user does not have oidc credentials yet", func(t *testing.T) { _, userData := passwordIdentity() userID, client := testhelpers.AddAndLoginIdentity(t, reg, userData) provider := "google" subject := testhelpers.RandomEmail() _, res, req := link(t, wrapClientForHydraLoginConsent(t, client, hydraAdmin, hydraFlowParams{subject: subject, scope: []string{"openid", "offline"}}), provider) assert.Contains(t, res.Request.URL.String(), uiTS.URL) rs, _, err := testhelpers.NewSDKCustomClient(publicTS, client).FrontendAPI.GetSettingsFlow(t.Context()).Id(req.Id).Execute() require.NoError(t, err) require.EqualValues(t, flow.StateSuccess, rs.State) snapshotx.SnapshotT(t, rs.Ui.Nodes, snapshotx.ExceptPaths("0.attributes.value", "1.attributes.value")) checkCredentials(t, true, userID, provider, subject, true) }) t.Run("case=upstream parameters", func(t *testing.T) { t.Run("case=should pass upstream parameters when linking a connection", func(t *testing.T) { req := nprSDK(t, client) // copy over the client so we can disable redirects c := *client // We need to disable redirects because the upstream parameters are only passed on to the provider c.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } values := &url.Values{} values.Set("csrf_token", nosurfx.FakeCSRFToken) values.Set("link", "google") values.Set("upstream_parameters.login_hint", "foo@bar.com") values.Set("upstream_parameters.hd", "bar.com") values.Set("upstream_parameters.prompt", "consent") resp, err := c.PostForm(req.Ui.Action, *values) require.NoError(t, err) require.Equal(t, http.StatusSeeOther, resp.StatusCode) loc, err := resp.Location() require.NoError(t, err) // login_hint is never forwarded: a caller-supplied value cannot be // trusted (HackerOne #3239672, ory-corp/cloud#8955). assert.Empty(t, loc.Query().Get("login_hint")) assert.EqualValues(t, "bar.com", loc.Query().Get("hd")) assert.EqualValues(t, "consent", loc.Query().Get("prompt")) }) t.Run("case=invalid query parameters should be ignored", func(t *testing.T) { req := nprSDK(t, client) // copy over the client so we can disable redirects c := *client // We need to disable redirects because the upstream parameters are only passed on to the provider c.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } values := &url.Values{} values.Set("csrf_token", nosurfx.FakeCSRFToken) values.Set("link", "google") values.Set("upstream_parameters.lol", "invalid") resp, err := c.PostForm(req.Ui.Action, *values) require.NoError(t, err) require.Equal(t, http.StatusSeeOther, resp.StatusCode) loc, err := resp.Location() require.NoError(t, err) assert.False(t, loc.Query().Has("lol")) }) }) t.Run("case=should not be able to link a connection without a privileged session", func(t *testing.T) { _, userData := singleOIDCIdentity() userID, client := testhelpers.AddAndLoginIdentity(t, reg, userData) provider := "google" subject := testhelpers.RandomEmail() runUnauthed := func(t *testing.T) *kratos.SettingsFlow { conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, time.Millisecond) t.Cleanup(func() { conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, normalPrivilegedSessionFor) }) _, res, req := link(t, client, provider) assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login") fa := testhelpers.NewSDKCustomClient(publicTS, client).FrontendAPI lf, _, err := fa.GetLoginFlow(t.Context()).Id(res.Request.URL.Query()["flow"][0]).Execute() require.NoError(t, err) var allProviders []any for _, n := range lf.Ui.Nodes { if n.Group == "oidc" && n.Attributes.UiNodeInputAttributes.Name == "provider" { allProviders = append(allProviders, n.Attributes.UiNodeInputAttributes.Value) } } assert.ElementsMatch(t, []string{"ory"}, allProviders) rs, _, err := fa.GetSettingsFlow(t.Context()).Id(req.Id).Execute() require.NoError(t, err) require.EqualValues(t, flow.StateShowForm, rs.State) checkCredentials(t, false, userID, provider, subject, true) return req } t.Run("subcase=should not update without re-auth", func(t *testing.T) { _ = runUnauthed(t) }) t.Run("subcase=should update after re-auth", func(t *testing.T) { req := runUnauthed(t) // fake login by allowing longer sessions... conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, normalPrivilegedSessionFor) body, res := testhelpers.HTTPPostForm(t, wrapClientForHydraLoginConsent(t, client, hydraAdmin, hydraFlowParams{subject: subject, scope: []string{"openid", "offline"}}), req.Ui.Action, &url.Values{"csrf_token": {nosurfx.FakeCSRFToken}, "unlink": {provider}}) assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?flow="+req.Id) assert.Equal(t, "success", gjson.GetBytes(body, "state").String()) checkCredentials(t, true, userID, provider, subject, true) }) }) }) t.Run("suite=api", func(t *testing.T) { newAPIClient := func(t *testing.T, userData userDataFunc) (uuid.UUID, *http.Client) { _, id := userData() id.State = identity.StateActive return id.ID, testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id) } nprAPISDK := func(t *testing.T, client *http.Client) *kratos.SettingsFlow { return testhelpers.InitializeSettingsFlowViaAPI(t, client, publicTS) } t.Run("case=API flow populates link and unlink nodes", func(t *testing.T) { for name, userData := range map[string]userDataFunc{ "password_only": passwordIdentity, "single_oidc": singleOIDCIdentity, "multi_oidc": multiOIDCIdentity, "multi_credentials": multiCredentialIdentity, } { t.Run("identity="+name, func(t *testing.T) { _, client := newAPIClient(t, userData) rs := nprAPISDK(t, client) snapshotx.SnapshotT(t, rs.Ui.Nodes, snapshotx.ExceptPaths("0.attributes.value", "1.attributes.value")) }) } }) makeOIDCPayload := func(rs *kratos.SettingsFlow, key, provider string) string { values := url.Values{ "csrf_token": {nosurfx.FakeCSRFToken}, key: {provider}, } return testhelpers.EncodeFormAsJSON(t, true, values) } t.Run("case=API flow link returns 422 BrowserLocationChangeRequired", func(t *testing.T) { _, client := newAPIClient(t, multiOIDCIdentity) provider := "google" rs := nprAPISDK(t, client) payload := makeOIDCPayload(rs, "link", provider) actual, res := testhelpers.SettingsMakeRequest(t, true, false, rs, client, payload) require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode, "%s", actual) assert.NotEmpty(t, gjson.Get(actual, "redirect_browser_to").String(), "%s", actual) var changeLocation flow.BrowserLocationChangeRequiredError require.NoError(t, json.Unmarshal([]byte(actual), &changeLocation)) assert.Contains(t, changeLocation.RedirectBrowserTo, "oauth2/auth") }) t.Run("case=API flow unlink succeeds", func(t *testing.T) { email, id := multiOIDCIdentity() id.State = identity.StateActive client := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id) provider := "github" rs := nprAPISDK(t, client) payload := makeOIDCPayload(rs, "unlink", provider) actual, res := testhelpers.SettingsMakeRequest(t, true, false, rs, client, payload) require.Equal(t, http.StatusOK, res.StatusCode, "%s", actual) assert.EqualValues(t, "success", gjson.Get(actual, "state").String(), "%s", actual) // id.ID is set by NewHTTPClientWithIdentitySessionToken after persistence checkCredentials(t, false, id.ID, provider, email, false) checkCredentials(t, true, id.ID, "ory", email, false) }) t.Run("case=API flow unlink last connection fails", func(t *testing.T) { _, client := newAPIClient(t, singleOIDCIdentity) provider := "ory" rs := nprAPISDK(t, client) payload := makeOIDCPayload(rs, "unlink", provider) actual, res := testhelpers.SettingsMakeRequest(t, true, false, rs, client, payload) require.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", actual) assert.Contains(t, gjson.Get(actual, "ui.messages.0.text").String(), "can not unlink OpenID Connect connection because it is the last remaining first factor credential", "%s", actual) }) t.Run("case=API flow unlink non-existing connection fails", func(t *testing.T) { _, client := newAPIClient(t, multiOIDCIdentity) provider := "i-do-not-exist" rs := nprAPISDK(t, client) payload := makeOIDCPayload(rs, "unlink", provider) actual, res := testhelpers.SettingsMakeRequest(t, true, false, rs, client, payload) require.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", actual) assert.Contains(t, gjson.Get(actual, "ui.messages.0.text").String(), "can not unlink non-existing OpenID Connect connection", "%s", actual) }) t.Run("case=API flow unlink connection not yet linked fails", func(t *testing.T) { _, client := newAPIClient(t, multiOIDCIdentity) provider := "google" rs := nprAPISDK(t, client) payload := makeOIDCPayload(rs, "unlink", provider) actual, res := testhelpers.SettingsMakeRequest(t, true, false, rs, client, payload) require.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", actual) assert.Contains(t, gjson.Get(actual, "ui.messages.0.text").String(), "can not unlink non-existing OpenID Connect connection", "%s", actual) }) t.Run("case=API flow link non-existing provider fails", func(t *testing.T) { _, client := newAPIClient(t, singleOIDCIdentity) provider := "i-do-not-exist" rs := nprAPISDK(t, client) payload := makeOIDCPayload(rs, "link", provider) actual, res := testhelpers.SettingsMakeRequest(t, true, false, rs, client, payload) require.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", actual) assert.Contains(t, gjson.Get(actual, "ui.messages.0.text").String(), "can not link unknown or already existing OpenID Connect connection", "%s", actual) }) t.Run("case=API flow link already linked provider fails", func(t *testing.T) { _, client := newAPIClient(t, multiOIDCIdentity) provider := "github" rs := nprAPISDK(t, client) payload := makeOIDCPayload(rs, "link", provider) actual, res := testhelpers.SettingsMakeRequest(t, true, false, rs, client, payload) require.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", actual) assert.Contains(t, gjson.Get(actual, "ui.messages.0.text").String(), "can not link unknown or already existing OpenID Connect connection", "%s", actual) }) t.Run("case=API flow privileged session requirement for unlink returns 403", func(t *testing.T) { conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, time.Nanosecond) t.Cleanup(func() { conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, normalPrivilegedSessionFor) }) _, client := newAPIClient(t, multiOIDCIdentity) provider := "github" // Ensure the privileged session window has expired. time.Sleep(time.Millisecond) rs := nprAPISDK(t, client) payload := makeOIDCPayload(rs, "unlink", provider) actual, res := testhelpers.SettingsMakeRequest(t, true, false, rs, client, payload) assert.Equal(t, http.StatusForbidden, res.StatusCode, "%s", actual) assert.Contains(t, gjson.Get(actual, "redirect_browser_to").String(), publicTS.URL+"/self-service/login/browser?refresh=true&return_to=") assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(actual), []string{"redirect_browser_to"}) }) t.Run("case=API flow privileged session requirement for link returns 403", func(t *testing.T) { conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, time.Nanosecond) t.Cleanup(func() { conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, normalPrivilegedSessionFor) }) _, client := newAPIClient(t, multiOIDCIdentity) provider := "google" // Ensure the privileged session window has expired. time.Sleep(time.Millisecond) rs := nprAPISDK(t, client) payload := makeOIDCPayload(rs, "link", provider) actual, res := testhelpers.SettingsMakeRequest(t, true, false, rs, client, payload) assert.Equal(t, http.StatusForbidden, res.StatusCode, "%s", actual) assert.Contains(t, gjson.Get(actual, "redirect_browser_to").String(), publicTS.URL+"/self-service/login/browser?refresh=true&return_to=") assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(actual), []string{"redirect_browser_to"}) }) }) } func TestPopulateSettingsMethod(t *testing.T) { t.Parallel() nCtx := func(t *testing.T, conf *oidc.ConfigurationCollection) (*driver.RegistryDefault, context.Context) { _, reg := pkg.NewFastRegistryWithMocks(t) ctx := context.Background() ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/registration.schema.json") ctx = contextx.WithConfigValue(ctx, config.ViperKeyPublicBaseURL, "https://www.ory.com/") baseKey := fmt.Sprintf("%s.%s", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeOIDC) ctx = contextx.WithConfigValues(ctx, map[string]interface{}{ baseKey + ".enabled": true, baseKey + ".config": conf, }) // Enabled per default: // conf.Set(ctx, configuration.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword), map[string]interface{}{"enabled": true}) // viperSetProviderConfig(t, c, conf.Providers...) return reg, ctx } ns := func(t *testing.T, reg *driver.RegistryDefault, ctx context.Context) *oidc.Strategy { ss, err := reg.SettingsStrategies(ctx).Strategy(identity.CredentialsTypeOIDC.String()) require.NoError(t, err) return ss.(*oidc.Strategy) } nr := func() *settings.Flow { return &settings.Flow{Type: flow.TypeBrowser, ID: x.NewUUID(), UI: container.New("")} } populate := func(t *testing.T, reg *driver.RegistryDefault, ctx context.Context, i *identity.Identity, f *settings.Flow) *container.Container { require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i)) req := new(http.Request) require.NoError(t, ns(t, reg, ctx).PopulateSettingsMethod(ctx, req, i, f)) require.NotNil(t, f.UI) require.NotNil(t, f.UI.Nodes) assert.Equal(t, "POST", f.UI.Method) return f.UI } defaultConfig := []oidc.Configuration{ {Provider: "generic", ID: "facebook"}, {Provider: "generic", ID: "google"}, {Provider: "generic", ID: "github"}, } t.Run("case=should populate API flow with link nodes", func(t *testing.T) { t.Parallel() reg, ctx := nCtx(t, &oidc.ConfigurationCollection{Providers: []oidc.Configuration{{Provider: "generic", ID: "github"}}}) i := &identity.Identity{Traits: []byte(`{"subject":"foo@bar.com"}`)} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i)) f := &settings.Flow{Type: flow.TypeAPI, ID: x.NewUUID(), UI: container.New("")} req := new(http.Request) require.NoError(t, ns(t, reg, ctx).PopulateSettingsMethod(ctx, req, i, f)) require.NotEmpty(t, f.UI.Nodes) assert.EqualValues(t, node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), oidc.NewLinkNode("github", "github"), }, f.UI.Nodes) }) for k, tc := range []struct { c []oidc.Configuration i *identity.Credentials e node.Nodes withpw bool }{ { c: []oidc.Configuration{}, e: node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), }, }, { c: []oidc.Configuration{ {Provider: "generic", ID: "github"}, }, e: node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), oidc.NewLinkNode("github", "github"), }, }, { c: defaultConfig, e: node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), oidc.NewLinkNode("facebook", "facebook"), oidc.NewLinkNode("google", "google"), oidc.NewLinkNode("github", "github"), }, }, { c: defaultConfig, e: node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), oidc.NewLinkNode("facebook", "facebook"), oidc.NewLinkNode("google", "google"), oidc.NewLinkNode("github", "github"), }, i: &identity.Credentials{Type: identity.CredentialsTypeOIDC, Identifiers: []string{}, Config: []byte(`{}`)}, }, { c: defaultConfig, e: node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), oidc.NewLinkNode("facebook", "facebook"), oidc.NewLinkNode("github", "github"), }, i: &identity.Credentials{Type: identity.CredentialsTypeOIDC, Identifiers: []string{ "google:1234", }, Config: []byte(`{"providers":[{"provider":"google","subject":"1234"}]}`)}, }, { c: defaultConfig, e: node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), oidc.NewLinkNode("facebook", "facebook"), oidc.NewLinkNode("github", "github"), oidc.NewUnlinkNode("google", "google"), }, withpw: true, i: &identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{ "google:1234", }, Config: []byte(`{"providers":[{"provider":"google","subject":"1234"}]}`), }, }, { c: defaultConfig, e: node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), oidc.NewLinkNode("github", "github"), oidc.NewUnlinkNode("google", "google"), oidc.NewUnlinkNode("facebook", "facebook"), }, i: &identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{ "google:1234", "facebook:1234", }, Config: []byte(`{"providers":[{"provider":"google","subject":"1234"},{"provider":"facebook","subject":"1234"}]}`), }, }, { c: []oidc.Configuration{ {Provider: "generic", ID: "labeled", Label: "Labeled"}, }, e: node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), oidc.NewLinkNode("labeled", "Labeled"), }, }, { c: []oidc.Configuration{ {Provider: "generic", ID: "labeled", Label: "Labeled"}, {Provider: "generic", ID: "facebook"}, }, e: node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), oidc.NewUnlinkNode("labeled", "Labeled"), oidc.NewUnlinkNode("facebook", "facebook"), }, i: &identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{ "labeled:1234", "facebook:1234", }, Config: []byte(`{"providers":[{"provider":"labeled","subject":"1234"},{"provider":"facebook","subject":"1234"}]}`), }, }, } { t.Run("iteration="+strconv.Itoa(k), func(t *testing.T) { t.Parallel() reg, ctx := nCtx(t, &oidc.ConfigurationCollection{Providers: tc.c}) i := &identity.Identity{ Traits: []byte(`{"subject":"foo@bar.com"}`), Credentials: make(map[identity.CredentialsType]identity.Credentials, 2), } if tc.i != nil { i.Credentials[identity.CredentialsTypeOIDC] = *tc.i } if tc.withpw { i.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{"foo@bar.com"}, Config: []byte(`{"hashed_password":"$argon2id$..."}`), } } actual := populate(t, reg, ctx, i, nr()) assert.EqualValues(t, tc.e, actual.Nodes) }) } t.Run("case=org-scoped flow renders only org providers and surfaces linked ones as disabled unlink nodes", func(t *testing.T) { t.Parallel() orgA := uuid.Must(uuid.NewV4()) orgB := uuid.Must(uuid.NewV4()) reg, ctx := nCtx(t, &oidc.ConfigurationCollection{Providers: []oidc.Configuration{ {Provider: "generic", ID: "public-github"}, {Provider: "generic", ID: "orgA-google", Label: "Org A Google", OrganizationID: orgA.String()}, {Provider: "generic", ID: "orgA-facebook", OrganizationID: orgA.String()}, {Provider: "generic", ID: "orgB-github", OrganizationID: orgB.String()}, }}) i := &identity.Identity{ Traits: []byte(`{"subject":"foo@bar.com"}`), Credentials: make(map[identity.CredentialsType]identity.Credentials, 1), } // Identity has already linked orgA-google. i.Credentials[identity.CredentialsTypeOIDC] = identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{"orgA-google:1234"}, Config: []byte(`{"providers":[{"provider":"orgA-google","subject":"1234"}]}`), } f := nr() f.OrganizationID = uuid.NullUUID{UUID: orgA, Valid: true} ui := populate(t, reg, ctx, i, f) // Expected nodes: CSRF + an unlink node for the already-linked orgA-google // (disabled — the org owns the binding) + a link node for the unlinked // orgA-facebook. Public/other-org providers are filtered out. linkedNode := oidc.NewUnlinkNode("orgA-google", "Org A Google") linkedNode.Attributes.(*node.InputAttributes).Disabled = true expected := node.Nodes{ node.NewCSRFNode(nosurfx.FakeCSRFToken), linkedNode, oidc.NewLinkNode("orgA-facebook", "orgA-facebook"), } assert.EqualValues(t, expected, ui.Nodes) // Sanity: no provider from another org or public provider leaks in. for _, n := range ui.Nodes { attrs, ok := n.Attributes.(*node.InputAttributes) if !ok { continue } assert.NotEqual(t, "public-github", attrs.FieldValue) assert.NotEqual(t, "orgB-github", attrs.FieldValue) } }) }