UNPKG

@ebondu/angular2-keycloak

Version:
1,210 lines (1,198 loc) 76.4 kB
import * as i0 from '@angular/core'; import { Injectable, NgModule, InjectionToken, inject, Injector, PLATFORM_ID, NgZone } from '@angular/core'; import { HttpClient, HttpParams, HttpHeaders, HttpErrorResponse, HttpEventType } from '@angular/common/http'; import { BehaviorSubject, Observable, of, EMPTY, throwError } from 'rxjs'; import { v4 } from 'uuid'; import { fromByteArray } from 'base64-js'; import { sha256 } from 'js-sha256'; import { filter, map, mergeMap, switchMap, tap, first, catchError, finalize } from 'rxjs/operators'; import { isPlatformBrowser } from '@angular/common'; /* * Copyright 2018 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ class AngularKeycloakService { constructor() { } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: AngularKeycloakService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: AngularKeycloakService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: AngularKeycloakService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); /* * Copyright 2018 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ class AngularKeycloakModule { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: AngularKeycloakModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "18.2.9", ngImport: i0, type: AngularKeycloakModule }); static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: AngularKeycloakModule }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: AngularKeycloakModule, decorators: [{ type: NgModule, args: [{ imports: [] }] }] }); /* * Copyright 2022 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const KEYCLOAK_JSON_PATH = new InjectionToken('keycloakJsonPath'); const KEYCLOAK_INIT_OPTIONS = new InjectionToken('keycloakOptions'); const KEYCLOAK_CONF = new InjectionToken('keycloakConfiguration'); var KeycloakAdapterName; (function (KeycloakAdapterName) { KeycloakAdapterName["CORDOVA"] = "cordova"; KeycloakAdapterName["DEFAULT"] = "default"; KeycloakAdapterName["ANY"] = "any"; })(KeycloakAdapterName || (KeycloakAdapterName = {})); var KeycloakOnLoad; (function (KeycloakOnLoad) { KeycloakOnLoad["LOGIN_REQUIRED"] = "login-required"; KeycloakOnLoad["CHECK_SSO"] = "check-sso"; })(KeycloakOnLoad || (KeycloakOnLoad = {})); var KeycloakResponseMode; (function (KeycloakResponseMode) { KeycloakResponseMode["QUERY"] = "query"; KeycloakResponseMode["FRAGMENT"] = "fragment"; })(KeycloakResponseMode || (KeycloakResponseMode = {})); var KeycloakResponseType; (function (KeycloakResponseType) { KeycloakResponseType["CODE"] = "code"; KeycloakResponseType["ID_TOKEN"] = "id_token token"; KeycloakResponseType["CODE_ID_TOKEN"] = "code id_token token"; })(KeycloakResponseType || (KeycloakResponseType = {})); var KeycloakFlow; (function (KeycloakFlow) { KeycloakFlow["STANDARD"] = "standard"; KeycloakFlow["IMPLICIT"] = "implicit"; KeycloakFlow["HYBRID"] = "hybrid"; })(KeycloakFlow || (KeycloakFlow = {})); var LogoutMethod; (function (LogoutMethod) { LogoutMethod["POST"] = "post"; LogoutMethod["GET"] = "get"; })(LogoutMethod || (LogoutMethod = {})); /* * Copyright 2018 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Default adapter for web browsers */ class DefaultAdapter { keycloak; constructor(keycloak) { this.keycloak = keycloak; } login(options) { window.location.href = this.keycloak.createLoginUrl(options); } logout(options) { window.location.href = this.keycloak.createLogoutUrl(options); } register(options) { window.location.href = this.keycloak.createRegisterUrl(options); } accountManagement() { window.location.href = this.keycloak.createAccountUrl({}); } passwordManagement() { window.location.href = this.keycloak.createChangePasswordUrl({}); } redirectUri(options, encodeHash) { if (arguments.length === 1) { encodeHash = true; } if (options && options.redirectUri) { return options.redirectUri; } else { let redirectUri = location.href; if (location.hash && encodeHash) { redirectUri = redirectUri.substring(0, location.href.indexOf('#')); redirectUri += (redirectUri.indexOf('?') === -1 ? '?' : '&') + 'redirect_fragment=' + encodeURIComponent(location.hash.substring(1)); } return redirectUri; } } } /* * Copyright 2018 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * To store Keycloak objects like tokens using a localStorage. */ class LocalStorage { clearExpired() { const time = new Date().getTime(); for (let i = 1; i <= localStorage.length; i++) { const key = localStorage.key(i); if (key && key.indexOf('kc-callback-') === 0) { const value = localStorage.getItem(key); if (value) { try { const expires = JSON.parse(value).expires; if (!expires || expires < time) { localStorage.removeItem(key); } } catch (err) { localStorage.removeItem(key); } } } } } get(state) { if (!state) { return; } const key = 'kc-callback-' + state; let value = localStorage.getItem(key); if (value) { localStorage.removeItem(key); value = JSON.parse(value); } this.clearExpired(); return value; } add(state) { this.clearExpired(); const key = 'kc-callback-' + state.state; state.expires = new Date().getTime() + (60 * 60 * 1000); localStorage.setItem(key, JSON.stringify(state)); } } /* * Copyright 2018 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * URI parser. */ class URIParser { static initialParse(uriToParse, responseMode) { let baseUri; let queryString; let fragmentString; const questionMarkIndex = uriToParse.indexOf('?'); let fragmentIndex = uriToParse.indexOf('#', questionMarkIndex + 1); if (questionMarkIndex === -1 && fragmentIndex === -1) { baseUri = uriToParse; } else if (questionMarkIndex !== -1) { baseUri = uriToParse.substring(0, questionMarkIndex); queryString = uriToParse.substring(questionMarkIndex + 1); if (fragmentIndex !== -1) { fragmentIndex = queryString.indexOf('#'); fragmentString = queryString.substring(fragmentIndex + 1); queryString = queryString.substring(0, fragmentIndex); } } else { baseUri = uriToParse.substring(0, fragmentIndex); fragmentString = uriToParse.substring(fragmentIndex + 1); } return { baseUri: baseUri, queryString: queryString, fragmentString: fragmentString }; } static parseParams(paramString) { const result = {}; const params = paramString.split('&'); for (let i = 0; i < params.length; i++) { const p = params[i].split('='); const paramName = decodeURIComponent(p[0]); const paramValue = decodeURIComponent(p[1]); result[paramName] = paramValue; } return result; } static handleQueryParam(paramName, paramValue, oauth) { const supportedOAuthParams = ['code', 'state', 'error', 'session_state', 'error_description']; for (let i = 0; i < supportedOAuthParams.length; i++) { if (paramName === supportedOAuthParams[i]) { oauth[paramName] = paramValue; return true; } } return false; } static parseUri(uriToParse, responseMode) { const parsedUri = this.initialParse(decodeURIComponent(uriToParse), responseMode); let queryParams = {}; if (parsedUri.queryString) { queryParams = this.parseParams(parsedUri.queryString); } const oauth = { newUrl: parsedUri.baseUri }; Object.keys(queryParams).forEach(param => { switch (param) { case 'redirect_fragment': oauth.fragment = queryParams[param]; break; case 'prompt': oauth.prompt = queryParams[param]; break; default: if (responseMode !== 'query' || !this.handleQueryParam(param, queryParams[param], oauth)) { oauth.newUrl += (oauth.newUrl.indexOf('?') === -1 ? '?' : '&') + param + '=' + queryParams[param]; } break; } }); if (responseMode === 'fragment') { let fragmentParams = {}; if (parsedUri.fragmentString) { fragmentParams = this.parseParams(parsedUri.fragmentString); } Object.keys(fragmentParams).forEach(param => { oauth[param] = fragmentParams[param]; }); } return oauth; } } /* * Copyright 2018 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Token utility */ class Token { static decodeToken(str) { str = str.split('.')[1]; str = str.replace('/-/g', '+'); str = str.replace('/_/g', '/'); switch (str.length % 4) { case 0: break; case 2: str += '=='; break; case 3: str += '='; break; default: throw new Error('Invalid token'); } str = (str + '===').slice(0, str.length + (str.length % 4)); str = str.replace(/-/g, '+').replace(/_/g, '/'); str = decodeURIComponent(escape(atob(str))); str = JSON.parse(str); return str; } static generateRandomData(len) { // use web crypto APIs if possible let array = null; const crypto = window.crypto; if (crypto && crypto.getRandomValues && window.Uint8Array) { array = new Uint8Array(len); crypto.getRandomValues(array); return array; } // fallback to Math random array = new Array(len); for (let j = 0; j < array.length; j++) { array[j] = Math.floor(256 * Math.random()); } return array; } static generateCodeVerifier(len) { return Token.generateRandomString(len, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'); } static generateRandomString(len, alphabet) { const randomData = this.generateRandomData(len); const chars = new Array(len); for (let i = 0; i < len; i++) { chars[i] = alphabet.charCodeAt(randomData[i] % alphabet.length); } return String.fromCharCode.apply(null, chars); } static generatePkceChallenge(pkceMethod, codeVerifier) { switch (pkceMethod) { // The use of the "plain" method is considered insecure and therefore not supported. case 'S256': // hash codeVerifier, then encode as url-safe base64 without padding const hashBytes = new Uint8Array(sha256.arrayBuffer(codeVerifier)); const encodedHash = fromByteArray(hashBytes) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/\=/g, ''); return encodedHash; default: throw new Error('Invalid value for pkceMethod'); } } } /* * Copyright 2018 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Cordova adapter for hybrid apps. */ class CordovaAdapter { keycloak; constructor(keycloak) { this.keycloak = keycloak; } login(options) { // let promise = Keycloak.createPromise(); let o = 'location=no'; if (options && options.prompt === 'none') { o += ',hidden=yes'; } const loginUrl = this.keycloak.createLoginUrl(options); // console.log('opening login frame from cordova: ' + loginUrl); if (!window.cordova) { throw new Error('Cannot authenticate via a web browser'); } if (!window.cordova.InAppBrowser || !window.cordova.plugins.browsertab) { throw new Error('The Apache Cordova InAppBrowser/BrowserTab plugins was not found and are required'); } let ref; // let ref = window.cordova.InAppBrowser.open(loginUrl, '_blank', o); // let ref = window.cordova.InAppBrowser.open(loginUrl, '_system', o); let completed = false; window.cordova.plugins.browsertab.themeable.isAvailable(function (result) { if (!result) { ref = window.cordova.InAppBrowser.open(loginUrl, '_system'); ref.addEventListener('loadstart', function (event) { if (event.url.indexOf('http://localhost') === 0) { const callback = this.keycloak.parseCallback(event.url); this.keycloak.processCallback(callback).subscribe(processed => { ref.close(); completed = true; }); } }); ref.addEventListener('loaderror', function (event) { if (!completed) { if (event.url.indexOf('http://localhost') === 0) { const callback = this.keycloak.parseCallback(event.url); this.keycloak.processCallback(callback).subscribe(processed => { this.closeBrowserTab(); // ref.close(); // completed = true; }); } else { this.closeBrowserTab(); // ref.close(); } } }); } else { this.openBrowserTab(loginUrl, options); } }, function (isAvailableError) { console.error('failed to query availability of in-app browser tab'); }); } closeBrowserTab() { const cordova = window.cordova; cordova.plugins.browsertab.themeable.close(); // completed = true; } logout(options) { const cordova = window.cordova; const logoutUrl = this.keycloak.createLogoutUrl(options); let ref; let error; cordova.plugins.browsertab.themeable.isAvailable(function (result) { if (!result) { ref = cordova.InAppBrowser.open(logoutUrl, '_system'); ref.addEventListener('loadstart', function (event) { if (event.url.indexOf('http://localhost') === 0) { this.ref.close(); this.closeBrowserTab(); } }); ref.addEventListener('loaderror', function (event) { if (event.url.indexOf('http://localhost') === 0) { this.ref.close(); this.closeBrowserTab(); } else { error = true; this.ref.close(); this.closeBrowserTab(); } }); ref.addEventListener('exit', function (event) { if (error) { console.error('listener of in-app browser tab exited due to error', error); } else { this.keycloak.clearToken({}); } }); } else { this.openBrowserTab(logoutUrl, options); } }, function (isAvailableError) { console.error('failed to query availability of in-app browser tab', isAvailableError); }); } register(options) { const registerUrl = this.keycloak.createRegisterUrl({}); window.cordova.plugins.browsertab.themeable.isAvailable(function (result) { if (!result) { window.cordova.InAppBrowser.open(registerUrl, '_system'); } else { this.openBrowserTab(registerUrl, options); } }, function (isAvailableError) { console.error('failed to query availability of in-app browser tab', isAvailableError); }); } accountManagement(options) { const accountUrl = this.keycloak.createAccountUrl({}); window.cordova.plugins.browsertab.themeable.isAvailable(function (result) { if (!result) { window.cordova.InAppBrowser.open(accountUrl, '_system'); } else { this.openBrowserTab(accountUrl, options); } }, function (isAvailableError) { console.error('failed to query availability of in-app browser tab', isAvailableError); }); } passwordManagement(options) { const accountUrl = this.keycloak.createChangePasswordUrl({}); window.cordova.plugins.browsertab.themeable.isAvailable(function (result) { if (!result) { window.cordova.InAppBrowser.open(accountUrl, '_system'); } else { this.openBrowserTab(accountUrl, options); } }, function (isAvailableError) { console.error('failed to query availability of in-app browser tab', isAvailableError); }); } redirectUri(options) { if (options.redirectUri) { return options.redirectUri; } else { return 'http://localhost'; } } openBrowserTab(url, options) { const cordova = window.cordova; if (options.toolbarColor) { cordova.plugins.browsertab.themeable.openUrl(url, options); } else { cordova.plugins.browsertab.themeable.openUrl(url); } } } /* * Copyright 2018 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * To store Keycloak objects like tokens using a cookie. */ class CookieStorage { getCookie = function (key) { const name = key + '='; const ca = document.cookie.split(';'); for (let i = 0; i < ca.length; i++) { let c = ca[i]; while (c.charAt(0) === ' ') { c = c.substring(1); } if (c.indexOf(name) === 0) { return c.substring(name.length, c.length); } } return ''; }; get(state) { if (!state) { return; } const value = this.getCookie('kc-callback-' + state); this.setCookie('kc-callback-' + state, '', this.cookieExpiration(-100)); if (value) { return JSON.parse(value); } } add(state) { this.setCookie('kc-callback-' + state.state, JSON.stringify(state), this.cookieExpiration(60)); } removeItem(key) { this.setCookie(key, '', this.cookieExpiration(-100)); } cookieExpiration(minutes) { const exp = new Date(); exp.setTime(exp.getTime() + (minutes * 60 * 1000)); return exp; } setCookie(key, value, expirationDate) { const cookie = key + '=' + value + '; ' + 'expires=' + expirationDate.toUTCString() + '; '; document.cookie = cookie; } } /* * Copyright 2022 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Silent login check Iframe utility */ class KeycloakSilentCheckLoginIframe { keycloak; iframe; iframeSrc; constructor(keycloak, silentRedirectUri) { this.keycloak = keycloak; this.iframeSrc = this.keycloak.createLoginUrl({ prompt: 'none', redirectUri: silentRedirectUri }); this.initIframe(); } initIframe() { this.iframe = document.createElement('iframe'); this.iframe.setAttribute('src', this.iframeSrc); this.iframe.style.display = 'none'; this.iframe.setAttribute('title', 'keycloak-silent-check-sso'); document.body.appendChild(this.iframe); window.addEventListener('message', () => this.processSilentLoginCallbackMessage(event), false); } processSilentLoginCallbackMessage(event) { const origin = this.iframeSrc.substring(0, this.iframeSrc.indexOf('/', 8)); // console.log('checking iframe message callback..' + event.data + ' ' + event.origin); if ((event.origin !== window.location.origin) || (this.iframe.contentWindow !== event.source)) { // console.log('event is not coming from the iframe, ignoring it'); return; } const oauth = this.keycloak.parseCallback(event.data); if (!!oauth) { this.keycloak.processCallback(oauth).subscribe(() => console.log('Silent login ended')); } document.body.removeChild(this.iframe); window.removeEventListener('message', () => this.processSilentLoginCallbackMessage(event), false); } } /* * Copyright 2022 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * 3Party cookie Iframe utility */ class KeycloakCheck3pCookiesIframe { keycloak; iframe; interval; iframeSrc; supportedBS; supportedObs; constructor(keycloak) { this.keycloak = keycloak; this.iframeSrc = this.keycloak.getRealmUrl() + '/protocol/openid-connect/3p-cookies/step1.html'; this.supportedBS = new BehaviorSubject(null); this.supportedObs = this.supportedBS.asObservable(); this.initIframe(); } initIframe() { this.iframe = document.createElement('iframe'); this.iframe.setAttribute('src', this.iframeSrc); this.iframe.setAttribute('title', 'keycloak-3p-check-iframe'); this.iframe.style.display = 'none'; document.body.appendChild(this.iframe); window.addEventListener('message', () => this.process3pCookieCallbackMessage(event), false); } process3pCookieCallbackMessage(event) { // console.log('checking iframe message callback..' + event.data + ' ' + event.origin); if (this.iframe.contentWindow !== event.source) { // console.log('event is not coming from the iframe, ignoring it'); return; } // console.log('Checking iframe message ' + event.data); if (event.data !== 'supported' && event.data !== 'unsupported') { return; } if (event.data === 'unsupported') { console.warn('[KEYCLOAK] 3rd party cookies aren\'t supported by this browser.' + ' checkLoginIframe and silent check-sso are not available.'); this.supportedBS.next(false); } else { this.supportedBS.next(true); } document.body.removeChild(this.iframe); window.removeEventListener('message', () => this.process3pCookieCallbackMessage(event)); } } /* * Copyright 2024 ebondu and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Keycloak core classes to manage tokens with a keycloak server. * * Used for login, logout, register, account management, profile. * Provide Angular Observable objects for initialization, authentication, token expiration. * */ class KeycloakService { initializedObs; initializedAuthzObs; authenticationObs; tokenExpiredObs; authenticationErrorObs; // tokens accessToken; tokenParsed; sessionId; // Observables initBS; initAuthzBS; authenticationsBS; tokenExpiredBS; authenticationErrorBS; refreshToken; refreshTokenParsed; rpt; idToken; idTokenParsed; // keycloak conf umaConfig; adapter; callbackStorage; responseType; timeSkew; tokenTimeoutHandle; subject; realmAccess; resourceAccess; loginIframe; #injector = inject(Injector); #platformId = inject(PLATFORM_ID); #ngZone = inject(NgZone); #configUrl = inject(KEYCLOAK_JSON_PATH, { optional: true }); keycloakConfig = inject(KEYCLOAK_CONF, { optional: true }); initOptions = inject(KEYCLOAK_INIT_OPTIONS); get http() { return this.#injector.get(HttpClient); } constructor() { this.initBS = new BehaviorSubject(false); this.initializedObs = this.initBS.asObservable(); this.initAuthzBS = new BehaviorSubject(false); this.initializedAuthzObs = this.initAuthzBS.asObservable(); this.authenticationsBS = new BehaviorSubject(false); this.authenticationObs = this.authenticationsBS.asObservable(); this.tokenExpiredBS = new BehaviorSubject(false); this.tokenExpiredObs = this.tokenExpiredBS.asObservable(); this.authenticationErrorBS = new BehaviorSubject(null); this.authenticationErrorObs = this.authenticationErrorBS.asObservable(); // console.log('Keycloak service created with init options and configuration file', initOptions, configUrl); if (!isPlatformBrowser(this.#platformId)) { // console.log('Keycloak service init only available on browser platform'); this.initBS.next(false); } else { if (!globalThis.isSecureContext) { console.warn('Keycloak JS must be used in a \'secure context\' to function properly as it relies on browser APIs that are otherwise not available'); } if (this.#configUrl) { this.http.get(this.#configUrl).subscribe({ next: (config) => { this.keycloakConfig = { authServerUrl: config['auth-server-url'], realm: config['realm'], clientId: config['resource'], clientSecret: (config['credentials'] || {})['secret'] }; // console.log('Conf loaded', this.keycloakConfig); this.initService(); }, error: () => { // console.log('Unable to load keycloak.json', error); this.initBS.next(false); } }); } else if (this.keycloakConfig) { this.initService(); } else { // console.log('Keycloak service init fails : no keycloak.json or configuration provided'); this.initBS.next(false); } this.initializedObs.pipe(filter(initialized => !!initialized)).subscribe(next => { // console.log('Keycloak initialized, initializing authz service', this); if (next) { const url = this.keycloakConfig.authServerUrl + '/realms/' + this.keycloakConfig.realm + '/.well-known/uma2-configuration'; this.http.get(url).subscribe({ next: (authz) => { // console.log('Authz configuration file loaded, continuing authz'); this.umaConfig = authz; this.initAuthzBS.next(true); }, error: () => { // console.log('unable to get uma file', error); this.initAuthzBS.next(false); } }); } }); } } parseCallback(url) { const oauth = URIParser.parseUri(url, this.initOptions.responseMode); const state = oauth.state; const oauthState = this.callbackStorage.get(state); if (oauthState && (oauth.code || oauth.error || oauth.access_token || oauth.id_token)) { oauth.valid = true; oauth.redirectUri = oauthState.redirectUri; oauth.storedNonce = oauthState.nonce; oauth.prompt = oauthState.prompt; oauth.pkceCodeVerifier = oauthState.pkceCodeVerifier; if (oauth.fragment) { oauth.newUrl += '#' + oauth.fragment; } return oauth; } } processCallback(oauth) { return new Observable((observer) => { const code = oauth.code; const error = oauth.error; const prompt = oauth.prompt; const timeLocal = new Date().getTime(); if (error) { const errorData = { error: error, error_description: oauth.error_description }; this.authenticationErrorBS.next(errorData); if (prompt !== 'none') { // console.log('error while processing callback'); observer.next(false); } return; } else if ((this.initOptions.flow !== KeycloakFlow.STANDARD) && (oauth.access_token || oauth.id_token)) { this.authSuccess(oauth.access_token, null, oauth.id_token, true, timeLocal, oauth); observer.next(true); } if ((this.initOptions.flow !== KeycloakFlow.IMPLICIT) && code) { let withCredentials = false; const url = this.getRealmUrl() + '/protocol/openid-connect/token'; let params = new HttpParams(); params = params.set('code', code); params = params.set('grant_type', 'authorization_code'); let headers = new HttpHeaders(); headers = headers.set('Content-type', 'application/x-www-form-urlencoded'); if (this.keycloakConfig.clientId && this.keycloakConfig.clientSecret) { headers = headers.set('Authorization', 'Basic ' + btoa(this.keycloakConfig.clientId + ':' + this.keycloakConfig.clientSecret)); withCredentials = true; } else { params = params.set('client_id', this.keycloakConfig.clientId); } params = params.set('redirect_uri', oauth.redirectUri); if (oauth.pkceCodeVerifier) { params = params.set('code_verifier', oauth.pkceCodeVerifier); } const options = { headers: headers, withCredentials: withCredentials }; this.http.post(url, params, options).subscribe({ next: (token) => { this.authSuccess(token['access_token'], token['refresh_token'], token['id_token'], this.initOptions.flow === KeycloakFlow.STANDARD, timeLocal, oauth); this.authenticationsBS.next(true); observer.next(true); }, error: (errorToken) => { this.authenticationErrorBS.next({ error: errorToken, error_description: 'unable to get token from server' }); // console.log('Unable to get token', errorToken); observer.next(false); } }); } }); } login(options) { return this.adapter.login(options); } // ################################### // ####### Keycloak methods ###### // ################################### logout(options) { return this.adapter.logout(options); } updateToken(minValidity) { minValidity = minValidity || 5; if (!this.isTokenExpired(minValidity)) { // console.log('token still valid'); return of(this.accessToken); } else { if (this.isRefreshTokenExpired(5)) { this.login(this.keycloakConfig); return EMPTY; } else { // console.log('refreshing token'); let params = new HttpParams(); params = params.set('grant_type', 'refresh_token'); params = params.set('refresh_token', this.refreshToken); const url = this.getRealmUrl() + '/protocol/openid-connect/token'; let headers = new HttpHeaders({ 'Content-type': 'application/x-www-form-urlencoded' }); let withCredentials = false; if (this.keycloakConfig.clientId && this.keycloakConfig.clientSecret) { headers = headers.append('Authorization', 'Basic ' + btoa(this.keycloakConfig.clientId + ': ' + this.keycloakConfig.clientSecret)); withCredentials = true; } else { params = params.set('client_id', this.keycloakConfig.clientId); } let timeLocal = new Date().getTime(); return this.http.post(url, params, { headers: headers, withCredentials: withCredentials }).pipe(map((token) => { timeLocal = (timeLocal + new Date().getTime()) / 2; this.setToken(token['access_token'], token['refresh_token'], token['id_token'], true); this.timeSkew = Math.floor(timeLocal / 1000) - this.tokenParsed.iat; return token['access_token']; })); } } } register(options) { return this.adapter.register(options); } accountManagement(options) { return this.adapter.accountManagement(options); } loadChangePassword(options) { return this.adapter.passwordManagement(options); } loadUserProfile() { // need to refresh token to get account-console as aud let paramsToSend = new HttpParams(); let headersToSend = new HttpHeaders(); headersToSend = headersToSend.set('Content-type', 'application/x-www-form-urlencoded'); paramsToSend = paramsToSend.set('client_id', this.keycloakConfig.clientId); paramsToSend = paramsToSend.set('grant_type', 'refresh_token'); paramsToSend = paramsToSend.set('refresh_token', this.refreshToken); headersToSend = headersToSend.set('Authorization', 'bearer ' + this.accessToken); const url = this.getRealmUrl() + '/account/'; return this.http.post(this.umaConfig?.token_endpoint, paramsToSend, { withCredentials: false, headers: headersToSend }) .pipe(mergeMap((token) => { const headers = new HttpHeaders({ 'Authorization': 'bearer ' + token.access_token }); return this.http.get(url, { headers: headers, withCredentials: false }); })); } updateUserProfile(profile) { // need to refresh token to get account-console as aud let paramsToSend = new HttpParams(); let headersToSend = new HttpHeaders(); headersToSend = headersToSend.set('Content-type', 'application/x-www-form-urlencoded'); paramsToSend = paramsToSend.set('client_id', this.keycloakConfig.clientId); paramsToSend = paramsToSend.set('grant_type', 'refresh_token'); paramsToSend = paramsToSend.set('refresh_token', this.refreshToken); headersToSend = headersToSend.set('Authorization', 'bearer ' + this.accessToken); const url = this.getRealmUrl() + '/account/'; return this.http.post(this.umaConfig?.token_endpoint, paramsToSend, { withCredentials: true, headers: headersToSend }) .pipe(mergeMap((token) => { const headers = new HttpHeaders({ 'Authorization': 'bearer ' + token.access_token }); return this.http.post(url, profile, { headers: headers, withCredentials: false }); })); } createDeleteAccountUrl(options) { const state = v4(); const nonce = this.initOptions.useNonce ? v4() : null; // const redirectUri = this.getRealmUrl() + '/account/#/personal-info'; const redirectUri = this.adapter.redirectUri({}); const callback = { state: state, nonce: nonce, redirectUri: redirectUri }; const action = 'auth'; let url = this.getRealmUrl() + '/protocol/openid-connect/' + action + '?client_id=' + encodeURIComponent(this.keycloakConfig.clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&state=' + encodeURIComponent(state) + '&response_mode=' + encodeURIComponent(this.initOptions.responseMode) + '&response_type=' + encodeURIComponent(this.responseType) + '&scope=' + encodeURIComponent('openid') + '&kc_action=' + encodeURIComponent('delete_account'); if (options.useNonce) { url += '&nonce=' + encodeURIComponent(nonce); } let codeVerifier; const pkceMethod = this.initOptions.pkceMethod; codeVerifier = Token.generateCodeVerifier(96); callback.pkceCodeVerifier = codeVerifier; const pkceChallenge = Token.generatePkceChallenge(pkceMethod, codeVerifier); url += '&code_challenge=' + pkceChallenge; url += '&code_challenge_method=' + pkceMethod; this.callbackStorage.add(callback); return url; } createUpdateProfileUrl(options) { const state = v4(); const nonce = this.initOptions.useNonce ? v4() : null; const redirectUri = this.adapter.redirectUri({}); const callback = { state: state, nonce: nonce, redirectUri: redirectUri }; const action = 'auth'; let url = this.getRealmUrl() + '/protocol/openid-connect/' + action + '?client_id=' + encodeURIComponent(this.keycloakConfig.clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&state=' + encodeURIComponent(state) + '&response_mode=' + encodeURIComponent(this.initOptions.responseMode) + '&response_type=' + encodeURIComponent(this.responseType) + '&scope=' + encodeURIComponent('openid') + '&kc_action=' + encodeURIComponent('UPDATE_PROFILE'); if (options.useNonce) { url += '&nonce=' + encodeURIComponent(nonce); } let codeVerifier; const pkceMethod = this.initOptions.pkceMethod; codeVerifier = Token.generateCodeVerifier(96); callback.pkceCodeVerifier = codeVerifier; const pkceChallenge = Token.generatePkceChallenge(pkceMethod, codeVerifier); url += '&code_challenge=' + pkceChallenge; url += '&code_challenge_method=' + pkceMethod; this.callbackStorage.add(callback); return url; } changePassword() { const state = v4(); const nonce = this.initOptions.useNonce ? v4() : null; const redirectUri = this.adapter.redirectUri({}); const callback = { state: state, nonce: nonce, redirectUri: redirectUri }; const action = 'auth'; let url = this.getRealmUrl() + '/protocol/openid-connect/' + action + '?client_id=' + encodeURIComponent(this.keycloakConfig.clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&state=' + encodeURIComponent(state) + '&response_mode=' + encodeURIComponent(this.initOptions.responseMode) + '&response_type=' + encodeURIComponent(this.responseType) + '&scope=' + encodeURIComponent('openid') + '&kc_action=' + encodeURIComponent('UPDATE_PASSWORD'); if (this.initOptions.useNonce) { url += '&nonce=' + encodeURIComponent(nonce); } let codeVerifier; const pkceMethod = this.initOptions.pkceMethod; codeVerifier = Token.generateCodeVerifier(96); callback.pkceCodeVerifier = codeVerifier; const pkceChallenge = Token.generatePkceChallenge(pkceMethod, codeVerifier); url += '&code_challenge=' + pkceChallenge; url += '&code_challenge_method=' + pkceMethod; this.callbackStorage.add(callback); return url; } loadUserInfo() { const url = this.getRealmUrl() + '/protocol/openid-connect/userinfo'; const headers = new HttpHeaders({ 'Accept': 'application/json', 'Authorization': 'bearer ' + this.accessToken }); return this.http.get(url, { headers: headers }); } hasRealmRole(role) { const access = this.realmAccess; return !!access && access.roles.indexOf(role) >= 0; } hasResourceRole(role, resource) { if (!this.resourceAccess) { return false; } const access = this.resourceAccess[resource || this.keycloakConfig.clientId]; return !!access && access.roles.indexOf(role) >= 0; } isTokenExpired(minValidity) { if (!this.tokenParsed || (!this.refreshToken && this.initOptions.flow !== KeycloakFlow.IMPLICIT)) { throw new Error('Not authenticated'); } let expiresIn = this.tokenParsed['exp'] - (new Date().getTime() / 1000) + this.timeSkew; if (minValidity) { expiresIn -= minValidity; } return expiresIn < 0; } isRefreshTokenExpired(minValidity) { if (!this.tokenParsed || (!this.refreshToken && this.initOptions.flow !== KeycloakFlow.IMPLICIT)) { throw new Error('Not authenticated'); } let expiresIn = this.refreshTokenParsed['exp'] - (new Date().getTime() / 1000) + this.timeSkew; if (minValidity) { expiresIn -= minValidity; } return expiresIn < 0; } /** * This method enables client applications to better integrate with resource servers protected by a Keycloak * policy enforcer. * * In this case, the res