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
38 changes: 35 additions & 3 deletions src/api/authInterceptor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type AxiosError, isAxiosError } from "axios";

import { OAuthError } from "../oauth/errors";
import { toSafeHost } from "../util";

import type * as vscode from "vscode";
Expand Down Expand Up @@ -27,6 +28,7 @@ export type AuthRequiredHandler = (hostname: string) => Promise<boolean>;
*/
export class AuthInterceptor implements vscode.Disposable {
private readonly interceptorId: number;
private authRequiredPromise: Promise<boolean> | null = null;

constructor(
private readonly client: CoderApi,
Expand Down Expand Up @@ -82,13 +84,21 @@ export class AuthInterceptor implements vscode.Disposable {
this.logger.debug("Token refresh successful, retrying request");
return this.retryRequest(error, newTokens.access_token);
} catch (refreshError) {
this.logger.error("OAuth refresh failed:", refreshError);
if (refreshError instanceof OAuthError) {
const msg = `Token refresh failed: ${refreshError.message}`;
if (refreshError.requiresReAuth) {
this.logger.warn(msg);
} else {
this.logger.error(msg);
}
} else {
this.logger.error("Token refresh failed:", refreshError);
}
}
}

if (this.onAuthRequired) {
this.logger.debug("Triggering interactive re-authentication");
const success = await this.onAuthRequired(hostname);
const success = await this.executeAuthRequired(hostname);
if (success) {
const auth = await this.secretsManager.getSessionAuth(hostname);
if (auth) {
Expand All @@ -101,6 +111,28 @@ export class AuthInterceptor implements vscode.Disposable {
throw error;
}

/**
* Execute auth required callback with deduplication.
* Multiple concurrent 401s will share the same promise.
*/
private async executeAuthRequired(hostname: string): Promise<boolean> {
if (this.authRequiredPromise) {
this.logger.debug(
"Auth callback already in progress, waiting for result",
);
return this.authRequiredPromise;
}

this.logger.debug("Triggering re-authentication");
this.authRequiredPromise = this.onAuthRequired!(hostname);

try {
return await this.authRequiredPromise;
} finally {
this.authRequiredPromise = null;
}
}

private retryRequest(error: AxiosError, token: string): Promise<unknown> {
if (!error.config) {
throw error;
Expand Down
4 changes: 2 additions & 2 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,12 @@ export class Commands {

await this.deploymentManager.clearDeployment();

void vscode.window
vscode.window
.showInformationMessage("You've been logged out of Coder!", "Login")
.then((action) => {
if (action === "Login") {
this.login().catch((error) => {
this.logger.error("Failed to login", error);
this.logger.error("Login failed", error);
});
}
});
Expand Down
2 changes: 1 addition & 1 deletion src/core/secretsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type CurrentDeploymentState = z.infer<
*/
const OAuthTokenDataSchema = z.object({
refresh_token: z.string().optional(),
scope: z.string().optional(),
scope: z.string(),
expiry_timestamp: z.number(),
});

Expand Down
60 changes: 41 additions & 19 deletions src/deployment/deploymentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ import { type Deployment, type DeploymentWithAuth } from "./types";
import type { User } from "coder/site/src/api/typesGenerated";
import type * as vscode from "vscode";

/**
* Internal state type that allows mutation of user property.
*/
type DeploymentWithUser = Deployment & { user: User };

/**
* Manages deployment state for the extension.
*
Expand All @@ -35,7 +30,7 @@ export class DeploymentManager implements vscode.Disposable {
private readonly contextManager: ContextManager;
private readonly logger: Logger;

#deployment: DeploymentWithUser | null = null;
#deployment: Deployment | null = null;
#authListenerDisposable: vscode.Disposable | undefined;
#crossWindowSyncDisposable: vscode.Disposable | undefined;

Expand Down Expand Up @@ -78,7 +73,7 @@ export class DeploymentManager implements vscode.Disposable {
* Check if we have an authenticated deployment (does not guarantee that the current auth data is valid).
*/
public isAuthenticated(): boolean {
return this.#deployment !== null;
return this.contextManager.get("coder.authenticated");
}

/**
Expand All @@ -89,10 +84,10 @@ export class DeploymentManager implements vscode.Disposable {
public async setDeploymentIfValid(
deployment: Deployment & { token?: string },
): Promise<boolean> {
const auth = await this.secretsManager.getSessionAuth(
deployment.safeHostname,
);
const token = deployment.token ?? auth?.token;
const token =
deployment.token ??
(await this.secretsManager.getSessionAuth(deployment.safeHostname))
?.token;
const tempClient = CoderApi.create(deployment.url, token, this.logger);

try {
Expand Down Expand Up @@ -132,7 +127,7 @@ export class DeploymentManager implements vscode.Disposable {
// Register auth listener before setDeployment so background token refresh
// can update client credentials via the listener
this.registerAuthListener();
this.updateAuthContexts();
this.updateAuthContexts(deployment.user);
this.refreshWorkspaces();

await this.oauthSessionManager.setDeployment(deployment);
Expand All @@ -143,16 +138,32 @@ export class DeploymentManager implements vscode.Disposable {
* Clears the current deployment.
*/
public async clearDeployment(): Promise<void> {
this.suspendSession();
this.#authListenerDisposable?.dispose();
this.#authListenerDisposable = undefined;
this.#deployment = null;

this.client.setCredentials(undefined, undefined);
await this.secretsManager.setCurrentDeployment(undefined);
}

/**
* Suspend session: shows logged-out state but keeps deployment for easy re-login.
* Auth listener remains active so recovery can happen automatically if tokens update.
*/
public suspendSession(): void {
this.oauthSessionManager.clearDeployment();
this.updateAuthContexts();
this.refreshWorkspaces();
this.client.setCredentials(undefined, undefined);
this.updateAuthContexts(undefined);
this.clearWorkspaces();
}

await this.secretsManager.setCurrentDeployment(undefined);
/**
* Clear all workspace providers without fetching.
*/
private clearWorkspaces(): void {
for (const provider of this.workspaceProviders) {
provider.clear();
}
}

public dispose(): void {
Expand All @@ -163,6 +174,7 @@ export class DeploymentManager implements vscode.Disposable {
/**
* Register auth listener for the current deployment.
* Updates credentials when they change (token refresh, cross-window sync).
* Also handles recovery from suspended session state.
*/
private registerAuthListener(): void {
if (!this.#deployment) {
Expand All @@ -182,7 +194,18 @@ export class DeploymentManager implements vscode.Disposable {
}

if (auth) {
this.client.setCredentials(auth.url, auth.token);
if (this.isAuthenticated()) {
this.client.setCredentials(auth.url, auth.token);
} else {
this.logger.debug(
"Token updated after session suspended, recovering",
);
await this.setDeploymentIfValid({
url: auth.url,
safeHostname,
token: auth.token,
});
}
} else {
await this.clearDeployment();
}
Expand Down Expand Up @@ -210,8 +233,7 @@ export class DeploymentManager implements vscode.Disposable {
/**
* Update authentication-related contexts.
*/
private updateAuthContexts(): void {
const user = this.#deployment?.user;
private updateAuthContexts(user: User | undefined): void {
this.contextManager.set("coder.authenticated", Boolean(user));
const isOwner = user?.roles.some((r) => r.name === "owner") ?? false;
this.contextManager.set("coder.isOwner", isOwner);
Expand Down
35 changes: 28 additions & 7 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,33 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {

const deployment = await secretsManager.getCurrentDeployment();

// Create OAuth session manager with login coordinator
// Shared handler for auth failures (used by interceptor + session manager)
const handleAuthFailure = (): Promise<void> => {
deploymentManager.suspendSession();
vscode.window
.showWarningMessage(
"Session expired. You have been signed out.",
"Log In",
)
.then(async (action) => {
if (action === "Log In") {
try {
await commands.login({
url: deploymentManager.getCurrentDeployment()?.url,
});
} catch (err) {
output.error("Login failed", err);
}
}
});
return Promise.resolve();
};

// Create OAuth session manager - callback handles background refresh failures
const oauthSessionManager = OAuthSessionManager.create(
deployment,
serviceContainer,
handleAuthFailure,
);
ctx.subscriptions.push(oauthSessionManager);

Expand All @@ -94,11 +117,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
output,
oauthSessionManager,
secretsManager,
() => {
void vscode.window.showWarningMessage(
"Session expired. Please log in again using the Coder sidebar.",
);
return Promise.resolve(false);
async () => {
await handleAuthFailure();
return false;
},
);
ctx.subscriptions.push(authInterceptor);
Expand Down Expand Up @@ -324,7 +345,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
process.env.CODER_URL?.trim();
if (defaultUrl) {
commands.login({ url: defaultUrl, autoLogin: true }).catch((error) => {
output.error("Failed to auto-login", error);
output.error("Auto-login failed", error);
});
}
}
Expand Down
14 changes: 1 addition & 13 deletions src/oauth/authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { type Logger } from "../logging/logger";

import {
AUTH_GRANT_TYPE,
DEFAULT_OAUTH_SCOPES,
PKCE_CHALLENGE_METHOD,
RESPONSE_TYPE,
TOKEN_ENDPOINT_AUTH_METHOD,
Expand All @@ -29,19 +30,6 @@ import type {
User,
} from "coder/site/src/api/typesGenerated";

/**
* Minimal scopes required by the VS Code extension.
*/
const DEFAULT_OAUTH_SCOPES = [
"workspace:read",
"workspace:update",
"workspace:start",
"workspace:ssh",
"workspace:application_connect",
"template:read",
"user:read_personal",
].join(" ");

/**
* Handles the OAuth authorization code flow for authenticating with Coder deployments.
* Encapsulates client registration, PKCE challenge, and token exchange.
Expand Down
11 changes: 11 additions & 0 deletions src/oauth/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
export const AUTH_GRANT_TYPE = "authorization_code";
export const REFRESH_GRANT_TYPE = "refresh_token";

// Minimal scopes required by the VS Code extension
export const DEFAULT_OAUTH_SCOPES = [
"workspace:read",
"workspace:update",
"workspace:start",
"workspace:ssh",
"workspace:application_connect",
"template:read",
"user:read_personal",
].join(" ");

// OAuth 2.1 Response Types
export const RESPONSE_TYPE = "code";

Expand Down
15 changes: 9 additions & 6 deletions src/oauth/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export class OAuthError extends Error {
);
this.name = "OAuthError";
}

/**
* Returns true if this error indicates the user needs to re-authenticate.
*/
get requiresReAuth(): boolean {
return (
this.errorCode === "invalid_grant" || this.errorCode === "invalid_client"
);
}
}

export function parseOAuthError(error: unknown): OAuthError | null {
Expand All @@ -57,9 +66,3 @@ function isOAuth2Error(data: unknown): data is OAuth2Error {
typeof data.error === "string"
);
}

export function requiresReAuthentication(error: OAuthError): boolean {
return (
error.errorCode === "invalid_grant" || error.errorCode === "invalid_client"
);
}
2 changes: 1 addition & 1 deletion src/oauth/metadataClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { Logger } from "../logging/logger";

const OAUTH_DISCOVERY_ENDPOINT = "/.well-known/oauth-authorization-server";

const REQUIRED_GRANT_TYPES: readonly string[] = [
const REQUIRED_GRANT_TYPES: readonly OAuth2ProviderGrantType[] = [
AUTH_GRANT_TYPE,
REFRESH_GRANT_TYPE,
];
Expand Down
Loading