From 2271b373f0049ffca5c7a6d23515a27a271b9274 Mon Sep 17 00:00:00 2001 From: Lamine <104593675+laminelam@users.noreply.github.com> Date: Thu, 14 Sep 2023 00:04:22 +0200 Subject: [PATCH] SOLR-16896: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (front-end) (#1791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lamine Idjeraoui Co-authored-by: Jan Høydahl (cherry picked from commit 086dcbe960ff6cd8f604e5b988972f8cfc1ef0b8) --- NOTICE.txt | 7 + solr/CHANGES.txt | 2 +- solr/webapp/web/index.html | 1 + .../web/js/angular/controllers/login.js | 267 ++++++++++++------ solr/webapp/web/js/angular/services.js | 78 ++++- .../webapp/web/libs/jssha-3.3.1-sha256.min.js | 24 ++ 6 files changed, 291 insertions(+), 88 deletions(-) create mode 100644 solr/webapp/web/libs/jssha-3.3.1-sha256.min.js diff --git a/NOTICE.txt b/NOTICE.txt index 396db11e6cb..0fa2229e28a 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -618,6 +618,13 @@ https://github.com/yonik/noggit This product includes the Angular UI UI Grid JavaScript library. Copyright (c) 2015 the AngularUI Team, http://angular-ui.github.com +========================================================================= +== jsSHA notice == +========================================================================= + +This product includes the jsSHA library. +Copyright (c) 2008-2023 Brian Turek, 1998-2009 Paul Johnston & Contributors +https://github.com/Caligatio/jsSHA ========================================================================= == grpc notice == diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt index 09679c9a0c1..21055518960 100644 --- a/solr/CHANGES.txt +++ b/solr/CHANGES.txt @@ -51,7 +51,7 @@ Improvements * SOLR-15474: Make Circuit breakers individually pluggable (Atri Sharma, Christine Poerschke, janhoy) -* SOLR-16896, SOLR-16897: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (Lamine Idjeraoui, janhoy, Kevin Risden) +* SOLR-16896, SOLR-16897: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (Lamine Idjeraoui, janhoy, Kevin Risden, Anshum Gupta) * SOLR-16879: Limit the number of concurrent expensive core admin operations by running them in a dedicated thread pool. Backup, Restore and Split are expensive operations. diff --git a/solr/webapp/web/index.html b/solr/webapp/web/index.html index afffe67f764..35b1d664860 100644 --- a/solr/webapp/web/index.html +++ b/solr/webapp/web/index.html @@ -70,6 +70,7 @@ + diff --git a/solr/webapp/web/js/angular/controllers/login.js b/solr/webapp/web/js/angular/controllers/login.js index b76ec1f4a8a..0e22100c306 100644 --- a/solr/webapp/web/js/angular/controllers/login.js +++ b/solr/webapp/web/js/angular/controllers/login.js @@ -60,92 +60,166 @@ solrAdminApp.controller('LoginController', var hp = AuthenticationService.decodeHashParams(hash); var expectedState = sessionStorage.getItem("auth.stateRandom") + "_" + sessionStorage.getItem("auth.location"); sessionStorage.setItem("auth.state", "error"); - if (hp['access_token'] && hp['token_type'] && hp['state']) { - // Validate state - if (hp['state'] !== expectedState) { - $scope.error = "Problem with auth callback"; - console.log("Expected state param " + expectedState + " but got " + hp['state']); - errorText += "Invalid values in state parameter. "; - } - // Validate token type - if (hp['token_type'].toLowerCase() !== "bearer") { - console.log("Expected token_type param 'bearer', but got " + hp['token_type']); - errorText += "Invalid values in token_type parameter. "; - } - // Unpack ID token and validate nonce, get username - if (hp['id_token']) { - var idToken = hp['id_token'].split("."); - if (idToken.length === 3) { - var payload = AuthenticationService.decodeJwtPart(idToken[1]); - if (!payload['nonce'] || payload['nonce'] !== sessionStorage.getItem("auth.nonce")) { - errorText += "Invalid 'nonce' value, possible attack detected. Please log in again. "; - } + $scope.authData = AuthenticationService.getAuthDataHeader(); + if (!validateState(hp['state'], expectedState)) { + $scope.error = "Problems with OpenID callback"; + $scope.errorDescription = errorText; + $scope.http401 = "true"; + sessionStorage.setItem("auth.state", "error"); + } + else { + var flow = $scope.authData ? $scope.authData['authorization_flow'] : undefined; + console.log("Callback: authorization_flow : " +flow); + var isCodePKCE = flow == 'code_pkce'; + if (isCodePKCE) { + // code flow with PKCE + var code = hp['code']; + var tokenEndpoint = $scope.authData['tokenEndpoint']; + // concurrent Solr API calls will trigger 401 and erase session's "auth.realm" in app.js + // save it before it's erased + var authRealm = sessionStorage.getItem("auth.realm"); + + var data = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': $window.location.href.split('#')[0], + 'scope': "openid " + $scope.authData['scope'], + 'code_verifier': sessionStorage.getItem('codeVerifier'), + "client_id": $scope.authData['client_id'] + }; - if (errorText === "") { - sessionStorage.setItem("auth.username", payload['sub']); - sessionStorage.setItem("auth.header", "Bearer " + hp['access_token']); - sessionStorage.removeItem("auth.statusText"); - sessionStorage.removeItem("auth.stateRandom"); - sessionStorage.removeItem("auth.wwwAuthHeader"); - console.log("User " + payload['sub'] + " is logged in"); - var redirectTo = sessionStorage.getItem("auth.location"); - console.log("Redirecting to stored location " + redirectTo); - sessionStorage.setItem("auth.state", "authenticated"); - sessionStorage.removeItem("http401"); - $location.path(redirectTo).hash(""); + console.debug(`Callback. Got code: ${code} \nCalling token endpoint:: ${tokenEndpoint} `); + AuthenticationService.getOAuthTokens(tokenEndpoint, data).then(function(response) { + var accessToken = response.access_token; + var idToken = response.id_token; + var tokenType = response.access_type; + sessionStorage.setItem("auth.realm", authRealm); + processTokensResponse(accessToken, idToken, tokenType, expectedState, hp); + }).catch(function (error) { + errorText += "Error calling token endpoint. "; + $scope.error = "Problems with OpenID callback"; + $scope.errorDescription = errorText; + $scope.http401 = "true"; + sessionStorage.setItem("auth.state", "error"); + if (error && error.data) { + console.error("Error getting tokens: " + JSON.stringify(error.data)); + } else { + console.error("Error getting tokens: " + error); } - } else { - console.log("Expected JWT compact id_token param but got " + idToken); - errorText += "Invalid values in id_token parameter. "; - } - } else { - console.log("Callback was missing the id_token parameter, could not validate nonce."); - errorText += "Callback was missing the id_token parameter, could not validate nonce. "; - } - if (hp['access_token'].split(".").length !== 3) { - console.log("Expected JWT compact access_token param but got " + hp['access_token']); - errorText += "Invalid values in access_token parameter. "; - } - if (errorText !== "") { - $scope.error = "Problems with OpenID callback"; - $scope.errorDescription = errorText; - $scope.http401 = "true"; + }); } - // End callback processing - } else if (hp['error']) { - // The callback had errors - console.log("Error received from idp: " + hp['error']); - var errorDescriptions = {}; - errorDescriptions['invalid_request'] = "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed."; - errorDescriptions['unauthorized_client'] = "The client is not authorized to request an access token using this method."; - errorDescriptions['access_denied'] = "The resource owner or authorization server denied the request."; - errorDescriptions['unsupported_response_type'] = "The authorization server does not support obtaining an access token using this method."; - errorDescriptions['invalid_scope'] = "The requested scope is invalid, unknown, or malformed."; - errorDescriptions['server_error'] = "The authorization server encountered an unexpected condition that prevented it from fulfilling the request."; - errorDescriptions['temporarily_unavailable'] = "The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server."; - $scope.error = "Callback from Id Provider contained error. "; - if (hp['error_description']) { - $scope.errorDescription = decodeURIComponent(hp['error_description']); - } else { - $scope.errorDescription = errorDescriptions[hp['error']]; + else { + // implicit flow + processTokensResponse(hp['access_token'], hp['id_token'], hp['token_type'], expectedState, hp); } - if (hp['error_uri']) { - $scope.errorDescription += " More information at " + hp['error_uri'] + ". "; + } + } + } + + function validateState(state, expectedState) { + if (state !== expectedState) { + $scope.error = "Problem with auth callback"; + console.error("Expected state param " + expectedState + " but got " + state); + errorText += "Invalid values in state parameter. "; + return false; + } + return true; + } + + function processTokensResponse(accessToken, idToken, tokenType, expectedState, hp) { + if (accessToken && hp['state']) { + // Validate token type. + if (!tokenType) { + //Assume the type is 'bearer' if it's not returned. Most IdProviders support 'bearer' by default but don't always return the type. + tokenType = "bearer"; + } + else if(tokenType.toLowerCase() !== "bearer") { + console.error("Expected token_type param 'bearer', but got " + tokenType); + errorText += "Invalid values in token_type parameter. "; + } + // Unpack ID token and validate nonce, get username + if (idToken) { + var idTokenArray = idToken.split("."); + if (idTokenArray.length === 3) { + var payload = AuthenticationService.decodeJwtPart(idTokenArray[1]); + if (!payload['nonce'] || payload['nonce'] !== sessionStorage.getItem("auth.nonce")) { + errorText += "Invalid 'nonce' value, possible attack detected. Please log in again. "; } - if (hp['state'] !== expectedState) { - $scope.errorDescription += "The state parameter returned from ID Provider did not match the one we sent."; + + if (errorText === "") { + sessionStorage.setItem("auth.username", payload['sub']); + sessionStorage.setItem("auth.header", "Bearer " + accessToken); + sessionStorage.removeItem("auth.statusText"); + sessionStorage.removeItem("auth.stateRandom"); + sessionStorage.removeItem("auth.wwwAuthHeader"); + console.log("User " + payload['sub'] + " is logged in"); + var redirectTo = sessionStorage.getItem("auth.location"); + console.log("Redirecting to stored location " + redirectTo); + sessionStorage.setItem("auth.state", "authenticated"); + sessionStorage.removeItem("http401"); + sessionStorage.setItem("auth.scheme", "Bearer"); + $location.path(redirectTo).hash(""); } - sessionStorage.setItem("auth.state", "error"); + } else { + console.error("Expected JWT compact id_token param but got " + idTokenArray); + errorText += "Invalid values in id_token parameter. "; } + } else { + console.error("Callback was missing the id_token parameter, could not validate nonce."); + errorText += "Callback was missing the id_token parameter, could not validate nonce. "; + } + if (accessToken.split(".").length !== 3) { + console.error("Expected JWT compact access_token param but got " + accessToken); + errorText += "Invalid values in access_token parameter. "; + } + if (errorText !== "") { + $scope.error = "Problems with OpenID callback"; + $scope.errorDescription = errorText; + $scope.http401 = "true"; + } + // End callback processing + } else if (hp['error']) { + // The callback had errors + console.error("Error received from idp: " + hp['error']); + var errorDescriptions = {}; + errorDescriptions['invalid_request'] = "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed."; + errorDescriptions['unauthorized_client'] = "The client is not authorized to request an access token using this method."; + errorDescriptions['access_denied'] = "The resource owner or authorization server denied the request."; + errorDescriptions['unsupported_response_type'] = "The authorization server does not support obtaining an access token using this method."; + errorDescriptions['invalid_scope'] = "The requested scope is invalid, unknown, or malformed."; + errorDescriptions['server_error'] = "The authorization server encountered an unexpected condition that prevented it from fulfilling the request."; + errorDescriptions['temporarily_unavailable'] = "The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server."; + $scope.error = "Callback from Id Provider contained error. "; + if (hp['error_description']) { + $scope.errorDescription = decodeURIComponent(hp['error_description']); + } else { + $scope.errorDescription = errorDescriptions[hp['error']]; + } + if (hp['error_uri']) { + $scope.errorDescription += " More information at " + hp['error_uri'] + ". "; } + if (hp['state'] !== expectedState) { + $scope.errorDescription += "The state parameter returned from ID Provider did not match the one we sent."; + } + sessionStorage.setItem("auth.state", "error"); + } + else{ + console.error(`Invalid data received from idp: accessToken: ${accessToken}, + idToken: ${idToken}, state: ${hp['state']}`); + errorText += "Invalid data received from the OpenID provider. "; + $scope.http401 = "true"; + $scope.error = "Problems with OpenID callback."; + $scope.errorDescription = errorText; + sessionStorage.setItem("auth.state", "error"); } + } if (errorText === "" && !$scope.error && authParams) { $scope.error = authParams['error']; $scope.errorDescription = authParams['error_description']; $scope.authData = AuthenticationService.getAuthDataHeader(); } - + $scope.authScheme = sessionStorage.getItem("auth.scheme"); $scope.authRealm = sessionStorage.getItem("auth.realm"); $scope.wwwAuthHeader = sessionStorage.getItem("auth.wwwAuthHeader"); @@ -165,20 +239,49 @@ solrAdminApp.controller('LoginController', $location.path("/"); }; - $scope.jwtLogin = function () { + $scope.jwtLogin = async function () { var stateRandom = Math.random().toString(36).substr(2); sessionStorage.setItem("auth.stateRandom", stateRandom); var authState = stateRandom + "_" + sessionStorage.getItem("auth.location"); var authNonce = Math.random().toString(36).substr(2) + Math.random().toString(36).substr(2) + Math.random().toString(36).substr(2); sessionStorage.setItem("auth.nonce", authNonce); - var params = { - "response_type" : "id_token token", - "client_id" : $scope.authData['client_id'], - "redirect_uri" : $window.location.href.split('#')[0], - "scope" : "openid " + $scope.authData['scope'], - "state" : authState, - "nonce" : authNonce - }; + var authData = AuthenticationService.getAuthDataHeader(); + var flow = authData ? authData['authorization_flow'] : "implicit"; + console.log("jwtLogin flow: "+ flow); + var isCodePKCE = flow == 'code_pkce'; + + var params = {}; + if (isCodePKCE) { + console.debug("Login with 'Code PKCE' flow"); + var codeVerifier = AuthenticationService.generateCodeVerifier(); + var code_challenge = await AuthenticationService.generateCodeChallengeFromVerifier(codeVerifier); + var codeChallengeMethod = AuthenticationService.getCodeChallengeMethod(); + sessionStorage.setItem('codeVerifier', codeVerifier); + params = { + "response_type": "code", + "client_id": $scope.authData['client_id'], + "redirect_uri": $window.location.href.split('#')[0], + "scope": "openid " + $scope.authData['scope'], + "state": authState, + "nonce": authNonce, + "response_mode": "fragment", + "code_challenge": code_challenge, + "code_challenge_method": codeChallengeMethod + }; + } + else { + console.debug("Login with 'Implicit' flow"); + params = { + "response_type": "id_token token", + "client_id": $scope.authData['client_id'], + "redirect_uri": $window.location.href.split('#')[0], + "scope": "openid " + $scope.authData['scope'], + "state": authState, + "nonce": authNonce, + "response_mode": 'fragment', + "grant_type": 'implicit' + }; + } var endpointBaseUrl = $scope.authData['authorizationEndpoint']; var loc = endpointBaseUrl + "?" + paramsToString(params); @@ -191,7 +294,7 @@ solrAdminApp.controller('LoginController', for (var p in params) { if( params.hasOwnProperty(p) ) { arr.push(p + "=" + encodeURIComponent(params[p])); - } + } } return arr.join("&"); } @@ -204,7 +307,7 @@ solrAdminApp.controller('LoginController', redirect.forEach(function(uri) { // Check that current node URL is among the configured callback URIs if ($window.location.href.startsWith(uri)) isLoginNode = true; }); - return isLoginNode; + return isLoginNode; } else { return true; // no redirect UIRs configured, all nodes are potential login nodes } diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 1c0b702dbe1..2b870ab53ff 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -286,8 +286,75 @@ solrAdminServices.factory('System', }) }]) .factory('AuthenticationService', - ['base64', function (base64) { - var service = {}; + ['base64', '$resource', function (base64, $resource) { + var service = {}; + + service.getOAuthTokens = function (url, data) { + var serializedData = serialize(data); + var resource = $resource(url, {}, { + getToken: { + method: 'POST', + timeout: 10000, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': undefined // Set this header to undefined to prevent preflight requests + }, + transformResponse: function (data) { + return angular.fromJson(data); + } + } + }); + return resource.getToken({}, serializedData).$promise; + }; + + var codeChallengeMethod = "S256"; + service.getCodeChallengeMethod = function getCodeChallengeMethod() { + return codeChallengeMethod; + } + + service.generateCodeVerifier = function generateCodeVerifier() { + var codeVerifier = ''; + var possibleChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + for (var i = 0; i < 96; i++) { + codeVerifier += possibleChars.charAt(Math.floor(Math.random() * possibleChars.length)); + } + return codeVerifier; + } + + service.generateCodeChallengeFromVerifier = async function generateCodeChallengeFromVerifier(v) { + var hashed = await sha256(v); + var base64encoded = base64urlencode(hashed); + return base64encoded; + } + + function sha256(str) { + const shaObj = new jsSHA("SHA-256", "TEXT", { encoding: "UTF8" }); + shaObj.update(str); + return shaObj.getHash("UINT8ARRAY"); + } + + function base64urlencode(a) { + var str = ""; + var bytes = new Uint8Array(a); + var len = bytes.byteLength; + for (var i = 0; i < len; i++) { + str += String.fromCharCode(bytes[i]); + } + return btoa(str) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + } + + var serialize = function (obj) { + var str = []; + for (var p in obj) { + if (obj.hasOwnProperty(p)) { + str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p])); + } + } + return str.join("&"); + }; service.SetCredentials = function (username, password) { var authdata = base64.encode(username + ':' + password); @@ -305,6 +372,7 @@ solrAdminServices.factory('System', sessionStorage.removeItem("auth.statusText"); localStorage.removeItem("auth.stateRandom"); sessionStorage.removeItem("auth.nonce"); + sessionStorage.removeItem("auth.flow"); }; service.getAuthDataHeader = function () { @@ -330,11 +398,11 @@ solrAdminServices.factory('System', service.isJwtCallback = function (hash) { var hp = this.decodeHashParams(hash); // console.log("Decoded hash as " + JSON.stringify(hp, undefined, 2)); // For debugging callbacks - return (hp['access_token'] && hp['token_type'] && hp['state']) || hp['error']; + return (hp['access_token'] && hp['token_type'] && hp['state']) || (hp['code'] && hp['state'])|| hp['error']; }; - + service.decodeHashParams = function(hash) { - // access_token, token_type, expires_in, state + // access_token, token_type, expires_in, state, code if (hash == null || hash.length === 0) { return {}; } diff --git a/solr/webapp/web/libs/jssha-3.3.1-sha256.min.js b/solr/webapp/web/libs/jssha-3.3.1-sha256.min.js new file mode 100644 index 00000000000..697ee447f4c --- /dev/null +++ b/solr/webapp/web/libs/jssha-3.3.1-sha256.min.js @@ -0,0 +1,24 @@ +/* + + * A JavaScript implementation of the SHA family of hashes - defined in FIPS PUB 180-4, FIPS PUB 202, + * and SP 800-185 - as well as the corresponding HMAC implementation as defined in FIPS PUB 198-1. + * + * Copyright 2008-2023 Brian Turek, 1998-2009 Paul Johnston & Contributors + * Distributed under the BSD License + * See http://caligatio.github.com/jsSHA/ for more information + * + * Two ECMAScript polyfill functions carry the following license: + * + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING WITHOUT LIMITATION ANY IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, + * MERCHANTABLITY OR NON-INFRINGEMENT. + * + * See the Apache Version 2.0 License for specific language governing permissions and limitations under the License. + + */ + +!function(t,r){"object"==typeof exports&&"undefined"!=typeof module?module.exports=r():"function"==typeof define&&define.amd?define(r):(t="undefined"!=typeof globalThis?globalThis:t||self).jsSHA=r()}(this,function(){"use strict";var n=function(t,r){return(n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,r){t.__proto__=r}||function(t,r){for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(t[n]=r[n])})(t,r)},v="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",r="ARRAYBUFFER not supported by this environment",e="UINT8ARRAY not supported by this environment";function o(t,r,n,e){for(var o,i,s=r||[0],u=(n=n||0)>>>3,h=-1===e?3:0,a=0;a>>2,s.length<=o&&s.push(0),s[o]|=t[a]<<8*(h+e*(i%4));return{value:s,binLen:8*t.length+n}}function i(t,A,g){switch(A){case"UTF8":case"UTF16BE":case"UTF16LE":break;default:throw new Error("encoding must be UTF8, UTF16BE, or UTF16LE")}switch(t){case"HEX":return function(t,r,n){var e,o,i,s=t,t=r,r=n,u=g;if(0!=s.length%2)throw new Error("String of HEX type must be in byte increments");for(var h=t||[0],a=(r=r||0)>>>3,f=-1===u?3:0,c=0;c>>1)+a)>>>2;h.length<=o;)h.push(0);h[o]|=e<<8*(f+u*(i%4))}return{value:h,binLen:4*s.length+r}};case"TEXT":return function(t,r,n){var e,o,i,s,u,h,a,f,c=t,t=A,p=0,l=g,w=0,d=r||[0],v=(p=n||0)>>>3;if("UTF8"===t)for(a=-1===l?3:0,i=0;i>>6),o.push(128|63&e)):e<55296||57344<=e?o.push(224|e>>>12,128|e>>>6&63,128|63&e):(e=65536+((1023&e)<<10|1023&c.charCodeAt(i+=1)),o.push(240|e>>>18,128|e>>>12&63,128|e>>>6&63,128|63&e)),s=0;s>>2;d.length<=u;)d.push(0);d[u]|=o[s]<<8*(a+l*(h%4)),w+=1}else for(a=-1===l?2:0,f="UTF16LE"===t&&1!==l||"UTF16LE"!==t&&1===l,i=0;i>>8),u=(h=w+v)>>>2;d.length<=u;)d.push(0);d[u]|=e<<8*(a+l*(h%4)),w+=2}return{value:d,binLen:8*w+p}};case"B64":return function(t,r,n){var e,o,i,s,u,h,a=t,t=0,f=g,c=0,p=r||[0],l=(t=n||0)>>>3,w=-1===f?3:0,r=a.indexOf("=");if(-1===a.search(/^[a-zA-Z0-9=+/]+$/))throw new Error("Invalid character in base-64 string");if(a=a.replace(/=/g,""),-1!==r&&r