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

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import { test } from "../../../fixtures"
import { toConfig } from "../../../lib/helper"
import smsSchema from "../../../fixtures/schemas/sms"
import { LoginPage } from "../../../models/elements/login"
import { hasSession } from "../../../actions/session"
import { createIdentityWithPhoneNumber } from "../../../actions/identity"
import {
deleteDocument,
documentUrl,
fetchDocument,
} from "../../../actions/webhook"
import { RegistrationPage } from "../../../models/elements/registration"
import { CountryNames, generatePhoneNumber } from "phone-number-generator-js"
const documentId = "doc-" + Math.random().toString(36).substring(7)
test.describe("account enumeration protection off", () => {
test.use({
configOverride: {
security: {
account_enumeration: {
enabled: false,
},
},
selfservice: {
flows: {
login: {
style: "unified",
},
registration: {
after: {
code: {
hooks: [
{
hook: "session",
},
],
},
},
},
},
methods: {
code: {
passwordless_enabled: true,
},
password: {
enabled: false,
},
},
},
courier: {
channels: [
{
id: "sms",
type: "http",
request_config: {
body: "base64://ZnVuY3Rpb24oY3R4KSB7DQpjdHg6IGN0eCwNCn0=",
method: "PUT",
url: documentUrl(documentId),
},
},
],
},
identity: {
default_schema_id: "sms",
schemas: [
{
id: "sms",
url:
"base64://" +
Buffer.from(JSON.stringify(smsSchema), "ascii").toString(
"base64",
),
},
],
},
},
})
test.afterEach(async () => {
await deleteDocument(documentId)
})
test("login succeeds", async ({ page, config, kratosPublicURL }) => {
const identity = await createIdentityWithPhoneNumber(page.request)
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(identity.phone)
const result = await fetchDocument(documentId)
await login.codeInput.input.fill(result.ctx.template_data.login_code)
await login.codeSubmit.getByText("Continue").click()
await hasSession(page.request, kratosPublicURL)
})
test("registration succeeds", async ({ page, config, kratosPublicURL }) => {
const phone = generatePhoneNumber({
countryName: CountryNames.Germany,
withoutCountryCode: false,
})
const registration = new RegistrationPage(page, config)
await registration.open()
await registration.triggerRegistrationWithCode(phone)
const result = await fetchDocument(documentId)
const code = result.ctx.template_data.registration_code
await registration.inputField("code").fill(code)
await registration.submitField("code").getByText("Continue").click()
await hasSession(page.request, kratosPublicURL)
})
})

View File

