mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue