eleven / messenger Public

home: light the Google door (OIDC web flow) (#1153)

The #1127 server verifier was live but dark β€” this is the client slice:
an OIDC_WEB provider table generalizing the Apple journey (shared
pending/cover choreography, the provider-blind /oidc/return trampoline,
a required nonce that must round-trip), the #oidc= return leg, the
Google button on the card + all-ways step, METHOD_KINDS/add-google, and
the pre-paint gate extended so the return leg never flashes. The 409
combine offer stays Apple/email-only for now (the prove step doesn't
speak OIDC yet β€” follow-up).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 155592dc28f03c0e41aa0432006e9084b71c4d08 parent 5d5f5d5
3 files changed, +193 −11
  • changelog/2026-07-19-google-signin.md +6 −0
  • home/web/pages/index.html +4 −4
  • home/web/static/home.js +183 −7
diff --git a/changelog/2026-07-19-google-signin.md b/changelog/2026-07-19-google-signin.md
new file mode 100644
index 0000000..cd4404f
--- /dev/null
+++ b/changelog/2026-07-19-google-signin.md
@@ -0,0 +1,6 @@
+πŸ€– You can now sign in to your Eleven home with Google. It works like the
+Apple door: Google confirms who you are and Eleven stores only a scrambled
+fingerprint of that β€” never your name, email, or anything Google knows
+about you. Find it under "Other ways to sign in" (or front and centre on
+Android and ChromeOS), and add it to an existing account from Sign-in
+options so more than one door opens your spaces.
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index a8e4f9d..8ada238 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -740,7 +740,8 @@
// (last-used/platform), so the default markup must not get a frame.
// home.js lifts this at the end of module init on every path.
document.documentElement.classList.add("composing");
- if (location.hash.indexOf("#siwa=") === 0 || location.hash.indexOf("#email=") === 0)
+ if (location.hash.indexOf("#siwa=") === 0 || location.hash.indexOf("#oidc=") === 0 ||
+ location.hash.indexOf("#email=") === 0)
document.documentElement.classList.add("booting", "siwa-wait");
else if ((sessionStorage.getItem("home_token") && sessionStorage.getItem("home_dek")) ||
+localStorage.getItem("home_session_expires") > Date.now())
@@ -789,6 +790,7 @@
<div id="signed-out">
<div class="actions" id="main-doors">
<button id="apple-signin" class="apple-btn" 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>Continue with Apple</button>
+ <button id="google-signin" class="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>Continue with Google</button>
<button id="email-signin" class="secondary" type="button">Continue with email</button>
<button id="signin" class="passkey-link" type="button">Sign in with a passkey</button>
<button id="other-ways" class="passkey-link" type="button">Other ways to sign in</button>
@@ -842,9 +844,7 @@
<h2 class="create-title">Other ways to sign in</h2>
<div class="actions">
<button id="otherways-apple" class="apple-btn" 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>Continue with Apple</button>
- <!-- Google's door joins here when the provider ships (#1112): flip
- DOORS.google in home.js and add
- <button id="otherways-google" class="secondary" type="button">Continue with Google</button> -->
+ <button id="otherways-google" class="secondary" type="button">Continue with Google</button>
<!-- Microsoft's door, same shape: DOORS.microsoft plus
<button id="otherways-microsoft" class="secondary" type="button">Continue with Microsoft</button> -->
<!-- GitHub's door ("for the nerds"), all-ways only, never a platform
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 6d33b00..d8d7395 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -217,8 +217,8 @@ async function saveVault() {
// door mid-code-round-trip only confuses. The passkey controls additionally
// stay off in a browser with no WebAuthn (the Apple/email doors still work).
function setSignedOutBusy(busy) {
- for (const id of ["apple-signin", "email-signin", "email-send", "email-verify",
- "other-ways", "otherways-apple", "otherways-email"]) $(id).disabled = busy;
+ for (const id of ["apple-signin", "google-signin", "email-signin", "email-send", "email-verify",
+ "other-ways", "otherways-apple", "otherways-google", "otherways-email"]) $(id).disabled = busy;
for (const id of ["signin", "create-passkey", "unlock-passkey", "otherways-passkey"]) {
$(id).disabled = busy || !window.PublicKeyCredential;
}
@@ -251,7 +251,7 @@ const DOORS = {
apple: true,
email: true,
passkey: true,
- google: false, // Android/ChromeOS's platform door, once it ships
+ google: true, // Android/ChromeOS's platform door (client id below)
microsoft: false, // Windows's platform door, once it ships
github: false, // all-ways only ("for the nerds") β€” never a platform default
};
@@ -292,7 +292,9 @@ function primaryDoor() {
function composeCard() {
const primary = primaryDoor();
const apple = $("apple-signin"), email = $("email-signin"), passkey = $("signin");
+ const google = $("google-signin");
apple.classList.toggle("hidden", primary !== "apple");
+ google.classList.toggle("hidden", primary !== "google");
// Promotions restyle in place: email sheds .secondary when it leads, and
// the passkey door trades its grey link for a proper primary button.
// Wholesale className is safe β€” these two never carry other classes.
@@ -300,7 +302,7 @@ function composeCard() {
passkey.className = primary === "passkey" ? "" : "passkey-link hidden";
// DOM order is focus order: the one door, then the link. append MOVES the
// existing (already-wired) nodes; hidden doors stay parked, unfocusable.
- $("main-doors").append({ apple, email, passkey }[primary], $("other-ways"));
+ $("main-doors").append({ apple, email, passkey, google }[primary], $("other-ways"));
}
async function signIn() {
@@ -599,6 +601,134 @@ async function finishSiwaLink(idToken, markRaw) {
finishHomeLoader({ immediate: true });
}
+// ---- the OIDC web doors (Google today; Microsoft slots in) ----------------------
+
+// The Apple journey, generalized per oidc.go's provider table: the page
+// mints the state AND a nonce (Google refuses a bare id_token request; the
+// verifier checks it round-trips), departs for the provider's authorize
+// endpoint, and lands back through the provider-blind /oidc/return
+// trampoline as #oidc=<id_token>&state=web.<r>. Which door was opened rides
+// sessionStorage (home_oidc_state), never the fragment. The pending/cover
+// choreography and the methods round-trip markers are the Apple machinery,
+// shared verbatim. Like Apple's client id, a provider's is a public
+// identifier and lives here in code.
+const GOOGLE_CLIENT_ID =
+ "981276018948-b4cr3ctqi6cr9e8rs2fmqouip1hmd9iq.apps.googleusercontent.com";
+const OIDC_WEB = {
+ google: {
+ display: "Google",
+ verify: "/api/oidc/google/verify",
+ authorize: (st, nonce) => "https://accounts.google.com/o/oauth2/v2/auth" +
+ "?client_id=" + encodeURIComponent(GOOGLE_CLIENT_ID) +
+ "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") +
+ "&response_type=id_token" +
+ "&response_mode=form_post" +
+ "&scope=openid" +
+ "&nonce=" + encodeURIComponent(nonce) +
+ "&state=" + encodeURIComponent(st),
+ },
+};
+
+function startOidcSignIn(provider, e, opts = {}) {
+ // The same staged wait as the Apple tap (see startAppleSignIn): button
+ // spins, stage clears, the smoke cover takes over by the time the
+ // provider's page is up; any input releases a cancelled arrangement.
+ const btn = e.currentTarget;
+ applePendingBtn = btn;
+ btn.disabled = true;
+ btn.classList.add("loading");
+ document.querySelector("main.card").classList.add("apple-pending");
+ applePendingTimer = setTimeout(engageSiwaCover, 1600);
+ window.addEventListener("pointerdown", releaseApplePending, true);
+ window.addEventListener("keydown", releaseApplePending, true);
+ const hex = () => Array.from(crypto.getRandomValues(new Uint8Array(16)),
+ (b) => b.toString(16).padStart(2, "0")).join("");
+ const st = "web." + hex();
+ const nonce = hex();
+ 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.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 */ }
+ sessionStorage.setItem("home_oidc_state", JSON.stringify({ state: st, provider, nonce }));
+ location.href = OIDC_WEB[provider].authorize(st, nonce);
+}
+
+// finishOidc β€” finishSiwa's twin against /api/oidc/{provider}/verify: the
+// same one-verifier answer (token + proof), the same anchor-session landing
+// under the waiting face.
+async function finishOidc(rec, idToken, backToMethods = false, intentStr = "") {
+ const p = OIDC_WEB[rec.provider];
+ setStatus("Confirming it’s you…", "busy");
+ try {
+ const res = await apiJSON(p.verify, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ identity_token: idToken, nonce: rec.nonce }),
+ });
+ noteProof(res.proof);
+ markSignedIn(rec.provider);
+ persistSession(res.token, rec.provider);
+ await enterAnchorSession(res);
+ } catch {
+ if (backToMethods && state.token) {
+ await openMethods();
+ methodsStatus("That " + p.display + " sign-in couldn’t be confirmed β€” try again.", "error");
+ finishHomeLoader({ immediate: true });
+ return;
+ }
+ setStatus("That " + p.display + " sign-in couldn’t be confirmed β€” try again.", "error");
+ finishHomeLoader({ immediate: true });
+ return;
+ }
+ if (backToMethods) {
+ await openMethods();
+ const intent = parseIntentToken(intentStr);
+ if (intent && freshProof()) runIntent(intent, freshProof());
+ finishHomeLoader({ immediate: true });
+ return;
+ }
+}
+
+// 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.
+async function finishOidcLink(rec, idToken, markRaw) {
+ const p = OIDC_WEB[rec.provider];
+ let mark = null;
+ try { mark = JSON.parse(markRaw); } catch { /* mangled marker β€” treat as lapsed */ }
+ let outcome;
+ if (!mark || !mark.proof || !(mark.expires > Date.now())) {
+ outcome = { msg: "That took a little too long β€” confirm it's you again, then tap Add " + p.display + ".", kind: "error" };
+ } else {
+ try {
+ const res = await fetch(p.verify, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Ceremony-Proof": mark.proof },
+ body: JSON.stringify({ identity_token: idToken, nonce: rec.nonce, link: true }),
+ });
+ if (res.ok) {
+ outcome = { msg: p.display + " is now a way into your account.", kind: "ok" };
+ } else {
+ const msg = ((await res.text().catch(() => "")) || "").trim();
+ outcome = { msg: msg || "Couldn't add " + p.display + " β€” try again.", kind: "error" };
+ }
+ } catch {
+ outcome = { msg: "Couldn't add " + p.display + " β€” try again.", kind: "error" };
+ }
+ }
+ if (await resumeAfterLinkReturn()) {
+ await openMethods();
+ methodsStatus(outcome.msg, outcome.kind);
+ } else {
+ setStatus(outcome.msg, outcome.kind === "ok" ? "" : "error");
+ }
+ finishHomeLoader({ immediate: true });
+}
+
// finishSiwaMergeProve is the merge-apple intent's return leg: the combine
// step sent the person to Apple to prove the OTHER account is theirs. The
// id_token verifies in SIGN-IN mode, but the answer is deliberately NOT
@@ -987,6 +1117,8 @@ const METHOD_KINDS = [
enabled: () => !!window.PublicKeyCredential, start: (e) => startAddPasskey(e) },
{ kind: "siwa", name: "Apple", first: "Add Apple",
enabled: () => DOORS.apple, start: (e) => startAddApple(e) },
+ { kind: "google", name: "Google", first: "Add Google",
+ enabled: () => DOORS.google, start: (e) => startAddGoogle(e) },
{ kind: "email", name: "Email", first: "Add email",
enabled: () => DOORS.email, start: (e) => startAddEmail(e) },
];
@@ -1053,7 +1185,7 @@ function intentToken(intent) {
return intent.kind; // add-email | add-passkey | add-apple
}
function parseIntentToken(s) {
- if (s === "add-email" || s === "add-passkey" || s === "add-apple") return { kind: s };
+ if (s === "add-email" || s === "add-passkey" || s === "add-apple" || s === "add-google") return { kind: s };
const m = /^remove:([^:]+):(.+)$/.exec(s || "");
return m ? { kind: "remove", anchorKind: m[1], id: m[2] } : null;
}
@@ -1065,6 +1197,7 @@ function runIntent(intent, proof) {
case "add-email": openMethodsEmailStep(); return;
case "add-passkey": openMethodsPasskeyStep(); return;
case "add-apple": startAddApple(null); return; // pocket is fresh β€” straight to Apple in link mode
+ case "add-google": startAddGoogle(null); return;
case "verify-email":
// In-memory only (never serialized): retry the exact typed code β€” it
// survived, link mode gates on the proof BEFORE redeeming (email.go).
@@ -1630,6 +1763,18 @@ function startAddApple(e) {
openMethodsConfirmStep();
}
+// startAddGoogle β€” the same ADD round trip through the Google door.
+function startAddGoogle(e) {
+ const src = e && e.currentTarget;
+ const go = (proof) => startOidcSignIn("google",
+ { currentTarget: src && src.isConnected ? src : document.createElement("button") },
+ { linkProof: { proof, expires: ceremonyProof ? ceremonyProof.expires : Date.now() + PROOF_TTL_MS } });
+ const p = freshProof();
+ if (p) { go(p); return; }
+ pendingIntent = { kind: "add-google" };
+ openMethodsConfirmStep();
+}
+
// startAddEmail / startAddPasskey open their serial card steps β€” confirm
// first when the pocket is empty (list β†’ confirm β†’ the add step β†’ list).
function startAddEmail() {
@@ -3451,6 +3596,7 @@ $("signin").addEventListener("click", () => signIn().catch(onSignInError));
$("create-passkey").addEventListener("click", () => createPasskey().catch(onCreatePasskeyError));
$("unlock-passkey").addEventListener("click", () => signIn().catch(onSignInError));
$("apple-signin").addEventListener("click", startAppleSignIn);
+$("google-signin").addEventListener("click", (e) => startOidcSignIn("google", e));
// The instant the Apple confirmation proceeds, this page navigates away β€”
// the waiting face covers whatever frames remain (bfcache return releases it).
window.addEventListener("pagehide", engageSiwaCover);
@@ -3473,6 +3619,7 @@ $("email-code-form").addEventListener("submit", verifyEmailCode);
$("other-ways").addEventListener("click", openOtherWays);
$("otherways-back").addEventListener("click", closeOtherWays);
$("otherways-apple").addEventListener("click", startAppleSignIn);
+$("otherways-google").addEventListener("click", (e) => startOidcSignIn("google", e));
$("otherways-email").addEventListener("click", openEmailDoor);
$("otherways-passkey").addEventListener("click", () => signIn().catch(onSignInError));
@@ -3511,6 +3658,35 @@ let siwaBooting = false; // claims the boot β€” the cold-open path below stands
}
}
+// The OIDC web round trips (Google today) land as #oidc=<id_token>&state=
+// (oidc.go's provider-blind trampoline). Which door was opened β€” and the
+// nonce the verifier must see round-trip β€” rides home_oidc_state, minted
+// beside the state before departure. Same one-shot marker consumption as
+// the #siwa= leg above.
+let oidcBooting = false; // claims the boot β€” the cold-open path below stands down
+if (!siwaBooting) {
+ const m = location.hash.match(/^#oidc=([^&]*)&state=([^&]*)$/);
+ if (m) {
+ oidcBooting = true;
+ history.replaceState(null, "", location.pathname + location.search);
+ let rec = null;
+ 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 methodsIntent = sessionStorage.getItem("home_methods_intent") || "";
+ sessionStorage.removeItem("home_siwa_link");
+ sessionStorage.removeItem("home_methods_return");
+ sessionStorage.removeItem("home_methods_intent");
+ if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) {
+ if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark);
+ else finishOidc(rec, decodeURIComponent(m[1]), false, methodsIntent);
+ } else {
+ finishHomeLoader({ immediate: true });
+ setStatus("That sign-in didn’t start here β€” try again.", "error");
+ }
+ }
+}
+
// The mail's magic link (email.go) lands here as #email=<address>&code=<6
// digits> β€” the same values the mail asks you to type, riding the fragment,
// which never reaches any server. It runs the ordinary /api/email/verify, so
@@ -3519,7 +3695,7 @@ let siwaBooting = false; // claims the boot β€” the cold-open path below stands
// hash clears before the verify, so a reload can't rerun the (single-use
// anyway) code.
let emailLinkBooting = false; // claims the boot β€” the cold-open path below stands down
-if (!siwaBooting && location.hash.indexOf("#email=") === 0) {
+if (!siwaBooting && !oidcBooting && location.hash.indexOf("#email=") === 0) {
emailLinkBooting = true;
// The pre-paint gate already put the waiting face up (html.siwa-wait) β€”
// the clean card never shows.
@@ -3649,7 +3825,7 @@ if (state.token && state.dek) {
} catch { /* token or DEK went stale; the button still works */ }
finally { finishHomeLoader(); }
})();
-} else if (!siwaBooting && !emailLinkBooting) {
+} else if (!siwaBooting && !oidcBooting && !emailLinkBooting) {
// The cold open (stay signed in, Option A): no tab session, but a live
// persisted token in localStorage. Adopt it and land exactly like an anchor
// sign-in β€” the sealed prompt when a blob exists, the empty list when not;