Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 28 additions & 2 deletions docs/platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<provider>?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

Expand Down
53 changes: 52 additions & 1 deletion script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"); }
Expand Down
20 changes: 14 additions & 6 deletions scripts/auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.");
Expand All @@ -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) {
Expand All @@ -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");
Expand All @@ -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."); }
}
};
Expand Down
Loading
Loading