diff --git a/packages/auth/src/keycloak.js b/packages/auth/src/keycloak.js new file mode 100644 index 0000000000000000000000000000000000000000..ce2eb876f72dbbb0d6c7420fc812f0093acafcf6 --- /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 1178c2e2d11a25487a91e1224cd90df30437082d..e0e099f9f3664b1445b7a4010080e0ee416f5150 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> `; }