175 lines
4.7 KiB
TypeScript

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<string>(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<AccessKeyPrincipal | null> {
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),
};
}