From 8255cb554e7450a9fe9305f0ba4cc444c1f85269 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 29 Sep 2026 12:55:17 +0300 Subject: [PATCH 1/2] fix(oauth): request the scopes the extension and CLI actually need Servers that enforce OAuth2 scopes rejected GET /users/me right after the token exchange, so OAuth login always failed. The same gap broke resolving a workspace by owner and name, which `coder ssh`, start, update, ping, speedtest and support bundle all do, and stop builds were refused outright. Request coder:workspaces.operate and coder:workspaces.access (which also grant organization_member:read, required for shared workspaces and not requestable on its own), workspace:create for `coder start` dry-runs when a workspace must update, and user:read. Verified against v2.25 through v2.37 and current main. Fixes VSC-24 --- src/oauth/constants.ts | 15 ++++++++------- test/unit/oauth/sessionManager.test.ts | 5 +++-- test/unit/oauth/testUtils.ts | 10 ++++------ 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/oauth/constants.ts b/src/oauth/constants.ts index 542fa517c9..4f45a80efd 100644 --- a/src/oauth/constants.ts +++ b/src/oauth/constants.ts @@ -2,14 +2,15 @@ export const AUTH_GRANT_TYPE = "authorization_code"; export const REFRESH_GRANT_TYPE = "refresh_token"; -// Minimal scopes required by the VS Code extension +// Minimal scopes required by the VS Code extension and the CLI it runs export const DEFAULT_OAUTH_SCOPES = [ - "workspace:read", - "workspace:update", - "workspace:start", - "workspace:ssh", - "workspace:application_connect", - "template:read", + // Composites also grant organization_member:read, needed for shared workspaces + "coder:workspaces.operate", + "coder:workspaces.access", + // `coder start` dry-runs a build when the workspace must update first + "workspace:create", + // `/users/me` and workspace owner lookups + "user:read", "user:read_personal", ].join(" "); diff --git a/test/unit/oauth/sessionManager.test.ts b/test/unit/oauth/sessionManager.test.ts index b02927fc4a..0569e2a333 100644 --- a/test/unit/oauth/sessionManager.test.ts +++ b/test/unit/oauth/sessionManager.test.ts @@ -531,8 +531,9 @@ describe("OAuthSessionManager", () => { oauth: { refresh_token: "refresh-token", expiry_timestamp: Date.now() + ONE_HOUR_MS, - // workspace:* covers workspace:read, workspace:update, etc. - scope: "workspace:* template:read user:read_personal", + // workspace:* and user:* cover the low-level scopes + scope: + "coder:workspaces.operate coder:workspaces.access workspace:* user:*", }, }); diff --git a/test/unit/oauth/testUtils.ts b/test/unit/oauth/testUtils.ts index 8729aa4291..f12954b82b 100644 --- a/test/unit/oauth/testUtils.ts +++ b/test/unit/oauth/testUtils.ts @@ -64,12 +64,10 @@ export function createMockOAuthMetadata( revocation_endpoint: `${issuer}/oauth2/revoke`, registration_endpoint: `${issuer}/oauth2/register`, scopes_supported: [ - "workspace:read", - "workspace:update", - "workspace:start", - "workspace:ssh", - "workspace:application_connect", - "template:read", + "coder:workspaces.operate", + "coder:workspaces.access", + "workspace:create", + "user:read", "user:read_personal", ], response_types_supported: ["code"], From 329435accb3a5247a28a7cda912621f201b53834 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 29 Sep 2026 14:48:38 +0300 Subject: [PATCH 2/2] test(oauth): check OAuth scopes against a live server Calls every Coder API method and stream the extension uses with a DEFAULT_OAUTH_SCOPES token against coder-preview, on changes to the scope or probe list and nightly. A unit test finds those methods in src/ with the type checker and fails when one has no probe. test/scopes/compose.yaml runs the same deployment locally. --- .github/workflows/oauth-scopes.yaml | 33 ++++ CONTRIBUTING.md | 12 ++ package.json | 1 + test/scopes/compose.yaml | 27 +++ test/scopes/deployment.ts | 219 ++++++++++++++++++++++++ test/scopes/oauthScopes.test.ts | 34 ++++ test/scopes/probes.ts | 249 ++++++++++++++++++++++++++++ test/unit/oauth/scopeProbes.test.ts | 120 ++++++++++++++ vitest.config.mts | 28 +++- 9 files changed, 714 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/oauth-scopes.yaml create mode 100644 test/scopes/compose.yaml create mode 100644 test/scopes/deployment.ts create mode 100644 test/scopes/oauthScopes.test.ts create mode 100644 test/scopes/probes.ts create mode 100644 test/unit/oauth/scopeProbes.test.ts diff --git a/.github/workflows/oauth-scopes.yaml b/.github/workflows/oauth-scopes.yaml new file mode 100644 index 0000000000..84e7191675 --- /dev/null +++ b/.github/workflows/oauth-scopes.yaml @@ -0,0 +1,33 @@ +name: OAuth Scopes + +# Nightly runs track coder/coder main, catching server-side scope changes early. +on: + push: + branches: [main] + paths: &paths + - src/oauth/constants.ts + - test/scopes/** + - .github/workflows/oauth-scopes.yaml + pull_request: + paths: *paths + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + live-server: + name: Live Server Test (coder-preview) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - run: docker compose -f test/scopes/compose.yaml up -d --wait + - run: pnpm test:scopes + env: + CODER_URL: http://localhost:7080 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18bf8c53fa..46570915e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -242,6 +242,18 @@ pnpm test:integration - Requires closing VS Code or running in a clean environment - Test files in `test/integration/` are compiled to `out/` before running +### OAuth Scope Tests + +`test/scopes/` calls every Coder API method the extension uses with a token +limited to `DEFAULT_OAUTH_SCOPES`, against a live server. It needs Docker and a +fresh deployment, since it creates users and workspaces: + +```bash +docker compose -f test/scopes/compose.yaml up -d --wait +CODER_URL=http://localhost:7080 pnpm test:scopes +docker compose -f test/scopes/compose.yaml down -v # before the next run +``` + ## Development > [!IMPORTANT] diff --git a/package.json b/package.json index f5d5320801..3f27f33ef1 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "test": "cross-env CI=true ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs", "test:extension": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension", "test:integration": "pnpm build:test && node esbuild.mjs && vscode-test", + "test:scopes": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project scopes", "test:webview": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project webview", "typecheck": "concurrently -g -n extension,tests,packages,storybook \"tsc --noEmit\" \"tsc --noEmit -p test\" \"pnpm typecheck:packages\" \"tsc --noEmit -p .storybook\"", "typecheck:packages": "pnpm -r --filter \"./packages/*\" --parallel typecheck", diff --git a/test/scopes/compose.yaml b/test/scopes/compose.yaml new file mode 100644 index 0000000000..0033470d65 --- /dev/null +++ b/test/scopes/compose.yaml @@ -0,0 +1,27 @@ +# A throwaway deployment for `pnpm test:scopes`; see CONTRIBUTING.md. +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: coder + POSTGRES_PASSWORD: coder + POSTGRES_DB: coder + healthcheck: + test: pg_isready -U coder + interval: 2s + retries: 30 + + coder: + image: ghcr.io/coder/coder-preview:latest + ports: ["7080:7080"] + environment: + CODER_PG_CONNECTION_URL: postgres://coder:coder@postgres:5432/coder?sslmode=disable + CODER_HTTP_ADDRESS: 0.0.0.0:7080 + CODER_ACCESS_URL: http://localhost:7080 + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: curl -fs http://localhost:7080/healthz + interval: 2s + retries: 60 diff --git a/test/scopes/deployment.ts b/test/scopes/deployment.ts new file mode 100644 index 0000000000..82eac2be95 --- /dev/null +++ b/test/scopes/deployment.ts @@ -0,0 +1,219 @@ +import { isAxiosError } from "axios"; +import { Api } from "coder/site/src/api/api"; +import { once } from "node:events"; +import WebSocket from "ws"; + +import type { + APIKeyScope, + ProvisionerJob, + Template, + Workspace, + WorkspaceBuild, +} from "coder/site/src/api/typesGenerated"; + +export interface Deployment { + url: string; + /** The member's token, limited to the scopes under test. */ + token: string; + /** Signed in with `token`. */ + scoped: Api; + /** The deployment owner, used for setup and to move workspaces between states. */ + admin: Api; + /** Owned by the member. */ + own: Workspace; + /** Owned by the admin and shared with the member. */ + shared: Workspace; + /** The template version both workspaces run. */ + versionId: string; +} + +/** Password for every user this module creates. */ +const PASSWORD = "SomeLongPassw0rd!"; + +/** Uses only the coder provider, so builds need no infrastructure or modules. */ +const TEMPLATE = ` +terraform { + required_providers { + coder = { source = "coder/coder" } + } +} +data "coder_workspace" "me" {} +data "coder_parameter" "size" { + name = "size" + type = "string" + default = "small" + mutable = true +} +data "coder_workspace_preset" "small" { + name = "small" + parameters = { size = "small" } +} +resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" +} +resource "terraform_data" "dev" { + count = data.coder_workspace.me.start_count + input = coder_agent.dev.token +} +`; + +/** + * Creates users, a template and two running workspaces on a fresh deployment. + * Refuses one that already has users, so it never writes to a real server. + */ +export async function setUpDeployment( + url: string, + scopes: string, +): Promise { + const admin = createClient(url); + if (await admin.hasFirstUser()) { + throw new Error(`${url} already has users; use a fresh deployment`); + } + const email = "admin@example.com"; + await admin.getAxiosInstance().post("/api/v2/users/first", { + email, + username: "admin", + password: PASSWORD, + trial: false, + }); + await signIn(admin, email); + const [org] = (await admin.getAuthenticatedUser()).organization_ids; + const templateId = await createTemplate(admin, org); + + const memberUser = await admin.createUser({ + email: "member@example.com", + username: "member", + name: "", + password: PASSWORD, + login_type: "password", + user_status: null, + organization_ids: [org], + }); + const member = await signIn(createClient(url), memberUser.email); + const [own, shared] = await Promise.all([ + createRunningWorkspace(member, "me", "own", templateId), + createRunningWorkspace(admin, "me", "shared", templateId), + ]); + await admin.updateWorkspaceACL(shared.id, { + user_roles: { [memberUser.id]: "admin" }, + }); + + const { key: token } = await member.createToken({ + token_name: "scopes", + lifetime: 0, // the deployment's default + scopes: scopes.split(" ") as APIKeyScope[], + }); + const scoped = createClient(url, token); + const versionId = own.template_active_version_id; + return { url, token, scoped, admin, own, shared, versionId }; +} + +/** Creates a workspace for `owner` and returns it, with its agents, once built. */ +export async function createRunningWorkspace( + api: Api, + owner: string, + name: string, + templateId: string, +): Promise { + const workspace = await api.createWorkspace(owner, { + name, + template_id: templateId, + }); + await waitForBuild(api, workspace.latest_build); + return api.getWorkspace(workspace.id); +} + +/** Waits for the build to finish and fails unless it succeeded. */ +export function waitForBuild(api: Api, build: WorkspaceBuild): Promise { + return waitForJob(api, `/api/v2/workspacebuilds/${build.id}`); +} + +/** + * Follows the log stream of the job behind `path` (a build or template + * version), which the server closes once the job completes, even when it + * completed before the stream opened. Then fails unless the job succeeded. + */ +async function waitForJob(api: Api, path: string): Promise { + const logs = new URL( + `${path}/logs?follow=true`, + api.getAxiosInstance().defaults.baseURL, + ); + logs.protocol = logs.protocol.replace("http", "ws"); + const headers = { "Coder-Session-Token": api.getSessionToken() ?? "" }; + await once(new WebSocket(logs, { headers }), "close"); + const { data } = await api + .getAxiosInstance() + .get<{ job: ProvisionerJob }>(path); + if (data.job.status !== "succeeded") { + throw new Error(`${path}: job ${data.job.status}: ${data.job.error ?? ""}`); + } +} + +/** Creates a client whose request errors include the server's message, not just the status. */ +function createClient(url: string, token?: string): Api { + const api = new Api(); + api.setHost(url); + if (token) { + api.setSessionToken(token); + } + api + .getAxiosInstance() + .interceptors.response.use(undefined, (error: unknown) => { + if ( + isAxiosError<{ message?: string; detail?: string }>(error) && + error.response + ) { + const { config, response } = error; + const reason = [response.data?.message, response.data?.detail] + .filter(Boolean) + .join(": "); + error.message = `${config?.method?.toUpperCase()} ${config?.url}: ${response.status} ${reason}`; + } + throw error; + }); + return api; +} + +async function signIn(api: Api, email: string): Promise { + const { session_token } = await api.login(email, PASSWORD); + api.setSessionToken(session_token); + return api; +} + +async function createTemplate(admin: Api, org: string): Promise { + const { data: file } = await admin + .getAxiosInstance() + .post<{ hash: string }>("/api/v2/files", tarFile("main.tf", TEMPLATE), { + headers: { "Content-Type": "application/x-tar" }, + }); + const version = await admin.createTemplateVersion(org, { + storage_method: "file", + file_id: file.hash, + provisioner: "terraform", + tags: {}, + }); + await waitForJob(admin, `/api/v2/templateversions/${version.id}`); + const { data: template } = await admin + .getAxiosInstance() + .post