Skip to content

Commit 183b2bc

Browse files
committed
fix(oauth): revoke replaced sessions and preserve token metadata
Clean up replaced OAuth sessions without forcing a re-login when the required scopes change. - Insufficient scopes prevent refresh but keep the stored access token, so normal 401 recovery applies after expiry. Rename the check to `canRefreshOAuthSession`. - Save a successful replacement first, then revoke the overwritten OAuth pair in the background with the client registration captured before login. Failed or canceled logins, same-token reuse, and stored-session adoption revoke nothing. - Keep refresh credentials and scopes when the same token is reused on the same origin, instead of turning it into a manual-token session. If a refresh rotates the session while its stored token is checked, keep the rotated session. - Share revocation with logout through `withOAuthMetadata`, which token refresh also uses, and request `user:update_personal` to refresh expired external-auth links. - If the server refuses dynamic client registration (off by default from Coder 2.38), name the setting and offer session-token sign-in. - Start every CLI invocation's global flags with an empty `--token=`, so `CODER_SESSION_TOKEN` in the extension host (in any case) no longer overrides the stored session. This covers the SSH ProxyCommand and terminals too. `coder login --use-token-as-session` omits it, since it reads the token from the environment, and gets an environment without the host's case variants of the variable. Closes #1140
1 parent 01093dc commit 183b2bc

20 files changed

Lines changed: 538 additions & 186 deletions

‎CHANGELOG.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,18 @@
2020

2121
### Fixed
2222

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.
23+
- OAuth sign-in now works on deployments that enforce token scopes. Sessions
24+
missing required scopes stop refreshing and require sign-in when they expire.
25+
- Revoke OAuth tokens on logout and after successful session replacement, even
26+
when their scopes are outdated. Failed or canceled sign-ins do not revoke
27+
the stored session.
28+
- Preserve OAuth refresh credentials when reusing a stored token, and request
29+
the permissions needed to refresh workspace external-auth links.
30+
- If a deployment disables dynamic client registration (the default from Coder
31+
2.38), OAuth sign-in now names the setting and offers session-token sign-in.
32+
- CLI commands and SSH connections that the extension starts ignore
33+
`CODER_SESSION_TOKEN` from the environment, so they no longer fail or run as a
34+
different user inside a Coder workspace.
2635

2736
## [v1.16.4](https://github.com/coder/vscode-coder/releases/tag/v1.16.4) 2026-09-23
2837

‎src/api/authInterceptor.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export class AuthInterceptor implements vscode.Disposable {
102102

103103
// 1) OAuth refresh path.
104104
const isOAuth =
105-
await this.oauthSessionManager.isLoggedInWithOAuth(hostname);
105+
await this.oauthSessionManager.canRefreshOAuthSession(hostname);
106106
recorder.setRefreshAttempted(isOAuth);
107107
if (isOAuth) {
108108
const newToken = await this.tryOAuthRefresh();

‎src/cli/cliCredentialManager.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { recordError } from "../instrumentation/outcomes";
1515
import {
1616
type CliAuth,
17+
EMPTY_TOKEN_FLAG,
1718
getGlobalFlags,
1819
mayUseCliStore,
1920
resolveCliAuth,
@@ -75,10 +76,16 @@ export class CliCredentialManager {
7576
return;
7677
}
7778
span.setProperty("store", cli.auth.store);
78-
await this.exec(cli, ["login", "--use-token-as-session", url], {
79-
env: { ...process.env, CODER_SESSION_TOKEN: token },
80-
signal: options?.signal,
81-
});
79+
// `login` reads the token from the environment, which the empty flag would hide.
80+
const flags = cli.flags.filter((flag) => flag !== EMPTY_TOKEN_FLAG);
81+
await this.exec(
82+
{ ...cli, flags },
83+
["login", "--use-token-as-session", url],
84+
{
85+
env: envWithSessionToken(token),
86+
signal: options?.signal,
87+
},
88+
);
8289
span.setProperty("outcome", "stored");
8390
this.logger.info("Stored token via CLI for", url);
8491
});
@@ -268,3 +275,13 @@ export class CliCredentialManager {
268275
return results.every(Boolean);
269276
}
270277
}
278+
279+
/** `process.env` with `token` as the only session token, since the CLI reads the name in any case. */
280+
function envWithSessionToken(token: string): NodeJS.ProcessEnv {
281+
const env = Object.fromEntries(
282+
Object.entries(process.env).filter(
283+
([key]) => key.toUpperCase() !== "CODER_SESSION_TOKEN",
284+
),
285+
);
286+
return { ...env, CODER_SESSION_TOKEN: token };
287+
}

