From 639a84928dac67c9846cc52ce4ed3c1441815456 Mon Sep 17 00:00:00 2001
From: Christoph Reiter <reiter.christoph@gmail.com>
Date: Tue, 19 Nov 2019 13:34:38 +0100
Subject: [PATCH] Refactor keycloak logic

Move the keycloak code into its own class and try to abstract away as much as possible.

We now also react to all keycloak related events so logout, re-login etc are handled as well.
---
 packages/auth/src/keycloak.js | 136 ++++++++++++++++++
 packages/auth/src/vpu-auth.js | 261 +++++++++++++---------------------
 2 files changed, 232 insertions(+), 165 deletions(-)
 create mode 100644 packages/auth/src/keycloak.js

diff --git a/packages/auth/src/keycloak.js b/packages/auth/src/keycloak.js
new file mode 100644
index 00000000..ce2eb876
--- /dev/null
+++ b/packages/auth/src/keycloak.js
@@ -0,0 +1,136 @@
+/**
+ * Imports the keycloak JS API as if it was a module.
+ *
+ * @param baseUrl {string}
+ */
+async function importKeycloak(baseUrl) {
+    const keycloakSrc = baseUrl + '/js/keycloak.js';
+    // Importing will write it to window so we take it from there
+    await import(keycloakSrc);
+    if (importKeycloak._keycloakMod !== undefined)
+        return importKeycloak._keycloakMod;
+    importKeycloak._keycloakMod = {Keycloak: window.Keycloak};
+    delete window.Keycloak;
+    return importKeycloak._keycloakMod;
+}
+
+
+async function kcMakeAsync(promise) {
+    // the native keycloak promise implementation is broken, wrap it instead
+    // https://stackoverflow.com/questions/58436689/react-keycloak-typeerror-kc-updatetoken-success-is-not-a-function
+    return new Promise(function(resolve, reject) {
+        promise.success((...args) => { resolve(...args); }).error((...args) => { reject(...args)});
+    });
+}
+
+
+/**
+ * Wraps the keycloak API to support async/await, adds auto token refreshing and consolidates all
+ * events into one native "changed" event
+ * 
+ * The "changed" event has the real keycloak instance as "detail"
+ */
+export class KeycloakWrapper extends EventTarget {
+
+    constructor(baseURL, realm, clientId) {
+        super();
+
+        this._baseURL = baseURL;
+        this._realm = realm;
+        this._clientId = clientId;
+        this._keycloak = null;
+        this._initDone = false;
+    }
+
+    _onChanged() {
+        const event = new CustomEvent("changed", {
+            detail: this._keycloak,
+            bubbles: true,
+            composed: true
+        });
+        this.dispatchEvent(event);
+    }
+
+    _onReady(authenticated) {
+        // Avoid emitting changed when nothing has changed on init()
+        if (authenticated)
+            this._onChanged();
+    }
+
+    async _onTokenExpired() {
+        console.log('Token has expired');
+        let refreshed = false;
+
+        try {
+            refreshed = await kcMakeAsync(that._keycloak.updateToken(5));
+        } catch (error) {
+            console.log('Failed to refresh the token', error);
+            return;
+        }
+
+        if (refreshed) {
+            console.log('Token was successfully refreshed');
+        } else {
+            console.log('Token is still valid');
+        }
+    }
+
+    async _ensureInstance() {
+        if (this._keycloak !== null)
+            return;
+
+        const module = await importKeycloak(this._baseURL)
+
+        this._keycloak = module.Keycloak({
+            url: this._baseURL,
+            realm: this._realm,
+            clientId: this._clientId,
+        });
+
+        this._keycloak.onTokenExpired = this._onTokenExpired.bind(this);
+        this._keycloak.onAuthRefreshSuccess = this._onChanged.bind(this);
+        this._keycloak.onAuthRefreshError = this._onChanged.bind(this);
+        this._keycloak.onAuthLogout = this._onChanged.bind(this);
+        this._keycloak.onAuthSuccess = this._onChanged.bind(this);
+        this._keycloak.onAuthError = this._onChanged.bind(this);
+        this._keycloak.onReady = this._onReady.bind(this);
+    }
+
+    async _ensureInit() {
+        await this._ensureInstance();
+        if (this._initDone)
+            return;
+        this._initDone = true;
+        await kcMakeAsync(this._keycloak.init());
+    }
+
+    /**
+     * Returns true in case we just got redirected from the login page
+     */
+    isLoggingIn() {
+        const href = window.location.href;
+        return (href.search('[&#]state=') >= 0 && href.search('[&#]session_state=') >= 0);
+    }
+
+    async login(options) {
+        await this._ensureInit();
+
+        options = options || {};
+        const language = options['lang'] || 'en';
+
+        if (!this._keycloak.authenticated) {
+            await kcMakeAsync(this._keycloak.login({
+                kcLocale: language,
+            }));
+        }
+    }
+
+    async clearToken() {
+        this._keycloak.clearToken();
+    }
+
+    async logout() {
+        await this._ensureInit();
+        this._keycloak.logout();
+    }
+}
\ No newline at end of file
diff --git a/packages/auth/src/vpu-auth.js b/packages/auth/src/vpu-auth.js
index 1178c2e2..e0e099f9 100644
--- a/packages/auth/src/vpu-auth.js
+++ b/packages/auth/src/vpu-auth.js
@@ -7,6 +7,7 @@ import * as commonStyles from 'vpu-common/styles';
 import * as events from 'vpu-common/events.js';
 import 'vpu-common/vpu-icon.js';
 import VPULitElement from 'vpu-common/vpu-lit-element';
