Files
vibn-agent-runner/vibn-frontend/lib/auth/authOptions.ts

174 lines
5.5 KiB
TypeScript

import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import { query } from "@/lib/db-postgres";
import { ensureWorkspaceForUser } from "@/lib/workspaces";
const prisma = new PrismaClient();
const nextAuthUrl = (process.env.NEXTAUTH_URL ?? "").trim();
const isLocalNextAuth =
nextAuthUrl.startsWith("http://localhost") ||
nextAuthUrl.startsWith("http://127.0.0.1") ||
(process.env.NODE_ENV === "development" && !nextAuthUrl);
/** Set in .env.local (server + client): one email for local dev bypass. */
const devLocalEmail = (
process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ?? ""
).trim();
const devLocalSecret = (process.env.DEV_LOCAL_AUTH_SECRET ?? "").trim();
const devLocalAuthEnabled =
process.env.NODE_ENV === "development" && devLocalEmail.length > 0;
function isLocalhostHost(host: string): boolean {
const h = host.split(":")[0]?.toLowerCase() ?? "";
return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1";
}
export const authOptions: NextAuthOptions = {
/** Verbose adapter/session logs; enable with NEXTAUTH_DEBUG=true when needed */
debug: process.env.NEXTAUTH_DEBUG === "true",
adapter: PrismaAdapter(prisma),
providers: [
...(devLocalAuthEnabled
? [
CredentialsProvider({
id: "dev-local",
name: "Local dev",
credentials: {
password: { label: "Dev secret", type: "password" },
},
async authorize(credentials, req) {
const headers = (req as { headers?: Headers } | undefined)
?.headers;
const host =
headers && typeof headers.get === "function"
? (headers.get("host") ?? "")
: "";
if (devLocalSecret) {
if ((credentials?.password ?? "") !== devLocalSecret) {
return null;
}
} else if (!isLocalhostHost(host)) {
return null;
}
const name =
(process.env.DEV_LOCAL_AUTH_NAME ?? "").trim() || "Local dev";
const user = await prisma.user.upsert({
where: { email: devLocalEmail },
create: {
email: devLocalEmail,
name,
emailVerified: new Date(),
},
update: { name },
});
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
]
: []),
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
}),
],
pages: {
signIn: "/signin",
error: "/signin",
},
callbacks: {
async session({ session, user }) {
if (session.user && "id" in user && user.id) {
(session.user as { id: string }).id = user.id;
}
return session;
},
async signIn({ user }) {
if (!user?.email) return true;
try {
const workspace =
user.email
.split("@")[0]
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-") + "-account";
const data = JSON.stringify({
email: user.email,
name: user.name,
image: user.image,
workspace,
});
// Two-step upsert avoids relying on ON CONFLICT expression matching
const existing = await query<{ id: string }>(
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[user.email],
);
let fsUserId: string;
if (existing.length === 0) {
const inserted = await query<{ id: string }>(
`INSERT INTO fs_users (id, user_id, data)
VALUES (gen_random_uuid()::text, $1, $2::jsonb)
RETURNING id`,
[user.id, data],
);
fsUserId = inserted[0].id;
} else {
await query(
`UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`,
[user.id, data, existing[0].id],
);
fsUserId = existing[0].id;
}
// Ensure a Vibn workspace exists for this user. We DO NOT
// provision Coolify/Gitea here — that happens lazily on first
// project create so signin stays fast and resilient to outages.
try {
await ensureWorkspaceForUser({
userId: fsUserId,
email: user.email,
displayName: user.name ?? null,
});
} catch (wsErr) {
console.error("[signIn] Failed to ensure workspace:", wsErr);
}
} catch (e) {
console.error("[signIn] Failed to upsert fs_user:", e);
}
return true;
},
},
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
secret: process.env.NEXTAUTH_SECRET,
cookies: {
sessionToken: {
// __Secure- prefix requires Secure; localhost HTTP needs plain name + secure: false
name: isLocalNextAuth
? "next-auth.session-token"
: "__Secure-next-auth.session-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: !isLocalNextAuth,
...(isLocalNextAuth ? {} : { domain: ".vibnai.com" }),
},
},
},
};