Skip to content
39 changes: 28 additions & 11 deletions .eslintrc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,17 +23,6 @@
"import/internal-regex": "^@/"
},
"overrides": [
{
"files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"],
"settings":{
"import/resolver":{
"typescript":{
// In tests, resolve using the test tsconfig
"project": "test/tsconfig.json"
}
}
}
},
{
"files": ["*.ts"],
"rules":{
Expand All@@ -46,9 +35,30 @@
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"@typescript-eslint/switch-exhaustiveness-check": [
"error",
{"considerDefaultExhaustiveForUnions": true }
]
}
},
{
"files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"],
"settings":{
"import/resolver":{
"typescript":{
// In tests, resolve using the test tsconfig
"project": "test/tsconfig.json"
}
}
}
},
{
"files": ["src/core/contextManager.ts"],
"rules":{
"no-restricted-syntax": "off"
}
},
{
"extends": ["plugin:package-json/legacy-recommended"],
"files": ["*.json"],
Expand DownExpand Up@@ -106,6 +116,13 @@
"sublings_only": true
}
}
],
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.property.name='executeCommand'][arguments.0.value='setContext'][arguments.length>=3]",
"message": "Do not use executeCommand('setContext', ...) directly. Use the ContextManager class instead."
}
]
}
}
72 changes: 42 additions & 30 deletions src/commands.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
CollaboratorAuthor

@EhabYEhabYSep 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import*asvscodefrom"vscode";

constCONTEXT_DEFAULTS={
"coder.authenticated": false,
"coder.isOwner": false,
"coder.loaded": false,
"coder.workspace.updatable": false,
}asconst;

typeCoderContext=keyoftypeofCONTEXT_DEFAULTS;

exportclassContextManagerimplementsvscode.Disposable{
privatereadonlycontext=newMap<CoderContext,boolean>();

publicconstructor(){
(Object.keys(CONTEXT_DEFAULTS)asCoderContext[]).forEach((key)=>{
this.set(key,CONTEXT_DEFAULTS[key]);
});
}

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

publicget(key: CoderContext): boolean{
returnthis.context.get(key)??CONTEXT_DEFAULTS[key];
}

publicdispose(){
this.context.clear();
}
}
52 changes: 48 additions & 4 deletions src/core/secretsManager.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
import type{SecretStorage } from "vscode"
import type{SecretStorage, Disposable } from "vscode"

const SESSION_TOKEN_KEY = "sessionToken"

const LOGIN_STATE_KEY = "loginState"

export enum AuthAction{
LOGIN,
LOGOUT,
INVALID,
}

export class SecretsManager{
constructor(private readonly secrets: SecretStorage){}
Expand All@@ -8,9 +18,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 +29,45 @@ 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: "login" | "logout",
): 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(AuthAction.LOGIN);
} else if (state?.startsWith("logout")){
listener(AuthAction.LOGOUT);
} else{
// Secret was deleted or is invalid
listener(AuthAction.INVALID);
}
}
});
}
}
3 changes: 3 additions & 0 deletions src/error.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,6 +64,8 @@ export class CertificateError extends Error{
returnnewCertificateError(err.message,X509_ERR.UNTRUSTED_LEAF);
caseX509_ERR_CODE.SELF_SIGNED_CERT_IN_CHAIN:
returnnewCertificateError(err.message,X509_ERR.UNTRUSTED_CHAIN);
caseundefined:
break;
}
}
returnerr;
Expand DownExpand Up@@ -154,6 +156,7 @@ export class CertificateError extends Error{
);
switch(val){
caseCertificateError.ActionOK:
caseundefined:
return;
caseCertificateError.ActionAllowInsecure:
awaitthis.allowInsecure();
Expand Down
Loading