ubiquitous-invention/apps/web/lib/auth.ts

451 lines
16 KiB
TypeScript
Raw Normal View History

import NextAuth from "next-auth";
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 11:14:16 -04:00
import type { Account, DefaultSession, NextAuthConfig, Profile } from "next-auth";
import Authentik from "next-auth/providers/authentik";
import Credentials from "next-auth/providers/credentials";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
/**
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 11:14:16 -04:00
* Session strategy is JWT (no `@auth/drizzle-adapter`). The callbacks below
* are the manual replacement for what an adapter would normally do:
*
* 1. Credentials sign-in resolves to a `users.id` directly inside `authorize`
* because the row already exists (Credentials never creates users).
*
* 2. OAuth sign-in resolves via the linked `accounts` row when one exists
* (`(provider, provider_account_id)` is the canonical "this OAuth
* identity belongs to this user" record). When it doesn't, we fall back
* to looking up the OAuth-claimed email in `user_email_identities` and
* either link to that existing user, or mint a fresh `users` + primary
* identity row. The `accounts` row is then written so subsequent
* sign-ins use the fast path.
*
* Security model for OAuth:
* - Trust the provider's `email_verified` claim, but only after we've
* resolved it correctly per provider (Google/Authentik expose it on the
* OIDC profile; GitHub does not we hit `/user/emails` with the access
* token and read the `verified` flag on the matching entry).
* - An unverified email never produces a verified identity row, which
* means the partial-unique constraint on `verified_at IS NOT NULL` can
* never be tricked into resolving the wrong human.
* - The "Bob claims alice's email via GitHub" attack is foreclosed two
* ways: (a) GitHub would not return `verified: true` for an email Bob
* doesn't actually own, and (b) if a verified identity already exists
* for the email under another user with an existing OAuth account, we
* reject the sign-in rather than silently re-linking.
*
* `db` is loaded dynamically inside callbacks so this module stays importable
* from edge contexts (middleware, etc.); the actual SQL only runs on the
* Node route handler.
*/
type Sql = (t: TemplateStringsArray, ...v: unknown[]) => Promise<unknown[]>;
async function getSql(): Promise<Sql> {
const { db } = await import("@tasks/database/client");
return (db as { $client: Sql }).$client;
}
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 11:14:16 -04:00
/**
* Per-provider resolution of `email_verified`. The provider's profile object
* is the source of truth for Google and Authentik (both expose the claim on
* the OIDC profile directly); GitHub does not, so we make a single REST call
* to `/user/emails` and read the `verified` flag on the entry matching the
* primary email.
*
* Authentik caveat: since the 2025.10 release, `email_verified` defaults to
* `false` on the OIDC ID token unless an authentik admin has added a custom
* property mapping that derives it. We honor whatever the provider says
* if it's false, the identity row gets `verified_at = null` and the user
* will be prompted to verify via another provider (or, eventually, the
* manual-verification flow) before they can accept invites to that email.
*/
async function resolveOAuthEmailVerified(args: {
provider: string;
profile: Profile | undefined;
accessToken: string | null | undefined;
fallbackEmail: string;
}): Promise<boolean> {
if (args.provider === "google" || args.provider === "authentik") {
return Boolean(args.profile?.email_verified);
}
if (args.provider === "github" && args.accessToken) {
try {
const res = await fetch("https://api.github.com/user/emails", {
headers: {
Authorization: `Bearer ${args.accessToken}`,
Accept: "application/vnd.github+json",
"User-Agent": "echodo-auth",
},
});
if (!res.ok) return false;
const emails = (await res.json()) as Array<{
email: string;
primary: boolean;
verified: boolean;
}>;
const target = args.fallbackEmail.toLowerCase();
const match = emails.find((e) => e.email.toLowerCase() === target);
return Boolean(match?.verified);
} catch (e) {
console.warn("[auth] failed to fetch github /user/emails:", e);
return false;
}
}
return false;
}
/**
* Result of resolving an OAuth sign-in to a `users.id`.
*
* - `userId`: the canonical id the JWT should carry. Sign-in proceeds.
* - `conflict: "cross_user_email"`: a verified identity for this email
* already belongs to *another* user who has at least one OAuth account
* already linked. We refuse to silently re-link the original owner has
* primacy. Sign-in is aborted; the user sees the NextAuth error page.
*/
type OAuthResolution =
| { userId: string }
| { conflict: "cross_user_email" };
async function resolveOAuthUser(args: {
account: Account;
email: string;
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 11:14:16 -04:00
emailVerified: boolean;
name: string | null;
image: string | null;
}): Promise<OAuthResolution> {
const sql = await getSql();
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 11:14:16 -04:00
const provider = args.account.provider;
const providerAccountId = args.account.providerAccountId;
const emailLower = args.email.trim().toLowerCase();
if (!emailLower || !providerAccountId) {
return { conflict: "cross_user_email" };
}
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 11:14:16 -04:00
// Fast path: (provider, providerAccountId) is the canonical record of "this
// OAuth identity belongs to this user." If we've seen them before, return
// immediately and bump last_used_at on the matching identity row (if any).
const linkedAccount = (await sql`
SELECT user_id
FROM accounts
WHERE provider = ${provider} AND provider_account_id = ${providerAccountId}
LIMIT 1
`) as { user_id: string }[];
if (linkedAccount[0]) {
const userId = linkedAccount[0].user_id;
await sql`
UPDATE user_email_identities
SET last_used_at = now()
WHERE user_id = ${userId} AND email = ${emailLower}
`;
return { userId };
}
// New (provider, providerAccountId). Resolve which user this OAuth identity
// should attach to.
const verifiedClaim = (await sql`
SELECT user_id
FROM user_email_identities
WHERE email = ${emailLower} AND verified_at IS NOT NULL
LIMIT 1
`) as { user_id: string }[];
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 11:14:16 -04:00
let userId: string;
if (verifiedClaim[0]) {
// The email is verified for an existing user. Two sub-cases:
// (a) They have no other OAuth accounts → this is their first OAuth
// sign-in for an email they previously used via Credentials. Link.
// (b) They have an OAuth account already → an OAuth account on a
// different provider is claiming an email another verified user
// already owns. Refuse.
const ownerHasOAuthAccount = (await sql`
SELECT 1
FROM accounts
WHERE user_id = ${verifiedClaim[0].user_id} AND provider != 'credentials'
LIMIT 1
`) as unknown[];
if (ownerHasOAuthAccount.length > 0) {
return { conflict: "cross_user_email" };
}
userId = verifiedClaim[0].user_id;
} else {
// No verified identity for this email. Check the legacy `users.email`
// column (covers Credentials users whose primary identity is in the
// identities table as well, but defensive against any drift).
const legacy = (await sql`
SELECT id FROM users WHERE lower(email) = ${emailLower} LIMIT 1
`) as { id: string }[];
if (legacy[0]) {
userId = legacy[0].id;
} else {
const created = (await sql`
INSERT INTO users (email, name, avatar_url)
VALUES (${emailLower}, ${args.name}, ${args.image})
RETURNING id
`) as { id: string }[];
userId = created[0]!.id;
await sql`
INSERT INTO user_email_identities
(user_id, email, verified_at, source, created_at, last_used_at)
VALUES
(${userId}, ${emailLower}, now(), 'primary', now(), now())
ON CONFLICT (user_id, email) DO NOTHING
`;
}
}
// Write the accounts row so subsequent sign-ins take the fast path.
await sql`
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 11:14:16 -04:00
INSERT INTO accounts (
user_id, type, provider, provider_account_id,
access_token, refresh_token, expires_at, token_type, scope,
id_token, session_state
) VALUES (
${userId}, ${args.account.type}, ${provider}, ${providerAccountId},
${args.account.access_token ?? null}, ${args.account.refresh_token ?? null},
${typeof args.account.expires_at === "number" ? args.account.expires_at : null},
${args.account.token_type ?? null}, ${args.account.scope ?? null},
${args.account.id_token ?? null}, ${args.account.session_state ?? null}
)
ON CONFLICT (provider, provider_account_id) DO NOTHING
`;
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 11:14:16 -04:00
// Write the oauth identity row only if the provider verified the email.
// An unverified claim leaves the identities table alone — the user still
// signs in (we know they control the OAuth account), they just can't yet
// accept invites sent to that address until they verify it.
if (args.emailVerified) {
await sql`
INSERT INTO user_email_identities
(user_id, email, verified_at, source, created_at, last_used_at)
VALUES
(${userId}, ${emailLower}, now(), ${`oauth:${provider}`}, now(), now())
ON CONFLICT (user_id, email) DO UPDATE SET
verified_at = COALESCE(user_email_identities.verified_at, EXCLUDED.verified_at),
last_used_at = EXCLUDED.last_used_at
`;
}
return { userId };
}
function slugifyForWorkspace(seed: string): string {
const cleaned = seed
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50);
return cleaned || "workspace";
}
/**
* Idempotent: if the user already owns or is a member of any workspace, no-op.
* Otherwise mint a personal workspace and add them as `owner`. Runs on every
* first sign-in (credentials and OAuth alike) so freshly-created OAuth users
* don't land in the app with no tenant scope and an unusable session.
*
* Slug collisions are handled by retrying with a random 6-char suffix; we cap
* attempts so a misbehaving DB can't lock the sign-in flow.
*/
async function ensureUserHasWorkspace(args: {
userId: string;
displayName: string | null;
email: string;
}): Promise<void> {
const sql = await getSql();
const existing = (await sql`
SELECT 1
FROM workspaces w
LEFT JOIN workspace_members m
ON m.workspace_id = w.id AND m.user_id = ${args.userId}
WHERE w.owner_user_id = ${args.userId} OR m.user_id = ${args.userId}
LIMIT 1
`) as unknown[];
if (existing.length > 0) return;
const trimmedName = args.displayName?.trim() ?? "";
const seed = trimmedName || args.email.split("@")[0] || "workspace";
const baseSlug = slugifyForWorkspace(seed);
const workspaceName = trimmedName ? `${trimmedName}'s workspace` : "My workspace";
let workspaceId: string | null = null;
let candidate = baseSlug;
for (let attempt = 0; attempt < 5 && !workspaceId; attempt += 1) {
const inserted = (await sql`
INSERT INTO workspaces (slug, name, owner_user_id)
VALUES (${candidate}, ${workspaceName}, ${args.userId})
ON CONFLICT (slug) DO NOTHING
RETURNING id
`) as { id: string }[];
if (inserted[0]) {
workspaceId = inserted[0].id;
break;
}
candidate = `${baseSlug}-${Math.random().toString(36).slice(2, 8)}`;
}
if (!workspaceId) {
console.warn("[auth] Failed to provision workspace for user", args.userId);
return;
}
await sql`
INSERT INTO workspace_members (workspace_id, user_id, role)
VALUES (${workspaceId}, ${args.userId}, 'owner')
ON CONFLICT (workspace_id, user_id) DO NOTHING
`;
}
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}
const providers: NextAuthConfig["providers"] = [
Credentials({
name: "Email",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const email = credentials?.email as string | undefined;
const password = credentials?.password as string | undefined;
if (!email?.trim() || !password) return null;
const devPassword = process.env.AUTH_DEV_PASSWORD;
if (!devPassword) {
console.warn("[auth] AUTH_DEV_PASSWORD is not set; credentials sign-in disabled.");
return null;
}
if (password !== devPassword) return null;
const sql = await getSql();
const rows = (await sql`
SELECT id, email, name, avatar_url AS "avatarUrl"
FROM users
WHERE lower(email) = lower(${email.trim()})
LIMIT 1
`) as { id: string; email: string; name: string | null; avatarUrl: string | null }[];
const user = rows[0];
if (!user) return null;
return {
id: user.id,
email: user.email,
name: user.name ?? undefined,
image: user.avatarUrl ?? undefined,
};
},
}),
];
if (process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET) {
providers.push(
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
);
}
if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
providers.push(
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
);
}
if (
process.env.AUTH_AUTHENTIK_ID &&
process.env.AUTH_AUTHENTIK_SECRET &&
process.env.AUTH_AUTHENTIK_ISSUER
) {
providers.push(
Authentik({
clientId: process.env.AUTH_AUTHENTIK_ID,
clientSecret: process.env.AUTH_AUTHENTIK_SECRET,
issuer: process.env.AUTH_AUTHENTIK_ISSUER,
}),
);
}
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: "jwt" },
pages: {
signIn: "/sign-in",
},
providers,
callbacks: {
async signIn({ user, account }) {
// OAuth providers must give us an email so we can map to a `users` row.
if (account && account.provider !== "credentials" && !user?.email) {
return false;
}
return true;
},
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 11:14:16 -04:00
// The Auth.js v5 jwt callback receives `profile` on the initial sign-in
// call (when `account && user` are present). We use it to read provider-
// specific claims like Google/Authentik's `email_verified`. For GitHub
// we fall back to a REST call inside `resolveOAuthEmailVerified`.
async jwt({ token, user, account, profile }) {
if (account && user) {
let dbId: string | null = null;
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 11:14:16 -04:00
if (account.provider === "credentials") {
dbId = user.id ?? null;
} else if (user.email) {
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 11:14:16 -04:00
const emailVerified = await resolveOAuthEmailVerified({
provider: account.provider,
profile,
accessToken: account.access_token ?? null,
fallbackEmail: user.email,
});
const resolution = await resolveOAuthUser({
account,
email: user.email,
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 11:14:16 -04:00
emailVerified,
name: user.name ?? null,
image: user.image ?? null,
});
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 11:14:16 -04:00
if ("conflict" in resolution) {
console.warn(
"[auth] OAuth sign-in refused: email %s already verified for another user (provider=%s)",
user.email,
account.provider,
);
// Returning a token with no `id` field denies the session — the
// session callback below leaves `session.user.id` unset, which
// protectedProcedure / workspaceProcedure both reject.
return token;
}
dbId = resolution.userId;
}
if (dbId) {
token.id = dbId;
await ensureUserHasWorkspace({
userId: dbId,
displayName: user.name ?? null,
email: user.email ?? "",
});
}
}
return token;
},
async session({ session, token }) {
if (session.user && token.id) {
session.user.id = token.id as string;
}
return session;
},
},
trustHost: true,
});