Skip to content

Commit e40f20e

Browse files
committed
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 2885986 commit e40f20e

9 files changed

Lines changed: 113 additions & 35 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
the socket never reaches a terminal failure. The extension keeps retrying until
1919
the server is reachable again.
2020

21+
### Fixed
22+
23+
- OAuth sign-in now works on deployments that enforce token scopes. Users signed
24+
in with OAuth are asked to sign in again once.
25+
- Revoke OAuth tokens on logout even when their scopes are outdated.
26+
2127
## [v1.16.4](https://github.com/coder/vscode-coder/releases/tag/v1.16.4) 2026-09-23
2228

2329
### 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+
IF_SUPPORTED_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+
...IF_SUPPORTED_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: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,24 @@
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 the CLI need. Stored sessions must have all of
7+
* them, so adding one signs out users of 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+
"coder:workspaces.operate",
11+
"coder:workspaces.access",
12+
"workspace:create",
13+
"user:read",
1314
"user:read_personal",
1415
].join(" ");
1516

17+
/**
18+
* Requested only when the server lists them in `scopes_supported`, as servers
19+
* reject scopes they don't know. Stored sessions don't need them.
20+
*/
21+
export const IF_SUPPORTED_OAUTH_SCOPES = ["inbox_notification:read"];
22+
1623
// OAuth 2.1 Response Types
1724
export const RESPONSE_TYPE = "code";
1825