@@ -0,0 +1,636 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import { expect } from "@playwright/test"
import { search } from "../../../actions/mail"
import { getSession, hasNoSession, hasSession } from "../../../actions/session"
import { test } from "../../../fixtures"
import { extractCode, toConfig } from "../../../lib/helper"
import { LoginPage } from "../../../models/elements/login"
import { SettingsPage } from "../../../models/elements/settings"
import { logoutUrl } from "../../../actions/login"
test.describe("account enumeration protection off", () => {
test.use({
configOverride: toConfig({
style: "identifier_first",
mitigateEnumeration: false,
selfservice: {
methods: {
code: {
passwordless_enabled: true,
},
},
},
}),
})
test("login fails because user does not exist", async ({ page, config }) => {
const login = new LoginPage(page, config)
await login.open()
await login.submitIdentifierFirst("i@donot.exist")
await expect(
page.locator('[data-testid="ui/message/4000037"]'),
"expect account not exist message to be shown",
).toBeVisible()
})
test("login with wrong code fails", async ({
page,
identity,
kratosPublicURL,
config,
}) => {
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(identity.email)
await login.codeInput.input.fill("123123")
await login.codeSubmit.getByText("Continue").click()
await hasNoSession(page.request, kratosPublicURL)
await expect(
page.locator('[data-testid="ui/message/4010008"]'),
"expect to be shown a wrong code error",
).toBeVisible()
})
test("login succeeds", async ({
page,
identity,
config,
kratosPublicURL,
}) => {
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(identity.email)
const mails = await search({ query: identity.email, kind: "to" })
expect(mails).toHaveLength(1)
const code = extractCode(mails[0])
await login.codeInput.input.fill(code)
await login.codeSubmit.getByText("Continue").click()
await hasSession(page.request, kratosPublicURL)
})
})
test.describe("account enumeration protection on", () => {
test.use({
configOverride: toConfig({
style: "identifier_first",
mitigateEnumeration: true,
selfservice: {
methods: {
password: {
enabled: false,
},
code: {
passwordless_enabled: true,
},
},
},
}),
})
test("login fails because user does not exist", async ({ page, config }) => {
const login = new LoginPage(page, config)
await login.open()
await login.submitIdentifierFirst("i@donot.exist")
await expect(
page.locator('button[name="method"][value="code"]'),
"expect to show the code form",
).toBeVisible()
})
test("login with wrong code fails", async ({
page,
identity,
kratosPublicURL,
config,
}) => {
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(identity.email)
await login.codeInput.input.fill("123123")
await login.codeSubmit.getByText("Continue").click()
await hasNoSession(page.request, kratosPublicURL)
await expect(
page.locator('[data-testid="ui/message/4010008"]'),
"expect to be shown a wrong code error",
).toBeVisible()
})
test("login succeeds", async ({
page,
identity,
config,
kratosPublicURL,
}) => {
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(identity.email)
const mails = await search({ query: identity.email, kind: "to" })
expect(mails).toHaveLength(1)
const code = extractCode(mails[0])
await login.codeInput.input.fill(code)
await login.codeSubmit.getByText("Continue").click()
await hasSession(page.request, kratosPublicURL)
})
})
for (const tc of [
{
name: "do fast login when only code method enabled and configured, mitigation off",
methodsEnabled: ["code"],
methodsConfigured: "code",
mitigation: false,
expectFastLogin: true,
},
{
name: "do fast login when only code method enabled and configured, mitigation on",
methodsEnabled: ["code"],
methodsConfigured: "code",
mitigation: true,
expectFastLogin: true,
},
{
name: "do not fast login when multiple methods enabled, all configured, mitigation off",
methodsEnabled: ["password", "code"],
methodsConfigured: "all",
mitigation: false,
expectFastLogin: false,
},
{
name: "do not fast login when multiple methods enabled, all configured, mitigation on",
methodsEnabled: ["password", "code"],
methodsConfigured: "all",
mitigation: true,
expectFastLogin: false,
},
{
name: "do fast login when multiple methods enabled, only code configured, mitigation off",
methodsEnabled: ["password", "code"],
methodsConfigured: "code",
mitigation: false,
expectFastLogin: true,
},
{
name: "do not fast login when multiple methods enabled, only code configured, mitigation on",
methodsEnabled: ["password", "code"],
methodsConfigured: "code",
mitigation: true,
expectFastLogin: false,
},
]) {
test.describe(`account enumeration protection ${
tc.mitigation ? "on" : "off"
}`, () => {
test.use({
configOverride: toConfig({
style: "identifier_first",
mitigateEnumeration: tc.mitigation,
selfservice: {
methods: {
oidc: {
enabled: false,
},
password: {
enabled: tc.methodsEnabled.includes("password"),
},
code: {
passwordless_enabled: tc.methodsEnabled.includes("code"),
},
},
},
}),
})
test(
tc.name,
async ({ page, config, identity, identityWithoutPassword }) => {
const id =
tc.methodsConfigured === "all" ? identity : identityWithoutPassword
const login = new LoginPage(page, config)
await login.open()
await login.submitIdentifierFirst(id.email)
if (tc.expectFastLogin) {
await expect(login.submitPassword).toBeHidden()
await expect(
page.locator('[data-testid="ui/message/1010014"]'),
"expect code sent message to be shown",
).toBeVisible()
await expect(login.codeSubmit).toBeVisible()
} else {
await expect(login.submitPassword).toBeVisible()
await expect(login.codeSubmit).toBeVisible()
}
},
)
})
}
test.describe(() => {
test.use({
configOverride: toConfig({
style: "identifier_first",
mitigateEnumeration: false,
selfservice: {
methods: {
password: {
enabled: false,
},
code: {
passwordless_enabled: true,
},
},
},
featureFlags: {
refresh_login_choose_address: true,
},
}),
})
test("refresh", async ({ page, identity, config, kratosPublicURL }) => {
const login = new LoginPage(page, config)
const [initialSession, initialCode] =
await test.step("initial login", async () => {
await login.open()
await login.triggerLoginWithCode(identity.email)
const mails = await search({ query: identity.email, kind: "to" })
expect(mails).toHaveLength(1)
const code = extractCode(mails[0])
await login.codeInput.input.fill(code)
await login.codeSubmit.getByText("Continue").click()
const session = await getSession(page.request, kratosPublicURL)
expect(session).toBeDefined()
expect(session.active).toBe(true)
return [session, code]
})
await login.open({
refresh: true,
})
// On a refresh login the identity is fixed by the session, so the screen
// shows a "Send code to <address>" button instead of an identifier field
// (ory/kratos#4194).
await expect(login.inputField("identifier")).toBeHidden()
await login.codeSubmitMfa.click()
const mails = await search({
query: identity.email,
kind: "to",
filter: (m) => !m.html.includes(initialCode),
})
expect(mails).toHaveLength(1)
const code = extractCode(mails[0])
await login.codeInput.input.fill(code)
await login.codeSubmit.getByText("Continue").click()
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
const newSession = await getSession(page.request, kratosPublicURL)
expect(newSession).toBeDefined()
expect(newSession.active).toBe(true)
const initDate = Date.parse(initialSession.authenticated_at)
const newDate = Date.parse(newSession.authenticated_at)
expect(newDate).toBeGreaterThanOrEqual(initDate)
})
})
test.describe("second factor", () => {
for (const tc of [
{
name: "do fast login when only code method enabled and configured",
methodsEnabled: ["code"],
methodsConfigured: "code",
expectFastLogin: true,
},
{
name: "do not fast login when multiple methods enabled, all configured",
methodsEnabled: ["totp", "code"],
methodsConfigured: "all",
expectFastLogin: false,
},
{
name: "do fast login when multiple methods enabled, only code configured",
methodsEnabled: ["totp", "code"],
methodsConfigured: "code",
expectFastLogin: true,
},
]) {
test.describe(`code mfa ${
tc.expectFastLogin ? "with" : "without"
} fast login`, () => {
test.use({
configOverride: toConfig({
style: "identifier_first",
selfservice: {
methods: {
oidc: {
enabled: false,
},
password: {
enabled: true,
},
totp: {
enabled: tc.methodsEnabled.includes("totp"),
},
code: {
passwordless_enabled: false,
mfa_enabled: tc.methodsEnabled.includes("code"),
},
},
},
}),
})
test(tc.name, async ({ page, config, identity, kratosPublicURL }) => {
const login = new LoginPage(page, config)
if (tc.methodsConfigured === "all") {
await test.step("setup TOTP", async () => {
await login.open()
await login.loginWithPassword(identity.email, identity.password)
const mails = await search({ query: identity.email, kind: "to" })
expect(mails).toHaveLength(1)
const code = extractCode(mails[0])
await login.codeInput.input.fill(code)
await login.codeSubmit.getByText("Continue").click()
const settings = new SettingsPage(page, config)
await settings.open()
await settings.setupTotp()
await page.goto(await logoutUrl(page.request, kratosPublicURL))
})
}
test.step("first factor authentication", async () => {
await login.open()
await login.loginWithPassword(identity.email, identity.password)
})
if (tc.expectFastLogin) {
await expect(login.totpInput.input).toBeHidden()
await expect(
page.locator('[data-testid="ui/message/1010025"]'),
"expect code sent message to be shown",
).toBeVisible()
await expect(login.codeSubmit).toBeVisible()
} else {
await expect(login.totpInput.input).toBeVisible()
await expect(login.codeSubmitMfa).toBeVisible()
}
})
})
}
})
test.describe("second factor refresh", () => {
// TOTP is enabled alongside code so the code method does not fast-login as the
// only second factor. That keeps the flow on the regular handler path, which
// renders the "Send code to <address>" button this test exercises.
test.use({
configOverride: toConfig({
style: "identifier_first",
selfservice: {
methods: {
oidc: {
enabled: false,
},
password: {
enabled: true,
},
totp: {
enabled: true,
},
code: {
passwordless_enabled: false,
mfa_enabled: true,
},
},
},
}),
})
test("refreshes an aal2 session through the code address button", async ({
page,
config,
identity,
kratosPublicURL,
}) => {
const login = new LoginPage(page, config)
const seenCodes: string[] = []
await test.step("set up TOTP as a second 2FA method", async () => {
await login.open()
await login.loginWithPassword(identity.email, identity.password)
// The verified email already works as a code second factor, so with
// `highest_available` the flow fast-logs in to aal2 with code. Complete
// that before opening settings to set up TOTP.
const mails = await search({
query: identity.email,
kind: "to",
filter: (m) => !seenCodes.some((c) => m.html.includes(c)),
})
const code = extractCode(mails[0])
seenCodes.push(code)
await login.codeInput.input.fill(code)
await login.codeSubmit.getByText("Continue").click()
const settings = new SettingsPage(page, config)
await settings.open()
await settings.setupTotp()
await page.goto(await logoutUrl(page.request, kratosPublicURL))
})
const initialSession =
await test.step("log in to aal2 with the code second factor", async () => {
await login.open()
await login.loginWithPassword(identity.email, identity.password)
// With TOTP and code both configured, the flow escalates to aal2 and
// renders both factors instead of fast-logging in.
await expect(login.totpInput.input).toBeVisible()
await expect(login.codeSubmitMfa).toBeVisible()
await login.codeSubmitMfa.click()
const mails = await search({
query: identity.email,
kind: "to",
filter: (m) => !seenCodes.some((c) => m.html.includes(c)),
})
const code = extractCode(mails[0])
seenCodes.push(code)
await login.codeInput.input.fill(code)
await login.codeSubmit.getByText("Continue").click()
const session = await getSession(page.request, kratosPublicURL)
expect(session.active).toBe(true)
expect(session.authenticator_assurance_level).toBe("aal2")
return session
})
await test.step("refresh the aal2 session", async () => {
await login.open({
refresh: true,
aal: "aal2",
})
// On a refresh + aal2 flow the second-factor hydrator wins over the
// first-factor refresh branch: it renders "Send code to <address>"
// buttons and never the hidden first-factor identifier field
// (ory/kratos#4194).
await expect(login.codeSubmitMfa).toBeVisible()
await expect(login.inputField("identifier")).toBeHidden()
await login.codeSubmitMfa.click()
const mails = await search({
query: identity.email,
kind: "to",
filter: (m) => !seenCodes.some((c) => m.html.includes(c)),
})
expect(mails).toHaveLength(1)
const code = extractCode(mails[0])
await login.codeInput.input.fill(code)
await login.codeSubmit.getByText("Continue").click()
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
const refreshedSession = await getSession(page.request, kratosPublicURL)
expect(refreshedSession.active).toBe(true)
expect(refreshedSession.authenticator_assurance_level).toBe("aal2")
const initialAuthAt = Date.parse(initialSession.authenticated_at)
const refreshedAuthAt = Date.parse(refreshedSession.authenticated_at)
expect(refreshedAuthAt).toBeGreaterThanOrEqual(initialAuthAt)
})
})
test("rejects a foreign address on an aal2 refresh", async ({
page,
config,
identity,
kratosPublicURL,
}) => {
const login = new LoginPage(page, config)
const seenCodes: string[] = []
await test.step("set up TOTP as a second 2FA method", async () => {
await login.open()
await login.loginWithPassword(identity.email, identity.password)
// Complete the code second factor that fast-login auto-sends, then set
// up TOTP so later flows render the address button instead of fast-login.
const mails = await search({
query: identity.email,
kind: "to",
filter: (m) => !seenCodes.some((c) => m.html.includes(c)),
})
const code = extractCode(mails[0])
seenCodes.push(code)
await login.codeInput.input.fill(code)
await login.codeSubmit.getByText("Continue").click()
const settings = new SettingsPage(page, config)
await settings.open()
await settings.setupTotp()
await page.goto(await logoutUrl(page.request, kratosPublicURL))
})
const aal2Session =
await test.step("log in to aal2 with the code second factor", async () => {
await login.open()
await login.loginWithPassword(identity.email, identity.password)
await login.codeSubmitMfa.click()
const mails = await search({
query: identity.email,
kind: "to",
filter: (m) => !seenCodes.some((c) => m.html.includes(c)),
})
const code = extractCode(mails[0])
seenCodes.push(code)
await login.codeInput.input.fill(code)
await login.codeSubmit.getByText("Continue").click()
const session = await getSession(page.request, kratosPublicURL)
expect(session.authenticator_assurance_level).toBe("aal2")
return session
})
await test.step("tampering the address with a foreign identifier is rejected", async () => {
await login.open({
refresh: true,
aal: "aal2",
})
// Tamper the server-rendered address button so it posts a foreign
// identifier. A refresh login must not send a code to it
// (ory/kratos#4194).
const foreignEmail = "not-" + identity.email
await login.codeSubmitMfa.evaluate(
(el, value) => el.setAttribute("value", value),
foreignEmail,
)
await login.codeSubmitMfa.click()
await expect(
page.locator('[data-testid="ui/message/4000035"]'),
"expect the no-code-credentials error to be shown",
).toBeVisible()
await expect(login.codeInput.input).toBeHidden()
// The active session is untouched: still aal2, same authentication time.
const session = await getSession(page.request, kratosPublicURL)
expect(session.active).toBe(true)
expect(session.authenticator_assurance_level).toBe("aal2")
expect(session.authenticated_at).toBe(aal2Session.authenticated_at)
})
})
})

