From 2ecc743851f5c1d1460173f2b636c6052499d108 Mon Sep 17 00:00:00 2001 From: lboyette-okta Date: Fri, 15 Jul 2016 12:33:19 -0700 Subject: [PATCH] Added token.getWithRedirect and token.parseFromUrl Resolves: OKTA-94711 --- lib/clientBuilder.js | 7 + lib/token.js | 332 ++++++++++++++++++++++++----------------- package.json | 3 +- test/spec/oauth.js | 4 +- test/spec/token.js | 85 ++++++++++- test/util/oauthUtil.js | 15 +- test/util/util.js | 6 +- webpack.config.js | 5 +- 8 files changed, 311 insertions(+), 146 deletions(-) diff --git a/lib/clientBuilder.js b/lib/clientBuilder.js index 5a5e52e46b..778da695cf 100644 --- a/lib/clientBuilder.js +++ b/lib/clientBuilder.js @@ -80,8 +80,15 @@ function OktaAuthBuilder(args) { sdk.token = { getWithoutPrompt: util.bind(token.getWithoutPrompt, sdk, sdk), getWithPopup: util.bind(token.getWithPopup, sdk, sdk), + getWithRedirect: util.bind(token.getWithRedirect, sdk, sdk), + parseFromUrl: util.bind(token.parseFromUrl, sdk, sdk), decode: util.bind(token.decodeToken, sdk) }; + + // This is exposed so we can set window.location in our tests + sdk.token.getWithRedirect._setLocation = function(url) { + window.location = url; + }; } var proto = OktaAuthBuilder.prototype; diff --git a/lib/token.js b/lib/token.js index bd7c9aefd3..3aa2553d2b 100644 --- a/lib/token.js +++ b/lib/token.js @@ -6,6 +6,7 @@ var sdkCrypto = require('./crypto'); var AuthSdkError = require('./errors/AuthSdkError'); var OAuthError = require('./errors/OAuthError'); var config = require('./config.json'); +var cookies = require('./cookies'); function getWellKnown(sdk) { return http.get(sdk, sdk.options.url + '/.well-known/openid-configuration'); @@ -190,37 +191,37 @@ function addPostMessageListener(sdk, timeout) { }); } -function addFragmentListener(sdk, windowEl, timeout) { - var deferred = Q.defer(); - +function hashToObject(hash) { // Predefine regexs for parsing hash var plus2space = /\+/g; var paramSplit = /([^&=]+)=?([^&]*)/g; - function hashToObject(hash) { - // Remove the leading hash - var fragment = hash.substring(1); + // Remove the leading hash + var fragment = hash.substring(1); - var obj = {}; + var obj = {}; - // Loop until we have no more params - var param; - while (true) { // eslint-disable-line no-constant-condition - param = paramSplit.exec(fragment); - if (!param) { break; } + // Loop until we have no more params + var param; + while (true) { // eslint-disable-line no-constant-condition + param = paramSplit.exec(fragment); + if (!param) { break; } - var key = param[1]; - var value = param[2]; + var key = param[1]; + var value = param[2]; - // id_token should remain base64url encoded - if (key === 'id_token') { - obj[key] = value; - } else { - obj[key] = decodeURIComponent(value.replace(plus2space, ' ')); - } + // id_token should remain base64url encoded + if (key === 'id_token' || key === 'access_token') { + obj[key] = value; + } else { + obj[key] = decodeURIComponent(value.replace(plus2space, ' ')); } - return obj; } + return obj; +} + +function addFragmentListener(sdk, windowEl, timeout) { + var deferred = Q.defer(); function hashChangeHandler() { /* @@ -248,6 +249,128 @@ function addFragmentListener(sdk, windowEl, timeout) { return deferred.promise.timeout(timeout || 120000, new AuthSdkError('OAuth flow timed out')); } +function handleOAuthResponse(sdk, oauthParams, res) { + if (res['error'] || res['error_description']) { + throw new OAuthError(res['error'], res['error_description']); + } + + if (res.state !== oauthParams.state) { + throw new AuthSdkError('OAuth flow response state doesn\'t match request state'); + } + + var tokenTypes = oauthParams.responseType; + var scopes = util.clone(oauthParams.scope); + var tokenDict = {}; + + if (res['id_token']) { + var jwt = sdk.idToken.decode(res['id_token']); + if (jwt.payload.nonce !== oauthParams.nonce) { + throw new AuthSdkError('OAuth flow response nonce doesn\'t match request nonce'); + } + + validateClaims(jwt.payload, sdk.options.url, oauthParams.clientId); + + var idToken = { + idToken: res['id_token'], + claims: jwt.payload, + expiresAt: jwt.payload.exp, + scopes: scopes + }; + + if (Array.isArray(tokenTypes)) { + tokenDict['id_token'] = idToken; + } else { + return idToken; + } + } + + if (res['access_token']) { + var accessToken = { + accessToken: res['access_token'], + expiresAt: Number(res['expires_in']) + Math.floor(Date.now()/1000), + tokenType: res['token_type'], + scopes: scopes + }; + + if (Array.isArray(tokenTypes)) { + tokenDict['token'] = accessToken; + } else { + return accessToken; + } + } + + if (!tokenDict['token'] && !tokenDict['id_token']) { + throw new AuthSdkError('Unable to parse OAuth flow response'); + } + + var tokens = []; + + // Create token array in the order of the responseType array + for (var t = 0, tl = tokenTypes.length; t < tl; t++) { + var tokenType = tokenTypes[t]; + tokens.push(tokenDict[tokenType]); + } + + return tokens; +} + +function getDefaultOAuthParams(sdk, oauthOptions) { + oauthOptions = util.clone(oauthOptions) || {}; + var defaults = { + clientId: sdk.options.clientId, + redirectUri: sdk.options.redirectUri || window.location.href, + responseType: 'id_token', + responseMode: 'okta_post_message', + state: util.genRandomString(64), + nonce: util.genRandomString(64), + scope: ['openid', 'email'] + }; + util.extend(defaults, oauthOptions); + return defaults; +} + +function convertOAuthParamsToQueryParams(oauthParams) { + // Quick validation + if (!oauthParams.clientId) { + throw new AuthSdkError('A clientId must be specified in the OktaAuth constructor to get a token'); + } + + if (util.isString(oauthParams.responseType) && oauthParams.responseType.indexOf(' ') !== -1) { + throw new AuthSdkError('Multiple OAuth responseTypes must be defined as an array'); + } + + // Convert our params to their actual OAuth equivalents + var oauthQueryParams = util.removeNils({ + 'client_id': oauthParams.clientId, + 'redirect_uri': oauthParams.redirectUri, + 'response_type': oauthParams.responseType, + 'response_mode': oauthParams.responseMode, + 'state': oauthParams.state, + 'nonce': oauthParams.nonce, + 'prompt': oauthParams.prompt, + 'display': oauthParams.display, + 'sessionToken': oauthParams.sessionToken, + 'idp': oauthParams.idp + }); + + if (Array.isArray(oauthQueryParams['response_type'])) { + oauthQueryParams['response_type'] = oauthQueryParams['response_type'].join(' '); + } + + if (oauthParams.scope.indexOf('openid') !== -1) { + oauthQueryParams.scope = oauthParams.scope.join(' '); + } else { + throw new AuthSdkError('openid scope must be specified in the scope argument'); + } + + return oauthQueryParams; +} + +function buildAuthorizeUrl(sdk, oauthParams) { + var oauthQueryParams = convertOAuthParamsToQueryParams(oauthParams); + return sdk.options.url + '/oauth2/v1/authorize' + util.toQueryParams(oauthQueryParams); +} + /* * Retrieve an idToken from an Okta or a third party idp * @@ -308,18 +431,7 @@ function getIdToken(sdk, oauthOptions, options) { } // Default OAuth query params - var oauthParams = { - clientId: sdk.options.clientId, - redirectUri: sdk.options.redirectUri || window.location.href, - responseType: 'id_token', - responseMode: 'okta_post_message', - state: util.genRandomString(64), - nonce: util.genRandomString(64), - scope: ['openid', 'email'] - }; - - // Add user-provided options - util.extend(oauthParams, oauthOptions); + var oauthParams = getDefaultOAuthParams(sdk, oauthOptions); // Start overriding any options that don't make sense var sessionTokenOverrides = { @@ -338,41 +450,8 @@ function getIdToken(sdk, oauthOptions, options) { util.extend(oauthParams, idpOverrides); } - // Quick validation - if (!oauthParams.clientId) { - throw new AuthSdkError('A clientId must be specified in the OktaAuth constructor to get an idToken'); - } - - if (util.isString(oauthParams.responseType) && oauthParams.responseType.indexOf(' ') !== -1) { - throw new AuthSdkError('Multiple OAuth responseTypes must be defined as an array'); - } - - // Convert our params to their actual OAuth equivalents - var oauthQueryHash = util.removeNils({ - 'client_id': oauthParams.clientId, - 'redirect_uri': oauthParams.redirectUri, - 'response_type': oauthParams.responseType, - 'response_mode': oauthParams.responseMode, - 'state': oauthParams.state, - 'nonce': oauthParams.nonce, - 'prompt': oauthParams.prompt, - 'display': oauthParams.display, - 'sessionToken': oauthParams.sessionToken, - 'idp': oauthParams.idp - }); - - if (Array.isArray(oauthQueryHash['response_type'])) { - oauthQueryHash['response_type'] = oauthQueryHash['response_type'].join(' '); - } - - if (oauthParams.scope.indexOf('openid') !== -1) { - oauthQueryHash.scope = oauthParams.scope.join(' '); - } else { - throw new AuthSdkError('openid scope must be specified in the scope argument'); - } - // Use the query params to build the authorize url - var requestUrl = sdk.options.url + '/oauth2/v1/authorize' + util.toQueryParams(oauthQueryHash); + var requestUrl = buildAuthorizeUrl(sdk, oauthParams); // Determine the flow type var flowType; @@ -384,74 +463,6 @@ function getIdToken(sdk, oauthOptions, options) { flowType = 'IMPLICIT'; } - function handleOAuthResponse(res) { - if (res['error'] || res['error_description']) { - throw new OAuthError(res['error'], res['error_description']); - } - - if (res.state !== oauthParams.state) { - throw new AuthSdkError('OAuth flow response state doesn\'t match request state'); - } - - // If we're passed an array of tokenTypes, - // we return an array in the order specified. - // Otherwise, we return only the token requested - var tokenTypes = oauthParams.responseType; - var scopes = oauthQueryHash.scope.split(' '); - var tokenDict = {}; - - if (res['id_token']) { - var jwt = sdk.idToken.decode(res['id_token']); - if (jwt.payload.nonce !== oauthParams.nonce) { - throw new AuthSdkError('OAuth flow response nonce doesn\'t match request nonce'); - } - - validateClaims(jwt.payload, sdk.options.url, oauthParams.clientId); - - var idToken = { - idToken: res['id_token'], - claims: jwt.payload, - expiresAt: jwt.payload.exp, - scopes: scopes - }; - - if (Array.isArray(tokenTypes)) { - tokenDict['id_token'] = idToken; - } else { - return idToken; - } - } - - if (res['access_token']) { - var accessToken = { - accessToken: res['access_token'], - expiresAt: Number(res['expires_in']) + Math.floor(Date.now()/1000), - tokenType: res['token_type'], - scopes: scopes - }; - - if (Array.isArray(tokenTypes)) { - tokenDict['token'] = accessToken; - } else { - return accessToken; - } - } - - if (!tokenDict['token'] && !tokenDict['id_token']) { - throw new AuthSdkError('Unable to parse OAuth flow response'); - } - - var tokens = []; - - // Create token array in the order of the responseType array - for (var t = 0, tl = tokenTypes.length; t < tl; t++) { - var tokenType = tokenTypes[t]; - tokens.push(tokenDict[tokenType]); - } - - return tokens; - } - function getOrigin(url) { var originRegex = /^(https?\:\/\/)?([^:\/?#]*(?:\:[0-9]+)?)/; return originRegex.exec(url)[0]; @@ -463,7 +474,9 @@ function getIdToken(sdk, oauthOptions, options) { var iframePromise = addPostMessageListener(sdk, options.timeout); var iframeEl = loadFrame(requestUrl, config.FRAME_ID); return iframePromise - .then(handleOAuthResponse) + .then(function(res) { + return handleOAuthResponse(sdk, oauthParams, res); + }) .fin(function() { if (document.body.contains(iframeEl)) { iframeEl.parentElement.removeChild(iframeEl); @@ -520,7 +533,9 @@ function getIdToken(sdk, oauthOptions, options) { }); return popupDeferred.promise - .then(handleOAuthResponse) + .then(function(res) { + return handleOAuthResponse(sdk, oauthParams, res); + }) .fin(function() { if (!windowEl.closed) { clearInterval(closePoller); @@ -551,10 +566,53 @@ function getWithPopup(sdk, oauthOptions, options) { return getIdToken(sdk, oauthParams, options); } +function getWithRedirect(sdk, oauthOptions, options) { + oauthOptions = util.clone(oauthOptions) || {}; + var oauthParams = getDefaultOAuthParams(sdk, oauthOptions); + util.extend(oauthParams, { + responseMode: 'fragment' + }); + var requestUrl = buildAuthorizeUrl(sdk, oauthParams); + + // Set session cookie to store the oauthParams + cookies.setCookie(config.REDIRECT_OAUTH_PARAMS_COOKIE_NAME, JSON.stringify({ + state: oauthParams.state, + nonce: oauthParams.nonce, + scope: oauthParams.scope + })); + + sdk.token.getWithRedirect._setLocation(requestUrl); +} + +function parseFromUrl(sdk, url) { + var hash = window.location.hash; + if (url) { + hash = url.substring(url.indexOf('#')); + } + + var oauthParamsCookie = cookies.getCookie(config.REDIRECT_OAUTH_PARAMS_COOKIE_NAME); + if (!hash || !oauthParamsCookie) { + return Q.reject(new AuthSdkError('Unable to parse a token from the url')); + } + try { + var oauthParams = JSON.parse(oauthParamsCookie); + } catch(e) { + return Q.reject(new AuthSdkError('Unable to parse the ' + + config.REDIRECT_OAUTH_PARAMS_COOKIE_NAME + ' cookie: ' + e.message)); + } + + return Q.resolve(hashToObject(hash)) + .then(function(res) { + return handleOAuthResponse(sdk, oauthParams, res); + }); +} + module.exports = { getIdToken: getIdToken, getWithoutPrompt: getWithoutPrompt, getWithPopup: getWithPopup, + getWithRedirect: getWithRedirect, + parseFromUrl: parseFromUrl, refreshIdToken: refreshIdToken, decodeToken: decodeToken, verifyIdToken: verifyIdToken diff --git a/package.json b/package.json index 35718e9c86..3fbd372c55 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "okta-auth-js": { "STATE_TOKEN_COOKIE_NAME": "oktaStateToken", "DEFAULT_POLLING_DELAY": 500, - "FRAME_ID": "okta-oauth-helper-frame" + "FRAME_ID": "okta-oauth-helper-frame", + "REDIRECT_OAUTH_PARAMS_COOKIE_NAME": "okta-oauth-redirect-params" } } diff --git a/test/spec/oauth.js b/test/spec/oauth.js index 7c803c8929..f523ac3532 100644 --- a/test/spec/oauth.js +++ b/test/spec/oauth.js @@ -260,9 +260,9 @@ define(function(require) { }, { name: 'AuthSdkError', - message: 'A clientId must be specified in the OktaAuth constructor to get an idToken', + message: 'A clientId must be specified in the OktaAuth constructor to get a token', errorCode: 'INTERNAL', - errorSummary: 'A clientId must be specified in the OktaAuth constructor to get an idToken', + errorSummary: 'A clientId must be specified in the OktaAuth constructor to get a token', errorLink: 'INTERNAL', errorId: 'INTERNAL', errorCauses: [] diff --git a/test/spec/token.js b/test/spec/token.js index 938140778a..efba64f999 100644 --- a/test/spec/token.js +++ b/test/spec/token.js @@ -372,7 +372,7 @@ define(function(require) { 'expires_in': 3600, 'state': oauthUtil.mockedState }, - expectedResp: [{ + expectedResp: [{ accessToken: tokens.standardAccessToken, expiresAt: 1449703529, scopes: ['openid', 'email'], @@ -422,7 +422,7 @@ define(function(require) { 'expires_in': 3600, 'state': oauthUtil.mockedState }, - expectedResp: [{ + expectedResp: [{ idToken: tokens.standardIdToken, claims: tokens.standardIdTokenClaims, expiresAt: 1449699930, @@ -439,4 +439,85 @@ define(function(require) { }); }); }); + + describe('token.getWithRedirect', function() { + it('sets authorize url and cookie for id_token using sessionToken', function() { + oauthUtil.setupRedirect({ + getWithRedirectArgs: { + sessionToken: 'testToken' + }, + expectedRedirectUrl: 'https://lboyette.trexcloud.com/oauth2/v1/authorize?' + + 'client_id=NPSfOkH5eZrTy8PMDlvx&' + + 'redirect_uri=https%3A%2F%2Flboyette.trexcloud.com%2Fredirect&' + + 'response_type=id_token&' + + 'response_mode=fragment&' + + 'state=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&' + + 'nonce=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&' + + 'sessionToken=testToken&' + + 'scope=openid%20email' + }); + }); + + it('sets authorize url for access_token using sessionToken', function() { + oauthUtil.setupRedirect({ + getWithRedirectArgs: { + responseType: 'token', + sessionToken: 'testToken' + }, + expectedRedirectUrl: 'https://lboyette.trexcloud.com/oauth2/v1/authorize?' + + 'client_id=NPSfOkH5eZrTy8PMDlvx&' + + 'redirect_uri=https%3A%2F%2Flboyette.trexcloud.com%2Fredirect&' + + 'response_type=token&' + + 'response_mode=fragment&' + + 'state=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&' + + 'nonce=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&' + + 'sessionToken=testToken&' + + 'scope=openid%20email' + }); + }); + + it('sets authorize url for access_token and id_token using idp', function() { + oauthUtil.setupRedirect({ + getWithRedirectArgs: { + responseType: ['token', 'id_token'], + idp: 'testIdp' + }, + expectedRedirectUrl: 'https://lboyette.trexcloud.com/oauth2/v1/authorize?' + + 'client_id=NPSfOkH5eZrTy8PMDlvx&' + + 'redirect_uri=https%3A%2F%2Flboyette.trexcloud.com%2Fredirect&' + + 'response_type=token%20id_token&' + + 'response_mode=fragment&' + + 'state=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&' + + 'nonce=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&' + + 'idp=testIdp&' + + 'scope=openid%20email' + }); + }); + }); + + describe('token.parseFromUrl', function() { + xit('defaults to window.location.href', function() { + oauthUtil.setupParseUrl({ + hrefMock: 'https://lboyette.trexcloud.com#' + + 'id_token=' + tokens.standardIdToken + + '&state=' + oauthUtil.mockedState, + expectedResp: { + idToken: tokens.standardIdToken, + claims: tokens.standardIdTokenClaims, + expiresAt: 1449699930, + scopes: ['openid', 'email'] + } + }); + }); + + it('parses id_token'); + + it('parses access_token'); + + it('parses access_token and id_token'); + + it('throws an error if nothing to parse'); + + it('throws an error if no cookie set'); + }); }); diff --git a/test/util/oauthUtil.js b/test/util/oauthUtil.js index 3d017a32ec..e13b843183 100644 --- a/test/util/oauthUtil.js +++ b/test/util/oauthUtil.js @@ -73,7 +73,7 @@ define(function(require) { util.warpToUnixTime(time); if (opts.hrefMock) { - util.mockWindowLocationHref(authClient, opts.hrefMock); + util.mockGetWindowLocation(authClient, opts.hrefMock); } var promise; @@ -207,6 +207,19 @@ define(function(require) { } }); }; + + oauthUtil.setupRedirect = function(opts) { + var client = new OktaAuth({ + url: 'https://lboyette.trexcloud.com', + clientId: 'NPSfOkH5eZrTy8PMDlvx', + redirectUri: 'https://lboyette.trexcloud.com/redirect' + }); + + oauthUtil.mockStateAndNonce(); + var windowLocationMock = util.mockSetWindowLocation(client); + client.token.getWithRedirect(opts.getWithRedirectArgs); + expect(windowLocationMock).toHaveBeenCalledWith(opts.expectedRedirectUrl); + }; function expectErrorToEqual(actual, expected) { expect(actual.name).toEqual(expected.name); diff --git a/test/util/util.js b/test/util/util.js index e61a3252c4..438427aa3a 100644 --- a/test/util/util.js +++ b/test/util/util.js @@ -314,9 +314,13 @@ define(function(require) { }); }; - util.mockWindowLocationHref = function (client, href) { + util.mockGetWindowLocation = function (client, href) { spyOn(client.idToken.authorize, '_getLocationHref').and.returnValue(href); }; + util.mockSetWindowLocation = function (client) { + return spyOn(client.token.getWithRedirect, '_setLocation'); + }; + return util; }); diff --git a/webpack.config.js b/webpack.config.js index 8c4a7cf10b..0dd9be912c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -25,7 +25,7 @@ module.exports = _.extend(commonConfig, { compress: { warnings: false }, - sourceMap: false, + sourceMap: true, comments: function(node, comment) { // Remove other Okta copyrights var isLicense = /^!/.test(comment.value); @@ -36,5 +36,6 @@ module.exports = _.extend(commonConfig, { // Add a single Okta license after removing others new webpack.BannerPlugin(license) - ] + ], + devtool: 'source-map' });