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>
159 lines
10 KiB
Markdown
159 lines
10 KiB
Markdown
---
|
|
kind: task
|
|
slug: multi-email-identity
|
|
title: Multi-email identity on user profile (foundation for invite-by-email)
|
|
plan_slug: multitenant-saas-hardening
|
|
epic_slug: tenant-lifecycle
|
|
status: done
|
|
priority: P1
|
|
tenant_id: global
|
|
owner: unassigned
|
|
cursor_todo_id: null
|
|
updated_at: "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):
|
|
|
|
```ts
|
|
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).
|
|
|
|
```sql
|
|
-- 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 hits `user_email_identities WHERE email = lower(?) AND verified_at IS NOT NULL` first. On miss, create a new user + a `source='primary'` identity in one transaction.
|
|
- Add a callback hook (see research subagent recommendation — likely `signIn` or `jwt`) that, on OAuth sign-in where `profile.email_verified === true`, upserts a `source='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_at` on 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/database` or `apps/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.
|
|
- `userOwnsEmail` returns the right boolean across all four identity states (primary/oauth/manual/missing).
|
|
|
|
## Subtasks
|
|
|
|
- [x] Added `user_email_identities` schema in `packages/database/src/schema/users.ts` with full inline documentation of source values and constraint semantics.
|
|
- [x] Generated migration `0005_cooing_midnight.sql` via `pnpm db:generate`, augmented with the backfill `INSERT` (using `ON CONFLICT (user_id, email)` — Drizzle generates unique indexes, not named constraints, so column-based conflict targets are required). Applied to dev DB; existing `admin@tasks.dev` row now has a `primary` identity with `verified_at = users.created_at`.
|
|
- [x] Created `apps/web/server/lib/identity.ts` exporting `userOwnsEmail(userId, email)` and `findUserIdByVerifiedEmail(email)`. Both are pure read queries that the invite-accept procedure (Task 2) will call.
|
|
- [x] 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 `accounts` table**, 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_verified` resolution** via a new `resolveOAuthEmailVerified` helper. Google and Authentik read directly from `profile.email_verified`. GitHub does not expose the claim, so we make a `GET /user/emails` call with the OAuth access token and read `verified` on the entry matching the primary email. Authentik post-2025.10 caveat (default `false` unless 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 `id` field, which causes the session to be unauthenticated and the user lands on the NextAuth error page.
|
|
- [x] Added a tRPC procedure `identity.listMine` and a read-only "Linked emails" section at `/<workspaceSlug>/settings/profile` that lists each identity with email, source badge, verified state, and last-used relative time.
|
|
- [ ] **Deferred: vitest tests for the auth callback.** `apps/web` does not have vitest configured yet (the test foundation in `Plan-multitenant-saas-hardening/Epic-test-foundation` only wired it up for `packages/*`). Setting up vitest for a Next.js app — alias resolution for `@/`, environment for server modules, optional JSDOM — is its own task. Filed as `Task-bootstrap-vitest-for-apps-web.md` P2. 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.
|
|
- [x] `pnpm lint && pnpm type-check && pnpm test` all 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:
|
|
|
|
1. Sign in via OAuth as a fresh account (no existing `users` row). Confirm: new `users` row, new `user_email_identities` row with `source='oauth:<provider>'` and `verified_at != null`, new `accounts` row with the matching `(provider, providerAccountId)`.
|
|
2. Sign out, sign back in as the same OAuth account. Confirm: no new rows; `user_email_identities.last_used_at` bumped.
|
|
3. With the same email, attempt to sign in via a *different* OAuth provider. Confirm: a second `accounts` row is written; the existing identity row gets `last_used_at` bumped (no new identity row because the email is the same).
|
|
4. 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
|
|
|
|
- [x] Existing users have one `primary` identity row each after migration. Verified via `psql` against the dev DB.
|
|
- [x] `userOwnsEmail(userId, emailLower)` is exported and correctly filters on `verified_at IS NOT NULL`.
|
|
- [x] All three CI gates green (lint, type-check, test).
|
|
- [ ] **Operator-verified**: cross-OAuth-provider linking works (verified email matches existing user, new `accounts` row 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 for `apps/web` so 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`
|