Skip to content

Commit ab2c9fe

Browse files
authored
Add OAuth 2.1 authentication support (#693)
Implements OAuth 2.1 with PKCE as an alternative authentication method to session tokens, providing a more secure and user-friendly login experience. Authentication Options: - When connecting to an OAuth-enabled Coder deployment, users can choose between: - OAuth (Recommended): Secure login with automatic token refresh - Session Token (Legacy): Manual token generation and paste - OAuth is automatically detected via the ".well-known/oauth-authorization-server" endpoint Token Lifecycle: - Tokens refresh automatically in the background before expiration - No manual re-authentication needed during normal usage - When OAuth sessions become invalid (revoked, expired refresh token), users see a clear re-authentication prompt - Works across multiple VS Code windows - tokens stay synchronized OAuth Callback Handling: - New /oauth/callback URI handler for authorization responses - Cross-window callback support: if the OAuth callback arrives in a different VS Code window, it's properly routed back Implementation Details: - OAuth modules (src/oauth/): `OAuthAuthorizer` (authorization flow + PKCE), `OAuthSessionManager` (token refresh + revocation), `OAuthMetadataClient` (server discovery + validation), typed error handling - Auth interceptor (`src/api/authInterceptor.ts`): Unified 401 handling - automatic refresh for OAuth, interactive re-auth for tokens - SecretsManager: Stores OAuth tokens and client registrations per-deployment with Zod validation; cross-window sync via SecretStorage events - Scopes: [workspace:{read,update,start,ssh,application_connect}, template:read, user:read_personal] Closes #586
1 parent 859bbb4 commit ab2c9fe

31 files changed

+4341
-200
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@
437437
"@vscode/test-electron": "^2.5.2",
438438
"@vscode/vsce": "^3.7.1",
439439
"bufferutil": "^4.1.0",
440-
"coder": "https://github.com/coder/coder#main",
440+
"coder": "github:coder/coder#main",
441441
"dayjs": "^1.11.19",
442442
"electron": "^39.2.7",
443443
"esbuild": "^0.27.2",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/authInterceptor.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { type AxiosError, isAxiosError } from "axios";
2+
3+
import { toSafeHost } from "../util";
4+
5+
import type * as vscode from "vscode";
6+
7+
import type { SecretsManager } from "../core/secretsManager";
8+
import type { Logger } from "../logging/logger";
9+
import type { RequestConfigWithMeta } from "../logging/types";
10+
import type { OAuthSessionManager } from "../oauth/sessionManager";
11+
12+
import type { CoderApi } from "./coderApi";
13+
14+
const coderSessionTokenHeader = "Coder-Session-Token";
15+
16+
/**
17+
* Callback invoked when authentication is required.
18+
* Returns true if user successfully re-authenticated.
19+
*/
20+
export type AuthRequiredHandler = (hostname: string) => Promise<boolean>;
21+
22+
/**
23+
* Intercepts 401 responses and handles re-authentication.
24+
*
25+
* Always attached to the axios instance. Handles both OAuth (automatic refresh)
26+
* and non-OAuth (interactive re-auth via callback) authentication failures.
27+
*/
28+
export class AuthInterceptor implements vscode.Disposable {
29+
private readonly interceptorId: number;
30+
31+
constructor(
32+
private readonly client: CoderApi,
33+
private readonly logger: Logger,
34+
private readonly oauthSessionManager: OAuthSessionManager,
35+
private readonly secretsManager: SecretsManager,
36+
private readonly onAuthRequired?: AuthRequiredHandler,
37+
) {
38+
this.interceptorId = this.client
39+
.getAxiosInstance()
40+
.interceptors.response.use(
41+
(r) => r,
42+
(error: unknown) => this.handleError(error),
43+
);
44+
this.logger.debug("Auth interceptor attached");
45+
}
46+
47+
private async handleError(error: unknown): Promise<unknown> {
48+
if (!isAxiosError(error)) {
49+
throw error;
50+
}
51+
52+
if (error.config) {
53+
const config = error.config as { _retryAttempted?: boolean };
54+
if (config._retryAttempted) {
55+
throw error;
56+
}
57+
}
58+
59+
if (error.response?.status !== 401) {
60+
throw error;
61+
}
62+
63+
const baseUrl = this.client.getHost();
64+
if (!baseUrl) {
65+
throw error;
66+
}
67+
const hostname = toSafeHost(baseUrl);
68+
69+
return this.handle401Error(error, hostname);
70+
}
71+
72+
private async handle401Error(
73+
error: AxiosError,
74+
hostname: string,
75+
): Promise<unknown> {
76+
this.logger.debug("Received 401 response, attempting recovery");
77+
78+
if (await this.oauthSessionManager.isLoggedInWithOAuth(hostname)) {
79+
try {
80+
const newTokens = await this.oauthSessionManager.refreshToken();
81+
this.client.setSessionToken(newTokens.access_token);
82+
this.logger.debug("Token refresh successful, retrying request");
83+
return this.retryRequest(error, newTokens.access_token);
84+
} catch (refreshError) {
85+
this.logger.error("OAuth refresh failed:", refreshError);
86+
}
87+
}
88+
89+
if (this.onAuthRequired) {
90+
this.logger.debug("Triggering interactive re-authentication");
91+
const success = await this.onAuthRequired(hostname);
92+
if (success) {
93+
const auth = await this.secretsManager.getSessionAuth(hostname);
94+
if (auth) {
95+
this.logger.debug("Re-authentication successful, retrying request");
96+
return this.retryRequest(error, auth.token);
97+
}
98+
}
99+
}
100+
101+
throw error;
102+
}
103+
104+
private retryRequest(error: AxiosError, token: string): Promise<unknown> {
105+
if (!error.config) {
106+
throw error;
107+
}
108+
109+
const config = error.config as RequestConfigWithMeta & {
110+
_retryAttempted?: boolean;
111+
};
112+
config._retryAttempted = true;
113+
config.headers[coderSessionTokenHeader] = token;
114+
return this.client.getAxiosInstance().request(config);
115+
}
116+
117+
public dispose(): void {
118+
this.client
119+
.getAxiosInstance()
120+
.interceptors.response.eject(this.interceptorId);
121+
this.logger.debug("Auth interceptor detached");
122+
}
123+
}

src/core/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export class ServiceContainer implements vscode.Disposable {
4848
this.mementoManager,
4949
this.vscodeProposed,
5050
this.logger,
51+
context.extension.id,
5152
);
5253
}
5354

@@ -89,5 +90,6 @@ export class ServiceContainer implements vscode.Disposable {
8990
dispose(): void {
9091
this.contextManager.dispose();
9192
this.logger.dispose();
93+
this.loginCoordinator.dispose();
9294
}
9395
}

0 commit comments

Comments
 (0)