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

### 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 cancelled 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.

## [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
85 changes: 50 additions & 35 deletions src/login/loginCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import { HttpStatusCode } from "../api/httpStatusCode";
import { needToken } from "../api/utils";
import { CertificateError } from "../error/certificateError";
import { OAuthAuthorizer } from "../oauth/authorizer";
import { revokeOAuthTokens } from "../oauth/revocation";
import { buildOAuthTokenData } from "../oauth/utils";
import { withCancellableProgress } from "../progress";
import { maybeAskAuthMethod, maybeAskUrl } from "../promptUtils";
import { showStoreCredentialsError } from "../util/credentials";
import { isSameOrigin, openInBrowser } from "../util/uri";
import { vscodeProposed } from "../vscodeProposed";

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

import type { CliCredentialManager } from "../core/cliCredentialManager";
import type { MementoManager } from "../core/mementoManager";
Expand Down Expand Up @@ -108,18 +112,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 @@ -169,15 +169,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 @@ -197,28 +193,40 @@ 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 reusesToken =
previous?.url === url && previous.token === result.token;
const oauth = result.oauth ?? (reusesToken ? 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,
});
await this.mementoManager.addToUrlHistory(url);

if (
result.method !== "stored_token" &&
previous?.oauth &&
previous.token !== result.token
) {
await revokeOAuthTokens(previous, 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 @@ -285,15 +293,22 @@ export class LoginCoordinator implements vscode.Disposable {
providedToken?: string,
tokenSignInConfirmed = false,
): Promise<LoginResult> {
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
1 change: 1 addition & 0 deletions src/oauth/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const DEFAULT_OAUTH_SCOPES = [
"workspace:create",
"user:read",
"user:read_personal",
"user:update_personal",
].join(" ");

/**
Expand Down
62 changes: 62 additions & 0 deletions src/oauth/revocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { CoderApi } from "../api/coderApi";

import { OAuthMetadataClient } from "./metadataClient";
import { toUrlSearchParams } from "./utils";

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

import type { SessionAuth } from "../core/secretsManager";
import type { Logger } from "../logging/logger";

/** Best-effort revocation of a captured OAuth session; never reads replacement credentials. */
export async function revokeOAuthTokens(
auth: SessionAuth,
registration: OAuth2ClientRegistrationResponse | undefined,
logger: Logger,
): Promise<void> {
if (!auth.oauth || !registration) {
return;
}
let client: CoderApi | undefined;
try {
client = CoderApi.create(auth.url, auth.token, logger);
const axiosInstance = client.getAxiosInstance();
const metadata = await new OAuthMetadataClient(
axiosInstance,
logger,
).getMetadata();
const endpoint = metadata.revocation_endpoint;
if (!endpoint) {
logger.debug("No revocation endpoint; skipping revocation");
return;
}
const targets: Array<[string, "access_token" | "refresh_token"]> = [];
if (auth.oauth.refresh_token) {
targets.push([auth.oauth.refresh_token, "refresh_token"]);
}
targets.push([auth.token, "access_token"]);
for (const [token, token_type_hint] of targets) {
const params: OAuth2TokenRevocationRequest = {
token,
client_id: registration.client_id,
client_secret: registration.client_secret,
token_type_hint,
};
try {
await axiosInstance.post(endpoint, toUrlSearchParams(params), {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
logger.debug(`Revoked ${token_type_hint}`);
} catch (error) {
logger.warn(`Failed to revoke ${token_type_hint}:`, error);
}
}
} catch (error) {
logger.warn("Token revocation failed:", error);
} finally {
client?.dispose();
}
}
67 changes: 21 additions & 46 deletions src/oauth/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { DEFAULT_OAUTH_SCOPES, REFRESH_GRANT_TYPE } from "./constants";
import { OAuthError, parseOAuthError } from "./errors";
import { OAuthMetadataClient } from "./metadataClient";
import { revokeOAuthTokens } from "./revocation";
import { buildOAuthTokenData, toUrlSearchParams } from "./utils";
import { OAuth2TokenResponseSchema, parseOAuthResponse } from "./validation";

Expand All @@ -16,7 +17,6 @@ import type {
OAuth2ClientRegistrationResponse,
OAuth2TokenRequest,
OAuth2TokenResponse,
OAuth2TokenRevocationRequest,
} from "coder/site/src/api/typesGenerated";
import type * as vscode from "vscode";

Expand Down Expand Up @@ -484,64 +484,39 @@ export class OAuthSessionManager implements vscode.Disposable {

/** Best-effort server-side revocation of the stored refresh and access tokens; never throws. */
public async revokeTokens(): Promise<void> {
// Includes tokens lacking a required scope, which still work on the server.
const storedTokens = await this.getStoredTokens().catch((error) => {
this.logger.warn("Failed to read stored tokens for revocation:", error);
return undefined;
});
if (!storedTokens) {
return;
}

// Refresh token first, while the access token still authenticates the call.
const targets: Array<[string, "access_token" | "refresh_token"]> = [];
if (storedTokens.refresh_token) {
targets.push([storedTokens.refresh_token, "refresh_token"]);
}
targets.push([storedTokens.access_token, "access_token"]);

try {
await this.withOAuthOperation(
storedTokens.access_token,
async ({ axiosInstance, metadata, registration }) => {
const endpoint = metadata.revocation_endpoint;
if (!endpoint) {
this.logger.debug("No revocation endpoint; skipping revocation");
return;
}
for (const [token, token_type_hint] of targets) {
const params: OAuth2TokenRevocationRequest = {
token,
client_id: registration.client_id,
client_secret: registration.client_secret,
token_type_hint,
};
try {
await axiosInstance.post(endpoint, toUrlSearchParams(params), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
this.logger.debug(`Revoked ${token_type_hint}`);
} catch (error) {
this.logger.warn(`Failed to revoke ${token_type_hint}:`, error);
}
}
// Includes tokens lacking a required scope, which still work on the server.
const storedTokens = await this.getStoredTokens();
if (!storedTokens) {
return;
}
const deployment = this.requireDeployment();
const registration = await this.secretsManager.getOAuthClientRegistration(
deployment.safeHostname,
);
await revokeOAuthTokens(
{
url: deployment.url,
token: storedTokens.access_token,
oauth: storedTokens,
},
registration,
this.logger,
);
} catch (error) {
this.logger.warn("Token revocation failed:", error);
}
}

/**
* Returns true if OAuth tokens exist for the current deployment.
* Always reads fresh from secrets to ensure cross-window synchronization.
* Returns true if the current deployment has OAuth tokens that may be
* refreshed, i.e. they carry every required scope. Always reads fresh from
* secrets to ensure cross-window synchronization.
*
* @param hostname Optional hostname to validate against current deployment.
* If provided and doesn't match, returns false (race-safety).
*/
public async isLoggedInWithOAuth(hostname?: string): Promise<boolean> {
public async canRefreshOAuthSession(hostname?: string): Promise<boolean> {
if (hostname && hostname !== this.deployment?.safeHostname) {
return false;
}
Expand Down
2 changes: 1 addition & 1 deletion test/mocks/testHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1068,7 +1068,7 @@ export class MockOAuthSessionManager {
.fn()
.mockResolvedValue({ access_token: "test-token" });
readonly revokeTokens = vi.fn().mockResolvedValue(undefined);
readonly isLoggedInWithOAuth = vi.fn().mockResolvedValue(false);
readonly canRefreshOAuthSession = vi.fn().mockResolvedValue(false);
readonly clearOAuthState = vi.fn().mockResolvedValue(undefined);
readonly dispose = vi.fn();
}
Expand Down
8 changes: 4 additions & 4 deletions test/unit/api/authInterceptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function createTestContext() {
const mockOAuthManager = new MockOAuthSessionManager();

// Default: not logged in with OAuth
mockOAuthManager.isLoggedInWithOAuth.mockResolvedValue(false);
mockOAuthManager.canRefreshOAuthSession.mockResolvedValue(false);

/** Sets up OAuth tokens in storage and configures mock */
const setupOAuthTokens = async () => {
Expand All @@ -129,7 +129,7 @@ function createTestContext() {
scope: "workspace:read",
},
});
mockOAuthManager.isLoggedInWithOAuth.mockImplementation(
mockOAuthManager.canRefreshOAuthSession.mockImplementation(
async (hostname?: string) => {
if (hostname && hostname !== TEST_HOSTNAME) {
return false;
Expand Down Expand Up @@ -556,8 +556,8 @@ describe("AuthInterceptor", () => {

await setupOAuthTokens();

// Make isLoggedInWithOAuth return false for different hostname
mockOAuthManager.isLoggedInWithOAuth.mockImplementation(
// Make canRefreshOAuthSession return false for different hostname
mockOAuthManager.canRefreshOAuthSession.mockImplementation(
(hostname?: string) => {
// Simulate hostname mismatch (deployment changed)
if (hostname === TEST_HOSTNAME) {
Expand Down
Loading
Loading