snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
391
selfservice/flow/login/test_handler_test.go
Normal file
391
selfservice/flow/login/test_handler_test.go
Normal file
@@ -0,0 +1,391 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"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/ui/container"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/otelx"
|
||||
"github.com/ory/x/sqlxx"
|
||||
)
|
||||
|
||||
// newRegistryWithOIDCProvider returns a fast test registry configured with a
|
||||
// single generic OIDC provider whose ID is "google". The populator can find
|
||||
// the provider when the admin-create handler invokes it.
|
||||
func newRegistryWithOIDCProvider(t *testing.T) *driver.RegistryDefault {
|
||||
t.Helper()
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://../../strategy/oidc/stub/registration.schema.json")),
|
||||
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeOIDC, true)),
|
||||
configx.WithValue(
|
||||
config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".config.providers",
|
||||
[]map[string]any{{
|
||||
"provider": "generic",
|
||||
"id": "google",
|
||||
"label": "Google",
|
||||
"client_id": "invalid",
|
||||
"client_secret": "invalid",
|
||||
"issuer_url": "https://foobar/",
|
||||
"mapper_url": "file://../../strategy/oidc/stub/oidc.facebook.jsonnet",
|
||||
}},
|
||||
),
|
||||
)
|
||||
return reg
|
||||
}
|
||||
|
||||
func doAdminPost(t *testing.T, adminURL string, body any) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
res, err := testhelpers.NewTestClient(t).Post(adminURL+"/admin"+login.RouteAdminCreateTestFlow, "application/json", bytes.NewReader(b))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = res.Body.Close() })
|
||||
respBody, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
return res, respBody
|
||||
}
|
||||
|
||||
func doDelete(t *testing.T, c *http.Client, u string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodDelete, u, nil)
|
||||
require.NoError(t, err)
|
||||
res, err := c.Do(req)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = res.Body.Close() })
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
return res, body
|
||||
}
|
||||
|
||||
// newSeedTestFlow creates a minimal test login flow directly via the
|
||||
// persister to support cap-enforcement seeding.
|
||||
func newSeedTestFlow(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, exp time.Duration) *login.Flow {
|
||||
t.Helper()
|
||||
f := &login.Flow{
|
||||
ID: x.NewUUID(),
|
||||
Type: flow.TypeBrowser,
|
||||
State: flow.StateChooseMethod,
|
||||
RequestURL: "https://example.com/self-service/login/browser",
|
||||
IssuedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(exp),
|
||||
CSRFToken: nosurfx.FakeCSRFToken,
|
||||
UI: container.New(""),
|
||||
InternalContext: sqlxx.JSONRawMessage(`{}`),
|
||||
}
|
||||
require.NoError(t, f.SetTestContext(&login.TestContext{ProviderID: "google"}))
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(ctx, f))
|
||||
return f
|
||||
}
|
||||
|
||||
func TestHandler_AdminCreateTestLoginFlow_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := newRegistryWithOIDCProvider(t)
|
||||
_, admin := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
res, body := doAdminPost(t, admin.URL, map[string]string{"provider_id": "google"})
|
||||
require.Equal(t, http.StatusCreated, res.StatusCode, "%s", body)
|
||||
assert.NotEmpty(t, gjson.GetBytes(body, "id").String())
|
||||
assert.Equal(t, "google", gjson.GetBytes(body, "test_context.provider_id").String())
|
||||
assert.Equal(t, string(flow.StateChooseMethod), gjson.GetBytes(body, "state").String())
|
||||
|
||||
// UI contains a provider submit node scoped to google.
|
||||
found := false
|
||||
for _, n := range gjson.GetBytes(body, "ui.nodes").Array() {
|
||||
if n.Get("attributes.name").String() == "provider" && n.Get("attributes.value").String() == "google" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected provider=google submit node in UI: %s", body)
|
||||
}
|
||||
|
||||
// TestHandler_AdminCreateTestLoginFlow_Tracing asserts that the handler
|
||||
// emits a span recording which strategy served the request and what the
|
||||
// provider lookup saw. A production incident where the wrong strategy was
|
||||
// selected (and thus an empty provider list was searched) was invisible in
|
||||
// traces because this path had no instrumentation.
|
||||
func TestHandler_AdminCreateTestLoginFlow_Tracing(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := newRegistryWithOIDCProvider(t)
|
||||
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
reg.SetTracer(otelx.NewNoop().WithOTLP(provider.Tracer("test")))
|
||||
|
||||
_, admin := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
res, body := doAdminPost(t, admin.URL, map[string]string{"provider_id": "google"})
|
||||
require.Equal(t, http.StatusCreated, res.StatusCode, "%s", body)
|
||||
res, body = doAdminPost(t, admin.URL, map[string]string{"provider_id": "nonesuch"})
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode, "%s", body)
|
||||
|
||||
var spans []sdktrace.ReadOnlySpan
|
||||
for _, sp := range recorder.Ended() {
|
||||
if sp.Name() == "login.Handler.adminCreateTestLoginFlow" {
|
||||
spans = append(spans, sp)
|
||||
}
|
||||
}
|
||||
require.Len(t, spans, 2, "expected one handler span per request")
|
||||
|
||||
attrs := func(sp sdktrace.ReadOnlySpan) map[attribute.Key]attribute.Value {
|
||||
out := make(map[attribute.Key]attribute.Value, len(sp.Attributes()))
|
||||
for _, kv := range sp.Attributes() {
|
||||
out[kv.Key] = kv.Value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
found := attrs(spans[0])
|
||||
assert.Equal(t, "oidc", found["test_login_flow.strategy"].AsString())
|
||||
assert.Equal(t, int64(1), found["test_login_flow.available_providers"].AsInt64())
|
||||
assert.True(t, found["test_login_flow.provider_found"].AsBool())
|
||||
|
||||
notFound := attrs(spans[1])
|
||||
assert.Equal(t, "oidc", notFound["test_login_flow.strategy"].AsString())
|
||||
assert.Equal(t, int64(1), notFound["test_login_flow.available_providers"].AsInt64())
|
||||
assert.False(t, notFound["test_login_flow.provider_found"].AsBool())
|
||||
}
|
||||
|
||||
func TestHandler_AdminCreateTestLoginFlow_BadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := newRegistryWithOIDCProvider(t)
|
||||
_, admin := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]string
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "empty provider_id", body: map[string]string{}, wantStatus: http.StatusBadRequest},
|
||||
{name: "unknown provider_id", body: map[string]string{"provider_id": "nonesuch"}, wantStatus: http.StatusNotFound},
|
||||
{name: "provider_id too long", body: map[string]string{"provider_id": strings.Repeat("a", 256)}, wantStatus: http.StatusBadRequest},
|
||||
} {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
res, body := doAdminPost(t, admin.URL, tc.body)
|
||||
assert.Equal(t, tc.wantStatus, res.StatusCode, "%s", body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_DeleteTestLoginFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type setup func(t *testing.T, reg *driver.RegistryDefault) *login.Flow
|
||||
|
||||
seedUncaptured := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedTestFlow(t, context.Background(), reg, time.Hour)
|
||||
}
|
||||
// seedCapturedMatching: captured flow whose CSRFToken matches what the
|
||||
// fake CSRF generator returns — simulates the browser that holds the
|
||||
// post-callback cookie.
|
||||
seedCapturedMatching := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, nosurfx.FakeCSRFToken)
|
||||
}
|
||||
// seedCapturedMismatch: captured flow with a CSRFToken that won't match,
|
||||
// simulating a request without (or with the wrong) CSRF cookie.
|
||||
seedCapturedMismatch := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, "some-other-token")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup setup
|
||||
wantStatus int
|
||||
wantDeleted bool
|
||||
}{
|
||||
{
|
||||
name: "uncaptured: 204",
|
||||
setup: seedUncaptured,
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantDeleted: true,
|
||||
},
|
||||
{
|
||||
name: "captured with mismatched CSRF token: 403",
|
||||
setup: seedCapturedMismatch,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "captured with matching CSRF token: 204",
|
||||
setup: seedCapturedMatching,
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantDeleted: true,
|
||||
},
|
||||
} {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
reg := newRegistryWithOIDCProvider(t)
|
||||
public, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
f := tc.setup(t, reg)
|
||||
u := fmt.Sprintf("%s%s?id=%s", public.URL, login.RouteDeleteTestFlow, f.ID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, u, nil)
|
||||
require.NoError(t, err)
|
||||
res, err := public.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = res.Body.Close() })
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantStatus, res.StatusCode, "%s", body)
|
||||
|
||||
_, getErr := reg.LoginFlowPersister().GetLoginFlow(ctx, f.ID)
|
||||
if tc.wantDeleted {
|
||||
assert.Error(t, getErr, "flow should be deleted")
|
||||
} else {
|
||||
assert.NoError(t, getErr, "flow should still exist")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_DeleteTestLoginFlow_NotATestFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
public, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
// Create a regular (non-test) login flow directly.
|
||||
f := &login.Flow{
|
||||
ID: x.NewUUID(),
|
||||
Type: flow.TypeBrowser,
|
||||
State: flow.StateChooseMethod,
|
||||
RequestURL: "https://example.com/self-service/login/browser",
|
||||
IssuedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
CSRFToken: nosurfx.FakeCSRFToken,
|
||||
UI: container.New(""),
|
||||
InternalContext: sqlxx.JSONRawMessage(`{}`),
|
||||
}
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(ctx, f))
|
||||
|
||||
u := fmt.Sprintf("%s%s?id=%s", public.URL, login.RouteDeleteTestFlow, f.ID)
|
||||
res, body := doDelete(t, public.Client(), u)
|
||||
assert.Equal(t, http.StatusNotFound, res.StatusCode, "%s", body)
|
||||
}
|
||||
|
||||
func TestHandler_DeleteTestLoginFlow_InvalidUUID(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
public, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
u := fmt.Sprintf("%s%s?id=not-a-uuid", public.URL, login.RouteDeleteTestFlow)
|
||||
res, body := doDelete(t, public.Client(), u)
|
||||
assert.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", body)
|
||||
}
|
||||
|
||||
func TestHandler_GetTestLoginFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type setup func(t *testing.T, reg *driver.RegistryDefault) *login.Flow
|
||||
|
||||
seedUncaptured := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedTestFlow(t, context.Background(), reg, time.Hour)
|
||||
}
|
||||
seedCapturedMatching := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, nosurfx.FakeCSRFToken)
|
||||
}
|
||||
seedCapturedMismatch := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, "some-other-token")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup setup
|
||||
wantStatus int
|
||||
assertBody func(t *testing.T, body []byte)
|
||||
}{
|
||||
{
|
||||
name: "uncaptured: 200",
|
||||
setup: seedUncaptured,
|
||||
wantStatus: http.StatusOK,
|
||||
assertBody: func(t *testing.T, body []byte) {
|
||||
assert.Equal(t, "google", gjson.GetBytes(body, "test_context.provider_id").String())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "captured with mismatched CSRF token: 403",
|
||||
setup: seedCapturedMismatch,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "captured with matching CSRF token: 200",
|
||||
setup: seedCapturedMatching,
|
||||
wantStatus: http.StatusOK,
|
||||
assertBody: func(t *testing.T, body []byte) {
|
||||
assert.Equal(t, "alice@example.com",
|
||||
gjson.GetBytes(body, "test_context.debug_payload.id_token_claims.sub").String())
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := newRegistryWithOIDCProvider(t)
|
||||
public, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
f := tc.setup(t, reg)
|
||||
u := fmt.Sprintf("%s%s?id=%s", public.URL, login.RouteGetFlow, f.ID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
require.NoError(t, err)
|
||||
res, err := public.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = res.Body.Close() })
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantStatus, res.StatusCode, "%s", body)
|
||||
if tc.assertBody != nil {
|
||||
tc.assertBody(t, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newSeedCapturedTestFlow creates a test login flow already advanced to the
|
||||
// passed_challenge state with a DebugPayload populated, simulating what the
|
||||
// OIDC callback would have persisted. csrfToken is written to f.CSRFToken so
|
||||
// tests can simulate either the post-callback "matches the cookie" state or a
|
||||
// mismatch.
|
||||
func newSeedCapturedTestFlow(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, exp time.Duration, csrfToken string) *login.Flow {
|
||||
t.Helper()
|
||||
f := newSeedTestFlow(t, ctx, reg, exp)
|
||||
f.CSRFToken = csrfToken
|
||||
f.State = flow.StatePassedChallenge
|
||||
require.NoError(t, f.SetTestContext(&login.TestContext{
|
||||
ProviderID: "google",
|
||||
DebugPayload: &login.DebugPayload{
|
||||
IDTokenClaims: map[string]any{"sub": "alice@example.com"},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(ctx, f))
|
||||
return f
|
||||
}
|
||||
Reference in New Issue
Block a user