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
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@
"default": "",
"scope": "application"
},
"coder.alternativeWebUrl": {
"markdownDescription": "An alternative URL to use when opening Coder pages in the browser. When set, this replaces the connection URL for browser links only (dashboard, workspace pages, token authentication page, OAuth authorization page). When empty, the connection URL is used for the UI as well. The connection URL is always used for API calls, SSH, and CLI operations. Useful when the Coder API runs on a port that browsers restrict (e.g., 7004) but the web UI is accessible on a standard port (e.g., 443).",
"type": "string",
"default": ""
},
"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 @@ -26,7 +26,7 @@ import {
applySettingOverrides,
} from "./remote/sshOverrides";
import { resolveCliAuth } from "./settings/cli";
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 @@ -118,6 +118,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 @@ -573,9 +584,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 @@ -588,15 +597,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 @@ -612,15 +619,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
24 changes: 20 additions & 4 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 All @@ -205,25 +208,36 @@ export class OAuthAuthorizer implements vscode.Disposable {
}
}

const params = new URLSearchParams({
const params: Record<string, string> = {
client_id: clientId,
response_type: RESPONSE_TYPE,
redirect_uri: this.getRedirectUri(),
scope: DEFAULT_OAUTH_SCOPES,
state,
code_challenge: challenge,
code_challenge_method: PKCE_CHALLENGE_METHOD,
});
};

const url = `${metadata.authorization_endpoint}?${params.toString()}`;
// Server is authoritative for the path; alternativeWebUrl can swap the origin.
const endpoint = new URL(metadata.authorization_endpoint);
const browserBase = new URL(resolveUiUrl(connectionUrl));
endpoint.protocol = browserBase.protocol;
endpoint.hostname = browserBase.hostname;
endpoint.port = browserBase.port;
// Preserve any path prefix on the alternative URL (e.g. reverse proxy).
const prefix = browserBase.pathname.replace(/\/$/, "");
endpoint.pathname = `${prefix}${endpoint.pathname}`;
for (const [key, value] of Object.entries(params)) {
endpoint.searchParams.set(key, value);
}

Comment on lines +222 to 233
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The old code was:

const url = `${metadata.authorization_endpoint}?${params.toString()}`;

The new code drops endpoint.search and passes endpoint.origin (not a full connection URL) into the helper, which is inconsistent with every other call site. Preserve the original shape and just swap the origin:

const endpoint = new URL(metadata.authorization_endpoint);
const browserOrigin = new URL(resolveUiUrl(coderApi.getHost())).origin;
endpoint.protocol = new URL(browserOrigin).protocol;
endpoint.host = new URL(browserOrigin).host;
for (const [key, value] of Object.entries(params)) {
    endpoint.searchParams.set(key, value);
}
const url = endpoint.toString();

Or, if the helper is as per the suggestion above, just call the open helper with the endpoint pathname and the params as the query. Either way, query strings already on authorization_endpoint are preserved and the helper receives a real connection URL

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 +246,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 +255,7 @@ export class OAuthAuthorizer implements vscode.Disposable {
const { verifier, challenge } = generatePKCE();

const authUrl = this.buildAuthorizationUrl(
connectionUrl,
metadata,
registration.client_id,
state,
Expand Down
28 changes: 28 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,30 @@ 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));
const segment = path.replace(/^\/+/, "");
return vscode.env.openExternal(vscode.Uri.joinPath(base, segment));
}
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
70 changes: 70 additions & 0 deletions test/unit/oauth/authorizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,76 @@ describe("OAuthAuthorizer", () => {
"fetching user...",
]);
});

it("rewrites authorization endpoint origin when alternativeWebUrl is set", async () => {
const { mockAdapter, configurationProvider, startLogin, completeLogin } =
createTestContext();
configurationProvider.set(
"coder.alternativeWebUrl",
"https://web.example.com",
);
setupAxiosMockRoutes(mockAdapter, {
"/.well-known/oauth-authorization-server": createMockOAuthMetadata(
"https://coder.example.com:7004",
),
"/oauth2/register": createMockClientRegistration(),
"/oauth2/token": createMockTokenResponse(),
"/api/v2/users/me": { username: "test-user" },
});

const { loginPromise, authUrl, state } = await startLogin();
expect(authUrl.origin).toBe("https://web.example.com");
expect(authUrl.pathname).toBe("/oauth2/authorize");

await completeLogin(state);
await loginPromise;
});

it("preserves path prefix on alternativeWebUrl", async () => {
const { mockAdapter, configurationProvider, startLogin, completeLogin } =
createTestContext();
configurationProvider.set(
"coder.alternativeWebUrl",
"https://proxy.example.com/coder",
);
setupAxiosMockRoutes(mockAdapter, {
"/.well-known/oauth-authorization-server": createMockOAuthMetadata(
"https://coder.example.com:7004",
),
"/oauth2/register": createMockClientRegistration(),
"/oauth2/token": createMockTokenResponse(),
"/api/v2/users/me": { username: "test-user" },
});

const { loginPromise, authUrl, state } = await startLogin();
expect(authUrl.origin).toBe("https://proxy.example.com");
expect(authUrl.pathname).toBe("/coder/oauth2/authorize");

await completeLogin(state);
await loginPromise;
});

it("preserves query params already on the authorization endpoint", async () => {
const { mockAdapter, startLogin, completeLogin } = createTestContext();
setupAxiosMockRoutes(mockAdapter, {
"/.well-known/oauth-authorization-server": createMockOAuthMetadata(
TEST_URL,
{
authorization_endpoint: `${TEST_URL}/oauth2/authorize?audience=workspace`,
},
),
"/oauth2/register": createMockClientRegistration(),
"/oauth2/token": createMockTokenResponse(),
"/api/v2/users/me": { username: "test-user" },
});

const { loginPromise, authUrl, state } = await startLogin();
expect(authUrl.searchParams.get("audience")).toBe("workspace");
expect(authUrl.searchParams.get("client_id")).toBeTruthy();

await completeLogin(state);
await loginPromise;
});
});

describe("callback handling", () => {
Expand Down
3 changes: 2 additions & 1 deletion test/unit/oauth/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function createBaseTestContext() {
vi.mocked(getHeaders).mockResolvedValue({});

// Constructor sets up vscode.workspace mock
const _configurationProvider = new MockConfigurationProvider();
const configurationProvider = new MockConfigurationProvider();

const secretStorage = new InMemorySecretStorage();
const memento = new InMemoryMemento();
Expand All @@ -148,5 +148,6 @@ export function createBaseTestContext() {
oauthCallback,
logger,
setupOAuthRoutes,
configurationProvider,
};
}
Loading