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
392 lines
14 KiB
Go
392 lines
14 KiB
Go
// 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
|
|
}
|