home: light the Microsoft door (#1160)
Same play as Google (#1153): OIDC_WEB entry against the common endpoint (any tenant + personal accounts; checkIssuer handles per-tenant iss), brand button (the four squares, white spec shared with .google-btn), the all-ways slot goes real, METHOD_KINDS row, and the add starter generalizes to startAddOidc(provider). Windows's platform default now resolves to a live door.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+43
−15
changelog/2026-07-19-microsoft-signin.md+5 −0home/web/pages/index.html+4 −4home/web/static/home.js+34 −11
diff --git a/changelog/2026-07-19-microsoft-signin.md b/changelog/2026-07-19-microsoft-signin.md| new file mode 100644 |
| index 0000000..45b264f |
| --- /dev/null |
| +++ b/changelog/2026-07-19-microsoft-signin.md |
| @@ -0,0 +1,5 @@ |
| +🤖 Microsoft joins the sign-in doors: your personal or work Microsoft |
| +account now signs you in to your Eleven home, exactly like the Apple and |
| +Google doors — Microsoft confirms who you are, Eleven keeps only a |
| +scrambled fingerprint, never your email or name. It's the front door on |
| +Windows, and lives under "Other ways to sign in" everywhere else. |
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index 9a1a558..79c1b6a 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -49,13 +49,13 @@ |
| /* Google's door, to their light-button spec: white, hairline border, the |
| official four-colour G (inline SVG — their rendered button needs their |
| JS, which the CSP rightly blocks; hand-built like Apple's). */ |
| - .google-btn { |
| + .google-btn, .ms-btn { |
| display: flex; align-items: center; justify-content: center; gap: 8px; |
| background: light-dark(#fff, #131314); color: light-dark(#1f1f1f, #e3e3e3); |
| border: 1px solid light-dark(#747775, #8e918f); |
| position: relative; /* anchors the loading ring */ |
| } |
| - .google-btn.hidden { display: none; } |
| + .google-btn.hidden, .ms-btn.hidden { display: none; } |
| /* A working button wears a ring (Apple hand-over, Send code): the wait |
| needs a face even when the server is instant. White reads on both the |
| black and accent buttons — the only wearers. Reduced motion keeps a |
| @@ -804,6 +804,7 @@ |
| <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="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>Continue with Google</button> |
| + <button id="microsoft-signin" 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>Continue with Microsoft</button> |
| <button id="email-signin" class="secondary" type="button">Continue with email</button> |
| <button id="signin" class="passkey-link" type="button"><svg class="pk-ico" viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><circle cx="6" cy="4.4" r="2.7"/><path d="M1.2 13.9a4.8 4.8 0 0 1 9.6 0z"/><path fill-rule="evenodd" d="M12.1 7.3a2.1 2.1 0 0 1 2.06 2.52l1.6 1.6-1.2 1.2-.7-.7-.65.65-.85-.85-.62.62a2.1 2.1 0 1 1 .36-4.94zm-.9 1.6a.8.8 0 1 0 0 1.6.8.8 0 0 0 0-1.6z"/></svg>Sign in with a passkey</button> |
| <button id="other-ways" class="passkey-link" type="button">Other ways to sign in</button> |
| @@ -864,8 +865,7 @@ |
| <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> |
| <button id="otherways-google" class="google-btn" 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> |
| - <!-- Microsoft's door, same shape: DOORS.microsoft plus |
| - <button id="otherways-microsoft" class="secondary" type="button">Continue with Microsoft</button> --> |
| + <button id="otherways-microsoft" class="ms-btn" 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>Continue with Microsoft</button> |
| <!-- GitHub's door ("for the nerds"), all-ways only, never a platform |
| default: DOORS.github plus |
| <button id="otherways-github" class="secondary" type="button">Continue with GitHub</button> --> |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index b9f19fc..e4f4df4 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -217,8 +217,9 @@ 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", "google-signin", "email-signin", "email-send", "email-verify", |
| - "other-ways", "otherways-apple", "otherways-google", "otherways-email"]) $(id).disabled = busy; |
| + for (const id of ["apple-signin", "google-signin", "microsoft-signin", "email-signin", |
| + "email-send", "email-verify", "other-ways", "otherways-apple", "otherways-google", |
| + "otherways-microsoft", "otherways-email"]) $(id).disabled = busy; |
| for (const id of ["signin", "create-passkey", "unlock-passkey", "otherways-passkey"]) { |
| $(id).disabled = busy || !window.PublicKeyCredential; |
| } |
| @@ -252,7 +253,7 @@ const DOORS = { |
| email: true, |
| passkey: true, |
| google: true, // Android/ChromeOS's platform door (client id below) |
| - microsoft: false, // Windows's platform door, once it ships |
| + microsoft: true, // Windows's platform door (client id below) |
| github: false, // all-ways only ("for the nerds") — never a platform default |
| }; |
| @@ -292,9 +293,10 @@ function primaryDoor() { |
| function composeCard() { |
| const primary = primaryDoor(); |
| const apple = $("apple-signin"), email = $("email-signin"), passkey = $("signin"); |
| - const google = $("google-signin"); |
| + const google = $("google-signin"), microsoft = $("microsoft-signin"); |
| apple.classList.toggle("hidden", primary !== "apple"); |
| google.classList.toggle("hidden", primary !== "google"); |
| + microsoft.classList.toggle("hidden", primary !== "microsoft"); |
| // 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. |
| @@ -302,7 +304,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, google }[primary], $("other-ways")); |
| + $("main-doors").append({ apple, email, passkey, google, microsoft }[primary], $("other-ways")); |
| } |
| async function signIn() { |
| @@ -614,7 +616,22 @@ async function finishSiwaLink(idToken, markRaw) { |
| // identifier and lives here in code. |
| const GOOGLE_CLIENT_ID = |
| "981276018948-b4cr3ctqi6cr9e8rs2fmqouip1hmd9iq.apps.googleusercontent.com"; |
| +const MICROSOFT_CLIENT_ID = "25874cf3-213c-4124-9641-65de8a513787"; |
| const OIDC_WEB = { |
| + microsoft: { |
| + display: "Microsoft", |
| + verify: "/api/oidc/microsoft/verify", |
| + // The "common" endpoint admits any tenant plus personal accounts — |
| + // oidc.go's checkIssuer handles the per-tenant iss. |
| + authorize: (st, nonce) => "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + |
| + "?client_id=" + encodeURIComponent(MICROSOFT_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), |
| + }, |
| google: { |
| display: "Google", |
| verify: "/api/oidc/google/verify", |
| @@ -1123,6 +1140,8 @@ const METHOD_KINDS = [ |
| enabled: () => DOORS.apple, start: (e) => startAddApple(e) }, |
| { kind: "google", name: "Google", first: "Add Google", |
| enabled: () => DOORS.google, start: (e) => startAddGoogle(e) }, |
| + { kind: "microsoft", name: "Microsoft", first: "Add Microsoft", |
| + enabled: () => DOORS.microsoft, start: (e) => startAddOidc("microsoft", e) }, |
| { kind: "email", name: "Email", first: "Add email", |
| enabled: () => DOORS.email, start: (e) => startAddEmail(e) }, |
| ]; |
| @@ -1189,7 +1208,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" || s === "add-google") return { kind: s }; |
| + if (["add-email", "add-passkey", "add-apple", "add-google", "add-microsoft"].includes(s)) return { kind: s }; |
| const m = /^remove:([^:]+):(.+)$/.exec(s || ""); |
| return m ? { kind: "remove", anchorKind: m[1], id: m[2] } : null; |
| } |
| @@ -1201,7 +1220,8 @@ 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 "add-google": startAddOidc("google", null); return; |
| + case "add-microsoft": startAddOidc("microsoft", 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). |
| @@ -1767,17 +1787,18 @@ function startAddApple(e) { |
| openMethodsConfirmStep(); |
| } |
| -// startAddGoogle — the same ADD round trip through the Google door. |
| -function startAddGoogle(e) { |
| +// startAddOidc — the ADD round trip through any OIDC web door. |
| +function startAddOidc(provider, e) { |
| const src = e && e.currentTarget; |
| - const go = (proof) => startOidcSignIn("google", |
| + const go = (proof) => startOidcSignIn(provider, |
| { 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" }; |
| + pendingIntent = { kind: "add-" + provider }; |
| openMethodsConfirmStep(); |
| } |
| +function startAddGoogle(e) { startAddOidc("google", e); } |
| // startAddEmail / startAddPasskey open their serial card steps — confirm |
| // first when the pocket is empty (list → confirm → the add step → list). |
| @@ -3601,6 +3622,7 @@ $("create-passkey").addEventListener("click", () => createPasskey().catch(onCrea |
| $("unlock-passkey").addEventListener("click", () => signIn().catch(onSignInError)); |
| $("apple-signin").addEventListener("click", startAppleSignIn); |
| $("google-signin").addEventListener("click", (e) => startOidcSignIn("google", e)); |
| +$("microsoft-signin").addEventListener("click", (e) => startOidcSignIn("microsoft", 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); |
| @@ -3624,6 +3646,7 @@ $("other-ways").addEventListener("click", openOtherWays); |
| $("otherways-back").addEventListener("click", closeOtherWays); |
| $("otherways-apple").addEventListener("click", startAppleSignIn); |
| $("otherways-google").addEventListener("click", (e) => startOidcSignIn("google", e)); |
| +$("otherways-microsoft").addEventListener("click", (e) => startOidcSignIn("microsoft", e)); |
| $("otherways-email").addEventListener("click", openEmailDoor); |
| $("otherways-passkey").addEventListener("click", () => signIn().catch(onSignInError)); |