diff --git a/doc/CLI.md b/doc/CLI.md index 646a815d1d4..2f568261688 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -9,7 +9,9 @@ Paperclip CLI now supports both: ## Browser-approved Board authentication `npx paperclipai auth login --api-base ` creates a ten-minute -challenge and prints a browser approval link. Sign in to the instance, review +challenge and prints a browser approval link. The link uses the instance's +canonical public origin when configured, so reverse proxies do not send the +browser to an internal upstream hostname. Sign in to the instance, review the requesting command and full Board access, then approve. The pending token cannot authenticate before approval. The CLI verifies its resulting identity before storing its credential in its private auth store. The default Board key diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index c60cf770d06..24b6857ecb9 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -53,7 +53,7 @@ function registerModuleMocks() { let appImportCounter = 0; -async function createApp(actor: any, db: any = {} as any, options: { cliAuthCreateRateLimiter?: InviteRateLimiter; cliAuthPollRateLimiter?: InviteRateLimiter } = {}) { +async function createApp(actor: any, db: any = {} as any, options: { authPublicBaseUrl?: string; cliAuthCreateRateLimiter?: InviteRateLimiter; cliAuthPollRateLimiter?: InviteRateLimiter } = {}) { appImportCounter += 1; const routeModulePath = `../routes/access.js?cli-auth-routes-${appImportCounter}`; const middlewareModulePath = `../middleware/index.js?cli-auth-routes-${appImportCounter}`; @@ -137,6 +137,31 @@ describe("cli auth routes", () => { expect(res.body.approvalUrl).toContain("/cli-auth/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret"); }); + it("uses the configured public origin for CLI approval behind an upstream proxy", async () => { + mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({ + challenge: { + id: "12345678-1234-4123-8123-123456789abc", + expiresAt: new Date(Date.now() + 60_000), + }, + challengeSecret: "pcp_cli_auth_secret", + pendingBoardToken: "pcp_board_token", + }); + const app = await createApp({ type: "none", source: "none" }, {}, { + authPublicBaseUrl: "https://workspace.example.com/", + }); + const res = await request(app) + .post("/api/cli-auth/challenges") + .set("Host", "upstream.example.net") + .set("X-Forwarded-Host", "upstream.example.net") + .set("X-Forwarded-Proto", "https") + .send({ command: "paperclipai auth login", requestedAccess: "board" }); + + expect(res.status).toBe(201); + expect(res.body.approvalUrl).toBe( + `https://workspace.example.com${res.body.approvalPath}`, + ); + }); + it("bounds anonymous create and poll independently without trusting spoofed forwarding headers", async () => { let now = 1_000; mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({ diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 3279a1ec780..96e549afdfa 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2795,7 +2795,7 @@ export function accessRoutes( created.challenge.id, created.challengeSecret, ); - const baseUrl = requestBaseUrl(req); + const baseUrl = resolveBaseUrl(req, opts.authPublicBaseUrl); res.status(201).json({ id: created.challenge.id, token: created.challengeSecret,