import "server-only"; import { and, desc, eq } from "drizzle-orm"; import { db } from "@/lib/db"; import { user, userAccessKey } from "@/lib/db/schema"; import { generateAccessKey, getAccessKeyPrefix, hashAccessKey, } from "@/lib/access-keys/token"; export const ACCESS_KEY_SCOPES = ["canvas:read", "canvas:write"] as const; export type AccessKeyScope = (typeof ACCESS_KEY_SCOPES)[number]; const ACCESS_KEY_SCOPE_SET = new Set(ACCESS_KEY_SCOPES); const DEFAULT_SCOPES: AccessKeyScope[] = ["canvas:read", "canvas:write"]; function getAccessKeyPepper() { const pepper = String( process.env.ACCESS_KEY_PEPPER || process.env.AUTH_SECRET || "" ).trim(); if (!pepper) { throw new Error("ACCESS_KEY_PEPPER or AUTH_SECRET is required"); } return pepper; } export function normalizeAccessKeyScopes(value: unknown): AccessKeyScope[] { const scopes = value === undefined ? DEFAULT_SCOPES : Array.isArray(value) ? value : []; const normalized = scopes .map((scope) => String(scope || "").trim()) .filter((scope): scope is AccessKeyScope => ACCESS_KEY_SCOPE_SET.has(scope)); return Array.from(new Set(normalized)); } function serializeAccessKey(row: typeof userAccessKey.$inferSelect) { return { id: row.id, name: row.name, tokenPrefix: row.tokenPrefix, scopes: normalizeAccessKeyScopes(row.scopes), lastUsedAt: row.lastUsedAt?.toISOString() || null, expiresAt: row.expiresAt?.toISOString() || null, revokedAt: row.revokedAt?.toISOString() || null, createdAt: row.createdAt.toISOString(), }; } export async function listUserAccessKeys(userId: string) { const rows = await db .select() .from(userAccessKey) .where(eq(userAccessKey.userId, userId)) .orderBy(desc(userAccessKey.createdAt)); return rows.map(serializeAccessKey); } export async function createUserAccessKey(params: { userId: string; name?: string; scopes?: unknown; expiresInDays?: number | null; }) { const name = String(params.name || "OpenClaw").trim().slice(0, 100); if (!name) { throw new Error("Access Key name is required"); } const expiresInDays = params.expiresInDays == null ? Number.NaN : Number(params.expiresInDays); const boundedDays = Number.isFinite(expiresInDays) ? Math.min(365, Math.max(1, Math.round(expiresInDays))) : 90; const expiresAt = new Date(Date.now() + boundedDays * 24 * 60 * 60 * 1000); const { token, tokenPrefix } = generateAccessKey(); const tokenHash = hashAccessKey(token, getAccessKeyPepper()); const [created] = await db .insert(userAccessKey) .values({ userId: params.userId, name, tokenPrefix, tokenHash, scopes: normalizeAccessKeyScopes(params.scopes), expiresAt, }) .returning(); if (!created) { throw new Error("Failed to create Access Key"); } return { ...serializeAccessKey(created), token, }; } export async function revokeUserAccessKey(params: { userId: string; accessKeyId: string; }) { const now = new Date(); const [revoked] = await db .update(userAccessKey) .set({ revokedAt: now, updatedAt: now }) .where( and( eq(userAccessKey.id, params.accessKeyId), eq(userAccessKey.userId, params.userId) ) ) .returning({ id: userAccessKey.id }); return Boolean(revoked); } export type AccessKeyPrincipal = { userId: string; accessKeyId: string; accessKeyName: string; scopes: AccessKeyScope[]; }; export async function authenticateUserAccessKey( token: string ): Promise { const tokenPrefix = getAccessKeyPrefix(token); if (!tokenPrefix) return null; const tokenHash = hashAccessKey(token, getAccessKeyPepper()); const rows = await db .select({ accessKey: userAccessKey, userId: user.id, userIsActive: user.isActive, }) .from(userAccessKey) .innerJoin(user, eq(user.id, userAccessKey.userId)) .where( and( eq(userAccessKey.tokenHash, tokenHash), eq(userAccessKey.tokenPrefix, tokenPrefix) ) ) .limit(1); const match = rows[0]; if (!match?.userIsActive || match.accessKey.revokedAt) return null; if ( match.accessKey.expiresAt && match.accessKey.expiresAt.getTime() <= Date.now() ) { return null; } const lastUsedAt = match.accessKey.lastUsedAt; if (!lastUsedAt || Date.now() - lastUsedAt.getTime() > 5 * 60 * 1000) { const now = new Date(); await db .update(userAccessKey) .set({ lastUsedAt: now, updatedAt: now }) .where(eq(userAccessKey.id, match.accessKey.id)); } return { userId: match.userId, accessKeyId: match.accessKey.id, accessKeyName: match.accessKey.name, scopes: normalizeAccessKeyScopes(match.accessKey.scopes), }; }