diff --git a/README.md b/README.md index b8b206a..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. 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 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. @@ -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..e7dabbb 100644 --- a/docs/platform.md +++ b/docs/platform.md @@ -7,8 +7,34 @@ 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). 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 457bdbf..a08d26e 100644 --- a/script.js +++ b/script.js @@ -78,7 +78,54 @@ function renderSignIn() { })); } export function openSignIn() { renderSignIn(); loginDialog.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."; }); + +// 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 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: 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) })]))), + 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; + 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.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."; }); export function openJoin(callback, browserOnly = false) { afterJoin = typeof callback === "function" ? callback : null; @@ -214,6 +261,10 @@ sessionReady.then(() => { url.searchParams.delete("signin"); 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(); 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; 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..4b72cf6 100644 --- a/scripts/auth.mjs +++ b/scripts/auth.mjs @@ -56,6 +56,10 @@ 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. +// 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(); const clients = new Map(); @@ -107,7 +111,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 +120,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 +146,11 @@ 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. + // 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: Array.isArray(emails) ? verifiedEmail(primary?.email, true) : undefined }; } else { const claims = tokens.claims(); if (!claims?.sub) throw new Error("Missing identity"); @@ -151,9 +159,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..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); } @@ -162,18 +165,27 @@ export function createAppServer(directory = root, options = {}) { 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 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 { 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) { + // No session for the held login; the page asks this browser to confirm with the existing account. + next.searchParams.set("signin", "confirm-link"); + 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.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)]); } @@ -258,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 accc609..f1ead6c 100644 --- a/scripts/store.mjs +++ b/scripts/store.mjs @@ -7,9 +7,10 @@ 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), 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); @@ -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)); @@ -113,12 +116,16 @@ 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); 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"); @@ -178,27 +185,47 @@ 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. + // 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; 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 }, 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: loginsOf(owner) } }; + } + } 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; 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 }); - } + 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) { state.pendingLinks = state.pendingLinks.filter((entry) => entry !== waiting); link = { linkMismatch: 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; @@ -206,7 +233,46 @@ 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 }; + }); + }, + // 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, 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."); + 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); + // 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) }; + } + 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); + // 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) }; }); }, async logout(token) { diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs index 3cdd7d9..cde53a2 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,137 @@ 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 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 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", 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 }); + 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 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-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 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, 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"); + 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 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 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"]); + 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 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, 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; + 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 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 } }); + 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" }); + }; + const cookie = (response, name) => response.headers.getSetCookie().find(entry => entry.startsWith(name + "=")); + try { + 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"); + 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", 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]); + 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"]]); + 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(); }); } +}); 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);