Skip to content

Commit ed8e384

Browse files
committed
test(oauth): check OAuth scopes against a live server
Add `pnpm test:scopes`, which signs in through the extension's OAuth flow against a throwaway deployment (test/scopes/compose.yaml) and runs a probe for every Coder API method and CLI request the extension makes with the granted token. It also checks that each requested scope is needed by a named probe, and asserts the exact error of known gaps. `scopeProbes.test.ts` runs on every PR and uses the TypeScript checker to find every CoderApi method src/ references, failing when one has no probe. The workflow runs the suite on relevant changes against a pinned coder-preview image, and nightly against `latest`. `pnpm test` and the CI unit job leave it out.
1 parent e40f20e commit ed8e384

15 files changed

Lines changed: 1204 additions & 47 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: OAuth Scopes
2+
3+
# Pushes and PRs use the image pinned in compose.yaml; nightly runs use latest
4+
# to catch server changes early.
5+
on:
6+
push:
7+
branches: [main]
8+
# Code the probes run or import
9+
paths: &paths
10+
- src/**
11+
- test/mocks/**
12+
- test/scopes/**
13+
- package.json
14+
- pnpm-lock.yaml
15+
- vitest.config.mts
16+
- .github/workflows/oauth-scopes.yaml
17+
pull_request:
18+
paths: *paths
19+
schedule:
20+
- cron: "0 6 * * *"
21+
workflow_dispatch:
22+
23+
permissions:
24+
contents: read
25+
26+
jobs:
27+
live-server:
28+
name: Live Server Test (coder-preview)
29+
runs-on: ubuntu-24.04
30+
timeout-minutes: 15
31+
steps:
32+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
33+
with:
34+
persist-credentials: false
35+
- uses: ./.github/actions/setup
36+
- run: docker compose -f test/scopes/compose.yaml up -d --wait
37+
env:
38+
CODER_IMAGE: ${{ github.event_name == 'schedule' && 'ghcr.io/coder/coder-preview:latest' || '' }}
39+
- run: pnpm test:scopes
40+
env:
41+
CODER_SCOPES_TEST_URL: http://localhost:7080

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ test/
9595
├── unit/ # Extension unit tests (mirrors src/ structure)
9696
├── webview/ # Webview unit tests (by package name)
9797
├── integration/ # VS Code integration tests (uses Mocha, not Vitest)
98+
├── scopes/ # OAuth scopes against a live server (`pnpm test:scopes`)
9899
├── utils/ # Test utilities that are also tested
99100
└── mocks/ # Shared test mocks
100101
```

‎CONTRIBUTING.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,22 @@ pnpm test:integration
242242
- Requires closing VS Code or running in a clean environment
243243
- Test files in `test/integration/` are compiled to `out/` before running
244244

245+
### OAuth Scope Tests
246+
247+
`test/scopes/` signs in through the extension's OAuth flow against a live
248+
server and runs the probes in `test/scopes/probes.ts` with the token. It needs
249+
Docker and a fresh deployment, since it creates users and workspaces, and
250+
`pnpm test` leaves it out:
251+
252+
```bash
253+
docker compose -f test/scopes/compose.yaml up -d --wait
254+
CODER_SCOPES_TEST_URL=http://localhost:7080 pnpm test:scopes
255+
docker compose -f test/scopes/compose.yaml down -v # before the next run
256+
```
257+
258+
Pull requests run against the coder-preview image pinned in `compose.yaml`;
259+
the nightly run uses `latest`. Set `CODER_IMAGE` to test another image.
260+
245261
## Development
246262

247263
> [!IMPORTANT]

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@
3434
"storybook:build": "storybook build --config-dir .storybook",
3535
"storybook:ci": "storybook build --test --config-dir .storybook",
3636
"sync:vscode-themes": "node .storybook/themes/sync.mjs",
37-
"test": "cross-env CI=true ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs",
37+
"test": "cross-env CI=true ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project !scopes",
3838
"test:extension": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension",
3939
"test:integration": "pnpm build:test && node esbuild.mjs && vscode-test",
40+
"test:scopes": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project scopes",
4041
"test:webview": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project webview",
4142
"typecheck": "concurrently -g -n extension,tests,packages,storybook \"tsc --noEmit\" \"tsc --noEmit -p test\" \"pnpm typecheck:packages\" \"tsc --noEmit -p .storybook\"",
4243
"typecheck:packages": "pnpm -r --filter \"./packages/*\" --parallel typecheck",

‎pnpm-workspace.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ catalog:
2222
react-dom: ^19.3.0
2323
storybook: ^10.6.1
2424
storybook-addon-pseudo-states: ^10.6.1
25-
# typescript stays on the 6.0 API for typescript-eslint; TypeScript 7 runs `tsc`.
25+
# typescript stays on the 6.0 API for typescript-eslint and the compiler API in
26+
# test/unit/oauth/scopeProbes.test.ts; TypeScript 7 runs `tsc`.
2627
# https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/
2728
typescript: npm:@typescript/typescript6@^6.0.2
2829
vite: ^8.3.2

‎scripts/test-electron.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,4 @@ pnpm --dir "$RUNNER_DIR" add "electron@$ELECTRON_VERSION"
3030

3131
echo "Running tests with Electron $ELECTRON_VERSION..."
3232
ELECTRON_RUN_AS_NODE=1 \
33-
"$RUNNER_DIR/node_modules/.bin/electron" node_modules/vitest/vitest.mjs
33+
"$RUNNER_DIR/node_modules/.bin/electron" node_modules/vitest/vitest.mjs --project "!scopes"

‎src/oauth/constants.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const REFRESH_GRANT_TYPE = "refresh_token";
55
/**
66
* Scopes the extension and the CLI need. Stored sessions must have all of
77
* them, so adding one signs out users of servers that enforce scopes.
8+
* `scopeConsumers` in test/scopes/probes.ts names what needs each scope.
89
*/
910
export const DEFAULT_OAUTH_SCOPES = [
1011
"coder:workspaces.operate",

‎src/oauth/sessionManager.ts‎

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import {
44
type AuthTokenRefreshTrigger,
55
} from "../instrumentation/auth";
66

7-
import { DEFAULT_OAUTH_SCOPES, REFRESH_GRANT_TYPE } from "./constants";
7+
import { REFRESH_GRANT_TYPE } from "./constants";
88
import { OAuthError, parseOAuthError } from "./errors";
99
import { OAuthMetadataClient } from "./metadataClient";
10-
import { buildOAuthTokenData, toUrlSearchParams } from "./utils";
10+
import {
11+
buildOAuthTokenData,
12+
hasRequiredScopes,
13+
toUrlSearchParams,
14+
} from "./utils";
1115
import { OAuth2TokenResponseSchema, parseOAuthResponse } from "./validation";
1216

1317
import type { AxiosInstance } from "axios";
@@ -132,7 +136,7 @@ export class OAuthSessionManager implements vscode.Disposable {
132136
StoredTokens | undefined
133137
> {
134138
const storedTokens = await this.getStoredTokens();
135-
if (storedTokens && !this.hasRequiredScopes(storedTokens.scope)) {
139+
if (storedTokens && !hasRequiredScopes(storedTokens.scope)) {
136140
this.logger.warn("Stored tokens have insufficient scopes", {
137141
scope: storedTokens.scope,
138142
});
@@ -262,38 +266,6 @@ export class OAuthSessionManager implements vscode.Disposable {
262266
});
263267
}
264268

265-
/**
266-
* Check if granted scopes cover all required scopes.
267-
* Supports wildcard scopes like "workspace:*".
268-
*/
269-
private hasRequiredScopes(grantedScope: string): boolean {
270-
const grantedScopes = new Set(grantedScope.split(" "));
271-
if (grantedScopes.has("coder:all")) {
272-
return true;
273-
}
274-
const requiredScopes = DEFAULT_OAUTH_SCOPES.split(" ");
275-
276-
for (const required of requiredScopes) {
277-
if (grantedScopes.has(required)) {
278-
continue;
279-
}
280-
281-
// Check wildcard match (e.g., "workspace:*" grants "workspace:read")
282-
const colonIndex = required.indexOf(":");
283-
if (colonIndex !== -1) {
284-
const prefix = required.substring(0, colonIndex);
285-
const wildcard = `${prefix}:*`;
286-
if (grantedScopes.has(wildcard)) {
287-
continue;
288-
}
289-
}
290-
291-
return false;
292-
}
293-
294-
return true;
295-
}
296-
297269
/**
298270
* Run `fn` with a per-call CoderApi configured for the current
299271
* deployment. The client is disposed on exit so its config-change

‎src/oauth/utils.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { createHash, randomBytes } from "node:crypto";
22

3+
import { DEFAULT_OAUTH_SCOPES } from "./constants";
4+
35
import type { OAuth2TokenResponse } from "coder/site/src/api/typesGenerated";
46

57
import type { OAuthTokenData } from "../core/secretsManager";
@@ -51,6 +53,38 @@ export function toUrlSearchParams(obj: object): URLSearchParams {
5153
return new URLSearchParams(params);
5254
}
5355

56+
/**
57+
* Whether the granted scopes cover DEFAULT_OAUTH_SCOPES.
58+
* Supports wildcard scopes like "workspace:*".
59+
*/
60+
export function hasRequiredScopes(grantedScope: string): boolean {
61+
const grantedScopes = new Set(grantedScope.split(" "));
62+
if (grantedScopes.has("coder:all")) {
63+
return true;
64+
}
65+
const requiredScopes = DEFAULT_OAUTH_SCOPES.split(" ");
66+
67+
for (const required of requiredScopes) {
68+
if (grantedScopes.has(required)) {
69+
continue;
70+
}
71+
72+
// Check wildcard match (e.g., "workspace:*" grants "workspace:read")
73+
const colonIndex = required.indexOf(":");
74+
if (colonIndex !== -1) {
75+
const prefix = required.substring(0, colonIndex);
76+
const wildcard = `${prefix}:*`;
77+
if (grantedScopes.has(wildcard)) {
78+
continue;
79+
}
80+
}
81+
82+
return false;
83+
}
84+
85+
return true;
86+
}
87+
5488
/**
5589
* Build OAuthTokenData from a token response.
5690
* Prefers the `expiry` timestamp over calculating from `expires_in`.

‎test/scopes/compose.yaml‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# A throwaway deployment for `pnpm test:scopes`; see CONTRIBUTING.md.
2+
services:
3+
postgres:
4+
image: postgres:17
5+
environment:
6+
POSTGRES_USER: coder
7+
POSTGRES_PASSWORD: coder
8+
POSTGRES_DB: coder
9+
healthcheck:
10+
test: pg_isready -U coder
11+
interval: 2s
12+
retries: 30
13+
14+
coder:
15+
image: ${CODER_IMAGE:-ghcr.io/coder/coder-preview@sha256:22951b2840d24d0b2dcb75990f499cb844840ecd763731cc0361683c1ee457aa}
16+
ports: ["127.0.0.1:7080:7080"]
17+
environment:
18+
CODER_PG_CONNECTION_URL: postgres://coder:coder@postgres:5432/coder?sslmode=disable
19+
CODER_HTTP_ADDRESS: 0.0.0.0:7080
20+
CODER_ACCESS_URL: http://localhost:7080
21+
CODER_OAUTH2_PROVIDER_ENABLE: "true"
22+
depends_on:
23+
postgres:
24+
condition: service_healthy
25+
healthcheck:
26+
test: curl -fs http://localhost:7080/healthz
27+
interval: 2s
28+
retries: 60

0 commit comments

Comments
 (0)