Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions src/oauth/authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down
21 changes: 14 additions & 7 deletions src/oauth/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
36 changes: 23 additions & 13 deletions src/oauth/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StoredTokens | undefined> {
if (!this.deployment) {
Expand All @@ -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.
Expand Down Expand Up @@ -186,7 +192,7 @@ export class OAuthSessionManager implements vscode.Disposable {
this.refreshTimer = undefined;
}

this.getStoredTokens()
this.getTokensWithRequiredScopes()
.then((storedTokens) => {
if (!storedTokens?.refresh_token) {
return;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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<void> {
// 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;
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 2 additions & 4 deletions src/oauth/utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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),
};
}
Expand Down
25 changes: 25 additions & 0 deletions test/unit/oauth/authorizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand Down
25 changes: 23 additions & 2 deletions test/unit/oauth/sessionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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", () => {
Expand Down
10 changes: 4 additions & 6 deletions test/unit/oauth/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
6 changes: 6 additions & 0 deletions test/unit/oauth/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down
Loading