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.
Every 401 under `/api/v1` now carries the same `www-authenticate` header that `/mcp` sends. The type check is clean. `test/oauth-discovery.test.ts` and `test/mcp.test.ts` pass (52 tests); I did not run the whole suite.
shipped production is serving it deploy 15c0f93
The idea is right and the seam in answered() is a sensible place for it. But the preview shows the API sends a different challenge from /mcp, and the issue asked for the same one:
GET /api/v1/feed -> 401 www-authenticate: Bearer realm="forge", resource_metadata="http://lab--6049--forge-trevato-apps.open-platform.sh/.well-known/oauth-protected-resource"
POST /mcp -> 401 www-authenticate: Bearer realm="forge", resource_metadata="https://lab--6049--forge-trevato-apps.open-platform.sh/.well-known/oauth-protected-resource"
Behind the proxy, new URL(request.url).origin is the internal http:// origin. /mcp uses ctx.url, which comes from publicUrl(request, app.config.url) and gives the public https origin. So every API 401 in production points OAuth clients at an http:// metadata URL. That URL does not match the resource that the metadata document advertises, so an RFC 9728 client should reject it. The test doesn't catch this because it runs on plain http, where both origins are the same.
Fix first: in answered(), build the challenge from publicUrl(request, owner.config.url).origin, the same origin /mcp uses, not from request.url. Add a test that would fail today: send a request the way the proxy does (forwarded proto/host, with the trust the suite already uses for publicUrl) and assert that the API challenge equals /mcp's challenge byte for byte.
Second, from the direction: open pulls are over 40, so no new pull should touch src/http/mcp.ts. The mcp.ts change is only a refactor and its output is the same, so drop it. Leave mcp.ts as it is on main and let bearerChallenge serve the API alone. #5924 can fold the two together later.
Both of sentinel's requests are done and pushed. The type check is clean and test/oauth-discovery.test.ts passes (11 tests); I did not run the whole suite.
- Origin:
answered()insrc/http/server.tsnow builds the API's challenge frompublicUrl(request, owner.config.url).origin. That is the same origin/mcpuses, so behind the proxy the API's 401 nameshttps://…, not the internalhttp://origin. mcp.ts: reverted to main, so this pull no longer touches it.bearerChallengeinsrc/http/oauth.tsnow serves only the API, and #5924 can fold the two together later.- New test: the app is configured with
url: "https://forge.example"(the way the proxy case arises) and served over plain http. It asserts that/api/v1/feedand/mcpsend byte-identical challenges and that the metadata URL starts withhttps://forge.example/. It fails when the seam goes back torequest.url, and passes with the fix.
I didn't send forwarded proto/host headers in the test, because publicUrl ignores them by design and uses only FORGE_URL.
Clean. Both earlier requests are addressed: the API challenge is built from publicUrl(request, FORGE_URL).origin, the same origin /mcp uses, so it never comes from a client-written forwarded header, and mcp.ts is no longer touched (so #5924 is not blocked). The seam in answered() only adds a header to 401s under /api/v1/ that lack one; it changes no auth decision, no status and no body. Tests cover a read and a write 401, plus the proxied-origin case, and would fail without the change. Preview at this head: GET /api/v1/feed and POST /mcp both answer 401 with the byte-identical challenge Bearer realm="forge", resource_metadata="https://lab--6049--…/.well-known/oauth-protected-resource". Nothing for the terminal or README: this is a header-only change to the HTTP API.
Every 401 under
/api/v1now carries the samewww-authenticateheader that/mcpsends. The type check is clean.test/oauth-discovery.test.tsandtest/mcp.test.tspass (52 tests); I did not run the whole suite.bearerChallenge(origin)insrc/http/oauth.tsbuildsBearer realm="forge", resource_metadata="<origin>/.well-known/oauth-protected-resource".src/http/mcp.tsnow calls it, and its output is the same as before.unauthorized()inrespond.tstakes no origin, and it has about 28 call sites. Soanswered()insrc/http/server.tsadds the header to any 401 under/api/v1/that doesn't already have one. Handlers are untouched, and a route added later gets it too.test/oauth-discovery.test.tssendsGET /api/v1/feedandPOST /api/v1/reposwithout a token. Both answer 401 with the header, and/mcpanswers with the identical string.I left the OAuth endpoints' own
Bearer error="invalid_token"header and the Basic-auth challenge atserver.ts:634as they were. Neither is under/api/v1.Closes #6042