Files
Epicure/apps/web/lib/api-auth.ts
T
Arnaud 2beb23b360 feat: public shopping list links can allow editing
Owner opts in per-list via a new "Allow editing" toggle next to the
existing public-link switch. Anonymous writes are scoped to that one
list only — the link id is the sole credential, enforced in
getShoppingListAccess and the item routes (no session required there
now), with an IP rate limit on genuinely anonymous requests. Turning
off the public link also revokes editing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 12:53:52 +02:00

148 lines
4.7 KiB
TypeScript

import crypto from "node:crypto";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { db, apiKeys, users, eq } from "@epicure/db";
import { applyRateLimit } from "@/lib/rate-limit";
export async function requireSession() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
return { session, response: null };
}
/** Like requireSession, but never 401s — for endpoints that also accept anonymous
* access via a resource-scoped capability (e.g. a public-editable share link). */
export async function getOptionalSession() {
return auth.api.getSession({ headers: await headers() });
}
export async function requireAdmin() {
const { session, response } = await requireSession();
if (response) return { session: null, response };
// Don't trust session.user.role — it comes from a 5-minute cookieCache
// (see lib/auth/server.ts), so a just-demoted admin would keep access for
// up to 5 minutes. Query the current role directly.
const [dbUser] = await db
.select({ role: users.role })
.from(users)
.where(eq(users.id, session!.user.id))
.limit(1);
if (dbUser?.role !== "admin") {
return { session: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { session, response: null };
}
type SessionLike = {
user: {
id: string;
email: string;
name: string;
tier: string;
role?: string;
image?: string | null;
};
};
type RateLimitOpts = { limit: number; windowSeconds: number };
export async function requireSessionOrApiKey(
req: NextRequest,
opts?: { rateLimit?: RateLimitOpts }
): Promise<{ session: SessionLike; response: null } | { session: null; response: NextResponse }> {
// 1. Try Bearer API key
const authHeader = req.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
const rawKey = authHeader.slice(7).trim();
if (rawKey.startsWith("ek_")) {
const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
const [keyRow] = await db
.select({ id: apiKeys.id, userId: apiKeys.userId, scope: apiKeys.scope })
.from(apiKeys)
.where(eq(apiKeys.keyHash, keyHash))
.limit(1);
if (keyRow) {
// Read-scoped keys can't make any state-changing request — enforced
// once here rather than in every route, since a route can't tell
// whether it's being called by a "read" key without this check.
if (keyRow.scope === "read" && !["GET", "HEAD", "OPTIONS"].includes(req.method)) {
return {
session: null,
response: NextResponse.json({ error: "This API key is read-only" }, { status: 403 }),
};
}
// Update lastUsedAt asynchronously — don't block response
void db
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, keyRow.id))
.catch((err) => console.error("[api-auth] failed to update apiKeys.lastUsedAt", err));
const [user] = await db
.select({
id: users.id,
email: users.email,
name: users.name,
tier: users.tier,
role: users.role,
})
.from(users)
.where(eq(users.id, keyRow.userId))
.limit(1);
if (user) {
// Rate limit per API key (not per user — a user's other keys shouldn't
// share this bucket).
if (opts?.rateLimit) {
const { limit, windowSeconds } = opts.rateLimit;
const rateLimitResponse = await applyRateLimit(
`rl:api:key:${keyRow.id}`,
limit,
windowSeconds
);
if (rateLimitResponse) {
return { session: null, response: rateLimitResponse };
}
}
return {
session: { user: { ...user, image: null } },
response: null,
};
}
}
return {
session: null,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
};
}
}
// 2. Fall back to session cookie
const result = await requireSession();
if (result.response) return result;
if (opts?.rateLimit) {
const { limit, windowSeconds } = opts.rateLimit;
const rateLimitResponse = await applyRateLimit(
`rl:api:session:${result.session!.user.id}`,
limit,
windowSeconds
);
if (rateLimitResponse) {
return { session: null, response: rateLimitResponse };
}
}
return result;
}