Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 6 additions & 1 deletion src/deployment/deploymentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,13 @@ export class DeploymentManager implements vscode.Disposable {
* Returns true if deployment was changed, false otherwise.
*/
public async setDeploymentIfValid(
deployment: Deployment & { token?: string },
deployment: DeploymentWithAuth,
): Promise<boolean> {
if (deployment.user) {
await this.setDeployment({ ...deployment, user: deployment.user });
return true;
}

const auth = await this.secretsManager.getSessionAuth(
deployment.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
182 changes: 182 additions & 0 deletions src/uri/uriHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import * as vscode from "vscode";

import { errToStr } from "../api/api-helper";
import { type Commands } from "../commands";
import { type ServiceContainer } from "../core/container";
import { type DeploymentManager } from "../deployment/deploymentManager";
import { maybeAskUrl } from "../promptUtils";
import { toSafeHost } from "../util";

interface UriRouteContext {
params: URLSearchParams;
serviceContainer: ServiceContainer;
deploymentManager: DeploymentManager;
commands: Commands;
}

type UriRouteHandler = (ctx: UriRouteContext) => Promise<void>;

const routes: Record<string, UriRouteHandler> = {
"/open": handleOpen,
"/openDevContainer": handleOpenDevContainer,
};

/**
* Registers the URI handler for `{vscode.env.uriScheme}://coder.coder-remote`... URIs.
*/
export function registerUriHandler(
serviceContainer: ServiceContainer,
deploymentManager: DeploymentManager,
commands: Commands,
vscodeProposed: typeof vscode,
): vscode.Disposable {
const output = serviceContainer.getLogger();

return vscode.window.registerUriHandler({
handleUri: async (uri) => {
try {
await routeUri(uri, serviceContainer, deploymentManager, commands);
} catch (error) {
const message = errToStr(error, "No error message was provided");
output.warn(`Failed to handle URI ${uri.toString()}: ${message}`);
vscodeProposed.window.showErrorMessage("Failed to handle URI", {
detail: message,
modal: true,
useCustom: true,
});
}
},
});
}

async function routeUri(
uri: vscode.Uri,
serviceContainer: ServiceContainer,
deploymentManager: DeploymentManager,
commands: Commands,
): Promise<void> {
const handler = routes[uri.path];
if (!handler) {
throw new Error(`Unknown path ${uri.path}`);
}

await handler({
params: new URLSearchParams(uri.query),
serviceContainer,
deploymentManager,
commands,
});
}

function getRequiredParam(params: URLSearchParams, name: string): string {
const value = params.get(name);
if (!value) {
throw new Error(`${name} must be specified as a query parameter`);
}
return value;
}

async function handleOpen(ctx: UriRouteContext): Promise<void> {
const { params, serviceContainer, deploymentManager, commands } = ctx;

const owner = getRequiredParam(params, "owner");
const workspace = getRequiredParam(params, "workspace");
const agent = params.get("agent");
const folder = params.get("folder");
const openRecent =
params.has("openRecent") &&
(!params.get("openRecent") || params.get("openRecent") === "true");

await setupDeployment(params, serviceContainer, deploymentManager);

await commands.open(
owner,
workspace,
agent ?? undefined,
folder ?? undefined,
openRecent,
);
}

async function handleOpenDevContainer(ctx: UriRouteContext): Promise<void> {
const { params, serviceContainer, deploymentManager, commands } = ctx;

const owner = getRequiredParam(params, "owner");
const workspace = getRequiredParam(params, "workspace");
const agent = getRequiredParam(params, "agent");
const devContainerName = getRequiredParam(params, "devContainerName");
const devContainerFolder = getRequiredParam(params, "devContainerFolder");
const localWorkspaceFolder = params.get("localWorkspaceFolder");
const localConfigFile = params.get("localConfigFile");

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

await setupDeployment(params, serviceContainer, deploymentManager);

await commands.openDevContainer(
owner,
workspace,
agent,
devContainerName,
devContainerFolder,
localWorkspaceFolder ?? "",
localConfigFile ?? "",
);
}

/**
* Sets up deployment from URI parameters. Handles URL prompting, client setup,
* and token storage. Throws if user cancels URL input or login fails.
*/
async function setupDeployment(
params: URLSearchParams,
serviceContainer: ServiceContainer,
deploymentManager: DeploymentManager,
): Promise<void> {
const secretsManager = serviceContainer.getSecretsManager();
const mementoManager = serviceContainer.getMementoManager();
const loginCoordinator = serviceContainer.getLoginCoordinator();

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 safeHostname = toSafeHost(url);

const token: string | null = params.get("token");
if (token !== null) {
await secretsManager.setSessionAuth(safeHostname, { url, token });
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we wait to set the token until after ensureLoggedIn? To avoid potentially storing an invalid or blank token.

Not a huge deal since usually the token should be set and valid, I just noticed that if I am already logged in and the URI contains an invalid or blank token, I get partially logged out (my workspace list is empty but I still look logged-in) and if I reload VS Code I am completely logged out. Just seems unfortunate to lose the perfectly good token I had stored previously.

Actually ensureLoggedIn already sets auth so I suppose we can skip it here. Although, it also sets it before validating. Maybe something we can visit later.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oooh that's a valid point, I've added an optional token parameter so we try to login in the following order:

  1. mTLS (non-token auth)
  2. Provided token
  3. Stored token
  4. User prompt

We store whatever succeeds in this order


const result = await loginCoordinator.ensureLoggedIn({
safeHostname,
url,
});

if (!result.success) {
throw new Error("Failed to login to deployment from URI");
}

await deploymentManager.setDeploymentIfValid({
safeHostname,
url,
token: result.token,
user: result.user,
});
}
1 change: 1 addition & 0 deletions test/mocks/vscode.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const window = {
clear: vi.fn(),
})),
createStatusBarItem: vi.fn(),
registerUriHandler: vi.fn(() => ({ dispose: vi.fn() })),
};

export const commands = {
Expand Down
Loading