| 48 | const DEFAULT_TIMEOUT_MS = 120_000; |
| 49 | |
| 50 | export async function startLoopback(opts: LoopbackOptions): Promise<LoopbackHandle> { |
| 51 | const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 52 | |
| 53 | let resolveResult!: (value: LoopbackResult) => void; |
| 54 | let rejectResult!: (err: Error) => void; |
| 55 | const result = new Promise<LoopbackResult>((resolve, reject) => { |
| 56 | resolveResult = resolve; |
| 57 | rejectResult = reject; |
| 58 | }); |
| 59 | |
| 60 | // redirectUri is the value the IdP sees on /authorize. RFC 6749 §4.1.3 |
| 61 | // requires the token exchange's redirect_uri to be byte-identical to |
| 62 | // it, so we capture this string once and reuse it on both hops — never |
| 63 | // reconstructing from req.socket.localAddress later (which can drift |
| 64 | // on dual-stack hosts). |
| 65 | let redirectUri = ""; |
| 66 | |
| 67 | const server = createServer((req, res) => |
| 68 | handleRequest(req, res, opts.state, redirectUri, resolveResult, rejectResult), |
| 69 | ); |
| 70 | |
| 71 | await listen(server, opts.port ?? 0); |
| 72 | const address = server.address() as AddressInfo; |
| 73 | redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`; |
| 74 | |
| 75 | let closed = false; |
| 76 | const close = async (): Promise<void> => { |
| 77 | if (closed) return; |
| 78 | closed = true; |
| 79 | clearTimeout(timer); |
| 80 | // `server.close()` only refuses NEW connections — it does NOT |
| 81 | // terminate existing keep-alive sockets, which browsers default to |
| 82 | // and idle for minutes (Chrome ~5min). Without `closeAllConnections` |
| 83 | // the CLI process hangs after "Signed in" until the browser closes |
| 84 | // its idle socket. `respond()` also emits `Connection: close` so the |
| 85 | // browser doesn't try to keep-alive in the first place. |
| 86 | server.closeAllConnections?.(); |
| 87 | await new Promise<void>((resolve) => server.close(() => resolve())); |
| 88 | }; |
| 89 | |
| 90 | const timer = setTimeout(() => { |
| 91 | rejectResult(new Error(`OAuth callback timed out after ${timeoutMs}ms`)); |
| 92 | void close(); |
| 93 | }, timeoutMs); |
| 94 | |
| 95 | // When result settles, drain the timer + shutdown. |
| 96 | result.finally(close).catch(() => {}); |
| 97 | |
| 98 | return { result, redirectUri, close }; |
| 99 | } |
| 100 | |
| 101 | async function listen(server: Server, port: number): Promise<void> { |
| 102 | await new Promise<void>((resolve, reject) => { |