fix: fall back to next port when OAuth callback server can't bind 8080 (#560)

* fix: fall back to next port when OAuth callback server can't bind 8080

On Windows with Hyper-V/WSL2/Docker, port 8080 is often reserved by the
OS (EACCES) or already in use (EADDRINUSE), making sign-in completely
impossible. The app now scans 8080–8089 and binds the first available
port. For DCR providers, a stale registration locked to a blocked port
is detected and cleared so the client re-registers on the new port.
Static-client providers (Google BYOK) keep fixed-port behaviour with a
clear error message instead of a raw Node.js exception.

* fix: keep createAuthServer fixed-port by default, opt-in fallback

Address review feedback:
- Flip createAuthServer default to fixed-port; fallback is now opt-in via
  { fallback: true }. Composio (composio-handler.ts) keeps exact-port
  semantics with no code change — only the Rowboat sign-in call site,
  which builds its redirect URI from the actual bound port, opts in.
- Wrap post-bind setup (DCR, PKCE, auth URL) in try/catch and close the
  server on any failure so the port is released for retries.

* fix: clear stale DCR registration when bound port differs from start port
This commit is contained in:
gagan 2026-05-22 00:10:41 +05:30 committed by GitHub
parent c4888e2899
commit 0a3fc3736f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 302 additions and 167 deletions

View file

@ -3,14 +3,21 @@ import fs from 'fs/promises';
import path from 'path';
import { ClientRegistrationResponse } from './types.js';
export const DEFAULT_CALLBACK_PORT = 8080;
export interface IClientRegistrationRepo {
getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null>;
saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise<void>;
/** Returns the port that was used when DCR-registering this provider, or DEFAULT_CALLBACK_PORT if not stored. */
getRegisteredPort(provider: string): Promise<number>;
saveClientRegistration(provider: string, registration: ClientRegistrationResponse, port: number): Promise<void>;
clearClientRegistration(provider: string): Promise<void>;
}
// _registeredPort is our private field — stripped by Zod when we parse the RFC response fields
type StoredEntry = Record<string, unknown> & { _registeredPort?: number };
type ClientRegistrationStorage = {
[provider: string]: ClientRegistrationResponse;
[provider: string]: StoredEntry;
};
export class FSClientRegistrationRepo implements IClientRegistrationRepo {
@ -45,14 +52,14 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo {
async getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null> {
const config = await this.readConfig();
const registration = config[provider];
if (!registration) {
const entry = config[provider];
if (!entry) {
return null;
}
// Validate registration structure
// Validate registration structure (Zod strips unknown fields like _registeredPort)
try {
return ClientRegistrationResponse.parse(registration);
return ClientRegistrationResponse.parse(entry);
} catch {
// Invalid registration, remove it
await this.clearClientRegistration(provider);
@ -60,9 +67,14 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo {
}
}
async saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise<void> {
async getRegisteredPort(provider: string): Promise<number> {
const config = await this.readConfig();
config[provider] = registration;
return config[provider]?._registeredPort ?? DEFAULT_CALLBACK_PORT;
}
async saveClientRegistration(provider: string, registration: ClientRegistrationResponse, port: number): Promise<void> {
const config = await this.readConfig();
config[provider] = { ...registration, _registeredPort: port };
await this.writeConfig(config);
}