6

I have an Ionic app that needs to authenticate in Azure, so I installed MSAL following this tutorial: https://learn.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

Danstan
  • 1,501
  • 1
  • 13
  • 20
Carlos
  • 1,638
  • 5
  • 21
  • 39

3 Answers3

6

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",
    ...
}
Jazjef
  • 461
  • 3
  • 10
  • 1
    I will take a look deeper as soon as I can. I was not able to make it work, but I will try again with your code. Thank you a lot – Carlos Feb 16 '22 at 13:26
  • How do you configure your redirection URI in Azure Portal? As a single page app or as a mobile app? Do you set it as "http://localhost:8100"? – Carlos Mar 26 '22 at 15:27
  • 1
    I registered the app as Single Page App and added localhost:8001 aswell as localhost:4200 as return URLs. – Jazjef Mar 30 '22 at 13:51
  • Thanks. That is working. That answer should me mark as correct . – Rizwan Ansar Sep 10 '22 at 17:31
4

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();
      }
    });

  • 1
    Your answer could be improved by adding more information on what the code does and how it helps the OP. – Tyler2P Dec 13 '21 at 18:08
  • Hi @Paolo, I will try your proposed solution as soon as I can. But there is one point I do not see clear: what is `http://localhost/msal-login`? Does it work in production in an android device with this URL? Thank you – Carlos Dec 14 '21 at 11:54
  • 1
    `http://localhost` is the root url of your angular application served inside the android device, it will work in production because with cordova you don't fetch any remote path but instead you use local paths, `/msal-login` is a workaround to be sure that the angular router perceive the route change and re-initialize app.component – Paolo Cuscela Dec 14 '21 at 13:23
  • @PaoloCuscela is it an IONIC app? It seems like you are running an angular app (not Ionic). I now it is possible to make it work in an angular app, but the problem appears when you run the IONIC app in a real device. – Carlos Dec 14 '21 at 18:14
  • Yes, It's a Ionic cordova app, the part in the if(window.hasOwnProperty("cordova")) clause is the Ionic implementation, if you're using capacitor it may differ a little – Paolo Cuscela Dec 18 '21 at 11:08
  • Thanks for your answer! We successfully implemented this for capacitor. – Jazjef Feb 12 '22 at 23:50
  • Has anyone actually managed to get this working on iOS? Android is just fine but we're stuck on iOS, can't log in at all. If interested you can check this issue: https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/4910 – Zarko Jun 20 '22 at 08:19
2

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;

}