Search code examples
angularionic-frameworkionic-nativeangular-auth-oidc-client

angular-auth-oidc-client in a Ionic Android application


I'm trying to use angular-auth-oidc-client in an Android Ionic-Angular app authenticating against MS Identity server.

Versions:

  • angular-auth-oidc-client 11.1.4
  • @angular 10.0.2
  • @ionic/angular 5.2.3

Capacitor platform: Android

Where I am:

  • Authentication is successful when running plain web app (from desktop browser)
  • An intentent filter is declared in android manifest and the app correctly opens when authorization-server redirects to my-app://login-callback (real Android device).
  • Using Deeplinks plugin, I can intercept calls to the login callback and can read the query-string containing code, scope, state and session_state params.

What to do next? The authentication remains false. What should I call with the callback queryString?

I found this CallBackService which seems to match my need but is unfortunately not part of the lib public API :/


Solution

  • Please note this solution works with refresh-token only (set useRefreshToken: true in conf). I couldn't get it work properly using silentRenewUrl (yet?)

    First, the AppComponent:

    export class AppComponent implements OnInit, OnDestroy {
      currentUser: KeycloakUser;
    
      private deeplinksRouteSubscription: Subscription;
    
      constructor(
        private deeplinks: Deeplinks,
        private navController: NavController,
        private platform: Platform,
        private uaa: UaaService,
        private changedetector: ChangeDetectorRef
      ) {}
    
      async ngOnInit() {
        await this.platform.ready();
        console.log('PLATFORMS: ' + this.platform.platforms());
    
        if (this.platform.is('capacitor')) {
          this.setupDeeplinks();
          const { SplashScreen, StatusBar } = Plugins;
          StatusBar.setStyle({ style: StatusBarStyle.Light });
          SplashScreen.hide();
        }
    
        await this.initUaa();
      }
    
      ngOnDestroy() {
        this.deeplinksRouteSubscription.unsubscribe();
      }
    
      login() {
        this.uaa.login();
      }
    
      logout() {
        this.uaa.logout();
      }
    
      private setupDeeplinks() {
        this.deeplinks.routeWithNavController(this.navController, {}).subscribe(
          (match) =>
            this.navController
              .navigateForward(match.$link.path + '?' + match.$link.queryString)
              .then(async () => await this.initUaa()),
          (nomatch) =>
            console.error(
              "Got a deeplink that didn't match",
              JSON.stringify(nomatch)
            )
        );
      }
    
      private async initUaa(): Promise<void> {
        await this.uaa.init();
    
        this.uaa.currentUser$.subscribe((u) => {
          if (this.currentUser !== u) {
            this.currentUser = u;
            this.changedetector.detectChanges();
          }
        });
      }
    }
    

    Now, the UAA service I use to turn Keycloak ID tokens into user objects. Actual initialisation occurs in onBackOnline():

    import { Injectable, OnDestroy } from '@angular/core';
    import { OidcSecurityService } from 'angular-auth-oidc-client';
    import {
      BehaviorSubject,
      fromEvent,
      merge,
      Observable,
      Subscription,
    } from 'rxjs';
    import { map } from 'rxjs/operators';
    import { KeycloakUser } from './domain/keycloak-user';
    
    @Injectable({ providedIn: 'root' })
    export class UaaService implements OnDestroy {
      private user$ = new BehaviorSubject<KeycloakUser>(KeycloakUser.ANONYMOUS);
      private userdataSubscription: Subscription;
    
      constructor(private oidcSecurityService: OidcSecurityService) {
        console.log(
          `Starting UaaService in ${navigator.onLine ? 'online' : 'offline'} mode`
        );
        merge<boolean>(
          fromEvent(window, 'offline').pipe(
            map((): boolean => {
              console.log('Switching UaaService to offline mode');
              return true;
            })
          ),
          fromEvent(window, 'online').pipe(
            map((): boolean => {
              console.log('Switching UaaService to online mode');
              return false;
            })
          )
        ).subscribe((isOffline: boolean) => {
          if (isOffline) {
            this.onOffline();
          } else {
            this.onBackOnline();
          }
        });
      }
    
      public ngOnDestroy() {
        this.userdataSubscription.unsubscribe();
      }
    
      public async init(): Promise<boolean> {
        if (!navigator.onLine) {
          this.user$.next(KeycloakUser.ANONYMOUS);
          return false;
        }
    
        const user = await this.onBackOnline();
        return !!user.sub;
      }
    
      private async onBackOnline(): Promise<KeycloakUser> {
        const isAlreadyAuthenticated = await this.oidcSecurityService
          .checkAuth()
          .toPromise()
          .catch(() => false);
    
        const user = UaaService.fromToken(
          this.oidcSecurityService.getPayloadFromIdToken()
        );
        console.log('UaaService::onBackOnline', isAlreadyAuthenticated, user);
    
        this.userdataSubscription?.unsubscribe();
        this.userdataSubscription = this.oidcSecurityService.isAuthenticated$.subscribe(
          () =>
            this.user$.next(
              UaaService.fromToken(this.oidcSecurityService.getPayloadFromIdToken())
            )
        );
    
        return user;
      }
    
      private static fromToken = (idToken: any) =>
        idToken?.sub
          ? new KeycloakUser({
              sub: idToken.sub,
              preferredUsername: idToken.preferred_username,
              roles: idToken?.resource_access?.['tahiti-devops']?.roles || [],
            })
          : KeycloakUser.ANONYMOUS;
    
      private onOffline() {
        this.userdataSubscription?.unsubscribe();
      }
    
      get currentUser$(): Observable<KeycloakUser> {
        return this.user$;
      }
    
      public login(): void {
        this.oidcSecurityService.authorize();
      }
    
      public logout(): boolean {
        this.oidcSecurityService.logoff();
    
        if (this.user$.value !== KeycloakUser.ANONYMOUS) {
          this.user$.next(KeycloakUser.ANONYMOUS);
          return true;
        }
    
        return false;
      }
    }
    

    And this is the conf I use (note eagerLoadAuthWellKnownEndpoints and useRefreshToken):

    import { LogLevel } from 'angular-auth-oidc-client';
    
    export const environment = {
      production: false,
      openIdConfiguration: {
        // https://github.com/damienbod/angular-auth-oidc-client/blob/master/docs/configuration.md
        clientId: 'tahiti-devops',
        forbiddenRoute: '/settings',
        eagerLoadAuthWellKnownEndpoints: false,
        ignoreNonceAfterRefresh: true, // Keycloak sends refresh_token with nonce
        logLevel: LogLevel.Warn,
        postLogoutRedirectUri: 'com.c4-soft://device/cafe-skifo',
        redirectUrl: 'com.c4-soft://device/cafe-skifo',
        renewTimeBeforeTokenExpiresInSeconds: 10,
        responseType: 'code',
        scope: 'email openid offline_access roles',
        silentRenew: true,
        // silentRenewUrl: 'com.c4soft.mobileapp://cafe-skifo/silent-renew-pkce.html',
        useRefreshToken: true,
        stsServer: 'https://laptop-jerem:8443/auth/realms/master',
        unauthorizedRoute: '/settings',
      },
    };