I have created an interceptor to ensure that every request sends HttpOnly
JWT/refresh
tokens. I am attempting to refresh my short-lived JWT
by catching a 401 error and asking the server for a refresh. It seems to be working, though perhaps not as I expect.
This is my code:
export const jwtInterceptor: HttpInterceptorFn = (req, next) => {
const router: Router = inject(Router);
const authService: AuthService = inject(AuthService);
req = req.clone({
withCredentials: true,
});
return next(req).pipe(
catchError((err: any) => {
if (err instanceof HttpErrorResponse) {
// Handle HTTP errors
if (err.status === 401) {
// handle unauthorized errors
console.error('Unauthorized request:', err);
// retry the request
authService.refreshToken().subscribe({
next: () => {
authService.isRefreshed$.next(true);
},
error: () => {
// Redirect to login page
authService.logout(true);
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: router.url },
});
},
});
return authService.isRefreshed$.pipe(switchMap(() => next(req)));
} else {
// Handle other HTTP error codes
console.error('HTTP error:', err);
}
} else {
// Handle non-HTTP errors
console.error('An error occurred:', err);
}
// Re-throw the error to propagate it further
return throwError(() => err);
})
);
};
When I look at the network calls through developer tools, they seem to be made in the order I expect. The cookie is refreshed successfully but is not being used in the retried request.
I cannot modify the request with the HttpOnly cookie through TS, so I'm unsure how to proceed.
The goal is to retry the failed request with the refreshed cookie. In the future, I will think about caching multiple requests and retrying them in a queue.
Update 1:
Changes made using suggestions from Naren:
const handle401Error = (
isRefreshing: boolean,
req: HttpRequest<any>,
next: HttpHandlerFn
) => {
if (!isRefreshing) {
isRefreshing = true;
// issue described below was resolved with the return statement
return authService.refreshToken().pipe(
switchMap(() => {
isRefreshing = false;
console.log('Token refreshed successfully.');
return next(req);
}),
catchError((err) => {
isRefreshing = false;
console.error('Error refreshing token:', err);
authService.logout();
router.navigate(['/login'], { queryParams: { returnUrl: req.url } });
return throwError(() => err);
})
);
}
return next(req);
};
I am calling handle401error when if (err.status == 401)
But the above code does not ever make the refresh API call. I suspect the call is canceled as return next(req)
at the end of handle401error is canceling the refresh request. authService.refreshToken()
has a log instruction so I know it is being entered. But neither the switchMap() nor the catchError() are executed in handle401error.
Update 2:
The final code I have settled on is below. Although failing calls can still be made while the token is refreshing, further attempts to refresh the tokens do not occur and all failed calls are retried.
let refreshTokenInProgress$: Subject<boolean> | null = null;
export const jwtInterceptor: HttpInterceptorFn = (req, next) => {
const router: Router = inject(Router);
const authService: AuthService = inject(AuthService);
req = req.clone({
withCredentials: true,
});
// if token is refreshing wait for it to complete before making the request
const handle401error = () => {
if (!refreshTokenInProgress$) {
refreshTokenInProgress$ = new Subject<boolean>();
authService.refreshToken().subscribe({
next: () => {
refreshTokenInProgress$!.next(true);
},
error: () => {
refreshTokenInProgress$!.next(false);
authService.logout();
router.navigate(['/login']);
},
complete: () => {
refreshTokenInProgress$!.complete();
refreshTokenInProgress$ = null;
},
});
}
};
return next(req).pipe(
catchError((err: any) => {
if (err instanceof HttpErrorResponse) {
if (err.status === 401) {
handle401error();
return refreshTokenInProgress$!.pipe(switchMap(() => next(req)));
}
}
return throwError(() => err);
})
);
};
Revised Approach => Return the Observable: authService.refreshToken() should return an observable that emits after the token is refreshed. This allows handle401Error to complete only after the refresh is done.
=> Use a Subject for the Refresh Process: A Subject can manage multiple calls that encounter a 401 error while the token is refreshing. This will help avoid race conditions or duplicate refresh attempts if multiple requests fail simultaneously.
import { Subject, throwError } from 'rxjs';
import { switchMap, catchError, filter, take, tap } from 'rxjs/operators';
let isRefreshing = false;
const refreshSubject = new Subject<boolean>();
const handle401Error = (
req: HttpRequest<any>,
next: HttpHandlerFn
) => {
if (!isRefreshing) {
isRefreshing = true;
authService.refreshToken().pipe(
tap(() => {
isRefreshing = false;
refreshSubject.next(true); // Notify others waiting for the refresh
}),
catchError((err) => {
isRefreshing = false;
refreshSubject.next(false); // Notify failure
authService.logout();
router.navigate(['/login'], { queryParams: { returnUrl: req.url } });
return throwError(() => err);
})
).subscribe();
}
// Wait for the refresh to complete
return refreshSubject.pipe(
filter(isRefreshed => isRefreshed), // Proceed only if refresh succeeded
take(1), // Only take the first successful refresh event
switchMap(() => next(req))
);
};
// In the interceptor catchError block:
return next(req).pipe(
catchError((err: any) => {
if (err instanceof HttpErrorResponse && err.status === 401) {
return handle401Error(req, next);
}
return throwError(() => err);
})
);
Explanation
Notes This approach should ensure the refreshed token is in place for subsequent retries. For the request queuing functionality you mentioned, this setup is already laying the groundwork by leveraging refreshSubject, which can handle multiple queued requests.