ubiquitous-invention/apps
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
..
collab-server chore(lint+types): green pnpm lint && pnpm type-check from a clean clone 2026-06-02 00:11:52 -05:00
mcp-server chore(lint+types): green pnpm lint && pnpm type-check from a clean clone 2026-06-02 00:11:52 -05:00
web feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2) 2026-06-02 10:14:16 -05:00