Skip to content

Commit e5d637a

Browse files
authored
fix(oauth): request the scopes the extension and CLI actually need
Servers that enforce OAuth scopes (from 2.38) refused the extension right after sign-in: `/users/me` needs `user:read`. Request what the extension and the CLI it runs need: the `coder:workspaces.operate` and `coder:workspaces.access` composites, `workspace:create`, `user:read` and `user:read_personal`. Request `inbox_notification:read` only when the server lists it in `scopes_supported`, since servers reject unknown scopes and 2.38 does not offer it yet. Store `coder:all` when the server returns no scope, as servers that ignore scopes grant everything, so later scope changes do not sign their users out. Sessions stored with the old list sign in again once. Revoke tokens on logout even when their scopes are outdated, and accept sessions granted `coder:all`.
1 parent a39a19e commit e5d637a

9 files changed

Lines changed: 181 additions & 68 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@
1313
**Show Logs** and **Open in Dashboard** actions. These failures used to appear
1414
only in the Coder output channel.
1515

16+
### Fixed
17+
18+
- Request the OAuth scopes the extension and the Coder CLI need, so OAuth
19+
sign-in, remote SSH, starting and stopping workspaces, and workspaces shared
20+
with you work on deployments that enforce token scopes. Inbox notifications
21+
also work on servers that offer `inbox_notification:read`. The consent screen
22+
now also asks to create workspaces and read users, and everyone signed in with
23+
OAuth is asked to sign in again once.
24+
- Revoke OAuth tokens on logout even when they were granted outdated scopes.
25+
1626
## [v1.16.4](https://github.com/coder/vscode-coder/releases/tag/v1.16.4) 2026-09-23
1727

1828
### Added

‎src/oauth/authorizer.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { resolveCoderDashboardUrl } from "../util/uri";
66
import {
77
AUTH_GRANT_TYPE,
88
DEFAULT_OAUTH_SCOPES,
9+
OPTIONAL_OAUTH_SCOPES,
910
PKCE_CHALLENGE_METHOD,
1011
RESPONSE_TYPE,
1112
TOKEN_ENDPOINT_AUTH_METHOD,
@@ -213,17 +214,23 @@ export class OAuthAuthorizer implements vscode.Disposable {
213214
);
214215
if (unsupportedScopes.length > 0) {
215216
this.logger.warn(
216-
`Requested scopes not in server's supported scopes: ${unsupportedScopes.join(", ")}. Server may still accept them.`,
217+
`Requested scopes not in server's supported scopes: ${unsupportedScopes.join(", ")}. Servers that enforce scopes reject the request.`,
217218
{ supported_scopes: metadata.scopes_supported },
218219
);
219220
}
220221
}
221222

