Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@
``!#$%&'*+-.^_`|~``, so whitespace (including tabs and carriage returns) and
`:` are not allowed. An invisible byte order mark at the start of the output,
which some Windows editors and PowerShell add, is ignored.
- Treat TLS client certificates as transport configuration only. An empty
session token follows the same authentication path as any other token, with
stored or CLI credentials and OAuth or token prompts available after a 401.
- An empty session token is no longer passed to `coder login`, which waited a
minute for a pasted token and then failed the connection. It now clears the
token the extension stored for the Coder CLI, leaving the CLI's own session
and the keyring alone. Logging out with an empty token no longer offers to
sign out the Coder CLI.

## [v1.17.2](https://github.com/coder/vscode-coder/releases/tag/v1.17.2) 2026-10-28

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,14 @@
"ignoreSync": true
},
"coder.tlsCertFile": {
"markdownDescription": "Path to file for TLS client cert. When specified, token authorization will be skipped. `http.proxySupport` must be set to `on` or `off`, otherwise VS Code will override the proxy agent set by the plugin.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`.",
"markdownDescription": "Path to file for TLS client cert. `http.proxySupport` must be set to `on` or `off`, otherwise VS Code will override the proxy agent set by the plugin.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`.",
"type": "string",
"default": "",
"scope": "application",
"ignoreSync": true
},
"coder.tlsKeyFile": {
"markdownDescription": "Path to file for TLS client key. When specified, token authorization will be skipped. `http.proxySupport` must be set to `on` or `off`, otherwise VS Code will override the proxy agent set by the plugin.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`.",
"markdownDescription": "Path to file for TLS client key. `http.proxySupport` must be set to `on` or `off`, otherwise VS Code will override the proxy agent set by the plugin.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`.",
"type": "string",
"default": "",
"scope": "application",
Expand Down
13 changes: 0 additions & 13 deletions src/api/httpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,6 @@ import { LocalProxySettings, type LocalProxySetting } from "../settings/proxy";

import { getProxyForUrl, joinNoProxy } from "./proxy";

/**
* Return whether the API will need a token for authorization.
* If mTLS is in use (as specified by the cert or key files being set) then
* token authorization is disabled. Otherwise, it is enabled.
*/
export function needToken(cfg: Pick<WorkspaceConfiguration, "get">): boolean {
const certFile = expandPath(
String(cfg.get("coder.tlsCertFile") ?? "").trim(),
);
const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim());
return !certFile && !keyFile;
}

/**
* Create a new HTTP agent based on the current VS Code settings.
* Configures proxy, TLS certificates, and security options.
Expand Down
43 changes: 41 additions & 2 deletions src/cli/cliCredentialManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const execFileAsync = promisify(execFile);
const EXEC_TIMEOUT_MS = 60_000;
const EXEC_LOG_INTERVAL_MS = 5_000;

/** Session files whose removal already warned, to avoid repeated warnings. */
const warnedSessionPaths = new Set<string>();

