Files
clawhub/convex/cliDeviceAuth.ts
T

203 lines
7.0 KiB
TypeScript

import { ConvexError, v } from "convex/values";
import type { Doc } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalMutation, mutation } from "./functions";
import { requireUser } from "./lib/access";
import { generateToken, hashToken } from "./lib/tokens";
const DEVICE_CODE_TTL_MS = 15 * 60_000;
const DEVICE_POLL_INTERVAL_SECONDS = 5;
const MAX_DUPLICATE_USER_CODE_ROWS = 50;
const USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export const createInternal = internalMutation({
args: {
scope: v.optional(v.string()),
label: v.optional(v.string()),
siteUrl: v.optional(v.string()),
},
handler: async (ctx, args) => {
const deviceCode = generateOpaqueCode();
const userCode = generateUserCode();
const now = Date.now();
const label = (args.label?.trim() || "CLI device login").slice(0, 120);
const scope = (args.scope?.trim() || "read write").slice(0, 200);
await ctx.db.insert("cliDeviceCodes", {
deviceCodeHash: await hashToken(deviceCode),
userCodeHash: await hashToken(normalizeUserCode(userCode)),
userCode,
label,
scope,
status: "pending",
createdAt: now,
expiresAt: now + DEVICE_CODE_TTL_MS,
});
const verificationUrl = getVerificationUrl(args.siteUrl, userCode);
return {
device_code: deviceCode,
user_code: userCode,
verification_uri: verificationUrl.toString(),
expires_in: Math.floor(DEVICE_CODE_TTL_MS / 1000),
interval: DEVICE_POLL_INTERVAL_SECONDS,
};
},
});
export const pollInternal = internalMutation({
args: { deviceCode: v.string() },
handler: async (ctx, args) => {
const deviceCodeHash = await hashToken(args.deviceCode);
const row = await ctx.db
.query("cliDeviceCodes")
.withIndex("by_device_code_hash", (q) => q.eq("deviceCodeHash", deviceCodeHash))
.unique();
if (!row) return { error: "expired_token" as const };
const now = Date.now();
if (row.expiresAt <= now) {
if (row.status !== "expired") await ctx.db.patch(row._id, { status: "expired" });
return { error: "expired_token" as const };
}
if (row.status === "pending") return { error: "authorization_pending" as const };
if (row.status === "denied") return { error: "access_denied" as const };
if (row.status === "consumed" || row.status === "expired") {
return { error: "expired_token" as const };
}
if (!row.approvedByUserId) return { error: "authorization_pending" as const };
const { token, prefix } = generateToken();
await ctx.db.insert("apiTokens", {
userId: row.approvedByUserId,
label: row.label,
prefix,
tokenHash: await hashToken(token),
createdAt: now,
lastUsedAt: undefined,
revokedAt: undefined,
});
await ctx.db.patch(row._id, { status: "consumed", consumedAt: now });
return { access_token: token, token_type: "bearer" as const, scope: row.scope };
},
});
export const approve = mutation({
args: { userCode: v.string() },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx);
const normalized = normalizeUserCode(args.userCode);
if (!normalized) throw new ConvexError("Code required");
const userCodeHash = await hashToken(normalized);
const now = Date.now();
const rows = await expireStaleRows(ctx, await getRowsByUserCodeHash(ctx, userCodeHash), now);
const row =
pickLatestRow(rows, now, "pending") ?? pickLatestRow(rows, now) ?? pickLatestRow(rows);
if (!row) throw new ConvexError("Device code not found");
if (row.expiresAt <= now) throw new ConvexError("Device code expired");
if (row.status === "expired") throw new ConvexError("Device code expired");
if (row.status === "consumed") throw new ConvexError("Device code already used");
if (row.status === "approved") throw new ConvexError("Device code already authorized");
if (row.status === "denied") throw new ConvexError("Device code was denied");
await ctx.db.patch(row._id, {
status: "approved",
approvedByUserId: userId,
approvedAt: now,
});
return { ok: true, userCode: row.userCode, expiresAt: row.expiresAt };
},
});
export const deny = mutation({
args: { userCode: v.string() },
handler: async (ctx, args) => {
await requireUser(ctx);
const normalized = normalizeUserCode(args.userCode);
if (!normalized) throw new ConvexError("Code required");
const userCodeHash = await hashToken(normalized);
const now = Date.now();
const rows = await expireStaleRows(ctx, await getRowsByUserCodeHash(ctx, userCodeHash), now);
const row =
pickLatestRow(rows, now, "pending") ?? pickLatestRow(rows, now) ?? pickLatestRow(rows);
if (!row) throw new ConvexError("Device code not found");
if (row.status === "approved") throw new ConvexError("Device code already authorized");
if (row.status === "pending") {
await ctx.db.patch(row._id, { status: "denied", deniedAt: now });
}
return { ok: true };
},
});
function normalizeUserCode(value: string) {
return value
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]/g, "");
}
async function getRowsByUserCodeHash(ctx: MutationCtx, userCodeHash: string) {
return await ctx.db
.query("cliDeviceCodes")
.withIndex("by_user_code_hash", (q) => q.eq("userCodeHash", userCodeHash))
.order("desc")
.take(MAX_DUPLICATE_USER_CODE_ROWS);
}
async function expireStaleRows(ctx: MutationCtx, rows: Array<Doc<"cliDeviceCodes">>, now: number) {
const nextRows: Array<Doc<"cliDeviceCodes">> = [];
for (const row of rows) {
if (row.status === "pending" && row.expiresAt <= now) {
await ctx.db.patch(row._id, { status: "expired" });
nextRows.push({ ...row, status: "expired" });
} else {
nextRows.push(row);
}
}
return nextRows;
}
function pickLatestRow(
rows: Array<Doc<"cliDeviceCodes">>,
now?: number,
status?: Doc<"cliDeviceCodes">["status"],
) {
return rows.find(
(row) =>
(now === undefined || row.expiresAt > now) && (status === undefined || row.status === status),
);
}
function generateUserCode() {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
const raw = Array.from(
bytes,
(byte) => USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length],
).join("");
return `${raw.slice(0, 4)}-${raw.slice(4)}`;
}
function generateOpaqueCode() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function getVerificationUrl(siteUrlValue: string | undefined, userCode: string) {
const baseUrl = siteUrlValue?.trim() || "https://clawhub.ai";
let verificationUrl: URL;
try {
verificationUrl = new URL("/cli/device", baseUrl);
if (verificationUrl.protocol !== "http:" && verificationUrl.protocol !== "https:") {
verificationUrl = new URL("/cli/device", "https://clawhub.ai");
}
} catch {
verificationUrl = new URL("/cli/device", "https://clawhub.ai");
}
verificationUrl.searchParams.set("user_code", userCode);
return verificationUrl;
}