Skip to content

Commit 5ea28ef

Browse files
committed
Merge branch 'main' into release
2 parents 0ed175a + 01a0ee2 commit 5ea28ef

4 files changed

Lines changed: 96 additions & 6 deletions

File tree

.env_template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ PORT=
99
IMAGE_UPLOAD_URL=
1010
UPLOAD_BUCKET_NAME=
1111
UPLOAD_SIZE_LIMIT=
12+
# Comma-separated full email addresses allowed in addition to @cornell.edu (case-insensitive)
13+
EMAIL_WHITELIST=

src/api/middlewares/FirebaseAuth.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
import { firebaseAdmin } from "../../firebase";
88
import { getManager } from "typeorm";
99
import { UserModel } from "../../models/UserModel";
10+
import {
11+
buildForbiddenEmailMessage,
12+
isAllowedLoginEmail,
13+
} from "../../utils/allowlistedEmail";
1014

1115
export const FirebaseCurrentUserChecker = async (
1216
action: Action,
@@ -25,9 +29,8 @@ export const FirebaseCurrentUserChecker = async (
2529
const decodedToken = await firebaseAdmin.auth().verifyIdToken(token);
2630
const userId = decodedToken.uid;
2731
const email = decodedToken.email;
28-
// Enforce Cornell email domain restriction
29-
if (email && !email.endsWith("@cornell.edu")) {
30-
throw new ForbiddenError("Only Cornell email addresses are allowed");
32+
if (!isAllowedLoginEmail(email)) {
33+
throw new ForbiddenError(buildForbiddenEmailMessage(email));
3134
}
3235
// Fetch and return the user from the database
3336
const user = await getManager().findOne(UserModel, {
@@ -38,6 +41,9 @@ export const FirebaseCurrentUserChecker = async (
3841
}
3942
return user;
4043
} catch (error) {
44+
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
45+
throw error;
46+
}
4147
throw new UnauthorizedError("Invalid or expired authorization token");
4248
}
4349
};

src/app.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import resellConnection from './utils/DB';
2626
import { ReportService } from './services/ReportService';
2727
import { reportToString } from './utils/Requests';
2828
import { startTransactionConfirmationCron } from './cron/transactionCron';
29+
import {
30+
buildForbiddenEmailMessage,
31+
isAllowedLoginEmail,
32+
} from './utils/allowlistedEmail';
2933

3034
// Setup dependency injection containers
3135
routingUseContainer(Container);
@@ -56,13 +60,12 @@ async function main() {
5660
try {
5761
// Verify the token using Firebase Admin SDK
5862
const decodedToken = await admin.auth().verifyIdToken(token);
59-
// Check if the email is a Cornell email
6063
const email = decodedToken.email;
6164
const userId = decodedToken.uid;
6265
action.request.email = email;
6366
action.request.firebaseUid = userId;
64-
if (!email || !email.endsWith("@cornell.edu")) {
65-
throw new ForbiddenError("Only Cornell email addresses are allowed");
67+
if (!isAllowedLoginEmail(email)) {
68+
throw new ForbiddenError(buildForbiddenEmailMessage(email));
6669
}
6770
// Find or create user in your database using Firebase UID
6871
const manager = getManager();

src/utils/allowlistedEmail.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
const CORNELL_SUFFIX = "@cornell.edu";
2+
3+
function normalizeEmail(email: string): string {
4+
return email.trim().toLowerCase();
5+
}
6+
7+
/** Full addresses from EMAIL_WHITELIST (comma-separated), lowercased. */
8+
export function getEmailWhitelist(): Set<string> {
9+
const raw = process.env.EMAIL_WHITELIST ?? "";
10+
const set = new Set<string>();
11+
for (const part of raw.split(",")) {
12+
const normalized = normalizeEmail(part);
13+
if (normalized.length > 0) {
14+
set.add(normalized);
15+
}
16+
}
17+
return set;
18+
}
19+
20+
/**
21+
* True if the Firebase user email may use the API: @cornell.edu or listed in EMAIL_WHITELIST.
22+
* When true, `email` is a non-empty string.
23+
*/
24+
export function isAllowedLoginEmail(
25+
email: string | undefined,
26+
whitelist: Set<string> = getEmailWhitelist(),
27+
): email is string {
28+
if (!email) {
29+
return false;
30+
}
31+
const e = normalizeEmail(email);
32+
if (e.endsWith(CORNELL_SUFFIX)) {
33+
return true;
34+
}
35+
return whitelist.has(e);
36+
}
37+
38+
function isProdEnv(): boolean {
39+
return process.env.IS_PROD?.toLowerCase() === "true";
40+
}
41+
42+
/**
43+
* Human-readable token email for logs / dev error text (never throws).
44+
*/
45+
export function normalizedTokenEmailForDebug(
46+
email: string | undefined,
47+
): string {
48+
if (!email || !email.trim()) {
49+
return "(no email claim on ID token)";
50+
}
51+
return normalizeEmail(email);
52+
}
53+
54+
/**
55+
* 403 message for disallowed emails. In non-prod, includes normalized token
56+
* email and parsed EMAIL_WHITELIST for debugging. Prod stays generic.
57+
* Always logs a line to stderr when called.
58+
*/
59+
export function buildForbiddenEmailMessage(email: string | undefined): string {
60+
const base =
61+
"Only Cornell email addresses or whitelisted emails are allowed.";
62+
const normalizedFromToken = normalizedTokenEmailForDebug(email);
63+
const whitelistEntries = Array.from(getEmailWhitelist()).sort();
64+
const listText =
65+
whitelistEntries.length > 0
66+
? whitelistEntries.join(", ")
67+
: "(EMAIL_WHITELIST is empty)";
68+
69+
console.warn("[email-auth] rejected", {
70+
normalizedFromToken,
71+
whitelistEntryCount: whitelistEntries.length,
72+
whitelistEntries: isProdEnv() ? "[redacted in prod logs]" : whitelistEntries,
73+
});
74+
75+
if (isProdEnv()) {
76+
return base;
77+
}
78+
return `${base} Token email (normalized): "${normalizedFromToken}". Whitelist: ${listText}.`;
79+
}

0 commit comments

Comments
 (0)