1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

First attempt at DECC Integration

This commit is contained in:
Steve Richards 2020-09-21 13:52:05 +01:00
parent f5d41c645c
commit c9e0f573ee
9 changed files with 458 additions and 9 deletions

View File

@ -188,6 +188,8 @@
"immer": "^7.0.5",
"js-yaml": "^3.14.0",
"jsonpath": "^1.0.2",
"jwt-decode": "^3.0.0-beta.2",
"keycloak-js": "^10.0.2",
"lodash": "^4.17.15",
"mac-ca": "^1.0.4",
"marked": "^1.1.0",
@ -240,6 +242,7 @@
"@types/hoist-non-react-statics": "^3.3.1",
"@types/html-webpack-plugin": "^3.2.3",
"@types/jest": "^25.2.3",
"@types/jwt-decode": "^2.2.1",
"@types/material-ui": "^0.21.7",
"@types/md5-file": "^4.0.2",
"@types/mini-css-extract-plugin": "^0.9.1",

View File

@ -13,6 +13,16 @@ function getDefaultRequestOpts(): Partial<request.Options> {
}
}
// export function globalRequestOpts(requestOpts: request.Options ) {
// const userPrefs = userStore.getPreferences()
// if (userPrefs.httpsProxy) {
// requestOpts.proxy = userPrefs.httpsProxy
// }
// requestOpts.rejectUnauthorized = !userPrefs.allowUntrustedCAs;
// return requestOpts
// }
/**
* @deprecated
*/

View File

