import delay from 'lodash.delay';
import differenceInMilliseconds from 'date-fns/difference_in_milliseconds';
export const NO_OAUTH_SECRET_PROVIDED = `
No oAuth client secret was provided.
Provide a base64 secret to allow oAuth logins.
`;
/**
* Manages the end user authentication session against (currently) pauth token.
*
* <p>This class is not intended to be used directly. You should instead use {@link Client#login},
* {@link Client#logout} and [Client's session property]{@link Client}</p>
*
* <p>See the {@link https://identity-reference.sequoia.piksel.com/docs|Sequoia Documentation} for more information.</p>
*
* @param {Transport} transport - Transport instance to use for fetching
* @param {string} directory - The directory (sometimes refered to as 'domain') that the user belongs to.
* This is prepended to the username on login requests (POSTs to /pauth/token)
* @param {Registry} registry - a stored registry reference to query
* @param {string?} identityUri - The endpoint URI for the sequoia identity service e.g. https://identity-sandbox.sequoia.piksel.com
*
* @property {string} directory - Stored from the initial `directory` parameter
* @property {string?} identityUri - Stored from the initial `identityUri` parameter
* @property {string?} token - The access_token returned from the call to
* {@link https://identity-reference.sequoia.piksel.com/docs/routes/pauth-token|/pauth/token} after logging in
* @property {Object[]} tenants - Stored array of tenants the user has access to
* See the {@link https://identity-reference.sequoia.piksel.com/docs/routes/pauth-tenants|tenants documentation} for more information.
* @property {Object} access - Stored access information for the logged in user.
* See the {@link https://identity-reference.sequoia.piksel.com/docs/routes/pauth-access|access documentation} for more information.
*
* @example
* import Session from '@pikselpalette/sequoia-js-client-sdk/lib/session';
*
* const session = new Session('piksel', 'https://identity-sandbox.sequoia.piksel.com');
* session.authenticateWithCredentials('username', 'password').then((session) => this.registry.fetch(session.currentOwner())).then(() => this.session)
*
*/
class Session {
constructor(transport, directory, registry, identityUri) {
this.transport = transport;
this.directory = directory;
this.registry = registry;
this.identityUri = identityUri;
this.token = null;
this.tenants = [];
this.access = {};
this.currentTenantName = null;
this.token = null;
this.expiryWarningRef = null;
}
getIdentityUri() {
const identityUri = this.registry.getServiceLocation('identity');
if (identityUri !== null) {
this.identityUri = identityUri;
}
// We either had the identityUri supplied via the `client` constructor
// or we had a pre-populated registry, so return it
if (this.identityUri) {
return Promise.resolve(this.identityUri);
}
// We don't know about the location of identity, so ask the registry
const tenancy = this.currentTenant
? this.currentTenant.name
: this.directory;
return this.registry.fetch(tenancy).then(() => {
this.identityUri = this.registry.getServiceLocation('identity');
});
}
/**
* @typedef {Object} AuthenticationOptions
* @property {string?} strategy - 'pauth' or 'oauth'
* @property {string?} secret - [base64 clientId and clientSecret]{@link https://identity-euw1shared.sequoia.piksel.com/docs/groups/oauth}
* @property {string?} url - Override the URL to hit for authentication. Useful
* for providing custom endpoints to provide an access_token
*/
/**
* Create a session based on end user credentials
*
* @param {string?} username
* @param {string?} password
* @param {AuthenticationOptions?} options
*
* @see {Client#login}
* @returns {Promise}
*/
authenticateWithCredentials(
username,
password,
options = { strategy: 'pauth' }
) {
return this.getIdentityUri().then(() => {
const { strategy, url, secret } = options;
let endpoint = `${this.identityUri}/pauth/token`;
let body = JSON.stringify({
username: `${this.directory}\\${username}`,
password
});
let headers;
if (strategy === 'oauth') {
if (secret === undefined) {
return Promise.reject(NO_OAUTH_SECRET_PROVIDED);
}
endpoint = `${this.identityUri}/oauth/token`;
if (typeof username === 'string' && typeof password === 'string') {
body = `grant_type=password&username=${
this.directory
}\\${username}&password=${encodeURIComponent(password)}`;
} else {
body = 'grant_type=client_credentials';
}
headers = {
authorization: `Basic ${secret}`,
'Content-Type': 'application/x-www-form-urlencoded'
};
}
// The `url` option overrides
if (url) {
endpoint = url;
}
delete options.url;
delete options.strategy;
delete options.secret;
const fetchOptions = { body };
// Just { body, headers } leaves options.headers as undefined and therefor
// overrides the defaults in transport.js
if (headers) {
fetchOptions.headers = headers;
}
return (
this.transport
.post(endpoint, Object.assign({}, fetchOptions, options))
// eslint-disable-next-line camelcase
.then(({ access_token }) => this.authenticateWithToken(access_token))
.then(() => this)
);
});
}
/**
* Create a session based on an existing bearer token
*
* Use this if you acquire an access token via other means,
* i.e. an existing oauth mechanism for Sequoia
*
* @param {string} token
*
* @returns {Promise}
*/
authenticateWithToken(token) {
if (token) {
this.token = token;
this.transport.defaults.headers.authorization = `Bearer ${this.token}`;
}
this.clearExpiryWarning();
return this.populateTenants()
.then(() => this.populateAccess())
.then(() => {
this.startOnExpiryWarningTimer();
return this;
});
}
/**
* Change the password of the user. Piksel Auth only.
*
* @param {string} oldPassword
* @param {string} newPassword
*/
changePassword(username, password, newPassword) {
return this.getIdentityUri().then(identityUrl => (
this.transport.post(`${identityUrl}/pauth/passwords`, {
body: JSON.stringify({
username,
password,
newPassword
})
})
));
}
/**
* Reset the password of the user. Piksel Auth only.
*
* @param {string} username
*/
resetPassword(username) {
return this.getIdentityUri().then(identityUrl => (
this.transport.post(`${identityUrl}/pauth/password-resets`, {
body: JSON.stringify({
username
})
})
));
}
/**
* Set the directory after initialisation
*
* Use this if you want to cache your client, but want to
* login under a different directory / domain
*
* @param {string} directory
*/
setDirectory(directory) {
this.directory = directory;
}
/**
* Set the expiry warning callback and threshold.
* @param {Function?} callback Callback invoked when token is expiring
* @param {Number?} threshold Threshold before expiry on which to invoke the callback, default 60000ms
*/
setOnExpiryWarning(callback, threshold = 60000) {
this.clearExpiryWarning();
if (callback) {
this.onExpiryWarning = callback;
this.expiryThreshold = threshold;
}
this.startOnExpiryWarningTimer();
}
/**
* Clear the expiry warning callback and cancel any timers.
*/
clearExpiryWarning() {
this.onExpiryWarning = null;
this.expiryThreshold = null;
// Clear any existing timers
if (this.expiryWarningRef) {
clearTimeout(this.expiryWarningRef);
this.expiryWarningRef = null;
}
}
/**
* Start the expiry warning timer.
*/
startOnExpiryWarningTimer() {
if (this.access && this.onExpiryWarning) {
const expiryWarning = differenceInMilliseconds(this.access.expiresAt, new Date()) - this.expiryThreshold;
this.expiryWarningRef = delay(
expiringAccess => this.tokenExpiring(expiringAccess),
expiryWarning,
this.access
);
}
}
/**
* Called when the token is about to expire.
* Calls the callback if registered.
* @param {AccessSession} access The Sequoia access session
*/
tokenExpiring(access) {
if (this.onExpiryWarning) {
this.onExpiryWarning(access);
}
}
/**
* Populate the `tenants` properties of `Session` with
* data returned from Sequoia.
*
* @private
*
* @returns {Promise}
*/
populateTenants() {
if (this.token === null) {
// Not authenticated, so do nothing
return Promise.resolve();
}
return this.getIdentityUri()
.then(() => this.transport.get(`${this.identityUri}/pauth/tenants`))
.then((json) => {
this.tenants = json.tenants;
});
}
/**
* Populate the `access` properties of `Session` with
* data returned from Sequoia.
*
* @private
*
* @returns {Promise}
*/
populateAccess() {
if (this.token === null) {
// Not authenticated, so do nothing
return Promise.resolve();
}
return this.getIdentityUri()
.then(() => {
let accessUri = `${this.identityUri}/pauth/access`;
if (this.currentOwner()) {
accessUri = `${accessUri}?tenants=${this.currentOwner()}`;
}
return this.transport.get(accessUri);
})
.then((json) => {
this.access = json;
});
}
/**
* Returns whether there is a currently active logged in session
*
* @returns {boolean}
*/
isActive() {
return this.token !== null;
}
/**
* Find a tenant by name in the current set of user available tenants
*
* @returns {Object}
*/
findTenant(tenantName) {
return this.tenants.find(item => item.name === tenantName);
}
/**
* The current tenant being used in this session.
*
* When getting this propery, it will default to the first tenant in the {@link Session.tenants} array
* if this property has not been explicitly set
*
* Will return null if there are no tenants available (e.g. the session is inactive)
* @type {string?}
*/
get currentTenant() {
if (!this.tenants.length) {
return null;
}
return this.currentTenantName === null
? this.tenants[0]
: this.findTenant(this.currentTenantName);
}
set currentTenant(tenantName) {
this.currentTenantName = tenantName;
}
/**
* Get the name (owner) of the current tenant
*
* @returns {string}
*/
currentOwner() {
return this.currentTenant
? this.currentTenant.name
: this.currentTenantName;
}
/**
* Log out an end user
*
* @todo Should this make a call to some service to revoke the auth token?
*
* @returns {Session}
*/
destroy() {
this.token = null;
this.tenants = [];
this.access = {};
this.currentTenantName = null;
delete this.transport.defaults.headers.Authorization;
if (this.expiryWarningRef) {
clearTimeout(this.expiryWarningRef);
this.expiryWarningRef = null;
}
return this;
}
}
export default Session;