Skip to content
72 changes: 42 additions & 30 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { CoderApi } from "./api/coderApi";
import { needToken } from "./api/utils";
import { type CliManager } from "./core/cliManager";
import { type ServiceContainer } from "./core/container";
import { type ContextManager } from "./core/contextManager";
import { type MementoManager } from "./core/mementoManager";
import { type PathResolver } from "./core/pathResolver";
import { type SecretsManager } from "./core/secretsManager";
Expand All @@ -32,6 +33,7 @@ export class Commands {
private readonly mementoManager: MementoManager;
private readonly secretsManager: SecretsManager;
private readonly cliManager: CliManager;
private readonly contextManager: ContextManager;
// These will only be populated when actively connected to a workspace and are
// used in commands. Because commands can be executed by the user, it is not
// possible to pass in arguments, so we have to store the current workspace
Expand All @@ -53,6 +55,7 @@ export class Commands {
this.mementoManager = serviceContainer.getMementoManager();
this.secretsManager = serviceContainer.getSecretsManager();
this.cliManager = serviceContainer.getCliManager();
this.contextManager = serviceContainer.getContextManager();
}

/**
Expand Down Expand Up @@ -179,31 +182,34 @@ export class Commands {
}

/**
* Log into the provided deployment. If the deployment URL is not specified,
* Log into the provided deployment. If the deployment URL is not specified,
* ask for it first with a menu showing recent URLs along with the default URL
* and CODER_URL, if those are set.
*/
public async login(...args: string[]): Promise<void> {
// Destructure would be nice but VS Code can pass undefined which errors.
const inputUrl = args[0];
const inputToken = args[1];
const inputLabel = args[2];
const isAutologin =
typeof args[3] === "undefined" ? false : Boolean(args[3]);

const url = await this.maybeAskUrl(inputUrl);
public async login(args?: {
url?: string;
token?: string;
label?: string;
autoLogin?: boolean;
}): Promise<void> {
if (this.contextManager.get("coder.authenticated")) {
return;
}
this.logger.info("Logging in");

const url = await this.maybeAskUrl(args?.url);
if (!url) {
return; // The user aborted.
}

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

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

// These contexts control various menu items and the sidebar.
await vscode.commands.executeCommand(
"setContext",
"coder.authenticated",
true,
);
this.contextManager.set("coder.authenticated", true);
if (res.user.roles.find((role) => role.name === "owner")) {
await vscode.commands.executeCommand("setContext", "coder.isOwner", true);
this.contextManager.set("coder.isOwner", true);
}

vscode.window
Expand All @@ -245,6 +247,7 @@ export class Commands {
}
});

await this.secretsManager.triggerLoginStateChange("login");
// Fetch workspaces for the new deployment.
vscode.commands.executeCommand("coder.refreshWorkspaces");
}
Expand All @@ -257,19 +260,21 @@ export class Commands {
*/
private async maybeAskToken(
url: string,
token: string,
isAutologin: boolean,
token: string | undefined,
isAutoLogin: boolean,
): Promise<{ user: User; token: string } | null> {
const client = CoderApi.create(url, token, this.logger);
if (!needToken(vscode.workspace.getConfiguration())) {
const needsToken = needToken(vscode.workspace.getConfiguration());
if (!needsToken || token) {
try {
const user = await client.getAuthenticatedUser();
// For non-token auth, we write a blank token since the `vscodessh`
// command currently always requires a token file.
return { token: "", user };
// For token auth, we have valid access so we can just return the user here
return { token: needsToken && token ? token : "", user };
} catch (err) {
const message = getErrorMessage(err, "no response from the server");
if (isAutologin) {
if (isAutoLogin) {
this.logger.warn("Failed to log in to Coder server:", message);
} else {
this.vscodeProposed.window.showErrorMessage(
Expand Down Expand Up @@ -301,6 +306,9 @@ export class Commands {
value: token || (await this.secretsManager.getSessionToken()),
ignoreFocusOut: true,
validateInput: async (value) => {
if (!value) {
return null;
}
client.setSessionToken(value);
try {
user = await client.getAuthenticatedUser();
Comment on lines +309 to 314
Copy link
Collaborator Author

@EhabY EhabY Sep 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we attempt to get the authenticated user when the token is a blank string (like when this first opens)?

Expand Down Expand Up @@ -369,7 +377,14 @@ export class Commands {
// Sanity check; command should not be available if no url.
throw new Error("You are not logged in");
}
await this.forceLogout();
}

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

await vscode.commands.executeCommand(
"setContext",
"coder.authenticated",
false,
);
this.contextManager.set("coder.authenticated", false);
vscode.window
.showInformationMessage("You've been logged out of Coder!", "Login")
.then((action) => {
if (action === "Login") {
vscode.commands.executeCommand("coder.login");
this.login();
}
});

await this.secretsManager.triggerLoginStateChange("logout");
// This will result in clearing the workspace list.
vscode.commands.executeCommand("coder.refreshWorkspaces");
}
Expand Down
8 changes: 8 additions & 0 deletions src/core/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as vscode from "vscode";
import { type Logger } from "../logging/logger";

import { CliManager } from "./cliManager";
import { ContextManager } from "./contextManager";
import { MementoManager } from "./mementoManager";
import { PathResolver } from "./pathResolver";
import { SecretsManager } from "./secretsManager";
Expand All @@ -17,6 +18,7 @@ export class ServiceContainer implements vscode.Disposable {
private readonly mementoManager: MementoManager;
private readonly secretsManager: SecretsManager;
private readonly cliManager: CliManager;
private readonly contextManager: ContextManager;

constructor(
context: vscode.ExtensionContext,
Expand All @@ -34,6 +36,7 @@ export class ServiceContainer implements vscode.Disposable {
this.logger,
this.pathResolver,
);
this.contextManager = new ContextManager();
}

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

getContextManager(): ContextManager {
return this.contextManager;
}

/**
* Dispose of all services and clean up resources.
*/
dispose(): void {
this.contextManager.dispose();
this.logger.dispose();
}
}
33 changes: 33 additions & 0 deletions src/core/contextManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as vscode from "vscode";

const CONTEXT_DEFAULTS = {
"coder.authenticated": false,
"coder.isOwner": false,
"coder.loaded": false,
"coder.workspace.updatable": false,
} as const;

type CoderContext = keyof typeof CONTEXT_DEFAULTS;

export class ContextManager implements vscode.Disposable {
private readonly context = new Map<CoderContext, boolean>();

public constructor() {
(Object.keys(CONTEXT_DEFAULTS) as CoderContext[]).forEach((key) => {
this.set(key, CONTEXT_DEFAULTS[key]);
});
}

public set(key: CoderContext, value: boolean): void {
this.context.set(key, value);
vscode.commands.executeCommand("setContext", key, value);
}

public get(key: CoderContext): boolean {
return this.context.get(key) ?? CONTEXT_DEFAULTS[key];
}

public dispose() {
this.context.clear();
}
}
46 changes: 42 additions & 4 deletions src/core/secretsManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { SecretStorage } from "vscode";
import type { SecretStorage, Disposable } from "vscode";

const SESSION_TOKEN_KEY = "sessionToken";

const LOGIN_STATE_KEY = "loginState";

type AuthAction = "login" | "logout";

export class SecretsManager {
constructor(private readonly secrets: SecretStorage) {}
Expand All @@ -8,9 +14,9 @@ export class SecretsManager {
*/
public async setSessionToken(sessionToken?: string): Promise<void> {
if (!sessionToken) {
await this.secrets.delete("sessionToken");
await this.secrets.delete(SESSION_TOKEN_KEY);
} else {
await this.secrets.store("sessionToken", sessionToken);
await this.secrets.store(SESSION_TOKEN_KEY, sessionToken);
}
}

Expand All @@ -19,11 +25,43 @@ export class SecretsManager {
*/
public async getSessionToken(): Promise<string | undefined> {
try {
return await this.secrets.get("sessionToken");
return await this.secrets.get(SESSION_TOKEN_KEY);
} catch {
// The VS Code session store has become corrupt before, and
// will fail to get the session token...
return undefined;
}
}

/**
* Triggers a login/logout event that propagates across all VS Code windows.
* Uses the secrets storage onDidChange event as a cross-window communication mechanism.
* Appends a timestamp to ensure the value always changes, guaranteeing the event fires.
*/
public async triggerLoginStateChange(action: AuthAction): Promise<void> {
const date = new Date().toISOString();
await this.secrets.store(LOGIN_STATE_KEY, `${action}-${date}`);
}

/**
* Listens for login/logout events from any VS Code window.
* The secrets storage onDidChange event fires across all windows, enabling cross-window sync.
*/
public onDidChangeLoginState(
listener: (state?: AuthAction) => Promise<void>,
): Disposable {
return this.secrets.onDidChange(async (e) => {
if (e.key === LOGIN_STATE_KEY) {
const state = await this.secrets.get(LOGIN_STATE_KEY);
if (state?.startsWith("login")) {
listener("login");
} else if (state?.startsWith("logout")) {
listener("logout");
} else {
// Secret was deleted or is invalid
listener(undefined);
}
}
});
}
}
Loading