📜 ⬆️ ⬇️

Angular: authorization, refresh token and HttpInterceptor

Good day.

I will describe the authorization process using some authorization server and the HttpInterceptor interface, which became available from Angular 4.3+. With the help of the HttpInterceptor`a we will add our token to the Header of the request before sending each request. Also, after the expiration of the token, getting a 401 error, we will recover the token and repeat requests that did not pass authorization while waiting for a refresh.

Let's start with the Interceptor configuration:


Prefer configuration with the main application module. Or if your application is already large enough, I advise you to make configurations in CoreModule.
In the article I will use the CoreModule, but you can do this in the root (usually the AppModule) application module, the differences are minor.
')
While writing an article on angular.io resource on CoreModule disappeared
In short, this is a module that should contain global services. The advantage is that this module is imported in the application module (AppModule). All services exported by the Core module are guaranteed to have only one instance per application, including lazy loaded modules.

//core.module.ts //imports.... @NgModule({ providers: [ AuthService, { provide: HTTP_INTERCEPTORS, //  interceptor`   auth header useClass: ApplyTokenInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, //     useClass: RefreshTokenInterceptor, multi: true } ], exports: [HttpClientModule] }) export class CoreModule { //@Optional() @SkipSelf() -      CoreModule  AppModule   UserModule -   constructor(@Optional() @SkipSelf() parentModule: CoreModule, userService: UserService, inj: Injector, auth: AuthService, http: HttpClient) { //     AuthInterceptor let interceptors = inj.get<AuthInterceptor[]>(HTTP_INTERCEPTORS) .filter(i => { return i.init; }); // http    . interceptors.forEach(i => i.init(http, auth)); userService.init(); if (parentModule) { //  ,    CoreModule      throw new Error( 'CoreModule is already loaded. Import it in the AppModule only'); } } } 

Of course, using the AuthInterceptor interface is not necessary, because only the init method is interesting for us. Just with the interface, we see the method arguments in the IDE and so there are guarantees that we will not get other Intercoptors that may not implement the init method, or implement but have a different signature.

 export interface AuthInterceptor { init(http: HttpClient, auth: AuthRefreshProvider); } 

About InjectionToken and how inj.get works (HTTP_INTERCEPTORS)
InjectionToken , who do not know about this useful thing - we are studying.

If you are familiar with the principle of DI operation in strongly typed languages, then briefly provide: HTTP_INTERCEPTORS in the module decorator connects the interface with the implementation (useClass: ApplyTokenInterceptor in our case) and then we can get the instances of our implementations by specifying the HTTP_INTERCEPTORS constant as a parameter, besides specify interface type as a generic parameter: inj.get <AuthInterceptor []> (HTTP_INTERCEPTORS)
Here is what the HTTP_INTERCEPTORS constant in angular / common / http is (this is a string + type based on which DI can return us the necessary instances):

 export const HTTP_INTERCEPTORS = new InjectionToken<HttpInterceptor[]>('HTTP_INTERCEPTORS'); 


