diff --git a/package.json b/package.json index fd5a013bad..1e6b053bc5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands.ts b/src/commands.ts index 1f48d36a40..138c3f68d9 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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 { @@ -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. @@ -573,9 +584,7 @@ export class Commands { * Must only be called if currently logged in. */ public async createWorkspace(): Promise { - const baseUrl = this.requireExtensionBaseUrl(); - const uri = baseUrl + "/templates"; - await vscode.commands.executeCommand("vscode.open", uri); + await openInBrowser(this.requireExtensionBaseUrl(), "/templates"); } /** @@ -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."); } @@ -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."); } diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index e70da211a5..baac3f28d9 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -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"; @@ -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 diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index 3d45bd61d9..da149ced8f 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; +import { resolveUiUrl } from "../util"; import { AUTH_GRANT_TYPE, @@ -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, @@ -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, @@ -205,7 +208,7 @@ export class OAuthAuthorizer implements vscode.Disposable { } } - const params = new URLSearchParams({ + const params: Record = { client_id: clientId, response_type: RESPONSE_TYPE, redirect_uri: this.getRedirectUri(), @@ -213,9 +216,20 @@ export class OAuthAuthorizer implements vscode.Disposable { 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); + } this.logger.debug("Built OAuth authorization URL:", { client_id: clientId, @@ -223,7 +237,7 @@ export class OAuthAuthorizer implements vscode.Disposable { scope: DEFAULT_OAUTH_SCOPES, }); - return url; + return endpoint.toString(); } /** @@ -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, @@ -240,6 +255,7 @@ export class OAuthAuthorizer implements vscode.Disposable { const { verifier, challenge } = generatePKCE(); const authUrl = this.buildAuthorizationUrl( + connectionUrl, metadata, registration.client_id, state, diff --git a/src/util.ts b/src/util.ts index 405d4f1769..08329b7417 100644 --- a/src/util.ts +++ b/src/util.ts @@ -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; @@ -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("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 { + const base = vscode.Uri.parse(resolveUiUrl(connectionUrl)); + const segment = path.replace(/^\/+/, ""); + return vscode.env.openExternal(vscode.Uri.joinPath(base, segment)); +} diff --git a/src/webviews/tasks/tasksPanelProvider.ts b/src/webviews/tasks/tasksPanelProvider.ts index 46614665f6..7aad4ef364 100644 --- a/src/webviews/tasks/tasksPanelProvider.ts +++ b/src/webviews/tasks/tasksPanelProvider.ts @@ -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, @@ -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 @@ -308,21 +309,19 @@ export class TasksPanelProvider } private async handleViewInCoder(taskId: string): Promise { - 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 { - 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 { diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index 55560d84e9..336d2c2f1e 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -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", () => { diff --git a/test/unit/oauth/testUtils.ts b/test/unit/oauth/testUtils.ts index 714c581197..ce4736122e 100644 --- a/test/unit/oauth/testUtils.ts +++ b/test/unit/oauth/testUtils.ts @@ -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(); @@ -148,5 +148,6 @@ export function createBaseTestContext() { oauthCallback, logger, setupOAuthRoutes, + configurationProvider, }; } diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index 02212df16a..bb9b292eea 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -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, @@ -8,10 +9,14 @@ import { escapeShellArg, expandPath, findPort, + openInBrowser, parseRemoteAuthority, + resolveUiUrl, toSafeHost, } from "@/util"; +import { MockConfigurationProvider } from "../mocks/testHelpers"; + describe("parseRemoteAuthority", () => { const remoteAuthority = (sshHost: string) => `vscode://ssh-remote+${sshHost}`; @@ -397,3 +402,124 @@ describe("findPort", () => { expect(findPort(log)).toBe(3333); }); }); + +describe("resolveUiUrl", () => { + let configurationProvider: MockConfigurationProvider; + + beforeEach(() => { + configurationProvider = new MockConfigurationProvider(); + }); + + afterEach(() => { + vi.mocked(vscode.workspace.getConfiguration).mockReset(); + }); + + it("returns connection URL when setting is not configured", () => { + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com:7004", + ); + }); + + it("returns connection URL when setting is empty", () => { + configurationProvider.set("coder.alternativeWebUrl", ""); + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com:7004", + ); + }); + + it("returns connection URL when setting is whitespace", () => { + configurationProvider.set("coder.alternativeWebUrl", " "); + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com:7004", + ); + }); + + it("returns alternative URL when configured", () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com", + ); + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com", + ); + }); + + it("strips trailing slashes from alternative URL", () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com/", + ); + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com", + ); + }); + + it("strips multiple trailing slashes from alternative URL", () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com///", + ); + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com", + ); + }); + + it("trims whitespace from alternative URL", () => { + configurationProvider.set( + "coder.alternativeWebUrl", + " https://coder.example.com ", + ); + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com", + ); + }); +}); + +describe("openInBrowser", () => { + let configurationProvider: MockConfigurationProvider; + + beforeEach(() => { + configurationProvider = new MockConfigurationProvider(); + vi.mocked(vscode.env.openExternal).mockClear(); + }); + + afterEach(() => { + vi.mocked(vscode.workspace.getConfiguration).mockReset(); + }); + + it("opens the connection URL with the given path when no alt URL set", () => { + openInBrowser("https://coder.example.com:7004", "/templates"); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://coder.example.com:7004/templates"), + ); + }); + + it("opens the alternative URL when configured", () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com", + ); + openInBrowser("https://coder.example.com:7004", "/templates"); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://coder.example.com/templates"), + ); + }); + + it("preserves a path prefix on the alternative URL", () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://proxy.example.com/coder", + ); + openInBrowser("https://coder.example.com:7004", "/templates"); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://proxy.example.com/coder/templates"), + ); + }); + + it("handles paths without a leading slash", () => { + openInBrowser("https://coder.example.com", "templates"); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://coder.example.com/templates"), + ); + }); +}); diff --git a/test/unit/webviews/tasks/tasksPanelProvider.test.ts b/test/unit/webviews/tasks/tasksPanelProvider.test.ts index 99526d2af7..69b5118f94 100644 --- a/test/unit/webviews/tasks/tasksPanelProvider.test.ts +++ b/test/unit/webviews/tasks/tasksPanelProvider.test.ts @@ -23,6 +23,7 @@ import { import { createAxiosError, createMockLogger, + MockConfigurationProvider, MockUserInteraction, } from "../../../mocks/testHelpers"; @@ -200,9 +201,13 @@ function createHarness(): Harness { } describe("TasksPanelProvider", () => { + let configurationProvider: MockConfigurationProvider; + beforeEach(() => { // Reset shared vscode mocks between tests vi.resetAllMocks(); + + configurationProvider = new MockConfigurationProvider(); }); describe("getTasks", () => { @@ -678,6 +683,48 @@ describe("TasksPanelProvider", () => { expect(vscode.env.openExternal).not.toHaveBeenCalled(); }); + + it("viewInCoder uses alternative web URL when configured", async () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com:443", + ); + + const h = createHarness(); + h.client.getTask.mockResolvedValue( + task({ id: "task-1", owner_name: "alice" }), + ); + + await h.command(TasksApi.viewInCoder, { taskId: "task-1" }); + + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://coder.example.com:443/tasks/alice/task-1"), + ); + }); + + it("viewLogs uses alternative web URL when configured", async () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com:443", + ); + + const h = createHarness(); + h.client.getTask.mockResolvedValue( + task({ + owner_name: "alice", + workspace_name: "my-ws", + workspace_build_number: 42, + }), + ); + + await h.command(TasksApi.viewLogs, { taskId: "task-1" }); + + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse( + "https://coder.example.com:443/@alice/my-ws/builds/42", + ), + ); + }); }); describe("downloadLogs", () => {