View File

@@ -0,0 +1,171 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import { faker } from "@faker-js/faker"
import { expect, Page } from "@playwright/test"
import { getSession, hasSession } from "../../../actions/session"
import { test } from "../../../fixtures"
import { toConfig } from "../../../lib/helper"
import { LoginPage } from "../../../models/elements/login"
import { OryKratosConfiguration } from "../../../../shared/config"
async function loginHydra(page: Page) {
return test.step("login with hydra", async () => {
await page
.locator("input[name=username]")
.fill(faker.internet.email({ provider: "ory.sh" }))
await page.locator("button[name=action][value=accept]").click()
await page.locator("#offline").check()
await page.locator("#openid").check()
await page.locator("input[name=website]").fill(faker.internet.url())
await page.locator("button[name=action][value=accept]").click()
})
}
async function registerWithHydra(
page: Page,
config: OryKratosConfiguration,
kratosPublicURL: string,
) {
return await test.step("register", async () => {
await page.goto("/registration")
await page.locator(`button[name=provider][value=hydra]`).click()
const email = faker.internet.email({ provider: "ory.sh" })
await page.locator("input[name=username]").fill(email)
await page.locator("#remember").check()
await page.locator("button[name=action][value=accept]").click()
await page.locator("#offline").check()
await page.locator("#openid").check()
await page.locator("input[name=website]").fill(faker.internet.url())
await page.locator("button[name=action][value=accept]").click()
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
await page.context().clearCookies({
domain: new URL(kratosPublicURL).hostname,
})
await expect(
getSession(page.request, kratosPublicURL),
).rejects.toThrowError()
return email
})
}
for (const mitigateEnumeration of [true, false]) {
test.describe(`account enumeration protection ${
mitigateEnumeration ? "on" : "off"
}`, () => {
test.use({
configOverride: toConfig({
mitigateEnumeration,
selfservice: {
methods: {
password: {
enabled: true,
},
},
},
}),
})
test("login", async ({ page, config, kratosPublicURL }) => {
const login = new LoginPage(page, config)
await login.open()
await page.locator(`button[name=provider][value=hydra]`).click()
await loginHydra(page)
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
await hasSession(page.request, kratosPublicURL)
})
test("oidc sign in on second step", async ({
page,
config,
kratosPublicURL,
}) => {
const email = await registerWithHydra(page, config, kratosPublicURL)
const login = new LoginPage(page, config)
await login.open()
await login.submitIdentifierFirst(email)
// If account enumeration is mitigated, we should see the password method,
// because the identity has not set up a password
await expect(
page.locator('button[name="method"][value="password"]'),
"hide the password method",
).toBeVisible({ visible: mitigateEnumeration })
await page.locator(`button[name=provider][value=hydra]`).click()
await loginHydra(page)
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
const session = await getSession(page.request, kratosPublicURL)
expect(session).toBeDefined()
expect(session.active).toBe(true)
})
})
}
test("login with refresh", async ({ page, config, kratosPublicURL }) => {
await registerWithHydra(page, config, kratosPublicURL)
const login = new LoginPage(page, config)
const initialSession = await test.step("initial login", async () => {
await login.open()
await page.locator(`button[name=provider][value=hydra]`).click()
await loginHydra(page)
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
return await getSession(page.request, kratosPublicURL)
})
// This is required, because OIDC issues a new session on refresh (TODO), and MySQL does not store sub second timestamps, so we need to wait a bit
await page.waitForTimeout(1000)
await test.step("refresh login", async () => {
await login.open({
refresh: true,
})
await expect(
page.locator('[data-testid="ui/message/1010003"]'),
"show the refresh message",
).toBeVisible()
await page.locator(`button[name=provider][value=hydra]`).click()
await loginHydra(page)
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
const newSession = await getSession(page.request, kratosPublicURL)
// expect(newSession.authentication_methods).toHaveLength(
// initialSession.authentication_methods.length + 1,
// )
expect(newSession.authenticated_at).not.toBe(
initialSession.authenticated_at,
)
})
})

View File

