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
3 changes: 0 additions & 3 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,6 @@ export class Commands {
return;
}

// Login might have happened in another process/window so we do not have the user yet.
result.user ??= await this.extensionClient.getAuthenticatedUser();

await this.deploymentManager.setDeployment({
url,
safeHostname,
Expand Down
147 changes: 8 additions & 139 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ import { ServiceContainer } from "./core/container";
import { type SecretsManager } from "./core/secretsManager";
import { DeploymentManager } from "./deployment/deploymentManager";
import { CertificateError, getErrorDetail } from "./error";
import { maybeAskUrl } from "./promptUtils";
import { Remote } from "./remote/remote";
import { getRemoteSshExtension } from "./remote/sshExtension";
import { toSafeHost } from "./util";
import { registerUriHandler } from "./uri/uriHandler";
import {
WorkspaceProvider,
WorkspaceQuery,
Expand Down Expand Up @@ -129,103 +128,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
]);
ctx.subscriptions.push(deploymentManager);

// Handle vscode:// URIs.
const uriHandler = vscode.window.registerUriHandler({
handleUri: async (uri) => {
const params = new URLSearchParams(uri.query);

if (uri.path === "/open") {
const owner = params.get("owner");
const workspace = params.get("workspace");
const agent = params.get("agent");
const folder = params.get("folder");
const openRecent =
params.has("openRecent") &&
(!params.get("openRecent") || params.get("openRecent") === "true");

if (!owner) {
throw new Error("owner must be specified as a query parameter");
}
if (!workspace) {
throw new Error("workspace must be specified as a query parameter");
}

await setupDeploymentFromUri(params, serviceContainer);

await commands.open(
owner,
workspace,
agent ?? undefined,
folder ?? undefined,
openRecent,
);
} else if (uri.path === "/openDevContainer") {
const workspaceOwner = params.get("owner");
const workspaceName = params.get("workspace");
const workspaceAgent = params.get("agent");
const devContainerName = params.get("devContainerName");
const devContainerFolder = params.get("devContainerFolder");
const localWorkspaceFolder = params.get("localWorkspaceFolder");
const localConfigFile = params.get("localConfigFile");

if (!workspaceOwner) {
throw new Error(
"workspace owner must be specified as a query parameter",
);
}

if (!workspaceName) {
throw new Error(
"workspace name must be specified as a query parameter",
);
}

if (!workspaceAgent) {
throw new Error(
"workspace agent must be specified as a query parameter",
);
}

if (!devContainerName) {
throw new Error(
"dev container name must be specified as a query parameter",
);
}

if (!devContainerFolder) {
throw new Error(
"dev container folder must be specified as a query parameter",
);
}

if (localConfigFile && !localWorkspaceFolder) {
throw new Error(
"local workspace folder must be specified as a query parameter if local config file is provided",
);
}

await setupDeploymentFromUri(params, serviceContainer);

await commands.openDevContainer(
workspaceOwner,
workspaceName,
workspaceAgent,
devContainerName,
devContainerFolder,
localWorkspaceFolder ?? "",
localConfigFile ?? "",
);
} else {
throw new Error(`Unknown path ${uri.path}`);
}
},
});
ctx.subscriptions.push(uriHandler);

// Register globally available commands. Many of these have visibility
// controlled by contexts, see `when` in the package.json.
const commands = new Commands(serviceContainer, client, deploymentManager);

ctx.subscriptions.push(
registerUriHandler(
serviceContainer,
deploymentManager,
commands,
vscodeProposed,
),
vscode.commands.registerCommand(
"coder.login",
commands.login.bind(commands),
Expand Down Expand Up @@ -418,50 +331,6 @@ async function showTreeViewSearch(id: string): Promise<void> {
await vscode.commands.executeCommand("list.find");
}

/**
* Sets up deployment from URI parameters. Handles URL prompting, client setup,
* and token storage. Throws if user cancels URL input.
*/
async function setupDeploymentFromUri(
params: URLSearchParams,
serviceContainer: ServiceContainer,
): Promise<void> {
const secretsManager = serviceContainer.getSecretsManager();
const mementoManager = serviceContainer.getMementoManager();
const currentDeployment = await secretsManager.getCurrentDeployment();

// We are not guaranteed that the URL we currently have is for the URL
// this workspace belongs to, or that we even have a URL at all (the
// queries will default to localhost) so ask for it if missing.
// Pre-populate in case we do have the right URL so the user can just
// hit enter and move on.
const url = await maybeAskUrl(
mementoManager,
params.get("url"),
currentDeployment?.url,
);
if (!url) {
throw new Error("url must be provided or specified as a query parameter");
}

const safeHost = toSafeHost(url);

// If the token is missing we will get a 401 later and the user will be
// prompted to sign in again, so we do not need to ensure it is set now.
const token: string | null = params.get("token");
if (token === null) {
// We need to ensure there is at least an entry for this in storage
// so that we know what URL to prompt the user with when logging in.
const auth = await secretsManager.getSessionAuth(safeHost);
if (!auth) {
// Racy, we could accidentally overwrite the token that is written in the meantime.
await secretsManager.setSessionAuth(safeHost, { url, token: "" });
}
} else {
await secretsManager.setSessionAuth(safeHost, { url, token });
}
}

async function listStoredDeployments(
secretsManager: SecretsManager,
): Promise<void> {
Expand Down
127 changes: 88 additions & 39 deletions src/login/loginCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ import type { Logger } from "../logging/logger";

type LoginResult =
| { success: false }
| { success: true; user?: User; token: string };
| { success: true; user: User; token: string };

interface LoginOptions {
safeHostname: string;
url: string | undefined;
autoLogin?: boolean;
token?: string;
}

/**
Expand Down Expand Up @@ -49,6 +50,7 @@ export class LoginCoordinator {
const result = await this.attemptLogin(
{ safeHostname, url },
options.autoLogin ?? false,
options.token,
);

await this.persistSessionAuth(result, safeHostname, url);
Expand Down Expand Up @@ -95,6 +97,7 @@ export class LoginCoordinator {
const result = await this.attemptLogin(
{ url: newUrl, safeHostname },
false,
options.token,
);

await this.persistSessionAuth(result, safeHostname, newUrl);
Expand Down Expand Up @@ -168,10 +171,17 @@ export class LoginCoordinator {
const promise = new Promise<LoginResult>((resolve) => {
disposable = this.secretsManager.onDidChangeSessionAuth(
safeHostname,
(auth) => {
async (auth) => {
if (auth?.token) {
disposable?.dispose();
resolve({ success: true, token: auth.token });
const client = CoderApi.create(auth.url, auth.token, this.logger);
try {
const user = await client.getAuthenticatedUser();
resolve({ success: true, token: auth.token, user });
} catch {
// Token from other window was invalid, ignore and keep waiting
// (or user can click Login/Cancel in the dialog)
}
}
},
);
Expand All @@ -191,54 +201,93 @@ export class LoginCoordinator {
private async attemptLogin(
deployment: Deployment,
isAutoLogin: boolean,
providedToken?: string,
): Promise<LoginResult> {
const needsToken = needToken(vscode.workspace.getConfiguration());
const client = CoderApi.create(deployment.url, "", this.logger);

let storedToken: string | undefined;
if (needsToken) {
const auth = await this.secretsManager.getSessionAuth(
deployment.safeHostname,
// mTLS authentication (no token needed)
if (!needToken(vscode.workspace.getConfiguration())) {
return this.tryMtlsAuth(client, isAutoLogin);
}

// Try provided token first
if (providedToken) {
const result = await this.tryTokenAuth(
client,
providedToken,
isAutoLogin,
);
storedToken = auth?.token;
if (storedToken) {
client.setSessionToken(storedToken);
if (result !== "retry") {
return result;
}
}

// Attempt authentication with current credentials (token or mTLS)
try {
if (!needsToken || storedToken) {
const user = await client.getAuthenticatedUser();
// Return the token that was used (empty string for mTLS since
// the `vscodessh` command currently always requires a token file)
return { success: true, token: storedToken ?? "", user };
// Try stored token (skip if same as provided)
const auth = await this.secretsManager.getSessionAuth(
deployment.safeHostname,
);
if (auth?.token && auth.token !== providedToken) {
const result = await this.tryTokenAuth(client, auth.token, isAutoLogin);
if (result !== "retry") {
return result;
}
}

// Prompt user for token
return this.loginWithToken(client);
}

private async tryMtlsAuth(
client: CoderApi,
isAutoLogin: boolean,
): Promise<LoginResult> {
try {
const user = await client.getAuthenticatedUser();
return { success: true, token: "", user };
} catch (err) {
const is401 = isAxiosError(err) && err.response?.status === 401;
if (needsToken && is401) {
// For token auth with 401: silently continue to prompt for new credentials
} else {
// For mTLS or non-401 errors: show error and abort
const message = getErrorMessage(err, "no response from the server");
if (isAutoLogin) {
this.logger.warn("Failed to log in to Coder server:", message);
} else {
this.vscodeProposed.window.showErrorMessage(
"Failed to log in to Coder server",
{
detail: message,
modal: true,
useCustom: true,
},
);
}
return { success: false };
this.showAuthError(err, isAutoLogin);
return { success: false };
}
}

/**
* Returns 'retry' on 401 to signal trying next token.
*/
private async tryTokenAuth(
client: CoderApi,
token: string,
isAutoLogin: boolean,
): Promise<LoginResult | "retry"> {
client.setSessionToken(token);
try {
const user = await client.getAuthenticatedUser();
return { success: true, token, user };
} catch (err) {
if (isAxiosError(err) && err.response?.status === 401) {
return "retry";
}
this.showAuthError(err, isAutoLogin);
return { success: false };
}
}

const result = await this.loginWithToken(client);
return result;
/**
* Shows auth error via dialog or logs it for autoLogin.
*/
private showAuthError(err: unknown, isAutoLogin: boolean): void {
const message = getErrorMessage(err, "no response from the server");
if (isAutoLogin) {
this.logger.warn("Failed to log in to Coder server:", message);
} else {
this.vscodeProposed.window.showErrorMessage(
"Failed to log in to Coder server",
{
detail: message,
modal: true,
useCustom: true,
},
);
}
}

/**
Expand Down
Loading