| 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; |