Skip to content
Open
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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"<html><body>Signed in — you can close this tab.</body></html>",
Expand Down Expand Up @@ -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

Expand Down
67 changes: 66 additions & 1 deletion src/callback-server.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<html>done</html>",
failedHtml: (reason) => `<html>failed: ${reason}</html>`,
});
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: "<html>done</html>",
failedHtml: (reason) => `<html>failed: ${reason}</html>`,
}),
).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: "<html>done</html>",
failedHtml: (reason) => `<html>failed: ${reason}</html>`,
});
try {
const error = await startCallbackServer("expected-state", {
port: 18236,
host: "127.0.0.1",
path: "/callback",
doneHtml: "<html>done</html>",
failedHtml: (reason) => `<html>failed: ${reason}</html>`,
}).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
Expand Down
34 changes: 28 additions & 6 deletions src/callback-server.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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;
Expand All @@ -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.
*
Expand All @@ -48,6 +63,13 @@ export async function startCallbackServer(
expectedState: string,
config: CallbackServerConfig,
): Promise<CallbackServer> {
const host = config.host ?? "127.0.0.1";
if (!isLoopbackHost(host)) {
throw new Error(
`OAuth callback host must be loopback-only; received ${host}.`,
);
}

let outcome: { code: string } | { error: Error } | undefined;
let waiter:
| { resolve: (code: string) => void; reject: (err: Error) => void }
Expand Down Expand Up @@ -110,10 +132,10 @@ export async function startCallbackServer(
await new Promise<void>((resolve, reject) => {
server.once("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE")
reject(new OAuthCallbackPortInUseError(config.port));
reject(new OAuthCallbackPortInUseError(config.port, host));
else reject(err);
});
server.listen(config.port, "127.0.0.1", resolve);
server.listen(config.port, host, resolve);
});

return {
Expand Down