223+
const scope = [
224+
DEFAULT_OAUTH_SCOPES,
225+
...OPTIONAL_OAUTH_SCOPES.filter((s) =>
226+
metadata.scopes_supported?.includes(s),
227+
),
228+
].join(" ");
222229
const params = new URLSearchParams({
223230
client_id: clientId,
224231
response_type: RESPONSE_TYPE,
225232
redirect_uri: this.getRedirectUri(),
226-
scope: DEFAULT_OAUTH_SCOPES,
233+
scope,
227234
state,
228235
code_challenge: challenge,
229236
code_challenge_method: PKCE_CHALLENGE_METHOD,
@@ -240,7 +247,7 @@ export class OAuthAuthorizer implements vscode.Disposable {
240247
this.logger.debug("Built OAuth authorization URL:", {
241248
client_id: clientId,
242249
redirect_uri: this.getRedirectUri(),
243-
scope: DEFAULT_OAUTH_SCOPES,
250+
scope,
244251
});
245252

246253
return endpoint.toString();

‎src/oauth/constants.ts‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,32 @@
22
export const AUTH_GRANT_TYPE = "authorization_code";
33
export const REFRESH_GRANT_TYPE = "refresh_token";
44

5-
// Minimal scopes required by the VS Code extension
5+
/**
6+
* Scopes the extension and its CLI need. Stored sessions must cover them all,
7+
* so adding one signs users out on servers that enforce scopes.
8+
*/
69
export const DEFAULT_OAUTH_SCOPES = [
7-
"workspace:read",
8-
"workspace:update",
9-
"workspace:start",
10-
"workspace:ssh",
11-
"workspace:application_connect",
12-
"template:read",
10+
// Workspace and template access, plus organization_member:read for shared
11+
// workspaces, which no low-level scope offers
12+
"coder:workspaces.operate",
13+
"coder:workspaces.access",
14+
// `coder start` dry-runs builds that must update first
15+
"workspace:create",
16+
// `/users/me` and workspace owner lookups
17+
"user:read",
18+
// External auth links, read by `coder start`
1319
"user:read_personal",
1420
].join(" ");
1521

22+
/**
23+
* Requested only if in `scopes_supported`, as servers reject unknown scopes.
24+
* Not required of stored sessions, which gain them at the next sign-in.
25+
*/
26+
export const OPTIONAL_OAUTH_SCOPES = [
27+
// Inbox notifications (coder/coder#30176)
28+
"inbox_notification:read",
29+
];
30+
1631
// OAuth 2.1 Response Types
1732
export const RESPONSE_TYPE = "code";
1833

‎src/oauth/sessionManager.ts‎

Lines changed: 30 additions & 48 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";
@@ -37,7 +41,6 @@ const BACKGROUND_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
3741

3842
/**
3943
* Internal type combining access token with OAuth-specific data.
40-
* Used by getStoredTokens() for token refresh and validation.
4144
*/
4245
type StoredTokens = OAuthTokenData & {
4346
access_token: string;
@@ -95,13 +98,25 @@ export class OAuthSessionManager implements vscode.Disposable {
9598
return this.deployment;
9699
}
97100

101+
/** Like getStoredTokensIgnoringScopes, but rejects tokens missing a required scope. */
102+
private async getStoredTokens(): Promise<StoredTokens | undefined> {
103+
const storedTokens = await this.getStoredTokensIgnoringScopes();
104+
if (storedTokens && !hasRequiredScopes(storedTokens.scope)) {
105+
this.logger.warn("Stored tokens have insufficient scopes", {
106+
scope: storedTokens.scope,
107+
});
108+
return undefined;
109+
}
110+
return storedTokens;
111+
}
112+
98113
/**
99-
* Get stored tokens fresh from secrets manager.
100-
* Always reads from storage to ensure cross-window synchronization.
101-
* Validates that tokens match current deployment URL and have required scopes.
102-
* Invalid tokens are cleared and undefined is returned.
114+
* Reads storage on every call, for cross-window sync. Returns undefined for
115+
* another deployment's tokens, leaving them stored.
103116
*/
104-
private async getStoredTokens(): Promise<StoredTokens | undefined> {
117+
private async getStoredTokensIgnoringScopes(): Promise<
118+
StoredTokens | undefined
119+
> {
105120
if (!this.deployment) {
106121
return undefined;
107122
}
@@ -122,13 +137,6 @@ export class OAuthSessionManager implements vscode.Disposable {
122137
return undefined;
123138
}
124139

125-
if (!this.hasRequiredScopes(auth.oauth.scope)) {
126-
this.logger.warn("Stored tokens have insufficient scopes", {
127-
scope: auth.oauth.scope,
128-
});
129-
return undefined;
130-
}
131-
132140
return {
133141
access_token: auth.token,
134142
...auth.oauth,
@@ -256,35 +264,6 @@ export class OAuthSessionManager implements vscode.Disposable {
256264
});
257265
}
258266

259-
/**
260-
* Check if granted scopes cover all required scopes.
261-
* Supports wildcard scopes like "workspace:*".
262-
*/
263-
private hasRequiredScopes(grantedScope: string): boolean {
264-
const grantedScopes = new Set(grantedScope.split(" "));
265-
const requiredScopes = DEFAULT_OAUTH_SCOPES.split(" ");
266-
267-
for (const required of requiredScopes) {
268-
if (grantedScopes.has(required)) {
269-
continue;
270-
}
271-
272-
// Check wildcard match (e.g., "workspace:*" grants "workspace:read")
273-
const colonIndex = required.indexOf(":");
274-
if (colonIndex !== -1) {
275-
const prefix = required.substring(0, colonIndex);
276-
const wildcard = `${prefix}:*`;
277-
if (grantedScopes.has(wildcard)) {
278-
continue;
279-
}
280-
}
281-
282-
return false;
283-
}
284-
285-
return true;
286-
}
287-
288267
/**
289268
* Run `fn` with a per-call CoderApi configured for the current
290269
* deployment. The client is disposed on exit so its config-change
@@ -475,10 +454,13 @@ export class OAuthSessionManager implements vscode.Disposable {
475454

476455
/** Best-effort server-side revocation of the stored refresh and access tokens; never throws. */
477456
public async revokeTokens(): Promise<void> {
478-
const storedTokens = await this.getStoredTokens().catch((error) => {
479-
this.logger.warn("Failed to read stored tokens for revocation:", error);
480-
return undefined;
481-
});
457+
// Outdated scopes don't invalidate tokens on the server.
458+
const storedTokens = await this.getStoredTokensIgnoringScopes().catch(
459+
(error) => {
460+
this.logger.warn("Failed to read stored tokens for revocation:", error);
461+
return undefined;
462+
},
463+
);
482464
if (!storedTokens) {
483465
return;
484466
}

‎src/oauth/utils.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,34 @@ export function toUrlSearchParams(obj: object): URLSearchParams {
5353
return new URLSearchParams(params);
5454
}
5555

56+
/** Whether granted scopes cover DEFAULT_OAUTH_SCOPES (OPTIONAL_OAUTH_SCOPES are not required). */
57+
export function hasRequiredScopes(grantedScope: string): boolean {
58+
const grantedScopes = new Set(grantedScope.split(" "));
59+
if (grantedScopes.has("coder:all")) {
60+
return true;
61+
}
62+
const requiredScopes = DEFAULT_OAUTH_SCOPES.split(" ");
63+
64+
for (const required of requiredScopes) {
65+
if (grantedScopes.has(required)) {
66+
continue;
67+
}
68+
69+
const colonIndex = required.indexOf(":");
70+
if (colonIndex !== -1) {
71+
const prefix = required.substring(0, colonIndex);
72+
const wildcard = `${prefix}:*`;
73+
if (grantedScopes.has(wildcard)) {
74+
continue;
75+
}
76+
}
77+
78+
return false;
79+
}
80+
81+
return true;
82+
}
83+
5684
/**
5785
* Build OAuthTokenData from a token response.
5886
* Prefers the `expiry` timestamp over calculating from `expires_in`.
@@ -68,8 +96,8 @@ export function buildOAuthTokenData(
6896

6997
return {
7098
refresh_token: tokenResponse.refresh_token,
71-
// Use default scopes when server returns empty, so scope changes can invalidate tokens
72-
scope: tokenResponse.scope || DEFAULT_OAUTH_SCOPES,
99+
// Servers that ignore scopes return none and grant everything
100+
scope: tokenResponse.scope || "coder:all",
73101
expiry_timestamp: getExpiryTimestamp(tokenResponse),
74102
};
75103
}

‎test/unit/oauth/authorizer.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as vscode from "vscode";
44
import { InvalidApiResponseError } from "@/api/responseValidation";
55
import { getHeaders } from "@/headers";
66
import { OAuthAuthorizer } from "@/oauth/authorizer";
7+
import { DEFAULT_OAUTH_SCOPES, OPTIONAL_OAUTH_SCOPES } from "@/oauth/constants";
78

89
import {
910
createMockUser,
@@ -372,6 +373,34 @@ describe("OAuthAuthorizer", () => {
372373
await completeLogin(state);
373374
await loginPromise;
374375
});
376+
377+
it.each([
378+
{
379+
offered: true,
380+
expected: `${DEFAULT_OAUTH_SCOPES} inbox_notification:read`,
381+
},
382+
{ offered: false, expected: DEFAULT_OAUTH_SCOPES },
383+
])(
384+
"requests optional scopes only when the server supports them (offered: $offered)",
385+
async ({ offered, expected }) => {
386+
const { setupOAuthRoutes, startLogin, completeLogin } =
387+
createTestContext();
388+
const metadata = createMockOAuthMetadata(TEST_URL);
389+
setupOAuthRoutes({
390+
...metadata,
391+
scopes_supported: [
392+
...(metadata.scopes_supported ?? []),
393+
...(offered ? OPTIONAL_OAUTH_SCOPES : []),
394+
],
395+
});
396+
397+
const { loginPromise, authUrl, state } = await startLogin();
398+
expect(authUrl.searchParams.get("scope")).toBe(expected);
399+
400+
await completeLogin(state);
401+
await loginPromise;
402+
},
403+
);
375404
});
376405

377406
describe("callback handling", () => {

‎test/unit/oauth/sessionManager.test.ts‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,27 @@ describe("OAuthSessionManager", () => {
484484
]);
485485
});
486486

487+
it("revokes tokens granted outdated scopes", async () => {
488+
const { manager, setupForOAuthOperation } = createTestContext();
489+
490+
const revoked: Array<string | null> = [];
491+
await setupForOAuthOperation(
492+
{
493+
"/oauth2/revoke": (config: InternalAxiosRequestConfig) => {
494+
revoked.push(
495+
new URLSearchParams(config.data as string).get("token"),
496+
);
497+
return {};
498+
},
499+
},
500+
{ scope: "workspace:read" },
501+
);
502+
503+
await manager.revokeTokens();
504+
505+
expect(revoked).toEqual(["refresh-token", "access-token"]);
506+
});
507+
487508
it("does not throw when revocation fails", async () => {
488509
const { manager, setupForOAuthOperation } = createTestContext();
489510

@@ -531,14 +552,23 @@ describe("OAuthSessionManager", () => {
531552
oauth: {
532553
refresh_token: "refresh-token",
533554
expiry_timestamp: Date.now() + ONE_HOUR_MS,
534-
// workspace:* covers workspace:read, workspace:update, etc.
535-
scope: "workspace:* template:read user:read_personal",
555+
// workspace:* and user:* cover the low-level scopes
556+
scope:
557+
"coder:workspaces.operate coder:workspaces.access workspace:* user:*",
536558
},
537559
});
538560

539561
const result = await manager.isLoggedInWithOAuth();
540562
expect(result).toBe(true);
541563
});
564+
565+
it("accepts coder:all", async () => {
566+
const { manager, setupOAuthSession } = createTestContext();
567+
568+
await setupOAuthSession({ scope: "coder:all" });
569+
570+
expect(await manager.isLoggedInWithOAuth()).toBe(true);
571+
});
542572
});
543573

544574
describe("telemetry", () => {

‎test/unit/oauth/testUtils.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,10 @@ export function createMockOAuthMetadata(
6464
revocation_endpoint: `${issuer}/oauth2/revoke`,
6565
registration_endpoint: `${issuer}/oauth2/register`,
6666
scopes_supported: [
67-
"workspace:read",
68-
"workspace:update",
69-
"workspace:start",
70-
"workspace:ssh",
71-
"workspace:application_connect",
72-
"template:read",
67+
"coder:workspaces.operate",
68+
"coder:workspaces.access",
69+
"workspace:create",
70+
"user:read",
7371
"user:read_personal",
7472
],
7573
response_types_supported: ["code"],

0 commit comments

Comments
 (0)