Skip to content

Commit 639df18

Browse files
committed
Allow callback handling in multiple VS Code windows
1 parent b93e027 commit 639df18

File tree

3 files changed

+85
-77
lines changed

3 files changed

+85
-77
lines changed

src/core/secretsManager.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,19 @@ const OAUTH_CLIENT_REGISTRATION_KEY = "oauthClientRegistration";
1313

1414
const OAUTH_TOKENS_KEY = "oauthTokens";
1515

16+
const OAUTH_CALLBACK_KEY = "coder.oauthCallback";
17+
1618
export type StoredOAuthTokens = Omit<TokenResponse, "expires_in"> & {
1719
expiry_timestamp: number;
1820
deployment_url: string;
1921
};
2022

23+
interface OAuthCallbackData {
24+
state: string;
25+
code: string | null;
26+
error: string | null;
27+
}
28+
2129
export enum AuthAction {
2230
LOGIN,
2331
LOGOUT,
@@ -163,4 +171,36 @@ export class SecretsManager {
163171
}
164172
return undefined;
165173
}
174+
175+
/**
176+
* Write an OAuth callback result to secrets storage.
177+
* Used for cross-window communication when OAuth callback arrives in a different window.
178+
*/
179+
public async setOAuthCallback(data: OAuthCallbackData): Promise<void> {
180+
await this.secrets.store(OAUTH_CALLBACK_KEY, JSON.stringify(data));
181+
}
182+
183+
/**
184+
* Listen for OAuth callback results from any VS Code window.
185+
* The listener receives the state parameter, code (if success), and error (if failed).
186+
*/
187+
public onDidChangeOAuthCallback(
188+
listener: (data: OAuthCallbackData) => void,
189+
): Disposable {
190+
return this.secrets.onDidChange(async (e) => {
191+
if (e.key !== OAUTH_CALLBACK_KEY) {
192+
return;
193+
}
194+
195+
try {
196+
const data = await this.secrets.get(OAUTH_CALLBACK_KEY);
197+
if (data) {
198+
const parsed = JSON.parse(data) as OAuthCallbackData;
199+
listener(parsed);
200+
}
201+
} catch {
202+
// Ignore parse errors
203+
}
204+
});
205+
}
166206
}

src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
163163
const code = params.get("code");
164164
const state = params.get("state");
165165
const error = params.get("error");
166-
oauthSessionManager.handleCallback(code, state, error);
166+
await oauthSessionManager.handleCallback(code, state, error);
167167
return;
168168
}
169169

src/oauth/sessionManager.ts

