androidcordovaoauth-2.0keycloakionic4

Keycloak for ionic app : keycloak-js with cordova-native does not work


I am trying to use the Keycloak-js(from 4.4.0.Final) library in my ionic(4) cordova application. I have followed the example and instructions from the documentation. I have installed cordova-plugin-browsertab, cordova-plugin-deeplinks, cordova-plugin-inappbrowser. Added <preference name="AndroidLaunchMode" value="singleTask" /> in my config.xml And this is how my modifications to config.xml looks like.

<widget id="org.phidatalab.radar_armt"....>

<plugin name="cordova-plugin-browsertab" spec="0.2.0" />
<plugin name="cordova-plugin-inappbrowser" spec="3.0.0" />
<plugin name="cordova-plugin-deeplinks" spec="1.1.0" />
<preference name="AndroidLaunchMode" value="singleTask" />
<allow-intent href="http://*/*" />
<allow-intent href="https://*/*" />
<universal-links>
    <host name="keycloak-cordova-example.exampledomain.net" scheme="https">
        <path event="keycloak" url="/login" />
    </host>
</universal-links>
</widget>

and my service which uses Keycloak-js looks like below.

static init(): Promise<any> {
  // Create a new Keycloak Client Instance
  let keycloakAuth: any = new Keycloak({
      url: 'https://exampledomain.net/auth/',
      realm: 'mighealth',
      clientId: 'armt',

  });

    return new Promise((resolve, reject) => {
      keycloakAuth.init({
          onLoad: 'login-required',
          adapter: 'cordova-native',
          responseMode: 'query',
          redirectUri: 'android-app://org.phidatalab.radar_armt/https/keycloak-cordova-example.github.io/login'
      }).success(() => {

          console.log("Success")
          resolve();
        }).error((err) => {
          reject(err);
        });
    });
  }

I can successfully build and run the application for Android. However, it doesn't work. From adb logs I get ( For both cordova and cordova-native adapters)

12-04 19:07:35.911 32578-32578/org.phidatalab.radar_armt D/SystemWebChromeClient: ng:///AuthModule/EnrolmentPageComponent.ngfactory.js: Line 457 : ERROR
12-04 19:07:35.911 32578-32578/org.phidatalab.radar_armt I/chromium: [INFO:CONSOLE(457)] "ERROR", source: ng:///AuthModule/EnrolmentPageComponent.ngfactory.js (457)
12-04 19:07:35.918 32578-32578/org.phidatalab.radar_armt D/SystemWebChromeClient: ng:///AuthModule/EnrolmentPageComponent.ngfactory.js: Line 457 : ERROR CONTEXT
12-04 19:07:35.919 32578-32578/org.phidatalab.radar_armt I/chromium: [INFO:CONSOLE(457)] "ERROR CONTEXT", source: ng:///AuthModule/EnrolmentPageComponent.ngfactory.js (457)

If I try to run it on browser, I get "universalLink is undefined".

I would really like some help to get this working. What am I missing? Any kind of help is much appreciated. Or is there a workaround/examples to get keycloak working for an ionic (public) client?


