Skip to content

Commit cef3efa

Browse files
authored
Merge pull request #52 from nestm-dev/codex/oauth-loopback-redirect
fix(client): allow native loopback OAuth redirects
2 parents 99e476a + 31b41a3 commit cef3efa

3 files changed

Lines changed: 81 additions & 2 deletions

File tree

.changeset/calm-loops-authorize.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nestm/mcp-client": patch
3+
---
4+
5+
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.

packages/mcp-client/src/oauth/protocol.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ export class McpClientOAuthProtocol {
361361
if (typeof input !== "object" || input === null) throw invalidOptionsError();
362362
const authority = normalizeAuthority(input.authority);
363363
const client = normalizeClient(input.client, authority);
364-
const redirectUri = requireSecureUrl(input.redirectUri, { query: true }).href;
364+
const redirectUri = requireRedirectUri(input.redirectUri).href;
365365
const scope = normalizeScopes(input.scopes, authority);
366366
throwIfAborted(input.signal);
367367
await this.#authorizeEndpoint(authority.authorizationEndpoint, {
@@ -778,7 +778,7 @@ function normalizeTransaction(
778778
if (transaction.authorityDigest !== createAuthorityDigest(authority)) {
779779
throw transactionInvalidError();
780780
}
781-
const redirectUri = requireSecureUrl(transaction.redirectUri, { query: true }).href;
781+
const redirectUri = requireRedirectUri(transaction.redirectUri).href;
782782
assertBoundedOpaqueValue(transaction.clientId, MAX_CLIENT_ID_LENGTH);
783783
if (!isClientAuthenticationMethod(transaction.clientAuthenticationMethod)) {
784784
throw transactionInvalidError();
@@ -1184,6 +1184,41 @@ function requireSecureUrl(value: string, options: { readonly query: boolean }):
11841184
return url;
11851185
}
11861186

1187+
function requireRedirectUri(value: string): URL {
1188+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_URL_LENGTH) {
1189+
throw authorityInvalidError();
1190+
}
1191+
let url: URL;
1192+
try {
1193+
url = new URL(value);
1194+
} catch {
1195+
throw authorityInvalidError();
1196+
}
1197+
if (
1198+
url.username.length > 0 ||
1199+
url.password.length > 0 ||
1200+
url.hash.length > 0 ||
1201+
(url.protocol !== "https:" && !isLoopbackHttpRedirect(url))
1202+
) {
1203+
throw authorityInvalidError();
1204+
}
1205+
return url;
1206+
}
1207+
1208+
function isLoopbackHttpRedirect(url: URL): boolean {
1209+
if (url.protocol !== "http:") return false;
1210+
const host = url.hostname.toLowerCase();
1211+
if (host === "localhost" || host === "[::1]") return true;
1212+
const octets = host.split(".");
1213+
return octets.length === 4 && octets[0] === "127" && octets.every(isDecimalOctet);
1214+
}
1215+
1216+
function isDecimalOctet(value: string): boolean {
1217+
if (!/^\d{1,3}$/u.test(value)) return false;
1218+
const parsed = Number(value);
1219+
return parsed >= 0 && parsed <= 255 && String(parsed) === value;
1220+
}
1221+
11871222
function requireCanonicalResource(value: string): string {
11881223
const resource = requireSecureUrl(value, { query: true });
11891224
if (resource.href !== value) throw authorityInvalidError();

packages/mcp-client/tests/oauth-protocol.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,45 @@ describe("McpClientOAuthProtocol discovery", () => {
421421
});
422422

423423
describe("McpClientOAuthProtocol authorization transactions", () => {
424+
it("supports native loopback HTTP redirects through authorization and token exchange", async () => {
425+
const redirectUri = "http://127.0.0.1:5173/api/mcp/oauth/callback";
426+
const requests: RecordedRequest[] = [];
427+
const protocol = new McpClientOAuthProtocol({
428+
fetch: recordingFetch(requests, async () => tokenResponse()),
429+
endpointPolicy: allowEndpoint,
430+
});
431+
const started = await protocol.startAuthorization({
432+
authority: defaultAuthority(),
433+
client: noneClient(),
434+
redirectUri,
435+
});
436+
const authorizationUrl = new URL(started.authorizationUrl);
437+
const state = requireParameter(authorizationUrl, "state");
438+
expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(redirectUri);
439+
440+
await protocol.exchangeAuthorization({
441+
transaction: started.transaction,
442+
client: noneClient(),
443+
callback: new URLSearchParams({ code: "loopback-code", state, iss: ISSUER_URL }),
444+
});
445+
const request = expectSingleTokenRequest(requests);
446+
expect(new URLSearchParams(request.body).get("redirect_uri")).toBe(redirectUri);
447+
});
448+
449+
it("rejects a non-loopback HTTP redirect before endpoint policy", async () => {
450+
const endpointPolicy = vi.fn<McpClientOAuthEndpointPolicy>(allowEndpoint);
451+
const protocol = new McpClientOAuthProtocol({ fetch: unexpectedFetch, endpointPolicy });
452+
453+
await expect(
454+
protocol.startAuthorization({
455+
authority: defaultAuthority(),
456+
client: noneClient(),
457+
redirectUri: "http://platform.example.test/oauth/callback",
458+
}),
459+
).rejects.toMatchObject({ code: McpClientOAuthProtocolErrorCode.AuthorityInvalid });
460+
expect(endpointPolicy).not.toHaveBeenCalled();
461+
});
462+
424463
it("returns digest-only state and a deeply pinned transaction with a matching S256 challenge", async () => {
425464
const policyCalls: PolicyObservation[] = [];
426465
const protocol = new McpClientOAuthProtocol({

0 commit comments

Comments
 (0)