175 lines
4.7 KiB
TypeScript
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),
|
|
};
|
|
}
|