-
Notifications
You must be signed in to change notification settings - Fork 43
Expand file tree
/
Copy pathauthInterceptor.ts
More file actions
192 lines (166 loc) · 5.61 KB
/
Copy pathauthInterceptor.ts
File metadata and controls
192 lines (166 loc) · 5.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
import { type AxiosError, isAxiosError } from "axios";
import { AuthTelemetry } from "../instrumentation/auth";
import { OAuthError } from "../oauth/errors";
import { toSafeHost } from "../util";
import type * as vscode from "vscode";
import type { ServiceContainer } from "../core/container";
import type { SecretsManager } from "../core/secretsManager";
import type { Logger } from "../logging/logger";
import type { OAuthSessionManager } from "../oauth/sessionManager";
import type { CoderApi } from "./coderApi";
const coderSessionTokenHeader = "Coder-Session-Token";
/**
* Callback invoked when authentication is required.
* Returns true if user successfully re-authenticated.
*/
export type AuthRequiredHandler = (hostname: string) => Promise<boolean>;
/**
* Intercepts 401 responses and handles re-authentication.
*
* Always attached to the axios instance. Handles both OAuth (automatic refresh)
* and non-OAuth (interactive re-auth via callback) authentication failures.
*/
export class AuthInterceptor implements vscode.Disposable {
private readonly interceptorId: number;
private readonly authTelemetry: AuthTelemetry;
private readonly logger: Logger;
private readonly secretsManager: SecretsManager;
private authRequiredPromise: Promise<boolean> | null = null;
constructor(
private readonly client: CoderApi,
private readonly oauthSessionManager: OAuthSessionManager,
container: ServiceContainer,
private readonly onAuthRequired?: AuthRequiredHandler,
) {
this.logger = container.getLogger();
this.secretsManager = container.getSecretsManager();
this.authTelemetry = new AuthTelemetry(container.getTelemetryService());
this.interceptorId = this.client
.getAxiosInstance()
.interceptors.response.use(
(r) => r,
(error: unknown) => this.handleError(error),
);
this.logger.debug("Auth interceptor attached");
}
private async handleError(error: unknown): Promise<unknown> {
if (!isAxiosError(error)) {
throw error;
}
if (error.response?.status !== 401) {
throw error;
}
const baseUrl = this.client.getHost();
if (!baseUrl) {
throw error;
}
const hostname = toSafeHost(baseUrl);
return this.recoverFromUnauthorized(error, hostname);
}
private recoverFromUnauthorized(
error: AxiosError,
hostname: string,
): Promise<unknown> {
const config = error.config;
// Checked before _retryAttempted so an OAuth-retry 401 caused by a
// fresh settings change still gets one silent attempt.
if (
config &&
!config._authConfigRetryAttempted &&
this.client.hasAuthConfigChangedSince(config.authConfigVersion)
) {
config._authConfigRetryAttempted = true;
this.logger.debug(
"Authentication settings changed during request, retrying once",
);
return this.client.getAxiosInstance().request(config);
}
if (config?._retryAttempted) {
throw error;
}
this.logger.debug("Received 401 response, attempting recovery");
return this.authTelemetry.traceRecovery(async (recorder) => {
recorder.logReceived();
// 1) OAuth refresh path.
const isOAuth =
await this.oauthSessionManager.isLoggedInWithOAuth(hostname);
recorder.setRefreshAttempted(isOAuth);
if (isOAuth) {
const newToken = await this.tryOAuthRefresh();
if (newToken) {
recorder.setRecovery("refresh_success");
return this.retryRequest(error, newToken);
}
}
// 2) Interactive re-auth fallback.
if (!this.onAuthRequired) {
recorder.setRecovery("none");
throw error;
}
recorder.setRecovery("login_required");
const success = await this.executeAuthRequired(hostname);
const auth = success
? await this.secretsManager.getSessionAuth(hostname)
: undefined;
if (!auth) {
throw error;
}
this.logger.debug("Re-authentication successful, retrying request");
return this.retryRequest(error, auth.token);
});
}
/** Returns the new access token on success, or undefined when refresh fails. */
private async tryOAuthRefresh(): Promise<string | undefined> {
try {
const newTokens = await this.oauthSessionManager.refreshToken();
this.client.setSessionToken(newTokens.access_token);
this.logger.debug("Token refresh successful");
return newTokens.access_token;
} catch (refreshError) {
if (!(refreshError instanceof OAuthError)) {
this.logger.error("Token refresh failed:", refreshError);
} else if (refreshError.requiresReAuth) {
this.logger.warn(`Token refresh failed: ${refreshError.message}`);
} else {
this.logger.error(`Token refresh failed: ${refreshError.message}`);
}
return undefined;
}
}
/**
* Execute auth required callback with deduplication.
* Multiple concurrent 401s will share the same promise.
*/
private async executeAuthRequired(hostname: string): Promise<boolean> {
if (this.authRequiredPromise) {
this.logger.debug(
"Auth callback already in progress, waiting for result",
);
return this.authRequiredPromise;
}
if (!this.onAuthRequired) {
throw new Error("No auth handler registered");
}
this.logger.debug("Triggering re-authentication");
this.authRequiredPromise = this.onAuthRequired(hostname);
try {
return await this.authRequiredPromise;
} finally {
this.authRequiredPromise = null;
}
}
private retryRequest(error: AxiosError, token: string): Promise<unknown> {
if (!error.config) {
throw error;
}
error.config._retryAttempted = true;
error.config.headers[coderSessionTokenHeader] = token;
return this.client.getAxiosInstance().request(error.config);
}
public dispose(): void {
this.client
.getAxiosInstance()
.interceptors.response.eject(this.interceptorId);
this.logger.debug("Auth interceptor detached");
}
}