diff --git a/README.md b/README.md index 58a7fbf..c66a961 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ const handle = await startOAuthLogin( startCallbackServer: (state) => startCallbackServer(state, { port: 8765, + // Optional loopback hostname or address; defaults to 127.0.0.1. + // Routable and wildcard hosts are rejected. + host: "127.0.0.1", path: "/callback", doneHtml: "
Signed in — you can close this tab.", @@ -116,9 +119,11 @@ See `src/index.ts` for the full export surface. - No confidential client / client secret support — public clients (PKCE) only. - No token revocation endpoint call. -- Fixed-port loopback only, no dynamic port selection: authorization - servers only accept the registered `redirect_uri` for the client, so a - randomly chosen port would be rejected. +- Fixed-port, loopback-only callback binding with configurable host selection + (`127.0.0.1`, `localhost`, or `::1`); wildcard and routable hosts are + rejected. There is no dynamic port selection: authorization servers only + accept the registered `redirect_uri` for the client, so a randomly chosen + port would be rejected. ## License diff --git a/src/callback-server.test.ts b/src/callback-server.test.ts index 6cb0de2..04f3703 100644 --- a/src/callback-server.test.ts +++ b/src/callback-server.test.ts @@ -1,8 +1,73 @@ import { describe, expect, test } from "bun:test"; -import { startCallbackServer } from "./index"; +import { OAuthCallbackPortInUseError, startCallbackServer } from "./index"; describe("Callback server startCallbackServer — state validation", () => { + test("listens on the configured loopback hostname", async () => { + const server = await startCallbackServer("expected-state", { + port: 18235, + host: "localhost", + path: "/callback", + doneHtml: "done", + failedHtml: (reason) => `failed: ${reason}`, + }); + try { + const waiting = server.waitForCode(new AbortController().signal); + const response = await fetch( + "http://localhost:18235/callback?code=some-code&state=expected-state", + ); + expect(response.status).toBe(200); + await expect(waiting).resolves.toBe("some-code"); + } finally { + server.close(); + } + }); + + test.each(["0.0.0.0", "::", "192.168.1.10"])( + "rejects non-loopback host %s", + async (host) => { + await expect( + startCallbackServer("expected-state", { + port: 18237, + host, + path: "/callback", + doneHtml: "done", + failedHtml: (reason) => `failed: ${reason}`, + }), + ).rejects.toThrow(/loopback/); + }, + ); + + test("includes the configured host in port-in-use errors", async () => { + const first = await startCallbackServer("expected-state", { + port: 18236, + host: "127.0.0.1", + path: "/callback", + doneHtml: "done", + failedHtml: (reason) => `failed: ${reason}`, + }); + try { + const error = await startCallbackServer("expected-state", { + port: 18236, + host: "127.0.0.1", + path: "/callback", + doneHtml: "done", + failedHtml: (reason) => `failed: ${reason}`, + }).then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).toBeInstanceOf(OAuthCallbackPortInUseError); + expect(error).toMatchObject({ port: 18236, host: "127.0.0.1" }); + expect(error).toHaveProperty( + "message", + expect.stringContaining("127.0.0.1"), + ); + } finally { + first.close(); + } + }); + test("rejects a redirect whose state does not match, without trusting the code", async () => { // Load-bearing: the state check is what stops a redirect from an // unrelated flow (or an attacker's crafted link) from being accepted as diff --git a/src/callback-server.ts b/src/callback-server.ts index c6441ec..02bd377 100644 --- a/src/callback-server.ts +++ b/src/callback-server.ts @@ -1,5 +1,12 @@ +import { isIP } from "node:net"; import { createServer, type Server } from "node:http"; +function isLoopbackHost(host: string): boolean { + if (host.toLowerCase() === "localhost" || host === "::1") return true; + if (isIP(host) !== 4) return false; + return host.split(".")[0] === "127"; +} + function isNonEmptyCode(value: string | null): value is string { return typeof value === "string" && value.length > 0; } @@ -11,6 +18,8 @@ export type CallbackServer = { export type CallbackServerConfig = { port: number; + /** Optional loopback hostname or address; defaults to 127.0.0.1. */ + host?: string; path: string; doneHtml: string; failedHtml: (reason: string) => string; @@ -25,17 +34,23 @@ export class OAuthCallbackError extends Error { export class OAuthCallbackPortInUseError extends Error { readonly port: number; + readonly host: string; - constructor(port: number) { - super(`Port ${String(port)} is already in use by another process.`); + constructor(port: number, host: string) { + super( + `Port ${String(port)} on ${host} is already in use by another process.`, + ); this.name = "OAuthCallbackPortInUseError"; this.port = port; + this.host = host; } } /** - * Start a fixed-port loopback server that receives an OAuth redirect. The - * port is fixed because authorization servers only accept the registered + * Start a fixed-port loopback-only server that receives an OAuth redirect. + * The optional host may select a loopback hostname or address; routable and + * wildcard hosts are rejected. The port is fixed because authorization + * servers only accept the registered * redirect_uri for the client. A bind failure means the port is already in * use (e.g. a concurrent login), not a cue to pick another port. * @@ -48,6 +63,13 @@ export async function startCallbackServer( expectedState: string, config: CallbackServerConfig, ): Promise