mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Add browser-based board CLI auth flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
1376fc8f44
commit
37c2c4acc4
31 changed files with 13299 additions and 19 deletions
|
|
@ -3,7 +3,10 @@ import express from "express";
|
|||
import request from "supertest";
|
||||
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
|
||||
|
||||
function createApp(actorType: "board" | "agent", boardSource: "session" | "local_implicit" = "session") {
|
||||
function createApp(
|
||||
actorType: "board" | "agent",
|
||||
boardSource: "session" | "local_implicit" | "board_key" = "session",
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
|
|
@ -29,11 +32,26 @@ describe("boardMutationGuard", () => {
|
|||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("blocks board mutations without trusted origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app).post("/mutate").send({ ok: true });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Board mutation requires trusted browser origin" });
|
||||
it("blocks board mutations without trusted origin", () => {
|
||||
const middleware = boardMutationGuard();
|
||||
const req = {
|
||||
method: "POST",
|
||||
actor: { type: "board", userId: "board", source: "session" },
|
||||
header: () => undefined,
|
||||
} as any;
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
} as any;
|
||||
const next = vi.fn();
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: "Board mutation requires trusted browser origin",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local implicit board mutations without origin", async () => {
|
||||
|
|
@ -42,6 +60,12 @@ describe("boardMutationGuard", () => {
|
|||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("allows board bearer-key mutations without origin", async () => {
|
||||
const app = createApp("board", "board_key");
|
||||
const res = await request(app).post("/mutate").send({ ok: true });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("allows board mutations from trusted origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
|
|
|
|||
164
server/src/__tests__/cli-auth-routes.test.ts
Normal file
164
server/src/__tests__/cli-auth-routes.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBoardAuthService = vi.hoisted(() => ({
|
||||
createCliAuthChallenge: vi.fn(),
|
||||
describeCliAuthChallenge: vi.fn(),
|
||||
approveCliAuthChallenge: vi.fn(),
|
||||
cancelCliAuthChallenge: vi.fn(),
|
||||
resolveBoardAccess: vi.fn(),
|
||||
assertCurrentBoardKey: vi.fn(),
|
||||
revokeBoardApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
deduplicateAgentName: vi.fn((name: string) => name),
|
||||
}));
|
||||
|
||||
function createApp(actor: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
return import("../routes/access.js").then(({ accessRoutes }) =>
|
||||
import("../middleware/index.js").then(({ errorHandler }) => {
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes({} as any, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe("cli auth routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a CLI auth challenge with approval metadata", async () => {
|
||||
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
||||
challenge: {
|
||||
id: "challenge-1",
|
||||
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
||||
},
|
||||
challengeSecret: "pcp_cli_auth_secret",
|
||||
pendingBoardToken: "pcp_board_token",
|
||||
});
|
||||
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges")
|
||||
.send({
|
||||
command: "paperclipai company import",
|
||||
clientName: "paperclipai cli",
|
||||
requestedAccess: "board",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "challenge-1",
|
||||
token: "pcp_cli_auth_secret",
|
||||
boardApiToken: "pcp_board_token",
|
||||
approvalPath: "/cli-auth/challenge-1?token=pcp_cli_auth_secret",
|
||||
pollPath: "/cli-auth/challenges/challenge-1",
|
||||
expiresAt: "2026-03-23T13:00:00.000Z",
|
||||
});
|
||||
expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret");
|
||||
});
|
||||
|
||||
it("marks challenge status as requiring sign-in for anonymous viewers", async () => {
|
||||
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
|
||||
id: "challenge-1",
|
||||
status: "pending",
|
||||
command: "paperclipai company import",
|
||||
clientName: "paperclipai cli",
|
||||
requestedAccess: "board",
|
||||
requestedCompanyId: null,
|
||||
requestedCompanyName: null,
|
||||
approvedAt: null,
|
||||
cancelledAt: null,
|
||||
expiresAt: "2026-03-23T13:00:00.000Z",
|
||||
approvedByUser: null,
|
||||
});
|
||||
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.requiresSignIn).toBe(true);
|
||||
expect(res.body.canApprove).toBe(false);
|
||||
});
|
||||
|
||||
it("approves a CLI auth challenge for a signed-in board user", async () => {
|
||||
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
|
||||
status: "approved",
|
||||
challenge: {
|
||||
id: "challenge-1",
|
||||
boardApiKeyId: "board-key-1",
|
||||
requestedAccess: "board",
|
||||
requestedCompanyId: "company-1",
|
||||
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
mockBoardAuthService.resolveBoardAccess.mockResolvedValue({
|
||||
user: { id: "user-1", name: "User One", email: "user@example.com" },
|
||||
companyIds: ["company-1"],
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges/challenge-1/approve")
|
||||
.send({ token: "pcp_cli_auth_secret" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
approved: true,
|
||||
status: "approved",
|
||||
userId: "user-1",
|
||||
keyId: "board-key-1",
|
||||
expiresAt: "2026-03-23T13:00:00.000Z",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
action: "board_api_key.created",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -23,11 +23,22 @@ const mockAgentService = vi.hoisted(() => ({
|
|||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBoardAuthService = vi.hoisted(() => ({
|
||||
createCliAuthChallenge: vi.fn(),
|
||||
describeCliAuthChallenge: vi.fn(),
|
||||
approveCliAuthChallenge: vi.fn(),
|
||||
cancelCliAuthChallenge: vi.fn(),
|
||||
resolveBoardAccess: vi.fn(),
|
||||
assertCurrentBoardKey: vi.fn(),
|
||||
revokeBoardApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
|||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { boardAuthService } from "../services/board-auth.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
|
|
@ -18,6 +19,7 @@ interface ActorMiddlewareOptions {
|
|||
}
|
||||
|
||||
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
|
||||
const boardAuth = boardAuthService(db);
|
||||
return async (req, _res, next) => {
|
||||
req.actor =
|
||||
opts.deploymentMode === "local_trusted"
|
||||
|
|
@ -80,6 +82,25 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
|||
return;
|
||||
}
|
||||
|
||||
const boardKey = await boardAuth.findBoardApiKeyByToken(token);
|
||||
if (boardKey) {
|
||||
const access = await boardAuth.resolveBoardAccess(boardKey.userId);
|
||||
if (access.user) {
|
||||
await boardAuth.touchBoardApiKey(boardKey.id);
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId: boardKey.userId,
|
||||
companyIds: access.companyIds,
|
||||
isInstanceAdmin: access.isInstanceAdmin,
|
||||
keyId: boardKey.id,
|
||||
runId: runIdHeader || undefined,
|
||||
source: "board_key",
|
||||
};
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(token);
|
||||
const key = await db
|
||||
.select()
|
||||
|
|
|
|||
|
|
@ -49,10 +49,9 @@ export function boardMutationGuard(): RequestHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
// Local-trusted mode uses an implicit board actor for localhost-only development.
|
||||
// In this mode, origin/referer headers can be omitted by some clients for multipart
|
||||
// uploads; do not block those mutations.
|
||||
if (req.actor.source === "local_implicit") {
|
||||
// Local-trusted mode and board bearer keys are not browser-session requests.
|
||||
// In these modes, origin/referer headers can be absent; do not block those mutations.
|
||||
if (req.actor.source === "local_implicit" || req.actor.source === "board_key") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
301
server/src/services/board-auth.ts
Normal file
301
server/src/services/board-auth.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
authUsers,
|
||||
boardApiKeys,
|
||||
cliAuthChallenges,
|
||||
companies,
|
||||
companyMemberships,
|
||||
instanceUserRoles,
|
||||
} from "@paperclipai/db";
|
||||
import { conflict, forbidden, notFound } from "../errors.js";
|
||||
|
||||
export const BOARD_API_KEY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
export const CLI_AUTH_CHALLENGE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export type CliAuthChallengeStatus = "pending" | "approved" | "cancelled" | "expired";
|
||||
|
||||
export function hashBearerToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function tokenHashesMatch(left: string, right: string) {
|
||||
const leftBytes = Buffer.from(left, "utf8");
|
||||
const rightBytes = Buffer.from(right, "utf8");
|
||||
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
||||
}
|
||||
|
||||
export function createBoardApiToken() {
|
||||
return `pcp_board_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function createCliAuthSecret() {
|
||||
return `pcp_cli_auth_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function boardApiKeyExpiresAt(nowMs: number = Date.now()) {
|
||||
return new Date(nowMs + BOARD_API_KEY_TTL_MS);
|
||||
}
|
||||
|
||||
export function cliAuthChallengeExpiresAt(nowMs: number = Date.now()) {
|
||||
return new Date(nowMs + CLI_AUTH_CHALLENGE_TTL_MS);
|
||||
}
|
||||
|
||||
function challengeStatusForRow(row: typeof cliAuthChallenges.$inferSelect): CliAuthChallengeStatus {
|
||||
if (row.cancelledAt) return "cancelled";
|
||||
if (row.expiresAt.getTime() <= Date.now()) return "expired";
|
||||
if (row.approvedAt && row.boardApiKeyId) return "approved";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function boardAuthService(db: Db) {
|
||||
async function resolveBoardAccess(userId: string) {
|
||||
const [user, memberships, adminRole] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
name: authUsers.name,
|
||||
email: authUsers.email,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ companyId: companyMemberships.companyId })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, userId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.map((row) => row.companyId)),
|
||||
db
|
||||
.select({ id: instanceUserRoles.id })
|
||||
.from(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
|
||||
return {
|
||||
user,
|
||||
companyIds: memberships,
|
||||
isInstanceAdmin: Boolean(adminRole),
|
||||
};
|
||||
}
|
||||
|
||||
async function findBoardApiKeyByToken(token: string) {
|
||||
const tokenHash = hashBearerToken(token);
|
||||
const now = new Date();
|
||||
return db
|
||||
.select()
|
||||
.from(boardApiKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(boardApiKeys.keyHash, tokenHash),
|
||||
isNull(boardApiKeys.revokedAt),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.find((row) => !row.expiresAt || row.expiresAt.getTime() > now.getTime()) ?? null);
|
||||
}
|
||||
|
||||
async function touchBoardApiKey(id: string) {
|
||||
await db.update(boardApiKeys).set({ lastUsedAt: new Date() }).where(eq(boardApiKeys.id, id));
|
||||
}
|
||||
|
||||
async function revokeBoardApiKey(id: string) {
|
||||
const now = new Date();
|
||||
return db
|
||||
.update(boardApiKeys)
|
||||
.set({ revokedAt: now, lastUsedAt: now })
|
||||
.where(and(eq(boardApiKeys.id, id), isNull(boardApiKeys.revokedAt)))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function createCliAuthChallenge(input: {
|
||||
command: string;
|
||||
clientName?: string | null;
|
||||
requestedAccess: "board" | "instance_admin_required";
|
||||
requestedCompanyId?: string | null;
|
||||
}) {
|
||||
const challengeSecret = createCliAuthSecret();
|
||||
const pendingBoardToken = createBoardApiToken();
|
||||
const expiresAt = cliAuthChallengeExpiresAt();
|
||||
const labelBase = input.clientName?.trim() || "paperclipai cli";
|
||||
const pendingKeyName =
|
||||
input.requestedAccess === "instance_admin_required"
|
||||
? `${labelBase} (instance admin)`
|
||||
: `${labelBase} (board)`;
|
||||
|
||||
const created = await db
|
||||
.insert(cliAuthChallenges)
|
||||
.values({
|
||||
secretHash: hashBearerToken(challengeSecret),
|
||||
command: input.command.trim(),
|
||||
clientName: input.clientName?.trim() || null,
|
||||
requestedAccess: input.requestedAccess,
|
||||
requestedCompanyId: input.requestedCompanyId?.trim() || null,
|
||||
pendingKeyHash: hashBearerToken(pendingBoardToken),
|
||||
pendingKeyName,
|
||||
expiresAt,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return {
|
||||
challenge: created,
|
||||
challengeSecret,
|
||||
pendingBoardToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCliAuthChallenge(id: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(cliAuthChallenges)
|
||||
.where(eq(cliAuthChallenges.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getCliAuthChallengeBySecret(id: string, token: string) {
|
||||
const challenge = await getCliAuthChallenge(id);
|
||||
if (!challenge) return null;
|
||||
if (!tokenHashesMatch(challenge.secretHash, hashBearerToken(token))) return null;
|
||||
return challenge;
|
||||
}
|
||||
|
||||
async function describeCliAuthChallenge(id: string, token: string) {
|
||||
const challenge = await getCliAuthChallengeBySecret(id, token);
|
||||
if (!challenge) return null;
|
||||
|
||||
const [company, approvedBy] = await Promise.all([
|
||||
challenge.requestedCompanyId
|
||||
? db
|
||||
.select({ id: companies.id, name: companies.name })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, challenge.requestedCompanyId))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
challenge.approvedByUserId
|
||||
? db
|
||||
.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, challenge.approvedByUserId))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: challenge.id,
|
||||
status: challengeStatusForRow(challenge),
|
||||
command: challenge.command,
|
||||
clientName: challenge.clientName ?? null,
|
||||
requestedAccess: challenge.requestedAccess as "board" | "instance_admin_required",
|
||||
requestedCompanyId: challenge.requestedCompanyId ?? null,
|
||||
requestedCompanyName: company?.name ?? null,
|
||||
approvedAt: challenge.approvedAt?.toISOString() ?? null,
|
||||
cancelledAt: challenge.cancelledAt?.toISOString() ?? null,
|
||||
expiresAt: challenge.expiresAt.toISOString(),
|
||||
approvedByUser: approvedBy
|
||||
? {
|
||||
id: approvedBy.id,
|
||||
name: approvedBy.name,
|
||||
email: approvedBy.email,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function approveCliAuthChallenge(id: string, token: string, userId: string) {
|
||||
const challenge = await getCliAuthChallengeBySecret(id, token);
|
||||
if (!challenge) throw notFound("CLI auth challenge not found");
|
||||
|
||||
const status = challengeStatusForRow(challenge);
|
||||
if (status === "expired") return { status, challenge };
|
||||
if (status === "cancelled") return { status, challenge };
|
||||
|
||||
const access = await resolveBoardAccess(userId);
|
||||
if (challenge.requestedAccess === "instance_admin_required" && !access.isInstanceAdmin) {
|
||||
throw forbidden("Instance admin required");
|
||||
}
|
||||
|
||||
let boardKeyId = challenge.boardApiKeyId;
|
||||
if (!boardKeyId) {
|
||||
const createdKey = await db
|
||||
.insert(boardApiKeys)
|
||||
.values({
|
||||
userId,
|
||||
name: challenge.pendingKeyName,
|
||||
keyHash: challenge.pendingKeyHash,
|
||||
expiresAt: boardApiKeyExpiresAt(),
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
boardKeyId = createdKey.id;
|
||||
}
|
||||
|
||||
const approvedAt = challenge.approvedAt ?? new Date();
|
||||
const updated = await db
|
||||
.update(cliAuthChallenges)
|
||||
.set({
|
||||
approvedByUserId: userId,
|
||||
boardApiKeyId: boardKeyId,
|
||||
approvedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(cliAuthChallenges.id, challenge.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? challenge);
|
||||
|
||||
return { status: "approved" as const, challenge: updated };
|
||||
}
|
||||
|
||||
async function cancelCliAuthChallenge(id: string, token: string) {
|
||||
const challenge = await getCliAuthChallengeBySecret(id, token);
|
||||
if (!challenge) throw notFound("CLI auth challenge not found");
|
||||
|
||||
const status = challengeStatusForRow(challenge);
|
||||
if (status === "approved") return { status, challenge };
|
||||
if (status === "expired") return { status, challenge };
|
||||
if (status === "cancelled") return { status, challenge };
|
||||
|
||||
const updated = await db
|
||||
.update(cliAuthChallenges)
|
||||
.set({
|
||||
cancelledAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(cliAuthChallenges.id, challenge.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? challenge);
|
||||
|
||||
return { status: "cancelled" as const, challenge: updated };
|
||||
}
|
||||
|
||||
async function assertCurrentBoardKey(keyId: string | undefined, userId: string | undefined) {
|
||||
if (!keyId || !userId) throw conflict("Board API key context is required");
|
||||
const key = await db
|
||||
.select()
|
||||
.from(boardApiKeys)
|
||||
.where(and(eq(boardApiKeys.id, keyId), eq(boardApiKeys.userId, userId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!key || key.revokedAt) throw notFound("Board API key not found");
|
||||
return key;
|
||||
}
|
||||
|
||||
return {
|
||||
resolveBoardAccess,
|
||||
findBoardApiKeyByToken,
|
||||
touchBoardApiKey,
|
||||
revokeBoardApiKey,
|
||||
createCliAuthChallenge,
|
||||
getCliAuthChallengeBySecret,
|
||||
describeCliAuthChallenge,
|
||||
approveCliAuthChallenge,
|
||||
cancelCliAuthChallenge,
|
||||
assertCurrentBoardKey,
|
||||
};
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ export { heartbeatService } from "./heartbeat.js";
|
|||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { accessService } from "./access.js";
|
||||
export { boardAuthService } from "./board-auth.js";
|
||||
export { instanceSettingsService } from "./instance-settings.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
|
|
|
|||
2
server/src/types/express.d.ts
vendored
2
server/src/types/express.d.ts
vendored
|
|
@ -12,7 +12,7 @@ declare global {
|
|||
isInstanceAdmin?: boolean;
|
||||
keyId?: string;
|
||||
runId?: string;
|
||||
source?: "local_implicit" | "session" | "agent_key" | "agent_jwt" | "none";
|
||||
source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "none";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue