Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/oauth-scopes.yaml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 8 additions & 7 deletions src/oauth/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ");

Expand Down
27 changes: 27 additions & 0 deletions test/scopes/compose.yaml
Original file line number Diff line number Diff line change
@@ -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
219 changes: 219 additions & 0 deletions test/scopes/deployment.ts
Original file line number Diff line number Diff line change
@@ -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<Deployment> {
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<Workspace> {
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<void> {
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<void> {
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<Api> {
const { session_token } = await api.login(email, PASSWORD);
api.setSessionToken(session_token);
return api;
}

async function createTemplate(admin: Api, org: string): Promise<string> {
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<Template>(`/api/v2/organizations/${org}/templates`, {
name: "scopes",
template_version_id: version.id,
});
return template.id;
}

/** Builds a tar archive holding one file; unset header fields read as zero. */
function tarFile(name: string, content: string): Buffer {
const body = Buffer.from(content);
const header = Buffer.alloc(512);
header.write(name, 0);
header.write("0000644\0", 100); // mode
header.write(`${body.length.toString(8).padStart(11, "0")}\0`, 124);
header.write(" ", 148); // checksum placeholder
header.write("0", 156); // regular file
const checksum = header.reduce((sum, byte) => sum + byte, 0);
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148);
const padding = Buffer.alloc((512 - (body.length % 512)) % 512);
return Buffer.concat([header, body, padding, Buffer.alloc(1024)]);
}
34 changes: 34 additions & 0 deletions test/scopes/oauthScopes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { beforeAll, describe, expect, it } from "vitest";

import { DEFAULT_OAUTH_SCOPES } from "@/oauth/constants";

import { setUpDeployment, type Deployment } from "./deployment";
import { cliProbes, knownGaps, probes } from "./probes";

/** A fresh Coder deployment, e.g. from compose.yaml; the suite is skipped without one. */
const url = process.env.CODER_URL ?? "";

describe.skipIf(!url)("OAuth scopes", () => {
let deployment: Deployment;

beforeAll(async () => {
deployment = await setUpDeployment(url, DEFAULT_OAUTH_SCOPES);
});

it.each(Object.entries({ ...probes, ...cliProbes }))(
"%s",
async (_name, probe) => {
await probe(deployment);
},
);

it.each(Object.entries(knownGaps))(
"%s (known gap)",
async (_name, { probe, error }) => {
const passed = new Error("The gap is fixed: move this probe to `probes`");
await expect(
probe(deployment).then(() => Promise.reject(passed)),
).rejects.toThrow(error);
},
);
});
Loading
Loading