idp.js

/**
 *
 * @class 
 * @classdesc
 * Provides methods to connect to and manage the IDP.
 * It assumes that the user has already been authenticated by the IDP.
 * This will typically have been done by the Apache OIDC module on the server before the page is served. 
 * 
 * ## Dependencies ##
 * * The Keycloak JavaScript adapter must have been loaded from the IDP via a script tag:
 * ```html
 * <script src="https://idp.leadershipfactor.com/auth/js/keycloak.js"></script>
 * ```
 * 
 * * A page must exist at <code><em>site_root</em>/status/silent-check-sso.html</code> with the following code:
 * ```html
 * <html>
 * <body>
 * <script>
 *    parent.postMessage(location.href, location.origin);
 * </script>
 * </body>
 * </html>
 * ```
 * See {@link https://github.com/keycloak/keycloak-documentation/blob/master/securing_apps/topics/oidc/javascript-adapter.adoc Keycloak JavaScript Adapter} for details.
 * 
 * @copyright (c) 2021 TLF Research Ltd.
 * 
 * @example
 * const idp = new IDP("https://idp.leadershipfactor.com", "Company X", "portal1");
 * 
 * @param {string} root Root URL for the IDP that manages this client
 * @param {string} realm The name of the IDP realm that controls this client
 * @param {string} client The name of this "client" i.e. the application or site name as defined on the IDP 
 * 
 */
function IDP(root, realm, client) {

    const self = this;

    self.root = root;
    self.realm = realm;

    // Public methods
    self.init = init.bind(self);
    self.get = get.bind(self);
    self.post = post.bind(self);
    self.del = del.bind(self);
    self.put = put.bind(self);

    const keycloak = new Keycloak({
        'realm': realm,
        'auth-server-url': root + '/auth/',
        'ssl-required': 'external',
        'public-client': true,
        'confidential-port': 0,
        'url': root + '/auth',
        'clientId': client,
        'enable-cors': true
    });

    function init() {
        const self = this;
        return new Promise(function (resolve, reject) {
            keycloak.init({
                promiseType: 'native',
                onLoad: 'check-sso',
                enableLogging: true,
                silentCheckSsoRedirectUri: window.location.origin + '/status/silent-check-sso.html'
            }).then(function () {
                if (keycloak.token) {
                    document.body.addEventListener('beforeLogout', function () { keycloak.logout() });
                    resolve();
                } else {
                    keycloak.login();
                    resolve(Error('Not authenticated'));
                }
            }).catch(function (err) {
                reject(err);
            });
        });
    }

    /**
     * Fetches a resource asynchronously from the IDP via an HTTP GET method.
     * This is an idempotent method.
     *
     * @example
     * idp.get("roles")
     *    .then(roles => {
     *       // Do something with roles here
     *    }
     *    .catch(err => {
     *       // Something went wrong
     *       console.log(err);
     *    });
     * 
     * @param {string} url URL of the endpoint to invoke on the IDP. Omit the root, including its traling slash
     *
     * @return {Promise}
     */
    function get(url) {
        return exec.call(this, 'GET', url);
    }

    /**
     * Creates a new resource asynchronously on the IDP via an HTTP POST method.
     *
     * @example
     * idp.post(`users/${userid}/role-mappings/realm`, [newRole]).catch(err => console.log(err));
     * 
     * @param {string} url URL of the endpoint to invoke on the IDP. Omit the root, including its traling slash
     * @param {object} data Data to apply as the update
     *
     * @return {Promise}
     */
    function post(url, data) {
        return exec.call(this, 'POST', url, data);
    }

    /**
     * Deletes a resource asynchronously from the IDP via an HTTP DELETE method.
     *
     * @example
     * idp.del(`users/${userid}/role-mappings/realm`, [oldRole]).catch(err => console.log(err));

     * @param {string} url URL of the endpoint to invoke on the IDP. Omit the root, including its traling slash
     * @param {object} data Data identifying the resource to be removed
     *
     * @return {Promise}
     */
    function del(url, data) {
        return exec.call(this, 'DELETE', url, data);
    }

    /**
     * Updates a resource asynchronously on the IDP via an HTTP PUT method.
     * This is an idempotent method.
     *
     * @example
     * idp.put(`users/${userid}`, { enabled: true }).catch(err => console.log(err))
     * 
     * @param {string} url URL of the endpoint to invoke on the IDP. Omit the root, including its traling slash
     * @param {object} data JSON-encoded data to apply as the update
     *
     * @return {Promise}
     */
    function put(url, data) {
        return exec.call(this, 'PUT', url, data);
    }

    /**
     * Executes the given remote call via Ajax.
     * 
     * @private
     *
     * @param {string} method HTTP operation to perform - one of 'GET', 'PUT', 'DELETE' or 'POST'
     * @param {string} url URL of the endpoint to invoke on the IDP. Omit the root, including its traling slash
     * @param {object} [data] Data to be sent
     *
     * @return {Promise}
     */
    function exec(method, url, data) {
        const self = this;
        return new Promise(function (resolve, reject) {
            // Need to update our access token first before trying to fetch any data
            keycloak.updateToken(30)
                .then(function () {
                    if (!keycloak.token) {
                        reject(Error('Missing access token'));
                        return;
                    }
                    fetch(self.root + '/auth/admin/realms/' + self.realm + '/' + url,
                        {
                            method: method,
                            headers: {
                                'Accept': 'application/json',
                                'Authorization': 'Bearer ' + keycloak.token,
                                'Content-Type': 'application/json;charset=utf-8'
                            },
                            body: JSON.stringify(data)
                        })
                        .then(function (resp) {
                            if (!resp.ok) {
                                throw new Error(method + ' to the IDP at ' + url + ' failed (' + resp.status + ')');
                            }
                            switch (resp.status) {
                                case 200:
                                    return resp.json();
                            }
                        })
                        .then(function (data) { resolve(data) })
                        .catch(function (err) { reject(err) })
                })
                .catch(function (err) {
                    reject(Error('Failed to refresh IDP access token: ' + err));
                });
        });
    }
}