UNPKG

angular-simple-oidc

Version:

Angular Library implementing Open Id Connect specification. Code Flow, Refresh Tokens, Session Management, Discovery Document.

1,219 lines (1,187 loc) 67 kB
import * as i0 from '@angular/core'; import { InjectionToken, APP_INITIALIZER, Optional, Injectable, Inject, NgModule } from '@angular/core'; import { isObservable, of, BehaviorSubject, throwError, combineLatest, Subject, merge, interval, fromEvent } from 'rxjs'; import { ConfigService } from 'angular-simple-oidc/config'; import { map, tap, catchError, switchMap, take, shareReplay, withLatestFrom, delay, takeUntil, filter, finalize, timeout } from 'rxjs/operators'; import * as i4 from 'angular-simple-oidc/events'; import { EventsService, SimpleOidcInfoEvent } from 'angular-simple-oidc/events'; import * as i6 from 'angular-simple-oidc/core'; import { SimpleOidcError, TokenStorageKeys, TokenValidationService, TokenHelperService, AuthorizationCallbackFormatError, TokenUrlService, RefreshTokenValidationService, AngularSimpleOidcCoreModule } from 'angular-simple-oidc/core'; import { HttpClient, HttpHeaders, HttpClientModule } from '@angular/common/http'; import { switchTap, filterInstanceOf } from 'angular-simple-oidc/operators'; import { __awaiter } from 'tslib'; const AUTH_CONFIG_REQUIRED_FIELDS = ['clientId', 'openIDProviderUrl']; class TokenEndpointUnexpectedError extends SimpleOidcError { constructor(context) { super(`Token endpoint returned unexpected error`, `token-endpoint-unexpected-error`, context); } } class TokenEndpointError extends SimpleOidcError { constructor(error, context) { super(`Token endpoint returned error: ${error}`, `token-endpoint-${error}`, context); // TODO: Use errors from https://tools.ietf.org/html/rfc6749#section-5.2 } } class AuthenticationConfigurationMissingError extends SimpleOidcError { constructor() { super(`Expected AUTH_CONFIG to be in Injector.` + `\nYou need to provide a configuration either with AngularSimpleOidc.forRoot() ` + `or by adding your own (Observable<AuthConfig> | AuthConfig) ` + `into the injector with the AUTH_CONFIG injection token.`, `auth-config-missing`, null); } } class UserInfoNotSupportedError extends SimpleOidcError { constructor(doc) { super(`User Info is not supported, or it's URL was not in discovery document`, `user-info-not-suppo`, doc); } } const WINDOW_REF = new InjectionToken('Angular Simple OIDC Window Reference'); const LOCAL_STORAGE_REF = new InjectionToken('Angular Simple OIDC LocalStorage Reference'); function localStorageFactory() { return localStorage; } const LOCAL_STORAGE_PROVIDER = { provide: LOCAL_STORAGE_REF, useFactory: localStorageFactory }; function windowFactory() { return window; } const WINDOW_PROVIDER = { provide: WINDOW_REF, useFactory: windowFactory }; // Configuration const AUTH_CONFIG_SERVICE = new InjectionToken('AUTH_CONFIG'); const AUTH_CONFIG = new InjectionToken('AUTH_CONFIG'); const defaultConfig$2 = { discoveryDocumentUrl: `/.well-known/openid-configuration`, tokenCallbackRoute: 'oidc-token-callback', tokenValidation: { disableIdTokenIATValidation: false, idTokenIATOffsetAllowed: 10 // seconds }, enableAuthorizationCallbackAppInitializer: true }; function getApplicationBaseUrl() { const base = document.querySelector('base'); return base && base.href || ''; } function authConfigFactory(configInput, configService, window, events) { if (!configInput) { throw new AuthenticationConfigurationMissingError(); } const config$ = isObservable(configInput) ? configInput : of(configInput); return () => config$.pipe(map(config => { if (config && config.openIDProviderUrl) { // do not modify the provided objects. return Object.assign(Object.assign({}, config), { openIDProviderUrl: config.openIDProviderUrl.toLowerCase() }); } return config; }), tap(config => configService.configure(config, { defaultConfig: Object.assign(Object.assign({}, defaultConfig$2), { baseUrl: getApplicationBaseUrl() }), requiredFields: AUTH_CONFIG_REQUIRED_FIELDS })), catchError(e => { // make sure this errors get logged. console.error('Callback failed in AUTH_CONFIG_INITIALIZER'); console.error(e); events.dispatchError(e); // Do not prevent bootstrapping in order to be able to handle errors gracefully. return of(null); })) .toPromise(); } const AUTH_CONFIG_INITIALIZER = { multi: true, provide: APP_INITIALIZER, deps: [ [new Optional(), AUTH_CONFIG], AUTH_CONFIG_SERVICE, WINDOW_REF, EventsService ], useFactory: authConfigFactory }; const AUTH_CONFIG_SERVICE_PROVIDER = { provide: AUTH_CONFIG_SERVICE, useClass: ConfigService, }; // @dynamic class TokenStorageService { constructor(localStorage) { this.localStorage = localStorage; this.localStateSubject = new BehaviorSubject(this.getCurrentLocalState()); } get currentState$() { return this.localStateSubject.asObservable(); } get storage() { return this.localStorage; } storePreAuthorizationState(authState) { this.storage.setItem(TokenStorageKeys.Nonce, authState.nonce); this.storage.setItem(TokenStorageKeys.State, authState.state); this.storage.setItem(TokenStorageKeys.CodeVerifier, authState.codeVerifier); this.storage.setItem(TokenStorageKeys.PreRedirectUrl, authState.preRedirectUrl); const state = this.getCurrentLocalState(); this.localStateSubject.next(state); return of(state); } clearPreAuthorizationState() { this.storage.removeItem(TokenStorageKeys.Nonce); this.storage.removeItem(TokenStorageKeys.State); this.storage.removeItem(TokenStorageKeys.CodeVerifier); this.storage.removeItem(TokenStorageKeys.PreRedirectUrl); const state = this.getCurrentLocalState(); this.localStateSubject.next(state); return of(state); } storeAuthorizationCode(authorizationCode, sessionState) { this.storage.setItem(TokenStorageKeys.AuthorizationCode, authorizationCode); if (sessionState) { this.storage.setItem(TokenStorageKeys.SessionState, sessionState); } const state = this.getCurrentLocalState(); this.localStateSubject.next(state); return of(state); } storeOriginalIdToken(idToken) { this.storage.setItem(TokenStorageKeys.OriginalIdentityToken, idToken); const state = this.getCurrentLocalState(); this.localStateSubject.next(state); return of(state); } storeTokens(tokens) { this.storage.setItem(TokenStorageKeys.IdentityToken, tokens.idToken); this.storeJSON(TokenStorageKeys.IdentityTokenDecoded, tokens.decodedIdToken); this.storage.setItem(TokenStorageKeys.AccessToken, tokens.accessToken); if (tokens.accessTokenExpiresAt) { this.storage.setItem(TokenStorageKeys.AccessTokenExpiration, tokens.accessTokenExpiresAt.toString()); } if (tokens.refreshToken) { this.storage.setItem(TokenStorageKeys.RefreshToken, tokens.refreshToken); } const state = this.getCurrentLocalState(); this.localStateSubject.next(state); return of(state); } removeAll() { for (const k of Object.keys(TokenStorageKeys)) { // We can't use clear since we could // potentially delete keys which are not owned by us this.storage.removeItem(TokenStorageKeys[k]); } return of(this.getCurrentLocalState()); } getCurrentLocalState() { const state = { nonce: this.storage.getItem(TokenStorageKeys.Nonce), state: this.storage.getItem(TokenStorageKeys.State), codeVerifier: this.storage.getItem(TokenStorageKeys.CodeVerifier), authorizationCode: this.storage.getItem(TokenStorageKeys.AuthorizationCode), sessionState: this.storage.getItem(TokenStorageKeys.SessionState), identityToken: this.storage.getItem(TokenStorageKeys.IdentityToken), originalIdentityToken: this.storage.getItem(TokenStorageKeys.OriginalIdentityToken), accessToken: this.storage.getItem(TokenStorageKeys.AccessToken), accessTokenExpiration: parseInt(this.storage.getItem(TokenStorageKeys.AccessTokenExpiration), 10), refreshToken: this.storage.getItem(TokenStorageKeys.RefreshToken), preRedirectUrl: this.storage.getItem(TokenStorageKeys.PreRedirectUrl), decodedIdentityToken: this.readJSON(TokenStorageKeys.IdentityTokenDecoded) }; return state; } storeJSON(key, obj) { this.storage.setItem(key, JSON.stringify(obj)); } readJSON(key) { const json = this.storage.getItem(key); return json ? JSON.parse(json) : null; } } TokenStorageService.decorators = [ { type: Injectable } ]; TokenStorageService.ctorParameters = () => [ { type: Storage, decorators: [{ type: Inject, args: [LOCAL_STORAGE_REF,] }] } ]; const defaultUrlRegExp = /^(\w+:\/\/[^/?]+)?(.*?)(\?.+)?$/; const protocolRelativeUrlRegExp = /^(\/\/[^/?]+)(.*?)(\?.+)?$/; function splitUrl(partsStr, { protocolRelative }) { const match = (protocolRelative && partsStr.match(protocolRelativeUrlRegExp)) || partsStr.match(defaultUrlRegExp) || []; const beforePathname = match[1] || ''; const pathname = (match[2] || '') // Remove leading slashes .replace(/^\/+/, '') // Remove trailing slashes .replace(/\/+$/, '') // Normalize consecutive slashes to just one .replace(/\/+/g, '/'); const afterPathname = (match[3] || ''); return { beforePathname, pathname, afterPathname }; } function urlJoin(...parts) { const lastArg = parts[parts.length - 1]; let options; // If last argument is an object, then it's the options // Note that null is an object, so we verify if is truthy if (lastArg && typeof lastArg === 'object') { options = lastArg; parts = parts.slice(0, -1); } // Parse options options = Object.assign({ leadingSlash: true, trailingSlash: false, protocolRelative: false }, options); // Join parts const partsStr = parts .filter((part) => typeof part === 'string' || typeof part === 'number') .join('/'); // Split the parts into beforePathname, pathname, and afterPathname // (scheme://host)(/pathname)(?queryString) const { beforePathname, pathname, afterPathname } = splitUrl(partsStr, options); let url = ''; // Start with beforePathname if not empty (http://google.com) if (beforePathname) { url += beforePathname + (pathname ? '/' : ''); // Otherwise start with the leading slash } else if (options.leadingSlash) { url += '/'; } // Add pathname (foo/bar) url += pathname; // Add trailing slash if (options.trailingSlash && !url.endsWith('/')) { url += '/'; } return url; } class ObtainDiscoveryDocumentError extends SimpleOidcError { constructor(context) { super('Failed to obtain discovery document', 'discovery-doc-fetch-failed', context); } } class ObtainJWTKeysError extends SimpleOidcError { constructor(context) { super('Failed to obtain JWT Keys', 'jwt-keys-fetch-failed', context); } } class DiscoveryDocumentObtainedEvent extends SimpleOidcInfoEvent { constructor(discoveryDocument) { super(`Discovery Document Obtained`, discoveryDocument); } } class OidcDiscoveryDocClient { constructor(config, http, events) { this.config = config; this.http = http; this.events = events; this.current$ = this.config.current$ .pipe(map(config => urlJoin(config.openIDProviderUrl, config.discoveryDocumentUrl)), tap(url => this.events.dispatch(new SimpleOidcInfoEvent('Obtaining discovery document', url))), switchMap(url => this.http.get(url)), tap(doc => this.events.dispatch(new DiscoveryDocumentObtainedEvent(doc))), catchError(e => throwError(new ObtainDiscoveryDocumentError(e))), take(1), shareReplay()); this.jwtKeys$ = this.current$ .pipe(tap(doc => this.events.dispatch(new SimpleOidcInfoEvent('Obtaining JWT Keys', doc.jwks_uri))), switchMap(doc => this.http.get(doc.jwks_uri)), tap(j => this.events.dispatch(new SimpleOidcInfoEvent('JWT Keys obtained', j))), catchError(e => throwError(new ObtainJWTKeysError(e))), take(1), shareReplay()); } } OidcDiscoveryDocClient.decorators = [ { type: Injectable } ]; OidcDiscoveryDocClient.ctorParameters = () => [ { type: ConfigService, decorators: [{ type: Inject, args: [AUTH_CONFIG_SERVICE,] }] }, { type: HttpClient }, { type: EventsService } ]; class TokensObtainedEvent extends SimpleOidcInfoEvent { constructor(tokens) { super(`Tokens obtained`, tokens); } } class TokensValidatedEvent extends SimpleOidcInfoEvent { constructor(tokens) { super(`Tokens validated`, tokens); } } class TokensReadyEvent extends SimpleOidcInfoEvent { constructor(tokens) { super(`Tokens are ready to be used (validated and stored)`, tokens); } } class AccessTokenExpiredEvent extends SimpleOidcInfoEvent { constructor(payload) { super(`Access token has expired`, payload); } } class AccessTokenExpiringEvent extends SimpleOidcInfoEvent { constructor(payload) { super(`Access token is almost expired`, payload); } } class UserInfoObtainedEvent extends SimpleOidcInfoEvent { constructor(payload) { super(`Obtained User Profile`, payload); } } class TokenEndpointClientService { constructor(http, discoveryDocumentClient, tokenValidation, tokenHelper, events) { this.http = http; this.discoveryDocumentClient = discoveryDocumentClient; this.tokenValidation = tokenValidation; this.tokenHelper = tokenHelper; this.events = events; } call(payload) { const headers = new HttpHeaders() .set('Content-Type', 'application/x-www-form-urlencoded'); return this.discoveryDocumentClient.current$ .pipe(take(1), switchMap(({ token_endpoint }) => { this.events.dispatch(new SimpleOidcInfoEvent(`Executing Token Endpoint`, { url: token_endpoint, payload })); return this.http.post(token_endpoint, payload, { headers: headers }); }), tap({ error: (e) => { if (e instanceof SimpleOidcError) { return; } if (e.status === 400) { // https://tools.ietf.org/html/rfc6749#section-5.2 throw new TokenEndpointError(e.error.error, e); } else { throw new TokenEndpointUnexpectedError(e); } } }), map(response => { let expiresAt; if (response.expires_in) { expiresAt = this.tokenHelper.getExpirationFromExpiresIn(response.expires_in); } else { this.events.dispatch(new SimpleOidcInfoEvent(`Token Response did not contain expires_in`, response)); } let decodedToken; if (response.id_token) { this.events.dispatch(new SimpleOidcInfoEvent(`Validating Identity Token format`, response.id_token)); this.tokenValidation.validateIdTokenFormat(response.id_token); decodedToken = this.tokenHelper.getPayloadFromToken(response.id_token); this.events.dispatch(new SimpleOidcInfoEvent(`Identity Token Payload decoded`, decodedToken)); } else { this.events.dispatch(new SimpleOidcInfoEvent(`Token Response did not contain id_token`, response)); } const result = { accessToken: response.access_token, accessTokenExpiresIn: response.expires_in, accessTokenExpiresAt: expiresAt ? expiresAt.getTime() : null, error: response.error, idToken: response.id_token, refreshToken: response.refresh_token, decodedIdToken: decodedToken }; this.events.dispatch(new TokensObtainedEvent(result)); return result; })); } } TokenEndpointClientService.decorators = [ { type: Injectable } ]; TokenEndpointClientService.ctorParameters = () => [ { type: HttpClient }, { type: OidcDiscoveryDocClient }, { type: TokenValidationService }, { type: TokenHelperService }, { type: EventsService } ]; class DynamicIframe { constructor(document) { this.document = document; this.handle = this.document.createElement('iframe'); } appendTo(e) { e.appendChild(this.handle); return this; } appendToBody() { this.appendTo(this.document.body); return this; } setSource(url) { this.handle.src = url; return this; } hide() { this.handle.style.display = 'none'; return this; } postMessage(msg, origin) { this.handle.contentWindow.postMessage(msg, origin); return this; } remove() { // iframe may not have been appended. if (this.handle.parentElement) { this.handle.parentElement.removeChild(this.handle); } return this; } } // @dynamic class DynamicIframeService { constructor(window) { this.window = window; this.pending = []; } create() { const frame = new DynamicIframe(this.window.document); this.pending.push(frame); return frame; } } DynamicIframeService.ɵprov = i0.ɵɵdefineInjectable({ factory: function DynamicIframeService_Factory() { return new DynamicIframeService(i0.ɵɵinject(WINDOW_REF)); }, token: DynamicIframeService, providedIn: "root" }); DynamicIframeService.decorators = [ { type: Injectable, args: [{ providedIn: 'root' },] } ]; DynamicIframeService.ctorParameters = () => [ { type: Window, decorators: [{ type: Inject, args: [WINDOW_REF,] }] } ]; // @dynamic class OidcCodeFlowClient { constructor(window, config, discoveryDocumentClient, tokenStorage, tokenValidation, tokenUrl, tokenEndpointClient, events, dynamicIframe) { this.window = window; this.config = config; this.discoveryDocumentClient = discoveryDocumentClient; this.tokenStorage = tokenStorage; this.tokenValidation = tokenValidation; this.tokenUrl = tokenUrl; this.tokenEndpointClient = tokenEndpointClient; this.events = events; this.dynamicIframe = dynamicIframe; } startCodeFlow(options = {}) { const opts = Object.assign({}, options); if (!opts.returnUrlAfterCallback) { opts.returnUrlAfterCallback = this.window.location.href; } return this.config.current$ .pipe(map(config => urlJoin(config.baseUrl, config.tokenCallbackRoute)), switchMap(redirectUri => this.generateCodeFlowMetadata({ redirectUri })), tap(() => this.events.dispatch(new SimpleOidcInfoEvent(`Starting Code Flow`))), switchMap((result) => { this.events.dispatch(new SimpleOidcInfoEvent(`Authorize URL generated`, result)); return this.tokenStorage.storePreAuthorizationState({ nonce: result.nonce, state: result.state, codeVerifier: result.codeVerifier, preRedirectUrl: opts.returnUrlAfterCallback }).pipe(tap((state) => { this.events.dispatch(new SimpleOidcInfoEvent(`Pre-authorize state stored`, state)); this.redirectToUrl(result.url); })); }), take(1)); } generateCodeFlowMetadata(params) { return this.discoveryDocumentClient.current$ .pipe(withLatestFrom(this.config.current$), map(([discoveryDocument, config]) => this.tokenUrl.createAuthorizeUrl(discoveryDocument.authorization_endpoint, Object.assign(Object.assign({ clientId: config.clientId, scope: config.scope, responseType: 'code' }, config.acrValues && { acrValues: config.acrValues }), params))), take(1)); } parseCodeFlowCallbackParams(href) { try { const result = this.tokenUrl.parseAuthorizeCallbackParamsFromUrl(href); return Object.assign(Object.assign({}, result), { href }); } catch (error) { throw new AuthorizationCallbackFormatError(error); } } validateCodeFlowCallback(params, localState) { const { href, code, state, error } = params; this.events.dispatch(new SimpleOidcInfoEvent(`Validating URL params`, { code, state, error, href })); this.tokenValidation.validateAuthorizeCallbackFormat(code, state, error, href); this.events.dispatch(new SimpleOidcInfoEvent(`Validating state vs local state`, { localState, state })); this.tokenValidation.validateAuthorizeCallbackState(localState, state); this.events.dispatch(new SimpleOidcInfoEvent(`Obtained authorization code.`, { code, state })); } codeFlowCallback(href, redirectUri, metadata) { const params = this.parseCodeFlowCallbackParams(href); this.validateCodeFlowCallback(params, metadata.state); return this.tokenStorage.storeAuthorizationCode(params.code, params.sessionState) .pipe(switchMap(() => this.config.current$), switchMap(authConfig => { const payload = this.tokenUrl.createAuthorizationCodeRequestPayload({ clientId: authConfig.clientId, clientSecret: authConfig.clientSecret, scope: authConfig.scope, redirectUri, code: params.code, codeVerifier: metadata.codeVerifier, }); return this.requestTokenWithAuthCode(payload, metadata.nonce); })); } currentWindowCodeFlowCallback() { return this.tokenStorage.currentState$ .pipe(take(1), // we will be trigger currentState$ below tap(() => this.events.dispatch(new SimpleOidcInfoEvent(`Starting Code Flow callback`))), withLatestFrom(this.config.current$), map(([localState, config]) => { const redirectUri = urlJoin(config.baseUrl, config.tokenCallbackRoute); return { localState, redirectUri }; }), switchMap(({ localState, redirectUri }) => this.codeFlowCallback(this.window.location.href, redirectUri, localState).pipe(tap(() => this.historyChangeUrl(localState.preRedirectUrl)))), take(1)); } requestTokenWithAuthCode(payload, nonce) { // The discovery document for issuer const discoveryDocument$ = this.discoveryDocumentClient.current$ .pipe(take(1)); // JWT Keys to validate id token signature const jwtKeys$ = this.discoveryDocumentClient.jwtKeys$ .pipe(take(1)); return this.tokenEndpointClient.call(payload) .pipe(tap(() => this.events.dispatch(new SimpleOidcInfoEvent(`Requesting token using authorization code`, payload))), withLatestFrom(discoveryDocument$, jwtKeys$, this.config.current$), take(1), tap(([result, discoveryDocument, jwtKeys, config]) => { this.events.dispatch(new SimpleOidcInfoEvent('Validating identity token..', { result, nonce, discoveryDocument, jwtKeys })); this.tokenValidation.validateIdToken(config.clientId, result.idToken, result.decodedIdToken, nonce, discoveryDocument, jwtKeys, config.tokenValidation); }), tap(([result]) => { this.events.dispatch(new SimpleOidcInfoEvent('Validating access token..', result)); this.tokenValidation.validateAccessToken(result.accessToken, result.decodedIdToken.at_hash); }), switchTap(() => { this.events.dispatch(new SimpleOidcInfoEvent('Clearing pre-authorize state..')); return this.tokenStorage.clearPreAuthorizationState(); }), switchTap(([result]) => { this.events.dispatch(new TokensValidatedEvent(result)); this.events.dispatch(new SimpleOidcInfoEvent('Storing tokens..', result)); return this.tokenStorage.storeTokens(result); }), switchTap(([result]) => { this.events.dispatch(new SimpleOidcInfoEvent('Storing original Identity Token..', result.idToken)); return this.tokenStorage.storeOriginalIdToken(result.idToken); }), map(([result]) => result), tap((result) => this.events.dispatch(new TokensReadyEvent(result)))); } redirectToUrl(url) { this.events.dispatch(new SimpleOidcInfoEvent(`Redirecting`, url)); this.window.location.href = url; } historyChangeUrl(url) { if (this.window.history) { this.events.dispatch(new SimpleOidcInfoEvent(`Changing URL with history API`, url)); this.window.history.pushState({}, null, url); } else { this.redirectToUrl(url); } } } OidcCodeFlowClient.decorators = [ { type: Injectable } ]; OidcCodeFlowClient.ctorParameters = () => [ { type: Window, decorators: [{ type: Inject, args: [WINDOW_REF,] }] }, { type: ConfigService, decorators: [{ type: Inject, args: [AUTH_CONFIG_SERVICE,] }] }, { type: OidcDiscoveryDocClient }, { type: TokenStorageService }, { type: TokenValidationService }, { type: TokenUrlService }, { type: TokenEndpointClientService }, { type: EventsService }, { type: DynamicIframeService } ]; // This APP_INITIALIZER makes sure the OAuthService is ready // must be an export function for AOT to work function simpleOidcInitializer(configService, oidcCodeFlowClient, events, window) { return () => { return configService.current$ .pipe(take(1), switchMap(config => { if (config.enableAuthorizationCallbackAppInitializer && window.location.pathname.includes(config.tokenCallbackRoute)) { return oidcCodeFlowClient.currentWindowCodeFlowCallback(); } else { return of(null); } }), catchError(e => { // make sure this errors get logged. console.error('Callback failed in APP_INITIALIZER'); console.error(e); events.dispatchError(e); // Do not prevent bootstrapping in order to be able to handle errors gracefully. return of(null); })) .toPromise(); }; } const SIMPLE_OIDC_APP_INITIALIZER = { provide: APP_INITIALIZER, useFactory: simpleOidcInitializer, deps: [ AUTH_CONFIG_SERVICE, OidcCodeFlowClient, EventsService, WINDOW_REF ], multi: true, }; class RefreshTokenClient { constructor(config, tokenStorage, tokenUrl, tokenHelper, tokenEndpointClient, refreshTokenValidation, tokenValidation, events) { this.config = config; this.tokenStorage = tokenStorage; this.tokenUrl = tokenUrl; this.tokenHelper = tokenHelper; this.tokenEndpointClient = tokenEndpointClient; this.refreshTokenValidation = refreshTokenValidation; this.tokenValidation = tokenValidation; this.events = events; } requestTokenWithRefreshCode() { return this.tokenStorage.currentState$.pipe(withLatestFrom(this.config.current$), take(1), switchMap(([localState, config]) => { const payload = this.tokenUrl.createRefreshTokenRequestPayload({ clientId: config.clientId, clientSecret: config.clientSecret, refreshToken: localState.refreshToken }); this.events.dispatch(new SimpleOidcInfoEvent(`Refreshing token using refresh code`, { payload, refreshToken: localState.refreshToken })); return this.tokenEndpointClient.call(payload); }), withLatestFrom(this.tokenStorage.currentState$), tap(([result, localState]) => { const originalToken = this.tokenHelper.getPayloadFromToken(localState.originalIdentityToken); this.events.dispatch(new SimpleOidcInfoEvent(`Validating new Identity Token against original`, { result, originalToken })); this.refreshTokenValidation.validateIdToken(originalToken, result.decodedIdToken); }), tap(([result]) => { this.events.dispatch(new SimpleOidcInfoEvent(`Validating access token against at_hash`, { accessToken: result.accessToken, hash: result.decodedIdToken.at_hash })); this.tokenValidation.validateAccessToken(result.accessToken, result.decodedIdToken.at_hash); }), tap(([result]) => this.events.dispatch(new TokensValidatedEvent(result))), switchMap(([result]) => { this.events.dispatch(new SimpleOidcInfoEvent(`Storing new tokens..`, result)); return this.tokenStorage.storeTokens(result) .pipe(map(() => result)); }), tap((result) => this.events.dispatch(new TokensReadyEvent(result)))); } } RefreshTokenClient.decorators = [ { type: Injectable } ]; RefreshTokenClient.ctorParameters = () => [ { type: ConfigService, decorators: [{ type: Inject, args: [AUTH_CONFIG_SERVICE,] }] }, { type: TokenStorageService }, { type: TokenUrlService }, { type: TokenHelperService }, { type: TokenEndpointClientService }, { type: RefreshTokenValidationService }, { type: TokenValidationService }, { type: EventsService } ]; // @dynamic class EndSessionClientService { constructor(window, discoveryDocumentClient, tokenUrl, tokenStorage, events) { this.window = window; this.discoveryDocumentClient = discoveryDocumentClient; this.tokenUrl = tokenUrl; this.tokenStorage = tokenStorage; this.events = events; } logoutWithRedirect(postLogoutRedirectUri) { const doc$ = this.discoveryDocumentClient.current$; const localState$ = this.tokenStorage.currentState$; return combineLatest(doc$, localState$) .pipe(take(1), map(([doc, localState]) => this.tokenUrl.createEndSessionUrl(doc.end_session_endpoint, { idTokenHint: localState.identityToken, postLogoutRedirectUri })), switchTap(() => { this.events.dispatch(new SimpleOidcInfoEvent('Deleting Local Session')); return this.tokenStorage.removeAll(); }), tap(({ url }) => { this.events.dispatch(new SimpleOidcInfoEvent('Redirecting to End Session Endpoint', url)); this.window.location.href = url; })); } } EndSessionClientService.decorators = [ { type: Injectable } ]; EndSessionClientService.ctorParameters = () => [ { type: Window, decorators: [{ type: Inject, args: [WINDOW_REF,] }] }, { type: OidcDiscoveryDocClient }, { type: TokenUrlService }, { type: TokenStorageService }, { type: EventsService } ]; // @dynamic class UserInfoClientService { constructor(discoveryDocumentClient, tokenStorage, events, http) { this.discoveryDocumentClient = discoveryDocumentClient; this.tokenStorage = tokenStorage; this.events = events; this.http = http; } getUserInfo() { const doc$ = this.discoveryDocumentClient.current$; const localState$ = this.tokenStorage.currentState$; return combineLatest(doc$, localState$) .pipe(take(1), tap(() => this.events.dispatch(new SimpleOidcInfoEvent('Requesting User Info'))), tap(([doc]) => { if (!doc.userinfo_endpoint) { throw new UserInfoNotSupportedError(doc); } }), switchMap(([doc, localState]) => this.http.get(doc.userinfo_endpoint, { headers: new HttpHeaders({ authorization: `Bearer ${localState.accessToken}` }) })), tap(profile => this.events.dispatch(new UserInfoObtainedEvent(profile)))); } } UserInfoClientService.decorators = [ { type: Injectable } ]; UserInfoClientService.ctorParameters = () => [ { type: OidcDiscoveryDocClient }, { type: TokenStorageService }, { type: EventsService }, { type: HttpClient } ]; class AuthService { constructor(oidcClient, tokenHelper, tokenStorage, refreshTokenClient, endSessionClient, config, events, userInfoClient) { this.oidcClient = oidcClient; this.tokenHelper = tokenHelper; this.tokenStorage = tokenStorage; this.refreshTokenClient = refreshTokenClient; this.endSessionClient = endSessionClient; this.config = config; this.events = events; this.userInfoClient = userInfoClient; this.userInfo$ = this.events$.pipe(filterInstanceOf(TokensReadyEvent), switchMap(() => this.userInfoClient.getUserInfo()), shareReplay()); } get isLoggedIn$() { return this.tokenStorage.currentState$ .pipe(map(({ accessToken, accessTokenExpiration }) => { if (!accessToken || this.tokenHelper.isTokenExpired(accessTokenExpiration)) { return false; } return true; })); } get accessToken$() { return this.tokenStorage.currentState$ .pipe(map(s => s.accessToken)); } get tokenExpiration$() { return this.tokenStorage.currentState$ .pipe(map(s => new Date(s.accessTokenExpiration))); } get refreshToken$() { return this.tokenStorage.currentState$ .pipe(map(s => s.refreshToken)); } get identityToken$() { return this.tokenStorage.currentState$ .pipe(map(s => s.identityToken)); } get identityTokenDecoded$() { return this.tokenStorage.currentState$ .pipe(map(s => s.decodedIdentityToken)); } get events$() { return this.events.events$; } get errors$() { return this.events.errors$; } startCodeFlow(options) { return this.oidcClient.startCodeFlow(options) .pipe(tap({ error: e => this.events.dispatchError(e) })); } refreshAccessToken() { return this.refreshTokenClient.requestTokenWithRefreshCode() .pipe(tap({ error: e => this.events.dispatchError(e) })); } endSession(postLogoutRedirectUri) { return this.config.current$ .pipe(take(1), switchMap(config => this.endSessionClient.logoutWithRedirect(postLogoutRedirectUri || config.baseUrl)), tap({ error: e => this.events.dispatchError(e) })); } } AuthService.decorators = [ { type: Injectable } ]; AuthService.ctorParameters = () => [ { type: OidcCodeFlowClient }, { type: TokenHelperService }, { type: TokenStorageService }, { type: RefreshTokenClient }, { type: EndSessionClientService }, { type: ConfigService, decorators: [{ type: Inject, args: [AUTH_CONFIG_SERVICE,] }] }, { type: EventsService }, { type: UserInfoClientService } ]; class AuthGuard { constructor(auth, events) { this.auth = auth; this.events = events; } canActivate(next, state) { return this.auth.isLoggedIn$ .pipe(take(1), switchMap(authenticated => { if (!authenticated) { this.events.dispatch(new SimpleOidcInfoEvent(`Route requires auth. No token or it's expired.`, { route: state.url })); return this.auth.startCodeFlow({ returnUrlAfterCallback: state.url }) // return false so that route change does not happen .pipe(map(() => false)); } return of(true); })); } } AuthGuard.decorators = [ { type: Injectable } ]; AuthGuard.ctorParameters = () => [ { type: AuthService }, { type: EventsService } ]; class TokenExpirationDaemonService { constructor(auth, events) { this.auth = auth; this.events = events; this.destroyedSubject = new Subject(); } startDaemon() { this.watchTokenExpiration(); } /** * Sets timeouts based on the token expiration. * Dispatches AccessTokenExpiringEvent before the access token expires (grace period) * Dispatches AccessTokenExpiredEvent when the access token expires. */ watchTokenExpiration() { this.auth.events$ .pipe(filterInstanceOf(TokensReadyEvent), switchMap(({ payload }) => { if (payload.accessToken && payload.accessTokenExpiresAt) { const expiration = new Date(payload.accessTokenExpiresAt); // TODO: Expose as config? const gracePeriod = 60; const beforeExpiration = new Date(payload.accessTokenExpiresAt); beforeExpiration.setSeconds(beforeExpiration.getSeconds() - gracePeriod); const expiring$ = of(new AccessTokenExpiringEvent({ token: payload.accessToken, expiresAt: expiration, })).pipe(delay(beforeExpiration)); const expired$ = of(new AccessTokenExpiredEvent({ token: payload.accessToken, expiredAt: expiration, })).pipe(delay(expiration)); return merge(expiring$, expired$); } else { return of(new SimpleOidcInfoEvent('TokenExpired event not configured due to access token or expiration empty.')); } }), takeUntil(this.destroyedSubject)) .subscribe((e) => { if (e.payload) { e.payload.now = new Date(); } this.events.dispatch(e); }); } ngOnDestroy() { this.destroyedSubject.next(); this.destroyedSubject.complete(); } } TokenExpirationDaemonService.decorators = [ { type: Injectable } ]; TokenExpirationDaemonService.ctorParameters = () => [ { type: AuthService }, { type: EventsService } ]; class TokenFromStorageInitializerDaemonService { constructor(events, storage, tokenHelper) { this.events = events; this.storage = storage; this.tokenHelper = tokenHelper; this.destroyedSubject = new Subject(); } startDaemon() { this.loadTokenFromStorage(); } loadTokenFromStorage() { return __awaiter(this, void 0, void 0, function* () { const currentState = yield this.storage.currentState$ .pipe(take(1), takeUntil(this.destroyedSubject)) .toPromise(); if (!currentState.accessToken && !currentState.identityToken && !currentState.refreshToken) { return; } if (!this.tokenHelper.isTokenExpired(currentState.accessTokenExpiration)) { this.events.dispatch(new TokensReadyEvent({ accessToken: currentState.accessToken, accessTokenExpiresAt: currentState.accessTokenExpiration, decodedIdToken: currentState.decodedIdentityToken, idToken: currentState.identityToken, refreshToken: currentState.refreshToken, })); } else { this.events.dispatch(new SimpleOidcInfoEvent('Found token in storage but it\'s expired')); } }); } ngOnDestroy() { this.destroyedSubject.next(); this.destroyedSubject.complete(); } } TokenFromStorageInitializerDaemonService.decorators = [ { type: Injectable } ]; TokenFromStorageInitializerDaemonService.ctorParameters = () => [ { type: EventsService }, { type: TokenStorageService }, { type: TokenHelperService } ]; class TokenEventsModule { constructor(tokenExpirationDaemonService, tokenFromStorageInitializerDaemonService) { this.tokenExpirationDaemonService = tokenExpirationDaemonService; this.tokenFromStorageInitializerDaemonService = tokenFromStorageInitializerDaemonService; this.tokenExpirationDaemonService.startDaemon(); this.tokenFromStorageInitializerDaemonService.startDaemon(); } } TokenEventsModule.decorators = [ { type: NgModule, args: [{ providers: [TokenExpirationDaemonService, TokenFromStorageInitializerDaemonService], },] } ]; TokenEventsModule.ctorParameters = () => [ { type: TokenExpirationDaemonService }, { type: TokenFromStorageInitializerDaemonService } ]; class AngularSimpleOidcModule { /** * Should be called once on your Angular Root Application Module */ static forRoot(config) { return { ngModule: AngularSimpleOidcModule, providers: [ config != null ? { provide: AUTH_CONFIG, useValue: config } : [], AUTH_CONFIG_SERVICE_PROVIDER, AUTH_CONFIG_INITIALIZER, SIMPLE_OIDC_APP_INITIALIZER, ] }; } } AngularSimpleOidcModule.decorators = [ { type: NgModule, args: [{ imports: [ HttpClientModule, AngularSimpleOidcCoreModule, TokenEventsModule ], providers: [ WINDOW_PROVIDER, LOCAL_STORAGE_PROVIDER, TokenStorageService, TokenEndpointClientService, OidcDiscoveryDocClient, OidcCodeFlowClient, RefreshTokenClient, EndSessionClientService, UserInfoClientService, AuthService, AuthGuard, ], declarations: [], exports: [ AngularSimpleOidcCoreModule ] },] } ]; class ExpiringTokenRefresherDaemonService { constructor(auth, events) { this.auth = auth; this.events = events; this.destroyedSubject = new Subject(); } startDaemon() { this.auth.events$ .pipe(filterInstanceOf(AccessTokenExpiringEvent), switchMap(() => this.auth.refreshAccessToken()), takeUntil(this.destroyedSubject)) .subscribe(); } ngOnDestroy() { this.destroyedSubject.next(); this.destroyedSubject.complete(); } } ExpiringTokenRefresherDaemonService.decorators = [ { type: Injectable } ]; ExpiringTokenRefresherDaemonService.ctorParameters = () => [ { type: AuthService }, { type: EventsService } ]; class AutomaticRefreshModule { constructor(daemon) { this.daemon = daemon; this.daemon.startDaemon(); } } AutomaticRefreshModule.decorators = [ { type: NgModule, args: [{ providers: [ExpiringTokenRefresherDaemonService], },] } ]; AutomaticRefreshModule.ctorParameters = () => [ { type: ExpiringTokenRefresherDaemonService } ]; class SessionCheckNotSupportedError extends SimpleOidcError { constructor(context) { super(`Session check not supported by OP: check_session_iframe on discovery document and session_state are required`, `session-check-not-supported`, context); } } class SessionCheckFailedError extends SimpleOidcError { constructor(context) { super(`OP iframe returned error. According to spec, message malformed?`, `session-check-error`, context); } } class IframePostMessageTimeoutError extends SimpleOidcError { constructor(context) { super(`Iframe failed to postMessage back in given time.`, `iframe-post-message-timeout`, context); } } class SessionManagementConfigurationMissingError extends SimpleOidcError { constructor() { super(`Expected SESSION_MANAGEMENT_CONFIG to be in Injector.` + `\nYou need to provide a configuration either with SessionManagementModule.forRoot() ` + `or by adding your own (Observable<SessionManagementConfig> | SessionManagementConfig) ` + `into the injector with the SESSION_MANAGEMENT_CONFIG injection token.`, `session-management-config-missing`, null); } } class SessionChangedEvent extends SimpleOidcInfoEvent { constructor() { super(`Session has changed.`); } } class SessionTerminatedEvent extends SimpleOidcInfoEvent { constructor(context) { super(`Session has been terminated.`, context); } } const SESSION_MANAGEMENT_CONFIG_REQUIRED_FIELDS = [ 'iframePath' ]; const SESSION_MANAGEMENT_CONFIG_SERVICE = new InjectionToken('SESSION_MANAGEMENT_CONFIG_SERVICE'); const SESSION_MANAGEMENT_CONFIG = new InjectionToken('SESSION_MANAGEMENT_CONFIG'); const defaultConfig$1 = { opIframePollInterval: 1 * 1000, iframeTimeout: 10 * 1000 }; function sessionManagementConfigFactory(configInput, configService, events) { if (!configInput) { throw new SessionManagementConfigurationMissingError(); } const config$ = isObservable(configInput) ? configInput : of(configInput); return () => config$.pipe(tap(config => configService.configure(config, { defaultConfig: defaultConfig$1, requiredFields: SESSION_MANAGEMENT_CONFIG_REQUIRED_FIELDS })), catchError(e => { // make sure this errors get logged. console.error('Callback failed in SESSION_MANAGEMENT_CONFIG_INITIALIZER'); console.error(e); events.dispatchError(e); // Do not prevent bootstrapping in order to be able to handle errors gracefully. return of(null); })) .toPromise(); } const SESSION_MANAGEMENT_CONFIG_INITIALIZER = { multi: true, provide: APP_INITIALIZER, deps: [ [new Optional(), SESSION_MANAGEMENT_CONFIG], SESSION_MANAGEMENT_CONFIG_SERVICE, EventsService ], useFactory: sessionManagementConfigFactory }; const SESSION_MANAGEMENT_CONFIG_SERVICE_PROVIDER = { provide: SESSION_MANAGEMENT_CONFIG_SERVICE, useClass: ConfigService, }; // @dynamic class SessionCheckService { constructor(window, discoveryClient, dynamicIframe, tokenStorage, config, sessionConfig, events) { this.window = window; this.discoveryClient = discoveryClient; this.dynamicIframe = dynamicIframe; this.tokenStorage = tokenStorage; this.config = config; this.sessionConfig = sessionConfig; this.events = events; this.destroyedSubject = new Subject(); } ngOnDestroy() { this.destroyedSubject.next(); this.destroyedSubject.complete(); } startSessionCheck() { const localState$ = this.tokenStorage.currentState$ .pipe(take(1)); const doc$ = this.discoveryClient.current$ .pipe(take(1)); const iframe = this.dynamicIframe .create() .hide(); return combineLatest(doc$, localState$) .pipe(tap(() => this.events.dispatch(new SimpleOidcInfoEvent('Starting Session Check'))), map(([doc, localState]) => { if (!doc.check_session_iframe || !localState.sessionState) { throw new SessionCheckNotSupportedError({ doc, localState }); } else { iframe.setSource(doc.check_session_iframe) .appendToBody(); return localState; } }), withLatestFrom(this.config.current$, this.sessionConfig.current$), take(1), switchMap(([localState, authConfig, sessionConfig]) => { const expectedIframeOrigin = new URL(authConfig.openIDProviderUrl).origin; const pollIframe$ = interval(sessionConfig.opIframePollInterval) .pipe(map(() => `${authConfig.clientId.toLowerCase()} ${localState.sessionState}`), tap(msg => iframe.postMessage(msg, expectedIframeOrigin))); const listen$ = fromEvent(this.window, 'message') .pipe(filter((e) => e.origin === expectedIframeOrigin), map((e) => this.fireEventsFromMessage(e)), filter(msg => !!msg) // only the messages we care ); return combineLatest(pollIframe$, listen$) .pipe(map((arr) => arr[1])); }), finalize(() => iframe.remove()), takeUntil(this.destroyedSubject.asObservable())); } /** * The received data will either be changed or unchanged * unless the syntax of the message sent was determined by the OP to be malformed, * in which case the received data will be error. * @param msg */ fireEventsFromMessage(msg) { const result = msg.data; switch (result) { case 'changed': this.events.dispatch(new SessionChangedEvent()); return result; case 'unchanged': return result; case 'error': throw new SessionCheckFailedError(msg); default: // If the message is not the one we expect, ignore it. return null; } } } SessionCheckService.decorators = [ { type: Injectable } ]; SessionCheckService.ctorParameters = () => [ { type: Window, decorators: [{ type: Inject, args: [WINDOW_REF,] }] }, { type: OidcDiscoveryDocClient }, { type: DynamicIframeService }, { type: TokenStorageService }, { type: ConfigService, decorators: [{ type: Inject, args: [AUTH_CONFIG_SERVICE,] }] }, { type: ConfigService, decorators: [{ type: Inject, args: [SESSION_MANAGEMENT_CONFIG_SERVICE,] }] }, { type: EventsService } ]; // @dynamic class AuthorizeEndpointSilentClientService { constructor(window, discoveryClient, dynamicIframe, tokenStorage, authConfig, sessionConfig, events, oidcClient, tokenUrl) {