UNPKG

@terminus/ngx-tools

Version:

[![CircleCI][circle-badge]][circle-link] [![codecov][codecov-badge]][codecov-project] [![semantic-release][semantic-release-badge]][semantic-release] [![MIT License][license-image]][license-url] <br> [![NPM version][npm-version-image]][npm-url] [![Github

657 lines (638 loc) 25.2 kB
import { defineTypeEnum } from '@terminus/ngx-tools/utilities'; import { __decorate, __param } from 'tslib'; import { InjectionToken, Optional, Inject, Injectable, NgModule } from '@angular/core'; import { ofType, Actions, Effect, EffectsModule } from '@ngrx/effects'; import { createFeatureSelector, createSelector, Store, select, StoreModule } from '@ngrx/store'; import { TsCookieService } from '@terminus/ngx-tools/browser'; import { throwError, merge, timer, Scheduler, of } from 'rxjs'; import { async } from 'rxjs/internal/scheduler/async'; import { retryWhen, mergeMap, take, delay, filter, switchMap, withLatestFrom, map, flatMap, tap, catchError } from 'rxjs/operators'; import { HttpHeaders, HttpClient, HttpClientModule } from '@angular/common/http'; import { isTokenResponse, isHttpResponse } from '@terminus/ngx-tools/type-guards'; /* eslint-disable @typescript-eslint/no-magic-numbers, no-bitwise, no-mixed-operators */ /** * The code was extracted from: * https://github.com/davidchambers/Base64.js */ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; class InvalidCharacterError extends Error { constructor(message) { super(message); this.message = message; } } /** * Encode value * * @param input * @returns The encoded value */ function atobPolyfill(input) { const str = String(input).replace(/=+$/, ''); if (str.length % 4 === 1) { throw new InvalidCharacterError("NGXTools: 'atob' failed: The string to be decoded is not correctly encoded."); } let output = ''; for ( // initialize result and counters let bc = 0, bs, buffer, idx = 0; // get next character buffer = str.charAt(idx++); // character found in table? initialize bit storage and add its ascii value; // eslint-disable-next-line @typescript-eslint/no-explicit-any ~buffer && (bs = bc % 4 ? (bs * 64) + buffer : buffer, // and if not first of each 4 characters, // convert the first 8 bits to one ascii character bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0) { // try to find character in table (0-63, not found => -1) buffer = chars.indexOf(buffer); } return output; } /* eslint-disable @typescript-eslint/no-magic-numbers */ const localAtob = window.atob || atobPolyfill; /** * Decode unicode value * * @param str * @returns The decoded value */ const b64DecodeUnicode = (str) => decodeURIComponent(localAtob(str).replace(/(.)/g, function (m, p) { let code = p.charCodeAt(0).toString(16).toUpperCase(); if (code.length < 2) { code = `0${code}`; } return `%${code}`; })); const ɵ0 = b64DecodeUnicode; // eslint-disable-next-line camelcase /** * Decode url encoded value * * @param str * @returns The decoded value */ // eslint-disable-next-line camelcase function base64_url_decode(str) { let output = str.replace(/-/g, '+').replace(/_/g, '/'); switch (output.length % 4) { case 0: break; case 2: output += '=='; break; case 3: output += '='; break; default: throw new Error('Illegal base64url string!'); } try { return b64DecodeUnicode(output); } catch (err) { return localAtob(output); } } // Rewritten from https://github.com/auth0/jwt-decode/tree/master/lib to be typescript compliant class InvalidTokenError { constructor(message) { this.message = message; } } // eslint-disable-next-line @typescript-eslint/no-explicit-any InvalidTokenError.prototype.name = 'InvalidTokenError'; /** * Decode JWT token * * @param token * @param options * @returns Token */ function jwtDecode(token, options) { if (typeof token !== 'string') { throw new InvalidTokenError('Invalid token specified'); } options = options || {}; const pos = options.header === true ? 0 : 1; try { return JSON.parse(base64_url_decode(token.split('.')[pos])); } catch (e) { if (e instanceof Error) { throw new InvalidTokenError(`Invalid token specified: ${e.message}`); } else { throw e; } } } var ActionTypes; (function (ActionTypes) { ActionTypes["StoreToken"] = "[ngx-tools-jwt-token-provider] Store Token"; ActionTypes["TokenNearingExpiration"] = "[ngx-tools-jwt-token-provider] Token Nearing Expiration"; ActionTypes["TokenExpired"] = "[ngx-tools-jwt-token-provider] Token Expired"; ActionTypes["EscalateToken"] = "[ngx-tools-jwt-token-provider] Escalate Token"; ActionTypes["EscalationSuccess"] = "[ngx-tools-jwt-token-provider] Escalation Success"; ActionTypes["EscalationFailed"] = "[ngx-tools-jwt-token-provider] Escalation Failed"; ActionTypes["AllTokensExpired"] = "[ngx-tools-jwt-token-provider] All Tokens have Expired"; ActionTypes["InitialTokenExtracted"] = "[ngx-tools-jwt-token-provider] Initial Token Extracted"; ActionTypes["FailedToActivateRoute"] = "[ngx-tools-jwt-token-provider] Failed To Activate Route"; })(ActionTypes || (ActionTypes = {})); defineTypeEnum(ActionTypes); /** * InitialTokenExtracted */ class InitialTokenExtracted { constructor(token) { this.token = token; this.type = ActionTypes.InitialTokenExtracted; } } /** * FailedToActivateRoute */ class FailedToActivateRoute { constructor() { this.type = ActionTypes.FailedToActivateRoute; } } /** * StoreToken */ class StoreToken { constructor({ tokenName, token, isDefaultToken, }) { this.type = ActionTypes.StoreToken; this.tokenName = tokenName; this.token = token; this.isDefaultToken = !!isDefaultToken; } } /** * TokenExpired */ class TokenExpired { constructor({ tokenName, token, }) { this.type = ActionTypes.TokenExpired; this.tokenName = tokenName; this.token = token; } } /** * AllTokensExpired */ class AllTokensExpired { constructor() { this.type = ActionTypes.AllTokensExpired; } } /** * TokenNearingExpiration */ class TokenNearingExpiration { constructor({ tokenName, token, }) { this.type = ActionTypes.TokenNearingExpiration; this.tokenName = tokenName; this.token = token; } } /** * EscalateToken */ class EscalateToken { constructor(tokenName) { this.tokenName = tokenName; this.type = ActionTypes.EscalateToken; } } /** * EscalationSuccess */ class EscalationSuccess { constructor(tokenName) { this.tokenName = tokenName; this.type = ActionTypes.EscalationSuccess; } } /** * EscalationFailed */ class EscalationFailed { constructor(tokenName) { this.tokenName = tokenName; this.type = ActionTypes.EscalationFailed; } } const JWT_TOKEN_MANAGEMENT_STATE_TOKEN = 'ngx-tools-jwtTokenManagement'; const jwtModuleEmptyState = { jwtTokens: { initialTokenStatus: 'empty', tokens: {}, }, }; const getJwtTokenRoot = () => createFeatureSelector(JWT_TOKEN_MANAGEMENT_STATE_TOKEN); /** * Return all current tokens */ const getTokens = () => createSelector(getJwtTokenRoot(), (jwtTokenState) => (jwtTokenState ? jwtTokenState.jwtTokens.tokens : {})); const getDefaultToken = () => createSelector(getJwtTokenRoot(), jwtTokenState => (jwtTokenState ? jwtTokenState.jwtTokens.defaultToken : undefined)); const tokenForWithoutDefault = (serviceName) => createSelector(getTokens(), (userState) => userState[serviceName]); const tokenFor = (serviceName) => createSelector(getDefaultToken(), tokenForWithoutDefault(serviceName), (defaultToken, serviceToken) => serviceToken || defaultToken); const claimsFor = (serviceName) => createSelector(tokenFor(serviceName), (token) => { if (token) { try { return jwtDecode(token); } catch (e) { if (e.name === 'InvalidTokenError') { return null; } throw e; } } else { return null; } }); const claimValue = (serviceName, claimName) => createSelector(claimsFor(serviceName), (claims) => (claims ? claims[claimName] : null)); const INITIAL_TOKEN_NAME = new InjectionToken('jwt-token-managment INITIAL_JWT_TOKEN_NAME'); // TODO: Scheduler is marked as deprecated to stop others from using although it is not technically deprecated from what I can tell. The // 'correct' path would be to create our own class extending `SchedulerLike`. https://github.com/GetTerminus/ngx-tools/issues/287 // eslint-disable-next-line deprecation/deprecation const SCHEDULER = new InjectionToken('scheduler'); const ESCALATION_WAIT_TIME = new InjectionToken('wait time'); const FORBIDDEN_ERROR = 403; const DEFAULT_ESCALATION_WAIT_TIME = 30000; let RetryWithEscalation = class RetryWithEscalation { constructor(actions$, // eslint-disable-next-line @typescript-eslint/no-explicit-any store, // TODO: Scheduler is marked as deprecated to stop others from using although it is not technically deprecated // from what I can tell. The 'correct' path would be to create our own class extending `SchedulerLike`. // https://github.com/GetTerminus/ngx-tools/issues/287 // eslint-disable-next-line deprecation/deprecation scheduler, waitTime) { this.actions$ = actions$; this.store = store; this.scheduler = scheduler; this.waitTime = waitTime; } retryWithEscalation(tokenName) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (source) => source.pipe(retryWhen((errors) => { const DELAY_MS = 10; let tries = 0; return errors.pipe(mergeMap(err => { if (tries > 0 || err instanceof Error || err.status !== FORBIDDEN_ERROR) { return throwError(err); } tries += 1; this.store.dispatch(new EscalateToken(tokenName)); return merge(this.waitForResult(tokenName), this.expirationTimer()).pipe(take(1), delay(DELAY_MS, this.scheduler || async)); })); })); } waitForResult(tokenName) { return this.actions$ .pipe(ofType(ActionTypes.EscalationFailed, ActionTypes.EscalationSuccess), filter(a => a.tokenName === tokenName), switchMap(escResult => { if (escResult.type === ActionTypes.EscalationSuccess) { return 'complete'; } return this.failureError(); })); } expirationTimer() { return timer(this.waitTime || DEFAULT_ESCALATION_WAIT_TIME, this.scheduler || async).pipe(switchMap(() => this.failureError())); } failureError() { return throwError(new Error('Failed to escalate token')); } }; RetryWithEscalation.ctorParameters = () => [ { type: Actions }, { type: Store }, { type: Scheduler, decorators: [{ type: Optional }, { type: Inject, args: [SCHEDULER,] }] }, { type: Number, decorators: [{ type: Optional }, { type: Inject, args: [ESCALATION_WAIT_TIME,] }] } ]; RetryWithEscalation = __decorate([ Injectable(), __param(2, Optional()), __param(2, Inject(SCHEDULER)), __param(3, Optional()), __param(3, Inject(ESCALATION_WAIT_TIME)) ], RetryWithEscalation); const SECONDS_BEFORE_EXPIRATION_TO_NOTIFY = new InjectionToken('wait time'); const SECONDS_IN_MINUTE = 60; const DEFAULT_MINUTES_BEFORE_EXPIRATION_TO_NOTIFY = 5; const DEFAULT_SECONDS_BEFORE_EXPIRATION_TO_NOTIFY = DEFAULT_MINUTES_BEFORE_EXPIRATION_TO_NOTIFY * SECONDS_IN_MINUTE; const CLEANUP_DELAY = 100; const TOKENS_EXPIRED_DELAY = 10; const MS_IN_SECONDS = 1000; let JwtTokenProviderEffects = class JwtTokenProviderEffects { constructor(actions$, // eslint-disable-next-line @typescript-eslint/no-explicit-any store, cookieService, initialTokenName, scheduler, timeToWaitBeforeExpiration) { this.actions$ = actions$; this.store = store; this.cookieService = cookieService; this.initialTokenName = initialTokenName; this.scheduler = scheduler; this.timeToWaitBeforeExpiration = timeToWaitBeforeExpiration; this.initializationCleanup$ = of(true) .pipe(delay(CLEANUP_DELAY, this.scheduler || async), withLatestFrom(this.store.select(getTokens())), map(([_, tokens]) => tokens), take(1), flatMap(tokens => { const actions = []; for (const tokenName in tokens) { if (tokens.hasOwnProperty(tokenName)) { const token = tokens[tokenName]; if (token) { actions.push(new StoreToken({ tokenName, token, })); } } } return actions; })); this.allTokensExpired$ = this.actions$ .pipe(ofType(ActionTypes.TokenExpired), delay(TOKENS_EXPIRED_DELAY, this.scheduler || async), withLatestFrom(this.store.select(getTokens())), map(([_, tokens]) => tokens), filter(tokens => Object.keys(tokens).length === 0), map(tokens => new AllTokensExpired())); this.notifyOfTokenExpiration$ = this.actions$ .pipe(ofType(ActionTypes.StoreToken), // eslint-disable-next-line max-len map((action) => [action, jwtDecode(action.token)]), filter((a) => a[1].exp !== undefined), mergeMap(([action, claims]) => { const currentEpoch = Math.ceil((new Date()).getTime() / MS_IN_SECONDS); if (claims.exp > currentEpoch) { const expiresIn = claims.exp - currentEpoch; const expirationBuffer = this.timeToWaitBeforeExpiration || DEFAULT_SECONDS_BEFORE_EXPIRATION_TO_NOTIFY; let expirationNearIn = 0; if (expiresIn < expirationBuffer) { expirationNearIn = 1; } else { expirationNearIn = expiresIn - expirationBuffer; } return merge(this.buildDelayedExpirationObservable(expirationNearIn * MS_IN_SECONDS, action, false), this.buildDelayedExpirationObservable(expiresIn * MS_IN_SECONDS, action, true)); } return of(new TokenExpired({ tokenName: action.tokenName, token: action.token, })); })); this.initialCookieLoader$ = ({ currentState = this.store.select(getJwtTokenRoot()), } = {}) => of(true).pipe(take(1), withLatestFrom(currentState), filter(([_, state]) => !!(state && state.jwtTokens.initialTokenStatus === 'uninitialized')), mergeMap(([a, _]) => { const cookie = this.cookieService.get('jwt_cookie'); if (cookie.length > 0) { return [ new InitialTokenExtracted(cookie), new StoreToken({ tokenName: this.initialTokenName, token: cookie, isDefaultToken: true, }), ]; } return [ new InitialTokenExtracted(cookie), ]; })); } /* * This next function is being excluded from coverage due the complexities of testing the `delay` function. * In order to test as much as possible, each piece has been separated into smaller testable functions. */ buildDelayedExpirationObservable(emitTime, action, expired) { const outputActionArgs = { tokenName: action.tokenName, token: action.token, }; return timer(emitTime, this.scheduler || async).pipe(take(1), map(() => (expired ? new TokenExpired(outputActionArgs) : new TokenNearingExpiration(outputActionArgs)))); } }; JwtTokenProviderEffects.ctorParameters = () => [ { type: Actions }, { type: Store }, { type: TsCookieService }, { type: String, decorators: [{ type: Inject, args: [INITIAL_TOKEN_NAME,] }] }, { type: Scheduler, decorators: [{ type: Optional }, { type: Inject, args: [SCHEDULER,] }] }, { type: Number, decorators: [{ type: Optional }, { type: Inject, args: [SECONDS_BEFORE_EXPIRATION_TO_NOTIFY,] }] } ]; __decorate([ Effect() ], JwtTokenProviderEffects.prototype, "initializationCleanup$", void 0); __decorate([ Effect() ], JwtTokenProviderEffects.prototype, "allTokensExpired$", void 0); __decorate([ Effect() ], JwtTokenProviderEffects.prototype, "notifyOfTokenExpiration$", void 0); __decorate([ Effect() ], JwtTokenProviderEffects.prototype, "initialCookieLoader$", void 0); JwtTokenProviderEffects = __decorate([ Injectable(), __param(3, Inject(INITIAL_TOKEN_NAME)), __param(4, Optional()), __param(4, Inject(SCHEDULER)) // TODO: Scheduler is marked as deprecated to stop others from using although it is not technically deprecated from // what I can tell. The 'correct' path would be to create our own class extending `SchedulerLike`. // https://github.com/GetTerminus/ngx-tools/issues/287 // eslint-disable-next-line deprecation/deprecation , __param(5, Optional()), __param(5, Inject(SECONDS_BEFORE_EXPIRATION_TO_NOTIFY)) ], JwtTokenProviderEffects); const jwtEmptyStateReset = { [JWT_TOKEN_MANAGEMENT_STATE_TOKEN]: jwtModuleEmptyState }; let DefaultTokenRequired = class DefaultTokenRequired { constructor( // eslint-disable-next-line @typescript-eslint/no-explicit-any store) { this.store = store; this.currentLoadState = this.store.pipe(select(getJwtTokenRoot()), map(s => (s && s.jwtTokens.initialTokenStatus) || 'uninitialized')); this.currentToken = this.store.pipe(select(getDefaultToken()), map(s => s || '')); } canActivate() { return this.currentLoadState.pipe(filter(s => s !== 'uninitialized'), withLatestFrom(this.currentToken), map(([_, token]) => token.length > 0), tap(result => { if (!result) { this.store.dispatch(new FailedToActivateRoute()); } })); } }; DefaultTokenRequired.ctorParameters = () => [ { type: Store } ]; DefaultTokenRequired = __decorate([ Injectable() ], DefaultTokenRequired); const initialState = { initialTokenStatus: 'uninitialized', tokens: {}, }; /** * @param state * @param action */ function jwtTokenProviderReducer(state = initialState, action) { switch (action.type) { case ActionTypes.InitialTokenExtracted: { if (state.initialTokenStatus !== 'uninitialized') { return state; } if (action.token.length === 0) { return { initialTokenStatus: 'empty', tokens: {}, }; } return { initialTokenStatus: 'loaded', defaultToken: action.token, tokens: {}, }; } case ActionTypes.StoreToken: { const newState = Object.assign(Object.assign({}, state), { tokens: Object.assign({}, state.tokens) }); if (action.isDefaultToken) { newState.defaultToken = action.token; newState.tokens = {}; } newState.tokens[action.tokenName] = action.token; return newState; } case ActionTypes.TokenExpired: { const newState = Object.assign(Object.assign({}, state), { tokens: Object.assign({}, state.tokens) }); if (state.defaultToken && state.defaultToken === action.token) { delete newState.defaultToken; } for (const k in state.tokens) { if (state.tokens[k] && state.tokens[k] === action.token) { delete newState.tokens[k]; } } return newState; } default: { return state; } } } const TOKEN_NOT_FOUND_ERROR = new Error('Token Not found in response'); let TokenExtractor = class TokenExtractor { constructor( // eslint-disable-next-line @typescript-eslint/no-explicit-any store) { this.store = store; } // eslint-disable-next-line @typescript-eslint/no-explicit-any extractJwtToken({ tokenName, isDefaultToken }) { return (source) => source.pipe(tap(request => { const token = this.extractTokenFromResponse(request); if (token === '') { throw TOKEN_NOT_FOUND_ERROR; } else { this.store.dispatch(new StoreToken({ tokenName, token, isDefaultToken, })); } })); } // eslint-disable-next-line @typescript-eslint/no-explicit-any extractTokenFromResponse(input) { let token = ''; if (isTokenResponse(input)) { token = input.token; } else if (isHttpResponse(input)) { const authHeader = input.headers.get('Authorization'); const tokenStartsAtChar = 7; if (authHeader && authHeader.startsWith('Bearer ')) { token = authHeader.substr(tokenStartsAtChar); } } return token; } }; TokenExtractor.ctorParameters = () => [ { type: Store } ]; TokenExtractor = __decorate([ Injectable() ], TokenExtractor); let TokenEscalator = class TokenEscalator { constructor(actions$, // eslint-disable-next-line @typescript-eslint/no-explicit-any store, http, tokenExtractor) { this.actions$ = actions$; this.store = store; this.http = http; this.tokenExtractor = tokenExtractor; } // eslint-disable-next-line @typescript-eslint/no-explicit-any escalateToken({ tokenName, authorizeUrl, isDefaultToken }) { return this.actions$ .pipe(ofType(ActionTypes.EscalateToken), filter(a => a.tokenName === tokenName), withLatestFrom(authorizeUrl, this.store.select(tokenFor(tokenName))), switchMap(([action, url, currentToken]) => { const headers = new HttpHeaders({ Authorization: `Bearer ${currentToken}` }); return this.http.get(url, { headers }).pipe(this.tokenExtractor.extractJwtToken({ tokenName, isDefaultToken, }), map(() => new EscalationSuccess(tokenName)), catchError(() => of(new EscalationFailed(tokenName)))); })); } }; TokenEscalator.ctorParameters = () => [ { type: Actions }, { type: Store }, { type: HttpClient }, { type: TokenExtractor } ]; TokenEscalator = __decorate([ Injectable() ], TokenEscalator); var JwtTokenManagementModule_1; // NOTE: Not sure why this second param is required in strict mode // eslint-disable-next-line @typescript-eslint/no-explicit-any const reducers = { jwtTokens: jwtTokenProviderReducer }; let JwtTokenManagementModule = JwtTokenManagementModule_1 = class JwtTokenManagementModule { static forRoot(options) { return { ngModule: JwtTokenManagementModule_1, providers: [ { provide: INITIAL_TOKEN_NAME, useValue: options.initialTokenName, }, ], }; } }; JwtTokenManagementModule = JwtTokenManagementModule_1 = __decorate([ NgModule({ imports: [ HttpClientModule, StoreModule.forFeature(JWT_TOKEN_MANAGEMENT_STATE_TOKEN, reducers), EffectsModule.forFeature([ JwtTokenProviderEffects, ]), ], providers: [ RetryWithEscalation, TokenEscalator, TokenExtractor, DefaultTokenRequired, ], }) ], JwtTokenManagementModule); /** * Regenerate on retry * * @param obs * @returns Observable */ const regenerateOnRetry = (obs) => of(true).pipe(switchMap(() => obs())); /** * Generated bundle index. Do not edit. */ export { ActionTypes, AllTokensExpired as AllJwtTokensExpired, AllTokensExpired, DefaultTokenRequired, ESCALATION_WAIT_TIME, EscalateToken as EscalateJwtToken, EscalateToken, EscalationFailed, EscalationSuccess, FailedToActivateRoute, INITIAL_TOKEN_NAME, InitialTokenExtracted, InvalidCharacterError, InvalidTokenError, JWT_TOKEN_MANAGEMENT_STATE_TOKEN, TokenExpired as JwtTokenExpired, ActionTypes as JwtTokenManagementActionTypes, JwtTokenManagementModule, TokenNearingExpiration as JwtTokenNearingExpiration, JwtTokenProviderEffects, RetryWithEscalation, SCHEDULER, SECONDS_BEFORE_EXPIRATION_TO_NOTIFY, StoreToken as StoreJwtToken, StoreToken, TOKEN_NOT_FOUND_ERROR, TokenEscalator, TokenExpired, TokenExtractor, TokenNearingExpiration, atobPolyfill, base64_url_decode, claimValue, claimsFor, getDefaultToken, getJwtTokenRoot, getTokens, initialState, jwtDecode, jwtEmptyStateReset, jwtModuleEmptyState, jwtTokenProviderReducer, reducers, regenerateOnRetry, tokenFor, tokenForWithoutDefault, ɵ0 }; //# sourceMappingURL=terminus-ngx-tools-jwt.js.map