Skip to content

Commit 7b59373

Browse files
authored
feat: add keyring support via CLI for secure session token storage (#816)
Store session tokens in the OS keyring (macOS Keychain / Windows Credential Manager) by delegating to the Coder CLI instead of native binaries. Falls back to file-based storage on Linux or older CLIs. - Add CliCredentialManager that shells out to `coder` for store/read/delete token operations - Gate keyring usage on CLI version (>= 2.29.0), platform, and coder.useKeyring setting - Derive safeHostname internally from url, simplifying the API surface - Filter --use-keyring and --global-config from user global flags - Add cancellable VS Code progress notifications for credential ops - Add locateBinary for lightweight stat-only CLI lookups Closes #825
1 parent 3179bc8 commit 7b59373

22 files changed

+1560
-272
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
- Fixed CLI binary downloads failing when servers or proxies compress responses unexpectedly.
2020
- Clarified CLI download progress notification wording.
2121

22+
### Added
23+
24+
- Session tokens are now stored in the OS keyring (Keychain on macOS, Credential Manager on
25+
Windows) instead of plaintext files, when using CLI >= 2.29.0. Falls back to file storage on
26+
Linux, older CLIs, or if the keyring write fails. Controlled via the `coder.useKeyring` setting.
27+
2228
## [v1.13.0](https://github.com/coder/vscode-coder/releases/tag/v1.13.0) 2026-03-03
2329

2430
### Added

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export default defineConfig(
154154

155155
// Build config - ESM with Node globals
156156
{
157-
files: ["esbuild.mjs"],
157+
files: ["esbuild.mjs", "scripts/*.mjs"],
158158
languageOptions: {
159159
globals: {
160160
...globals.node,

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,17 @@
150150
]
151151
},
152152
"coder.globalFlags": {
153-
"markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item; values are passed verbatim and in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nNote that for `--header-command`, precedence is: `#coder.headerCommand#` setting, then `CODER_HEADER_COMMAND` environment variable, then the value specified here. The `--global-config` flag is explicitly ignored.",
153+
"markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item; values are passed verbatim and in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nNote that for `--header-command`, precedence is: `#coder.headerCommand#` setting, then `CODER_HEADER_COMMAND` environment variable, then the value specified here. The `--global-config` and `--use-keyring` flags are silently ignored as the extension manages them via `#coder.useKeyring#`.",
154154
"type": "array",
155155
"items": {
156156
"type": "string"
157157
}
158158
},
159+
"coder.useKeyring": {
160+
"markdownDescription": "Store session tokens in the OS keyring (macOS Keychain, Windows Credential Manager) instead of plaintext files. Requires CLI >= 2.29.0. Has no effect on Linux.",
161+
"type": "boolean",
162+
"default": true
163+
},
159164
"coder.httpClientLogLevel": {
160165
"markdownDescription": "Controls the verbosity of HTTP client logging. This affects what details are logged for each HTTP request and response.",
161166
"type": "string",

src/api/workspace.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { spawn } from "node:child_process";
88
import * as vscode from "vscode";
99

10-
import { getGlobalFlags } from "../cliConfig";
10+
import { type CliAuth, getGlobalFlags } from "../cliConfig";
1111
import { type FeatureSet } from "../featureSet";
1212
import { escapeCommandArg } from "../util";
1313
import { type UnidirectionalStream } from "../websocket/eventStreamConnection";
@@ -50,7 +50,7 @@ export class LazyStream<T> {
5050
*/
5151
export async function startWorkspaceIfStoppedOrFailed(
5252
restClient: Api,
53-
globalConfigDir: string,
53+
auth: CliAuth,
5454
binPath: string,
5555
workspace: Workspace,
5656
writeEmitter: vscode.EventEmitter<string>,
@@ -65,7 +65,7 @@ export async function startWorkspaceIfStoppedOrFailed(
6565

6666
return new Promise((resolve, reject) => {
6767
const startArgs = [
68-
...getGlobalFlags(vscode.workspace.getConfiguration(), globalConfigDir),
68+
...getGlobalFlags(vscode.workspace.getConfiguration(), auth),
6969
"start",
7070
"--yes",
7171
createWorkspaceIdentifier(workspace),

src/cliConfig.ts

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import { type WorkspaceConfiguration } from "vscode";
2-
1+
import { isKeyringSupported } from "./core/cliCredentialManager";
32
import { getHeaderArgs } from "./headers";
43
import { escapeCommandArg } from "./util";
54

5+
import type { WorkspaceConfiguration } from "vscode";
6+
7+
import type { FeatureSet } from "./featureSet";
8+
9+
export type CliAuth =
10+
| { mode: "global-config"; configDir: string }
11+
| { mode: "url"; url: string };
12+
613
/**
714
* Returns the raw global flags from user configuration.
815
*/
@@ -14,19 +21,70 @@ export function getGlobalFlagsRaw(
1421

1522
/**
1623
* Returns global configuration flags for Coder CLI commands.
17-
* Always includes the `--global-config` argument with the specified config directory.
24+
* Includes either `--global-config` or `--url` depending on the auth mode.
1825
*/
1926
export function getGlobalFlags(
2027
configs: Pick<WorkspaceConfiguration, "get">,
21-
configDir: string,
28+
auth: CliAuth,
2229
): string[] {
23-
// Last takes precedence/overrides previous ones
24-
return [
25-
...getGlobalFlagsRaw(configs),
26-
"--global-config",
27-
escapeCommandArg(configDir),
28-
...getHeaderArgs(configs),
29-
];
30+
const authFlags =
31+
auth.mode === "url"
32+
? ["--url", escapeCommandArg(auth.url)]
33+
: ["--global-config", escapeCommandArg(auth.configDir)];
34+
35+
const raw = getGlobalFlagsRaw(configs);
36+
const filtered = stripManagedFlags(raw);
37+
38+
return [...filtered, ...authFlags, ...getHeaderArgs(configs)];
39+
}
40+
41+
function stripManagedFlags(rawFlags: string[]): string[] {
42+
const filtered: string[] = [];
43+
for (let i = 0; i < rawFlags.length; i++) {
44+
if (isFlag(rawFlags[i], "--use-keyring")) {
45+
continue;
46+
}
47+
if (isFlag(rawFlags[i], "--global-config")) {
48+
// Skip the next item too when the value is a separate entry.
49+
if (rawFlags[i] === "--global-config") {
50+
i++;
51+
}
52+
continue;
53+
}
54+
filtered.push(rawFlags[i]);
55+
}
56+
return filtered;
57+
}
58+
59+
function isFlag(item: string, name: string): boolean {
60+
return (
61+
item === name || item.startsWith(`${name}=`) || item.startsWith(`${name} `)
62+
);
63+
}
64+
65+
/**
66+
* Returns true when the user has keyring enabled and the platform supports it.
67+
*/
68+
export function isKeyringEnabled(
69+
configs: Pick<WorkspaceConfiguration, "get">,
70+
): boolean {
71+
return isKeyringSupported() && configs.get<boolean>("coder.useKeyring", true);
72+
}
73+
74+
/**
75+
* Resolves how the CLI should authenticate: via the keyring (`--url`) or via
76+
* the global config directory (`--global-config`).
77+
*/
78+
export function resolveCliAuth(
79+
configs: Pick<WorkspaceConfiguration, "get">,
80+
featureSet: FeatureSet,
81+
deploymentUrl: string,
82+
configDir: string,
83+
): CliAuth {
84+
if (isKeyringEnabled(configs) && featureSet.keyringAuth) {
85+
return { mode: "url", url: deploymentUrl };
86+
}
87+
return { mode: "global-config", configDir };
3088
}
3189

3290
/**

src/commands.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,22 @@ import {
44
} from "coder/site/src/api/typesGenerated";
55
import * as fs from "node:fs/promises";
66
import * as path from "node:path";
7+
import * as semver from "semver";
78
import * as vscode from "vscode";
89

910
import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper";
1011
import { type CoderApi } from "./api/coderApi";
11-
import { getGlobalFlags } from "./cliConfig";
12+
import { getGlobalFlags, resolveCliAuth } from "./cliConfig";
1213
import { type CliManager } from "./core/cliManager";
14+
import * as cliUtils from "./core/cliUtils";
1315
import { type ServiceContainer } from "./core/container";
1416
import { type MementoManager } from "./core/mementoManager";
1517
import { type PathResolver } from "./core/pathResolver";
1618
import { type SecretsManager } from "./core/secretsManager";
1719
import { type DeploymentManager } from "./deployment/deploymentManager";
1820
import { CertificateError } from "./error/certificateError";
1921
import { toError } from "./error/errorUtils";
22+
import { featureSetForVersion } from "./featureSet";
2023
import { type Logger } from "./logging/logger";
2124
import { type LoginCoordinator } from "./login/loginCoordinator";
2225
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
@@ -210,13 +213,13 @@ export class Commands {
210213

211214
this.logger.debug("Logging out");
212215

213-
const safeHostname =
214-
this.deploymentManager.getCurrentDeployment()?.safeHostname;
216+
const deployment = this.deploymentManager.getCurrentDeployment();
215217

216218
await this.deploymentManager.clearDeployment();
217219

218-
if (safeHostname) {
219-
await this.secretsManager.clearAllAuthData(safeHostname);
220+
if (deployment) {
221+
await this.cliManager.clearCredentials(deployment.url);
222+
await this.secretsManager.clearAllAuthData(deployment.safeHostname);
220223
}
221224

222225
vscode.window
@@ -283,6 +286,10 @@ export class Commands {
283286

284287
if (selected.hostnames.length === 1) {
285288
const selectedHostname = selected.hostnames[0];
289+
const auth = await this.secretsManager.getSessionAuth(selectedHostname);
290+
if (auth?.url) {
291+
await this.cliManager.clearCredentials(auth.url);
292+
}
286293
await this.secretsManager.clearAllAuthData(selectedHostname);
287294
this.logger.info("Removed credentials for", selectedHostname);
288295
vscode.window.showInformationMessage(
@@ -300,9 +307,13 @@ export class Commands {
300307
);
301308
if (confirm === "Remove All") {
302309
await Promise.all(
303-
selected.hostnames.map((h) =>
304-
this.secretsManager.clearAllAuthData(h),
305-
),
310+
selected.hostnames.map(async (h) => {
311+
const auth = await this.secretsManager.getSessionAuth(h);
312+
if (auth?.url) {
313+
await this.cliManager.clearCredentials(auth.url);
314+
}
315+
await this.secretsManager.clearAllAuthData(h);
316+
}),
306317
);
307318
this.logger.info(
308319
"Removed credentials for all deployments:",
@@ -449,14 +460,14 @@ export class Commands {
449460
const safeHost = toSafeHost(baseUrl);
450461
const binary = await this.cliManager.fetchBinary(
451462
this.extensionClient,
452-
safeHost,
453463
);
454464

465+
const version = semver.parse(await cliUtils.version(binary));
466+
const featureSet = featureSetForVersion(version);
455467
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
456-
const globalFlags = getGlobalFlags(
457-
vscode.workspace.getConfiguration(),
458-
configDir,
459-
);
468+
const configs = vscode.workspace.getConfiguration();
469+
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
470+
const globalFlags = getGlobalFlags(configs, auth);
460471
terminal.sendText(
461472
`${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`,
462473
);

0 commit comments

Comments
 (0)