ch4mp
ch4mp

Reputation: 12925

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:

Capacitor platform: Android

Where I am:

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 :/

Upvotes: 0

Views: 2894

Answers (1)

ch4mp
ch4mp

Reputation: 12925

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',
  },
};

Upvotes: 1

Related Questions