Skip to content

Commit 8c37902

Browse files
fix(web): require User.email and reject SSO sign-ins without an email (#1310)
* fix(web): require User.email and reject SSO sign-ins without an email Some User rows could be created with a null email — most commonly OAuth/OIDC accounts created from an identity-provider profile that returned no email. These rows crashed the Members and Pending Requests pages (`email.toLowerCase()` on a null) and left unusable identities in the org. - Make `User.email` required (`String @unique`) with a self-healing migration: backfill any existing null emails with a deterministic, unique, synthetic placeholder before applying NOT NULL, so the startup `migrate deploy` can never fail on instances that already have null rows. - Reject any sign-in that arrives without an email in the `signIn` callback, so a null can never reach `createUser`. - Drop the now-redundant `email!` assertions and tighten the public EE user API schemas (`email` is no longer nullable); regenerate the OpenAPI spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: add CHANGELOG entry for #1310 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * less verbose comment * custom error page --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4ec4de1 commit 8c37902

12 files changed

Lines changed: 48 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
### Fixed
1818
- Validated that `SOURCEBOT_ENCRYPTION_KEY` is exactly 32 characters at startup, failing fast with an actionable message instead of a runtime encryption error. [#1305](https://github.com/sourcebot-dev/sourcebot/pull/1305)
1919
- Fixed the web UI crashing when anonymous access is enabled and a request omits the `User-Agent` header (e.g. proxy or health-check probes). [#1309](https://github.com/sourcebot-dev/sourcebot/pull/1309)
20+
- Fixed the Members page crashing when a `User` had a null email. `User.email` is now required (with a backfilling migration), and SSO sign-ins without an email are rejected. [#1310](https://github.com/sourcebot-dev/sourcebot/pull/1310)
2021

2122
## [5.0.2] - 2026-06-11
2223

docs/api-reference/sourcebot-public.openapi.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,8 +1095,7 @@
10951095
"nullable": true
10961096
},
10971097
"email": {
1098-
"type": "string",
1099-
"nullable": true
1098+
"type": "string"
11001099
},
11011100
"createdAt": {
11021101
"type": "string",
@@ -1140,8 +1139,7 @@
11401139
"nullable": true
11411140
},
11421141
"email": {
1143-
"type": "string",
1144-
"nullable": true
1142+
"type": "string"
11451143
},
11461144
"role": {
11471145
"type": "string",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- Make `User.email` required (NOT NULL).
2+
--
3+
-- This migration runs automatically on startup (`prisma migrate deploy`), so it must
4+
-- never fail on existing data. Some instances have legacy `User` rows with a NULL
5+
-- email — most commonly OAuth/OIDC accounts created from an identity-provider profile
6+
-- that returned no email. A bare `SET NOT NULL` would error on those rows and brick the
7+
-- upgrade, so we first backfill any NULL email with a deterministic, unique, obviously
8+
-- synthetic placeholder (the `.invalid` TLD is reserved and can never be a real address;
9+
-- keying off the row `id` guarantees uniqueness under the existing unique constraint).
10+
--
11+
-- Going forward, the `signIn` callback in `packages/web/src/auth.ts` rejects OAuth/OIDC
12+
-- sign-ins whose profile has no email, so no new NULL/placeholder rows are created.
13+
-- Operators can identify backfilled accounts with:
14+
-- SELECT id, email FROM "User" WHERE email LIKE 'placeholder-%@no-email.invalid';
15+
UPDATE "User"
16+
SET "email" = 'placeholder-' || "id" || '@no-email.invalid'
17+
WHERE "email" IS NULL;
18+
19+
-- AlterTable
20+
ALTER TABLE "User" ALTER COLUMN "email" SET NOT NULL;

packages/db/prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ model Audit {
430430
model User {
431431
id String @id @default(cuid())
432432
name String?
433-
email String? @unique
433+
email String @unique
434434
hashedPassword String?
435435
emailVerified DateTime?
436436
image String?

packages/web/src/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ export const createAccountRequest = async () => sew(async () => {
443443
baseUrl: deploymentUrl,
444444
requestor: {
445445
name: user.name ?? undefined,
446-
email: user.email!,
446+
email: user.email,
447447
avatarUrl: user.image ?? undefined,
448448
},
449449
orgName: org.name,

packages/web/src/app/invite/actions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,12 @@ export const getInviteInfo = async (inviteId: string) => sew(async () => {
195195
orgImageUrl: invite.org.imageUrl ?? undefined,
196196
host: {
197197
name: invite.host.name ?? undefined,
198-
email: invite.host.email!,
198+
email: invite.host.email,
199199
avatarUrl: invite.host.image ?? undefined,
200200
},
201201
recipient: {
202202
name: user.name ?? undefined,
203-
email: user.email!,
203+
email: user.email,
204204
}
205205
};
206206
});

packages/web/src/app/login/error/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const ERROR_CONTENT: Record<string, { title: string; description: string }> = {
2020
title: "Access denied",
2121
description: "You do not have permission to sign in.",
2222
},
23+
EmailRequired: {
24+
title: "No email on your account",
25+
description: "Your identity provider didn't share an email address, which Sourcebot requires to sign you in. Add or verify an email on your upstream account, then try again.",
26+
},
2327
Verification: {
2428
title: "This sign-in link has expired",
2529
description: "The code or link you used is no longer valid - it may have expired or already been used. Request a new one and try again.",

packages/web/src/auth.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ const nextAuthResult = NextAuth(async () => ({
288288
}
289289
},
290290
callbacks: {
291-
async signIn({ account }) {
291+
async signIn({ account, user }) {
292292
const matchingProvider = account
293293
? (await getProviders()).find((p) => p.id === account.provider)
294294
: undefined;
@@ -318,6 +318,12 @@ const nextAuthResult = NextAuth(async () => ({
318318
return false;
319319
}
320320

321+
// Reject any sign-in that arrives without an email.
322+
// @see 20260616000000_make_user_email_required/migration.sql
323+
if (!user.email) {
324+
return '/login/error?error=EmailRequired';
325+
}
326+
321327
return true;
322328
},
323329
// Restrict post-auth redirects (sign-in / sign-out, `callbackUrl`,

packages/web/src/features/userManagement/actions.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -233,15 +233,15 @@ export const approveAccountRequest = async (requestId: string) => sew(async () =
233233
baseUrl: env.AUTH_URL,
234234
user: {
235235
name: request.requestedBy.name ?? undefined,
236-
email: request.requestedBy.email!,
236+
email: request.requestedBy.email,
237237
avatarUrl: request.requestedBy.image ?? undefined,
238238
},
239239
orgName: org.name,
240240
}));
241241

242242
const transport = createTransport(smtpConnectionUrl);
243243
const result = await transport.sendMail({
244-
to: request.requestedBy.email!,
244+
to: request.requestedBy.email,
245245
from: env.EMAIL_FROM_ADDRESS,
246246
subject: `Your request to join ${org.name} has been approved`,
247247
html,
@@ -391,7 +391,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea
391391
baseUrl: env.AUTH_URL,
392392
host: {
393393
name: user.name ?? undefined,
394-
email: user.email!,
394+
email: user.email,
395395
avatarUrl: user.image ?? undefined,
396396
},
397397
recipient: {
@@ -481,7 +481,7 @@ export const getOrgMembers = async () => sew(() =>
481481

482482
return members.map((member) => ({
483483
id: member.userId,
484-
email: member.user.email!,
484+
email: member.user.email,
485485
name: member.user.name ?? undefined,
486486
avatarUrl: member.user.image ?? undefined,
487487
role: member.role,
@@ -520,7 +520,7 @@ export const getOrgAccountRequests = async () => sew(() =>
520520

521521
return requests.map((request) => ({
522522
id: request.id,
523-
email: request.requestedBy.email!,
523+
email: request.requestedBy.email,
524524
createdAt: request.createdAt,
525525
name: request.requestedBy.name ?? undefined,
526526
image: request.requestedBy.image ?? undefined,

packages/web/src/lib/authUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ export const addUserToOrganization = async (userId: string, orgId: number): Prom
260260
// Delete any invites that may exist for this user since we've added them to the org
261261
const invites = await tx.invite.findMany({
262262
where: {
263-
recipientEmail: user.email!,
263+
recipientEmail: user.email,
264264
orgId: org.id,
265265
},
266266
})

0 commit comments

Comments
 (0)