Skip to content

Commit a20f0af

Browse files
committed
fix(oauth): address review on CLI token env and client registrations
- Spawned CLI processes get `process.env` without `CODER_SESSION_TOKEN`, matched as the CLI reads it: any letter case, with or without the `HOMEBREW_` prefix. Only shell command strings (the SSH ProxyCommand and `terminal.sendText`) keep an inline `--token=`, since the extension can't set their environment. - Save a client registration only after the OAuth login succeeds, so a failed login keeps the registration a stored session was issued to. - Rename the recorded OAuth client to `registration` to tell the registered app apart from the HTTP client. - Offer OAuth only when discovery advertises `registration_endpoint`, which Coder omits while dynamic client registration is off (the default from 2.37), instead of failing after the user picks OAuth.
1 parent b1e1e0f commit a20f0af

21 files changed

Lines changed: 331 additions & 295 deletions

‎CHANGELOG.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@
2727
the stored session.
2828
- Preserve OAuth refresh credentials when reusing a stored token, and request
2929
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-
OAuth becomes the default sign-in method in a future release, so ask your
33-
Coder administrator to enable dynamic client registration. For instructions,
34-
see [OAuth2 provider](https://coder.com/docs/admin/integrations/oauth2-provider).
30+
- OAuth sign-in is offered only when the deployment allows dynamic client
31+
registration, which is off by default from Coder 2.37; otherwise sign-in uses
32+
a session token. OAuth becomes the default sign-in method in a future
33+
release, so ask your Coder administrator to enable dynamic client
34+
registration. For instructions, see
35+
[OAuth2 provider](https://coder.com/docs/admin/integrations/oauth2-provider).
3536
- Keep session tokens, OAuth tokens, and client secrets from failed requests out
3637
of the buffered logs that the Coder output channel replays.
3738
- CLI commands and SSH connections that the extension starts ignore

‎src/api/workspace.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { spawn } from "node:child_process";
22
import * as vscode from "vscode";
33

44
import { errToStr } from "../error/normalize";
5-
import { getGlobalFlags, type CliAuth } from "../settings/cli";
5+
import {
6+
getCliProcessEnv,
7+
getGlobalFlags,
8+
type CliAuth,
9+
} from "../settings/cli";
610

711
import { createWorkspaceIdentifier } from "./models";
812

@@ -80,7 +84,7 @@ function runCliCommand(ctx: CliContext, args: string[]): Promise<void> {
8084
...args,
8185
createWorkspaceIdentifier(ctx.workspace),
8286
];
83-
const proc = spawn(ctx.binPath, fullArgs);
87+
const proc = spawn(ctx.binPath, fullArgs, { env: getCliProcessEnv() });
8488
// Unexpected prompts EOF instead of hanging forever.
8589
proc.stdin.end();
8690

‎src/cli/cliCredentialManager.ts‎

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { recordError } from "../instrumentation/outcomes";
1515
import {
1616
type CliAuth,
17-
EMPTY_TOKEN_FLAG,
17+
getCliProcessEnv,
1818
getGlobalFlags,
1919
mayUseCliStore,
2020
resolveCliAuth,
@@ -76,16 +76,10 @@ export class CliCredentialManager {
7676
return;
7777
}
7878
span.setProperty("store", cli.auth.store);
79-
// `login` reads the token from the environment, so drop the empty flag.
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-
);
79+
await this.exec(cli, ["login", "--use-token-as-session", url], {
80+
env: { ...getCliProcessEnv(), CODER_SESSION_TOKEN: token },
81+
signal: options?.signal,
82+
});
8983
span.setProperty("outcome", "stored");
9084
this.logger.info("Stored token via CLI for", url);
9185
});
@@ -241,6 +235,7 @@ export class CliCredentialManager {
241235
}, EXEC_LOG_INTERVAL_MS);
242236
try {
243237
return await execFileAsync(cli.binPath, [...cli.flags, ...args], {
238+
env: getCliProcessEnv(),
244239
...options,
245240
timeout: EXEC_TIMEOUT_MS,
246241
});
@@ -275,13 +270,3 @@ export class CliCredentialManager {
275270
return results.every(Boolean);
276271
}
277272
}
278-
279-
/** `process.env` with `token` as its only `CODER_SESSION_TOKEN`, 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/cli/cliExec.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isAbortError } from "../error/abort";
88
import { toError } from "../error/normalize";
99
import {
1010
type CliAuth,
11+
getCliProcessEnv,
1112
getGlobalFlags,
1213
getGlobalShellFlags,
1314
} from "../settings/cli";
@@ -88,7 +89,10 @@ export async function speedtest(
8889
args.push("-t", duration);
8990
}
9091
try {
91-
const result = await execFileAsync(env.binary, args, { signal });
92+
const result = await execFileAsync(env.binary, args, {
93+
env: getCliProcessEnv(),
94+
signal,
95+
});
9296
return result.stdout;
9397
} catch (error) {
9498
throw cliError(error);
@@ -107,7 +111,7 @@ export async function netcheck(
107111
const result = await execFileAsync(
108112
env.binary,
109113
[...globalFlags, "netcheck"],
110-
{ signal },
114+
{ env: getCliProcessEnv(), signal },
111115
);
112116
return result.stdout;
113117
} catch (error) {
@@ -142,7 +146,7 @@ export async function supportBundle(
142146
...workspaceFiles.flatMap((file) => ["--workspace-file", file]),
143147
];
144148
try {
145-
await execFileAsync(env.binary, args, { signal });
149+
await execFileAsync(env.binary, args, { env: getCliProcessEnv(), signal });
146150
} catch (error) {
147151
throw cliError(error);
148152
}
@@ -221,6 +225,7 @@ function spawnCliInTerminal(options: {
221225
// fall back to proc.kill() there.
222226
const useProcessGroup = process.platform !== "win32";
223227
const proc = spawn(options.binary, options.args, {
228+
env: getCliProcessEnv(),
224229
detached: useProcessGroup,
225230
});
226231

‎src/login/loginCoordinator.ts‎

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,7 @@ 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 {
11-
ClientRegistrationDisabledError,
12-
OAuthAuthorizer,
13-
} from "../oauth/authorizer";
10+
import { OAuthAuthorizer } from "../oauth/authorizer";
1411
import { revokeOAuthTokens } from "../oauth/revocation";
1512
import { buildOAuthTokenData } from "../oauth/tokens";
1613
import { withCancellableProgress } from "../ui/progress";
@@ -289,18 +286,19 @@ export class LoginCoordinator implements vscode.Disposable {
289286

290287
/**
291288
* Attempt to authenticate using OAuth, token, or mTLS. If necessary, prompts
292-
* for authentication method and credentials. Returns the token, user, and
293-
* method on success, or a bounded reason when the user aborts or
294-
* authentication fails.
289+
* for authentication method and credentials. On success, persists the
290+
* session, revokes the OAuth session it replaces, and returns the token,
291+
* user, and method. Otherwise returns a bounded reason when the user aborts
292+
* or authentication fails.
295293
*/
296294
private async attemptLogin(
297295
deployment: Deployment,
298296
isAutoLogin: boolean,
299297
providedToken?: string,
300298
tokenSignInConfirmed = false,
301299
): Promise<LoginResult> {
302-
// Revokes sessions that don't record their client. Read it before login can
303-
// re-register the client.
300+
// Read before OAuth login replaces it; it revokes a replaced session saved
301+
// without its own registration.
304302
const registration = await this.secretsManager.getOAuthClientRegistration(
305303
deployment.safeHostname,
306304
);
@@ -499,16 +497,11 @@ export class LoginCoordinator implements vscode.Disposable {
499497
private async askAuthMethod(ctx: LoginAttemptContext): Promise<LoginResult> {
500498
const authMethod = await maybeAskAuthMethod(ctx.client);
501499
switch (authMethod) {
502-
case "oauth": {
503-
const result = await this.loginWithOAuth(ctx.deployment);
504-
if (result !== "use_token") {
505-
return withLoginMethod("oauth", result);
506-
}
500+
case "oauth":
507501
return withLoginMethod(
508-
"cli_token",
509-
await this.loginWithToken(ctx.client),
502+
"oauth",
503+
await this.loginWithOAuth(ctx.deployment),
510504
);
511-
}
512505
case "legacy":
513506
return withLoginMethod(
514507
"cli_token",
@@ -663,10 +656,12 @@ export class LoginCoordinator implements vscode.Disposable {
663656
return { success: false, reason: "user_dismissed" };
664657
}
665658

666-
/** OAuth flow; "use_token" means the user chose session-token sign-in instead. */
659+
/**
660+
* OAuth authentication flow.
661+
*/
667662
private async loginWithOAuth(
668663
deployment: Deployment,
669-
): Promise<LoginAttemptResult | "use_token"> {
664+
): Promise<LoginAttemptResult> {
670665
try {
671666
this.logger.debug("Starting OAuth authentication");
672667

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

‎src/oauth/authorizer.ts‎

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { type AxiosInstance, isAxiosError } from "axios";
21
import { createHash, randomBytes } from "node:crypto";
32
import * as vscode from "vscode";
43

54
import { CoderApi } from "../api/coderApi";
6-
import { HttpStatusCode } from "../api/httpStatusCode";
75
import { resolveCoderDashboardUrl } from "../common/url";
86

97
import {
@@ -23,6 +21,7 @@ import {
2321
parseOAuthResponse,
2422
} from "./validation";
2523

24+
import type { AxiosInstance } from "axios";
2625
import type {
2726
OAuth2AuthorizationServerMetadata,
2827
OAuth2ClientRegistrationRequest,
@@ -41,18 +40,10 @@ import type { OAuthCallback } from "./oauthCallback";
4140
interface OAuthLoginResult {
4241
tokenResponse: OAuth2TokenResponse;
4342
user: User;
44-
/** Client the tokens were issued to. */
43+
/** Registration the tokens were issued to. */
4544
registration: OAuth2ClientRegistrationResponse;
4645
}
4746

48-
/** The server refuses client registration, which is off by default from Coder 2.38. */
49-
export class ClientRegistrationDisabledError extends Error {
50-
constructor() {
51-
super("Dynamic client registration is disabled on this deployment");
52-
this.name = "ClientRegistrationDisabledError";
53-
}
54-
}
55-
5647
/**
5748
* Handles the OAuth authorization code flow for authenticating with Coder deployments.
5849
* Encapsulates client registration, PKCE challenge, and token exchange.
@@ -67,7 +58,7 @@ export class OAuthAuthorizer implements vscode.Disposable {
6758
private readonly extensionId: string,
6859
) {}
6960

70-
/** Runs the OAuth login flow. Saves the client registration, not the tokens. */
61+
/** Runs the OAuth login flow. On success, saves the client registration but not the tokens. */
7162
public async login(
7263
deployment: Deployment,
7364
progress: vscode.Progress<{ message?: string; increment?: number }>,
@@ -134,6 +125,11 @@ export class OAuthAuthorizer implements vscode.Disposable {
134125
reportProgress("fetching user...", 20);
135126
const user = await client.getAuthenticatedUser();
136127

128+
// Save only on success so a failed login keeps the stored session's registration.
129+
await this.secretsManager.setOAuthClientRegistration(
130+
deployment.safeHostname,
131+
registration,
132+
);
137133
this.logger.debug("OAuth login flow completed successfully");
138134

139135
return { tokenResponse, user, registration };
@@ -183,33 +179,19 @@ export class OAuthAuthorizer implements vscode.Disposable {
183179
token_endpoint_auth_method: TOKEN_ENDPOINT_AUTH_METHOD,
184180
};
185181

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

201187
const registrationResponse = parseOAuthResponse(
202188
OAuth2ClientRegistrationResponseSchema,
203189
response.data,
204190
metadata.registration_endpoint,
205191
);
206192

207-
await this.secretsManager.setOAuthClientRegistration(
208-
deployment.safeHostname,
209-
registrationResponse,
210-
);
211193
this.logger.debug(
212-
"Saved OAuth client registration:",
194+
"Registered OAuth client:",
213195
registrationResponse.client_id,
214196
);
215197

‎src/oauth/metadataClient.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,17 @@ export class OAuthMetadataClient {
4848
) {}
4949

5050
/**
51-
* Check if a server supports OAuth by attempting to fetch the well-known endpoint.
51+
* True when the server advertises OAuth client registration. Coder omits it
52+
* while dynamic client registration is off, the default from 2.37.
5253
*/
5354
public static async checkOAuthSupport(
5455
axiosInstance: AxiosInstance,
5556
): Promise<boolean> {
5657
try {
57-
await axiosInstance.get(OAUTH_DISCOVERY_ENDPOINT);
58-
return true;
58+
const response = await axiosInstance.get<
59+
Partial<OAuth2AuthorizationServerMetadata>
60+
>(OAUTH_DISCOVERY_ENDPOINT);
61+
return Boolean(response.data.registration_endpoint);
5962
} catch {
6063
return false;
6164
}

‎src/oauth/revocation.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
11
import { withOAuthMetadata } from "./metadataClient";
2-
import { type OAuthClient, toUrlSearchParams } from "./tokens";
2+
import { type OAuthClientRegistration, toUrlSearchParams } from "./tokens";
33

44
import type { OAuth2TokenRevocationRequest } from "coder/site/src/api/typesGenerated";
55

66
import type { Logger } from "../logging/logger";
77
import type { SessionAuth } from "../storage/secretsManager";
88

99
/**
10-
* Best-effort revocation of the token pair in `auth`; never throws. Signs in as
11-
* the client that issued the tokens, else `fallbackClient`.
10+
* Best-effort revocation of the token pair in `auth`; never throws. Authenticates
11+
* as the registration the tokens were issued to, else `storedRegistration`.
1212
*/
1313
export async function revokeOAuthTokens(
1414
auth: SessionAuth,
15-
fallbackClient: OAuthClient | undefined,
15+
storedRegistration: OAuthClientRegistration | undefined,
1616
logger: Logger,
1717
): Promise<void> {
1818
if (!auth.oauth) {
1919
return;
2020
}
21-
const client = auth.oauth.client ?? fallbackClient;
22-
if (!client) {
21+
const registration = auth.oauth.registration ?? storedRegistration;
22+
if (!registration) {
2323
logger.warn("Skipped token revocation: no client registration");
2424
return;
2525
}
@@ -46,8 +46,8 @@ export async function revokeOAuthTokens(
4646
}
4747
const params: OAuth2TokenRevocationRequest = {
4848
token,
49-
client_id: client.client_id,
50-
client_secret: client.client_secret,
49+
client_id: registration.client_id,
50+
client_secret: registration.client_secret,
5151
token_type_hint,
5252
};
5353
try {

0 commit comments

Comments
 (0)