Skip to content

Commit 351eeb5

Browse files
authored
Merge pull request #25 from shanehull/feat/auth-session-fix
fix: bridge browser OAuth to REST API session
2 parents 7bd69a6 + 9a5776e commit 351eeb5

4 files changed

Lines changed: 325 additions & 7 deletions

File tree

src/ib-client.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,40 @@ export class IBClient {
210210
this.stopTickle();
211211
}
212212

213+
/**
214+
* Re-authenticate the REST API session after browser OAuth completes.
215+
* This must be called after the browser login creates the server-side session.
216+
*/
217+
async reauthenticate(): Promise<void> {
218+
try {
219+
const authClient = axios.create({
220+
baseURL: this.baseUrl,
221+
timeout: 30000,
222+
httpsAgent: new https.Agent({
223+
rejectUnauthorized: false,
224+
}),
225+
});
226+
227+
Logger.log("[REAUTH] Re-authenticating REST session...");
228+
await authClient.post("/iserver/reauthenticate");
229+
230+
// Verify the reauthentication worked
231+
const statusResponse = await authClient.get("/iserver/auth/status");
232+
if (statusResponse.data.authenticated) {
233+
Logger.log("[REAUTH] Re-authentication successful");
234+
this.isAuthenticated = true;
235+
this.authAttempts = 0;
236+
this.startTickle();
237+
} else {
238+
Logger.warn("[REAUTH] Re-authentication request sent but auth status is still false, will retry via interceptor");
239+
this.isAuthenticated = false;
240+
}
241+
} catch (error) {
242+
Logger.warn("[REAUTH] Re-authentication failed, will fall back to interceptor-based auth:", error);
243+
this.isAuthenticated = false;
244+
}
245+
}
246+
213247
private async authenticate(): Promise<void> {
214248
Logger.log(`[AUTH] Starting authentication process... (attempt ${this.authAttempts + 1}/${this.maxAuthAttempts})`);
215249
this.authAttempts++;

src/tool-handlers.ts

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,22 @@ export class ToolHandlers {
107107
throw new Error(`Authentication failed: ${result.message}`);
108108
}
109109
} else {
110-
// In non-headless mode, throw an error asking user to authenticate manually
111-
const port = this.context.gatewayManager
112-
? this.context.gatewayManager.getCurrentPort()
113-
: this.context.config.IB_GATEWAY_PORT;
114-
const authUrl = `https://${this.context.config.IB_GATEWAY_HOST}:${port}`;
115-
throw new Error(`Authentication required. Please use the 'authenticate' tool to complete the authentication process at ${authUrl}.`);
110+
// In non-headless mode, check if the gateway is already authenticated
111+
// (the user may have completed browser auth via the authenticate tool)
112+
const authStatus = await this.context.ibClient.checkAuthenticationStatus();
113+
if (!authStatus) {
114+
const port = this.context.gatewayManager
115+
? this.context.gatewayManager.getCurrentPort()
116+
: this.context.config.IB_GATEWAY_PORT;
117+
const authUrl = `https://${this.context.config.IB_GATEWAY_HOST}:${port}`;
118+
throw new Error(`Authentication required. Please use the 'authenticate' tool to complete the authentication process at ${authUrl}.`);
119+
}
120+
// Auth status is true, reauthenticate the REST session if needed
121+
try {
122+
await this.context.ibClient.reauthenticate();
123+
} catch (e) {
124+
Logger.warn("[ENSURE-AUTH] Reauthenticate after status check failed, but status is true:", e);
125+
}
116126
}
117127
}
118128

@@ -145,6 +155,46 @@ export class ToolHandlers {
145155
return `Authentication required. Please use the 'authenticate' tool to complete the authentication process (configured for ${mode}) at ${authUrl}.`;
146156
}
147157

