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
20 changes: 17 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,23 @@

### Fixed

- OAuth sign-in now works on deployments that enforce token scopes. Users signed
in with OAuth are asked to sign in again once.
- Revoke OAuth tokens on logout even when their scopes are outdated.
- OAuth sign-in now works on deployments that enforce token scopes. Sessions
missing required scopes stop refreshing and require sign-in when they expire.
- Revoke OAuth tokens on logout and after successful session replacement, even
when their scopes are outdated. Failed or canceled sign-ins do not revoke
the stored session.
- Preserve OAuth refresh credentials when reusing a stored token, and request
the permissions needed to refresh workspace external-auth links.
- If a deployment disables dynamic client registration (the default from Coder
2.38), OAuth sign-in now names the setting and offers session-token sign-in.
OAuth becomes the default sign-in method in a future release, so ask your
Coder administrator to enable dynamic client registration. For instructions,
see [OAuth2 provider](https://coder.com/docs/admin/integrations/oauth2-provider).
- Keep session tokens, OAuth tokens, and client secrets from failed requests out
of the buffered logs that the Coder output channel replays.
- CLI commands and SSH connections that the extension starts ignore
`CODER_SESSION_TOKEN` from the environment, so they no longer fail or run as a
different user inside a Coder workspace.

## [v1.16.4](https://github.com/coder/vscode-coder/releases/tag/v1.16.4) 2026-09-23

Expand Down
2 changes: 1 addition & 1 deletion src/api/authInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export class AuthInterceptor implements vscode.Disposable {

// 1) OAuth refresh path.
const isOAuth =
await this.oauthSessionManager.isLoggedInWithOAuth(hostname);
await this.oauthSessionManager.canRefreshOAuthSession(hostname);
recorder.setRefreshAttempted(isOAuth);
if (isOAuth) {
const newToken = await this.tryOAuthRefresh();
Expand Down
25 changes: 21 additions & 4 deletions src/cli/cliCredentialManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { recordError } from "../instrumentation/outcomes";
import {
type CliAuth,
EMPTY_TOKEN_FLAG,
getGlobalFlags,
mayUseCliStore,
resolveCliAuth,
Expand Down Expand Up @@ -75,10 +76,16 @@ export class CliCredentialManager {
return;
}
span.setProperty("store", cli.auth.store);
await this.exec(cli, ["login", "--use-token-as-session", url], {
env: { ...process.env, CODER_SESSION_TOKEN: token },
signal: options?.signal,
});
// `login` reads the token from the environment, so drop the empty flag.
const flags = cli.flags.filter((flag) => flag !== EMPTY_TOKEN_FLAG);
await this.exec(
{ ...cli, flags },
["login", "--use-token-as-session", url],
{
env: envWithSessionToken(token),
signal: options?.signal,
},
);
span.setProperty("outcome", "stored");
this.logger.info("Stored token via CLI for", url);
});
Expand Down Expand Up @@ -268,3 +275,13 @@ export class CliCredentialManager {
return results.every(Boolean);
}
}

/** `process.env` with `token` as its only `CODER_SESSION_TOKEN`, in any case. */
function envWithSessionToken(token: string): NodeJS.ProcessEnv {
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) => key.toUpperCase() !== "CODER_SESSION_TOKEN",
),
);
return { ...env, CODER_SESSION_TOKEN: token };
}
18 changes: 15 additions & 3 deletions src/logging/formatters.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { type AxiosError, type AxiosRequestConfig, isAxiosError } from "axios";
import { Buffer } from "node:buffer";
import util from "node:util";
import prettyBytes from "pretty-bytes";

import { lowercase } from "../common/strings";

import type { AxiosRequestConfig } from "axios";

