src/salte-auth.profile.js
import Cookie from 'js-cookie';
import defaultsDeep from 'lodash/defaultsDeep';
import find from 'lodash/find';
import debug from 'debug';
/** @ignore */
const logger = debug('@salte-auth/salte-auth:profile');
/**
* All the profile information associated with the current authentication session
*/
class SalteAuthProfile {
/**
* Parses the current url for the authentication values
* @param {Config} config configuration for salte auth
*/
constructor(config) {
logger('Appending defaults to config...');
/** @ignore */
this.$$config = defaultsDeep(config, {
validation: {
nonce: true,
state: true,
azp: true,
aud: true
},
storageType: 'session'
});
/**
* The parsed user information from the id token
* @type {Object}
*/
this.userInfo = null;
this.$refreshUserInfo();
}
/**
* Checks for a hash / query params, parses it, and removes it.
*/
$parseParams() {
if (location.search || location.hash) {
const params = location.search.replace(/^\?/, '').split('&')
.concat(location.hash.replace(/(#!?[^#]+)?#/, '').split('&'));
logger(`Hash detected, parsing...`, params);
for (let i = 0; i < params.length; i++) {
const param = params[i];
const [key, value] = param.split('=');
this.$parse(key, decodeURIComponent(value));
}
logger(`Removing hash...`);
history.pushState('', document.title, location.href.replace(location.search, '').replace(location.hash, ''));
}
}
/**
* Parse a key-value pair
* @param {String} key the key to parse
* @param {Object} value the matching value to parse
* @private
*/
$parse(key, value) {
switch (key) {
case 'token_type':
this.$tokenType = value;
break;
case 'expires_in':
this.$expiration = Date.now() + (Number(value) * 1000);
break;
case 'access_token':
this.$accessToken = value;
break;
case 'id_token':
this.$idToken = value;
break;
case 'code':
this.code = value;
break;
case 'state':
this.$state = value;
break;
case 'error':
this.$error = value;
break;
case 'error_description':
this.$errorDescription = value;
break;
}
}
/**
* Whether the ID Token has expired
* @return {Boolean} true if the "id_token" has expired
*/
get idTokenExpired() {
return !this.$idToken || Date.now() >= (this.userInfo.exp * 1000);
}
/**
* Whether the Access Token has expired
* @return {Boolean} true if the "access_token" has expired
*/
get accessTokenExpired() {
return !this.$accessToken || Date.now() >= this.$expiration;
}
/**
* The type of Access Token that was returned by the identity provider
* @return {String} the type of access token
* @private
*/
get $tokenType() {
return this.$getItem('salte.auth.$token-type', 'cookie');
}
set $tokenType(tokenType) {
this.$saveItem('salte.auth.$token-type', tokenType, 'cookie');
}
/**
* The date and time that the access token will expire
* @return {Number} the expiration time as unix timestamp
* @private
*/
get $expiration() {
const expiration = this.$getItem('salte.auth.expiration');
return expiration ? Number(expiration) : null;
}
set $expiration(expiration) {
this.$saveItem('salte.auth.expiration', expiration);
}
/**
* The Access Token returned by the identity provider
* @return {String} the access token
* @private
*/
get $accessToken() {
return this.$getItem('salte.auth.access-token');
}
set $accessToken(accessToken) {
this.$saveItem('salte.auth.access-token', accessToken);
}
/**
* The ID Token returned by the identity provider
* @return {String} the id token
* @private
*/
get $idToken() {
return this.$getItem('salte.auth.id-token');
}
set $idToken(idToken) {
this.$saveItem('salte.auth.id-token', idToken);
}
/**
* The Authorization Code returned by the identity provider
* @return {String} the authorization code
* @private
*/
get code() {
return this.$getItem('salte.auth.code');
}
set code(code) {
this.$saveItem('salte.auth.code', code);
}
/**
* The authentication state returned by the identity provider
* @return {String} the state value
* @private
*
* @see https://tools.ietf.org/html/rfc6749#section-4.1.1
*/
get $state() {
return this.$getItem('salte.auth.$state', 'cookie');
}
set $state(state) {
this.$saveItem('salte.auth.$state', state, 'cookie');
}
/**
* The locally generate authentication state
* @return {String} the local state value
* @private
*
* @see https://tools.ietf.org/html/rfc6749#section-4.1.1
*/
get $localState() {
return this.$getItem('salte.auth.$local-state', 'cookie');
}
set $localState(localState) {
this.$saveItem('salte.auth.$local-state', localState, 'cookie');
}
/**
* The error returned by the identity provider
* @return {String} the state value
* @private
*/
get $error() {
return this.$getItem('salte.auth.error');
}
set $error(error) {
this.$saveItem('salte.auth.error', error);
}
/**
* The error description returned by the identity provider
* @return {String} a string that describes the error that occurred
* @private
*/
get $errorDescription() {
return this.$getItem('salte.auth.error-description');
}
set $errorDescription(errorDescription) {
this.$saveItem('salte.auth.error-description', errorDescription);
}
/**
* The url the user originated from before authentication occurred
* @return {String} The url the user originated from before authentication occurred
* @private
*/
get $redirectUrl() {
return this.$getItem('salte.auth.$redirect-url', 'cookie');
}
set $redirectUrl(redirectUrl) {
this.$saveItem('salte.auth.$redirect-url', redirectUrl, 'cookie');
}
/**
* Parses the User Info from the ID Token
* @return {String} The User Info from the ID Token
* @private
*/
get $nonce() {
return this.$getItem('salte.auth.$nonce', 'cookie');
}
set $nonce(nonce) {
this.$saveItem('salte.auth.$nonce', nonce, 'cookie');
}
/**
* Sets or Gets an action based on whether a action was passed.
* @param {String} state The state this action is tied to.
* @param {String} action The action to store.
* @return {String|undefined} Returns a string if an action wasn't provided.
* @private
*/
$actions(state, action) {
if (action) {
this.$saveItem(`salte.auth.action.${state}`, action);
} else {
return this.$getItem(`salte.auth.action.${state}`);
}
}
/**
* Parses the User Info from the ID Token
* @param {String} idToken the id token to update based off
* @private
*/
$refreshUserInfo(idToken = this.$idToken) {
let userInfo = null;
if (idToken) {
const separatedToken = idToken.split('.');
if (separatedToken.length === 3) {
// This fixes an issue where various providers will encode values
// incorrectly and cause the browser to fail to decode.
// https://stackoverflow.com/questions/43065553/base64-decoded-differently-in-java-jjwt
const payload = separatedToken[1].replace(/-/g, '+').replace(/_/g, '/');
userInfo = JSON.parse(atob(payload));
}
}
this.userInfo = userInfo;
}
/**
* Verifies that we were logged in successfully and that all security checks pass
* @param {Boolean} accessTokenRequest if the request we're validating was an access token request
* @return {Object} the error message
* @private
*/
$validate(accessTokenRequest) {
this.$refreshUserInfo();
if (!this.$$config.validation) {
logger('Validation is disabled, skipping...');
return;
}
if (this.$error) {
return {
code: this.$error,
description: this.$errorDescription
};
}
if ((this.$$config.responseType === 'code' && !this.code) || (this.$$config.responseType !== 'code' && !this.$idToken)) {
return {
code: 'login_canceled',
description: 'User likely canceled the login or something unexpected occurred.'
};
}
if (this.$$config.validation.state && this.$localState !== this.$state) {
return {
code: 'invalid_state',
description: 'State provided by identity provider did not match local state.'
};
}
if (this.$$config.responseType === 'code' || accessTokenRequest) return;
if (this.$$config.validation.nonce && this.$nonce !== this.userInfo.nonce) {
return {
code: 'invalid_nonce',
description: 'Nonce provided by identity provider did not match local nonce.'
};
}
if (Array.isArray(this.userInfo.aud)) {
if (this.$$config.validation.azp) {
if (!this.userInfo.azp) {
return {
code: 'invalid_azp',
description: 'Audience was returned as an array and AZP was not present on the ID Token.'
};
}
if (this.userInfo.azp !== this.$$config.clientId) {
return {
code: 'invalid_azp',
description: 'AZP does not match the Client ID.'
};
}
}
if (this.$$config.validation.aud) {
const aud = find(this.userInfo.aud, (audience) => {
return audience === this.$$config.clientId;
});
if (!aud) {
return {
code: 'invalid_aud',
description: 'None of the audience values matched the Client ID.'
};
}
}
} else if (this.$$config.validation.aud && this.userInfo.aud !== this.$$config.clientId) {
return {
code: 'invalid_aud',
description: 'The audience did not match the Client ID.'
};
}
}
/**
* Saves a value to the Web Storage API
* @param {String} key The key to save to
* @param {String} overrideStorageType the name of the storageType to use
* @return {*} the storage value for the given key
* @private
*/
$getItem(key, overrideStorageType) {
let value;
if (overrideStorageType === 'cookie') {
value = Cookie.get(key);
} else {
const storage = overrideStorageType ? this.$$getStorage(overrideStorageType) : this.$storage;
value = storage.getItem(key);
}
return [undefined, null].indexOf(value) === -1 ? value : null;
}
/**
* Saves a value to the Web Storage API
* @param {String} key The key to save to
* @param {*} value The value to save, if this is undefined or null it will delete the key
* @param {String} overrideStorageType the name of the storageType to use
* @private
*/
$saveItem(key, value, overrideStorageType) {
if (overrideStorageType === 'cookie') {
if ([undefined, null].indexOf(value) !== -1) {
Cookie.remove(key);
} else {
Cookie.set(key, value);
}
} else {
const storage = overrideStorageType ? this.$$getStorage(overrideStorageType) : this.$storage;
if ([undefined, null].indexOf(value) !== -1) {
storage.removeItem(key);
} else {
storage.setItem(key, value);
}
}
}
/**
* Return the active Web Storage API
* @return {Storage} the storage api to save and pull values from
* @private
*/
get $storage() {
return this.$$getStorage(this.$$config.storageType);
}
/**
* Determines which Web Storage API to return using the name provided
* @param {String} storageType the name of the storageType to use
* @return {Storage} the web storage api that matches the given string
* @ignore
*/
$$getStorage(storageType) {
if (storageType === 'local') {
return localStorage;
} else if (storageType === 'session') {
return sessionStorage;
} else {
throw new ReferenceError(`Unknown Storage Type (${storageType})`);
}
}
/**
* Clears out all `salte.auth` values from localStorage, sessionStorage, and Cookies
* @param {Boolean} withPrivates whether we should also clear out the private values.
* @private
*/
$clear(withPrivates) {
const regex = withPrivates ? new RegExp(/^salte\.auth\./) : new RegExp(/^salte\.auth\.[^$]/);
for (const key in localStorage) {
if (key.match(regex)) {
localStorage.removeItem(key);
}
}
for (const key in sessionStorage) {
if (key.match(regex)) {
sessionStorage.removeItem(key);
}
}
for (const key in Cookie.getJSON()) {
if (key.match(regex)) {
Cookie.remove(key);
}
}
this.$refreshUserInfo();
}
/**
* Clears all `salte.auth` error values from localStorage
* @private
*/
$clearErrors() {
this.$error = undefined;
this.$errorDescription = undefined;
}
}
export { SalteAuthProfile };
export default SalteAuthProfile;