Skip to content

Commit 1af820c

Browse files
committed
Add OAuth 2.1 authentication support
Implements OAuth 2.1 with PKCE as an alternative authentication method to session tokens. When connecting to a Coder deployment that supports OAuth, users can choose between OAuth and legacy token authentication. Key changes: OAuth Flow: - Add OAuthSessionManager to handle the complete OAuth lifecycle: dynamic client registration, PKCE authorization flow, token exchange, automatic refresh, and revocation - Add OAuthMetadataClient to discover and validate OAuth server metadata from the well-known endpoint, ensuring server meets OAuth 2.1 requirements - Handle OAuth callbacks via vscode:// URI handler with cross-window support for when callback arrives in a different VS Code window Token Management: - Store OAuth tokens (access, refresh, expiry) per-deployment in secrets - Store dynamic client registrations per-deployment in secrets - Proactive token refresh when approaching expiry (via response interceptor) - Reactive token refresh on 401 responses with automatic request retry - Handle OAuth errors (invalid_grant, invalid_client) by prompting for re-authentication Integration: - Add auth method selection prompt when server supports OAuth - Attach OAuth interceptors to CoderApi for automatic token refresh - Clear OAuth state when user explicitly chooses token auth - DeploymentManager coordinates OAuth session state with deployment changes Error Handling: - Typed OAuth error classes (InvalidGrantError, InvalidClientError, etc.) - Parse OAuth error responses from token endpoint - Show re-authentication modal for errors requiring user action
1 parent 99d1fab commit 1af820c

File tree

13 files changed

+1764
-24
lines changed

13 files changed

+1764
-24
lines changed

src/api/oauthInterceptors.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { type AxiosError, isAxiosError } from "axios";
2+
3+
import { type Logger } from "../logging/logger";
4+
import { type RequestConfigWithMeta } from "../logging/types";
5+
import { parseOAuthError, requiresReAuthentication } from "../oauth/errors";
6+
import { type OAuthSessionManager } from "../oauth/sessionManager";
7+
8+
import { type CoderApi } from "./coderApi";
9+
10+
const coderSessionTokenHeader = "Coder-Session-Token";
11+
12+
/**
13+
* Attach OAuth token refresh interceptors to a CoderApi instance.
14+
* This should be called after creating the CoderApi when OAuth authentication is being used.
15+
*
16+
* Success interceptor: proactively refreshes token when approaching expiry.
17+
* Error interceptor: reactively refreshes token on 401 responses.
18+
*/
19+
export function attachOAuthInterceptors(
20+
client: CoderApi,
21+
logger: Logger,
22+
oauthSessionManager: OAuthSessionManager,
23+
): void {
24+
client.getAxiosInstance().interceptors.response.use(
25+
// Success response interceptor: proactive token refresh
26+
(response) => {
27+
// Fire-and-forget: don't await, don't block response
28+
oauthSessionManager.refreshIfAlmostExpired().catch((error) => {
29+
logger.warn("Proactive background token refresh failed:", error);
30+
});
31+
32+
return response;
33+
},
34+
// Error response interceptor: reactive token refresh on 401
35+
async (error: unknown) => {
36+
if (!isAxiosError(error)) {
37+
throw error;
38+
}
39+
40+
if (error.config) {
41+
const config = error.config as {
42+
_oauthRetryAttempted?: boolean;
43+
};
44+
if (config._oauthRetryAttempted) {
45+
throw error;
46+
}
47+
}
48+
49+
const status = error.response?.status;
50+
51+
// These could indicate permanent auth failures that won't be fixed by token refresh
52+
if (status === 400 || status === 403) {
53+
handlePossibleOAuthError(error, logger, oauthSessionManager);
54+
throw error;
55+
} else if (status === 401) {
56+
return handle401Error(error, client, logger, oauthSessionManager);
57+
}
58+
59+
throw error;
60+
},
61+
);
62+
}
63+
64+
function handlePossibleOAuthError(
65+
error: unknown,
66+
logger: Logger,
67+
oauthSessionManager: OAuthSessionManager,
68+
): void {
69+
const oauthError = parseOAuthError(error);
70+
if (oauthError && requiresReAuthentication(oauthError)) {
71+
logger.error(
72+
`OAuth error requires re-authentication: ${oauthError.errorCode}`,
73+
);
74+
75+
oauthSessionManager.showReAuthenticationModal(oauthError).catch((err) => {
76+
logger.error("Failed to show re-auth modal:", err);
77+
});
78+
}
79+
}
80+
81+
async function handle401Error(
82+
error: AxiosError,
83+
client: CoderApi,
84+
logger: Logger,
85+
oauthSessionManager: OAuthSessionManager,
86+
): Promise<void> {
87+
if (!oauthSessionManager.isLoggedInWithOAuth()) {
88+
throw error;
89+
}
90+
91+
logger.info("Received 401 response, attempting token refresh");
92+
93+
try {
94+
const newTokens = await oauthSessionManager.refreshToken();
95+
client.setSessionToken(newTokens.access_token);
96+
97+
logger.info("Token refresh successful, retrying request");
98+
99+
// Retry the original request with the new token
100+
if (error.config) {
101+
const config = error.config as RequestConfigWithMeta & {
102+
_oauthRetryAttempted?: boolean;
103+
};
104+
config._oauthRetryAttempted = true;
105+
config.headers[coderSessionTokenHeader] = newTokens.access_token;
106+
return client.getAxiosInstance().request(config);
107+
}
108+
109+
throw error;
110+
} catch (refreshError) {
111+
logger.error("Token refresh failed:", refreshError);
112+
113+
handlePossibleOAuthError(refreshError, logger, oauthSessionManager);
114+
throw error;
115+
}
116+
}

