Listen, I'll explain how it all happens.
I'm making a refresh system for a jwt token. It's a standard thing. I configured exactly two systems: guards and interceptors. That's why I'm sure people will find an answer. (I'm a beginner)
My guard has basic code. All it does is duplicate the work of the interceptor but for paths. Here is the code:
Frontend: (authGuard)
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { TokenService } from '../../services/authentication/token-service/token.service';
import { catchError, map, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
export const authGuard: CanActivateFn = (route, state) => {
const tokenService: TokenService = inject(TokenService);
const router: Router = inject(Router);
const http: HttpClient = inject(HttpClient);
const token = tokenService.getAccessToken();
return http.get('/api/token/validate-access-token').pipe(
map(() => true),
catchError(() => {
return of(false);
})
);
};
Backend (TokenController)
[Authorize]
[HttpGet("validate-access-token")]
public async Task<ActionResult> ValidateAccessToken()
{
return Ok();
}
What happens here - I send a request to the backend which is protected by the [Authorization] attribute. If the request passes - I simply return ok. If not - a 401 error is automatically returned, which I plan to successfully catch with an introceptor and implement the redirect logic there in case it fails to refresh
Now as for the interceptor. Here is its implementation: Frontend (auth.interceptor.ts)
let isRefreshing = false;
let refreshSubject = new BehaviorSubject<boolean>(false);
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const service = inject(TokenService);
const router = inject(Router);
const excludedPaths = ['/.well-known/openid-configuration'];
if (excludedPaths.some(path => req.url.includes(path)))
return next(req);
skipAuthCheck(req, next);
const accessToken = service.getAccessToken();
const clonedRequest = accessToken
? req.clone({
setHeaders: {
Authorization: `Bearer ${accessToken}`
}
})
: req;
return next(clonedRequest).pipe(
catchError(error => handleAuthError(error, service, router, clonedRequest, next))
);
};
function skipAuthCheck (req: HttpRequest<any>, next: HttpHandlerFn) {
if (req.context.get(SKIP_AUTH))
return next(req);
return null;
}
function handleAuthError(
error: HttpErrorResponse,
tokenService: TokenService,
router: Router,
req: HttpRequest<any>,
next: HttpHandlerFn
): Observable<any> {
if (error.status === 401) {
if (!isRefreshing) {
isRefreshing = true;
refreshSubject.next(false);
return tokenService.refreshToken().pipe(
switchMap(() => {
isRefreshing = false;
refreshSubject.next(true);
const accessToken = tokenService.getAccessToken();
console.log('Access token after refresh: ', accessToken);
if (!accessToken) {
console.error('No access token after refresh');
}
const clonedRequest = req.clone({
setHeaders: {
Authorization: `Bearer ${accessToken}`
},
context: req.context.set(SKIP_AUTH, true)
});
return next(clonedRequest);
}),
catchError((err) => {
isRefreshing = false;
refreshSubject.next(false);
tokenService.revokeToken();
router.navigateByUrl('/login');
console.error('Token refresh error: ', err);
return throwError(() => new Error("Authentication Error: Redirected"));
})
);
} else {
return refreshSubject.pipe(
filter(isRefreshed => isRefreshed),
take(1),
switchMap(() => {
const clonedRequest = req.clone({
setHeaders: {
Authorization: `Bearer ${tokenService.getAccessToken()}`
}
});
return next(clonedRequest);
})
);
}
}
return throwError(() => error);
}
I just catch an error - I try to refresh. If there is an error here too - I logout and redirect. If there is another error - I just pass it on.
Now regarding tokenService Frontend (tokenService)
public refreshToken(): Observable<string> {
console.log('Attempting to refresh token...');
return this._http.post<{accessToken: string }> (
'/api/token/refresh', {}
).pipe(
map(result => result.accessToken),
tap((result) => {
console.log('New access token:', result);
this.setAccessToken(result);
}),
shareReplay(1),
catchError((error) => {
console.error('Refresh token error:', error);
return throwError(() => error);
})
);
}
Backend (TokenController)
[HttpPost("refresh")]
public async Task<ActionResult<object>> RefreshToken()
{
if (Request.Cookies.TryGetValue("RefreshToken", out string? refreshToken))
{
var result = await _tokenManager.RefreshToken(refreshToken);
if (result.Error == ErrorType.Unauthorized)
{
return Unauthorized();
}
var accessTokenModel = result.Data;
return Ok(new { accessToken = accessTokenModel.AccessToken });
}
return Unauthorized();
}
Here is the configuration:
provideHttpClient(
withInterceptors([
//toastInterceptor,
//dateFormatInterceptor,
authInterceptor,
//camelCaseInterceptor
])
),
And the last thing I will add is how I implemented jwtAuth:
services.Configure<JwtSettings>(
configuration.GetSection("Jwt")
);
services.AddAuthentication(options => {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"]!))
};
});
return services;
// ...
app.UseAuthentication();
app.UseAuthorization();
Even wrote:
builder.Services
.AddControllers(
options =>
{
options.Filters.Add(new AuthorizeFilter());
}
)
What is the problem: the request returns 401
And the most interesting thing! What I noticed.
In the "frontend (tokenService) I wrote console.log('Attempting to refresh token...');, so this is the only thing that is displayed in the console. It does not go further in the request, neither in catch nor in pipe. AND REGARDLESS OF THE THING that I do not insert Bearer , and despite the fact that I do not have [Authorization] - for some reason I get 401 and it is not caught as I would like (as you can see from the photo - the background is transparent. It does not throw me to login).. It doesn't go into the controller at all.
One problem I notice is, you are using an interceptorFn
so you need to define all properties used inside the interceptor, inside the callback itself. So that the properties are always initialized to the default values, when the interceptor is called multiple times.
export const authInterceptor: HttpInterceptorFn = (req, next) => {
let isRefreshing = false;
let refreshSubject = new BehaviorSubject<boolean>(false);
const service = inject(TokenService);
const router = inject(Router);
const excludedPaths = ['/.well-known/openid-configuration'];
Also please remove the shareReplay
there is no need to cache the refresh token, since it can expire.
public refreshToken(): Observable<string> {
console.log('Attempting to refresh token...');
return this._http.post<{accessToken: string }> (
'/api/token/refresh', {}
).pipe(
map(result => result.accessToken),
tap((result) => {
console.log('New access token:', result);
this.setAccessToken(result);
}),
catchError((error) => {
console.error('Refresh token error:', error);
return throwError(() => error);
})
);
}