UNPKG

angular-oauth2-oidc

Version:

Support for OAuth 2 and OpenId Connect (OIDC) in Angular. Already prepared for the upcoming OAuth 2.1.

1,194 lines 293 kB
import { Injectable, Optional, Inject } from '@angular/core'; import { HttpHeaders, HttpParams, } from '@angular/common/http'; import { Subject, of, race, from, combineLatest, throwError, } from 'rxjs'; import { filter, delay, first, tap, map, switchMap, debounceTime, catchError, } from 'rxjs/operators'; import { DOCUMENT } from '@angular/common'; import { OAuthInfoEvent, OAuthErrorEvent, OAuthSuccessEvent, } from './events'; import { b64DecodeUnicode, base64UrlEncode } from './base64-helper'; import { AuthConfig } from './auth.config'; import { WebHttpUrlEncodingCodec } from './encoder'; import * as i0 from "@angular/core"; import * as i1 from "@angular/common/http"; import * as i2 from "./types"; import * as i3 from "./token-validation/validation-handler"; import * as i4 from "./auth.config"; import * as i5 from "./url-helper.service"; import * as i6 from "./token-validation/hash-handler"; import * as i7 from "./date-time-provider"; /** * Service for logging in and logging out with * OIDC and OAuth2. Supports implicit flow and * password flow. */ export class OAuthService extends AuthConfig { constructor(ngZone, http, storage, tokenValidationHandler, config, urlHelper, logger, crypto, document, dateTimeService) { super(); this.ngZone = ngZone; this.http = http; this.config = config; this.urlHelper = urlHelper; this.logger = logger; this.crypto = crypto; this.dateTimeService = dateTimeService; /** * @internal * Deprecated: use property events instead */ this.discoveryDocumentLoaded = false; /** * The received (passed around) state, when logging * in with implicit flow. */ this.state = ''; this.eventsSubject = new Subject(); this.discoveryDocumentLoadedSubject = new Subject(); this.grantTypesSupported = []; this.inImplicitFlow = false; this.saveNoncesInLocalStorage = false; this.debug('angular-oauth2-oidc v10'); // See https://github.com/manfredsteyer/angular-oauth2-oidc/issues/773 for why this is needed this.document = document; if (!config) { config = {}; } this.discoveryDocumentLoaded$ = this.discoveryDocumentLoadedSubject.asObservable(); this.events = this.eventsSubject.asObservable(); if (tokenValidationHandler) { this.tokenValidationHandler = tokenValidationHandler; } if (config) { this.configure(config); } try { if (storage) { this.setStorage(storage); } else if (typeof sessionStorage !== 'undefined') { this.setStorage(sessionStorage); } } catch (e) { console.error('No OAuthStorage provided and cannot access default (sessionStorage).' + 'Consider providing a custom OAuthStorage implementation in your module.', e); } // in IE, sessionStorage does not always survive a redirect if (this.checkLocalStorageAccessable()) { const ua = window?.navigator?.userAgent; const msie = ua?.includes('MSIE ') || ua?.includes('Trident'); if (msie) { this.saveNoncesInLocalStorage = true; } } this.setupRefreshTimer(); } checkLocalStorageAccessable() { if (typeof window === 'undefined') return false; const test = 'test'; try { if (typeof window['localStorage'] === 'undefined') return false; localStorage.setItem(test, test); localStorage.removeItem(test); return true; } catch (e) { return false; } } /** * Use this method to configure the service * @param config the configuration */ configure(config) { // For the sake of downward compatibility with // original configuration API Object.assign(this, new AuthConfig(), config); this.config = Object.assign({}, new AuthConfig(), config); if (this.sessionChecksEnabled) { this.setupSessionCheck(); } this.configChanged(); } configChanged() { this.setupRefreshTimer(); } restartSessionChecksIfStillLoggedIn() { if (this.hasValidIdToken()) { this.initSessionCheck(); } } restartRefreshTimerIfStillLoggedIn() { this.setupExpirationTimers(); } setupSessionCheck() { this.events .pipe(filter((e) => e.type === 'token_received')) .subscribe(() => { this.initSessionCheck(); }); } /** * Will setup up silent refreshing for when the token is * about to expire. When the user is logged out via this.logOut method, the * silent refreshing will pause and not refresh the tokens until the user is * logged back in via receiving a new token. * @param params Additional parameter to pass * @param listenTo Setup automatic refresh of a specific token type */ setupAutomaticSilentRefresh(params = {}, listenTo, noPrompt = true) { let shouldRunSilentRefresh = true; this.clearAutomaticRefreshTimer(); this.automaticRefreshSubscription = this.events .pipe(tap((e) => { if (e.type === 'token_received') { shouldRunSilentRefresh = true; } else if (e.type === 'logout') { shouldRunSilentRefresh = false; } }), filter((e) => e.type === 'token_expires' && (listenTo == null || listenTo === 'any' || e.info === listenTo)), debounceTime(1000)) .subscribe(() => { if (shouldRunSilentRefresh) { // this.silentRefresh(params, noPrompt).catch(_ => { this.refreshInternal(params, noPrompt).catch(() => { this.debug('Automatic silent refresh did not work'); }); } }); this.restartRefreshTimerIfStillLoggedIn(); } refreshInternal(params, noPrompt) { if (!this.useSilentRefresh && this.responseType === 'code') { return this.refreshToken(); } else { return this.silentRefresh(params, noPrompt); } } /** * Convenience method that first calls `loadDiscoveryDocument(...)` and * directly chains using the `then(...)` part of the promise to call * the `tryLogin(...)` method. * * @param options LoginOptions to pass through to `tryLogin(...)` */ loadDiscoveryDocumentAndTryLogin(options = null) { return this.loadDiscoveryDocument().then(() => { return this.tryLogin(options); }); } /** * Convenience method that first calls `loadDiscoveryDocumentAndTryLogin(...)` * and if then chains to `initLoginFlow()`, but only if there is no valid * IdToken or no valid AccessToken. * * @param options LoginOptions to pass through to `tryLogin(...)` */ loadDiscoveryDocumentAndLogin(options = null) { options = options || {}; return this.loadDiscoveryDocumentAndTryLogin(options).then(() => { if (!this.hasValidIdToken() || !this.hasValidAccessToken()) { const state = typeof options.state === 'string' ? options.state : ''; this.initLoginFlow(state); return false; } else { return true; } }); } debug(...args) { if (this.showDebugInformation) { this.logger.debug(...args); } } validateUrlFromDiscoveryDocument(url) { const errors = []; const httpsCheck = this.validateUrlForHttps(url); const issuerCheck = this.validateUrlAgainstIssuer(url); if (!httpsCheck) { errors.push('https for all urls required. Also for urls received by discovery.'); } if (!issuerCheck) { errors.push('Every url in discovery document has to start with the issuer url.' + 'Also see property strictDiscoveryDocumentValidation.'); } return errors; } validateUrlForHttps(url) { if (!url) { return true; } const lcUrl = url.toLowerCase(); if (this.requireHttps === false) { return true; } if ((lcUrl.match(/^http:\/\/localhost($|[:/])/) || lcUrl.match(/^http:\/\/localhost($|[:/])/)) && this.requireHttps === 'remoteOnly') { return true; } return lcUrl.startsWith('https://'); } assertUrlNotNullAndCorrectProtocol(url, description) { if (!url) { throw new Error(`'${description}' should not be null`); } if (!this.validateUrlForHttps(url)) { throw new Error(`'${description}' must use HTTPS (with TLS), or config value for property 'requireHttps' must be set to 'false' and allow HTTP (without TLS).`); } } validateUrlAgainstIssuer(url) { if (!this.strictDiscoveryDocumentValidation) { return true; } if (!url) { return true; } return url.toLowerCase().startsWith(this.issuer.toLowerCase()); } setupRefreshTimer() { if (typeof window === 'undefined') { this.debug('timer not supported on this plattform'); return; } if (this.hasValidIdToken() || this.hasValidAccessToken()) { this.clearAccessTokenTimer(); this.clearIdTokenTimer(); this.setupExpirationTimers(); } if (this.tokenReceivedSubscription) this.tokenReceivedSubscription.unsubscribe(); this.tokenReceivedSubscription = this.events .pipe(filter((e) => e.type === 'token_received')) .subscribe(() => { this.clearAccessTokenTimer(); this.clearIdTokenTimer(); this.setupExpirationTimers(); }); } setupExpirationTimers() { if (this.hasValidAccessToken()) { this.setupAccessTokenTimer(); } if (!this.disableIdTokenTimer && this.hasValidIdToken()) { this.setupIdTokenTimer(); } } setupAccessTokenTimer() { const expiration = this.getAccessTokenExpiration(); const storedAt = this.getAccessTokenStoredAt(); const timeout = this.calcTimeout(storedAt, expiration); this.ngZone.runOutsideAngular(() => { this.accessTokenTimeoutSubscription = of(new OAuthInfoEvent('token_expires', 'access_token')) .pipe(delay(timeout)) .subscribe((e) => { this.ngZone.run(() => { this.eventsSubject.next(e); }); }); }); } setupIdTokenTimer() { const expiration = this.getIdTokenExpiration(); const storedAt = this.getIdTokenStoredAt(); const timeout = this.calcTimeout(storedAt, expiration); this.ngZone.runOutsideAngular(() => { this.idTokenTimeoutSubscription = of(new OAuthInfoEvent('token_expires', 'id_token')) .pipe(delay(timeout)) .subscribe((e) => { this.ngZone.run(() => { this.eventsSubject.next(e); }); }); }); } /** * Stops timers for automatic refresh. * To restart it, call setupAutomaticSilentRefresh again. */ stopAutomaticRefresh() { this.clearAccessTokenTimer(); this.clearIdTokenTimer(); this.clearAutomaticRefreshTimer(); } clearAccessTokenTimer() { if (this.accessTokenTimeoutSubscription) { this.accessTokenTimeoutSubscription.unsubscribe(); } } clearIdTokenTimer() { if (this.idTokenTimeoutSubscription) { this.idTokenTimeoutSubscription.unsubscribe(); } } clearAutomaticRefreshTimer() { if (this.automaticRefreshSubscription) { this.automaticRefreshSubscription.unsubscribe(); } } calcTimeout(storedAt, expiration) { const now = this.dateTimeService.now(); const delta = (expiration - storedAt) * this.timeoutFactor - (now - storedAt); const duration = Math.max(0, delta); const maxTimeoutValue = 2147483647; return duration > maxTimeoutValue ? maxTimeoutValue : duration; } /** * DEPRECATED. Use a provider for OAuthStorage instead: * * { provide: OAuthStorage, useFactory: oAuthStorageFactory } * export function oAuthStorageFactory(): OAuthStorage { return localStorage; } * Sets a custom storage used to store the received * tokens on client side. By default, the browser's * sessionStorage is used. * @ignore * * @param storage */ setStorage(storage) { this._storage = storage; this.configChanged(); } /** * Loads the discovery document to configure most * properties of this service. The url of the discovery * document is infered from the issuer's url according * to the OpenId Connect spec. To use another url you * can pass it to to optional parameter fullUrl. * * @param fullUrl */ loadDiscoveryDocument(fullUrl = null) { return new Promise((resolve, reject) => { if (!fullUrl) { fullUrl = this.issuer || ''; if (!fullUrl.endsWith('/')) { fullUrl += '/'; } fullUrl += '.well-known/openid-configuration'; } if (!this.validateUrlForHttps(fullUrl)) { reject("issuer must use HTTPS (with TLS), or config value for property 'requireHttps' must be set to 'false' and allow HTTP (without TLS)."); return; } this.http.get(fullUrl).subscribe((doc) => { if (!this.validateDiscoveryDocument(doc)) { this.eventsSubject.next(new OAuthErrorEvent('discovery_document_validation_error', null)); reject('discovery_document_validation_error'); return; } this.loginUrl = doc.authorization_endpoint; this.logoutUrl = doc.end_session_endpoint || this.logoutUrl; this.grantTypesSupported = doc.grant_types_supported; this.issuer = doc.issuer; this.tokenEndpoint = doc.token_endpoint; this.userinfoEndpoint = doc.userinfo_endpoint || this.userinfoEndpoint; this.jwksUri = doc.jwks_uri; this.sessionCheckIFrameUrl = doc.check_session_iframe || this.sessionCheckIFrameUrl; this.discoveryDocumentLoaded = true; this.discoveryDocumentLoadedSubject.next(doc); this.revocationEndpoint = doc.revocation_endpoint || this.revocationEndpoint; if (this.sessionChecksEnabled) { this.restartSessionChecksIfStillLoggedIn(); } this.loadJwks() .then((jwks) => { const result = { discoveryDocument: doc, jwks: jwks, }; const event = new OAuthSuccessEvent('discovery_document_loaded', result); this.eventsSubject.next(event); resolve(event); return; }) .catch((err) => { this.eventsSubject.next(new OAuthErrorEvent('discovery_document_load_error', err)); reject(err); return; }); }, (err) => { this.logger.error('error loading discovery document', err); this.eventsSubject.next(new OAuthErrorEvent('discovery_document_load_error', err)); reject(err); }); }); } loadJwks() { return new Promise((resolve, reject) => { if (this.jwksUri) { this.http.get(this.jwksUri).subscribe((jwks) => { this.jwks = jwks; // this.eventsSubject.next( // new OAuthSuccessEvent('discovery_document_loaded') // ); resolve(jwks); }, (err) => { this.logger.error('error loading jwks', err); this.eventsSubject.next(new OAuthErrorEvent('jwks_load_error', err)); reject(err); }); } else { resolve(null); } }); } validateDiscoveryDocument(doc) { let errors; if (!this.skipIssuerCheck && doc.issuer !== this.issuer) { this.logger.error('invalid issuer in discovery document', 'expected: ' + this.issuer, 'current: ' + doc.issuer); return false; } errors = this.validateUrlFromDiscoveryDocument(doc.authorization_endpoint); if (errors.length > 0) { this.logger.error('error validating authorization_endpoint in discovery document', errors); return false; } errors = this.validateUrlFromDiscoveryDocument(doc.end_session_endpoint); if (errors.length > 0) { this.logger.error('error validating end_session_endpoint in discovery document', errors); return false; } errors = this.validateUrlFromDiscoveryDocument(doc.token_endpoint); if (errors.length > 0) { this.logger.error('error validating token_endpoint in discovery document', errors); } errors = this.validateUrlFromDiscoveryDocument(doc.revocation_endpoint); if (errors.length > 0) { this.logger.error('error validating revocation_endpoint in discovery document', errors); } errors = this.validateUrlFromDiscoveryDocument(doc.userinfo_endpoint); if (errors.length > 0) { this.logger.error('error validating userinfo_endpoint in discovery document', errors); return false; } errors = this.validateUrlFromDiscoveryDocument(doc.jwks_uri); if (errors.length > 0) { this.logger.error('error validating jwks_uri in discovery document', errors); return false; } if (this.sessionChecksEnabled && !doc.check_session_iframe) { this.logger.warn('sessionChecksEnabled is activated but discovery document' + ' does not contain a check_session_iframe field'); } return true; } /** * Uses password flow to exchange userName and password for an * access_token. After receiving the access_token, this method * uses it to query the userinfo endpoint in order to get information * about the user in question. * * When using this, make sure that the property oidc is set to false. * Otherwise stricter validations take place that make this operation * fail. * * @param userName * @param password * @param headers Optional additional http-headers. */ fetchTokenUsingPasswordFlowAndLoadUserProfile(userName, password, headers = new HttpHeaders()) { return this.fetchTokenUsingPasswordFlow(userName, password, headers).then(() => this.loadUserProfile()); } /** * Loads the user profile by accessing the user info endpoint defined by OpenId Connect. * * When using this with OAuth2 password flow, make sure that the property oidc is set to false. * Otherwise stricter validations take place that make this operation fail. */ loadUserProfile() { if (!this.hasValidAccessToken()) { throw new Error('Can not load User Profile without access_token'); } if (!this.validateUrlForHttps(this.userinfoEndpoint)) { throw new Error("userinfoEndpoint must use HTTPS (with TLS), or config value for property 'requireHttps' must be set to 'false' and allow HTTP (without TLS)."); } return new Promise((resolve, reject) => { const headers = new HttpHeaders().set('Authorization', 'Bearer ' + this.getAccessToken()); this.http .get(this.userinfoEndpoint, { headers, observe: 'response', responseType: 'text', }) .subscribe((response) => { this.debug('userinfo received', JSON.stringify(response)); if (response.headers .get('content-type') .startsWith('application/json')) { let info = JSON.parse(response.body); const existingClaims = this.getIdentityClaims() || {}; if (!this.skipSubjectCheck) { if (this.oidc && (!existingClaims['sub'] || info.sub !== existingClaims['sub'])) { const err = 'if property oidc is true, the received user-id (sub) has to be the user-id ' + 'of the user that has logged in with oidc.\n' + 'if you are not using oidc but just oauth2 password flow set oidc to false'; reject(err); return; } } info = Object.assign({}, existingClaims, info); this._storage.setItem('id_token_claims_obj', JSON.stringify(info)); this.eventsSubject.next(new OAuthSuccessEvent('user_profile_loaded')); resolve({ info }); } else { this.debug('userinfo is not JSON, treating it as JWE/JWS'); this.eventsSubject.next(new OAuthSuccessEvent('user_profile_loaded')); resolve(JSON.parse(response.body)); } }, (err) => { this.logger.error('error loading user info', err); this.eventsSubject.next(new OAuthErrorEvent('user_profile_load_error', err)); reject(err); }); }); } /** * Uses password flow to exchange userName and password for an access_token. * @param userName * @param password * @param headers Optional additional http-headers. */ fetchTokenUsingPasswordFlow(userName, password, headers = new HttpHeaders()) { const parameters = { username: userName, password: password, }; return this.fetchTokenUsingGrant('password', parameters, headers); } /** * Uses a custom grant type to retrieve tokens. * @param grantType Grant type. * @param parameters Parameters to pass. * @param headers Optional additional HTTP headers. */ fetchTokenUsingGrant(grantType, parameters, headers = new HttpHeaders()) { this.assertUrlNotNullAndCorrectProtocol(this.tokenEndpoint, 'tokenEndpoint'); /** * A `HttpParameterCodec` that uses `encodeURIComponent` and `decodeURIComponent` to * serialize and parse URL parameter keys and values. * * @stable */ let params = new HttpParams({ encoder: new WebHttpUrlEncodingCodec() }) .set('grant_type', grantType) .set('scope', this.scope); if (this.useHttpBasicAuth) { const header = btoa(`${this.clientId}:${this.dummyClientSecret}`); headers = headers.set('Authorization', 'Basic ' + header); } if (!this.useHttpBasicAuth) { params = params.set('client_id', this.clientId); } if (!this.useHttpBasicAuth && this.dummyClientSecret) { params = params.set('client_secret', this.dummyClientSecret); } if (this.customQueryParams) { for (const key of Object.getOwnPropertyNames(this.customQueryParams)) { params = params.set(key, this.customQueryParams[key]); } } // set explicit parameters last, to allow overwriting for (const key of Object.keys(parameters)) { params = params.set(key, parameters[key]); } headers = headers.set('Content-Type', 'application/x-www-form-urlencoded'); return new Promise((resolve, reject) => { this.http .post(this.tokenEndpoint, params, { headers }) .subscribe((tokenResponse) => { this.debug('tokenResponse', tokenResponse); this.storeAccessTokenResponse(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in || this.fallbackAccessTokenExpirationTimeInSec, tokenResponse.scope, this.extractRecognizedCustomParameters(tokenResponse)); if (this.oidc && tokenResponse.id_token) { this.processIdToken(tokenResponse.id_token, tokenResponse.access_token).then((result) => { this.storeIdToken(result); resolve(tokenResponse); }); } this.eventsSubject.next(new OAuthSuccessEvent('token_received')); resolve(tokenResponse); }, (err) => { this.logger.error('Error performing ${grantType} flow', err); this.eventsSubject.next(new OAuthErrorEvent('token_error', err)); reject(err); }); }); } /** * Refreshes the token using a refresh_token. * This does not work for implicit flow, b/c * there is no refresh_token in this flow. * A solution for this is provided by the * method silentRefresh. */ refreshToken() { this.assertUrlNotNullAndCorrectProtocol(this.tokenEndpoint, 'tokenEndpoint'); return new Promise((resolve, reject) => { let params = new HttpParams({ encoder: new WebHttpUrlEncodingCodec() }) .set('grant_type', 'refresh_token') .set('scope', this.scope) .set('refresh_token', this._storage.getItem('refresh_token')); let headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded'); if (this.useHttpBasicAuth) { const header = btoa(`${this.clientId}:${this.dummyClientSecret}`); headers = headers.set('Authorization', 'Basic ' + header); } if (!this.useHttpBasicAuth) { params = params.set('client_id', this.clientId); } if (!this.useHttpBasicAuth && this.dummyClientSecret) { params = params.set('client_secret', this.dummyClientSecret); } if (this.customQueryParams) { for (const key of Object.getOwnPropertyNames(this.customQueryParams)) { params = params.set(key, this.customQueryParams[key]); } } this.http .post(this.tokenEndpoint, params, { headers }) .pipe(switchMap((tokenResponse) => { if (this.oidc && tokenResponse.id_token) { return from(this.processIdToken(tokenResponse.id_token, tokenResponse.access_token, true)).pipe(tap((result) => this.storeIdToken(result)), map(() => tokenResponse)); } else { return of(tokenResponse); } })) .subscribe((tokenResponse) => { this.debug('refresh tokenResponse', tokenResponse); this.storeAccessTokenResponse(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in || this.fallbackAccessTokenExpirationTimeInSec, tokenResponse.scope, this.extractRecognizedCustomParameters(tokenResponse)); this.eventsSubject.next(new OAuthSuccessEvent('token_received')); this.eventsSubject.next(new OAuthSuccessEvent('token_refreshed')); resolve(tokenResponse); }, (err) => { this.logger.error('Error refreshing token', err); this.eventsSubject.next(new OAuthErrorEvent('token_refresh_error', err)); reject(err); }); }); } removeSilentRefreshEventListener() { if (this.silentRefreshPostMessageEventListener) { window.removeEventListener('message', this.silentRefreshPostMessageEventListener); this.silentRefreshPostMessageEventListener = null; } } setupSilentRefreshEventListener() { this.removeSilentRefreshEventListener(); this.silentRefreshPostMessageEventListener = (e) => { const message = this.processMessageEventMessage(e); if (this.checkOrigin && e.origin !== location.origin) { console.error('wrong origin requested silent refresh!'); } this.tryLogin({ customHashFragment: message, preventClearHashAfterLogin: true, customRedirectUri: this.silentRefreshRedirectUri || this.redirectUri, }).catch((err) => this.debug('tryLogin during silent refresh failed', err)); }; window.addEventListener('message', this.silentRefreshPostMessageEventListener); } /** * Performs a silent refresh for implicit flow. * Use this method to get new tokens when/before * the existing tokens expire. */ silentRefresh(params = {}, noPrompt = true) { const claims = this.getIdentityClaims() || {}; if (this.useIdTokenHintForSilentRefresh && this.hasValidIdToken()) { params['id_token_hint'] = this.getIdToken(); } if (!this.validateUrlForHttps(this.loginUrl)) { throw new Error("loginUrl must use HTTPS (with TLS), or config value for property 'requireHttps' must be set to 'false' and allow HTTP (without TLS)."); } if (typeof this.document === 'undefined') { throw new Error('silent refresh is not supported on this platform'); } const existingIframe = this.document.getElementById(this.silentRefreshIFrameName); if (existingIframe) { this.document.body.removeChild(existingIframe); } this.silentRefreshSubject = claims['sub']; const iframe = this.document.createElement('iframe'); iframe.id = this.silentRefreshIFrameName; this.setupSilentRefreshEventListener(); const redirectUri = this.silentRefreshRedirectUri || this.redirectUri; this.createLoginUrl(null, null, redirectUri, noPrompt, params).then((url) => { iframe.setAttribute('src', url); if (!this.silentRefreshShowIFrame) { iframe.style['display'] = 'none'; } this.document.body.appendChild(iframe); }); const errors = this.events.pipe(filter((e) => e instanceof OAuthErrorEvent), first()); const success = this.events.pipe(filter((e) => e.type === 'token_received'), first()); const timeout = of(new OAuthErrorEvent('silent_refresh_timeout', null)).pipe(delay(this.silentRefreshTimeout)); return race([errors, success, timeout]) .pipe(map((e) => { if (e instanceof OAuthErrorEvent) { if (e.type === 'silent_refresh_timeout') { this.eventsSubject.next(e); } else { e = new OAuthErrorEvent('silent_refresh_error', e); this.eventsSubject.next(e); } throw e; } else if (e.type === 'token_received') { e = new OAuthSuccessEvent('silently_refreshed'); this.eventsSubject.next(e); } return e; })) .toPromise(); } /** * This method exists for backwards compatibility. * {@link OAuthService#initLoginFlowInPopup} handles both code * and implicit flows. */ initImplicitFlowInPopup(options) { return this.initLoginFlowInPopup(options); } initLoginFlowInPopup(options) { options = options || {}; return this.createLoginUrl(null, null, this.silentRefreshRedirectUri, false, { display: 'popup', }).then((url) => { return new Promise((resolve, reject) => { /** * Error handling section */ const checkForPopupClosedInterval = 500; let windowRef = null; // If we got no window reference we open a window // else we are using the window already opened if (!options.windowRef) { windowRef = window.open(url, 'ngx-oauth2-oidc-login', this.calculatePopupFeatures(options)); } else if (options.windowRef && !options.windowRef.closed) { windowRef = options.windowRef; windowRef.location.href = url; } let checkForPopupClosedTimer; const tryLogin = (hash) => { this.tryLogin({ customHashFragment: hash, preventClearHashAfterLogin: true, customRedirectUri: this.silentRefreshRedirectUri, }).then(() => { cleanup(); resolve(true); }, (err) => { cleanup(); reject(err); }); }; const checkForPopupClosed = () => { if (!windowRef || windowRef.closed) { cleanup(); reject(new OAuthErrorEvent('popup_closed', {})); } }; if (!windowRef) { reject(new OAuthErrorEvent('popup_blocked', {})); } else { checkForPopupClosedTimer = window.setInterval(checkForPopupClosed, checkForPopupClosedInterval); } const cleanup = () => { window.clearInterval(checkForPopupClosedTimer); window.removeEventListener('storage', storageListener); window.removeEventListener('message', listener); if (windowRef !== null) { windowRef.close(); } windowRef = null; }; const listener = (e) => { const message = this.processMessageEventMessage(e); if (message && message !== null) { window.removeEventListener('storage', storageListener); tryLogin(message); } else { console.log('false event firing'); } }; const storageListener = (event) => { if (event.key === 'auth_hash') { window.removeEventListener('message', listener); tryLogin(event.newValue); } }; window.addEventListener('message', listener); window.addEventListener('storage', storageListener); }); }); } calculatePopupFeatures(options) { // Specify an static height and width and calculate centered position const height = options.height || 470; const width = options.width || 500; const left = window.screenLeft + (window.outerWidth - width) / 2; const top = window.screenTop + (window.outerHeight - height) / 2; return `location=no,toolbar=no,width=${width},height=${height},top=${top},left=${left}`; } processMessageEventMessage(e) { let expectedPrefix = '#'; if (this.silentRefreshMessagePrefix) { expectedPrefix += this.silentRefreshMessagePrefix; } if (!e || !e.data || typeof e.data !== 'string') { return; } const prefixedMessage = e.data; if (!prefixedMessage.startsWith(expectedPrefix)) { return; } return '#' + prefixedMessage.substr(expectedPrefix.length); } canPerformSessionCheck() { if (!this.sessionChecksEnabled) { return false; } if (!this.sessionCheckIFrameUrl) { console.warn('sessionChecksEnabled is activated but there is no sessionCheckIFrameUrl'); return false; } const sessionState = this.getSessionState(); if (!sessionState) { console.warn('sessionChecksEnabled is activated but there is no session_state'); return false; } if (typeof this.document === 'undefined') { return false; } return true; } setupSessionCheckEventListener() { this.removeSessionCheckEventListener(); this.sessionCheckEventListener = (e) => { const origin = e.origin.toLowerCase(); const issuer = this.issuer.toLowerCase(); this.debug('sessionCheckEventListener'); if (!issuer.startsWith(origin)) { this.debug('sessionCheckEventListener', 'wrong origin', origin, 'expected', issuer, 'event', e); return; } // only run in Angular zone if it is 'changed' or 'error' switch (e.data) { case 'unchanged': this.ngZone.run(() => { this.handleSessionUnchanged(); }); break; case 'changed': this.ngZone.run(() => { this.handleSessionChange(); }); break; case 'error': this.ngZone.run(() => { this.handleSessionError(); }); break; } this.debug('got info from session check inframe', e); }; // prevent Angular from refreshing the view on every message (runs in intervals) this.ngZone.runOutsideAngular(() => { window.addEventListener('message', this.sessionCheckEventListener); }); } handleSessionUnchanged() { this.debug('session check', 'session unchanged'); this.eventsSubject.next(new OAuthInfoEvent('session_unchanged')); } handleSessionChange() { this.eventsSubject.next(new OAuthInfoEvent('session_changed')); this.stopSessionCheckTimer(); if (!this.useSilentRefresh && this.responseType === 'code') { this.refreshToken() .then(() => { this.debug('token refresh after session change worked'); }) .catch(() => { this.debug('token refresh did not work after session changed'); this.eventsSubject.next(new OAuthInfoEvent('session_terminated')); this.logOut(true); }); } else if (this.silentRefreshRedirectUri) { this.silentRefresh().catch(() => this.debug('silent refresh failed after session changed')); this.waitForSilentRefreshAfterSessionChange(); } else { this.eventsSubject.next(new OAuthInfoEvent('session_terminated')); this.logOut(true); } } waitForSilentRefreshAfterSessionChange() { this.events .pipe(filter((e) => e.type === 'silently_refreshed' || e.type === 'silent_refresh_timeout' || e.type === 'silent_refresh_error'), first()) .subscribe((e) => { if (e.type !== 'silently_refreshed') { this.debug('silent refresh did not work after session changed'); this.eventsSubject.next(new OAuthInfoEvent('session_terminated')); this.logOut(true); } }); } handleSessionError() { this.stopSessionCheckTimer(); this.eventsSubject.next(new OAuthInfoEvent('session_error')); } removeSessionCheckEventListener() { if (this.sessionCheckEventListener) { window.removeEventListener('message', this.sessionCheckEventListener); this.sessionCheckEventListener = null; } } initSessionCheck() { if (!this.canPerformSessionCheck()) { return; } const existingIframe = this.document.getElementById(this.sessionCheckIFrameName); if (existingIframe) { this.document.body.removeChild(existingIframe); } const iframe = this.document.createElement('iframe'); iframe.id = this.sessionCheckIFrameName; this.setupSessionCheckEventListener(); const url = this.sessionCheckIFrameUrl; iframe.setAttribute('src', url); iframe.style.display = 'none'; this.document.body.appendChild(iframe); this.startSessionCheckTimer(); } startSessionCheckTimer() { this.stopSessionCheckTimer(); this.ngZone.runOutsideAngular(() => { this.sessionCheckTimer = setInterval(this.checkSession.bind(this), this.sessionCheckIntervall); }); } stopSessionCheckTimer() { if (this.sessionCheckTimer) { clearInterval(this.sessionCheckTimer); this.sessionCheckTimer = null; } } checkSession() { const iframe = this.document.getElementById(this.sessionCheckIFrameName); if (!iframe) { this.logger.warn('checkSession did not find iframe', this.sessionCheckIFrameName); } const sessionState = this.getSessionState(); if (!sessionState) { this.stopSessionCheckTimer(); } const message = this.clientId + ' ' + sessionState; iframe.contentWindow.postMessage(message, this.issuer); } async createLoginUrl(state = '', loginHint = '', customRedirectUri = '', noPrompt = false, params = {}) { const that = this; // eslint-disable-line @typescript-eslint/no-this-alias let redirectUri; if (customRedirectUri) { redirectUri = customRedirectUri; } else { redirectUri = this.redirectUri; } const nonce = await this.createAndSaveNonce(); if (state) { state = nonce + this.config.nonceStateSeparator + encodeURIComponent(state); } else { state = nonce; } if (!this.requestAccessToken && !this.oidc) { throw new Error('Either requestAccessToken or oidc or both must be true'); } if (this.config.responseType) { this.responseType = this.config.responseType; } else { if (this.oidc && this.requestAccessToken) { this.responseType = 'id_token token'; } else if (this.oidc && !this.requestAccessToken) { this.responseType = 'id_token'; } else { this.responseType = 'token'; } } const seperationChar = that.loginUrl.indexOf('?') > -1 ? '&' : '?'; let scope = that.scope; if (this.oidc && !scope.match(/(^|\s)openid($|\s)/)) { scope = 'openid ' + scope; } let url = that.loginUrl + seperationChar + 'response_type=' + encodeURIComponent(that.responseType) + '&client_id=' + encodeURIComponent(that.clientId) + '&state=' + encodeURIComponent(state) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&scope=' + encodeURIComponent(scope); if (this.responseType.includes('code') && !this.disablePKCE) { const [challenge, verifier] = await this.createChallangeVerifierPairForPKCE(); if (this.saveNoncesInLocalStorage && typeof window['localStorage'] !== 'undefined') { localStorage.setItem('PKCE_verifier', verifier); } else { this._storage.setItem('PKCE_verifier', verifier); } url += '&code_challenge=' + challenge; url += '&code_challenge_method=S256'; } if (loginHint) { url += '&login_hint=' + encodeURIComponent(loginHint); } if (that.resource) { url += '&resource=' + encodeURIComponent(that.resource); } if (that.oidc) { url += '&nonce=' + encodeURIComponent(nonce); } if (noPrompt) { url += '&prompt=none'; } for (const key of Object.keys(params)) { url += '&' + encodeURIComponent(key) + '=' + encodeURIComponent(params[key]); } if (this.customQueryParams) { for (const key of Object.getOwnPropertyNames(this.customQueryParams)) { url += '&' + key + '=' + encodeURIComponent(this.customQueryParams[key]); } } return url; } initImplicitFlowInternal(additionalState = '', params = '') { if (this.inImplicitFlow) { return; } this.inImplicitFlow = true; if (!this.validateUrlForHttps(this.loginUrl)) { throw new Error("loginUrl must use HTTPS (with TLS), or config value for property 'requireHttps' must be set to 'false' and allow HTTP (without TLS)."); } let addParams = {}; let loginHint = null; if (typeof params === 'string') { loginHint = params; } else if (typeof params === 'object') { addParams = params; } this.createLoginUrl(additionalState, loginHint, null, false, addParams) .then(this.config.openUri) .catch((error) => { console.error('Error in initImplicitFlow', error); this.inImplicitFlow = false; }); } /** * Starts the implicit flow and redirects to user to * the auth servers' login url. * * @param additionalState Optional state that is passed around. * You'll find this state in the property `state` after `tryLogin` logged in the user. * @param params Hash with additional parameter. If it is a string, it is used for the * parameter loginHint (for the sake of compatibility with former versions) */ initImplicitFlow(additionalState = '', params = '') { if (this.loginUrl !== '') { this.initImplicitFlowInternal(additionalState, params); } else { this.events .pipe(filter((e) => e.type === 'discovery_document_loaded')) .subscribe(() => this.initImplicitFlowInternal(additionalState, params)); } } /** * Reset current implicit flow * * @description This method allows resetting the current implict flow in order to be initialized again. */ resetImplicitFlow() { this.inImplicitFlow = false; } callOnTokenReceivedIfExists(options) { const that = this; // eslint-disable-line @typescript-eslint/no-this-alias if (options.onTokenReceived) { const tokenParams = { idClaims: that.getIdentityClaims(), idToken: that.getIdToken(), accessToken: that.getAccessToken(), state: that.state, }; options.onTokenReceived(tokenParams); } } storeAccessTokenResponse(accessToken, refreshToken, expiresIn, grantedScopes, customParameters) { this._storage.setItem('access_token', accessToken); if (grantedScopes && !Array.isArray(grantedScopes)) { this._storage.setItem('granted_scopes', JSON.stringify(grantedScopes.split(' '))); } else if (grantedScopes && Array.isArray(grantedScopes)) { this._storage.setItem('granted_scopes', JSON.stringify(grantedScopes)); } this._storage.setItem('access_token_stored_at', '' + this.dateTimeService.now()); if (expiresIn) { const expiresInMilliSeconds = expiresIn * 1000; const now = this.dateTimeService.new(); const expiresAt = now.getTime() + expiresInMilliSeconds; this._storage.setItem('expires_at', '' + expiresAt); } if (refreshToken) { this._storage.setItem('refresh_token', refreshToken); } if (customParameters) { customParameters.forEach((value, key) => { this._storage.setItem(key, value); }); } } /** * Delegates to tryLoginImplicitFlow for the sake of competability * @param options Optional options. */ tryLogin(options = null) { if (this.config.responseType === 'code') { return this.tryLoginCodeFlow(options).then(() => true); } else { return this.tryLoginImplicitFlow(options); } } parseQueryString(queryString) { if (!qu