mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Taesu <bytaesu@gmail.com>
261 lines
7.3 KiB
TypeScript
261 lines
7.3 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { Readable } from "node:stream";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
lookup: vi.fn(),
|
|
request: vi.fn(),
|
|
}));
|
|
|
|
vi.mock(import("node:dns/promises"), () => ({ lookup: mocks.lookup }));
|
|
vi.mock(import("node:https"), () => ({ request: mocks.request }));
|
|
|
|
import { fetchClientMetadataResource } from "./node";
|
|
|
|
interface MockLookupAddress {
|
|
address: string;
|
|
family: number;
|
|
}
|
|
|
|
interface MockResponseOptions {
|
|
body?: string;
|
|
headers?: Record<string, string>;
|
|
lookupOptions?: { all?: boolean };
|
|
status?: number;
|
|
}
|
|
|
|
function mockHttpsResponse(options: MockResponseOptions = {}) {
|
|
mocks.request.mockImplementation(
|
|
(
|
|
_url: URL,
|
|
requestOptions: {
|
|
lookup: (
|
|
hostname: string,
|
|
lookupOptions: { all?: boolean },
|
|
callback: (
|
|
error: Error | null,
|
|
result: string | MockLookupAddress[],
|
|
family?: number,
|
|
) => void,
|
|
) => void;
|
|
},
|
|
onResponse: (response: Readable) => void,
|
|
) => {
|
|
const request = new EventEmitter() as EventEmitter & {
|
|
end: () => void;
|
|
destroy: (error?: Error) => void;
|
|
};
|
|
request.end = () => {
|
|
requestOptions.lookup(
|
|
"client.example.com",
|
|
options.lookupOptions ?? {},
|
|
(error, result) => {
|
|
if (error) {
|
|
request.emit("error", error);
|
|
return;
|
|
}
|
|
|
|
if (options.lookupOptions?.all && !Array.isArray(result)) {
|
|
request.emit("error", new TypeError("Invalid IP address"));
|
|
return;
|
|
}
|
|
|
|
const response = Readable.from([
|
|
Buffer.from(options.body ?? "metadata"),
|
|
]) as Readable & {
|
|
headers: Record<string, string>;
|
|
statusCode: number;
|
|
statusMessage: string;
|
|
};
|
|
response.headers = options.headers ?? {
|
|
"content-type": "application/json",
|
|
};
|
|
response.statusCode = options.status ?? 200;
|
|
response.statusMessage = "OK";
|
|
onResponse(response);
|
|
},
|
|
);
|
|
};
|
|
request.destroy = (error) => {
|
|
if (error) request.emit("error", error);
|
|
};
|
|
return request;
|
|
},
|
|
);
|
|
}
|
|
|
|
describe("Node CIMD metadata transport", () => {
|
|
it("rejects private DNS answers before opening a connection", async () => {
|
|
mocks.lookup.mockResolvedValue([
|
|
{ address: "93.184.216.34", family: 4 },
|
|
{ address: "127.0.0.1", family: 4 },
|
|
]);
|
|
|
|
await expect(
|
|
fetchClientMetadataResource("https://client.example.com/client.json"),
|
|
).rejects.toThrow("public");
|
|
expect(mocks.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves once, pins an approved address, and preserves Host and TLS SNI", async () => {
|
|
mocks.lookup.mockResolvedValue([
|
|
{ address: "93.184.216.34", family: 4 },
|
|
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
|
|
]);
|
|
let pinnedAddress: string | undefined;
|
|
mockHttpsResponse({ body: "ok" });
|
|
mocks.request.mockImplementationOnce(
|
|
(
|
|
url: URL,
|
|
options: {
|
|
agent: boolean;
|
|
headers: Record<string, string>;
|
|
lookup: (
|
|
hostname: string,
|
|
options: object,
|
|
callback: (
|
|
error: Error | null,
|
|
address: string,
|
|
family: number,
|
|
) => void,
|
|
) => void;
|
|
servername: string;
|
|
},
|
|
onResponse: (response: Readable) => void,
|
|
) => {
|
|
const request = new EventEmitter() as EventEmitter & {
|
|
end: () => void;
|
|
destroy: (error?: Error) => void;
|
|
};
|
|
request.end = () => {
|
|
options.lookup(url.hostname, {}, (_error, address) => {
|
|
pinnedAddress = address;
|
|
const response = Readable.from([Buffer.from("ok")]) as Readable & {
|
|
headers: Record<string, string>;
|
|
statusCode: number;
|
|
statusMessage: string;
|
|
};
|
|
response.headers = { "content-type": "text/plain" };
|
|
response.statusCode = 200;
|
|
response.statusMessage = "OK";
|
|
onResponse(response);
|
|
});
|
|
};
|
|
request.destroy = () => {};
|
|
expect(options.servername).toBe("client.example.com");
|
|
expect(options.agent).toBe(false);
|
|
expect(options.headers.host).toBe("client.example.com");
|
|
return request;
|
|
},
|
|
);
|
|
|
|
const response = await fetchClientMetadataResource(
|
|
"https://client.example.com/client.json",
|
|
);
|
|
expect(await response.text()).toBe("ok");
|
|
expect(mocks.lookup).toHaveBeenCalledTimes(1);
|
|
expect(mocks.lookup).toHaveBeenCalledWith("client.example.com", {
|
|
all: true,
|
|
verbatim: true,
|
|
});
|
|
expect(pinnedAddress).toBe("93.184.216.34");
|
|
});
|
|
|
|
/**
|
|
* @see https://github.com/better-auth/better-auth/issues/10810
|
|
*/
|
|
it("fetches metadata when Node requests all addresses", async () => {
|
|
mocks.lookup.mockResolvedValue([
|
|
{ address: "93.184.216.34", family: 4 },
|
|
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
|
|
]);
|
|
mockHttpsResponse({
|
|
body: "ok",
|
|
lookupOptions: { all: true },
|
|
});
|
|
|
|
const response = await fetchClientMetadataResource(
|
|
"https://client.example.com/client.json",
|
|
);
|
|
|
|
expect(await response.text()).toBe("ok");
|
|
});
|
|
|
|
it("returns redirect responses without following them", async () => {
|
|
mocks.lookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
|
mockHttpsResponse({
|
|
status: 302,
|
|
headers: { location: "https://other.example/client.json" },
|
|
});
|
|
|
|
const response = await fetchClientMetadataResource(
|
|
"https://client.example.com/client.json",
|
|
);
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.get("location")).toBe(
|
|
"https://other.example/client.json",
|
|
);
|
|
expect(mocks.request).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns a 304 response without constructing a forbidden body", async () => {
|
|
mocks.lookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
|
mockHttpsResponse({ status: 304, headers: { etag: '"current"' } });
|
|
|
|
const response = await fetchClientMetadataResource(
|
|
"https://client.example.com/client.json",
|
|
);
|
|
expect(response.status).toBe(304);
|
|
expect(response.body).toBeNull();
|
|
expect(response.headers.get("etag")).toBe('"current"');
|
|
});
|
|
|
|
it("preserves supplied method, headers, and abort signal", async () => {
|
|
mocks.lookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
|
mockHttpsResponse();
|
|
const controller = new AbortController();
|
|
|
|
await fetchClientMetadataResource(
|
|
"https://client.example.com/client.json",
|
|
{
|
|
method: "HEAD",
|
|
headers: { accept: "application/oauth-client-metadata+json" },
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
|
|
const options = mocks.request.mock.calls[0]?.[1] as {
|
|
headers: Record<string, string>;
|
|
method: string;
|
|
signal: AbortSignal;
|
|
};
|
|
expect(options.method).toBe("HEAD");
|
|
expect(options.headers.accept).toBe(
|
|
"application/oauth-client-metadata+json",
|
|
);
|
|
expect(options.signal).toBe(controller.signal);
|
|
});
|
|
|
|
it("rejects unsupported methods instead of dropping their request body", async () => {
|
|
await expect(
|
|
fetchClientMetadataResource("https://client.example.com/client.json", {
|
|
method: "POST",
|
|
body: "must-not-be-dropped",
|
|
}),
|
|
).rejects.toThrow("GET and HEAD");
|
|
expect(mocks.lookup).not.toHaveBeenCalled();
|
|
expect(mocks.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not send SNI for an IP-literal URL", async () => {
|
|
mocks.lookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]);
|
|
mockHttpsResponse();
|
|
|
|
await fetchClientMetadataResource("https://8.8.8.8/client.json");
|
|
const options = mocks.request.mock.calls[0]?.[1] as {
|
|
servername?: string;
|
|
};
|
|
expect(options.servername).toBeUndefined();
|
|
});
|
|
});
|