Verification/recovery: guard the OTP code field against a pasted space (numeric inputmode + digits-only pattern + one-time-code autofill); Kratos doesn't trim, so a space-padded code was rejected as 'invalid or already used'

This commit is contained in:
2026-06-21 23:26:23 +02:00
parent 166f38d5dd
commit af097a8885
4 changed files with 38 additions and 2 deletions
+4
View File
@@ -43,6 +43,10 @@ test("field defaults to a bare text input, escapes a string error, and never thr
const bare = flat(await render({ id: "x", name: "x", label: "X" })); const bare = flat(await render({ id: "x", name: "x", label: "X" }));
assert.match(bare, /<div class="field"><label for="x">X<\/label><div class="input-wrap"><input class="input" id="x" name="x" type="text"><\/div><\/div>/); assert.match(bare, /<div class="field"><label for="x">X<\/label><div class="input-wrap"><input class="input" id="x" name="x" type="text"><\/div><\/div>/);
// OTP code field: inputmode + pattern render (both after autocomplete, before required).
const code = flat(await render({ id: "field-code", name: "code", label: "Verification code", autocomplete: "one-time-code", inputmode: "numeric", pattern: "[0-9]*", required: true, icon: "i-shield" }));
assert.match(code, /<input class="input has-ico" id="field-code" name="code" type="text" autocomplete="one-time-code" inputmode="numeric" pattern="\[0-9\]\*" required>/);
const stringErr = flat(await render({ id: "x", name: "x", label: "X", error: "<b>Required</b>." })); const stringErr = flat(await render({ id: "x", name: "x", label: "X", error: "<b>Required</b>." }));
assert.match(stringErr, /<span>&lt;b&gt;Required&lt;\/b&gt;\.<\/span>/); // string error is escaped assert.match(stringErr, /<span>&lt;b&gt;Required&lt;\/b&gt;\.<\/span>/); // string error is escaped
assert.match(stringErr, /aria-describedby="x-err"/); assert.match(stringErr, /aria-describedby="x-err"/);
+24
View File
@@ -96,6 +96,30 @@ 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", () => {
// Verification/recovery enter a numeric OTP. Kratos doesn't trim, so a stray pasted space makes it
// reject the code as "invalid"; a digits-only pattern blocks that in the browser before submit.
const view = buildFlowView(
flow([
node({ name: "csrf_token", type: "hidden", value: "tok" }),
node({ name: "code", type: "text", required: true }, { label: "Verification code", group: "code" }),
node({ name: "method", type: "submit", value: "code" }, { label: "Continue", group: "code" }),
]),
"verification",
);
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
icon: "i-shield",
id: "field-code",
inputmode: "numeric",
label: "Verification code",
name: "code",
pattern: "[0-9]*",
required: true,
type: "text",
});
});
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");
+8 -1
View File
@@ -11,8 +11,10 @@ export interface FlowField {
error?: { text: string }; error?: { text: string };
icon?: string; // Lucide sprite id for the input icon?: string; // Lucide sprite id for the input
id: string; id: string;
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
required?: boolean; required?: boolean;
type: string; type: string;
value?: string; value?: string;
@@ -93,7 +95,11 @@ const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase(
function toField(node: UiNode, name: string, type: string): FlowField { function toField(node: UiNode, name: string, type: string): FlowField {
const value = str(node.attributes["value"]); const value = str(node.attributes["value"]);
const autocomplete = str(node.attributes["autocomplete"]); // The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
// space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in
// the browser; one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
const isCode = name === "code";
const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined);
const icon = iconFor(name, type); const icon = iconFor(name, type);
const errorMsg = node.messages.find((m) => m.type === "error"); const errorMsg = node.messages.find((m) => m.type === "error");
return { return {
@@ -104,6 +110,7 @@ function toField(node: UiNode, name: string, type: string): FlowField {
...(autocomplete ? { autocomplete } : {}), ...(autocomplete ? { autocomplete } : {}),
...(errorMsg ? { error: { text: errorMsg.text } } : {}), ...(errorMsg ? { error: { text: errorMsg.text } } : {}),
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}),
...(node.attributes["required"] === true ? { required: true } : {}), ...(node.attributes["required"] === true ? { required: true } : {}),
...(value ? { value } : {}), ...(value ? { value } : {}),
}; };
+2 -1
View File
@@ -3,6 +3,7 @@
Mirrors html-css-foundation auth markup. Config: Mirrors html-css-foundation auth markup. Config:
id, name, label id, name, label
type? (default "text"), value?, placeholder?, autocomplete?, required?, readonly?, minlength? type? (default "text"), value?, placeholder?, autocomplete?, required?, readonly?, minlength?
inputmode?, pattern? virtual-keyboard hint + client-side validity regex (e.g. the OTP code)
icon? input-ico id (e.g. "i-mail") → left-padded input icon? input-ico id (e.g. "i-mail") → left-padded input
optional? show an "Optional" tag in the label row optional? show an "Optional" tag in the label row
link? { href, label } inline beside the label (e.g. Forgot password?) link? { href, label } inline beside the label (e.g. Forgot password?)
@@ -24,7 +25,7 @@
<% } else { -%> <% } else { -%>
<label for="<%= id %>"><%= locals.label %></label> <label for="<%= id %>"><%= locals.label %></label>
<% } -%> <% } -%>
<div class="input-wrap"><% if (icon) { %><svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#<%= icon %>"/></svg><% } %><input class="input<% if (icon) { %> has-ico<% } %>" id="<%= id %>" name="<%= locals.name %>" type="<%= type %>"<% if (locals.autocomplete) { %> autocomplete="<%= locals.autocomplete %>"<% } %><% if (locals.placeholder) { %> placeholder="<%= locals.placeholder %>"<% } %><% if (locals.value != null) { %> value="<%= locals.value %>"<% } %><% if (locals.minlength != null) { %> minlength="<%= locals.minlength %>"<% } %><% if (error) { %> aria-invalid="true" aria-describedby="<%= errId %>"<% } %><% if (locals.required) { %> required<% } %><% if (locals.readonly) { %> readonly<% } %>></div> <div class="input-wrap"><% if (icon) { %><svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#<%= icon %>"/></svg><% } %><input class="input<% if (icon) { %> has-ico<% } %>" id="<%= id %>" name="<%= locals.name %>" type="<%= type %>"<% if (locals.autocomplete) { %> autocomplete="<%= locals.autocomplete %>"<% } %><% if (locals.inputmode) { %> inputmode="<%= locals.inputmode %>"<% } %><% if (locals.pattern) { %> pattern="<%= locals.pattern %>"<% } %><% if (locals.placeholder) { %> placeholder="<%= locals.placeholder %>"<% } %><% if (locals.value != null) { %> value="<%= locals.value %>"<% } %><% if (locals.minlength != null) { %> minlength="<%= locals.minlength %>"<% } %><% if (error) { %> aria-invalid="true" aria-describedby="<%= errId %>"<% } %><% if (locals.required) { %> required<% } %><% if (locals.readonly) { %> readonly<% } %>></div>
<% if (hint) { -%> <% if (hint) { -%>
<span class="field-hint"><%= hint %></span> <span class="field-hint"><%= hint %></span>
<% } -%> <% } -%>