From e9c6367c2d97a7f3debcebe0387617eeb94964d7 Mon Sep 17 00:00:00 2001 From: Brent G Date: Sun, 11 Oct 2026 03:27:08 +0000 Subject: [PATCH 1/3] Connect logins that share a verified email after the owner confirms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new login whose provider-verified email already belongs to an account no longer creates a second account. It is held for ten minutes in this browser and connected only when the browser signs in with one of the account's existing logins. Unverified emails never match; emails stay private to the owner and are never shown to applications. 🤖 Built with SMT --- README.md | 4 +- account.js | 2 +- docs/platform.md | 16 +++++- script.js | 30 ++++++++++++ scripts/auth.mjs | 18 ++++--- scripts/serve.mjs | 21 ++++++-- scripts/store.mjs | 38 ++++++++++++-- tests/auth.test.mjs | 106 +++++++++++++++++++++++++++++++++++++++- tests/platform.test.mjs | 3 ++ 9 files changed, 215 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index b8b206a..045060e 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Joining the foundry, changing an intent, creating a room, and joining a room ros Joining requires a name, bio, skills, a public contact link, and a real intent. Profiles and meeting rosters are public. Sign-in creates a private account first; nothing is published until the person supplies their intent and saves a profile. Returning to the same provider identity on another device restores the same profile, room ownership, and private radar. -The sign-in dialog supports Google and GitHub OAuth, Sign in with Apple, and a configurable Agora OIDC identity service. Members can explicitly connect another provider to the same account. An existing browser-only profile can connect a login while preserving its ID and rooms; its old browser token is then invalidated. Accounts are never merged by email. Provider subjects and session hashes stay private. Account sessions last 30 days and logout revokes the current session. Removing a public profile also removes its radar and owned rooms, while the private login account remains available to start a new profile. +The sign-in dialog supports Google and GitHub OAuth, Sign in with Apple, and a configurable Agora OIDC identity service. Members can explicitly connect another provider to the same account. Signing in with a new provider whose verified email already belongs to an account does not create a second account: the browser is asked to sign in once with that account's existing login, and only then are the two connected. Unverified emails never match, and two accounts that already share an email are not merged. An existing browser-only profile can connect a login while preserving its ID and rooms; its old browser token is then invalidated. Accounts are never merged by email alone. Provider subjects and session hashes stay private. Account sessions last 30 days and logout revokes the current session. Removing a public profile also removes its radar and owned rooms, while the private login account remains available to start a new profile. Browser-only participation is still available. Its editing access belongs to that browser until a login is connected; clearing the cookie loses access. Public GitHub contributor snapshots are not authenticated accounts. @@ -76,7 +76,7 @@ Set `SITE_URL` to the public HTTPS origin, such as `https://agora.build`. Google | Apple | `/auth/apple/callback` | `APPLE_CLIENT_ID` (Services ID), `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY_FILE` | | Agora Identity | `/auth/agora/callback` | `AGORA_OIDC_ISSUER`, `AGORA_OIDC_CLIENT_ID`, `AGORA_OIDC_CLIENT_SECRET` | -Google and OIDC identity tokens are verified for signature, issuer, audience, expiry, and nonce. GitHub identities come from its authenticated `/user` API. Flows use one-use browser-bound state, and PKCE where supported. Apple's name/email scope requires a `form_post` callback; its transaction cookie uses SameSite=None and Secure, and the server generates a short-lived client secret from the `.p8` key. Keep that private key outside the repository and deployment output. Apple requires a registered HTTPS domain and return URL; localhost does not work for it. +Google and OIDC identity tokens are verified for signature, issuer, audience, expiry, and nonce. GitHub identities come from its authenticated `/user` API; a GitHub email counts only when `/user/emails` marks it primary and verified, and Google or Apple emails only when the token says `email_verified`. Flows use one-use browser-bound state, and PKCE where supported. Apple's name/email scope requires a `form_post` callback; its transaction cookie uses SameSite=None and Secure, and the server generates a short-lived client secret from the `.p8` key. Keep that private key outside the repository and deployment output. Apple requires a registered HTTPS domain and return URL; localhost does not work for it. Agora RTC App IDs and certificates authorize media channels, not account login. Use a registered Agora OIDC service or the existing Agora OAuth flow described in [the platform guide](docs/platform.md#agora-console-connections). Existing OAuth must provide a verified permanent user ID before it can create a portal user; callback login IDs and emails cannot replace it. Without configuration, each provider is visibly unavailable and browser-only participation continues to work. OAuth transactions last ten minutes and are held in process memory; use one Node process or sticky routing, and restart an interrupted sign-in if the server restarts. diff --git a/account.js b/account.js index 6b28e82..6e6d7f3 100644 --- a/account.js +++ b/account.js @@ -87,7 +87,7 @@ async function loadAccount() { const version = ++renderVersion; const account = state.account; status.textContent = account ? "Signed in as " + account.name : "Sign in to manage your account, membership, and credits."; - document.querySelector("#account-identity").replaceChildren(account ? el("div", {}, [el("h3", { text: account.name }), el("p", { class: "account-user-id mono", text: account.id }), el("p", { text: "Logins: " + (account.providers.join(", ") || "Connected application") })]) : el("p", { text: "Your private account is separate from your public community profile." })); + document.querySelector("#account-identity").replaceChildren(account ? el("div", {}, [el("h3", { text: account.name }), el("p", { class: "account-user-id mono", text: account.id }), el("p", { text: "Logins: " + (account.providers.join(", ") || "Connected application") }), ...(account.emails?.length ? [el("p", { class: "muted", text: "Verified email" + (account.emails.length > 1 ? "s" : "") + " (private): " + account.emails.join(", ") })] : [])]) : el("p", { text: "Your private account is separate from your public community profile." })); document.querySelector("#logout-everywhere").hidden = !account; renderPlans(); if (!account) { diff --git a/docs/platform.md b/docs/platform.md index 73f3bf8..e7e0b02 100644 --- a/docs/platform.md +++ b/docs/platform.md @@ -7,8 +7,20 @@ private user immediately; publishing a profile requires real intent. Google, GitHub, Apple, and Agora identities are matched by verified issuer and subject. Connecting another provider is an explicit operation on a signed-in -account. Email addresses and Agora callback `loginId` values never identify or -merge users. Accounts and purchased credits survive public profile deletion. +account. Agora callback `loginId` values never identify users, and an email +never signs anyone in on its own. + +Each login keeps the provider's verified email privately, refreshed at every +sign-in (GitHub: primary and verified in `/user/emails`; Google and Apple: +`email_verified`; Agora OIDC: `email_verified`; Agora OAuth: none until its SSO returns a stable user ID). A new login +whose verified email already belongs to an account does not create a second +account. It is held for ten minutes in an HttpOnly `house_link` cookie in that +browser, and it is connected only when that browser then signs in with one of the +account's existing logins (`/auth/?confirmLink=1`). A sign-in to a +different account, an expired hold, or an account that already has a login from +the same provider connects nothing. Accounts that already share an email are not +merged. Emails appear only to the owner in a browser session (`/api/me`), never +to applications. Accounts and purchased credits survive public profile deletion. ## Storage And Deployment diff --git a/script.js b/script.js index 457bdbf..5f76fc1 100644 --- a/script.js +++ b/script.js @@ -78,6 +78,28 @@ function renderSignIn() { })); } export function openSignIn() { renderSignIn(); loginDialog.showModal(); } + +// A login whose verified email already has an account waits here until the person signs in to that account to confirm it. +const PROVIDER_NAMES = { google: "Google", github: "GitHub", apple: "Apple", agora: "Agora" }; +const nameOf = (id) => PROVIDER_NAMES[id] || "another login"; +function confirmLink(login, via) { + const choices = via.filter((id) => Object.hasOwn(PROVIDER_NAMES, id)); + const dialog = el("dialog", { id: "link-dialog", "aria-labelledby": "link-title" }, [ + el("div", { class: "dialog-head" }, [el("span", { class: "kicker", text: "ONE ACCOUNT, MANY LOGINS" }), el("button", { class: "close-button", type: "button", "data-close": "", "aria-label": "Close", text: "\u00d7" })]), + el("h2", { id: "link-title", text: "This email already has an account." }), + el("p", { class: "dialog-intro", text: "Your " + nameOf(login) + " email already belongs to an Agora.Build account that signs in with " + choices.map(nameOf).join(" or ") + ". Sign in with it once to connect " + nameOf(login) + ", and both will open the same account." }), + el("div", { class: "login-providers" }, choices.map((id) => el("button", { class: "login-provider", type: "button", onclick: () => { + const url = new URL("/auth/" + id, location.origin); + url.searchParams.set("returnTo", location.pathname + location.search + location.hash); + url.searchParams.set("confirmLink", "1"); + location.href = url.href; + } }, [el("span", { class: "login-provider-mark mono", "aria-hidden": "true", text: id === "agora" ? "a_" : nameOf(id)[0] }), el("span", { text: "Continue with " + nameOf(id) })]))), + el("p", { class: "form-note", text: "Not you? Close this and nothing is connected. The request expires in ten minutes." }) + ]); + dialog.addEventListener("close", () => dialog.remove()); + document.body.append(dialog); + dialog.showModal(); +} api("/api/auth/providers").then((result) => { providers = result.providers; renderSignIn(); }).catch(() => { loginStatus.textContent = "Sign-in options could not load. You can continue with this browser."; }); export function openJoin(callback, browserOnly = false) { @@ -213,7 +235,15 @@ sessionReady.then(() => { if (!result) return; url.searchParams.delete("signin"); history.replaceState(null, "", url.pathname + url.search + url.hash); + const login = url.searchParams.get("login"); + url.searchParams.delete("login"); + const via = (url.searchParams.get("via") || "").split(",").filter(Boolean); + url.searchParams.delete("via"); + history.replaceState(null, "", url.pathname + url.search + url.hash); if (result === "failed") { notify("Sign-in could not be completed. Please try again."); return; } + if (result === "confirm-link") { confirmLink(login, via); return; } + const linkNotices = { linked: nameOf(login) + " is now connected. Either login opens this account.", "link-mismatch": "You signed in to a different account, so the new login was not connected.", "link-expired": "The request to connect that login expired. Sign in with it again to retry.", "link-failed": "That login could not be connected because this account already has a login from the same provider." }; + if (Object.hasOwn(linkNotices, result)) { notify(linkNotices[result]); return; } if (state.account && !state.profile && location.pathname !== "/account.html" && location.pathname !== "/services.html") { let draft; try { draft = JSON.parse(sessionStorage.getItem("foundry-room-draft")); } catch { sessionStorage.removeItem("foundry-room-draft"); } diff --git a/scripts/auth.mjs b/scripts/auth.mjs index 0b0b003..06ae049 100644 --- a/scripts/auth.mjs +++ b/scripts/auth.mjs @@ -56,6 +56,9 @@ export function returnPath(value) { return url.pathname + url.search + url.hash; } +// An email counts only when the provider says it verified it; stored lower-case so the same address matches across providers. +const verifiedEmail = (value, verified) => verified && typeof value === "string" && value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? value.trim().toLowerCase() : null; + export function createAuth(config = authConfig(), { request = fetch, now = Date.now } = {}) { const pending = new Map(); const clients = new Map(); @@ -107,7 +110,7 @@ export function createAuth(config = authConfig(), { request = fetch, now = Date. config, agoraIdentity, providers() { return Object.values(config.providers).map(({ id, name, ready }) => ({ id, name, ready })); }, - async begin(id, { returnTo, linkToken } = {}) { + async begin(id, { returnTo, linkToken, pendingLink } = {}) { const value = provider(id); for (const [state, transaction] of pending) if (transaction.expiresAt <= now()) pending.delete(state); if (pending.size >= 500) throw new AppError(429, "There are too many sign-ins in progress. Try again shortly."); @@ -116,11 +119,11 @@ export function createAuth(config = authConfig(), { request = fetch, now = Date. const nonce = oidc.randomNonce(); const verifier = id === "apple" ? undefined : oidc.randomPKCECodeVerifier(); const redirect = config.origin + "/auth/" + id + "/callback"; - const parameters = { redirect_uri: redirect, response_type: "code", state, scope: value.mode === "oauth" ? "basic_info,console" : id === "github" ? "read:user" : id === "apple" ? "openid name email" : "openid profile email" }; + const parameters = { redirect_uri: redirect, response_type: "code", state, scope: value.mode === "oauth" ? "basic_info,console" : id === "github" ? "read:user user:email" : id === "apple" ? "openid name email" : "openid profile email" }; if (id !== "github" && value.mode !== "oauth") parameters.nonce = nonce; if (verifier) { parameters.code_challenge = await oidc.calculatePKCECodeChallenge(verifier); parameters.code_challenge_method = "S256"; } if (id === "apple") parameters.response_mode = "form_post"; - pending.set(state, { id, nonce, verifier, redirect, returnTo: returnPath(returnTo), linkToken, expiresAt: now() + 600000 }); + pending.set(state, { id, nonce, verifier, redirect, returnTo: returnPath(returnTo), linkToken, pendingLink, expiresAt: now() + 600000 }); return { url: oidc.buildAuthorizationUrl(client, parameters).href, state, formPost: id === "apple" }; }, async complete(id, parameters, browserState) { @@ -142,7 +145,10 @@ export function createAuth(config = authConfig(), { request = fetch, now = Date. if (!response.ok) throw new Error("GitHub profile unavailable"); const user = await response.json(); if (!Number.isSafeInteger(user.id) || user.id < 1 || typeof user.login !== "string" || !/^[a-z0-9-]+$/i.test(user.login)) throw new Error("Invalid GitHub identity"); - identity = { subject: String(user.id), name: user.name || user.login, avatar: safeUrl(user.avatar_url), contact: "https://github.com/" + user.login }; + // The account email links logins only when GitHub has verified it; the public profile email is not used. + const emails = await request("https://api.github.com/user/emails", { headers: { Authorization: "Bearer " + tokens.access_token, Accept: "application/vnd.github+json", "User-Agent": "Agora-Build-Portal", "X-GitHub-Api-Version": "2022-11-28" }, signal: AbortSignal.timeout(10000) }).then((reply) => reply.ok ? reply.json() : [], () => []); + const primary = Array.isArray(emails) ? emails.find((entry) => entry?.primary && entry.verified === true) : null; + identity = { subject: String(user.id), name: user.name || user.login, avatar: safeUrl(user.avatar_url), contact: "https://github.com/" + user.login, email: verifiedEmail(primary?.email, true) }; } else { const claims = tokens.claims(); if (!claims?.sub) throw new Error("Missing identity"); @@ -151,9 +157,9 @@ export function createAuth(config = authConfig(), { request = fetch, now = Date. try { const user = JSON.parse(parameters.get("user")); name = [user.name?.firstName, user.name?.lastName].filter((part) => typeof part === "string").join(" "); } catch { /* Apple returns the name only on the first authorization. */ } } - identity = { subject: claims.sub, name, avatar: safeUrl(claims.picture), contact: "" }; + identity = { subject: claims.sub, name, avatar: safeUrl(claims.picture), contact: "", email: verifiedEmail(claims.email, claims.email_verified === true || claims.email_verified === "true") }; } - return { identity: { ...identity, provider: id, issuer: value.issuer }, returnTo: transaction.returnTo, linkToken: transaction.linkToken, ...(value.mode === "oauth" ? { connection: { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || "", expiresAt: now() + Math.min(Number(tokens.expires_in) || 300, 86400) * 1000 } } : {}) }; + return { identity: { ...identity, provider: id, issuer: value.issuer }, returnTo: transaction.returnTo, linkToken: transaction.linkToken, pendingLink: transaction.pendingLink, ...(value.mode === "oauth" ? { connection: { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || "", expiresAt: now() + Math.min(Number(tokens.expires_in) || 300, 86400) * 1000 } } : {}) }; } catch { throw new AppError(400, "Sign-in could not be verified. Please try again."); } } }; diff --git a/scripts/serve.mjs b/scripts/serve.mjs index feee5b2..13f2f17 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -156,24 +156,35 @@ export function createAppServer(directory = root, options = {}) { const id = loginRoute[1]; const cookieName = "foundry_oauth_" + id; const transactionCookie = (value, maxAge = 600) => cookieName + "=" + value + "; HttpOnly; Path=/auth/; Max-Age=" + maxAge + "; SameSite=" + (id === "apple" ? "None" : "Lax") + (secure ? "; Secure" : ""); + // A login waiting to be connected by email; only this browser can confirm it, by signing in to the matching account. + const linkCookie = (value, maxAge = 600) => "house_link=" + value + "; HttpOnly; Path=/auth/; Max-Age=" + maxAge + "; SameSite=Lax" + (secure ? "; Secure" : ""); if (!loginRoute[2]) { if (request.method !== "GET") throw new AppError(405, "Use a sign-in button to start this login."); limit("login:" + request.socket.remoteAddress, 20, 60000); const linking = requestUrl.searchParams.get("link") === "1"; const current = await store.session(token); if (linking && !current.account && !current.profile) throw new AppError(401, "Sign in before connecting another login."); - const result = await auth.begin(id, { returnTo: requestUrl.searchParams.get("returnTo"), linkToken: linking ? token : undefined }); + const pendingLink = requestUrl.searchParams.get("confirmLink") === "1" ? cookieValue(request, "house_link") || undefined : undefined; + const result = await auth.begin(id, { returnTo: requestUrl.searchParams.get("returnTo"), linkToken: linking ? token : undefined, pendingLink }); redirect(response, result.url, [transactionCookie(result.state)]); } else { if (!["GET", "POST"].includes(request.method)) throw new AppError(405, "This login callback method is not supported."); try { const parameters = request.method === "POST" ? await formBody(request) : requestUrl.searchParams; const result = await auth.complete(id, parameters, cookieValue(request, cookieName)); - const account = await store.login(result.identity, { linkToken: result.linkToken }); - if (result.connection) await connections.save(account.account.id, result.connection); + const account = await store.login(result.identity, { linkToken: result.linkToken, pendingLink: result.pendingLink }); const next = new URL(result.returnTo, "https://foundry.invalid"); - next.searchParams.set("signin", "success"); - redirect(response, next.pathname + next.search + next.hash, [sessionCookie(account.token), transactionCookie("", 0)]); + if (account.pendingLink) { + next.searchParams.set("signin", "confirm-link"); + next.searchParams.set("login", account.pendingLink.provider); + next.searchParams.set("via", account.pendingLink.providers.join(",")); + redirect(response, next.pathname + next.search + next.hash, [linkCookie(account.pendingLink.token), transactionCookie("", 0)]); + return; + } + if (result.connection) await connections.save(account.account.id, result.connection); + next.searchParams.set("signin", account.linked ? "linked" : account.linkMismatch ? "link-mismatch" : account.linkExpired ? "link-expired" : account.linkFailed ? "link-failed" : "success"); + if (account.linked) next.searchParams.set("login", account.linked); + redirect(response, next.pathname + next.search + next.hash, [sessionCookie(account.token), transactionCookie("", 0), ...(result.pendingLink ? [linkCookie("", 0)] : [])]); } catch { redirect(response, "/?signin=failed", [transactionCookie("", 0)]); } diff --git a/scripts/store.mjs b/scripts/store.mjs index accc609..515c658 100644 --- a/scripts/store.mjs +++ b/scripts/store.mjs @@ -9,7 +9,7 @@ export class AppError extends Error { const hash = (value) => createHash("sha256").update(value).digest("hex"); const publicProfile = ({ sessionHash, accountId, ...person }) => person; const publicRoom = ({ videoUrl, ...room }) => ({ ...room, callProvider: "agora", path: "/meet/" + room.id }); -const publicAccount = (account) => account ? { id: account.id, name: account.name, avatar: account.avatar, contact: account.contact, providers: account.identities.filter((identity) => identity.provider !== "service").map((identity) => identity.provider), plan: effectivePlan(account), entitlements: entitlements(effectivePlan(account)), connections: Object.keys(account.connections || {}) } : null; +const publicAccount = (account) => account ? { id: account.id, name: account.name, avatar: account.avatar, contact: account.contact, providers: account.identities.filter((identity) => identity.provider !== "service").map((identity) => identity.provider), emails: [...new Set(account.identities.map((identity) => identity.email).filter(Boolean))], plan: effectivePlan(account), entitlements: entitlements(effectivePlan(account)), connections: Object.keys(account.connections || {}) } : null; export const safeUrl = (value) => { try { const url = new URL(value); @@ -113,7 +113,11 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence async session(token) { const state = await read(); const person = personFor(state, token); - return { profile: person ? publicProfile(person) : null, account: publicAccount(accountFor(state, token)) }; + const account = publicAccount(accountFor(state, token)); + // Emails are for the owner's own browser; an app token reading /api/me never sees them. + const browser = token && state.sessions.some((session) => session.hash === hash(token) && session.expiresAt > now()); + if (account && !browser) delete account.emails; + return { profile: person ? publicProfile(person) : null, account }; }, async accountIdentity(id) { const account = (await read()).accounts.find((account) => account.id === id); @@ -178,17 +182,33 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence state.authorizationCodes = state.authorizationCodes.filter((code) => code.accountId !== account.id); }); }, - async login(identity, { linkToken } = {}) { + async login(identity, { linkToken, pendingLink } = {}) { const { provider, issuer, subject } = identity; + // Providers pass an email only when they verified it; it is private and never identifies a user on its own. + const email = typeof identity.email === "string" && identity.email ? identity.email.toLowerCase() : null; + const same = (entry) => (other) => other.provider === entry.provider && other.issuer === entry.issuer && other.subject === entry.subject; if (!["google", "github", "apple", "agora"].includes(provider) || typeof subject !== "string" || !subject || subject.length > 255 || typeof issuer !== "string" || !issuer) throw new AppError(422, "This login identity is invalid."); const token = randomBytes(32).toString("base64url"); return update((state) => { + state.pendingLinks = (state.pendingLinks || []).filter((entry) => entry.expiresAt > now()); + const waiting = pendingLink ? state.pendingLinks.find((entry) => entry.hash === hash(pendingLink)) || null : null; + if (waiting) state.pendingLinks = state.pendingLinks.filter((entry) => entry !== waiting); let account = state.accounts.find((entry) => entry.identities.some((entry) => entry.provider === provider && entry.issuer === issuer && entry.subject === subject)); const browser = linkToken && state.sessions.find((session) => session.hash === hash(linkToken) && session.expiresAt > now()); const linking = browser && accountFor(state, linkToken); const legacy = linkToken && state.people.find((person) => person.sessionHash === hash(linkToken)); if (linkToken && !linking && !legacy) throw new AppError(401, "Your session expired. Sign in again before connecting another login."); if (account && ((linking && account.id !== linking.id) || (!linking && legacy && account.profileId && account.profileId !== legacy.id))) throw new AppError(409, "That login belongs to another account. Sign out to use it."); + // A new login whose verified email already belongs to an account is held until that account signs in to confirm it. + if (!account && !linkToken && email) { + const owner = state.accounts.find((entry) => entry.identities.some((other) => other.email === email)); + if (owner) { + const raw = randomBytes(32).toString("base64url"); + state.pendingLinks.push({ hash: hash(raw), accountId: owner.id, identity: { provider, issuer, subject, email }, expiresAt: now() + 600000 }); + state.pendingLinks = state.pendingLinks.slice(-500); + return { pendingLink: { token: raw, provider, providers: [...new Set(owner.identities.filter((entry) => entry.provider !== "service").map((entry) => entry.provider))] } }; + } + } if (!account) { if (linking?.identities.some((entry) => entry.provider === provider)) throw new AppError(409, "A login from this provider is already connected."); if (linking) account = linking; @@ -197,7 +217,15 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence account = { id: "account:" + randomUUID(), identities: [], name: String(identity.name || "Builder").slice(0, 60), avatar: safeUrl(identity.avatar || ""), contact: safeUrl(identity.contact || ""), profileId: null, createdAt: new Date(now()).toISOString() }; state.accounts.push(account); } - account.identities.push({ provider, issuer, subject }); + account.identities.push({ provider, issuer, subject, email }); + } else account.identities.find(same({ provider, issuer, subject })).email = email; + let link = {}; + if (pendingLink && !waiting) link = { linkExpired: true }; + else if (waiting && waiting.accountId !== account.id) link = { linkMismatch: true }; + else if (waiting) { + const held = waiting.identity; + if (state.accounts.some((entry) => entry.identities.some(same(held))) || account.identities.some((entry) => entry.provider === held.provider)) link = { linkFailed: true }; + else { account.identities.push(held); link = { linked: held.provider }; } } if (legacy && !linking) { account.profileId = legacy.id; @@ -206,7 +234,7 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence } addSession(state, account, token); const person = state.people.find((person) => person.id === account.profileId); - return { token, account: publicAccount(account), profile: person ? publicProfile(person) : null }; + return { token, account: publicAccount(account), profile: person ? publicProfile(person) : null, ...link }; }); }, async logout(token) { diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs index 3cdd7d9..1cc1cc3 100644 --- a/tests/auth.test.mjs +++ b/tests/auth.test.mjs @@ -28,7 +28,7 @@ const store = (name, options) => createStore(resolve(root, "data/people.json"), function configuration(extra = {}) { return authConfig({ SITE_URL: "https://portal.example", GOOGLE_CLIENT_ID: "test-client", GOOGLE_CLIENT_SECRET: "test-secret", GITHUB_CLIENT_ID: "test-client", GITHUB_CLIENT_SECRET: "test-secret", APPLE_CLIENT_ID: "test-client", APPLE_TEAM_ID: "test-team", APPLE_KEY_ID: "test-key", APPLE_PRIVATE_KEY_FILE: keyFile, AGORA_OIDC_ISSUER: "https://identity.agora.example", AGORA_OIDC_CLIENT_ID: "test-client", AGORA_OIDC_CLIENT_SECRET: "test-secret", ...extra }); } -function providerRequests({ id = "google", nonce, expectedChallenge, claims = {}, badSignature = false, audience = "test-client", tokenIssuer, expired = false } = {}) { +function providerRequests({ id = "google", githubEmails = [], nonce, expectedChallenge, claims = {}, badSignature = false, audience = "test-client", tokenIssuer, expired = false } = {}) { let seenBody; const issuer = id === "google" ? "https://accounts.google.com" : id === "apple" ? "https://appleid.apple.com" : "https://identity.agora.example"; const request = async (value, options = {}) => { @@ -51,6 +51,7 @@ function providerRequests({ id = "google", nonce, expectedChallenge, claims = {} return Response.json({ access_token: "test-access", token_type: "Bearer", expires_in: 300, id_token: token }); } if (url.href === "https://api.github.com/user") return Response.json({ id: 12345, login: "test-builder", name: "GitHub builder", avatar_url: "https://avatars.example/builder" }); + if (url.href === "https://api.github.com/user/emails") return Response.json(githubEmails); throw new Error("Unexpected upstream URL " + url.href); }; return { request, body: () => seenBody }; @@ -134,7 +135,7 @@ test("returning logins recover the same profile and rooms across devices without const publicText = JSON.stringify(await data.people()); assert.ok(!publicText.includes("identities") && !publicText.includes("accountId") && !publicText.includes("sessionHash") && !publicText.includes("stable-person")); }); -test("connecting a provider preserves browser-only profiles; identities are never merged by email or transferred between accounts", async () => { +test("connecting a provider preserves browser-only profiles; identities are never silently merged by email or transferred between accounts", async () => { const data = store("linking"); const guest = await data.join(profile); const room = await data.createRoom(guest.token, { title: "Guest room", intent: "Keep the room" }); @@ -219,3 +220,104 @@ test("unconfigured providers report availability without secrets and cannot issu assert.deepEqual(await (await fetch(base + "/api/me")).json(), { profile: null, account: null }); } finally { await new Promise(done => { server.close(done); server.closeIdleConnections(); }); } }); + +const verified = (provider, subject, email) => ({ ...identity(provider, subject), email }); +test("a verified email that already has an account waits for a sign-in with that account before connecting", async () => { + let clock = 1800000000000; + const data = store("email-link", { now: () => clock }); + const first = await data.login(verified("github", "123", "ada@example.com")); + const second = await data.login(verified("google", "456", "Ada@Example.com")); + assert.equal(second.token, undefined, "no new account or session is created"); + assert.deepEqual(second.pendingLink.providers, ["github"]); + assert.equal(second.pendingLink.provider, "google"); + const confirmed = await data.login(verified("github", "123", "ada@example.com"), { pendingLink: second.pendingLink.token }); + assert.equal(confirmed.account.id, first.account.id); + assert.deepEqual(confirmed.account.providers, ["github", "google"]); + assert.equal(confirmed.linked, "google"); + assert.equal((await data.login(verified("google", "456", "ada@example.com"))).account.id, first.account.id, "either login now signs in to the same user"); + const again = await data.login(verified("github", "123", "ada@example.com"), { pendingLink: second.pendingLink.token }); + assert.equal(again.linkExpired, true, "a pending link works once; signing in still works"); +}); +test("a pending email link is discarded when another account signs in, and expires after ten minutes", async () => { + let clock = 1800000000000; + const data = store("email-link-mismatch", { now: () => clock }); + const ada = await data.login(verified("github", "1", "ada@example.com")); + const bo = await data.login(verified("github", "2", "bo@example.com")); + const waiting = await data.login(verified("google", "g1", "ada@example.com")); + const other = await data.login(verified("github", "2", "bo@example.com"), { pendingLink: waiting.pendingLink.token }); + assert.equal(other.account.id, bo.account.id); + assert.equal(other.linked, undefined); + assert.equal(other.linkMismatch, true); + assert.deepEqual(other.account.providers, ["github"], "nothing is connected to the wrong account"); + const later = await data.login(verified("google", "g1", "ada@example.com")); + clock += 600001; + const expired = await data.login(verified("github", "1", "ada@example.com"), { pendingLink: later.pendingLink.token }); + assert.equal(expired.linkExpired, true); + assert.deepEqual(expired.account.providers, ["github"]); + assert.ok(ada.account); +}); +test("logins without a verified email, or with a new email, create their own account; emails are private to the owner", async () => { + const data = store("email-none"); + const ada = await data.login(verified("github", "1", "ada@example.com")); + assert.deepEqual(ada.account.emails, ["ada@example.com"]); + const noEmail = await data.login(identity("google", "g1")); + assert.notEqual(noEmail.account.id, ada.account.id); + const fresh = await data.login(verified("apple", "a1", "someone@privaterelay.appleid.com")); + assert.notEqual(fresh.account.id, ada.account.id); + const explicit = await data.login(verified("google", "g2", "ada@example.com"), { linkToken: ada.token }); + assert.equal(explicit.account.id, ada.account.id, "explicit connecting from a signed-in account still works directly"); + const changed = await data.login(verified("github", "1", "ada@new.example")); + assert.deepEqual(changed.account.emails.sort(), ["ada@example.com", "ada@new.example"].sort(), "each login keeps its own latest email"); +}); +test("providers report only verified emails", async () => { + for (const [emails, expected] of [[[{ email: "Ada@Example.com", primary: true, verified: true }, { email: "x@example.com", primary: false, verified: true }], "ada@example.com"], [[{ email: "ada@example.com", primary: true, verified: false }], null], [[], null]]) { + const upstream = providerRequests({ id: "github", githubEmails: emails }); + const auth = createAuth(configuration(), { request: upstream.request }); + const started = await auth.begin("github"); + assert.match(new URL(started.url).searchParams.get("scope"), /user:email/); + const result = await auth.complete("github", new URLSearchParams({ code: "test-code", state: started.state }), started.state); + assert.equal(result.identity.email, expected); + } + for (const [claims, expected] of [[{ email: "Ada@Example.com", email_verified: true }, "ada@example.com"], [{ email: "ada@example.com", email_verified: false }, null], [{ email: "ada@example.com", email_verified: "true" }, "ada@example.com"], [{}, null]]) { + let nonce; + const upstream = providerRequests({ nonce: () => nonce, claims }); + const auth = createAuth(configuration(), { request: upstream.request }); + const started = await auth.begin("google"); + nonce = new URL(started.url).searchParams.get("nonce"); + const result = await auth.complete("google", new URLSearchParams({ code: "test-code", state: started.state }), started.state); + assert.equal(result.identity.email, expected, JSON.stringify(claims)); + } +}); +test("HTTP: a second provider with the same verified email is held in this browser and connected by signing in to the first", async () => { + const data = store("http-email-link"); + let nonce; + const google = providerRequests({ nonce: () => nonce, claims: { email: "ada@example.com", email_verified: true } }); + const github = providerRequests({ id: "github", githubEmails: [{ email: "ADA@example.com", primary: true, verified: true }] }); + const auth = createAuth(configuration(), { request: (url, options) => /(^|\.)github\.com$/.test(new URL(url).hostname) ? github.request(url, options) : google.request(url, options) }); + const server = createAppServer(root, { store: data, auth, monitor: false, models: createModelClient(modelConfig({})), calls: createAgoraCalls(agoraConfig({})) }); + await new Promise(done => server.listen(0, "127.0.0.1", done)); + const base = "http://127.0.0.1:" + server.address().port; + const signIn = async (id, query = "", cookies = []) => { + const start = await fetch(base + "/auth/" + id + "?returnTo=%2Faccount.html" + query, { headers: cookies.length ? { Cookie: cookies.join("; ") } : {}, redirect: "manual" }); + const url = new URL(start.headers.get("location")); nonce = url.searchParams.get("nonce"); + const transaction = start.headers.get("set-cookie").split(";")[0]; + return fetch(base + "/auth/" + id + "/callback?code=test-code&state=" + url.searchParams.get("state"), { headers: { Cookie: [transaction, ...cookies].join("; ") }, redirect: "manual" }); + }; + try { + const first = await signIn("github"); + assert.equal(first.headers.get("location"), "/account.html?signin=success"); + const second = await signIn("google"); + assert.equal(second.headers.get("location"), "/account.html?signin=confirm-link&login=google&via=github"); + assert.ok(!second.headers.getSetCookie().some(cookie => cookie.startsWith("house_session=")), "no session for the held login"); + const held = second.headers.getSetCookie().find(cookie => cookie.startsWith("house_link=")); + assert.match(held, /HttpOnly; Path=\/auth\/; Max-Age=600; SameSite=Lax; Secure/); + const confirmed = await signIn("github", "&confirmLink=1", [held.split(";")[0]]); + assert.equal(confirmed.headers.get("location"), "/account.html?signin=linked&login=google"); + assert.ok(confirmed.headers.getSetCookie().some(cookie => cookie.startsWith("house_link=;")), "the held login is cleared"); + const session = confirmed.headers.getSetCookie().find(cookie => cookie.startsWith("house_session=")).split(";")[0]; + const me = await (await fetch(base + "/api/me", { headers: { Cookie: session } })).json(); + assert.deepEqual([me.account.providers, me.account.emails], [["github", "google"], ["ada@example.com"]]); + const again = await signIn("google"); + assert.equal(again.headers.get("location"), "/account.html?signin=success", "Google now signs straight in to the same user"); + } finally { await new Promise(done => { server.close(done); server.closeIdleConnections(); }); } +}); diff --git a/tests/platform.test.mjs b/tests/platform.test.mjs index 8e40ce9..d74c2a9 100644 --- a/tests/platform.test.mjs +++ b/tests/platform.test.mjs @@ -234,6 +234,9 @@ test("server-side integration completes direct app login, shares credits, isolat assert.equal(forbidden.status, 401); const disguisedCookie = await fetch(base + "/api/me", { headers: { Cookie: "house_session=" + tokens.access_token } }); assert.equal((await disguisedCookie.json()).account, null); + const appView = await (await fetch(base + "/api/me", { headers: { Authorization: "Bearer " + tokens.access_token } })).json(); + assert.ok(appView.account.id, "an app token reads the account"); + assert.equal("emails" in appView.account, false, "but never the owner's private emails"); const mismatch = await fetch(base + "/api/platform/credits/reserve", { method: "POST", headers: { Authorization: "Bearer " + clientSecret, "X-Agora-Client-Id": "other", "X-Agora-User-Token": tokens.access_token, "Content-Type": "application/json" }, body: JSON.stringify({ service: "analysis", idempotencyKey: "other-request-001" }) }); assert.equal(mismatch.status, 403); const signInPage = await fetch(base + "/oauth/authorize?" + new URL(start.url).searchParams); From 11bb0e34aee33c2b2c696e7f64d350cbaa070ed1 Mon Sep 17 00:00:00 2001 From: Brent G Date: Sun, 11 Oct 2026 03:34:00 +0000 Subject: [PATCH 2/3] Make connecting a held login an explicit, same-origin choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing in to the matching account now only readies the held login; the portal then asks to connect it and does so on a same-origin POST. The hold cookie is __Host- prefixed over HTTPS, confirming sign-ins are refused from other sites, and a held login can become a separate account instead. Identity lookup no longer returns emails, a failed GitHub email lookup keeps the stored email, and only ASCII addresses match. 🤖 Built with SMT --- README.md | 2 +- docs/platform.md | 32 ++++++++--- script.js | 57 ++++++++++++------- scripts/auth.mjs | 8 ++- scripts/serve.mjs | 23 +++++--- scripts/store.mjs | 65 ++++++++++++++++----- tests/auth.test.mjs | 134 +++++++++++++++++++++++++++----------------- 7 files changed, 214 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 045060e..83c3ea2 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Joining the foundry, changing an intent, creating a room, and joining a room ros Joining requires a name, bio, skills, a public contact link, and a real intent. Profiles and meeting rosters are public. Sign-in creates a private account first; nothing is published until the person supplies their intent and saves a profile. Returning to the same provider identity on another device restores the same profile, room ownership, and private radar. -The sign-in dialog supports Google and GitHub OAuth, Sign in with Apple, and a configurable Agora OIDC identity service. Members can explicitly connect another provider to the same account. Signing in with a new provider whose verified email already belongs to an account does not create a second account: the browser is asked to sign in once with that account's existing login, and only then are the two connected. Unverified emails never match, and two accounts that already share an email are not merged. An existing browser-only profile can connect a login while preserving its ID and rooms; its old browser token is then invalidated. Accounts are never merged by email alone. Provider subjects and session hashes stay private. Account sessions last 30 days and logout revokes the current session. Removing a public profile also removes its radar and owned rooms, while the private login account remains available to start a new profile. +The sign-in dialog supports Google and GitHub OAuth, Sign in with Apple, and a configurable Agora OIDC identity service. Members can explicitly connect another provider to the same account. Signing in with a new provider whose verified email already belongs to an account does not create a second account: the browser is asked to sign in once with that account's existing login and then to confirm the connection, or to create a separate account instead. Unverified emails never match, and two accounts that already share an email are not merged. See [the platform guide](docs/platform.md) for the details. An existing browser-only profile can connect a login while preserving its ID and rooms; its old browser token is then invalidated. Accounts are never merged by email alone. Provider subjects and session hashes stay private. Account sessions last 30 days and logout revokes the current session. Removing a public profile also removes its radar and owned rooms, while the private login account remains available to start a new profile. Browser-only participation is still available. Its editing access belongs to that browser until a login is connected; clearing the cookie loses access. Public GitHub contributor snapshots are not authenticated accounts. diff --git a/docs/platform.md b/docs/platform.md index e7e0b02..e7dabbb 100644 --- a/docs/platform.md +++ b/docs/platform.md @@ -12,15 +12,29 @@ never signs anyone in on its own. Each login keeps the provider's verified email privately, refreshed at every sign-in (GitHub: primary and verified in `/user/emails`; Google and Apple: -`email_verified`; Agora OIDC: `email_verified`; Agora OAuth: none until its SSO returns a stable user ID). A new login -whose verified email already belongs to an account does not create a second -account. It is held for ten minutes in an HttpOnly `house_link` cookie in that -browser, and it is connected only when that browser then signs in with one of the -account's existing logins (`/auth/?confirmLink=1`). A sign-in to a -different account, an expired hold, or an account that already has a login from -the same provider connects nothing. Accounts that already share an email are not -merged. Emails appear only to the owner in a browser session (`/api/me`), never -to applications. Accounts and purchased credits survive public profile deletion. +`email_verified`; Agora OIDC: `email_verified`; Agora OAuth: none until its SSO +returns a stable user ID). Only plain ASCII addresses match. If GitHub's email +lookup fails, the stored email is kept rather than cleared. + +A new login whose verified email already belongs to an account does not create a +second account and gets no session. It is held for ten minutes, referenced by an +HttpOnly `__Host-house_link` cookie (`house_link` over plain HTTP in development), +which a sibling subdomain cannot set. The browser then has three choices: + +1. Sign in with one of the matching account's logins (`/auth/?confirmLink=1`, + refused when `Sec-Fetch-Site` is not same-origin). This only readies the hold; + the page then asks "Connect Google (a***@example.com) to this account?" and the + login is connected by a same-origin `POST /api/auth/pending-link {action:"connect"}`. +2. Create a separate account for the new login (`{action:"separate"}`). +3. Discard it (`{action:"discard"}`), or let it lapse. + +A sign-in to a different account discards the hold, and an account that already +has a login from the same provider cannot connect another. `GET /api/auth/pending-link` +shows the holding browser only the provider, a masked email, and the matching +account's providers. Accounts that already share an email are not merged. Emails +appear only to the owner in a browser session (`/api/me`), never to applications: +not in `/oauth/userinfo`, app reads of `/api/me`, or identity lookup. +Accounts and purchased credits survive public profile deletion. ## Storage And Deployment diff --git a/script.js b/script.js index 5f76fc1..ff458cb 100644 --- a/script.js +++ b/script.js @@ -79,28 +79,51 @@ function renderSignIn() { } export function openSignIn() { renderSignIn(); loginDialog.showModal(); } -// A login whose verified email already has an account waits here until the person signs in to that account to confirm it. +// A login whose verified email already has an account is held by the server; these dialogs read it from there, never from the URL. const PROVIDER_NAMES = { google: "Google", github: "GitHub", apple: "Apple", agora: "Agora" }; const nameOf = (id) => PROVIDER_NAMES[id] || "another login"; -function confirmLink(login, via) { - const choices = via.filter((id) => Object.hasOwn(PROVIDER_NAMES, id)); +function linkDialog(kicker, title, intro, actions, note) { const dialog = el("dialog", { id: "link-dialog", "aria-labelledby": "link-title" }, [ - el("div", { class: "dialog-head" }, [el("span", { class: "kicker", text: "ONE ACCOUNT, MANY LOGINS" }), el("button", { class: "close-button", type: "button", "data-close": "", "aria-label": "Close", text: "\u00d7" })]), - el("h2", { id: "link-title", text: "This email already has an account." }), - el("p", { class: "dialog-intro", text: "Your " + nameOf(login) + " email already belongs to an Agora.Build account that signs in with " + choices.map(nameOf).join(" or ") + ". Sign in with it once to connect " + nameOf(login) + ", and both will open the same account." }), + el("div", { class: "dialog-head" }, [el("span", { class: "kicker", text: kicker }), el("button", { class: "close-button", type: "button", "data-close": "", "aria-label": "Close", text: "\u00d7" })]), + el("h2", { id: "link-title", text: title }), el("p", { class: "dialog-intro", text: intro }), ...actions, el("p", { class: "form-note", text: note }) + ]); + dialog.addEventListener("close", () => dialog.remove()); + document.body.append(dialog); + dialog.showModal(); + return dialog; +} +const resolveLink = async (action) => api("/api/auth/pending-link", { method: "POST", body: JSON.stringify({ action }) }); +async function confirmLink() { + let held; + try { held = await api("/api/auth/pending-link"); } catch { notify("That sign-in request expired. Sign in again."); return; } + const choices = held.providers.filter((id) => Object.hasOwn(PROVIDER_NAMES, id)); + const separate = el("button", { class: "inline-link", type: "button", text: "Create a separate account instead", onclick: async () => { + try { await resolveLink("separate"); location.reload(); } catch (error) { notify(error.message); } + } }); + const dialog = linkDialog("ONE ACCOUNT, MANY LOGINS", "This email already has an account.", "Your " + nameOf(held.provider) + " login (" + held.email + ") matches an Agora.Build account that signs in with " + choices.map(nameOf).join(" or ") + ". Sign in with it to connect " + nameOf(held.provider) + ", so both open the same account.", [ el("div", { class: "login-providers" }, choices.map((id) => el("button", { class: "login-provider", type: "button", onclick: () => { const url = new URL("/auth/" + id, location.origin); url.searchParams.set("returnTo", location.pathname + location.search + location.hash); url.searchParams.set("confirmLink", "1"); location.href = url.href; } }, [el("span", { class: "login-provider-mark mono", "aria-hidden": "true", text: id === "agora" ? "a_" : nameOf(id)[0] }), el("span", { text: "Continue with " + nameOf(id) })]))), - el("p", { class: "form-note", text: "Not you? Close this and nothing is connected. The request expires in ten minutes." }) - ]); - dialog.addEventListener("close", () => dialog.remove()); - document.body.append(dialog); - dialog.showModal(); + separate + ], "Not you? Close this and nothing is connected. The request expires in ten minutes."); + dialog.addEventListener("cancel", () => { resolveLink("discard").catch(() => {}); }); } -api("/api/auth/providers").then((result) => { providers = result.providers; renderSignIn(); }).catch(() => { loginStatus.textContent = "Sign-in options could not load. You can continue with this browser."; }); +async function confirmConnect() { + let held; + try { held = await api("/api/auth/pending-link"); } catch { notify("The request to connect that login expired. Sign in with it again to retry."); return; } + if (!held.ready) { notify("Sign in to the matching account to connect that login."); return; } + let dialog; + const choose = (action) => async () => { + try { const result = await resolveLink(action); dialog.close(); await refreshSession(); notify(result.linked ? nameOf(result.linked) + " is now connected. Either login opens this account." : "Nothing was connected."); } + catch (error) { dialog.close(); notify(error.message); } + }; + dialog = linkDialog("CONNECT A LOGIN", "Connect " + nameOf(held.provider) + " to this account?", nameOf(held.provider) + " (" + held.email + ") will sign in to this account from now on.", [ + el("div", { class: "dialog-actions" }, [el("button", { class: "button", type: "button", text: "Connect " + nameOf(held.provider), onclick: choose("connect") }), el("button", { class: "button button-secondary", type: "button", text: "Don't connect", onclick: choose("discard") })]) + ], "Only connect a login you own."); +}api("/api/auth/providers").then((result) => { providers = result.providers; renderSignIn(); }).catch(() => { loginStatus.textContent = "Sign-in options could not load. You can continue with this browser."; }); export function openJoin(callback, browserOnly = false) { afterJoin = typeof callback === "function" ? callback : null; @@ -235,14 +258,10 @@ sessionReady.then(() => { if (!result) return; url.searchParams.delete("signin"); history.replaceState(null, "", url.pathname + url.search + url.hash); - const login = url.searchParams.get("login"); - url.searchParams.delete("login"); - const via = (url.searchParams.get("via") || "").split(",").filter(Boolean); - url.searchParams.delete("via"); - history.replaceState(null, "", url.pathname + url.search + url.hash); if (result === "failed") { notify("Sign-in could not be completed. Please try again."); return; } - if (result === "confirm-link") { confirmLink(login, via); return; } - const linkNotices = { linked: nameOf(login) + " is now connected. Either login opens this account.", "link-mismatch": "You signed in to a different account, so the new login was not connected.", "link-expired": "The request to connect that login expired. Sign in with it again to retry.", "link-failed": "That login could not be connected because this account already has a login from the same provider." }; + if (result === "confirm-link") { confirmLink(); return; } + if (result === "confirm-connect") { confirmConnect(); return; } + const linkNotices = { "link-mismatch": "You signed in to a different account, so the new login was not connected.", "link-expired": "The request to connect that login expired. Sign in with it again to retry." }; if (Object.hasOwn(linkNotices, result)) { notify(linkNotices[result]); return; } if (state.account && !state.profile && location.pathname !== "/account.html" && location.pathname !== "/services.html") { let draft; diff --git a/scripts/auth.mjs b/scripts/auth.mjs index 06ae049..4b72cf6 100644 --- a/scripts/auth.mjs +++ b/scripts/auth.mjs @@ -57,7 +57,8 @@ export function returnPath(value) { } // An email counts only when the provider says it verified it; stored lower-case so the same address matches across providers. -const verifiedEmail = (value, verified) => verified && typeof value === "string" && value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? value.trim().toLowerCase() : null; +// Only plain ASCII addresses match, so no Unicode case-folding can make two mailboxes look alike. +const verifiedEmail = (value, verified) => verified && typeof value === "string" && value.length <= 254 && /^[\x21-\x3f\x41-\x7e]+@[\x21-\x3f\x41-\x7e]+\.[\x21-\x3f\x41-\x7e]+$/.test(value) ? value.toLowerCase() : null; export function createAuth(config = authConfig(), { request = fetch, now = Date.now } = {}) { const pending = new Map(); @@ -146,9 +147,10 @@ export function createAuth(config = authConfig(), { request = fetch, now = Date. const user = await response.json(); if (!Number.isSafeInteger(user.id) || user.id < 1 || typeof user.login !== "string" || !/^[a-z0-9-]+$/i.test(user.login)) throw new Error("Invalid GitHub identity"); // The account email links logins only when GitHub has verified it; the public profile email is not used. - const emails = await request("https://api.github.com/user/emails", { headers: { Authorization: "Bearer " + tokens.access_token, Accept: "application/vnd.github+json", "User-Agent": "Agora-Build-Portal", "X-GitHub-Api-Version": "2022-11-28" }, signal: AbortSignal.timeout(10000) }).then((reply) => reply.ok ? reply.json() : [], () => []); + // If GitHub can't be asked this time, the email is left undefined so the stored one is kept rather than cleared. + const emails = await request("https://api.github.com/user/emails", { headers: { Authorization: "Bearer " + tokens.access_token, Accept: "application/vnd.github+json", "User-Agent": "Agora-Build-Portal", "X-GitHub-Api-Version": "2022-11-28" }, signal: AbortSignal.timeout(10000) }).then((reply) => reply.ok ? reply.json() : undefined).catch(() => undefined); const primary = Array.isArray(emails) ? emails.find((entry) => entry?.primary && entry.verified === true) : null; - identity = { subject: String(user.id), name: user.name || user.login, avatar: safeUrl(user.avatar_url), contact: "https://github.com/" + user.login, email: verifiedEmail(primary?.email, true) }; + identity = { subject: String(user.id), name: user.name || user.login, avatar: safeUrl(user.avatar_url), contact: "https://github.com/" + user.login, email: Array.isArray(emails) ? verifiedEmail(primary?.email, true) : undefined }; } else { const claims = tokens.claims(); if (!claims?.sub) throw new Error("Missing identity"); diff --git a/scripts/serve.mjs b/scripts/serve.mjs index 13f2f17..edb0b08 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -104,6 +104,9 @@ export function createAppServer(directory = root, options = {}) { const token = bearer || (cookieToken && await store.browserToken(cookieToken) ? cookieToken : ""); const secure = auth.config.origin.startsWith("https:") || Boolean(request.socket.encrypted); const sessionCookie = (value, maxAge = 30 * 86400) => "house_session=" + value + "; HttpOnly; SameSite=Lax; Path=/; Max-Age=" + maxAge + (secure ? "; Secure" : ""); + // A login waiting to be connected by email. Over HTTPS the __Host- prefix stops a sibling subdomain from planting one. + const linkName = secure ? "__Host-house_link" : "house_link"; + const linkCookie = (value, maxAge = 600) => linkName + "=" + value + "; HttpOnly; SameSite=Lax; Path=/; Max-Age=" + maxAge + (secure ? "; Secure" : ""); if (path === "/api/billing/webhook" && request.method === "POST") { let size = 0; const chunks = []; for await (const chunk of request) { size += chunk.length; if (size > 262144) throw new AppError(413, "Payment event too large."); chunks.push(chunk); } @@ -156,15 +159,15 @@ export function createAppServer(directory = root, options = {}) { const id = loginRoute[1]; const cookieName = "foundry_oauth_" + id; const transactionCookie = (value, maxAge = 600) => cookieName + "=" + value + "; HttpOnly; Path=/auth/; Max-Age=" + maxAge + "; SameSite=" + (id === "apple" ? "None" : "Lax") + (secure ? "; Secure" : ""); - // A login waiting to be connected by email; only this browser can confirm it, by signing in to the matching account. - const linkCookie = (value, maxAge = 600) => "house_link=" + value + "; HttpOnly; Path=/auth/; Max-Age=" + maxAge + "; SameSite=Lax" + (secure ? "; Secure" : ""); if (!loginRoute[2]) { if (request.method !== "GET") throw new AppError(405, "Use a sign-in button to start this login."); limit("login:" + request.socket.remoteAddress, 20, 60000); const linking = requestUrl.searchParams.get("link") === "1"; const current = await store.session(token); if (linking && !current.account && !current.profile) throw new AppError(401, "Sign in before connecting another login."); - const pendingLink = requestUrl.searchParams.get("confirmLink") === "1" ? cookieValue(request, "house_link") || undefined : undefined; + const confirming = requestUrl.searchParams.get("confirmLink") === "1"; + if (confirming && request.headers["sec-fetch-site"] && request.headers["sec-fetch-site"] !== "same-origin") throw new AppError(403, "Connect logins from the Agora.Build website."); + const pendingLink = confirming ? cookieValue(request, linkName) || undefined : undefined; const result = await auth.begin(id, { returnTo: requestUrl.searchParams.get("returnTo"), linkToken: linking ? token : undefined, pendingLink }); redirect(response, result.url, [transactionCookie(result.state)]); } else { @@ -175,16 +178,14 @@ export function createAppServer(directory = root, options = {}) { const account = await store.login(result.identity, { linkToken: result.linkToken, pendingLink: result.pendingLink }); const next = new URL(result.returnTo, "https://foundry.invalid"); if (account.pendingLink) { + // No session for the held login; the page asks this browser to confirm with the existing account. next.searchParams.set("signin", "confirm-link"); - next.searchParams.set("login", account.pendingLink.provider); - next.searchParams.set("via", account.pendingLink.providers.join(",")); redirect(response, next.pathname + next.search + next.hash, [linkCookie(account.pendingLink.token), transactionCookie("", 0)]); return; } if (result.connection) await connections.save(account.account.id, result.connection); - next.searchParams.set("signin", account.linked ? "linked" : account.linkMismatch ? "link-mismatch" : account.linkExpired ? "link-expired" : account.linkFailed ? "link-failed" : "success"); - if (account.linked) next.searchParams.set("login", account.linked); - redirect(response, next.pathname + next.search + next.hash, [sessionCookie(account.token), transactionCookie("", 0), ...(result.pendingLink ? [linkCookie("", 0)] : [])]); + next.searchParams.set("signin", account.linkReady ? "confirm-connect" : account.linkMismatch ? "link-mismatch" : account.linkExpired ? "link-expired" : "success"); + redirect(response, next.pathname + next.search + next.hash, [sessionCookie(account.token), transactionCookie("", 0), ...(result.pendingLink && !account.linkReady ? [linkCookie("", 0)] : [])]); } catch { redirect(response, "/?signin=failed", [transactionCookie("", 0)]); } @@ -269,6 +270,12 @@ export function createAppServer(directory = root, options = {}) { await store.logoutEverywhere(token); json(response, 200, { signedOut: true }, { "Set-Cookie": sessionCookie("", 0) }); } + else if (path === "/api/auth/pending-link" && request.method === "GET") json(response, 200, await store.pendingLink(cookieValue(request, linkName), token)); + else if (path === "/api/auth/pending-link" && request.method === "POST") { + // Same-origin only: /api/ POSTs already refuse other origins and cross-site fetches. + const result = await store.resolvePendingLink(cookieValue(request, linkName), token, (await body(request))?.action); + json(response, 200, { linked: result.linked || null, separate: Boolean(result.token), account: result.account || null }, { "Set-Cookie": [linkCookie("", 0), ...(result.token ? [sessionCookie(result.token)] : [])] }); + } else if (path === "/api/auth/logout" && request.method === "POST") { await store.logout(token); json(response, 200, { signedOut: true }, { "Set-Cookie": sessionCookie("", 0) }); diff --git a/scripts/store.mjs b/scripts/store.mjs index 515c658..c36edbb 100644 --- a/scripts/store.mjs +++ b/scripts/store.mjs @@ -7,6 +7,7 @@ export class AppError extends Error { constructor(status, message) { super(message); this.status = status; } } const hash = (value) => createHash("sha256").update(value).digest("hex"); +const same = (entry) => (other) => other.provider === entry.provider && other.issuer === entry.issuer && other.subject === entry.subject; const publicProfile = ({ sessionHash, accountId, ...person }) => person; const publicRoom = ({ videoUrl, ...room }) => ({ ...room, callProvider: "agora", path: "/meet/" + room.id }); const publicAccount = (account) => account ? { id: account.id, name: account.name, avatar: account.avatar, contact: account.contact, providers: account.identities.filter((identity) => identity.provider !== "service").map((identity) => identity.provider), emails: [...new Set(account.identities.map((identity) => identity.email).filter(Boolean))], plan: effectivePlan(account), entitlements: entitlements(effectivePlan(account)), connections: Object.keys(account.connections || {}) } : null; @@ -71,6 +72,8 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence queue = operation; return operation; } + const newAccount = (profile) => ({ id: "account:" + randomUUID(), identities: [], name: String(profile.name || "Builder").slice(0, 60), avatar: safeUrl(profile.avatar || ""), contact: safeUrl(profile.contact || ""), profileId: null, createdAt: new Date(now()).toISOString() }); + const loginsOf = (account) => [...new Set(account.identities.filter((entry) => entry.provider !== "service").map((entry) => entry.provider))]; const accountFor = (state, token) => { const session = token && state.sessions.find((session) => session.hash === hash(token) && session.expiresAt > now()); const grant = token && state.grants.find((grant) => grant.accessHash === hash(token) && grant.accessExpiresAt > now() && validGrant(state, grant)); @@ -122,7 +125,7 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence async accountIdentity(id) { const account = (await read()).accounts.find((account) => account.id === id); if (!account) throw new AppError(404, "Account not found."); - return { sub: account.id, name: account.name, picture: account.avatar, identities: account.identities, plan: effectivePlan(account), entitlements: entitlements(effectivePlan(account)) }; + return { sub: account.id, name: account.name, picture: account.avatar, identities: account.identities.map(({ provider, issuer, subject }) => ({ provider, issuer, subject })), plan: effectivePlan(account), entitlements: entitlements(effectivePlan(account)) }; }, async authorize(token, { clientId, redirectUri, challenge, scopes }) { const code = randomBytes(32).toString("base64url"); @@ -185,14 +188,13 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence async login(identity, { linkToken, pendingLink } = {}) { const { provider, issuer, subject } = identity; // Providers pass an email only when they verified it; it is private and never identifies a user on its own. - const email = typeof identity.email === "string" && identity.email ? identity.email.toLowerCase() : null; - const same = (entry) => (other) => other.provider === entry.provider && other.issuer === entry.issuer && other.subject === entry.subject; + // Undefined means the provider could not be asked this time, so the stored email is kept. + const email = identity.email === undefined ? undefined : typeof identity.email === "string" && identity.email ? identity.email.toLowerCase() : null; if (!["google", "github", "apple", "agora"].includes(provider) || typeof subject !== "string" || !subject || subject.length > 255 || typeof issuer !== "string" || !issuer) throw new AppError(422, "This login identity is invalid."); const token = randomBytes(32).toString("base64url"); return update((state) => { state.pendingLinks = (state.pendingLinks || []).filter((entry) => entry.expiresAt > now()); const waiting = pendingLink ? state.pendingLinks.find((entry) => entry.hash === hash(pendingLink)) || null : null; - if (waiting) state.pendingLinks = state.pendingLinks.filter((entry) => entry !== waiting); let account = state.accounts.find((entry) => entry.identities.some((entry) => entry.provider === provider && entry.issuer === issuer && entry.subject === subject)); const browser = linkToken && state.sessions.find((session) => session.hash === hash(linkToken) && session.expiresAt > now()); const linking = browser && accountFor(state, linkToken); @@ -204,9 +206,9 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence const owner = state.accounts.find((entry) => entry.identities.some((other) => other.email === email)); if (owner) { const raw = randomBytes(32).toString("base64url"); - state.pendingLinks.push({ hash: hash(raw), accountId: owner.id, identity: { provider, issuer, subject, email }, expiresAt: now() + 600000 }); + state.pendingLinks.push({ hash: hash(raw), accountId: owner.id, identity: { provider, issuer, subject, email }, profile: { name: identity.name, avatar: identity.avatar, contact: identity.contact }, confirmedBy: null, expiresAt: now() + 600000 }); state.pendingLinks = state.pendingLinks.slice(-500); - return { pendingLink: { token: raw, provider, providers: [...new Set(owner.identities.filter((entry) => entry.provider !== "service").map((entry) => entry.provider))] } }; + return { pendingLink: { token: raw, provider, providers: loginsOf(owner) } }; } } if (!account) { @@ -214,19 +216,16 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence if (linking) account = linking; else { if (state.accounts.length >= 1000) throw new AppError(409, "Account sign-ups are full for now."); - account = { id: "account:" + randomUUID(), identities: [], name: String(identity.name || "Builder").slice(0, 60), avatar: safeUrl(identity.avatar || ""), contact: safeUrl(identity.contact || ""), profileId: null, createdAt: new Date(now()).toISOString() }; + account = newAccount(identity); state.accounts.push(account); } - account.identities.push({ provider, issuer, subject, email }); - } else account.identities.find(same({ provider, issuer, subject })).email = email; + account.identities.push({ provider, issuer, subject, email: email ?? null }); + } else if (email !== undefined) account.identities.find(same({ provider, issuer, subject })).email = email; + // Signing in to the matching account only readies the held login; connecting it is a separate, explicit step on the portal. let link = {}; if (pendingLink && !waiting) link = { linkExpired: true }; - else if (waiting && waiting.accountId !== account.id) link = { linkMismatch: true }; - else if (waiting) { - const held = waiting.identity; - if (state.accounts.some((entry) => entry.identities.some(same(held))) || account.identities.some((entry) => entry.provider === held.provider)) link = { linkFailed: true }; - else { account.identities.push(held); link = { linked: held.provider }; } - } + else if (waiting && waiting.accountId !== account.id) { state.pendingLinks = state.pendingLinks.filter((entry) => entry !== waiting); link = { linkMismatch: true }; } + else if (waiting) { waiting.confirmedBy = account.id; waiting.expiresAt = now() + 600000; link = { linkReady: true }; } if (legacy && !linking) { account.profileId = legacy.id; legacy.accountId = account.id; @@ -237,6 +236,42 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence return { token, account: publicAccount(account), profile: person ? publicProfile(person) : null, ...link }; }); }, + // What a held login is, for the browser holding it: the provider, a masked email, and whether this session may connect it. + async pendingLink(raw, sessionToken) { + const state = await read(); + const hold = raw && (state.pendingLinks || []).find((entry) => entry.hash === hash(raw) && entry.expiresAt > now()); + if (!hold) throw new AppError(404, "There is no login waiting to be connected."); + const owner = state.accounts.find((account) => account.id === hold.accountId); + const current = accountFor(state, sessionToken); + const [name, domain] = hold.identity.email.split("@"); + return { provider: hold.identity.provider, email: name[0] + "***@" + domain, providers: owner ? loginsOf(owner) : [], ready: Boolean(current && hold.confirmedBy === current.id) }; + }, + async resolvePendingLink(raw, sessionToken, action) { + if (!["connect", "separate", "discard"].includes(action)) throw new AppError(422, "Choose connect, separate, or discard."); + const token = randomBytes(32).toString("base64url"); + return update((state) => { + state.pendingLinks = (state.pendingLinks || []).filter((entry) => entry.expiresAt > now()); + const hold = raw && state.pendingLinks.find((entry) => entry.hash === hash(raw)); + if (!hold) throw new AppError(404, "There is no login waiting to be connected."); + state.pendingLinks = state.pendingLinks.filter((entry) => entry !== hold); + if (action === "discard") return { discarded: true }; + const held = hold.identity; + if (state.accounts.some((account) => account.identities.some(same(held)))) throw new AppError(409, "That login already belongs to an account."); + if (action === "connect") { + const current = accountFor(state, sessionToken); + if (!current || hold.confirmedBy !== current.id) throw new AppError(403, "Sign in to the matching account before connecting this login."); + if (current.identities.some((entry) => entry.provider === held.provider)) throw new AppError(409, "This account already has a login from that provider."); + current.identities.push(held); + return { linked: held.provider, account: publicAccount(current) }; + } + if (state.accounts.length >= 1000) throw new AppError(409, "Account sign-ups are full for now."); + const account = newAccount(hold.profile || {}); + account.identities.push(held); + state.accounts.push(account); + addSession(state, account, token); + return { token, account: publicAccount(account) }; + }); + }, async logout(token) { return update((state) => { for (const grant of state.grants) if (grant.sourceHash === hash(token)) grant.revoked = true; diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs index 1cc1cc3..cbaa6e0 100644 --- a/tests/auth.test.mjs +++ b/tests/auth.test.mjs @@ -222,61 +222,79 @@ test("unconfigured providers report availability without secrets and cannot issu }); const verified = (provider, subject, email) => ({ ...identity(provider, subject), email }); -test("a verified email that already has an account waits for a sign-in with that account before connecting", async () => { - let clock = 1800000000000; - const data = store("email-link", { now: () => clock }); +test("a verified email that already has an account is held, readied by signing in to that account, and connected only on request", async () => { + const data = store("email-link"); const first = await data.login(verified("github", "123", "ada@example.com")); - const second = await data.login(verified("google", "456", "Ada@Example.com")); - assert.equal(second.token, undefined, "no new account or session is created"); - assert.deepEqual(second.pendingLink.providers, ["github"]); - assert.equal(second.pendingLink.provider, "google"); - const confirmed = await data.login(verified("github", "123", "ada@example.com"), { pendingLink: second.pendingLink.token }); - assert.equal(confirmed.account.id, first.account.id); - assert.deepEqual(confirmed.account.providers, ["github", "google"]); - assert.equal(confirmed.linked, "google"); - assert.equal((await data.login(verified("google", "456", "ada@example.com"))).account.id, first.account.id, "either login now signs in to the same user"); - const again = await data.login(verified("github", "123", "ada@example.com"), { pendingLink: second.pendingLink.token }); - assert.equal(again.linkExpired, true, "a pending link works once; signing in still works"); + const held = await data.login(verified("google", "456", "Ada@Example.com")); + assert.equal(held.token, undefined, "no new account or session is created"); + assert.deepEqual([held.pendingLink.provider, held.pendingLink.providers], ["google", ["github"]]); + const raw = held.pendingLink.token; + assert.deepEqual(await data.pendingLink(raw, null), { provider: "google", email: "a***@example.com", providers: ["github"], ready: false }); + await assert.rejects(data.resolvePendingLink(raw, first.token, "connect"), { status: 403 }, "an old session cannot connect without signing in through the hold"); + const confirmed = await data.login(verified("github", "123", "ada@example.com"), { pendingLink: raw }); + assert.equal(confirmed.linkReady, true); + assert.deepEqual(confirmed.account.providers, ["github"], "signing in alone connects nothing"); + assert.equal((await data.pendingLink(raw, confirmed.token)).ready, true); + const stranger = await data.login(identity("github", "999")); + assert.equal((await data.pendingLink(raw, stranger.token)).ready, false, "another account is never ready"); + await assert.rejects(data.resolvePendingLink(raw, stranger.token, "connect"), { status: 403 }); + const linked = await data.resolvePendingLink(raw, confirmed.token, "connect"); + assert.deepEqual([linked.linked, linked.account.providers], ["google", ["github", "google"]]); + assert.equal((await data.login(verified("google", "456", "ada@example.com"))).account.id, first.account.id, "either login now opens the same user"); + await assert.rejects(data.resolvePendingLink(raw, confirmed.token, "connect"), { status: 404 }, "a hold works once"); }); -test("a pending email link is discarded when another account signs in, and expires after ten minutes", async () => { +test("a held login can become its own account, be discarded, or lapse; a different account connects nothing", async () => { let clock = 1800000000000; - const data = store("email-link-mismatch", { now: () => clock }); + const data = store("email-link-choices", { now: () => clock }); const ada = await data.login(verified("github", "1", "ada@example.com")); const bo = await data.login(verified("github", "2", "bo@example.com")); - const waiting = await data.login(verified("google", "g1", "ada@example.com")); - const other = await data.login(verified("github", "2", "bo@example.com"), { pendingLink: waiting.pendingLink.token }); - assert.equal(other.account.id, bo.account.id); - assert.equal(other.linked, undefined); - assert.equal(other.linkMismatch, true); - assert.deepEqual(other.account.providers, ["github"], "nothing is connected to the wrong account"); - const later = await data.login(verified("google", "g1", "ada@example.com")); + const first = await data.login(verified("google", "g1", "ada@example.com")); + const other = await data.login(verified("github", "2", "bo@example.com"), { pendingLink: first.pendingLink.token }); + assert.deepEqual([other.account.id, other.linkMismatch, other.account.providers], [bo.account.id, true, ["github"]]); + await assert.rejects(data.pendingLink(first.pendingLink.token, other.token), { status: 404 }, "a mismatch discards the hold"); + const second = await data.login(verified("google", "g1", "ada@example.com")); + const separate = await data.resolvePendingLink(second.pendingLink.token, null, "separate"); + assert.ok(separate.token); + assert.notEqual(separate.account.id, ada.account.id); + assert.equal((await data.login(verified("google", "g1", "ada@example.com"))).account.id, separate.account.id, "the separate account keeps that login from then on"); + const third = await data.login(verified("apple", "a1", "ada@example.com")); + assert.deepEqual(await data.resolvePendingLink(third.pendingLink.token, null, "discard"), { discarded: true }); + await assert.rejects(data.resolvePendingLink(third.pendingLink.token, null, "separate"), { status: 404 }); + const fourth = await data.login(verified("apple", "a1", "ada@example.com")); clock += 600001; - const expired = await data.login(verified("github", "1", "ada@example.com"), { pendingLink: later.pendingLink.token }); - assert.equal(expired.linkExpired, true); - assert.deepEqual(expired.account.providers, ["github"]); - assert.ok(ada.account); + const lapsed = await data.login(verified("github", "1", "ada@example.com"), { pendingLink: fourth.pendingLink.token }); + assert.deepEqual([lapsed.linkExpired, lapsed.account.providers], [true, ["github"]]); + await assert.rejects(data.resolvePendingLink(fourth.pendingLink.token, null, "nonsense"), { status: 422 }); +}); +test("connecting refuses a provider the account already has", async () => { + const data = store("email-link-conflict"); + const ada = await data.login(verified("github", "1", "ada@example.com")); + await data.login(verified("google", "g1", "other@example.com"), { linkToken: ada.token }); + const held = await data.login(verified("google", "g2", "ada@example.com")); + const ready = await data.login(verified("github", "1", "ada@example.com"), { pendingLink: held.pendingLink.token }); + await assert.rejects(data.resolvePendingLink(held.pendingLink.token, ready.token, "connect"), { status: 409 }); }); -test("logins without a verified email, or with a new email, create their own account; emails are private to the owner", async () => { +test("logins without a verified email create their own account; a failed lookup keeps the stored email; apps never see emails", async () => { const data = store("email-none"); const ada = await data.login(verified("github", "1", "ada@example.com")); assert.deepEqual(ada.account.emails, ["ada@example.com"]); - const noEmail = await data.login(identity("google", "g1")); - assert.notEqual(noEmail.account.id, ada.account.id); - const fresh = await data.login(verified("apple", "a1", "someone@privaterelay.appleid.com")); - assert.notEqual(fresh.account.id, ada.account.id); - const explicit = await data.login(verified("google", "g2", "ada@example.com"), { linkToken: ada.token }); - assert.equal(explicit.account.id, ada.account.id, "explicit connecting from a signed-in account still works directly"); - const changed = await data.login(verified("github", "1", "ada@new.example")); - assert.deepEqual(changed.account.emails.sort(), ["ada@example.com", "ada@new.example"].sort(), "each login keeps its own latest email"); + assert.notEqual((await data.login(identity("google", "g1"))).account.id, ada.account.id); + assert.notEqual((await data.login(verified("apple", "a1", "someone@privaterelay.appleid.com"))).account.id, ada.account.id); + assert.equal((await data.login(verified("google", "g2", "ada@example.com"), { linkToken: ada.token })).account.id, ada.account.id, "explicit connecting still works directly"); + assert.deepEqual((await data.login({ ...identity("github", "1"), email: undefined })).account.emails.sort(), ["ada@example.com"], "an unanswered lookup keeps the email"); + assert.deepEqual((await data.login(verified("github", "1", null))).account.emails, ["ada@example.com"], "a positively unverified email clears GitHub's, leaving Google's"); + const lookup = await data.accountIdentity(ada.account.id); + assert.ok(lookup.identities.length >= 2); + assert.ok(lookup.identities.every((entry) => !("email" in entry)), "identity lookup for apps carries no emails"); }); -test("providers report only verified emails", async () => { - for (const [emails, expected] of [[[{ email: "Ada@Example.com", primary: true, verified: true }, { email: "x@example.com", primary: false, verified: true }], "ada@example.com"], [[{ email: "ada@example.com", primary: true, verified: false }], null], [[], null]]) { +test("providers report only verified ASCII emails, and a failed GitHub lookup reports nothing", async () => { + for (const [emails, expected] of [[[{ email: "Ada@Example.com", primary: true, verified: true }, { email: "x@example.com", primary: false, verified: true }], "ada@example.com"], [[{ email: "ada@example.com", primary: true, verified: false }], null], [[], null], [{ message: "Requires authentication" }, undefined], [[{ email: "Kada@example.com", primary: true, verified: true }], null]]) { const upstream = providerRequests({ id: "github", githubEmails: emails }); const auth = createAuth(configuration(), { request: upstream.request }); const started = await auth.begin("github"); assert.match(new URL(started.url).searchParams.get("scope"), /user:email/); const result = await auth.complete("github", new URLSearchParams({ code: "test-code", state: started.state }), started.state); - assert.equal(result.identity.email, expected); + assert.equal(result.identity.email, expected, JSON.stringify(emails)); } for (const [claims, expected] of [[{ email: "Ada@Example.com", email_verified: true }, "ada@example.com"], [{ email: "ada@example.com", email_verified: false }, null], [{ email: "ada@example.com", email_verified: "true" }, "ada@example.com"], [{}, null]]) { let nonce; @@ -288,7 +306,7 @@ test("providers report only verified emails", async () => { assert.equal(result.identity.email, expected, JSON.stringify(claims)); } }); -test("HTTP: a second provider with the same verified email is held in this browser and connected by signing in to the first", async () => { +test("HTTP: a held login is confirmed by signing in to the matching account, then connected by a same-origin request", async () => { const data = store("http-email-link"); let nonce; const google = providerRequests({ nonce: () => nonce, claims: { email: "ada@example.com", email_verified: true } }); @@ -303,21 +321,33 @@ test("HTTP: a second provider with the same verified email is held in this brows const transaction = start.headers.get("set-cookie").split(";")[0]; return fetch(base + "/auth/" + id + "/callback?code=test-code&state=" + url.searchParams.get("state"), { headers: { Cookie: [transaction, ...cookies].join("; ") }, redirect: "manual" }); }; + const cookie = (response, name) => response.headers.getSetCookie().find(entry => entry.startsWith(name + "=")); try { - const first = await signIn("github"); - assert.equal(first.headers.get("location"), "/account.html?signin=success"); + assert.equal((await signIn("github")).headers.get("location"), "/account.html?signin=success"); const second = await signIn("google"); - assert.equal(second.headers.get("location"), "/account.html?signin=confirm-link&login=google&via=github"); - assert.ok(!second.headers.getSetCookie().some(cookie => cookie.startsWith("house_session=")), "no session for the held login"); - const held = second.headers.getSetCookie().find(cookie => cookie.startsWith("house_link=")); - assert.match(held, /HttpOnly; Path=\/auth\/; Max-Age=600; SameSite=Lax; Secure/); - const confirmed = await signIn("github", "&confirmLink=1", [held.split(";")[0]]); - assert.equal(confirmed.headers.get("location"), "/account.html?signin=linked&login=google"); - assert.ok(confirmed.headers.getSetCookie().some(cookie => cookie.startsWith("house_link=;")), "the held login is cleared"); - const session = confirmed.headers.getSetCookie().find(cookie => cookie.startsWith("house_session=")).split(";")[0]; + assert.equal(second.headers.get("location"), "/account.html?signin=confirm-link"); + assert.equal(cookie(second, "house_session"), undefined, "no session for the held login"); + const held = cookie(second, "__Host-house_link"); + assert.match(held, /HttpOnly; SameSite=Lax; Path=\/; Max-Age=600; Secure/); + const heldCookie = held.split(";")[0]; + assert.deepEqual(await (await fetch(base + "/api/auth/pending-link", { headers: { Cookie: heldCookie } })).json(), { provider: "google", email: "a***@example.com", providers: ["github"], ready: false }); + const crossSite = await fetch(base + "/auth/github?confirmLink=1", { headers: { Cookie: heldCookie, "Sec-Fetch-Site": "cross-site" }, redirect: "manual" }); + assert.equal(crossSite.status, 403, "another site cannot start the confirming sign-in"); + const confirmed = await signIn("github", "&confirmLink=1", [heldCookie]); + assert.equal(confirmed.headers.get("location"), "/account.html?signin=confirm-connect"); + assert.equal(cookie(confirmed, "__Host-house_link"), undefined, "the hold stays until the person chooses"); + const session = cookie(confirmed, "house_session").split(";")[0]; + const both = [session, heldCookie].join("; "); + assert.equal((await (await fetch(base + "/api/auth/pending-link", { headers: { Cookie: both } })).json()).ready, true); + assert.deepEqual((await (await fetch(base + "/api/me", { headers: { Cookie: session } })).json()).account.providers, ["github"], "nothing is connected by the sign-in alone"); + const forged = await fetch(base + "/api/auth/pending-link", { method: "POST", headers: { Cookie: both, "Content-Type": "application/json", Origin: "https://evil.example" }, body: JSON.stringify({ action: "connect" }) }); + assert.equal(forged.status, 403, "other origins cannot connect it"); + const connect = await fetch(base + "/api/auth/pending-link", { method: "POST", headers: { Cookie: both, "Content-Type": "application/json" }, body: JSON.stringify({ action: "connect" }) }); + assert.equal(connect.status, 200); + assert.equal((await connect.json()).linked, "google"); + assert.match(cookie(connect, "__Host-house_link"), /Max-Age=0/); const me = await (await fetch(base + "/api/me", { headers: { Cookie: session } })).json(); assert.deepEqual([me.account.providers, me.account.emails], [["github", "google"], ["ada@example.com"]]); - const again = await signIn("google"); - assert.equal(again.headers.get("location"), "/account.html?signin=success", "Google now signs straight in to the same user"); + assert.equal((await signIn("google")).headers.get("location"), "/account.html?signin=success", "Google now signs straight in to the same user"); } finally { await new Promise(done => { server.close(done); server.closeIdleConnections(); }); } }); From 32134ea3d1c515c2d684f4f1eec7298da1f84a9b Mon Sep 17 00:00:00 2001 From: Brent G Date: Sun, 11 Oct 2026 03:35:54 +0000 Subject: [PATCH 3/3] Tie connecting to the confirming session and show the held login's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- script.js | 4 +++- scripts/store.mjs | 9 ++++++--- tests/auth.test.mjs | 9 ++++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/script.js b/script.js index ff458cb..a08d26e 100644 --- a/script.js +++ b/script.js @@ -109,7 +109,9 @@ async function confirmLink() { } }, [el("span", { class: "login-provider-mark mono", "aria-hidden": "true", text: id === "agora" ? "a_" : nameOf(id)[0] }), el("span", { text: "Continue with " + nameOf(id) })]))), separate ], "Not you? Close this and nothing is connected. The request expires in ten minutes."); + // Closing by Esc or by the close button means "not now": the held login is discarded. dialog.addEventListener("cancel", () => { resolveLink("discard").catch(() => {}); }); + dialog.querySelector(".close-button").addEventListener("click", () => { resolveLink("discard").catch(() => {}); }); } async function confirmConnect() { let held; @@ -120,7 +122,7 @@ async function confirmConnect() { try { const result = await resolveLink(action); dialog.close(); await refreshSession(); notify(result.linked ? nameOf(result.linked) + " is now connected. Either login opens this account." : "Nothing was connected."); } catch (error) { dialog.close(); notify(error.message); } }; - dialog = linkDialog("CONNECT A LOGIN", "Connect " + nameOf(held.provider) + " to this account?", nameOf(held.provider) + " (" + held.email + ") will sign in to this account from now on.", [ + dialog = linkDialog("CONNECT A LOGIN", "Connect " + nameOf(held.provider) + " to this account?", nameOf(held.provider) + (held.name ? " login “" + held.name + "”" : "") + " (" + held.email + ") will sign in to this account from now on. Connect it only if it is yours.", [ el("div", { class: "dialog-actions" }, [el("button", { class: "button", type: "button", text: "Connect " + nameOf(held.provider), onclick: choose("connect") }), el("button", { class: "button button-secondary", type: "button", text: "Don't connect", onclick: choose("discard") })]) ], "Only connect a login you own."); }api("/api/auth/providers").then((result) => { providers = result.providers; renderSignIn(); }).catch(() => { loginStatus.textContent = "Sign-in options could not load. You can continue with this browser."; }); diff --git a/scripts/store.mjs b/scripts/store.mjs index c36edbb..f1ead6c 100644 --- a/scripts/store.mjs +++ b/scripts/store.mjs @@ -225,7 +225,7 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence let link = {}; if (pendingLink && !waiting) link = { linkExpired: true }; else if (waiting && waiting.accountId !== account.id) { state.pendingLinks = state.pendingLinks.filter((entry) => entry !== waiting); link = { linkMismatch: true }; } - else if (waiting) { waiting.confirmedBy = account.id; waiting.expiresAt = now() + 600000; link = { linkReady: true }; } + else if (waiting) { waiting.confirmedBy = account.id; waiting.confirmedSession = hash(token); waiting.expiresAt = now() + 600000; link = { linkReady: true }; } if (legacy && !linking) { account.profileId = legacy.id; legacy.accountId = account.id; @@ -244,7 +244,7 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence const owner = state.accounts.find((account) => account.id === hold.accountId); const current = accountFor(state, sessionToken); const [name, domain] = hold.identity.email.split("@"); - return { provider: hold.identity.provider, email: name[0] + "***@" + domain, providers: owner ? loginsOf(owner) : [], ready: Boolean(current && hold.confirmedBy === current.id) }; + return { provider: hold.identity.provider, email: name[0] + "***@" + domain, name: String(hold.profile?.name || "").slice(0, 60), providers: owner ? loginsOf(owner) : [], ready: Boolean(current && hold.confirmedBy === current.id && sessionToken && hold.confirmedSession === hash(sessionToken)) }; }, async resolvePendingLink(raw, sessionToken, action) { if (!["connect", "separate", "discard"].includes(action)) throw new AppError(422, "Choose connect, separate, or discard."); @@ -259,7 +259,8 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence if (state.accounts.some((account) => account.identities.some(same(held)))) throw new AppError(409, "That login already belongs to an account."); if (action === "connect") { const current = accountFor(state, sessionToken); - if (!current || hold.confirmedBy !== current.id) throw new AppError(403, "Sign in to the matching account before connecting this login."); + // Only the very session that signed in through the hold may connect it. + if (!current || hold.confirmedBy !== current.id || !sessionToken || hold.confirmedSession !== hash(sessionToken)) throw new AppError(403, "Sign in to the matching account before connecting this login."); if (current.identities.some((entry) => entry.provider === held.provider)) throw new AppError(409, "This account already has a login from that provider."); current.identities.push(held); return { linked: held.provider, account: publicAccount(current) }; @@ -268,6 +269,8 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence const account = newAccount(hold.profile || {}); account.identities.push(held); state.accounts.push(account); + // The browser switches to the new account, so its previous session ends rather than lingering. + if (sessionToken) state.sessions = state.sessions.filter((session) => session.hash !== hash(sessionToken)); addSession(state, account, token); return { token, account: publicAccount(account) }; }); diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs index cbaa6e0..cde53a2 100644 --- a/tests/auth.test.mjs +++ b/tests/auth.test.mjs @@ -229,12 +229,14 @@ test("a verified email that already has an account is held, readied by signing i assert.equal(held.token, undefined, "no new account or session is created"); assert.deepEqual([held.pendingLink.provider, held.pendingLink.providers], ["google", ["github"]]); const raw = held.pendingLink.token; - assert.deepEqual(await data.pendingLink(raw, null), { provider: "google", email: "a***@example.com", providers: ["github"], ready: false }); + assert.deepEqual(await data.pendingLink(raw, null), { provider: "google", email: "a***@example.com", name: "Signed-in builder", providers: ["github"], ready: false }); await assert.rejects(data.resolvePendingLink(raw, first.token, "connect"), { status: 403 }, "an old session cannot connect without signing in through the hold"); const confirmed = await data.login(verified("github", "123", "ada@example.com"), { pendingLink: raw }); assert.equal(confirmed.linkReady, true); assert.deepEqual(confirmed.account.providers, ["github"], "signing in alone connects nothing"); assert.equal((await data.pendingLink(raw, confirmed.token)).ready, true); + assert.equal((await data.pendingLink(raw, first.token)).ready, false, "another session of the same account is not the one that confirmed"); + await assert.rejects(data.resolvePendingLink(raw, first.token, "connect"), { status: 403 }); const stranger = await data.login(identity("github", "999")); assert.equal((await data.pendingLink(raw, stranger.token)).ready, false, "another account is never ready"); await assert.rejects(data.resolvePendingLink(raw, stranger.token, "connect"), { status: 403 }); @@ -253,7 +255,8 @@ test("a held login can become its own account, be discarded, or lapse; a differe assert.deepEqual([other.account.id, other.linkMismatch, other.account.providers], [bo.account.id, true, ["github"]]); await assert.rejects(data.pendingLink(first.pendingLink.token, other.token), { status: 404 }, "a mismatch discards the hold"); const second = await data.login(verified("google", "g1", "ada@example.com")); - const separate = await data.resolvePendingLink(second.pendingLink.token, null, "separate"); + const separate = await data.resolvePendingLink(second.pendingLink.token, bo.token, "separate"); + assert.equal((await data.session(bo.token)).account, null, "the browser's previous session ends"); assert.ok(separate.token); assert.notEqual(separate.account.id, ada.account.id); assert.equal((await data.login(verified("google", "g1", "ada@example.com"))).account.id, separate.account.id, "the separate account keeps that login from then on"); @@ -330,7 +333,7 @@ test("HTTP: a held login is confirmed by signing in to the matching account, the const held = cookie(second, "__Host-house_link"); assert.match(held, /HttpOnly; SameSite=Lax; Path=\/; Max-Age=600; Secure/); const heldCookie = held.split(";")[0]; - assert.deepEqual(await (await fetch(base + "/api/auth/pending-link", { headers: { Cookie: heldCookie } })).json(), { provider: "google", email: "a***@example.com", providers: ["github"], ready: false }); + assert.deepEqual(await (await fetch(base + "/api/auth/pending-link", { headers: { Cookie: heldCookie } })).json(), { provider: "google", email: "a***@example.com", name: "Verified provider name", providers: ["github"], ready: false }); const crossSite = await fetch(base + "/auth/github?confirmLink=1", { headers: { Cookie: heldCookie, "Sec-Fetch-Site": "cross-site" }, redirect: "manual" }); assert.equal(crossSite.status, 403, "another site cannot start the confirming sign-in"); const confirmed = await signIn("github", "&confirmLink=1", [heldCookie]);