Refactoring JavaScript code of WebAuthn's authenticators to follow the current Keycloak's JavaScript coding convention

closes #26713

Signed-off-by: Takashi Norimatsu <takashi.norimatsu.ws@hitachi.com>
This commit is contained in:
Takashi Norimatsu 2024-02-22 22:50:41 +09:00 committed by Alexander Schwartz
parent 269027571a
commit d5bf79b932
11 changed files with 289 additions and 361 deletions

View file

@ -20,6 +20,7 @@ package org.keycloak.testsuite.webauthn.pages;
import org.jboss.arquillian.graphene.page.Page;
import org.keycloak.testsuite.pages.LanguageComboboxAwarePage;
import org.keycloak.testsuite.util.WaitUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
@ -48,7 +49,7 @@ public class WebAuthnLoginPage extends LanguageComboboxAwarePage {
public boolean isCurrent() {
try {
authenticateButton.getText();
return driver.getPageSource().contains("navigator.credentials.get");
return driver.findElement(By.id("authenticateWebAuthnButton")).isDisplayed();
} catch (NoSuchElementException e) {
return false;
}

View file

@ -21,6 +21,7 @@ import org.hamcrest.CoreMatchers;
import org.keycloak.testsuite.pages.LogoutSessionsPage;
import org.keycloak.testsuite.util.WaitUtils;
import org.openqa.selenium.Alert;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.TimeoutException;
import org.openqa.selenium.WebElement;
@ -85,7 +86,7 @@ public class WebAuthnRegisterPage extends LogoutSessionsPage {
// label edit after registering authenticator by .create()
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(seconds));
Alert promptDialog = wait.until(ExpectedConditions.alertIsPresent());
assertThat(promptDialog.getText(), CoreMatchers.is("Please input your registered authenticator's label"));
assertThat(promptDialog.getText(), CoreMatchers.is("Please input your registered passkey's label"));
return true;
} catch (TimeoutException e) {
return false;
@ -115,7 +116,7 @@ public class WebAuthnRegisterPage extends LogoutSessionsPage {
public boolean isCurrent() {
final String formTitle = getFormTitle();
return formTitle != null && formTitle.equals("Passkey Registration") &&
driver.getPageSource().contains("navigator.credentials.create");
driver.findElement(By.id("registerWebAuthn")).isDisplayed();
}
@Override

View file

@ -485,6 +485,9 @@ webauthn-available-authenticators=Available Passkeys
webauthn-unsupported-browser-text=WebAuthn is not supported by this browser. Try another one or contact your administrator.
webauthn-doAuthenticate=Sign in with Passkey
webauthn-createdAt-label=Created
webauthn-registration-init-label=Passkey (Default Label)
webauthn-registration-init-label-prompt=Please input your registered passkey''s label
# WebAuthn Error
webauthn-error-title=Passkey Error

View file

@ -1,114 +0,0 @@
// for embedded scripts, quoted and modified from https://github.com/swansontec/rfc4648.js by William Swanson
'use strict';
var base64url = base64url || {};
(function(base64url) {
function parse (string, encoding, opts = {}) {
// Build the character lookup table:
if (!encoding.codes) {
encoding.codes = {};
for (let i = 0; i < encoding.chars.length; ++i) {
encoding.codes[encoding.chars[i]] = i;
}
}
// The string must have a whole number of bytes:
if (!opts.loose && (string.length * encoding.bits) & 7) {
throw new SyntaxError('Invalid padding');
}
// Count the padding bytes:
let end = string.length;
while (string[end - 1] === '=') {
--end;
// If we get a whole number of bytes, there is too much padding:
if (!opts.loose && !(((string.length - end) * encoding.bits) & 7)) {
throw new SyntaxError('Invalid padding');
}
}
// Allocate the output:
const out = new (opts.out || Uint8Array)(((end * encoding.bits) / 8) | 0);
// Parse the data:
let bits = 0; // Number of bits currently in the buffer
let buffer = 0; // Bits waiting to be written out, MSB first
let written = 0; // Next byte to write
for (let i = 0; i < end; ++i) {
// Read one character from the string:
const value = encoding.codes[string[i]];
if (value === void 0) {
throw new SyntaxError('Invalid character ' + string[i]);
}
// Append the bits to the buffer:
buffer = (buffer << encoding.bits) | value;
bits += encoding.bits;
// Write out some bits if the buffer has a byte's worth:
if (bits >= 8) {
bits -= 8;
out[written++] = 0xff & (buffer >> bits);
}
}
// Verify that we have received just enough bits:
if (bits >= encoding.bits || 0xff & (buffer << (8 - bits))) {
throw new SyntaxError('Unexpected end of data');
}
return out
}
function stringify (data, encoding, opts = {}) {
const { pad = true } = opts;
const mask = (1 << encoding.bits) - 1;
let out = '';
let bits = 0; // Number of bits currently in the buffer
let buffer = 0; // Bits waiting to be written out, MSB first
for (let i = 0; i < data.length; ++i) {
// Slurp data into the buffer:
buffer = (buffer << 8) | (0xff & data[i]);
bits += 8;
// Write out as much as we can:
while (bits > encoding.bits) {
bits -= encoding.bits;
out += encoding.chars[mask & (buffer >> bits)];
}
}
// Partial character:
if (bits) {
out += encoding.chars[mask & (buffer << (encoding.bits - bits))];
}
// Add padding characters until we hit a byte boundary:
if (pad) {
while ((out.length * encoding.bits) & 7) {
out += '=';
}
}
return out
}
const encoding = {
chars: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
bits: 6
}
base64url.decode = function (string, opts) {
return parse(string, encoding, opts);
}
base64url.encode = function (data, opts) {
return stringify(data, encoding, opts)
}
return base64url;
}(base64url));

View file

@ -0,0 +1,82 @@
import { base64url } from "rfc4648";
export async function authenticateByWebAuthn(input) {
if (!input.isUserIdentified) {
try {
const result = await doAuthenticate([], input.challenge, input.userVerification, input.rpId, input.createTimeout, input.errmsg);
returnSuccess(result);
} catch (error) {
returnFailure(error);
}
return;
}
checkAllowCredentials(input.challenge, input.userVerification, input.rpId, input.createTimeout, input.errmsg);
}
async function checkAllowCredentials(challenge, userVerification, rpId, createTimeout, errmsg) {
const allowCredentials = [];
const authnUse = document.forms['authn_select'].authn_use_chk;
if (authnUse !== undefined) {
if (authnUse.length === undefined) {
allowCredentials.push({
id: base64url.parse(authnUse.value, {loose: true}),
type: 'public-key',
});
} else {
authnUse.forEach((entry) =>
allowCredentials.push({
id: base64url.parse(entry.value, {loose: true}),
type: 'public-key',
}));
}
}
try {
const result = await doAuthenticate(allowCredentials, challenge, userVerification, rpId, createTimeout, errmsg);
returnSuccess(result);
} catch (error) {
returnFailure(error);
}
}
function doAuthenticate(allowCredentials, challenge, userVerification, rpId, createTimeout, errmsg) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
returnFailure(errmsg);
return;
}
const publicKey = {
rpId : rpId,
challenge: base64url.parse(challenge, { loose: true })
};
if (createTimeout !== 0) {
publicKey.timeout = createTimeout * 1000;
}
if (allowCredentials.length) {
publicKey.allowCredentials = allowCredentials;
}
if (userVerification !== 'not specified') {
publicKey.userVerification = userVerification;
}
return navigator.credentials.get({publicKey});
}
function returnSuccess(result) {
document.getElementById("clientDataJSON").value = base64url.stringify(new Uint8Array(result.response.clientDataJSON), { pad: false });
document.getElementById("authenticatorData").value = base64url.stringify(new Uint8Array(result.response.authenticatorData), { pad: false });
document.getElementById("signature").value = base64url.stringify(new Uint8Array(result.response.signature), { pad: false });
document.getElementById("credentialId").value = result.id;
if (result.response.userHandle) {
document.getElementById("userHandle").value = base64url.stringify(new Uint8Array(result.response.userHandle), { pad: false });
}
document.getElementById("webauth").submit();
}
function returnFailure(err) {
document.getElementById("error").value = err;
document.getElementById("webauth").submit();
}

View file

@ -0,0 +1,140 @@
import { base64url } from "rfc4648";
export async function registerByWebAuthn(input) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
returnFailure(input.errmsg);
return;
}
const publicKey = {
challenge: base64url.parse(input.challenge, {loose: true}),
rp: {id: input.rpId, name: input.rpEntityName},
user: {
id: base64url.parse(input.userid, {loose: true}),
name: input.username,
displayName: input.username
},
pubKeyCredParams: getPubKeyCredParams(input.signatureAlgorithms),
};
if (input.attestationConveyancePreference !== 'not specified') {
publicKey.attestation = input.attestationConveyancePreference;
}
const authenticatorSelection = {};
let isAuthenticatorSelectionSpecified = false;
if (input.authenticatorAttachment !== 'not specified') {
authenticatorSelection.authenticatorAttachment = input.authenticatorAttachment;
isAuthenticatorSelectionSpecified = true;
}
if (input.requireResidentKey !== 'not specified') {
if (input.requireResidentKey === 'Yes') {
authenticatorSelection.requireResidentKey = true;
} else {
authenticatorSelection.requireResidentKey = false;
}
isAuthenticatorSelectionSpecified = true;
}
if (input.userVerificationRequirement !== 'not specified') {
authenticatorSelection.userVerification = input.userVerificationRequirement;
isAuthenticatorSelectionSpecified = true;
}
if (isAuthenticatorSelectionSpecified) {
publicKey.authenticatorSelection = authenticatorSelection;
}
if (input.createTimeout !== 0) {
publicKey.timeout = input.createTimeout * 1000;
}
const excludeCredentials = getExcludeCredentials(input.excludeCredentialIds);
if (excludeCredentials.length > 0) {
publicKey.excludeCredentials = excludeCredentials;
}
try {
const result = await doRegister(publicKey);
returnSuccess(result, input.initLabel, input.initLabelPrompt);
} catch (error) {
returnFailure(error);
}
}
function doRegister(publicKey) {
return navigator.credentials.create({publicKey});
}
function getPubKeyCredParams(signatureAlgorithmsList) {
const pubKeyCredParams = [];
if (signatureAlgorithmsList.length === 0) {
pubKeyCredParams.push({type: "public-key", alg: -7});
return pubKeyCredParams;
}
for (const entry of signatureAlgorithmsList) {
pubKeyCredParams.push({
type: "public-key",
alg: entry
});
}
return pubKeyCredParams;
}
function getExcludeCredentials(excludeCredentialIds) {
const excludeCredentials = [];
if (excludeCredentialIds === "") {
return excludeCredentials;
}
for (const entry of excludeCredentialIds.split(',')) {
excludeCredentials.push({
type: "public-key",
id: base64url.parse(entry, {loose: true})
});
}
return excludeCredentials;
}
function getTransportsAsString(transportsList) {
if (!Array.isArray(transportsList)) {
return "";
}
return transportsList.join();
}
function returnSuccess(result, initLabel, initLabelPrompt) {
document.getElementById("clientDataJSON").value = base64url.stringify(new Uint8Array(result.response.clientDataJSON), {pad: false});
document.getElementById("attestationObject").value = base64url.stringify(new Uint8Array(result.response.attestationObject), {pad: false});
document.getElementById("publicKeyCredentialId").value = base64url.stringify(new Uint8Array(result.rawId), {pad: false});
if (typeof result.response.getTransports === "function") {
const transports = result.response.getTransports();
if (transports) {
document.getElementById("transports").value = getTransportsAsString(transports);
}
} else {
console.log("Your browser is not able to recognize supported transport media for the authenticator.");
}
let labelResult = window.prompt(initLabelPrompt, initLabel);
if (labelResult === null) {
labelResult = initLabel;
}
document.getElementById("authenticatorLabel").value = labelResult;
document.getElementById("register").submit();
}
function returnFailure(err) {
document.getElementById("error").value = err;
document.getElementById("register").submit();
}

