import NextAuth from "next-auth"; 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"; import { rateLimit } from "@/server/lib/rate-limit"; const SIGNIN_RATE_LIMIT = { limit: 5, windowMs: 60_000 } as const; /** * Best-effort IP extraction for the Credentials `authorize` callback. * Auth.js v5 doesn't hand us the request, so we reach for the Next.js * `headers()` helper — which works inside the route-handler context that * `/api/auth/[...nextauth]` runs in, but may throw in other contexts * (e.g. server actions during sign-in testing). On throw we return * `null` and the caller decides whether to skip the limiter (dev) or * lock the address as `"unknown"` (prod, so a header-stripping proxy * doesn't accidentally disable the guardrail entirely). */ async function resolveSignInIp(): Promise { try { const { headers } = await import("next/headers"); const h = await headers(); const fwd = h.get("x-forwarded-for"); if (fwd) { const first = fwd.split(",")[0]?.trim(); if (first) return first; } return h.get("x-real-ip") ?? null; } catch { return null; } } /** * 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; async function getSql(): Promise { const { db } = await import("@tasks/database/client"); return (db as { $client: Sql }).$client; } /** * 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 { 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; emailVerified: boolean; name: string | null; image: string | null; }): Promise { const sql = await getSql(); const provider = args.account.provider; const providerAccountId = args.account.providerAccountId; const emailLower = args.email.trim().toLowerCase(); if (!emailLower || !providerAccountId) { return { conflict: "cross_user_email" }; } // 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 }[]; 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` 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 `; // 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 { 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; // Rate-limit by IP before doing any DB work. 5/minute is well above // the rate a human can plausibly fat-finger a password but tight // enough that an automated guesser hits the wall before it can // burn through a meaningful dictionary slice. In dev we skip the // limiter when we can't resolve an IP so a header-less local test // run doesn't lock itself out. const ip = await resolveSignInIp(); const limiterKey = `signin:${ip ?? "unknown"}`; const skipLimiter = ip === null && process.env.NODE_ENV === "development"; if (!skipLimiter) { const rl = rateLimit({ key: limiterKey, limit: SIGNIN_RATE_LIMIT.limit, windowMs: SIGNIN_RATE_LIMIT.windowMs, }); if (!rl.allowed) { console.warn( "[auth] credentials sign-in rate-limited ip=%s retryAfterMs=%d", ip ?? "unknown", rl.retryAfterMs, ); 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; }, // 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; if (account.provider === "credentials") { dbId = user.id ?? null; } else if (user.email) { const emailVerified = await resolveOAuthEmailVerified({ provider: account.provider, profile, accessToken: account.access_token ?? null, fallbackEmail: user.email, }); const resolution = await resolveOAuthUser({ account, email: user.email, emailVerified, name: user.name ?? null, image: user.image ?? null, }); 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, });