UNPKG

forgerockembeddedlogin

Version:

Library to assist with login for ForgeRock web clients

456 lines (422 loc) 23 kB
(function () { "use strict"; /** * Module used to encapsilate interaction with ForgeRock Access Management's authentication API * @module ForgeRockEmbeddedLogin */ let findName = (array, name) => array.reduce( (found, item) => found || (item.name === name && item), false ); /** * @constructor * @param {Object} options * @param {String} options.authenticateUrl - The URL to the AM authentication API, with all options included within the query string * @param {function} options.postRenderHandler - Function to call after the login form is rendered on the page. Includes (header, stage, template) arguments * @param {function} options.successHandler - Function to call whenever authentication succeeds * @param {function} options.failureHandler - Function to call whenever authentication fails * @param {Object} options.loginElement - Place in the DOM used to render the credential input fields */ let embeddedLogin = function (options) { this.authenticateUrl = options.authenticateUrl; this.postRenderHandler = options.postRenderHandler; this.successHandler = options.successHandler; this.failureHandler = options.failureHandler; this.loginElement = options.loginElement; return this; }; /** @function handleCallbackResponse * Unlikely to need to be overridden or called directly. This function is called * by the default implementation of startLogin and submitCallbacks. It reacts to * the response produced from either by delegating the work to the proper handler, * based on the state determined. */ embeddedLogin.prototype.handleCallbackResponse = function () { if (this.success() && this.successHandler) { this.successHandler(); } else if (this.failure() && this.failureHandler) { this.failureHandler(); } else { this.renderAllCallbacks() .then((loginContent) => this.renderHandler(loginContent)) .then(() => { if (this.postRenderHandler) { this.postRenderHandler( this.currentCallbacks.header, this.currentCallbacks.stage, this.currentCallbacks.template ); } }); } return this; }; /** @function startLogin * This function *MUST* be called by your application, when you are ready to start * interacting with the AM authentication API. It is unlikely that you will need * to override it. It makes XHR calls to the authenticateUrl without any credentials * supplied (besides the cookie that may be present in the case of an established session). */ embeddedLogin.prototype.startLogin = function () { this.currentCallbacks = {}; return this.submitCallbacks(); }; /** @function success * How to determine if the authentication has succeeded. Unlikely to need to be overridden. */ embeddedLogin.prototype.success = function () { return !!this.currentCallbacks.tokenId; }; /** @function failure * How to determine if the authentication has failed. Unlikely to need to be overridden. */ embeddedLogin.prototype.failure = function () { return typeof this.currentCallbacks.authId === "undefined" && this.currentCallbacks.code === 401; }; /** @function renderHandler * Binds the content produced from renderAllCallbacks to the DOM. The default implementation * merely sets the innerHTML and attaches an onsubmit handler to the form within the content. * * You may want to override this if you want more control over how the fields are inserted into * the DOM. Be sure to set the onsubmit handler for the form to call out to `handleLoginSubmit` * if you do override it. */ embeddedLogin.prototype.renderHandler = function (loginContent) { if (this.loginElement) { // clear the content from this.loginElement let cNode = this.loginElement.cloneNode(false); this.loginElement.parentNode.replaceChild(cNode, this.loginElement); this.loginElement = cNode; this.loginElement.appendChild(loginContent); let form = this.loginElement.getElementsByTagName("form")[0]; form.onsubmit = this.handleLoginSubmit.bind(this); } return this; }; /** @function renderAllCallbacks * Loops over every callback returned by the authentication API, calling the renderCallback * function for each of them. The default function adds a ConfirmationCallback if there isn't * normally one present in the response. * * You may want to override this function if you want to change the behavior for that added * ConfirmationCallback. */ embeddedLogin.prototype.renderAllCallbacks = function () { var needsLoginButton = !this.currentCallbacks.callbacks.reduce((result, callback) => result || ["ConfirmationCallback","PollingWaitCallback","RedirectCallback"].indexOf(callback.type) !== -1, false), loginCallback = { input: { index: this.currentCallbacks.callbacks.length, name: "loginButton", value: 0 }, output: [{ name: "options", value: [ this.getLoginButtonText() ] }], type: "ConfirmationCallback" }; return Promise.all( (needsLoginButton ? this.currentCallbacks.callbacks.concat(loginCallback) : this.currentCallbacks.callbacks) .map((callback, index) => this.renderCallback(callback, index)) ) .then(this.joinRenderedCallbacks); }; /** @function getLoginButtonText * Provides a default English-language option for the login button produced by the * `renderAllCallbacks` function. You may want to override this function if you want * to have support for internationalization. */ embeddedLogin.prototype.getLoginButtonText = function () { return "Login"; }; /** @function handleLoginSubmit * The default function supplied as the "onsubmit" handler for the input form. Assumes * the input fields are named like so: * callback_0 * callback_1 * etc... * * Maps the value from those inputs into the `currentCallbacks` structure last fetched. * * You will need to override this if the naming convention for your inputs are different. */ embeddedLogin.prototype.handleLoginSubmit = function (event) { event.preventDefault(); for (var entry of (new FormData(event.currentTarget))) { let callback_entry = entry[0].match(/^callback_(\d+)$/); if (callback_entry) { this.currentCallbacks.callbacks[parseInt(callback_entry[1], 10)].input[0].value = entry[1]; } } return this.submitCallbacks(); }; /** @function submitCallbacks * This function is similar to `startLogin`, except it supplies the gathered * inputs captured in the `currentCallbacks` data. It makes XHR calls to the * authenticateUrl and uses `handleCallbackResponse` afterwards. */ embeddedLogin.prototype.submitCallbacks = function () { return fetch(this.authenticateUrl, { mode: "cors", method: "POST", credentials: "include", headers: { "accept-api-version": "protocol=1.0,resource=2.1", "content-type": "application/json" }, body: JSON.stringify(this.currentCallbacks) }) .then((resp) => resp.json()) .then((jsonResp) => { this.currentCallbacks = jsonResp; return this.currentCallbacks; }) .then(() => this.handleCallbackResponse()); }; /** @function renderCallback * Delegates the current callback to the appropriate type-specific rendering function. * It's not expected that this should need to be overriden; the various callback-specific * logic included within this function should be generally-applicable. */ embeddedLogin.prototype.renderCallback = function (callback, index) { let prompt = "", promptOutput = findName(callback.output, "prompt"); if (promptOutput && promptOutput.value && promptOutput.value.length) { prompt = promptOutput.value.replace(/:$/, ""); } switch (callback.type) { case "NameCallback": return this.renderNameCallback(callback, index, prompt); case "PasswordCallback": return this.renderPasswordCallback(callback, index, prompt); case "TextInputCallback": return this.renderTextInputCallback(callback, index, prompt); case "TextOutputCallback": var type = findName(callback.output, "messageType"), message = findName(callback.output, "message"), messageTypeMap = { 0: "INFORMATION", 1: "WARNING", 2: "ERROR" }; // Magic number 4 is for a <script>, taken from ScriptTextOutputCallback.java if (type.value === "4") { return this.renderTextOutputScript(index, message.value); } else { return this.renderTextOutputMessage(index, message.value, messageTypeMap[type.value]); } case "ConfirmationCallback": var options = findName(callback.output, "options"); if (options && options.value !== undefined) { // if there is only one option then mark it as default. let defaultOption = options.value.length > 1 ? findName(callback.output, "defaultOption") : { "value": 0 }; return Promise.all( options.value.map((option, key) => this.renderConfirmationCallbackOption(option, index, key, defaultOption && defaultOption.value === key) ) ); } else { return Promise.all([]); } case "ChoiceCallback": var choiceOutput = findName(callback.output, "choices"); if (choiceOutput && choiceOutput.value !== undefined) { let choices = choiceOutput.value.map((option, key) => ({ active: callback.input.value === key, key, value: option })); return this.renderChoiceCallback(callback, index, prompt, choices); } else { return Promise.all([]); } case "HiddenValueCallback": return this.renderHiddenValueCallback(callback, index); case "RedirectCallback": var redirectUrl = findName(callback.output, "redirectUrl"); var redirectMethod = findName(callback.output, "redirectMethod"); var redirectData = findName(callback.output, "redirectData"); var form = document.createElement("form"); form.action = redirectUrl.value; form.method = redirectMethod.value; if (redirectData && redirectData.value) { redirectData.value.forEach((v, k) => { let input = document.createElement("input"); input.type = "hidden"; input.name = k; input.value = v; form.appendChild(input); }); } document.getElementsByTagName("body")[0].appendChild(form); form.submit(); // no return from here, expectation is the page transitions to the redirectUrl break; case "PollingWaitCallback": var pollingWaitTimeoutMs = findName(callback.output, "waitTime").value; setTimeout(() => { this.pollingInProgress = true; // figure out how to handle this later }, pollingWaitTimeoutMs); return this.renderPollingWaitCallback(callback, index, findName(callback.output, "message").value); default: return this.renderUnknownCallback(callback, index, prompt); } }; /** @function joinRenderedCallbacks * @param {Array} renderedCallbacks - Array of resolved values which have been produced by the `renderCallback` method * @returns {Promise} - resolved when the full content of the form to render is available. Promise is resolved with DOM Node represeting the complete login form * * It is expected that this function will be overridden. The default implementation is very simple, and merely adds * <form> tags around the callbacks, along with breaks between them. If you want more sophisticated markup around your * input controls, you can provide it here. */ embeddedLogin.prototype.joinRenderedCallbacks = function (renderedCallbacks) { let form = document.createElement("form"); renderedCallbacks // flatten callback results .reduce((result, item) => result.concat(item), []) .forEach((callback) => { form.appendChild(callback); form.appendChild(document.createElement("br")); }); return Promise.resolve(form); }; /** @function renderNameCallback * @param {Object} callback - structure of data returned from authentication API for this specific callback type * @param {number} index - ordinal position of this callback relative to others * @param {string} prompt - Text to present to the user describing the callback * @returns {Promise} - resolved when the full content of this callback is available. Expected to be resolved with a DOM node * * It is expected that this function will be overridden. The default implementation is a very simple text input field. */ embeddedLogin.prototype.renderNameCallback = function (callback, index, prompt) { let el = document.createElement("div"); el.innerHTML = `<input type="text" name="callback_${index}" value="${callback.input[0].value}" placeholder="${prompt}">`; return Promise.resolve(el.firstElementChild); }; /** @function renderPasswordCallback * @param {Object} callback - structure of data returned from authentication API for this specific callback type * @param {number} index - ordinal position of this callback relative to others * @param {string} prompt - Text to present to the user describing the callback * @returns {Promise} - resolved when the full content of this callback is available. Expected to be resolved with a DOM node * * It is expected that this function will be overridden. The default implementation is a very simple password input field. */ embeddedLogin.prototype.renderPasswordCallback = function (callback, index, prompt) { let el = document.createElement("div"); el.innerHTML = `<input type="password" name="callback_${index}" value="${callback.input[0].value}" placeholder="${prompt}">`; return Promise.resolve(el.firstElementChild); }; /** @function renderTextInputCallback * @param {Object} callback - structure of data returned from authentication API for this specific callback type * @param {number} index - ordinal position of this callback relative to others * @param {string} prompt - Text to present to the user describing the callback * @returns {Promise} - resolved when the full content of this callback is available. Expected to be resolved with a DOM node * * It is expected that this function will be overridden. The default implementation is a very simple textarea input field. */ embeddedLogin.prototype.renderTextInputCallback = function (callback, index, prompt) { let el = document.createElement("div"); el.innerHTML = `<textarea name="callback_${index}">${callback.input[0].value}</textarea>`; return Promise.resolve(el.firstElementChild); }; /** @function renderTextOutputScript * @param {number} index - ordinal position of this callback relative to others * @param {string} messageValue - Script to be executed * @returns {Promise} - resolved when the full content of this callback is available. Expected to be resolved with a DOM node * * This is the special-case of a "TextOutputCallback" that is of type "4" - indicating a script. * This adds client-side JavaScript code to execute in the browser. You shouldn't have * to override this under normal circumstances. */ embeddedLogin.prototype.renderTextOutputScript = function (index, messageValue) { let el = document.createElement("script"); el.innerHTML = messageValue; return Promise.resolve(el); }; /** @function renderTextOutputMessage * @param {number} index - ordinal position of this callback relative to others * @param {string} messageValue - Script to be executed * @param {string} typeValue - type of output message [INFORMATION,WARNING,ERROR] * @returns {Promise} - resolved when the full content of this callback is available. Expected to be resolved with a DOM node * * This is the general-case of a "TextOutputCallback". * This outputs a non-interactive text message, of a particular type. */ embeddedLogin.prototype.renderTextOutputMessage = function (index, messageValue, typeValue) { let el = document.createElement("div"); el.innerHTML = `<div id="callback_${index}" class="${typeValue}">${messageValue}</div>`; return Promise.resolve(el.firstElementChild); }; /** @function renderConfirmationCallbackOption * @param {string} option - value of this particular option * @param {number} index - ordinal position of this callback relative to others * @param {number} key - ordinal position of this option relative to others * @param {boolean} isDefault - true if this option is the "default" one; should only be one within this callback set to true * @returns {Promise} - resolved when the full content of this callback is available. Expected to be resolved with a DOM node * * This renders one particular confirmation callback option. It is one value within a set, * the totality of which represents a single confirmation callback. */ embeddedLogin.prototype.renderConfirmationCallbackOption = function (option, index, key, isDefault) { let el = document.createElement("div"); el.innerHTML = `<input name="callback_${index}" type="submit" index="${key}" value="${option}">`; return Promise.resolve(el.firstElementChild); }; /** @function renderChoiceCallback * @param {Object} callback - structure of data returned from authentication API for this specific callback type * @param {number} index - ordinal position of this callback relative to others * @param {string} prompt - Text to present to the user describing the callback * @param {Array} choices - List of choicesffor this callback * @param {string} choices[].key - the value to be submitted for this callback if it is selected * @param {boolean} choices[].active - the default value to be selected * @param {string} choices[].value - the content to display to the user representing this choice * @returns {Promise} - resolved when the full content of this callback is available. Expected to be resolved with a DOM node * * This renders a set of choices, intended for the user to choose between. */ embeddedLogin.prototype.renderChoiceCallback = function (callback, index, prompt, choices) { let el = document.createElement("div"); el.innerHTML = `<label for="callback_${index}" id="label_callback_${index}">${prompt}</label> <select name="callback_${index}" id="callback_${index}"> ${choices.map((choice) => `<option value="${choice.key}" ${choice.active ? "selected" : ""}>${choice.value}</option>`)} </select>`; return Promise.resolve(el.firstElementChild); }; /** @function renderHiddenValueCallback * @param {Object} callback - structure of data returned from authentication API for this specific callback type * @param {number} index - ordinal position of this callback relative to others * @returns {Promise} - resolved when the full content of this callback is available. Expected to be resolved with a DOM node * * This includes a hidden value within the form. */ embeddedLogin.prototype.renderHiddenValueCallback = function (callback, index) { let el = document.createElement("div"); el.innerHTML = `<input type="hidden" id="${callback.input.value}" aria-hidden="true" name="callback_${index}" value="" />`; return Promise.resolve(el.firstElementChild); }; /** @function renderPollingWaitCallback * @param {Object} callback - structure of data returned from authentication API for this specific callback type * @param {number} index - ordinal position of this callback relative to others * @param {string} messageValue - Text to be displayed to the user while the client waits * @returns {Promise} - resolved when the full content of this callback is available. Expected to be resolved with a DOM node * * This displays text to the user while waiting for something to happen out-of-channel. */ embeddedLogin.prototype.renderPollingWaitCallback = function (callback, index, message) { let el = document.createElement("div"); el.innerHTML = `<input type="hidden" id="${callback.input.value}" aria-hidden="true" name="callback_${index}" value="" />`; return Promise.resolve(el.firstElementChild); }; /** @function renderUnknownCallback * @param {Object} callback - structure of data returned from authentication API for this specific callback type * @param {number} index - ordinal position of this callback relative to others * @param {string} prompt - Text to present to the user describing the callback * @returns {Promise} - resolved when the full content of this callback is available. Expected to be resolved with a DOM node * * Handler for an unknown callback type. By default it just uses the name callback. */ embeddedLogin.prototype.renderUnknownCallback = function (callback, index, prompt) { return this.renderNameCallback(callback, index, prompt); }; module.exports = embeddedLogin; }());