angular-oauth2-oidc
Version:
Support for OAuth 2 and OpenId Connect (OIDC) in Angular.
1,446 lines (1,433 loc) • 85.6 kB
JavaScript
import { __awaiter } from 'tslib';
import { Injectable, NgZone, Optional, NgModule, InjectionToken } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams, HTTP_INTERCEPTORS } from '@angular/common/http';
import { Subject, of, race, throwError } from 'rxjs';
import { filter, delay, first, tap, map, catchError } from 'rxjs/operators';
import { CommonModule } from '@angular/common';
import { KEYUTIL, KJUR } from 'jsrsasign';
/**
* @fileoverview added by tsickle
* @suppress {checkTypes,extraRequire,uselessCode} checked by tsc
*/
/**
* Additional options that can be passt to tryLogin.
*/
class LoginOptions {
constructor() {
/**
* Normally, you want to clear your hash fragment after
* the lib read the token(s) so that they are not displayed
* anymore in the url. If not, set this to true.
*/
this.preventClearHashAfterLogin = false;
}
}
/**
* Defines the logging interface the OAuthService uses
* internally. Is compatible with the `console` object,
* but you can provide your own implementation as well
* through dependency injection.
* @abstract
*/
class OAuthLogger {
}
/**
* Defines a simple storage that can be used for
* storing the tokens at client side.
* Is compatible to localStorage and sessionStorage,
* but you can also create your own implementations.
* @abstract
*/
class OAuthStorage {
}
/**
* Represents the received tokens, the received state
* and the parsed claims from the id-token.
*/
class ReceivedTokens {
}
/**
* Represents the parsed and validated id_token.
* @record
*/
/**
* Represents the response from the token endpoint
* http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint
* @record
*/
/**
* Represents the response from the user info endpoint
* http://openid.net/specs/openid-connect-core-1_0.html#UserInfo
* @record
*/
/**
* Represents an OpenID Connect discovery document
* @record
*/
/**
* @fileoverview added by tsickle
* @suppress {checkTypes,extraRequire,uselessCode} checked by tsc
*/
/**
* @record
*/
/**
* Interface for Handlers that are hooked in to
* validate tokens.
* @abstract
*/
class ValidationHandler {
}
/**
* This abstract implementation of ValidationHandler already implements
* the method validateAtHash. However, to make use of it,
* you have to override the method calcHash.
* @abstract
*/
class AbstractValidationHandler {
/**
* Validates the at_hash in an id_token against the received access_token.
* @param {?} params
* @return {?}
*/
validateAtHash(params) {
return __awaiter(this, void 0, void 0, function* () {
/** @type {?} */
let hashAlg = this.inferHashAlgorithm(params.idTokenHeader);
/** @type {?} */
let tokenHash = yield this.calcHash(params.accessToken, hashAlg);
// sha256(accessToken, { asString: true });
/** @type {?} */
let leftMostHalf = tokenHash.substr(0, tokenHash.length / 2);
/** @type {?} */
let tokenHashBase64 = btoa(leftMostHalf);
/** @type {?} */
let atHash = tokenHashBase64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
/** @type {?} */
let claimsAtHash = params.idTokenClaims['at_hash'].replace(/=/g, '');
if (atHash !== claimsAtHash) {
console.error('exptected at_hash: ' + atHash);
console.error('actual at_hash: ' + claimsAtHash);
}
return atHash === claimsAtHash;
});
}
/**
* Infers the name of the hash algorithm to use
* from the alg field of an id_token.
*
* @param {?} jwtHeader the id_token's parsed header
* @return {?}
*/
inferHashAlgorithm(jwtHeader) {
/** @type {?} */
let alg = jwtHeader['alg'];
if (!alg.match(/^.S[0-9]{3}$/)) {
throw new Error('Algorithm not supported: ' + alg);
}
return 'sha-' + alg.substr(2);
}
}
/**
* @fileoverview added by tsickle
* @suppress {checkTypes,extraRequire,uselessCode} checked by tsc
*/
class UrlHelperService {
/**
* @param {?=} customHashFragment
* @return {?}
*/
getHashFragmentParams(customHashFragment) {
/** @type {?} */
let hash = customHashFragment || window.location.hash;
hash = decodeURIComponent(hash);
if (hash.indexOf('#') !== 0) {
return {};
}
/** @type {?} */
const questionMarkPosition = hash.indexOf('?');
if (questionMarkPosition > -1) {
hash = hash.substr(questionMarkPosition + 1);
}
else {
hash = hash.substr(1);
}
return this.parseQueryString(hash);
}
/**
* @param {?} queryString
* @return {?}
*/
parseQueryString(queryString) {
/** @type {?} */
const data = {};
/** @type {?} */
let pairs;
/** @type {?} */
let pair;
/** @type {?} */
let separatorIndex;
/** @type {?} */
let escapedKey;
/** @type {?} */
let escapedValue;
/** @type {?} */
let key;
/** @type {?} */
let value;
if (queryString === null) {
return data;
}
pairs = queryString.split('&');
for (let i = 0; i < pairs.length; i++) {
pair = pairs[i];
separatorIndex = pair.indexOf('=');
if (separatorIndex === -1) {
escapedKey = pair;
escapedValue = null;
}
else {
escapedKey = pair.substr(0, separatorIndex);
escapedValue = pair.substr(separatorIndex + 1);
}
key = decodeURIComponent(escapedKey);
value = decodeURIComponent(escapedValue);
if (key.substr(0, 1) === '/') {
key = key.substr(1);
}
data[key] = value;
}
return data;
}
}
UrlHelperService.decorators = [
{ type: Injectable },
];
/**
* @fileoverview added by tsickle
* @suppress {checkTypes,extraRequire,uselessCode} checked by tsc
*/
/**
* @abstract
*/
class OAuthEvent {
/**
* @param {?} type
*/
constructor(type) {
this.type = type;
}
}
class OAuthSuccessEvent extends OAuthEvent {
/**
* @param {?} type
* @param {?=} info
*/
constructor(type, info = null) {
super(type);
this.info = info;
}
}
class OAuthInfoEvent extends OAuthEvent {
/**
* @param {?} type
* @param {?=} info
*/
constructor(type, info = null) {
super(type);
this.info = info;
}
}
class OAuthErrorEvent extends OAuthEvent {
/**
* @param {?} type
* @param {?} reason
* @param {?=} params
*/
constructor(type, reason, params = null) {
super(type);
this.reason = reason;
this.params = params;
}
}
/**
* @fileoverview added by tsickle
* @suppress {checkTypes,extraRequire,uselessCode} checked by tsc
*/
// see: https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding#The_.22Unicode_Problem.22
/**
* @param {?} str
* @return {?}
*/
function b64DecodeUnicode(str) {
/** @type {?} */
const base64 = str.replace(/\-/g, '+').replace(/\_/g, '/');
return decodeURIComponent(atob(base64)
.split('')
.map(function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
})
.join(''));
}
/**
* @fileoverview added by tsickle
* @suppress {checkTypes,extraRequire,uselessCode} checked by tsc
*/
class AuthConfig {
/**
* @param {?=} json
*/
constructor(json) {
/**
* The client's id as registered with the auth server
*/
this.clientId = '';
/**
* The client's redirectUri as registered with the auth server
*/
this.redirectUri = '';
/**
* An optional second redirectUri where the auth server
* redirects the user to after logging out.
*/
this.postLogoutRedirectUri = '';
/**
* The auth server's endpoint that allows to log
* the user in when using implicit flow.
*/
this.loginUrl = '';
/**
* The requested scopes
*/
this.scope = 'openid profile';
this.resource = '';
this.rngUrl = '';
/**
* Defines whether to use OpenId Connect during
* implicit flow.
*/
this.oidc = true;
/**
* Defines whether to request an access token during
* implicit flow.
*/
this.requestAccessToken = true;
this.options = null;
/**
* The issuer's uri.
*/
this.issuer = '';
/**
* The logout url.
*/
this.logoutUrl = '';
/**
* Defines whether to clear the hash fragment after logging in.
*/
this.clearHashAfterLogin = true;
/**
* Url of the token endpoint as defined by OpenId Connect and OAuth 2.
*/
this.tokenEndpoint = null;
/**
* Url of the userinfo endpoint as defined by OpenId Connect.
*/
this.userinfoEndpoint = null;
this.responseType = '';
/**
* Defines whether additional debug information should
* be shown at the console. Note that in certain browsers
* the verbosity of the console needs to be explicitly set
* to include Debug level messages.
*/
this.showDebugInformation = false;
/**
* The redirect uri used when doing silent refresh.
*/
this.silentRefreshRedirectUri = '';
this.silentRefreshMessagePrefix = '';
/**
* Set this to true to display the iframe used for
* silent refresh for debugging.
*/
this.silentRefreshShowIFrame = false;
/**
* Timeout for silent refresh.
* \@internal
* depreacted b/c of typo, see silentRefreshTimeout
*/
this.siletRefreshTimeout = 1000 * 20;
/**
* Timeout for silent refresh.
*/
this.silentRefreshTimeout = 1000 * 20;
/**
* Some auth servers don't allow using password flow
* w/o a client secret while the standards do not
* demand for it. In this case, you can set a password
* here. As this password is exposed to the public
* it does not bring additional security and is therefore
* as good as using no password.
*/
this.dummyClientSecret = null;
/**
* Defines whether https is required.
* The default value is remoteOnly which only allows
* http for localhost, while every other domains need
* to be used with https.
*/
this.requireHttps = 'remoteOnly';
/**
* Defines whether every url provided by the discovery
* document has to start with the issuer's url.
*/
this.strictDiscoveryDocumentValidation = true;
/**
* JSON Web Key Set (https://tools.ietf.org/html/rfc7517)
* with keys used to validate received id_tokens.
* This is taken out of the disovery document. Can be set manually too.
*/
this.jwks = null;
/**
* Map with additional query parameter that are appended to
* the request when initializing implicit flow.
*/
this.customQueryParams = null;
this.silentRefreshIFrameName = 'angular-oauth-oidc-silent-refresh-iframe';
/**
* Defines when the token_timeout event should be raised.
* If you set this to the default value 0.75, the event
* is triggered after 75% of the token's life time.
*/
this.timeoutFactor = 0.75;
/**
* If true, the lib will try to check whether the user
* is still logged in on a regular basis as described
* in http://openid.net/specs/openid-connect-session-1_0.html#ChangeNotification
*/
this.sessionChecksEnabled = false;
/**
* Interval in msec for checking the session
* according to http://openid.net/specs/openid-connect-session-1_0.html#ChangeNotification
*/
this.sessionCheckIntervall = 3 * 1000;
/**
* Url for the iframe used for session checks
*/
this.sessionCheckIFrameUrl = null;
/**
* Name of the iframe to use for session checks
*/
this.sessionCheckIFrameName = 'angular-oauth-oidc-check-session-iframe';
/**
* This property has been introduced to disable at_hash checks
* and is indented for Identity Provider that does not deliver
* an at_hash EVEN THOUGH its recommended by the OIDC specs.
* Of course, when disabling these checks the we are bypassing
* a security check which means we are more vulnerable.
*/
this.disableAtHashCheck = false;
/**
* Defines wether to check the subject of a refreshed token after silent refresh.
* Normally, it should be the same as before.
*/
this.skipSubjectCheck = false;
this.useIdTokenHintForSilentRefresh = false;
/**
* Defined whether to skip the validation of the issuer in the discovery document.
* Normally, the discovey document's url starts with the url of the issuer.
*/
this.skipIssuerCheck = false;
/**
* final state sent to issuer is built as follows:
* state = nonce + nonceStateSeparator + additional state
* Default separator is ';' (encoded %3B).
* In rare cases, this character might be forbidden or inconvenient to use by the issuer so it can be customized.
*/
this.nonceStateSeparator = ';';
/**
* Set this to true to use HTTP BASIC auth for password flow
*/
this.useHttpBasicAuthForPasswordFlow = false;
/**
* This property allows you to override the method that is used to open the login url,
* allowing a way for implementations to specify their own method of routing to new
* urls.
*/
this.openUri = uri => {
location.href = uri;
};
if (json) {
Object.assign(this, json);
}
}
}
/**
* @fileoverview added by tsickle
* @suppress {checkTypes,extraRequire,uselessCode} checked by tsc
*/
/**
* This custom encoder allows charactes like +, % and / to be used in passwords
*/
class WebHttpUrlEncodingCodec {
/**
* @param {?} k
* @return {?}
*/
encodeKey(k) {
return encodeURIComponent(k);
}
/**
* @param {?} v
* @return {?}
*/
encodeValue(v) {
return encodeURIComponent(v);
}
/**
* @param {?} k
* @return {?}
*/
decodeKey(k) {
return decodeURIComponent(k);
}
/**
* @param {?} v
* @return {?}
*/
decodeValue(v) {
return decodeURIComponent(v);
}
}
/**
* @fileoverview added by tsickle
* @suppress {checkTypes,extraRequire,uselessCode} checked by tsc
*/
/**
* Service for logging in and logging out with
* OIDC and OAuth2. Supports implicit flow and
* password flow.
*/
class OAuthService extends AuthConfig {
/**
* @param {?} ngZone
* @param {?} http
* @param {?} storage
* @param {?} tokenValidationHandler
* @param {?} config
* @param {?} urlHelper
* @param {?} logger
*/
constructor(ngZone, http, storage, tokenValidationHandler, config, urlHelper, logger) {
super();
this.ngZone = ngZone;
this.http = http;
this.config = config;
this.urlHelper = urlHelper;
this.logger = logger;
/**
* \@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.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);
}
this.setupRefreshTimer();
}
/**
* Use this method to configure the service
* @param {?} config the configuration
* @return {?}
*/
configure(config) {
// For the sake of downward compatibility with
// original configuration API
Object.assign(this, new AuthConfig(), config);
this.config = Object.assign((/** @type {?} */ ({})), new AuthConfig(), config);
if (this.sessionChecksEnabled) {
this.setupSessionCheck();
}
this.configChanged();
}
/**
* @return {?}
*/
configChanged() { }
/**
* @return {?}
*/
restartSessionChecksIfStillLoggedIn() {
if (this.hasValidIdToken()) {
this.initSessionCheck();
}
}
/**
* @return {?}
*/
restartRefreshTimerIfStillLoggedIn() {
this.setupExpirationTimers();
}
/**
* @return {?}
*/
setupSessionCheck() {
this.events.pipe(filter(e => e.type === 'token_received')).subscribe(e => {
this.initSessionCheck();
});
}
/**
* Will setup up silent refreshing for when the token is
* about to expire.
* @param {?=} params Additional parameter to pass
* @return {?}
*/
setupAutomaticSilentRefresh(params = {}) {
this.events.pipe(filter(e => e.type === 'token_expires')).subscribe(e => {
this.silentRefresh(params).catch(_ => {
this.debug('Automatic silent refresh did not work');
});
});
this.restartRefreshTimerIfStillLoggedIn();
}
/**
* 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(...)`
* @return {?}
*/
loadDiscoveryDocumentAndTryLogin(options = null) {
return this.loadDiscoveryDocument().then(doc => {
return this.tryLogin(options);
});
}
/**
* Convenience method that first calls `loadDiscoveryDocumentAndTryLogin(...)`
* and if then chains to `initImplicitFlow()`, but only if there is no valid
* IdToken or no valid AccessToken.
*
* @param {?=} options LoginOptions to pass through to `tryLogin(...)`
* @return {?}
*/
loadDiscoveryDocumentAndLogin(options = null) {
return this.loadDiscoveryDocumentAndTryLogin(options).then(_ => {
if (!this.hasValidIdToken() || !this.hasValidAccessToken()) {
this.initImplicitFlow();
return false;
}
else {
return true;
}
});
}
/**
* @param {...?} args
* @return {?}
*/
debug(...args) {
if (this.showDebugInformation) {
this.logger.debug.apply(console, args);
}
}
/**
* @param {?} url
* @return {?}
*/
validateUrlFromDiscoveryDocument(url) {
/** @type {?} */
const errors = [];
/** @type {?} */
const httpsCheck = this.validateUrlForHttps(url);
/** @type {?} */
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;
}
/**
* @param {?} url
* @return {?}
*/
validateUrlForHttps(url) {
if (!url) {
return true;
}
/** @type {?} */
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://');
}
/**
* @param {?} url
* @return {?}
*/
validateUrlAgainstIssuer(url) {
if (!this.strictDiscoveryDocumentValidation) {
return true;
}
if (!url) {
return true;
}
return url.toLowerCase().startsWith(this.issuer.toLowerCase());
}
/**
* @return {?}
*/
setupRefreshTimer() {
if (typeof window === 'undefined') {
this.debug('timer not supported on this plattform');
return;
}
if (this.hasValidIdToken()) {
this.clearAccessTokenTimer();
this.clearIdTokenTimer();
this.setupExpirationTimers();
}
this.events.pipe(filter(e => e.type === 'token_received')).subscribe(_ => {
this.clearAccessTokenTimer();
this.clearIdTokenTimer();
this.setupExpirationTimers();
});
}
/**
* @return {?}
*/
setupExpirationTimers() {
/** @type {?} */
const idTokenExp = this.getIdTokenExpiration() || Number.MAX_VALUE;
/** @type {?} */
const accessTokenExp = this.getAccessTokenExpiration() || Number.MAX_VALUE;
/** @type {?} */
const useAccessTokenExp = accessTokenExp <= idTokenExp;
if (this.hasValidAccessToken() && useAccessTokenExp) {
this.setupAccessTokenTimer();
}
if (this.hasValidIdToken() && !useAccessTokenExp) {
this.setupIdTokenTimer();
}
}
/**
* @return {?}
*/
setupAccessTokenTimer() {
/** @type {?} */
const expiration = this.getAccessTokenExpiration();
/** @type {?} */
const storedAt = this.getAccessTokenStoredAt();
/** @type {?} */
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);
});
});
});
}
/**
* @return {?}
*/
setupIdTokenTimer() {
/** @type {?} */
const expiration = this.getIdTokenExpiration();
/** @type {?} */
const storedAt = this.getIdTokenStoredAt();
/** @type {?} */
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);
});
});
});
}
/**
* @return {?}
*/
clearAccessTokenTimer() {
if (this.accessTokenTimeoutSubscription) {
this.accessTokenTimeoutSubscription.unsubscribe();
}
}
/**
* @return {?}
*/
clearIdTokenTimer() {
if (this.idTokenTimeoutSubscription) {
this.idTokenTimeoutSubscription.unsubscribe();
}
}
/**
* @param {?} storedAt
* @param {?} expiration
* @return {?}
*/
calcTimeout(storedAt, expiration) {
/** @type {?} */
const delta = (expiration - storedAt) * this.timeoutFactor;
return delta;
}
/**
* DEPRECATED. Use a provider for OAuthStorage instead:
*
* { provide: OAuthStorage, useValue: 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
* @return {?}
*/
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
* @return {?}
*/
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, or config value for property requireHttps must allow http');
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.jwksUri = doc.jwks_uri;
this.sessionCheckIFrameUrl = doc.check_session_iframe || this.sessionCheckIFrameUrl;
this.discoveryDocumentLoaded = true;
this.discoveryDocumentLoadedSubject.next(doc);
if (this.sessionChecksEnabled) {
this.restartSessionChecksIfStillLoggedIn();
}
this.loadJwks()
.then(jwks => {
/** @type {?} */
const result = {
discoveryDocument: doc,
jwks: jwks
};
/** @type {?} */
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);
});
});
}
/**
* @return {?}
*/
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);
}
});
}
/**
* @param {?} doc
* @return {?}
*/
validateDiscoveryDocument(doc) {
/** @type {?} */
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.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.
* @return {?}
*/
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.
* @return {?}
*/
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 http, or config value for property requireHttps must allow http');
}
return new Promise((resolve, reject) => {
/** @type {?} */
const headers = new HttpHeaders().set('Authorization', 'Bearer ' + this.getAccessToken());
this.http.get(this.userinfoEndpoint, { headers }).subscribe(info => {
this.debug('userinfo received', info);
/** @type {?} */
const existingClaims = this.getIdentityClaims() || {};
if (!this.skipSubjectCheck) {
if (this.oidc &&
(!existingClaims['sub'] || info.sub !== existingClaims['sub'])) {
/** @type {?} */
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);
}, 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.
* @return {?}
*/
fetchTokenUsingPasswordFlow(userName, password, headers = new HttpHeaders()) {
if (!this.validateUrlForHttps(this.tokenEndpoint)) {
throw new Error('tokenEndpoint must use http, or config value for property requireHttps must allow http');
}
return new Promise((resolve, reject) => {
/**
* A `HttpParameterCodec` that uses `encodeURIComponent` and `decodeURIComponent` to
* serialize and parse URL parameter keys and values.
*
* \@stable
* @type {?}
*/
let params = new HttpParams({ encoder: new WebHttpUrlEncodingCodec() })
.set('grant_type', 'password')
.set('scope', this.scope)
.set('username', userName)
.set('password', password);
if (this.useHttpBasicAuthForPasswordFlow) {
/** @type {?} */
const header = btoa(`${this.clientId}:${this.dummyClientSecret}`);
headers = headers.set('Authorization', 'Basic ' + header);
}
if (!this.useHttpBasicAuthForPasswordFlow) {
params = params.set('client_id', this.clientId);
}
if (!this.useHttpBasicAuthForPasswordFlow && 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]);
}
}
headers = headers.set('Content-Type', 'application/x-www-form-urlencoded');
this.http
.post(this.tokenEndpoint, params, { headers })
.subscribe(tokenResponse => {
this.debug('tokenResponse', tokenResponse);
this.storeAccessTokenResponse(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in, tokenResponse.scope);
this.eventsSubject.next(new OAuthSuccessEvent('token_received'));
resolve(tokenResponse);
}, err => {
this.logger.error('Error performing password 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.
* @return {?}
*/
refreshToken() {
if (!this.validateUrlForHttps(this.tokenEndpoint)) {
throw new Error('tokenEndpoint must use http, or config value for property requireHttps must allow http');
}
return new Promise((resolve, reject) => {
/** @type {?} */
let params = new HttpParams()
.set('grant_type', 'refresh_token')
.set('client_id', this.clientId)
.set('scope', this.scope)
.set('refresh_token', this._storage.getItem('refresh_token'));
if (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]);
}
}
/** @type {?} */
const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded');
this.http
.post(this.tokenEndpoint, params, { headers })
.subscribe(tokenResponse => {
this.debug('refresh tokenResponse', tokenResponse);
this.storeAccessTokenResponse(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in, tokenResponse.scope);
this.eventsSubject.next(new OAuthSuccessEvent('token_received'));
this.eventsSubject.next(new OAuthSuccessEvent('token_refreshed'));
resolve(tokenResponse);
}, err => {
this.logger.error('Error performing password flow', err);
this.eventsSubject.next(new OAuthErrorEvent('token_refresh_error', err));
reject(err);
});
});
}
/**
* @return {?}
*/
removeSilentRefreshEventListener() {
if (this.silentRefreshPostMessageEventListener) {
window.removeEventListener('message', this.silentRefreshPostMessageEventListener);
this.silentRefreshPostMessageEventListener = null;
}
}
/**
* @return {?}
*/
setupSilentRefreshEventListener() {
this.removeSilentRefreshEventListener();
this.silentRefreshPostMessageEventListener = (e) => {
/** @type {?} */
let expectedPrefix = '#';
if (this.silentRefreshMessagePrefix) {
expectedPrefix += this.silentRefreshMessagePrefix;
}
if (!e || !e.data || typeof e.data !== 'string') {
return;
}
/** @type {?} */
const prefixedMessage = e.data;
if (!prefixedMessage.startsWith(expectedPrefix)) {
return;
}
/** @type {?} */
const message = '#' + prefixedMessage.substr(expectedPrefix.length);
this.tryLogin({
customHashFragment: message,
preventClearHashAfterLogin: true,
onLoginError: err => {
this.eventsSubject.next(new OAuthErrorEvent('silent_refresh_error', err));
},
onTokenReceived: () => {
this.eventsSubject.next(new OAuthSuccessEvent('silently_refreshed'));
}
}).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.
* @param {?=} params
* @param {?=} noPrompt
* @return {?}
*/
silentRefresh(params = {}, noPrompt = true) {
/** @type {?} */
const claims = this.getIdentityClaims() || {};
if (this.useIdTokenHintForSilentRefresh && this.hasValidIdToken()) {
params['id_token_hint'] = this.getIdToken();
}
if (!this.validateUrlForHttps(this.loginUrl)) {
throw new Error('tokenEndpoint must use https, or config value for property requireHttps must allow http');
}
if (typeof document === 'undefined') {
throw new Error('silent refresh is not supported on this platform');
}
/** @type {?} */
const existingIframe = document.getElementById(this.silentRefreshIFrameName);
if (existingIframe) {
document.body.removeChild(existingIframe);
}
this.silentRefreshSubject = claims['sub'];
/** @type {?} */
const iframe = document.createElement('iframe');
iframe.id = this.silentRefreshIFrameName;
this.setupSilentRefreshEventListener();
/** @type {?} */
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';
}
document.body.appendChild(iframe);
});
/** @type {?} */
const errors = this.events.pipe(filter(e => e instanceof OAuthErrorEvent), first());
/** @type {?} */
const success = this.events.pipe(filter(e => e.type === 'silently_refreshed'), first());
/** @type {?} */
const timeout = of(new OAuthErrorEvent('silent_refresh_timeout', null)).pipe(delay(this.silentRefreshTimeout));
return race([errors, success, timeout])
.pipe(tap(e => {
if (e.type === 'silent_refresh_timeout') {
this.eventsSubject.next(e);
}
}), map(e => {
if (e instanceof OAuthErrorEvent) {
throw e;
}
return e;
}))
.toPromise();
}
/**
* @return {?}
*/
canPerformSessionCheck() {
if (!this.sessionChecksEnabled) {
return false;
}
if (!this.sessionCheckIFrameUrl) {
console.warn('sessionChecksEnabled is activated but there is no sessionCheckIFrameUrl');
return false;
}
/** @type {?} */
const sessionState = this.getSessionState();
if (!sessionState) {
console.warn('sessionChecksEnabled is activated but there is no session_state');
return false;
}
if (typeof document === 'undefined') {
return false;
}
return true;
}
/**
* @return {?}
*/
setupSessionCheckEventListener() {
this.removeSessionCheckEventListener();
this.sessionCheckEventListener = (e) => {
/** @type {?} */
const origin = e.origin.toLowerCase();
/** @type {?} */
const issuer = this.issuer.toLowerCase();
this.debug('sessionCheckEventListener');
if (!issuer.startsWith(origin)) {
this.debug('sessionCheckEventListener', 'wrong origin', origin, 'expected', issuer);
}
// only run in Angular zone if it is 'changed' or 'error'
switch (e.data) {
case 'unchanged':
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);
});
}
/**
* @return {?}
*/
handleSessionUnchanged() {
this.debug('session check', 'session unchanged');
}
/**
* @return {?}
*/
handleSessionChange() {
/* events: session_changed, relogin, stopTimer, logged_out*/
this.eventsSubject.next(new OAuthInfoEvent('session_changed'));
this.stopSessionCheckTimer();
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);
}
}
/**
* @return {?}
*/
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);
}
});
}
/**
* @return {?}
*/
handleSessionError() {
this.stopSessionCheckTimer();
this.eventsSubject.next(new OAuthInfoEvent('session_error'));
}
/**
* @return {?}
*/
removeSessionCheckEventListener() {
if (this.sessionCheckEventListener) {
window.removeEventListener('message', this.sessionCheckEventListener);
this.sessionCheckEventListener = null;
}
}
/**
* @return {?}
*/
initSessionCheck() {
if (!this.canPerformSessionCheck()) {
return;
}
/** @type {?} */
const existingIframe = document.getElementById(this.sessionCheckIFrameName);
if (existingIframe) {
document.body.removeChild(existingIframe);
}
/** @type {?} */
const iframe = document.createElement('iframe');
iframe.id = this.sessionCheckIFrameName;
this.setupSessionCheckEventListener();
/** @type {?} */
const url = this.sessionCheckIFrameUrl;
iframe.setAttribute('src', url);
iframe.style.display = 'none';
document.body.appendChild(iframe);
this.startSessionCheckTimer();
}
/**
* @return {?}
*/
startSessionCheckTimer() {
this.stopSessionCheckTimer();
this.ngZone.runOutsideAngular(() => {
this.sessionCheckTimer = setInterval(this.checkSession.bind(this), this.sessionCheckIntervall);
});
}
/**
* @return {?}
*/
stopSessionCheckTimer() {
if (this.sessionCheckTimer) {
clearInterval(this.sessionCheckTimer);
this.sessionCheckTimer = null;
}
}
/**
* @return {?}
*/
checkSession() {
/** @type {?} */
const iframe = document.getElementById(this.sessionCheckIFrameName);
if (!iframe) {
this.logger.warn('checkSession did not find iframe', this.sessionCheckIFrameName);
}
/** @type {?} */
const sessionState = this.getSessionState();
if (!sessionState) {
this.stopSessionCheckTimer();
}
/** @type {?} */
const message = this.clientId + ' ' + sessionState;
iframe.contentWindow.postMessage(message, this.issuer);
}
/**
* @param {?=} state
* @param {?=} loginHint
* @param {?=} customRedirectUri
* @param {?=} noPrompt
* @param {?=} params
* @return {?}
*/
createLoginUrl(state = '', loginHint = '', customRedirectUri = '', noPrompt = false, params = {}) {
/** @type {?} */
const that = this;
/** @type {?} */
let redirectUri;
if (customRedirectUri) {
redirectUri = customRedirectUri;
}
else {
redirectUri = this.redirectUri;
}
return this.createAndSaveNonce().then((nonce) => {
if (state) {
state = nonce + this.config.nonceStateSeparator + 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) {