Lines changed: 44 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,7 @@ export class OAuthSessionManager implements vscode.Disposable {
6464
private refreshInProgress = false;
6565
private lastRefreshAttempt = 0;
6666

67-
// Pending authorization flow state
68-
private pendingAuthResolve:
69-
| ((value: { code: string; verifier: string }) => void)
70-
| undefined;
7167
private pendingAuthReject: ((reason: Error) => void) | undefined;
72-
private expectedState: string | undefined;
73-
private pendingVerifier: string | undefined;
7468

7569
/**
7670
* Create and initialize a new OAuth session manager.
@@ -370,106 +364,80 @@ export class OAuthSessionManager implements vscode.Disposable {
370364
challenge,
371365
);
372366

373-
return new Promise<{ code: string; verifier: string }>(
367+
const callbackPromise = new Promise<{ code: string; verifier: string }>(
374368
(resolve, reject) => {
375369
const timeoutMins = 5;
376-
const timeout = setTimeout(
370+
const timeoutHandle = setTimeout(
377371
() => {
378-
this.clearPendingAuth();
372+
cleanup();
379373
reject(
380374
new Error(`OAuth flow timed out after ${timeoutMins} minutes`),
381375
);
382376
},
383377
timeoutMins * 60 * 1000,
384378
);
385379

386-
const clearPromise = () => {
387-
clearTimeout(timeout);
388-
this.clearPendingAuth();
389-
};
390-
391-
this.pendingAuthResolve = (result) => {
392-
clearPromise();
393-
resolve(result);
394-
};
395-
396-
this.pendingAuthReject = (error) => {
397-
clearPromise();
398-
reject(error);
399-
};
380+
const listener = this.secretsManager.onDidChangeOAuthCallback(
381+
({ state: callbackState, code, error }) => {
382+
if (callbackState !== state) {
383+
return;
384+
}
400385

401-
this.expectedState = state;
402-
this.pendingVerifier = verifier;
386+
cleanup();
403387

404-
vscode.env.openExternal(vscode.Uri.parse(authUrl)).then(
405-
() => {},
406-
(error) => {
407-
if (error instanceof Error) {
408-
this.pendingAuthReject?.(error);
388+
if (error) {
389+
reject(new Error(`OAuth error: ${error}`));
390+
} else if (code) {
391+
resolve({ code, verifier });
409392
} else {
410-
this.pendingAuthReject?.(new Error("Failed to open browser"));
393+
reject(new Error("No authorization code received"));
411394
}
412395
},
413396
);
397+
398+
const cleanup = () => {
399+
clearTimeout(timeoutHandle);
400+
listener.dispose();
401+
};
402+
403+
this.pendingAuthReject = (error) => {
404+
cleanup();
405+
reject(error);
406+
};
414407
},
415408
);
416-
}
417409

418-
/**
419-
* Clear pending authorization flow state.
420-
*/
421-
private clearPendingAuth(): void {
422-
this.pendingAuthResolve = undefined;
423-
this.pendingAuthReject = undefined;
424-
this.expectedState = undefined;
425-
this.pendingVerifier = undefined;
410+
try {
411+
await vscode.env.openExternal(vscode.Uri.parse(authUrl));
412+
} catch (error) {
413+
throw error instanceof Error
414+
? error
415+
: new Error("Failed to open browser");
416+
}
417+
418+
return callbackPromise;
426419
}
427420

428421
/**
429422
* Handle OAuth callback from browser redirect.
430-
* Validates state and resolves pending authorization promise.
431-
*
432-
* // TODO this has to work across windows!
423+
* Writes the callback result to secrets storage, triggering the waiting window to proceed.
433424
*/
434-
handleCallback(
425+
async handleCallback(
435426
code: string | null,
436427
state: string | null,
437428
error: string | null,
438-
): void {
439-
if (!this.pendingAuthResolve || !this.pendingAuthReject) {
440-
this.logger.warn("Received OAuth callback but no pending auth flow");
441-
return;
442-
}
443-
444-
if (error) {
445-
this.pendingAuthReject(new Error(`OAuth error: ${error}`));
446-
return;
447-
}
448-
449-
if (!code) {
450-
this.pendingAuthReject(new Error("No authorization code received"));
451-
return;
452-
}
453-
429+
): Promise<void> {
454430
if (!state) {
455-
this.pendingAuthReject(new Error("No state received"));
456-
return;
457-
}
458-
459-
if (state !== this.expectedState) {
460-
this.pendingAuthReject(
461-
new Error("State mismatch - possible CSRF attack"),
462-
);
431+
this.logger.warn("Received OAuth callback with no state parameter");
463432
return;
464433
}
465434

466-
const verifier = this.pendingVerifier;
467-
if (!verifier) {
468-
this.pendingAuthReject(new Error("No PKCE verifier found"));
469-
return;
435+
try {
436+
await this.secretsManager.setOAuthCallback({ state, code, error });
437+
this.logger.debug("OAuth callback processed successfully");
438+
} catch (err) {
439+
this.logger.error("Failed to process OAuth callback:", err);
470440
}
471-
472-
this.pendingAuthResolve({ code, verifier });
473441
}
474442

475443
/**
@@ -712,13 +680,13 @@ export class OAuthSessionManager implements vscode.Disposable {
712680
}
713681

714682
/**
715-
* Clears all in-memory state and rejects any pending operations.
683+
* Clears all in-memory state.
716684
*/
717685
dispose(): void {
718686
if (this.pendingAuthReject) {
719687
this.pendingAuthReject(new Error("OAuth session manager disposed"));
720688
}
721-
this.clearPendingAuth();
689+
this.pendingAuthReject = undefined;
722690
this.storedTokens = undefined;
723691
this.refreshInProgress = false;
724692
this.lastRefreshAttempt = 0;

0 commit comments

Comments
 (0)