Add browser-based board CLI auth flow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-23 07:48:03 -05:00
parent 1376fc8f44
commit 37c2c4acc4
31 changed files with 13299 additions and 19 deletions

View file

@ -19,10 +19,12 @@ import {
} from "@paperclipai/db";
import {
acceptInviteSchema,
createCliAuthChallengeSchema,
claimJoinRequestApiKeySchema,
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
listJoinRequestsQuerySchema,
resolveCliAuthChallengeSchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
PERMISSION_KEYS
@ -40,6 +42,7 @@ import { validate } from "../middleware/validate.js";
import {
accessService,
agentService,
boardAuthService,
deduplicateAgentName,
logActivity,
notifyHireApproved
@ -95,6 +98,10 @@ function requestBaseUrl(req: Request) {
return `${proto}://${host}`;
}
function buildCliAuthApprovalPath(challengeId: string, token: string) {
return `/cli-auth/${challengeId}?token=${encodeURIComponent(token)}`;
}
function readSkillMarkdown(skillName: string): string | null {
const normalized = skillName.trim().toLowerCase();
if (
@ -1537,6 +1544,7 @@ export function accessRoutes(
) {
const router = Router();
const access = accessService(db);
const boardAuth = boardAuthService(db);
const agents = agentService(db);
async function assertInstanceAdmin(req: Request) {
@ -1594,6 +1602,159 @@ export function accessRoutes(
throw conflict("Board claim challenge is no longer available");
});
router.post(
"/cli-auth/challenges",
validate(createCliAuthChallengeSchema),
async (req, res) => {
const created = await boardAuth.createCliAuthChallenge(req.body);
const approvalPath = buildCliAuthApprovalPath(
created.challenge.id,
created.challengeSecret,
);
const baseUrl = requestBaseUrl(req);
res.status(201).json({
id: created.challenge.id,
token: created.challengeSecret,
boardApiToken: created.pendingBoardToken,
approvalPath,
approvalUrl: baseUrl ? `${baseUrl}${approvalPath}` : null,
pollPath: `/cli-auth/challenges/${created.challenge.id}`,
expiresAt: created.challenge.expiresAt.toISOString(),
suggestedPollIntervalMs: 1000,
});
},
);
router.get("/cli-auth/challenges/:id", async (req, res) => {
const id = (req.params.id as string).trim();
const token =
typeof req.query.token === "string" ? req.query.token.trim() : "";
if (!id || !token) throw notFound("CLI auth challenge not found");
const challenge = await boardAuth.describeCliAuthChallenge(id, token);
if (!challenge) throw notFound("CLI auth challenge not found");
const isSignedInBoardUser =
req.actor.type === "board" &&
(req.actor.source === "session" || isLocalImplicit(req)) &&
Boolean(req.actor.userId);
const canApprove =
isSignedInBoardUser &&
(challenge.requestedAccess !== "instance_admin_required" ||
isLocalImplicit(req) ||
Boolean(req.actor.isInstanceAdmin));
res.json({
...challenge,
requiresSignIn: !isSignedInBoardUser,
canApprove,
currentUserId: req.actor.type === "board" ? req.actor.userId ?? null : null,
});
});
router.post(
"/cli-auth/challenges/:id/approve",
validate(resolveCliAuthChallengeSchema),
async (req, res) => {
const id = (req.params.id as string).trim();
if (
req.actor.type !== "board" ||
(!req.actor.userId && !isLocalImplicit(req))
) {
throw unauthorized("Sign in before approving CLI access");
}
const userId = req.actor.userId ?? "local-board";
const approved = await boardAuth.approveCliAuthChallenge(
id,
req.body.token,
userId,
);
if (approved.status === "approved") {
const accessSnapshot = await boardAuth.resolveBoardAccess(userId);
for (const companyId of accessSnapshot.companyIds) {
await logActivity(db, {
companyId,
actorType: "user",
actorId: userId,
action: "board_api_key.created",
entityType: "user",
entityId: userId,
details: {
boardApiKeyId: approved.challenge.boardApiKeyId,
requestedAccess: approved.challenge.requestedAccess,
requestedCompanyId: approved.challenge.requestedCompanyId,
challengeId: approved.challenge.id,
},
});
}
}
res.json({
approved: approved.status === "approved",
status: approved.status,
userId,
keyId: approved.challenge.boardApiKeyId ?? null,
expiresAt: approved.challenge.expiresAt.toISOString(),
});
},
);
router.post(
"/cli-auth/challenges/:id/cancel",
validate(resolveCliAuthChallengeSchema),
async (req, res) => {
const id = (req.params.id as string).trim();
const cancelled = await boardAuth.cancelCliAuthChallenge(id, req.body.token);
res.json({
status: cancelled.status,
cancelled: cancelled.status === "cancelled",
});
},
);
router.get("/cli-auth/me", async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
const accessSnapshot = await boardAuth.resolveBoardAccess(req.actor.userId);
res.json({
user: accessSnapshot.user,
userId: req.actor.userId,
isInstanceAdmin: accessSnapshot.isInstanceAdmin,
companyIds: accessSnapshot.companyIds,
source: req.actor.source ?? "none",
keyId: req.actor.source === "board_key" ? req.actor.keyId ?? null : null,
});
});
router.post("/cli-auth/revoke-current", async (req, res) => {
if (req.actor.type !== "board" || req.actor.source !== "board_key") {
throw badRequest("Current board API key context is required");
}
const key = await boardAuth.assertCurrentBoardKey(
req.actor.keyId,
req.actor.userId,
);
await boardAuth.revokeBoardApiKey(key.id);
const accessSnapshot = await boardAuth.resolveBoardAccess(key.userId);
for (const companyId of accessSnapshot.companyIds) {
await logActivity(db, {
companyId,
actorType: "user",
actorId: key.userId,
action: "board_api_key.revoked",
entityType: "user",
entityId: key.userId,
details: {
boardApiKeyId: key.id,
revokedVia: "cli_auth_logout",
},
});
}
res.json({ revoked: true, keyId: key.id });
});
async function assertCompanyPermission(
req: Request,
companyId: string,