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 @@ -121,6 +121,11 @@
"type": "string",
"default": ""
},
"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). The connection URL is still 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
25 changes: 17 additions & 8 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 { resolveBrowserUrl, 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,7 +584,7 @@ export class Commands {
* Must only be called if currently logged in.
*/
public async createWorkspace(): Promise<void> {
const baseUrl = this.requireExtensionBaseUrl();
const baseUrl = resolveBrowserUrl(this.requireExtensionBaseUrl());
const uri = baseUrl + "/templates";
await vscode.commands.executeCommand("vscode.open", uri);
}
Expand All @@ -588,13 +599,12 @@ export class Commands {
*/
public async navigateToWorkspace(item?: OpenableTreeItem) {
if (item) {
const baseUrl = this.requireExtensionBaseUrl();
const baseUrl = resolveBrowserUrl(this.requireExtensionBaseUrl());
const workspaceId = createWorkspaceIdentifier(item.workspace);
const uri = baseUrl + `/@${workspaceId}`;
await vscode.commands.executeCommand("vscode.open", uri);
} else if (this.workspace && this.remoteWorkspaceClient) {
const baseUrl =
this.remoteWorkspaceClient.getAxiosInstance().defaults.baseURL;
const baseUrl = resolveBrowserUrl(this.requireRemoteBaseUrl());
const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}`;
await vscode.commands.executeCommand("vscode.open", uri);
} else {
Expand All @@ -612,13 +622,12 @@ export class Commands {
*/
public async navigateToWorkspaceSettings(item?: OpenableTreeItem) {
if (item) {
const baseUrl = this.requireExtensionBaseUrl();
const baseUrl = resolveBrowserUrl(this.requireExtensionBaseUrl());
const workspaceId = createWorkspaceIdentifier(item.workspace);
const uri = baseUrl + `/@${workspaceId}/settings`;
await vscode.commands.executeCommand("vscode.open", uri);
} else if (this.workspace && this.remoteWorkspaceClient) {
const baseUrl =
this.remoteWorkspaceClient.getAxiosInstance().defaults.baseURL;
const baseUrl = resolveBrowserUrl(this.requireRemoteBaseUrl());
const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}/settings`;
await vscode.commands.executeCommand("vscode.open", uri);
} else {
Expand Down
5 changes: 4 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 { resolveBrowserUrl } from "../util";
import { vscodeProposed } from "../vscodeProposed";

import type { User } from "coder/site/src/api/typesGenerated";
Expand Down Expand Up @@ -361,7 +362,9 @@ 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 vscode.env.openExternal(
vscode.Uri.parse(`${resolveBrowserUrl(url)}/cli-auth`),
);
Comment on lines +365 to +367
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply alternative URL to OAuth login

This only switches the legacy token page to coder.alternativeWebUrl; when coder.experimental.oauth is enabled and the user chooses OAuth, loginWithOAuth still goes through OAuthAuthorizer.startAuthorization, which opens the discovered authorization URL directly. In deployments where the connection URL uses a browser-restricted/unreachable port and the web UI is available via the alternative URL, OAuth login still opens the restricted connection URL and cannot complete, while token login works.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

We didn't touch the OAuth path deliberately, but I think the case highlighted here is worth addressing so we have consistency. Will submit a fix shortly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed!


// 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
9 changes: 8 additions & 1 deletion 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 { resolveBrowserUrl } from "../util";

import {
AUTH_GRANT_TYPE,
Expand Down Expand Up @@ -201,7 +202,13 @@ export class OAuthAuthorizer implements vscode.Disposable {
code_challenge_method: PKCE_CHALLENGE_METHOD,
});

const url = `${metadata.authorization_endpoint}?${params.toString()}`;
// The server-advertised endpoint is authoritative for the path, but the
// origin may be unreachable from a browser (e.g. blocked port). When
// `coder.alternativeWebUrl` is set, swap the origin so the user lands on
// a reachable host while preserving the path the server told us to use.
const endpoint = new URL(metadata.authorization_endpoint);
const browserBase = resolveBrowserUrl(endpoint.origin);
const url = `${browserBase}${endpoint.pathname}?${params.toString()}`;

this.logger.debug("Built OAuth authorization URL:", {
client_id: clientId,
Expand Down
15 changes: 15 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as os from "node:os";
import url from "node:url";
import * as vscode from "vscode";

export interface AuthorityParts {
agent: string | undefined;
Expand Down Expand Up @@ -202,6 +203,20 @@ export async function renameWithRetry(
}
}

/**
* 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 resolveBrowserUrl(connectionUrl: string): string {
const alt = vscode.workspace
.getConfiguration("coder")
.get<string>("alternativeWebUrl")
?.trim()
.replace(/\/+$/, "");
return alt || connectionUrl;
}

export function escapeCommandArg(arg: string): string {
const escapedString = arg.replaceAll('"', String.raw`\"`);
return `"${escapedString}"`;
Expand Down
8 changes: 7 additions & 1 deletion src/webviews/chat/chatPanelProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {

import { type CoderApi } from "../../api/coderApi";
import { type Logger } from "../../logging/logger";
import { resolveBrowserUrl } from "../../util";
import {
dispatchCommand,
dispatchRequest,
Expand Down Expand Up @@ -154,7 +155,12 @@ export class ChatPanelProvider
const resolved = new URL(url, coderUrl);
const expected = new URL(coderUrl);
if (resolved.origin === expected.origin) {
void vscode.env.openExternal(vscode.Uri.parse(resolved.toString()));
const browserBase = resolveBrowserUrl(coderUrl);
// Concatenate rather than `new URL(path, base)` so a path prefix on
// the alternative URL (e.g. a reverse proxy at https://host/coder)
// is preserved.
const browserUrl = `${browserBase}${resolved.pathname}${resolved.search}${resolved.hash}`;
void vscode.env.openExternal(vscode.Uri.parse(browserUrl));
}
} catch {
this.logger.warn(`Chat: invalid navigate URL: ${url}`);
Expand Down
11 changes: 7 additions & 4 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 { resolveBrowserUrl } from "../../util";
import { vscodeProposed } from "../../vscodeProposed";
import {
dispatchCommand,
Expand Down Expand Up @@ -308,19 +309,21 @@ 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 baseUrl = resolveBrowserUrl(connUrl);
const task = await this.client.getTask("me", taskId);
vscode.env.openExternal(
vscode.Uri.parse(`${baseUrl}/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 baseUrl = resolveBrowserUrl(connUrl);
const task = await this.client.getTask("me", taskId);
vscode.env.openExternal(vscode.Uri.parse(getTaskBuildUrl(baseUrl, task)));
}
Expand Down
28 changes: 28 additions & 0 deletions test/unit/oauth/authorizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,34 @@ 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;
});
});

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,
};
}
63 changes: 63 additions & 0 deletions test/unit/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os from "node:os";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import * as vscode from "vscode";

import {
type AuthorityParts,
Expand All @@ -9,6 +10,7 @@ import {
findPort,
parseRemoteAuthority,
renameWithRetry,
resolveBrowserUrl,
tempFilePath,
toSafeHost,
} from "@/util";
Expand Down Expand Up @@ -389,4 +391,65 @@ describe("renameWithRetry", () => {
},
);
});

describe("resolveBrowserUrl", () => {
function mockAlternativeWebUrl(value: string | undefined): void {
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
get: vi.fn().mockReturnValue(value),
} as unknown as vscode.WorkspaceConfiguration);
}

afterEach(() => {
vi.mocked(vscode.workspace.getConfiguration).mockReset();
});

it("returns connection URL when setting is not configured", () => {
mockAlternativeWebUrl(undefined);
expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe(
"https://coder.example.com:7004",
);
});

it("returns connection URL when setting is empty", () => {
mockAlternativeWebUrl("");
expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe(
"https://coder.example.com:7004",
);
});

it("returns connection URL when setting is whitespace", () => {
mockAlternativeWebUrl(" ");
expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe(
"https://coder.example.com:7004",
);
});

it("returns alternative URL when configured", () => {
mockAlternativeWebUrl("https://coder.example.com");
expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe(
"https://coder.example.com",
);
});

it("strips trailing slashes from alternative URL", () => {
mockAlternativeWebUrl("https://coder.example.com/");
expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe(
"https://coder.example.com",
);
});

it("strips multiple trailing slashes from alternative URL", () => {
mockAlternativeWebUrl("https://coder.example.com///");
expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe(
"https://coder.example.com",
);
});

it("trims whitespace from alternative URL", () => {
mockAlternativeWebUrl(" https://coder.example.com ");
expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe(
"https://coder.example.com",
);
});
});
});
41 changes: 41 additions & 0 deletions test/unit/webviews/chat/chatPanelProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ describe("ChatPanelProvider", () => {
beforeEach(() => {
vi.resetAllMocks();
windowMock.__setActiveColorThemeKind(vscode.ColorThemeKind.Dark);

vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
get: vi.fn(),
has: vi.fn().mockReturnValue(false),
inspect: vi.fn(),
update: vi.fn().mockResolvedValue(undefined),
} as unknown as vscode.WorkspaceConfiguration);
});

describe("theme sync", () => {
Expand Down Expand Up @@ -171,6 +178,40 @@ describe("ChatPanelProvider", () => {
);
});

it("uses alternative web URL for navigation when configured", () => {
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
get: vi.fn().mockReturnValue("https://web.example.com"),
} as unknown as vscode.WorkspaceConfiguration);

const { sendFromWebview } = createHarness();

sendFromWebview({
method: "coder:navigate",
params: { url: "/templates" },
});

expect(vscode.env.openExternal).toHaveBeenCalledWith(
vscode.Uri.parse("https://web.example.com/templates"),
);
});

it("preserves path prefix in alternative web URL", () => {
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
get: vi.fn().mockReturnValue("https://proxy.example.com/coder"),
} as unknown as vscode.WorkspaceConfiguration);

const { sendFromWebview } = createHarness();

sendFromWebview({
method: "coder:navigate",
params: { url: "/templates" },
});

expect(vscode.env.openExternal).toHaveBeenCalledWith(
vscode.Uri.parse("https://proxy.example.com/coder/templates"),
);
});

it("ignores navigate without url payload", () => {
const { sendFromWebview } = createHarness();

Expand Down
Loading