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:
@@ -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><b>Required<\/b>\.<\/span>/); // string error is escaped
|
assert.match(stringErr, /<span><b>Required<\/b>\.<\/span>/); // string error is escaped
|
||||||
assert.match(stringErr, /aria-describedby="x-err"/);
|
assert.match(stringErr, /aria-describedby="x-err"/);
|
||||||
|
|||||||
@@ -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
@@ -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 } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
|
|||||||
Reference in New Issue
Block a user