Picking up where we left off, the first use case for our Auth service is a header token. The best way to add a header token is via an Http interceptor. Let's start.
Since we are injecting the AuthService
anyway, and using AppModule
, it does not make much difference to use HttpInterceptorFunction
instead of the goold ol' HttpClientModule
. Down the line it will be more evident that it is indeed a better choice.
You can read about Angular 15 standalone HTTPClient provider.
Follow along on StackBlitz
In our App Module provider array, we add another entry for the interceptor:
// app.module
@NgModule({
// ...
providers: [
{
provide: HTTP_INTERCEPTORS,
multi: true,
useClass: AppInterceptor,
},
// ...
]
})
export class AppModule {}
The interceptor injects the AuthService
immediately to use it. Let me add a console log as the first line.
// services/http HttpInterceptor
@Injectable()
export class AppInterceptor implements HttpInterceptor {
constructor(private authState: AuthState) {
console.log('interceptor injected');
}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// prefixing the api with proper value, mostly from config
// remote config url are expected to be filtered out, it would not make sense
const url = 'https://saphire.sekrab.com/api' + req.url;
const adjustedReq = req.clone({
url: url,
setHeaders: this.getHeaders(),
});
return next.handle(adjustedReq);
}
private getHeaders(): any {
// TODO authorization here
let headers: any = {};
return headers;
}
}
Before we add our header, let's remember our sequence of events: assuming we are using Http call to get remote configuration, which normally has the correct API URL, it is clear that we need to filter out the configuration URL. In this example, I am not calling a remote URL for configurations, but it's good to know that the interceptor should check the req.url
and filter out the ones it does not wish to deal with.
// simple check to exclude local data or config url
if (req.url.indexOf('config') > -1) {
// pass through
return next(req);
}
The Circular Dependency in DI issue
Error: NG0200: Circular dependency in DI detected for InjectionToken HTTP_INTERCEPTORS.
Have you ever seen this? It occurs when you inject a service in another service, that injects it back in itself. In our case, the AuthService
and HttpClient
kind of inject each other.
In addition to those two services, the configuration service that uses Http is also injected in AuthService
. It's a mess no matter how you look at it.
But here is the thing that will kill you before you reach midlife. Since we are not making any use of the HttpClient
in the AuthService
constructor, this tumor is benign. If however we do initiate an Http call in the constructor, that's when it blows up in our faces.
There are many fixes, most of them are around delaying the Http call a bit just to make sure the AuthService
has been constructed. Like waiting for the remote configuration to be ready. But that isn't a clean cut solution.
That settles it then, as a general rule: avoid Http requests in your service constructors. Especially those injected early on.
If indeed you need to inject a service that calls an Http in its constructor (next week we'll have a use case for that), break your services apart, and spread them around in your app.
AuthState service
To clean up and be more systematic as we move forward let's move all non Http related methods to their own service. The AuthState
is the service that will hold the Observable
state, and contain no references to HttpClient
. The constructor is responsible for reading the LocalStorage
information, and it has the GetToken
new method to return the token.
// services/auth.state
@Injectable({ providedIn: 'root' })
export class AuthState {
// create an internal subject and an observable to keep track
private stateItem: BehaviorSubject<IAuthInfo | null> = new BehaviorSubject(
null
);
stateItem$: Observable<IAuthInfo | null> = this.stateItem.asObservable();
constructor() {
// simpler to initiate state here
// check item validity
console.log('authState in');
const _localuser: IAuthInfo = JSON.parse(localStorage.getItem('user'));
if (this.CheckAuth(_localuser)) {
this.SetState(_localuser);
} else {
this.Logout();
}
}
// also move here: SetState RemoveState CheckAuth Logout
}
Now the AuthService
is much simpler, it only has the Login, and it uses AuthState
to save into localStorage
. We will enhance this later when we use a proper localStorage
wrapper.
So now we need to create a GetToken
method, to retrieve the access token, then use it in HttpInterceptor
// services/auth.state
// add this new method
GetToken() {
const _auth = this.stateItem.getValue();
// check if auth is still valid first before you return
return this.CheckAuth(_auth) ? _auth.accessToken : null;;
}
We will add the logic for checking the token later. Then let's use it in the interceptor
// update http file to fill out get headers
private getHeaders(): any {
// authorization here
let headers: any = {};
const _auth = this.authState.GetToken();
if (_auth && _auth !== '') {
headers['authorization'] = `Bearer ${_auth}`;
}
return headers;
}
In Module-full solution, everything is a private member of our Class. In standalone, everything is a loose variable. Which way do you prefer?
401 Refresh
What happens when we get a 401? We can either show the user out, or use our refresh token to get a new access token. In Angular, this probably is one of the tasks that left me bruised for a while. Here is the sequence of events:
- Catch a 401 (which 401)
- Create a new Http call with refresh token, and request a new access token
- Wait for response
- Update localStorage
- Resubmit original request (retry)
- Return and have a nice life
- Else logout
- Catch another concurrent 401, queue and wait
So let's first modify the Http function to catch 401, and call a function for it.
// services/http
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// ...
return next.handle(adjustedReq).pipe(
catchError(error => {
// if this is really an http error
if (error instanceof HttpErrorResponse
// and of 401 status
&& error.status === 401
){
// handle 401 error, return an observable to continue the pipe
return this.handle401Error();
}
// rethrow error, to be caught elsewhere
return throwError(() => error);
})
);
}
private handle401Error(): Observable<any> {
// let's first try to submit a refresh access token request
// return authService.RefreshToken()
// switchMap when done to resubmit the req passed, using next.handler
// catchError means it is not working, rethrow and logout
}
What we want to do now, is fill out the handle401Error
function. First, it looks like we need a RefreshToken
method in AuthService
(not AuthState
). Which means we need to inject that as well. Remember: AuthService
has no Http calls in the constructor.
// services/auth.service
// add RefreshToken method
RefreshToken(): Observable<boolean> {
return this.http
.post(this._refreshUrl, { token: this.authState.GetRefreshToken() })
.pipe(
map((response) => {
// this response has the new refresh token and access token
if (!response) {
// something terrible happened
throw(new Error('Oh oh'));
}
// update session
const retUser: IAuthInfo = <IAuthInfo>(<any>response).data;
// we'll be more selective later...
localStorage.setItem('user', JSON.stringify(retUser));
this.authState.SetState(retUser);
return true;
})
);
}
Back to our handle401Error
function
// services/http
// update handle401Error function, also, inject AuthService in the constructor
private handle401Error(
// pass in orginalReq and handler
originalReq: HttpRequest<any>,
next: HttpHandler
): Observable<any> {
return this.authService.RefreshToken().pipe(
switchMap((result: boolean) => {
if (result) {
// token saved (in RefreshToken), now recall the original req after adjustment
// so we need to pass "next" handler, and "originalReq"
return next.handle(originalReq.clone({setHeaders: this.getHeaders()}));
}
}),
catchError(error => {
// else refresh token did not work, its bigger than both of us
// log out and throw error
this.authState.Logout();
return throwError(() => error);
})
);
}
We adjust the signature to pass in the originalReq
and the next
handler:
// services/http
// adjust call
return next.handle(adjustedReq).pipe(
catchError((error) => {
// ...
return this.handle401Error(adjustedReq, next);
}
// ...
})
);
Testing this, the first issue is the /login
point. If it 401's, there is no need to retry, it just means bad credentials. So the handler must filter out /login
point
// services/http filter out login from handler401Error
return next.handle(adjustedReq).pipe(
catchError((error) => {
// if this is really an http error
if (
error instanceof HttpErrorResponse &&
// and of 401 status
error.status === 401 &&
// filter out login calls
req.url.indexOf('login') < 0
) {
return this.handle401Error(adjustedReq, next);
}
// rethrow error
return throwError(() => error);
})
Testing this by making a call in some page, and hard coding few things on my test server, this is what I get of logging out the sequence:
So you can see that the original request was recalled, with the proper access token. (Don't mind the value 'access_token_1', it's dummy date.)
You want to produce similar colorful log? Read Taming the console
Locking and unlocking
We're not done yet. Let's create an example usage to see the problem that comes out of this. We are going to make two requests in parallel. This means that while the first request is trying to refresh token, the second request comes in, and it might request a new token as well, screwing up the original token. Here is the dummy log, that does not break the system, because well, its dumb:
Notice the following:
- Two 401 errors were thrown, that's expected
- Two calls to refresh token, with the same refresh token, one should work, the other should not
- Response comes in with new access token, in my example it's the same, because it's dumb. In real life there will be two different access tokens, one must fail (if it hadn't already)
To fix that we need to lock, queue, then unlock.
It's straightforward to lock and unlock, using a private Boolean member:
// services/http
// add lock boolean
@Injectable()
export class AppInterceptor implements HttpInterceptor {
// if refreshing token, it is busy, lock
isBusy: boolean;
private handle401Error(
originalReq: HttpRequest<any>,
next: HttpHandler
): Observable<any> {
if (!this.isBusy) {
// lock
this.isBusy = true;
return this.authService.RefreshToken().pipe(
// ...
finalize(() => {
// unlock
this.isBusy = false;
})
);
} else {
// return unadjusted, for now
return next.handle(originalReq);
}
}
}
Now with this, one call would retry, and all other would fail. We need to just wait a bit until the token is ready, before we adjust and recall all other ones. To do that, we can have a private member to keep track of the successful token. Once it's ready, flush out.
The most widely accepted solution to this is using a Subject
of Boolean
and piping on it. It updates when locking, and when token is ready.
// services/http update to allow subject queuing
@Injectable()
export class AppInterceptor implements HttpInterceptor {
// create a subject to queue outstanding refresh calls
recall: Subject<boolean> = new Subject();
// ...
private handle401Error(...): Observable<any> {
if (!this.isBusy) {
// ...
// progress subject to false
this.recall.next(false);
return this.authService.RefreshToken().pipe(
switchMap((result: boolean) => {
if (result) {
// progress subject to true
this.recall.next(true);
// ... return next.handle
}
}),
// ...
);
} else {
// return the subject, watch when it's ready, switch to recall original request
return this.recall.pipe(
filter(ready => ready === true),
switchMap(ready => {
// try again with adjusted header
return next.handle(originalReq.clone({ setHeaders: this.getHeaders() }));
})
);
}
}
I tried to break it, but I couldn't. If you run into scenarios where it is acting up, let me know please.
Side point
You might be tempted to stop an outgoing request if the access token is not valid (expired). Don't. That is an API decision. Some points do not need an access token (like /login
), some are flexible in returning less data if token is not valid.
Enhance
One enhancement we can add is to redirect user to login page if the refresh token fails.
Another enhancement is in the login resolve. We can now save the URL that caused the redirection in the auth state, and try to redirect to it after login. That and one more rant about user account details is coming up next week. 😴
Thank you for reading this far, did you break the 401 handler?
Top comments (0)