Public: anyone can read this; the crew can change it.
Every 401 under /api/v1 carries the WWW-Authenticate header /mcp already sends #6049
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6042-every-401-under-api-v1-carries-the-www-a → main 2 commits fork 3 threads · 3 unresolved
3 files
changed +51
−1
src/http/oauth.ts +4
| @@ -41,6 +41,10 @@ const limiterOf = (app: App): RateLimiter => { | ||
| 41 | 41 | export const resourceMetadataUrl = (origin: string): string => |
| 42 | 42 | `${origin}/.well-known/oauth-protected-resource`; |
| 43 | 43 | |
| 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 | ||
| 44 | 48 | const open = { "access-control-allow-origin": "*" }; |
| 45 | 49 | const docHeaders = { ...open, "cache-control": "public, max-age=300" }; |
| 46 | 50 |
src/http/server.ts +6 −1
| @@ -481,7 +481,7 @@ import { | ||
| 481 | 481 | type RouteTiming, |
| 482 | 482 | } from "../core/route_timing.ts"; |
| 483 | 483 | import { methodOf, noteUsage, roleOf, surfaceOf, takeUsage } from "./usage_tag.ts"; |
| 484 | import { oauthRoutes } from "./oauth.ts"; | |
| 484 | import { bearerChallenge, oauthRoutes } from "./oauth.ts"; | |
| 485 | 485 | import { flushAccess, recordAccess } from "./access_log.ts"; |
| 486 | 486 | import { carryPeer, clientAddress, notePeer, type ProxyTrust } from "./peer.ts"; |
| 487 | 487 | import { isProbe } from "../kernel/probe.ts"; |
| @@ -2219,6 +2219,11 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2219 | 2219 | const work = run().then( |
| 2220 | 2220 | async (response) => { |
| 2221 | 2221 | const compressing = performance.now(); |
| 2222 | if (response.status === 401 && !response.headers.has("www-authenticate")) { | |
| 2223 | const url = publicUrl(request, owner.config.url); | |
| 2224 | if (url.pathname.startsWith("/api/v1/")) | |
| 2225 | response.headers.set("www-authenticate", bearerChallenge(url.origin)); | |
| 2226 | } | |
| 2222 | 2227 | const done = await compress(request, response).then(securityFloor); |
| 2223 | 2228 | if (namesFrame(request)) notePhase(request, "compress", performance.now() - compressing); |
| 2224 | 2229 | return done; |
test/oauth-discovery.test.ts +41
| @@ -70,6 +70,47 @@ describe("oauth discovery and registration", () => { | ||
| 70 | 70 | ); |
| 71 | 71 | }); |
| 72 | 72 | |
| 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 | ||
| 73 | 114 | test("a valid registration is stored and returns a client_id", async () => { |
| 74 | 115 | const res = await register({ |
| 75 | 116 | client_name: "Inspector", |
new above
offline
High:
request.urlis the internal origin behind the proxy (http://), so on the preview this header points athttp://…/.well-known/oauth-protected-resource, while /mcp points athttps://…. UsepublicUrl(request, owner.config.url).origininstead, the same originctx.urlgives /mcp.