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
35 changes: 35 additions & 0 deletions docs/specs/2026-09-14-v5-data-platform-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions v5/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Comment on lines +149 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the canonical auth guidance for the new allowlist

Document this variable in the scoped v5 guidance and the Stage 3 deployment instructions: the former still says AUTH_SUPER_ADMIN_EMAILS is the one remaining environment list, while the latter omits AUTH_ALLOWED_EMAILS entirely. An operator following those canonical instructions cannot configure the off-domain floor that this change is intended to support, leaving the lock-out recovery procedure incomplete.

AGENTS.md reference: v5/AGENTS.md:L109-L114

Useful? React with 👍 / 👎.


# The super-admin FLOOR — comma-separated addresses. Not a roster: everyone
# else's role is the `user.role` column, changed on /admin/users.
#
Expand Down
13 changes: 11 additions & 2 deletions v5/src/lib/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -104,6 +104,7 @@ export async function getAuth(): Promise<AuthInstance | null> {
export function createAuth(db: Db) {
const secret = process.env.AUTH_SECRET || "";
const domain = allowedEmailDomain();
const namedExceptions = allowedEmails().length > 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the allowlist to the auth-instance fingerprint

When AUTH_ALLOWED_EMAILS changes after getAuth() has memoized an instance, the new value changes namedExceptions and therefore whether Better Auth receives hd, but envFingerprint() does not include this variable. The cached instance can consequently retain the institutional hd check after an off-domain exception is enabled, causing Google to reject that named account before isAllowedEmail() runs. Include AUTH_ALLOWED_EMAILS in the fingerprint so configuration changes rebuild the instance as intended.

Useful? React with 👍 / 👎.


return betterAuth({
secret,
Expand All @@ -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 }),
},
}
: {},
Expand Down
23 changes: 23 additions & 0 deletions v5/src/lib/auth/roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
31 changes: 27 additions & 4 deletions v5/src/lib/auth/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down