Skip to content

Commit 15fa403

Browse files
committed
Always attach the auth interceptor and unify 401 handling
1 parent b887736 commit 15fa403

File tree

9 files changed

+580
-658
lines changed

9 files changed

+580
-658
lines changed

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/deployment/deploymentManager.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { type ContextManager } from "../core/contextManager";
44
import { type MementoManager } from "../core/mementoManager";
55
import { type SecretsManager } from "../core/secretsManager";
66
import { type Logger } from "../logging/logger";
7-
import { type OAuthInterceptor } from "../oauth/axiosInterceptor";
87
import { type OAuthSessionManager } from "../oauth/sessionManager";
98
import { type WorkspaceProvider } from "../workspace/workspacesProvider";
109

@@ -44,7 +43,6 @@ export class DeploymentManager implements vscode.Disposable {
4443
serviceContainer: ServiceContainer,
4544
private readonly client: CoderApi,
4645
private readonly oauthSessionManager: OAuthSessionManager,
47-
private readonly oauthInterceptor: OAuthInterceptor,
4846
private readonly workspaceProviders: WorkspaceProvider[],
4947
) {
5048
this.secretsManager = serviceContainer.getSecretsManager();
@@ -57,14 +55,12 @@ export class DeploymentManager implements vscode.Disposable {
5755
serviceContainer: ServiceContainer,
5856
client: CoderApi,
5957
oauthSessionManager: OAuthSessionManager,
60-
oauthInterceptor: OAuthInterceptor,
6158
workspaceProviders: WorkspaceProvider[],
6259
): DeploymentManager {
6360
const manager = new DeploymentManager(
6461
serviceContainer,
6562
client,
6663
oauthSessionManager,
67-
oauthInterceptor,
6864
workspaceProviders,
6965
);
7066
manager.subscribeToCrossWindowChanges();
@@ -140,7 +136,6 @@ export class DeploymentManager implements vscode.Disposable {
140136
this.refreshWorkspaces();
141137

142138
await this.oauthSessionManager.setDeployment(deployment);
143-
await this.oauthInterceptor.setDeployment(deployment);
144139
await this.persistDeployment(deployment);
145140
}
146141

@@ -154,7 +149,6 @@ export class DeploymentManager implements vscode.Disposable {
154149

155150
this.client.setCredentials(undefined, undefined);
156151
this.oauthSessionManager.clearDeployment();
157-
this.oauthInterceptor.clearDeployment();
158152
this.updateAuthContexts();
159153
this.refreshWorkspaces();
160154

src/extension.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import * as path from "node:path";
77
import * as vscode from "vscode";
88

99
import { errToStr } from "./api/api-helper";
10+
import { AuthInterceptor } from "./api/authInterceptor";
1011
import { CoderApi } from "./api/coderApi";
1112
import { Commands } from "./commands";
1213
import { ServiceContainer } from "./core/container";
1314
import { type SecretsManager } from "./core/secretsManager";
1415
import { DeploymentManager } from "./deployment/deploymentManager";
1516
import { CertificateError } from "./error/certificateError";
1617
import { getErrorDetail, toError } from "./error/errorUtils";
17-
import { OAuthInterceptor } from "./oauth/axiosInterceptor";
1818
import { OAuthSessionManager } from "./oauth/sessionManager";
1919
import { Remote } from "./remote/remote";
2020
import { getRemoteSshExtension } from "./remote/sshExtension";
@@ -88,15 +88,20 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
8888
);
8989
ctx.subscriptions.push(client);
9090

91-
// Create OAuth interceptor - auto attaches/detaches based on token state
92-
const oauthInterceptor = await OAuthInterceptor.create(
91+
// Handles 401 responses (OAuth and otherwise)
92+
const authInterceptor = new AuthInterceptor(
9393
client,
9494
output,
9595
oauthSessionManager,
9696
secretsManager,
97-
deployment?.safeHostname ?? "",
97+
() => {
98+
void vscode.window.showWarningMessage(
99+
"Session expired. Please log in again using the Coder sidebar.",
100+
);
101+
return Promise.resolve(false);
102+
},
98103
);
99-
ctx.subscriptions.push(oauthInterceptor);
104+
ctx.subscriptions.push(authInterceptor);
100105

101106
const myWorkspacesProvider = new WorkspaceProvider(
102107
WorkspaceQuery.Mine,
@@ -146,7 +151,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
146151
serviceContainer,
147152
client,
148153
oauthSessionManager,
149-
oauthInterceptor,
150154
[myWorkspacesProvider, allWorkspacesProvider],
151155
);
152156
ctx.subscriptions.push(deploymentManager);

0 commit comments

Comments
 (0)