From 57ad426bb969931f3bccc6beb1cc0d787e87afe1 Mon Sep 17 00:00:00 2001 From: Itay Dafna Date: Fri, 15 May 2026 10:55:56 -0700 Subject: [PATCH 1/6] Add `alternativeWebUrl` param --- package.json | 5 ++ src/commands.ts | 25 +++++--- src/login/loginCoordinator.ts | 5 +- src/util.ts | 15 +++++ src/webviews/tasks/tasksPanelProvider.ts | 11 ++-- test/unit/util.test.ts | 63 +++++++++++++++++++ .../webviews/tasks/tasksPanelProvider.test.ts | 47 ++++++++++++++ 7 files changed, 158 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 03cf037b0f..00b717778e 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). 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", diff --git a/src/commands.ts b/src/commands.ts index 6dc95290d6..19bc7007ab 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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 { resolveBrowserUrl, toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; import { parseSpeedtestResult } from "./webviews/speedtest/types"; import { @@ -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. @@ -587,7 +598,7 @@ export class Commands { * Must only be called if currently logged in. */ public async createWorkspace(): Promise { - const baseUrl = this.requireExtensionBaseUrl(); + const baseUrl = resolveBrowserUrl(this.requireExtensionBaseUrl()); const uri = baseUrl + "/templates"; await vscode.commands.executeCommand("vscode.open", uri); } @@ -602,13 +613,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 { @@ -626,13 +636,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 { diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index e70da211a5..2e0a67b963 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 { resolveBrowserUrl } from "../util"; import { vscodeProposed } from "../vscodeProposed"; import type { User } from "coder/site/src/api/typesGenerated"; @@ -398,7 +399,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`), + ); // 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/util.ts b/src/util.ts index 405d4f1769..f8994d4691 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,17 @@ 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 resolveBrowserUrl(connectionUrl: string): string { + const alt = vscode.workspace + .getConfiguration("coder") + .get("alternativeWebUrl") + ?.trim() + .replace(/\/+$/, ""); + return alt || connectionUrl; +} diff --git a/src/webviews/tasks/tasksPanelProvider.ts b/src/webviews/tasks/tasksPanelProvider.ts index 46614665f6..cfab63860d 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 { resolveBrowserUrl } from "../../util"; import { vscodeProposed } from "../../vscodeProposed"; import { dispatchCommand, @@ -308,9 +309,10 @@ 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 baseUrl = resolveBrowserUrl(connUrl); const task = await this.client.getTask("me", taskId); vscode.env.openExternal( vscode.Uri.parse(`${baseUrl}/tasks/${task.owner_name}/${task.id}`), @@ -318,9 +320,10 @@ export class TasksPanelProvider } private async handleViewLogs(taskId: string): Promise { - 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))); } diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index 02212df16a..6d5408c8ce 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, @@ -9,6 +10,7 @@ import { expandPath, findPort, parseRemoteAuthority, + resolveBrowserUrl, toSafeHost, } from "@/util"; @@ -397,3 +399,64 @@ describe("findPort", () => { expect(findPort(log)).toBe(3333); }); }); + +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", + ); + }); +}); diff --git a/test/unit/webviews/tasks/tasksPanelProvider.test.ts b/test/unit/webviews/tasks/tasksPanelProvider.test.ts index 99526d2af7..2103c0287e 100644 --- a/test/unit/webviews/tasks/tasksPanelProvider.test.ts +++ b/test/unit/webviews/tasks/tasksPanelProvider.test.ts @@ -203,6 +203,13 @@ describe("TasksPanelProvider", () => { beforeEach(() => { // Reset shared vscode mocks between tests vi.resetAllMocks(); + + 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("getTasks", () => { @@ -678,6 +685,46 @@ describe("TasksPanelProvider", () => { expect(vscode.env.openExternal).not.toHaveBeenCalled(); }); + + it("viewInCoder uses alternative web URL when configured", async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue("https://coder.example.com:443"), + } as unknown as vscode.WorkspaceConfiguration); + + 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 () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue("https://coder.example.com:443"), + } as unknown as vscode.WorkspaceConfiguration); + + 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", () => { From 1054a8ae73e7031b6628ce4f68e00c79ca7b50fd Mon Sep 17 00:00:00 2001 From: Itay Dafna Date: Fri, 15 May 2026 11:45:56 -0700 Subject: [PATCH 2/6] Codex review: apply `alternativeWebUrl` to OAuth base authorization URL --- package.json | 10 +++++----- src/oauth/authorizer.ts | 9 ++++++++- test/unit/oauth/authorizer.test.ts | 28 ++++++++++++++++++++++++++++ test/unit/oauth/testUtils.ts | 3 ++- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 00b717778e..d5c42924a4 100644 --- a/package.json +++ b/package.json @@ -134,11 +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). 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.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", diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index 3d45bd61d9..c6d1c36df1 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 { resolveBrowserUrl } from "../util"; import { AUTH_GRANT_TYPE, @@ -215,7 +216,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, diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index 55560d84e9..fc0ad4aaf2 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -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", () => { 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, }; } From 16324622d141b68df19b098f140467f517905b73 Mon Sep 17 00:00:00 2001 From: Itay Dafna Date: Thu, 28 May 2026 13:37:44 -0700 Subject: [PATCH 3/6] Address feedback --- package.json | 10 +- src/commands.ts | 31 +++--- src/login/loginCoordinator.ts | 6 +- src/oauth/authorizer.ts | 29 +++-- src/util.ts | 15 ++- src/webviews/tasks/tasksPanelProvider.ts | 18 ++- test/unit/oauth/authorizer.test.ts | 54 ++++++++- test/unit/util.test.ts | 105 ++++++++++++++---- .../webviews/tasks/tasksPanelProvider.test.ts | 24 ++-- 9 files changed, 206 insertions(+), 86 deletions(-) diff --git a/package.json b/package.json index d5c42924a4..81d0e64546 100644 --- a/package.json +++ b/package.json @@ -134,11 +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). 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.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 19bc7007ab..1a219148f7 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -27,7 +27,7 @@ import { import { resolveCliAuth } from "./settings/cli"; import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs"; import { runExportTelemetryCommand } from "./telemetry/export/command"; -import { resolveBrowserUrl, toRemoteAuthority, toSafeHost } from "./util"; +import { openInBrowser, toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; import { parseSpeedtestResult } from "./webviews/speedtest/types"; import { @@ -598,9 +598,7 @@ export class Commands { * Must only be called if currently logged in. */ public async createWorkspace(): Promise { - const baseUrl = resolveBrowserUrl(this.requireExtensionBaseUrl()); - const uri = baseUrl + "/templates"; - await vscode.commands.executeCommand("vscode.open", uri); + await openInBrowser(this.requireExtensionBaseUrl(), "/templates"); } /** @@ -613,14 +611,13 @@ export class Commands { */ public async navigateToWorkspace(item?: OpenableTreeItem) { if (item) { - const baseUrl = resolveBrowserUrl(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 = resolveBrowserUrl(this.requireRemoteBaseUrl()); - 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."); } @@ -636,14 +633,16 @@ export class Commands { */ public async navigateToWorkspaceSettings(item?: OpenableTreeItem) { if (item) { - const baseUrl = resolveBrowserUrl(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 = resolveBrowserUrl(this.requireRemoteBaseUrl()); - 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 2e0a67b963..baac3f28d9 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -10,7 +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 { openInBrowser } from "../util"; import { vscodeProposed } from "../vscodeProposed"; import type { User } from "coder/site/src/api/typesGenerated"; @@ -399,9 +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(`${resolveBrowserUrl(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 c6d1c36df1..da149ced8f 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; -import { resolveBrowserUrl } from "../util"; +import { resolveUiUrl } from "../util"; import { AUTH_GRANT_TYPE, @@ -99,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, @@ -188,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, @@ -206,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(), @@ -214,15 +216,20 @@ export class OAuthAuthorizer implements vscode.Disposable { state, code_challenge: challenge, code_challenge_method: PKCE_CHALLENGE_METHOD, - }); + }; - // 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. + // Server is authoritative for the path; alternativeWebUrl can swap the origin. const endpoint = new URL(metadata.authorization_endpoint); - const browserBase = resolveBrowserUrl(endpoint.origin); - const url = `${browserBase}${endpoint.pathname}?${params.toString()}`; + 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, @@ -230,7 +237,7 @@ export class OAuthAuthorizer implements vscode.Disposable { scope: DEFAULT_OAUTH_SCOPES, }); - return url; + return endpoint.toString(); } /** @@ -239,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, @@ -247,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 f8994d4691..08329b7417 100644 --- a/src/util.ts +++ b/src/util.ts @@ -202,7 +202,7 @@ export function escapeShellArg(arg: string): string { * `coder.alternativeWebUrl` setting when configured, otherwise returns * the connection URL unchanged. */ -export function resolveBrowserUrl(connectionUrl: string): string { +export function resolveUiUrl(connectionUrl: string): string { const alt = vscode.workspace .getConfiguration("coder") .get("alternativeWebUrl") @@ -210,3 +210,16 @@ export function resolveBrowserUrl(connectionUrl: string): string { .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 cfab63860d..7aad4ef364 100644 --- a/src/webviews/tasks/tasksPanelProvider.ts +++ b/src/webviews/tasks/tasksPanelProvider.ts @@ -25,7 +25,7 @@ import { streamBuildLogs, } from "../../api/workspace"; import { type Logger } from "../../logging/logger"; -import { resolveBrowserUrl } from "../../util"; +import { openInBrowser } from "../../util"; import { vscodeProposed } from "../../vscodeProposed"; import { dispatchCommand, @@ -44,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 @@ -312,20 +312,16 @@ export class TasksPanelProvider 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}`), - ); + await openInBrowser(connUrl, `/tasks/${task.owner_name}/${task.id}`); } private async handleViewLogs(taskId: string): Promise { 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))); + 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 fc0ad4aaf2..336d2c2f1e 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -262,12 +262,8 @@ describe("OAuthAuthorizer", () => { }); it("rewrites authorization endpoint origin when alternativeWebUrl is set", async () => { - const { - mockAdapter, - configurationProvider, - startLogin, - completeLogin, - } = createTestContext(); + const { mockAdapter, configurationProvider, startLogin, completeLogin } = + createTestContext(); configurationProvider.set( "coder.alternativeWebUrl", "https://web.example.com", @@ -288,6 +284,52 @@ describe("OAuthAuthorizer", () => { 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/util.test.ts b/test/unit/util.test.ts index 6d5408c8ce..bb9b292eea 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -9,11 +9,14 @@ import { escapeShellArg, expandPath, findPort, + openInBrowser, parseRemoteAuthority, - resolveBrowserUrl, + resolveUiUrl, toSafeHost, } from "@/util"; +import { MockConfigurationProvider } from "../mocks/testHelpers"; + describe("parseRemoteAuthority", () => { const remoteAuthority = (sshHost: string) => `vscode://ssh-remote+${sshHost}`; @@ -400,63 +403,123 @@ describe("findPort", () => { }); }); -describe("resolveBrowserUrl", () => { - function mockAlternativeWebUrl(value: string | undefined): void { - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ - get: vi.fn().mockReturnValue(value), - } as unknown as vscode.WorkspaceConfiguration); - } +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", () => { - mockAlternativeWebUrl(undefined); - expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + expect(resolveUiUrl("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( + 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", () => { - mockAlternativeWebUrl(" "); - expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + configurationProvider.set("coder.alternativeWebUrl", " "); + expect(resolveUiUrl("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( + 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", () => { - mockAlternativeWebUrl("https://coder.example.com/"); - expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + 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", () => { - mockAlternativeWebUrl("https://coder.example.com///"); - expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + 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", () => { - mockAlternativeWebUrl(" https://coder.example.com "); - expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + 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 2103c0287e..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,16 +201,13 @@ function createHarness(): Harness { } describe("TasksPanelProvider", () => { + let configurationProvider: MockConfigurationProvider; + beforeEach(() => { // Reset shared vscode mocks between tests vi.resetAllMocks(); - 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); + configurationProvider = new MockConfigurationProvider(); }); describe("getTasks", () => { @@ -687,9 +685,10 @@ describe("TasksPanelProvider", () => { }); it("viewInCoder uses alternative web URL when configured", async () => { - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ - get: vi.fn().mockReturnValue("https://coder.example.com:443"), - } as unknown as vscode.WorkspaceConfiguration); + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com:443", + ); const h = createHarness(); h.client.getTask.mockResolvedValue( @@ -704,9 +703,10 @@ describe("TasksPanelProvider", () => { }); it("viewLogs uses alternative web URL when configured", async () => { - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ - get: vi.fn().mockReturnValue("https://coder.example.com:443"), - } as unknown as vscode.WorkspaceConfiguration); + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com:443", + ); const h = createHarness(); h.client.getTask.mockResolvedValue( From 5222afcbcaba4849d40a6cbdb5e081ba50e14a6b Mon Sep 17 00:00:00 2001 From: Itay Dafna Date: Mon, 8 Jun 2026 10:03:34 -0700 Subject: [PATCH 4/6] Refactor OAuth endpoint handling to correctly manage path prefixes for sub-path deployments and alternative web URLs. Add tests to verify that the path prefix is not doubled and that the correct prefix is swapped based on the alternative web URL configuration. --- src/oauth/authorizer.ts | 20 ++++++++--- test/unit/oauth/authorizer.test.ts | 57 +++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index da149ced8f..b6a4498a06 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -218,15 +218,27 @@ export class OAuthAuthorizer implements vscode.Disposable { code_challenge_method: PKCE_CHALLENGE_METHOD, }; - // Server is authoritative for the path; alternativeWebUrl can swap the origin. + // The server's endpoint path already includes the connection URL's path + // prefix (e.g. a sub-path deployment). alternativeWebUrl can swap the + // origin and path prefix (e.g. a reverse proxy) for browser use. const endpoint = new URL(metadata.authorization_endpoint); + const connectionBase = new URL(connectionUrl); 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}`; + // Swap the connection prefix for the browser prefix without doubling it. + const connectionPrefix = connectionBase.pathname.replace(/\/$/, ""); + const browserPrefix = browserBase.pathname.replace(/\/$/, ""); + let endpointPath = endpoint.pathname; + if ( + connectionPrefix && + (endpointPath === connectionPrefix || + endpointPath.startsWith(`${connectionPrefix}/`)) + ) { + endpointPath = endpointPath.slice(connectionPrefix.length); + } + endpoint.pathname = `${browserPrefix}${endpointPath}`; for (const [key, value] of Object.entries(params)) { endpoint.searchParams.set(key, value); } diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index 336d2c2f1e..2c378a635c 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -20,6 +20,7 @@ import { TEST_URL, } from "./testUtils"; +import type { Deployment } from "@/deployment/types"; import type { CreateAxiosDefaults } from "axios"; vi.mock("axios", async () => { @@ -70,11 +71,12 @@ function createTestContext() { const startLogin = async (options?: { progress?: MockProgress; token?: MockCancellationToken; + deployment?: Deployment; }) => { const progress = options?.progress ?? new MockProgress(); const token = options?.token ?? new MockCancellationToken(); const loginPromise = authorizer.login( - createTestDeployment(), + options?.deployment ?? createTestDeployment(), progress, token, ); @@ -309,6 +311,59 @@ describe("OAuthAuthorizer", () => { await loginPromise; }); + it("does not double the path prefix on sub-path deployments", async () => { + const { mockAdapter, startLogin, completeLogin } = createTestContext(); + setupAxiosMockRoutes(mockAdapter, { + "/.well-known/oauth-authorization-server": createMockOAuthMetadata( + "https://example.com/coder", + ), + "/oauth2/register": createMockClientRegistration(), + "/oauth2/token": createMockTokenResponse(), + "/api/v2/users/me": { username: "test-user" }, + }); + + const { loginPromise, authUrl, state } = await startLogin({ + deployment: { + url: "https://example.com/coder", + safeHostname: "example.com", + }, + }); + expect(authUrl.origin).toBe("https://example.com"); + expect(authUrl.pathname).toBe("/coder/oauth2/authorize"); + + await completeLogin(state); + await loginPromise; + }); + + it("swaps the sub-path prefix for the alternativeWebUrl prefix", async () => { + const { mockAdapter, configurationProvider, startLogin, completeLogin } = + createTestContext(); + configurationProvider.set( + "coder.alternativeWebUrl", + "https://proxy.example.com/proxy", + ); + setupAxiosMockRoutes(mockAdapter, { + "/.well-known/oauth-authorization-server": createMockOAuthMetadata( + "https://example.com/coder", + ), + "/oauth2/register": createMockClientRegistration(), + "/oauth2/token": createMockTokenResponse(), + "/api/v2/users/me": { username: "test-user" }, + }); + + const { loginPromise, authUrl, state } = await startLogin({ + deployment: { + url: "https://example.com/coder", + safeHostname: "example.com", + }, + }); + expect(authUrl.origin).toBe("https://proxy.example.com"); + expect(authUrl.pathname).toBe("/proxy/oauth2/authorize"); + + await completeLogin(state); + await loginPromise; + }); + it("preserves query params already on the authorization endpoint", async () => { const { mockAdapter, startLogin, completeLogin } = createTestContext(); setupAxiosMockRoutes(mockAdapter, { From 513a8362c191c9d7e2bc804f79b03a175d9a0f39 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 8 Jun 2026 23:35:54 +0300 Subject: [PATCH 5/6] fix: harden alternativeWebUrl handling and tidy tests - Scope coder.alternativeWebUrl to "application" so a workspace's settings.json cannot override the browser redirect target - Only rebase the OAuth authorization endpoint when it lives under the connection URL, and extract the logic into toBrowserAuthorizationUrl - Build params with URLSearchParams and merge onto the endpoint, so an endpoint that already carries a query string is preserved - Simplify openInBrowser to rely on Uri.joinPath - Make the mock Uri.joinPath collapse seam slashes like vscode-uri - Simplify the new util/authorizer/tasks tests --- package.json | 3 +- src/oauth/authorizer.ts | 59 ++++++---- src/util.ts | 3 +- test/mocks/vscode.runtime.ts | 10 +- test/unit/oauth/authorizer.test.ts | 111 +++++++++--------- test/unit/oauth/testUtils.ts | 11 +- test/unit/util.test.ts | 87 +++++--------- .../webviews/tasks/tasksPanelProvider.test.ts | 6 +- 8 files changed, 136 insertions(+), 154 deletions(-) diff --git a/package.json b/package.json index 81d0e64546..ac5166732f 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,8 @@ "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": "" + "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.", diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index b6a4498a06..809ccfaed5 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -208,7 +208,7 @@ export class OAuthAuthorizer implements vscode.Disposable { } } - const params: Record = { + const params = new URLSearchParams({ client_id: clientId, response_type: RESPONSE_TYPE, redirect_uri: this.getRedirectUri(), @@ -216,30 +216,13 @@ export class OAuthAuthorizer implements vscode.Disposable { state, code_challenge: challenge, code_challenge_method: PKCE_CHALLENGE_METHOD, - }; + }); - // The server's endpoint path already includes the connection URL's path - // prefix (e.g. a sub-path deployment). alternativeWebUrl can swap the - // origin and path prefix (e.g. a reverse proxy) for browser use. - const endpoint = new URL(metadata.authorization_endpoint); - const connectionBase = new URL(connectionUrl); - const browserBase = new URL(resolveUiUrl(connectionUrl)); - endpoint.protocol = browserBase.protocol; - endpoint.hostname = browserBase.hostname; - endpoint.port = browserBase.port; - // Swap the connection prefix for the browser prefix without doubling it. - const connectionPrefix = connectionBase.pathname.replace(/\/$/, ""); - const browserPrefix = browserBase.pathname.replace(/\/$/, ""); - let endpointPath = endpoint.pathname; - if ( - connectionPrefix && - (endpointPath === connectionPrefix || - endpointPath.startsWith(`${connectionPrefix}/`)) - ) { - endpointPath = endpointPath.slice(connectionPrefix.length); - } - endpoint.pathname = `${browserPrefix}${endpointPath}`; - for (const [key, value] of Object.entries(params)) { + const endpoint = toBrowserAuthorizationUrl( + metadata.authorization_endpoint, + connectionUrl, + ); + for (const [key, value] of params) { endpoint.searchParams.set(key, value); } @@ -387,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; +} diff --git a/src/util.ts b/src/util.ts index 08329b7417..375d6a29c2 100644 --- a/src/util.ts +++ b/src/util.ts @@ -220,6 +220,5 @@ export function openInBrowser( path: string, ): Thenable { const base = vscode.Uri.parse(resolveUiUrl(connectionUrl)); - const segment = path.replace(/^\/+/, ""); - return vscode.env.openExternal(vscode.Uri.joinPath(base, segment)); + return vscode.env.openExternal(vscode.Uri.joinPath(base, path)); } diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 42415bcb24..a111a5c42c 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -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); } } diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index 2c378a635c..976a8d4069 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -20,9 +20,10 @@ import { TEST_URL, } from "./testUtils"; -import type { Deployment } from "@/deployment/types"; import type { CreateAxiosDefaults } from "axios"; +import type { Deployment } from "@/deployment/types"; + vi.mock("axios", async () => { const actual = await vi.importActual("axios"); const mockAdapter = vi.fn(); @@ -263,47 +264,55 @@ describe("OAuthAuthorizer", () => { ]); }); - it("rewrites authorization endpoint origin when alternativeWebUrl is set", async () => { - const { mockAdapter, configurationProvider, startLogin, completeLogin } = - createTestContext(); + it("rewrites the endpoint origin when alternativeWebUrl is set", async () => { + const { + configurationProvider, + setupOAuthRoutes, + startLogin, + completeLogin, + } = createTestContext(); configurationProvider.set( "coder.alternativeWebUrl", - "https://web.example.com", + "https://coder.example.com", + ); + setupOAuthRoutes( + createMockOAuthMetadata("https://coder.example.com:7004"), ); - 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"); + const { loginPromise, authUrl, state } = await startLogin({ + deployment: { + url: "https://coder.example.com:7004", + safeHostname: "coder.example.com", + }, + }); + expect(authUrl.origin).toBe("https://coder.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(); + it("preserves a path prefix on alternativeWebUrl", async () => { + const { + configurationProvider, + setupOAuthRoutes, + 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" }, - }); + setupOAuthRoutes( + createMockOAuthMetadata("https://coder.example.com:7004"), + ); - const { loginPromise, authUrl, state } = await startLogin(); + const { loginPromise, authUrl, state } = await startLogin({ + deployment: { + url: "https://coder.example.com:7004", + safeHostname: "coder.example.com", + }, + }); expect(authUrl.origin).toBe("https://proxy.example.com"); expect(authUrl.pathname).toBe("/coder/oauth2/authorize"); @@ -312,15 +321,9 @@ describe("OAuthAuthorizer", () => { }); it("does not double the path prefix on sub-path deployments", async () => { - const { mockAdapter, startLogin, completeLogin } = createTestContext(); - setupAxiosMockRoutes(mockAdapter, { - "/.well-known/oauth-authorization-server": createMockOAuthMetadata( - "https://example.com/coder", - ), - "/oauth2/register": createMockClientRegistration(), - "/oauth2/token": createMockTokenResponse(), - "/api/v2/users/me": { username: "test-user" }, - }); + const { setupOAuthRoutes, startLogin, completeLogin } = + createTestContext(); + setupOAuthRoutes(createMockOAuthMetadata("https://example.com/coder")); const { loginPromise, authUrl, state } = await startLogin({ deployment: { @@ -336,20 +339,17 @@ describe("OAuthAuthorizer", () => { }); it("swaps the sub-path prefix for the alternativeWebUrl prefix", async () => { - const { mockAdapter, configurationProvider, startLogin, completeLogin } = - createTestContext(); + const { + configurationProvider, + setupOAuthRoutes, + startLogin, + completeLogin, + } = createTestContext(); configurationProvider.set( "coder.alternativeWebUrl", "https://proxy.example.com/proxy", ); - setupAxiosMockRoutes(mockAdapter, { - "/.well-known/oauth-authorization-server": createMockOAuthMetadata( - "https://example.com/coder", - ), - "/oauth2/register": createMockClientRegistration(), - "/oauth2/token": createMockTokenResponse(), - "/api/v2/users/me": { username: "test-user" }, - }); + setupOAuthRoutes(createMockOAuthMetadata("https://example.com/coder")); const { loginPromise, authUrl, state } = await startLogin({ deployment: { @@ -365,18 +365,13 @@ describe("OAuthAuthorizer", () => { }); 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 { setupOAuthRoutes, startLogin, completeLogin } = + createTestContext(); + setupOAuthRoutes( + createMockOAuthMetadata(TEST_URL, { + authorization_endpoint: `${TEST_URL}/oauth2/authorize?audience=workspace`, + }), + ); const { loginPromise, authUrl, state } = await startLogin(); expect(authUrl.searchParams.get("audience")).toBe("workspace"); diff --git a/test/unit/oauth/testUtils.ts b/test/unit/oauth/testUtils.ts index ce4736122e..58e210fb54 100644 --- a/test/unit/oauth/testUtils.ts +++ b/test/unit/oauth/testUtils.ts @@ -131,11 +131,14 @@ export function createBaseTestContext() { const secretsManager = new SecretsManager(secretStorage, memento, logger); const oauthCallback = new OAuthCallback(secretStorage, logger); - /** Sets up default OAuth routes - use explicit routes when asserting on values */ - const setupOAuthRoutes = () => { + /** Sets up OAuth routes, defaulting to metadata for TEST_URL. */ + const setupOAuthRoutes = ( + metadata: OAuth2AuthorizationServerMetadata = createMockOAuthMetadata( + TEST_URL, + ), + ) => { setupAxiosMockRoutes(mockAdapter, { - "/.well-known/oauth-authorization-server": - createMockOAuthMetadata(TEST_URL), + "/.well-known/oauth-authorization-server": metadata, "/oauth2/register": createMockClientRegistration(), "/oauth2/token": createMockTokenResponse(), "/api/v2/users/me": { username: "test-user" }, diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index bb9b292eea..39daa59dc2 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -410,65 +410,38 @@ describe("resolveUiUrl", () => { 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", ""); + it("returns the connection URL when no alternative is configured", () => { 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.each([ + { name: "empty", value: "" }, + { name: "whitespace", value: " " }, + ])( + "returns the connection URL when the alternative is $name", + ({ value }) => { + configurationProvider.set("coder.alternativeWebUrl", value); + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com:7004", + ); + }, + ); - it("trims whitespace from alternative URL", () => { - configurationProvider.set( - "coder.alternativeWebUrl", - " https://coder.example.com ", - ); + it.each([ + { + name: "uses the alternative URL when configured", + value: "https://coder.example.com", + }, + { name: "strips trailing slashes", value: "https://coder.example.com/" }, + { + name: "strips multiple trailing slashes", + value: "https://coder.example.com///", + }, + { name: "trims whitespace", value: " https://coder.example.com " }, + ])("$name", ({ value }) => { + configurationProvider.set("coder.alternativeWebUrl", value); expect(resolveUiUrl("https://coder.example.com:7004")).toBe( "https://coder.example.com", ); @@ -483,11 +456,7 @@ describe("openInBrowser", () => { 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", () => { + it("opens the connection URL joined with the path when no alt URL is set", () => { openInBrowser("https://coder.example.com:7004", "/templates"); expect(vscode.env.openExternal).toHaveBeenCalledWith( vscode.Uri.parse("https://coder.example.com:7004/templates"), @@ -516,7 +485,7 @@ describe("openInBrowser", () => { ); }); - it("handles paths without a leading slash", () => { + it("joins 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 69b5118f94..1e73477d18 100644 --- a/test/unit/webviews/tasks/tasksPanelProvider.test.ts +++ b/test/unit/webviews/tasks/tasksPanelProvider.test.ts @@ -684,12 +684,11 @@ describe("TasksPanelProvider", () => { expect(vscode.env.openExternal).not.toHaveBeenCalled(); }); - it("viewInCoder uses alternative web URL when configured", async () => { + it("viewInCoder uses the 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" }), @@ -702,12 +701,11 @@ describe("TasksPanelProvider", () => { ); }); - it("viewLogs uses alternative web URL when configured", async () => { + it("viewLogs uses the alternative web URL when configured", async () => { configurationProvider.set( "coder.alternativeWebUrl", "https://coder.example.com:443", ); - const h = createHarness(); h.client.getTask.mockResolvedValue( task({ From 92ea26b0fafadfa52526ff46696f72ee26e0e496 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 9 Jun 2026 00:22:05 +0300 Subject: [PATCH 6/6] docs: make alternativeWebUrl setting description more concise --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac5166732f..e98f549b12 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "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).", + "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"