158+
/**
159+
* After browser opens for OAuth, poll the gateway until authenticated,
160+
* then trigger reauthenticate to establish the REST API session.
161+
* This bridges the gap between browser-based OAuth and the REST API auth state.
162+
*/
163+
private startBrowserAuthPolling(authUrl: string, port: number): void {
164+
const maxAttempts = 60; // 2 minutes total
165+
const initialDelay = 2000; // 2 second initial delay
166+
let attempts = 0;
167+
168+
const poll = async () => {
169+
attempts++;
170+
Logger.log(`[BROWSER-AUTH-POLL] Attempt ${attempts}/${maxAttempts}`);
171+
172+
try {
173+
const isAuth = await this.context.ibClient.checkAuthenticationStatus();
174+
175+
if (isAuth) {
176+
Logger.log("[BROWSER-AUTH-POLL] Authentication detected, reauthenticating REST session");
177+
// Trigger reauthenticate to establish REST API session
178+
await this.context.ibClient.reauthenticate();
179+
Logger.log("[BROWSER-AUTH-POLL] Reauthentication successful, REST session established");
180+
return; // Success, stop polling
181+
}
182+
} catch (error) {
183+
Logger.warn("[BROWSER-AUTH-POLL] Poll attempt failed:", error);
184+
}
185+
186+
if (attempts < maxAttempts) {
187+
const delay = Math.min(initialDelay + (attempts * 500), 10000);
188+
setTimeout(poll, delay);
189+
} else {
190+
Logger.warn("[BROWSER-AUTH-POLL] Timed out waiting for browser authentication");
191+
}
192+
};
193+
194+
// Start polling after initial delay
195+
setTimeout(poll, initialDelay);
196+
}
197+
148198
private formatError(error: unknown): string {
149199
if (this.isAuthenticationError(error)) {
150200
return this.getAuthenticationErrorMessage();
@@ -241,6 +291,11 @@ export class ToolHandlers {
241291
try {
242292
await open(authUrl);
243293

294+
// Start polling for authentication to complete
295+
// The browser auth creates a server-side session that the REST API can use
296+
// We poll until authenticated, then trigger reauthenticate for the REST session
297+
this.startBrowserAuthPolling(authUrl, port);
298+
244299
return {
245300
content: [
246301
{
@@ -257,7 +312,8 @@ export class ToolHandlers {
257312
"5. Once authenticated, you can use other trading tools"
258313
],
259314
browserOpened: true,
260-
note: "IB Gateway is running locally - your credentials stay secure on your machine"
315+
polling: true,
316+
note: "IB Gateway is running locally - your credentials stay secure on your machine. Polling for authentication completion..."
261317
}, null, 2),
262318
},
263319
],

test/ib-client.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,51 @@ describe('IBClient', () => {
353353
});
354354
});
355355

356+
describe('reauthenticate', () => {
357+
it('should reauthenticate and start tickle on success', async () => {
358+
const mockAuthClient = {
359+
post: vi.fn().mockResolvedValue({ data: {} }),
360+
get: vi.fn().mockResolvedValue({
361+
data: { authenticated: true },
362+
}),
363+
};
364+
365+
vi.mocked(axios.create).mockReturnValueOnce(mockAuthClient as any);
366+
367+
await client.reauthenticate();
368+
369+
expect(mockAuthClient.post).toHaveBeenCalledWith('/iserver/reauthenticate');
370+
expect(mockAuthClient.get).toHaveBeenCalledWith('/iserver/auth/status');
371+
});
372+
373+
it('should handle reauth when status returns false', async () => {
374+
const mockAuthClient = {
375+
post: vi.fn().mockResolvedValue({ data: {} }),
376+
get: vi.fn().mockResolvedValue({
377+
data: { authenticated: false },
378+
}),
379+
};
380+
381+
vi.mocked(axios.create).mockReturnValueOnce(mockAuthClient as any);
382+
383+
// Should not throw — handled internally
384+
await expect(client.reauthenticate()).resolves.not.toThrow();
385+
expect(mockAuthClient.post).toHaveBeenCalledWith('/iserver/reauthenticate');
386+
});
387+
388+
it('should handle network errors gracefully', async () => {
389+
const mockAuthClient = {
390+
post: vi.fn().mockRejectedValue(new Error('Connection refused')),
391+
get: vi.fn(),
392+
};
393+
394+
vi.mocked(axios.create).mockReturnValueOnce(mockAuthClient as any);
395+
396+
// Should not throw — errors are caught internally
397+
await expect(client.reauthenticate()).resolves.not.toThrow();
398+
});
399+
});
400+
356401
describe('Cleanup', () => {
357402
it('should stop tickle on destroy', () => {
358403
// Start with authenticated state to trigger tickle

0 commit comments

Comments
 (0)