eleven / messenger Public

home: confirm-it's-you takes any door + the proof pocket survives round trips (#1258)

Adding a second sign-in method kept re-demanding a passkey ceremony even
though the server accepts a fresh 5-minute proof from ANY door
(stateless.go's ceremonyUser). Three client-side causes, three fixes:

- The ceremony-proof pocket now mirrors into sessionStorage, so it
  survives the full-page navigation every Apple/OIDC add takes (and a
  reload). Sign-out drops it.
- The add round-trip return legs re-pocket the proof the server just
  accepted (or drop it on a lapsed-proof refusal), so chained adds inside
  the window skip the confirm step entirely.
- The confirm step offers EVERY door the account actually has — passkey,
  Apple, Google, Microsoft, GitHub, email — last-used first, instead of
  the passkey > email > Apple ladder that never offered the OIDC doors.
  Confirm-with-OIDC rides the same marker-routed round trip as Apple
  (startOidcSignIn now honors methodsReturn; the boot legs pass it on).

Also fixes the stale finishOidcLink comment (the OIDC combine offer IS
wired) and hides dark doors from the confirm rungs.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 04c46989b16ed3b9a538f97afda8a3033140a3c2 parent 8c1544a
3 files changed, +117 −28
  • changelog/2026-07-22-confirm-any-door.md +5 −0
  • home/web/pages/index.html +8 −4
  • home/web/static/home.js +104 −24
diff --git a/changelog/2026-07-22-confirm-any-door.md b/changelog/2026-07-22-confirm-any-door.md
new file mode 100644
index 0000000..d29021a
--- /dev/null
+++ b/changelog/2026-07-22-confirm-any-door.md
@@ -0,0 +1,5 @@
+🤖 Changing your sign-in options got smoother. The "confirm it's you" check
+now offers every way you can sign in — Apple, Google, Microsoft, GitHub,
+email, or a passkey — with your usual way first, instead of insisting on a
+passkey. And one confirmation now covers everything you do in the next few
+minutes: add two sign-in methods back to back and you're only asked once.
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 81544c0..0659713 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -939,16 +939,20 @@
<!-- Confirm it's you — its own serial step, FIRST whenever the proof
pocket is empty: list → here → the add step (or the removal) → list.
- home.js offers whatever the account can actually run: the passkey
- ceremony, an email code re-verify, else a fresh Apple sign-in that
- lands straight back on the list. -->
+ home.js offers EVERY door the account can actually run — the server
+ takes a fresh proof from any of them — last-used first (the email
+ button unfolds into the code mini-form). -->
<div id="methods-confirm" class="hidden screen">
<button id="methods-confirm-back" class="icon-back" type="button" aria-label="Back to sign-in options"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<h2 class="create-title" id="methods-confirm-title" tabindex="-1">Confirm it&rsquo;s you</h2>
<p class="create-sub" id="methods-confirm-why"></p>
- <div class="actions">
+ <div class="actions" id="methods-confirm-actions">
<button id="methods-confirm-passkey" class="secondary hidden" type="button">Confirm with your passkey</button>
<button id="methods-confirm-apple" class="apple-btn hidden" type="button"><svg viewBox="0 0 814 1000" width="14" height="17" fill="currentColor" aria-hidden="true"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>Confirm with Apple</button>
+ <button id="methods-confirm-google" class="google-btn hidden" type="button"><svg viewBox="0 0 48 48" width="16" height="16" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>Confirm with Google</button>
+ <button id="methods-confirm-microsoft" class="ms-btn hidden" type="button"><svg viewBox="0 0 21 21" width="15" height="15" aria-hidden="true"><rect x="1" y="1" width="9" height="9" fill="#F25022"/><rect x="11" y="1" width="9" height="9" fill="#7FBA00"/><rect x="1" y="11" width="9" height="9" fill="#00A4EF"/><rect x="11" y="11" width="9" height="9" fill="#FFB900"/></svg>Confirm with Microsoft</button>
+ <button id="methods-confirm-github" class="gh-btn hidden" type="button"><svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>Confirm with GitHub</button>
+ <button id="methods-confirm-email" class="secondary hidden" type="button">Email me a code</button>
</div>
<div id="methods-cemail" class="email-mini hidden">
<form id="methods-cemail-form">
diff --git a/home/web/static/home.js b/home/web/static/home.js
index d97f805..bbfe0e8 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -570,6 +570,7 @@ async function finishSiwaLink(idToken, markRaw) {
let outcome, conflict = false;
if (!mark || !mark.proof || !(mark.expires > Date.now())) {
// The proof's 5-minute window lapsed during the trip — re-prompt.
+ dropProof(); // the pocket mirrors the same lapsed proof
outcome = { msg: "That took a little too long — confirm it's you again, then tap Add Apple.", kind: "error" };
} else {
try {
@@ -579,6 +580,9 @@ async function finishSiwaLink(idToken, markRaw) {
body: JSON.stringify({ identity_token: idToken, link: true }),
});
if (res.ok) {
+ // The proof just verified — good for the window's remainder, so a
+ // chained second add skips the confirm step.
+ noteProof(mark.proof, mark.expires);
outcome = { msg: "Apple is now a way into your account.", kind: "ok" };
} else {
// The server's refusals verbatim: a lapsed proof (401 — the line
@@ -587,6 +591,9 @@ async function finishSiwaLink(idToken, markRaw) {
// the status, never off message text.
const msg = ((await res.text().catch(() => "")) || "").trim();
conflict = res.status === 409;
+ // A 409 still verified the proof (only the identity clashed); any
+ // other refusal is almost always the lapsed proof — re-confirm next.
+ if (conflict) noteProof(mark.proof, mark.expires); else dropProof();
outcome = { msg: msg || "Couldn't add Apple — try again.", kind: "error" };
}
} catch {
@@ -683,7 +690,8 @@ function startOidcSignIn(provider, e, opts = {}) {
try {
if (opts.linkProof) sessionStorage.setItem("home_siwa_link", JSON.stringify(opts.linkProof));
else sessionStorage.removeItem("home_siwa_link");
- sessionStorage.removeItem("home_methods_return");
+ if (opts.methodsReturn) sessionStorage.setItem("home_methods_return", "1");
+ else sessionStorage.removeItem("home_methods_return");
if (opts.intent) sessionStorage.setItem("home_methods_intent", opts.intent);
else sessionStorage.removeItem("home_methods_intent");
} catch { /* no storage — the return leg lands as a plain sign-in */ }
@@ -736,15 +744,16 @@ async function finishOidc(rec, cred, backToMethods = false, intentStr = "") {
}
// finishOidcLink — finishSiwaLink's twin: the ADD round trip (link:true +
-// the stashed proof). The 409's combine offer is deliberately NOT wired for
-// OIDC providers yet — the combine prove step speaks Apple and email today;
-// the refusal shows verbatim and combining goes via those doors.
+// the stashed proof). The 409 grows the same combine offer as Apple's leg —
+// the prove step sends the person back through the clashing provider's own
+// door (combine-oidc-go → the merge-<provider> intent).
async function finishOidcLink(rec, cred, markRaw) {
const p = OIDC_WEB[rec.provider];
let mark = null;
try { mark = JSON.parse(markRaw); } catch { /* mangled marker — treat as lapsed */ }
let outcome, conflict = false;
if (!mark || !mark.proof || !(mark.expires > Date.now())) {
+ dropProof(); // the pocket mirrors the same lapsed proof
outcome = { msg: "That took a little too long — confirm it's you again, then tap Add " + p.display + ".", kind: "error" };
} else {
try {
@@ -754,10 +763,16 @@ async function finishOidcLink(rec, cred, markRaw) {
body: JSON.stringify({ ...oidcPayload(p, cred, rec), link: true }),
});
if (res.ok) {
+ // The proof just verified — good for the window's remainder, so a
+ // chained second add skips the confirm step.
+ noteProof(mark.proof, mark.expires);
outcome = { msg: p.display + " is now a way into your account.", kind: "ok" };
} else {
const msg = ((await res.text().catch(() => "")) || "").trim();
conflict = res.status === 409;
+ // A 409 still verified the proof (only the identity clashed); any
+ // other refusal is almost always the lapsed proof — re-confirm next.
+ if (conflict) noteProof(mark.proof, mark.expires); else dropProof();
outcome = { msg: msg || "Couldn't add " + p.display + " — try again.", kind: "error" };
}
} catch {
@@ -1104,7 +1119,7 @@ function signOut() {
combine = null;
anchors = null;
armedRemove = "";
- ceremonyProof = null; // proofs are per-person, never per-browser
+ dropProof(); // proofs are per-person, never per-browser
archiving = null;
clearUndo();
$("spaces").textContent = "";
@@ -1172,14 +1187,27 @@ function accountMenuKey(e) {
// ceremonyWindow); every ceremony that mints one drops it here, and
// freshProof answers only while a client-side mirror of that window still
// has margin, so a call never leaves with a proof that will be refused on
-// arrival. In-memory only, like the passkey path's transient use.
+// arrival. The pocket mirrors into sessionStorage — same tab, like the
+// round-trip markers — because adding Apple or an OIDC provider IS a
+// full-page navigation: without the mirror, every return leg came back to
+// an empty pocket and the next change re-demanded a ceremony inside the
+// same 5-minute window. Sign-out drops it (proofs are per-person).
const PROOF_TTL_MS = 5 * 60 * 1000;
const PROOF_MARGIN_MS = 10 * 1000;
let ceremonyProof = null; // { proof, expires } | null
-function noteProof(proof) {
- if (proof) ceremonyProof = { proof, expires: Date.now() + PROOF_TTL_MS };
+function noteProof(proof, expires) {
+ if (!proof) return;
+ ceremonyProof = { proof, expires: expires || Date.now() + PROOF_TTL_MS };
+ try { sessionStorage.setItem("home_proof", JSON.stringify(ceremonyProof)); } catch { /* memory still holds it */ }
+}
+function dropProof() {
+ ceremonyProof = null;
+ try { sessionStorage.removeItem("home_proof"); } catch { /* nothing stored */ }
}
function freshProof() {
+ if (!ceremonyProof) {
+ try { ceremonyProof = JSON.parse(sessionStorage.getItem("home_proof") || "null"); } catch { /* mangled — an empty pocket */ }
+ }
return ceremonyProof && ceremonyProof.expires - PROOF_MARGIN_MS > Date.now()
? ceremonyProof.proof : null;
}
@@ -1348,6 +1376,10 @@ function resetMethodsSteps() {
// The confirm step.
$("methods-confirm-passkey").classList.add("hidden");
$("methods-confirm-apple").classList.add("hidden");
+ $("methods-confirm-google").classList.add("hidden");
+ $("methods-confirm-microsoft").classList.add("hidden");
+ $("methods-confirm-github").classList.add("hidden");
+ $("methods-confirm-email").classList.add("hidden");
$("methods-cemail").classList.add("hidden");
$("methods-cemail-form").classList.remove("hidden");
$("methods-cemail-code-form").classList.add("hidden");
@@ -1720,33 +1752,65 @@ function resumeIntent() {
// ---- the confirm step -----------------------------------------------------------
+// The confirm rungs, in fallback order; last-used (the #1112 marker) is
+// promoted to the front. Each rung answers "can this account actually run
+// it" from the anchors listing — the server takes a fresh proof from ANY
+// door equally (stateless.go's ceremonyUser), so the step offers every one
+// the account has, never passkey-or-bust. Apple doubles as the fallback for
+// an account whose listing hasn't loaded (the pre-anchors shape).
+const CONFIRM_RUNGS = ["passkey", "apple", "google", "microsoft", "github", "email"];
+function confirmRungAvailable(rung) {
+ const kinds = new Set((anchors || []).map((x) => x.kind));
+ if (rung === "passkey") return !!window.PublicKeyCredential && kinds.has("passkey");
+ if (rung === "apple") return DOORS.apple && kinds.has("siwa");
+ // google | microsoft | github | email — anchor kinds verbatim, gated by the
+ // door being LIVE on this build (a dark door can't run its ceremony).
+ return DOORS[rung] && kinds.has(rung);
+}
+
// openMethodsConfirmStep is the serial "it's really you" screen, FIRST
-// whenever an action needs a proof the pocket doesn't hold. It offers
-// whatever the account can actually run, best first: the passkey ceremony,
-// an email code re-verify (sign-in mode — the answer's proof is all we
-// keep), else a fresh Apple sign-in that lands straight back on the list.
+// whenever an action needs a proof the pocket doesn't hold. Every door the
+// account has shows, last-used first; the email rung unfolds into its code
+// mini-form (immediately when it's the only rung — no pointless extra tap).
function openMethodsConfirmStep(note) {
resetMethodsSteps(); // fresh forms; the parked change rides pendingIntent
showMethodsScreen("methods-confirm");
- const havePasskey = !!window.PublicKeyCredential && (anchors || []).some((x) => x.kind === "passkey");
- const haveEmail = (anchors || []).some((x) => x.kind === "email");
- $("methods-confirm-passkey").classList.toggle("hidden", !havePasskey);
- $("methods-confirm-apple").classList.toggle("hidden", havePasskey || haveEmail);
+ let rungs = CONFIRM_RUNGS.filter(confirmRungAvailable);
+ if (!rungs.length) rungs = ["apple"];
+ let last = "";
+ try { last = localStorage.getItem("home_last_signin") || ""; } catch { /* fallback order stands */ }
+ if (rungs.includes(last)) rungs = [last, ...rungs.filter((r) => r !== last)];
+ const btns = {
+ passkey: $("methods-confirm-passkey"), apple: $("methods-confirm-apple"),
+ google: $("methods-confirm-google"), microsoft: $("methods-confirm-microsoft"),
+ github: $("methods-confirm-github"), email: $("methods-confirm-email"),
+ };
+ for (const [rung, btn] of Object.entries(btns)) btn.classList.toggle("hidden", !rungs.includes(rung));
+ // DOM order is focus order — append MOVES the wired nodes into rung order.
+ for (const rung of rungs) $("methods-confirm-actions").append(btns[rung]);
const why = $("methods-confirm-why");
if (note) confirmStatus(note, "error");
- if (havePasskey) {
- why.textContent = "Changing your sign-in options needs a quick check — confirm with your passkey and you'll carry straight on.";
- $("methods-confirm-passkey").focus();
- } else if (haveEmail) {
+ if (rungs.length === 1 && rungs[0] === "email") {
+ btns.email.classList.add("hidden"); // the one rung — straight to its form
why.textContent = "Changing your sign-in options needs a quick check — enter the email address you sign in with and we'll send a code.";
$("methods-cemail").classList.remove("hidden");
$("methods-cemail-addr").focus();
} else {
- why.textContent = "Changing your sign-in options needs a quick check — sign in with Apple again and you'll come straight back here.";
- $("methods-confirm-apple").focus();
+ why.textContent = rungs.length === 1
+ ? "Changing your sign-in options needs a quick check — confirm it's you and you'll carry straight on."
+ : "Changing your sign-in options needs a quick check — confirm it's you with any of these and you'll carry straight on.";
+ btns[rungs[0]].focus();
}
}
+// The email rung's tap unfolds the code mini-form in place — the other
+// rungs stay, so a mind-change needs no back-and-forth.
+function confirmMethodsEmail() {
+ $("methods-confirm-email").classList.add("hidden");
+ $("methods-cemail").classList.remove("hidden");
+ $("methods-cemail-addr").focus();
+}
+
async function confirmMethodsPasskey() {
const btn = $("methods-confirm-passkey");
btn.disabled = true;
@@ -1776,6 +1840,16 @@ function confirmMethodsApple(e) {
startAppleSignIn(e, { methodsReturn: true, intent });
}
+// confirmMethodsOidc — the Apple confirm through an OIDC web door: the same
+// marker-routed round trip (finishOidc pockets the proof, methodsReturn
+// lands back on the step family, the intent resumes). select_account so a
+// person with several provider accounts can pick the linked one.
+function confirmMethodsOidc(provider, e) {
+ const intent = intentToken(pendingIntent);
+ pendingIntent = null; // payloads can't survive the navigation; the token rides
+ startOidcSignIn(provider, e, { methodsReturn: true, intent, select: true });
+}
+
// The confirm step's email re-verify: the same two-step form as the door,
// but the answer's TOKEN is deliberately dropped — the proof is the point,
// and this tab keeps the session it already has.
@@ -3793,6 +3867,7 @@ if (!siwaBooting) {
try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ }
sessionStorage.removeItem("home_oidc_state");
const linkMark = sessionStorage.getItem("home_siwa_link");
+ const methodsReturn = !!sessionStorage.getItem("home_methods_return");
const methodsIntent = sessionStorage.getItem("home_methods_intent") || "";
sessionStorage.removeItem("home_siwa_link");
sessionStorage.removeItem("home_methods_return");
@@ -3800,7 +3875,7 @@ if (!siwaBooting) {
if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) {
if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark);
else if (methodsIntent === "merge-" + rec.provider) finishOidcMergeProve(rec, decodeURIComponent(m[1]));
- else finishOidc(rec, decodeURIComponent(m[1]), false, methodsIntent);
+ else finishOidc(rec, decodeURIComponent(m[1]), methodsReturn, methodsIntent);
} else {
finishHomeLoader({ immediate: true });
setStatus("That sign-in didn’t start here — try again.", "error");
@@ -3821,6 +3896,7 @@ if (!siwaBooting && !oidcBooting) {
try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ }
sessionStorage.removeItem("home_oidc_state");
const linkMark = sessionStorage.getItem("home_siwa_link");
+ const methodsReturn = !!sessionStorage.getItem("home_methods_return");
const methodsIntent = sessionStorage.getItem("home_methods_intent") || "";
sessionStorage.removeItem("home_siwa_link");
sessionStorage.removeItem("home_methods_return");
@@ -3828,7 +3904,7 @@ if (!siwaBooting && !oidcBooting) {
if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) {
if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark);
else if (methodsIntent === "merge-" + rec.provider) finishOidcMergeProve(rec, decodeURIComponent(m[1]));
- else finishOidc(rec, decodeURIComponent(m[1]), false, methodsIntent);
+ else finishOidc(rec, decodeURIComponent(m[1]), methodsReturn, methodsIntent);
} else {
finishHomeLoader({ immediate: true });
setStatus("That sign-in didn’t start here — try again.", "error");
@@ -3885,6 +3961,10 @@ $("methods-confirm-back").addEventListener("click", () => {
});
$("methods-confirm-passkey").addEventListener("click", confirmMethodsPasskey);
$("methods-confirm-apple").addEventListener("click", confirmMethodsApple);
+$("methods-confirm-google").addEventListener("click", (e) => confirmMethodsOidc("google", e));
+$("methods-confirm-microsoft").addEventListener("click", (e) => confirmMethodsOidc("microsoft", e));
+$("methods-confirm-github").addEventListener("click", (e) => confirmMethodsOidc("github", e));
+$("methods-confirm-email").addEventListener("click", confirmMethodsEmail);
$("methods-cemail-form").addEventListener("submit", cemailSend);
$("methods-cemail-code-form").addEventListener("submit", cemailVerify);
$("methods-email-back").addEventListener("click", () => backToMethodsList("", "", { reload: false }));