src/commands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { type DeploymentManager } from "./deployment/deploymentManager";
1919
import { CertificateError } from "./error";
2020
import { type Logger } from "./logging/logger";
2121
import { type LoginCoordinator } from "./login/loginCoordinator";
22+
import { type OAuthSessionManager } from "./oauth/sessionManager";
2223
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
2324
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
2425
import {
@@ -51,6 +52,7 @@ export class Commands {
5152
public constructor(
5253
serviceContainer: ServiceContainer,
5354
private readonly extensionClient: CoderApi,
55+
private readonly oauthSessionManager: OAuthSessionManager,
5456
private readonly deploymentManager: DeploymentManager,
5557
) {
5658
this.vscodeProposed = serviceContainer.getVsCodeProposed();
@@ -105,6 +107,7 @@ export class Commands {
105107
safeHostname,
106108
url,
107109
autoLogin: args?.autoLogin,
110+
oauthSessionManager: this.oauthSessionManager,
108111
});
109112

110113
if (!result.success) {

src/core/secretsManager.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { type Logger } from "../logging/logger";
2+
import {
3+
type ClientRegistrationResponse,
4+
type TokenResponse,
5+
} from "../oauth/types";
26
import { toSafeHost } from "../util";
37

48
import type { Memento, SecretStorage, Disposable } from "vscode";
@@ -8,8 +12,11 @@ import type { Deployment } from "../deployment/types";
812
// Each deployment has its own key to ensure atomic operations (multiple windows
913
// writing to a shared key could drop data) and to receive proper VS Code events.
1014
const SESSION_KEY_PREFIX = "coder.session.";
15+
const OAUTH_TOKENS_PREFIX = "coder.oauth.tokens.";
16+
const OAUTH_CLIENT_PREFIX = "coder.oauth.client.";
1117

1218
const CURRENT_DEPLOYMENT_KEY = "coder.currentDeployment";
19+
const OAUTH_CALLBACK_KEY = "coder.oauthCallback";
1320

1421
const DEPLOYMENT_USAGE_KEY = "coder.deploymentUsage";
1522
const DEFAULT_MAX_DEPLOYMENTS = 10;
@@ -31,6 +38,17 @@ interface DeploymentUsage {
3138
lastAccessedAt: string;
3239
}
3340

41+
export type StoredOAuthTokens = Omit<TokenResponse, "expires_in"> & {
42+
expiry_timestamp: number;
43+
deployment_url: string;
44+
};
45+
46+
interface OAuthCallbackData {
47+
state: string;
48+
code: string | null;
49+
error: string | null;
50+
}
51+
3452
export class SecretsManager {
3553
constructor(
3654
private readonly secrets: SecretStorage,
@@ -97,6 +115,38 @@ export class SecretsManager {
97115
});
98116
}
99117

118+
/**
119+
* Write an OAuth callback result to secrets storage.
120+
* Used for cross-window communication when OAuth callback arrives in a different window.
121+
*/
122+
public async setOAuthCallback(data: OAuthCallbackData): Promise<void> {
123+
await this.secrets.store(OAUTH_CALLBACK_KEY, JSON.stringify(data));
124+
}
125+
126+
/**
127+
* Listen for OAuth callback results from any VS Code window.
128+
* The listener receives the state parameter, code (if success), and error (if failed).
129+
*/
130+
public onDidChangeOAuthCallback(
131+
listener: (data: OAuthCallbackData) => void,
132+
): Disposable {
133+
return this.secrets.onDidChange(async (e) => {
134+
if (e.key !== OAUTH_CALLBACK_KEY) {
135+
return;
136+
}
137+
138+
try {
139+
const data = await this.secrets.get(OAUTH_CALLBACK_KEY);
140+
if (data) {
141+
const parsed = JSON.parse(data) as OAuthCallbackData;
142+
listener(parsed);
143+
}
144+
} catch {
145+
// Ignore parse errors
146+
}
147+
});
148+
}
149+
100150
/**
101151
* Listen for changes to a specific deployment's session auth.
102152
*/
@@ -153,6 +203,77 @@ export class SecretsManager {
153203
return `${SESSION_KEY_PREFIX}${safeHostname || "<legacy>"}`;
154204
}
155205

206+
public async getOAuthTokens(
207+
safeHostname: string,
208+
): Promise<StoredOAuthTokens | undefined> {
209+
try {
210+
const data = await this.secrets.get(
211+
`${OAUTH_TOKENS_PREFIX}${safeHostname}`,
212+
);
213+
if (!data) {
214+
return undefined;
215+
}
216+
return JSON.parse(data) as StoredOAuthTokens;
217+
} catch {
218+
return undefined;
219+
}
220+
}
221+
222+
public async setOAuthTokens(
223+
safeHostname: string,
224+
tokens: StoredOAuthTokens,
225+
): Promise<void> {
226+
await this.secrets.store(
227+
`${OAUTH_TOKENS_PREFIX}${safeHostname}`,
228+
JSON.stringify(tokens),
229+
);
230+
await this.recordDeploymentAccess(safeHostname);
231+
}
232+
233+
public async clearOAuthTokens(safeHostname: string): Promise<void> {
234+
await this.secrets.delete(`${OAUTH_TOKENS_PREFIX}${safeHostname}`);
235+
}
236+
237+
public async getOAuthClientRegistration(
238+
safeHostname: string,
239+
): Promise<ClientRegistrationResponse | undefined> {
240+
try {
241+
const data = await this.secrets.get(
242+
`${OAUTH_CLIENT_PREFIX}${safeHostname}`,
243+
);
244+
if (!data) {
245+
return undefined;
246+
}
247+
return JSON.parse(data) as ClientRegistrationResponse;
248+
} catch {
249+
return undefined;
250+
}
251+
}
252+
253+
public async setOAuthClientRegistration(
254+
safeHostname: string,
255+
registration: ClientRegistrationResponse,
256+
): Promise<void> {
257+
await this.secrets.store(
258+
`${OAUTH_CLIENT_PREFIX}${safeHostname}`,
259+
JSON.stringify(registration),
260+
);
261+
await this.recordDeploymentAccess(safeHostname);
262+
}
263+
264+
public async clearOAuthClientRegistration(
265+
safeHostname: string,
266+
): Promise<void> {
267+
await this.secrets.delete(`${OAUTH_CLIENT_PREFIX}${safeHostname}`);
268+
}
269+
270+
public async clearOAuthData(safeHostname: string): Promise<void> {
271+
await Promise.all([
272+
this.clearOAuthTokens(safeHostname),
273+
this.clearOAuthClientRegistration(safeHostname),
274+
]);
275+
}
276+
156277
/**
157278
* Record that a deployment was accessed, moving it to the front of the LRU list.
158279
* Prunes deployments beyond maxCount, clearing their auth data.
@@ -181,6 +302,10 @@ export class SecretsManager {
181302
* Clear all auth data for a deployment and remove it from the usage list.
182303
*/
183304
public async clearAllAuthData(safeHostname: string): Promise<void> {
305+
await Promise.all([
306+
this.clearSessionAuth(safeHostname),
307+
this.clearOAuthData(safeHostname),
308+
]);
184309
await this.clearSessionAuth(safeHostname);
185310
const usage = this.getDeploymentUsage().filter(
186311
(u) => u.safeHostname !== safeHostname,

0 commit comments

Comments
 (0)