Skip to content

Commit 648360a

Browse files
authored
Fix login/logout synchronization across multiple VS Code windows (#590)
Introduce ContextManager for centralized state management and use secrets to propagate authentication events between windows. Resolves race conditions in session token handling and ensures consistent authentication behavior across all open extension instances. Fixes #498
1 parent 4600567 commit 648360a

File tree

11 files changed

+336
-122
lines changed

11 files changed

+336
-122
lines changed

.eslintrc.json

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,6 @@
2323
"import/internal-regex": "^@/"
2424
},
2525
"overrides": [
26-
{
27-
"files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"],
28-
"settings": {
29-
"import/resolver": {
30-
"typescript": {
31-
// In tests, resolve using the test tsconfig
32-
"project": "test/tsconfig.json"
33-
}
34-
}
35-
}
36-
},
3726
{
3827
"files": ["*.ts"],
3928
"rules": {
@@ -46,9 +35,30 @@
4635
"prefer": "type-imports",
4736
"fixStyle": "inline-type-imports"
4837
}
38+
],
39+
"@typescript-eslint/switch-exhaustiveness-check": [
40+
"error",
41+
{ "considerDefaultExhaustiveForUnions": true }
4942
]
5043
}
5144
},
45+
{
46+
"files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"],
47+
"settings": {
48+
"import/resolver": {
49+
"typescript": {
50+
// In tests, resolve using the test tsconfig
51+
"project": "test/tsconfig.json"
52+
}
53+
}
54+
}
55+
},
56+
{
57+
"files": ["src/core/contextManager.ts"],
58+
"rules": {
59+
"no-restricted-syntax": "off"
60+
}
61+
},
5262
{
5363
"extends": ["plugin:package-json/legacy-recommended"],
5464
"files": ["*.json"],
@@ -106,6 +116,13 @@
106116
"sublings_only": true
107117
}
108118
}
119+
],
120+
"no-restricted-syntax": [
121+
"error",
122+
{
123+
"selector": "CallExpression[callee.property.name='executeCommand'][arguments.0.value='setContext'][arguments.length>=3]",
124+
"message": "Do not use executeCommand('setContext', ...) directly. Use the ContextManager class instead."
125+
}
109126
]
110127
}
111128
}

