import {EventTarget} from "event-target-shim"; // Because EventTarget() doesn't exist on Safari /** * 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'; // Importing will write it to window so we take it from there await import(keycloakSrc); if (importKeycloak._keycloakMod !== undefined) return importKeycloak._keycloakMod; importKeycloak._keycloakMod = window.Keycloak; delete window.Keycloak; return importKeycloak._keycloakMod; } const promiseTimeout = function(ms, promise) { let timeout = new Promise((resolve, reject) => { let id = setTimeout(() => { clearTimeout(id); reject('Timed out in '+ ms + 'ms.'); }, ms); }); return Promise.race([ promise, timeout ]); }; /** * Returns a URL for a relative path or URL * * @param {string} urlOrPath */ const ensureURL = function(urlOrPath) { try { return new URL(urlOrPath).href; } catch (e) { return new URL(urlOrPath, window.location.href).href; } }; /** * 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, silentCheckSsoUri, idpHint) { super(); this._baseURL = baseURL; this._realm = realm; this._clientId = clientId; this._keycloak = null; this._initDone = false; this._silentCheckSsoUri = silentCheckSsoUri; this._idpHint = idpHint; this._checkId = null; /* Minimum validity of the token in seconds */ this.MIN_VALIDITY = 20; /* Interval at which the token validity is checked, in seconds */ this.CHECK_INTERVAL = 10; /* Enables extra debug logging */ this.DEBUG = false; this._onVisibilityChanged = this._onVisibilityChanged.bind(this); document.addEventListener("visibilitychange", this._onVisibilityChanged); } /** * This needs to be called or the instance will leak; */ close() { document.removeEventListener("visibilitychange", this._onVisibilityChanged); } _onVisibilityChanged() { let isVisible = (document.visibilityState === 'visible'); if (isVisible && this._keycloak.authenticated) { this._checkTokeHasExpired(); } } _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 { // -1 means force a refresh refreshed = await this._keycloak.updateToken(-1); } catch (error) { console.log('Failed to refresh the token', error); return; } console.assert(refreshed, "token should have been refreshed"); } async _checkTokeHasExpired() { let refreshed; let minValidity = this.MIN_VALIDITY + this.CHECK_INTERVAL; if (this.DEBUG) { console.log(`Updating token if not valid for at least ${minValidity}s`); } try { refreshed = await this._keycloak.updateToken(minValidity); } catch (error) { console.log('Failed to refresh the token', error); } if (this.DEBUG && refreshed) console.log("token has been refreshed"); } async _onAuthSuccess() { // We check every once in a while if the token is still valid and // and refresh it if needed. if (this._checkId !== null) { clearInterval(this._checkId); this._checkId = null; } this._checkId = setInterval(this._checkTokeHasExpired.bind(this), this.CHECK_INTERVAL * 1000); this._onChanged(); } async _onAuthLogout() { if (this._checkId !== null) { clearInterval(this._checkId); this._checkId = null; } this._onChanged(); } async _ensureInstance() { if (this._keycloak !== null) return; const Keycloak = await importKeycloak(this._baseURL); this._keycloak = 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._onAuthLogout.bind(this); this._keycloak.onAuthSuccess = this._onAuthSuccess.bind(this); this._keycloak.onAuthError = this._onChanged.bind(this); this._keycloak.onReady = this._onReady.bind(this); } async _keycloakInit(options) { // https://gitlab.tugraz.at/dbp/apps/library/issues/41 // retry the keycloak init in case it fails, maybe it helps :/ try { return await this._keycloak.init(options); } catch (e) { return await this._keycloak.init(options); } } async _ensureInit() { await this._ensureInstance(); if (this._initDone) return; this._initDone = true; const options = { promiseType: 'native', pkceMethod: 'S256', }; if (this.DEBUG) { options['enableLogging'] = true; } if (this._silentCheckSsoUri) { options['onLoad'] = 'check-sso'; options['silentCheckSsoRedirectUri'] = ensureURL(this._silentCheckSsoUri); // When silent-sso-check is active but the iframe doesn't load/work we will // never return here, so add a timeout and emit a signal so the app can continue await promiseTimeout(5000, this._keycloakInit(options)).catch(() => { console.log('Login timed out'); this._onChanged(); }); } else { await this._keycloakInit(options); } } /** * If this returns true you need to call login() at one point to finish the login process. */ isLoggingIn() { const href = window.location.href; return (href.search('[&#]state=') >= 0 && href.search('[&#]session_state=') >= 0); } /** * Logs the user in. Might lead to a site refresh or the user needing to authenticate. * * @param {object} options * @param {string} [options.lang] - The locale to use on the keycloak login page */ async login(options) { await this._ensureInit(); options = options || {}; const language = options['lang'] || 'en'; const scope = options['scope'] || ''; if (!this._keycloak.authenticated) { await this._keycloak.login({ kcLocale: language, // Keycloak < 9.0 locale: language, scope: scope, idpHint: this._idpHint, }); } } /** * Logs the user in if it is possible without leaving the page or the user needing to authenticate again. */ async tryLogin() { await this._ensureInit(); } /** * Logs the user out locally, but not with keycloak. Login will instantly log the user back in without * requiring a re-auth. */ async localLogout() { this._keycloak.clearToken(); } /** * Log the user out from keycloak. */ async logout() { await this._ensureInit(); this._keycloak.logout(); } }