‎src/oauth/sessionManager.ts‎

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,7 @@ export class OAuthSessionManager implements vscode.Disposable {
9898
/**
9999
* Get stored tokens fresh from secrets manager.
100100
* 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.
101+
* Returns undefined for tokens of another deployment URL.
103102
*/
104103
private async getStoredTokens(): Promise<StoredTokens | undefined> {
105104
if (!this.deployment) {
@@ -122,19 +121,26 @@ export class OAuthSessionManager implements vscode.Disposable {
122121
return undefined;
123122
}
124123

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-
132124
return {
133125
access_token: auth.token,
134126
...auth.oauth,
135127
};
136128
}
137129

130+
/** Stored tokens, or undefined when they lack a required scope. */
131+
private async getTokensWithRequiredScopes(): Promise<
132+
StoredTokens | undefined
133+
> {
134+
const storedTokens = await this.getStoredTokens();
135+
if (storedTokens && !this.hasRequiredScopes(storedTokens.scope)) {
136+
this.logger.warn("Stored tokens have insufficient scopes", {
137+
scope: storedTokens.scope,
138+
});
139+
return undefined;
140+
}
141+
return storedTokens;
142+
}
143+
138144
/**
139145
* Clear all refresh-related state: in-flight promise, timer, and listener.
140146
* Aborts any in-flight refresh request to prevent stale token updates.
@@ -186,7 +192,7 @@ export class OAuthSessionManager implements vscode.Disposable {
186192
this.refreshTimer = undefined;
187193
}
188194

189-
this.getStoredTokens()
195+
this.getTokensWithRequiredScopes()
190196
.then((storedTokens) => {
191197
if (!storedTokens?.refresh_token) {
192198
return;
@@ -262,6 +268,9 @@ export class OAuthSessionManager implements vscode.Disposable {
262268
*/
263269
private hasRequiredScopes(grantedScope: string): boolean {
264270
const grantedScopes = new Set(grantedScope.split(" "));
271+
if (grantedScopes.has("coder:all")) {
272+
return true;
273+
}
265274
const requiredScopes = DEFAULT_OAUTH_SCOPES.split(" ");
266275

267276
for (const required of requiredScopes) {
@@ -331,7 +340,7 @@ export class OAuthSessionManager implements vscode.Disposable {
331340
this.deployment = deployment;
332341
this.clearRefreshState();
333342

334-
const storedTokens = await this.getStoredTokens();
343+
const storedTokens = await this.getTokensWithRequiredScopes();
335344
if (storedTokens) {
336345
this.logger.debug("Switching OAuth deployment", deployment);
337346
}
@@ -385,7 +394,7 @@ export class OAuthSessionManager implements vscode.Disposable {
385394
this.refreshAbortController = abortController;
386395

387396
try {
388-
const storedTokens = await this.getStoredTokens();
397+
const storedTokens = await this.getTokensWithRequiredScopes();
389398
if (!storedTokens?.refresh_token) {
390399
throw new Error("No refresh token available");
391400
}
@@ -475,6 +484,7 @@ export class OAuthSessionManager implements vscode.Disposable {
475484

476485
/** Best-effort server-side revocation of the stored refresh and access tokens; never throws. */
477486
public async revokeTokens(): Promise<void> {
487+
// Includes tokens lacking a required scope, which still work on the server.
478488
const storedTokens = await this.getStoredTokens().catch((error) => {
479489
this.logger.warn("Failed to read stored tokens for revocation:", error);
480490
return undefined;
@@ -535,7 +545,7 @@ export class OAuthSessionManager implements vscode.Disposable {
535545
if (hostname && hostname !== this.deployment?.safeHostname) {
536546
return false;
537547
}
538-
const storedTokens = await this.getStoredTokens();
548+
const storedTokens = await this.getTokensWithRequiredScopes();
539549
return storedTokens !== undefined;
540550
}
541551

‎src/oauth/utils.ts‎

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

3-
import { DEFAULT_OAUTH_SCOPES } from "./constants";
4-
53
import type { OAuth2TokenResponse } from "coder/site/src/api/typesGenerated";
64

75
import type { OAuthTokenData } from "../core/secretsManager";
@@ -68,8 +66,8 @@ export function buildOAuthTokenData(
6866

6967
return {
7068
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,
69+
// Servers that ignore scopes return none and grant everything
70+
scope: tokenResponse.scope || "coder:all",
7371
expiry_timestamp: getExpiryTimestamp(tokenResponse),
7472
};
7573
}

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

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

812
import {
913
createMockUser,
@@ -372,6 +376,27 @@ describe("OAuthAuthorizer", () => {
372376
await completeLogin(state);
373377
await loginPromise;
374378
});
379+
380+
it.each([
381+
{ name: "the server supports", supported: IF_SUPPORTED_OAUTH_SCOPES },
382+
{ name: "none when the server supports none", supported: [] },
383+
])("requests IF_SUPPORTED_OAUTH_SCOPES: $name", async ({ supported }) => {
384+
const { setupOAuthRoutes, startLogin, completeLogin } =
385+
createTestContext();
386+
setupOAuthRoutes(
387+
createMockOAuthMetadata(TEST_URL, {
388+
scopes_supported: [...DEFAULT_OAUTH_SCOPES.split(" "), ...supported],
389+
}),
390+
);
391+
392+
const { loginPromise, authUrl, state } = await startLogin();
393+
expect(authUrl.searchParams.get("scope")).toBe(
394+
[DEFAULT_OAUTH_SCOPES, ...supported].join(" "),
395+
);
396+
397+
await completeLogin(state);
398+
await loginPromise;
399+
});
375400
});
376401

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

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

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

487+
it("revokes tokens with outdated scopes", async () => {
488+
const { manager, setupForOAuthOperation } = createTestContext();
489+
const revoke = vi.fn(() => ({}));
490+
await setupForOAuthOperation(
491+
{ "/oauth2/revoke": revoke },
492+
{ scope: "workspace:read" },
493+
);
494+
495+
await manager.revokeTokens();
496+
497+
expect(revoke).toHaveBeenCalledTimes(2);
498+
});
499+
487500
it("does not throw when revocation fails", async () => {
488501
const { manager, setupForOAuthOperation } = createTestContext();
489502

@@ -531,14 +544,22 @@ describe("OAuthSessionManager", () => {
531544
oauth: {
532545
refresh_token: "refresh-token",
533546
expiry_timestamp: Date.now() + ONE_HOUR_MS,
534-
// workspace:* covers workspace:read, workspace:update, etc.
535-
scope: "workspace:* template:read user:read_personal",
547+
// workspace:* and user:* cover the low-level scopes
548+
scope:
549+
"coder:workspaces.operate coder:workspaces.access workspace:* user:*",
536550
},
537551
});
538552

539553
const result = await manager.isLoggedInWithOAuth();
540554
expect(result).toBe(true);
541555
});
556+
557+
it("accepts coder:all", async () => {
558+
const { manager, setupOAuthSession } = createTestContext();
559+
await setupOAuthSession({ scope: "coder:all" });
560+
561+
expect(await manager.isLoggedInWithOAuth()).toBe(true);
562+
});
542563
});
543564

544565
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"],

‎test/unit/oauth/utils.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ describe("buildOAuthTokenData", () => {
147147
});
148148
});
149149

150+
it("stores coder:all when the server returns no scope", () => {
151+
expect(
152+
buildOAuthTokenData(createTokenResponse({ scope: undefined })).scope,
153+
).toBe("coder:all");
154+
});
155+
150156
describe("token_type validation", () => {
151157
it("accepts Bearer tokens", () => {
152158
expect(() =>

0 commit comments

Comments
 (0)