Accept a verification or recovery code pasted with whitespace around it #199
@@ -6,6 +6,12 @@ at boot. Entries start at 0.3.0.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### A verification or recovery code pasted with whitespace around it is accepted
|
||||||
|
|
||||||
|
The code field on the verification and recovery pages takes a code with spaces or tabs around it, as
|
||||||
|
a paste from an email often brings. Before, the browser refused it. Those two forms now post to
|
||||||
|
Plainpages, which trims the code and passes the form on to Kratos at `KRATOS_PUBLIC_URL`.
|
||||||
|
|
||||||
### A filter shows its selected and focused choice in browsers without `:has()`
|
### A filter shows its selected and focused choice in browsers without `:has()`
|
||||||
|
|
||||||
On Chromium below 105, Firefox below 121 and Safari below 15.4, a filter's segments and chips now show
|
On Chromium below 105, Firefox below 121 and Safari below 15.4, a filter's segments and chips now show
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||||
# login, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
|
# login, emailed-code verification, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
|
||||||
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
||||||
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
||||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
||||||
@@ -79,6 +79,17 @@ services:
|
|||||||
timeout: 4s
|
timeout: 4s
|
||||||
retries: 15
|
retries: 15
|
||||||
|
|
||||||
|
# Catches the courier's mail (kratos.yml → smtp://mailpit:1025), so a test can read an emailed code.
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:v1.33.0
|
||||||
|
mem_limit: 128m
|
||||||
|
memswap_limit: 128m
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/mailpit", "readyz"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 4s
|
||||||
|
retries: 15
|
||||||
|
|
||||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||||
proxy:
|
proxy:
|
||||||
image: node:24.22.2-alpine3.24
|
image: node:24.22.2-alpine3.24
|
||||||
@@ -103,6 +114,8 @@ services:
|
|||||||
dockerfile: e2e-tests/Dockerfile
|
dockerfile: e2e-tests/Dockerfile
|
||||||
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
|
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
|
mailpit:
|
||||||
|
condition: service_healthy
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
proxy:
|
proxy:
|
||||||
@@ -110,5 +123,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
BASE_URL: http://proxy
|
BASE_URL: http://proxy
|
||||||
KRATOS_ADMIN_URL: http://kratos:4434
|
KRATOS_ADMIN_URL: http://kratos:4434
|
||||||
|
MAILPIT_URL: http://mailpit:8025
|
||||||
volumes:
|
volumes:
|
||||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the
|
|||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
||||||
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
||||||
|
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||||
|
const MAILPIT = process.env.MAILPIT_URL ?? "http://mailpit:8025";
|
||||||
|
|
||||||
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
|
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
|
||||||
async function loginPassword(page: Page): Promise<void> {
|
async function loginPassword(page: Page): Promise<void> {
|
||||||
@@ -274,3 +276,36 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
|
|||||||
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
|
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
|
||||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
|
||||||
|
async function mailedCode(to: string): Promise<string> {
|
||||||
|
let code = "";
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const search = (await (await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:"${to}"`)}`)).json()) as { messages: { ID: string }[] };
|
||||||
|
const id = search.messages[0]?.ID;
|
||||||
|
if (!id) return "";
|
||||||
|
const message = (await (await fetch(`${MAILPIT}/api/v1/message/${id}`)).json()) as { Text: string };
|
||||||
|
code = /\b(\d{6})\b/.exec(message.Text)?.[1] ?? "";
|
||||||
|
return code;
|
||||||
|
}, { timeout: 30_000 }).not.toBe("");
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("verification: a code pasted with whitespace around it is accepted", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const email = `verify-${suffix}@plainpages.local`;
|
||||||
|
const created = await fetch(`${KRATOS_ADMIN}/admin/identities`, {
|
||||||
|
body: JSON.stringify({ schema_id: "default", traits: { email } }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
|
||||||
|
await page.goto("/verification");
|
||||||
|
await page.fill('input[name="email"]', email);
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
await expect(page.locator('input[name="code"]')).toBeVisible();
|
||||||
|
await page.fill('input[name="code"]', ` ${await mailedCode(email)}\t `); // as a paste from an email often arrives
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
await expect(page.getByText("You successfully verified your email address.")).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -100,9 +100,8 @@ test("collects oidc nodes as SSO providers (text logo = initial), keeping csrf a
|
|||||||
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
|
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the code field guards a pasted space: one-time-code autofill + numeric inputmode + digits-only pattern", () => {
|
test("the code field takes a pasted code: one-time-code autofill + numeric inputmode + digits, whitespace around them allowed", () => {
|
||||||
// Verification/recovery enter a numeric OTP. Kratos doesn't trim, so a stray pasted space makes it
|
// The host trims the code before Kratos sees it (routes.ts), so only a stray non-digit is refused.
|
||||||
// reject the code as "invalid"; a digits-only pattern blocks that in the browser before submit.
|
|
||||||
const view = buildFlowView(
|
const view = buildFlowView(
|
||||||
flow([
|
flow([
|
||||||
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
||||||
@@ -113,18 +112,26 @@ test("the code field guards a pasted space: one-time-code autofill + numeric inp
|
|||||||
);
|
);
|
||||||
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
|
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
|
||||||
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
|
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
|
||||||
hint: "Digits only — no spaces.", // the pattern refusal alone reads as a bare "match the requested format"
|
hint: "Digits only.", // the pattern refusal alone reads as a bare "match the requested format"
|
||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
id: "field-code",
|
id: "field-code",
|
||||||
inputmode: "numeric",
|
inputmode: "numeric",
|
||||||
label: "Verification code",
|
label: "Verification code",
|
||||||
name: "code",
|
name: "code",
|
||||||
pattern: "[0-9]*",
|
pattern: "\\s*[0-9]*\\s*",
|
||||||
required: true,
|
required: true,
|
||||||
type: "text",
|
type: "text",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the code flows post to the host, which trims the code; every other flow posts to Kratos", () => {
|
||||||
|
for (const type of ["recovery", "verification"] as const) assert.equal(buildFlowView(flow([]), type).action, `/${type}?flow=f1`);
|
||||||
|
for (const type of ["login", "registration", "settings"] as const) {
|
||||||
|
assert.equal(buildFlowView(flow([]), type).action, "http://127.0.0.1:4433/self-service/login?flow=f1");
|
||||||
|
}
|
||||||
|
assert.equal(buildFlowView({ ...flow([]), id: "a&b" }, "recovery").action, "/recovery?flow=a%26b");
|
||||||
|
});
|
||||||
|
|
||||||
test("chrome varies per flow type: registration alt, recovery back link", () => {
|
test("chrome varies per flow type: registration alt, recovery back link", () => {
|
||||||
const reg = buildFlowView(flow([]), "registration");
|
const reg = buildFlowView(flow([]), "registration");
|
||||||
assert.equal(reg.title, "Create account");
|
assert.equal(reg.title, "Create account");
|
||||||
|
|||||||
+11
-8
@@ -1,8 +1,8 @@
|
|||||||
// Kratos flow → themed view model. Pure: turns a fetched self-service Flow
|
// Kratos flow → themed view model. Pure: turns a fetched self-service Flow
|
||||||
// (src/auth/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
|
// (src/auth/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
|
||||||
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
|
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
|
||||||
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
|
// configured `oidc` provider. The form posts to `flow.ui.action`, except a CODE_FLOWS form, which
|
||||||
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
|
// posts to the host first; Kratos owns the CSRF of both. No providers configured ⇒ no SSO buttons.
|
||||||
|
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
import { ENGLISH } from "../i18n/english.ts";
|
||||||
import type { Translate } from "../i18n/translate.ts";
|
import type { Translate } from "../i18n/translate.ts";
|
||||||
@@ -17,7 +17,7 @@ export interface FlowField {
|
|||||||
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
|
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
pattern?: string; // client-side validity regex; blocks a pasted space before it reaches Kratos
|
pattern?: string; // client-side validity regex
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
type: string;
|
type: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -69,6 +69,9 @@ export const AUTH_FLOWS: Record<string, FlowType> = {
|
|||||||
"/verification": "verification",
|
"/verification": "verification",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The flows that take an emailed one-time code, whose form posts to the host to have it trimmed.
|
||||||
|
export const CODE_FLOWS: ReadonlySet<FlowType> = new Set(["recovery", "verification"]);
|
||||||
|
|
||||||
// Where each flow's card links; its words come from the catalog under `auth.<flow>.*`.
|
// Where each flow's card links; its words come from the catalog under `auth.<flow>.*`.
|
||||||
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
|
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
|
||||||
login: { alt: "/registration" },
|
login: { alt: "/registration" },
|
||||||
@@ -123,9 +126,9 @@ const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase(
|
|||||||
|
|
||||||
function toField(node: UiNode, name: string, type: string, t: Translate): FlowField {
|
function toField(node: UiNode, name: string, type: string, t: Translate): FlowField {
|
||||||
const value = str(node.attributes["value"]);
|
const value = str(node.attributes["value"]);
|
||||||
// The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
|
// The recovery/verification one-time code: numeric, so a numeric keypad and a digits pattern, which
|
||||||
// space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in
|
// allows the whitespace a paste brings since the host trims it (routes.ts → codeFlowSubmit).
|
||||||
// the browser; one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
|
// one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
|
||||||
const isCode = name === "code";
|
const isCode = name === "code";
|
||||||
const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined);
|
const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined);
|
||||||
const icon = iconFor(name, type);
|
const icon = iconFor(name, type);
|
||||||
@@ -140,7 +143,7 @@ function toField(node: UiNode, name: string, type: string, t: Translate): FlowFi
|
|||||||
...(autocomplete ? { autocomplete } : {}),
|
...(autocomplete ? { autocomplete } : {}),
|
||||||
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
||||||
...(icon ? { icon } : {}),
|
...(icon ? { icon } : {}),
|
||||||
...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "\\s*[0-9]*\\s*" } : {}),
|
||||||
...(node.attributes["required"] === true ? { required: true } : {}),
|
...(node.attributes["required"] === true ? { required: true } : {}),
|
||||||
...(value ? { value } : {}),
|
...(value ? { value } : {}),
|
||||||
};
|
};
|
||||||
@@ -174,7 +177,7 @@ export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: flow.ui.action,
|
action: CODE_FLOWS.has(type) ? `/${type}?flow=${encodeURIComponent(flow.id)}` : flow.ui.action,
|
||||||
buttons,
|
buttons,
|
||||||
fields,
|
fields,
|
||||||
hidden,
|
hidden,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints.
|
// Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints.
|
||||||
// Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping
|
// Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping
|
||||||
// (200/401/4xx, validation-flow vs success, tokenized JWT). Live wiring is verified by the
|
// (200/401/4xx, a submit's redirect, tokenized JWT). Live wiring is verified by the
|
||||||
// flow pages; these catch contract drift with a mock fetch.
|
// flow pages; these catch contract drift with a mock fetch.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
@@ -57,35 +57,27 @@ test("getFlow throws a KratosError carrying the status when the flow is gone (41
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submitFlow POSTs urlencoded to the action and reports success + relays Set-Cookie", async () => {
|
test("submitFlow POSTs the form to the flow on Kratos' own base URL, as a browser would, and relays the redirect + Set-Cookie", async () => {
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, { session: { active: true } }, ["plainpages_session=s; Path=/"]));
|
const { calls, fetchImpl } = recorder(() => new Response(null, {
|
||||||
|
headers: new Headers([["location", "http://app/settings?flow=s1"], ["set-cookie", "plainpages_session=s; Path=/"]]),
|
||||||
|
status: 303,
|
||||||
|
}));
|
||||||
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl })
|
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl })
|
||||||
.submitFlow(`${BASE}/self-service/login?flow=f`, { body: "identifier=a&password=b", cookie: "csrf_token=abc" });
|
.submitFlow("recovery", "f 1", { body: "code=123456&method=code", cookie: "csrf_token=abc" });
|
||||||
assert.equal(out.ok, true);
|
assert.deepEqual(out, { location: "http://app/settings?flow=s1", setCookie: ["plainpages_session=s; Path=/"], status: 303 });
|
||||||
assert.equal(out.status, 200);
|
assert.equal(calls[0]!.url, `${BASE}/self-service/recovery?flow=f+1`);
|
||||||
assert.deepEqual(out.setCookie, ["plainpages_session=s; Path=/"]);
|
|
||||||
assert.equal(calls[0]!.method, "POST");
|
assert.equal(calls[0]!.method, "POST");
|
||||||
|
// Not JSON: Kratos then answers with the same 303s a browser posting to it directly would get.
|
||||||
|
assert.equal(calls[0]!.headers.get("accept"), "text/html");
|
||||||
assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded");
|
assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded");
|
||||||
assert.equal(calls[0]!.body, "identifier=a&password=b");
|
assert.equal(calls[0]!.headers.get("cookie"), "csrf_token=abc");
|
||||||
|
assert.equal(calls[0]!.body, "code=123456&method=code");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submitFlow returns the re-rendered flow (no throw) on a 400 validation error", async () => {
|
test("submitFlow reports a 4xx without a redirect as a null location, and throws on a Kratos 5xx", async () => {
|
||||||
const flow = { id: "f", ui: { action: "x", messages: [{ id: 4000006, text: "invalid credentials", type: "error" }], method: "POST", nodes: [] } };
|
const k = (status: number) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(() => res(status, { error: "x" })).fetchImpl });
|
||||||
const { fetchImpl } = recorder(() => res(400, flow));
|
assert.deepEqual(await k(400).submitFlow("verification", "f", { body: "x=1" }), { location: null, setCookie: [], status: 400 });
|
||||||
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).submitFlow(`${BASE}/x`, { body: "x=1" });
|
await assert.rejects(k(502).submitFlow("verification", "f", { body: "x=1" }), (e: unknown) => e instanceof KratosError && e.status === 502);
|
||||||
assert.equal(out.ok, false);
|
|
||||||
assert.equal(out.status, 400);
|
|
||||||
assert.deepEqual(out.body, flow);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("submitFlow surfaces the redirect target — Location header or a 422 redirect_browser_to body", async () => {
|
|
||||||
const k = (handler: () => Response) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(handler).fetchImpl });
|
|
||||||
const viaHeader = await k(() => new Response(null, { headers: new Headers({ location: "http://app/" }), status: 303 }))
|
|
||||||
.submitFlow(`${BASE}/x`, { body: "x=1" });
|
|
||||||
assert.equal(viaHeader.location, "http://app/");
|
|
||||||
const viaBody = await k(() => res(422, { redirect_browser_to: "http://app/login?flow=next" }))
|
|
||||||
.submitFlow(`${BASE}/x`, { body: "x=1" });
|
|
||||||
assert.equal(viaBody.location, "http://app/login?flow=next");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => {
|
test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => {
|
||||||
|
|||||||
+13
-27
@@ -51,9 +51,7 @@ export interface LogoutFlow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FlowSubmission {
|
export interface FlowSubmission {
|
||||||
body: unknown; // parsed JSON: the re-rendered flow on 400, the success payload on 200
|
location: string | null; // where Kratos sends the browser next
|
||||||
location: string | null; // redirect target (Location header, or a 422 redirect_browser_to)
|
|
||||||
ok: boolean; // status === 200
|
|
||||||
setCookie: string[];
|
setCookie: string[];
|
||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
@@ -74,23 +72,10 @@ export interface KratosPublic {
|
|||||||
createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401)
|
createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401)
|
||||||
getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>;
|
getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>;
|
||||||
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
|
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
|
||||||
submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise<FlowSubmission>;
|
submitFlow(type: FlowType, flowId: string, opts: { body: string; cookie?: string }): Promise<FlowSubmission>;
|
||||||
whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>;
|
whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBody(text: string): unknown {
|
|
||||||
if (!text) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic {
|
export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic {
|
||||||
const base = config.baseUrl.replace(/\/+$/, "");
|
const base = config.baseUrl.replace(/\/+$/, "");
|
||||||
const http = config.fetchImpl ?? fetch;
|
const http = config.fetchImpl ?? fetch;
|
||||||
@@ -129,16 +114,17 @@ export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
return (await res.json()) as Flow;
|
return (await res.json()) as Flow;
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitFlow(action, opts) {
|
async submitFlow(type, flowId, opts) {
|
||||||
const h = headers(opts.cookie);
|
const url = new URL(`${base}/self-service/${type}`);
|
||||||
h["content-type"] = opts.contentType ?? "application/x-www-form-urlencoded";
|
url.searchParams.set("flow", flowId);
|
||||||
// Manual redirect so we can read a 303 Location instead of following it server-side.
|
// Asked for HTML, Kratos answers with the redirects a browser posting to it directly gets, so
|
||||||
const res = await http(action, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
|
// every success/error/expiry branch stays Kratos'. Its nosurf demands a Referer, which this
|
||||||
const body = parseBody(await res.text());
|
// POST lacks, only on an https request: valid while KRATOS_PUBLIC_URL is in-cluster http.
|
||||||
const location =
|
const h = { ...headers(opts.cookie), accept: "text/html", "content-type": "application/x-www-form-urlencoded" };
|
||||||
res.headers.get("location") ??
|
const res = await http(url, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
|
||||||
(isRecord(body) && typeof body["redirect_browser_to"] === "string" ? body["redirect_browser_to"] : null);
|
if (res.status >= 500) throw new KratosError(`Kratos submit ${type} flow failed (${res.status})`, res.status, await res.text());
|
||||||
return { body, location, ok: res.status === 200, setCookie: res.headers.getSetCookie(), status: res.status };
|
await res.body?.cancel();
|
||||||
|
return { location: res.headers.get("location"), setCookie: res.headers.getSetCookie(), status: res.status };
|
||||||
},
|
},
|
||||||
|
|
||||||
async whoami(opts = {}) {
|
async whoami(opts = {}) {
|
||||||
|
|||||||
+27
-3
@@ -1,5 +1,5 @@
|
|||||||
// The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table
|
// The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table
|
||||||
// (src/http/builtin-routes.ts): the themed Kratos self-service pages, the Hydra OAuth2
|
// (src/http/builtin-routes.ts): the themed Kratos self-service pages and their code submits, the Hydra OAuth2
|
||||||
// login/consent/logout challenges, login completion, the first-party logout, and Kratos'
|
// login/consent/logout challenges, login completion, the first-party logout, and Kratos'
|
||||||
// flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing
|
// flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing
|
||||||
// client means the path simply doesn't exist (404). `view` results render the core views.
|
// client means the path simply doesn't exist (404). `view` results render the core views.
|
||||||
@@ -8,11 +8,11 @@ import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
|
|||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
import { CSRF_FIELD } from "./csrf.ts";
|
import { CSRF_FIELD } from "./csrf.ts";
|
||||||
import { chosenLocale } from "../i18n/locale.ts";
|
import { chosenLocale } from "../i18n/locale.ts";
|
||||||
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
import { AUTH_FLOWS, buildFlowView, CODE_FLOWS } from "./flow-view.ts";
|
||||||
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
||||||
import type { KetoClient } from "./keto-client.ts";
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||||
import { type Flow, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts";
|
import { type Flow, type FlowSubmission, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts";
|
||||||
import { clearSessionCookie, completeLogin, sessionCookie } from "./login.ts";
|
import { clearSessionCookie, completeLogin, sessionCookie } from "./login.ts";
|
||||||
import type { MenuConfig } from "../ui/menu-config.ts";
|
import type { MenuConfig } from "../ui/menu-config.ts";
|
||||||
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
|
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
|
||||||
@@ -114,6 +114,29 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A code flow's POST (flow-view.ts → CODE_FLOWS): trim the pasted one-time code, which Kratos would
|
||||||
|
// refuse with its surrounding whitespace, and relay the rest to Kratos and its redirect back. Kratos'
|
||||||
|
// own csrf_token field guards the POST, so the host checks no token of its own.
|
||||||
|
function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType): BuiltinRoute["handler"] {
|
||||||
|
return async (ctx: RequestContext): Promise<RouteResult> => {
|
||||||
|
const pathname = ctx.url.pathname;
|
||||||
|
const flowId = ctx.url.searchParams.get("flow");
|
||||||
|
if (!flowId) return { redirect: pathname };
|
||||||
|
const form = await readFormBody(ctx.req);
|
||||||
|
const code = form.get("code");
|
||||||
|
if (code !== null) form.set("code", code.trim());
|
||||||
|
const cookie = ctx.req.headers.cookie;
|
||||||
|
let submitted: FlowSubmission;
|
||||||
|
try {
|
||||||
|
submitted = await kratos.submitFlow(flowType, flowId, { body: form.toString(), ...(cookie ? { cookie } : {}) });
|
||||||
|
} catch (err) {
|
||||||
|
return flowFailure(err, ctx, flowType);
|
||||||
|
}
|
||||||
|
if (submitted.setCookie.length) ctx.res.appendHeader("set-cookie", submitted.setCookie);
|
||||||
|
return { redirect: submitted.location ?? `${pathname}?flow=${encodeURIComponent(flowId)}` };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// OAuth2 login challenge: Hydra hands the browser here when another app logs in *through* us.
|
// OAuth2 login challenge: Hydra hands the browser here when another app logs in *through* us.
|
||||||
// Resolve it via the Kratos session and accept; an unauthenticated user bounces to our themed
|
// Resolve it via the Kratos session and accept; an unauthenticated user bounces to our themed
|
||||||
// login and returns here once signed in. Provider-only.
|
// login and returns here once signed in. Provider-only.
|
||||||
@@ -250,6 +273,7 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
|
|||||||
if (kratos) {
|
if (kratos) {
|
||||||
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
||||||
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
|
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
|
||||||
|
if (CODE_FLOWS.has(flowType)) routes.push({ handler: codeFlowSubmit(kratos, flowType), method: "POST", path, public: true });
|
||||||
}
|
}
|
||||||
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
|
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
|||||||
import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
|
import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
|
||||||
import { staticJwks } from "../auth/jwks.ts";
|
import { staticJwks } from "../auth/jwks.ts";
|
||||||
import type { Identity } from "../auth/kratos-admin.ts";
|
import type { Identity } from "../auth/kratos-admin.ts";
|
||||||
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
import { KratosError, type Flow, type FlowSubmission, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
||||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
import { createI18n } from "../i18n/runtime.ts";
|
import { createI18n } from "../i18n/runtime.ts";
|
||||||
import { loadI18n } from "../i18n/load.ts";
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
@@ -389,3 +389,50 @@ test("OAuth2 challenge endpoints degrade identically: stale Hydra 4xx → 400, o
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// The code flows post to the host, which trims the code (a pasted one often carries whitespace that
|
||||||
|
// Kratos would refuse) and relays the rest of the browser's POST to Kratos verbatim.
|
||||||
|
test("code-flow POST: the host trims the code, relays the POST to Kratos and Kratos' redirect + cookies back", async (t) => {
|
||||||
|
const calls: { body: string; cookie: string | undefined; flowId: string; type: FlowType }[] = [];
|
||||||
|
let answer: () => Promise<FlowSubmission> = async () =>
|
||||||
|
({ location: "http://localhost:3000/settings?flow=s1", setCookie: ["plainpages_session=s; Path=/; HttpOnly"], status: 303 });
|
||||||
|
const kratos: KratosPublic = {
|
||||||
|
...mockKratos(async () => { throw new Error("unused"); }),
|
||||||
|
submitFlow: async (type, flowId, opts) => { calls.push({ body: opts.body, cookie: opts.cookie, flowId, type }); return answer(); },
|
||||||
|
};
|
||||||
|
const app = createApp({ jwks: staticJwks([ecJwk]), kratos });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const post = (path: string, body: string) => fetch(url + path, {
|
||||||
|
body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: "csrf_token_x=c" }, method: "POST", redirect: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await post("/recovery?flow=f1", "csrf_token=tok&code=%C2%A0123456%09%0A&method=code");
|
||||||
|
assert.equal(res.status, 303);
|
||||||
|
assert.equal(res.headers.get("location"), "http://localhost:3000/settings?flow=s1");
|
||||||
|
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_session=s/);
|
||||||
|
assert.deepEqual(calls[0], { body: "csrf_token=tok&code=123456&method=code", cookie: "csrf_token_x=c", flowId: "f1", type: "recovery" });
|
||||||
|
|
||||||
|
// The first step has no code field; its body passes through unchanged.
|
||||||
|
await post("/verification?flow=f2", "csrf_token=tok&email=a%40b.c&method=code");
|
||||||
|
assert.deepEqual(calls[1], { body: "csrf_token=tok&email=a%40b.c&method=code", cookie: "csrf_token_x=c", flowId: "f2", type: "verification" });
|
||||||
|
|
||||||
|
// No ?flow= → start over rather than post a flow-less submit.
|
||||||
|
const noFlow = await post("/recovery", "code=1");
|
||||||
|
assert.equal(noFlow.headers.get("location"), "/recovery");
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
|
||||||
|
// Kratos answered without a redirect → back to the flow, whose page shows its state.
|
||||||
|
answer = async () => ({ location: null, setCookie: [], status: 400 });
|
||||||
|
assert.equal((await post("/recovery?flow=f1", "code=1")).headers.get("location"), "/recovery?flow=f1");
|
||||||
|
|
||||||
|
// Kratos down → the honest 503 the flow pages render, not a 500.
|
||||||
|
answer = async () => { throw new KratosError("bad gateway", 502, ""); };
|
||||||
|
assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503);
|
||||||
|
answer = async () => { throw new TypeError("fetch failed"); };
|
||||||
|
assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503);
|
||||||
|
|
||||||
|
// Sign-in still posts straight to Kratos: the host takes no POST there.
|
||||||
|
assert.notEqual((await post("/login?flow=f1", "x=1")).status, 303);
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const messages = {
|
|||||||
"auth.continue": "Continue",
|
"auth.continue": "Continue",
|
||||||
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
|
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
|
||||||
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
|
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
|
||||||
"auth.field.code.hint": "Digits only — no spaces.",
|
"auth.field.code.hint": "Digits only.",
|
||||||
"auth.field.email": "Email",
|
"auth.field.email": "Email",
|
||||||
"auth.field.identifier": "Email",
|
"auth.field.identifier": "Email",
|
||||||
"auth.field.password": "Password",
|
"auth.field.password": "Password",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { CoreMessages } from "./en-US.ts";
|
|||||||
|
|
||||||
const messages: CoreMessages = {
|
const messages: CoreMessages = {
|
||||||
"auth.continue": "Fortsätt",
|
"auth.continue": "Fortsätt",
|
||||||
"auth.field.code.hint": "Endast siffror — inga mellanslag.",
|
"auth.field.code.hint": "Endast siffror.",
|
||||||
"auth.field.email": "E-postadress",
|
"auth.field.email": "E-postadress",
|
||||||
"auth.field.identifier": "E-postadress",
|
"auth.field.identifier": "E-postadress",
|
||||||
"auth.field.password": "Lösenord",
|
"auth.field.password": "Lösenord",
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
`Bar = 1`
|
`Bar = 1`
|
||||||
|
|
||||||
`Next ID = 45`
|
`Next ID = 46`
|
||||||
|
|
||||||
## Items
|
## Items
|
||||||
|
|
||||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 |
|
| 45 | 0.4.21 | | | **Link the admin's recovery-code notice to the flow the code was minted on, so the user can enter it.** | 1 | 2 | 2 | 3 | 2 | 2 by 5 | 7.0 |
|
||||||
| 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
|
| 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
|
||||||
| 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
|
| 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
|
||||||
| 6 | 0.5.0 | | | **Warn at discovery when a route or nav node gates on a permission no plugin declares.** | 2 | 1 | 1 | 2 | 2 | 7 by 3, 9 by 3 | 8.0 |
|
| 6 | 0.5.0 | | | **Warn at discovery when a route or nav node gates on a permission no plugin declares.** | 2 | 1 | 1 | 2 | 2 | 7 by 3, 9 by 3 | 8.0 |
|
||||||
@@ -26,17 +26,12 @@
|
|||||||
|
|
||||||
## Details
|
## Details
|
||||||
|
|
||||||
### 13. Accept a verification code pasted with surrounding whitespace.
|
### 45. Link the admin's recovery-code notice to the flow the code was minted on, so the user can enter it.
|
||||||
|
|
||||||
A pasted code fails the browser's `pattern` check. No zero-JS fix exists: `pattern`
|
The notice (`examples/plugins/admin/views/partials/user-form-body.ejs`) links a bare `/recovery`,
|
||||||
cannot transform, so loosening it only lets the untrimmed value through to Kratos. **Check first:**
|
which starts a fresh flow at its email step. Kratos binds an admin-minted code to the flow it minted,
|
||||||
`kratos-admin.ts` returns a `recovery_link` alongside the code; if the stock courier mail can carry
|
whose URL is the `recovery_link` that `createRecoveryCode` returns as `RecoveryCode.link`. Render that
|
||||||
one, a template change fixes this with no host code. Otherwise proxy the flow POST through the host:
|
link instead.
|
||||||
`submitFlow` (`src/auth/kratos-public.ts`) already relays cookies and normalises a 422
|
|
||||||
`redirect_browser_to`, and has no callers outside tests. CSRF improves, since the POST becomes
|
|
||||||
same-origin and `form-action 'self'` can join the CSP. Risks: one `flow.ui.action` serves all five
|
|
||||||
flows, so this rewrites the sign-in path; and Go's nosurf checks Referer only over https, which no
|
|
||||||
test covers. Code entry has no E2E coverage today.
|
|
||||||
|
|
||||||
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
|
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<%#
|
<%#
|
||||||
Themed Kratos self-service page inside the unified app shell: sign-in / register /
|
Themed Kratos self-service page inside the unified app shell: sign-in / register /
|
||||||
reset / verify / settings. Renders a FlowView (src/auth/flow-view.ts) into the shell content, reusing the
|
reset / verify / settings. Renders a FlowView (src/auth/flow-view.ts) into the shell content, reusing the
|
||||||
auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF. The
|
auth-card + field partials. The form posts to flow.action — Kratos owns its CSRF. The
|
||||||
shell's menu is permission-filtered (anonymous ⇒ public items + Sign in); the topbar carries no heading,
|
shell's menu is permission-filtered (anonymous ⇒ public items + Sign in); the topbar carries no heading,
|
||||||
so the card's own <h1> is the page's single heading. Data: chrome (PageChrome), flow (FlowView).
|
so the card's own <h1> is the page's single heading. Data: chrome (PageChrome), flow (FlowView).
|
||||||
%><%
|
%><%
|
||||||
|
|||||||
Reference in New Issue
Block a user