From 5b913e9a30a28e4a8ee292f2e158653a44a93bc9 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 8 Oct 2026 21:24:17 +0300 Subject: [PATCH] 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`. --- CHANGELOG.md | 6 +++++ src/oauth/authorizer.ts | 13 +++++++--- src/oauth/constants.ts | 21 ++++++++++----- src/oauth/sessionManager.ts | 36 ++++++++++++++++---------- src/oauth/utils.ts | 6 ++--- test/unit/oauth/authorizer.test.ts | 25 ++++++++++++++++++ test/unit/oauth/sessionManager.test.ts | 25 ++++++++++++++++-- test/unit/oauth/testUtils.ts | 10 +++---- test/unit/oauth/utils.test.ts | 6 +++++ 9 files changed, 113 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 513f273fbf..acac656635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ the socket never reaches a terminal failure. The extension keeps retrying until the server is reachable again. +### Fixed + +- OAuth sign-in now works on deployments that enforce token scopes. Users signed + in with OAuth are asked to sign in again once. +- Revoke OAuth tokens on logout even when their scopes are outdated. + ## [v1.16.4](https://github.com/coder/vscode-coder/releases/tag/v1.16.4) 2026-09-23 ### Added diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index b22bb90a70..6d23718a7b 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -6,6 +6,7 @@ import { resolveCoderDashboardUrl } from "../util/uri"; import { AUTH_GRANT_TYPE, DEFAULT_OAUTH_SCOPES, + IF_SUPPORTED_OAUTH_SCOPES, PKCE_CHALLENGE_METHOD, RESPONSE_TYPE, TOKEN_ENDPOINT_AUTH_METHOD, @@ -213,17 +214,23 @@ export class OAuthAuthorizer implements vscode.Disposable { ); if (unsupportedScopes.length > 0) { this.logger.warn( - `Requested scopes not in server's supported scopes: ${unsupportedScopes.join(", ")}. Server may still accept them.`, + `Requested scopes not in server's supported scopes: ${unsupportedScopes.join(", ")}. Servers that enforce scopes reject the request.`, { supported_scopes: metadata.scopes_supported }, ); } } + const scope = [ + DEFAULT_OAUTH_SCOPES, + ...IF_SUPPORTED_OAUTH_SCOPES.filter((s) => + metadata.scopes_supported?.includes(s), + ), + ].join(" "); const params = new URLSearchParams({ client_id: clientId, response_type: RESPONSE_TYPE, redirect_uri: this.getRedirectUri(), - scope: DEFAULT_OAUTH_SCOPES, + scope, state, code_challenge: challenge, code_challenge_method: PKCE_CHALLENGE_METHOD, @@ -240,7 +247,7 @@ export class OAuthAuthorizer implements vscode.Disposable { this.logger.debug("Built OAuth authorization URL:", { client_id: clientId, redirect_uri: this.getRedirectUri(), - scope: DEFAULT_OAUTH_SCOPES, + scope, }); return endpoint.toString(); diff --git a/src/oauth/constants.ts b/src/oauth/constants.ts index 542fa517c9..3f8356c62a 100644 --- a/src/oauth/constants.ts +++ b/src/oauth/constants.ts @@ -2,17 +2,24 @@ export const AUTH_GRANT_TYPE = "authorization_code"; export const REFRESH_GRANT_TYPE = "refresh_token"; -// Minimal scopes required by the VS Code extension +/** + * Scopes the extension and the CLI need. Stored sessions must have all of + * them, so adding one signs out users of servers that enforce scopes. + */ export const DEFAULT_OAUTH_SCOPES = [ - "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", ].join(" "); +/** + * Requested only when the server lists them in `scopes_supported`, as servers + * reject scopes they don't know. Stored sessions don't need them. + */ +export const IF_SUPPORTED_OAUTH_SCOPES = ["inbox_notification:read"]; + // OAuth 2.1 Response Types export const RESPONSE_TYPE = "code"; diff --git a/src/oauth/sessionManager.ts b/src/oauth/sessionManager.ts index 5ccc122aa5..b435910171 100644 --- a/src/oauth/sessionManager.ts +++ b/src/oauth/sessionManager.ts @@ -98,8 +98,7 @@ export class OAuthSessionManager implements vscode.Disposable { /** * Get stored tokens fresh from secrets manager. * Always reads from storage to ensure cross-window synchronization. - * Validates that tokens match current deployment URL and have required scopes. - * Invalid tokens are cleared and undefined is returned. + * Returns undefined for tokens of another deployment URL. */ private async getStoredTokens(): Promise { if (!this.deployment) { @@ -122,19 +121,26 @@ export class OAuthSessionManager implements vscode.Disposable { return undefined; } - if (!this.hasRequiredScopes(auth.oauth.scope)) { - this.logger.warn("Stored tokens have insufficient scopes", { - scope: auth.oauth.scope, - }); - return undefined; - } - return { access_token: auth.token, ...auth.oauth, }; } + /** Stored tokens, or undefined when they lack a required scope. */ + private async getTokensWithRequiredScopes(): Promise< + StoredTokens | undefined + > { + const storedTokens = await this.getStoredTokens(); + if (storedTokens && !this.hasRequiredScopes(storedTokens.scope)) { + this.logger.warn("Stored tokens have insufficient scopes", { + scope: storedTokens.scope, + }); + return undefined; + } + return storedTokens; + } + /** * Clear all refresh-related state: in-flight promise, timer, and listener. * Aborts any in-flight refresh request to prevent stale token updates. @@ -186,7 +192,7 @@ export class OAuthSessionManager implements vscode.Disposable { this.refreshTimer = undefined; } - this.getStoredTokens() + this.getTokensWithRequiredScopes() .then((storedTokens) => { if (!storedTokens?.refresh_token) { return; @@ -262,6 +268,9 @@ export class OAuthSessionManager implements vscode.Disposable { */ private hasRequiredScopes(grantedScope: string): boolean { const grantedScopes = new Set(grantedScope.split(" ")); + if (grantedScopes.has("coder:all")) { + return true; + } const requiredScopes = DEFAULT_OAUTH_SCOPES.split(" "); for (const required of requiredScopes) { @@ -331,7 +340,7 @@ export class OAuthSessionManager implements vscode.Disposable { this.deployment = deployment; this.clearRefreshState(); - const storedTokens = await this.getStoredTokens(); + const storedTokens = await this.getTokensWithRequiredScopes(); if (storedTokens) { this.logger.debug("Switching OAuth deployment", deployment); } @@ -385,7 +394,7 @@ export class OAuthSessionManager implements vscode.Disposable { this.refreshAbortController = abortController; try { - const storedTokens = await this.getStoredTokens(); + const storedTokens = await this.getTokensWithRequiredScopes(); if (!storedTokens?.refresh_token) { throw new Error("No refresh token available"); } @@ -475,6 +484,7 @@ export class OAuthSessionManager implements vscode.Disposable { /** Best-effort server-side revocation of the stored refresh and access tokens; never throws. */ public async revokeTokens(): Promise { + // Includes tokens lacking a required scope, which still work on the server. const storedTokens = await this.getStoredTokens().catch((error) => { this.logger.warn("Failed to read stored tokens for revocation:", error); return undefined; @@ -535,7 +545,7 @@ export class OAuthSessionManager implements vscode.Disposable { if (hostname && hostname !== this.deployment?.safeHostname) { return false; } - const storedTokens = await this.getStoredTokens(); + const storedTokens = await this.getTokensWithRequiredScopes(); return storedTokens !== undefined; } diff --git a/src/oauth/utils.ts b/src/oauth/utils.ts index a61226ed66..1c65818ea5 100644 --- a/src/oauth/utils.ts +++ b/src/oauth/utils.ts @@ -1,7 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import { DEFAULT_OAUTH_SCOPES } from "./constants"; - import type { OAuth2TokenResponse } from "coder/site/src/api/typesGenerated"; import type { OAuthTokenData } from "../core/secretsManager"; @@ -68,8 +66,8 @@ export function buildOAuthTokenData( return { refresh_token: tokenResponse.refresh_token, - // Use default scopes when server returns empty, so scope changes can invalidate tokens - scope: tokenResponse.scope || DEFAULT_OAUTH_SCOPES, + // Servers that ignore scopes return none and grant everything + scope: tokenResponse.scope || "coder:all", expiry_timestamp: getExpiryTimestamp(tokenResponse), }; } diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index 2f95f7ae8c..34549bcb61 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -4,6 +4,10 @@ import * as vscode from "vscode"; import { InvalidApiResponseError } from "@/api/responseValidation"; import { getHeaders } from "@/headers"; import { OAuthAuthorizer } from "@/oauth/authorizer"; +import { + DEFAULT_OAUTH_SCOPES, + IF_SUPPORTED_OAUTH_SCOPES, +} from "@/oauth/constants"; import { createMockUser, @@ -372,6 +376,27 @@ describe("OAuthAuthorizer", () => { await completeLogin(state); await loginPromise; }); + + it.each([ + { name: "the server supports", supported: IF_SUPPORTED_OAUTH_SCOPES }, + { name: "none when the server supports none", supported: [] }, + ])("requests IF_SUPPORTED_OAUTH_SCOPES: $name", async ({ supported }) => { + const { setupOAuthRoutes, startLogin, completeLogin } = + createTestContext(); + setupOAuthRoutes( + createMockOAuthMetadata(TEST_URL, { + scopes_supported: [...DEFAULT_OAUTH_SCOPES.split(" "), ...supported], + }), + ); + + const { loginPromise, authUrl, state } = await startLogin(); + expect(authUrl.searchParams.get("scope")).toBe( + [DEFAULT_OAUTH_SCOPES, ...supported].join(" "), + ); + + await completeLogin(state); + await loginPromise; + }); }); describe("callback handling", () => { diff --git a/test/unit/oauth/sessionManager.test.ts b/test/unit/oauth/sessionManager.test.ts index b02927fc4a..34608e2fc1 100644 --- a/test/unit/oauth/sessionManager.test.ts +++ b/test/unit/oauth/sessionManager.test.ts @@ -484,6 +484,19 @@ describe("OAuthSessionManager", () => { ]); }); + it("revokes tokens with outdated scopes", async () => { + const { manager, setupForOAuthOperation } = createTestContext(); + const revoke = vi.fn(() => ({})); + await setupForOAuthOperation( + { "/oauth2/revoke": revoke }, + { scope: "workspace:read" }, + ); + + await manager.revokeTokens(); + + expect(revoke).toHaveBeenCalledTimes(2); + }); + it("does not throw when revocation fails", async () => { const { manager, setupForOAuthOperation } = createTestContext(); @@ -531,14 +544,22 @@ 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:*", }, }); const result = await manager.isLoggedInWithOAuth(); expect(result).toBe(true); }); + + it("accepts coder:all", async () => { + const { manager, setupOAuthSession } = createTestContext(); + await setupOAuthSession({ scope: "coder:all" }); + + expect(await manager.isLoggedInWithOAuth()).toBe(true); + }); }); describe("telemetry", () => { 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"], diff --git a/test/unit/oauth/utils.test.ts b/test/unit/oauth/utils.test.ts index 4dafa024ba..1bebaf8e95 100644 --- a/test/unit/oauth/utils.test.ts +++ b/test/unit/oauth/utils.test.ts @@ -147,6 +147,12 @@ describe("buildOAuthTokenData", () => { }); }); + it("stores coder:all when the server returns no scope", () => { + expect( + buildOAuthTokenData(createTokenResponse({ scope: undefined })).scope, + ).toBe("coder:all"); + }); + describe("token_type validation", () => { it("accepts Bearer tokens", () => { expect(() =>