Files
MineGeo/lib/account-api.ts

287 lines
9.1 KiB
TypeScript

import "server-only";
import { createClient } from "@supabase/supabase-js";
import { NextRequest, NextResponse } from "next/server";
import {
admin,
authClient,
required,
user,
rawUser,
accountState,
} from "./supabase";
import { accountProfile } from "./profile";
import {
authActionSchema,
authDestination,
passwordUpdateSchema,
profileUpdateSchema,
initialPasswordSchema,
} from "@/shared/account";
import { createHash } from "node:crypto";
import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery";
import { requestOrigin } from "./request-origin";
import { issueInitialPassword } from "@/shared/initial-password";
type Context = {
jsonBody: (r: NextRequest) => Promise<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
};
const json = (body: unknown, status = 200) =>
NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } });
export async function accountApi(
request: NextRequest,
path: string[],
context: Context,
) {
if (
path[0] === "auth" &&
path[1] === "logout" &&
path.length === 2 &&
request.method === "POST"
) {
const client = await authClient();
const { error } = await client.auth.signOut({ scope: "local" });
if (error) throw new Error("Could not sign out. Please try again.");
const response = json({ ok: true });
for (const name of ["mg_guest", "mg_multiplayer"])
response.cookies.set(name, "", {
path: "/",
maxAge: 0,
httpOnly: true,
sameSite: "lax",
});
return response;
}
if (
path[0] === "auth" &&
path[1] === "initial-password" &&
path.length === 2
) {
const account = await rawUser();
if (!account?.email || !account.email_confirmed_at)
return json({ error: "Log in to manage your account" }, 401);
if (request.method === "GET") {
return json({
needed: required(
await admin().rpc("legacy_password_needed", { p_user: account.id }),
),
});
}
if (request.method !== "POST")
return json({ error: "Method not allowed" }, 405);
const body = initialPasswordSchema.parse(await context.jsonBody(request));
await context.limit(`initial-password:${account.id}`, 3, 600);
await issueInitialPassword(account.id, body.password, {
claim: async (id) =>
required(await admin().rpc("claim_legacy_password", { p_user: id })),
update: async (id, password) => {
const { error } = await admin().auth.admin.updateUserById(id, {
password,
});
if (error)
throw new Error(
"Password setup could not be confirmed. Use password recovery before signing out.",
);
},
});
return json({ ok: true });
}
if (path[0] === "profile" && path.length === 1) {
const account = await user();
if (!account) return json({ error: "Log in to manage your account" }, 401);
if (request.method === "GET")
return json({
profile: await accountProfile(account),
email: account.email,
});
if (request.method === "PATCH") {
const body = profileUpdateSchema.parse(await context.jsonBody(request));
const current = await accountProfile(account);
if (body.glowColor !== undefined && !current.name_glow)
return json(
{ error: "Earn a username glow before choosing its color" },
403,
);
const profile = required(
await admin()
.from("profiles")
.update({
...(body.displayName !== undefined
? { display_name: body.displayName }
: {}),
...(body.glowColor !== undefined
? { glow_color: body.glowColor }
: {}),
})
.eq("id", account.id)
.select(
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
)
.single(),
);
return json({ profile });
}
return json({ error: "Method not allowed" }, 405);
}
if (path[0] !== "auth" || request.method !== "POST" || path.length > 2)
return json({ error: "Not found" }, 404);
const client = await authClient();
if (path.length === 1) {
const body = authActionSchema.parse(await context.jsonBody(request));
const identity = createHash("sha256")
.update(body.email.toLowerCase())
.digest("hex");
await context.limit(
`auth:${body.action}:${identity}`,
body.action === "login" ? 15 : 5,
600,
);
const origin = requestOrigin(request, process.env.MINEGEO_URL);
const next = authDestination("next" in body ? body.next : null);
const callback = new URL("/auth/callback", origin);
callback.searchParams.set(
"type",
body.action === "recover" ? "recovery" : "signup",
);
callback.searchParams.set(
"next",
body.action === "recover"
? "/auth/reset-password"
: next === "/auth/reset-password"
? "/dashboard"
: next,
);
if (body.action === "login") {
const { data, error } = await client.auth.signInWithPassword({
email: body.email,
password: body.password,
});
if (error)
return json(
{
error:
error.code === "email_not_confirmed"
? "Confirm your email address before logging in."
: "Email or password is incorrect. Try again or reset your password.",
},
401,
);
return json({
next:
data.user && (await accountState(data.user.id)) !== "active"
? "/ban/appeal"
: next,
});
}
if (body.action === "signup") {
const { data, error } = await client.auth.signUp({
email: body.email,
password: body.password,
options: {
data: { display_name: body.displayName },
emailRedirectTo: callback.toString(),
},
});
if (error) throw new Error(error.message);
if (data.session && data.user) {
await accountProfile(data.user);
return json({ next });
}
return json({
message:
"Check your email to confirm your account. If you already have an account, log in or reset your password.",
});
}
const { error } = await client.auth.resetPasswordForEmail(body.email, {
redirectTo: callback.toString(),
});
if (
error &&
!["user_not_found", "signup_disabled"].includes(error.code ?? "")
)
throw new Error(error.message);
return json({
message:
"If an account exists for this email, a password reset link is on its way. Check your inbox.",
});
}
if (path[1] !== "password") return json({ error: "Not found" }, 404);
const account = await rawUser();
if (!account?.email)
return json({ error: "Log in before changing your password" }, 401);
const body = passwordUpdateSchema.parse(await context.jsonBody(request));
await context.limit(`password:${account.id}`, 10, 600);
const { data: claimData, error: claimError } = await client.auth.getClaims();
const sessionId =
typeof claimData?.claims.session_id === "string"
? claimData.claims.session_id
: "";
if (claimError || !sessionId)
return json({ error: "Your session expired. Log in again." }, 401);
if (body.currentPassword) {
// Verify through Auth without replacing this browser's session.
const verifier = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
const { error } = await verifier.auth.signInWithPassword({
email: account.email,
password: body.currentPassword,
});
if (error)
return json(
{
error:
"Your current password is incorrect. Use an email reset link if you have not set a password yet.",
},
403,
);
await verifier.auth.signOut({ scope: "local" });
} else if (
!verifyRecoveryGrant(
request.cookies.get(recoveryCookie)?.value,
account.id,
sessionId,
process.env.SUPABASE_SECRET_KEY!,
)
) {
return json(
{
error:
"Enter your current password or open a fresh password reset link from your email.",
},
403,
);
}
const { error } = await client.auth.updateUser({
password: body.password,
...(body.currentPassword ? { current_password: body.currentPassword } : {}),
...(body.nonce ? { nonce: body.nonce } : {}),
});
if (error)
return json(
{
error: error.message,
needsReauthentication: error.code === "reauthentication_needed",
},
400,
);
const { error: signOutError } = await client.auth.signOut({
scope: "others",
});
const response = json({
ok: true,
message: signOutError
? "Password updated. Other sessions could not be signed out; please try signing out other devices again."
: "Password updated. Other devices have been signed out.",
});
response.cookies.set(recoveryCookie, "", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 0,
});
return response;
}