Skip to content

Commit c6d18e6

Browse files
committed
Better error handling on 400/401/403 due to auth
1 parent efa1ee3 commit c6d18e6

File tree

3 files changed

+116
-81
lines changed

3 files changed

+116
-81
lines changed

src/api/coderApi.ts

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type AxiosHeaders,
55
type AxiosResponseTransformer,
66
isAxiosError,
7+
type AxiosError,
78
} from "axios";
89
import { Api } from "coder/site/src/api/api";
910
import {
@@ -32,11 +33,7 @@ import {
3233
HttpClientLogLevel,
3334
} from "../logging/types";
3435
import { sizeOf } from "../logging/utils";
35-
import {
36-
parseOAuthError,
37-
requiresReAuthentication,
38-
isNetworkError,
39-
} from "../oauth/errors";
36+
import { parseOAuthError, requiresReAuthentication } from "../oauth/errors";
4037
import { type OAuthSessionManager } from "../oauth/sessionManager";
4138
import { type UnidirectionalStream } from "../websocket/eventStreamConnection";
4239
import {
@@ -421,7 +418,7 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) {
421418
/**
422419
* Add OAuth token refresh interceptors.
423420
* Success interceptor: proactively refreshes token when approaching expiry.
424-
* Error interceptor: reactively refreshes token on 401/403 responses.
421+
* Error interceptor: reactively refreshes token on 401 responses.
425422
*/
426423
function addOAuthInterceptors(
427424
client: CoderApi,
@@ -444,53 +441,90 @@ function addOAuthInterceptors(
444441

445442
return response;
446443
},
447-
// Error response interceptor: reactive token refresh on 401/403
444+
// Error response interceptor: reactive token refresh on 401
448445
async (error: unknown) => {
449446
if (!isAxiosError(error)) {
450447
throw error;
451448
}
452449

453-
const status = error.response?.status;
454-
if (status !== 401 && status !== 403) {
455-
throw error;
450+
if (error.config) {
451+
const config = error.config as {
452+
_oauthRetryAttempted?: boolean;
453+
};
454+
if (config._oauthRetryAttempted) {
455+
throw error;
456+
}
456457
}
457458

458-
if (!oauthSessionManager.isLoggedInWithOAuth()) {
459+
const status = error.response?.status;
460+
461+
// These could indicate permanent auth failures that won't be fixed by token refresh
462+
if (status === 400 || status === 403) {
463+
handlePossibleOAuthError(error, logger, oauthSessionManager);
459464
throw error;
465+
} else if (status === 401) {
466+
return handle401Error(error, client, logger, oauthSessionManager);
460467
}
461468

462-
logger.info(`Received ${status} response, attempting token refresh`);
463-
464-
try {
465-
const newTokens = await oauthSessionManager.refreshToken();
466-
client.setSessionToken(newTokens.access_token);
467-
468-
logger.info("Token refresh successful, updated session token");
469-
} catch (refreshError) {
470-
logger.error("Token refresh failed:", refreshError);
471-
472-
const oauthError = parseOAuthError(refreshError);
473-
if (oauthError && requiresReAuthentication(oauthError)) {
474-
logger.error(
475-
`OAuth error requires re-authentication: ${oauthError.errorCode}`,
476-
);
477-
478-
oauthSessionManager
479-
.showReAuthenticationModal(oauthError)
480-
.catch((err) => {
481-
logger.error("Failed to show re-auth modal:", err);
482-
});
483-
} else if (isNetworkError(refreshError)) {
484-
logger.warn(
485-
"Token refresh failed due to network error, will retry later",
486-
);
487-
}
488-
}
489469
throw error;
490470
},
491471
);
492472
}
493473

474+
function handlePossibleOAuthError(
475+
error: unknown,
476+
logger: Logger,
477+
oauthSessionManager: OAuthSessionManager,
478+
): void {
479+
const oauthError = parseOAuthError(error);
480+
if (oauthError && requiresReAuthentication(oauthError)) {
481+
logger.error(
482+
`OAuth error requires re-authentication: ${oauthError.errorCode}`,
483+
);
484+
485+
oauthSessionManager.showReAuthenticationModal(oauthError).catch((err) => {
486+
logger.error("Failed to show re-auth modal:", err);
487+
});
488+
}
489+
}
490+
491+
async function handle401Error(
492+
error: AxiosError,
493+
client: CoderApi,
494+
logger: Logger,
495+
oauthSessionManager: OAuthSessionManager,
496+
): Promise<void> {
497+
if (!oauthSessionManager.isLoggedInWithOAuth()) {
498+
throw error;
499+
}
500+
501+
logger.info("Received 401 response, attempting token refresh");
502+
503+
try {
504+
const newTokens = await oauthSessionManager.refreshToken();
505+
client.setSessionToken(newTokens.access_token);
506+
507+
logger.info("Token refresh successful, retrying request");
508+
509+
// Retry the original request with the new token
510+
if (error.config) {
511+
const config = error.config as RequestConfigWithMeta & {
512+
_oauthRetryAttempted?: boolean;
513+
};
514+
config._oauthRetryAttempted = true;
515+
config.headers[coderSessionTokenHeader] = newTokens.access_token;
516+
return client.getAxiosInstance().request(config);
517+
}
518+
519+
throw error;
520+
} catch (refreshError) {
521+
logger.error("Token refresh failed:", refreshError);
522+
523+
handlePossibleOAuthError(refreshError, logger, oauthSessionManager);
524+
throw error;
525+
}
526+
}
527+
494528
function wrapRequestTransform(
495529
transformer: AxiosResponseTransformer | AxiosResponseTransformer[],
496530
config: RequestConfigWithMeta,

src/oauth/errors.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,3 @@ export function requiresReAuthentication(error: OAuthError): boolean {
164164
error instanceof InvalidGrantError || error instanceof InvalidClientError
165165
);
166166
}
167-
168-
/**
169-
* Checks if an error is a network/connectivity error
170-
*/
171-
export function isNetworkError(error: unknown): boolean {
172-
return isAxiosError(error) && !error.response && Boolean(error.request);
173-
}

src/oauth/sessionManager.ts

Lines changed: 44 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const DEFAULT_OAUTH_SCOPES = [
6161
*/
6262
export class OAuthSessionManager implements vscode.Disposable {
6363
private storedTokens: StoredOAuthTokens | undefined;
64-
private refreshInProgress = false;
64+
private refreshPromise: Promise<TokenResponse> | null = null;
6565
private lastRefreshAttempt = 0;
6666

6767
private pendingAuthReject: ((reason: Error) => void) | undefined;
@@ -134,7 +134,7 @@ export class OAuthSessionManager implements vscode.Disposable {
134134
*/
135135
private async clearTokenState(): Promise<void> {
136136
this.storedTokens = undefined;
137-
this.refreshInProgress = false;
137+
this.refreshPromise = null;
138138
this.lastRefreshAttempt = 0;
139139
await this.secretsManager.setOAuthTokens(undefined);
140140
await this.secretsManager.setOAuthClientRegistration(undefined);
@@ -489,56 +489,64 @@ export class OAuthSessionManager implements vscode.Disposable {
489489

490490
/**
491491
* Refresh the access token using the stored refresh token.
492-
* Uses a mutex to prevent concurrent refresh attempts.
492+
* Uses a shared promise to handle concurrent refresh attempts.
493493
*/
494494
async refreshToken(): Promise<TokenResponse> {
495-
if (this.refreshInProgress) {
496-
throw new Error("Token refresh already in progress");
495+
// If a refresh is already in progress, return the existing promise
496+
if (this.refreshPromise) {
497+
this.logger.debug(
498+
"Token refresh already in progress, waiting for result",
499+
);
500+
return this.refreshPromise;
497501
}
498502

499503
if (!this.storedTokens?.refresh_token) {
500504
throw new Error("No refresh token available");
501505
}
502506

503-
this.refreshInProgress = true;
507+
const refreshToken = this.storedTokens.refresh_token;
508+
const accessToken = this.storedTokens.access_token;
509+
504510
this.lastRefreshAttempt = Date.now();
505511

506-
try {
507-
const { axiosInstance, metadata, registration } =
508-
await this.prepareOAuthOperation(
509-
this.deploymentUrl,
510-
this.storedTokens.access_token,
511-
);
512+
// Create and store the refresh promise
513+
this.refreshPromise = (async () => {
514+
try {
515+
const { axiosInstance, metadata, registration } =
516+
await this.prepareOAuthOperation(this.deploymentUrl, accessToken);
512517

513-
this.logger.debug("Refreshing access token");
518+
this.logger.debug("Refreshing access token");
514519

515-
const params: RefreshTokenRequestParams = {
516-
grant_type: REFRESH_GRANT_TYPE,
517-
refresh_token: this.storedTokens.refresh_token,
518-
client_id: registration.client_id,
519-
client_secret: registration.client_secret,
520-
};
520+
const params: RefreshTokenRequestParams = {
521+
grant_type: REFRESH_GRANT_TYPE,
522+
refresh_token: refreshToken,
523+
client_id: registration.client_id,
524+
client_secret: registration.client_secret,
525+
};
521526

522-
const tokenRequest = toUrlSearchParams(params);
527+
const tokenRequest = toUrlSearchParams(params);
523528

524-
const response = await axiosInstance.post<TokenResponse>(
525-
metadata.token_endpoint,
526-
tokenRequest,
527-
{
528-
headers: {
529-
"Content-Type": "application/x-www-form-urlencoded",
529+
const response = await axiosInstance.post<TokenResponse>(
530+
metadata.token_endpoint,
531+
tokenRequest,
532+
{
533+
headers: {
534+
"Content-Type": "application/x-www-form-urlencoded",
535+
},
530536
},
531-
},
532-
);
537+
);
533538

534-
this.logger.debug("Token refresh successful");
539+
this.logger.debug("Token refresh successful");
535540

536-
await this.saveTokens(response.data);
541+
await this.saveTokens(response.data);
537542

538-
return response.data;
539-
} finally {
540-
this.refreshInProgress = false;
541-
}
543+
return response.data;
544+
} finally {
545+
this.refreshPromise = null;
546+
}
547+
})();
548+
549+
return this.refreshPromise;
542550
}
543551

544552
/**
@@ -579,7 +587,7 @@ export class OAuthSessionManager implements vscode.Disposable {
579587
if (
580588
!this.isLoggedInWithOAuth() ||
581589
!this.storedTokens?.refresh_token ||
582-
this.refreshInProgress
590+
this.refreshPromise !== null
583591
) {
584592
return false;
585593
}
@@ -695,7 +703,7 @@ export class OAuthSessionManager implements vscode.Disposable {
695703
}
696704
this.pendingAuthReject = undefined;
697705
this.storedTokens = undefined;
698-
this.refreshInProgress = false;
706+
this.refreshPromise = null;
699707
this.lastRefreshAttempt = 0;
700708

701709
this.logger.debug("OAuth session manager disposed");

0 commit comments

Comments
 (0)