UNPKG

@devlearning/jwt-auth

Version:

Jwt Angular Authentication manager with automatic Refresh Token management.

562 lines (541 loc) 24.1 kB
import * as i0 from '@angular/core'; import { InjectionToken, Inject, Injectable, inject, APP_INITIALIZER, makeEnvironmentProviders, importProvidersFrom, NgModule } from '@angular/core'; import * as i1 from '@angular/common/http'; import { HttpErrorResponse, provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http'; import { BehaviorSubject, of, throwError, firstValueFrom } from 'rxjs'; import { exhaustMap, catchError, tap, switchMap, filter, take, finalize, mergeMap } from 'rxjs/operators'; import * as i2 from '@devlearning/mutex-fast-lock'; import { MutexFastLockModule } from '@devlearning/mutex-fast-lock'; class JwtTokenBase { } class JwtResponseError extends Error { get message() { return this._message; } get detailedMessage() { return this._detailedMessage; } get status() { return this._status; } get isUnhautorized() { return this._status == 401; } constructor(message, detailedMessage, status = null) { super(message); this._message = message; this._detailedMessage = detailedMessage; this._status = status; Object.setPrototypeOf(this, JwtResponseError.prototype); } } var StorageType; (function (StorageType) { StorageType[StorageType["SESSION_STORAGE"] = 1] = "SESSION_STORAGE"; StorageType[StorageType["LOCAL_STORAGE"] = 2] = "LOCAL_STORAGE"; })(StorageType || (StorageType = {})); class JwtAuthConfig { } var JwtAuthLogLevel; (function (JwtAuthLogLevel) { JwtAuthLogLevel[JwtAuthLogLevel["VERBOSE"] = 1] = "VERBOSE"; JwtAuthLogLevel[JwtAuthLogLevel["INFO"] = 2] = "INFO"; JwtAuthLogLevel[JwtAuthLogLevel["WARNING"] = 3] = "WARNING"; JwtAuthLogLevel[JwtAuthLogLevel["ERROR"] = 4] = "ERROR"; JwtAuthLogLevel[JwtAuthLogLevel["NONE"] = 5] = "NONE"; })(JwtAuthLogLevel || (JwtAuthLogLevel = {})); class TokenRequest { } class RefreshTokenRequest { } const JWT_AUTH_CONFIG = new InjectionToken('JWT_AUTH_CONFIG'); const ERROR_INVALID_TOKEN = 'invalid_token'; const ERROR_EXPIRED_REFRESH_TOKEN = 'expired_refresh_token'; class WWWAuthenticateMessage { constructor(message, error, description) { this.scheme = "Bearer"; this.message = message; this.error = error; this.description = description; } } class WWWAuthenticateMessageFactory { static create(content) { if (content == null) return null; if (!content.toLowerCase().startsWith("bearer")) throw new Error("Unmanaged scheme authentication"); //Bearer error="invalid_token", error_description="The token expired at '07/25/2023 08:05:20'" let message = content; const parts = content.substring("bearer ".length).split(','); let error = null; let description = null; parts.forEach(part => { part = part.trim(); if (part.startsWith("error=")) { error = part.split('=')[1].replaceAll("\"", '').replaceAll(',', ''); } else if (part.startsWith("error_description=")) { description = part.split('=')[1].replaceAll("\"", '').replaceAll(',', ''); } }); return new WWWAuthenticateMessage(message, error, description); } } /* https://www.rfc-editor.org/rfc/rfc6750#section-3 invalid_request The request is missing a required parameter, includes an unsupported parameter or parameter value, repeats the same parameter, uses more than one method for including an access token, or is otherwise malformed. The resource server SHOULD respond with the HTTP 400 (Bad Request) status code. invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons. The resource SHOULD respond with the HTTP 401 (Unauthorized) status code. The client MAY request a new access token and retry the protected resource request. insufficient_scope The request requires higher privileges than provided by the access token. The resource server SHOULD respond with the HTTP 403 (Forbidden) status code and MAY include the "scope" attribute with the scope necessary to access the protected resource. */ const JWT_AUTH_KEY_STORAGE = "jwt-auth"; const TOKEN_KEY_STORAGE = JWT_AUTH_KEY_STORAGE + "-token"; const REFRESHING_KEY_STORAGE = JWT_AUTH_KEY_STORAGE + "-refreshing"; const REFRESHING_EVENT_CHANGED = JWT_AUTH_KEY_STORAGE + "-refreshing-changed"; class JwtAuthService { /** Emits whenever the login state changes. */ get isLoggedIn$() { return this._isLoggedInSubject.asObservable(); } /** Emits whenever the current token changes (null when logged out). */ get jwtToken$() { return this._jwtTokenSubject.asObservable(); } /** Emits `true` while a token refresh is in progress. */ get refreshingToken$() { return this._isRefreshingTokenSubject.asObservable(); } /** Current login state (synchronous). */ get isLoggedIn() { return this._isLoggedInSubject.value; } /** Current token (synchronous). `null` when logged out. */ get jwtToken() { return this._jwtTokenSubject.value; } constructor(_config, _http, _mutexFastLock) { this._config = _config; this._http = _http; this._mutexFastLock = _mutexFastLock; this._isLocalStorageSupported = false; this._isLoggedInSubject = new BehaviorSubject(false); this._isRefreshingTokenSubject = new BehaviorSubject(false); this._jwtTokenSubject = new BehaviorSubject(null); this._refreshTokenSubject = new BehaviorSubject(null); if (this._config.storageType == StorageType.SESSION_STORAGE) { this._storage = sessionStorage; } else { this._storage = localStorage; } this._isLocalStorageSupported = this._checkStorageIsSupported(); this._getLocalStorageSupported(); this.setRefreshingToken(false); var that = this; window.addEventListener('storage', function (ev) { if (ev.key === TOKEN_KEY_STORAGE) { if (that._config.logLevel <= JwtAuthLogLevel.VERBOSE) console.debug("JwtAuth - eventListener storage token changed"); let token = JSON.parse(ev.newValue ?? 'null'); if (token?.accessToken != that.jwtToken?.accessToken) { that._setToken(token); that._refreshTokenSubject.next(token); } } }); } /** * Restores the session from storage. If the access token is expired but the * refresh token is still valid, a refresh is attempted automatically. * Call this manually when `useManualInitialization` is `true`. */ init() { return of(this._getJwtToken()) .pipe(exhaustMap(jwtToken => { if (jwtToken != null && jwtToken.accessToken != null) { this._jwtTokenSubject.next(jwtToken); if (!this.isTokenExpired()) { if (this._config.logLevel <= JwtAuthLogLevel.VERBOSE) console.debug("JwtAuth - init - isTokenExpired: false"); this._setToken(jwtToken); return of(jwtToken); } else if (!this.isRefreshTokenExpired()) { if (this._config.logLevel <= JwtAuthLogLevel.VERBOSE) console.debug("JwtAuth - init - isRefreshTokenExpired: false"); return this.refreshToken() .pipe(catchError((err, obs) => { this.logout(); return this._handleError(err); })); } else { if (this._config.logLevel <= JwtAuthLogLevel.VERBOSE) console.debug("JwtAuth - init - token and refresh token is expired"); this.logout(); return of(null); } } else { if (this._config.logLevel <= JwtAuthLogLevel.VERBOSE) console.debug("JwtAuth - init - token is null"); this.logout(); return of(null); } })); } /** * Authenticates the user by posting `request` to `tokenUrl`. * On success the token is persisted to storage and reactive state is updated. * * @param request The login request body. Use the generic parameter `TRequest` * to get full type-safety for your specific API contract. */ token(request) { return this._http.post(this._config.tokenUrl, request).pipe(tap(x => { this._setToken(x); }), catchError(err => { this._cleanToken(); return this._handleError(err); })); } /** * Refreshes the access token using the stored refresh token. * Concurrent calls are serialized with a mutex so only one HTTP request is * made; other callers wait and receive the same result. * * The request body is built by `refreshTokenRequestFactory` (if configured) * or falls back to `{ username, refreshToken }`. */ refreshToken() { if (this._jwtTokenSubject.value == null || this._jwtTokenSubject.value.accessToken == null) { if (this._config.logLevel <= JwtAuthLogLevel.ERROR) console.error("JwtAuth - refreshToken this._jwtTokenSubject.value was null"); return throwError(() => new Error("User is logged out")); } if (this._config.logLevel <= JwtAuthLogLevel.VERBOSE) console.debug("JWT refreshToken"); return this._mutexFastLock.lock(REFRESHING_KEY_STORAGE, 100) .pipe(switchMap(x => { if (!this.getIsRefreshingToken()) { this.setRefreshingToken(true); return this._mutexFastLock.lock(TOKEN_KEY_STORAGE) .pipe(tap(x => this._refreshTokenSubject.next(null)), tap(x => this._isRefreshingTokenSubject.next(true)), switchMap(x => { let jwtToken = this._getJwtToken(); let request; if (this._config.refreshTokenRequestFactory) { request = this._config.refreshTokenRequestFactory(jwtToken ?? new JwtTokenBase()); } else { const defaultRequest = new RefreshTokenRequest(); defaultRequest.username = jwtToken?.username ?? ''; defaultRequest.refreshToken = jwtToken?.refreshToken ?? ''; request = defaultRequest; } return this._http.post(this._config.refreshUrl, request) .pipe(tap(x => { this._setToken(x); this._refreshTokenSubject.next(x); }), catchError(err => { if (this._config.logLevel <= JwtAuthLogLevel.VERBOSE) console.debug("JWT refreshToken err " + err); if (err.message && err.message.indexOf("Lock could not be acquired") >= 0) { return this._refreshTokenSubject .pipe(filter(x => x != null), filter(x => !this._checkTokenIsExpired(x)), take(1)); } else { if (err.status == 468) { return throwError(() => new Error("Refresh token expired")); } else if (err.status == 401) { const wwwAuthenticate = WWWAuthenticateMessageFactory.create(err.headers.get('WWW-Authenticate')); if (wwwAuthenticate.error == ERROR_EXPIRED_REFRESH_TOKEN) { this._cleanToken(); return throwError(() => new Error("Refresh token expired")); } return throwError(() => new Error("Unauthorized")); } else { this._cleanToken(); return this._handleError(err); } } }), finalize(() => { this.setRefreshingToken(false); this._mutexFastLock.release(TOKEN_KEY_STORAGE); this._mutexFastLock.release(REFRESHING_KEY_STORAGE); this._isRefreshingTokenSubject.next(false); })); })); } else { return this._refreshTokenSubject .pipe(filter(x => x != null), filter(x => !this._checkTokenIsExpired(x)), take(1)); } }), catchError((err) => { this._mutexFastLock.release(REFRESHING_KEY_STORAGE); if (err.message && err.message.indexOf("Lock could not be acquired") >= 0) { return this._refreshTokenSubject .pipe(filter(x => x != null), filter(x => !this._checkTokenIsExpired(x)), take(1)); } else { return throwError(() => new Error(err)); } })); } /** Clears the stored token and emits the logged-out state. */ logout() { this._cleanToken(); } /** Returns `true` if `url` is one of the authentication endpoints (token or refresh). */ isAuthenticationUrl(url) { return url == this._config.tokenUrl || url == this._config.refreshUrl; } /** Returns `true` if the refresh token has passed its expiry timestamp. */ isRefreshTokenExpired() { return new Date().getTime() > (this._jwtTokenSubject.value?.refreshTokenExpiresIn ?? 0); } /** Returns `true` if the access token has passed its expiry timestamp. */ isTokenExpired() { return this._checkTokenIsExpired(this._jwtTokenSubject.value); } /** Overrides the token URL at runtime. */ setTokenUrl(url) { this._config.tokenUrl = url; } /** Overrides the refresh URL at runtime. */ setRefreshUrl(url) { this._config.refreshUrl = url; } getIsRefreshingToken() { return this._storage.getItem(REFRESHING_KEY_STORAGE) == 'true'; } setRefreshingToken(refreshing) { this._storage.setItem(REFRESHING_KEY_STORAGE, '' + refreshing); } /** Manually sets a token, persisting it to storage and updating reactive state. */ setToken(jwtToken) { this._setToken(jwtToken); } _setToken(jwtToken) { if (jwtToken != null) { this._saveJwtToken(jwtToken); this._isLoggedInSubject.next(true); this._jwtTokenSubject.next(jwtToken); } else { this._cleanToken(); } } _cleanToken() { this._deleteJwtToken(); this._isLoggedInSubject.next(false); this._jwtTokenSubject.next(null); } _checkStorageIsSupported() { try { this._storage.setItem(JWT_AUTH_KEY_STORAGE + '-test-storage', "test"); this._storage.removeItem(JWT_AUTH_KEY_STORAGE + '-test-storage'); return true; } catch (e) { return false; } } _getLocalStorageSupported() { if (!this._isLocalStorageSupported) { if (this._config.logLevel <= JwtAuthLogLevel.ERROR) console.error("LocalStorage is not supported"); } return this._isLocalStorageSupported; } _saveJwtToken(jwtToken) { if (!this._isLocalStorageSupported) return; this._storage.setItem(TOKEN_KEY_STORAGE, JSON.stringify(jwtToken)); } _getJwtToken() { if (!this._isLocalStorageSupported) return undefined; return JSON.parse(this._storage.getItem(TOKEN_KEY_STORAGE) ?? 'null'); } _deleteJwtToken() { this._storage.removeItem(TOKEN_KEY_STORAGE); } _checkTokenIsExpired(token) { return new Date().getTime() > (token.expiresIn ?? 0); } _handleError(error) { let message; let detailedMessage; if (error.error instanceof Error) { if (this._config.logLevel <= JwtAuthLogLevel.ERROR) console.error('An error occurred:', error.error.message); message = error.error.message; } else if (error instanceof HttpErrorResponse) { if (error.status == 500) { message = error.error?.message; detailedMessage = error.error?.detailedMessage; } else { console.error(`Backend returned code ${error.status}, body was: ${error.message}`); message = error.message; } } let jwtResponse = new JwtResponseError(message ?? '', detailedMessage ?? '', error.status); return throwError(() => jwtResponse); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: JwtAuthService, deps: [{ token: JWT_AUTH_CONFIG }, { token: i1.HttpClient }, { token: i2.MutexFastLockService }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: JwtAuthService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: JwtAuthService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: JwtAuthConfig, decorators: [{ type: Inject, args: [JWT_AUTH_CONFIG] }] }, { type: i1.HttpClient }, { type: i2.MutexFastLockService }] }); function applyCredentials(req, token) { if (!token) return req; return req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); } const jwtAuthInterceptorFn = (req, next) => { const config = inject(JWT_AUTH_CONFIG); const jwtAuth = inject(JwtAuthService); if (config.logLevel <= JwtAuthLogLevel.VERBOSE) { // evita di loggare token/headers console.debug('JwtAuthInterceptor', req.url); } // non toccare le chiamate di autenticazione if (jwtAuth.isAuthenticationUrl(req.url)) { return next(req); } const authedReq = applyCredentials(req, jwtAuth.jwtToken?.accessToken); return next(authedReq).pipe(catchError((error) => { // solo HttpErrorResponse ci interessa if (!(error instanceof HttpErrorResponse)) { return throwError(() => error); } switch (error.status) { case 401: { if (jwtAuth.jwtToken != null && jwtAuth.isLoggedIn) { // refresh e retry con nuovo access token return jwtAuth.refreshToken().pipe(switchMap(t => next(applyCredentials(req, t.accessToken)))); } return throwError(() => error); } default: return throwError(() => error); } })); }; class JwtAuthInterceptor { constructor(jwtAuth, config) { this.jwtAuth = jwtAuth; this.config = config; } intercept(req, next) { if (this.config.logLevel <= JwtAuthLogLevel.VERBOSE) { console.debug('JwtAuthInterceptor', req.url); } if (this.jwtAuth.isAuthenticationUrl(req.url)) { return next.handle(req); } const authedReq = applyCredentials(req, this.jwtAuth.jwtToken?.accessToken); return next.handle(authedReq).pipe(catchError((error) => { if (!(error instanceof HttpErrorResponse)) { return throwError(() => error); } if (error.status === 401 && this.jwtAuth.jwtToken != null && this.jwtAuth.isLoggedIn) { return this.jwtAuth.refreshToken().pipe(switchMap(t => next.handle(applyCredentials(req, t.accessToken)))); } return throwError(() => error); })); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: JwtAuthInterceptor, deps: [{ token: JwtAuthService }, { token: JWT_AUTH_CONFIG }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: JwtAuthInterceptor }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: JwtAuthInterceptor, decorators: [{ type: Injectable }], ctorParameters: () => [{ type: JwtAuthService }, { type: JwtAuthConfig, decorators: [{ type: Inject, args: [JWT_AUTH_CONFIG] }] }] }); class JwtAuthGuard { constructor(_jwtAuthService) { this._jwtAuthService = _jwtAuthService; } canActivateBase(route, state) { if (this._jwtAuthService.jwtToken == null || this._jwtAuthService.isLoggedIn == null || !this._jwtAuthService.isLoggedIn || this._jwtAuthService.isRefreshTokenExpired()) { return of(false); } else if (this._jwtAuthService.isTokenExpired()) { return this._jwtAuthService.refreshToken() .pipe(mergeMap(x => of(true)), catchError(err => of(false))); } else { return of(true); } } } function provideJwtAuth(config) { const initializerProvider = { provide: APP_INITIALIZER, multi: true, useFactory: (svc) => () => firstValueFrom(svc.init()), deps: [JwtAuthService], }; return makeEnvironmentProviders([ importProvidersFrom(MutexFastLockModule), { provide: JWT_AUTH_CONFIG, useValue: config }, provideHttpClient(withInterceptors([jwtAuthInterceptorFn])), ...(!config.useManualInitialization ? [initializerProvider] : []), ]); } class JwtAuthModule { static forRoot(config) { const providers = [ { provide: JWT_AUTH_CONFIG, useValue: config }, { provide: HTTP_INTERCEPTORS, useClass: JwtAuthInterceptor, multi: true }, ]; if (!config.useManualInitialization) { providers.push({ provide: APP_INITIALIZER, multi: true, useFactory: (svc) => () => firstValueFrom(svc.init()), deps: [JwtAuthService], }); } return { ngModule: JwtAuthModule, providers, }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: JwtAuthModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.1", ngImport: i0, type: JwtAuthModule, imports: [MutexFastLockModule] }); } static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: JwtAuthModule, imports: [MutexFastLockModule] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: JwtAuthModule, decorators: [{ type: NgModule, args: [{ imports: [MutexFastLockModule], }] }] }); /* * Public API Surface of jwt-auth */ /** * Generated bundle index. Do not edit. */ export { JwtAuthConfig, JwtAuthGuard, JwtAuthInterceptor, JwtAuthLogLevel, JwtAuthModule, JwtAuthService, JwtResponseError, JwtTokenBase, StorageType, TokenRequest, jwtAuthInterceptorFn, provideJwtAuth }; //# sourceMappingURL=devlearning-jwt-auth.mjs.map