@@ -0,0 +1,238 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import { faker } from "@faker-js/faker"
import { CDPSession, expect, Page } from "@playwright/test"
import { OryKratosConfiguration } from "../../../../shared/config"
import { getSession } from "../../../actions/session"
import { test } from "../../../fixtures"
import { toConfig } from "../../../lib/helper"
import { LoginPage } from "../../../models/elements/login"
async function toggleAutomaticPresenceSimulation(
cdpSession: CDPSession,
authenticatorId: string,
enabled: boolean,
) {
await cdpSession.send("WebAuthn.setAutomaticPresenceSimulation", {
authenticatorId,
enabled,
})
}
async function registerWithPasskey(
page: Page,
pageCDPSession: CDPSession,
config: OryKratosConfiguration,
authenticatorId: string,
simulatePresence: boolean,
) {
return await test.step("create webauthn identity", async () => {
await page.goto("/registration")
const identifier = faker.internet.email()
await page.locator(`input[name="traits.email"]`).fill(identifier)
await page
.locator(`input[name="traits.website"]`)
.fill(faker.internet.url())
await page.locator("button[name=method][value=profile]").click()
await toggleAutomaticPresenceSimulation(
pageCDPSession,
authenticatorId,
true,
)
await page.locator("button[name=passkey_register_trigger]").click()
await toggleAutomaticPresenceSimulation(
pageCDPSession,
authenticatorId,
simulatePresence,
)
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
return identifier
})
}
const passkeyConfig = {
methods: {
passkey: {
enabled: true,
config: {
rp: {
display_name: "ORY",
id: "localhost",
origins: ["http://localhost:4455"],
},
},
},
},
}
for (const mitigateEnumeration of [true, false]) {
test.describe(`account enumeration protection ${
mitigateEnumeration ? "on" : "off"
}`, () => {
test.use({
configOverride: toConfig({
mitigateEnumeration,
style: "identifier_first",
selfservice: passkeyConfig,
}),
})
for (const simulatePresence of [true, false]) {
test.describe(`${
simulatePresence ? "with" : "without"
} automatic presence proof`, () => {
test.use({
virtualAuthenticatorOptions: {
automaticPresenceSimulation: simulatePresence,
// hasResidentKey: simulatePresence,
},
})
test("login", async ({
config,
page,
kratosPublicURL,
virtualAuthenticator,
pageCDPSession,
}) => {
const identifier = await registerWithPasskey(
page,
pageCDPSession,
config,
virtualAuthenticator.authenticatorId,
simulatePresence,
)
await page.context().clearCookies({})
const login = new LoginPage(page, config)
await login.open()
if (!simulatePresence) {
await login.submitIdentifierFirst(identifier)
const passkeyLoginTrigger = page.locator(
"button[name=passkey_login_trigger]",
)
await passkeyLoginTrigger.waitFor()
await page.waitForLoadState("load")
await toggleAutomaticPresenceSimulation(
pageCDPSession,
virtualAuthenticator.authenticatorId,
true,
)
await passkeyLoginTrigger.click()
await toggleAutomaticPresenceSimulation(
pageCDPSession,
virtualAuthenticator.authenticatorId,
false,
)
}
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
await expect(
getSession(page.request, kratosPublicURL),
).resolves.toMatchObject({
active: true,
identity: {
traits: {
email: identifier,
},
},
})
})
})
}
})
}
test.describe("without automatic presence simulation", () => {
test.use({
virtualAuthenticatorOptions: {
automaticPresenceSimulation: false,
},
configOverride: toConfig({
selfservice: passkeyConfig,
}),
})
test("login with refresh", async ({
page,
config,
kratosPublicURL,
pageCDPSession,
virtualAuthenticator,
}) => {
const identifier = await registerWithPasskey(
page,
pageCDPSession,
config,
virtualAuthenticator.authenticatorId,
true,
)
const login = new LoginPage(page, config)
// Due to resetting automatic presence simulating to "true" in the previous step,
// opening the login page automatically triggers the passkey login
await login.open()
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
await expect(
getSession(page.request, kratosPublicURL),
).resolves.toMatchObject({
active: true,
identity: {
traits: {
email: identifier,
},
},
})
await login.open({
refresh: true,
})
await expect(
page.locator('[data-testid="ui/message/1010003"]'),
"show the refresh message",
).toBeVisible()
const initialSession = await getSession(page.request, kratosPublicURL)
const passkeyLoginTrigger = page.locator(
"button[name=passkey_login_trigger]",
)
await passkeyLoginTrigger.waitFor()
await page.waitForLoadState("load")
await toggleAutomaticPresenceSimulation(
pageCDPSession,
virtualAuthenticator.authenticatorId,
true,
)
await passkeyLoginTrigger.click()
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
const newSession = await getSession(page.request, kratosPublicURL)
expect(newSession.authentication_methods).toHaveLength(
initialSession.authentication_methods.length + 1,
)
})
})

View File

