You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ja...@apache.org on 2023/09/13 22:30:55 UTC
[solr] branch branch_9x updated: SOLR-16896: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (front-end) (#1791)
This is an automated email from the ASF dual-hosted git repository.
janhoy pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/branch_9x by this push:
new 2271b373f00 SOLR-16896: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (front-end) (#1791)
2271b373f00 is described below
commit 2271b373f0049ffca5c7a6d23515a27a271b9274
Author: Lamine <10...@users.noreply.github.com>
AuthorDate: Thu Sep 14 00:04:22 2023 +0200
SOLR-16896: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (front-end) (#1791)
Co-authored-by: Lamine Idjeraoui <li...@apple.com>
Co-authored-by: Jan Høydahl <ja...@users.noreply.github.com>
(cherry picked from commit 086dcbe960ff6cd8f604e5b988972f8cfc1ef0b8)
---
NOTICE.txt | 7 +
solr/CHANGES.txt | 2 +-
solr/webapp/web/index.html | 1 +
solr/webapp/web/js/angular/controllers/login.js | 267 ++++++++++++++++--------
solr/webapp/web/js/angular/services.js | 78 ++++++-
solr/webapp/web/libs/jssha-3.3.1-sha256.min.js | 24 +++
6 files changed, 291 insertions(+), 88 deletions(-)
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 @@ limitations under the License.
<script src="libs/ui-grid.min.js?_=${version}"></script>
<script src="libs/jquery-ui.min.js?_=${version}"></script>
<script src="libs/angular-utf8-base64.min.js?_=${version}"></script>
+ <script src="libs/jssha-3.3.1-sha256.min.js?_=${version}"></script>
<script src="js/angular/app.js?_=${version}"></script>
<script src="js/angular/services.js?_=${version}"></script>
<script src="js/angular/permissions.js?_=${version}"></script>
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="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01 [...]
\ No newline at end of file