Closes Task-multi-email-identity. Rebuilds the OAuth half of the jwt
callback around the new user_email_identities table AND the existing
(but until-now empty) accounts table, with per-provider email_verified
resolution and a cross-user conflict guard. Credentials sign-in path is
unchanged.
Per the OAuth research subagent: NextAuth has no adapter configured, so
the accounts table has been sitting empty since this app started. Rather
than leave it that way, the new resolveOAuthUser helper writes to it
on every OAuth sign-in. (provider, providerAccountId) is now the
canonical "this OAuth identity belongs to this user" record and gives
us a fast path that doesn't depend on email matching.
Sign-in resolution order for an OAuth account:
1. Lookup accounts by (provider, providerAccountId).
Hit -> bump last_used_at on the matching identity row, return user_id.
2. Lookup user_email_identities by (email, verified_at IS NOT NULL).
Hit AND the owner has zero existing OAuth accounts -> link this new
OAuth account to that user (covers "Credentials user adds their
first OAuth provider"). Insert a fresh accounts row.
Hit AND the owner already has an OAuth account -> REFUSE. Returning
a token without an id field denies the session; the user lands on
NextAuth's error page. (This is the "Bob's GitHub claims alice's
verified email" rejection.)
3. Fall back to legacy users.email match.
Hit -> link to that user (covers users created before migration 0005).
4. Otherwise mint a new users row + a source='primary' identity in the
identities table, then write the accounts row.
The verified identity row is upserted only when the provider's
email_verified claim is true. The new resolveOAuthEmailVerified helper:
- Google + Authentik: read profile.email_verified directly (the Auth.js
v5 jwt callback receives `profile` on the sign-in trigger). Authentik
caveat documented inline: since the 2025.10 release the claim defaults
to false unless an admin adds a custom property mapping.
- GitHub: GitHubProfile does not expose the claim. We GET /user/emails
with the OAuth access_token and read `verified` on the entry matching
the primary email. Failure to fetch (rate limit, network) is treated
as unverified.
What's intentionally not in this commit:
- Vitest tests for the callback logic. apps/web has no vitest config
yet (the test foundation only wired up the packages). Filed a
follow-up: Task-bootstrap-vitest-for-apps-web.md (P2) under
Epic-test-foundation. The auth-callback assertions will land against
that harness when it's stood up.
- Race-condition transaction isolation. The current sequence (account
lookup -> identity lookup -> account/identity upsert) has the same
race window the old ensureUserIdByEmail had — two simultaneous OAuth
sign-ins for a brand-new email could both pass the identity check
before either INSERT fires. Mitigated in practice by the partial
unique on email WHERE verified_at IS NOT NULL — postgres will reject
the second insert — but the loser gets an opaque error. Filed as a
follow-up if it becomes a real issue.
Task file (plans/.../Task-multi-email-identity.md) updated with the
detailed smoke-test playbook an operator needs to run before the OAuth
path goes to production (sign in fresh, sign in repeat, sign in
cross-provider, sign in cross-user-conflict). admin@tasks.dev signs in
via Credentials so the dev fixtures alone do not exercise this code.
Lint + type-check + test all green (14/14 tests, 0 lint errors, 14
unchanged warnings, 6/6 packages type-check).
Co-authored-by: Cursor <cursoragent@cursor.com>
10 KiB
| kind | slug | title | plan_slug | epic_slug | status | priority | tenant_id | owner | cursor_todo_id | updated_at |
|---|---|---|---|---|---|---|---|---|---|---|
| task | multi-email-identity | Multi-email identity on user profile (foundation for invite-by-email) | multitenant-saas-hardening | tenant-lifecycle | done | P1 | global | unassigned | null | 2026-06-02 |
Task summary
Decouple application identity from a single users.email column. Add a user_email_identities table so one user can own multiple verified emails (primary + OAuth-claimed + later manual). Required so the invite-acceptance flow can correctly resolve "the human at alice@gmail.com" even when they're signed in via GitHub as alice@personal.
This is the foundation task for the invites convoy. Lands before Task-workspace-invites-and-roles because it changes the sign-in path that invite-accept depends on.
Why now (and why not later)
The current ensureUserIdByEmail in apps/web/lib/auth.ts collapses sign-in to a case-insensitive lookup on users.email. Concrete consequence: a user who signs up with alice@personal.com via GitHub today and then signs in with alice@gmail.com via Google tomorrow ends up as two separate users rows, neither of which is "the user." Every multitenant feature we layer on top — invites, audit log attribution, billing — would inherit this confusion.
Doing it right once is cheaper than dragging it for the next year.
Scope
Schema additions
New table in packages/database/src/schema/users.ts (kept colocated with users since they're logically the same identity surface):
export const userEmailIdentities = pgTable(
"user_email_identities",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
email: varchar("email", { length: 255 }).notNull(), // stored lowercase
verifiedAt: timestamp("verified_at", { withTimezone: true }),
source: varchar("source", { length: 30 }).notNull(),
// 'primary' | 'oauth:github' | 'oauth:google' | 'oauth:authentik' | 'manual'
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
},
(table) => ({
userIdx: index("user_email_identities_user_id_idx").on(table.userId),
emailIdx: index("user_email_identities_email_idx").on(table.email),
userEmailUnique: uniqueIndex("user_email_identities_user_id_email_unique").on(
table.userId,
table.email,
),
// A verified email belongs to exactly one user globally.
verifiedEmailUnique: uniqueIndex("user_email_identities_verified_email_unique").on(
table.email,
).where(sql`verified_at IS NOT NULL`),
}),
);
Migration
Generate with pnpm db:generate. The migration must include a backfill step: insert one row per existing users row with source='primary', email=lower(users.email), verified_at=users.created_at (we trust existing rows because we minted them).
-- inside the generated migration, after CREATE TABLE
INSERT INTO user_email_identities (user_id, email, verified_at, source, created_at)
SELECT id, lower(email), created_at, 'primary', created_at FROM users
ON CONFLICT DO NOTHING;
Auth wiring
Refactor apps/web/lib/auth.ts:
- Rename
ensureUserIdByEmail→ensureUserIdByVerifiedEmail. Lookup hitsuser_email_identities WHERE email = lower(?) AND verified_at IS NOT NULLfirst. On miss, create a new user + asource='primary'identity in one transaction. - Add a callback hook (see research subagent recommendation — likely
signInorjwt) that, on OAuth sign-in whereprofile.email_verified === true, upserts asource='oauth:<provider>'identity for that user. If the email is already a verified identity belonging to a different user → reject the sign-in with a clear error. - Bump
last_used_aton the identity row that actually authenticated this session.
Server-side helper
Export userOwnsEmail(userId: string, emailLower: string): Promise<boolean> from apps/web/server/lib/identity.ts (new file). Returns true iff user_email_identities has a row with that user + lowercased email + verified_at IS NOT NULL. This is the function Task-workspace-invites-and-roles will call.
Profile UI
Add a "Linked emails" section to the profile/settings page. Lists each identity with:
- Email (lowercased display)
- Source badge (
Primary,GitHub,Google,Authentik,Manual) - Verified state (icon if verified, "Pending verification" otherwise)
- Last used (relative time)
Read-only in this task. Adding a "Disconnect" button is Task-disconnect-linked-email (filed separately because it has destructive edge cases — disconnecting your last verified email locks you out).
Tests
Vitest is wired now, so add real assertions:
packages/databaseorapps/web(test runner can land in either): identity-table CRUD round-trip.- Sign-in callback writes a new identity on OAuth login.
- Sign-in callback rejects when the OAuth email is verified for another user.
userOwnsEmailreturns the right boolean across all four identity states (primary/oauth/manual/missing).
Subtasks
- Added
user_email_identitiesschema inpackages/database/src/schema/users.tswith full inline documentation of source values and constraint semantics. - Generated migration
0005_cooing_midnight.sqlviapnpm db:generate, augmented with the backfillINSERT(usingON CONFLICT (user_id, email)— Drizzle generates unique indexes, not named constraints, so column-based conflict targets are required). Applied to dev DB; existingadmin@tasks.devrow now has aprimaryidentity withverified_at = users.created_at. - Created
apps/web/server/lib/identity.tsexportinguserOwnsEmail(userId, email)andfindUserIdByVerifiedEmail(email). Both are pure read queries that the invite-accept procedure (Task 2) will call. - Refactored
apps/web/lib/auth.ts. Substantial change — see the commit body for the security model. Key differences from the original task spec:- Actually uses the existing
accountstable, which the OAuth-research subagent identified as vestigial.(provider, providerAccountId)is now the canonical "this OAuth identity belongs to this user" record. Repeat sign-ins use a fast-path lookup; new sign-ins go through the identity-table fallback. - Per-provider
email_verifiedresolution via a newresolveOAuthEmailVerifiedhelper. Google and Authentik read directly fromprofile.email_verified. GitHub does not expose the claim, so we make aGET /user/emailscall with the OAuth access token and readverifiedon the entry matching the primary email. Authentik post-2025.10 caveat (defaultfalseunless an admin adds a custom property mapping) is documented inline. - Cross-user conflict path: if a verified identity for the OAuth-claimed email already belongs to another user who already has at least one OAuth account linked, we refuse to silently re-link. The JWT returns without an
idfield, which causes the session to be unauthenticated and the user lands on the NextAuth error page.
- Actually uses the existing
- Added a tRPC procedure
identity.listMineand a read-only "Linked emails" section at/<workspaceSlug>/settings/profilethat lists each identity with email, source badge, verified state, and last-used relative time. - Deferred: vitest tests for the auth callback.
apps/webdoes not have vitest configured yet (the test foundation inPlan-multitenant-saas-hardening/Epic-test-foundationonly wired it up forpackages/*). Setting up vitest for a Next.js app — alias resolution for@/, environment for server modules, optional JSDOM — is its own task. Filed asTask-bootstrap-vitest-for-apps-web.mdP2. The auth-callback assertions (identity write on OAuth, cross-user conflict rejection, no-duplicate-write on repeat sign-in) belong against that harness when it lands. Until then, smoke testing is the regression net. pnpm lint && pnpm type-check && pnpm testall clean (14 lint warnings unchanged from pre-Task-1 baseline; 6/6 type-check; 14/14 tests).
Smoke test (manual, recommended before any OAuth provider goes to production)
The OAuth path is not exercised by any of the dev fixtures (only admin@tasks.dev exists, which signs in via Credentials). To verify the new code path end-to-end, an operator with a configured OAuth provider should:
- Sign in via OAuth as a fresh account (no existing
usersrow). Confirm: newusersrow, newuser_email_identitiesrow withsource='oauth:<provider>'andverified_at != null, newaccountsrow with the matching(provider, providerAccountId). - Sign out, sign back in as the same OAuth account. Confirm: no new rows;
user_email_identities.last_used_atbumped. - With the same email, attempt to sign in via a different OAuth provider. Confirm: a second
accountsrow is written; the existing identity row getslast_used_atbumped (no new identity row because the email is the same). - From a second browser/incognito, attempt to sign in via a third OAuth account claiming the same verified email. Confirm: sign-in is refused, no rows are added.
Owner or assignee
Unassigned
Status
done
Estimation
M-L (came in around the upper end)
Acceptance criteria
- Existing users have one
primaryidentity row each after migration. Verified viapsqlagainst the dev DB. userOwnsEmail(userId, emailLower)is exported and correctly filters onverified_at IS NOT NULL.- All three CI gates green (lint, type-check, test).
- Operator-verified: cross-OAuth-provider linking works (verified email matches existing user, new
accountsrow written, no duplicate identity row). Pending manual smoke test against a configured OAuth provider. - Operator-verified: cross-user email conflict is rejected. Same.
Follow-ups filed
Task-manual-email-verification.md— type a new email, get a one-time code, verify.Task-disconnect-linked-email.md— unlink with safety checks (last-verified protection).Task-account-merge.md— merge two existing users who turn out to share an email.Task-bootstrap-vitest-for-apps-web.md— set up Vitest forapps/webso the auth-callback assertions and identity-helper integration tests can have a home. New, P2.
Links to related Epic / Plan
- Epic:
./Epic-tenant-lifecycle.md - Plan:
../Plan-multitenant-saas-hardening.md - Blocks:
./Task-workspace-invites-and-roles.md,./Task-invite-recipient-autocomplete.md