UNPKG

@trimble-oss/trimble-id

Version:

Trimble Identity SDK for JavaScript/ TypeScript

406 lines (378 loc) 19.7 kB
'use strict'; (function (root, factory) { /* istanbul ignore next */ if (typeof define === 'function' && define.amd) { // AMD define(['./HttpClient', './AnalyticsHttpClient', 'jsrsasign'], (HttpClient, AnalyticsHttpClient, rs) => { return factory({ HttpClient: HttpClient, AnalyticsHttpClient: AnalyticsHttpClient, KJUR: rs.KJUR, hextob64: rs.hextob64 }) }); } else if (typeof exports === 'object') { // CommonJS var rs = require('jsrsasign'); module.exports = factory({ HttpClient: require('./HttpClient'), AnalyticsHttpClient: require('./AnalyticsHttpClient'), KJUR: rs.KJUR, hextob64: rs.hextob64 }); } else { // Browser globals (Note: root is window) root.ImplicitGrantTokenProvider = factory(root); } }(this, function (imports) { /** * @implements {ITokenProvider} * @description In the Implicit Grant flow, Application gets an access token directly without an intermediate code exchange step. */ class ImplicitGrantTokenProvider { /** * @description Public constructor for ImplicitGrantTokenProvider class * @param {IEndpointProvider} endpointProvider An endpoint provider that provides the URL for the Trimble Identity authorization and token endpoints. * It can be be OpenIdEndpointProvider/FixedEndpointProvider * @param {string} consumerKey The consumer key for the calling application * @param {string} redirectUrl The URL to which Trimble Identity should redirect after successfully authenticating a user */ constructor(endpointProvider, consumerKey, redirectUrl) { this._endpointProvider = endpointProvider; this._consumerKey = consumerKey; this._redirectUrl = redirectUrl; this._logoutRedirectUrl = null; this._scopes = ['openid']; this._accessToken = null; this._tokenExpiry = new Date(0); this.state = null; this._requireIdToken = false; this._requireIdTokenValidation = false; this._idToken = null; this._persistantStorage = null; this._claimsetProvider = null; this._silentAuthenticationSupported = false; this._silentAuthenticationCallback = null; //Send analytics this._analyticshttpclient = imports.AnalyticsHttpClient; this._analyticshttpclient.sendInitEvent(this.constructor.name, consumerKey); } /** * @description Fluent extension to add scopes * @param {IEnumerable<string>} scopes The scopes to add to the token provider */ WithScopes(scopes) { this._scopes = this._scopes.concat(scopes); return this; } /** * @description Add ID token validation to an ImplicitGrantTokenProvider * @param {IPersistantStorage} persistantStorage A store that persists the nonce value during the OAuth redirect workflow * @param {IClaimsetProvider} claimsetProvider A claimset provider that returns the JSON web keyset for validating the JWT ID token */ WithIDTokenValidation(persistantStorage, claimsetProvider) { this._requireIdToken = true; this._requireIdTokenValidation = true; this._persistantStorage = persistantStorage; this._claimsetProvider = claimsetProvider; return this; } /** * @description Add silent authentication to an ImplicitGrantTokenProvider * @param {string} state The state parameter to pass with silent authentication * @param {string} callback The callback URL for silent authentication */ WithSilentAuthentication(state, callback) { this._silentAuthenticationSupported = true; this._silentAuthenticationState = state; this._silentAuthenticationCallback = callback; this._activePromise = null; return this; } /** * @description Fluent extension to add logout redirect URL * @param {string} logoutRedirectUrl */ WithLogoutRedirect(logoutRedirectUrl) { this._requireIdToken = true; this._logoutRedirectUrl = logoutRedirectUrl; return this; } /** * @description Get a redirect URL for Trimble Identity * @param {string} state An optional state parameter that will be passed back to the caller via the redirect URL * @returns {PromiseLike<string>} An awaitable Task that resolves to the redirect URL * @exception Thrown when an authorization endpoint is not provided by the endpoint provider */ GetOAuthRedirect(state = null) { //Send analytics this._analyticshttpclient.sendMethodEvent(this.GetOAuthRedirect.name, this._consumerKey); var self = this; return new Promise((resolve, reject) => { self._endpointProvider.RetrieveAuthorizationEndpoint() .then((endpoint) => { var responseType = self._requireIdToken ? 'id_token%20token' : 'token'; var url = endpoint + '?response_type=' + responseType + '&scope=' + encodeURIComponent(self._scopes.join(' ')) + '&client_id=' + encodeURIComponent(self._consumerKey) + '&redirect_uri=' + encodeURIComponent(self._redirectUrl); if (self._requireIdToken) { var nonce = randomString(16); url += '&nonce=' + encodeURIComponent(nonce); if (self._requireIdTokenValidation) self._persistantStorage.SetItem('nonce', nonce); } if (state) { url = url + '&state=' + encodeURIComponent(state); } resolve(url); }) .catch((err) => { self._analyticshttpclient.sendExceptionEvent(self.GetOAuthRedirect.name, err, self._consumerKey); reject('Unable to retrieve token endpoint: ' + err); }); }); } /** * @description Validate the hash parameters passed back to the application by Trimble Identity * @param {string} hash The hash string from the URL * @returns {PromiseLike<string>} An awaitable Task * @exception Thrown when a token endpoint is not provided by the endpoint provider * @exception Thrown when a call to the token endpoint fails */ DecodeHash(hash) { //Send analytics this._analyticshttpclient.sendMethodEvent(this.DecodeHash.name, this._consumerKey); var self = this; return new Promise((resolve, reject) => { if (hash.startsWith('#')) hash = hash.substr(1); if (hash.startsWith('?')) hash = hash.substr(1); var query = {}; hash.split('&').forEach((parameter) => { var parts = parameter.split('='); query[parts[0]] = decodeURIComponent(parts[1]); }); self._validateIdToken(query.access_token, query.id_token) .then(() => { self._accessToken = query.access_token; self._idToken = query.id_token; var now = new Date(); self._tokenExpiry = new Date(now.getTime() + query.expires_in * 1000); self.state = query.state; resolve(); }) .catch((error) => { self._analyticshttpclient.sendExceptionEvent(self.DecodeHash.name, error, self._consumerKey); reject(error); }); }); } /** * @description Retrieves an access token for the authenticated user * @returns {PromiseLike<string>} A Task that resolves to the value of the access token on completion * @exception Thrown when a token endpoint is not provided by the endpoint provider * @exception Thrown when a call to the token endpoint fails */ RetrieveToken() { //Send analytics this._analyticshttpclient.sendMethodEvent(this.RetrieveToken.name, this._consumerKey); var self = this; return new Promise((resolve, reject) => { if (self._tokenExpiry < new Date()) { if (self._silentAuthenticationSupported) { if (self._activePromise) { self._activePromise .then((token) => { resolve(token); }) .catch((error) => { reject(error); }); } else { self._activePromise = self._singleThreadedPromise(); self._activePromise .then((token) => { resolve(token); }) .catch((error) => { reject(error); }) .finally(() => { self._activePromise = null; }); } } else { self._analyticshttpclient.sendExceptionEvent(self.RetrieveToken.name, "Token has expired", self._consumerKey); reject('Token has expired'); } } else resolve(self._accessToken); }); } /** * @description Revoke the token for the authenticated user and return a redirect URL to log them out of all Trimble Identity applications * @returns {PromiseLike<string>} A Task that resolves to the value of the redirect URL on completion * @exception Thrown when a token revocation endpoint is not provided by the endpoint provider * @exception Thrown when a call to the token revocation endpoint fails * @remarks Obsolete! Access tokens cannot be revoked in Trimble Identity v4 */ RevokeToken() { //Send analytics this._analyticshttpclient.sendMethodEvent(this.RevokeToken.name, this._consumerKey); var self = this; return new Promise((resolve, reject) => { self._endpointProvider.RetrieveTokenRevocationEndpoint() .then((tokenRevocationEndpoint) => { var data = 'token=' + encodeURIComponent(self._accessToken) + '&token_type_hint=access_token' + '&client_id=' + encodeURIComponent(self._consumerKey); new imports.HttpClient().httpPost(tokenRevocationEndpoint, data, { headers: { 'content-type': 'application/x-www-form-urlencoded' } }) .then(() => { // for backwards compatibility with earlier releases var redirectUrl = self._redirectUrl; if (self._logoutRedirectUrl) redirectUrl = self._logoutRedirectUrl; // what is the recommended manner to derive this URL??? var root = new URL(tokenRevocationEndpoint).origin; resolve(root + '/i/commonauth' + '?commonAuthLogout=true&' + 'type=samlsso' + '&relyingParty=' + encodeURIComponent(self._consumerKey) + '&commonAuthCallerPath=' + encodeURIComponent(redirectUrl)); }); }); }); } /** * @description Return a redirect URL to log out of all Trimble Identity applications * @param {string} state An optional state parameter that will be passed back to the caller via the redirect URL * @returns {PromiseLike<string>} A promise that resolves to the value of the redirect URL on completion */ GetOAuthLogoutRedirect(state = null) { //Send analytics this._analyticshttpclient.sendMethodEvent(this.GetOAuthLogoutRedirect.name, this._consumerKey); var self = this; return new Promise((resolve, reject) => { self._endpointProvider.RetrieveEndSessionEndpoint() .then((endpoint) => { // Trimble Identity v3 handles logout differently to Trimble Identity v4 if (endpoint.endsWith("/commonauth")) // v3 resolve(endpoint + "?commonAuthLogout=true" + "&type=samlsso" + "&relyingParty=" + encodeURIComponent(self._consumerKey) + "&commonAuthCallerPath=" + encodeURIComponent(self._logoutRedirectUrl) ); else // v4+ { if (self._idToken) { var url = endpoint + "?id_token_hint=" + encodeURIComponent(self._idToken) + "&post_logout_redirect_uri=" + encodeURIComponent(self._logoutRedirectUrl); if (state) { url = url + "&state=" + encodeURIComponent(state); } resolve(url); } else { self._analyticshttpclient.sendExceptionEvent(self.GetOAuthLogoutRedirect.name, "An id_token is required to log out of all applications", self._consumerKey); // an id token must be provided to log out reject('An id_token is required to log out of all applications') } } }) .catch((error) => { self._analyticshttpclient.sendExceptionEvent(self.GetOAuthLogoutRedirect.name, "An id_token is required to log out of all applications", self._consumerKey); reject(error) }); }); } _validateIdToken(accessToken, idToken) { var self = this; return new Promise((resolve, reject) => { if (!self._requireIdTokenValidation) { resolve(); } else { if (!idToken) { reject('Missing ID token'); } self._claimsetProvider.RetrieveClaimset(idToken) .then((claimset) => { if (claimset.nonce != self._persistantStorage.GetItem('nonce')) { self._persistantStorage.RemoveItem('nonce'); reject('Stored value does not match nonce claim'); return; } if (claimset.at_hash) { // does this make sense or should we always require the at_hash claim?? // calculate hash of accessToken var hash = imports.KJUR.crypto.Util.hashString(accessToken, 'sha256'); var at_hash = imports.hextob64(hash.substring(0, 32)).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, ''); // RFC 4648 if (claimset.at_hash != at_hash) { self._persistantStorage.RemoveItem('nonce'); reject('Calculated hash does not match at_hash claim'); return; } } self._persistantStorage.RemoveItem('nonce'); resolve(); }) .catch(() => { self._persistantStorage.RemoveItem('nonce'); reject('Unable to validate ID token'); }); } }); } _singleThreadedPromise() { var self = this; return new Promise((resolve, reject) => { self.GetOAuthRedirect(self._silentAuthenticationState, true) .then((redirect) => { self._silentAuthenticationCallback(redirect + '&prompt=none') .then((tokenOrError) => { if (tokenOrError.error) { reject(tokenOrError.error); } else { self._validateIdToken(tokenOrError.access_token, tokenOrError.id_token) .then(() => { self._accessToken = tokenOrError.access_token; var now = new Date(); self._tokenExpiry = new Date(now.getTime() + tokenOrError.expires_in * 1000); self.state = tokenOrError.state; // do we want to update the state with the one from silent authentication?? resolve(self._accessToken); }) .catch((error) => { reject(error); }); } }) .catch((err) => { reject('Error from silent authentication callback: ' + err); }); }) .catch((err) => { reject('Failed to generate redirect URL: ' + err); }); }); } } function randomString(length) { var charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._~' var result = '' while (length > 0) { var bytes = new Uint8Array(16); var random = window.crypto.getRandomValues(bytes); random.forEach(function (c) { if (length == 0) { return; } if (c < charset.length) { result += charset[c]; length--; } }); } return result; } // Exposed public methods return ImplicitGrantTokenProvider; }));