+import  {KeycloakWrapper} from './keycloak.js';
 
 
 const LoginStatus = Object.freeze({
@@ -17,23 +18,6 @@ const LoginStatus = Object.freeze({
     LOGGED_OUT: 'logged-out',
 });
 
-
-/**
- * Imports the keycloak JS API as if it was a module.
- *
- * @param baseUrl {string}
- */
-async function importKeycloak(baseUrl) {
-    const keycloakSrc = baseUrl + '/js/keycloak.min.js';
-    await import(keycloakSrc);
-    if (importKeycloak._keycloakMod !== undefined)
-        return importKeycloak._keycloakMod;
-    importKeycloak._keycloakMod = {Keycloak: window.Keycloak};
-    delete window.Keycloak;
-    return importKeycloak._keycloakMod;
-}
-
-
 /**
  * Keycloak auth web component
  * https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter
@@ -53,13 +37,10 @@ class VPUAuth extends VPULitElement {
         this.forceLogin = false;
         this.loadPerson = false;
         this.clientId = "";
-        this.keyCloakInitCalled = false;
-        this._keycloak = null;
         this.token = "";
         this.subject = "";
         this.name = "";
         this.personId = "";
-        this.loggedIn = false;
         this.rememberLogin = false;
         this.person = null;
 
@@ -83,11 +64,74 @@ class VPUAuth extends VPULitElement {
         this.keycloakDataUpdateEvent = new CustomEvent("vpu-auth-keycloak-data-update", { "detail": "KeyCloak data was updated", bubbles: true, composed: true });
 
         this.closeDropdown = this.closeDropdown.bind(this);
+        this._onKCChanged = this._onKCChanged.bind(this)
+   }
+
+    _onKCChanged(event) {
+        const kc = event.detail;
+        let newPerson = false;
+
+        if (kc.authenticated) {
+            this.name = kc.idTokenParsed.name;
+            this.token = kc.token;
+            this.subject = kc.subject;
+            const personId = kc.idTokenParsed.preferred_username;
+            if (personId !== this.personId) {
+                this.person = null;
+                newPerson = true;
+            }
+            this.personId = personId;
+            this._setLoginStatus(LoginStatus.LOGGED_IN);
+        } else {
+            this.name = "";
+            this.token = "";
+            this.subject = "";
+            this.personId = "";
+            this.person = null;
+            this._setLoginStatus(LoginStatus.LOGGED_OUT);
+        }
+
+        window.VPUAuthSubject = this.subject;
+        window.VPUAuthToken = this.token;
+        window.VPUUserFullName = this.name;
+        window.VPUPersonId = this.personId;
+        window.VPUPerson = this.person;
+
+        const that = this;
+
+        if (newPerson) {
+            this.dispatchEvent(this.initEvent);
+        }
+
+        if (newPerson && this.loadPerson) {
+            JSONLD.initialize(commonUtils.getAPiUrl(), (jsonld) => {
+                // find the correct api url for the current person
+                // we are fetching the logged-in person directly to respect the REST philosophy
+                // see: https://github.com/api-platform/api-platform/issues/337
+                const apiUrl = jsonld.getApiUrlForEntityName("Person") + '/' + that.personId;
+
+                fetch(apiUrl, {
+                    headers: {
+                        'Content-Type': 'application/ld+json',
+                        'Authorization': 'Bearer ' + that.token,
+                    },
+                })
+                .then(response => response.json())
+                .then((person) => {
+                    that.person = person;
+                    window.VPUPerson = person;
+                    that.dispatchEvent(that.personInitEvent);
+                });
+            }, {}, that.lang);
+        }
+
+        this.dispatchEvent(this.keycloakDataUpdateEvent);
     }
 
     _setLoginStatus(status, force) {
         if (this._loginStatus === status && !force)
             return;
+
         this._loginStatus = status;
         this._emitter.emit();
     }
@@ -100,38 +144,42 @@ class VPUAuth extends VPULitElement {
             lang: { type: String },
             forceLogin: { type: Boolean, attribute: 'force-login' },
             rememberLogin: { type: Boolean, attribute: 'remember-login' },
-            loggedIn: { type: Boolean},
             loadPerson: { type: Boolean, attribute: 'load-person' },
             clientId: { type: String, attribute: 'client-id' },
             name: { type: String, attribute: false },
             token: { type: String, attribute: false },
             subject: { type: String, attribute: false },
             personId: { type: String, attribute: false },
-            keycloak: { type: Object, attribute: false },
             person: { type: Object, attribute: false },
+            _loginStatus: { type: String, attribute: false },
         };
     }
 
     connectedCallback() {
         super.connectedCallback();
-        const href = window.location.href;
 
-        if (this.rememberLogin && sessionStorage.getItem('vpu-logged-in')) {
-            this.forceLogin = true;
+        const baseURL = commonUtils.setting('keyCloakBaseURL');
+        const realm = commonUtils.setting('keyCloakRealm');
+        this._kcwrapper = new KeycloakWrapper(baseURL, realm, this.clientId);
+        this._kcwrapper.addEventListener('changed', this._onKCChanged);
+
+
+        let doLogin = false;
+        if ((this.rememberLogin && sessionStorage.getItem('vpu-logged-in')) || this.forceLogin) {
+            doLogin = true;
         }
 
         // load Keycloak if we want to force the login or if we were redirected from the Keycloak login page
-        if (this.forceLogin || (href.search('[&#]state=') >= 0 && href.search('[&#]session_state=') >= 0)) {
-            this.loadKeycloak();
+        if (doLogin || this._kcwrapper.isLoggingIn()) {
+            this._setLoginStatus(LoginStatus.LOGGING_IN);
+            this._kcwrapper.login()
         } else {
             this._setLoginStatus(LoginStatus.LOGGED_OUT);
         }
 
-        const that = this;
-
-        this.updateComplete.then(()=>{
+        this.updateComplete.then(() => {
             window.onresize = () => {
-                that.updateDropdownWidth();
+                this.updateDropdownWidth();
             };
         });
 
@@ -153,141 +201,18 @@ class VPUAuth extends VPULitElement {
     }
 
     disconnectedCallback() {
+        this._kcwrapper.removeEventListener('changed', this._onKCChanged);
         document.removeEventListener('click', this.closeDropdown);
         super.disconnectedCallback();
     }
 
-    loadKeycloak() {
-        const that = this;
-        const baseURL = commonUtils.setting('keyCloakBaseURL');
-        const realm = commonUtils.setting('keyCloakRealm');
-
-        if (!this.keyCloakInitCalled) {
-            importKeycloak(baseURL).then((module) => {
-                that.keyCloakInitCalled = true;
-
-                that._keycloak = module.Keycloak({
-                    url: baseURL,
-                    realm: realm,
-                    clientId: that.clientId,
-                });
-
-                this._setLoginStatus(LoginStatus.LOGGING_IN);
-
-                // See: https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter
-                that._keycloak.init().success((authenticated) => {
-
-
-                    if (!authenticated) {
-                        // set locale of Keycloak login page
-                        that._keycloak.login({kcLocale: that.lang});
-
-                        return;
-                    }
-
-                    that.loggedIn = false;
-                    that.updateKeycloakData();
-                    that.dispatchInitEvent();
-
-                    if (that.loadPerson) {
-                        JSONLD.initialize(commonUtils.getAPiUrl(), (jsonld) => {
-                            // find the correct api url for the current person
-                            // we are fetching the logged-in person directly to respect the REST philosophy
-                            // see: https://github.com/api-platform/api-platform/issues/337
-                            const apiUrl = jsonld.getApiUrlForEntityName("Person") + '/' + that.personId;
-
-                            fetch(apiUrl, {
-                                headers: {
-                                    'Content-Type': 'application/ld+json',
-                                    'Authorization': 'Bearer ' + that.token,
-                                },
-                            })
-                            .then(response => response.json())
-                            .then((person) => {
-                                that.person = person;
-                                window.VPUPerson = person;
-                                that.dispatchPersonInitEvent();
-                            });
-                        }, {}, that.lang);
-                    }
-
-                }).error(() => {
-                    this._setLoginStatus(LoginStatus.LOGGED_OUT);
-                    console.error('Keycloak failed to initialize!');
-                });
-
-                // auto-refresh token
-                that._keycloak.onTokenExpired = function() {
-                    that._keycloak.updateToken(5).success(function(refreshed) {
-                        if (refreshed) {
-                            console.log('Token was successfully refreshed');
-                            that.updateKeycloakData();
-                        } else {
-                            console.log('Token is still valid');
-                        }
-                    }).error(function() {
-                        console.log('Failed to refresh the token, or the session has expired');
-                    });
-                };
-            }).catch((e) => {
-                console.log('Loading keycloack failed', e);
-            });
-        }
-    }
-
-    login(e) {
-        this.loadKeycloak();
+    onLoginClicked(e) {
+        this._kcwrapper.login();
     }
 
-    logout(e) {
+    onLogoutClicked(e) {
         this._setLoginStatus(LoginStatus.LOGGING_OUT);
-        sessionStorage.removeItem('vpu-logged-in');
-        this._keycloak.logout();
-    }
-
-    /**
-     * Dispatches the init event
-     */
-    dispatchInitEvent() {
-        this.loggedIn = true;
-        this.dispatchEvent(this.initEvent);
-    }
-
-    /**
-     * Dispatches the person init event
-     */
-    dispatchPersonInitEvent() {
-        this.dispatchEvent(this.personInitEvent);
-    }
-
-    /**
-     * Dispatches the profile event
-     */
-    dispatchProfileEvent() {
-        this.dispatchEvent(this.profileEvent);
-    }
-
-    /**
-     * Dispatches the keycloak data update event
-     */
-    dispatchKeycloakDataUpdateEvent() {
-        this.dispatchEvent(this.keycloakDataUpdateEvent);
-    }
-
-    updateKeycloakData() {
-        this.name = this._keycloak.idTokenParsed.name;
-        this.token = this._keycloak.token;
-        this.subject = this._keycloak.subject;
-        this.personId = this._keycloak.idTokenParsed.preferred_username;
-
-        window.VPUAuthSubject = this.subject;
-        window.VPUAuthToken = this.token;
-        window.VPUUserFullName = this.name;
-        window.VPUPersonId = this.personId;
-
-        this.dispatchKeycloakDataUpdateEvent();
-
-        this._setLoginStatus(LoginStatus.LOGGED_IN, true);
+        this._kcwrapper.logout();
     }
 
     update(changedProperties) {
@@ -295,8 +220,8 @@ class VPUAuth extends VPULitElement {
             if (propName === "lang") {
                 i18n.changeLanguage(this.lang);
             }
-            if (propName == "loggedIn") {
-                if (this.loggedIn)
+            if (propName == "_loginStatus") {
+                if (this._loginStatus === LoginStatus.LOGGED_IN)
                     sessionStorage.setItem('vpu-logged-in', true);
                 else
                     sessionStorage.removeItem('vpu-logged-in');
@@ -438,6 +363,11 @@ class VPUAuth extends VPULitElement {
         });
     }
 
+    onProfileClicked(event) {
+        event.preventDefault();
+        this.dispatchEvent(this.profileEvent);
+    }
+
     renderLoggedIn() {
         const imageURL = (this.person && this.person.image) ? this.person.image : null;
 
@@ -451,8 +381,8 @@ class VPUAuth extends VPULitElement {
                     <div class="dropdown-content">
                         ${imageURL ? html`<img alt="" src="${imageURL}" class="dropdown-item">` : ''}
                         <div class="menu">
-                            <a href="#" @click="${(e) => {e.preventDefault(); this.dispatchProfileEvent();}}" class="dropdown-item">${i18n.t('profile')}</a>
-                            <a href="#" @click="${this.logout}" class="dropdown-item">${i18n.t('logout')}</a>
+                            <a href="#" @click="${this.onProfileClicked}" class="dropdown-item">${i18n.t('profile')}</a>
+                            <a href="#" @click="${this.onLogoutClicked}" class="dropdown-item">${i18n.t('logout')}</a>
                         </div>
                     </div>
                 </div>
@@ -482,7 +412,7 @@ class VPUAuth extends VPULitElement {
         `;
 
         return html`
-            <div class="loginbox" @click="${this.login}">
+            <div class="loginbox" @click="${this.onLoginClicked}">
                 <div class="icon">${unsafeHTML(loginSVG)}</div>
                 <div class="label">${i18n.t('login')}</div>
             </div>
@@ -491,9 +421,10 @@ class VPUAuth extends VPULitElement {
 
     render() {
         commonUtils.initAssetBaseURL('vpu-auth-src');
+        const loggedIn = (this._loginStatus === LoginStatus.LOGGED_IN);
         return html`
             <div class="authbox">
-                ${this.loggedIn ? this.renderLoggedIn() : this.renderLoggedOut()}
+                ${loggedIn ? this.renderLoggedIn() : this.renderLoggedOut()}
             </div>
         `;
     }
-- 
GitLab