src/lib/auth.ts
Rama main · réplica de lectura del repositorio original.
import {
randomBytes,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import type { McpScope, Prisma, User, UserRole } from "@prisma/client";
import { db } from "@/lib/db";
import { sweepArchive } from "@/lib/data/archive";
import { sweepRevisions } from "@/lib/data/revisions";
import { getSettings } from "@/lib/settings";
import { baseUrlFrom } from "@/lib/request-url";
const SESSION_COOKIE = "hired_session";
/**
* The name this cookie had before the rename. Sessions are rows in the
* database keyed by token, not by cookie name, so reading the old name keeps
* everyone signed in through the deploy that renamed it — otherwise a branding
* change silently logs out every person on the instance.
*
* Safe to delete once every live session predating the rename has expired,
* which is 30 days after it shipped.
*/
const LEGACY_SESSION_COOKIE = "resume_os_session";
async function sessionToken(jar: Awaited<ReturnType<typeof cookies>>) {
return jar.get(SESSION_COOKIE)?.value ?? jar.get(LEGACY_SESSION_COOKIE)?.value;
}
const SESSION_DAYS = 30;
/**
* How long a session lasts when the person did not tick "keep me signed in".
* Its cookie also loses its expiry, so closing the browser ends it — this is
* the backstop for the browser that is never closed.
*/
const UNREMEMBERED_HOURS = 12;
// ---------------------------------------------------------------------------
// The signed-in hint — how a landing page knows to send somebody to the app
// ---------------------------------------------------------------------------
/**
* A cookie that says "somebody is signed in on this instance", and nothing else.
*
* hired.tools is a static site on its own origin, so it cannot ask this app
* anything: the session cookie is SameSite=Lax and is simply not sent on a
* cross-site request, and relaxing it to None so that one would work trades a
* real defence against cross-site requests for a convenience. What two hosts
* under one domain can share is a cookie on the domain above them both, so
* signing in leaves a "1" up there and the landing page reads it in two lines
* of script.
*
* Readable by script on purpose, and it carries no authority — the session
* token stays httpOnly on the app's own host. The worst a forged one does is
* send somebody to a sign-in page.
*/
const SIGNED_IN_COOKIE = "hired_signed_in";
/**
* The domain the app and its landing page have in common, or null when they
* have none worth writing to.
*
* It is the longest run of labels the two hosts share, which means it can never
* be wider than the landing page an admin wrote down, whatever Host header a
* request arrives with. Two labels is the floor. `.co.uk` is the case this
* deliberately does not get clever about — a browser refuses a cookie on a
* public suffix, so the hint is simply never stored and the landing page shows
* the landing page.
*/
export function sharedCookieDomain(appHost: string, landingUrl: string): string | null {
const app = appHost.split(":")[0].trim().toLowerCase();
let landing = "";
try {
const url = /^https?:\/\//i.test(landingUrl) ? landingUrl : `https://${landingUrl}`;
landing = new URL(url).hostname.toLowerCase();
} catch {
return null;
}
if (!app || !landing) return null;
const left = app.split(".");
const right = landing.split(".");
const shared: string[] = [];
while (left.length > 0 && right.length > 0 && left[left.length - 1] === right[right.length - 1]) {
shared.unshift(left.pop() as string);
right.pop();
}
return shared.length >= 2 ? shared.join(".") : null;
}
/**
* Where the hint goes on this instance, or null when it goes nowhere — which is
* every instance that has not configured a landing page, meaning almost every
* self-hosted one.
*
* The Public URL setting first and the request second, the same order the rest
* of the app resolves its own address in; `baseUrlFrom` is what knows about the
* forwarded headers every supported deployment sits behind.
*/
export async function signedInHintDomain(): Promise<string | null> {
const { landingUrl, publicUrl } = await getSettings();
if (!landingUrl.trim()) return null;
const hostOf = (url: string) => {
try {
return new URL(url).host;
} catch {
return "";
}
};
// A Public URL too malformed to parse should cost the redirect, not the login.
const host = (publicUrl.trim() && hostOf(publicUrl)) || hostOf(baseUrlFrom(await headers()));
return host ? sharedCookieDomain(host, landingUrl) : null;
}
type Jar = Awaited<ReturnType<typeof cookies>>;
/** Leave the hint. No expiry means it dies with the browser, like the session. */
async function writeSignedInHint(jar: Jar, expires?: Date) {
const domain = await signedInHintDomain();
if (!domain) return;
jar.set(SIGNED_IN_COOKIE, "1", {
// Not httpOnly: a script on the landing page is the only thing that reads it.
httpOnly: false,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
domain,
...(expires ? { expires } : {}),
});
}
/** Take it away again. Deleting a cookie means naming the domain it was set on. */
async function clearSignedInHint(jar: Jar) {
const domain = await signedInHintDomain();
if (!domain) return;
jar.set(SIGNED_IN_COOKIE, "", { path: "/", domain, maxAge: 0 });
}
// ---------------------------------------------------------------------------
// Passwords — scrypt from the standard library, no native dependency.
// ---------------------------------------------------------------------------
const SCRYPT_N = 16384;
const SCRYPT_r = 8;
const SCRYPT_p = 1;
const KEY_LEN = 64;
export function hashPassword(password: string): string {
const salt = randomBytes(16);
const key = scryptSync(password.normalize("NFKC"), salt, KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_r,
p: SCRYPT_p,
});
return `scrypt:${SCRYPT_N}:${SCRYPT_r}:${SCRYPT_p}:${salt.toString("hex")}:${key.toString("hex")}`;
}
export function verifyPassword(password: string, stored: string): boolean {
// An empty hash is the unclaimed-placeholder marker; nothing may match it.
if (!stored) return false;
const parts = stored.split(":");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const [, n, r, p, saltHex, keyHex] = parts;
try {
const salt = Buffer.from(saltHex, "hex");
const expected = Buffer.from(keyHex, "hex");
const actual = scryptSync(password.normalize("NFKC"), salt, expected.length, {
N: Number(n),
r: Number(r),
p: Number(p),
});
return actual.length === expected.length && timingSafeEqual(actual, expected);
} catch {
return false;
}
}
export function generateMcpToken() {
return `rsm_${randomBytes(24).toString("hex")}`;
}
function generateSessionToken() {
return randomBytes(32).toString("hex");
}
export function generateInviteToken() {
return randomBytes(24).toString("hex");
}
/**
* The capture link's token. Same shape as an MCP token, different prefix, so a
* glance at a string says which credential it is and the endpoint can refuse a
* connection token pasted into the capture address.
*
* Here rather than beside the data layer that uses it because this file is
* where this codebase mints tokens, and a fourth one somewhere else is how
* conventions rot.
*/
export function generateCaptureToken() {
return `cap_${randomBytes(24).toString("hex")}`;
}
// ---------------------------------------------------------------------------
// Instance setup
// ---------------------------------------------------------------------------
/**
* What makes an account real.
*
* The migration to multi-user left behind a placeholder row owning any
* single-user-era data: no password, and nothing may sign in as it. For a long
* time "has a password" and "is a real account" were the same sentence, so the
* check was spelled `passwordHash != ""` in sixteen places.
*
* Google sign-in breaks that: someone who has only ever used Google has no
* password and is entirely real. So the rule gets a name and one
* implementation — a Prisma fragment for queries and a predicate for rows in
* hand — because sixteen copies of a rule is sixteen chances for the next
* sign-in method to lock somebody out of one screen and not the others.
*/
export const CLAIMED: Prisma.UserWhereInput = {
OR: [{ passwordHash: { not: "" } }, { googleId: { not: null } }],
};
export function isClaimed(user: { passwordHash: string; googleId: string | null }) {
return Boolean(user.passwordHash || user.googleId);
}
/**
* The address the owner account is created with at first boot. Nobody's real
* address, so the first sign-in asks for one: see the change-password page.
*/
export const PLACEHOLDER_OWNER_EMAIL = "owner@localhost";
/** The instance is unclaimed until somebody who can sign in exists. */
export async function instanceNeedsSetup() {
return (await db.user.count({ where: CLAIMED })) === 0;
}
export function setupKeyIsRequired() {
return Boolean(process.env.APP_PASSWORD);
}
export function setupKeyMatches(candidate: string) {
const expected = process.env.APP_PASSWORD ?? "";
if (!expected) return true; // nothing configured — nothing to check
const a = Buffer.from(candidate);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
/**
* Claim the instance as super admin. Adopts the placeholder owner when one
* exists so data from the single-user era keeps its ids and simply changes
* hands, rather than being orphaned or deleted.
*/
export async function claimInstance(input: {
email: string;
name: string;
password: string;
}) {
if (!(await instanceNeedsSetup())) {
throw new Error("This instance has already been set up.");
}
const email = input.email.trim().toLowerCase();
const placeholder = await db.user.findFirst({ where: { passwordHash: "", googleId: null } });
const data = {
email,
name: input.name.trim(),
passwordHash: hashPassword(input.password),
role: "SUPER_ADMIN" as const,
isActive: true,
emailProvenAt: new Date(),
};
const user = placeholder
? await db.user.update({ where: { id: placeholder.id }, data })
: await db.user.create({ data });
// Same as acceptInvite: the name and address they just typed are the header
// of every resume, and without this they would have to type them again.
await db.profile.upsert({
where: { userId: user.id },
create: { userId: user.id, fullName: input.name.trim(), email },
update: {},
});
await ensureDefaultConnection(user.id);
return user;
}
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
/**
* Sign this browser in. `remember` is the sign-in page's "keep me signed in":
* on, a session lasts a month and survives the browser closing; off, it is a
* few hours and dies with the window, which is what somebody on a borrowed
* machine is asking for. Everything else that starts a session — setup,
* accepting an invitation, changing your own password — is already a
* deliberate act on a machine you own, so it takes the default.
*/
export async function startSession(userId: string, options: { remember?: boolean } = {}) {
const remember = options.remember !== false;
const token = generateSessionToken();
const expiresAt = new Date(
Date.now() + (remember ? SESSION_DAYS * 86400_000 : UNREMEMBERED_HOURS * 3600_000),
);
const headerList = await headers();
await db.session.create({
data: {
token,
userId,
expiresAt,
userAgent: (headerList.get("user-agent") ?? "").slice(0, 200),
},
});
await db.user.update({ where: { id: userId }, data: { lastLoginAt: new Date() } });
const jar = await cookies();
jar.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
// No expiry when they did not ask to be remembered: the cookie goes when
// the browser does, and the row above expires on its own soon after.
...(remember ? { expires: expiresAt } : {}),
});
await writeSignedInHint(jar, remember ? expiresAt : undefined);
}
/**
* A session that exists only long enough for the PDF renderer to load one page.
*
* Deliberately an ordinary Session row rather than a new kind of token: the
* print page keeps its `requireUser()` guard, so there is no new authentication
* bypass to reason about, and this inherits every check that already applies —
* a suspended user's ephemeral session is rejected exactly like any other.
* Callers must delete it when they're done; the short expiry is the backstop.
*/
export async function createEphemeralSession(userId: string, seconds = 120) {
const token = generateSessionToken();
await db.session.create({
data: {
token,
userId,
expiresAt: new Date(Date.now() + seconds * 1000),
userAgent: "hired-pdf-renderer",
},
});
return token;
}
export async function destroySession(token: string) {
await db.session.deleteMany({ where: { token } });
}
export async function endSession() {
const jar = await cookies();
const token = await sessionToken(jar);
if (token) await db.session.deleteMany({ where: { token } });
jar.delete(SESSION_COOKIE);
jar.delete(LEGACY_SESSION_COOKIE);
await clearSignedInHint(jar);
}
/** Sign every device out — used when a password changes. */
export async function endAllSessions(userId: string) {
await db.session.deleteMany({ where: { userId } });
}
export async function getCurrentUser(): Promise<User | null> {
const jar = await cookies();
const token = await sessionToken(jar);
if (!token) return null;
const session = await db.session.findUnique({ where: { token }, include: { user: true } });
if (!session) return null;
if (session.expiresAt < new Date()) {
await db.session.delete({ where: { id: session.id } }).catch(() => {});
return null;
}
if (!session.user.isActive || !isClaimed(session.user)) return null;
return session.user;
}
/**
* The signed-in person for a route that must answer rather than redirect —
* the assistant's stream, the OAuth hops — with requireUser's password gate
* kept. An account still on a password somebody handed it gets null here, the
* same as no session: otherwise the drawer could drive every tool, and the
* Google link route could turn a leaked temporary password into a lasting
* sign-in, without the person ever setting their own.
*/
export async function currentUserForApi(): Promise<User | null> {
const user = await getCurrentUser();
if (!user || user.mustChangePassword) return null;
return user;
}
/**
* Sign in with a password. Deliberately still tests `passwordHash` rather than
* `isClaimed`: a Google-only account has no password, so there is nothing here
* for it to match, and saying so is the point.
*/
export async function authenticate(email: string, password: string): Promise<User | null> {
const user = await db.user.findUnique({ where: { email: email.trim().toLowerCase() } });
// Spend the same work whether or not the account exists.
const hash = user?.passwordHash || "scrypt:16384:8:1:00:00";
const ok = verifyPassword(password, hash);
if (!user || !ok || !user.isActive || !user.passwordHash) return null;
return user;
}
// ---------------------------------------------------------------------------
// Guards
// ---------------------------------------------------------------------------
/**
* The signed-in person, or the sign-in page.
*
* It also enforces the one gate that is not about identity: an account whose
* password was handed to it by an admin — an invitation with a password on it,
* or a reset marked "must change" — gets nothing but `/change-password` until
* it sets its own. The check lives here rather than in the app layout because
* every server action goes through this function too, and a gate that only the
* chrome enforces is a gate you can walk around by posting a form.
*
* `/change-password` itself uses `requireUserPendingPasswordChange`, which is
* the same function without the redirect — otherwise the one screen that can
* clear the flag would bounce off it.
*/
export async function requireUser(): Promise<User> {
const user = await requireUserPendingPasswordChange();
if (user.mustChangePassword) redirect("/change-password");
return user;
}
/** `requireUser` minus the password gate. Only the screen behind it may call this. */
export async function requireUserPendingPasswordChange(): Promise<User> {
const user = await getCurrentUser();
if (!user) redirect("/login");
return user;
}
export function isAdmin(user: { role: UserRole }) {
return user.role === "ADMIN" || user.role === "SUPER_ADMIN";
}
export async function requireAdmin(): Promise<User> {
const user = await requireUser();
if (!isAdmin(user)) redirect("/");
return user;
}
export async function requireSuperAdmin(): Promise<User> {
const user = await requireUser();
if (user.role !== "SUPER_ADMIN") redirect("/");
return user;
}
// ---------------------------------------------------------------------------
// MCP tokens — each connection belongs to exactly one user, so a connection URL
// only ever reaches that person's data.
// ---------------------------------------------------------------------------
/** Don't write a timestamp on every single tool call. */
const LAST_USED_RESOLUTION_MS = 60_000;
/**
* The caller, and the connection they came in on.
*
* The connection id travels with the user because a tool that manages
* connections has to know which one it is speaking through — that is what lets
* "disconnect the old laptop" refuse to cut the wire it is standing on.
*/
/**
* `connectionName` rides along because the change log stores the name as it was
* at the time: a log that resolved the name at read time would relabel every
* historic row the day somebody renamed a connection, and go blank the day they
* deleted one. Same call AdminAudit makes about actorId.
*/
export type McpCaller = {
user: User;
connectionId: string;
connectionName: string;
/**
* What this connection is served. Re-read from the row on EVERY post — no
* cache, because the transport is stateless and that is what makes "narrow it
* now" take effect on the client's next call rather than after a restart.
*/
scope: McpScope;
};
export async function userByMcpToken(
token: string | null | undefined,
userAgent = "",
): Promise<McpCaller | null> {
if (!token || !token.startsWith("rsm_")) return null;
const connection = await db.mcpConnection.findUnique({
where: { token },
include: { user: true },
});
if (!connection) return null;
const { user } = connection;
if (!user.isActive || !isClaimed(user)) return null;
const now = Date.now();
const stale =
!connection.lastUsedAt || now - connection.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS;
if (stale) {
// Piggybacking on the once-a-minute-per-connection throttle already here,
// which closes the assistant-only case: an instance nobody signs into and
// nobody restarts still clears its archive. Never let bookkeeping fail a
// real request.
void sweepArchive().catch(() => {});
// The version store's sweep rides the same throttle, for the same reason.
void sweepRevisions().catch(() => {});
void db.mcpConnection
.update({
where: { id: connection.id },
data: {
lastUsedAt: new Date(now),
lastUsedFrom: userAgent.slice(0, 200),
},
})
.catch(() => {});
}
return { user, connectionId: connection.id, connectionName: connection.name, scope: connection.scope };
}
/** Every user starts with one connection so Settings is never an empty page. */
export async function ensureDefaultConnection(userId: string) {
const existing = await db.mcpConnection.count({ where: { userId } });
if (existing > 0) return;
await db.mcpConnection.create({
data: { userId, name: "Claude", client: "claude", token: generateMcpToken() },
});
}
export { SESSION_COOKIE, SIGNED_IN_COOKIE };