Why transfer HttpClient to the Interceptor from the module constructor in general if you can simply describe it in the Interceptor `a constructor or get an instance via the Injector?

Initialization of our Interceptor `s takes place in the module just because we cannot directly implement the http service into the Interceptor`a constructor, we just get a cyclic dependency:

 @Injectable() export class ApplyTokenInterceptor implements HttpInterceptor, AuthInterceptor { //circular dependency Error! public constructor(private http: HttpClient) { //.... } //.... } 

You can also make an attempt to use Injector and get an instance of the HttpClient service through it, but it will also not work if you do this in the constructor:

 @Injectable() export class ApplyTokenInterceptor implements HttpInterceptor, AuthInterceptor { public constructor(private injector: Injector) { //circular dependency Error! injector.get(HttpClient); } //.... } 

In addition, you cannot get an instance of other services that de-inject yourself HttpClient - like AuthService for example.

 //auth.service.ts export class AuthService implements AuthRefreshProvider { constructor(client: HttpClient) { } } //apply.interceptor.ts export class ApplyTokenInterceptor implements HttpInterceptor, AuthInterceptor { //circular dependency Error! ,  AuthService    HttpClient public constructor(private auth: AuthService ) { } } 


By the way, the release of the second version of Angular is not far off, and in this version we will finally be able to implement HttpClient in the Interceptor!

where did this circular dependency Error come from!
  • Previously, an interceptor of the interceptor attempting to interceptor interceptor instances. Users want to inject HttpClient into interceptors to make supporting;
  • Either HttpClient or Dependency. This change moves that responsibility into HttpClient itself. By utilizing a new class HttpInterceptingHandler, it’s possible that you’ve no longer need it.



If the idea with initialization inside the module is not impressive, but the “extra if” is impressive, you can do it directly in the intercept method and not use the AuthInterceptor and Injector interface in the constructor of the CoreModule module at all.

 @Injectable() export class ApplyTokenInterceptor implements HttpInterceptor, AuthInterceptor { private http: HttpClient; constructor(private injector: Injector) { } intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { // HttpClient      if (!http) { this.http = injector.get(HttpClient); } } } 

Initializing our ApplyTokenInterceptor and RefreshTokenInterceptor on almost everything!

Add one more initialization in CoreModule, refresh the token when the application is initialized, if the token has almost expired (for example, 60 seconds left) or completely expired, then why not update it immediately:

 @NgModule({ providers: [ AuthService, UserService, export class CoreModule { { provide: APP_INITIALIZER, //    : (a) => a.renewAuth() // ! renewAuth   Promise,   Observable .   //    //       ,    AOT  useFactory: refreshToken, deps: [AuthService], multi: true }, { provide: HTTP_INTERCEPTORS, useClass: ApplyTokenInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: RefreshTokenInterceptor, multi: true } ], exports: [HttpClientModule] }) export class CoreModule { // some code } 

APP_INITIALIZER
APP_INITIALIZER is our InjectionToken, in the depths of Angular (if you don’t want to source, here’s an article ) there is a code that pulls out all APP_INITIALIZERs from DI and executes them at the loading stage (while we see loading ... for example).


We describe our refresh factory. Ideally, renewAuth () should return Promise, not Observable. It will also work with Observable (but not correctly, you can not even notice right away), but I did a test that used Observable, it resolved on a timer (10s) and as a result the module was initialized before 10 seconds expired (this means our refresh the token can come after the preloader disappears, which is not correct). I prefer to bring Observable to Promise in the factory (`export function refreshToken () {... return o.toPromise ();}`), so the rest of my code will still work with Observable, which is clearly more convenient than Promise.
 export function refreshToken(auth: AuthService) { return () => { // return own subject to complete this initialization step in any case // otherwise app will stay on preloader if any error while token refreshing occurred const subj = new Subject(); auth.renewAuth() .finally(() => { subj.complete(); }) .catch((err, caught: Observable<any>) => { // do logout, redirect to login will occurs at UserService with onLoggedOut event auth.logout(); return ErrorObservable.create(err); }) .subscribe(); // need to return Promise!! return subj.toPromise(); }; } 


Another important point! Of course there is a possibility that Refresh token may expire, or it may be revoked by the administrator. In this case, the request will return an error, and the initialization process will be interrupted and the user will hang on the preloader. Therefore, in addition to the need to return Promise, we will also return our own Subject that will be completed in any case, but if an error occurs we will make a logout and if you need a redirect to the login. The approach with the complit can be useful in many other initialization processes.

It is necessary to check the token for “freshness” in renewAuth () , so as not to update it for each click on F5 in the browser and at the same time we have the opportunity to update it before the application starts to send requests to our API and collides with the expired token ( if he still expired of course).

 //auth.service.ts public renewAuth(): Observable < string > { if(!this.isNeedRefreshToken()) { return Observable.empty<string>(); } return this._http.post<string>(`https://${authConfig.domain}/oauth/token`, { client_id: authConfig.clientID, grant_type: 'refresh_token', refresh_token: localStorage.getItem('refresh_token') }).do(res => this.onAuthRenew(res)); } public isNeedRefreshToken(): boolean { //expires_at -     ,        let expiresAtString = localStorage.getItem('expires_at'); if (!expiresAtString) { return false; } const expiresAt = JSON.parse(expiresAtString); //,         ,       let isExpireInMinute = new Date().getTime() > (expiresAt - 60000); return isExpireInMinute; } 