Solution

  • I am posting my solution here, since I wasted a lot of time getting available plugin working for my environments. The implementation provided by keycloak-js is fairly outdated. So if you try to use it for an ionic-3 app, it just doesn't work.

    My solution to get this working is using InAppBrowser plugin (similar to cordova approach of keycloak-js) and follow standard Oauth2 authorization_code procedure. I had a look into the code of keycloak-js and implemented solution based on it. Thanks to keycloak-js too.

    Here it is. Step1: Install [cordova-inapp-browser][1].

    Step2: A sample keycloak-auth.service.ts could look like below. This could potentially replace keycloak-js, but only for cordova option.

    import 'rxjs/add/operator/toPromise'
    
    import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'
    import {Injectable} from '@angular/core'
    import {JwtHelperService} from '@auth0/angular-jwt'
    import {StorageService} from '../../../core/services/storage.service'
    import {StorageKeys} from '../../../shared/enums/storage'
    import {InAppBrowser, InAppBrowserOptions} from '@ionic-native/in-app-browser';
    
    
    const uuidv4 = require('uuid/v4');
    
    @Injectable()
    export class AuthService {
      URI_base: 'https://my-server-location/auth';
      keycloakConfig: any;
    
      constructor(
        public http: HttpClient,
        public storage: StorageService,
        private jwtHelper: JwtHelperService,
        private inAppBrowser: InAppBrowser,
      ) {
          this.keycloakConfig = {
            authServerUrl: 'https://my-server-location/auth/', //keycloak-url
            realm: 'myrealmmName', //realm-id
            clientId: 'clientId', // client-id
            redirectUri: 'http://my-demo-app/callback/',  //callback-url registered for client.
                                                          // This can be anything, but should be a valid URL
          };
      }
    
      public keycloakLogin(login: boolean): Promise<any> {
        return new Promise((resolve, reject) => {
          const url = this.createLoginUrl(this.keycloakConfig, login);
    
          const options: InAppBrowserOptions = {
            zoom: 'no',
            location: 'no',
            clearsessioncache: 'yes',
            clearcache: 'yes'
          }
          const browser = this.inAppBrowser.create(url, '_blank', options);
    
          const listener = browser.on('loadstart').subscribe((event: any) => {
            const callback = encodeURI(event.url);
            //Check the redirect uri
            if (callback.indexOf(this.keycloakConfig.redirectUri) > -1) {
              listener.unsubscribe();
              browser.close();
              const code = this.parseUrlParamsToObject(event.url);
              this.getAccessToken(this.keycloakConfig, code).then(
                () => {
                  const token = this.storage.get(StorageKeys.OAUTH_TOKENS);
                  resolve(token);
                },
                () => reject("Count not login in to keycloak")
              );
            }
          });
    
        });
      }
    
      parseUrlParamsToObject(url: any) {
        const hashes = url.slice(url.indexOf('?') + 1).split('&');
        return hashes.reduce((params, hash) => {
          const [key, val] = hash.split('=');
          return Object.assign(params, {[key]: decodeURIComponent(val)})
        }, {});
      }
    
      createLoginUrl(keycloakConfig: any, isLogin: boolean) {
        const state = uuidv4();
        const nonce = uuidv4();
        const responseMode = 'query';
        const responseType = 'code';
        const scope = 'openid';
        return this.getUrlForAction(keycloakConfig, isLogin) +
          '?client_id=' + encodeURIComponent(keycloakConfig.clientId) +
          '&state=' + encodeURIComponent(state) +
          '&redirect_uri=' + encodeURIComponent(keycloakConfig.redirectUri) +
          '&response_mode=' + encodeURIComponent(responseMode) +
          '&response_type=' + encodeURIComponent(responseType) +
          '&scope=' + encodeURIComponent(scope) +
          '&nonce=' + encodeURIComponent(nonce);
      }
    
      getUrlForAction(keycloakConfig: any, isLogin: boolean) {
        return isLogin ? this.getRealmUrl(keycloakConfig) + '/protocol/openid-connect/auth'
          : this.getRealmUrl(keycloakConfig) + '/protocol/openid-connect/registrations';
      }
    
      loadUserInfo() {
        return this.storage.get(StorageKeys.OAUTH_TOKENS).then( tokens => {
          const url = this.getRealmUrl(this.keycloakConfig) + '/protocol/openid-connect/userinfo';
          const headers = this.getAccessHeaders(tokens.access_token, 'application/json');
          return this.http.get(url, {headers: headers}).toPromise();
        })
      }
    
      getAccessToken(kc: any, authorizationResponse: any) {
        const URI = this.getTokenUrl();
        const body = this.getAccessTokenParams(authorizationResponse.code, kc.clientId, kc.redirectUri);
        const headers = this.getTokenRequestHeaders();
    
        return this.createPostRequest(URI,  body, {
          header: headers,
        }).then((newTokens: any) => {
          newTokens.iat = (new Date().getTime() / 1000) - 10; // reduce 10 sec to for delay
          this.storage.set(StorageKeys.OAUTH_TOKENS, newTokens);
        });
      }
    
      refresh() {
        return this.storage.get(StorageKeys.OAUTH_TOKENS)
          .then(tokens => {
            const decoded = this.jwtHelper.decodeToken(tokens.access_token)
            if (decoded.iat + tokens.expires_in < (new Date().getTime() /1000)) {
              const URI = this.getTokenUrl();
              const headers = this.getTokenRequestHeaders();
              const body = this.getRefreshParams(tokens.refresh_token, this.keycloakConfig.clientId);
              return this.createPostRequest(URI, body, {
                headers: headers
              })
            } else {
              return tokens
            }
          })
          .then(newTokens => {
            newTokens.iat = (new Date().getTime() / 1000) - 10;
            return this.storage.set(StorageKeys.OAUTH_TOKENS, newTokens)
          })
          .catch((reason) => console.log(reason))
      }
    
      createPostRequest(uri, body, headers) {
        return this.http.post(uri, body, headers).toPromise()
      }
    
      getAccessHeaders(accessToken, contentType) {
        return new HttpHeaders()
          .set('Authorization', 'Bearer ' + accessToken)
          .set('Content-Type', contentType);
      }
    
      getRefreshParams(refreshToken, clientId) {
        return new HttpParams()
          .set('grant_type', 'refresh_token')
          .set('refresh_token', refreshToken)
          .set('client_id', encodeURIComponent(clientId))
      }
    
      getAccessTokenParams(code , clientId, redirectUrl) {
        return new HttpParams()
          .set('grant_type', 'authorization_code')
          .set('code', code)
          .set('client_id', encodeURIComponent(clientId))
          .set('redirect_uri', redirectUrl);
      }
    
      getTokenUrl() {
        return this.getRealmUrl(this.keycloakConfig) + '/protocol/openid-connect/token';
      }
    
      getTokenRequestHeaders() {
        const headers = new HttpHeaders()
          .set('Content-Type', 'application/x-www-form-urlencoded');
    
        const clientSecret = (this.keycloakConfig.credentials || {}).secret;
        if (this.keycloakConfig.clientId && clientSecret) {
          headers.set('Authorization', 'Basic ' + btoa(this.keycloakConfig.clientId + ':' + clientSecret));
        }
        return headers;
      }
    
      getRealmUrl(kc: any) {
        if (kc && kc.authServerUrl) {
          if (kc.authServerUrl.charAt(kc.authServerUrl.length - 1) == '/') {
            return kc.authServerUrl + 'realms/' + encodeURIComponent(kc.realm);
          } else {
            return kc.authServerUrl + '/realms/' + encodeURIComponent(kc.realm);
          }
        } else {
          return undefined;
        }
      }
    }
    
    

    Step 3: Then you can use this service in your components to what is necessary.

    @Component({
      selector: 'page-enrolment',
      templateUrl: 'enrolment-page.component.html'
    })
    export class EnrolmentPageComponent {
    constructor(
        public storage: StorageService,
        private authService: AuthService,
      ) {}
      goToRegistration() {
        this.loading = true;
        this.authService.keycloakLogin(false)
          .then(() => {
            return this.authService.retrieveUserInformation(this.language)
          });
      }
    }
    

    Note: keycloakLogin(true) takes you to login page or keycloakLogin(false) takes you to registration page of keycloak.

    I hope this helps you solve it more or less.