diff --git a/.changeset/calm-loops-authorize.md b/.changeset/calm-loops-authorize.md new file mode 100644 index 0000000..7e5ec41 --- /dev/null +++ b/.changeset/calm-loops-authorize.md @@ -0,0 +1,5 @@ +--- +"@nestm/mcp-client": patch +--- + +Allow native OAuth clients to use RFC 8252 loopback HTTP redirect URIs throughout authorization and token exchange while continuing to reject non-loopback HTTP callbacks. diff --git a/packages/mcp-client/src/oauth/protocol.ts b/packages/mcp-client/src/oauth/protocol.ts index 7548796..537c9d0 100644 --- a/packages/mcp-client/src/oauth/protocol.ts +++ b/packages/mcp-client/src/oauth/protocol.ts @@ -361,7 +361,7 @@ export class McpClientOAuthProtocol { if (typeof input !== "object" || input === null) throw invalidOptionsError(); const authority = normalizeAuthority(input.authority); const client = normalizeClient(input.client, authority); - const redirectUri = requireSecureUrl(input.redirectUri, { query: true }).href; + const redirectUri = requireRedirectUri(input.redirectUri).href; const scope = normalizeScopes(input.scopes, authority); throwIfAborted(input.signal); await this.#authorizeEndpoint(authority.authorizationEndpoint, { @@ -778,7 +778,7 @@ function normalizeTransaction( if (transaction.authorityDigest !== createAuthorityDigest(authority)) { throw transactionInvalidError(); } - const redirectUri = requireSecureUrl(transaction.redirectUri, { query: true }).href; + const redirectUri = requireRedirectUri(transaction.redirectUri).href; assertBoundedOpaqueValue(transaction.clientId, MAX_CLIENT_ID_LENGTH); if (!isClientAuthenticationMethod(transaction.clientAuthenticationMethod)) { throw transactionInvalidError(); @@ -1184,6 +1184,41 @@ function requireSecureUrl(value: string, options: { readonly query: boolean }): return url; } +function requireRedirectUri(value: string): URL { + if (typeof value !== "string" || value.length === 0 || value.length > MAX_URL_LENGTH) { + throw authorityInvalidError(); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw authorityInvalidError(); + } + if ( + url.username.length > 0 || + url.password.length > 0 || + url.hash.length > 0 || + (url.protocol !== "https:" && !isLoopbackHttpRedirect(url)) + ) { + throw authorityInvalidError(); + } + return url; +} + +function isLoopbackHttpRedirect(url: URL): boolean { + if (url.protocol !== "http:") return false; + const host = url.hostname.toLowerCase(); + if (host === "localhost" || host === "[::1]") return true; + const octets = host.split("."); + return octets.length === 4 && octets[0] === "127" && octets.every(isDecimalOctet); +} + +function isDecimalOctet(value: string): boolean { + if (!/^\d{1,3}$/u.test(value)) return false; + const parsed = Number(value); + return parsed >= 0 && parsed <= 255 && String(parsed) === value; +} + function requireCanonicalResource(value: string): string { const resource = requireSecureUrl(value, { query: true }); if (resource.href !== value) throw authorityInvalidError(); diff --git a/packages/mcp-client/tests/oauth-protocol.test.ts b/packages/mcp-client/tests/oauth-protocol.test.ts index 8e6bd74..9cc1a8d 100644 --- a/packages/mcp-client/tests/oauth-protocol.test.ts +++ b/packages/mcp-client/tests/oauth-protocol.test.ts @@ -421,6 +421,45 @@ describe("McpClientOAuthProtocol discovery", () => { }); describe("McpClientOAuthProtocol authorization transactions", () => { + it("supports native loopback HTTP redirects through authorization and token exchange", async () => { + const redirectUri = "http://127.0.0.1:5173/api/mcp/oauth/callback"; + const requests: RecordedRequest[] = []; + const protocol = new McpClientOAuthProtocol({ + fetch: recordingFetch(requests, async () => tokenResponse()), + endpointPolicy: allowEndpoint, + }); + const started = await protocol.startAuthorization({ + authority: defaultAuthority(), + client: noneClient(), + redirectUri, + }); + const authorizationUrl = new URL(started.authorizationUrl); + const state = requireParameter(authorizationUrl, "state"); + expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(redirectUri); + + await protocol.exchangeAuthorization({ + transaction: started.transaction, + client: noneClient(), + callback: new URLSearchParams({ code: "loopback-code", state, iss: ISSUER_URL }), + }); + const request = expectSingleTokenRequest(requests); + expect(new URLSearchParams(request.body).get("redirect_uri")).toBe(redirectUri); + }); + + it("rejects a non-loopback HTTP redirect before endpoint policy", async () => { + const endpointPolicy = vi.fn(allowEndpoint); + const protocol = new McpClientOAuthProtocol({ fetch: unexpectedFetch, endpointPolicy }); + + await expect( + protocol.startAuthorization({ + authority: defaultAuthority(), + client: noneClient(), + redirectUri: "http://platform.example.test/oauth/callback", + }), + ).rejects.toMatchObject({ code: McpClientOAuthProtocolErrorCode.AuthorityInvalid }); + expect(endpointPolicy).not.toHaveBeenCalled(); + }); + it("returns digest-only state and a deeply pinned transaction with a matching S256 challenge", async () => { const policyCalls: PolicyObservation[] = []; const protocol = new McpClientOAuthProtocol({