‎src/login/loginCoordinator.ts‎

Lines changed: 82 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,22 @@ import { needToken } from "../api/httpAgent";
77
import { HttpStatusCode } from "../api/httpStatusCode";
88
import { isSameOrigin, openInBrowser } from "../common/url";
99
import { CertificateError } from "../error/certificateError";
10-
import { OAuthAuthorizer } from "../oauth/authorizer";
10+
import {
11+
ClientRegistrationDisabledError,
12+
OAuthAuthorizer,
13+
} from "../oauth/authorizer";
14+
import { revokeOAuthTokens } from "../oauth/revocation";
1115
import { buildOAuthTokenData } from "../oauth/tokens";
1216
import { withCancellableProgress } from "../ui/progress";
1317
import { maybeAskAuthMethod, maybeAskUrl } from "../ui/prompts";
1418
import { vscodeProposed } from "../vscode/proposed";
1519

1620
import { showStoreCredentialsError } from "./credentials";
1721

18-
import type { User } from "coder/site/src/api/typesGenerated";
22+
import type {
23+
User,
24+
OAuth2ClientRegistrationResponse,
25+
} from "coder/site/src/api/typesGenerated";
1926

2027
import type { CliCredentialManager } from "../cli/cliCredentialManager";
2128
import type { Deployment } from "../deployment/types";
@@ -109,18 +116,14 @@ export class LoginCoordinator implements vscode.Disposable {
109116
options: LoginOptions & { url: string },
110117
): Promise<LoginResult> {
111118
const { safeHostname, url } = options;
112-
return this.executeWithGuard(async () => {
113-
const result = await this.attemptLogin(
119+
return this.executeWithGuard(() =>
120+
this.attemptLogin(
114121
{ safeHostname, url },
115122
options.autoLogin ?? false,
116123
options.token,
117124
options.tokenSignInConfirmed ?? false,
118-
);
119-
120-
await this.persistSessionAuth(result, safeHostname, url);
121-
122-
return result;
123-
});
125+
),
126+
);
124127
}
125128

