Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
1066 lines
42 KiB
Go
1066 lines
42 KiB
Go
// 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)
|
|
}
|
|
})
|
|
}
|