@@ -0,0 +1,243 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import { faker } from "@faker-js/faker"
import { CDPSession, expect, Page } from "@playwright/test"
import { OryKratosConfiguration } from "../../../../shared/config"
import { getSession, hasNoSession } from "../../../actions/session"
import { test } from "../../../fixtures"
import { toConfig } from "../../../lib/helper"
async function toggleAutomaticPresenceSimulation(
cdpSession: CDPSession,
authenticatorId: string,
enabled: boolean,
) {
await cdpSession.send("WebAuthn.setAutomaticPresenceSimulation", {
authenticatorId,
enabled,
})
}
async function startPasskeyRegistration(page: Page) {
await page.goto("/registration")
const identifier = faker.internet.email()
await page.locator(`input[name="traits.email"]`).fill(identifier)
await page.locator(`input[name="traits.website"]`).fill(faker.internet.url())
await page.locator("button[name=method][value=profile]").click()
return identifier
}
async function registerWithPasskey(
page: Page,
pageCDPSession: CDPSession,
config: OryKratosConfiguration,
authenticatorId: string,
) {
return await test.step("create passkey identity", async () => {
const identifier = await startPasskeyRegistration(page)
await toggleAutomaticPresenceSimulation(
pageCDPSession,
authenticatorId,
true,
)
await page.locator("button[name=passkey_register_trigger]").click()
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
return identifier
})
}
test.describe("passkey options - cross-platform attachment", () => {
test.use({
configOverride: toConfig({
style: "identifier_first",
selfservice: {
methods: {
passkey: {
enabled: true,
config: {
rp: {
display_name: "ORY",
id: "localhost",
origins: ["http://localhost:4455"],
},
authenticator_selection: {
attachment: "cross-platform",
},
},
},
},
flows: {
registration: {
after: {
passkey: {
hooks: [{ hook: "session" }],
},
},
},
},
},
}),
virtualAuthenticatorOptions: {
automaticPresenceSimulation: true,
hasResidentKey: true,
transport: "usb",
},
})
test("registration with cross-platform authenticator succeeds", async ({
config,
page,
pageCDPSession,
virtualAuthenticator,
kratosPublicURL,
}) => {
const identifier = await registerWithPasskey(
page,
pageCDPSession,
config,
virtualAuthenticator.authenticatorId,
)
await expect(
getSession(page.request, kratosPublicURL),
).resolves.toMatchObject({
active: true,
identity: { traits: { email: identifier } },
})
})
})
test.describe("passkey options - user verification required", () => {
test.use({
configOverride: toConfig({
style: "identifier_first",
selfservice: {
methods: {
passkey: {
enabled: true,
config: {
rp: {
display_name: "ORY",
id: "localhost",
origins: ["http://localhost:4455"],
},
authenticator_selection: {
user_verification: "required",
},
},
},
},
flows: {
registration: {
after: {
passkey: {
hooks: [{ hook: "session" }],
},
},
},
},
},
}),
virtualAuthenticatorOptions: {
automaticPresenceSimulation: true,
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
})
test("registration with user verification required succeeds", async ({
config,
page,
pageCDPSession,
virtualAuthenticator,
kratosPublicURL,
}) => {
const identifier = await registerWithPasskey(
page,
pageCDPSession,
config,
virtualAuthenticator.authenticatorId,
)
await expect(
getSession(page.request, kratosPublicURL),
).resolves.toMatchObject({
active: true,
identity: { traits: { email: identifier } },
})
})
})
test.describe("passkey options - user verification required, authenticator cannot verify", () => {
// Authenticator reports no UV capability, so the browser refuses to
// create a credential when the RP sets user_verification=required.
test.use({
configOverride: toConfig({
style: "identifier_first",
selfservice: {
methods: {
passkey: {
enabled: true,
config: {
rp: {
display_name: "ORY",
id: "localhost",
origins: ["http://localhost:4455"],
},
authenticator_selection: {
user_verification: "required",
},
},
},
},
flows: {
registration: {
after: {
passkey: {
hooks: [{ hook: "session" }],
},
},
},
},
},
}),
virtualAuthenticatorOptions: {
automaticPresenceSimulation: true,
hasResidentKey: true,
hasUserVerification: false,
isUserVerified: false,
},
})
test("registration fails and leaves the user on the registration page", async ({
page,
pageCDPSession,
virtualAuthenticator,
kratosPublicURL,
}) => {
await startPasskeyRegistration(page)
await toggleAutomaticPresenceSimulation(
pageCDPSession,
virtualAuthenticator.authenticatorId,
true,
)
await page.locator("button[name=passkey_register_trigger]").click()
// Credential creation must fail; the flow must not finalize a session.
// Give the browser time to reject the ceremony, then assert we stay on
// /registration and no session exists.
await page.waitForTimeout(1000)
await expect(page).toHaveURL(/\/registration/)
await hasNoSession(page.request, kratosPublicURL)
// The passkey trigger must remain visible so the user can retry.
await expect(
page.locator("button[name=passkey_register_trigger]"),
).toBeVisible()
})
})

View File

@@ -0,0 +1,236 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import { expect } from "@playwright/test"
import { loginWithPassword } from "../../../actions/login"
import { getSession, hasNoSession, hasSession } from "../../../actions/session"
import { test } from "../../../fixtures"
import { toConfig } from "../../../lib/helper"
import { LoginPage } from "../../../models/elements/login"
// These can run in parallel because they use the same config.
test.describe("account enumeration protection off", () => {
test.use({
configOverride: toConfig({
style: "identifier_first",
mitigateEnumeration: false,
selfservice: {
methods: {
password: {
enabled: true,
},
code: {
passwordless_enabled: false,
},
},
},
}),
})
test("login fails because user does not exist", async ({ page, config }) => {
const login = new LoginPage(page, config)
await login.open()
await login.submitIdentifierFirst("i@donot.exist")
await expect(
page.locator('[data-testid="ui/message/4000037"]'),
"expect account not exist message to be shown",
).toBeVisible()
})
test("login with wrong password fails", async ({
page,
identity,
kratosPublicURL,
config,
}) => {
const login = new LoginPage(page, config)
await login.open()
await login.loginWithPassword(identity.email, "wrong-password")
await login.isReady()
await hasNoSession(page.request, kratosPublicURL)
await expect(
page.locator('[data-testid="ui/message/4000006"]'),
"expect to be shown a credentials do not exist error",
).toBeVisible()
})
test("login succeeds", async ({
page,
identity,
config,
kratosPublicURL,
}) => {
const login = new LoginPage(page, config)
await login.open()
await login.inputField("identifier").fill(identity.email)
await login.submit("identifier_first", {
waitForURL: new RegExp(config.selfservice.flows.login.ui_url),
})
await login.inputField("password").fill(identity.password)
await login.submit("password", {
waitForURL: new RegExp(config.selfservice.default_browser_return_url),
})
await hasSession(page.request, kratosPublicURL)
})
test("login with refresh", async ({
page,
config,
identity,
kratosPublicURL,
}) => {
await loginWithPassword(
{
password: identity.password,
traits: {
email: identity.email,
},
},
page.request,
kratosPublicURL,
)
const login = new LoginPage(page, config)
await login.open({
refresh: true,
})
await expect(
page.locator('[data-testid="ui/message/1010003"]'),
"show the refresh message",
).toBeVisible()
const initialSession = await getSession(page.request, kratosPublicURL)
await login.inputField("password").fill(identity.password)
await login.submit("password", {
waitForURL: new RegExp(config.selfservice.default_browser_return_url),
})
const newSession = await getSession(page.request, kratosPublicURL)
expect(newSession.authentication_methods).toHaveLength(
initialSession.authentication_methods.length + 1,
)
})
})
test.describe("account enumeration protection on", () => {
test.use({
configOverride: toConfig({
style: "identifier_first",
mitigateEnumeration: true,
selfservice: {
methods: {
password: {
enabled: true,
},
code: {
passwordless_enabled: false,
},
},
},
}),
})
test("login fails because user does not exist", async ({ page, config }) => {
const login = new LoginPage(page, config)
await login.open()
await login.submitIdentifierFirst("i@donot.exist")
await expect(
page.locator('button[name="method"][value="password"]'),
"expect to show the password form",
).toBeVisible()
})
test("login with wrong password fails", async ({
page,
identity,
kratosPublicURL,
config,
}) => {
const login = new LoginPage(page, config)
await login.open()
await login.loginWithPassword(identity.email, "wrong-password")
await login.isReady()
await hasNoSession(page.request, kratosPublicURL)
await expect(
page.locator('[data-testid="ui/message/4000006"]'),
"expect to be shown a credentials do not exist error",
).toBeVisible()
})
test("login succeeds", async ({
page,
// projectFrontendClient,
identity,
config,
kratosPublicURL,
}) => {
const login = new LoginPage(page, config)
await login.open()
await login.inputField("identifier").fill(identity.email)
await login.submit("identifier_first", {
waitForURL: new RegExp(config.selfservice.flows.login.ui_url),
})
await login.inputField("password").fill(identity.password)
await login.submit("password", {
waitForURL: new RegExp(config.selfservice.default_browser_return_url),
})
await hasSession(page.request, kratosPublicURL)
})
test("login with refresh", async ({
page,
config,
identity,
kratosPublicURL,
}) => {
await loginWithPassword(
{
password: identity.password,
traits: {
email: identity.email,
},
},
page.request,
kratosPublicURL,
)
const login = new LoginPage(page, config)
await login.open({
refresh: true,
})
await expect(
page.locator('[data-testid="ui/message/1010003"]'),
"show the refresh message",
).toBeVisible()
const initialSession = await getSession(page.request, kratosPublicURL)
await login.inputField("password").fill(identity.password)
await login.submit("password", {
waitForURL: new RegExp(config.selfservice.default_browser_return_url),
})
const newSession = await getSession(page.request, kratosPublicURL)
expect(newSession.authentication_methods).toHaveLength(
initialSession.authentication_methods.length + 1,
)
})
})

View File

@@ -0,0 +1,147 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import { expect } from "@playwright/test"
import { test } from "../../../fixtures"
import { toConfig } from "../../../lib/helper"
import { RegistrationPage } from "../../../models/elements/registration"
import {
OryKratosConfiguration,
RegistrationFlowStyle,
RegistrationNodeGroup,
} from "../../../../shared/config"
const selfservice: Partial<OryKratosConfiguration["selfservice"]> = {
methods: {
code: {
passwordless_enabled: true,
},
password: {
enabled: true,
},
webauthn: {
enabled: true,
config: {
passwordless: true,
rp: { id: "localhost", display_name: "Ory Kratos" },
},
},
passkey: {
enabled: true,
config: {
rp: { id: "localhost", display_name: "Ory Kratos" },
},
},
totp: {
enabled: true,
},
lookup_secret: {
enabled: true,
},
oidc: {
enabled: true,
config: {
providers: [
{
id: "github",
provider: "github",
label: "GitHub",
client_id: "1",
client_secret: "1",
mapper_url: "base64://",
},
{
id: "google",
provider: "google",
label: "Google",
client_id: "1",
client_secret: "1",
mapper_url: "base64://e30=",
},
],
},
},
},
}
test.describe("profile_first strategy with all methods enabled", () => {
;["default", "password"].forEach((group: RegistrationNodeGroup) => {
test.describe(`password group behavior is ${group}`, () => {
;["profile_first", "unified"].forEach((style: RegistrationFlowStyle) => {
test.describe(`registration with ${style} enabled`, () => {
;[
["password"],
["password", "webauthn"],
["password", "code"],
["password", "code", "webauthn"],
["password", "code", "passkey"],
["password", "code", "passkey", "webauthn"],
].forEach((methods) => {
test.describe(`methods ${methods.join(", ")} enabled`, () => {
test.use({
configOverride: {
...toConfig({
style: "identifier_first",
mitigateEnumeration: false,
selfservice: {
...selfservice,
methods: {
password: {
enabled: methods.includes("password"),
},
webauthn: {
enabled: methods.includes("webauthn"),
config: {
passwordless: true,
rp: { id: "localhost", display_name: "Ory Kratos" },
},
},
passkey: {
enabled: methods.includes("passkey"),
config: {
rp: { id: "localhost", display_name: "Ory Kratos" },
},
},
code: {
enabled: methods.includes("code"),
passwordless_enabled: methods.includes("code"),
},
totp: {
enabled: false,
},
lookup_secret: {
enabled: false,
},
oidc: {
enabled: false,
},
},
flows: {
registration: { style },
},
},
}),
feature_flags: {
password_profile_registration_node_group: group,
},
},
})
test("registration does not have any duplicated fields when using profile first", async ({
page,
config,
}) => {
const registration = new RegistrationPage(page, config)
await registration.open()
await expect(
page.locator('[name="traits.email"]'),
"expect the profile form fields to not be duplicated",
).toHaveCount(style === "profile_first" ? 1 : methods.length)
})
})
})
})
})
})
})
})

View File

@@ -0,0 +1,769 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import { APIRequestContext } from "@playwright/test"
import type { OryKratosConfiguration } from "../../../../shared/config"
import { downgradeLegacyIdentity } from "../../../actions/db"
import {
createIdentity,
createIdentityWithPhoneAndPassword,
} from "../../../actions/identity"
import { hasNoSession, hasSession } from "../../../actions/session"
import {
deleteDocument,
documentUrl,
fetchDocument,
} from "../../../actions/webhook"
import { expect, test } from "../../../fixtures"
import smsSchema from "../../../fixtures/schemas/sms"
import smsOidcSchema from "../../../fixtures/schemas/sms-oidc"
import smsPasswordSchema from "../../../fixtures/schemas/sms-password"
import { LoginPage } from "../../../models/elements/login"
import { RegistrationPage } from "../../../models/elements/registration"
test.skip(
process.env.DB !== "sqlite",
"These tests rely on a SQL downgrade that only works with SQLite.",
)
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T
// Test phone numbers — deterministic so the SQL downgrade can target the known E.164 form
const DE_LEGACY = "+49 176 671 11 638"
const DE_E164 = "+4917667111638"
const DE_DASHED = "+49-176-671-11-638"
const US_LEGACY = "+1 (415) 555-2671"
const US_E164 = "+14155552671"
const smsDocumentId = "doc-sms-" + Math.random().toString(36).substring(7)
function smsSchemaBase64(schema: object) {
return (
"base64://" +
Buffer.from(JSON.stringify(schema), "ascii").toString("base64")
)
}
// Always register all three SMS schemas so identities from any test group
// can be listed and deleted via the admin API regardless of which schema
// is "active". Without this, cleanIdentities() silently fails when the
// current config doesn't know the schema of an existing identity.
function allSmsSchemas() {
return [
{ id: "sms" as const, url: smsSchemaBase64(smsSchema) },
{ id: "sms-password" as const, url: smsSchemaBase64(smsPasswordSchema) },
{ id: "sms-oidc" as const, url: smsSchemaBase64(smsOidcSchema) },
// Include default and email schemas so cleanIdentities can list identities
// left over from other test specs that use these schemas.
{
id: "default" as const,
url: "file://test/e2e/profiles/oidc/identity.traits.schema.json",
},
{
id: "email" as const,
url: "file://test/e2e/profiles/email/identity.traits.schema.json",
},
]
}
function smsCourierChannels() {
return [
{
id: "sms" as const,
type: "http" as const,
request_config: {
body: "base64://ZnVuY3Rpb24oY3R4KSB7DQpjdHg6IGN0eCwNCn0=",
method: "PUT",
url: documentUrl(smsDocumentId),
},
},
]
}
/**
* Config for code-only login flows (A, B, F tests).
* Password is disabled so the unified login page renders a single identifier
* input, avoiding the strict-mode violation caused by two `input[name=identifier]`
* elements when both code and password methods are active.
*/
function smsCodeConfig(schemaId: string): DeepPartial<OryKratosConfiguration> {
return {
security: { account_enumeration: { enabled: false } },
selfservice: {
flows: {
login: { style: "unified" },
registration: {
after: { code: { hooks: [{ hook: "session" }] } },
},
recovery: { enabled: true, use: "code" },
},
methods: {
code: { passwordless_enabled: true },
password: { enabled: false },
},
},
courier: { channels: smsCourierChannels() },
identity: { default_schema_id: schemaId, schemas: allSmsSchemas() },
}
}
/**
* Config for password+recovery flows (C, D, E tests).
* Code is enabled (for recovery) but passwordless_enabled is false so the
* login page shows only the password form (single identifier input).
* Recovery V2 (choose_recovery_address) is enabled so that SMS phone addresses
* are supported — Recovery V1 hardcodes AddressTypeEmail and never sends SMS.
*/
function smsPasswordConfig(
schemaId: string,
): DeepPartial<OryKratosConfiguration> {
return {
security: { account_enumeration: { enabled: false } },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
feature_flags: {
// choose_recovery_address enables Recovery V2 which supports SMS phone
// addresses — Recovery V1 hardcodes AddressTypeEmail and never sends SMS.
choose_recovery_address: true,
use_continue_with_transitions: true,
} as any,
selfservice: {
flows: {
login: { style: "unified" },
registration: {
after: { password: { hooks: [{ hook: "session" }] } },
},
recovery: { enabled: true, use: "code" },
},
methods: {
code: { enabled: true, passwordless_enabled: false },
password: { enabled: true },
},
},
courier: { channels: smsCourierChannels() },
identity: { default_schema_id: schemaId, schemas: allSmsSchemas() },
}
}
/**
* Deletes all identities via the admin API.
* Called in beforeEach to ensure a clean DB state for every test,
* preventing UNIQUE constraint violations from previous test runs or retries.
*/
async function cleanIdentities(request: APIRequestContext) {
const resp = await request.get(
"http://localhost:4434/admin/identities?per_page=250",
)
if (!resp.ok()) {
console.error(
`cleanIdentities: list failed with status ${resp.status()}: ${await resp.text()}`,
)
return
}
const identities: Array<{ id: string }> = await resp.json()
for (const identity of identities) {
const del = await request.delete(
`http://localhost:4434/admin/identities/${identity.id}`,
)
if (!del.ok()) {
console.error(
`cleanIdentities: delete ${identity.id} failed with status ${del.status()}`,
)
}
}
}
// ────────────────────────────────────────────────────────────────────
// A. Code login (SMS) — legacy data
// ────────────────────────────────────────────────────────────────────
test.describe("A: code login with legacy phone data", () => {
test.use({
configOverride: smsCodeConfig("sms"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("A1: login succeeds with exact legacy format", async ({
page,
config,
kratosPublicURL,
}) => {
// Create identity with DE_LEGACY via admin API — Kratos normalizes and stores DE_E164
const identity = await createIdentity(page.request, {
schema_id: "sms",
traits: { phone: DE_LEGACY },
})
// Revert DE_E164 → DE_LEGACY to simulate a pre-normalization DB record
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(DE_LEGACY)
const result = await fetchDocument(smsDocumentId)
await login.codeInput.input.fill(result.ctx.template_data.login_code)
await Promise.all([
page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
),
login.codeSubmit.getByText("Continue").click(),
])
await hasSession(page.request, kratosPublicURL)
})
test("A2: login fails with E.164 when DB has legacy format (known limitation)", async ({
page,
config,
kratosPublicURL,
}) => {
// Create with legacy, store as DE_E164, then downgrade to legacy
const identity = await createIdentity(page.request, {
schema_id: "sms",
traits: { phone: DE_LEGACY },
})
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
const login = new LoginPage(page, config)
await login.open()
// Enter E.164 format — IN query becomes IN(DE_E164, DE_E164), no match on legacy DB row
await login.inputField("identifier").fill(DE_E164)
await Promise.all([
page.waitForLoadState("networkidle"),
login.codeSubmit.click(),
])
// No SMS code is sent because no identity was found
await hasNoSession(page.request, kratosPublicURL)
})
test("A3: login fails with dashed format when DB has legacy format", async ({
page,
config,
kratosPublicURL,
}) => {
const identity = await createIdentity(page.request, {
schema_id: "sms",
traits: { phone: DE_LEGACY },
})
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
const login = new LoginPage(page, config)
await login.open()
await login.inputField("identifier").fill(DE_DASHED)
await Promise.all([
page.waitForLoadState("networkidle"),
login.codeSubmit.click(),
])
await hasNoSession(page.request, kratosPublicURL)
})
})
// ────────────────────────────────────────────────────────────────────
// B. Code login (SMS) — new data (control group)
// ────────────────────────────────────────────────────────────────────
test.describe("B: code login with newly registered phone data", () => {
test.use({
configOverride: smsCodeConfig("sms"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("B1: register with spaces, login with E.164", async ({
page,
config,
kratosPublicURL,
}) => {
// Register with non-normalized phone — new Kratos normalizes and stores DE_E164
const registration = new RegistrationPage(page, config)
await registration.open()
await registration.triggerRegistrationWithCode(DE_LEGACY)
let result = await fetchDocument(smsDocumentId)
const regCode = result.ctx.template_data.registration_code
await registration.inputField("code").fill(regCode)
await Promise.all([
page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
),
registration.submitField("code").getByText("Continue").click(),
])
await hasSession(page.request, kratosPublicURL)
// Clear session for login test
await page.context().clearCookies()
await deleteDocument(smsDocumentId)
// Login with E.164 — should succeed because DB now stores E.164 (new normalization)
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(DE_E164)
result = await fetchDocument(smsDocumentId)
await login.codeInput.input.fill(result.ctx.template_data.login_code)
await login.codeSubmit.getByText("Continue").click()
await hasSession(page.request, kratosPublicURL)
})
test("B2: register with spaces, login with same spaces", async ({
page,
config,
kratosPublicURL,
}) => {
const registration = new RegistrationPage(page, config)
await registration.open()
await registration.triggerRegistrationWithCode(DE_LEGACY)
let result = await fetchDocument(smsDocumentId)
const regCode = result.ctx.template_data.registration_code
await registration.inputField("code").fill(regCode)
await Promise.all([
page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
),
registration.submitField("code").getByText("Continue").click(),
])
await hasSession(page.request, kratosPublicURL)
await page.context().clearCookies()
await deleteDocument(smsDocumentId)
// Login with same spaced format — IN query's 2nd value (raw input) matches
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(DE_LEGACY)
result = await fetchDocument(smsDocumentId)
await login.codeInput.input.fill(result.ctx.template_data.login_code)
await login.codeSubmit.getByText("Continue").click()
await hasSession(page.request, kratosPublicURL)
})
})
// ────────────────────────────────────────────────────────────────────
// C. Password login with phone — legacy data
// ────────────────────────────────────────────────────────────────────
test.describe("C: password login with legacy phone data", () => {
const password = "vErY-s3cur3-p@ss!"
test.use({
configOverride: smsPasswordConfig("sms-password"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("C1: password login succeeds with exact legacy phone format", async ({
page,
config,
kratosPublicURL,
}) => {
// Create via admin API — Kratos stores DE_E164, then downgrade to legacy
const { identity } = await createIdentityWithPhoneAndPassword(
page.request,
DE_LEGACY,
password,
"sms-password",
)
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
const login = new LoginPage(page, config)
await login.open()
await login.loginWithPassword(DE_LEGACY, password)
await hasSession(page.request, kratosPublicURL)
})
test("C2: password login fails with E.164 when DB has legacy format (known limitation)", async ({
page,
config,
kratosPublicURL,
}) => {
const { identity } = await createIdentityWithPhoneAndPassword(
page.request,
DE_LEGACY,
password,
"sms-password",
)
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
const login = new LoginPage(page, config)
await login.open()
// E.164 input — IN query produces IN(DE_E164, DE_E164) — no match on legacy value
await login.loginWithPassword(DE_E164, password)
await page.waitForLoadState("networkidle")
await hasNoSession(page.request, kratosPublicURL)
})
})
// ────────────────────────────────────────────────────────────────────
// D. Recovery via SMS — legacy data
// ────────────────────────────────────────────────────────────────────
test.describe("D: SMS recovery with legacy phone data", () => {
const password = "vErY-s3cur3-p@ss!"
test.use({
configOverride: smsPasswordConfig("sms-password"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("D1: recovery succeeds with exact legacy phone format", async ({
page,
config,
}) => {
const { identity } = await createIdentityWithPhoneAndPassword(
page.request,
US_LEGACY,
password,
"sms-password",
)
downgradeLegacyIdentity(identity.id, US_LEGACY, US_E164)
await page.goto(config.selfservice.flows!.recovery!.ui_url!)
await page.locator('input[name="recovery_address"]').fill(US_LEGACY)
await page.locator('button[name="method"][value="code"]').click()
// Recovery code should be sent via SMS webhook
// RecoveryCodeValidModel.RecoveryCode is marshalled as "verification_code"
const result = await fetchDocument(smsDocumentId)
expect(result.ctx.template_data).toBeDefined()
expect(result.ctx.template_data.verification_code).toBeDefined()
})
test("D2: recovery fails with E.164 when DB has legacy format (known limitation)", async ({
page,
config,
}) => {
const { identity } = await createIdentityWithPhoneAndPassword(
page.request,
US_LEGACY,
password,
"sms-password",
)
downgradeLegacyIdentity(identity.id, US_LEGACY, US_E164)
await page.goto(config.selfservice.flows!.recovery!.ui_url!)
await page.locator('input[name="recovery_address"]').fill(US_E164)
await page.locator('button[name="method"][value="code"]').click()
// Wait for Kratos to process, then verify no SMS was sent
await page.waitForTimeout(2000)
try {
const result = await fetchDocument(smsDocumentId)
// If a document exists, recovery was triggered — unexpected for this known-limitation case
expect(result.ctx?.template_data?.verification_code).toBeUndefined()
} catch {
// fetchDocument retries and throws on 404 — no document means no SMS, which is expected
}
})
})
// ────────────────────────────────────────────────────────────────────
// E. Verification — legacy data
// ────────────────────────────────────────────────────────────────────
test.describe("E: verification address integrity with legacy data", () => {
test.use({
configOverride: smsPasswordConfig("sms-password"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test("E1: legacy identity retains verification address", async ({ page }) => {
const { identity } = await createIdentityWithPhoneAndPassword(
page.request,
DE_LEGACY,
"some-password-123!",
"sms-password",
)
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
// Fetch the identity via admin API and verify the address is still present
const resp = await page.request.get(
`http://localhost:4434/admin/identities/${identity.id}?include_credential=password`,
)
expect(resp.status()).toBe(200)
const fetched = await resp.json()
// The verifiable address should still exist (even if value was reverted to legacy)
expect(fetched.verifiable_addresses).toBeDefined()
expect(fetched.verifiable_addresses.length).toBeGreaterThan(0)
})
})
// ────────────────────────────────────────────────────────────────────
// F. Duplicate identity detection (ConflictingIdentity)
// ────────────────────────────────────────────────────────────────────
test.describe("F: duplicate identity detection with phone normalization", () => {
test.use({
configOverride: smsCodeConfig("sms"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("F1: legacy phone in DB, register with E.164 creates duplicate (known gap)", async ({
page,
config,
kratosPublicURL,
}) => {
// Create identity with DE_LEGACY → stored as DE_E164 → then downgrade to DE_LEGACY
const legacyIdentity = await createIdentity(page.request, {
schema_id: "sms",
traits: { phone: DE_LEGACY },
})
downgradeLegacyIdentity(legacyIdentity.id, DE_LEGACY, DE_E164)
// Register a new identity with DE_E164 (the E.164 form of the same number)
const registration = new RegistrationPage(page, config)
await registration.open()
await registration.triggerRegistrationWithCode(DE_E164)
// The IN query becomes IN(DE_E164, DE_E164) which does NOT match legacy "+49 176 671 11 638"
// So no conflict is detected and a registration code is sent
const result = await fetchDocument(smsDocumentId)
expect(result.ctx.template_data.registration_code).toBeDefined()
// Complete registration — this proves the system allowed a duplicate
await registration
.inputField("code")
.fill(result.ctx.template_data.registration_code)
await Promise.all([
page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
),
registration.submitField("code").getByText("Continue").click(),
])
await hasSession(page.request, kratosPublicURL)
// Verify two distinct identities exist for the same logical phone number — BUG
const listResp = await page.request.get(
"http://localhost:4434/admin/identities?per_page=250",
)
const identities = await listResp.json()
const matching = identities.filter(
(i: { traits: { phone: string } }) =>
i.traits.phone === DE_E164 || i.traits.phone === DE_LEGACY,
)
// BUG: Two identities exist for the same phone number
expect(matching.length).toBe(2)
})
test("F2: new E.164 in DB, register with spaced format detects conflict", async ({
page,
config,
}) => {
// Create identity with E.164 directly (modern Kratos stores this as-is)
await createIdentity(page.request, {
schema_id: "sms",
traits: { phone: DE_E164 },
})
// Attempt to register with the spaced variant of the same number.
// The IN query becomes IN(DE_E164, DE_LEGACY) — DE_E164 matches the DB record.
const registration = new RegistrationPage(page, config)
await registration.open()
await registration.triggerRegistrationWithCode(DE_LEGACY)
const result = await fetchDocument(smsDocumentId)
expect(result.ctx.template_data.registration_code).toBeDefined()
await registration
.inputField("code")
.fill(result.ctx.template_data.registration_code)
await registration.submitField("code").getByText("Continue").click()
// Conflict is detected: only one identity should exist
const listResp = await page.request.get(
"http://localhost:4434/admin/identities?per_page=250",
)
const identities = await listResp.json()
const matching = identities.filter(
(i: { traits: { phone: string } }) =>
i.traits.phone === DE_E164 || i.traits.phone === DE_LEGACY,
)
expect(matching.length).toBe(1)
})
})
// ────────────────────────────────────────────────────────────────────
// G. OIDC account linking — legacy data
// ────────────────────────────────────────────────────────────────────
const oidcConfig: DeepPartial<OryKratosConfiguration> = {
security: {
account_enumeration: {
enabled: false,
},
},
selfservice: {
flows: {
login: {
style: "unified",
},
registration: {
after: {
oidc: {
hooks: [{ hook: "session" }],
},
code: {
hooks: [{ hook: "session" }],
},
},
},
},
methods: {
code: {
passwordless_enabled: true,
},
password: {
enabled: false,
},
oidc: {
enabled: true,
config: {
providers: [
{
id: "hydra",
label: "Ory",
provider: "generic",
client_id: process.env["OIDC_HYDRA_CLIENT_ID"],
client_secret: process.env["OIDC_HYDRA_CLIENT_SECRET"],
issuer_url: "http://localhost:4444/",
scope: ["offline"],
mapper_url:
"file://test/e2e/playwright/fixtures/mappers/hydra-phone.jsonnet",
},
],
},
},
},
},
courier: { channels: smsCourierChannels() },
identity: { default_schema_id: "sms-oidc", schemas: allSmsSchemas() },
}
test.describe("G: OIDC account linking with legacy phone data", () => {
test.use({
configOverride: oidcConfig,
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("G1: legacy phone in DB, OIDC returns E.164 creates duplicate (known gap)", async ({
page,
config,
kratosPublicURL,
}) => {
// Create identity with legacy phone via admin API, then downgrade to legacy format
const legacyIdentity = await createIdentity(page.request, {
schema_id: "sms-oidc",
traits: { phone: DE_LEGACY },
})
downgradeLegacyIdentity(legacyIdentity.id, DE_LEGACY, DE_E164)
// Initiate OIDC registration — Hydra returns DE_E164 as sub claim
await page.goto(config.selfservice.flows!.registration!.ui_url!)
await page.locator('button[name="provider"][value="hydra"]').click()
// Hydra login: use E.164 phone as the username (sub claim)
await page.locator("input[name=username]").fill(DE_E164)
await page.locator("button[name=action][value=accept]").click()
// Consent screen
await page.locator("#offline").check()
await page.locator("#openid").check()
await page.locator("button[name=action][value=accept]").click()
// ConflictingIdentity uses IN(DE_E164, DE_E164) which does NOT match legacy record
// BUG: A second identity is created instead of linking to the existing one
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
await hasSession(page.request, kratosPublicURL)
const listResp = await page.request.get(
"http://localhost:4434/admin/identities?per_page=250",
)
const identities = await listResp.json()
const matching = identities.filter(
(i: { traits: { phone: string } }) =>
i.traits.phone === DE_E164 || i.traits.phone === DE_LEGACY,
)
// BUG: Two identities exist for the same phone number
expect(matching.length).toBe(2)
})
test("G2: new E.164 in DB, OIDC registration detects conflict (no duplicate)", async ({
page,
config,
}) => {
// Create identity with E.164 phone (modern format — no downgrade needed)
await createIdentity(page.request, {
schema_id: "sms-oidc",
traits: { phone: DE_E164 },
})
// Initiate OIDC registration — Hydra returns DE_E164 as sub claim
await page.goto(config.selfservice.flows!.registration!.ui_url!)
await page.locator('button[name="provider"][value="hydra"]').click()
await page.locator("input[name=username]").fill(DE_E164)
await page.locator("button[name=action][value=accept]").click()
await page.locator("#offline").check()
await page.locator("#openid").check()
await page.locator("button[name=action][value=accept]").click()
// ConflictingIdentity uses IN(DE_E164, DE_E164) which DOES match DE_E164 in DB.
// Conflict is detected → Kratos shows duplicate-credentials / account-linking
// page instead of completing registration. No session is created.
await page.waitForLoadState("networkidle")
// Key assertion: conflict was detected, so NO duplicate identity was created.
const listResp = await page.request.get(
"http://localhost:4434/admin/identities?per_page=250",
)
const identities = await listResp.json()
const matching = identities.filter(
(i: { traits: { phone: string } }) => i.traits.phone === DE_E164,
)
expect(matching.length).toBe(1)
})
})