From b4be390948f363fdc65f237647131df77a6d3a4a Mon Sep 17 00:00:00 2001 From: Razzeee Date: Mon, 14 May 2018 23:08:16 +0200 Subject: [PATCH 1/3] Update changelog link --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 731b9ce1..84658ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## New Features in Version 4.0.0 + +See [Release Notes](https://github.com/manfredsteyer/angular-oauth2-oidc/releases/tag/4.0.0) + ## New Features in Version 3.1 See [Release Notes](https://github.com/manfredsteyer/angular-oauth2-oidc/releases/tag/3.1) From f044533dc62810f2faced28cad5bab26eebf58cf Mon Sep 17 00:00:00 2001 From: Daniel Sirz Date: Tue, 15 May 2018 05:43:14 +0200 Subject: [PATCH 2/3] fixed broken Link in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 76731e7e..64d9b1f4 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Support for OAuth 2 and OpenId Connect (OIDC) in Angular. https://github.com/manfredsteyer/angular-oauth2-oidc - Source Code Documentation -https://manfredsteyer.github.io/angular-oauth2-oidc/angular-oauth2-oidc/docs/ +https://manfredsteyer.github.io/angular-oauth2-oidc/docs ## Tested Environment From 68730a72fbbf37069b08420630830120b722d649 Mon Sep 17 00:00:00 2001 From: "KALLY\\vytautas.pranskunas" Date: Tue, 15 May 2018 15:21:29 +0200 Subject: [PATCH 3/3] Fixed bug with required grant and made allowedUrls optional --- .../interceptors/default-oauth.interceptor.ts | 85 +- projects/lib/src/oauth-module.config.ts | 2 +- projects/lib/src/oauth-service.ts | 3337 +++++++++-------- 3 files changed, 1710 insertions(+), 1714 deletions(-) diff --git a/projects/lib/src/interceptors/default-oauth.interceptor.ts b/projects/lib/src/interceptors/default-oauth.interceptor.ts index 687bdc89..cb2c9c7a 100644 --- a/projects/lib/src/interceptors/default-oauth.interceptor.ts +++ b/projects/lib/src/interceptors/default-oauth.interceptor.ts @@ -2,12 +2,12 @@ import { Injectable, Inject, Optional } from '@angular/core'; import { OAuthService } from '../oauth-service'; import { OAuthStorage } from '../types'; import { - HttpEvent, - HttpHandler, - HttpInterceptor, - HttpRequest, - HttpResponse, - HttpErrorResponse + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, + HttpResponse, + HttpErrorResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -16,51 +16,46 @@ import { OAuthModuleConfig } from '../oauth-module.config'; @Injectable() export class DefaultOAuthInterceptor implements HttpInterceptor { - constructor( - private authStorage: OAuthStorage, - private errorHandler: OAuthResourceServerErrorHandler, - @Optional() private moduleConfig: OAuthModuleConfig - ) {} + constructor( + private authStorage: OAuthStorage, + private errorHandler: OAuthResourceServerErrorHandler, + @Optional() private moduleConfig: OAuthModuleConfig + ) { } + + private checkUrl(url: string): boolean { + const found = this.moduleConfig.resourceServer.allowedUrls.find(u => url.startsWith(u)); + return !!found; + } - private checkUrl(url: string): boolean { - const found = this.moduleConfig.resourceServer.allowedUrls.find(u => - url.startsWith(u) - ); - return !!found; - } + public intercept( + req: HttpRequest, + next: HttpHandler + ): Observable> { + const url = req.url.toLowerCase(); - public intercept( - req: HttpRequest, - next: HttpHandler - ): Observable> { - const url = req.url.toLowerCase(); + if (!this.moduleConfig) { + return next.handle(req); + } + if (!this.moduleConfig.resourceServer) { + return next.handle(req); + } + if (this.moduleConfig.resourceServer.allowedUrls && !this.checkUrl(url)) { + return next.handle(req); + } - if (!this.moduleConfig) { - return next.handle(req); - } - if (!this.moduleConfig.resourceServer) { - return next.handle(req); - } - if (!this.moduleConfig.resourceServer.allowedUrls) { - return next.handle(req); - } - if (!this.checkUrl(url)) { - return next.handle(req); - } + const sendAccessToken = this.moduleConfig.resourceServer.sendAccessToken; - const sendAccessToken = this.moduleConfig.resourceServer.sendAccessToken; + if (sendAccessToken && this.authStorage.getItem('access_token')) { + const token = this.authStorage.getItem('access_token'); + const header = 'Bearer ' + token; - if (sendAccessToken && this.authStorage.getItem('access_token')) { - const token = this.authStorage.getItem('access_token'); - const header = 'Bearer ' + token; + const headers = req.headers.set('Authorization', header); - const headers = req.headers.set('Authorization', header); + req = req.clone({ headers }); + } - req = req.clone({ headers }); + return next + .handle(req) + .pipe(catchError(err => this.errorHandler.handleError(err))); } - - return next - .handle(req) - .pipe(catchError(err => this.errorHandler.handleError(err))); - } } diff --git a/projects/lib/src/oauth-module.config.ts b/projects/lib/src/oauth-module.config.ts index a569a4ed..5a3e2c69 100644 --- a/projects/lib/src/oauth-module.config.ts +++ b/projects/lib/src/oauth-module.config.ts @@ -8,6 +8,6 @@ export abstract class OAuthResourceServerConfig { * If there is an ResourceServerErrorHandler registered, it is used for them. * If sendAccessToken is set to true, the access_token is send to them too. */ - allowedUrls: Array; + allowedUrls?: Array; sendAccessToken: boolean; } diff --git a/projects/lib/src/oauth-service.ts b/projects/lib/src/oauth-service.ts index 7bb73938..293eddac 100644 --- a/projects/lib/src/oauth-service.ts +++ b/projects/lib/src/oauth-service.ts @@ -4,23 +4,23 @@ import { Observable, Subject, Subscription, of, race } from 'rxjs'; import { filter, take, delay, first, tap, map } from 'rxjs/operators'; import { - ValidationHandler, - ValidationParams + ValidationHandler, + ValidationParams } from './token-validation/validation-handler'; import { UrlHelperService } from './url-helper.service'; import { - OAuthEvent, - OAuthInfoEvent, - OAuthErrorEvent, - OAuthSuccessEvent + OAuthEvent, + OAuthInfoEvent, + OAuthErrorEvent, + OAuthSuccessEvent } from './events'; import { - OAuthStorage, - LoginOptions, - ParsedIdToken, - OidcDiscoveryDoc, - TokenResponse, - UserInfo + OAuthStorage, + LoginOptions, + ParsedIdToken, + OidcDiscoveryDoc, + TokenResponse, + UserInfo } from './types'; import { b64DecodeUnicode } from './base64-helper'; import { AuthConfig } from './auth.config'; @@ -33,1747 +33,1748 @@ import { WebHttpUrlEncodingCodec } from './encoder'; */ @Injectable() export class OAuthService extends AuthConfig { - // extending AuthConfig ist just for LEGACY reasons - // to not break existing code - - /** - * The ValidationHandler used to validate received - * id_tokens. - */ - public tokenValidationHandler: ValidationHandler; - - /** - * @internal - * Deprecated: use property events instead - */ - public discoveryDocumentLoaded = false; - - /** - * @internal - * Deprecated: use property events instead - */ - public discoveryDocumentLoaded$: Observable; - - /** - * Informs about events, like token_received or token_expires. - * See the string enum EventType for a full list of events. - */ - public events: Observable; - - /** - * The received (passed around) state, when logging - * in with implicit flow. - */ - public state? = ''; - - private eventsSubject: Subject = new Subject(); - private discoveryDocumentLoadedSubject: Subject = new Subject< - object - >(); - private silentRefreshPostMessageEventListener: EventListener; - private grantTypesSupported: Array = []; - private _storage: OAuthStorage; - private accessTokenTimeoutSubscription: Subscription; - private idTokenTimeoutSubscription: Subscription; - private sessionCheckEventListener: EventListener; - private jwksUri: string; - private sessionCheckTimer: any; - private silentRefreshSubject: string; - private inImplicitFlow = false; - - constructor( - private ngZone: NgZone, - private http: HttpClient, - @Optional() storage: OAuthStorage, - @Optional() tokenValidationHandler: ValidationHandler, - @Optional() private config: AuthConfig, - private urlHelper: UrlHelperService - ) { - super(); - - 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( - 'cannot access sessionStorage. Consider setting an own storage implementation using setStorage', - e - ); - } - this.setupRefreshTimer(); - } - - /** - * Use this method to configure the service - * @param config the configuration - */ - public configure(config: AuthConfig) { - // For the sake of downward compatibility with - // original configuration API - Object.assign(this, new AuthConfig(), config); - - this.config = Object.assign({} as AuthConfig, new AuthConfig(), config); - - if (this.sessionChecksEnabled) { - this.setupSessionCheck(); - } - - this.configChanged(); - } - - private configChanged(): void {} - - public restartSessionChecksIfStillLoggedIn(): void { - if (this.hasValidIdToken()) { - this.initSessionCheck(); - } - } - - private restartRefreshTimerIfStillLoggedIn(): void { - this.setupExpirationTimers(); - } - - private setupSessionCheck() { - this.events.pipe(filter(e => e.type === 'token_received')).subscribe(e => { - this.initSessionCheck(); - }); - } - - /** - * - * @param params Additional parameter to pass - */ - public setupAutomaticSilentRefresh(params: object = {}) { - 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(); - } - - public loadDiscoveryDocumentAndTryLogin(options: LoginOptions = null) { - return this.loadDiscoveryDocument().then(doc => { - return this.tryLogin(options); - }); - } - - public loadDiscoveryDocumentAndLogin(options: LoginOptions = null) { - return this.loadDiscoveryDocumentAndTryLogin(options).then(_ => { - if (!this.hasValidIdToken() || !this.hasValidAccessToken()) { - this.initImplicitFlow(); - return false; - } else { - return true; - } - }); - } + // extending AuthConfig ist just for LEGACY reasons + // to not break existing code + + /** + * The ValidationHandler used to validate received + * id_tokens. + */ + public tokenValidationHandler: ValidationHandler; + + /** + * @internal + * Deprecated: use property events instead + */ + public discoveryDocumentLoaded = false; + + /** + * @internal + * Deprecated: use property events instead + */ + public discoveryDocumentLoaded$: Observable; + + /** + * Informs about events, like token_received or token_expires. + * See the string enum EventType for a full list of events. + */ + public events: Observable; + + /** + * The received (passed around) state, when logging + * in with implicit flow. + */ + public state?= ''; + + private eventsSubject: Subject = new Subject(); + private discoveryDocumentLoadedSubject: Subject = new Subject< + object + >(); + private silentRefreshPostMessageEventListener: EventListener; + private grantTypesSupported: Array = []; + private _storage: OAuthStorage; + private accessTokenTimeoutSubscription: Subscription; + private idTokenTimeoutSubscription: Subscription; + private sessionCheckEventListener: EventListener; + private jwksUri: string; + private sessionCheckTimer: any; + private silentRefreshSubject: string; + private inImplicitFlow = false; + + constructor( + private ngZone: NgZone, + private http: HttpClient, + @Optional() storage: OAuthStorage, + @Optional() tokenValidationHandler: ValidationHandler, + @Optional() private config: AuthConfig, + private urlHelper: UrlHelperService + ) { + super(); + + this.discoveryDocumentLoaded$ = this.discoveryDocumentLoadedSubject.asObservable(); + this.events = this.eventsSubject.asObservable(); + + if (tokenValidationHandler) { + this.tokenValidationHandler = tokenValidationHandler; + } + + if (config) { + this.configure(config); + } - private debug(...args): void { - if (this.showDebugInformation) { - console.debug.apply(console, args); + try { + if (storage) { + this.setStorage(storage); + } else if (typeof sessionStorage !== 'undefined') { + this.setStorage(sessionStorage); + } + } catch (e) { + console.error( + 'cannot access sessionStorage. Consider setting an own storage implementation using setStorage', + e + ); + } + this.setupRefreshTimer(); } - } - private validateUrlFromDiscoveryDocument(url: string): string[] { - const errors: string[] = []; - const httpsCheck = this.validateUrlForHttps(url); - const issuerCheck = this.validateUrlAgainstIssuer(url); + /** + * Use this method to configure the service + * @param config the configuration + */ + public configure(config: AuthConfig) { + // For the sake of downward compatibility with + // original configuration API + Object.assign(this, new AuthConfig(), config); + + this.config = Object.assign({} as AuthConfig, new AuthConfig(), config); + + if (this.sessionChecksEnabled) { + this.setupSessionCheck(); + } - if (!httpsCheck) { - errors.push( - 'https for all urls required. Also for urls received by discovery.' - ); + this.configChanged(); } - if (!issuerCheck) { - errors.push( - 'Every url in discovery document has to start with the issuer url.' + - 'Also see property strictDiscoveryDocumentValidation.' - ); + private configChanged(): void { } + + public restartSessionChecksIfStillLoggedIn(): void { + if (this.hasValidIdToken()) { + this.initSessionCheck(); + } } - return errors; - } + private restartRefreshTimerIfStillLoggedIn(): void { + this.setupExpirationTimers(); + } - private validateUrlForHttps(url: string): boolean { - if (!url) { - return true; + private setupSessionCheck() { + this.events.pipe(filter(e => e.type === 'token_received')).subscribe(e => { + this.initSessionCheck(); + }); } - const lcUrl = url.toLowerCase(); + /** + * + * @param params Additional parameter to pass + */ + public setupAutomaticSilentRefresh(params: object = {}) { + this.events.pipe(filter(e => e.type === 'token_expires')).subscribe(e => { + this.silentRefresh(params).catch(_ => { + this.debug('automatic silent refresh did not work'); + }); + }); - if (this.requireHttps === false) { - return true; + this.restartRefreshTimerIfStillLoggedIn(); } - if ( - (lcUrl.match(/^http:\/\/localhost($|[:\/])/) || - lcUrl.match(/^http:\/\/localhost($|[:\/])/)) && - this.requireHttps === 'remoteOnly' - ) { - return true; + public loadDiscoveryDocumentAndTryLogin(options: LoginOptions = null) { + return this.loadDiscoveryDocument().then(doc => { + return this.tryLogin(options); + }); } - return lcUrl.startsWith('https://'); - } + public loadDiscoveryDocumentAndLogin(options: LoginOptions = null) { + return this.loadDiscoveryDocumentAndTryLogin(options).then(_ => { + if (!this.hasValidIdToken() || !this.hasValidAccessToken()) { + this.initImplicitFlow(); + return false; + } else { + return true; + } + }); + } - private validateUrlAgainstIssuer(url: string) { - if (!this.strictDiscoveryDocumentValidation) { - return true; + private debug(...args): void { + if (this.showDebugInformation) { + console.debug.apply(console, args); + } } - if (!url) { - return true; + + private validateUrlFromDiscoveryDocument(url: string): string[] { + const errors: string[] = []; + 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; } - return url.toLowerCase().startsWith(this.issuer.toLowerCase()); - } - private setupRefreshTimer(): void { - if (typeof window === 'undefined') { - this.debug('timer not supported on this plattform'); - return; + private validateUrlForHttps(url: string): boolean { + 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://'); } - if (this.hasValidIdToken) { - this.clearAccessTokenTimer(); - this.clearIdTokenTimer(); - this.setupExpirationTimers(); + private validateUrlAgainstIssuer(url: string) { + if (!this.strictDiscoveryDocumentValidation) { + return true; + } + if (!url) { + return true; + } + return url.toLowerCase().startsWith(this.issuer.toLowerCase()); } - this.events.pipe(filter(e => e.type === 'token_received')).subscribe(_ => { - this.clearAccessTokenTimer(); - this.clearIdTokenTimer(); - this.setupExpirationTimers(); - }); - } + private setupRefreshTimer(): void { + if (typeof window === 'undefined') { + this.debug('timer not supported on this plattform'); + return; + } - private setupExpirationTimers(): void { - const idTokenExp = this.getIdTokenExpiration() || Number.MAX_VALUE; - const accessTokenExp = this.getAccessTokenExpiration() || Number.MAX_VALUE; - const useAccessTokenExp = accessTokenExp <= idTokenExp; + if (this.hasValidIdToken()) { + this.clearAccessTokenTimer(); + this.clearIdTokenTimer(); + this.setupExpirationTimers(); + } - if (this.hasValidAccessToken() && useAccessTokenExp) { - this.setupAccessTokenTimer(); + this.events.pipe(filter(e => e.type === 'token_received')).subscribe(_ => { + this.clearAccessTokenTimer(); + this.clearIdTokenTimer(); + this.setupExpirationTimers(); + }); } - if (this.hasValidIdToken() && !useAccessTokenExp) { - this.setupIdTokenTimer(); + private setupExpirationTimers(): void { + const idTokenExp = this.getIdTokenExpiration() || Number.MAX_VALUE; + const accessTokenExp = this.getAccessTokenExpiration() || Number.MAX_VALUE; + const useAccessTokenExp = accessTokenExp <= idTokenExp; + + if (this.hasValidAccessToken() && useAccessTokenExp) { + this.setupAccessTokenTimer(); + } + + if (this.hasValidIdToken() && !useAccessTokenExp) { + this.setupIdTokenTimer(); + } } - } - private setupAccessTokenTimer(): void { - const expiration = this.getAccessTokenExpiration(); - const storedAt = this.getAccessTokenStoredAt(); - const timeout = this.calcTimeout(storedAt, expiration); + private setupAccessTokenTimer(): void { + 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); + }); + }); + }); + } - this.ngZone.runOutsideAngular(() => { - this.accessTokenTimeoutSubscription = of( - new OAuthInfoEvent('token_expires', 'access_token') - ) - .pipe(delay(timeout)) - .subscribe(e => { - this.ngZone.run(() => { - this.eventsSubject.next(e); - }); + private setupIdTokenTimer(): void { + 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); + }); + }); }); - }); - } - - private setupIdTokenTimer(): void { - 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); - }); + } + + private clearAccessTokenTimer(): void { + if (this.accessTokenTimeoutSubscription) { + this.accessTokenTimeoutSubscription.unsubscribe(); + } + } + + private clearIdTokenTimer(): void { + if (this.idTokenTimeoutSubscription) { + this.idTokenTimeoutSubscription.unsubscribe(); + } + } + + private calcTimeout(storedAt: number, expiration: number): number { + const delta = (expiration - storedAt) * this.timeoutFactor; + return delta; + } + + /** + * Sets a custom storage used to store the received + * tokens on client side. By default, the browser's + * sessionStorage is used. + * + * @param storage + */ + public setStorage(storage: OAuthStorage): void { + 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 + */ + public loadDiscoveryDocument(fullUrl: string = null): Promise { + 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. Also check property requireHttps.'); + 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 => { + const result: object = { + 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 => { + console.error('error loading discovery document', err); + this.eventsSubject.next( + new OAuthErrorEvent('discovery_document_load_error', err) + ); + reject(err); + } + ); }); - }); - } - - private clearAccessTokenTimer(): void { - if (this.accessTokenTimeoutSubscription) { - this.accessTokenTimeoutSubscription.unsubscribe(); - } - } - - private clearIdTokenTimer(): void { - if (this.idTokenTimeoutSubscription) { - this.idTokenTimeoutSubscription.unsubscribe(); - } - } - - private calcTimeout(storedAt: number, expiration: number): number { - const delta = (expiration - storedAt) * this.timeoutFactor; - return delta; - } - - /** - * Sets a custom storage used to store the received - * tokens on client side. By default, the browser's - * sessionStorage is used. - * - * @param storage - */ - public setStorage(storage: OAuthStorage): void { - 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 - */ - public loadDiscoveryDocument(fullUrl: string = null): Promise { - 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. Also check property requireHttps.'); - return; - } - - this.http.get(fullUrl).subscribe( - doc => { - if (!this.validateDiscoveryDocument(doc)) { - this.eventsSubject.next( - new OAuthErrorEvent('discovery_document_validation_error', null) + } + + private loadJwks(): Promise { + 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 => { + console.error('error loading jwks', err); + this.eventsSubject.next( + new OAuthErrorEvent('jwks_load_error', err) + ); + reject(err); + } + ); + } else { + resolve(null); + } + }); + } + + private validateDiscoveryDocument(doc: OidcDiscoveryDoc): boolean { + let errors: string[]; + + if (!this.skipIssuerCheck && doc.issuer !== this.issuer) { + console.error( + 'invalid issuer in discovery document', + 'expected: ' + this.issuer, + 'current: ' + doc.issuer ); - 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 => { - const result: object = { - 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 => { - console.error('error loading discovery document', err); - this.eventsSubject.next( - new OAuthErrorEvent('discovery_document_load_error', err) - ); - reject(err); - } - ); - }); - } - - private loadJwks(): Promise { - 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') + return false; + } + + errors = this.validateUrlFromDiscoveryDocument(doc.authorization_endpoint); + if (errors.length > 0) { + console.error( + 'error validating authorization_endpoint in discovery document', + errors ); - resolve(jwks); - }, - err => { - console.error('error loading jwks', err); - this.eventsSubject.next( - new OAuthErrorEvent('jwks_load_error', err) + return false; + } + + errors = this.validateUrlFromDiscoveryDocument(doc.end_session_endpoint); + if (errors.length > 0) { + console.error( + 'error validating end_session_endpoint in discovery document', + errors ); - reject(err); - } + return false; + } + + errors = this.validateUrlFromDiscoveryDocument(doc.token_endpoint); + if (errors.length > 0) { + console.error( + 'error validating token_endpoint in discovery document', + errors + ); + } + + errors = this.validateUrlFromDiscoveryDocument(doc.userinfo_endpoint); + if (errors.length > 0) { + console.error( + 'error validating userinfo_endpoint in discovery document', + errors + ); + return false; + } + + errors = this.validateUrlFromDiscoveryDocument(doc.jwks_uri); + if (errors.length > 0) { + console.error('error validating jwks_uri in discovery document', errors); + return false; + } + + if (this.sessionChecksEnabled && !doc.check_session_iframe) { + console.warn( + 'sessionChecksEnabled is activated but discovery document' + + ' does not contain a check_session_iframe field' + ); + } + + // this.sessionChecksEnabled = !!doc.check_session_iframe; + + 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 happen that makes this operation + * fail. + * + * @param userName + * @param password + * @param headers Optional additional http-headers. + */ + public fetchTokenUsingPasswordFlowAndLoadUserProfile( + userName: string, + password: string, + headers: HttpHeaders = new HttpHeaders() + ): Promise { + return this.fetchTokenUsingPasswordFlow(userName, password, headers).then( + () => this.loadUserProfile() ); - } else { - resolve(null); - } - }); - } - - private validateDiscoveryDocument(doc: OidcDiscoveryDoc): boolean { - let errors: string[]; - - if (!this.skipIssuerCheck && doc.issuer !== this.issuer) { - console.error( - 'invalid issuer in discovery document', - 'expected: ' + this.issuer, - 'current: ' + doc.issuer - ); - return false; - } - - errors = this.validateUrlFromDiscoveryDocument(doc.authorization_endpoint); - if (errors.length > 0) { - console.error( - 'error validating authorization_endpoint in discovery document', - errors - ); - return false; - } - - errors = this.validateUrlFromDiscoveryDocument(doc.end_session_endpoint); - if (errors.length > 0) { - console.error( - 'error validating end_session_endpoint in discovery document', - errors - ); - return false; - } - - errors = this.validateUrlFromDiscoveryDocument(doc.token_endpoint); - if (errors.length > 0) { - console.error( - 'error validating token_endpoint in discovery document', - errors - ); - } - - errors = this.validateUrlFromDiscoveryDocument(doc.userinfo_endpoint); - if (errors.length > 0) { - console.error( - 'error validating userinfo_endpoint in discovery document', - errors - ); - return false; - } - - errors = this.validateUrlFromDiscoveryDocument(doc.jwks_uri); - if (errors.length > 0) { - console.error('error validating jwks_uri in discovery document', errors); - return false; - } - - if (this.sessionChecksEnabled && !doc.check_session_iframe) { - console.warn( - 'sessionChecksEnabled is activated but discovery document' + - ' does not contain a check_session_iframe field' - ); - } - - // this.sessionChecksEnabled = !!doc.check_session_iframe; - - 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 happen that makes this operation - * fail. - * - * @param userName - * @param password - * @param headers Optional additional http-headers. - */ - public fetchTokenUsingPasswordFlowAndLoadUserProfile( - userName: string, - password: string, - headers: HttpHeaders = new HttpHeaders() - ): Promise { - 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 happen that makes this operation - * fail. - */ - public loadUserProfile(): Promise { - 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. Also check property requireHttps.' - ); - } - - return new Promise((resolve, reject) => { - const headers = new HttpHeaders().set( - 'Authorization', - 'Bearer ' + this.getAccessToken() - ); - - this.http.get(this.userinfoEndpoint, { headers }).subscribe( - info => { - this.debug('userinfo received', info); - - 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); - }, - err => { - console.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. - */ - public fetchTokenUsingPasswordFlow( - userName: string, - password: string, - headers: HttpHeaders = new HttpHeaders() - ): Promise { - if (!this.validateUrlForHttps(this.tokenEndpoint)) { - throw new Error( - 'tokenEndpoint must use Http. Also check property requireHttps.' - ); - } - - return new Promise((resolve, reject) => { - /** - * 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', 'password') - .set('scope', this.scope) - .set('username', userName) - .set('password', password); - - if (this.useHttpBasicAuthForPasswordFlow) { - const header = btoa(`${this.clientId}:${this.dummyClientSecret}`); - headers = headers.set( + } + + /** + * 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 happen that makes this operation + * fail. + */ + public loadUserProfile(): Promise { + 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. Also check property requireHttps.' + ); + } + + return new Promise((resolve, reject) => { + const headers = new HttpHeaders().set( + 'Authorization', + 'Bearer ' + this.getAccessToken() + ); + + this.http.get(this.userinfoEndpoint, { headers }).subscribe( + info => { + this.debug('userinfo received', info); + + 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); + }, + err => { + console.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. + */ + public fetchTokenUsingPasswordFlow( + userName: string, + password: string, + headers: HttpHeaders = new HttpHeaders() + ): Promise { + if (!this.validateUrlForHttps(this.tokenEndpoint)) { + throw new Error( + 'tokenEndpoint must use Http. Also check property requireHttps.' + ); + } + + return new Promise((resolve, reject) => { + /** + * 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', 'password') + .set('scope', this.scope) + .set('username', userName) + .set('password', password); + + if (this.useHttpBasicAuthForPasswordFlow) { + const header = btoa(`${this.clientId}:${this.dummyClientSecret}`); + headers = headers.set( 'Authentication', 'BASIC ' + header); - } + } - if (!this.useHttpBasicAuthForPasswordFlow) { - params = params.set('client_id', this.clientId); - } + if (!this.useHttpBasicAuthForPasswordFlow) { + params = params.set('client_id', this.clientId); + } + + if (!this.useHttpBasicAuthForPasswordFlow && this.dummyClientSecret) { + params = params.set('client_secret', this.dummyClientSecret); + } - 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]); + } + } - 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 => { + console.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. + */ + public refreshToken(): Promise { + if (!this.validateUrlForHttps(this.tokenEndpoint)) { + throw new Error( + 'tokenEndpoint must use Http. Also check property requireHttps.' + ); } - } - headers = headers.set( - 'Content-Type', - 'application/x-www-form-urlencoded' - ); + return new Promise((resolve, reject) => { + 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')); - 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 + 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]); + } + } + + const headers = new HttpHeaders().set( + 'Content-Type', + 'application/x-www-form-urlencoded' ); - this.eventsSubject.next(new OAuthSuccessEvent('token_received')); - resolve(tokenResponse); - }, - err => { - console.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. - */ - public refreshToken(): Promise { - if (!this.validateUrlForHttps(this.tokenEndpoint)) { - throw new Error( - 'tokenEndpoint must use Http. Also check property requireHttps.' - ); - } - - return new Promise((resolve, reject) => { - 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]); - } - } - - 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.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 => { + console.error('Error performing password flow', err); + this.eventsSubject.next( + new OAuthErrorEvent('token_refresh_error', err) + ); + reject(err); + } + ); + }); + } + + private removeSilentRefreshEventListener(): void { + if (this.silentRefreshPostMessageEventListener) { + window.removeEventListener( + 'message', + this.silentRefreshPostMessageEventListener ); + this.silentRefreshPostMessageEventListener = null; + } + } - this.eventsSubject.next(new OAuthSuccessEvent('token_received')); - this.eventsSubject.next(new OAuthSuccessEvent('token_refreshed')); - resolve(tokenResponse); - }, - err => { - console.error('Error performing password flow', err); - this.eventsSubject.next( - new OAuthErrorEvent('token_refresh_error', err) + private setupSilentRefreshEventListener(): void { + this.removeSilentRefreshEventListener(); + + this.silentRefreshPostMessageEventListener = (e: MessageEvent) => { + let expectedPrefix = '#'; + + if (this.silentRefreshMessagePrefix) { + expectedPrefix += this.silentRefreshMessagePrefix; + } + + if (!e || !e.data || typeof e.data !== 'string') { + return; + } + + const prefixedMessage: string = e.data; + + if (!prefixedMessage.startsWith(expectedPrefix)) { + return; + } + + 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 a new tokens when/ before + * the existing tokens expires. + */ + public silentRefresh(params: object = {}, noPrompt = true): Promise { + const claims: object = this.getIdentityClaims() || {}; + + if (this.useIdTokenHintForSilentRefresh && this.hasValidIdToken()) { + params['id_token_hint'] = this.getIdToken(); + } + + /* + if (!claims) { + throw new Error('cannot perform a silent refresh as the user is not logged in'); + } + */ + + if (!this.validateUrlForHttps(this.loginUrl)) { + throw new Error( + 'tokenEndpoint must use Https. Also check property requireHttps.' ); - reject(err); - } + } + + if (typeof document === 'undefined') { + throw new Error('silent refresh is not supported on this platform'); + } + + const existingIframe = document.getElementById( + this.silentRefreshIFrameName ); - }); - } - - private removeSilentRefreshEventListener(): void { - if (this.silentRefreshPostMessageEventListener) { - window.removeEventListener( - 'message', - this.silentRefreshPostMessageEventListener - ); - this.silentRefreshPostMessageEventListener = null; - } - } - - private setupSilentRefreshEventListener(): void { - this.removeSilentRefreshEventListener(); - - this.silentRefreshPostMessageEventListener = (e: MessageEvent) => { - let expectedPrefix = '#'; - - if (this.silentRefreshMessagePrefix) { - expectedPrefix += this.silentRefreshMessagePrefix; - } - - if (!e || !e.data || typeof e.data !== 'string') { - return; - } - - const prefixedMessage: string = e.data; - - if (!prefixedMessage.startsWith(expectedPrefix)) { - return; - } - - 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 a new tokens when/ before - * the existing tokens expires. - */ - public silentRefresh(params: object = {}, noPrompt = true): Promise { - const claims: object = this.getIdentityClaims() || {}; - - if (this.useIdTokenHintForSilentRefresh && this.hasValidIdToken) { - params['id_token_hint'] = this.getIdToken(); - } - - /* - if (!claims) { - throw new Error('cannot perform a silent refresh as the user is not logged in'); - } - */ - - if (!this.validateUrlForHttps(this.loginUrl)) { - throw new Error( - 'tokenEndpoint must use Https. Also check property requireHttps.' - ); - } - - if (typeof document === 'undefined') { - throw new Error('silent refresh is not supported on this platform'); - } - - const existingIframe = document.getElementById( - this.silentRefreshIFrameName - ); - if (existingIframe) { - document.body.removeChild(existingIframe); - } - - this.silentRefreshSubject = claims['sub']; - - const iframe = 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'; - } - document.body.appendChild(iframe); - }); - - const errors = this.events.pipe( - filter(e => e instanceof OAuthErrorEvent), - first() - ); - const success = this.events.pipe( - filter(e => e.type === 'silently_refreshed'), - first() - ); - 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(); - } - - private canPerformSessionCheck(): boolean { - 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 document === 'undefined') { - return false; - } - - return true; - } - - private setupSessionCheckEventListener(): void { - this.removeSessionCheckEventListener(); - - this.sessionCheckEventListener = (e: MessageEvent) => { - 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 + if (existingIframe) { + document.body.removeChild(existingIframe); + } + + this.silentRefreshSubject = claims['sub']; + + const iframe = 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'; + } + document.body.appendChild(iframe); + }); + + const errors = this.events.pipe( + filter(e => e instanceof OAuthErrorEvent), + first() + ); + const success = this.events.pipe( + filter(e => e.type === 'silently_refreshed'), + first() ); - } - - switch (e.data) { - case 'unchanged': - this.handleSessionUnchanged(); - break; - case 'changed': - this.handleSessionChange(); - break; - case 'error': - this.handleSessionError(); - break; - } - - this.debug('got info from session check inframe', e); - }; - - window.addEventListener('message', this.sessionCheckEventListener); - } - - private handleSessionUnchanged(): void { - this.debug('session check', 'session unchanged'); - } - - private handleSessionChange(): void { - /* 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); - } - } - - private waitForSilentRefreshAfterSessionChange() { - this.events - .pipe( - filter( - (e: OAuthEvent) => - 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); - } - }); - } - - private handleSessionError(): void { - this.stopSessionCheckTimer(); - this.eventsSubject.next(new OAuthInfoEvent('session_error')); - } - - private removeSessionCheckEventListener(): void { - if (this.sessionCheckEventListener) { - window.removeEventListener('message', this.sessionCheckEventListener); - this.sessionCheckEventListener = null; - } - } - - private initSessionCheck(): void { - if (!this.canPerformSessionCheck()) { - return; - } - - const existingIframe = document.getElementById(this.sessionCheckIFrameName); - if (existingIframe) { - document.body.removeChild(existingIframe); - } - - const iframe = document.createElement('iframe'); - iframe.id = this.sessionCheckIFrameName; - - this.setupSessionCheckEventListener(); - - const url = this.sessionCheckIFrameUrl; - iframe.setAttribute('src', url); - // iframe.style.visibility = 'hidden'; - iframe.style.display = 'none'; - document.body.appendChild(iframe); - - this.startSessionCheckTimer(); - } - - private startSessionCheckTimer(): void { - this.stopSessionCheckTimer(); - this.sessionCheckTimer = setInterval( - this.checkSession.bind(this), - this.sessionCheckIntervall - ); - } - - private stopSessionCheckTimer(): void { - if (this.sessionCheckTimer) { - clearInterval(this.sessionCheckTimer); - this.sessionCheckTimer = null; - } - } - - private checkSession(): void { - const iframe: any = document.getElementById(this.sessionCheckIFrameName); - - if (!iframe) { - console.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); - } - - private createLoginUrl( - state = '', - loginHint = '', - customRedirectUri = '', - noPrompt = false, - params: object = {} - ) { - const that = this; + 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(); + } + + private canPerformSessionCheck(): boolean { + 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 document === 'undefined') { + return false; + } - let redirectUri: string; - - if (customRedirectUri) { - redirectUri = customRedirectUri; - } else { - redirectUri = this.redirectUri; + return true; } - return this.createAndSaveNonce().then((nonce: any) => { - if (state) { - state = nonce + this.config.nonceStateSeparator + state; - } else { - state = nonce; - } + private setupSessionCheckEventListener(): void { + this.removeSessionCheckEventListener(); + + this.sessionCheckEventListener = (e: MessageEvent) => { + 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 + ); + } + + switch (e.data) { + case 'unchanged': + this.handleSessionUnchanged(); + break; + case 'changed': + this.handleSessionChange(); + break; + case 'error': + this.handleSessionError(); + break; + } + + this.debug('got info from session check inframe', e); + }; + + window.addEventListener('message', this.sessionCheckEventListener); + } + + private handleSessionUnchanged(): void { + this.debug('session check', 'session unchanged'); + } - if (!this.requestAccessToken && !this.oidc) { - throw new Error( - 'Either requestAccessToken or oidc or both must be true' + private handleSessionChange(): void { + /* 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); + } + } + + private waitForSilentRefreshAfterSessionChange() { + this.events + .pipe( + filter( + (e: OAuthEvent) => + 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); + } + }); + } + + private handleSessionError(): void { + this.stopSessionCheckTimer(); + this.eventsSubject.next(new OAuthInfoEvent('session_error')); + } + + private removeSessionCheckEventListener(): void { + if (this.sessionCheckEventListener) { + window.removeEventListener('message', this.sessionCheckEventListener); + this.sessionCheckEventListener = null; + } + } + + private initSessionCheck(): void { + if (!this.canPerformSessionCheck()) { + return; + } + + const existingIframe = document.getElementById(this.sessionCheckIFrameName); + if (existingIframe) { + document.body.removeChild(existingIframe); + } + + const iframe = document.createElement('iframe'); + iframe.id = this.sessionCheckIFrameName; + + this.setupSessionCheckEventListener(); + + const url = this.sessionCheckIFrameUrl; + iframe.setAttribute('src', url); + // iframe.style.visibility = 'hidden'; + iframe.style.display = 'none'; + document.body.appendChild(iframe); + + this.startSessionCheckTimer(); + } + + private startSessionCheckTimer(): void { + this.stopSessionCheckTimer(); + this.sessionCheckTimer = setInterval( + this.checkSession.bind(this), + this.sessionCheckIntervall ); - } - - 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 (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: string | object = '' - ): void { - if (this.inImplicitFlow) { - return; - } - - this.inImplicitFlow = true; - - if (!this.validateUrlForHttps(this.loginUrl)) { - throw new Error( - 'loginUrl must use Http. Also check property requireHttps.' - ); - } - - let addParams: object = {}; - let loginHint: string = null; - - if (typeof params === 'string') { - loginHint = params; - } else if (typeof params === 'object') { - addParams = params; - } - - this.createLoginUrl(additionalState, loginHint, null, false, addParams) - .then(function(url) { - location.href = url; - }) - .catch(error => { - console.error('Error in initImplicitFlow'); - console.error(error); - this.inImplicitFlow = false; - }); - } - - /** - * Starts the implicit flow and redirects to user to - * the auth servers login url. - * - * @param additionalState Optinal state that is passes around. - * You 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) - */ - public initImplicitFlow( - additionalState = '', - params: string | object = '' - ): void { - if (this.loginUrl !== '') { - this.initImplicitFlowInternal(additionalState, params); - } else { - this.events - .pipe(filter(e => e.type === 'discovery_document_loaded')) - .subscribe(_ => this.initImplicitFlowInternal(additionalState, params)); - } - } - - private callOnTokenReceivedIfExists(options: LoginOptions): void { - const that = this; - if (options.onTokenReceived) { - const tokenParams = { - idClaims: that.getIdentityClaims(), - idToken: that.getIdToken(), - accessToken: that.getAccessToken(), - state: that.state - }; - options.onTokenReceived(tokenParams); - } - } - - private storeAccessTokenResponse( - accessToken: string, - refreshToken: string, - expiresIn: number, - grantedScopes: String - ): void { - this._storage.setItem('access_token', accessToken); - this._storage.setItem( - 'granted_scopes', - JSON.stringify(grantedScopes.split('+')) - ); - this._storage.setItem('access_token_stored_at', '' + Date.now()); - if (expiresIn) { - const expiresInMilliSeconds = expiresIn * 1000; - const now = new Date(); - const expiresAt = now.getTime() + expiresInMilliSeconds; - this._storage.setItem('expires_at', '' + expiresAt); - } - - if (refreshToken) { - this._storage.setItem('refresh_token', refreshToken); - } - } - - /** - * Checks whether there are tokens in the hash fragment - * as a result of the implicit flow. These tokens are - * parsed, validated and used to sign the user in to the - * current client. - * - * @param options Optinal options. - */ - public tryLogin(options: LoginOptions = null): Promise { - options = options || {}; - - let parts: object; - - if (options.customHashFragment) { - parts = this.urlHelper.getHashFragmentParams(options.customHashFragment); - } else { - parts = this.urlHelper.getHashFragmentParams(); - } - - this.debug('parsed url', parts); - - const state = parts['state']; - let nonceInState = state; - - if (state) { - const idx = state.indexOf(this.config.nonceStateSeparator); - - if (idx > -1) { - nonceInState = state.substr(0, idx); - this.state = state.substr(idx + this.config.nonceStateSeparator.length); - } - } - - if (parts['error']) { - this.debug('error trying to login'); - this.handleLoginError(options, parts); - const err = new OAuthErrorEvent('token_error', {}, parts); - this.eventsSubject.next(err); - return Promise.reject(err); - } - - const accessToken = parts['access_token']; - const idToken = parts['id_token']; - const sessionState = parts['session_state']; - const grantedScopes = parts['scope']; - - if (!this.requestAccessToken && !this.oidc) { - return Promise.reject( - 'Either requestAccessToken or oidc or both must be true.' - ); - } - - if (this.requestAccessToken && !accessToken) { - return Promise.resolve(); - } - if (this.requestAccessToken && !options.disableOAuth2StateCheck && !state) { - return Promise.resolve(); - } - if (this.oidc && !idToken) { - return Promise.resolve(); - } - - if (this.sessionChecksEnabled && !sessionState) { - console.warn( - 'session checks (Session Status Change Notification) ' + - 'is activated in the configuration but the id_token ' + - 'does not contain a session_state claim' - ); - } - - if (this.requestAccessToken && !options.disableOAuth2StateCheck) { - const success = this.validateNonceForAccessToken( - accessToken, - nonceInState - ); - if (!success) { - const event = new OAuthErrorEvent('invalid_nonce_in_state', null); - this.eventsSubject.next(event); - return Promise.reject(event); - } - } - - if (this.requestAccessToken) { - this.storeAccessTokenResponse( - accessToken, - null, - parts['expires_in'] || this.fallbackAccessTokenExpirationTimeInSec, - grantedScopes - ); } - if (!this.oidc) { - this.eventsSubject.next(new OAuthSuccessEvent('token_received')); - if (this.clearHashAfterLogin && !options.preventClearHashAfterLogin) { - location.hash = ''; - } - return Promise.resolve(); + private stopSessionCheckTimer(): void { + if (this.sessionCheckTimer) { + clearInterval(this.sessionCheckTimer); + this.sessionCheckTimer = null; + } } - return this.processIdToken(idToken, accessToken) - .then(result => { - if (options.validationHandler) { - return options - .validationHandler({ - accessToken: accessToken, - idClaims: result.idTokenClaims, - idToken: result.idToken, - state: state + private checkSession(): void { + const iframe: any = document.getElementById(this.sessionCheckIFrameName); + + if (!iframe) { + console.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); + } + + private createLoginUrl( + state = '', + loginHint = '', + customRedirectUri = '', + noPrompt = false, + params: object = {} + ) { + const that = this; + + let redirectUri: string; + + if (customRedirectUri) { + redirectUri = customRedirectUri; + } else { + redirectUri = this.redirectUri; + } + + return this.createAndSaveNonce().then((nonce: any) => { + 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.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 (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: string | object = '' + ): void { + if (this.inImplicitFlow) { + return; + } + + this.inImplicitFlow = true; + + if (!this.validateUrlForHttps(this.loginUrl)) { + throw new Error( + 'loginUrl must use Http. Also check property requireHttps.' + ); + } + + let addParams: object = {}; + let loginHint: string = null; + + if (typeof params === 'string') { + loginHint = params; + } else if (typeof params === 'object') { + addParams = params; + } + + this.createLoginUrl(additionalState, loginHint, null, false, addParams) + .then(function (url) { + location.href = url; + }) + .catch(error => { + console.error('Error in initImplicitFlow'); + console.error(error); + this.inImplicitFlow = false; + }); + } + + /** + * Starts the implicit flow and redirects to user to + * the auth servers login url. + * + * @param additionalState Optinal state that is passes around. + * You 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) + */ + public initImplicitFlow( + additionalState = '', + params: string | object = '' + ): void { + if (this.loginUrl !== '') { + this.initImplicitFlowInternal(additionalState, params); + } else { + this.events + .pipe(filter(e => e.type === 'discovery_document_loaded')) + .subscribe(_ => this.initImplicitFlowInternal(additionalState, params)); + } + } + + private callOnTokenReceivedIfExists(options: LoginOptions): void { + const that = this; + if (options.onTokenReceived) { + const tokenParams = { + idClaims: that.getIdentityClaims(), + idToken: that.getIdToken(), + accessToken: that.getAccessToken(), + state: that.state + }; + options.onTokenReceived(tokenParams); + } + } + + private storeAccessTokenResponse( + accessToken: string, + refreshToken: string, + expiresIn: number, + grantedScopes: String + ): void { + this._storage.setItem('access_token', accessToken); + if (grantedScopes) { + this._storage.setItem('granted_scopes', JSON.stringify(grantedScopes.split('+'))); + } + this._storage.setItem('access_token_stored_at', '' + Date.now()); + if (expiresIn) { + const expiresInMilliSeconds = expiresIn * 1000; + const now = new Date(); + const expiresAt = now.getTime() + expiresInMilliSeconds; + this._storage.setItem('expires_at', '' + expiresAt); + } + + if (refreshToken) { + this._storage.setItem('refresh_token', refreshToken); + } + } + + /** + * Checks whether there are tokens in the hash fragment + * as a result of the implicit flow. These tokens are + * parsed, validated and used to sign the user in to the + * current client. + * + * @param options Optinal options. + */ + public tryLogin(options: LoginOptions = null): Promise { + options = options || {}; + + let parts: object; + + if (options.customHashFragment) { + parts = this.urlHelper.getHashFragmentParams(options.customHashFragment); + } else { + parts = this.urlHelper.getHashFragmentParams(); + } + + this.debug('parsed url', parts); + + const state = parts['state']; + let nonceInState = state; + + if (state) { + const idx = state.indexOf(this.config.nonceStateSeparator); + + if (idx > -1) { + nonceInState = state.substr(0, idx); + this.state = state.substr(idx + this.config.nonceStateSeparator.length); + } + } + + if (parts['error']) { + this.debug('error trying to login'); + this.handleLoginError(options, parts); + const err = new OAuthErrorEvent('token_error', {}, parts); + this.eventsSubject.next(err); + return Promise.reject(err); + } + + const accessToken = parts['access_token']; + const idToken = parts['id_token']; + const sessionState = parts['session_state']; + const grantedScopes = parts['scope']; + + if (!this.requestAccessToken && !this.oidc) { + return Promise.reject( + 'Either requestAccessToken or oidc or both must be true.' + ); + } + + if (this.requestAccessToken && !accessToken) { + return Promise.resolve(); + } + if (this.requestAccessToken && !options.disableOAuth2StateCheck && !state) { + return Promise.resolve(); + } + if (this.oidc && !idToken) { + return Promise.resolve(); + } + + if (this.sessionChecksEnabled && !sessionState) { + console.warn( + 'session checks (Session Status Change Notification) ' + + 'is activated in the configuration but the id_token ' + + 'does not contain a session_state claim' + ); + } + + if (this.requestAccessToken && !options.disableOAuth2StateCheck) { + const success = this.validateNonceForAccessToken( + accessToken, + nonceInState + ); + if (!success) { + const event = new OAuthErrorEvent('invalid_nonce_in_state', null); + this.eventsSubject.next(event); + return Promise.reject(event); + } + } + + if (this.requestAccessToken) { + this.storeAccessTokenResponse( + accessToken, + null, + parts['expires_in'] || this.fallbackAccessTokenExpirationTimeInSec, + grantedScopes + ); + } + + if (!this.oidc) { + this.eventsSubject.next(new OAuthSuccessEvent('token_received')); + if (this.clearHashAfterLogin && !options.preventClearHashAfterLogin) { + location.hash = ''; + } + return Promise.resolve(); + } + + return this.processIdToken(idToken, accessToken) + .then(result => { + if (options.validationHandler) { + return options + .validationHandler({ + accessToken: accessToken, + idClaims: result.idTokenClaims, + idToken: result.idToken, + state: state + }) + .then(_ => result); + } + return result; + }) + .then(result => { + this.storeIdToken(result); + this.storeSessionState(sessionState); + if (this.clearHashAfterLogin) { + location.hash = ''; + } + this.eventsSubject.next(new OAuthSuccessEvent('token_received')); + this.callOnTokenReceivedIfExists(options); + this.inImplicitFlow = false; }) - .then(_ => result); + .catch(reason => { + this.eventsSubject.next( + new OAuthErrorEvent('token_validation_error', reason) + ); + console.error('Error validating tokens'); + console.error(reason); + return Promise.reject(reason); + }); + } + + private validateNonceForAccessToken( + accessToken: string, + nonceInState: string + ): boolean { + const savedNonce = this._storage.getItem('nonce'); + if (savedNonce !== nonceInState) { + const err = 'validating access_token failed. wrong state/nonce.'; + console.error(err, savedNonce, nonceInState); + return false; + } + return true; + } + + protected storeIdToken(idToken: ParsedIdToken) { + this._storage.setItem('id_token', idToken.idToken); + this._storage.setItem('id_token_claims_obj', idToken.idTokenClaimsJson); + this._storage.setItem('id_token_expires_at', '' + idToken.idTokenExpiresAt); + this._storage.setItem('id_token_stored_at', '' + Date.now()); + } + + protected storeSessionState(sessionState: string): void { + this._storage.setItem('session_state', sessionState); + } + + protected getSessionState(): string { + return this._storage.getItem('session_state'); + } + + private handleLoginError(options: LoginOptions, parts: object): void { + if (options.onLoginError) { + options.onLoginError(parts); } - return result; - }) - .then(result => { - this.storeIdToken(result); - this.storeSessionState(sessionState); if (this.clearHashAfterLogin) { - location.hash = ''; - } - this.eventsSubject.next(new OAuthSuccessEvent('token_received')); - this.callOnTokenReceivedIfExists(options); - this.inImplicitFlow = false; - }) - .catch(reason => { - this.eventsSubject.next( - new OAuthErrorEvent('token_validation_error', reason) - ); - console.error('Error validating tokens'); - console.error(reason); - return Promise.reject(reason); - }); - } - - private validateNonceForAccessToken( - accessToken: string, - nonceInState: string - ): boolean { - const savedNonce = this._storage.getItem('nonce'); - if (savedNonce !== nonceInState) { - const err = 'validating access_token failed. wrong state/nonce.'; - console.error(err, savedNonce, nonceInState); - return false; - } - return true; - } - - protected storeIdToken(idToken: ParsedIdToken) { - this._storage.setItem('id_token', idToken.idToken); - this._storage.setItem('id_token_claims_obj', idToken.idTokenClaimsJson); - this._storage.setItem('id_token_expires_at', '' + idToken.idTokenExpiresAt); - this._storage.setItem('id_token_stored_at', '' + Date.now()); - } - - protected storeSessionState(sessionState: string): void { - this._storage.setItem('session_state', sessionState); - } - - protected getSessionState(): string { - return this._storage.getItem('session_state'); - } - - private handleLoginError(options: LoginOptions, parts: object): void { - if (options.onLoginError) { - options.onLoginError(parts); - } - if (this.clearHashAfterLogin) { - location.hash = ''; - } - } - - /** - * @ignore - */ - public processIdToken( - idToken: string, - accessToken: string - ): Promise { - const tokenParts = idToken.split('.'); - const headerBase64 = this.padBase64(tokenParts[0]); - const headerJson = b64DecodeUnicode(headerBase64); - const header = JSON.parse(headerJson); - const claimsBase64 = this.padBase64(tokenParts[1]); - const claimsJson = b64DecodeUnicode(claimsBase64); - const claims = JSON.parse(claimsJson); - const savedNonce = this._storage.getItem('nonce'); - - if (Array.isArray(claims.aud)) { - if (claims.aud.every(v => v !== this.clientId)) { - const err = 'Wrong audience: ' + claims.aud.join(','); - console.warn(err); - return Promise.reject(err); - } - } else { - if (claims.aud !== this.clientId) { - const err = 'Wrong audience: ' + claims.aud; - console.warn(err); - return Promise.reject(err); - } - } - - /* - if (this.getKeyCount() > 1 && !header.kid) { - let err = 'There needs to be a kid property in the id_token header when multiple keys are defined via the property jwks'; + location.hash = ''; + } + } + + /** + * @ignore + */ + public processIdToken( + idToken: string, + accessToken: string + ): Promise { + const tokenParts = idToken.split('.'); + const headerBase64 = this.padBase64(tokenParts[0]); + const headerJson = b64DecodeUnicode(headerBase64); + const header = JSON.parse(headerJson); + const claimsBase64 = this.padBase64(tokenParts[1]); + const claimsJson = b64DecodeUnicode(claimsBase64); + const claims = JSON.parse(claimsJson); + const savedNonce = this._storage.getItem('nonce'); + + if (Array.isArray(claims.aud)) { + if (claims.aud.every(v => v !== this.clientId)) { + const err = 'Wrong audience: ' + claims.aud.join(','); + console.warn(err); + return Promise.reject(err); + } + } else { + if (claims.aud !== this.clientId) { + const err = 'Wrong audience: ' + claims.aud; + console.warn(err); + return Promise.reject(err); + } + } + + /* + if (this.getKeyCount() > 1 && !header.kid) { + let err = 'There needs to be a kid property in the id_token header when multiple keys are defined via the property jwks'; + console.warn(err); + return Promise.reject(err); + } + */ + + if (!claims.sub) { + const err = 'No sub claim in id_token'; + console.warn(err); + return Promise.reject(err); + } + + /* For now, we only check whether the sub against + * silentRefreshSubject when sessionChecksEnabled is on + * We will reconsider in a later version to do this + * in every other case too. + */ + if ( + this.sessionChecksEnabled && + this.silentRefreshSubject && + this.silentRefreshSubject !== claims['sub'] + ) { + const err = + 'After refreshing, we got an id_token for another user (sub). ' + + `Expected sub: ${this.silentRefreshSubject}, received sub: ${ + claims['sub'] + }`; + console.warn(err); return Promise.reject(err); } - */ - if (!claims.sub) { - const err = 'No sub claim in id_token'; - console.warn(err); - return Promise.reject(err); + if (!claims.iat) { + const err = 'No iat claim in id_token'; + console.warn(err); + return Promise.reject(err); + } + + if (claims.iss !== this.issuer) { + const err = 'Wrong issuer: ' + claims.iss; + console.warn(err); + return Promise.reject(err); + } + + if (claims.nonce !== savedNonce) { + const err = 'Wrong nonce: ' + claims.nonce; + console.warn(err); + return Promise.reject(err); + } + + if ( + !this.disableAtHashCheck && + this.requestAccessToken && + !claims['at_hash'] + ) { + const err = 'An at_hash is needed!'; + console.warn(err); + return Promise.reject(err); + } + + const now = Date.now(); + const issuedAtMSec = claims.iat * 1000; + const expiresAtMSec = claims.exp * 1000; + const tenMinutesInMsec = 1000 * 60 * 10; + + if ( + issuedAtMSec - tenMinutesInMsec >= now || + expiresAtMSec + tenMinutesInMsec <= now + ) { + const err = 'Token has been expired'; + console.error(err); + console.error({ + now: now, + issuedAtMSec: issuedAtMSec, + expiresAtMSec: expiresAtMSec + }); + return Promise.reject(err); + } + + const validationParams: ValidationParams = { + accessToken: accessToken, + idToken: idToken, + jwks: this.jwks, + idTokenClaims: claims, + idTokenHeader: header, + loadKeys: () => this.loadJwks() + }; + + if ( + !this.disableAtHashCheck && + this.requestAccessToken && + !this.checkAtHash(validationParams) + ) { + const err = 'Wrong at_hash'; + console.warn(err); + return Promise.reject(err); + } + + return this.checkSignature(validationParams).then(_ => { + const result: ParsedIdToken = { + idToken: idToken, + idTokenClaims: claims, + idTokenClaimsJson: claimsJson, + idTokenHeader: header, + idTokenHeaderJson: headerJson, + idTokenExpiresAt: expiresAtMSec + }; + return result; + }); } - /* For now, we only check whether the sub against - * silentRefreshSubject when sessionChecksEnabled is on - * We will reconsider in a later version to do this - * in every other case too. - */ - if ( - this.sessionChecksEnabled && - this.silentRefreshSubject && - this.silentRefreshSubject !== claims['sub'] - ) { - const err = - 'After refreshing, we got an id_token for another user (sub). ' + - `Expected sub: ${this.silentRefreshSubject}, received sub: ${ - claims['sub'] - }`; + /** + * Returns the received claims about the user. + */ + public getIdentityClaims(): object { + const claims = this._storage.getItem('id_token_claims_obj'); + if (!claims) { + return null; + } + return JSON.parse(claims); + } - console.warn(err); - return Promise.reject(err); + /** + * Returns the granted scopes from the server. + */ + public getGrantedScopes(): object { + const scopes = this._storage.getItem('granted_scopes'); + if (!scopes) { + return null; + } + return JSON.parse(scopes); } - if (!claims.iat) { - const err = 'No iat claim in id_token'; - console.warn(err); - return Promise.reject(err); + /** + * Returns the current id_token. + */ + public getIdToken(): string { + return this._storage + ? this._storage.getItem('id_token') + : null; } - if (claims.iss !== this.issuer) { - const err = 'Wrong issuer: ' + claims.iss; - console.warn(err); - return Promise.reject(err); + private padBase64(base64data): string { + while (base64data.length % 4 !== 0) { + base64data += '='; + } + return base64data; } - if (claims.nonce !== savedNonce) { - const err = 'Wrong nonce: ' + claims.nonce; - console.warn(err); - return Promise.reject(err); + /** + * Returns the current access_token. + */ + public getAccessToken(): string { + return this._storage.getItem('access_token'); } - if ( - !this.disableAtHashCheck && - this.requestAccessToken && - !claims['at_hash'] - ) { - const err = 'An at_hash is needed!'; - console.warn(err); - return Promise.reject(err); + public getRefreshToken(): string { + return this._storage.getItem('refresh_token'); } - const now = Date.now(); - const issuedAtMSec = claims.iat * 1000; - const expiresAtMSec = claims.exp * 1000; - const tenMinutesInMsec = 1000 * 60 * 10; + /** + * Returns the expiration date of the access_token + * as milliseconds since 1970. + */ + public getAccessTokenExpiration(): number { + if (!this._storage.getItem('expires_at')) { + return null; + } + return parseInt(this._storage.getItem('expires_at'), 10); + } - if ( - issuedAtMSec - tenMinutesInMsec >= now || - expiresAtMSec + tenMinutesInMsec <= now - ) { - const err = 'Token has been expired'; - console.error(err); - console.error({ - now: now, - issuedAtMSec: issuedAtMSec, - expiresAtMSec: expiresAtMSec - }); - return Promise.reject(err); - } - - const validationParams: ValidationParams = { - accessToken: accessToken, - idToken: idToken, - jwks: this.jwks, - idTokenClaims: claims, - idTokenHeader: header, - loadKeys: () => this.loadJwks() - }; - - if ( - !this.disableAtHashCheck && - this.requestAccessToken && - !this.checkAtHash(validationParams) - ) { - const err = 'Wrong at_hash'; - console.warn(err); - return Promise.reject(err); - } - - return this.checkSignature(validationParams).then(_ => { - const result: ParsedIdToken = { - idToken: idToken, - idTokenClaims: claims, - idTokenClaimsJson: claimsJson, - idTokenHeader: header, - idTokenHeaderJson: headerJson, - idTokenExpiresAt: expiresAtMSec - }; - return result; - }); - } - - /** - * Returns the received claims about the user. - */ - public getIdentityClaims(): object { - const claims = this._storage.getItem('id_token_claims_obj'); - if (!claims) { - return null; - } - return JSON.parse(claims); - } - - /** - * Returns the granted scopes from the server. - */ - public getGrantedScopes(): object { - const scopes = this._storage.getItem('granted_scopes'); - if (!scopes) { - return null; - } - return JSON.parse(scopes); - } - - /** - * Returns the current id_token. - */ - public getIdToken(): string { - return this._storage.getItem('id_token'); - } - - private padBase64(base64data): string { - while (base64data.length % 4 !== 0) { - base64data += '='; - } - return base64data; - } - - /** - * Returns the current access_token. - */ - public getAccessToken(): string { - return this._storage.getItem('access_token'); - } - - public getRefreshToken(): string { - return this._storage.getItem('refresh_token'); - } - - /** - * Returns the expiration date of the access_token - * as milliseconds since 1970. - */ - public getAccessTokenExpiration(): number { - if (!this._storage.getItem('expires_at')) { - return null; - } - return parseInt(this._storage.getItem('expires_at'), 10); - } - - private getAccessTokenStoredAt(): number { - return parseInt(this._storage.getItem('access_token_stored_at'), 10); - } - - private getIdTokenStoredAt(): number { - return parseInt(this._storage.getItem('id_token_stored_at'), 10); - } - - /** - * Returns the expiration date of the id_token - * as milliseconds since 1970. - */ - public getIdTokenExpiration(): number { - if (!this._storage.getItem('id_token_expires_at')) { - return null; - } - - return parseInt(this._storage.getItem('id_token_expires_at'), 10); - } - - /** - * Checkes, whether there is a valid access_token. - */ - public hasValidAccessToken(): boolean { - if (this.getAccessToken()) { - const expiresAt = this._storage.getItem('expires_at'); - const now = new Date(); - if (expiresAt && parseInt(expiresAt, 10) < now.getTime()) { - return false; - } + private getAccessTokenStoredAt(): number { + return parseInt(this._storage.getItem('access_token_stored_at'), 10); + } + + private getIdTokenStoredAt(): number { + return parseInt(this._storage.getItem('id_token_stored_at'), 10); + } + + /** + * Returns the expiration date of the id_token + * as milliseconds since 1970. + */ + public getIdTokenExpiration(): number { + if (!this._storage.getItem('id_token_expires_at')) { + return null; + } - return true; + return parseInt(this._storage.getItem('id_token_expires_at'), 10); } - return false; - } + /** + * Checkes, whether there is a valid access_token. + */ + public hasValidAccessToken(): boolean { + if (this.getAccessToken()) { + const expiresAt = this._storage.getItem('expires_at'); + const now = new Date(); + if (expiresAt && parseInt(expiresAt, 10) < now.getTime()) { + return false; + } + + return true; + } - /** - * Checkes, whether there is a valid id_token. - */ - public hasValidIdToken(): boolean { - if (this.getIdToken()) { - const expiresAt = this._storage.getItem('id_token_expires_at'); - const now = new Date(); - if (expiresAt && parseInt(expiresAt, 10) < now.getTime()) { return false; - } - - return true; - } - - return false; - } - - /** - * Returns the auth-header that can be used - * to transmit the access_token to a service - */ - public authorizationHeader(): string { - return 'Bearer ' + this.getAccessToken(); - } - - /** - * Removes all tokens and logs the user out. - * If a logout url is configured, the user is - * redirected to it. - * @param noRedirectToLogoutUrl - */ - public logOut(noRedirectToLogoutUrl = false): void { - const id_token = this.getIdToken(); - this._storage.removeItem('access_token'); - this._storage.removeItem('id_token'); - this._storage.removeItem('refresh_token'); - this._storage.removeItem('nonce'); - this._storage.removeItem('expires_at'); - this._storage.removeItem('id_token_claims_obj'); - this._storage.removeItem('id_token_expires_at'); - this._storage.removeItem('id_token_stored_at'); - this._storage.removeItem('access_token_stored_at'); - this._storage.removeItem('granted_scopes'); - this._storage.removeItem('session_state'); - - this.silentRefreshSubject = null; - - this.eventsSubject.next(new OAuthInfoEvent('logout')); - - if (!this.logoutUrl) { - return; - } - if (noRedirectToLogoutUrl) { - return; - } - if (!id_token) { - return; - } - - let logoutUrl: string; - - if (!this.validateUrlForHttps(this.logoutUrl)) { - throw new Error( - 'logoutUrl must use Http. Also check property requireHttps.' - ); - } - - // For backward compatibility - if (this.logoutUrl.indexOf('{{') > -1) { - logoutUrl = this.logoutUrl - .replace(/\{\{id_token\}\}/, id_token) - .replace(/\{\{client_id\}\}/, this.clientId); - } else { - logoutUrl = - this.logoutUrl + - (this.logoutUrl.indexOf('?') > -1 ? '&' : '?') + - 'id_token_hint=' + - encodeURIComponent(id_token) + - '&post_logout_redirect_uri=' + - encodeURIComponent(this.postLogoutRedirectUri || this.redirectUri); - } - location.href = logoutUrl; - } - - /** - * @ignore - */ - public createAndSaveNonce(): Promise { - const that = this; - return this.createNonce().then(function(nonce: any) { - that._storage.setItem('nonce', nonce); - return nonce; - }); - } - - protected createNonce(): Promise { - return new Promise((resolve, reject) => { - if (this.rngUrl) { - throw new Error( - 'createNonce with rng-web-api has not been implemented so far' - ); - } else { - let text = ''; - const possible = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - - for (let i = 0; i < 40; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - - resolve(text); - } - }); - } - - private checkAtHash(params: ValidationParams): boolean { - if (!this.tokenValidationHandler) { - console.warn( - 'No tokenValidationHandler configured. Cannot check at_hash.' - ); - return true; - } - return this.tokenValidationHandler.validateAtHash(params); - } - - private checkSignature(params: ValidationParams): Promise { - if (!this.tokenValidationHandler) { - console.warn( - 'No tokenValidationHandler configured. Cannot check signature.' - ); - return Promise.resolve(null); - } - return this.tokenValidationHandler.validateSignature(params); - } + } + + /** + * Checkes, whether there is a valid id_token. + */ + public hasValidIdToken(): boolean { + if (this.getIdToken()) { + const expiresAt = this._storage.getItem('id_token_expires_at'); + const now = new Date(); + if (expiresAt && parseInt(expiresAt, 10) < now.getTime()) { + return false; + } + + return true; + } + + return false; + } + + /** + * Returns the auth-header that can be used + * to transmit the access_token to a service + */ + public authorizationHeader(): string { + return 'Bearer ' + this.getAccessToken(); + } + + /** + * Removes all tokens and logs the user out. + * If a logout url is configured, the user is + * redirected to it. + * @param noRedirectToLogoutUrl + */ + public logOut(noRedirectToLogoutUrl = false): void { + const id_token = this.getIdToken(); + this._storage.removeItem('access_token'); + this._storage.removeItem('id_token'); + this._storage.removeItem('refresh_token'); + this._storage.removeItem('nonce'); + this._storage.removeItem('expires_at'); + this._storage.removeItem('id_token_claims_obj'); + this._storage.removeItem('id_token_expires_at'); + this._storage.removeItem('id_token_stored_at'); + this._storage.removeItem('access_token_stored_at'); + this._storage.removeItem('granted_scopes'); + this._storage.removeItem('session_state'); + + this.silentRefreshSubject = null; + + this.eventsSubject.next(new OAuthInfoEvent('logout')); + + if (!this.logoutUrl) { + return; + } + if (noRedirectToLogoutUrl) { + return; + } + if (!id_token) { + return; + } + + let logoutUrl: string; + + if (!this.validateUrlForHttps(this.logoutUrl)) { + throw new Error( + 'logoutUrl must use Http. Also check property requireHttps.' + ); + } + + // For backward compatibility + if (this.logoutUrl.indexOf('{{') > -1) { + logoutUrl = this.logoutUrl + .replace(/\{\{id_token\}\}/, id_token) + .replace(/\{\{client_id\}\}/, this.clientId); + } else { + logoutUrl = + this.logoutUrl + + (this.logoutUrl.indexOf('?') > -1 ? '&' : '?') + + 'id_token_hint=' + + encodeURIComponent(id_token) + + '&post_logout_redirect_uri=' + + encodeURIComponent(this.postLogoutRedirectUri || this.redirectUri); + } + location.href = logoutUrl; + } + + /** + * @ignore + */ + public createAndSaveNonce(): Promise { + const that = this; + return this.createNonce().then(function (nonce: any) { + that._storage.setItem('nonce', nonce); + return nonce; + }); + } + + protected createNonce(): Promise { + return new Promise((resolve, reject) => { + if (this.rngUrl) { + throw new Error( + 'createNonce with rng-web-api has not been implemented so far' + ); + } else { + let text = ''; + const possible = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + for (let i = 0; i < 40; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + + resolve(text); + } + }); + } + + private checkAtHash(params: ValidationParams): boolean { + if (!this.tokenValidationHandler) { + console.warn( + 'No tokenValidationHandler configured. Cannot check at_hash.' + ); + return true; + } + return this.tokenValidationHandler.validateAtHash(params); + } + + private checkSignature(params: ValidationParams): Promise { + if (!this.tokenValidationHandler) { + console.warn( + 'No tokenValidationHandler configured. Cannot check signature.' + ); + return Promise.resolve(null); + } + return this.tokenValidationHandler.validateSignature(params); + } }