Ionic and MSAL Authentication

Viewed 2343

I have an Ionic app that needs to authenticate in Azure, so I installed MSAL following this tutorial: https://docs.microsoft.com/en-us/graph/tutorials/angular

It works like a charm with "ionic serve" but when I run it in the device, it crashes when I try to sign in Azure. I think it is because the popup window MSAL shows for login is not allowed in Ionic.

So my first attempt was to change the loginPopup() call for a loginRedirect(). So I removed this code:

async signIn(): Promise<void> {
  const result = await this.msalService
    .loginPopup(OAuthSettings)
    .toPromise()
    .catch((reason) => {
      this.alertsService.addError('Login failed',
        JSON.stringify(reason, null, 2));
    });

  if (result) {
    this.msalService.instance.setActiveAccount(result.account);
    this.authenticated = true;
    this.user = await this.getUser();
  }
}

And I added this new one (based on https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/errors.md):

async signIn(): Promise<void> {
  await this.msalService.instance.handleRedirectPromise();

  const accounts = this.msalService.instance.getAllAccounts();
  if (accounts.length === 0) {
    // No user signed in
    this.msalService.instance.loginRedirect();
  }
}

But this way, the user information is not saved, because I have not a "result" to handle or to call to setActiveAccount(result). It did not work even in "ionic serve", so I discarded this approach.

