feat(api): 添加或删除 passkey、修改邮箱前须 5 分钟内用已有因子重新验证,变更后邮件通知账户,面板加确认对话框与修改邮箱入口

This commit is contained in:
Lemon-miaow committed 2026-09-25 14:47:54 +08:00
1 parent 9e7f23ca13
commit d3769b5c31
36 files changed
+2735 -288

No files matched your search

+114 -4
View File
@@ -52,6 +52,10 @@ interface MockAccount {
updated_at?: string;
quota?: QuotaView;
sessions?: SessionView[];
// Until when this browser's session counts as re-authenticated (ms epoch),
// and the address an email-change code was last sent to.
reauthUntil?: number;
pendingEmail?: string;
}
interface MockServer extends ServerStatus {
@@ -568,6 +572,41 @@ function setSessionCookie(res: ServerResponse, accountID: string): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=${accountID}; Path=/; SameSite=Lax`);
}
// signInProven opens a session through a door that proved a factor (email code,
// passkey, operator login), which like the real API counts as a re-auth.
function signInProven(ctx: RequestContext, accountID: AccountID): void {
setSessionCookie(ctx.res, accountID);
const acc = ctx.state.accounts[accountID];
if (acc) acc.reauthUntil = Date.now() + REAUTH_MS;
}
const REAUTH_MS = 5 * 60_000;
/** reauthFactors mirrors reauthState: a passkey, an email code to a verified
* address (users) or a fresh sign-in (operators). None → nothing to re-prove. */
function reauthFactors(state: MockState, acc: MockAccount): string[] {
const hasPasskey = (state.passkeys[acc.id] ?? []).length > 0;
if (!hasPasskey && !acc.emailVerified) return [];
const factors = hasPasskey ? ["passkey"] : [];
if (acc.role !== "user") factors.push("sign_in");
else if (acc.emailVerified) factors.push("email");
return factors;
}
/** refusedForReauth answers 403 reauth_required, as the real API does, for a
* change to how the account signs in without a recent proof. */
function refusedForReauth(ctx: SessionContext): boolean {
const acc = ctx.account;
if (reauthFactors(ctx.state, acc).length === 0 || (acc.reauthUntil ?? 0) > Date.now()) return false;
sendError(ctx.res, 403, "reauth_required", "confirm it's you first: this change needs your passkey or email code from the last few minutes");
return true;
}
function markReauth(ctx: SessionContext): void {
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
sendJSON(ctx.res, 200, { ok: true, until: new Date(ctx.account.reauthUntil).toISOString() });
}
function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
}
@@ -784,7 +823,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
return true;
}
opLogins.delete(body.request_id!);
setSessionCookie(ctx.res, "owner");
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" });
return true;
}
@@ -828,7 +867,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
sendError(ctx.res, 400, "bad_request", "login_id and assertion are required");
return true;
}
setSessionCookie(ctx.res, "owner");
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, {
user_id: "mock-owner",
role: "owner"
@@ -857,7 +896,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
sendError(ctx.res, 400, "bad_request", "email and assertion are required");
return true;
}
setSessionCookie(ctx.res, "owner");
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, {
user_id: "mock-owner",
role: "owner"
@@ -879,7 +918,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
setSessionCookie(ctx.res, "owner");
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, {
user_id: "mock-owner",
role: "owner"
@@ -993,6 +1032,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 400, "bad_request", "invalid email");
return true;
}
if (refusedForReauth(ctx)) return true;
ctx.account.pendingEmail = body.email.trim();
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
return true;
}
@@ -1002,11 +1043,22 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
const next = ctx.account.pendingEmail ?? ctx.account.email;
// Like the real API: replacing a verified address signs the other devices
// out, and proving a code counts as a re-auth.
if (ctx.account.emailVerified && next.toLowerCase() !== ctx.account.email.toLowerCase()) {
const here = thisSessionHash(ctx.account);
ctx.account.sessions = accountSessions(ctx.account).filter((s) => s.token_hash === here);
}
ctx.account.email = next;
ctx.account.pendingEmail = undefined;
ctx.account.emailVerified = true;
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
sendJSON(ctx.res, 200, { verified: true, email: ctx.account.email });
return true;
}
case "POST account/passkey/register/begin": {
if (refusedForReauth(ctx)) return true;
sendJSON(ctx.res, 200, {
challenge: "c29tZV9jaGFsbGVuZ2VfZGF0YQ",
rp: { name: "Felis Dev" },
@@ -1034,6 +1086,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
ctx.state.passkeys[ctx.account.id] = [];
}
ctx.state.passkeys[ctx.account.id].unshift(newCred);
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
sendJSON(ctx.res, 201, newCred);
return true;
}
@@ -1042,6 +1095,61 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
sendJSON(ctx.res, 200, { credentials: list });
return true;
}
case "GET account/reauth": {
const factors = reauthFactors(ctx.state, ctx.account);
const until = ctx.account.reauthUntil ?? 0;
if (factors.length === 0) sendJSON(ctx.res, 200, { needed: false, factors });
else if (until > Date.now()) sendJSON(ctx.res, 200, { needed: false, until: new Date(until).toISOString(), factors });
else sendJSON(ctx.res, 200, { needed: true, factors });
return true;
}
case "POST account/reauth/passkey/begin": {
const list = ctx.state.passkeys[ctx.account.id] ?? [];
if (list.length === 0) {
sendError(ctx.res, 409, "no_passkey", "no passkey enrolled; confirm with an email code instead");
return true;
}
sendJSON(ctx.res, 200, {
publicKey: {
challenge: "c29tZV9yZWF1dGhfY2hhbGxlbmdl",
rpId: "dev.felis.localhost",
allowCredentials: list.map(() => ({ type: "public-key", id: "cGstMQ" })),
userVerification: "preferred",
timeout: 60000,
},
});
return true;
}
case "POST account/reauth/passkey/finish": {
const body = await readJSON<{ assertion?: unknown }>(ctx.req);
if (!body.assertion) {
sendError(ctx.res, 400, "bad_request", "assertion is required");
return true;
}
markReauth(ctx);
return true;
}
case "POST account/reauth/email/start": {
if (ctx.account.role !== "user") {
sendError(ctx.res, 403, "staff_reauth", "operators confirm with a passkey or by signing in again");
return true;
}
if (!ctx.account.emailVerified) {
sendError(ctx.res, 409, "no_step_up_factor", "no verified email to send a code to");
return true;
}
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
return true;
}
case "POST account/reauth/email/verify": {
const body = await readJSON<{ code?: string }>(ctx.req);
if (body.code?.trim() !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
markReauth(ctx);
return true;
}
case "GET account/migrate":
// No migration pending: the real API's answer until one is started in-game.
sendJSON(ctx.res, 200, { active: false });
@@ -1076,6 +1184,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
return true;
}
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
// The real API asks for the re-auth before it looks the passkey up.
if (refusedForReauth(ctx)) return true;
const id = ctx.parts[5];
const list = ctx.state.passkeys[ctx.account.id] ?? [];
const idx = list.findIndex((k) => k.id === id);
+286
View File
@@ -0,0 +1,286 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { Fingerprint, Loader2, LogIn, Mail, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InlineError } from "@/components/MessageLine";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { api, clientError, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { requestAssertion } from "@/lib/passkey";
import { useTier } from "@/lib/tier";
// A change to how the account signs in (a passkey added or removed, the email
// changed) needs this session to have proven a factor in the last few minutes,
// so a browser left signed in cannot quietly swap the account's ways in. The API
// refuses such a change with 403 reauth_required; useReauth catches that, asks
// for the proof in this dialog and runs the change again once it is given.
export function isReauthRequired(e: unknown): boolean {
return (e as { code?: string } | null)?.code === "reauth_required";
}
/** isReauthCancelled: the confirmation was closed without a proof. Nothing
* failed, so callers show no error for it. */
export function isReauthCancelled(e: unknown): boolean {
return (e as { code?: string } | null)?.code === "reauth_cancelled";
}
export function useReauth() {
const [open, setOpen] = useState(false);
const settle = useRef<((ok: boolean) => void) | null>(null);
const finish = useCallback((ok: boolean) => {
setOpen(false);
const resolve = settle.current;
settle.current = null;
resolve?.(ok);
}, []);
// confirm opens the dialog and resolves true once a factor is proven, false
// when it is closed without one.
const confirm = useCallback(() => {
settle.current?.(false);
setOpen(true);
return new Promise<boolean>((resolve) => {
settle.current = resolve;
});
}, []);
// guard runs a change. Refused for want of a fresh proof, it asks for one and
// runs the change a second time; closing the dialog rejects with
// reauth_cancelled. A change that needs a user gesture of its own (a WebAuthn
// create) uses confirm and lets the user press its button again instead.
const guard = useCallback(
async <T,>(change: () => Promise<T>): Promise<T> => {
try {
return await change();
} catch (e) {
if (!isReauthRequired(e)) throw e;
if (!(await confirm())) throw clientError("reauth_cancelled");
return change();
}
},
[confirm],
);
const dialog = <ReauthDialog open={open} onDone={() => finish(true)} onCancel={() => finish(false)} />;
return { guard, confirm, dialog };
}
function ReauthDialog({ open, onDone, onCancel }: { open: boolean; onDone: () => void; onCancel: () => void }) {
const { t } = useTranslation("account");
return (
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-primary" />
{t("reauth_title")}
</DialogTitle>
<DialogDescription>{t("reauth_desc")}</DialogDescription>
</DialogHeader>
{/* Mounted per opening, so every confirmation starts from a fresh status. */}
{open && <ReauthBody onDone={onDone} onCancel={onCancel} />}
</DialogContent>
</Dialog>
);
}
type Pending = "passkey" | "send" | "verify" | "sign_in";
function ReauthBody({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
const { t } = useTranslation("account");
const { identity, refresh } = useTier();
const status = useAsync(() => api.reauthStatus(), []);
const [pending, setPending] = useState<Pending | null>(null);
const [error, setError] = useState<string | null>(null);
const [codeSent, setCodeSent] = useState(false);
const [code, setCode] = useState("");
// Proven meanwhile (in another tab, say): there is nothing to ask.
const proven = status.data?.needed === false;
useEffect(() => {
if (proven) onDone();
}, [proven, onDone]);
async function act(kind: Pending, fn: () => Promise<void>) {
if (pending) return;
setPending(kind);
setError(null);
try {
await fn();
} catch (e) {
setError(humanizeError(e));
} finally {
setPending(null);
}
}
const withPasskey = () =>
act("passkey", async () => {
const options = await api.reauthPasskeyBegin();
await api.reauthPasskeyFinish(await requestAssertion(options.publicKey));
onDone();
});
const sendCode = () =>
act("send", async () => {
await api.reauthEmailStart();
setCodeSent(true);
setCode("");
});
const verifyCode = (e: FormEvent) => {
e.preventDefault();
const trimmed = code.trim();
if (!trimmed) return;
void act("verify", async () => {
await api.reauthEmailVerify(trimmed);
onDone();
});
};
// A fresh sign-in counts as the proof. Signing out flips the session to
// signed-out and the route guard takes the browser to the sign-in page.
const signInAgain = () =>
act("sign_in", async () => {
await api.logout();
await refresh();
});
if (status.loading && !status.data) {
return (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("reauth_checking")}
</div>
);
}
if (status.error || !status.data || proven) {
return (
<>
<InlineError message={status.error ? humanizeError(status.error) : null} />
<DialogFooter>
<Button variant="outline" size="sm" onClick={onCancel}>
{t("common:cancel")}
</Button>
{status.error ? (
<Button size="sm" onClick={status.reload}>
{t("common:try_again")}
</Button>
) : null}
</DialogFooter>
</>
);
}
const factors = status.data.factors;
const email = identity?.email ?? "";
const options: React.ReactNode[] = [];
if (factors.includes("passkey")) {
options.push(
<Button key="passkey" className="w-full" onClick={() => void withPasskey()} disabled={pending !== null}>
{pending === "passkey" ? <Loader2 className="animate-spin" /> : <Fingerprint />}
{pending === "passkey" ? t("reauth_passkey_waiting") : t("reauth_passkey_btn")}
</Button>,
);
}
if (factors.includes("email")) {
options.push(
codeSent ? (
<form key="email" onSubmit={verifyCode} className="space-y-2">
<Label htmlFor="reauth-code">{t("reauth_code_label", { email })}</Label>
<div className="flex gap-2">
<Input
id="reauth-code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder={t("otp_code_placeholder")}
value={code}
onChange={(e) => setCode(e.target.value)}
disabled={pending !== null}
maxLength={6}
autoFocus
className="font-mono text-center tracking-[0.2em]"
/>
<Button type="submit" disabled={pending !== null || code.trim().length !== 6} className="shrink-0">
{pending === "verify" && <Loader2 className="animate-spin" />}
{pending === "verify" ? t("reauth_confirming") : t("reauth_confirm_btn")}
</Button>
</div>
<Button
type="button"
variant="link"
size="sm"
onClick={() => void sendCode()}
disabled={pending !== null}
className="h-auto p-0 font-normal"
>
{pending === "send" ? t("sending_code") : t("reauth_resend")}
</Button>
</form>
) : (
<Button
key="email"
variant="outline"
className="w-full"
onClick={() => void sendCode()}
disabled={pending !== null}
>
{pending === "send" ? <Loader2 className="animate-spin" /> : <Mail />}
<span className="truncate">{pending === "send" ? t("sending_code") : t("reauth_email_btn", { email })}</span>
</Button>
),
);
}
if (factors.includes("sign_in")) {
options.push(
<div key="sign_in" className="space-y-2">
<p className="text-sm text-muted-foreground">{t("reauth_sign_in_desc")}</p>
<Button variant="outline" className="w-full" onClick={() => void signInAgain()} disabled={pending !== null}>
{pending === "sign_in" ? <Loader2 className="animate-spin" /> : <LogIn />}
{t("reauth_sign_in_btn")}
</Button>
</div>,
);
}
return (
<>
<div className="space-y-3 py-2">
{options.length === 0 ? (
<InlineError message={t("errors:no_step_up_factor")} />
) : (
options.flatMap((option, i) =>
i === 0
? [option]
: [
<div key={`or-${i}`} className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
{t("reauth_or")}
<span className="h-px flex-1 bg-border" />
</div>,
option,
],
)
)}
<InlineError message={error} />
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onCancel}>
{t("common:cancel")}
</Button>
</DialogFooter>
</>
);
}
+16 -1
View File
@@ -89,5 +89,20 @@
"migration_redeem_placeholder": "Transfer code",
"migration_redeeming": "Redeeming…",
"migration_redeem_btn": "Redeem",
"migration_redeemed": "Migration complete — {{count}} server(s) moved to this account."
"migration_redeemed": "Migration complete — {{count}} server(s) moved to this account.",
"reauth_title": "Confirm it's you",
"reauth_desc": "Adding or removing a passkey and changing the email need a fresh check with a way in you already have. The check lasts 5 minutes.",
"reauth_checking": "Checking how you can confirm…",
"reauth_passkey_btn": "Use a passkey",
"reauth_passkey_waiting": "Waiting for your passkey…",
"reauth_or": "or",
"reauth_email_btn": "Email a code to {{email}}",
"reauth_code_label": "Code sent to {{email}}",
"reauth_confirm_btn": "Confirm",
"reauth_confirming": "Confirming…",
"reauth_resend": "Send another code",
"reauth_sign_in_desc": "Operator accounts can also sign out and sign in again. A fresh sign-in counts for 5 minutes.",
"reauth_sign_in_btn": "Sign out and sign in again",
"reauth_done_continue": "Confirmed. Press Continue to add the passkey.",
"email_change_desc": "Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out."
}
@@ -78,6 +78,9 @@
"passkey_login_failed": "Passkey verification failed — try again.",
"passkey_login_invalid": "That Passkey sign-in request is invalid or expired — start it again.",
"too_many_challenges": "Too many verification attempts right now — try again shortly.",
"reauth_required": "Confirm it's you first: this change needs your Passkey or an email code from the last few minutes.",
"staff_reauth": "Operator accounts confirm with a Passkey or by signing in again.",
"no_session": "This only works in a browser signed in to Felis.",
"op_login_invalid": "This operator sign-in couldn't be completed — restart the sign-in.",
"op_login_not_found": "No pending operator sign-in with that id.",
"too_many_streams": "Too many live streams are open — close some pages and try again.",
+16 -1
View File
@@ -88,5 +88,20 @@
"migration_redeem_placeholder": "转移码",
"migration_redeeming": "兑换中…",
"migration_redeem_btn": "兑换",
"migration_redeemed": "迁移完成——已有 {{count}} 台服务器转移至本账户。"
"migration_redeemed": "迁移完成——已有 {{count}} 台服务器转移至本账户。",
"reauth_title": "确认是你本人",
"reauth_desc": "添加或删除 Passkey、修改邮箱前,需要用你已有的登录方式再验证一次,验证后 5 分钟内有效。",
"reauth_checking": "正在查看可用的验证方式…",
"reauth_passkey_btn": "用 Passkey 验证",
"reauth_passkey_waiting": "等待 Passkey…",
"reauth_or": "或",
"reauth_email_btn": "发送验证码到 {{email}}",
"reauth_code_label": "验证码已发送到 {{email}}",
"reauth_confirm_btn": "确认",
"reauth_confirming": "确认中…",
"reauth_resend": "重新发送验证码",
"reauth_sign_in_desc": "管理员账户也可以退出后重新登录,重新登录后 5 分钟内视为已验证。",
"reauth_sign_in_btn": "退出并重新登录",
"reauth_done_continue": "已确认。点“继续”添加 Passkey。",
"email_change_desc": "输入新邮箱,我们会向新地址发送验证码。验证通过后登录验证码改发到新邮箱,旧邮箱会收到通知,其它设备会退出登录。"
}
@@ -78,6 +78,9 @@
"passkey_login_failed": "Passkey 验证失败——请重试。",
"passkey_login_invalid": "Passkey 登录请求无效或已过期——请重新发起。",
"too_many_challenges": "验证请求过于频繁——请稍后再试。",
"reauth_required": "请先确认是你本人:这项更改需要你在几分钟内用 Passkey 或邮箱验证码验证过。",
"staff_reauth": "管理员账户须用 Passkey 或重新登录来确认身份。",
"no_session": "只能在已登录 Felis 的浏览器中进行此操作。",
"op_login_invalid": "本次管理员登录未能完成——请重新发起登录。",
"op_login_not_found": "找不到该管理员登录请求。",
"too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。",
+12
View File
@@ -1015,4 +1015,16 @@ describe("copy for the generic server codes", () => {
expect(err.status).toBe(0);
expect(humanizeError(err)).toBe("The browser returned no passkey. Try again.");
});
it("words the re-authentication refusals", () => {
expect(humanizeError({ status: 403, code: "reauth_required", message: "raw" })).toBe(
"Confirm it's you first: this change needs your Passkey or an email code from the last few minutes.",
);
expect(humanizeError({ status: 403, code: "staff_reauth", message: "raw" })).toBe(
"Operator accounts confirm with a Passkey or by signing in again.",
);
expect(humanizeError({ status: 400, code: "no_session", message: "raw" })).toBe(
"This only works in a browser signed in to Felis.",
);
});
});
+27
View File
@@ -590,6 +590,26 @@ export const api = rejectingSync({
revokeMyOtherSessions: () =>
request<{ revoked: number }>("POST", "/account/sessions/revoke-others"),
// Re-authentication. Adding or removing a passkey and changing the email are
// refused with 403 reauth_required unless this session proved a factor in the
// last few minutes. Status names the factors that can prove it: "passkey",
// "email" (a code to the verified address) or "sign_in" (operators sign in
// again). Like the migrate begin, the passkey begin returns the raw
// {"publicKey": {...}} document.
reauthStatus: () =>
request<{ needed: boolean; until?: string; factors: string[] }>("GET", "/account/reauth"),
reauthPasskeyBegin: () => request<any>("POST", "/account/reauth/passkey/begin"),
reauthPasskeyFinish: (assertion: any) =>
request<{ ok: boolean; until: string }>("POST", "/account/reauth/passkey/finish", { assertion }),
reauthEmailStart: () =>
request<{ sent: boolean; expires_at: string }>("POST", "/account/reauth/email/start"),
reauthEmailVerify: (code: string) =>
request<{ ok: boolean; until: string }>("POST", "/account/reauth/email/verify", { code }),
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
// otherwise) → issue-code (source names the target account and reads the one-time
@@ -982,6 +1002,13 @@ export function humanizeError(e: unknown): string {
return t("passkey_login_invalid");
case "too_many_challenges":
return t("too_many_challenges");
// Re-authentication before a change to how the account signs in.
case "reauth_required":
return t("reauth_required");
case "staff_reauth":
return t("staff_reauth");
case "no_session":
return t("no_session");
// Operator-login approvals, live streams, and the remaining auth doors.
case "op_login_invalid":
return t("op_login_invalid");
+335 -6
View File
@@ -1363,7 +1363,7 @@ export interface paths {
put?: never;
/**
* Mint and deliver an email one-time code for the caller (web onboarding, spec §B2).
* @description Generates a one-time code bound to the authenticated principal and the supplied address, persists only its hash, and delivers it out of band. The code is never returned in the response. A re-request supersedes the prior unconsumed code.
* @description Generates a one-time code bound to the authenticated principal and the supplied address, persists only its hash, and delivers it out of band. The code is never returned in the response. A re-request supersedes the prior unconsumed code. Once the account has a passkey or a verified email, the session must have reauthed within 5 minutes (403 reauth_required).
*/
post: operations["emailOtpStart"];
delete?: never;
@@ -1383,7 +1383,7 @@ export interface paths {
put?: never;
/**
* Redeem an email one-time code and mark the caller's email verified (spec §B2).
* @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. When the new address replaces a different verified one, every other session of the caller is signed out: sign-in codes now go to the new address, so a session opened through the old one ends. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400.
* @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. When the new address replaces a different verified one, every other session of the caller is signed out: sign-in codes now go to the new address, so a session opened through the old one ends; the old address is mailed a notice with the new one masked. A verified code also counts as a reauth for this session. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400.
*/
post: operations["emailOtpVerify"];
delete?: never;
@@ -1403,7 +1403,7 @@ export interface paths {
put?: never;
/**
* Record the caller's email WITHOUT verifying it (setup bootstrap, spec §B2).
* @description Writes the supplied address to the authenticated principal's user row and clears email_verified (already false for a fresh Owner). The setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP flow proves control of the address via /account/email/verify.
* @description Writes the supplied address to the authenticated principal's user row and clears email_verified (already false for a fresh Owner). The setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP flow proves control of the address via /account/email/verify. Clearing a verified address strips a factor, so once the account has one the session must have reauthed within 5 minutes (403 reauth_required).
*/
post: operations["setEmail"];
delete?: never;
@@ -1423,7 +1423,7 @@ export interface paths {
put?: never;
/**
* Begin a passkey (WebAuthn) registration ceremony for the caller (spec §14, Phase 6 bind).
* @description Mints a credential-creation challenge bound to the authenticated principal, stashes the server-side ceremony state under a short TTL, and returns the WebAuthn publicKey creation options for navigator.credentials.create(). The challenge is never echoed by the client. Enrollment only — passkey login is a deferred slice. 503 when the WebAuthn verifier is not configured on this instance.
* @description Mints a credential-creation challenge bound to the authenticated principal, stashes the server-side ceremony state under a short TTL, and returns the WebAuthn publicKey creation options for navigator.credentials.create(). The challenge is never echoed by the client. Once the account has a passkey or a verified email, the session must have reauthed within 5 minutes (403 reauth_required). 503 when the WebAuthn verifier is not configured on this instance.
*/
post: operations["passkeyRegisterBegin"];
delete?: never;
@@ -1443,7 +1443,7 @@ export interface paths {
put?: never;
/**
* Finish a passkey registration ceremony and bind the credential (spec §14, Phase 6 bind).
* @description Consumes the caller's live registration challenge (single-use), verifies the authenticator's attestation against the server-stashed ceremony state, and persists the public credential. A missing or expired ceremony is a 400; an attestation that fails verification is a 400; a credential already bound to any account is a 409. 503 when the WebAuthn verifier is not configured.
* @description Consumes the caller's live registration challenge (single-use), verifies the authenticator's attestation against the server-stashed ceremony state, and persists the public credential. A missing or expired ceremony is a 400; an attestation that fails verification is a 400; a credential already bound to any account is a 409. The verified email is mailed a notice, and the ceremony counts as a reauth for this session. 503 when the WebAuthn verifier is not configured.
*/
post: operations["passkeyRegisterFinish"];
delete?: never;
@@ -1484,7 +1484,7 @@ export interface paths {
post?: never;
/**
* Unbind one of the caller's passkeys (spec §14, Phase 6 bind).
* @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in. Removing a passkey signs out every other session of the caller, so a session opened with that passkey ends with it.
* @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in. Removing a passkey signs out every other session of the caller, so a session opened with that passkey ends with it, and mails the verified email a notice. The session must have reauthed within 5 minutes (403 reauth_required).
*/
delete: operations["passkeyDelete"];
options?: never;
@@ -1492,6 +1492,103 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/account/reauth": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Say whether a passkey or email change needs a reauth first, and how to give one.
* @description needed is true when the account has a passkey or a verified email and this session has not proven one within the last 5 minutes. until is when the current proof stops counting. factors lists the ways this caller can reauth, best first: passkey (an enrolled passkey), email (a player's verified address), sign_in (an operator signs out and back in through op-login or a passkey).
*/
get: operations["reauthStatus"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/reauth/passkey/begin": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Begin a passkey assertion that reauths this session.
* @description Returns WebAuthn assertion request options over the caller's own passkeys, bound to a fresh reauth-purpose challenge.
*/
post: operations["reauthPasskeyBegin"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/reauth/passkey/finish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Finish the passkey assertion and mark this session reauthed for 5 minutes.
* @description Verifies the assertion against the reauth challenge with the login door's clone check (a cloned authenticator is 400 passkey_login_invalid).
*/
post: operations["reauthPasskeyFinish"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/reauth/email/start": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Mail a reauth code to the caller's verified address.
* @description For players with a verified email. Operators reauth with a passkey or by signing in again (403 staff_reauth).
*/
post: operations["reauthEmailStart"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/reauth/email/verify": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Redeem the reauth code and mark this session reauthed for 5 minutes. */
post: operations["reauthEmailVerify"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/sessions": {
parameters: {
query?: never;
@@ -2339,6 +2436,29 @@ export interface components {
"application/json": components["schemas"]["Error"];
};
};
/** @description This session is reauthed until the returned time. */
Reauthed: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @constant */
ok: true;
/** Format: date-time */
until: string;
};
};
};
/** @description reauth_required: this change adds, removes or moves a way into the account, and the account has a passkey or a verified email, so the session must have proven one of them within the last 5 minutes. Signing in by passkey, email code, op-login or the setup token counts; a bind-code sign-in does not. GET /api/v1/account/reauth lists the factors that can give the proof, then retry the change. */
ReauthRequired: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The configured SMTP relay refused the message (code mail_undeliverable), so no code was delivered. Distinct from 500 because the fault is in the install's [smtp] settings, not in the request or the platform — most often a From address the relay will not let this account send as. The relay's own text is deliberately withheld (it names the SMTP account) and written to the felis-api log instead, keyed by the same request_id this response carries. Retrying the same address changes nothing until an operator fixes the relay. */
MailUndeliverable: {
headers: {
@@ -5765,6 +5885,7 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["ReauthRequired"];
/** @description Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account spent its daily wrong-code budget (otp_account_locked, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */
429: {
headers: {
@@ -5865,6 +5986,7 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["ReauthRequired"];
};
};
passkeyRegisterBegin: {
@@ -5886,6 +6008,7 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["ReauthRequired"];
/** @description Passkey subsystem is not configured. */
503: {
headers: {
@@ -5997,6 +6120,7 @@ export interface operations {
content?: never;
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["ReauthRequired"];
/** @description No such passkey for this caller. */
404: {
headers: {
@@ -6017,6 +6141,211 @@ export interface operations {
};
};
};
reauthStatus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Where the caller stands. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
needed: boolean;
/** Format: date-time */
until?: string;
factors: ("passkey" | "email" | "sign_in")[];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
reauthPasskeyBegin: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": Record<string, never>;
};
};
/** @description The caller has no enrolled passkey (no_passkey), or no browser session to mark (no_session). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
503: components["responses"]["ServiceUnavailable"];
};
};
reauthPasskeyFinish: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description The navigator.credentials.get() PublicKeyCredential assertion. */
assertion: Record<string, never>;
};
};
};
responses: {
200: components["responses"]["Reauthed"];
/** @description Assertion invalid, challenge stale, or a cloned authenticator (passkey_login_invalid); no browser session (no_session). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
503: components["responses"]["ServiceUnavailable"];
};
};
reauthEmailStart: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Code minted and dispatched. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @constant */
sent: true;
/** Format: date-time */
expires_at: string;
};
};
};
/** @description No browser session to mark (no_session). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
/** @description Operators cannot reauth by email (staff_reauth). */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The account has no verified email (no_step_up_factor). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
502: components["responses"]["MailUndeliverable"];
};
};
reauthEmailVerify: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
code: string;
};
};
};
responses: {
200: components["responses"]["Reauthed"];
/** @description Invalid or expired code (invalid_code), or no browser session (no_session). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
/** @description Operators cannot reauth by email (staff_reauth). */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The account has no verified email (no_step_up_factor). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Too many incorrect attempts on this code (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
listMySessions: {
parameters: {
query?: never;
+31
View File
@@ -0,0 +1,31 @@
import { clientError } from "@/lib/api";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
/** requestAssertion runs the WebAuthn get() ceremony for the server's request
* options (their JSON form: challenge and credential ids base64url) and returns
* the assertion in the JSON form the API's finish endpoints decode. Begins that
* answer with go-webauthn's {"publicKey": {...}} envelope pass the inner object. */
export async function requestAssertion(options: any) {
const publicKey: PublicKeyCredentialRequestOptions = {
...options,
challenge: base64urlToBytes(options.challenge),
allowCredentials: options.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null;
if (!credential) throw clientError("passkey_no_credential");
const response = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
authenticatorData: bytesToBase64url(response.authenticatorData),
signature: bytesToBase64url(response.signature),
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
},
};
}
+59 -31
View File
@@ -14,8 +14,10 @@ import { formatAbsolute } from "@/lib/format";
import type { PasskeyCredential } from "@/lib/types";
import { useAsync } from "@/lib/hooks";
import { AccountSessionsCard } from "@/pages/AccountSessions";
import { isReauthCancelled, isReauthRequired, useReauth } from "@/components/ReauthDialog";
import { useTier } from "@/lib/tier";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import { requestAssertion } from "@/lib/passkey";
import {
Dialog,
DialogContent,
@@ -36,8 +38,12 @@ export function Account() {
const status = useAsync(() => api.linkStatus(), []);
const { identity, refresh } = useTier();
const { t, i18n } = useTranslation("account");
// Adding or removing a passkey and changing the email ask for a fresh proof
// of a factor first (ReauthDialog).
const reauth = useReauth();
// Email verification state
// Email verification state. A verified address is changed through the same
// two steps, opened with changingEmail.
const [emailInput, setEmailInput] = useState("");
const [otpCodeInput, setOtpCodeInput] = useState("");
const [emailSending, setEmailSending] = useState(false);
@@ -46,6 +52,7 @@ export function Account() {
const [emailSent, setEmailSent] = useState(false);
const [sentEmailAddress, setSentEmailAddress] = useState("");
const [initializedEmail, setInitializedEmail] = useState(false);
const [changingEmail, setChangingEmail] = useState(false);
useEffect(() => {
if (identity?.email && !initializedEmail) {
@@ -61,16 +68,30 @@ export function Account() {
setEmailSending(true);
setEmailError(null);
try {
await api.emailStart(trimmed);
await reauth.guard(() => api.emailStart(trimmed));
setEmailSent(true);
setSentEmailAddress(trimmed);
} catch (err) {
setEmailError(humanizeError(err));
if (!isReauthCancelled(err)) setEmailError(humanizeError(err));
} finally {
setEmailSending(false);
}
}
function startChangeEmail() {
setChangingEmail(true);
setEmailInput("");
setEmailSent(false);
setOtpCodeInput("");
setEmailError(null);
}
function cancelChangeEmail() {
setChangingEmail(false);
setEmailSent(false);
setEmailError(null);
}
async function verifyEmailOtp(e: FormEvent) {
e.preventDefault();
const trimmedCode = otpCodeInput.trim();
@@ -80,6 +101,11 @@ export function Account() {
try {
await api.emailVerify(trimmedCode);
await refresh();
if (changingEmail) {
// Replacing a verified address signs the other devices out.
setChangingEmail(false);
setSessionsVersion((v) => v + 1);
}
setEmailSent(false);
setEmailInput("");
setOtpCodeInput("");
@@ -97,6 +123,7 @@ export function Account() {
const [passkeyNickname, setPasskeyNickname] = useState("");
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [passkeyError, setPasskeyError] = useState<string | null>(null);
const [passkeyNotice, setPasskeyNotice] = useState<string | null>(null);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
// Deleting a passkey goes through a confirm dialog that names it. The API
// refuses to remove the only passkey of an account whose email is unverified
@@ -118,6 +145,7 @@ export function Account() {
setRegisterDialogOpen(false);
setPasskeyNickname("");
setPasskeyError(null);
setPasskeyNotice(null);
setRegisteringPasskey(false);
}
@@ -127,12 +155,22 @@ export function Account() {
if (!name || registeringPasskey) return;
setRegisteringPasskey(true);
setPasskeyError(null);
setPasskeyNotice(null);
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const options = await api.passkeyRegisterBegin();
let options;
try {
options = await api.passkeyRegisterBegin();
} catch (err) {
if (!isReauthRequired(err)) throw err;
// The browser lets navigator.credentials.create run only right after a
// click, which the confirmation used up: the user presses Continue again.
if (await reauth.confirm()) setPasskeyNotice(t("reauth_done_continue"));
return;
}
const publicKey: PublicKeyCredentialCreationOptions = {
...options,
challenge: base64urlToBytes(options.challenge),
@@ -189,12 +227,13 @@ export function Account() {
setDeletingPasskey(true);
setDeleteError(null);
try {
await api.passkeyDelete(pendingDelete.id);
await reauth.guard(() => api.passkeyDelete(pendingDelete.id));
setPendingDelete(null);
// The server signed the other devices out along with the passkey.
setSessionsVersion((v) => v + 1);
await passkeys.reload();
} catch (err) {
if (isReauthCancelled(err)) return;
// Another device may have changed the list meanwhile: refresh it. A 404
// means the passkey is already gone, which is what was asked for.
void passkeys.reload();
@@ -290,17 +329,20 @@ export function Account() {
</CardTitle>
</CardHeader>
<CardContent className="text-sm">
{identity?.email_verified ? (
{identity?.email_verified && !changingEmail ? (
<div className="space-y-3">
<div className="flex items-center gap-2 font-medium text-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
{t("email_verified")}
</div>
<p className="text-muted-foreground">{t("email_desc")}</p>
<div className="flex items-center gap-2 text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{identity.email}
</code>
<Button variant="link" size="sm" onClick={startChangeEmail} className="h-auto p-0 font-normal">
{t("change_email")}
</Button>
</div>
</div>
) : (
@@ -309,7 +351,7 @@ export function Account() {
<StepBadge n={1} />
<div className="w-full space-y-2">
<p className="font-medium text-foreground">{t("email_step1")}</p>
<p className="text-muted-foreground">{t("email_step1_desc")}</p>
<p className="text-muted-foreground">{t(changingEmail ? "email_change_desc" : "email_step1_desc")}</p>
{!emailSent ? (
<form onSubmit={sendEmailOtp} className="flex gap-2 max-w-md">
<Input
@@ -324,6 +366,11 @@ export function Account() {
<Button type="submit" disabled={emailSending || !emailInput}>
{emailSending ? t("sending_code") : t("send_code")}
</Button>
{changingEmail && (
<Button type="button" variant="ghost" onClick={cancelChangeEmail} disabled={emailSending}>
{t("common:cancel")}
</Button>
)}
</form>
) : (
<div className="flex items-center gap-2 text-emerald-600 font-medium dark:text-emerald-400">
@@ -409,6 +456,7 @@ export function Account() {
required
/>
</div>
{passkeyNotice && <MessageLine kind="success" message={passkeyNotice} />}
<InlineError message={passkeyError} />
</div>
<DialogFooter>
@@ -518,6 +566,8 @@ export function Account() {
onSignOut={() => void signOut()}
signingOut={signingOut}
/>
{reauth.dialog}
</>
);
}
@@ -654,29 +704,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
function confirmWithPasskey() {
void run(async () => {
const options = await api.migrateConfirmPasskeyBegin();
const pk = options.publicKey;
const publicKey: PublicKeyCredentialRequestOptions = {
...pk,
challenge: base64urlToBytes(pk.challenge),
allowCredentials: pk.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential;
if (!credential) throw clientError("passkey_no_credential");
const response = credential.response as AuthenticatorAssertionResponse;
await api.migrateConfirmPasskeyFinish({
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
authenticatorData: bytesToBase64url(response.authenticatorData),
signature: bytesToBase64url(response.signature),
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
},
});
await api.migrateConfirmPasskeyFinish(await requestAssertion(options.publicKey));
await mig.reload();
});
}
+303
View File
@@ -0,0 +1,303 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Identity, PasskeyCredential } from "@/lib/types";
import { Account } from "./Account";
// The Account page's changes to how the account signs in (delete or add a
// passkey, change the email) meet 403 reauth_required when the session has not
// proven a factor lately. These drive the page through that refusal: the check
// dialog, each factor, and the change running again afterwards.
const mocks = vi.hoisted(() => ({
passkeyList: vi.fn(),
passkeyDelete: vi.fn(),
passkeyRegisterBegin: vi.fn(),
listMySessions: vi.fn(),
emailStart: vi.fn(),
emailVerify: vi.fn(),
reauthStatus: vi.fn(),
reauthPasskeyBegin: vi.fn(),
reauthPasskeyFinish: vi.fn(),
reauthEmailStart: vi.fn(),
reauthEmailVerify: vi.fn(),
logout: vi.fn(),
refresh: vi.fn(),
credentialsGet: vi.fn(),
credentialsCreate: vi.fn(),
identity: null as Identity | null,
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: {
...actual.api,
linkStatus: () => Promise.resolve({ linked: true }),
migrateStatus: () => Promise.resolve({ active: false }),
passkeyList: mocks.passkeyList,
passkeyDelete: mocks.passkeyDelete,
passkeyRegisterBegin: mocks.passkeyRegisterBegin,
listMySessions: mocks.listMySessions,
emailStart: mocks.emailStart,
emailVerify: mocks.emailVerify,
reauthStatus: mocks.reauthStatus,
reauthPasskeyBegin: mocks.reauthPasskeyBegin,
reauthPasskeyFinish: mocks.reauthPasskeyFinish,
reauthEmailStart: mocks.reauthEmailStart,
reauthEmailVerify: mocks.reauthEmailVerify,
logout: mocks.logout,
},
};
});
vi.mock("@/lib/tier", () => ({
useTier: () => ({ identity: mocks.identity, refresh: mocks.refresh }),
}));
const refused = { status: 403, code: "reauth_required", message: "confirm it's you first" };
const laptop: PasskeyCredential = { id: "pk-1", name: "Laptop", created_at: "2026-03-01T10:00:00Z" };
const phone: PasskeyCredential = { id: "pk-2", name: "Phone", created_at: "2026-04-01T10:00:00Z" };
function identity(role: Identity["role"] = "user"): Identity {
return {
user_id: "u-1",
email: "[email protected]",
role,
is_admin: role !== "user",
is_owner: role === "owner",
email_verified: true,
};
}
const bytes = (s: string) => new TextEncoder().encode(s).buffer;
function renderAccount() {
return render(
<MemoryRouter>
<Account />
</MemoryRouter>,
);
}
const checkDialog = () => screen.findByRole("dialog", { name: "Confirm it's you" });
async function askToDeleteLaptop() {
await userEvent.click(await screen.findByRole("button", { name: "Delete passkey “Laptop”" }));
const confirmDelete = screen.getByRole("dialog", { name: "Delete this passkey?" });
await userEvent.click(within(confirmDelete).getByRole("button", { name: "Delete" }));
}
async function proveByEmail(dialog: HTMLElement) {
await userEvent.click(await within(dialog).findByRole("button", { name: "Email a code to [email protected]" }));
await userEvent.type(await within(dialog).findByLabelText("Code sent to [email protected]"), "123456");
await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" }));
}
beforeEach(() => {
for (const fn of Object.values(mocks)) if (typeof fn === "function") fn.mockReset();
mocks.identity = identity();
mocks.listMySessions.mockResolvedValue([]);
mocks.reauthEmailStart.mockResolvedValue({ sent: true, expires_at: "2026-09-25T10:10:00Z" });
mocks.reauthEmailVerify.mockResolvedValue({ ok: true, until: "2026-09-25T10:05:00Z" });
mocks.reauthPasskeyFinish.mockResolvedValue({ ok: true, until: "2026-09-25T10:05:00Z" });
Object.defineProperty(navigator, "credentials", {
value: { get: mocks.credentialsGet, create: mocks.credentialsCreate },
configurable: true,
});
});
describe("Account re-authentication", () => {
it("asks for an email code before deleting a passkey, then deletes it", async () => {
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
renderAccount();
await askToDeleteLaptop();
const dialog = await checkDialog();
expect(within(dialog).queryByRole("button", { name: "Use a passkey" })).toBeNull();
await proveByEmail(dialog);
expect(mocks.reauthEmailStart).toHaveBeenCalledTimes(1);
expect(mocks.reauthEmailVerify).toHaveBeenCalledWith("123456");
await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2));
expect(mocks.passkeyDelete).toHaveBeenLastCalledWith("pk-1");
await waitFor(() => expect(screen.queryByRole("button", { name: "Delete passkey “Laptop”" })).toBeNull());
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
it("closing the check keeps the passkey and the delete dialog, with no error", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
mocks.passkeyDelete.mockRejectedValue(refused);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
renderAccount();
await askToDeleteLaptop();
const dialog = await checkDialog();
await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Confirm it's you" })).toBeNull());
const confirmDelete = screen.getByRole("dialog", { name: "Delete this passkey?" });
expect(within(confirmDelete).queryByRole("alert")).toBeNull();
expect(mocks.passkeyDelete).toHaveBeenCalledTimes(1);
expect(mocks.passkeyList).toHaveBeenCalledTimes(1);
});
it("a wrong code keeps the check open with the reason and changes nothing", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
mocks.passkeyDelete.mockRejectedValue(refused);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
mocks.reauthEmailVerify.mockRejectedValue({ status: 400, code: "invalid_code", message: "raw" });
renderAccount();
await askToDeleteLaptop();
const dialog = await checkDialog();
await proveByEmail(dialog);
const alert = await within(dialog).findByRole("alert");
expect(alert.textContent).toBe("That code is invalid or expired — request a fresh one and try again.");
expect(mocks.passkeyDelete).toHaveBeenCalledTimes(1);
});
it("proves with a passkey from the envelope the begin returns", async () => {
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["passkey", "email"] });
mocks.reauthPasskeyBegin.mockResolvedValue({
publicKey: { challenge: "Y2hhbGxlbmdl", allowCredentials: [{ type: "public-key", id: "cGstMQ" }] },
});
mocks.credentialsGet.mockResolvedValue({
id: "cred-1",
rawId: bytes("raw"),
type: "public-key",
response: {
clientDataJSON: bytes("cd"),
authenticatorData: bytes("ad"),
signature: bytes("sig"),
userHandle: null,
},
});
renderAccount();
await askToDeleteLaptop();
const dialog = await checkDialog();
// Both factors are offered, the passkey first.
const buttons = within(dialog).getAllByRole("button").map((b) => b.textContent);
expect(buttons.indexOf("Use a passkey")).toBeLessThan(buttons.indexOf("Email a code to [email protected]"));
await userEvent.click(within(dialog).getByRole("button", { name: "Use a passkey" }));
await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2));
const publicKey = mocks.credentialsGet.mock.calls[0][0].publicKey;
expect(new TextDecoder().decode(publicKey.challenge)).toBe("challenge");
expect(new TextDecoder().decode(publicKey.allowCredentials[0].id)).toBe("pk-1");
expect(mocks.reauthPasskeyFinish).toHaveBeenCalledWith({
id: "cred-1",
rawId: "cmF3",
type: "public-key",
response: { clientDataJSON: "Y2Q", authenticatorData: "YWQ", signature: "c2ln", userHandle: null },
});
expect(mocks.reauthEmailStart).not.toHaveBeenCalled();
});
it("offers operators a fresh sign-in instead of an email code", async () => {
mocks.identity = identity("admin");
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
mocks.passkeyDelete.mockRejectedValue(refused);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["sign_in"] });
mocks.logout.mockResolvedValue(undefined);
renderAccount();
await askToDeleteLaptop();
const dialog = await checkDialog();
expect(
within(dialog).getByText("Operator accounts can also sign out and sign in again. A fresh sign-in counts for 5 minutes."),
).toBeTruthy();
expect(within(dialog).queryByRole("button", { name: /Email a code/ })).toBeNull();
await userEvent.click(within(dialog).getByRole("button", { name: "Sign out and sign in again" }));
expect(mocks.logout).toHaveBeenCalledTimes(1);
await waitFor(() => expect(mocks.refresh).toHaveBeenCalled());
});
it("goes straight on when the session was proven meanwhile", async () => {
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined);
mocks.reauthStatus.mockResolvedValue({ needed: false, until: "2026-09-25T10:05:00Z", factors: ["email"] });
renderAccount();
await askToDeleteLaptop();
await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2));
expect(mocks.reauthEmailStart).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("after the check, adding a passkey waits for Continue instead of starting the browser prompt", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
mocks.passkeyRegisterBegin.mockRejectedValue(refused);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
renderAccount();
await userEvent.click(await screen.findByRole("button", { name: "Add Passkey" }));
const register = screen.getByRole("dialog", { name: "Add Passkey" });
await userEvent.type(within(register).getByLabelText("Device Nickname"), "Phone");
await userEvent.click(within(register).getByRole("button", { name: "Continue" }));
await proveByEmail(await checkDialog());
const status = await within(screen.getByRole("dialog", { name: "Add Passkey" })).findByRole("status");
expect(status.textContent).toBe("Confirmed. Press Continue to add the passkey.");
expect(mocks.passkeyRegisterBegin).toHaveBeenCalledTimes(1);
expect(mocks.credentialsCreate).not.toHaveBeenCalled();
expect(within(screen.getByRole("dialog", { name: "Add Passkey" })).getByRole("button", { name: "Continue" })).toHaveProperty(
"disabled",
false,
);
});
it("changes a verified email after the check and refreshes the devices it signs out", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [] });
mocks.emailStart.mockRejectedValueOnce(refused).mockResolvedValue({ sent: true, expires_at: "2026-09-25T10:10:00Z" });
mocks.emailVerify.mockResolvedValue({ verified: true, email: "[email protected]" });
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
renderAccount();
await userEvent.click(await screen.findByRole("button", { name: "Change email" }));
expect(
screen.getByText(
"Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out.",
),
).toBeTruthy();
const address = screen.getByPlaceholderText("[email protected]");
expect((address as HTMLInputElement).value).toBe("");
await userEvent.type(address, "[email protected]");
await userEvent.click(screen.getByRole("button", { name: "Send Code" }));
await proveByEmail(await checkDialog());
await waitFor(() => expect(mocks.emailStart).toHaveBeenCalledTimes(2));
expect(mocks.emailStart).toHaveBeenLastCalledWith("[email protected]");
expect(await screen.findByText(/Verification code sent\./)).toBeTruthy();
await userEvent.type(screen.getByPlaceholderText("6-digit code"), "654321");
await userEvent.click(screen.getByRole("button", { name: "Verify" }));
expect(mocks.emailVerify).toHaveBeenCalledWith("654321");
await waitFor(() => expect(mocks.listMySessions).toHaveBeenCalledTimes(2));
expect(mocks.refresh).toHaveBeenCalled();
});
it("cancelling a change of email returns to the verified address", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [] });
renderAccount();
await userEvent.click(await screen.findByRole("button", { name: "Change email" }));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByText("[email protected]")).toBeTruthy();
expect(screen.queryByPlaceholderText("[email protected]")).toBeNull();
expect(mocks.emailStart).not.toHaveBeenCalled();
});
});
+4 -65
View File
@@ -9,9 +9,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
import { loginReturnPath } from "@/lib/auth";
import { api, clientError, humanizeError } from "@/lib/api";
import { api, humanizeError } from "@/lib/api";
import { loadConfig } from "@/lib/config";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import { requestAssertion } from "@/lib/passkey";
import { InlineError } from "@/components/MessageLine";
import { formatCountdown, opLoginDeadline, useOpLoginPoll } from "@/lib/opLoginPoll";
@@ -143,41 +143,10 @@ export function Login() {
const identifier = email.trim();
try {
let assertion: any;
if (!identifier) {
// Discoverable (Usernameless) passkey login
const options = await api.authPasskeyDiscoverableBegin();
const publicKey: PublicKeyCredentialRequestOptions = {
...options.publicKey,
challenge: base64urlToBytes(options.publicKey.challenge),
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential;
if (!credential) {
throw clientError("passkey_no_credential");
}
const response = credential.response as AuthenticatorAssertionResponse;
assertion = {
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
authenticatorData: bytesToBase64url(response.authenticatorData),
signature: bytesToBase64url(response.signature),
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
},
};
await api.authPasskeyDiscoverableFinish(options.login_id, assertion);
await api.authPasskeyDiscoverableFinish(options.login_id, await requestAssertion(options.publicKey));
} else {
// Email-first passkey login
if (!identifier.includes("@")) {
@@ -185,37 +154,7 @@ export function Login() {
}
const options = await api.authPasskeyLoginBegin(identifier);
const publicKey: PublicKeyCredentialRequestOptions = {
...options,
challenge: base64urlToBytes(options.challenge),
allowCredentials: options.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential;
if (!credential) {
throw clientError("passkey_no_credential");
}
const response = credential.response as AuthenticatorAssertionResponse;
assertion = {
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
authenticatorData: bytesToBase64url(response.authenticatorData),
signature: bytesToBase64url(response.signature),
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
},
};
await api.authPasskeyLoginFinish(identifier, assertion);
await api.authPasskeyLoginFinish(identifier, await requestAssertion(options));
}
await refresh();