interface ResolvedCli {
binPath: string;
featureSet: CliFeatureSet;
Expand All @@ -59,14 +62,25 @@ export class CliCredentialManager {
this.credentialTelemetry = new CredentialTelemetry(telemetry);
}

/** Stores a token via `coder login` and returns the store used. Skipped until the CLI is downloaded; throws when the CLI fails. */
/**
* Stores a token via `coder login` and returns the store used. An empty
* token instead removes the extension directory's session file,
* best-effort. Skipped until the CLI is downloaded; throws when the CLI fails.
*/
public storeToken(
url: string,
token: string,
configs: Pick<WorkspaceConfiguration, "get">,
options?: { signal?: AbortSignal },
): Promise<CliAuth | undefined> {
return this.credentialTelemetry.traceStore(configs, async (span) => {
// `coder login` would wait for a pasted token. Drop only the
// extension's own stored token so the CLI sends an empty one.
if (!token) {
span.setProperty("outcome", "empty_token");
await this.removeSessionFile(url);
return;
}
const cli = await this.resolveCli(url, configs);
if (!cli) {
span.setProperty("outcome", "no_binary");
Expand Down Expand Up @@ -125,14 +139,18 @@ export class CliCredentialManager {

/**
* True when the CLI's own store holds `token`. Below CLI 2.32 the token
* cannot be read back, so the CLI's store counts as holding it. False without a working CLI.
* cannot be read back, so the CLI's store counts as holding it. False without a working CLI
* and for an empty token, which is never stored.
*/
public async holdsToken(
url: string,
token: string,
configs: Pick<WorkspaceConfiguration, "get">,
options?: { signal?: AbortSignal },
): Promise<boolean> {
if (!token) {
return false;
}
try {
const cli = await this.resolveCli(url, configs);
if (cli?.auth.store !== "cli") {
Expand Down Expand Up @@ -250,6 +268,27 @@ export class CliCredentialManager {
}
}

/** Removes the extension directory's session file. Silent when absent; warns once per path on failure. */
private async removeSessionFile(url: string): Promise<void> {
const sessionPath = this.pathResolver.getSessionTokenPath(toSafeHost(url));
try {
await fs.rm(sessionPath);
} catch (error) {
if (
(error as NodeJS.ErrnoException).code === "ENOENT" ||
warnedSessionPaths.has(sessionPath)
) {
return;
}
warnedSessionPaths.add(sessionPath);
this.logger.warn(
"Failed to remove the stored CLI token",
sessionPath,
error,
);
}
}

/** Removes the url and session files. Never throws. */
private async deleteCredentialFiles(url: string): Promise<boolean> {
const safeHostname = toSafeHost(url);
Expand Down
4 changes: 2 additions & 2 deletions src/cli/cliManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,8 +998,8 @@ export class CliManager {
* Stores credentials via `coder login`: in the OS keyring when the setting is
* enabled and the CLI supports it, otherwise file-based under --global-config.
*
* Both URL and token are required. Empty tokens are allowed (e.g. mTLS
* authentication) but the URL must be a non-empty string.
* Both URL and token are required. Empty tokens are allowed but the URL
* must be a non-empty string.
*
* @param options.silent Suppress the progress notification only; failures
* still surface via {@link handleStoreError} (logged + error toast).
Expand Down
5 changes: 1 addition & 4 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,10 +760,7 @@ export class Commands {
private async askSignOutCli(
auth: SessionAuth | undefined,
): Promise<boolean | undefined> {
if (
!auth?.token ||
!(await this.cliManager.holdsToken(auth.url, auth.token))
) {
if (!auth || !(await this.cliManager.holdsToken(auth.url, auth.token))) {
return false;
}
// The CLI cannot refresh an OAuth token and logout revokes it, so there is nothing to keep.
Expand Down
24 changes: 12 additions & 12 deletions src/instrumentation/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,12 @@ Emitted by `AuthTelemetry`; the credential events by `CredentialTelemetry`.

#### `auth.login`

| Attribute | Values |
| ------------ | -------------------------------------------------------------------------------------------- |
| `source` | `auto_login`, `command`, `switch_deployment`, `uri` |
| `method` | `mtls`, `provided_token`, `stored_token`, `cli_token`, `oauth`, `unknown` (starts `unknown`) |
| `reason` | `user_dismissed`, `no_url_provided` (aborted logins only) |
| `error.type` | `auth_failed`, `exception` |
| Attribute | Values |
| ------------ | ------------------------------------------------------------------------------------ |
| `source` | `auto_login`, `command`, `switch_deployment`, `uri` |
| `method` | `provided_token`, `stored_token`, `cli_token`, `oauth`, `unknown` (starts `unknown`) |
| `reason` | `user_dismissed`, `no_url_provided` (aborted logins only) |
| `error.type` | `auth_failed`, `exception` |

#### `auth.logout`

Expand Down Expand Up @@ -206,12 +206,12 @@ Secret-storage session read during remote setup. No custom attributes.

#### `auth.credential.store` / `auth.credential.clear`

| Attribute | Values |
| ----------------- | ------------------------------------------------------------------------ |
| `keyring_enabled` | `true`, `false` (from settings) |
| `store` | `cli` (the CLI's own store), `extension` (the extension's directory) |
| `outcome` | `stored`, `no_binary` (store); `logged_out`, `kept`, `no_binary` (clear) |
| `error.type` | `binary`, `cli` |
| Attribute | Values |
| ----------------- | --------------------------------------------------------------------------------------- |
| `keyring_enabled` | `true`, `false` (from settings) |
| `store` | `cli` (the CLI's own store), `extension` (the extension's directory) |
| `outcome` | `stored`, `no_binary`, `empty_token` (store); `logged_out`, `kept`, `no_binary` (clear) |
| `error.type` | `binary`, `cli` |

### Logs

Expand Down
49 changes: 10 additions & 39 deletions src/login/loginCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { getErrorMessage } from "coder/site/src/api/errors";
import * as vscode from "vscode";

import { CoderApi } from "../api/coderApi";
import { needToken } from "../api/httpAgent";
import { HttpStatusCode } from "../api/httpStatusCode";
import { isSameOrigin, openInBrowser } from "../common/url";
import { CertificateError } from "../error/certificateError";
Expand Down Expand Up @@ -39,7 +38,6 @@ import type {
} from "../storage/secretsManager";

export type LoginMethod =
| "mtls"
| "provided_token"
| "stored_token"
| "cli_token"
Expand Down Expand Up @@ -211,7 +209,6 @@ export class LoginCoordinator implements vscode.Disposable {
? previous.oauth
: undefined;

// Empty token is valid for mTLS
await this.secretsManager.setSessionAuth(safeHostname, {
url,
token: result.token,
Expand All @@ -224,15 +221,11 @@ export class LoginCoordinator implements vscode.Disposable {
void revokeOAuthTokens(replaced, registration, this.logger);
}

if (result.token) {
const configs = vscode.workspace.getConfiguration();
this.cliCredentialManager
.storeToken(url, result.token, configs)
.then((auth) => notifyKeyringSkipped(auth, configs))
.catch((error) =>
showStoreCredentialsError(error, configs, this.logger),
);
}
const configs = vscode.workspace.getConfiguration();
this.cliCredentialManager
.storeToken(url, result.token, configs)
.then((auth) => notifyKeyringSkipped(auth, configs))
.catch((error) => showStoreCredentialsError(error, configs, this.logger));
}

/**
Expand Down Expand Up @@ -261,7 +254,7 @@ export class LoginCoordinator implements vscode.Disposable {
disposable = this.secretsManager.onDidChangeSessionAuth(
safeHostname,
async (auth) => {
if (auth?.token) {
if (auth) {
const client = CoderApi.create(auth.url, auth.token, this.logger);
try {
const user = await client.getAuthenticatedUser();
Expand All @@ -287,7 +280,7 @@ export class LoginCoordinator implements vscode.Disposable {
}

/**
* Authenticate via OAuth, token, or mTLS, prompting as needed. On success,
* Authenticate via OAuth or a token, prompting as needed. On success,
* persists the session and revokes the OAuth session it replaces.
*/
private async attemptLogin(
Expand Down Expand Up @@ -320,9 +313,8 @@ export class LoginCoordinator implements vscode.Disposable {
}

/**
* Try each token source in turn. A source returns undefined on 401 to hand
* over to the next one; any other failure is terminal since retrying more
* tokens against an erroring server would just repeat the error.
* A 401 hands over to the next token source; any other failure is terminal
* since retrying more tokens against an erroring server would repeat it.
*/
private async runLoginAttempts(
client: CoderApi,
Expand All @@ -331,14 +323,6 @@ export class LoginCoordinator implements vscode.Disposable {
providedToken: string | undefined,
tokenSignInConfirmed: boolean,
): Promise<LoginResult> {
if (!needToken(vscode.workspace.getConfiguration())) {
this.logger.debug("Attempting mTLS authentication (no token required)");
return withLoginMethod(
"mtls",
await this.tryMtlsAuth(client, isAutoLogin),
);
}

const auth = await this.secretsManager.getSessionAuth(
deployment.safeHostname,
);
Expand Down Expand Up @@ -427,7 +411,7 @@ export class LoginCoordinator implements vscode.Disposable {
ctx: LoginAttemptContext,
): Promise<LoginResult | undefined> {
const { client, isAutoLogin, sameOriginAuth } = ctx;
if (!sameOriginAuth?.token) {
if (sameOriginAuth === undefined) {
return undefined;
}
this.logger.debug("Trying stored session token");
Expand Down Expand Up @@ -537,19 +521,6 @@ export class LoginCoordinator implements vscode.Disposable {
return action === "Sign In";
}

private async tryMtlsAuth(
client: CoderApi,
isAutoLogin: boolean,
): Promise<LoginAttemptResult> {
try {
const user = await client.getAuthenticatedUser();
return { success: true, token: "", user };
} catch (err) {
this.showAuthError(err, isAutoLogin);
return { success: false, reason: "auth_failed" };
}
}

/**
* Returns 'unauthorized' on 401 to signal trying next token source.
*/
Expand Down
6 changes: 1 addition & 5 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
} from "../api/agentMetadata";
import { AuthInterceptor } from "../api/authInterceptor";
import { CoderApi } from "../api/coderApi";
import { needToken } from "../api/httpAgent";
import { HttpStatusCode } from "../api/httpStatusCode";
import { extractAgents } from "../api/models";
import { version as cliVersion } from "../cli/cliExec";
Expand Down Expand Up @@ -246,10 +245,7 @@ export class Remote {
disposables: [],
};

if (
!context.baseUrl ||
(!context.token && needToken(vscode.workspace.getConfiguration()))
) {
if (!context.baseUrl || context.token === undefined) {
return this.ensureLoggedInAndRetry(
context,
"You are not logged in...",
Expand Down
13 changes: 6 additions & 7 deletions test/unit/api/authInterceptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ function createTestContext() {
});
};

/** Sets up mTLS auth (no token) */
const setupMTLSAuth = async () => {
/** Sets up an empty session token */
const setupEmptyToken = async () => {
await secretsManager.setSessionAuth(TEST_HOSTNAME, {
url: TEST_URL,
token: "",
Expand Down Expand Up @@ -177,7 +177,7 @@ function createTestContext() {
MockOAuthSessionManager,
setupOAuthTokens,
setupSessionToken,
setupMTLSAuth,
setupEmptyToken,
createInterceptor,
};
}
Expand Down Expand Up @@ -350,12 +350,11 @@ describe("AuthInterceptor", () => {
expect(request).not.toHaveBeenCalled();
});

it("retries request with mTLS (no token)", async () => {
const { axiosInstance, setupMTLSAuth, createInterceptor } =
it("retries request with an empty token", async () => {
const { axiosInstance, setupEmptyToken, createInterceptor } =
createTestContext();

// Setup mTLS auth - callback will "re-authenticate" but there's no token
await setupMTLSAuth();
await setupEmptyToken();

const retryResponse = { data: "success", status: 200 };
vi.spyOn(axiosInstance, "request").mockResolvedValue(retryResponse);
Expand Down
Loading
Loading