diff --git a/docs/specs/2026-09-14-v5-data-platform-design.md b/docs/specs/2026-09-14-v5-data-platform-design.md index 35f3bc8..fc74715 100644 --- a/docs/specs/2026-09-14-v5-data-platform-design.md +++ b/docs/specs/2026-09-14-v5-data-platform-design.md @@ -1361,3 +1361,38 @@ permission, so an anonymous caller can make the server read up to 18 MB before i stored and no URL is returned, and the 15/min-per-IP limiter is what bounds it. **Status.** Accepted. §3.4 is no longer open. + +### 2026-09-22 — `AUTH_ALLOWED_EMAILS`, so the domain rule cannot become a lock-out + +**A new environment variable: `AUTH_ALLOWED_EMAILS`**, a comma-separated list of full addresses +allowed to sign in whatever domain they are on. Empty by default, and empty in the Cornell Tech +deployment, where it changes nothing. + +**Why.** §3.4's domain rule is right for a university — it admits every student without anybody +maintaining a roster — but it ties every account to one institution's Google Workspace, including +the accounts that must never be locked out. The people who hold `super_admin` are exactly the ones +whose institutional address is temporary: a director who graduates, a maintainer after the project +leaves Cornell, a handover to somebody who does not have a `cornell.edu` address yet. The +super-admin floor was built to make lock-out unrecoverable-proof, but `isSuperAdminFloor` calls +`isAllowedEmail` first, so a floor entry on any other domain was **silently ignored** — the +env var would look set and do nothing, which is the worst shape a safety net can have. + +**Named exceptions, never a second open domain.** `isAllowedEmail` now passes an address on the +configured domain **or** one named in the list. Adding an entry is a deliberate act by somebody who +can already deploy, and it admits exactly one person, not a provider. `AUTH_ALLOWED_EMAIL_DOMAIN` +keeps doing all the work for everybody else. + +**One consequence worth stating plainly: setting this drops the Google `hd` hint.** `hd` is not only +a hint to the account picker — Better Auth verifies the claim on the returned id token, and a +personal Google account carries no `hd` at all. Left on, it would refuse every named exception +before this app's own check ran. So `socialProviders.google.hd` is omitted whenever the list is +non-empty. The picker gets wider; the two server-side enforcement points in §3.4 do not move, and +they are the control. A deployment that leaves the list empty is byte-identical to before. + +**The bootstrap, decided 2026-09-22.** `AUTH_SUPER_ADMIN_EMAILS=ies22@cornell.edu` remains the +floor for launch — it works today with no code change. `steinbergisaac@gmail.com` goes in +`AUTH_ALLOWED_EMAILS`, and may be added to the floor, so access survives the Cornell account. + +**Status.** Accepted. Tested in `roles.test.ts`: an off-domain named address is admitted, its +domain is **not** opened to anybody else, the institutional domain keeps working alongside it, and +an empty list behaves exactly as the domain rule did. diff --git a/v5/.env.example b/v5/.env.example index a8a05e1..a1b9ded 100644 --- a/v5/.env.example +++ b/v5/.env.example @@ -146,6 +146,14 @@ AUTH_BASE_URL=http://localhost:3000 # also sent to Google as the `hd` hint, which only narrows the account picker. AUTH_ALLOWED_EMAIL_DOMAIN=cornell.edu +# Addresses allowed in BY NAME, whatever domain they are on — comma-separated. +# Named exceptions, never a second open domain: for a maintainer or a director +# whose institutional account is temporary. Setting any value here drops the +# Google `hd` hint, because Better Auth verifies that claim and a personal +# account carries none. Empty (the Cornell Tech deployment's setting) leaves +# the domain rule exactly as it was. +AUTH_ALLOWED_EMAILS= + # The super-admin FLOOR — comma-separated addresses. Not a roster: everyone # else's role is the `user.role` column, changed on /admin/users. # diff --git a/v5/src/lib/auth/config.ts b/v5/src/lib/auth/config.ts index ba7c5e7..f11f98a 100644 --- a/v5/src/lib/auth/config.ts +++ b/v5/src/lib/auth/config.ts @@ -9,7 +9,7 @@ import { admin } from "better-auth/plugins/admin"; import { dataSubstrate, getDb } from "../db/client"; import * as schema from "../db/schema/index"; import { ac, roles } from "./permissions"; -import { allowedEmailDomain, isAllowedEmail } from "./roles"; +import { allowedEmailDomain, allowedEmails, isAllowedEmail } from "./roles"; import { isSuperAdminFloor } from "./super-admins"; import type { Db } from "../db/types"; @@ -104,6 +104,7 @@ export async function getAuth(): Promise { export function createAuth(db: Db) { const secret = process.env.AUTH_SECRET || ""; const domain = allowedEmailDomain(); + const namedExceptions = allowedEmails().length > 0; return betterAuth({ secret, @@ -119,7 +120,15 @@ export function createAuth(db: Db) { clientId: process.env.GOOGLE_CLIENT_ID || "", clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", // UI hint + Better Auth's `hd` claim check. Not the control. - hd: domain, + // + // Dropped entirely once AUTH_ALLOWED_EMAILS names anybody, because + // `hd` is not only a hint to Google: Better Auth verifies the claim + // on the returned id token, and a personal account carries no `hd` + // at all. Left on, it would refuse every named exception before + // this app's own check ran — the allowlist would look configured + // and do nothing. The picker gets wider; the two enforcement + // points below do not move. + ...(namedExceptions ? {} : { hd: domain }), }, } : {}, diff --git a/v5/src/lib/auth/roles.test.ts b/v5/src/lib/auth/roles.test.ts index 8d90c1a..8e1e0ce 100644 --- a/v5/src/lib/auth/roles.test.ts +++ b/v5/src/lib/auth/roles.test.ts @@ -72,6 +72,29 @@ describe("isAllowedEmail", () => { expect(isAllowedEmail("someone@mail.cornell.edu")).toBe(false); }); + it("accepts an address named in AUTH_ALLOWED_EMAILS, off-domain", () => { + vi.stubEnv("AUTH_ALLOWED_EMAILS", "steinbergisaac@gmail.com"); + expect(isAllowedEmail("steinbergisaac@gmail.com")).toBe(true); + // Normalized on both sides, like every other address in this module. + expect(isAllowedEmail(" SteinbergIsaac@Gmail.com ")).toBe(true); + }); + + it("does not open the named address's domain to anybody else", () => { + // The whole point: exceptions are addresses, never domains. + vi.stubEnv("AUTH_ALLOWED_EMAILS", "steinbergisaac@gmail.com"); + expect(isAllowedEmail("someone.else@gmail.com")).toBe(false); + }); + + it("keeps accepting the institutional domain alongside the exceptions", () => { + vi.stubEnv("AUTH_ALLOWED_EMAILS", "steinbergisaac@gmail.com"); + expect(isAllowedEmail("abc123@cornell.edu")).toBe(true); + }); + + it("changes nothing when the list is empty or unset", () => { + vi.stubEnv("AUTH_ALLOWED_EMAILS", ""); + expect(isAllowedEmail("someone@gmail.com")).toBe(false); + }); + it("rejects null, undefined, and empty input", () => { expect(isAllowedEmail(null)).toBe(false); expect(isAllowedEmail(undefined)).toBe(false); diff --git a/v5/src/lib/auth/roles.ts b/v5/src/lib/auth/roles.ts index e740c4a..922e248 100644 --- a/v5/src/lib/auth/roles.ts +++ b/v5/src/lib/auth/roles.ts @@ -65,14 +65,37 @@ export function allowedEmailDomain(): string { } /** - * Server-side domain check. Google's `hd` parameter narrows the account picker - * and is a **UI hint, not a security control** — this is the enforcement, and it - * runs again on every request that resolves an identity. + * Addresses allowed in by name, whatever domain they are on. + * + * The domain rule is the right control for a university: it admits every + * student without anybody maintaining a list. But it ties the app to one + * institution's Google Workspace, and the people who must never be locked out + * are exactly the ones whose institutional account is temporary — a director + * who graduates, a handover to somebody who does not have a `cornell.edu` + * address yet, a maintainer after the project leaves the university. + * + * So: named exceptions, never a second open domain. Each entry is one full + * address, and adding one is a deliberate act by somebody who can already + * deploy. An empty list — the default, and the Cornell Tech deployment's + * setting — leaves the domain rule exactly as it was. + */ +export function allowedEmails(): string[] { + return parseEmailList(process.env.AUTH_ALLOWED_EMAILS); +} + +/** + * Server-side sign-in check: on the institution's domain, or named in + * {@link allowedEmails}. + * + * Google's `hd` parameter narrows the account picker and is a **UI hint, not a + * security control** — this is the enforcement, and it runs again on every + * request that resolves an identity. */ export function isAllowedEmail(email: string | null | undefined): boolean { const normalized = normalizeEmail(email); if (!normalized) return false; - return normalized.endsWith(`@${allowedEmailDomain()}`); + if (normalized.endsWith(`@${allowedEmailDomain()}`)) return true; + return allowedEmails().includes(normalized); } /** Lower-case and trim an address; returns "" for anything unusable. */