Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-loops-authorize.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 37 additions & 2 deletions packages/mcp-client/src/oauth/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
39 changes: 39 additions & 0 deletions packages/mcp-client/tests/oauth-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpClientOAuthEndpointPolicy>(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({
Expand Down