Serve the sample authorization server over loopback HTTP - #1802
Serve the sample authorization server over loopback HTTP#1802anneheartrecord wants to merge 2 commits into
Conversation
The ProtectedMcpServer sample pairs with TestOAuthServer, which hosts the authorization server on the ASP.NET Core developer certificate. Clients that keep their own CA list rather than using the OS trust store cannot fetch https://localhost:7029/.well-known/oauth-authorization-server from it. VS Code is one: the fetch fails, it treats that as a server without metadata, and falls back to the pre-2025-06-18 defaults derived from the MCP server URL. That drops the registration endpoint, so it asks for a client id, and then sends the browser to http://localhost:7071/authorize, which 404s. Host the standalone server over plain HTTP on loopback so its metadata is reachable without trusting anything first, and keep the developer certificate available behind --https. Tests construct Program directly and are unaffected.
|
Thank you for diving into this, @anneheartrecord! I want to make sure we can verify this against the VS Code setup but the PR looks really promising. It will take us a little time to get it validated and post reviews, but I wanted to know we'll get to it in about a week. |
|
Hi @jeffhandley — circling back on the ~week you mentioned on Aug 13. Completely understand if it is still in the queue. I am following up mainly to try to make the VS Code validation cheaper, since that was the part you flagged as the gate. The check does not actually need VS Code. What matters is that VS Code's HTTP stack is Node/undici, which carries its own CA list rather than the OS trust store — so
If it would help, I am glad to drop that probe script into the PR or a gist so you can run the whole thing in about a minute without standing anything up by hand. One decision that could unblock review even before validation: raising it again so it does not get lost from the PR description. In the new default mode the fixture publishes The branch is still conflict-free against |
luisangelrod
left a comment
There was a problem hiding this comment.
Thanks for the detailed diagnosis. I independently reproduced the new default path on Windows with Node 22:
- authorization-server metadata: 200
- dynamic client registration: 200
- PKCE authorization redirect: 302
- token exchange: 200
- authenticated MCP
initializeagainstProtectedMcpServer: 200 with server info
I also ran the new argument and HTTP discovery cases on net8.0, net9.0, and net10.0; all six focused runs passed.
There is one standards issue I think needs a maintainer decision before HTTP becomes the default. The current MCP authorization security requirements say that all authorization-server endpoints MUST be served over HTTPS: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#communication-security. RFC 8414 also requires an HTTPS issuer and an HTTPS jwks_uri: https://www.rfc-editor.org/rfc/rfc8414.html#section-2. The localhost exception is stated for redirect URIs, not for the authorization server itself.
Because the README presents this as the normal editor-integration setup, the default would demonstrate a configuration that works but does not conform to those requirements. I recommend the alternative already proposed in the PR description: retain HTTPS as the default and expose plain HTTP as an explicit compatibility option such as --http, with the existing warning. If maintainers intentionally want a test-only exception here, documenting that exception and its non-conformance explicitly would make the tradeoff clear.
Environment note: this machine has no ASP.NET Core development certificate, so the HTTPS theory case could not complete locally; an existing HTTPS OAuth test fails identically. The PR''s CI is green, and the HTTP behavior above was fully exercised.
Disclosure: Drafted with AI assistance. I reviewed the diff, verified the cited requirements, and ran the tests and end-to-end flow reported above.
The MCP authorization security requirements and RFC 8414 both require authorization server endpoints to be served over HTTPS, and the localhost carve-out covers redirect URIs rather than the authorization server itself. Defaulting the fixture to plain HTTP therefore demonstrated a configuration that works but does not conform, in a sample the README presents as the normal editor-integration setup. Invert the switch: TestOAuthServer hosts over HTTPS unless --http is passed, and the launch profiles follow. ProtectedMcpServer reads its authority from OAuth:ServerUrl with an HTTPS default, and derives RequireHttpsMetadata from that scheme, so the relaxation only applies when the sample has deliberately been pointed at a loopback HTTP authority. The READMEs document the --http pair of commands for clients that cannot fetch metadata from the ASP.NET Core developer certificate.
|
@luisangelrod thank you — that is a more thorough verification than I could do here, and the spec citation settles the question rather than leaving it to taste. You are right, and I have pushed the inversion in
That last point is the part I am least sure about and would still value a maintainer view on: the default configuration now conforms, but it also no longer works end to end in VS Code without reading the README and passing two extra flags. The original report was someone hitting exactly that wall. Conformance is clearly the right default — I am just not certain the README is a strong enough signpost, and if @jeffhandley would rather the sample detect and warn, or ship a second launch profile pair, I am happy to build that instead. Verification after the change: On the environment note in your review — no dev cert on that machine, so the HTTPS case could not complete: that is now the default path, so it may be worth one more pass with |
Fixes #689.
Root cause
The sample never gets as far as the authorization server. Reproduced against current
main, with the failure isolated to one request:POST http://localhost:7071/→401,WWW-Authenticate: Bearer resource_metadata="http://localhost:7071/.well-known/oauth-protected-resource/"GETthat document →{"resource":"http://localhost:7071","authorization_servers":["https://localhost:7029"], ...}GET https://localhost:7029/.well-known/oauth-authorization-server→ failsStep 3 fails because
TestOAuthServerhosts the authorization server on the ASP.NET Core developer certificate, and VS Code's HTTP stack carries its own CA list instead of using the operating system trust store. Node reports it asTypeError: fetch failed, causeDEPTH_ZERO_SELF_SIGNED_CERT— which is exactly theError populating auth metadata: TypeError: fetch failedline in the trace on microsoft/vscode#261120, where this was chased from the VS Code side and left as "something to do with your cert on localhost".From there VS Code treats it as a server without metadata and falls back to
getDefaultMetadataForUrl(mcpServerUrl), the compatibility path for the 2025-03-26 spec. That fallback has no registration endpoint, so it prompts for a client id (which reads as "DCR not supported"), and its authorization endpoint ishttp://localhost:7071/authorize— the 404 in the original report.So the answer to "sample, TestOAuthServer, or SDK auth handling" is TestOAuthServer, and neither of the two symptoms is the bug — both are downstream of one unreachable metadata document. The
.NETclient sample works becauseHttpClientdoes use the OS trust store, wheredotnet dev-certs https --trustput the certificate. The repo's own OAuth tests work becauseOAuthTestBaseturns certificate validation off.What changed
TestOAuthServernow listens over plain HTTP on loopback when it's run standalone, so its metadata is reachable without trusting anything first.--https(and thehttpslaunch profile) keeps the developer certificate available. The constructor still defaults to HTTPS, so the tests that constructProgramdirectly are untouched.ProtectedMcpServerpoints athttp://localhost:7029and setsRequireHttpsMetadata = false, scoped with a comment and a README note saying not to do that against anything but a loopback authority you control.Verifying
I don't have VS Code on this machine, so I drove the flow it performs with Node's
fetch— same HTTP stack, same certificate behaviour. Before: step 3 fails withDEPTH_ZERO_SELF_SIGNED_CERTandhttp://localhost:7071/authorizereturns 404. After: metadata → dynamic client registration → authorization code → token → an authenticatedinitializereturning 200. The token exchange also proves the sample'sJwtBearerbackchannel fetches its signing keys over the HTTP authority.dotnet buildis clean andtests/ModelContextProtocol.AspNetCore.Testspasses (582 tests,net10.0). The new test covers the two things this change can regress: the scheme the discovery document advertises has to match the origin it's hosted on, and the standalone default has to stay HTTP.One thing to call out
RFC 8414 wants an
httpsissuer identifier, and in the default mode this fixture now publishes"issuer": "http://localhost:7029". That felt like the right trade for a loopback test server whose whole job is to be easy to point a client at — but it does change what plaindotnet rundoes, so if you'd rather keep HTTPS as the default and make it--httpto opt out, say the word and I'll flip it. The samples would then need the README to tell people to pass the flag, which is the part I expect to keep tripping people up.