Skip to content
Merged
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
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@
"default": "",
"scope": "application"
},
"coder.alternativeWebUrl": {
"markdownDescription": "Alternative URL for opening Coder pages in the browser (dashboard, workspace, and login pages). API, SSH, and CLI always use the connection URL; when this is empty, so does the browser. Useful when the API runs on a browser-restricted port (e.g. 7004) but the web UI is served on a standard port (e.g. 443).",
"type": "string",
"default": "",
"scope": "application"
},
"coder.autologin": {
"markdownDescription": "Automatically log into the default URL when the extension is activated. coder.defaultUrl is preferred, otherwise the CODER_URL environment variable will be used. This setting has no effect if neither is set.",
"type": "boolean",
Expand Down
44 changes: 26 additions & 18 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
import { resolveCliAuth } from "./settings/cli";
import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs";
import { runExportTelemetryCommand } from "./telemetry/export/command";
import { toRemoteAuthority, toSafeHost } from "./util";
import { openInBrowser, toRemoteAuthority, toSafeHost } from "./util";
import { vscodeProposed } from "./vscodeProposed";
import { parseSpeedtestResult } from "./webviews/speedtest/types";
import {
Expand Down Expand Up @@ -122,6 +122,17 @@ export class Commands {
return url;
}

/**
* Get the remote workspace deployment URL, throwing if not connected.
*/
private requireRemoteBaseUrl(): string {
const url = this.remoteWorkspaceClient?.getAxiosInstance().defaults.baseURL;
if (!url) {
throw new Error("No remote workspace connection");
}
return url;
}

/**
* Log into a deployment. If already authenticated, this is a no-op.
* If no URL is provided, shows a menu of recent URLs plus defaults.
Expand Down Expand Up @@ -587,9 +598,7 @@ export class Commands {
* Must only be called if currently logged in.
*/
public async createWorkspace(): Promise<void> {
const baseUrl = this.requireExtensionBaseUrl();
const uri = baseUrl + "/templates";
await vscode.commands.executeCommand("vscode.open", uri);
await openInBrowser(this.requireExtensionBaseUrl(), "/templates");
}

/**
Expand All @@ -602,15 +611,13 @@ export class Commands {
*/
public async navigateToWorkspace(item?: OpenableTreeItem) {
if (item) {
const baseUrl = this.requireExtensionBaseUrl();
const workspaceId = createWorkspaceIdentifier(item.workspace);
const uri = baseUrl + `/@${workspaceId}`;
await vscode.commands.executeCommand("vscode.open", uri);
await openInBrowser(this.requireExtensionBaseUrl(), `/@${workspaceId}`);
} else if (this.workspace && this.remoteWorkspaceClient) {
const baseUrl =
this.remoteWorkspaceClient.getAxiosInstance().defaults.baseURL;
const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}`;
await vscode.commands.executeCommand("vscode.open", uri);
await openInBrowser(
this.requireRemoteBaseUrl(),
`/@${createWorkspaceIdentifier(this.workspace)}`,
);
} else {
vscode.window.showInformationMessage("No workspace found.");
}
Expand All @@ -626,15 +633,16 @@ export class Commands {
*/
public async navigateToWorkspaceSettings(item?: OpenableTreeItem) {
if (item) {
const baseUrl = this.requireExtensionBaseUrl();
const workspaceId = createWorkspaceIdentifier(item.workspace);
const uri = baseUrl + `/@${workspaceId}/settings`;
await vscode.commands.executeCommand("vscode.open", uri);
await openInBrowser(
this.requireExtensionBaseUrl(),
`/@${workspaceId}/settings`,
);
} else if (this.workspace && this.remoteWorkspaceClient) {
const baseUrl =
this.remoteWorkspaceClient.getAxiosInstance().defaults.baseURL;
const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}/settings`;
await vscode.commands.executeCommand("vscode.open", uri);
await openInBrowser(
this.requireRemoteBaseUrl(),
`/@${createWorkspaceIdentifier(this.workspace)}/settings`,
);
} else {
vscode.window.showInformationMessage("No workspace found.");
}
Expand Down
3 changes: 2 additions & 1 deletion src/login/loginCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { buildOAuthTokenData } from "../oauth/utils";
import { withOptionalProgress } from "../progress";
import { maybeAskAuthMethod, maybeAskUrl } from "../promptUtils";
import { isKeyringEnabled } from "../settings/cli";
import { openInBrowser } from "../util";
import { vscodeProposed } from "../vscodeProposed";

import type { User } from "coder/site/src/api/typesGenerated";
Expand Down Expand Up @@ -398,7 +399,7 @@ export class LoginCoordinator implements vscode.Disposable {
}
// This prompt is for convenience; do not error if they close it since
// they may already have a token or already have the page opened.
await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`));
await openInBrowser(url, "/cli-auth");

// For token auth, start with the existing token in the prompt or the last
// used token. Once submitted, if there is a failure we will keep asking
Expand Down
43 changes: 41 additions & 2 deletions src/oauth/authorizer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from "vscode";

import { CoderApi } from "../api/coderApi";
import { resolveUiUrl } from "../util";

import {
AUTH_GRANT_TYPE,
Expand Down Expand Up @@ -98,6 +99,7 @@ export class OAuthAuthorizer implements vscode.Disposable {

reportProgress("waiting for authorization...", 30);
const { code, verifier } = await this.startAuthorization(
deployment.url,
metadata,
registration,
cancellationToken,
Expand Down Expand Up @@ -187,6 +189,7 @@ export class OAuthAuthorizer implements vscode.Disposable {
* Build authorization URL with all required OAuth 2.1 parameters.
*/
private buildAuthorizationUrl(
connectionUrl: string,
metadata: OAuth2AuthorizationServerMetadata,
clientId: string,
state: string,
Expand Down Expand Up @@ -215,15 +218,21 @@ export class OAuthAuthorizer implements vscode.Disposable {
code_challenge_method: PKCE_CHALLENGE_METHOD,
});

const url = `${metadata.authorization_endpoint}?${params.toString()}`;
const endpoint = toBrowserAuthorizationUrl(
metadata.authorization_endpoint,
connectionUrl,
);
for (const [key, value] of params) {
endpoint.searchParams.set(key, value);
}

this.logger.debug("Built OAuth authorization URL:", {
client_id: clientId,
redirect_uri: this.getRedirectUri(),
scope: DEFAULT_OAUTH_SCOPES,
});

return url;
return endpoint.toString();
}

/**
Expand All @@ -232,6 +241,7 @@ export class OAuthAuthorizer implements vscode.Disposable {
* Returns authorization code and PKCE verifier on success.
*/
private async startAuthorization(
connectionUrl: string,
metadata: OAuth2AuthorizationServerMetadata,
registration: OAuth2ClientRegistrationResponse,
cancellationToken: vscode.CancellationToken,
Expand All @@ -240,6 +250,7 @@ export class OAuthAuthorizer implements vscode.Disposable {
const { verifier, challenge } = generatePKCE();

const authUrl = this.buildAuthorizationUrl(
connectionUrl,
metadata,
registration.client_id,
state,
Expand Down Expand Up @@ -359,3 +370,31 @@ export class OAuthAuthorizer implements vscode.Disposable {
}
}
}

/**
* Swap the server's authorization endpoint onto the browser-facing URL
* (`alternativeWebUrl`) when it lives under the connection URL, preserving any
* sub-path prefix. Endpoints on a different host are left untouched.
*/
function toBrowserAuthorizationUrl(
authorizationEndpoint: string,
connectionUrl: string,
): URL {
const endpoint = new URL(authorizationEndpoint);
const connectionBase = new URL(connectionUrl);
const browserBase = new URL(resolveUiUrl(connectionUrl));
const connectionPrefix = connectionBase.pathname.replace(/\/$/, "");
const browserPrefix = browserBase.pathname.replace(/\/$/, "");
const underConnection =
endpoint.origin === connectionBase.origin &&
(connectionPrefix === "" ||
endpoint.pathname === connectionPrefix ||
endpoint.pathname.startsWith(`${connectionPrefix}/`));
if (underConnection) {
endpoint.protocol = browserBase.protocol;
endpoint.hostname = browserBase.hostname;
endpoint.port = browserBase.port;
endpoint.pathname = `${browserPrefix}${endpoint.pathname.slice(connectionPrefix.length)}`;
}
return endpoint;
}
27 changes: 27 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os from "node:os";
import url from "node:url";
import * as vscode from "vscode";

export interface AuthorityParts {
agent: string | undefined;
Expand Down Expand Up @@ -195,3 +196,29 @@ export function escapeShellArg(arg: string): string {
}
return `'${arg.replace(/'/g, "'\\''")}'`;
}

