Skip to content

Commit c962c08

Browse files
committed
feat: use fixed oauth callback URL
1 parent 0f2be07 commit c962c08

File tree

4 files changed

+58
-9
lines changed

4 files changed

+58
-9
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ ol auth logout # clear saved credentials
3535
**Setup:**
3636

3737
1. Create a public OAuth app in Outline (Settings → Applications)
38-
2. Set the redirect URI to `http://localhost` (any port is fine)
38+
2. Set the redirect URI to `http://localhost:54969/callback`
3939
3. Run `ol auth login` and enter your OAuth client ID when prompted
4040
(or pass it directly with `--client-id <your-client-id>`)
4141
4. If needed, pass `--base-url <your-outline-url>` or set `OUTLINE_URL`

src/__tests__/oauth-server.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ describe("oauth callback server", () => {
66
const callbackServer = await startOAuthCallbackServer({
77
state: "expected-state",
88
timeoutMs: 10_000,
9+
port: 0,
910
});
1011

1112
const response = await fetch(
@@ -22,6 +23,7 @@ describe("oauth callback server", () => {
2223
const callbackServer = await startOAuthCallbackServer({
2324
state: "expected-state",
2425
timeoutMs: 10_000,
26+
port: 0,
2527
});
2628
const rejection = callbackServer.waitForCode.then(
2729
() => new Error("Expected OAuth state mismatch."),
@@ -43,6 +45,7 @@ describe("oauth callback server", () => {
4345
const callbackServer = await startOAuthCallbackServer({
4446
state: "expected-state",
4547
timeoutMs: 10_000,
48+
port: 0,
4649
});
4750
const rejection = callbackServer.waitForCode.then(
4851
() => new Error("Expected OAuth provider error."),

src/commands/auth.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
saveConfig,
1212
} from "../lib/auth.js";
1313
import { buildAuthorizationUrl, exchangeCodeForToken } from "../lib/oauth.js";
14-
import { startOAuthCallbackServer } from "../lib/oauth-server.js";
14+
import {
15+
DEFAULT_OAUTH_CALLBACK_PORT,
16+
startOAuthCallbackServer,
17+
} from "../lib/oauth-server.js";
1518
import { formatError } from "../lib/output.js";
1619
import {
1720
generateCodeChallenge,
@@ -117,7 +120,32 @@ export function registerAuthCommand(program: Command): void {
117120
const codeChallenge = generateCodeChallenge(codeVerifier);
118121
const state = generateState();
119122

120-
const callbackServer = await startOAuthCallbackServer({ state });
123+
let callbackServer: Awaited<
124+
ReturnType<typeof startOAuthCallbackServer>
125+
>;
126+
try {
127+
callbackServer = await startOAuthCallbackServer({ state });
128+
} catch (err) {
129+
const error = err as NodeJS.ErrnoException;
130+
const hints = [
131+
`Ensure http://localhost:${DEFAULT_OAUTH_CALLBACK_PORT}/callback is reachable from your browser`,
132+
"Re-run with 'ol auth login --token <token>' for manual auth",
133+
];
134+
if (error.code === "EADDRINUSE") {
135+
hints.unshift(
136+
`Port ${DEFAULT_OAUTH_CALLBACK_PORT} is already in use. Close the other process using it.`,
137+
);
138+
}
139+
140+
console.error(
141+
formatError(
142+
"OAUTH_CALLBACK_SERVER_FAILED",
143+
`Could not start local OAuth callback server: ${error.message}`,
144+
hints,
145+
),
146+
);
147+
process.exit(1);
148+
}
121149
const authorizationUrl = buildAuthorizationUrl({
122150
baseUrl: url,
123151
clientId,

src/lib/oauth-server.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { AddressInfo } from "node:net";
44
interface OAuthServerOptions {
55
state: string;
66
timeoutMs?: number;
7+
port?: number;
78
}
89

910
export interface OAuthCallbackServer {
@@ -13,6 +14,8 @@ export interface OAuthCallbackServer {
1314
close: () => void;
1415
}
1516

17+
export const DEFAULT_OAUTH_CALLBACK_PORT = 54969;
18+
1619
function escapeHtml(text: string): string {
1720
return text
1821
.replaceAll("&", "&amp;")
@@ -128,7 +131,11 @@ function renderErrorPage(message: string): string {
128131
export async function startOAuthCallbackServer(
129132
options: OAuthServerOptions,
130133
): Promise<OAuthCallbackServer> {
131-
const { state, timeoutMs = 3 * 60 * 1000 } = options;
134+
const {
135+
state,
136+
timeoutMs = 3 * 60 * 1000,
137+
port = DEFAULT_OAUTH_CALLBACK_PORT,
138+
} = options;
132139
let origin = "http://localhost";
133140
let resolved = false;
134141
let resolveCode: (code: string) => void;
@@ -202,12 +209,23 @@ export async function startOAuthCallbackServer(
202209
rejectCode(err as Error);
203210
});
204211

205-
await new Promise<void>((resolve) => {
206-
server.listen(0, "127.0.0.1", resolve);
212+
await new Promise<void>((resolve, reject) => {
213+
const onListening = () => {
214+
server.off("error", onError);
215+
resolve();
216+
};
217+
const onError = (err: Error) => {
218+
server.off("listening", onListening);
219+
reject(err);
220+
};
221+
222+
server.once("listening", onListening);
223+
server.once("error", onError);
224+
server.listen(port, "127.0.0.1");
207225
});
208226

209-
const { port } = server.address() as AddressInfo;
210-
origin = `http://localhost:${port}`;
227+
const { port: listeningPort } = server.address() as AddressInfo;
228+
origin = `http://localhost:${listeningPort}`;
211229
const redirectUri = `${origin}/callback`;
212230

213231
const timeout = setTimeout(() => {
@@ -233,5 +251,5 @@ export async function startOAuthCallbackServer(
233251
},
234252
);
235253

236-
return { port, redirectUri, waitForCode, close };
254+
return { port: listeningPort, redirectUri, waitForCode, close };
237255
}

0 commit comments

Comments
 (0)