Why you should not store the token on the client, as I did.
Now add the Authorization header to the requests to our API.

 export class ApplyTokenInterceptor implements HttpInterceptor, AuthInterceptor { intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { //. HttpInterceptor       ,  Authorization   //      API if (!req.url.includes('api/')) { return next.handle(req); } // ,      const authReq = req.clone({ headers: req.headers.set('Authorization', this.auth.authHeader) }); //     return next.handle(authReq); } } 

It is quite simple to add the Authorization heading (there are already articles in the open spaces). The only thing I would like to clarify is, why is HttpRequest made immutable? Most likely this is done so that it would be easier to test and, in principle, work with the Interceptor chain.

But if you imagine the HttpRequest as a mutable object, what will get worse (who knows)?

Mutable \ Immutable
Who does not know but wants to know about mutable and immutable objects - google. In short, we cannot change the immutable object after it has been created, but we can change the mutable. The call req.headers.set ('Authorization', this.auth.authHeader) - must first add to the header in the request, but since it is immutable, it will simply create a copy of the headers + add a new one that is not needed and return a new HttpHeader object with our new header. In addition, based on the idempotency of the objects, you can improve the performance of the Angular application ( link )

Finally, go directly to the token refresh.

Our task is to write another Interceptor, which will intercept all requests to our API, and if there is a 401 error in the response to the request, we need to refresh the token (authService.renewAuth ()) and:


First, the interception process:

Subject and what it is eaten with
When I started working with rxjs, I actively used the Subject, at first glance it may seem that the Subject will help solve all the problems, but this is not so. Who has not had time to get acquainted with the exact definition and behavior of this class, I advise the article to study.

 //           401   ""  //"" -      Observable       type CallerRequest = { subscriber: Subscriber<any>; failedRequest: HttpRequest<any>; }; @Injectable() export class RefreshTokenInterceptor implements HttpInterceptor, AuthInterceptor { private auth: AuthRefreshProvider; private http: HttpClient; private refreshInProgress: boolean; private requests: CallerRequest[] = []; init(http: HttpClient, auth: AuthRefreshProvider) { /*some init;*/ } intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { //  ""  if(!req.url.includes('api/')) { return next.handle(req); } // Observable    ,  Observable //     Observable,      let observable = new Observable<HttpEvent<any>>((subscriber) => { //             HttpRequest //    let originalRequestSubscription = next.handle(req) .subscribe((response) => { //   (success)    subscriber.next(response); }, (err) => { if (err.status === 401) { //  401 -      this.handleUnauthorizedError(subscriber, req); } else { //   subscriber.error(err); } }, () => { // ,  finally()  subscriber.complete(); }); return () => { //            //      ,  dev tools     , .  ( Controller)     ,      originalRequestSubscription.unsubscribe(); }; }); //   Observable,      . return observable; } //private handleUnauthorizedError //private repeatFailedRequests //private repeatRequest } 

Consider how we will memorize the “401s” and repeat them:

 private handleUnauthorizedError(subscriber: Subscriber < any >, request: HttpRequest<any>) { // "401"  this.requests.push({ subscriber, failedRequest: request }); if(!this.refreshInProgress) { //    ,   ,   "401" //     refresh this.refreshInProgress = true; this.auth.renewAuth() .finally(() => { this.refreshInProgress = false; }) .subscribe((authHeader) => //   ,           this.repeatFailedRequests(authHeader), () => { //   -       ,    this.auth.logout(); }); } } private repeatFailedRequests(authHeader) { this.requests.forEach((c) => { //  "" ,     const requestWithNewToken = c.failedRequest.clone({ headers: c.failedRequest.headers.set('Authorization', authHeader) }); //  ( .subscriber - subscriber  ) this.repeatRequest(requestWithNewToken, c.subscriber); }); this.requests = []; } private repeatRequest(requestWithNewToken: HttpRequest < any >, subscriber: Subscriber<any>) { //     this.http.request(requestWithNewToken).subscribe((res) => { subscriber.next(res); }, (err) => { if (err.status === 401) { // if just refreshed, but for unknown reasons we got 401 again - logout user this.auth.logout(); } subscriber.error(err); }, () => { subscriber.complete(); }); } 

That's all. Research and improve!

Source: https://habr.com/ru/post/351036/


All Articles