Skip to content

Commit 82acb21

Browse files
committed
Better error handling on 400/401/403 due to auth
1 parent 2672638 commit 82acb21

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 { HttpStatusCode } from "../websocket/codes";
4239
import {
@@ -493,7 +490,7 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) {
493490
/**
494491
* Add OAuth token refresh interceptors.
495492
* Success interceptor: proactively refreshes token when approaching expiry.
496-
* Error interceptor: reactively refreshes token on 401/403 responses.
493+
* Error interceptor: reactively refreshes token on 401 responses.
497494
*/
498495
function addOAuthInterceptors(
499496
client: CoderApi,
@@ -516,53 +513,90 @@ function addOAuthInterceptors(
516513

517514
return response;
518515
},
519-
// Error response interceptor: reactive token refresh on 401/403
516+
// Error response interceptor: reactive token refresh on 401
520517
async (error: unknown) => {
521518
if (!isAxiosError(error)) {
522519
throw error;
523520
}
524521

525-
const status = error.response?.status;
526-
if (status !== 401 && status !== 403) {
527-
throw error;
522+
if (error.config) {
523+
const config = error.config as {
524+
_oauthRetryAttempted?: boolean;
525+
};
526+
if (config._oauthRetryAttempted) {
527+
throw error;
528+
}
528529
}
529530

530-
if (!oauthSessionManager.isLoggedInWithOAuth()) {
531+
const status = error.response?.status;
532+
533+
// These could indicate permanent auth failures that won't be fixed by token refresh
534+
if (status === 400 || status === 403) {
535+
handlePossibleOAuthError(error, logger, oauthSessionManager);
531536
throw error;
537+
} else if (status === 401) {
538+
return handle401Error(error, client, logger, oauthSessionManager);
532539
}
533540

534-
logger.info(`Received ${status} response, attempting token refresh`);
535-
536-
try {
537-
const newTokens = await oauthSessionManager.refreshToken();
538-
client.setSessionToken(newTokens.access_token);
539-
540-
logger.info("Token refresh successful, updated session token");
541-
} catch (refreshError) {
542-
logger.error("Token refresh failed:", refreshError);
543-
544-
const oauthError = parseOAuthError(refreshError);
545-
if (oauthError && requiresReAuthentication(oauthError)) {
546-
logger.error(
547-
`OAuth error requires re-authentication: ${oauthError.errorCode}`,
548-
);
549-
550-
oauthSessionManager
551-
.showReAuthenticationModal(oauthError)
552-
.catch((err) => {
553-
logger.error("Failed to show re-auth modal:", err);
554-
});
555-
} else if (isNetworkError(refreshError)) {
556-
logger.warn(
557-
"Token refresh failed due to network error, will retry later",
558-
);
559-
}
560-
}
561541
throw error;
562542
},
563543
);
564544
}
565545

546+
function handlePossibleOAuthError(
547+
error: unknown,
548+
logger: Logger,
549+
oauthSessionManager: OAuthSessionManager,
550+
): void {
551+
const oauthError = parseOAuthError(error);
552+
if (oauthError && requiresReAuthentication(oauthError)) {
553+
logger.error(
554+
`OAuth error requires re-authentication: ${oauthError.errorCode}`,
555+
);
556+
557+
oauthSessionManager.showReAuthenticationModal(oauthError).catch((err) => {
558+
logger.error("Failed to show re-auth modal:", err);
559+
});
560+
}
561+
}
562+
563+
async function handle401Error(
564+
error: AxiosError,
565+
client: CoderApi,
566+
logger: Logger,
567+
oauthSessionManager: OAuthSessionManager,
568+
): Promise<void> {
569+
if (!oauthSessionManager.isLoggedInWithOAuth()) {
570+
throw error;
571+
}
572+
573+
logger.info("Received 401 response, attempting token refresh");
574+
575+
try {
576+
const newTokens = await oauthSessionManager.refreshToken();
577+
client.setSessionToken(newTokens.access_token);
578+
579+
logger.info("Token refresh successful, retrying request");
580+
581+
// Retry the original request with the new token
582+
if (error.config) {
583+
const config = error.config as RequestConfigWithMeta & {
584+
_oauthRetryAttempted?: boolean;
585+
};
586+
config._oauthRetryAttempted = true;
587+
config.headers[coderSessionTokenHeader] = newTokens.access_token;
588+
return client.getAxiosInstance().request(config);
589+
}
590+
591+
throw error;
592+
} catch (refreshError) {
593+
logger.error("Token refresh failed:", refreshError);
594+
595+
handlePossibleOAuthError(refreshError, logger, oauthSessionManager);
596+
throw error;
597+
}
598+
}
599+
566600
function wrapRequestTransform(
567601
transformer: AxiosResponseTransformer | AxiosResponseTransformer[],
568602
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)