When you want to implement a more secure authentication with Ionic, the Ionic JWT refesh token flow is an advanced pattern with two tokens to manage.
In this tutorial we will implement the Ionic app based on a simple API that I created upfront with NestJS. This flow is based on two tokens, one access token with a short time to live, and another refresh token which can be used to get a new access token from the server.
This means, even if the access token was leaked a potential hacker can only use this for a short duration, and if we know about a security breach we could also block the refresh tokens from being used again!
The API can be found at https://tutorial-token-api.herokuapp.com and offers the basic routes that we need to implement a full Ionic JWT refresh token flow.
What we will do is:
- Signup & login a user
- Attach a JWT to all of our calls to the API to authenticate the user
- Use a refresh token once our access token expires to get a new token for the next call
All of that needs some additional logic inside an interceptor, but let’s start with the basics.
Starting the Refresh Token App
To get started, we bring up a new Ionic app and add two pages and a service for our JWT refresh token flow. On top of that we can also add a guard to protect our internal routes, so run the following:
ionic start devdacticRefresh blank --type=angular --capacitor cd ./devdacticRefresh ionic g page pages/login ionic g page pages/inside ionic g service services/api # Secure your routes ionic g guard guards/auth --implements CanLoad
Now we can also add the URL to our backend to our src/environments/environment.ts to easily access it from anywhere:
export const environment = { production: false, api_url: 'https://tutorial-token-api.herokuapp.com' };
Since we need to make HTTP calls, we also add the HttpClientModule
to our app/app.module.ts as usual:
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouteReuseStrategy } from '@angular/router'; import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; import { SplashScreen } from '@ionic-native/splash-screen/ngx'; import { StatusBar } from '@ionic-native/status-bar/ngx'; import { AppComponent } from './app.component'; import { AppRoutingModule } from './app-routing.module'; import { HttpClientModule } from '@angular/common/http'; @NgModule({ declarations: [AppComponent], entryComponents: [], imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule, HttpClientModule], providers: [ StatusBar, SplashScreen, { provide: RouteReuseStrategy, useClass: IonicRouteStrategy } ], bootstrap: [AppComponent] }) export class AppModule {}
Finally we can get started with the routing for the app which should simply display the login page and afterwards an inside area that is protected by our guard (well not yet, but we will build out the guard later).
Go ahead and change the app/app-routing.module.ts to:
import { NgModule } from '@angular/core'; import { PreloadAllModules, RouterModule, Routes } from '@angular/router'; import { AuthGuard } from './guards/auth.guard'; const routes: Routes = [ { path: '', loadChildren: () => import('./pages/login/login.module').then( m => m.LoginPageModule) }, { path: 'inside', loadChildren: () => import('./pages/inside/inside.module').then( m => m.InsidePageModule), canLoad: [AuthGuard] }, ]; @NgModule({ imports: [ RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }) ], exports: [RouterModule] }) export class AppRoutingModule { }
Now we got the basics in place, and if you want an even more template you could also use the basics of the Ionic 5 navigation with login template!
Building the API Service Functionality
Next step is to implement the basic service functionality required for our token flow. First of all, here’s an overview of the relevant API routes in the dummy server:
- Register: POST to /users
- Login: POST to /auth
- New Access Token: POST to /auth/refresh
- Logout: POST to /auth/logout
- Get protected data: GET to /users/secret
For now we will get started with the basic mechanism to sign up a new user, a function to call the protected route (the JWT header will be added later), and the login, which will save upon successful login the access token and refresh token that we get back from the server.
Get started with the service by changing the services/api.service.ts to:
import { environment } from './../../environments/environment'; import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { tap, switchMap } from 'rxjs/operators'; import { BehaviorSubject, from, Observable, of } from 'rxjs'; import { Plugins } from '@capacitor/core'; import { Router } from '@angular/router'; const { Storage } = Plugins; const ACCESS_TOKEN_KEY = 'my-access-token'; const REFRESH_TOKEN_KEY = 'my-refresh-token'; @Injectable({ providedIn: 'root' }) export class ApiService { // Init with null to filter out the first value in a guard! isAuthenticated: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(null); currentAccessToken = null; url = environment.api_url; constructor(private http: HttpClient, private router: Router) { this.loadToken(); } // Load accessToken on startup async loadToken() { const token = await Storage.get({ key: ACCESS_TOKEN_KEY }); if (token && token.value) { this.currentAccessToken = token.value; this.isAuthenticated.next(true); } else { this.isAuthenticated.next(false); } } // Get our secret protected data getSecretData() { return this.http.get(`${this.url}/users/secret`); } // Create new user signUp(credentials: {username, password}): Observable<any> { return this.http.post(`${this.url}/users`, credentials); } // Sign in a user and store access and refres token login(credentials: {username, password}): Observable<any> { return this.http.post(`${this.url}/auth`, credentials).pipe( switchMap((tokens: {accessToken, refreshToken }) => { this.currentAccessToken = tokens.accessToken; const storeAccess = Storage.set({key: ACCESS_TOKEN_KEY, value: tokens.accessToken}); const storeRefresh = Storage.set({key: REFRESH_TOKEN_KEY, value: tokens.refreshToken}); return from(Promise.all([storeAccess, storeRefresh])); }), tap(_ => { this.isAuthenticated.next(true); }) ) } }
We also got the basic token loading in place when the app starts so we can see if a user was logged in before. In a real app you could also check the expiration dates of the tokens in that place as well!
On top of these functions we need to provide a way to get a new access token once the existing expires. To get a new token, we can load the current refresh token from storage, perform an APU request and return that result.
Caution: In my API I designed the /auth/refresh route to expect the refresh token inside the header of the HTTP call. Since we will later build an interceptor that automatically attaches the standard access token to the header, we now need to set the header manually for this call in here!
For the logout you could also use a POST to correctly sign out users of your API and remove any stored or active tokens for them, that’s why the logout is currently a POST. In our case, we will simply continue to remove all information inside the app and guide the user back to the login.
Now finish our service by adding the following to the services/api.service.ts:
// Potentially perform a logout operation inside your API // or simply remove all local tokens and navigate to login logout() { return this.http.post(`${this.url}/auth/logout`, {}).pipe( switchMap(_ => { this.currentAccessToken = null; // Remove all stored tokens const deleteAccess = Storage.remove({ key: ACCESS_TOKEN_KEY }); const deleteRefresh = Storage.remove({ key: REFRESH_TOKEN_KEY }); return from(Promise.all([deleteAccess, deleteRefresh])); }), tap(_ => { this.isAuthenticated.next(false); this.router.navigateByUrl('/', { replaceUrl: true }); }) ).subscribe(); } // Load the refresh token from storage // then attach it as the header for one specific API call getNewAccessToken() { const refreshToken = from(Storage.get({ key: REFRESH_TOKEN_KEY })); return refreshToken.pipe( switchMap(token => { if (token && token.value) { const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json', Authorization: `Bearer ${token.value}` }) } return this.http.get(`${this.url}/auth/refresh`, httpOptions); } else { // No stored refresh token return of(null); } }) ); } // Store a new access token storeAccessToken(accessToken) { this.currentAccessToken = accessToken; return from(Storage.set({ key: ACCESS_TOKEN_KEY, value: accessToken })); }
We got the logic in place to get all relevant information from our API, now we can integrate the service in our pages to move on with out JWT refresh flow.
Building the Login Page
To sign up and log in users we need a little form to capture data, and since we want to use a reactive form we need to add the ReactiveFormsModule
to our src/app/pages/login/login.module.ts:
import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { IonicModule } from '@ionic/angular'; import { LoginPageRoutingModule } from './login-routing.module'; import { LoginPage } from './login.page'; @NgModule({ imports: [ CommonModule, FormsModule, IonicModule, LoginPageRoutingModule, ReactiveFormsModule ], declarations: [LoginPage] }) export class LoginPageModule {}
Now we can build the form and the functions for sign up and login, which will basically simply call our service functionality and surround everything with a bit of loading and error handling!
If the API result is a success, we can move the user forward to the inside are of our app. Now go ahead and change the src/app/pages/login/login.page.ts to:
import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { AlertController, LoadingController } from '@ionic/angular'; import { Router } from '@angular/router'; import { ApiService } from '../../services/api.service'; @Component({ selector: 'app-login', templateUrl: './login.page.html', styleUrls: ['./login.page.scss'], }) export class LoginPage implements OnInit { credentials: FormGroup; constructor( private fb: FormBuilder, private apiService: ApiService, private alertController: AlertController, private router: Router, private loadingController: LoadingController ) {} ngOnInit() { this.credentials = this.fb.group({ username: ['', Validators.required], password: ['', Validators.required], }); } async login() { const loading = await this.loadingController.create(); await loading.present(); this.apiService.login(this.credentials.value).subscribe( async _ => { await loading.dismiss(); this.router.navigateByUrl('/inside', { replaceUrl: true }); }, async (res) => { await loading.dismiss(); const alert = await this.alertController.create({ header: 'Login failed', message: res.error.msg, buttons: ['OK'], }); await alert.present(); } ); } async signUp() { const loading = await this.loadingController.create(); await loading.present(); this.apiService.signUp(this.credentials.value).subscribe( async _ => { await loading.dismiss(); this.login(); }, async (res) => { await loading.dismiss(); const alert = await this.alertController.create({ header: 'Signup failed', message: res.error.msg, buttons: ['OK'], }); await alert.present(); } ); } }
To capture the data we bring up a few input fields that are connected to our formGroup
and the fields inside. We will handle both login and sign up on this page for simplicity, feel free to create another page for the registration in your app!
Simply open the src/app/pages/login/login.page.html for now and change it to:
<ion-header> <ion-toolbar color="primary"> <ion-title>Devdactic Refresh</ion-title> </ion-toolbar> </ion-header> <ion-content> <form (ngSubmit)="login()" [formGroup]="credentials"> <div class="input-group"> <ion-item> <ion-input placeholder="Username" formControlName="username"></ion-input> </ion-item> <ion-item> <ion-input type="password" placeholder="Password" formControlName="password"></ion-input> </ion-item> </div> <ion-button type="submit" expand="block" [disabled]="!credentials.valid">Log in</ion-button> <ion-button type="button" expand="block" (click)="signUp()" color="secondary">Sign up! </ion-button> </form> </ion-content>
Now we are able to sign up and log in afterwards with the credentials.
Note:: If the API takes a few seconds when using it the first time it’s because I’m using free dynos on Heroku for this project and the API needs to awake from sleep!
Building the Inside Area
This part is now even simpler since we just need a way to test our call to get secret data from the API, which is only allowed for authenticated users.
Therefore go ahead and add the call to our service inside the src/app/pages/inside/inside.page.ts now:
import { Component, OnInit } from '@angular/core'; import { ApiService } from '../../services/api.service'; @Component({ selector: 'app-inside', templateUrl: './inside.page.html', styleUrls: ['./inside.page.scss'], }) export class InsidePage implements OnInit { secretData = null; constructor(private apiService: ApiService) { } ngOnInit() { } async getData() { this.secretData = null; this.apiService.getSecretData().subscribe((res: any) => { this.secretData = res.msg; }); } logout() { this.apiService.logout(); } }
Just like the class, the template is pretty boring and should only display the buttons necessary for triggering our functions and the data from the API call.
Continue with the src/app/pages/inside/inside.page.html and change it to:
<ion-header> <ion-toolbar color="primary"> <ion-title>Inside</ion-title> <ion-buttons slot="end"> <ion-button (click)="logout()"> <ion-icon slot="icon-only" name="log-out"></ion-icon> </ion-button> </ion-buttons> </ion-toolbar> </ion-header> <ion-content> <ion-button expand="full" (click)="getData()">Get data</ion-button> <ion-card> <ion-card-content> {{ secretData }} </ion-card-content> </ion-card> </ion-content>
Now we are done with the UI and step into the more complex part of our Ionic JWT refresh token logic!
Protecting your App with Guards
The inside area should only open for authenticated users, and just like inside our Ionic 5 navigation with login we will protect the page based on the isAuthenticated
BehaviourSubject of our service.
Since the subject is initialised with null (see the service code in your project), we need to filter out that value inside the canLoad
function. Afterwards we can check for the first real value that is emitted, and based on the value we either allow access to the page or send the user back to the login.
Now implement our basic guard inside the src/app/guards/auth.guard.ts like this:
import { Injectable } from '@angular/core'; import { CanLoad, Router } from '@angular/router'; import { ApiService } from '../services/api.service'; import { Observable } from 'rxjs'; import { filter, map, take } from 'rxjs/operators' @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanLoad { constructor(private apiService: ApiService, private router: Router) { } canLoad(): Observable<boolean> { return this.apiService.isAuthenticated.pipe( filter(val => val !== null), // Filter out initial Behaviour subject value take(1), // Otherwise the Observable doesn't complete! map(isAuthenticated => { if (isAuthenticated) { return true; } else { this.router.navigateByUrl('/') return false; } }) ); } }
That was a nice and easy preparation for what comes next..
Creating the Token Interceptor Logic
We can build the whole logic for the Ionic JWT refresh token flow within an interceptor. This interceptor is used for two things:
- Attach the JWT (access token) to the header of an HTTP request
- Obtain a new access token if the API returns an a specific error indicating that the access token has expired
Since the CLI can’t create an Interceptor, we can now create a new file at src/app/interceptors/jwt.interceptor.ts (create the folder as well).
We can now tell the app to use our interceptor class inside the app/app.module.ts by adding it to the array of providers like this:
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouteReuseStrategy } from '@angular/router'; import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; import { SplashScreen } from '@ionic-native/splash-screen/ngx'; import { StatusBar } from '@ionic-native/status-bar/ngx'; import { AppComponent } from './app.component'; import { AppRoutingModule } from './app-routing.module'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { JwtInterceptor } from './interceptors/jwt.interceptor'; @NgModule({ declarations: [AppComponent], entryComponents: [], imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule, HttpClientModule], providers: [ StatusBar, SplashScreen, { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, { provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true } ], bootstrap: [AppComponent] }) export class AppModule {}
Now we can tackle the first part, which is adding the JWT to the header of the request if necessary.
I always recommend to check an internal blocked list since you might not want to attach the JWT to ALL outgoing requests of your app.
If the isInBlockedList()
returns true, we will simply handle the request as it is without changing it. Otherwise, we will also handle it but before continuing it the current JWT will be added inside the addToken()
function, which returns the changed request with the new header.
If we encounter a 400 or 401 error from the request, it means the token is invalid. These codes can be different in your own API, so talk to the backend team or make sure you catch the right error codes so you can handle them accordingly afterwards.
For now, go ahead and implement the first part of the interceptor inside the src/app/interceptors/jwt.interceptor.ts:
import { environment } from './../../environments/environment'; import { Injectable } from '@angular/core'; import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { Observable, throwError, BehaviorSubject, of } from 'rxjs'; import { ApiService } from '../services/api.service'; import { catchError, finalize, switchMap, filter, take, } from 'rxjs/operators'; import { ToastController } from '@ionic/angular'; @Injectable() export class JwtInterceptor implements HttpInterceptor { // Used for queued API calls while refreshing tokens tokenSubject: BehaviorSubject<string> = new BehaviorSubject<string>(null); isRefreshingToken = false; constructor(private apiService: ApiService, private toastCtrl: ToastController) { } // Intercept every HTTP call intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { // Check if we need additional token logic or not if (this.isInBlockedList(request.url)) { return next.handle(request); } else { return next.handle(this.addToken(request)).pipe( catchError(err => { if (err instanceof HttpErrorResponse) { switch (err.status) { case 400: return this.handle400Error(err); case 401: return this.handle401Error(request, next); default: return throwError(err); } } else { return throwError(err); } }) ); } } // Filter out URLs where you don't want to add the token! private isInBlockedList(url: string): Boolean { // Example: Filter out our login and logout API call if (url == `${environment.api_url}/auth` || url == `${environment.api_url}/auth/logout`) { return true; } else { return false; } } // Add our current access token from the service if present private addToken(req: HttpRequest<any>) { if (this.apiService.currentAccessToken) { return req.clone({ headers: new HttpHeaders({ Authorization: `Bearer ${this.apiService.currentAccessToken}` }) }); } else { return req; } } }
Now if we encounter a 400 error, this simply means we are not authorized and the refresh flow didn’t work for whatever reason. In that case, we will display an alert and perform the logout.
The 401 error is the more important part in our case, since we can now perform the dance:
- Set the
isRefreshingToken
variable so we block other calls from performing the dance as well at the same time - Get a new access token from the
getNewAccessToken()
- Store the new access token if we were able to get it
- Emit the new value on the
tokenSubject
so other calls can use it - Handle the request again by attaching the new token inside the
addToken()
All of that happens with RxJS logic by switching to different Observables and results! It might look kinda hard on the first look, so go through the code a few times to understand each part.
There’s also a block at the end we enter if we are already refreshing the token and another API call was made at the same time that encountered a 401 error: In that block we wait until we get a new value on the tokenSubject
and then perform the request again – so it’s kinda queued until we have obtained a new access token!
Now add the last missing functions to the src/app/interceptors/jwt.interceptor.ts:
// We are not just authorized, we couldn't refresh token // or something else along the caching went wrong! private async handle400Error(err) { // Potentially check the exact error reason for the 400 // then log out the user automatically const toast = await this.toastCtrl.create({ message: 'Logged out due to authentication mismatch', duration: 2000 }); toast.present(); this.apiService.logout(); return of(null); } // Indicates our access token is invalid, try to load a new one private handle401Error(request: HttpRequest < any >, next: HttpHandler): Observable < any > { // Check if another call is already using the refresh logic if(!this.isRefreshingToken) { // Set to null so other requests will wait // until we got a new token! this.tokenSubject.next(null); this.isRefreshingToken = true; this.apiService.currentAccessToken = null; // First, get a new access token return this.apiService.getNewAccessToken().pipe( switchMap((token: any) => { if (token) { // Store the new token const accessToken = token.accessToken; return this.apiService.storeAccessToken(accessToken).pipe( switchMap(_ => { // Use the subject so other calls can continue with the new token this.tokenSubject.next(accessToken); // Perform the initial request again with the new token return next.handle(this.addToken(request)); }) ); } else { // No new token or other problem occurred return of(null); } }), finalize(() => { // Unblock the token reload logic when everything is done this.isRefreshingToken = false; }) ); } else { // "Queue" other calls while we load a new token return this.tokenSubject.pipe( filter(token => token !== null), take(1), switchMap(token => { // Perform the request again now that we got a new token! return next.handle(this.addToken(request)); }) ); } }
We’ve done it!
Whenever the interceptor notices that an API call fails, it will automatically retrieve a new access token with our refresh token and retry the failed call.
Conclusion
The hardest part about the Ionic JWT refresh token flow is actually the automatic renewal of tokens, which can be build with some RxJS magic to perform everything under the hood without noticing the user.
If you would like to see another tutorial on how I built the NestJS API for this tutorial let me know in the comments!
You can also see a detailed explanation of everything in the video below.
The post Building an Ionic JWT Refresh Token Flow appeared first on Devdactic - Ionic Tutorials.