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(() =>