import { createHash, randomBytes } from "node:crypto"; import { and, eq, gt, isNull, or } from "drizzle-orm"; import { personalTokenScope } from "@/lib/auth/personal-token-scope"; import { databaseUserId } from "@/lib/auth/session-user"; import { db, PersonalAccessTokens } from "@/lib/db"; /** * Bearer-token auth for the public REST API, backed by personal_access_tokens * (the Laravel Sanctum table that already exists in the emulator DB). Tokens are * stored as the sha256 of the plaintext; the client sends the plaintext (or the * Sanctum "{id}|{plaintext}" form) as `Authorization: Bearer …`. */ function hashToken(raw: string): string { return createHash("sha256").update(raw).digest("hex"); } /** Resolve the user id behind a Bearer token, or null. */ export async function bearerUserId(req: Request): Promise { const header = req.headers.get("authorization") ?? ""; const m = header.match(/^Bearer\s+(.+)$/i); if (!m) return null; let raw = m[1].trim(); const pipe = raw.indexOf("|"); if (pipe >= 0) raw = raw.slice(pipe + 1); // Sanctum "{id}|{token}" if (!raw) return null; try { const [row] = await db .select({ id: PersonalAccessTokens.id, tokenableId: PersonalAccessTokens.tokenableId, }) .from(PersonalAccessTokens) .where( and( eq(PersonalAccessTokens.token, hashToken(raw)), or( isNull(PersonalAccessTokens.expiresAt), gt(PersonalAccessTokens.expiresAt, new Date()), ), ), ) .limit(1); if (!row) return null; // Best-effort last-used stamp (don't fail the request if it errors). void db .update(PersonalAccessTokens) .set({ lastUsedAt: new Date() }) .where(eq(PersonalAccessTokens.id, row.id)) .catch(() => {}); return databaseUserId(row.tokenableId); } catch { return null; } } /** Mint a new token for a user. Returns the plaintext (shown once). */ export async function issueToken( userId: number, name = "api", ): Promise { const plaintext = randomBytes(32).toString("hex"); const now = new Date(); try { await db.insert(PersonalAccessTokens).values({ ...personalTokenScope(userId), name: name.slice(0, 100), token: hashToken(plaintext), abilities: '["*"]', createdAt: now, updatedAt: now, }); return plaintext; } catch { return null; } }