@ -10,12 +10,15 @@ import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers";
import { tracker } from "./tracker";
import logger from "../main/logger";
import path from 'path';
import jwt_decode from "jwt-decode";
import { List } from "material-ui";
export interface UserStoreModel {
kubeConfigPath: string;
lastSeenAppVersion: string;
seenContexts: string[];
preferences: UserPreferences;
token: Token;
}
export interface UserPreferences {
@ -29,6 +32,48 @@ export interface UserPreferences {
kubectlBinariesPath?: string;
}
export interface Token {
preferredUserName?: string,
token?: string;
tokenValidTill?: number;
refreshToken?: string;
refreshTokenValidTill?: number;
}
interface IDToken {
jti: string,
exp: number,
nbf: number,
iat: number,
iss: string,
aud: string,
sub: string,
typ: string,
azp: string,
auth_time: number,
session_state: string,
acr: string,
iam_roles: string[],
email_verified: boolean,
preferred_username: string
}
interface RefreshToken {
jti: string,
exp: number,
nbf: number,
iat: number,
iss: string,
aud: string,
sub: string,
typ: string,
azp: string,
auth_time: number,
session_state: string,
realm_access: Array<string[]>,
scope: string
}
export class UserStore extends BaseStore<UserStoreModel> {
static readonly defaultTheme: ThemeId = "kontena-dark"
@ -63,6 +108,11 @@ export class UserStore extends BaseStore<UserStoreModel> {
kubectlBinariesPath: ""
};
@observable token: Token = {
token: "",
refreshToken: ""
}
get isNewVersion() {
return semver.gt(getAppVersion(), this.lastSeenAppVersion);
}
@ -114,9 +164,59 @@ export class UserStore extends BaseStore<UserStoreModel> {
return path.join((app || remote.app).getPath("userData"), "binaries")
}
getTokenDetails(): Token {
return this.token;
}
decodeToken(token: string) {
if (token.length > 0) {
return jwt_decode<IDToken>(token);
}
}
decodeRefreshToken(refreshToken: string) {
if (refreshToken.length > 0) {
return jwt_decode<RefreshToken>(refreshToken);
}
}
getIDTokenIAMPermissions(): string[] {
let tokenDecoded = this.decodeToken(this.token.token);
const userRoles = tokenDecoded.iam_roles || [];
return userRoles
}
isTokenExpired(validTill: number): boolean {
// Create a current UnixTime style date in ms
const timeNow = Date.now();
if ((new Date(validTill * 1000).getMinutes() - new Date().getMinutes()) / 1000 / 60 < 0) {
return true;
}
return false;
}
@action
setTokenDetails(token: string, refreshToken: string) {
let tokenDecoded = this.decodeToken(token);
let refreshTokenDecoded = this.decodeToken(refreshToken);
this.token.token = token;
this.token.refreshToken = refreshToken;
this.token.preferredUserName = tokenDecoded.preferred_username;
// Create a current UnixTime style date in secs
const timeNow = Math.round(Date.now() / 1000);
this.token.tokenValidTill = timeNow + tokenDecoded.exp;
this.token.refreshTokenValidTill = timeNow + refreshTokenDecoded.exp;
console.info('The saved token object is: ' + JSON.stringify(this.token));
console.info('Check if token date is expired: ' + this.isTokenExpired(this.token.tokenValidTill));
}
@action
protected async fromStore(data: Partial<UserStoreModel> = {}) {
const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data
const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath, token } = data
if (lastSeenAppVersion) {
this.lastSeenAppVersion = lastSeenAppVersion;
}
@ -125,6 +225,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
}
this.seenContexts.replace(seenContexts);
Object.assign(this.preferences, preferences);
Object.assign(this.token, token);
}
toJSON(): UserStoreModel {
@ -133,6 +234,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
lastSeenAppVersion: this.lastSeenAppVersion,
seenContexts: Array.from(this.seenContexts),
preferences: this.preferences,
token: this.token,
}
return toJS(model, {
recurseEverything: true,

View File

@ -10,7 +10,7 @@ jest.mock("electron", () => {
}
})
import { UserStore } from "./user-store"
import { UserStore, Keycloak } from "./user-store"
import { SemVer } from "semver"
import electron from "electron"
@ -73,6 +73,20 @@ describe("user store tests", () => {
us.lastSeenAppVersion = (new SemVer(electron.app.getVersion())).inc("major").format();
expect(us.isNewVersion).toBe(false);
})
it("allows setting and retrieving keycloak", () => {
const us = UserStore.getInstance<UserStore>();
us.keycloak.idToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MDAzNDc4NzUsImV4cCI6MTYzMTg4Mzg3NSwiYXVkIjoia2FhcyIsInN1YiI6Ijc5ZTEzZGU1LTEwYzgtNGEwNC04ZmEwLWI1OWExYmIzMjIyZiIsImlhbV9yb2xlcyI6WyJtOmthYXNAd3JpdGVyIiwibTprYWFzQHJlYWRlciJdLCJqdGkiOiJlYjNlODY2MC02ZjU0LTRlMTUtOTg3YS01MGIzYjU1MmZmMTIiLCJ0eXAiOiJJRCIsImF6cCI6ImthYXMiLCJlbWFpbF92ZXJpZmllZCI6ImZhbHNlIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYm9iIn0.e3xt3tCNQcxy5flfaQ663KzdBLrMV5gMTt537U4pCo4";
expect(us.keycloak.idToken).toBe("1.2.3");
us.keycloak.expiresIn = "1631883875";
expect(us.keycloak.idToken).toBe("1631883875");
var myKeycloak: {accessToken: "a new dummy access token", idToken: "a new dummy id token", refreshToken: "a new dummy refresg token", expiresIn: "1111", refresExpiresIn: "2222"};
us.setTokenDetails(myKeycloak);
expect(us.keycloak).toBe(myKeycloak);
})
})
describe("migrations", () => {

View File

@ -0,0 +1,46 @@
export function kubeconfig({
username, clusterName, clientId, idpCertificateAuthorityData, idpIssuerUrl,
idToken, refreshToken, server, apiCertificate
}) {
return {
apiVersion: 'v1',
clusters: [
{
name: clusterName,
cluster: {
'certificate-authority-data': apiCertificate,
server
}
}
],
contexts: [
{
context: {
cluster: clusterName,
user: username
},
name: `${username}@${clusterName}`
}
],
'current-context': `${username}@${clusterName}`,
kind: 'Config',
preferences: {},
users: [
{
name: username,
user: {
'auth-provider': {
config: {
'client-id': clientId,
'id-token': idToken,
'idp-certificate-authority-data': idpCertificateAuthorityData,
'idp-issuer-url': idpIssuerUrl,
'refresh-token': refreshToken
},
name: 'oidc'
}
}
}
]
};
}

View File

@ -13,11 +13,19 @@ import { shellSync } from "./shell-sync"
import { getFreePort } from "./port"
import { mangleProxyEnv } from "./proxy-env"
import { registerFileProtocol } from "../common/register-protocol";
import { clusterStore } from "../common/cluster-store"
import { ClusterStore, clusterStore } from "../common/cluster-store"
import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store";
import { tracker } from "../common/tracker";
import logger from "./logger"
import * as fs from 'fs';
import * as http from "http";
import * as request from "request-promise-native";
import { uniqueId } from "lodash";
import { v4 as uuid } from "uuid";
import { Cluster } from "../main/cluster";
import {kubeconfig} from '../common/utils/k8sTemplates';
import YAML from 'yaml'
const workingDir = path.join(app.getPath("appData"), appName);
app.setName(appName);
@ -28,12 +36,23 @@ if (!process.env.CICD) {
let windowManager: WindowManager;
let clusterManager: ClusterManager;
let proxyServer: LensProxy;
//let clusterStore: ClusterStore;
mangleProxyEnv()
if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server")
}
const keycloakWinURL = process.env.NODE_ENV === 'development'
? `http://localhost:3000/keycloak_index.html`
: `file://${__static}/keycloak_index.html`
const { ipcMain } = require('electron')
const ignoredDECCNamespaces = [
'kube-system', 'kube-public', 'openstack-provider-system', 'system',
'kaas', 'lcm-system', 'istio-system', 'kube-node-lease', 'stacklight'
];
async function main() {
await shellSync();
logger.info(`🚀 Starting Lens from "${workingDir}"`)
@ -73,8 +92,34 @@ async function main() {
app.quit();
}
//start renderer with keycloak login page
const keycloakServer = http.createServer(function(req: http.IncomingMessage, res: http.ServerResponse) {
res.writeHead(200, {"Content-Type": "text/html"});
var readSream = fs.createReadStream(__static + '/keycloak_index.html','utf8')
readSream.pipe(res);
}).listen(3000);
// create window manager and open app
windowManager = new WindowManager(proxyPort);
windowManager = new WindowManager(proxyPort, 3000);
//windowManager = new WindowManager(3000);
//open login page in keyloak renderer
// if (isDevelopment) {
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
// app.commandLine.appendSwitch('allow-insecure-localhost', 'true');
// app.commandLine.appendSwitch('ignore-certificate-errors', 'true');
// }
// SSL/TSL: this is the self signed certificate support
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
// On certificate error we disable default behaviour (stop loading the page)
// and we then say "it is all fine - true" to the callback
console.log('cert error: ' + error);
event.preventDefault();
callback(true);
});
//windowManager.showMain(keycloakWinURL);
}
app.on("ready", main);
@ -85,3 +130,139 @@ app.on("will-quit", async (event) => {
if (clusterManager) clusterManager.stop()
app.exit();
})
ipcMain.on('keycloak-token', (event, idToken, refreshToken) => {
logger.info('test keycloak close main win');
userStore.setTokenDetails(idToken, refreshToken);
logger.info('saved id token and refreshToken to userStore');
logger.info('the idToken is: ' + userStore.getTokenDetails().token);
var parsedToken = userStore.decodeToken (idToken);
var namespacesUserCanAccess: string[] = workspaceStore.workspacesList;
// get all namespaces this id has access to
const namespaces = {
method: 'GET',
url: 'http://a09bfce9ea3074e25b8e5e7b1df576fd-1162277427.eu-west-2.elb.amazonaws.com/api/v1/namespaces',
headers: {
'Authorization': 'Bearer ' + userStore.getTokenDetails().token
},
json: true
};
request(namespaces)
.then(function(response) {
//API call ok....
const deccNamespaces = response["items"];
// logger.info(JSON.stringify(deccNamespaces));
deccNamespaces.forEach(function(namespace) {
if (!ignoredDECCNamespaces.includes(namespace.metadata.name)) {
// console.log("Namespace Name: " + namespace.metadata.name);
let ns = namespace.metadata.name;
//console.log("parsedToken.iam_roles: ", parsedToken.iam_roles);
if (parsedToken.iam_roles.includes(`m:kaas:${ns}@reader`) || parsedToken.iam_roles.includes(`m:kaas:${ns}@writer`)) {
// add namespace to workspaceStore if not present
console.log(`User: ${parsedToken.preferred_username} has access to namespace: ${ns}`);
if (!workspaceStore.getByName(ns)) {
workspaceStore.saveWorkspace({id: uuid(), name: ns, description: `DECC Namespace: ${ns}`});
console.log(`Added new workspace: ${ns}`);
namespacesUserCanAccess.push(ns);
}
};
};
});
})
.catch(function (err) {
// API call failed...
console.log(err);
});
// now lets add the clusters for each namespace
workspaceStore.workspacesList.forEach(function(ws) {
console.log(`Adding clusters for ws: ${ws.name}`);
var clusters = {
method: 'GET',
url: `http://a09bfce9ea3074e25b8e5e7b1df576fd-1162277427.eu-west-2.elb.amazonaws.com/apis/cluster.k8s.io/v1alpha1/namespaces/${ws.name}/clusters`,
headers: {
'Authorization': 'Bearer ' + userStore.getTokenDetails().token
},
json: true
};
request(clusters)
.then(function(response) {
//API call ok....
const deccClusters = response["items"];
deccClusters.forEach(function(deccCluster: object) {
// check if cluster is already in the cluster store
let clusterPresent = false;
clusterStore.getByWorkspaceId(ws.id).forEach(wsCluster => {
if (wsCluster.contextName === `${parsedToken.preferred_username}@${deccCluster.metadata.name}`) {
clusterPresent = true;
}
});
if ("status" in deccCluster && !clusterPresent) {
// clusterUCPURL = cluster.status.
let ucpDashboard = `https://${deccCluster.status.providerStatus.ucpDashboard.split(":", 2).reverse()[0].substring(2)}:443`;
console.log (`ucpDashboard: ${ucpDashboard}`);
const jsConfig = kubeconfig({
username: parsedToken.preferred_username,
clusterName: deccCluster.metadata.name,
clientId: deccCluster.status.providerStatus.oidc.clientId,
idpCertificateAuthorityData: deccCluster.status.providerStatus.oidc.certificate,
idpIssuerUrl: deccCluster.status.providerStatus.oidc.issuerUrl,
server: ucpDashboard,
apiCertificate: deccCluster.status.providerStatus.apiServerCertificate,
idToken: idToken,
refreshToken: refreshToken
});
console.log(`Generated kubeconfig: ${YAML.stringify(jsConfig)}`)
console.log(`Cluster Name: ${deccCluster.metadata.name}, Cluster UCP Dashboard URL: ${deccCluster.status.providerStatus.ucpDashboard}`)
let newCluster = new Cluster({
id: uuid(),
contextName: `${parsedToken.preferred_username}@${deccCluster.metadata.name}`,
preferences: {
// icon: "data:;base64,iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: deccCluster.metadata.name,
httpsProxy: undefined,
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig(deccCluster.metadata.uid, YAML.stringify(jsConfig)),
workspace: ws.name,
});
clusterStore.addCluster(newCluster);
};
});
})
.catch(function (err: string) {
// API call failed...
console.log(err);
});
});
//TODO: Use vmURL instead of hardcoded localhost:9080 here
// const winURL = process.env.NODE_ENV === 'development'
// ? `http://localhost:9080?token=${token}`
// : `file://${__dirname}/index.html?token=${token}`
//TODO: refresh token!
windowManager.showMain();
});
ipcMain.on('keycloak-token-update', (event, token) => {
logger.error('token refresh receivied:' + token);
//TODO: handle refresh token!
});
ipcMain.on('keycloak-logout', (event, data) => {
logger.error('logout');
windowManager.showKeycloak();
});

View File

@ -12,7 +12,7 @@ export class WindowManager {
@observable activeClusterId: ClusterId;
constructor(protected proxyPort: number) {
constructor(protected proxyPort: number, protected keycloakPort: number) {
// Manage main window size and position with state persistence
this.windowState = windowStateKeeper({
defaultHeight: 900,
@ -35,6 +35,22 @@ export class WindowManager {
});
this.windowState.manage(this.mainView);
// handle external links
this.mainView.webContents.on("will-navigate", (event, link) => {
if (link.startsWith("http://localhost")) {
return;
}
if (link.startsWith("https://a69adcd0687194b2b8adebdbe93f2a02-977850409.eu-west-2.elb.amazonaws.com")) {
return;
}
if (link.startsWith("http://a09bfce9ea3074e25b8e5e7b1df576fd-1162277427.eu-west-2.elb.amazonaws.com")) {
return;
}
event.preventDefault();
shell.openExternal(link);
})
// open external links in default browser (target=_blank, window.open)
this.mainView.webContents.on("new-window", (event, url) => {
event.preventDefault();
@ -47,7 +63,7 @@ export class WindowManager {
});
// load & show app
this.showMain();
this.showKeycloak();
initMenu(this);
}
@ -68,10 +84,10 @@ export class WindowManager {
}
}
async showMain() {
public async showKeycloak() {
try {
await this.showSplash();
await this.mainView.loadURL(`http://localhost:${this.proxyPort}`)
await this.mainView.loadURL(`http://localhost:${this.keycloakPort}`)
this.mainView.show();
this.splashWindow.close();
} catch (err) {
@ -79,6 +95,17 @@ export class WindowManager {
}
}
public async showMain() {
try {
//await this.showSplash();
await this.mainView.loadURL(`http://localhost:${this.proxyPort}`)
this.mainView.show();
//this.splashWindow.close();
} catch (err) {
dialog.showErrorBox("ERROR!", err.toString())
}
}
async showSplash() {
if (!this.splashWindow) {
this.splashWindow = new BrowserWindow({

View File

@ -0,0 +1,43 @@
<!-- <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval' https://a69adcd0687194b2b8adebdbe93f2a02-977850409.eu-west-2.elb.amazonaws.com/auth/js/keycloak.js;"> -->
<script src="https://a69adcd0687194b2b8adebdbe93f2a02-977850409.eu-west-2.elb.amazonaws.com/auth/js/keycloak.js"></script>
<script>
const { ipcRenderer } = require('electron')
var keycloak = new Keycloak({
url: 'https://a69adcd0687194b2b8adebdbe93f2a02-977850409.eu-west-2.elb.amazonaws.com/auth',
realm: 'iam',
clientId: 'kaas'
});
//if param "logout" was passed to this page, logout user!
const logoutUser = new URL(location.href).searchParams.get('logout');
if(logoutUser){
keycloak.init();
keycloak.logout({redirectUri: 'http://localhost:3000'});
}else{
keycloak.init({ flow: 'standard', enableLogging: true, useNonce: false, onLoad: 'login-required', checkLoginIframe: false, redirectUri: 'http://localhost:3000'}).success(function(authenticated) {
console.log('success!!');
if (authenticated) {
console.log("keycloak.token: " + keycloak.token);
console.log("keycloak.idToken: " + keycloak.idToken);
console.log("keycloak.refreshToken: " + keycloak.refreshToken);
console.log("keycloak object: "+ JSON.stringify(keycloak));
ipcRenderer.send('keycloak-token', keycloak.idToken, keycloak.refreshToken);
//TODO: check if token refresh is possible here
/*
setInterval(() => {
console.log("interval");
keycloak.updateToken(10).error(() => keycloak.logout());
console.log(keycloak.token);
ipcRenderer.send('keycloak-token-update', keycloak.token);
}, 10000);
*/
}
}).error(function(error) {
console.log('error: ' + JSON.stringify(error));
});
}
</script>

View File

@ -1905,6 +1905,11 @@
resolved "https://registry.yarnpkg.com/@types/jsonpath/-/jsonpath-0.2.0.tgz#13c62db22a34d9c411364fac79fd374d63445aa1"
integrity sha512-v7qlPA0VpKUlEdhghbDqRoKMxFB3h3Ch688TApBJ6v+XLDdvWCGLJIYiPKGZnS6MAOie+IorCfNYVHOPIHSWwQ==
"@types/jwt-decode@^2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@types/jwt-decode/-/jwt-decode-2.2.1.tgz#afdf5c527fcfccbd4009b5fd02d1e18241f2d2f2"
integrity sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A==
"@types/lodash@^4.14.155":
version "4.14.155"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a"
@ -3042,7 +3047,7 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
base64-js@^1.0.2:
base64-js@1.3.1, base64-js@^1.0.2:
version "1.3.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
@ -7281,6 +7286,11 @@ js-base64@^2.1.8:
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.2.tgz#313b6274dda718f714d00b3330bbae6e38e90209"
integrity sha512-Vg8czh0Q7sFBSUMWWArX/miJeBWYBPpdU/3M/DKSaekLMqrqVPaedp+5mZhie/r0lgrcaYBfwXatEew6gwgiQQ==
js-sha256@0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966"
integrity sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==
js-sha3@^0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840"
@ -7505,6 +7515,19 @@ jss@10.2.0, jss@^10.0.3:
is-in-browser "^1.1.3"
tiny-warning "^1.0.2"
jwt-decode@^3.0.0-beta.2:
version "3.0.0-beta.2"
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.0.0-beta.2.tgz#5d928dd0d582caab47b252a5f12ad1721f2f6eef"
integrity sha512-AnENY5syz7PzgpTzos9sxkqKTmHU0JeJOXZFHUc41bDyybC2yzZ+1r43ZLhk7+JCwF0yjISPuVK9ZWfA1nCUPA==
keycloak-js@^10.0.2:
version "10.0.2"
resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-10.0.2.tgz#f0cf5b942627c5221f1466552c40e4624503b77b"
integrity sha512-7nkg4Ob1khHGcNbuK36AMndKUEuIQFpNlWU9ygWs7nSBPCI9VZ8dJjjXfKJHm0ewgcqLFGPIJ6bxxRlfcQ6sLg==
dependencies:
base64-js "1.3.1"
js-sha256 "0.9.0"
keyv@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373"