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