Files
PaperClipAI/server/src/__tests__/agent-auth-middleware.test.ts
T
DottaandPaperclip f589660ec0 feat(routines): add safe webhook setup and in-routine run management (#13637)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Routines turn scheduled work and external events into tasks for an
assigned agent.
> - Webhook setup was disabled, and actor authentication rejected valid
webhook bearer keys.
> - Operators need to connect and test a sending app before events can
start work.
> - This pull request adds a guided setup with durable connection tests
that cannot dispatch a task.
> - It keeps trigger management, execution tasks, and activity within
the routine.
> - The benefit is a webhook that can be configured, verified, and
operated from one place.

## Linked Issues or Issue Description

Fixes #11937.

Related: #13216 adds provider-specific Sentry support. This PR addresses
general routine setup and ingress. #6841 addresses legacy secret
bindings; this PR retains the existing secret service.

**Current behavior**

Webhook creation is disabled. Bearer deliveries can fail in agent
authentication before the routine checks its key. Setup has no safe
connection test. Runs and Activity send the operator away from the
routine.

**Proposed behavior**

Choose a schedule or a webhook. Follow the setup steps, copy credentials
or complete agent instructions, and test delivery without creating work.
Finish setup to allow future events to start tasks. Edit or remove
compact trigger cards, undo removal, and inspect tasks and activity
inside the routine.

**Reason and benefit**

An operator can verify credentials and delivery before enabling
automatic work. Durable setup state survives refreshes and restarts.
Retry receipts prevent an old test event from starting work after
activation.

## What Changed

- Add a production trigger wizard using reusable Slack setup navigation
and footer components.
- Add schedule and webhook choices, one-time credentials, agent
instructions, and live connection feedback.
- Persist pending setup, test delivery receipts, connection status, and
reversible trigger removal.
- Keep setup checks free of routine runs, tasks, and agent wakeups.
Preserve delivery idempotency after activation.
- Add compact trigger cards, inline editing, key rotation, pause
controls, removal, and Undo.
- Keep Runs and Activity in the routine. Use the shared task list and
compact activity rows.
- Permit only exact public delivery POSTs through actor authentication.
Retain webhook authentication, JSON-object validation, and log
redaction.
- Add production-backed Storybook states and focused server, database,
and UI coverage.
- Document signing modes, setup checks, retries, rotation, HTTPS
ingress, and navigation.

## Verification

- Full workspace typecheck, build, and token gates passed on the rebased
branch. Storybook also builds.
- Focused routine, middleware, logging, shared wizard, and UI coverage
passes on the rebased branch: 195 tests across 14 files. The migration
passed on a fresh PostgreSQL database and on two repeated applications.
- Browser testing used the real app, database, and a deterministic
process worker through Tailscale HTTPS and the current Cloud proxy code.
- Verified rejected keys, safe setup deliveries, persisted state after
restart, activation, retry deduplication, key rotation, schedule
editing, removal, and Undo.
- Fresh bearer and GitHub-signed deliveries created tasks that the
worker checked out and completed. Runs and Activity stayed within the
routine.
- Current Cloud ingress tests passed. Public delivery POSTs passed
through without a browser session; management routes remained gated.
- All 54 current-head PR checks pass, including general and serialized
tests, all eight browser E2E shards, typecheck, build, runner checks,
security checks, and the canary dry run. Two optional Storybook jobs are
skipped by workflow conditions.
- Greptile is 5/5 on commit `7ea63a61e`, with no unresolved review
threads. The stale connection-status finding is fixed and covered by a
regression test.
- No production deployment was performed.

## Risks

- Migration 0281 adds three trigger columns and a test-receipt table. It
is additive and safe to reapply. Apply it before running the new server.
Existing triggers remain live by default.
- Requests without delivery IDs are new events after activation. Senders
must reuse an event's delivery ID for retries.
- Completed webhooks keep normal dispatch behavior. Their management
connection check can start work; the UI states this.
- Removing a trigger archives it. Undo restores the URL and credentials.
Permanent deletion remains available through the existing API.
- Public ingress must remain restricted to the delivery POST route. The
tenant verifies credentials. Cloud sleeping-stack behavior is unchanged.
- Shared setup components also serve Slack. Existing setup contracts and
navigation tests cover that integration.
- Senders must use application/json with an object. Other media types
receive 415.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, shell
execution, and browser testing. The exact deployment model ID and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-19 10:49:05 -05:00

523 lines
18 KiB
TypeScript

import { createHash, createHmac, randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
activityLog,
agentApiKeys,
agents,
boardApiKeys,
heartbeatRuns,
} from "@paperclipai/db";
import { actorMiddleware } from "../middleware/auth.js";
import { errorHandler } from "../middleware/error-handler.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { assertCompanyAccess } from "../routes/authz.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function createSelectChain(rowsForTable: (table: unknown) => unknown[]) {
return {
from(table: unknown) {
return {
where() {
return Promise.resolve(rowsForTable(table));
},
};
},
};
}
function createDbState(input: {
agent: { id: string; companyId: string; status?: string };
agentKey?: { id: string; agentId: string; companyId: string; keyHash: string; responsibleUserId?: string | null };
run?: { id: string; companyId: string; agentId: string; responsibleUserId?: string | null };
}) {
const activity: Array<Record<string, unknown>> = [];
const agentRow = {
id: input.agent.id,
companyId: input.agent.companyId,
status: input.agent.status ?? "active",
};
const keyRow = input.agentKey
? {
id: input.agentKey.id,
agentId: input.agentKey.agentId,
companyId: input.agentKey.companyId,
keyHash: input.agentKey.keyHash,
responsibleUserId: input.agentKey.responsibleUserId ?? null,
revokedAt: null,
scopeConfig: null,
}
: null;
const runRow = input.run
? {
id: input.run.id,
companyId: input.run.companyId,
agentId: input.run.agentId,
responsibleUserId: input.run.responsibleUserId ?? null,
}
: null;
const db = {
select: () =>
createSelectChain((table) => {
if (table === boardApiKeys) return [];
if (table === agentApiKeys) return keyRow ? [keyRow] : [];
if (table === agents) return [agentRow];
if (table === heartbeatRuns) return runRow ? [runRow] : [];
return [];
}),
update: () => ({
set() {
return {
where() {
return Promise.resolve([]);
},
};
},
}),
insert: (table: unknown) => ({
values(values: Record<string, unknown>) {
if (table === activityLog) activity.push(values);
return Promise.resolve([]);
},
}),
} as any;
return { db, activity };
}
function createApp(db: any, deploymentMode: "authenticated" | "local_trusted" = "authenticated") {
const app = express();
app.use(express.json());
app.use(
actorMiddleware(db, {
deploymentMode,
resolveSession: async () => null,
}),
);
app.get("/actor", (req, res) => {
res.json(req.actor);
});
app.post("/api/routine-triggers/public/:publicId/fire", (req, res) => {
res.json({ reachedWebhook: true, actorType: req.actor.type });
});
app.post("/mcp/gateways/:gatewayPublicId", (req, res) => {
res.json({ reachedGatewayProtocol: true, actorType: req.actor.type });
});
app.get("/companies/:companyId/protected", (req, res) => {
assertCompanyAccess(req, req.params.companyId);
res.json({ ok: true });
});
app.get("/companies/:companyId/issues/:issueId", (req, res) => {
assertCompanyAccess(req, req.params.companyId);
res.json({ id: req.params.issueId, readable: true });
});
app.patch("/companies/:companyId/issues/:issueId", (req, res) => {
assertCompanyAccess(req, req.params.companyId);
res.json({ id: req.params.issueId, writable: true });
});
app.use(errorHandler);
return app;
}
function craftAgentJwtWithoutResponsibleClaim(input: {
secret: string;
agentId: string;
companyId: string;
adapterType: string;
runId: string;
expiresInSeconds?: number;
}) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: "HS256", typ: "JWT" };
const claims = {
sub: input.agentId,
company_id: input.companyId,
adapter_type: input.adapterType,
run_id: input.runId,
iat: now,
exp: now + (input.expiresInSeconds ?? 3600),
iss: "paperclip",
aud: "paperclip-api",
};
const headerB64 = Buffer.from(JSON.stringify(header), "utf8").toString("base64url");
const claimsB64 = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
const signingInput = `${headerB64}.${claimsB64}`;
// Sign with the same per-instance, per-company key the server derives. The
// instance defaults to "default" (beforeEach clears PAPERCLIP_INSTANCE_ID),
// matching the live control plane this middleware test exercises. This helper
// only omits the responsible_user_id claim — it is not a cross-instance token.
const signingKey = createHmac("sha256", input.secret).update(`jwt:default:${input.companyId}`).digest("hex");
const signature = createHmac("sha256", signingKey).update(signingInput).digest("base64url");
return `${signingInput}.${signature}`;
}
describe("agent auth middleware", () => {
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
const originalTtl = process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS;
const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
beforeEach(() => {
process.env.PAPERCLIP_AGENT_JWT_SECRET = "auth-middleware-secret";
process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS = "3600";
// Pin the control-plane instance so mint/verify (and the hand-crafted
// legacy token helper) all derive keys under the "default" live instance.
delete process.env.PAPERCLIP_INSTANCE_ID;
});
afterEach(() => {
if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret;
if (originalTtl === undefined) delete process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS;
else process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS = originalTtl;
if (originalInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId;
});
it("keeps header-less local requests as the implicit board actor with their run id", async () => {
const runId = randomUUID();
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
const res = await request(createApp(db, "local_trusted"))
.get("/actor")
.set("X-Paperclip-Run-Id", runId);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ type: "board", userId: "local-board", runId });
});
it.each([
["empty bearer token", "Bearer ", "Empty bearer token"],
["unverified token", "Bearer not-a-token", "Agent token did not verify"],
])("rejects %s instead of retaining the implicit local-board actor", async (_label, authorization, error) => {
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
let commentWrites = 0;
const app = createApp(db, "local_trusted");
app.post("/comments", (_req, res) => {
commentWrites += 1;
res.status(201).json({ ok: true });
});
const res = await request(app).post("/comments").set("Authorization", authorization).send({ body: "reply" });
expect(res.status).toBe(401);
expect(res.body.error).toContain(error);
expect(commentWrites).toBe(0);
});
it.each(["authenticated", "local_trusted"] as const)(
"leaves webhook authentication to the trigger in %s mode",
async (deploymentMode) => {
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
const res = await request(createApp(db, deploymentMode))
.post(`/api/routine-triggers/public/${"a".repeat(24)}/fire`)
.set("Authorization", "Bearer routine-secret")
.send({ event: "created" });
expect(res.status).toBe(200);
expect(res.body).toEqual({ reachedWebhook: true, actorType: "none" });
},
);
it.each([
`/api/routine-triggers/public/${"a".repeat(24)}/rotate-secret`,
"/api/routine-triggers/public/not-a-public-id/fire",
`/api/routine-triggers/public/${"a".repeat(24)}/fire/extra`,
])("does not bypass actor authentication for %s", async (path) => {
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
const res = await request(createApp(db)).post(path).set("Authorization", "Bearer routine-secret");
expect(res.status).toBe(401);
});
it("leaves public MCP gateway bearers for the gateway protocol to validate", async () => {
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
const publicId = `gw_${"a".repeat(32)}`;
const res = await request(createApp(db, "local_trusted"))
.post(`/mcp/gateways/${publicId}`)
.set("Authorization", "Bearer pcgw_runtime_token")
.send({ jsonrpc: "2.0", id: 1, method: "initialize" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ reachedGatewayProtocol: true });
});
it("does not bypass actor authentication for lookalike MCP gateway paths", async () => {
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
const res = await request(createApp(db, "local_trusted"))
.post("/mcp/gateways/not-a-public-id")
.set("Authorization", "Bearer pcgw_runtime_token")
.send({ jsonrpc: "2.0", id: 1, method: "initialize" });
expect(res.status).toBe(401);
expect(res.body.error).toContain("Agent token did not verify");
});
it.each([
["terminated", "Agent is terminated"],
["pending_approval", "Agent is pending approval"],
])("rejects a %s agent JWT instead of retaining local-board", async (status, error) => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const { db } = createDbState({ agent: { id: agentId, companyId, status } });
const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-1");
const res = await request(createApp(db, "local_trusted"))
.get("/actor")
.set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(401);
expect(res.body.error).toContain(error);
});
it("rejects an agent JWT when the agent record belongs to another company", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const { db } = createDbState({ agent: { id: agentId, companyId: randomUUID() } });
const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-1");
const res = await request(createApp(db, "local_trusted"))
.get("/actor")
.set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(401);
expect(res.body.error).toContain("missing or belongs to another company");
});
it("reports an expired agent JWT specifically", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const { db } = createDbState({ agent: { id: agentId, companyId } });
const token = craftAgentJwtWithoutResponsibleClaim({
secret: process.env.PAPERCLIP_AGENT_JWT_SECRET!,
agentId,
companyId,
adapterType: "codex_local",
runId,
expiresInSeconds: -1,
});
const res = await request(createApp(db, "local_trusted"))
.get("/actor")
.set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(401);
expect(res.body.error).toContain("Expired agent token");
});
it("uses the signed responsible_user_id claim and keeps the signed run id authoritative", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const { db } = createDbState({
agent: { id: agentId, companyId },
run: { id: runId, companyId, agentId, responsibleUserId: "user-row" },
});
const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim");
const res = await request(createApp(db))
.get("/actor")
.set("Authorization", `Bearer ${token}`)
.set("X-Paperclip-Run-Id", runId);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
type: "agent",
agentId,
companyId,
runId,
onBehalfOfUserId: "user-claim",
source: "agent_jwt",
});
});
it("preserves signed skill_test JWT scope on the request actor", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const issueId = randomUUID();
const { db } = createDbState({
agent: { id: agentId, companyId },
run: { id: runId, companyId, agentId, responsibleUserId: "user-claim" },
});
const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim", {
kind: "skill_test",
issueId,
});
const res = await request(createApp(db))
.get("/actor")
.set("Authorization", `Bearer ${token}`)
.set("X-Paperclip-Run-Id", runId);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
type: "agent",
agentId,
companyId,
keyScope: { kind: "skill_test", issueId },
source: "agent_jwt",
});
});
it("rejects mismatched run headers for agent JWTs and audits the spoof attempt", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const spoofedRunId = randomUUID();
const { db, activity } = createDbState({
agent: { id: agentId, companyId },
run: { id: runId, companyId, agentId, responsibleUserId: "user-claim" },
});
const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim");
const res = await request(createApp(db))
.get("/actor")
.set("Authorization", `Bearer ${token}`)
.set("X-Paperclip-Run-Id", spoofedRunId);
expect(res.status).toBe(422);
expect(res.body.code).toBe("agent_jwt_run_id_mismatch");
expect(activity).toHaveLength(1);
expect(activity[0]).toMatchObject({
companyId,
actorType: "agent",
actorId: agentId,
action: "auth.agent_jwt_run_header_mismatch",
entityType: "heartbeat_run",
entityId: runId,
runId,
details: { claimRunId: runId, headerRunId: spoofedRunId },
});
});
it("falls back to the run row responsible user for legacy claim-less agent JWTs", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const { db } = createDbState({
agent: { id: agentId, companyId },
run: { id: runId, companyId, agentId, responsibleUserId: "user-legacy" },
});
const token = craftAgentJwtWithoutResponsibleClaim({
secret: process.env.PAPERCLIP_AGENT_JWT_SECRET!,
agentId,
companyId,
adapterType: "codex_local",
runId,
});
const res = await request(createApp(db))
.get("/actor")
.set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
type: "agent",
runId,
onBehalfOfUserId: "user-legacy",
source: "agent_jwt",
});
});
it("rejects fork-minted run JWTs before issue reads or writes reach live issue data", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const issueId = randomUUID();
const { db } = createDbState({
agent: { id: agentId, companyId },
run: { id: runId, companyId, agentId, responsibleUserId: "user-claim" },
});
process.env.PAPERCLIP_INSTANCE_ID = "pap-12899-worktree";
const forkToken = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim");
expect(forkToken).not.toBeNull();
process.env.PAPERCLIP_INSTANCE_ID = "default";
const app = createApp(db);
const readRes = await request(app)
.get(`/companies/${companyId}/issues/${issueId}`)
.set("Authorization", `Bearer ${forkToken}`)
.set("X-Paperclip-Run-Id", runId);
const writeRes = await request(app)
.patch(`/companies/${companyId}/issues/${issueId}`)
.set("Authorization", `Bearer ${forkToken}`)
.set("X-Paperclip-Run-Id", runId)
.send({ title: "should not write" });
expect(readRes.status).toBe(401);
expect(writeRes.status).toBe(401);
});
it("populates agent-key actors from the key responsible user binding", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const token = "pcp_test_agent_key";
const { db } = createDbState({
agent: { id: agentId, companyId },
agentKey: {
id: randomUUID(),
agentId,
companyId,
keyHash: hashToken(token),
responsibleUserId: "user-key",
},
});
const res = await request(createApp(db))
.get("/actor")
.set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
type: "agent",
agentId,
companyId,
onBehalfOfUserId: "user-key",
source: "agent_key",
});
});
it("rejects agent keys that lack a responsible user binding and audits the denial", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const keyId = randomUUID();
const token = "pcp_test_agent_key_without_user";
const { db, activity } = createDbState({
agent: { id: agentId, companyId },
agentKey: {
id: keyId,
agentId,
companyId,
keyHash: hashToken(token),
responsibleUserId: null,
},
});
const res = await request(createApp(db))
.get(`/companies/${companyId}/protected`)
.set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe("RESPONSIBLE_USER_UNAVAILABLE");
expect(activity).toHaveLength(1);
expect(activity[0]).toMatchObject({
companyId,
actorType: "agent",
actorId: agentId,
action: "auth.agent_key_missing_responsible_user",
entityType: "agent_api_key",
entityId: keyId,
details: { method: "GET", url: `/companies/${companyId}/protected` },
});
});
});