import NextAuth from "next-auth"; import type { DefaultSession, NextAuthConfig } 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"; /** * Session strategy is JWT (no `@auth/drizzle-adapter`). We still want every * authenticated request to carry a real `users.id` so workspace-scoped tRPC * procedures can resolve membership, so OAuth sign-ins go through * `ensureUserIdByEmail` to upsert a row in `users` (matched case-insensitively * on email) and stamp `token.id` with the DB UUID. Credentials sign-in already * returns the DB id from `authorize`. * * `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; } async function ensureUserIdByEmail(args: { email: string; name?: string | null; image?: string | null; }): Promise { const email = args.email.trim(); if (!email) return null; const sql = await getSql(); const existing = (await sql` SELECT id FROM users WHERE lower(email) = lower(${email}) LIMIT 1 `) as { id: string }[]; if (existing[0]) return existing[0].id; await sql` INSERT INTO users (email, name, avatar_url) VALUES (${email.toLowerCase()}, ${args.name ?? null}, ${args.image ?? null}) ON CONFLICT (email) DO NOTHING `; const after = (await sql` SELECT id FROM users WHERE lower(email) = lower(${email}) LIMIT 1 `) as { id: string }[]; return after[0]?.id ?? null; } 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; 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; }, async jwt({ token, user, account }) { // First call (sign-in): `user` and `account` are present. if (account && user) { let dbId: string | null = null; if (account.provider === "credentials") { // `authorize` already returns a real DB UUID in `user.id`. dbId = user.id ?? null; } else if (user.email) { dbId = await ensureUserIdByEmail({ email: user.email, name: user.name ?? null, image: user.image ?? null, }); } if (dbId) { token.id = dbId; // Make sure every authenticated user has a tenant they can land in. // Cheap idempotent check; only mints a workspace on the first sign-in. 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, });