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
JavaScript
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