mission-control/lib/server/auth.ts

456 lines
12 KiB
TypeScript

import { randomBytes, createHash } from "crypto";
import { cookies } from "next/headers";
import { createClient } from "@supabase/supabase-js";
import { getServiceSupabase } from "@/lib/supabase/client";
const SESSION_COOKIE_NAME = "mission_control_session";
const SESSION_HOURS_SHORT = 12;
const SESSION_DAYS_REMEMBER = 30;
export interface AuthUser {
id: string;
name: string;
email: string;
avatarUrl?: string;
createdAt: string;
}
interface UserRow {
id: string;
name: string;
email: string;
avatar_url: string | null;
created_at: string;
}
function getPublicSupabase() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase public environment variables");
}
return createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
function normalizeAvatarUrl(value: string | null | undefined): string | undefined {
if (value == null) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (!trimmed.startsWith("data:image/")) {
throw new Error("Avatar must be an image");
}
if (trimmed.length > 2_000_000) {
throw new Error("Avatar image is too large");
}
return trimmed;
}
function fallbackNameFromEmail(email: string, preferredName?: string | null): string {
const trimmed = (preferredName || "").trim();
if (trimmed.length >= 2) return trimmed;
const base = email.split("@")[0]?.trim() || "User";
return base.length >= 2 ? base : "User";
}
function hashSessionToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
async function deleteExpiredSessions() {
const supabase = getServiceSupabase();
const { error } = await supabase
.from("sessions")
.delete()
.lte("expires_at", new Date().toISOString());
if (error) {
console.error("Failed to delete expired sessions:", error);
}
}
function mapUserRow(row: UserRow): AuthUser {
return {
id: row.id,
name: row.name,
email: row.email,
avatarUrl: row.avatar_url ?? undefined,
createdAt: row.created_at,
};
}
async function upsertUserMirrors(params: {
id: string;
name: string;
email: string;
avatarUrl?: string | null;
}) {
const supabase = getServiceSupabase();
const { error: userError } = await supabase.from("users").upsert(
{
id: params.id,
name: params.name,
email: normalizeEmail(params.email),
avatar_url: params.avatarUrl ?? null,
},
{ onConflict: "id" }
);
if (userError) {
throw new Error(userError.message || "Failed to sync user profile");
}
const { error: profileError } = await supabase.from("profiles").upsert(
{
id: params.id,
name: params.name,
avatar_url: params.avatarUrl ?? null,
updated_at: new Date().toISOString(),
},
{ onConflict: "id" }
);
if (profileError) {
console.error("Failed to sync profiles mirror:", profileError.message);
}
}
export async function registerUser(params: {
name: string;
email: string;
password: string;
}): Promise<AuthUser> {
await deleteExpiredSessions();
const name = params.name.trim();
const email = normalizeEmail(params.email);
const password = params.password;
if (name.length < 2) throw new Error("Name must be at least 2 characters");
if (!email.includes("@")) throw new Error("Invalid email");
if (password.length < 8) throw new Error("Password must be at least 8 characters");
const supabase = getServiceSupabase();
const { data, error } = await supabase.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { name },
});
if (error || !data.user) {
const message = error?.message || "Failed to create user";
if (message.toLowerCase().includes("already")) {
throw new Error("Email already exists");
}
throw new Error(message);
}
const authUser = data.user;
const userName = fallbackNameFromEmail(email, name);
await upsertUserMirrors({
id: authUser.id,
name: userName,
email,
avatarUrl: null,
});
const { data: userRow, error: userError } = await supabase
.from("users")
.select("id, name, email, avatar_url, created_at")
.eq("id", authUser.id)
.single();
if (userError || !userRow) {
throw new Error(userError?.message || "Failed to load user profile");
}
return mapUserRow(userRow as UserRow);
}
export async function authenticateUser(params: {
email: string;
password: string;
}): Promise<AuthUser | null> {
await deleteExpiredSessions();
const email = normalizeEmail(params.email);
const authClient = getPublicSupabase();
const { data: signInData, error: signInError } = await authClient.auth.signInWithPassword({
email,
password: params.password,
});
if (signInError || !signInData.user) return null;
const supabase = getServiceSupabase();
const authUser = signInData.user;
const { data: existingRow } = await supabase
.from("users")
.select("id, name, email, avatar_url, created_at")
.eq("id", authUser.id)
.maybeSingle();
const userName = fallbackNameFromEmail(
authUser.email || email,
typeof authUser.user_metadata?.name === "string" ? authUser.user_metadata.name : existingRow?.name
);
await upsertUserMirrors({
id: authUser.id,
name: userName,
email: authUser.email || email,
avatarUrl: existingRow?.avatar_url || null,
});
const { data: row } = await supabase
.from("users")
.select("id, name, email, avatar_url, created_at")
.eq("id", authUser.id)
.maybeSingle();
if (!row) return null;
return mapUserRow(row as UserRow);
}
export async function updateUserAccount(params: {
userId: string;
name?: string;
email?: string;
avatarUrl?: string | null;
currentPassword?: string;
newPassword?: string;
}): Promise<AuthUser> {
await deleteExpiredSessions();
const supabase = getServiceSupabase();
// Get current user data
const { data: row } = await supabase
.from("users")
.select("id, name, email, avatar_url, created_at")
.eq("id", params.userId)
.single();
if (!row) throw new Error("User not found");
const requestedName = typeof params.name === "string" ? params.name.trim() : row.name;
const requestedEmail = typeof params.email === "string" ? normalizeEmail(params.email) : row.email;
const hasAvatarInput = Object.prototype.hasOwnProperty.call(params, "avatarUrl");
const requestedAvatar = hasAvatarInput ? normalizeAvatarUrl(params.avatarUrl) : row.avatar_url;
const currentPassword = params.currentPassword || "";
const newPassword = params.newPassword || "";
if (requestedName.length < 2) throw new Error("Name must be at least 2 characters");
if (!requestedEmail.includes("@")) throw new Error("Invalid email");
if (newPassword && newPassword.length < 8) throw new Error("New password must be at least 8 characters");
const emailChanged = requestedEmail !== row.email;
const passwordChanged = newPassword.length > 0;
const needsPasswordCheck = emailChanged || passwordChanged;
if (needsPasswordCheck) {
if (!currentPassword) throw new Error("Current password is required");
const authClient = getPublicSupabase();
const { error: authError } = await authClient.auth.signInWithPassword({
email: row.email,
password: currentPassword,
});
if (authError) {
throw new Error("Current password is incorrect");
}
}
if (emailChanged) {
const { data: existing } = await supabase
.from("users")
.select("id")
.eq("email", requestedEmail)
.neq("id", row.id)
.maybeSingle();
if (existing) throw new Error("Email already exists");
}
if (emailChanged || passwordChanged || requestedName !== row.name) {
const updatePayload: {
email?: string;
password?: string;
user_metadata?: { name: string };
} = {
user_metadata: { name: requestedName },
};
if (emailChanged) updatePayload.email = requestedEmail;
if (passwordChanged) updatePayload.password = newPassword;
const { error: authUpdateError } = await supabase.auth.admin.updateUserById(row.id, updatePayload);
if (authUpdateError) {
const message = authUpdateError.message || "Failed to update account";
if (message.toLowerCase().includes("already")) {
throw new Error("Email already exists");
}
throw new Error(message);
}
}
const { data: updated, error } = await supabase
.from("users")
.update({
name: requestedName,
email: requestedEmail,
avatar_url: requestedAvatar ?? null,
})
.eq("id", row.id)
.select("id, name, email, avatar_url, created_at")
.single();
if (error || !updated) {
throw new Error(error?.message || "Failed to update user");
}
const { error: profileError } = await supabase.from("profiles").upsert(
{
id: row.id,
name: requestedName,
avatar_url: requestedAvatar ?? null,
updated_at: new Date().toISOString(),
},
{ onConflict: "id" }
);
if (profileError) {
console.error("Failed to sync profiles mirror:", profileError.message);
}
return mapUserRow(updated as UserRow);
}
export async function listUsers(): Promise<AuthUser[]> {
const supabase = getServiceSupabase();
const { data: rows, error } = await supabase
.from("users")
.select("id, name, email, avatar_url, created_at")
.order("name", { ascending: true });
if (error) {
console.error("Failed to list users:", error);
return [];
}
return (rows || []).map(mapUserRow);
}
export async function createUserSession(
userId: string,
rememberMe: boolean
): Promise<{ token: string; expiresAt: string }> {
await deleteExpiredSessions();
const supabase = getServiceSupabase();
const now = Date.now();
const ttlMs = rememberMe
? SESSION_DAYS_REMEMBER * 24 * 60 * 60 * 1000
: SESSION_HOURS_SHORT * 60 * 60 * 1000;
const createdAt = new Date(now).toISOString();
const expiresAt = new Date(now + ttlMs).toISOString();
const token = randomBytes(32).toString("hex");
const tokenHash = hashSessionToken(token);
const { error } = await supabase.from("sessions").insert({
user_id: userId,
token_hash: tokenHash,
created_at: createdAt,
expires_at: expiresAt,
});
if (error) {
throw new Error("Failed to create session");
}
return { token, expiresAt };
}
export async function revokeSession(token: string): Promise<void> {
const supabase = getServiceSupabase();
const tokenHash = hashSessionToken(token);
await supabase.from("sessions").delete().eq("token_hash", tokenHash);
}
export async function getUserBySessionToken(token: string): Promise<AuthUser | null> {
await deleteExpiredSessions();
const supabase = getServiceSupabase();
const tokenHash = hashSessionToken(token);
const now = new Date().toISOString();
const { data: row } = await supabase
.from("sessions")
.select("user_id, users(id, name, email, avatar_url, created_at)")
.eq("token_hash", tokenHash)
.gt("expires_at", now)
.maybeSingle();
if (!row || !row.users) return null;
const user = Array.isArray(row.users) ? row.users[0] : row.users;
return mapUserRow(user as UserRow);
}
export async function setSessionCookie(token: string, rememberMe: boolean): Promise<void> {
const cookieStore = await cookies();
const baseOptions = {
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
path: "/",
};
if (rememberMe) {
cookieStore.set(SESSION_COOKIE_NAME, token, {
...baseOptions,
maxAge: SESSION_DAYS_REMEMBER * 24 * 60 * 60,
});
return;
}
// Session cookie (clears on browser close)
cookieStore.set(SESSION_COOKIE_NAME, token, baseOptions);
}
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
}
export async function getSessionTokenFromCookies(): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null;
}
export async function getAuthenticatedUser(): Promise<AuthUser | null> {
const token = await getSessionTokenFromCookies();
if (!token) return null;
return getUserBySessionToken(token);
}