Files
upstream-mirror c60cc7ca75
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
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

2593 lines
116 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package oidc_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/davecgh/go-spew/spew"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"golang.org/x/oauth2"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/hook/hooktest"
"github.com/ory/kratos/selfservice/sessiontokenexchange"
"github.com/ory/kratos/selfservice/strategy/oidc"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/x/assertx"
"github.com/ory/x/configx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/randx"
"github.com/ory/x/snapshotx"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
"github.com/ory/x/uuidx"
)
func TestStrategy(t *testing.T) {
t.Parallel()
returnTSRouter := http.NewServeMux()
returnTS := httptest.NewServer(returnTSRouter)
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(map[string]any{
config.ViperKeyPublicBaseURL: "https://foo/", // deterministic value for snapshots
config.ViperKeyIdentitySchemas: config.Schemas{
{ID: "default", URL: "file://./stub/registration.schema.json"},
{ID: "email", URL: "file://./stub/registration.schema.json", SelfserviceSelectable: true},
{ID: "phone", URL: "file://./stub/registration-phone.schema.json", SelfserviceSelectable: true},
{ID: "extra_data", URL: "file://./stub/registration-multi-schema-extra-fields.schema.json", SelfserviceSelectable: true},
{ID: "aal", URL: "file://./stub/registration-aal.schema.json", SelfserviceSelectable: true},
{ID: "verifiable_email", URL: "file://./stub/registration-verifiable-email.schema.json", SelfserviceSelectable: true},
},
config.ViperKeyDefaultIdentitySchemaID: "default",
config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeOIDC.String()): []config.SelfServiceHook{{Name: "session"}},
config.ViperKeyURLsAllowedReturnToDomains: []string{returnTS.URL},
config.ViperKeySelfServiceBrowserDefaultReturnTo: returnTS.URL,
config.ViperKeyOAuth2ProviderURL: "http://fake-hydra",
}),
)
returnTSRouter.HandleFunc("/app_code", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) })
returnTSRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
sess, err := reg.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandEverything, identity.ExpandEverything)
if err != nil {
reg.Writer().WriteError(w, r, err)
return
}
reg.Writer().Write(w, r, sess)
})
hydraAdmin, hydraPublic := newHydra(t)
uiTS := newUI(t, reg)
errTS := testhelpers.NewErrorTestServer(t, reg)
routerP, routerA := httprouterx.NewRouterPublic(), httprouterx.NewRouterAdminWithPrefix()
ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, routerP, routerA)
invalid := newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "invalid-issuer")
orgID := uuidx.NewV4()
setProviderConfig(t, conf,
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid"),
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid2"),
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "secondProvider"),
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "claimsViaUserInfo", func(c *oidc.Configuration) { c.ClaimsSource = oidc.ClaimsSourceUserInfo }),
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "neverPKCE", func(c *oidc.Configuration) { c.PKCE = "never" }),
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "autoPKCE", func(c *oidc.Configuration) { c.PKCE = "auto" }),
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "forcePKCE", func(c *oidc.Configuration) { c.PKCE = "force" }),
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "aal2-acr", func(c *oidc.Configuration) { c.AAL2ACRValues = []string{"urn:mfa", "urn:strong"} }),
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "aal2-amr", func(c *oidc.Configuration) { c.AAL2AMRValues = []string{"mfa", "otp"} }),
oidc.Configuration{
Provider: "generic",
ID: "invalid-issuer",
ClientID: invalid.ClientID,
ClientSecret: invalid.ClientSecret,
// We replace this URL to cause an issuer validation mismatch.
IssuerURL: strings.Replace(hydraPublic, "127.0.0.1", "localhost", 1) + "/",
Mapper: "file://./stub/oidc.hydra.jsonnet",
},
)
t.Logf("Kratos Public URL: %s", ts.URL)
t.Logf("Kratos Error URL: %s", errTS.URL)
t.Logf("Hydra Public URL: %s", hydraPublic)
t.Logf("Hydra Admin URL: %s", hydraAdmin)
t.Logf("Return URL: %s", returnTS.URL)
// assert form values
assertFormValues := func(t *testing.T, flowID uuid.UUID, provider string) (action string) {
var cfg *container.Container
if req, err := reg.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), flowID); err == nil {
require.EqualValues(t, req.ID, flowID)
cfg = req.UI
require.NotNil(t, cfg)
} else if req, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), flowID); err == nil {
require.EqualValues(t, req.ID, flowID)
cfg = req.UI
require.NotNil(t, cfg)
} else {
require.NoError(t, err)
return
}
assert.Equal(t, "POST", cfg.Method)
var providers []interface{}
for _, nodes := range cfg.Nodes {
if strings.Contains(nodes.ID(), "provider") {
providers = append(providers, nodes.GetValue())
}
}
require.Containsf(t, providers, provider, "%+v", assertx.PrettifyJSONPayload(t, cfg))
return cfg.Action
}
registerAction := func(flowID uuid.UUID) string {
return ts.URL + registration.RouteSubmitFlow + "?flow=" + flowID.String()
}
loginAction := func(flowID uuid.UUID) string {
return ts.URL + login.RouteSubmitFlow + "?flow=" + flowID.String()
}
makeRequestWithClient := func(t *testing.T, client *http.Client, action, provider string, params hydraFlowParams, fv url.Values) (*http.Response, []byte) {
if fv == nil {
fv = make(url.Values)
}
fv.Set("provider", provider)
res, err := wrapClientForHydraLoginConsent(t, client, hydraAdmin, params).
PostForm(action, fv)
require.NoError(t, err, action)
body, err := io.ReadAll(res.Body)
require.NoError(t, res.Body.Close())
require.NoError(t, err)
require.Equalf(t, http.StatusOK, res.StatusCode, "%s: %s\n\t%s", action, res.Request.URL.String(), body)
return res, body
}
makeRequest := func(t *testing.T, action, provider string, params hydraFlowParams, fv url.Values) (*http.Response, []byte) {
return makeRequestWithClient(t, testhelpers.NewClientWithCookies(t), action, provider, params, fv)
}
makeJSONRequest := func(t *testing.T, action, provider, subject string, scope []string, fv url.Values) (*http.Response, []byte) {
if fv == nil {
fv = make(url.Values)
}
fv.Set("provider", provider)
req, err := http.NewRequest("POST", action, strings.NewReader(fv.Encode()))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err := wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookies(t), hydraAdmin, hydraFlowParams{subject: subject, scope: scope}).
Do(req)
require.NoError(t, err, action)
body, err := io.ReadAll(res.Body)
require.NoError(t, res.Body.Close())
require.NoError(t, err)
require.Equalf(t, 422, res.StatusCode, "%s: %s\n\t%s", action, res.Request.URL.String(), body)
return res, body
}
makeAPICodeFlowRequest := func(t *testing.T, client *http.Client, action, provider string, params hydraFlowParams) (returnToURL *url.URL) {
res, err := testhelpers.NewTestClient(t).Post(
action,
"application/json",
strings.NewReader(fmt.Sprintf(`
{
"method": "oidc",
"provider": %q
}`, provider)),
)
require.NoError(t, err)
require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode)
var changeLocation flow.BrowserLocationChangeRequiredError
require.NoError(t, json.NewDecoder(res.Body).Decode(&changeLocation))
if client == nil {
client = testhelpers.NewClientWithCookies(t)
}
res, err = wrapClientForHydraLoginConsent(t, client, hydraAdmin, params).
Get(changeLocation.RedirectBrowserTo)
require.NoError(t, err)
returnToURL = res.Request.URL
assert.Truef(t, strings.HasPrefix(returnToURL.String(), returnTS.URL+"/app_code"), "expected %s to have prefix %s/app_code", returnToURL, returnTS.URL)
return returnToURL
}
exchangeCodeForToken := func(t *testing.T, codes sessiontokenexchange.Codes) (codeResponse session.CodeExchangeResponse, err error) {
tokenURL := urlx.ParseOrPanic(ts.URL)
tokenURL.Path = "/sessions/token-exchange"
tokenURL.RawQuery = fmt.Sprintf("init_code=%s&return_to_code=%s", codes.InitCode, codes.ReturnToCode)
res, err := ts.Client().Get(tokenURL.String())
if err != nil {
return codeResponse, err
}
if res.StatusCode != 200 {
return codeResponse, fmt.Errorf("got status code %d", res.StatusCode)
}
require.NoError(t, json.NewDecoder(res.Body).Decode(&codeResponse))
return
}
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", prettyJSON(t, body))
assert.Contains(t, gjson.GetBytes(body, "reason").String(), reason, "%s", prettyJSON(t, body))
}
// assert system error (redirect to error endpoint)
assertSystemErrorWithMessage := func(t *testing.T, res *http.Response, body []byte, code int, message 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, "message").String(), message, "%s", body)
}
// assert ui error (redirect to login/registration ui endpoint)
assertUIError := func(t *testing.T, res *http.Response, body []byte, reason string) {
require.Contains(t, res.Request.URL.String(), uiTS.URL, "Redirect does not point to UI server. Status: %d, body: %s", res.StatusCode, body)
assert.Contains(t, gjson.GetBytes(body, "ui.messages.0.text").String(), reason, "%s", prettyJSON(t, body))
}
// assert identity (success)
assertIdentity := func(t *testing.T, res *http.Response, body []byte, subject string, claims idTokenClaims) {
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))
}
newLoginFlow := func(t *testing.T, requestURL string, exp time.Duration, flowType flow.Type) (req *login.Flow) {
// Use NewLoginFlow to instantiate the request but change the things we need to control a copy of it.
req, _, err := reg.LoginHandler().NewLoginFlow(httptest.NewRecorder(),
&http.Request{URL: urlx.ParseOrPanic(requestURL)}, flowType)
require.NoError(t, err)
req.RequestURL = requestURL
req.ExpiresAt = time.Now().Add(exp)
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), req))
// sanity check
got, err := reg.LoginFlowPersister().GetLoginFlow(t.Context(), req.ID)
require.NoError(t, err)
require.Len(t, got.UI.Nodes, len(req.UI.Nodes), "%+v", got)
return
}
newBrowserLoginFlow := func(t *testing.T, redirectTo string, exp time.Duration) (req *login.Flow) {
return newLoginFlow(t, redirectTo, exp, flow.TypeBrowser)
}
newAPILoginFlow := func(t *testing.T, redirectTo string, exp time.Duration) (req *login.Flow) {
return newLoginFlow(t, redirectTo, exp, flow.TypeAPI)
}
newRegistrationFlow := func(t *testing.T, redirectTo string, exp time.Duration, flowType flow.Type, opts ...registration.FlowOption) *registration.Flow {
req, err := reg.RegistrationHandler().NewRegistrationFlow(httptest.NewRecorder(),
&http.Request{URL: urlx.ParseOrPanic(redirectTo)}, flowType, opts...)
require.NoError(t, err)
req.RequestURL = redirectTo
req.ExpiresAt = time.Now().Add(exp)
require.NoError(t, reg.RegistrationFlowPersister().UpdateRegistrationFlow(t.Context(), req))
// sanity check
got, err := reg.RegistrationFlowPersister().GetRegistrationFlow(t.Context(), req.ID)
require.NoError(t, err)
require.Lenf(t, got.UI.Nodes, len(req.UI.Nodes), "%+v", req)
return req
}
newBrowserRegistrationFlow := func(t *testing.T, redirectTo string, exp time.Duration, opts ...registration.FlowOption) *registration.Flow {
return newRegistrationFlow(t, redirectTo, exp, flow.TypeBrowser, opts...)
}
newAPIRegistrationFlow := func(t *testing.T, redirectTo string, exp time.Duration, opts ...registration.FlowOption) *registration.Flow {
return newRegistrationFlow(t, redirectTo, exp, flow.TypeAPI, opts...)
}
t.Run("case=should fail because provider does not exist", func(t *testing.T) {
for name, action := range map[string]string{
"login": loginAction(newBrowserLoginFlow(t, returnTS.URL, time.Minute).ID),
"registration": registerAction(newBrowserRegistrationFlow(t, returnTS.URL, time.Minute).ID),
} {
t.Run("case="+name, func(t *testing.T) {
res, body := makeRequest(t, action, "provider-does-not-exist", hydraFlowParams{}, nil)
assertSystemErrorWithReason(t, res, body, http.StatusNotFound, "is unknown or has not been configured")
})
}
})
t.Run("case=should fail because the issuer is mismatching", func(t *testing.T) {
for name, action := range map[string]string{
"login": loginAction(newBrowserLoginFlow(t, returnTS.URL, time.Minute).ID),
"registration": registerAction(newBrowserRegistrationFlow(t, returnTS.URL, time.Minute).ID),
} {
t.Run("case="+name, func(t *testing.T) {
res, body := makeRequest(t, action, "invalid-issuer", hydraFlowParams{}, nil)
assertSystemErrorWithReason(t, res, body, http.StatusInternalServerError, "issuer did not match the issuer returned by provider")
})
}
})
t.Run("case=should fail because flow does not exist", func(t *testing.T) {
for name, action := range map[string]string{
"login": loginAction(x.NewUUID()),
"registration": registerAction(x.NewUUID()),
} {
t.Run("case="+name, func(t *testing.T) {
res, body := makeRequest(t, action, "valid", hydraFlowParams{}, nil)
assertSystemErrorWithMessage(t, res, body, http.StatusNotFound, "Unable to locate the resource")
})
}
})
t.Run("case=should fail because the flow is expired", func(t *testing.T) {
for name, flw := range map[string]uuid.UUID{
"login": newBrowserLoginFlow(t, returnTS.URL, -time.Minute).ID,
"registration": newBrowserRegistrationFlow(t, returnTS.URL, -time.Minute).ID,
} {
t.Run("case="+name, func(t *testing.T) {
action := assertFormValues(t, flw, "valid")
res, body := makeRequest(t, action, "valid", hydraFlowParams{}, nil)
assert.NotEqual(t, flw, gjson.GetBytes(body, "id"))
require.Contains(t, res.Request.URL.String(), uiTS.URL, "%s", body)
assert.Contains(t, gjson.GetBytes(body, "ui.messages.0.text").String(), "flow expired", "%s", body)
})
}
})
t.Run("case=should fail because scope was not provided", func(t *testing.T) {
for name, flowID := range map[string]uuid.UUID{
"login": newBrowserLoginFlow(t, returnTS.URL, time.Minute).ID,
"registration": newBrowserRegistrationFlow(t, returnTS.URL, time.Minute).ID,
} {
t.Run("case="+name, func(t *testing.T) {
action := assertFormValues(t, flowID, "valid")
res, body := makeRequest(t, action, "valid", hydraFlowParams{subject: "foo@bar.com", scope: []string{}}, nil)
assertUIError(t, res, body, "no id_token was returned")
})
}
})
t.Run("case=should fail because oidc does not satisfy AAL2", func(t *testing.T) {
user := testhelpers.AddAndLoginIdentities(t, reg, map[string]*identity.Identity{"u": {
SchemaID: "aal",
Traits: identity.Traits(`{"email":"just-aal1-identity@ory.sh"}`),
}})["u"]
client := wrapClientForHydraLoginConsent(t, user.Client, hydraAdmin, hydraFlowParams{subject: "foo@bar.com", scope: []string{"openid"}})
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, client, ts, true)
update, err := reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
update.RequestedAAL = identity.AuthenticatorAssuranceLevel2
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), update))
req, err := http.NewRequest("POST", f.Ui.Action, strings.NewReader(`{"method":"oidc"}`))
require.NoError(t, err)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), ts.URL+login.RouteSubmitFlow)
assert.Equal(t, text.NewErrorValidationLoginNoStrategyFound().Text, gjson.GetBytes(body, "ui.messages.0.text").String())
})
t.Run("case=should fail registration flow because subject is not an email", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", hydraFlowParams{subject: "not-an-email", scope: []string{"openid"}}, nil)
require.Containsf(t, res.Request.URL.String(), uiTS.URL, "%s", body)
assert.Containsf(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.subject).messages.0.text").String(), "Enter a valid email address", "%s\n%s", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.subject)").Raw, body)
})
t.Run("case=cannot register multiple accounts with the same OIDC account", func(t *testing.T) {
subject := testhelpers.RandomEmail()
scope := []string{"openid", "offline"}
expectTokens := func(t *testing.T, provider string, body []byte) {
i, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), 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) {
postRegistrationWebhook := hooktest.NewServer()
t.Cleanup(postRegistrationWebhook.Close)
postRegistrationWebhook.SetConfig(t, conf.GetProvider(t.Context()), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeOIDC.String()))
transientPayload := `{"data": "registration-one"}`
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, url.Values{"transient_payload": {transientPayload}})
assertIdentity(t, res, body, subject, idTokenClaims{})
expectTokens(t, "valid", body)
postRegistrationWebhook.AssertTransientPayload(t, transientPayload)
})
t.Run("case=try another registration", func(t *testing.T) {
transientPayload := `{"data": "registration-two"}`
postLoginWebhook := hooktest.NewServer()
t.Cleanup(postLoginWebhook.Close)
postLoginWebhook.SetConfig(t, conf.GetProvider(t.Context()), config.HookStrategyKey(config.ViperKeySelfServiceLoginAfter, identity.CredentialsTypeOIDC.String()))
returnTo := fmt.Sprintf("%s/home?query=true", returnTS.URL)
r := newBrowserRegistrationFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, url.QueryEscape(returnTo)), time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, url.Values{"transient_payload": {transientPayload}})
assert.Equal(t, returnTo, res.Request.URL.String())
assertIdentity(t, res, body, subject, idTokenClaims{})
expectTokens(t, "valid", body)
postLoginWebhook.AssertTransientPayload(t, transientPayload)
})
})
expectTokens := func(t *testing.T, provider, subject string, body []byte) uuid.UUID {
id := uuid.FromStringOrNil(gjson.GetBytes(body, "identity.id").String())
i, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), 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=force PKCE", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "forcePKCE")
subject := testhelpers.RandomEmail()
scope := []string{"openid", "offline"}
var redirects []*http.Request
client := testhelpers.NewClientWithCookieJar(t, nil, func(_ *http.Request, via []*http.Request) error {
redirects = via
return nil
})
res, body := makeRequestWithClient(t, client, action, "forcePKCE", hydraFlowParams{subject: subject, scope: scope}, nil)
require.Len(t, redirects, 2)
assert.Contains(t, redirects[0].URL.String(), "/oauth2/auth")
assert.Contains(t, redirects[0].URL.String(), "code_challenge_method=S256")
assert.Contains(t, redirects[0].URL.String(), "code_challenge=")
assert.Equal(t, redirects[1].URL.Path, "/self-service/methods/oidc/callback")
assertIdentity(t, res, body, subject, idTokenClaims{})
expectTokens(t, "forcePKCE", subject, body)
assert.Equal(t, "forcePKCE", gjson.GetBytes(body, "authentication_methods.0.provider").String(), "%s", body)
})
t.Run("case=force PKCE, invalid verifier", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "forcePKCE")
subject := testhelpers.RandomEmail()
scope := []string{"openid", "offline"}
verifierFalsified := false
client := testhelpers.NewClientWithCookieJar(t, nil, func(req *http.Request, via []*http.Request) error {
if req.URL.Path == "/oauth2/auth" && !verifierFalsified {
q := req.URL.Query()
require.NotEmpty(t, q.Get("code_challenge"))
require.Equal(t, "S256", q.Get("code_challenge_method"))
q.Set("code_challenge", oauth2.S256ChallengeFromVerifier(oauth2.GenerateVerifier()))
req.URL.RawQuery = q.Encode()
verifierFalsified = true
}
return nil
})
res, body := makeRequestWithClient(t, client, action, "forcePKCE", hydraFlowParams{subject: subject, scope: scope}, nil)
require.True(t, verifierFalsified)
assertSystemErrorWithMessage(t, res, body, http.StatusInternalServerError, "The PKCE code challenge did not match the code verifier.")
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(t.Context()).String())
})
t.Run("case=force PKCE, code challenge params removed from initial redirect", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "forcePKCE")
subject := testhelpers.RandomEmail()
scope := []string{"openid", "offline"}
challengeParamsRemoved := false
client := testhelpers.NewClientWithCookieJar(t, nil, func(req *http.Request, via []*http.Request) error {
if req.URL.Path == "/oauth2/auth" && !challengeParamsRemoved {
q := req.URL.Query()
require.NotEmpty(t, q.Get("code_challenge"))
require.Equal(t, "S256", q.Get("code_challenge_method"))
q.Del("code_challenge")
q.Del("code_challenge_method")
req.URL.RawQuery = q.Encode()
challengeParamsRemoved = true
}
return nil
})
res, body := makeRequestWithClient(t, client, action, "forcePKCE", hydraFlowParams{subject: subject, scope: scope}, nil)
require.True(t, challengeParamsRemoved)
assertSystemErrorWithMessage(t, res, body, http.StatusInternalServerError, "The PKCE code challenge did not match the code verifier.")
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(t.Context()).String())
})
t.Run("case=PKCE prevents authorization code injection attacks", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "forcePKCE")
subject := testhelpers.RandomEmail()
scope := []string{"openid", "offline"}
var code string
client := wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookieJar(t, nil, func(req *http.Request, via []*http.Request) error {
if req.URL.Query().Has("code") {
code = req.URL.Query().Get("code")
return errors.New("code intercepted")
}
return nil
}), hydraAdmin, hydraFlowParams{subject: subject, scope: scope})
_, err := client.PostForm(action, url.Values{"provider": {"forcePKCE"}})
require.ErrorContains(t, err, "code intercepted")
require.NotEmpty(t, code) // code now contains a valid authorization code
r2 := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action = assertFormValues(t, r2.ID, "forcePKCE")
jar, err := cookiejar.New(nil) // must capture the continuity cookie
require.NoError(t, err)
var redirectURI, state string
client = wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookieJar(t, jar, func(req *http.Request, via []*http.Request) error {
if req.URL.Path == "/oauth2/auth" {
redirectURI = req.URL.Query().Get("redirect_uri")
state = req.URL.Query().Get("state")
return errors.New("stop before redirect to Authorization URL")
}
return nil
}), hydraAdmin, hydraFlowParams{subject: subject, scope: scope})
_, err = client.PostForm(action, url.Values{"provider": {"forcePKCE"}})
require.ErrorContains(t, err, "stop")
require.NotEmpty(t, redirectURI)
require.NotEmpty(t, state)
client = wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookieJar(t, jar, nil), hydraAdmin, hydraFlowParams{subject: subject, scope: scope})
res, err := client.Get(redirectURI + "?code=" + code + "&state=" + state)
require.NoError(t, err)
body := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
assertSystemErrorWithMessage(t, res, body, http.StatusInternalServerError, "The PKCE code challenge did not match the code verifier.")
})
t.Run("case=confused providers are detected", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
subject := testhelpers.RandomEmail()
scope := []string{"openid", "offline"}
redirectConfused := false
client := wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookieJar(t, nil, func(req *http.Request, via []*http.Request) error {
if req.URL.Query().Has("code") {
req.URL.Path = strings.Replace(req.URL.Path, "valid", "valid2", 1)
redirectConfused = true
}
return nil
}), hydraAdmin, hydraFlowParams{subject: subject, scope: scope})
res, err := client.PostForm(action, url.Values{"provider": {"valid"}})
require.True(t, redirectConfused)
require.NoError(t, err)
body := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
assertSystemErrorWithReason(t, res, body, http.StatusBadRequest, "provider mismatch between internal state and URL")
})
t.Run("case=automatic PKCE", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "autoPKCE")
subject := testhelpers.RandomEmail()
scope := []string{"openid", "offline"}
var redirects []*http.Request
client := testhelpers.NewClientWithCookieJar(t, nil, func(_ *http.Request, via []*http.Request) error {
redirects = via
return nil
})
res, body := makeRequestWithClient(t, client, action, "autoPKCE", hydraFlowParams{subject: subject, scope: scope}, nil)
require.Len(t, redirects, 2)
assert.Contains(t, redirects[0].URL.String(), "/oauth2/auth")
assert.Contains(t, redirects[0].URL.String(), "code_challenge_method=S256")
assert.Contains(t, redirects[0].URL.String(), "code_challenge=")
assert.Equal(t, redirects[1].URL.Path, "/self-service/methods/oidc/callback/autoPKCE")
assertIdentity(t, res, body, subject, idTokenClaims{})
expectTokens(t, "autoPKCE", subject, body)
assert.Equal(t, "autoPKCE", gjson.GetBytes(body, "authentication_methods.0.provider").String(), "%s", body)
})
t.Run("case=disabled PKCE", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "neverPKCE")
subject := testhelpers.RandomEmail()
scope := []string{"openid", "offline"}
var redirects []*http.Request
client := testhelpers.NewClientWithCookieJar(t, nil, func(_ *http.Request, via []*http.Request) error {
redirects = via
return nil
})
res, body := makeRequestWithClient(t, client, action, "neverPKCE", hydraFlowParams{subject: subject, scope: scope}, nil)
require.Len(t, redirects, 2)
assert.Contains(t, redirects[0].URL.String(), "/oauth2/auth")
assert.NotContains(t, redirects[0].URL.String(), "code_challenge_method=")
assert.NotContains(t, redirects[0].URL.String(), "code_challenge=")
assert.Equal(t, redirects[1].URL.Path, "/self-service/methods/oidc/callback/neverPKCE")
assertIdentity(t, res, body, subject, idTokenClaims{})
expectTokens(t, "neverPKCE", subject, body)
assert.Equal(t, "neverPKCE", gjson.GetBytes(body, "authentication_methods.0.provider").String(), "%s", body)
})
t.Run("case=register and then login", func(t *testing.T) {
postRegistrationWebhook := hooktest.NewServer()
t.Cleanup(postRegistrationWebhook.Close)
postLoginWebhook := hooktest.NewServer()
t.Cleanup(postLoginWebhook.Close)
postRegistrationWebhook.SetConfig(t, conf.GetProvider(t.Context()),
config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeOIDC.String()))
postLoginWebhook.SetConfig(t, conf.GetProvider(t.Context()),
config.HookStrategyKey(config.ViperKeySelfServiceLoginAfter, config.HookGlobal))
subject := testhelpers.RandomEmail()
scope := []string{"openid", "offline"}
t.Run("case=should pass registration", func(t *testing.T) {
transientPayload := `{"data": "registration"}`
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, url.Values{
"transient_payload": {transientPayload},
})
assertIdentity(t, res, body, subject, idTokenClaims{})
expectTokens(t, "valid", subject, body)
assert.Equal(t, "valid", gjson.GetBytes(body, "authentication_methods.0.provider").String(), "%s", body)
postRegistrationWebhook.AssertTransientPayload(t, transientPayload)
})
t.Run("case=should pass login", func(t *testing.T) {
transientPayload := `{"data": "login"}`
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, url.Values{
"transient_payload": {transientPayload},
})
assertIdentity(t, res, body, subject, idTokenClaims{})
expectTokens(t, "valid", subject, body)
assert.Equal(t, "valid", gjson.GetBytes(body, "authentication_methods.0.provider").String(), "%s", body)
postLoginWebhook.AssertTransientPayload(t, transientPayload)
})
t.Run("case=should pass double submit", func(t *testing.T) {
// This test checks that the continuity manager uses a grace period to handle potential double-submit issues.
//
// It addresses issues where Facebook and Apple consent screens on mobile behave in a way that makes it
// easy for users to experience double-submit issues.
j, err := cookiejar.New(nil)
require.NoError(t, err)
makeInitialRequest := func(t *testing.T, provider, action string) (*http.Response, []byte, []string) {
fv := make(url.Values)
fv.Set("provider", provider)
var lastVia []*http.Request
hc := &http.Client{
Jar: j,
Transport: testhelpers.NewTestTransport(t),
CheckRedirect: func(req *http.Request, via []*http.Request) error {
lastVia = via
return nil
},
}
res, err := wrapClientForHydraLoginConsent(t, hc, hydraAdmin, hydraFlowParams{subject: subject, scope: scope}).PostForm(action, fv)
require.NoError(t, err, action)
body, err := io.ReadAll(res.Body)
require.NoError(t, res.Body.Close())
require.NoError(t, err)
require.NotEmpty(t, lastVia)
vias := make([]string, len(lastVia))
for k, v := range lastVia {
vias[k] = v.URL.String()
}
return res, body, vias
}
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
// First login
res, body, via := makeInitialRequest(t, "valid", action)
assertIdentity(t, res, body, subject, idTokenClaims{})
expectTokens(t, "valid", subject, body)
assert.Equal(t, "valid", gjson.GetBytes(body, "authentication_methods.0.provider").String(), "%s", body)
// We fetch the URL which includes the `?code` query parameter.
result := lo.Filter(via, func(s string, _ int) bool {
return strings.Contains(s, "code=")
})
require.Len(t, result, 1)
// And call that URL again. What's interesting here is that the whole requets passes because we are already authenticated.
//
// In this scenario, Ory Kratos correctly forwards the user to the return URL, which in our case returns the identity.
//
// We essentially run into this bit:
//
// if authenticated, err := s.alreadyAuthenticated(w, r, req); err != nil {
// s.forwardError(w, r, req, s.handleError(w, , r, req, pid, nil, err))
// } else if authenticated {
// return <-- we end up here on the second call
// }
res, err = (&http.Client{Jar: j, Transport: testhelpers.NewTestTransport(t)}).Get(result[0])
require.NoError(t, err)
body, err = io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assertIdentity(t, res, body, subject, idTokenClaims{})
expectTokens(t, "valid", subject, body)
assert.Equal(t, "valid", gjson.GetBytes(body, "authentication_methods.0.provider").String(), "%s", body)
// Trying this flow again without the Ory Session cookie will fail as we run into code reuse:
cookies := j.Cookies(urlx.ParseOrPanic(ts.URL))
t.Logf("Cookies: %s", spew.Sdump(cookies))
secondJar, err := cookiejar.New(nil)
require.NoError(t, err)
secondJar.SetCookies(urlx.ParseOrPanic(ts.URL), lo.Filter(cookies, func(item *http.Cookie, index int) bool {
return item.Name != "ory_kratos_session"
}))
cookies = secondJar.Cookies(urlx.ParseOrPanic(ts.URL))
t.Logf("Cookies after: %s", spew.Sdump(cookies))
// Doing the request but this time without the Ory Session Cookie. This may be the case in scenarios where we run into race conditions
// where the server sent a response but the client did not process it.
res, err = (&http.Client{Jar: secondJar, Transport: testhelpers.NewTestTransport(t)}).Get(result[0])
require.NoError(t, err)
body, err = io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Contains(t, string(body), "The authorization code has already been used", "%s", body)
})
})
t.Run("case=carries upstream acr/amr into session AAL", func(t *testing.T) {
// Registers the user through the provider and returns the decoded
// session JSON body from the return handler.
registerAndReturnSession := func(t *testing.T, provider, email string, acr *string, amr []string) []byte {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, provider)
res, body := makeRequest(t, action, provider, hydraFlowParams{
subject: email,
scope: []string{"openid", "offline"},
acr: acr,
amr: amr,
}, nil)
assertIdentity(t, res, body, email, idTokenClaims{})
return body
}
t.Run("case=matching acr elevates session to aal2", func(t *testing.T) {
body := registerAndReturnSession(t, "aal2-acr", testhelpers.RandomEmail(), new("urn:mfa"), nil)
assert.Equal(t, "aal2", gjson.GetBytes(body, "authenticator_assurance_level").String(), "%s", body)
assert.Equal(t, "aal2", gjson.GetBytes(body, "authentication_methods.0.aal").String(), "%s", body)
assert.Equal(t, "aal2-acr", gjson.GetBytes(body, "authentication_methods.0.provider").String(), "%s", body)
assert.Equal(t, "urn:mfa", gjson.GetBytes(body, "authentication_methods.0.upstream_acr").String(), "%s", body)
})
t.Run("case=mismatching acr stays aal1", func(t *testing.T) {
body := registerAndReturnSession(t, "aal2-acr", testhelpers.RandomEmail(), new("urn:basic"), nil)
assert.Equal(t, "aal1", gjson.GetBytes(body, "authenticator_assurance_level").String(), "%s", body)
assert.Equal(t, "aal1", gjson.GetBytes(body, "authentication_methods.0.aal").String(), "%s", body)
assert.Equal(t, "urn:basic", gjson.GetBytes(body, "authentication_methods.0.upstream_acr").String(), "%s", body)
})
t.Run("case=matching amr elevates session to aal2", func(t *testing.T) {
body := registerAndReturnSession(t, "aal2-amr", testhelpers.RandomEmail(), nil, []string{"pwd", "mfa"})
assert.Equal(t, "aal2", gjson.GetBytes(body, "authenticator_assurance_level").String(), "%s", body)
assert.Equal(t, "aal2", gjson.GetBytes(body, "authentication_methods.0.aal").String(), "%s", body)
assert.Equal(t, "aal2-amr", gjson.GetBytes(body, "authentication_methods.0.provider").String(), "%s", body)
// Upstream amr is persisted for auditing.
var gotAMR []string
require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(body, "authentication_methods.0.upstream_amr").Raw), &gotAMR))
assert.ElementsMatch(t, []string{"pwd", "mfa"}, gotAMR)
})
t.Run("case=mismatching amr stays aal1", func(t *testing.T) {
body := registerAndReturnSession(t, "aal2-amr", testhelpers.RandomEmail(), nil, []string{"pwd"})
assert.Equal(t, "aal1", gjson.GetBytes(body, "authenticator_assurance_level").String(), "%s", body)
assert.Equal(t, "aal1", gjson.GetBytes(body, "authentication_methods.0.aal").String(), "%s", body)
})
})
t.Run("case=login without registered account", func(t *testing.T) {
postRegistrationWebhook := hooktest.NewServer()
t.Cleanup(postRegistrationWebhook.Close)
postLoginWebhook := hooktest.NewServer()
t.Cleanup(postLoginWebhook.Close)
postRegistrationWebhook.SetConfig(t, conf.GetProvider(t.Context()),
config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeOIDC.String()))
postLoginWebhook.SetConfig(t, conf.GetProvider(t.Context()),
config.HookStrategyKey(config.ViperKeySelfServiceLoginAfter, config.HookGlobal))
subject := testhelpers.RandomEmail()
scope := []string{"openid"}
t.Run("case=should pass login", func(t *testing.T) {
transientPayload := `{"data": "login to registration"}`
r := newBrowserLoginFlow(t, "https://example.com?identity_schema=phone", time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, url.Values{
"transient_payload": {transientPayload},
})
assertIdentity(t, res, body, subject, idTokenClaims{})
assert.Equal(t, "phone", gjson.GetBytes(body, "identity.schema_id").String(), "%s", body)
assert.Equal(t, "valid", gjson.GetBytes(body, "authentication_methods.0.provider").String(), "%s", body)
assert.Empty(t, postLoginWebhook.LastBody,
"post login hook should not have been called, because this was a registration flow")
postRegistrationWebhook.AssertTransientPayload(t, transientPayload)
})
})
t.Run("case=login with Browser+JSON", func(t *testing.T) {
subject := testhelpers.RandomEmail()
scope := []string{"openid"}
t.Run("case=should pass login", func(t *testing.T) {
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeJSONRequest(t, action, "valid", subject, scope, nil)
assert.Equal(t, "browser_location_change_required", gjson.GetBytes(body, "error.id").String(), "%s", body)
continuityCookie := res.Header.Get("Set-Cookie")
key, val, ok := strings.Cut(continuityCookie, "=")
require.True(t, ok)
assert.Equal(t, "ory_kratos_continuity", key)
assert.NotEmpty(t, val)
})
})
t.Run("suite=API with session token exchange code", func(t *testing.T) {
loginOrRegister := func(t *testing.T, client *http.Client, flowID uuid.UUID, code string, params hydraFlowParams) {
_, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{InitCode: code})
require.Error(t, err)
action := assertFormValues(t, flowID, "valid")
returnToURL := makeAPICodeFlowRequest(t, client, action, "valid", params)
returnToCode := returnToURL.Query().Get("code")
assert.NotEmpty(t, code, "code query param was empty in the return_to URL")
codeResponse, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{
InitCode: code,
ReturnToCode: returnToCode,
})
require.NoError(t, err)
assert.NotEmpty(t, codeResponse.Token)
assert.Equal(t, params.subject, gjson.GetBytes(codeResponse.Session.Identity.Traits, "subject").String())
}
performRegistration := func(t *testing.T, client *http.Client, params hydraFlowParams) {
f := newAPIRegistrationFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", 1*time.Minute)
loginOrRegister(t, client, f.ID, f.SessionTokenExchangeCode, params)
}
performLogin := func(t *testing.T, client *http.Client, params hydraFlowParams) {
f := newAPILoginFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", 1*time.Minute)
loginOrRegister(t, client, f.ID, f.SessionTokenExchangeCode, params)
}
for _, tc := range []struct {
name string
first, then func(t *testing.T, client *http.Client, params hydraFlowParams)
}{{
name: "login-twice",
first: performLogin, then: performLogin,
}, {
name: "login-then-register",
first: performLogin, then: performRegistration,
}, {
name: "register-then-login",
first: performRegistration, then: performLogin,
}, {
name: "register-twice",
first: performRegistration, then: performRegistration,
}} {
t.Run("case="+tc.name, func(t *testing.T) {
params := hydraFlowParams{subject: testhelpers.RandomEmail(), scope: []string{"openid"}}
tc.first(t, nil, params)
tc.then(t, nil, params)
})
}
params := hydraFlowParams{subject: testhelpers.RandomEmail(), scope: []string{"openid"}}
t.Run("case=should pass transient payload to registration webhook", func(t *testing.T) {
transientPayload := `{"data": "api-registration"}`
webhook := hooktest.NewServer()
t.Cleanup(webhook.Close)
webhook.SetConfig(t, conf.GetProvider(t.Context()), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeOIDC.String()))
f := newAPIRegistrationFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", time.Minute)
action := assertFormValues(t, f.ID, "valid")
res, err := testhelpers.NewTestClient(t).Post(action, "application/json",
strings.NewReader(fmt.Sprintf(`{"method":"oidc","provider":"valid","transient_payload":%s}`, transientPayload)))
require.NoError(t, err)
require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode)
var changeLocation flow.BrowserLocationChangeRequiredError
require.NoError(t, json.NewDecoder(res.Body).Decode(&changeLocation))
res, err = wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookies(t), hydraAdmin, params).
Get(changeLocation.RedirectBrowserTo)
require.NoError(t, err)
returnToCode := res.Request.URL.Query().Get("code")
require.NotEmpty(t, returnToCode, "code query param was empty in the return_to URL")
codeResponse, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{
InitCode: f.SessionTokenExchangeCode,
ReturnToCode: returnToCode,
})
require.NoError(t, err)
assert.NotEmpty(t, codeResponse.Token)
webhook.AssertTransientPayload(t, transientPayload)
})
t.Run("case=should pass transient payload to login webhook", func(t *testing.T) {
// subject is the same as above, so this triggers a login (already registered)
transientPayload := `{"data": "api-login"}`
webhook := hooktest.NewServer()
t.Cleanup(webhook.Close)
webhook.SetConfig(t, conf.GetProvider(t.Context()), config.HookStrategyKey(config.ViperKeySelfServiceLoginAfter, identity.CredentialsTypeOIDC.String()))
f := newAPILoginFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", time.Minute)
action := assertFormValues(t, f.ID, "valid")
res, err := testhelpers.NewTestClient(t).Post(action, "application/json",
strings.NewReader(fmt.Sprintf(`{"method":"oidc","provider":"valid","transient_payload":%s}`, transientPayload)))
require.NoError(t, err)
require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode)
var changeLocation flow.BrowserLocationChangeRequiredError
require.NoError(t, json.NewDecoder(res.Body).Decode(&changeLocation))
res, err = wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookies(t), hydraAdmin, params).Get(changeLocation.RedirectBrowserTo)
require.NoError(t, err)
returnToCode := res.Request.URL.Query().Get("code")
require.NotEmpty(t, returnToCode, "code query param was empty in the return_to URL")
codeResponse, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{
InitCode: f.SessionTokenExchangeCode,
ReturnToCode: returnToCode,
})
require.NoError(t, err)
assert.NotEmpty(t, codeResponse.Token)
webhook.AssertTransientPayload(t, transientPayload)
})
t.Run("case=should register with incomplete data mapper on API flow", func(t *testing.T) {
t.Run("case=should fail registration on first attempt and succeed on second", func(t *testing.T) {
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid"},
}
params.claims.traits.website = "https://www.ory.com/kratos"
params.claims.traits.groups = []string{"group1", "group2"}
params.claims.metadataPublic.picture = "picture.png"
params.claims.metadataAdmin.phoneNumber = "911"
// First attempt with a too-short name trait.
f := newAPIRegistrationFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code&identity_schema", time.Minute)
action := assertFormValues(t, f.ID, "valid")
res, err := testhelpers.NewTestClient(t).Post(action, "application/json",
strings.NewReader(`{"method":"oidc","provider":"valid","traits.name":"i"}`))
require.NoError(t, err)
require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode)
var changeLocation flow.BrowserLocationChangeRequiredError
require.NoError(t, json.NewDecoder(res.Body).Decode(&changeLocation))
_, err = wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookies(t), hydraAdmin, params).Get(changeLocation.RedirectBrowserTo)
require.NoError(t, err)
// The callback redirects back but registration fails due to validation (name too short).
// Somehow we still end up at the default return_to URL. Please investigate and fix the test or code.
// assert.Truef(t, strings.HasPrefix(res.Request.URL.String(), errTS.URL), "expected %q to have prefix %q", res.Request.URL.String(), "error-ts")
// Second attempt with valid data on a new flow.
f = newAPIRegistrationFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", time.Minute)
action = assertFormValues(t, f.ID, "valid")
res, err = testhelpers.NewTestClient(t).Post(action, "application/json",
strings.NewReader(`{"method":"oidc","provider":"valid","traits.name":"valid-name"}`))
require.NoError(t, err)
require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode)
require.NoError(t, json.NewDecoder(res.Body).Decode(&changeLocation))
res, err = wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookies(t), hydraAdmin, params).Get(changeLocation.RedirectBrowserTo)
require.NoError(t, err)
returnToCode := res.Request.URL.Query().Get("code")
require.NotEmpty(t, returnToCode, "code query param was empty in the return_to URL")
codeResponse, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{
InitCode: f.SessionTokenExchangeCode,
ReturnToCode: returnToCode,
})
require.NoError(t, err)
assert.NotEmptyf(t, codeResponse.Token, "%+v", codeResponse)
assert.Equalf(t, "valid-name", gjson.GetBytes(codeResponse.Session.Identity.Traits, "name").String(), "%+v", codeResponse.Session)
assert.Equalf(t, "https://www.ory.com/kratos", gjson.GetBytes(codeResponse.Session.Identity.Traits, "website").String(), "%+v", codeResponse.Session)
})
})
t.Run("case=should return exchange code even if already authenticated", func(t *testing.T) {
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid"},
}
cl := testhelpers.NewClientWithCookies(t)
// step 1: register and create a session",
returnTo := "/foo"
r := newBrowserLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequestWithClient(t, cl, action, "valid", params, nil)
assert.True(t, strings.HasSuffix(res.Request.URL.String(), returnTo))
assertIdentity(t, res, body, params.subject, idTokenClaims{})
// step 2: perform login and get exchange code
performLogin(t, cl, params)
})
t.Run("case=should use redirect_to URL on failure", func(t *testing.T) {
subject := testhelpers.RandomEmail()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{
Identifiers: []string{subject},
})
i.Traits = identity.Traits(`{"subject":"` + subject + `"}`)
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(t.Context(), i))
f := newAPILoginFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", 1*time.Minute)
_, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{InitCode: f.SessionTokenExchangeCode})
require.Error(t, err)
action := assertFormValues(t, f.ID, "valid")
returnToURL := makeAPICodeFlowRequest(t, nil, action, "valid", hydraFlowParams{subject: subject, scope: []string{"openid"}})
returnedFlow := returnToURL.Query().Get("flow")
require.NotEmpty(t, returnedFlow, "flow query param was empty in the return_to URL")
loginFlow, err := reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(returnedFlow))
require.NoError(t, err)
assert.Equal(t, text.InfoSelfServiceLoginLink, loginFlow.UI.Messages[0].ID)
})
t.Run("case=should return flow ID in return_to when registration has missing required traits", func(t *testing.T) {
// Use a schema that requires "extra_data" which is NOT mapped by the OIDC jsonnet mapper,
// so the registration will fail with a validation error.
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid"},
}
params.claims.traits.website = "https://www.ory.com/kratos"
params.claims.traits.groups = []string{"group1", "group2"}
params.claims.metadataPublic.picture = "picture.png"
params.claims.metadataAdmin.phoneNumber = "911"
f := newAPIRegistrationFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code&identity_schema=extra_data", time.Minute)
_, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{InitCode: f.SessionTokenExchangeCode})
require.Error(t, err)
action := assertFormValues(t, f.ID, "valid")
returnToURL := makeAPICodeFlowRequest(t, nil, action, "valid", params)
returnToCode := returnToURL.Query().Get("code")
require.NotEmpty(t, returnToCode, "code query param was empty in the return_to URL")
returnedFlow := returnToURL.Query().Get("flow")
require.NotEmpty(t, returnedFlow, "flow query param was empty in the return_to URL")
// Verify the flow contains validation errors and trait input nodes
regFlow, err := reg.RegistrationFlowPersister().GetRegistrationFlow(t.Context(), uuid.FromStringOrNil(returnedFlow))
require.NoError(t, err)
assert.Equal(t, f.ID, regFlow.ID, "returned flow ID should match the original registration flow")
// The flow should have trait input nodes for the "extra_data" field that failed validation
body, err := json.Marshal(regFlow.UI)
require.NoError(t, err)
assert.NotEmpty(t, gjson.GetBytes(body, "nodes.#(attributes.name==traits.extra_data)").Raw, "flow UI should contain a traits.extra_data input node: %s", body)
})
})
t.Run("case=submit id_token during registration or login", func(t *testing.T) {
setProviderConfig(t, conf,
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid"),
oidc.Configuration{
Provider: "test-provider",
ID: "test-provider",
ClientID: invalid.ClientID,
ClientSecret: invalid.ClientSecret,
IssuerURL: hydraPublic + "/",
Mapper: "file://./stub/oidc.facebook.jsonnet",
})
oidc.RegisterTestProvider(t, "test-provider")
cl := http.Client{Transport: testhelpers.NewTestTransport(t)}
type testCase struct {
name string
idToken string
provider string
v func(string, string, string) url.Values
expect func(t *testing.T, res *http.Response, body []byte)
}
prep := func(tc *testCase) (provider string, token string, nonce string) {
provider = tc.provider
if provider == "" {
provider = "test-provider"
}
token = tc.idToken
token = strings.ReplaceAll(token, "{{sub}}", testhelpers.RandomEmail())
nonce = randx.MustString(16, randx.Alpha)
token = strings.ReplaceAll(token, "{{nonce}}", nonce)
return
}
for _, tc := range []testCase{
{
name: "should fail if provider does not support id_token submission",
idToken: "error",
provider: "valid",
expect: func(t *testing.T, res *http.Response, body []byte) {
require.Equal(t, "The provider generic does not support id_token verification", gjson.GetBytes(body, "error.reason").String(), "%s", body)
},
},
{
name: "should fail because id_token is invalid",
idToken: "error",
expect: func(t *testing.T, res *http.Response, body []byte) {
require.Equal(t, "Could not verify id_token", gjson.GetBytes(body, "error.reason").String(), "%s", body)
require.Equal(t, "stub error", gjson.GetBytes(body, "error.message").String(), "%s", body)
},
},
{
name: "should fail because claims are invalid",
idToken: "{}",
expect: func(t *testing.T, res *http.Response, body []byte) {
require.Equal(t, "The id_token claims were invalid", gjson.GetBytes(body, "error.reason").String(), "%s", body)
},
},
{
name: "should fail if no nonce is included in the id_token",
idToken: `{
"iss": "https://appleid.apple.com",
"sub": "{{sub}}"
}`,
expect: func(t *testing.T, res *http.Response, body []byte) {
require.Equal(t, "No nonce was included in the id_token but is required by the provider", gjson.GetBytes(body, "error.reason").String(), "%s", body)
},
},
{
name: "should fail if no nonce is supplied in request",
idToken: `{
"iss": "https://appleid.apple.com",
"sub": "{{sub}}",
"nonce": "{{nonce}}"
}`,
v: func(provider, token, _ string) url.Values {
return url.Values{
"id_token": {token},
"provider": {provider},
}
},
expect: func(t *testing.T, res *http.Response, body []byte) {
require.Equal(t, "No nonce was provided but is required by the provider", gjson.GetBytes(body, "error.reason").String(), "%s", body)
},
},
{
name: "should pass if claims are valid",
idToken: `{
"iss": "https://appleid.apple.com",
"sub": "{{sub}}",
"nonce": "{{nonce}}"
}`,
expect: func(t *testing.T, res *http.Response, body []byte) {
require.NotEmpty(t, gjson.GetBytes(body, "session_token").String(), "%s", body)
},
},
{
name: "nonce mismatch",
idToken: `{
"iss": "https://appleid.apple.com",
"sub": "{{sub}}",
"nonce": "random-nonce"
}`,
expect: func(t *testing.T, res *http.Response, body []byte) {
require.Equal(t, "The supplied nonce does not match the nonce from the id_token", gjson.GetBytes(body, "error.reason").String(), "%s", body)
},
},
} {
t.Run(fmt.Sprintf("flow=registration/case=%s", tc.name), func(t *testing.T) {
f := newAPIRegistrationFlow(t, returnTS.URL, time.Minute)
provider, token, nonce := prep(&tc)
action := assertFormValues(t, f.ID, "test-provider")
v := url.Values{
"id_token": {token},
"provider": {provider},
"id_token_nonce": {nonce},
}
if tc.v != nil {
v = tc.v(provider, token, nonce)
}
res, err := cl.PostForm(action, v)
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
tc.expect(t, res, body)
})
t.Run(fmt.Sprintf("flow=login/case=%s", tc.name), func(t *testing.T) {
provider, token, nonce := prep(&tc)
rf := newAPIRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, rf.ID, "test-provider")
v := url.Values{
"id_token": {token},
"provider": {provider},
"id_token_nonce": {nonce},
}
if tc.v != nil {
v = tc.v(provider, token, nonce)
}
_, err := cl.PostForm(action, v)
require.NoError(t, err)
lf := newAPILoginFlow(t, returnTS.URL, time.Minute)
action = assertFormValues(t, lf.ID, "test-provider")
res, err := cl.PostForm(action, v)
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
tc.expect(t, res, body)
})
t.Run(fmt.Sprintf("flow=login_without_registration/case=%s", tc.name), func(t *testing.T) {
provider, token, nonce := prep(&tc)
rf := newAPIRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, rf.ID, "test-provider")
v := url.Values{
"id_token": {token},
"provider": {provider},
"id_token_nonce": {nonce},
}
if tc.v != nil {
v = tc.v(provider, token, nonce)
}
_, err := cl.PostForm(action, v)
require.NoError(t, err)
lf := newAPIRegistrationFlow(t, returnTS.URL, time.Minute)
action = assertFormValues(t, lf.ID, "test-provider")
res, err := cl.PostForm(action, v)
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
tc.expect(t, res, body)
})
t.Run(fmt.Sprintf("flow=login_with_return_session_token_exchange_code/case=%s", tc.name), func(t *testing.T) {
provider, token, nonce := prep(&tc)
lf := newAPILoginFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", time.Minute)
action := assertFormValues(t, lf.ID, "test-provider")
v := url.Values{
"id_token": {token},
"provider": {provider},
"id_token_nonce": {nonce},
}
if tc.v != nil {
v = tc.v(provider, token, nonce)
}
res, err := cl.PostForm(action, v)
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
tc.expect(t, res, body)
})
t.Run(fmt.Sprintf("flow=registration_with_return_session_token_exchange_code/case=%s", tc.name), func(t *testing.T) {
provider, token, nonce := prep(&tc)
lf := newAPIRegistrationFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", time.Minute)
action := assertFormValues(t, lf.ID, "test-provider")
v := url.Values{
"id_token": {token},
"provider": {provider},
"id_token_nonce": {nonce},
}
if tc.v != nil {
v = tc.v(provider, token, nonce)
}
res, err := cl.PostForm(action, v)
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
tc.expect(t, res, body)
})
}
})
t.Run("case=login without registered account with return_to", func(t *testing.T) {
t.Run("case=should pass login", func(t *testing.T) {
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid"},
}
returnTo := "/foo"
r := newBrowserLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assert.True(t, strings.HasSuffix(res.Request.URL.String(), returnTo))
assertIdentity(t, res, body, params.subject, idTokenClaims{})
})
t.Run("case=should pass login and carry over login_challenge to registration", func(t *testing.T) {
client := wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookies(t), hydraAdmin, hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid"},
})
reg.SetHydra(hydra.NewFake())
r := newBrowserLoginFlow(t, fmt.Sprintf("%s?login_challenge=%s", returnTS.URL, hydra.FakeValidLoginChallenge), time.Minute)
action := assertFormValues(t, r.ID, "valid")
fv := url.Values{}
fv.Set("provider", "valid")
res, err := client.PostForm(action, fv)
require.NoError(t, err)
// Expect to be returned to the hydra instance, that instantiated the request
assert.Equal(t, hydra.FakePostLoginURL, strings.TrimSuffix(res.Request.URL.String(), "/"))
})
})
t.Run("case=register and register again but login", func(t *testing.T) {
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid"},
}
t.Run("case=should pass registration", func(t *testing.T) {
postRegistrationWebhook := hooktest.NewServer()
t.Cleanup(postRegistrationWebhook.Close)
postRegistrationWebhook.SetConfig(t, conf.GetProvider(t.Context()), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeOIDC.String()))
r := newBrowserRegistrationFlow(t, returnTS.URL+"?identity_schema=phone", time.Minute)
action := assertFormValues(t, r.ID, "valid")
transientPayload := `{"data": "registration-one"}`
res, body := makeRequest(t, action, "valid", params, url.Values{"transient_payload": {transientPayload}})
assertIdentity(t, res, body, params.subject, idTokenClaims{})
assert.Equalf(t, "phone", gjson.GetBytes(body, "identity.schema_id").String(), "%s", body)
postRegistrationWebhook.AssertTransientPayload(t, transientPayload)
})
t.Run("case=should pass second time registration", func(t *testing.T) {
postLoginWebhook := hooktest.NewServer()
t.Cleanup(postLoginWebhook.Close)
postLoginWebhook.SetConfig(t, conf.GetProvider(t.Context()), config.HookStrategyKey(config.ViperKeySelfServiceLoginAfter, identity.CredentialsTypeOIDC.String()))
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
transientPayload := `{"data": "registration-two"}`
res, body := makeRequest(t, action, "valid", params, url.Values{"transient_payload": {transientPayload}})
assertIdentity(t, res, body, params.subject, idTokenClaims{})
postLoginWebhook.AssertTransientPayload(t, transientPayload)
})
t.Run("case=should pass third time registration with return to", func(t *testing.T) {
postLoginWebhook := hooktest.NewServer()
t.Cleanup(postLoginWebhook.Close)
postLoginWebhook.SetConfig(t, conf.GetProvider(t.Context()), config.HookStrategyKey(config.ViperKeySelfServiceLoginAfter, identity.CredentialsTypeOIDC.String()))
returnTo := "/foo"
r := newBrowserLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute)
action := assertFormValues(t, r.ID, "valid")
transientPayload := `{"data": "registration-three"}`
res, body := makeRequest(t, action, "valid", params, url.Values{"transient_payload": {transientPayload}})
assert.True(t, strings.HasSuffix(res.Request.URL.String(), returnTo))
assertIdentity(t, res, body, params.subject, idTokenClaims{})
postLoginWebhook.AssertTransientPayload(t, transientPayload)
})
})
t.Run("case=register, merge, and complete data", func(t *testing.T) {
for _, tc := range []struct{ name, provider string }{
{name: "idtoken", provider: "valid"},
{name: "userinfo", provider: "claimsViaUserInfo"},
{name: "disable-pkce", provider: "neverPKCE"},
{name: "auto-pkce", provider: "autoPKCE"},
{name: "force-pkce", provider: "forcePKCE"},
} {
claims := idTokenClaims{}
claims.traits.website = "https://www.ory.com/kratos"
claims.traits.groups = []string{"group1", "group2"}
claims.metadataPublic.picture = "picture.png"
claims.metadataAdmin.phoneNumber = "911"
makeRequest := func(t *testing.T, action, subject, name string, fv url.Values) (*http.Response, []byte) {
if fv == nil {
fv = make(url.Values)
}
fv.Set("traits.name", name)
fv.Set("provider", tc.provider)
res, err := wrapClientForHydraLoginConsent(t, testhelpers.NewClientWithCookies(t), hydraAdmin,
hydraFlowParams{subject: subject, claims: claims, scope: []string{"openid"}},
).PostForm(action, fv)
require.NoError(t, err, action)
body, err := io.ReadAll(res.Body)
require.NoError(t, res.Body.Close())
require.NoError(t, err)
require.Equalf(t, http.StatusOK, res.StatusCode, "%s: %s\n\t%s", action, res.Request.URL.String(), body)
return res, body
}
t.Run(fmt.Sprintf("ClaimsSource=%s", tc.name), func(t *testing.T) {
t.Run("case=should fail registration on first attempt", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, tc.provider)
res, body := makeRequest(t, action, testhelpers.RandomEmail(), "i", nil)
require.Contains(t, res.Request.URL.String(), uiTS.URL, "%s", body)
assert.Equalf(t, "length must be >= 2, but got 1", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.name).messages.0.text").String(), "%s", body) // make sure the field is being echoed
assert.Equalf(t, "traits.name", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.name).attributes.name").String(), "%s", body) // make sure the field is being echoed
assert.Equalf(t, "i", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.name).attributes.value").String(), "%s", body) // make sure the field is being echoed
assert.Equalf(t, "https://www.ory.com/kratos", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.website).attributes.value").String(), "%s", body) // make sure the field is being echoed
})
t.Run("case=should pass registration with valid data", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, tc.provider)
subject := testhelpers.RandomEmail()
res, body := makeRequest(t, action, subject, "valid-name", nil)
assertIdentity(t, res, body, subject, claims)
assert.Equalf(t, "https://www.ory.com/kratos", gjson.GetBytes(body, "identity.traits.website").String(), "%s", body)
assert.Equalf(t, "valid-name", gjson.GetBytes(body, "identity.traits.name").String(), "%s", body)
assert.JSONEqf(t, `["group1","group2"]`, gjson.GetBytes(body, "identity.traits.groups").Raw, "%s", body)
})
t.Run("case=should fail registration on first attempt with multi-schema select", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL+"?identity_schema=extra_data", time.Minute)
action := assertFormValues(t, r.ID, tc.provider)
res, body := makeRequest(t, action, testhelpers.RandomEmail(), "i", nil)
require.Contains(t, res.Request.URL.String(), uiTS.URL, "%s", body)
assert.Equal(t, "length must be >= 2, but got 1", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.name).messages.0.text").String(), "%s", body) // make sure the field is being echoed
assert.Equal(t, "traits.name", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.name).attributes.name").String(), "%s", body) // make sure the field is being echoed
assert.Equal(t, "traits.extra_data", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.extra_data).attributes.name").String(), "%s", body) // make sure the field is being echoed
assert.Equal(t, "i", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.name).attributes.value").String(), "%s", body) // make sure the field is being echoed
assert.Equal(t, "https://www.ory.com/kratos", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.website).attributes.value").String(), "%s", body) // make sure the field is being echoed
})
t.Run("case=should pass registration with selected schema with valid data", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL+"?identity_schema=extra_data", time.Minute)
action := assertFormValues(t, r.ID, tc.provider)
subject := testhelpers.RandomEmail()
res, body := makeRequest(t, action, subject, "valid-name-2", url.Values{"traits.extra_data": {"extra-data"}})
assertIdentity(t, res, body, subject, claims)
assert.Equalf(t, "https://www.ory.com/kratos", gjson.GetBytes(body, "identity.traits.website").String(), "%s", body)
assert.Equalf(t, "valid-name-2", gjson.GetBytes(body, "identity.traits.name").String(), "%s", body)
assert.Equalf(t, "extra-data", gjson.GetBytes(body, "identity.traits.extra_data").String(), "%s", body)
assert.Equalf(t, `["group1","group2"]`, gjson.GetBytes(body, "identity.traits.groups").String(), "%s", body)
})
})
}
})
for _, loginHintsEnabled := range []bool{true, false} {
t.Run("login-hints-enabled="+strconv.FormatBool(loginHintsEnabled), func(t *testing.T) {
t.Run("case=should fail to register and return fresh login flow if email is already being used by password credentials", func(t *testing.T) {
subject := "email-exist-with-password-strategy-lh-" + strconv.FormatBool(loginHintsEnabled) + "@ory.sh"
scope := []string{"openid"}
conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationLoginHints, loginHintsEnabled)
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{
Identifiers: []string{subject},
Config: sqlxx.JSONRawMessage(`{"hashed_password": "foo"}`),
})
i.Traits = identity.Traits(`{"subject":"` + subject + `"}`)
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(t.Context(), i))
t.Run("case=should fail registration", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
_, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, nil)
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("expires_at", "updated_at", "issued_at", "id", "created_at", "ui.action", findCsrfTokenPath(t, body), "request_url"), snapshotx.ExceptNestedKeys("newLoginUrl", "new_login_url"))
})
t.Run("case=should fail registration id_first strategy enabled", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeySelfServiceLoginFlowStyle, "identifier_first")
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
_, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, nil)
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("expires_at", "updated_at", "issued_at", "id", "created_at", "ui.action", findCsrfTokenPath(t, body), "request_url"), snapshotx.ExceptNestedKeys("newLoginUrl", "new_login_url"))
})
t.Run("case=should fail login", func(t *testing.T) {
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
_, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, nil)
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("expires_at", "updated_at", "issued_at", "id", "created_at", "ui.action", findCsrfTokenPath(t, body), "request_url"), snapshotx.ExceptNestedKeys("newLoginUrl", "new_login_url"))
})
})
t.Run("case=should fail to register and return fresh login flow if email is already being used by oidc credentials", func(t *testing.T) {
subject := "email-exist-with-oidc-strategy-lh-" + strconv.FormatBool(loginHintsEnabled) + "@ory.sh"
scope := []string{"openid"}
conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationLoginHints, loginHintsEnabled)
t.Run("case=create oidc identity", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "secondProvider")
res, _ := makeRequest(t, action, "secondProvider", hydraFlowParams{subject: subject, scope: scope}, nil)
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("case=should fail registration", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
_, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, nil)
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("expires_at", "updated_at", "issued_at", "id", "created_at", "ui.action", findCsrfTokenPath(t, body), "request_url"), snapshotx.ExceptNestedKeys("newLoginUrl", "new_login_url"))
})
t.Run("case=should fail registration id_first strategy enabled", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeySelfServiceLoginFlowStyle, "identifier_first")
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
_, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, nil)
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("expires_at", "updated_at", "issued_at", "id", "created_at", "ui.action", findCsrfTokenPath(t, body), "request_url"), snapshotx.ExceptNestedKeys("newLoginUrl", "new_login_url"))
})
t.Run("case=should fail login", func(t *testing.T) {
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
_, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, nil)
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("expires_at", "updated_at", "issued_at", "id", "created_at", "ui.action", findCsrfTokenPath(t, body), "request_url"), snapshotx.ExceptNestedKeys("newLoginUrl", "new_login_url"))
})
})
})
}
t.Run("case=should redirect to default return ts when sending authenticated login flow without forced flag", func(t *testing.T) {
subject := testhelpers.RandomEmail()
scope := []string{"openid"}
fv := url.Values{"traits.name": {"valid-name"}}
client := testhelpers.NewClientWithCookies(t)
r1 := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
res1, body1 := makeRequestWithClient(t, client, assertFormValues(t, r1.ID, "valid"), "valid", hydraFlowParams{subject: subject, scope: scope}, fv)
assertIdentity(t, res1, body1, subject, idTokenClaims{})
r2 := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
res2, body2 := makeRequestWithClient(t, client, assertFormValues(t, r2.ID, "valid"), "valid", hydraFlowParams{subject: subject, scope: scope}, fv)
assertIdentity(t, res2, body2, subject, idTokenClaims{})
assert.Equal(t, body1, body2)
})
t.Run("case=should reauthenticate when sending authenticated login flow with forced flag", func(t *testing.T) {
subject := testhelpers.RandomEmail()
scope := []string{"openid"}
fv := url.Values{"traits.name": {"valid-name"}}
client := testhelpers.NewClientWithCookies(t)
r1 := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
res1, body1 := makeRequestWithClient(t, client, assertFormValues(t, r1.ID, "valid"), "valid", hydraFlowParams{subject: subject, scope: scope}, fv)
assertIdentity(t, res1, body1, subject, idTokenClaims{})
r2 := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
require.NoError(t, reg.LoginFlowPersister().ForceLoginFlow(t.Context(), r2.ID))
res2, body2 := makeRequestWithClient(t, client, assertFormValues(t, r2.ID, "valid"), "valid", hydraFlowParams{subject: subject, scope: scope}, fv)
assertIdentity(t, res2, body2, subject, idTokenClaims{})
assert.NotEqual(t, gjson.GetBytes(body1, "id"), gjson.GetBytes(body2, "id"))
authAt1, err := time.Parse(time.RFC3339, gjson.GetBytes(body1, "authenticated_at").String())
require.NoError(t, err)
authAt2, err := time.Parse(time.RFC3339, gjson.GetBytes(body2, "authenticated_at").String())
require.NoError(t, err)
// authenticated at is newer in the second body
assert.Greater(t, authAt2.Sub(authAt1).Milliseconds(), int64(0), "%s - %s : %s - %s", authAt2, authAt1, body2, body1)
})
t.Run("case=upstream parameters should be passed on to provider", func(t *testing.T) {
subject := testhelpers.RandomEmail()
scope := []string{"openid", "offline"}
// We need to disable redirects because the upstream parameters are only passed on to the provider
c := &http.Client{
Transport: testhelpers.NewTestTransport(t),
CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse },
}
t.Run("case=should pass when registering", func(t *testing.T) {
f := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, f.ID, "valid")
fv := url.Values{}
fv.Set("provider", "valid")
fv.Set("upstream_parameters.login_hint", "oidc-upstream-parameters@ory.sh")
fv.Set("upstream_parameters.hd", "ory.sh")
fv.Set("upstream_parameters.prompt", "select_account")
fv.Set("upstream_parameters.auth_type", "reauthenticate")
res, err := wrapClientForHydraLoginConsent(t, c, hydraAdmin, hydraFlowParams{subject: subject, scope: scope}).PostForm(action, fv)
require.NoError(t, err)
require.Equal(t, http.StatusSeeOther, res.StatusCode)
loc, err := res.Location()
require.NoError(t, err)
// login_hint is never forwarded: a caller-supplied value cannot be
// trusted (HackerOne #3239672, ory-corp/cloud#8955).
require.Empty(t, loc.Query().Get("login_hint"))
require.Equal(t, "ory.sh", loc.Query().Get("hd"))
require.Equal(t, "select_account", loc.Query().Get("prompt"))
require.Equal(t, "reauthenticate", loc.Query().Get("auth_type"))
})
t.Run("case=should pass when logging in", func(t *testing.T) {
f := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, f.ID, "valid")
fv := url.Values{}
fv.Set("provider", "valid")
fv.Set("upstream_parameters.login_hint", "oidc-upstream-parameters@ory.sh")
fv.Set("upstream_parameters.hd", "ory.sh")
fv.Set("upstream_parameters.prompt", "select_account")
res, err := c.PostForm(action, fv)
require.NoError(t, err)
require.Equal(t, http.StatusSeeOther, res.StatusCode)
loc, err := res.Location()
require.NoError(t, err)
// login_hint is never forwarded: a caller-supplied value cannot be
// trusted (HackerOne #3239672, ory-corp/cloud#8955).
require.Empty(t, loc.Query().Get("login_hint"))
require.Equal(t, "ory.sh", loc.Query().Get("hd"))
require.Equal(t, "select_account", loc.Query().Get("prompt"))
})
t.Run("case=should ignore invalid parameters when logging in", func(t *testing.T) {
f := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, f.ID, "valid")
fv := url.Values{}
fv.Set("upstream_parameters.lol", "invalid")
res, err := c.PostForm(action, fv)
require.NoError(t, err)
require.Equal(t, http.StatusSeeOther, res.StatusCode)
loc, err := res.Location()
require.NoError(t, err)
// upstream parameters that are not on the allow list will be ignored and not passed on to the upstream provider.
require.Empty(t, loc.Query().Get("lol"))
})
t.Run("case=should ignore invalid parameters when registering", func(t *testing.T) {
f := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, f.ID, "valid")
fv := url.Values{}
fv.Set("upstream_parameters.lol", "invalid")
res, err := c.PostForm(action, fv)
require.NoError(t, err)
require.Equal(t, http.StatusFound, res.StatusCode)
loc, err := res.Location()
require.NoError(t, err)
// upstream parameters that are not on the allow list will be ignored and not passed on to the upstream provider.
require.Empty(t, loc.Query().Get("lol"))
})
})
t.Run("case=verified addresses should be respected", func(t *testing.T) {
scope := []string{"openid"}
assertVerifiedEmail := func(t *testing.T, body []byte, subject string, verified bool) {
b := prettyJSON(t, body)
assert.Lenf(t, gjson.GetBytes(body, "identity.verifiable_addresses").Array(), 1, "%s", b)
assert.Equalf(t, "email", gjson.GetBytes(body, "identity.verifiable_addresses.0.via").String(), "%s", b)
assert.Equalf(t, subject, gjson.GetBytes(body, "identity.verifiable_addresses.0.value").String(), "%s", b)
assert.Equalf(t, verified, gjson.GetBytes(body, "identity.verifiable_addresses.0.verified").Bool(), "%s", b)
}
t.Run("case=should have verified address when subject matches", func(t *testing.T) {
subject := testhelpers.RandomEmail()
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute, registration.WithFlowIdentitySchema("verifiable_email"))
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, nil)
assertIdentity(t, res, body, subject, idTokenClaims{})
assertVerifiedEmail(t, body, subject, true)
})
t.Run("case=should have verified address when subject matches after normalization", func(t *testing.T) {
subject := " Denormalized-Verified-Email@ory.sh "
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute, registration.WithFlowIdentitySchema("verifiable_email"))
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, url.Values{"traits.subject": {"denormalized-verified-EMAIL@ory.sh"}})
subject = "denormalized-verified-EMAIL@ory.sh"
assertIdentity(t, res, body, subject, idTokenClaims{})
subject = "denormalized-verified-email@ory.sh"
assertVerifiedEmail(t, body, subject, true)
})
t.Run("case=should have unverified address when subject does not match", func(t *testing.T) {
subject := testhelpers.RandomEmail()
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute, registration.WithFlowIdentitySchema("verifiable_email"))
action := assertFormValues(t, r.ID, "valid")
newSubject := testhelpers.RandomEmail()
res, body := makeRequest(t, action, "valid", hydraFlowParams{subject: subject, scope: scope}, url.Values{"traits.subject": {newSubject}})
assertIdentity(t, res, body, newSubject, idTokenClaims{})
assertVerifiedEmail(t, body, newSubject, false)
})
})
loginWithOIDC := func(t *testing.T, c *http.Client, flowID uuid.UUID, provider, subject string, scope []string) (*http.Response, []byte) {
action := assertFormValues(t, flowID, provider)
res, err := wrapClientForHydraLoginConsent(t, c, hydraAdmin, hydraFlowParams{subject: subject, scope: scope}).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(t *testing.T, res *http.Response, body []byte, identityID uuid.UUID, provider, subject string) {
assert.Containsf(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(t.Context(), identityID)
require.NoError(t, err)
assert.NotEmptyf(t, i.Credentials["oidc"], "%+v", i.Credentials)
assert.Truef(t, gjson.GetBytes(i.Credentials["oidc"].Config, fmt.Sprintf("providers.#(provider=%q)", provider)).Exists(),
"%s", string(i.Credentials["oidc"].Config[:]))
assert.Contains(t, gjson.GetBytes(body, "authentication_methods").String(), "oidc", "%s", body)
}
t.Run("case=registration should start new login flow if duplicate credentials detected", func(t *testing.T) {
reg.Config().MustSet(t.Context(), config.ViperKeySelfServiceRegistrationLoginHints, true)
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" // #nosec G101
var i *identity.Identity
t.Run("step=create password identity", func(t *testing.T) {
i = identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
p, err := reg.Hasher(t.Context()).Generate(t.Context(), []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(t.Context(), 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(t.Context(), i2))
})
client := testhelpers.NewClientWithCookies(t)
loginFlow := newLoginFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser)
loginFlow.OrganizationID = uuid.NullUUID{UUID: orgID, Valid: true}
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), loginFlow))
var linkingLoginFlow struct {
ID string
UIAction string
CSRFToken string
}
t.Run("step=should fail login and start a new flow", func(t *testing.T) {
// To test that the subject is normalized properly
res, body := loginWithOIDC(t, client, loginFlow.ID, "valid", strings.ToUpper(subject), scope)
assert.True(t, res.Request.URL.Query().Has("no_org_ui"))
assertUIError(t, res, body, `You tried to sign in with "new-login-if-email-exist-with-password-strategy@ory.sh", but that email is already used by another account. Sign in to your account with one of the options below to add your account "new-login-if-email-exist-with-password-strategy@ory.sh" at "generic" as another way to sign in.`)
assert.True(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==identifier)").Exists(), "%s", body)
assert.True(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==password)").Exists(), "%s", body)
assert.Equal(t, "new-login-if-email-exist-with-password-strategy@ory.sh", gjson.GetBytes(body, "ui.messages.#(id==1010016).context.duplicateIdentifier").String())
assert.Equal(t, gjson.GetBytes(body, "organization_id").String(), orgID.String())
assert.NotContains(t, gjson.GetBytes(body, "request_url").String(), "code=")
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.EqualValues(t,
text.ErrorValidationLoginLinkedCredentialsDoNotMatch,
gjson.GetBytes(body, "ui.messages.0.id").Int(),
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(t, res, body, i.ID, "valid", subject)
})
})
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) {
r := newRegistrationFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser)
action := assertFormValues(t, r.ID, "secondProvider")
res, body := makeRequest(t, action, "secondProvider", hydraFlowParams{subject: email1, scope: scope}, nil)
assertIdentity(t, res, body, email1, idTokenClaims{})
identityID = expectTokens(t, "secondProvider", email1, body)
r = newRegistrationFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser)
action = assertFormValues(t, r.ID, "valid")
res, body = makeRequest(t, action, "valid", hydraFlowParams{subject: email2, scope: scope}, nil)
assertIdentity(t, res, body, email2, idTokenClaims{})
expectTokens(t, "valid", email2, body)
})
client := testhelpers.NewClientWithCookieJar(t, nil, nil)
loginFlow := newLoginFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser)
var linkingLoginFlowID string
t.Run("step=should fail login and start a new login", func(t *testing.T) {
// To test the identifier mismatch
conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationLoginHints, false)
res, body := loginWithOIDC(t, client, loginFlow.ID, "valid2", email1, scope)
assertUIError(t, res, body, `You tried to sign in with "existing-oidc-identity-1@ory.sh", but that email is already used by another account. Sign in to your account with one of the options below to add your account "existing-oidc-identity-1@ory.sh" at "generic" as another way to sign in.`)
linkingLoginFlowID = gjson.GetBytes(body, "id").String()
assert.NotEqual(t, loginFlow.ID.String(), linkingLoginFlowID, "should have started a new flow")
})
t.Run("step=should fail login if existing identity identifier doesn't match", func(t *testing.T) {
require.NotNil(t, linkingLoginFlowID)
require.NotEmpty(t, linkingLoginFlowID)
res, body := loginWithOIDC(t, client, uuid.Must(uuid.FromString(linkingLoginFlowID)), "valid", email2, scope)
assertUIError(t, res, body, "Linked credentials do not match.")
})
t.Run("step=should link oidc credentials to existing identity", func(t *testing.T) {
res, body := loginWithOIDC(t, client, uuid.Must(uuid.FromString(linkingLoginFlowID)), "secondProvider", email1, scope)
checkCredentialsLinked(t, res, body, identityID, "secondProvider", email1)
})
})
})
t.Run("case=account linking still works when registration is disabled", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationEnabled, false)
conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationLoginHints, true)
t.Cleanup(func() {
ctx := context.WithoutCancel(t.Context())
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, true)
})
subject := "linking-registration-disabled@ory.sh"
password := "lwkj52sdkjf" // #nosec G101
scope := []string{"openid"}
var i *identity.Identity
t.Run("step=create password identity", func(t *testing.T) {
i = identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
p, err := reg.Hasher(t.Context()).Generate(t.Context(), []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(t.Context(), i))
})
client := testhelpers.NewClientWithCookies(t)
loginFlow := newLoginFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser)
var linkingLoginFlow struct {
UIAction string
CSRFToken string
}
t.Run("step=converts to a linking login flow despite disabled registration", func(t *testing.T) {
res, body := loginWithOIDC(t, client, loginFlow.ID, "valid", subject, scope)
assertUIError(t, res, body, "You tried to sign in with")
assert.Equal(t, subject, gjson.GetBytes(body, "ui.messages.#(id==1010016).context.duplicateIdentifier").String(), "%s", body)
linkingLoginFlow.UIAction = gjson.GetBytes(body, "ui.action").String()
linkingLoginFlow.CSRFToken = gjson.GetBytes(body, `ui.nodes.#(attributes.name=="csrf_token").attributes.value`).String()
})
t.Run("step=confirm with password and link", 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(t, res, body, i.ID, "valid", subject)
})
})
t.Run("case=unknown user is still rejected when registration is disabled", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationEnabled, false)
t.Cleanup(func() {
ctx := context.WithoutCancel(t.Context())
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, true)
})
client := testhelpers.NewClientWithCookies(t)
loginFlow := newLoginFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser)
res, body := loginWithOIDC(t, client, loginFlow.ID, "valid", testhelpers.RandomEmail(), []string{"openid"})
assertUIError(t, res, body, "Registration is not allowed because it was disabled")
})
t.Run("suite=auto link policy", func(t *testing.T) {
t.Run("case=should automatically link credential if policy says so", func(t *testing.T) {
subject := testhelpers.RandomEmail()
scope := []string{"openid"}
reg.AllLoginStrategies().MustStrategy("oidc").(*oidc.Strategy).SetOnConflictingIdentity(t,
func(ctx context.Context, existingIdentity, newIdentity *identity.Identity, _ oidc.Provider, _ *oidc.Claims) oidc.ConflictingIdentityVerdict {
return oidc.ConflictingIdentityVerdictMerge
})
var i *identity.Identity
t.Run("step=create identity in org without credentials", func(t *testing.T) {
i = identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"subject":"` + subject + `"}`)
i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{
Type: identity.CredentialsTypePassword,
Identifiers: []string{subject},
Config: sqlxx.JSONRawMessage(`{}`),
})
i.OrganizationID = uuid.NullUUID{
UUID: orgID,
Valid: true,
}
i.VerifiableAddresses = []identity.VerifiableAddress{{Value: subject, Via: "email", Verified: true}}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(t.Context(), i))
})
t.Run("step=log in with OIDC", func(t *testing.T) {
loginFlow := newLoginFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser)
loginFlow.OrganizationID = i.OrganizationID
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), loginFlow))
client := testhelpers.NewClientWithCookieJar(t, nil, nil)
res, body := loginWithOIDC(t, client, loginFlow.ID, "valid", subject, scope)
checkCredentialsLinked(t, res, body, i.ID, "valid", subject)
})
})
t.Run("case=should remove use_auto_link credential if policy says so", func(t *testing.T) {
subject := testhelpers.RandomEmail()
scope := []string{"openid"}
reg.AllLoginStrategies().MustStrategy("oidc").(*oidc.Strategy).SetOnConflictingIdentity(t,
func(ctx context.Context, existingIdentity, newIdentity *identity.Identity, _ oidc.Provider, _ *oidc.Claims) oidc.ConflictingIdentityVerdict {
return oidc.ConflictingIdentityVerdictMerge
})
var i *identity.Identity
t.Run("step=create identity with use_auto_link", func(t *testing.T) {
i = identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"subject":"` + subject + `"}`)
i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{
Type: identity.CredentialsTypePassword,
Identifiers: []string{subject},
Config: sqlxx.JSONRawMessage(`{}`),
})
i.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{
Type: identity.CredentialsTypeOIDC,
Identifiers: []string{"valid:stub", "other:other-identifier"},
Config: sqlxx.JSONRawMessage(`{"providers": [{
"subject": "stub",
"provider": "valid",
"use_auto_link": true
},{
"subject": "other-identifier",
"provider": "other",
"use_auto_link": true
}]}`),
})
i.VerifiableAddresses = []identity.VerifiableAddress{{Value: subject, Via: "email", Verified: true}}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(t.Context(), i))
})
t.Run("step=log in with OIDC", func(t *testing.T) {
loginFlow := newLoginFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser)
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), loginFlow))
client := testhelpers.NewClientWithCookieJar(t, nil, nil)
res, body := loginWithOIDC(t, client, loginFlow.ID, "valid", subject, scope)
checkCredentialsLinked(t, res, body, i.ID, "valid", subject)
})
t.Run("step=should remove use_auto_link", func(t *testing.T) {
var err error
i, err = reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), i.ID)
require.NoError(t, err)
assert.False(t, gjson.GetBytes(i.Credentials["oidc"].Config, `providers.#(provider=="valid").use_auto_link`).Bool())
assert.NotContains(t, i.Credentials["oidc"].Identifiers, "valid:stub")
assert.Contains(t, i.Credentials["oidc"].Identifiers, "valid:"+subject)
assert.True(t, gjson.GetBytes(i.Credentials["oidc"].Config, `providers.#(provider=="other").use_auto_link`).Bool())
})
})
})
t.Run("case=update identity on login", func(t *testing.T) {
t.Run("case=should update traits when claims change", func(t *testing.T) {
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid", "offline"},
}
params.claims.traits.website = "https://original.example.com"
params.claims.metadataPublic.picture = "original.png"
params.claims.metadataAdmin.phoneNumber = "111"
// Set up a provider with update_identity_on_login: automatic.
setProviderConfig(t, conf,
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid", func(c *oidc.Configuration) {
c.UpdateIdentityOnLogin = oidc.UpdateIdentityOnLoginAutomatic
}),
)
t.Run("step=register", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assertIdentity(t, res, body, params.subject, params.claims)
})
t.Run("step=login with changed claims updates identity", func(t *testing.T) {
params := params
params.claims.traits.website = "https://updated.example.com"
params.claims.metadataPublic.picture = "updated.png"
params.claims.metadataAdmin.phoneNumber = "222"
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assert.Contains(t, res.Request.URL.String(), returnTS.URL, "%s", body)
assert.Equal(t, params.subject, gjson.GetBytes(body, "identity.traits.subject").String(), "%s", body)
// as per mapper the original website trait should remain unchanged
assert.Equal(t, "https://original.example.com", gjson.GetBytes(body, "identity.traits.website").String(), "%s", body)
assert.Equal(t, "updated.png", gjson.GetBytes(body, "identity.metadata_public.picture").String(), "%s", body)
// Verify metadata_admin was updated by reading the identity from the admin API.
identityID := gjson.GetBytes(body, "identity.id").String()
i, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), uuid.FromStringOrNil(identityID))
require.NoError(t, err)
assert.Equal(t, "222", gjson.GetBytes(i.MetadataAdmin, "phone_number").String())
})
})
t.Run("case=should not update identity when claims are unchanged", func(t *testing.T) {
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid", "offline"},
}
params.claims.traits.website = "https://stable.example.com"
params.claims.metadataPublic.picture = "stable.png"
setProviderConfig(t, conf,
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid", func(c *oidc.Configuration) {
c.UpdateIdentityOnLogin = oidc.UpdateIdentityOnLoginAutomatic
}),
)
t.Run("step=register", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assertIdentity(t, res, body, params.subject, params.claims)
})
t.Run("step=login with same claims does not error", func(t *testing.T) {
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assertIdentity(t, res, body, params.subject, params.claims)
})
})
t.Run("case=should not update identity when update_identity_on_login is never", func(t *testing.T) {
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid", "offline"},
}
params.claims.traits.website = "https://original.example.com"
params.claims.metadataPublic.picture = "original.png"
setProviderConfig(t, conf,
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid"),
)
t.Run("step=register", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assertIdentity(t, res, body, params.subject, params.claims)
})
t.Run("step=login with changed claims does not update identity", func(t *testing.T) {
params := params
params.claims.traits.website = "https://changed.example.com"
params.claims.metadataPublic.picture = "changed.png"
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assert.Contains(t, res.Request.URL.String(), returnTS.URL, "%s", body)
assert.Equal(t, params.subject, gjson.GetBytes(body, "identity.traits.subject").String(), "%s", body)
// Traits should still have the original values from registration.
assert.Equal(t, "https://original.example.com", gjson.GetBytes(body, "identity.traits.website").String(), "%s", body)
assert.Equal(t, "original.png", gjson.GetBytes(body, "identity.metadata_public.picture").String(), "%s", body)
})
})
t.Run("case=should update only metadata without changing traits", func(t *testing.T) {
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid", "offline"},
}
params.claims.traits.website = "https://example.com"
params.claims.metadataPublic.picture = "old-pic.png"
params.claims.metadataAdmin.phoneNumber = "111"
setProviderConfig(t, conf,
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid", func(c *oidc.Configuration) {
c.UpdateIdentityOnLogin = oidc.UpdateIdentityOnLoginAutomatic
}),
)
t.Run("step=register", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assertIdentity(t, res, body, params.subject, params.claims)
})
t.Run("step=login with changed metadata only", func(t *testing.T) {
// Traits stay the same, only metadata changes.
params := params
params.claims.metadataPublic.picture = "new-pic.png"
params.claims.metadataAdmin.phoneNumber = "222"
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assert.Contains(t, res.Request.URL.String(), returnTS.URL, "%s", body)
assert.Equal(t, "https://example.com", gjson.GetBytes(body, "identity.traits.website").String(), "%s", body)
assert.Equal(t, "new-pic.png", gjson.GetBytes(body, "identity.metadata_public.picture").String(), "%s", body)
// Verify metadata_admin was updated by reading the identity from the admin API.
identityID := gjson.GetBytes(body, "identity.id").String()
i, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), uuid.FromStringOrNil(identityID))
require.NoError(t, err)
assert.Equal(t, "222", gjson.GetBytes(i.MetadataAdmin, "phone_number").String())
})
})
t.Run("case=should fail login when mapper is invalid", func(t *testing.T) {
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid", "offline"},
}
params.claims.traits.website = "https://example.com"
// Register with a valid mapper.
setProviderConfig(t, conf,
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid", func(c *oidc.Configuration) {
c.UpdateIdentityOnLogin = oidc.UpdateIdentityOnLoginAutomatic
}),
)
t.Run("step=register", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assertIdentity(t, res, body, params.subject, params.claims)
})
t.Run("step=login with broken mapper causes error", func(t *testing.T) {
// Reconfigure with a mapper that produces invalid output.
setProviderConfig(t, conf,
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid", func(c *oidc.Configuration) {
c.UpdateIdentityOnLogin = oidc.UpdateIdentityOnLoginAutomatic
c.Mapper = "file://./stub/oidc.invalid-mapper.jsonnet"
}),
)
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assertSystemErrorWithReason(t, res, body, http.StatusInternalServerError,
"OpenID Connect Jsonnet mapper did not return an object for key identity.traits")
})
})
t.Run("case=should preserve existing traits when mapper omits them", func(t *testing.T) {
params := hydraFlowParams{
subject: testhelpers.RandomEmail(),
scope: []string{"openid", "offline"},
}
params.claims.traits.website = "https://example.com"
// Register with the standard mapper (includes subject and website in traits).
setProviderConfig(t, conf,
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid", func(c *oidc.Configuration) {
c.UpdateIdentityOnLogin = oidc.UpdateIdentityOnLoginAutomatic
}),
)
t.Run("step=register", func(t *testing.T) {
r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assertIdentity(t, res, body, params.subject, params.claims)
})
t.Run("step=login with mapper that omits subject preserves it via merge", func(t *testing.T) {
// Reconfigure with a mapper that omits the "subject" trait but
// outputs a new website. Merge should preserve subject from the
// existing identity.
params := params
params.claims.traits.website = "https://new.example.com"
setProviderConfig(t, conf,
newOIDCProvider(t, ts, hydraPublic, hydraAdmin, "valid", func(c *oidc.Configuration) {
c.UpdateIdentityOnLogin = oidc.UpdateIdentityOnLoginAutomatic
c.Mapper = "file://./stub/oidc.missing-subject.jsonnet"
}),
)
r := newBrowserLoginFlow(t, returnTS.URL, time.Minute)
action := assertFormValues(t, r.ID, "valid")
res, body := makeRequest(t, action, "valid", params, nil)
assert.Contains(t, res.Request.URL.String(), returnTS.URL, "%s", body)
// Subject should be preserved from the existing identity via merge.
assert.Equal(t, params.subject, gjson.GetBytes(body, "identity.traits.subject").String(), "%s", body)
// Website should be updated from the new mapper output.
assert.Equal(t, "https://new.example.com", gjson.GetBytes(body, "identity.traits.website").String(), "%s", body)
})
})
})
t.Run("method=TestPopulateSignUpMethod", func(t *testing.T) {
sr, err := registration.NewFlow(reg, &http.Request{URL: urlx.ParseOrPanic("/")}, flow.TypeBrowser)
require.NoError(t, err)
require.NoError(t, reg.RegistrationStrategies(t.Context()).MustStrategy(identity.CredentialsTypeOIDC).(*oidc.Strategy).PopulateRegistrationMethod(&http.Request{}, sr))
snapshotx.SnapshotT(t, sr.UI, snapshotx.ExceptPaths("action", "nodes.0.attributes.value"))
})
}
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) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t)
strategy := oidc.NewStrategy(reg)
toJSON := func(c identity.CredentialsOIDC) []byte {
out, err := json.Marshal(&c)
require.NoError(t, err)
return out
}
for k, tc := range []struct {
in map[identity.CredentialsType]identity.Credentials
expected int
}{
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte{},
}},
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: toJSON(identity.CredentialsOIDC{Providers: []identity.CredentialsOIDCProvider{
{Subject: "foo", Provider: "bar"},
}}),
}},
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{""},
Config: toJSON(identity.CredentialsOIDC{Providers: []identity.CredentialsOIDCProvider{
{Subject: "foo", Provider: "bar"},
}}),
}},
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"bar:"},
Config: toJSON(identity.CredentialsOIDC{Providers: []identity.CredentialsOIDCProvider{
{Subject: "foo", Provider: "bar"},
}}),
}},
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{":foo"},
Config: toJSON(identity.CredentialsOIDC{Providers: []identity.CredentialsOIDCProvider{
{Subject: "foo", Provider: "bar"},
}}),
}},
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"not-bar:foo"},
Config: toJSON(identity.CredentialsOIDC{Providers: []identity.CredentialsOIDCProvider{
{Subject: "foo", Provider: "bar"},
}}),
}},
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"bar:not-foo"},
Config: toJSON(identity.CredentialsOIDC{Providers: []identity.CredentialsOIDCProvider{
{Subject: "foo", Provider: "bar"},
}}),
}},
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"bar:foo"},
Config: toJSON(identity.CredentialsOIDC{Providers: []identity.CredentialsOIDCProvider{
{Subject: "foo", Provider: "bar"},
}}),
}},
expected: 1,
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
in := make(map[identity.CredentialsType]identity.Credentials)
for _, v := range tc.in {
in[v.Type] = v
}
actual, err := strategy.CountActiveFirstFactorCredentials(t.Context(), in)
require.NoError(t, err)
assert.Equal(t, tc.expected, actual)
})
}
}
func TestDisabledEndpoint(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeOIDC, false)),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/stub.schema.json")),
)
publicTS, _ := testhelpers.NewKratosServer(t, reg)
t.Run("case=should not callback when oidc method is disabled", func(t *testing.T) {
c := testhelpers.NewClientWithCookies(t)
res, err := c.Get(publicTS.URL + oidc.RouteCallback)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, res.StatusCode)
b := ioutilx.MustReadAll(res.Body)
assert.Contains(t, string(b), "This endpoint was disabled by system administrator")
})
t.Run("case=should not auth when oidc method is disabled", func(t *testing.T) {
c := testhelpers.NewClientWithCookies(t)
t.Run("flow=settings", func(t *testing.T) {
c := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t.Context(), t, reg)
f := testhelpers.InitializeSettingsFlowViaAPI(t, c, publicTS)
res, err := c.PostForm(f.Ui.Action, url.Values{"link": {"oidc"}})
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, res.StatusCode)
b := ioutilx.MustReadAll(res.Body)
assert.Contains(t, string(b), "This endpoint was disabled by system administrator")
})
t.Run("flow=login", func(t *testing.T) {
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, c, publicTS, false)
res, err := c.PostForm(f.Ui.Action, url.Values{"provider": {"oidc"}})
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, res.StatusCode)
b := ioutilx.MustReadAll(res.Body)
assert.Contains(t, string(b), "This endpoint was disabled by system administrator")
})
t.Run("flow=registration", func(t *testing.T) {
f := testhelpers.InitializeRegistrationFlowViaAPICtx(t.Context(), t, c, publicTS)
res, err := c.PostForm(f.Ui.Action, url.Values{"provider": {"oidc"}})
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, res.StatusCode)
b := ioutilx.MustReadAll(res.Body)
assert.Contains(t, string(b), "This endpoint was disabled by system administrator")
})
})
}
func TestPostEndpointRedirect(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeOIDC, true)),
)
remoteAdmin, remotePublic := newHydra(t)
publicTS, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
setProviderConfig(t, conf, newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "apple"))
for _, providerID := range []string{"apple", "apple-asd123"} {
t.Run("case=should redirect to GET and preserve parameters/id="+providerID, func(t *testing.T) {
// create a client that does not follow redirects
c := &http.Client{
Transport: testhelpers.NewTestTransport(t),
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
res, err := c.PostForm(publicTS.URL+"/self-service/methods/oidc/callback/"+providerID, url.Values{"state": {"foo"}, "test": {"3"}})
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, http.StatusFound, res.StatusCode)
location, err := res.Location()
require.NoError(t, err)
assert.Equal(t, publicTS.URL+"/self-service/methods/oidc/callback/"+providerID+"?state=foo&test=3", location.String())
// We don't want to add/override CSRF cookie when redirecting
testhelpers.AssertNoCSRFCookieInResponse(t, res)
})
}
}
func findCsrfTokenPath(t *testing.T, body []byte) string {
nodes := gjson.GetBytes(body, "ui.nodes").Array()
index := slices.IndexFunc(nodes, func(n gjson.Result) bool {
return n.Get("attributes.name").String() == "csrf_token"
})
require.GreaterOrEqual(t, index, 0)
return fmt.Sprintf("ui.nodes.%v.attributes.value", index)
}