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