View file

@ -29,6 +29,13 @@
<script src="${url.resourcesPath}/${script}" type="text/javascript"></script>
</#list>
</#if>
<script type="importmap">
{
"imports": {
"rfc4648": "${url.resourcesCommonPath}/node_modules/rfc4648/lib/rfc4648.js"
}
}
</script>
<script src="${url.resourcesPath}/js/menu-button-links.js" type="module"></script>
<#if scripts??>
<#list scripts as script>

View file

@ -69,99 +69,29 @@
</#if>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<input id="authenticateWebAuthnButton" type="button" onclick="webAuthnAuthenticate()" autofocus="autofocus"
<input id="authenticateWebAuthnButton" type="button" autofocus="autofocus"
value="${kcSanitize(msg("webauthn-doAuthenticate"))}"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"/>
</div>
</div>
</div>
<script type="text/javascript" src="${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js"></script>
<script type="text/javascript" src="${url.resourcesPath}/js/base64url.js"></script>
<script type="text/javascript">
function webAuthnAuthenticate() {
let isUserIdentified = ${isUserIdentified};
if (!isUserIdentified) {
doAuthenticate([]);
return;
}
checkAllowCredentials();
}
function checkAllowCredentials() {
let allowCredentials = [];
let authn_use = document.forms['authn_select'].authn_use_chk;
if (authn_use !== undefined) {
if (authn_use.length === undefined) {
allowCredentials.push({
id: base64url.decode(authn_use.value, {loose: true}),
type: 'public-key',
});
} else {
for (let i = 0; i < authn_use.length; i++) {
allowCredentials.push({
id: base64url.decode(authn_use[i].value, {loose: true}),
type: 'public-key',
});
}
}
}
doAuthenticate(allowCredentials);
}
function doAuthenticate(allowCredentials) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
$("#error").val("${msg("webauthn-unsupported-browser-text")?no_esc}");
$("#webauth").submit();
return;
}
let challenge = "${challenge}";
let userVerification = "${userVerification}";
let rpId = "${rpId}";
let publicKey = {
rpId : rpId,
challenge: base64url.decode(challenge, { loose: true })
};
let createTimeout = ${createTimeout};
if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000;
if (allowCredentials.length) {
publicKey.allowCredentials = allowCredentials;
}
if (userVerification !== 'not specified') publicKey.userVerification = userVerification;
navigator.credentials.get({publicKey})
.then((result) => {
window.result = result;
let clientDataJSON = result.response.clientDataJSON;
let authenticatorData = result.response.authenticatorData;
let signature = result.response.signature;
$("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), { pad: false }));
$("#authenticatorData").val(base64url.encode(new Uint8Array(authenticatorData), { pad: false }));
$("#signature").val(base64url.encode(new Uint8Array(signature), { pad: false }));
$("#credentialId").val(result.id);
if(result.response.userHandle) {
$("#userHandle").val(base64url.encode(new Uint8Array(result.response.userHandle), { pad: false }));
}
$("#webauth").submit();
})
.catch((err) => {
$("#error").val(err);
$("#webauth").submit();
})
;
}
<script type="module">
import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
const authButton = document.getElementById('authenticateWebAuthnButton');
authButton.addEventListener("click", function() {
const input = {
isUserIdentified : ${isUserIdentified},
challenge : '${challenge}',
userVerification : '${userVerification}',
rpId : '${rpId}',
createTimeout : ${createTimeout},
errmsg : "${msg("webauthn-unsupported-browser-text")?no_esc}"
};
authenticateByWebAuthn(input);
});
</script>
<#elseif section = "info">
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration">

