From 03e6861e3f2bf2c7578bc2fe698ee92dd19aec69 Mon Sep 17 00:00:00 2001 From: Isaac S Date: Tue, 22 Sep 2026 17:33:54 -0400 Subject: [PATCH] AUTH_ALLOWED_EMAILS, so the domain rule cannot become a lock-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The super-admin floor exists so nobody can be locked out permanently, but isSuperAdminFloor calls isAllowedEmail first, so a floor entry on any other domain was silently ignored: the environment variable looked set and did nothing. That is the worst shape a safety net can have, and it bites exactly the accounts that need it — the people holding super_admin are the ones whose institutional address is temporary. isAllowedEmail now admits an address on the configured domain or one named in AUTH_ALLOWED_EMAILS. Named exceptions, never a second open domain: one entry admits one person, and adding one is a deliberate act by somebody who can already deploy. Empty by default, where it changes nothing. Setting it drops the Google hd hint, and that is not optional. Better Auth verifies the hd claim on the returned id token and a personal account carries none, so leaving it on would refuse every named exception before this app's own check ran. The picker widens; the two server-side checks do not move. Verified with the credentials unset: 97 files / 1362 tests, typecheck clean, spec:coverage 74 items / 0 undocumented. The new tests assert the part that matters — naming one gmail address does not admit a second one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE --- .../2026-09-14-v5-data-platform-design.md | 35 +++++++++++++++++++ v5/.env.example | 8 +++++ v5/src/lib/auth/config.ts | 13 +++++-- v5/src/lib/auth/roles.test.ts | 23 ++++++++++++ v5/src/lib/auth/roles.ts | 31 +++++++++++++--- 5 files changed, 104 insertions(+), 6 deletions(-) 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. */