const SENSITIVE_HEADERS: ReadonlySet<Lowercase<string>> = new Set([
"authorization",
"coder-session-token",
Expand Down Expand Up @@ -200,9 +199,22 @@ export function sizeOf(data: unknown): number | undefined {
return undefined;
}

/** Drops the error's config and socket, which hold the request's credentials. */
function summarizeAxiosError(error: AxiosError) {
return {
name: error.name,
message: error.message,
code: error.code,
method: formatMethod(error.config?.method),
url: formatUri(error.config),
status: error.response?.status,
data: redactBodyFields(error.response?.data),
};
}

export function safeStringify(data: unknown): string | null {
try {
return util.inspect(data, {
return util.inspect(isAxiosError(data) ? summarizeAxiosError(data) : data, {
showHidden: false,
// Bounded so a single log line cannot balloon in size.
depth: 8,
Expand Down
155 changes: 98 additions & 57 deletions src/login/loginCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,22 @@ import { needToken } from "../api/httpAgent";
import { HttpStatusCode } from "../api/httpStatusCode";
import { isSameOrigin, openInBrowser } from "../common/url";
import { CertificateError } from "../error/certificateError";
import { OAuthAuthorizer } from "../oauth/authorizer";
import {
ClientRegistrationDisabledError,
OAuthAuthorizer,
} from "../oauth/authorizer";
import { revokeOAuthTokens } from "../oauth/revocation";
import { buildOAuthTokenData } from "../oauth/tokens";
import { withCancellableProgress } from "../ui/progress";
import { maybeAskAuthMethod, maybeAskUrl } from "../ui/prompts";
import { vscodeProposed } from "../vscode/proposed";

import { showStoreCredentialsError } from "./credentials";

import type { User } from "coder/site/src/api/typesGenerated";
import type {
User,
OAuth2ClientRegistrationResponse,
} from "coder/site/src/api/typesGenerated";

import type { CliCredentialManager } from "../cli/cliCredentialManager";
import type { Deployment } from "../deployment/types";
Expand Down Expand Up @@ -109,18 +116,14 @@ export class LoginCoordinator implements vscode.Disposable {
options: LoginOptions & { url: string },
): Promise<LoginResult> {
const { safeHostname, url } = options;
return this.executeWithGuard(async () => {
const result = await this.attemptLogin(
return this.executeWithGuard(() =>
this.attemptLogin(
{ safeHostname, url },
options.autoLogin ?? false,
options.token,
options.tokenSignInConfirmed ?? false,
);

await this.persistSessionAuth(result, safeHostname, url);

return result;
});
),
);
}

/**
Expand Down Expand Up @@ -170,15 +173,11 @@ export class LoginCoordinator implements vscode.Disposable {
return { success: false, reason: "no_url_provided" };
}

const result = await this.attemptLogin(
return this.attemptLogin(
{ url: newUrl, safeHostname },
false,
options.token,
);

await this.persistSessionAuth(result, safeHostname, newUrl);

return result;
}
// User cancelled
return { success: false, reason: "user_dismissed" };
Expand All @@ -198,28 +197,42 @@ export class LoginCoordinator implements vscode.Disposable {
}

private async persistSessionAuth(
result: LoginResult,
safeHostname: string,
url: string,
result: Extract<LoginResult, { success: true }>,
deployment: Deployment,
registration: OAuth2ClientRegistrationResponse | undefined,
): Promise<void> {
const { safeHostname, url } = deployment;
const previous = await this.secretsManager.getSessionAuth(safeHostname);
const replaced = previous?.token === result.token ? undefined : previous;
// A refresh replaced the stored session during this login; keep it.
if (result.method === "stored_token" && replaced) {
return;
}
const reusedOAuth =
!replaced && previous && isSameOrigin(previous.url, url)
? previous.oauth
: undefined;

// Empty token is valid for mTLS
if (result.success) {
await this.secretsManager.setSessionAuth(safeHostname, {
url,
token: result.token,
username: result.user.username,
oauth: result.oauth, // undefined for non-OAuth logins
});
await this.mementoManager.addToUrlHistory(url);

if (result.token) {
const configs = vscode.workspace.getConfiguration();
this.cliCredentialManager
.storeToken(url, result.token, configs)
.catch((error) =>
showStoreCredentialsError(error, configs, this.logger),
);
}
await this.secretsManager.setSessionAuth(safeHostname, {
url,
token: result.token,
username: result.user.username,
oauth: result.oauth ?? reusedOAuth,
});
await this.mementoManager.addToUrlHistory(url);

if (replaced?.oauth) {
void revokeOAuthTokens(replaced, registration, this.logger);
}

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

Expand Down Expand Up @@ -286,15 +299,24 @@ export class LoginCoordinator implements vscode.Disposable {
providedToken?: string,
tokenSignInConfirmed = false,
): Promise<LoginResult> {
// Revokes sessions that don't record their client. Read it before login can
// re-register the client.
const registration = await this.secretsManager.getOAuthClientRegistration(
deployment.safeHostname,
);
const client = CoderApi.create(deployment.url, "", this.logger);
try {
return await this.runLoginAttempts(
const result = await this.runLoginAttempts(
client,
deployment,
isAutoLogin,
providedToken,
tokenSignInConfirmed,
);
if (result.success) {
await this.persistSessionAuth(result, deployment, registration);
}
return result;
} finally {
client.dispose();
}
Expand Down Expand Up @@ -477,11 +499,16 @@ export class LoginCoordinator implements vscode.Disposable {
private async askAuthMethod(ctx: LoginAttemptContext): Promise<LoginResult> {
const authMethod = await maybeAskAuthMethod(ctx.client);
switch (authMethod) {
case "oauth":
case "oauth": {
const result = await this.loginWithOAuth(ctx.deployment);
if (result !== "use_token") {
return withLoginMethod("oauth", result);
}
return withLoginMethod(
"oauth",
await this.loginWithOAuth(ctx.deployment),
"cli_token",
await this.loginWithToken(ctx.client),
);
}
case "legacy":
return withLoginMethod(
"cli_token",
Expand Down Expand Up @@ -636,36 +663,50 @@ export class LoginCoordinator implements vscode.Disposable {
return { success: false, reason: "user_dismissed" };
}

/**
* OAuth authentication flow.
*/
/** OAuth flow; "use_token" means the user chose session-token sign-in instead. */
private async loginWithOAuth(
deployment: Deployment,
): Promise<LoginAttemptResult> {
): Promise<LoginAttemptResult | "use_token"> {
try {
this.logger.debug("Starting OAuth authentication");

const { tokenResponse, user } = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Authenticating",
cancellable: true,
},
async (progress, cancellationToken) =>
await this.oauthAuthorizer.login(
deployment,
progress,
cancellationToken,
),
);
const { tokenResponse, user, registration } =
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Authenticating",
cancellable: true,
},
async (progress, cancellationToken) =>
await this.oauthAuthorizer.login(
deployment,
progress,
cancellationToken,
),
);

return {
success: true,
token: tokenResponse.access_token,
user,
oauth: buildOAuthTokenData(tokenResponse),
oauth: buildOAuthTokenData(tokenResponse, registration),
};
} catch (error) {
if (error instanceof ClientRegistrationDisabledError) {
this.logger.warn("OAuth sign-in unavailable:", error.message);
const action = await vscodeProposed.window.showWarningMessage(
"OAuth sign-in is unavailable",
{
useCustom: true,
modal: true,
detail: `${error.message}. To use OAuth, ask an administrator to set dynamic_client_registration_enabled.`,
},
"Sign In with Token",
);
return action === "Sign In with Token"
? "use_token"
: { success: false, reason: "user_dismissed" };
}
const title = "OAuth authentication failed";
this.logger.error(title, error);
if (error instanceof CertificateError) {
Expand Down
Loading
Loading