/**
* Return the URL for opening Coder pages in the browser. Uses the
* `coder.alternativeWebUrl` setting when configured, otherwise returns
* the connection URL unchanged.
*/
export function resolveUiUrl(connectionUrl: string): string {
const alt = vscode.workspace
.getConfiguration("coder")
.get<string>("alternativeWebUrl")
?.trim()
.replace(/\/+$/, "");
return alt || connectionUrl;
}

/**
* Open a path on the Coder deployment in the user's browser, applying
* `coder.alternativeWebUrl` when configured.
*/
export function openInBrowser(
connectionUrl: string,
path: string,
): Thenable<boolean> {
const base = vscode.Uri.parse(resolveUiUrl(connectionUrl));
return vscode.env.openExternal(vscode.Uri.joinPath(base, path));
}
23 changes: 11 additions & 12 deletions src/webviews/tasks/tasksPanelProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
streamBuildLogs,
} from "../../api/workspace";
import { type Logger } from "../../logging/logger";
import { openInBrowser } from "../../util";
import { vscodeProposed } from "../../vscodeProposed";
import {
dispatchCommand,
Expand All @@ -43,12 +44,12 @@ import type {
WorkspaceAgentLog,
} from "coder/site/src/api/typesGenerated";

/** Build URL to view task build logs in Coder dashboard */
function getTaskBuildUrl(baseUrl: string, task: Task): string {
/** Build the dashboard path for a task's build logs. */
function getTaskBuildPath(task: Task): string {
if (task.workspace_name && task.workspace_build_number) {
return `${baseUrl}/@${task.owner_name}/${task.workspace_name}/builds/${task.workspace_build_number}`;
return `/@${task.owner_name}/${task.workspace_name}/builds/${task.workspace_build_number}`;
}
return `${baseUrl}/tasks/${task.owner_name}/${task.id}`;
return `/tasks/${task.owner_name}/${task.id}`;
}

export class TasksPanelProvider
Expand Down Expand Up @@ -308,21 +309,19 @@ export class TasksPanelProvider
}

private async handleViewInCoder(taskId: string): Promise<void> {
const baseUrl = this.client.getHost();
if (!baseUrl) return;
const connUrl = this.client.getHost();
if (!connUrl) return;

const task = await this.client.getTask("me", taskId);
vscode.env.openExternal(
vscode.Uri.parse(`${baseUrl}/tasks/${task.owner_name}/${task.id}`),
);
await openInBrowser(connUrl, `/tasks/${task.owner_name}/${task.id}`);
}

private async handleViewLogs(taskId: string): Promise<void> {
const baseUrl = this.client.getHost();
if (!baseUrl) return;
const connUrl = this.client.getHost();
if (!connUrl) return;

const task = await this.client.getTask("me", taskId);
vscode.env.openExternal(vscode.Uri.parse(getTaskBuildUrl(baseUrl, task)));
await openInBrowser(connUrl, getTaskBuildPath(task));
}

private async handleDownloadLogs(taskId: string): Promise<void> {
Expand Down
10 changes: 8 additions & 2 deletions test/mocks/vscode.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,14 @@ export class Uri {
: `${this.scheme}:${this.path}`;
}
static joinPath(base: Uri, ...paths: string[]) {
const sep = base.path.endsWith("/") ? "" : "/";
return new Uri(base.scheme, base.path + sep + paths.join("/"));
// Mirror vscode-uri: collapse slashes at the seams while preserving the
// leading "//" that separates the authority from the path.
const head = base.path.replace(/\/+$/, "");
const tail = paths
.map((p) => p.replace(/^\/+|\/+$/g, ""))
.filter(Boolean)
.join("/");
return new Uri(base.scheme, tail ? `${head}/${tail}` : head);
}
}

Expand Down
Loading
Loading