ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-multi-email-identity.md
Randall Stillwell 3a657a4aed feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2)
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>
2026-06-02 10:14:16 -05:00

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 ensureUserIdByEmailensureUserIdByVerifiedEmail. 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

  • Added user_email_identities schema in packages/database/src/schema/users.ts with full inline documentation of source values and constraint semantics.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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).

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

  • Existing users have one primary identity row each after migration. Verified via psql against the dev DB.
  • userOwnsEmail(userId, emailLower) is exported and correctly filters on verified_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 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.
  • Epic: ./Epic-tenant-lifecycle.md
  • Plan: ../Plan-multitenant-saas-hardening.md
  • Blocks: ./Task-workspace-invites-and-roles.md, ./Task-invite-recipient-autocomplete.md