126129
/**
@@ -170,15 +173,11 @@ export class LoginCoordinator implements vscode.Disposable {
170173
return { success: false, reason: "no_url_provided" };
171174
}
172175

173-
const result = await this.attemptLogin(
176+
return this.attemptLogin(
174177
{ url: newUrl, safeHostname },
175178
false,
176179
options.token,
177180
);
178-
179-
await this.persistSessionAuth(result, safeHostname, newUrl);
180-
181-
return result;
182181
}
183182
// User cancelled
184183
return { success: false, reason: "user_dismissed" };
@@ -198,28 +197,42 @@ export class LoginCoordinator implements vscode.Disposable {
198197
}
199198

200199
private async persistSessionAuth(
201-
result: LoginResult,
202-
safeHostname: string,
203-
url: string,
200+
result: Extract<LoginResult, { success: true }>,
201+
deployment: Deployment,
202+
registration: OAuth2ClientRegistrationResponse | undefined,
204203
): Promise<void> {
204+
const { safeHostname, url } = deployment;
205+
const previous = await this.secretsManager.getSessionAuth(safeHostname);
206+
const replaced = previous?.token === result.token ? undefined : previous;
207+
// A refresh rotated the stored session while this login checked it.
208+
if (result.method === "stored_token" && replaced) {
209+
return;
210+
}
211+
const reusedOAuth =
212+
!replaced && previous && isSameOrigin(previous.url, url)
213+
? previous.oauth
214+
: undefined;
215+
205216
// Empty token is valid for mTLS
206-
if (result.success) {
207-
await this.secretsManager.setSessionAuth(safeHostname, {
208-
url,
209-
token: result.token,
210-
username: result.user.username,
211-
oauth: result.oauth, // undefined for non-OAuth logins
212-
});
213-
await this.mementoManager.addToUrlHistory(url);
214-
215-
if (result.token) {
216-
const configs = vscode.workspace.getConfiguration();
217-
this.cliCredentialManager
218-
.storeToken(url, result.token, configs)
219-
.catch((error) =>
220-
showStoreCredentialsError(error, configs, this.logger),
221-
);
222-
}
217+
await this.secretsManager.setSessionAuth(safeHostname, {
218+
url,
219+
token: result.token,
220+
username: result.user.username,
221+
oauth: result.oauth ?? reusedOAuth,
222+
});
223+
await this.mementoManager.addToUrlHistory(url);
224+
225+
if (replaced?.oauth) {
226+
void revokeOAuthTokens(replaced, registration, this.logger);
227+
}
228+
229+
if (result.token) {
230+
const configs = vscode.workspace.getConfiguration();
231+
this.cliCredentialManager
232+
.storeToken(url, result.token, configs)
233+
.catch((error) =>
234+
showStoreCredentialsError(error, configs, this.logger),
235+
);
223236
}
224237
}
225238

@@ -286,15 +299,23 @@ export class LoginCoordinator implements vscode.Disposable {
286299
providedToken?: string,
287300
tokenSignInConfirmed = false,
288301
): Promise<LoginResult> {
302+
// Read before an OAuth login can re-register the client.
303+
const registration = await this.secretsManager.getOAuthClientRegistration(
304+
deployment.safeHostname,
305+
);
289306
const client = CoderApi.create(deployment.url, "", this.logger);
290307
try {
291-
return await this.runLoginAttempts(
308+
const result = await this.runLoginAttempts(
292309
client,
293310
deployment,
294311
isAutoLogin,
295312
providedToken,
296313
tokenSignInConfirmed,
297314
);
315+
if (result.success) {
316+
await this.persistSessionAuth(result, deployment, registration);
317+
}
318+
return result;
298319
} finally {
299320
client.dispose();
300321
}
@@ -477,11 +498,16 @@ export class LoginCoordinator implements vscode.Disposable {
477498
private async askAuthMethod(ctx: LoginAttemptContext): Promise<LoginResult> {
478499
const authMethod = await maybeAskAuthMethod(ctx.client);
479500
switch (authMethod) {
480-
case "oauth":
501+
case "oauth": {
502+
const result = await this.loginWithOAuth(ctx.deployment);
503+
if (result !== "use_token") {
504+
return withLoginMethod("oauth", result);
505+
}
481506
return withLoginMethod(
482-
"oauth",
483-
await this.loginWithOAuth(ctx.deployment),
507+
"cli_token",
508+
await this.loginWithToken(ctx.client),
484509
);
510+
}
485511
case "legacy":
486512
return withLoginMethod(
487513
"cli_token",
@@ -636,12 +662,10 @@ export class LoginCoordinator implements vscode.Disposable {
636662
return { success: false, reason: "user_dismissed" };
637663
}
638664

639-
/**
640-
* OAuth authentication flow.
641-
*/
665+
/** OAuth flow; "use_token" means the user chose session-token sign-in instead. */
642666
private async loginWithOAuth(
643667
deployment: Deployment,
644-
): Promise<LoginAttemptResult> {
668+
): Promise<LoginAttemptResult | "use_token"> {
645669
try {
646670
this.logger.debug("Starting OAuth authentication");
647671

@@ -666,6 +690,21 @@ export class LoginCoordinator implements vscode.Disposable {
666690
oauth: buildOAuthTokenData(tokenResponse),
667691
};
668692
} catch (error) {
693+
if (error instanceof ClientRegistrationDisabledError) {
694+
this.logger.warn("OAuth sign-in unavailable:", error.message);
695+
const action = await vscodeProposed.window.showWarningMessage(
696+
"OAuth sign-in is unavailable",
697+
{
698+
useCustom: true,
699+
modal: true,
700+
detail: `${error.message}. To use OAuth, ask an administrator to set dynamic_client_registration_enabled.`,
701+
},
702+
"Sign In with Token",
703+
);
704+
return action === "Sign In with Token"
705+
? "use_token"
706+
: { success: false, reason: "user_dismissed" };
707+
}
669708
const title = "OAuth authentication failed";
670709
this.logger.error(title, error);
671710
if (error instanceof CertificateError) {

‎src/oauth/authorizer.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { type AxiosInstance, isAxiosError } from "axios";
12
import { createHash, randomBytes } from "node:crypto";
23
import * as vscode from "vscode";
34

45
import { CoderApi } from "../api/coderApi";
6+
import { HttpStatusCode } from "../api/httpStatusCode";
57
import { resolveCoderDashboardUrl } from "../common/url";
68

79
import {
@@ -21,7 +23,6 @@ import {
2123
parseOAuthResponse,
2224
} from "./validation";
2325

24-
import type { AxiosInstance } from "axios";
2526
import type {
2627
OAuth2AuthorizationServerMetadata,
2728
OAuth2ClientRegistrationRequest,
@@ -37,6 +38,14 @@ import type { SecretsManager } from "../storage/secretsManager";
3738

3839
import type { OAuthCallback } from "./oauthCallback";
3940

41+
/** The server refuses client registration, which is off by default from Coder 2.38. */
42+
export class ClientRegistrationDisabledError extends Error {
43+
constructor() {
44+
super("Dynamic client registration is disabled on this deployment");
45+
this.name = "ClientRegistrationDisabledError";
46+
}
47+
}
48+
4049
/**
4150
* Handles the OAuth authorization code flow for authenticating with Coder deployments.
4251
* Encapsulates client registration, PKCE challenge, and token exchange.
@@ -171,10 +180,20 @@ export class OAuthAuthorizer implements vscode.Disposable {
171180
token_endpoint_auth_method: TOKEN_ENDPOINT_AUTH_METHOD,
172181
};
173182

174-
const response = await axiosInstance.post<OAuth2ClientRegistrationResponse>(
175-
metadata.registration_endpoint,
176-
registrationRequest,
177-
);
183+
const response = await axiosInstance
184+
.post<OAuth2ClientRegistrationResponse>(
185+
metadata.registration_endpoint,
186+
registrationRequest,
187+
)
188+
.catch((error: unknown) => {
189+
if (
190+
isAxiosError(error) &&
191+
error.response?.status === HttpStatusCode.FORBIDDEN
192+
) {
193+
throw new ClientRegistrationDisabledError();
194+
}
195+
throw error;
196+
});
178197

179198
const registrationResponse = parseOAuthResponse(
180199
OAuth2ClientRegistrationResponseSchema,

‎src/oauth/constants.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ export const AUTH_GRANT_TYPE = "authorization_code";
33
export const REFRESH_GRANT_TYPE = "refresh_token";
44

55
/**
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.
6+
* Scopes the extension and the CLI need. Sessions missing one stop refreshing,
7+
* so on servers that enforce scopes, adding one means signing in at expiry.
88
*/
99
export const DEFAULT_OAUTH_SCOPES = [
1010
"coder:workspaces.operate",
1111
"coder:workspaces.access",
1212
"workspace:create",
1313
"user:read",
1414
"user:read_personal",
15+
"user:update_personal",
1516
].join(" ");
1617

1718
/**

‎src/oauth/metadataClient.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CoderApi } from "../api/coderApi";
12
import { parseApiResponse } from "../api/responseValidation";
23

34
import {
@@ -142,6 +143,33 @@ export class OAuthMetadataClient {
142143
}
143144
}
144145

146+
/**
147+
* Run `fn` with a short-lived client for `url` and the server's OAuth
148+
* metadata. The client is disposed on exit so its config-change
149+
* subscriptions never outlive the operation.
150+
*/
151+
export async function withOAuthMetadata<T>(
152+
url: string,
153+
token: string | undefined,
154+
logger: Logger,
155+
fn: (ctx: {
156+
axiosInstance: AxiosInstance;
157+
metadata: OAuth2AuthorizationServerMetadata;
158+
}) => Promise<T>,
159+
): Promise<T> {
160+
const client = CoderApi.create(url, token, logger);
161+
try {
162+
const axiosInstance = client.getAxiosInstance();
163+
const metadata = await new OAuthMetadataClient(
164+
axiosInstance,
165+
logger,
166+
).getMetadata();
167+
return await fn({ axiosInstance, metadata });
168+
} finally {
169+
client.dispose();
170+
}
171+
}
172+
145173
/**
146174
* Check if an array includes all required types.
147175
*/

0 commit comments

Comments
 (0)