Public: anyone can read this; the crew can change it.

Every 401 under /api/v1 carries the WWW-Authenticate header /mcp already sends #6049

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6042-every-401-under-api-v1-carries-the-www-a → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6042-every-401-under-api-v1-carries-the-www-a was merged into main as 7e266f0.

Review

Sign in to review this change.
3 files changed +51 −1
src/http/oauth.ts +4
@@ -41,6 +41,10 @@ const limiterOf = (app: App): RateLimiter => {
4141 export const resourceMetadataUrl = (origin: string): string =>
4242 `${origin}/.well-known/oauth-protected-resource`;
4343
44 /** The challenge a refused client learns where to sign in from; /mcp and the API send this one. */
45 export const bearerChallenge = (origin: string): string =>
46 `Bearer realm="forge", resource_metadata="${resourceMetadataUrl(origin)}"`;
47
4448 const open = { "access-control-allow-origin": "*" };
4549 const docHeaders = { ...open, "cache-control": "public, max-age=300" };
4650
src/http/server.ts +6 −1
@@ -481,7 +481,7 @@ import {
481481 type RouteTiming,
482482 } from "../core/route_timing.ts";
483483 import { methodOf, noteUsage, roleOf, surfaceOf, takeUsage } from "./usage_tag.ts";
484 import { oauthRoutes } from "./oauth.ts";
484 import { bearerChallenge, oauthRoutes } from "./oauth.ts";
485485 import { flushAccess, recordAccess } from "./access_log.ts";
486486 import { carryPeer, clientAddress, notePeer, type ProxyTrust } from "./peer.ts";
487487 import { isProbe } from "../kernel/probe.ts";
@@ -2219,6 +2219,11 @@ export async function serve(app: App): Promise<Servers> {
22192219 const work = run().then(
22202220 async (response) => {
22212221 const compressing = performance.now();
2222 if (response.status === 401 && !response.headers.has("www-authenticate")) {
2223 const url = publicUrl(request, owner.config.url);
outdated

High: request.url is the internal origin behind the proxy (http://), so on the preview this header points at http://…/.well-known/oauth-protected-resource, while /mcp points at https://…. Use publicUrl(request, owner.config.url).origin instead, the same origin ctx.url gives /mcp.

2224 if (url.pathname.startsWith("/api/v1/"))
2225 response.headers.set("www-authenticate", bearerChallenge(url.origin));
2226 }
22222227 const done = await compress(request, response).then(securityFloor);
22232228 if (namesFrame(request)) notePhase(request, "compress", performance.now() - compressing);
22242229 return done;
test/oauth-discovery.test.ts +41
@@ -70,6 +70,47 @@ describe("oauth discovery and registration", () => {
7070 );
7171 });
7272
73 test("a 401 from the API carries the same challenge as /mcp", async () => {
74 const want = `Bearer realm="forge", resource_metadata="${base}/.well-known/oauth-protected-resource"`;
75 const mcp = await fetch(`${base}/mcp`, {
76 method: "POST",
77 headers: { "content-type": "application/json" },
78 body: "{}",
79 });
80 expect(mcp.headers.get("www-authenticate")).toBe(want);
81 const feed = await fetch(`${base}/api/v1/feed`);

This passes only because the test server is plain http, so the internal and public origins are the same. Add a case behind a forwarded https proto/host and assert the API's challenge equals the one /mcp sends. That test would fail on this head.

82 expect(feed.status).toBe(401);
83 expect(feed.headers.get("www-authenticate")).toBe(want);
84 const write = await fetch(`${base}/api/v1/repos`, {
85 method: "POST",
86 headers: { "content-type": "application/json" },
87 body: JSON.stringify({ name: "x" }),
88 });
89 expect(write.status).toBe(401);
90 expect(write.headers.get("www-authenticate")).toBe(want);
91 });
92
93 test("behind a proxy the API challenge names the public origin, as /mcp does", async () => {
94 const dir = await mkdtemp(join(tmpdir(), "forge-oauth-public-"));
95 const proxied = createApp({ dataDir: dir, port: 0, url: "https://forge.example" });
96 const behind = await serve(proxied);
97 try {
98 const mcp = await fetch(`${behind.url}/mcp`, {
99 method: "POST",
100 headers: { "content-type": "application/json" },
101 body: "{}",
102 });
103 const feed = await fetch(`${behind.url}/api/v1/feed`);
104 expect(feed.status).toBe(401);
105 const want = mcp.headers.get("www-authenticate");
106 expect(want).toContain('resource_metadata="https://forge.example/');
107 expect(feed.headers.get("www-authenticate")).toBe(want);
108 } finally {
109 behind.stop();
110 await rm(dir, { recursive: true, force: true });
111 }
112 });
113
73114 test("a valid registration is stored and returns a client_id", async () => {
74115 const res = await register({
75116 client_name: "Inspector",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs