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

250 lines
7.6 KiB
TypeScript
Raw Normal View History

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<unknown[]>;
async function getSql(): Promise<Sql> {
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<string | null> {
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<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;
},
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,
});