Skip to content

Commit fdabe5e

Browse files
authored
feat: default coder.useKeyring to true and harden shared credential handling (#1107)
Session tokens now also go to the OS keyring through the Coder CLI on macOS and Windows, so the terminal `coder` shares the extension's session. The extension's own secret storage is unchanged. Sharing the CLI's store made sign-out, missing-binary, and redirect handling explicit, and the CLI store model is now a single `CliAuth` type that every CLI call derives its flags from. - Default `coder.useKeyring` to `true`; Linux and CLIs below 2.29 keep using a file. Pass `--use-keyring` explicitly and honor `CODER_CONFIG_DIR`. - Ask at logout whether to sign the CLI out too when it holds the same token; leave a CLI signed in with another token alone, and always log the extension's own store out to revoke the token. - Ask before adopting the CLI's session for a different user. - Skip CLI credential steps with one info line when the binary is not downloaded yet; connect stores the token. - Pass `--allow-redirects` to CLI 2.38+ so redirected deployment URLs keep working; require CLI 2.32 for CLI token reads. - Cache `coder version` per binary and skip CLI reads when settings rule out the CLI's own store; run the logout check under cancellable progress. - Show the CLI's error with Open Settings when storing fails, and Show Output when logout cannot remove every credential. - Telemetry: `cli_token` replaces `keyring_token`; credential spans carry `store` and `outcome`; `auth.logout` gains `user_dismissed`. Closes #1106
1 parent 41fa903 commit fdabe5e

28 files changed

Lines changed: 1588 additions & 1387 deletions

‎CHANGELOG.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,34 @@
55
from published versions since it shows up in the VS Code extension changelog
66
tab and is confusing to users. Add it back between releases if needed. -->
77

8+
## Unreleased
9+
10+
### Changed
11+
12+
- Store session tokens in the OS keyring by default on macOS and Windows, in
13+
addition to the extension's own storage. The `coder` CLI reads the same
14+
entry, so once the extension has downloaded the CLI, signing in here also
15+
signs in the CLI. Requires Coder CLI 2.29.0 or later; older CLIs and Linux
16+
keep using a file. To opt out, set `coder.useKeyring` to `false`.
17+
- Ask at logout whether to sign the `coder` CLI out too when it shares the
18+
session, since anything else using that session is signed out with it.
19+
- Pass `coder.useKeyring` to the CLI as `--use-keyring`, so the setting wins
20+
over the `CODER_USE_KEYRING` environment variable.
21+
- Honor `CODER_CONFIG_DIR` like `--global-config` in `coder.globalFlags`.
22+
- Read the `coder` CLI's session only on Coder CLI 2.32.0 or later, up from
23+
2.31.0, where the CLI checks the stored URL against the one you connect to.
24+
- Ask before signing in with the `coder` CLI's session when it belongs to a
25+
different user than your previous session.
26+
- Show an error with **Open Settings** when the CLI cannot store the token at
27+
login, and a **Show Output** button when logout cannot remove every
28+
credential.
29+
30+
### Fixed
31+
32+
- Pass `--allow-redirects` to Coder CLI 2.38.0 or later. The extension already
33+
follows a redirected deployment URL, and without the flag that CLI fails
34+
`coder login`, `coder logout`, and `coder ssh` for it.
35+
836
## [v1.16.2](https://github.com/coder/vscode-coder/releases/tag/v1.16.2) 2026-08-25
937

1038
### Fixed

‎package.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@
195195
"ignoreSync": true
196196
},
197197
"coder.globalFlags": {
198-
"markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item, in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`. For `--flag=value` items the expansion applies to the value half, so `--cfg=~/coder` works.\n\nSet `--global-config` here to point the CLI at a shared config directory (e.g. `--global-config=~/.config/coderv2` to share login/auth with the Coder CLI); requires a deployment on 2.31.0+ and is ignored when `#coder.useKeyring#` is active. The `--use-keyring` flag is ignored; use `#coder.useKeyring#` instead.\n\nFor `--header-command`, precedence is: `#coder.headerCommand#` setting, then `CODER_HEADER_COMMAND` environment variable, then the value specified here.",
198+
"markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item, in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`. For `--flag=value` items the expansion applies to the value half, so `--cfg=~/coder` works.\n\nTo share a config directory with the `coder` CLI, add `--global-config` here (for example `--global-config=~/.config/coderv2`) or set `CODER_CONFIG_DIR`. Requires Coder CLI 2.32.0 or later. A `--use-keyring` item is ignored; use `#coder.useKeyring#` instead.\n\nFor `--header-command`, precedence is: `#coder.headerCommand#` setting, then `CODER_HEADER_COMMAND` environment variable, then the value specified here.",
199199
"type": "array",
200200
"items": {
201201
"type": "string"
@@ -204,9 +204,9 @@
204204
"ignoreSync": true
205205
},
206206
"coder.useKeyring": {
207-
"markdownDescription": "Store session tokens in the OS keyring (macOS Keychain, Windows Credential Manager) instead of plaintext files. Requires CLI >= 2.29.0 (>= 2.31.0 to sync login from CLI to VS Code). This will attempt to sync between the CLI and VS Code since they share the same keyring entry. It will log you out of the CLI if you log out of the IDE, and vice versa. Has no effect on Linux.",
207+
"markdownDescription": "Store session tokens in the OS keyring (macOS Keychain, Windows Credential Manager) instead of a file. Requires Coder CLI 2.29.0 or later; 2.32.0 or later to sign in with the CLI's existing session. Has no effect on Linux.\n\nThe keyring entry is shared with the `coder` CLI: signing in here also signs in the CLI, and signing out asks whether to sign out the CLI too.",
208208
"type": "boolean",
209-
"default": false,
209+
"default": true,
210210
"scope": "application"
211211
},
212212
"coder.networkThreshold.latencyMs": {

‎src/commands.ts‎

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import {
4343
RECOMMENDED_SSH_SETTINGS,
4444
applySettingOverrides,
4545
} from "./remote/sshOverrides";
46-
import { resolveCliAuth } from "./settings/cli";
46+
import { isKeyringEnabled, resolveCliAuth } from "./settings/cli";
4747
import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs";
4848
import {
4949
getRemoteServerDataPath,
@@ -82,7 +82,7 @@ import type { CliManager } from "./core/cliManager";
8282
import type { ServiceContainer } from "./core/container";
8383
import type { MementoManager } from "./core/mementoManager";
8484
import type { PathResolver } from "./core/pathResolver";
85-
import type { SecretsManager } from "./core/secretsManager";
85+
import type { SecretsManager, SessionAuth } from "./core/secretsManager";
8686
import type { DeploymentManager } from "./deployment/deploymentManager";
8787
import type { Logger } from "./logging/logger";
8888
import type { LoginCoordinator, LoginMethod } from "./login/loginCoordinator";
@@ -698,24 +698,41 @@ export class Commands {
698698
}
699699

700700
private async performLogout(): Promise<AuthLogoutOutcome> {
701-
if (!this.deploymentManager.isAuthenticated()) {
701+
const deployment = this.deploymentManager.getCurrentDeployment();
702+
if (!this.deploymentManager.isAuthenticated() || !deployment) {
702703
return { success: false, reason: "not_authenticated" };
703704
}
704705

705-
this.logger.debug("Logging out");
706+
const auth = await this.secretsManager.getSessionAuth(
707+
deployment.safeHostname,
708+
);
709+
const signOutCli = await this.askSignOutCli(auth);
710+
if (signOutCli === undefined) {
711+
return { success: false, reason: "user_dismissed" };
712+
}
713+
// Another window may have switched deployments while the prompt was open.
714+
if (this.deploymentManager.getCurrentDeployment()?.url !== deployment.url) {
715+
return { success: false, reason: "not_authenticated" };
716+
}
706717

707-
const deployment = this.deploymentManager.getCurrentDeployment();
718+
this.logger.debug("Logging out");
708719
await this.deploymentManager.clearDeployment("logout");
709-
710-
if (deployment) {
711-
const cleared = await this.cliManager.clearCredentials(deployment.url);
712-
await this.secretsManager.clearAllAuthData(deployment.safeHostname);
713-
if (!cleared) {
714-
vscode.window.showWarningMessage(
720+
const cleared = await this.cliManager.clearCredentials(deployment.url, {
721+
signOutCli,
722+
});
723+
await this.secretsManager.clearAllAuthData(deployment.safeHostname);
724+
if (!cleared) {
725+
vscode.window
726+
.showWarningMessage(
715727
'You\'ve been logged out of Coder, but some credentials could not be removed. Log out again to retry, or run "coder logout" in a terminal.',
716-
);
717-
return { success: false, reason: "cleanup_incomplete" };
718-
}
728+
"Show Output",
729+
)
730+
.then((action) => {
731+
if (action === "Show Output") {
732+
this.logger.show();
733+
}
734+
});
735+
return { success: false, reason: "cleanup_incomplete" };
719736
}
720737

721738
this.showLogoutMessage();
@@ -735,6 +752,36 @@ export class Commands {
735752
});
736753
}
737754

755+
/** Whether to sign the CLI out too. Asks when it holds this session's token; undefined when dismissed. */
756+
private async askSignOutCli(
757+
auth: SessionAuth | undefined,
758+
): Promise<boolean | undefined> {
759+
if (
760+
!auth?.token ||
761+
!(await this.cliManager.holdsToken(auth.url, auth.token))
762+
) {
763+
return false;
764+
}
765+
// The CLI cannot refresh an OAuth token and logout revokes it, so there is nothing to keep.
766+
if (auth.oauth) {
767+
return true;
768+
}
769+
const action = await vscodeProposed.window.showWarningMessage(
770+
"Sign out of the Coder CLI too?",
771+
{
772+
useCustom: true,
773+
modal: true,
774+
detail: `${auth.url}\n\nThe Coder CLI is signed in with this session. Signing it out also signs out other tools that rely on it.`,
775+
},
776+
"Sign Out",
777+
"Keep Signed In",
778+
);
779+
if (action === undefined) {
780+
return undefined;
781+
}
782+
return action === "Sign Out";
783+
}
784+
738785
/**
739786
* Switch to a different deployment without clearing credentials.
740787
* If login fails or user cancels, stays on current deployment.
@@ -790,7 +837,11 @@ export class Commands {
790837
const selectedHostname = selected.hostnames[0];
791838
const auth = await this.secretsManager.getSessionAuth(selectedHostname);
792839
if (auth?.url) {
793-
await this.cliManager.clearCredentials(auth.url);
840+
const signOutCli = await this.askSignOutCli(auth);
841+
if (signOutCli === undefined) {
842+
return;
843+
}
844+
await this.cliManager.clearCredentials(auth.url, { signOutCli });
794845
}
795846
await this.secretsManager.clearAllAuthData(selectedHostname);
796847
this.logger.info("Removed credentials for", selectedHostname);
@@ -803,20 +854,24 @@ export class Commands {
803854
{
804855
useCustom: true,
805856
modal: true,
806-
detail: `This will remove credentials for: ${selected.hostnames.join(", ")}\n\nYou'll need to log in again to access them.`,
857+
detail: `This will remove credentials for: ${selected.hostnames.join(", ")}\n\nYou'll need to log in again to access them.${isKeyringEnabled(vscode.workspace.getConfiguration()) ? " This also signs the Coder CLI out where it shares a session." : ""}`,
807858
},
808859
"Remove All",
809860
);
810861
if (confirm === "Remove All") {
811-
await Promise.all(
812-
selected.hostnames.map(async (h) => {
813-
const auth = await this.secretsManager.getSessionAuth(h);
814-
if (auth?.url) {
815-
await this.cliManager.clearCredentials(auth.url);
816-
}
817-
await this.secretsManager.clearAllAuthData(h);
818-
}),
819-
);
862+
// One at a time: `coder logout` rewrites the whole keyring entry.
863+
for (const h of selected.hostnames) {
864+
const auth = await this.secretsManager.getSessionAuth(h);
865+
if (auth?.url) {
866+
await this.cliManager.clearCredentials(auth.url, {
867+
signOutCli: await this.cliManager.holdsToken(
868+
auth.url,
869+
auth.token,
870+
),
871+
});
872+
}
873+
await this.secretsManager.clearAllAuthData(h);
874+
}
820875
this.logger.info(
821876
"Removed credentials for all deployments:",
822877
selected.hostnames.join(", "),
@@ -1410,12 +1465,9 @@ export class Commands {
14101465
throw new Error("You are not logged in");
14111466
}
14121467
const safeHost = toSafeHost(baseUrl);
1413-
let binary: string;
1414-
try {
1415-
binary = await this.cliManager.locateBinary(baseUrl);
1416-
} catch {
1417-
binary = await this.cliManager.fetchBinary(client);
1418-
}
1468+
const binary =
1469+
(await this.cliManager.locateBinary(baseUrl)) ??
1470+
(await this.cliManager.fetchBinary(client));
14191471
const version = semver.parse(await cliExec.version(binary));
14201472
const featureSet = featureSetForVersion(version);
14211473
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);

0 commit comments

Comments
 (0)