src/commands.ts

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CoderApi } from "./api/coderApi";
1212
import { needToken } from "./api/utils";
1313
import { type CliManager } from "./core/cliManager";
1414
import { type ServiceContainer } from "./core/container";
15+
import { type ContextManager } from "./core/contextManager";
1516
import { type MementoManager } from "./core/mementoManager";
1617
import { type PathResolver } from "./core/pathResolver";
1718
import { type SecretsManager } from "./core/secretsManager";
@@ -32,6 +33,7 @@ export class Commands {
3233
private readonly mementoManager: MementoManager;
3334
private readonly secretsManager: SecretsManager;
3435
private readonly cliManager: CliManager;
36+
private readonly contextManager: ContextManager;
3537
// These will only be populated when actively connected to a workspace and are
3638
// used in commands. Because commands can be executed by the user, it is not
3739
// possible to pass in arguments, so we have to store the current workspace
@@ -53,6 +55,7 @@ export class Commands {
5355
this.mementoManager = serviceContainer.getMementoManager();
5456
this.secretsManager = serviceContainer.getSecretsManager();
5557
this.cliManager = serviceContainer.getCliManager();
58+
this.contextManager = serviceContainer.getContextManager();
5659
}
5760

5861
/**
@@ -179,31 +182,34 @@ export class Commands {
179182
}
180183

181184
/**
182-
* Log into the provided deployment. If the deployment URL is not specified,
185+
* Log into the provided deployment. If the deployment URL is not specified,
183186
* ask for it first with a menu showing recent URLs along with the default URL
184187
* and CODER_URL, if those are set.
185188
*/
186-
public async login(...args: string[]): Promise<void> {
187-
// Destructure would be nice but VS Code can pass undefined which errors.
188-
const inputUrl = args[0];
189-
const inputToken = args[1];
190-
const inputLabel = args[2];
191-
const isAutologin =
192-
typeof args[3] === "undefined" ? false : Boolean(args[3]);
193-
194-
const url = await this.maybeAskUrl(inputUrl);
189+
public async login(args?: {
190+
url?: string;
191+
token?: string;
192+
label?: string;
193+
autoLogin?: boolean;
194+
}): Promise<void> {
195+
if (this.contextManager.get("coder.authenticated")) {
196+
return;
197+
}
198+
this.logger.info("Logging in");
199+
200+
const url = await this.maybeAskUrl(args?.url);
195201
if (!url) {
196202
return; // The user aborted.
197203
}
198204

199205
// It is possible that we are trying to log into an old-style host, in which
200206
// case we want to write with the provided blank label instead of generating
201207
// a host label.
202-
const label =
203-
typeof inputLabel === "undefined" ? toSafeHost(url) : inputLabel;
208+
const label = args?.label === undefined ? toSafeHost(url) : args.label;
204209

205210
// Try to get a token from the user, if we need one, and their user.
206-
const res = await this.maybeAskToken(url, inputToken, isAutologin);
211+
const autoLogin = args?.autoLogin === true;
212+
const res = await this.maybeAskToken(url, args?.token, autoLogin);
207213
if (!res) {
208214
return; // The user aborted, or unable to auth.
209215
}
@@ -221,13 +227,9 @@ export class Commands {
221227
await this.cliManager.configure(label, url, res.token);
222228

223229
// These contexts control various menu items and the sidebar.
224-
await vscode.commands.executeCommand(
225-
"setContext",
226-
"coder.authenticated",
227-
true,
228-
);
230+
this.contextManager.set("coder.authenticated", true);
229231
if (res.user.roles.find((role) => role.name === "owner")) {
230-
await vscode.commands.executeCommand("setContext", "coder.isOwner", true);
232+
this.contextManager.set("coder.isOwner", true);
231233
}
232234

233235
vscode.window
@@ -245,6 +247,7 @@ export class Commands {
245247
}
246248
});
247249

250+
await this.secretsManager.triggerLoginStateChange("login");
248251
// Fetch workspaces for the new deployment.
249252
vscode.commands.executeCommand("coder.refreshWorkspaces");
250253
}
@@ -257,19 +260,21 @@ export class Commands {
257260
*/
258261
private async maybeAskToken(
259262
url: string,
260-
token: string,
261-
isAutologin: boolean,
263+
token: string | undefined,
264+
isAutoLogin: boolean,
262265
): Promise<{ user: User; token: string } | null> {
263266
const client = CoderApi.create(url, token, this.logger);
264-
if (!needToken(vscode.workspace.getConfiguration())) {
267+
const needsToken = needToken(vscode.workspace.getConfiguration());
268+
if (!needsToken || token) {
265269
try {
266270
const user = await client.getAuthenticatedUser();
267271
// For non-token auth, we write a blank token since the `vscodessh`
268272
// command currently always requires a token file.
269-
return { token: "", user };
273+
// For token auth, we have valid access so we can just return the user here
274+
return { token: needsToken && token ? token : "", user };
270275
} catch (err) {
271276
const message = getErrorMessage(err, "no response from the server");
272-
if (isAutologin) {
277+
if (isAutoLogin) {
273278
this.logger.warn("Failed to log in to Coder server:", message);
274279
} else {
275280
this.vscodeProposed.window.showErrorMessage(
@@ -301,6 +306,9 @@ export class Commands {
301306
value: token || (await this.secretsManager.getSessionToken()),
302307
ignoreFocusOut: true,
303308
validateInput: async (value) => {
309+
if (!value) {
310+
return null;
311+
}
304312
client.setSessionToken(value);
305313
try {
306314
user = await client.getAuthenticatedUser();
@@ -369,7 +377,14 @@ export class Commands {
369377
// Sanity check; command should not be available if no url.
370378
throw new Error("You are not logged in");
371379
}
380+
await this.forceLogout();
381+
}
372382

383+
public async forceLogout(): Promise<void> {
384+
if (!this.contextManager.get("coder.authenticated")) {
385+
return;
386+
}
387+
this.logger.info("Logging out");
373388
// Clear from the REST client. An empty url will indicate to other parts of
374389
// the code that we are logged out.
375390
this.restClient.setHost("");
@@ -379,19 +394,16 @@ export class Commands {
379394
await this.mementoManager.setUrl(undefined);
380395
await this.secretsManager.setSessionToken(undefined);
381396

382-
await vscode.commands.executeCommand(
383-
"setContext",
384-
"coder.authenticated",
385-
false,
386-
);
397+
this.contextManager.set("coder.authenticated", false);
387398
vscode.window
388399
.showInformationMessage("You've been logged out of Coder!", "Login")
389400
.then((action) => {
390401
if (action === "Login") {
391-
vscode.commands.executeCommand("coder.login");
402+
this.login();
392403
}
393404
});
394405

406+
await this.secretsManager.triggerLoginStateChange("logout");
395407
// This will result in clearing the workspace list.
396408
vscode.commands.executeCommand("coder.refreshWorkspaces");
397409
}

src/core/container.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as vscode from "vscode";
33
import { type Logger } from "../logging/logger";
44

55
import { CliManager } from "./cliManager";
6+
import { ContextManager } from "./contextManager";
67
import { MementoManager } from "./mementoManager";
78
import { PathResolver } from "./pathResolver";
89
import { SecretsManager } from "./secretsManager";
@@ -17,6 +18,7 @@ export class ServiceContainer implements vscode.Disposable {
1718
private readonly mementoManager: MementoManager;
1819
private readonly secretsManager: SecretsManager;
1920
private readonly cliManager: CliManager;
21+
private readonly contextManager: ContextManager;
2022

2123
constructor(
2224
context: vscode.ExtensionContext,
@@ -34,6 +36,7 @@ export class ServiceContainer implements vscode.Disposable {
3436
this.logger,
3537
this.pathResolver,
3638
);
39+
this.contextManager = new ContextManager();
3740
}
3841

3942
getVsCodeProposed(): typeof vscode {
@@ -60,10 +63,15 @@ export class ServiceContainer implements vscode.Disposable {
6063
return this.cliManager;
6164
}
6265

66+
getContextManager(): ContextManager {
67+
return this.contextManager;
68+
}
69+
6370
/**
6471
* Dispose of all services and clean up resources.
6572
*/
6673
dispose(): void {
74+
this.contextManager.dispose();
6775
this.logger.dispose();
6876
}
6977
}

src/core/contextManager.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as vscode from "vscode";
2+
3+
const CONTEXT_DEFAULTS = {
4+
"coder.authenticated": false,
5+
"coder.isOwner": false,
6+
"coder.loaded": false,
7+
"coder.workspace.updatable": false,
8+
} as const;
9+
10+
type CoderContext = keyof typeof CONTEXT_DEFAULTS;
11+
12+
export class ContextManager implements vscode.Disposable {
13+
private readonly context = new Map<CoderContext, boolean>();
14+
15+
public constructor() {
16+
(Object.keys(CONTEXT_DEFAULTS) as CoderContext[]).forEach((key) => {
17+
this.set(key, CONTEXT_DEFAULTS[key]);
18+
});
19+
}
20+
21+
public set(key: CoderContext, value: boolean): void {
22+
this.context.set(key, value);
23+
vscode.commands.executeCommand("setContext", key, value);
24+
}
25+
26+
public get(key: CoderContext): boolean {
27+
return this.context.get(key) ?? CONTEXT_DEFAULTS[key];
28+
}
29+
30+
public dispose() {
31+
this.context.clear();
32+
}
33+
}

src/core/secretsManager.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import type { SecretStorage } from "vscode";
1+
import type { SecretStorage, Disposable } from "vscode";
2+
3+
const SESSION_TOKEN_KEY = "sessionToken";
4+
5+
const LOGIN_STATE_KEY = "loginState";
6+
7+
export enum AuthAction {
8+
LOGIN,
9+
LOGOUT,
10+
INVALID,
11+
}
212

313
export class SecretsManager {
414
constructor(private readonly secrets: SecretStorage) {}
@@ -8,9 +18,9 @@ export class SecretsManager {
818
*/
919
public async setSessionToken(sessionToken?: string): Promise<void> {
1020
if (!sessionToken) {
11-
await this.secrets.delete("sessionToken");
21+
await this.secrets.delete(SESSION_TOKEN_KEY);
1222
} else {
13-
await this.secrets.store("sessionToken", sessionToken);
23+
await this.secrets.store(SESSION_TOKEN_KEY, sessionToken);
1424
}
1525
}
1626

@@ -19,11 +29,45 @@ export class SecretsManager {
1929
*/
2030
public async getSessionToken(): Promise<string | undefined> {
2131
try {
22-
return await this.secrets.get("sessionToken");
32+
return await this.secrets.get(SESSION_TOKEN_KEY);
2333
} catch {
2434
// The VS Code session store has become corrupt before, and
2535
// will fail to get the session token...
2636
return undefined;
2737
}
2838
}
39+
40+
/**
41+
* Triggers a login/logout event that propagates across all VS Code windows.
42+
* Uses the secrets storage onDidChange event as a cross-window communication mechanism.
43+
* Appends a timestamp to ensure the value always changes, guaranteeing the event fires.
44+
*/
45+
public async triggerLoginStateChange(
46+
action: "login" | "logout",
47+
): Promise<void> {
48+
const date = new Date().toISOString();
49+
await this.secrets.store(LOGIN_STATE_KEY, `${action}-${date}`);
50+
}
51+
52+
/**
53+
* Listens for login/logout events from any VS Code window.
54+
* The secrets storage onDidChange event fires across all windows, enabling cross-window sync.
55+
*/
56+
public onDidChangeLoginState(
57+
listener: (state: AuthAction) => Promise<void>,
58+
): Disposable {
59+
return this.secrets.onDidChange(async (e) => {
60+
if (e.key === LOGIN_STATE_KEY) {
61+
const state = await this.secrets.get(LOGIN_STATE_KEY);
62+
if (state?.startsWith("login")) {
63+
listener(AuthAction.LOGIN);
64+
} else if (state?.startsWith("logout")) {
65+
listener(AuthAction.LOGOUT);
66+
} else {
67+
// Secret was deleted or is invalid
68+
listener(AuthAction.INVALID);
69+
}
70+
}
71+
});
72+
}
2973
}

src/error.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export class CertificateError extends Error {
6464
return new CertificateError(err.message, X509_ERR.UNTRUSTED_LEAF);
6565
case X509_ERR_CODE.SELF_SIGNED_CERT_IN_CHAIN:
6666
return new CertificateError(err.message, X509_ERR.UNTRUSTED_CHAIN);
67+
case undefined:
68+
break;
6769
}
6870
}
6971
return err;
@@ -154,6 +156,7 @@ export class CertificateError extends Error {
154156
);
155157
switch (val) {
156158
case CertificateError.ActionOK:
159+
case undefined:
157160
return;
158161
case CertificateError.ActionAllowInsecure:
159162
await this.allowInsecure();

0 commit comments

Comments
 (0)