From c9e0f573eea44ee05dd4880bbec6f1d3670352b2 Mon Sep 17 00:00:00 2001 From: Steve Richards Date: Mon, 21 Sep 2020 13:52:05 +0100 Subject: [PATCH] First attempt at DECC Integration --- package.json | 3 + src/common/request.ts | 10 ++ src/common/user-store.ts | 104 ++++++++++++++++- src/common/user-store_test.ts | 16 ++- src/common/utils/k8sTemplates.ts | 46 ++++++++ src/main/index.ts | 185 ++++++++++++++++++++++++++++++- src/main/window-manager.ts | 35 +++++- static/keycloak_index.html | 43 +++++++ yarn.lock | 25 ++++- 9 files changed, 458 insertions(+), 9 deletions(-) create mode 100644 src/common/utils/k8sTemplates.ts create mode 100644 static/keycloak_index.html diff --git a/package.json b/package.json index 534ef7fff9..ebef975aba 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/request.ts b/src/common/request.ts index 536e2eccc9..797eadba0f 100644 --- a/src/common/request.ts +++ b/src/common/request.ts @@ -13,6 +13,16 @@ function getDefaultRequestOpts(): Partial { } } +// export function globalRequestOpts(requestOpts: request.Options ) { +// const userPrefs = userStore.getPreferences() +// if (userPrefs.httpsProxy) { +// requestOpts.proxy = userPrefs.httpsProxy +// } +// requestOpts.rejectUnauthorized = !userPrefs.allowUntrustedCAs; + +// return requestOpts +// } + /** * @deprecated */ diff --git a/src/common/user-store.ts b/src/common/user-store.ts index a0cfe09fd5..731afcbd18 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -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, + scope: string +} + export class UserStore extends BaseStore { static readonly defaultTheme: ThemeId = "kontena-dark" @@ -63,6 +108,11 @@ export class UserStore extends BaseStore { kubectlBinariesPath: "" }; + @observable token: Token = { + token: "", + refreshToken: "" + } + get isNewVersion() { return semver.gt(getAppVersion(), this.lastSeenAppVersion); } @@ -114,9 +164,59 @@ export class UserStore extends BaseStore { return path.join((app || remote.app).getPath("userData"), "binaries") } + getTokenDetails(): Token { + return this.token; + } + + decodeToken(token: string) { + if (token.length > 0) { + return jwt_decode(token); + } + } + + decodeRefreshToken(refreshToken: string) { + if (refreshToken.length > 0) { + return jwt_decode(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 = {}) { - 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 { } 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 { lastSeenAppVersion: this.lastSeenAppVersion, seenContexts: Array.from(this.seenContexts), preferences: this.preferences, + token: this.token, } return toJS(model, { recurseEverything: true, diff --git a/src/common/user-store_test.ts b/src/common/user-store_test.ts index 4e9efe97d8..c840a49a78 100644 --- a/src/common/user-store_test.ts +++ b/src/common/user-store_test.ts @@ -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(); + + 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", () => { diff --git a/src/common/utils/k8sTemplates.ts b/src/common/utils/k8sTemplates.ts new file mode 100644 index 0000000000..c17b513b44 --- /dev/null +++ b/src/common/utils/k8sTemplates.ts @@ -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' + } + } + } + ] + }; +} \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts index e4fd246467..dfb63a9444 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(); +}); diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index be5a95e47a..48ab0e8328 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -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({ diff --git a/static/keycloak_index.html b/static/keycloak_index.html new file mode 100644 index 0000000000..4203d4e45e --- /dev/null +++ b/static/keycloak_index.html @@ -0,0 +1,43 @@ + + + + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 300efc58ce..1f053271da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"