The second approach, after seaching for a posible solution for two days, was to show the popup in a InAppBrowser (https://ionicframework.com/docs/native/in-app-browser), so I changed the code to:

async signIn(): Promise<void> {
  const browser = this.iab.create('https://www.microsoft.com/');
  browser.executeScript({ code: "\
    const result = await this.msalService\
      .loginPopup(OAuthSettings)\
      .toPromise()\
      .catch((reason) => {\
        this.alertsService.addError('Login failed',\
          JSON.stringify(reason, null, 2));\
      });\
    if (result) {\
      this.msalService.instance.setActiveAccount(result.account);\
      this.authenticated = true;\
      this.user = await this.getUser();\
    }"
  }); 
}

But it just open a new window and do nothing more, it does not execute loginPopup(), so I also discard this second approach.

Anyone knows how to avoid the popup problem in Ionic?

Thank you

3 Answers

I managed to solve this with cordova-plugin-inappbrowser by using a custom navigation client, here's my implementation:

CustomNavigationClient


    class CustomNavigationClient extends NavigationClient {
      async navigateExternal(url: string, options: any) {
        // Cortdova implementation
        if (window.hasOwnProperty("cordova")) {
          var ref = cordova.InAppBrowser.open(url, '_blank', 'location=yes,clearcache=yes,clearsessioncache=yes');

          // Check if the appbrowser started a navigation
          ref.addEventListener('loadstart', (event: any) => {
            // Check if the url contains the #state login parameter
            if (event.url.includes('#state')) {
              // Close the in app browser and redirect to localhost + the state parameter
              // msal-login is a fake route to trigger a page refresh
              ref.close();
              const domain = event.url.split('#')[0];
              const url = event.url.replace(domain, 'http://localhost/msal-login');
              window.location.href = url;
            }
          });
        } else {
          if (options.noHistory) {
            window.location.replace(url);
          } else {
            window.location.assign(url);
          }
        }
        return true;
      }
    }

app.component.ts


    const navigationClient = new CustomNavigationClient();
    this.msalService.instance.setNavigationClient(navigationClient);
    
    this.msalService.instance.handleRedirectPromise().then((authResult: any) => {
      console.debug('AuthResult ---->', authResult);
      if (authResult) { 
        // your login logic goes here. 
      } else {
        this.msalService.instance.loginRedirect();
      }
    });

I can confirm Paolo Cuscelas solution is working. We were using ionic & capacitor with the cordova InAppBrowser, since capacitors browser does not support listening to url changes, which is needed in order to "proxy" the msal route params.

Also, make sure to register the redirection uri in your azure portal.
The rest of the application is more or less setup based on the provided examples from microsoft for msal/angular package.

CustomNavigationClient for Capacitor
Make sure you setup the msal interaction type to "InteractionType.Redirect"
The constructor requires you to pass in a InAppBrowser reference.
Also azure returns the data in the url through #code instead of #state, so make sure to split the url accordingly.


class CustomNavigationClient extends NavigationClient {

  constructor(private iab: InAppBrowser) {
    super();
  }

  async navigateExternal(url: string, options: any) {
    if (Capacitor.isNativePlatform()) {
      const browser = this.iab.create(url, '_blank', {
        location: 'yes',
        clearcache: 'yes',
        clearsessioncache: 'yes',
        hidenavigationbuttons: 'yes',
        hideurlbar: 'yes',
        fullscreen: 'yes'
      });
      browser.on('loadstart').subscribe(event => {
        if (event.url.includes('#code')) {
          // Close the in app browser and redirect to localhost + the state parameter
          browser.close();
          
          const domain = event.url.split('#')[0];
          const url = event.url.replace(domain, 'http://localhost/home');
          console.log('will redirect to:', url);
          window.location.href = url;
        }
      });
    } else {
      if (options.noHistory) {
        window.location.replace(url);
      } else {
        window.location.assign(url);
      }
    }
    return true;
  }
}

app.component.ts
Register the navigation client

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { InAppBrowser } from '@awesome-cordova-plugins/in-app-browser/ngx';
import { MsalBroadcastService, MsalService } from '@azure/msal-angular';
import { AuthenticationResult, EventMessage, EventType, NavigationClient } from '@azure/msal-browser';
import { Capacitor } from '@capacitor/core';
import { Subject } from 'rxjs';
import { filter, tap } from 'rxjs/operators';
import { AzureAuthService } from '@core/auth';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {

  constructor(
    private azureAuthService: AzureAuthService,
    private authService: MsalService,
    private msalBroadcastService: MsalBroadcastService,
    private router: Router,
    private iab: InAppBrowser,
    private msalService: MsalService,
  ) {
    this.msalService.instance.setNavigationClient(new CustomNavigationClient(this.iab));
  }

  ngOnInit(): void {

    this.msalBroadcastService.msalSubject$
      .pipe(
        filter((msg: EventMessage) => msg.eventType === EventType.LOGIN_SUCCESS),
      )
      .subscribe((result: EventMessage) => {
        console.log('--> login success 1: ', result);
        const payload = result.payload as AuthenticationResult;
        this.authService.instance.setActiveAccount(payload.account);

        // custom service to handle authentication result within application
        this.azureAuthService.handleAuthentication(payload) 
          .pipe(
            tap(() => {
              console.log('--> login success 2: ');
              this.router.navigate(['/home']);
            })
          )
          .subscribe();
      });
  }

}

package.json
If you are using angulars monorepo approach, make sure you place the dependencies within you project specific package.json file, otherwise, when syncing plugins (cordova and capacitor) with npx cap sync, the plugins are ignored. Which results in the error "...plugin_not_installed"

"dependencies": {
    ...
    "@capacitor/android": "3.4.1",
    "cordova-plugin-inappbrowser": "^5.0.0",
    "@awesome-cordova-plugins/in-app-browser": "^5.39.1",
    "@azure/msal-angular": "^2.0.1",
    "@azure/msal-browser": "^2.15.0",
    ...
}

I confirm that Jazjef solution works on Ionic 6 using capacitor on Android work whitout problems but on IOS need to change the code of event.url.replace to redirect to the app using capacitor://localhost/, if you let 'http://localhost/' It will try to open the url on the system browser,

async navigateExternal(url: string, options: any) {
// Cortdova implementation
if (Capacitor.isNativePlatform()) {
  var browser = this.iab.create(url, '_blank', 'location=yes,clearcache=yes,clearsessioncache=yes,hidenavigationbuttons=true,hideurlbar=true,fullscreen=true');

  browser.on('loadstart').subscribe(event => {
     if (event.url.includes('#code')) {
       browser.close();
      const domain = event.url.split('#')[0];
     const url = event.url.replace(domain, 'capacitor://localhost/home');
      window.location.href = url;
    }


  });


} else {
  if (options.noHistory) {
   // window.location.replace(url);
  } else {
   // window.location.assign(url);
  }
}
return true;

}

Related