eleven / messenger Public

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>
Paul Campbell 0012f854def64dbdf3bdf8785f1e1c617edc9b3c parent fbbaeb5
3 files changed, +43 −15
  • changelog/2026-07-19-microsoft-signin.md +5 −0
  • home/web/pages/index.html +4 −4
  • home/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));