UNPKG

ng-ebi-authorization

Version:

The ng-ebi-authorization is a simple authentication Angular library that relies on EBI's Authentication and Authorization Profile (AAP) infrastructure. After successful login, a JWT token is stored on the browser (via cookie, local or session storage).

650 lines (642 loc) 20.7 kB
import { JwtHelperService } from '@auth0/angular-jwt'; import { Injectable, InjectionToken, Inject, RendererFactory2, NgModule, Optional, SkipSelf } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { BehaviorSubject } from 'rxjs'; import { map, tap } from 'rxjs/operators'; /** * @fileoverview added by tsickle * @suppress {checkTypes,extraRequire,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ /** * The purpose of this very simple service is to interface between the * AuthService and the specific token manipulation routing of JwtHelperService. * In this way, if in the future we want to replace JwtHelperService by * another service, AuthService doesn't need to be modified, only this service. */ class TokenService { /** * @param {?} _jwt */ constructor(_jwt) { this._jwt = _jwt; } /** * @return {?} */ getToken() { return this._jwt.tokenGetter(); } /** * @return {?} */ getTokenExpirationDate() { try { return this._jwt.getTokenExpirationDate(); } catch (error) { return null; } } /** * @return {?} */ isTokenValid() { try { return !this._jwt.isTokenExpired(); } catch (error) { return false; } } /** * Get claims from the token. * * @template T, C * @param {?} claim The name of the claim * @param {?} defaultValue The default value returned in case of error * * @return {?} claim or default value */ getClaim(claim, defaultValue) { try { /** @type {?} */ const value = (/** @type {?} */ (this._jwt.decodeToken()[claim])); if (value === undefined) { return defaultValue; } return value; } catch (error) { return defaultValue; } } } TokenService.decorators = [ { type: Injectable } ]; /** @nocollapse */ TokenService.ctorParameters = () => [ { type: JwtHelperService } ]; /** * @fileoverview added by tsickle * @suppress {checkTypes,extraRequire,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ /** @type {?} */ const AAP_CONFIG = new InjectionToken('AAP_CONFIG'); /** * @return {?} */ function getToken() { return localStorage.getItem('id_token') || ''; } /** * @return {?} */ function removeToken() { return localStorage.removeItem('id_token'); } /** * @param {?} newToken * @return {?} */ function updateToken(newToken) { return localStorage.setItem('id_token', newToken); } /** @type {?} */ const DEFAULT_CONF = { aapURL: 'https://api.aai.ebi.ac.uk', tokenGetter: getToken, tokenRemover: removeToken, tokenUpdater: updateToken }; /** * @fileoverview added by tsickle * @suppress {checkTypes,extraRequire,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ class AuthService { /** * @param {?} _rendererFactory * @param {?} _tokenService * @param {?} _http * @param {?} config */ constructor(_rendererFactory, _tokenService, _http, config) { this._rendererFactory = _rendererFactory; this._tokenService = _tokenService; this._http = _http; this.config = config; this._user = new BehaviorSubject(null); this._currentState = null; // stores the string token or null otherwise (logout) // stores the string token or null otherwise (logout) this._loginCallbacks = []; this._logoutCallbacks = []; this._timeoutID = null; // This two properties are used for inter-window communication. // It is achieve through the update of the dummy key storage '_commKeyName' this._commKeyName = 'AngularAapAuthUpdated'; this._commKeyUpdater = () => localStorage.setItem(this._commKeyName, '' + new Date().getTime()); this._domain = encodeURIComponent(window.location.origin); this._appURL = config.aapURL.replace(/\/$/, ''); this._authURL = `${this._appURL}/auth`; this._tokenURL = `${this._appURL}/token`; this._storageUpdater = config.tokenUpdater; if (config.tokenRemover) { this._storageRemover = config.tokenRemover; } else { this._storageRemover = () => config.tokenUpdater(null); } /** @type {?} */ const renderer = this._rendererFactory.createRenderer(null, null); this._unlistenEvents = [ this._listenLoginMessage(renderer), this._listenChangesFromOtherWindows(renderer) ]; this._currentState = null; this._updateUser(); // TODO: experiment with setTimeOut } /** * @return {?} */ ngOnDestroy() { this._unlistenEvents.forEach(fn => fn()); } /** * @return {?} */ user() { return this._user.asObservable(); } /** * Functions that opens a window instead of a tab. * * See method _filterLoginOptions regarding security risks of certain * LoginOptions. * * @param {?=} options * @param {?=} width Pixel width of the login window. * @param {?=} height Pixel height of the login window. * @param {?=} left Position of the left corners. If it is a negative * number it centres the login window on the screen. * @param {?=} top Position of the top corners. If it is a negative * number it centres the login window on the screen. * @return {?} */ openLoginWindow(options, width = 650, height = 1000, left = -1, top = -1) { if (left < 0) { /** @type {?} */ const screenWidth = screen.width; if (screenWidth > width) { left = Math.round((screenWidth - width) / 2); } } if (top < 0) { /** @type {?} */ const screenHeight = screen.height; if (screenHeight > height) { top = Math.round((screenHeight - height) / 2); } } /** @type {?} */ const windowOptions = [ `width=${width}`, `height=${height}`, `left=${left}`, `top=${top}`, 'personalbar=no', 'toolbar=no', 'scrollbars=yes', 'resizable=yes', 'directories=no', 'location=no', 'menubar=no', 'titlebar=no', 'toolbar=no' ]; /** @type {?} */ const loginWindow = window.open(this.getSSOURL(options), 'Sign in to Elixir', windowOptions.join(',')); if (loginWindow) { loginWindow.focus(); } } /** * @deprecated use openLoginWindow method instead (top and left arguments are inverted in the new method) * since version 1.0.0-beta.5. * windowOpen will be deleted in version 1.0.0 * @param {?=} options * @param {?=} width * @param {?=} height * @param {?=} top * @param {?=} left * @return {?} */ windowOpen(options, width = 650, height = 1000, top = -1, left = -1) { this._deprecationWarning('windowOpen', 'openLoginWindow'); this.openLoginWindow(options, width, height, left, top); } /** * Functions that opens a tab (in modern browser). * * See method _filterLoginOptions regarding security risks of certain * LoginOptions. * * @param {?=} options * @return {?} */ openLoginTab(options) { /** @type {?} */ const loginWindow = window.open(this.getSSOURL(options), 'Sign in to Elixir'); if (loginWindow) { loginWindow.focus(); } } /** * @deprecated use openLoginTab method instead * since version 1.0.0-beta.5. * tabOpen will be deleted in version 1.0.0 * @param {?=} options * @return {?} */ tabOpen(options) { this._deprecationWarning('tabOpen', 'openLoginTab'); this.openLoginTab(options); } /** * Produces a URL that allows logging into the single sign on (SSO) page. * The URL cans be opened in a new tab using target="_blank", * or in a new window using window.open(). * * See method _filterLoginOptions regarding security risks of certain * LoginOptions. * * @param {?=} options * @return {?} The SSO URL. * */ getSSOURL(options) { /** @type {?} */ const fragments = this._formatFragments(Object.assign({ 'from': this._domain }, options)); return `${this._appURL}/sso${fragments}`; } /** * Functions that logs out the user. * It triggers the logout callbacks. * It is an arrow function (lambda) because in that way it has a reference * to 'this' when used in setTimeout call. * @return {?} */ logOut() { this._storageRemover(); this._updateUser(); // Triggers updating other windows this._commKeyUpdater(); } /** * Create AAP account * * @param {?} newUser * @return {?} uid of the new user */ createAAPaccount(newUser) { return this._http.post(this._authURL, newUser, { responseType: 'text' }); } /** * Login directly through the AAP * * See method _filterLoginOptions regarding security risks of certain * LoginOptions. * * @param {?} user * @param {?=} options * @return {?} true if the user successfully login, otherwise false */ loginAAP(user, options) { /** @type {?} */ const fragments = this._formatFragments(options); return this._http.get(`${this._authURL}${fragments}`, { headers: this._createAuthHeader(user), responseType: 'text' }).pipe(tap(token => { this._storageRemover(); this._storageUpdater(token); this._updateUser(); // Triggers updating other windows this._commKeyUpdater(); }), map(Boolean)); } /** * Change password AAP account * * @param {?} __0 * @return {?} true when password is successfully changed */ changePasswordAAP({ username, oldPassword, newPassword }) { return this._http.patch(this._authURL, { username, password: newPassword }, { headers: this._createAuthHeader({ username, password: oldPassword }) }).pipe(map(response => true) // response is empty, but if it reaches this point the request has successfully completed ); } /** * Add a callback to the LogIn event. * * @param {?} callback The Function called when the login event is triggered and the * JWT token is received and accepted. * * @return {?} The event registration id (necessary to unregister the event). */ addLogInEventListener(callback) { return this._loginCallbacks.push(callback); } /** * Remove a callback from the LogIn event. * * @param {?} id The id given when event listener was added. * * @return {?} true when remove successfully, false otherwise. */ removeLogInEventListener(id) { return delete this._loginCallbacks[id - 1]; } /** * Add a callback to the LogOut event. * * @param {?} callback The Function called when the logout event is triggered and the * JWT token is received and accepted. * * @return {?} The registration id (necessary to unregister the event). */ addLogOutEventListener(callback) { return this._logoutCallbacks.push(callback); } /** * Remove a callback from the LogOut event. * * @param {?} id The id given when event listener was added. * * @return {?} true when remove successfully, false otherwise. */ removeLogOutEventListener(id) { return delete this._logoutCallbacks[id - 1]; } /** * Refresh token * * @return {?} true when token is successfully refreshed */ refresh() { return this._http.get(this._tokenURL, { responseType: 'text' }).pipe(tap(token => { this._storageRemover(); this._storageUpdater(token); this._updateUser(); // Triggers updating other windows this._commKeyUpdater(); }), map(Boolean)); } /** * Format and filter fragment options * * \@params options * * @private * @param {?=} options * @return {?} fragment string */ _formatFragments(options) { if (options) { this._filterLoginOptions(options); return '?' + Object.entries(options) .map(([key, value]) => `${key}=${value}`).join('&'); } return ''; } /** * Filters options that are unsecure. * * See the advance options that can be requested through the options parameter: * https://api.aai.ebi.ac.uk/docs/authentication/authentication.index.html#_common_attributes * * The time to live paramenter (ttl) default value is 60 minutes. It is a * big security risk to request longer ttl. If a third party gets hold of * such token, means that they could use it for a day, week, year * (essentially, like having the username/password). * * @private * @param {?} options * @return {?} */ _filterLoginOptions(options) { if (Object.keys(options).indexOf('ttl') > -1) { /** @type {?} */ const ttl = +options['ttl']; /** @type {?} */ const softLimit = 60; /** @type {?} */ const hardLimit = 60 * 24; if (ttl > hardLimit) { throw (new Error(`Login requested with an expiration longer than ${hardLimit} minutes! This is not allowed.`)); } if (ttl > softLimit) { window.console.warn(`Login requested with an expiration longer than ${softLimit} minutes!`); } } } /** * Creates Authorization header * * @private * @param {?} __0 * @return {?} New authorization header */ _createAuthHeader({ username, password }) { /** @type {?} */ const authToken = btoa(`${username}:${password}`); return new HttpHeaders({ 'Authorization': `Basic ${authToken}` }); } /** * Listen for login messages from other windows. * These messages contain the tokens from the AAP. * If a token is received then the callbacks are triggered. * @private * @param {?} renderer * @return {?} */ _listenLoginMessage(renderer) { return renderer.listen('window', 'message', (event) => { if (!this._messageIsAcceptable(event)) { return; } this._storageUpdater(event.data); // I don't know how to type guard event.source // This doesn't work // if (event.source instanceof Window) { if (event.source) { ((/** @type {?} */ (event.source))).close(); } this._updateUser(); // Triggers updating other windows this._commKeyUpdater(); }); } /** * Listen to changes in the token from *other* windows. * * For inter-window communication messages are transmitted trough changes * on a dummy storage key property: '_commKeyName'. * * Notice that changes in the '_commKeyName' produced by this class doesn't * trigger this event. * @private * @param {?} renderer * @return {?} */ _listenChangesFromOtherWindows(renderer) { return renderer.listen('window', 'storage', (event) => { if (event.key === this._commKeyName) { this._updateUser(); } }); } /** * Check if the message is coming from the same domain we use to generate * the SSO URL, otherwise it's iffy and shouldn't trust it. * @private * @param {?} event * @return {?} */ _messageIsAcceptable(event) { return event.origin === this._appURL; } /** * @private * @return {?} */ _updateUser() { if (this._timeoutID) { window.clearTimeout(this._timeoutID); } if (this._tokenService.isTokenValid()) { /** @type {?} */ const token = (/** @type {?} */ (this._tokenService.getToken())); this._user.next({ uid: this._getClaim('sub'), name: this._getClaim('name'), nickname: this._getClaim('nickname'), email: this._getClaim('email'), token }); if (this._currentState === null) { // this._loginCallbacks is an empty list when first called from the constructor. // Latter it could be filled as clients of the library call `this.addLogInEventListener(myfunction)` this._loginCallbacks.forEach(callback => callback()); } // Schedule future logout event base on token expiration /** @type {?} */ const expireDate = (/** @type {?} */ (this._tokenService.getTokenExpirationDate())); // Coercing dates to numbers with the unary operator '+' /** @type {?} */ const delay = +expireDate - +new Date(); this._timeoutID = window.setTimeout(() => this.logOut(), delay); if (this._currentState !== token) { this._currentState = token; } } else { this._storageRemover(); // Cleanup possible left behind token if (this._currentState !== null) { // this._logoutCallbacks is an empty list when first called from the constructor. // Latter it could be filled as clients of the library call `this.addLogOutEventListener(myfunction)` this._logoutCallbacks.forEach(callback => callback()); this._user.next(null); } this._currentState = null; } } /** * @private * @param {?} claim * @return {?} */ _getClaim(claim) { return this._tokenService.getClaim(claim, ''); } /** * @private * @param {?} oldMethod * @param {?} newMethod * @return {?} */ _deprecationWarning(oldMethod, newMethod) { window.console.warn(`Method '${oldMethod}' has been deprecated, please use '${newMethod}' method instead`); } } AuthService.decorators = [ { type: Injectable } ]; /** @nocollapse */ AuthService.ctorParameters = () => [ { type: RendererFactory2 }, { type: TokenService }, { type: HttpClient }, { type: undefined, decorators: [{ type: Inject, args: [AAP_CONFIG,] }] } ]; /** * @fileoverview added by tsickle * @suppress {checkTypes,extraRequire,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ class AuthModule { /** * @param {?} parentModule */ constructor(parentModule) { if (parentModule) { throw new Error('AuthModule is already loaded. It should only be imported in your application\'s main module.'); } } /** * @param {?=} options * @return {?} */ static forRoot(options) { return { ngModule: AuthModule, providers: [ TokenService, { provide: AAP_CONFIG, useValue: options ? options : DEFAULT_CONF }, AuthService ] }; } } AuthModule.decorators = [ { type: NgModule, args: [{},] } ]; /** @nocollapse */ AuthModule.ctorParameters = () => [ { type: AuthModule, decorators: [{ type: Optional }, { type: SkipSelf }] } ]; /** * @fileoverview added by tsickle * @suppress {checkTypes,extraRequire,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ /** * @fileoverview added by tsickle * @suppress {checkTypes,extraRequire,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ export { AuthModule, AuthService, TokenService, AAP_CONFIG as ɵb, DEFAULT_CONF as ɵf, getToken as ɵc, removeToken as ɵd, updateToken as ɵe }; //# sourceMappingURL=ng-ebi-authorization.js.map