View file

@ -1,7 +1,7 @@
<#import "template.ftl" as layout>
<#import "password-commons.ftl" as passwordCommons>
<#import "template.ftl" as layout>
<#import "password-commons.ftl" as passwordCommons>
<@layout.registrationLayout; section>
<@layout.registrationLayout; section>
<#if section = "title">
title
<#elseif section = "header">
@ -21,164 +21,34 @@
</div>
</form>
<script type="text/javascript" src="${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js"></script>
<script type="text/javascript" src="${url.resourcesPath}/js/base64url.js"></script>
<script type="text/javascript">
function registerSecurityKey() {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
$("#error").val("${msg("webauthn-unsupported-browser-text")?no_esc}");
$("#register").submit();
return;
}
// mandatory parameters
let challenge = "${challenge}";
let userid = "${userid}";
let username = "${username}";
let signatureAlgorithms =[<#list signatureAlgorithms as sigAlg>${sigAlg},</#list>]
let pubKeyCredParams = getPubKeyCredParams(signatureAlgorithms);
let rpEntityName = "${rpEntityName}";
let rp = {name: rpEntityName};
let publicKey = {
challenge: base64url.decode(challenge, {loose: true}),
rp: rp,
user: {
id: base64url.decode(userid, {loose: true}),
name: username,
displayName: username
},
pubKeyCredParams: pubKeyCredParams,
<script type="module">
import { registerByWebAuthn } from "${url.resourcesPath}/js/webauthnRegister.js";
const registerButton = document.getElementById('registerWebAuthn');
registerButton.addEventListener("click", function() {
const input = {
challenge : '${challenge}',
userid : '${userid}',
username : '${username}',
signatureAlgorithms : [<#list signatureAlgorithms as sigAlg>${sigAlg},</#list>],
rpEntityName : '${rpEntityName}',
rpId : '${rpId}',
attestationConveyancePreference : '${attestationConveyancePreference}',
authenticatorAttachment : '${authenticatorAttachment}',
requireResidentKey : '${requireResidentKey}',
userVerificationRequirement : '${userVerificationRequirement}',
createTimeout : ${createTimeout},
excludeCredentialIds : '${excludeCredentialIds}',
initLabel : "${msg("webauthn-registration-init-label")?no_esc}",
initLabelPrompt : "${msg("webauthn-registration-init-label-prompt")?no_esc}",
errmsg : "${msg("webauthn-unsupported-browser-text")?no_esc}"
};
// optional parameters
let rpId = "${rpId}";
publicKey.rp.id = rpId;
let attestationConveyancePreference = "${attestationConveyancePreference}";
if (attestationConveyancePreference !== 'not specified') publicKey.attestation = attestationConveyancePreference;
let authenticatorSelection = {};
let isAuthenticatorSelectionSpecified = false;
let authenticatorAttachment = "${authenticatorAttachment}";
if (authenticatorAttachment !== 'not specified') {
authenticatorSelection.authenticatorAttachment = authenticatorAttachment;
isAuthenticatorSelectionSpecified = true;
}
let requireResidentKey = "${requireResidentKey}";
if (requireResidentKey !== 'not specified') {
if (requireResidentKey === 'Yes')
authenticatorSelection.requireResidentKey = true;
else
authenticatorSelection.requireResidentKey = false;
isAuthenticatorSelectionSpecified = true;
}
let userVerificationRequirement = "${userVerificationRequirement}";
if (userVerificationRequirement !== 'not specified') {
authenticatorSelection.userVerification = userVerificationRequirement;
isAuthenticatorSelectionSpecified = true;
}
if (isAuthenticatorSelectionSpecified) publicKey.authenticatorSelection = authenticatorSelection;
let createTimeout = ${createTimeout};
if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000;
let excludeCredentialIds = "${excludeCredentialIds}";
let excludeCredentials = getExcludeCredentials(excludeCredentialIds);
if (excludeCredentials.length > 0) publicKey.excludeCredentials = excludeCredentials;
navigator.credentials.create({publicKey})
.then(function (result) {
window.result = result;
let clientDataJSON = result.response.clientDataJSON;
let attestationObject = result.response.attestationObject;
let publicKeyCredentialId = result.rawId;
$("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), {pad: false}));
$("#attestationObject").val(base64url.encode(new Uint8Array(attestationObject), {pad: false}));
$("#publicKeyCredentialId").val(base64url.encode(new Uint8Array(publicKeyCredentialId), {pad: false}));
if (typeof result.response.getTransports === "function") {
let transports = result.response.getTransports();
if (transports) {
$("#transports").val(getTransportsAsString(transports));
}
} else {
console.log("Your browser is not able to recognize supported transport media for the authenticator.");
}
let initLabel = "WebAuthn Authenticator (Default Label)";
let labelResult = window.prompt("Please input your registered authenticator's label", initLabel);
if (labelResult === null) labelResult = initLabel;
$("#authenticatorLabel").val(labelResult);
$("#register").submit();
})
.catch(function (err) {
$("#error").val(err);
$("#register").submit();
});
}
function getPubKeyCredParams(signatureAlgorithmsList) {
let pubKeyCredParams = [];
if (signatureAlgorithmsList === []) {
pubKeyCredParams.push({type: "public-key", alg: -7});
return pubKeyCredParams;
}
for (let i = 0; i < signatureAlgorithmsList.length; i++) {
pubKeyCredParams.push({
type: "public-key",
alg: signatureAlgorithmsList[i]
});
}
return pubKeyCredParams;
}
function getExcludeCredentials(excludeCredentialIds) {
let excludeCredentials = [];
if (excludeCredentialIds === "") return excludeCredentials;
let excludeCredentialIdsList = excludeCredentialIds.split(',');
for (let i = 0; i < excludeCredentialIdsList.length; i++) {
excludeCredentials.push({
type: "public-key",
id: base64url.decode(excludeCredentialIdsList[i],
{loose: true})
});
}
return excludeCredentials;
}
function getTransportsAsString(transportsList) {
if (transportsList === '' || transportsList.constructor !== Array) return "";
let transportsString = "";
for (let i = 0; i < transportsList.length; i++) {
transportsString += transportsList[i] + ",";
}
return transportsString.slice(0, -1);
}
registerByWebAuthn(input);
});
</script>
<input type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
id="registerWebAuthn" value="${msg("doRegisterSecurityKey")}" onclick="registerSecurityKey()"/>
id="registerWebAuthn" value="${msg("doRegisterSecurityKey")}"/>
<#if !isSetRetry?has_content && isAppInitiatedAction?has_content>
<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-webauthn-settings-form"
@ -191,4 +61,4 @@
</#if>
</#if>
</@layout.registrationLayout>
</@layout.registrationLayout>

View file

@ -13,7 +13,8 @@
"jquery": "^3.7.1",
"patternfly": "^3.59.5",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"rfc4648": "^1.5.3"
},
"packageManager": "pnpm@8.10.0",
"devDependencies": {

View file

@ -29,6 +29,9 @@ dependencies:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
rfc4648:
specifier: ^1.5.3
version: 1.5.3
devDependencies:
'@rollup/plugin-commonjs':
@ -1233,3 +1236,7 @@ packages:
/wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
dev: true
/rfc4648@1.5.3:
resolution: {integrity: sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==}
dev: false