@trimble-oss/trimble-id
Version:
Trimble Identity SDK for JavaScript/ TypeScript
406 lines (378 loc) • 19.7 kB
JavaScript
'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;
}));