diff --git a/packages/account-node/package.json b/packages/account-node/package.json index 74d92a34..a855dcff 100644 --- a/packages/account-node/package.json +++ b/packages/account-node/package.json @@ -21,7 +21,8 @@ "@verida/did-client": "^0.1.8", "@verida/did-document": "^1.0.5", "@verida/encryption-utils": "^1.1.3", - "@verida/keyring": "^1.1.3" + "@verida/keyring": "^1.1.3", + "axios": "^0.27.2" }, "devDependencies": { "did-jwt": "5.7.0", diff --git a/packages/account-node/src/authTypes/VeridaDatabase.ts b/packages/account-node/src/authTypes/VeridaDatabase.ts new file mode 100644 index 00000000..56d46f3d --- /dev/null +++ b/packages/account-node/src/authTypes/VeridaDatabase.ts @@ -0,0 +1,165 @@ +import Axios from "axios"; +import AutoAccount from "../auto"; +import { Interfaces } from '@verida/storage-link' +import { Account, VeridaDatabaseAuthContext, AuthType, VeridaDatabaseAuthTypeConfig, ContextAuthorizationError } from "@verida/account"; + +export default class VeridaDatabaseAuthType extends AuthType { + + protected contextAuth?: VeridaDatabaseAuthContext + protected account: AutoAccount + + public constructor(account: Account, contextName: string, serviceEndpoint: Interfaces.SecureContextEndpoint, signKey: Interfaces.SecureContextPublicKey) { + super(account, contextName, serviceEndpoint, signKey) + this.account = account + } + + public async getAuthContext(config: VeridaDatabaseAuthTypeConfig = { + deviceId: "Test device", + force: false + }): Promise { + const serverUrl = config && config.endpointUri ? config.endpointUri : this.serviceEndpoint.endpointUri + + // If we have an invalid access token, clear it + if (this.contextAuth && config.invalidAccessToken) { + this.contextAuth.accessToken = undefined + } + + // We already have a context auth object, so reuse it unless + // requested to force create or have a missing access token. + // This can happen if the access token has expired when being + // used and it can automatically be re-requested. + if (this.contextAuth && !config.force && this.contextAuth.accessToken) { + return this.contextAuth + } + + const did = await this.account!.did() + + // No context auth or no refresh token, so generate it by signing a consent message + if (!this.contextAuth || !this.contextAuth.refreshToken) { + //console.log('getContextAuth(): no refreshtoken, generating') + // @todo: get a new refresh token if getting close to expiring? + + let authJwt + try { + // Generate an auth token to start auth process + const authJwtResponse = await this.getAxios(this.contextName).post(serverUrl + "auth/generateAuthJwt",{ + did, + contextName: this.contextName + }) + + authJwt = authJwtResponse.data.authJwt + } catch (err: any) { + throw new Error(`Unable to connect to storage node (${serverUrl}): ${err.message}`) + } + + let refreshResponse + try { + // Generate a refresh token by authenticating + const consentMessage = `Authenticate this application context: "${this.contextName}"?\n\n${did.toLowerCase()}\n${authJwt.authRequestId}` + const signature = await this.account.sign(consentMessage) + + refreshResponse = await this.getAxios(this.contextName).post(serverUrl + "auth/authenticate",{ + authJwt: authJwt.authJwt, + did, + contextName: this.contextName, + signature, + deviceId: config.deviceId + }); + + //console.log('refresh response', refreshResponse.data) + } catch (err: any) { + throw new ContextAuthorizationError("Expired refresh token") + } + + //console.log("authenticate response", refreshResponse.data) + + const refreshToken = refreshResponse.data.refreshToken + const host = refreshResponse.data.host + const accessToken = refreshResponse.data.accessToken + + this.contextAuth = { + refreshToken, + accessToken, + host, + endpointUri: serverUrl, + publicSigningKey: this.signKey + } + + //console.log(this.contextAuth!) + + return this.contextAuth! + } + + // No access token, but have a refresh token, so generate access token + if (this.contextAuth && !this.contextAuth.accessToken) { + //console.log('getContextAuth(): no access token, but refresh token, so generating access token') + + try { + const accessResponse = await this.getAxios(this.contextName).post(serverUrl + "auth/connect",{ + refreshToken: this.contextAuth.refreshToken, + did, + contextName: this.contextName + }); + + const accessToken = accessResponse.data.accessToken + this.contextAuth.accessToken = accessToken + return this.contextAuth + } catch (err: any) { + // Refresh token is invalid, so raise an exception that will be caught within the protocol + // and force the sign in to be restarted + if (err.message == 'Request failed with status code 401') { + throw new ContextAuthorizationError("Expired refresh token") + } else { + throw err + } + } + } + + // @todo: test if connection is valid? + + return this.contextAuth! + } + + public async disconnectDevice(deviceId: string="Test device"): Promise { + const contextAuth = await this.getAuthContext() + + const did = await this.account.did(); + + const consentMessage = `Invalidate device for this application context: "${this.contextName}"?\n\n${did.toLowerCase()}\n${deviceId}` + const signature = await this.account.sign(consentMessage) + + try { + const response = await this.getAxios(this.contextName).post(`${contextAuth.endpointUri}auth/invalidateDeviceId`, { + did, + contextName: this.contextName, + deviceId: deviceId, + signature + }); + + return response.data.status == 'success' + } catch (err: any) { + if (err.response && err.response.data) { + throw new Error(`Unable to disconnect device: ${JSON.stringify(err.response.data.data)}`) + } + else { + throw new Error(`Unable to disconnect device: ${err.message}`) + } + } + } + + private getAxios(storageContext: string, accessToken?: string) { + let config: any = { + headers: { + // @todo: Application-Name needs to become Storage-Context + "Application-Name": storageContext, + }, + }; + + if (accessToken) { + config.headers['Authorization'] = `Bearer ${accessToken}` + } + + return Axios.create(config); + } + +} \ No newline at end of file diff --git a/packages/account-node/src/authcontext.ts b/packages/account-node/src/authcontext.ts new file mode 100644 index 00000000..7a3b8273 --- /dev/null +++ b/packages/account-node/src/authcontext.ts @@ -0,0 +1,44 @@ +import { AccountConfig, AuthContext, AuthTypeConfig, VeridaDatabaseAuthContext } from '@verida/account' +import { NodeAccountConfig } from './interfaces' +import LimitedAccount from './limited' +import { Interfaces } from '@verida/storage-link' +import VeridaDatabaseAuthType from "./authTypes/VeridaDatabase" + +/** + * A NodeJs account that only signs messages for a limited list of contexts. + * + * Used for testing. + */ +export default class AuthContextAccount extends LimitedAccount { + + /** + * This will need to be refactored when more db engines are supported. + * + * We are assuming we are dealing with a Verida Database Auth Context and then injecting + * a known context object into the in memory database. + * + * This is used for testing, by setting invalid access / request tokens in unit tests + * + * @param accountConfig + * @param autoConfig + * @param signingContext + * @param authContext + */ + constructor(accountConfig: AccountConfig, autoConfig: NodeAccountConfig, signingContext: string, authContext: VeridaDatabaseAuthContext) { + const signingContexts = [signingContext] + super(accountConfig, autoConfig, signingContexts) + + this.contextAuths[signingContext] = new VeridaDatabaseAuthType(this, signingContext, { + endpointUri: authContext.endpointUri, + type: 'VeridaDatabase' + }, authContext.publicSigningKey!) + + this.contextAuths[signingContext].setAuthContext(authContext) + + } + + public async getAuthContext(contextName: string, contextConfig: Interfaces.SecureContextConfig, authConfig: AuthTypeConfig, authType = "database"): Promise { + return super.getAuthContext(contextName, contextConfig, authConfig, authType) + } + +} \ No newline at end of file diff --git a/packages/account-node/src/auto.ts b/packages/account-node/src/auto.ts index f6df8314..dc7975d7 100644 --- a/packages/account-node/src/auto.ts +++ b/packages/account-node/src/auto.ts @@ -1,11 +1,12 @@ import { Interfaces, StorageLink, DIDStorageConfig } from '@verida/storage-link' import { Keyring } from '@verida/keyring' -import { Account, AccountConfig, Config } from '@verida/account' +import { Account, AccountConfig, Config, AuthType, VeridaDatabaseAuthTypeConfig, AuthContext, AuthTypeConfig } from '@verida/account' import { NodeAccountConfig } from './interfaces' import { DIDClient, Wallet } from '@verida/did-client' import EncryptionUtils from "@verida/encryption-utils" import { Interfaces as DIDDocumentInterfaces } from "@verida/did-document" +import VeridaDatabaseAuthType from "./authTypes/VeridaDatabase" /** * An Authenticator that automatically signs everything @@ -17,6 +18,7 @@ export default class AutoAccount extends Account { private wallet: Wallet protected accountConfig: AccountConfig + protected contextAuths: Record = {} constructor(accountConfig: AccountConfig, autoConfig: NodeAccountConfig) { super() @@ -102,4 +104,33 @@ export default class AutoAccount extends Account { return this.didClient } + public async getAuthContext(contextName: string, contextConfig: Interfaces.SecureContextConfig, authConfig: VeridaDatabaseAuthTypeConfig = { + force: false + }, authType = "database"): Promise { + // Use existing context auth instance if it exists + if (this.contextAuths[contextName] && !authConfig.force) { + return this.contextAuths[contextName].getAuthContext(authConfig) + } + + const signKey = contextConfig.publicKeys.signKey + + // @todo: Currently hard code database server, need to support other service types in the future + const serviceEndpoint = contextConfig.services.databaseServer + + if (serviceEndpoint.type == "VeridaDatabase") { + this.contextAuths[contextName] = new VeridaDatabaseAuthType(this, contextName, serviceEndpoint, signKey) + return this.contextAuths[contextName].getAuthContext(authConfig) + } + + throw new Error(`Unknown auth context type (${authType})`) + } + + public async disconnectDevice(contextName: string, deviceId: string="Test device"): Promise { + if (!this.contextAuths[contextName]) { + throw new Error(`Context not connected ${contextName}`) + } + + return this.contextAuths[contextName].disconnectDevice(deviceId) + } + } \ No newline at end of file diff --git a/packages/account-node/src/index.ts b/packages/account-node/src/index.ts index f8536a7f..ed5335ca 100644 --- a/packages/account-node/src/index.ts +++ b/packages/account-node/src/index.ts @@ -1,7 +1,11 @@ import AutoAccount from "./auto" import LimitedAccount from "./limited" +import AuthContextAccount from "./authcontext" +import VeridaDatabaseAuthType from "./authTypes/VeridaDatabase" export { AutoAccount, - LimitedAccount + VeridaDatabaseAuthType, + LimitedAccount, + AuthContextAccount } \ No newline at end of file diff --git a/packages/account-node/src/limited.ts b/packages/account-node/src/limited.ts index fee8d9d2..40865448 100644 --- a/packages/account-node/src/limited.ts +++ b/packages/account-node/src/limited.ts @@ -13,7 +13,7 @@ export default class LimitedAccount extends AutoAccount { private signingContexts: string[] - constructor(accountConfig: AccountConfig, autoConfig: NodeAccountConfig, signingContexts = []) { + constructor(accountConfig: AccountConfig, autoConfig: NodeAccountConfig, signingContexts: string[] = []) { super(accountConfig, autoConfig) this.signingContexts = signingContexts } diff --git a/packages/account-web-vault/README.md b/packages/account-web-vault/README.md index 7469df72..a7b162ac 100644 --- a/packages/account-web-vault/README.md +++ b/packages/account-web-vault/README.md @@ -1,2 +1,2 @@ -@todo \ No newline at end of file +See https://developers.verida.io/docs/single-sign-on-sdk \ No newline at end of file diff --git a/packages/account-web-vault/package.json b/packages/account-web-vault/package.json index 9b975af7..b7628e8f 100644 --- a/packages/account-web-vault/package.json +++ b/packages/account-web-vault/package.json @@ -18,6 +18,7 @@ "@verida/account": "^1.1.9", "@verida/encryption-utils": "^1.1.3", "@verida/keyring": "^1.1.3", + "jsonwebtoken": "^8.5.1", "lodash": "^4.17.21", "qrcode-with-logos": "^1.0.3", "store": "^2.0.12" diff --git a/packages/account-web-vault/src/interfaces.ts b/packages/account-web-vault/src/interfaces.ts index 2ccd8992..7e592107 100644 --- a/packages/account-web-vault/src/interfaces.ts +++ b/packages/account-web-vault/src/interfaces.ts @@ -1,8 +1,16 @@ import { Interfaces } from '@verida/storage-link' +export interface WalletConnectConfig { + version: string, + uri: string, + chainId: string +} + export interface VaultAccountRequest { logoUrl?: string, // Optional URL that will be displayed as part of the login process openUrl?: string, // Optional URL that will be opened on the user's mobile device once the user is logged in + userAgent?: string, // Optional User Agent that made the authentication request (makes it easier for a user to know which refresh token to disconnect) + walletConnect?: WalletConnectConfig // Optional configuration for WalletConnect } export interface VaultAccountConfig { diff --git a/packages/account-web-vault/src/vault-account.ts b/packages/account-web-vault/src/vault-account.ts index e2ac70cc..657b152e 100644 --- a/packages/account-web-vault/src/vault-account.ts +++ b/packages/account-web-vault/src/vault-account.ts @@ -1,7 +1,10 @@ -import { Account } from '@verida/account' +import { Account, VeridaDatabaseAuthContext, AuthTypeConfig, AuthContext, VeridaDatabaseAuthTypeConfig, ContextAuthorizationError } from '@verida/account' import { Interfaces } from '@verida/storage-link' import { Keyring } from '@verida/keyring' import VaultModalLogin from './vault-modal-login' +import Axios from "axios"; +const jwt = require('jsonwebtoken'); + const querystring = require('querystring') const _ = require('lodash') const store = require('store') @@ -69,14 +72,18 @@ export default class VaultAccount extends Account { constructor(config: VaultAccountConfig = {}) { super() this.config = config + this.config.request = this.config.request ? this.config.request : {} + this.config.request.userAgent = navigator.userAgent } - public async connectContext(contextName: string) { + public async connectContext(contextName: string, ignoreSession: boolean = false) { const vaultAccount = this - const contextConfig = await this.loadFromSession(contextName) - if (contextConfig) { - return contextConfig + if (!ignoreSession) { + const contextConfig = await this.loadFromSession(contextName) + if (contextConfig) { + return contextConfig + } } const promise = new Promise((resolve, reject) => { @@ -86,13 +93,13 @@ export default class VaultAccount extends Account { if (!storedSessions) { storedSessions = {} } - + storedSessions[contextName] = response store.set(VERIDA_AUTH_CONTEXT, storedSessions) } this.setDid(response.did) - vaultAccount.addContext(response.context, response.contextConfig, new Keyring(response.signature)) + vaultAccount.addContext(response.context, response.contextConfig, new Keyring(response.signature), response.contextAuth) resolve(true) } @@ -109,27 +116,60 @@ export default class VaultAccount extends Account { return promise } + /** + * Verify we have valid JWT's and non-expired accessToken and refreshToken + * + * @param contextAuth + * @returns + */ + public contextAuthIsValid(contextAuth: VeridaDatabaseAuthContext): boolean { + if (!contextAuth.accessToken || !contextAuth.refreshToken) { + return false + } + + // verify tokens are valid JWT's + const decodedAccessToken = jwt.decode(contextAuth.accessToken!) + if (!decodedAccessToken) { + return false + } + + const decodedRefreshToken = jwt.decode(contextAuth.refreshToken!) + if (!decodedRefreshToken) { + return false + } + + // verify tokens haven't expired + const now = Math.floor(Date.now() / 1000) + if (decodedAccessToken.exp < now || decodedRefreshToken.exp < now) { + return false + } + + return true + } + public async loadFromSession(contextName: string): Promise { // First, attempt to Load from query parameters if specified const token = getAuthTokenFromQueryParams() if (token && token.context == contextName) { - this.addContext(token.context, token.contextConfig, new Keyring(token.signature)) - this.setDid(token.did) + if (this.contextAuthIsValid(token.contextAuth)) { + this.addContext(token.context, token.contextConfig, new Keyring(token.signature), token.contextAuth) + this.setDid(token.did) - if (typeof(this.config!.callback) === "function") { - this.config!.callback(token) - } + if (typeof(this.config!.callback) === "function") { + this.config!.callback(token) + } - // Store the session from the query params so future page loads will be authenticated - let storedSessions = store.get(VERIDA_AUTH_CONTEXT) - if (!storedSessions) { - storedSessions = {} - } + // Store the session from the query params so future page loads will be authenticated + let storedSessions = store.get(VERIDA_AUTH_CONTEXT) + if (!storedSessions) { + storedSessions = {} + } - storedSessions[contextName] = token - store.set(VERIDA_AUTH_CONTEXT, storedSessions) - - return token.contextConfig + storedSessions[contextName] = token + store.set(VERIDA_AUTH_CONTEXT, storedSessions) + + return token.contextConfig + } } const storedSessions = store.get(VERIDA_AUTH_CONTEXT) @@ -140,14 +180,17 @@ export default class VaultAccount extends Account { const response = storedSessions[contextName] - this.setDid(response.did) - this.addContext(response.context, response.contextConfig, new Keyring(response.signature)) + if (this.contextAuthIsValid(response.contextAuth)) { + this.setDid(response.did) - if (typeof(this.config!.callback) === "function") { - this.config!.callback(response) - } + this.addContext(response.context, response.contextConfig, new Keyring(response.signature), response.contextAuth) + + if (typeof(this.config!.callback) === "function") { + this.config!.callback(response) + } - return response.contextConfig + return response.contextConfig + } } public async keyring(contextName: string): Promise { @@ -158,10 +201,11 @@ export default class VaultAccount extends Account { return this.contextCache[contextName].keyring } - public addContext(contextName: string, contextConfig: Interfaces.SecureContextConfig, keyring: Keyring) { + public addContext(contextName: string, contextConfig: Interfaces.SecureContextConfig, keyring: Keyring, contextAuth: VeridaDatabaseAuthContext) { this.contextCache[contextName] = { keyring, - contextConfig + contextConfig, + contextAuth } } @@ -214,4 +258,62 @@ export default class VaultAccount extends Account { store.remove(VERIDA_AUTH_CONTEXT) } + public async getAuthContext(contextName: string, contextConfig: Interfaces.SecureContextConfig, authConfig: AuthTypeConfig = { + force: false + }, authType: string = "database"): Promise { + if (authConfig.force || !this.contextCache[contextName]) { + // Don't have an existing context in the cache or we need to force refresh + await this.connectContext(contextName, true) + } + + const serviceEndpoint = contextConfig.services.databaseServer + if (serviceEndpoint.type == "VeridaDatabase") { + // If we have an invalid access token (detected by the internal libraries) + // then attempt to re-authenticate using the refreshToken + if (( authConfig).invalidAccessToken) { + const did = await this.did() + + try { + const accessResponse = await this.getAxios(contextName).post(serviceEndpoint.endpointUri + "auth/connect",{ + refreshToken: this.contextCache[contextName].contextAuth.refreshToken, + did, + contextName: contextName + }); + + const accessToken = accessResponse.data.accessToken + this.contextCache[contextName].contextAuth.accessToken = accessToken + return this.contextCache[contextName].contextAuth + } catch (err: any) { + // Refresh token is invalid, so raise an exception that will be caught within the protocol + // and force the sign in to be restarted + if (err.message == 'Request failed with status code 401') { + throw new ContextAuthorizationError("Expired refresh token") + } else { + throw err + } + } + } + } + + if (this.contextCache[contextName] && this.contextCache[contextName].contextAuth) { + return this.contextCache[contextName].contextAuth + } + + throw new Error(`Unknown auth context type (${authType})`) + } + + private getAxios(storageContext: string, accessToken?: string) { + let config: any = { + headers: { + // @todo: Application-Name needs to become Storage-Context + "Application-Name": storageContext, + }, + }; + + if (accessToken) { + config.headers['Authorization'] = `Bearer ${accessToken}` + } + + return Axios.create(config); + } } \ No newline at end of file diff --git a/packages/account/src/account.ts b/packages/account/src/account.ts index 40bc820c..147adef3 100644 --- a/packages/account/src/account.ts +++ b/packages/account/src/account.ts @@ -2,6 +2,7 @@ import { Keyring } from '@verida/keyring' import { Interfaces } from '@verida/storage-link' import { createJWT, ES256KSigner } from 'did-jwt' import { encodeBase64 } from "tweetnacl-util" +import { AuthContext, AuthTypeConfig } from './interfaces' const _ = require('lodash') @@ -104,4 +105,15 @@ export default class Account { public async disconnect(contextName?: string): Promise { throw new Error("Not implemented.") } + + public async getAuthContext(contextName: string, contextConfig: Interfaces.SecureContextConfig, authConfig: AuthTypeConfig = { + force: false + }, authType: string = "database"): Promise { + throw new Error("Not implemented.") + } + + public async disconnectDevice(contextName: string, deviceId: string="Test device"): Promise { + throw new Error("Not implemented.") + } + } \ No newline at end of file diff --git a/packages/account/src/index.ts b/packages/account/src/index.ts index 874d49b1..801af6ad 100644 --- a/packages/account/src/index.ts +++ b/packages/account/src/index.ts @@ -1,10 +1,16 @@ import Account from "./account" -import { AccountConfig, EnvironmentType } from "./interfaces" +import { AccountConfig, EnvironmentType, AuthContext, AuthType, AuthTypeConfig, VeridaDatabaseAuthContext, VeridaDatabaseAuthTypeConfig, ContextAuthorizationError } from "./interfaces" import Config from "./config" export { Account, AccountConfig, EnvironmentType, + AuthContext, + AuthType, + AuthTypeConfig, + VeridaDatabaseAuthContext, + VeridaDatabaseAuthTypeConfig, + ContextAuthorizationError, Config } \ No newline at end of file diff --git a/packages/account/src/interfaces.ts b/packages/account/src/interfaces.ts index 9e4b1182..79223990 100644 --- a/packages/account/src/interfaces.ts +++ b/packages/account/src/interfaces.ts @@ -1,4 +1,5 @@ import { Interfaces } from "@verida/storage-link" +import Account from "./account" export interface AccountConfig { defaultDatabaseServer: Interfaces.SecureContextEndpoint, @@ -11,4 +12,62 @@ export enum EnvironmentType { LOCAL = 'local', TESTNET = 'testnet', MAINNET = 'mainnet' +} + +export interface AuthContext { + publicSigningKey: Interfaces.SecureContextPublicKey +} + +export interface AuthTypeConfig { + force: boolean +} + +export class AuthType { + + protected contextAuth?: AuthContext + protected account: Account + protected contextName: string + protected serviceEndpoint: Interfaces.SecureContextEndpoint + protected signKey: Interfaces.SecureContextPublicKey + + public constructor(account: Account, contextName: string, serviceEndpoint: Interfaces.SecureContextEndpoint, signKey: Interfaces.SecureContextPublicKey) { + this.account = account + this.contextName = contextName + this.serviceEndpoint = serviceEndpoint + this.signKey = signKey + } + + getAuthContext(config: AuthTypeConfig): Promise { + throw new Error("Not implemented") + } + + setAuthContext(contextAuth: AuthContext) { + this.contextAuth = contextAuth + } + + disconnectDevice(deviceId: string="Test device"): Promise { + throw new Error("Not implemented") + } +} + +//// VeridaDatabase Authentication Interfaces + +export interface VeridaDatabaseAuthContext extends AuthContext { + refreshToken?: string, + accessToken?: string, + endpointUri: string, + host: string +} + +export interface VeridaDatabaseAuthTypeConfig extends AuthTypeConfig { + deviceId?: string, + endpointUri?: string, + invalidAccessToken?: boolean +} + +export class ContextAuthorizationError extends Error { + constructor(message: string) { + super(message) + this.name = "ContextAuthorizationError" + } } \ No newline at end of file diff --git a/packages/client-ts/src/context/context.ts b/packages/client-ts/src/context/context.ts index 14f7766b..42bafb03 100644 --- a/packages/client-ts/src/context/context.ts +++ b/packages/client-ts/src/context/context.ts @@ -1,4 +1,4 @@ -import { Account } from "@verida/account"; +import { Account, AuthContext, AuthTypeConfig } from "@verida/account"; import { Interfaces } from "@verida/storage-link"; import BaseStorageEngine from "./engines/base"; @@ -58,6 +58,8 @@ class Context { private databaseEngines: DatabaseEngines = {}; private dbRegistry: DbRegistry; + private databaseCache: Record> = {} + /** * Instantiate a new context. * @@ -149,11 +151,12 @@ class Context { `Unsupported database engine type specified: ${engineType}` ); } + const engine = DATABASE_ENGINES[engineType]; // @todo type cast correctly const databaseEngine = new engine( this.contextName, this.dbRegistry, - contextConfig.services.databaseServer.endpointUri + contextConfig ); /** @@ -290,21 +293,37 @@ class Context { config.did = accountDid; } - const databaseEngine = await this.getDatabaseEngine( - config.did, - config.createContext! - ); + const cacheKey = `${config.did}/${databaseName}` - if (!config.signingContext) { - config.signingContext = this; + if (this.databaseCache[cacheKey] && !config.ignoreCache) { + return this.databaseCache[cacheKey] } - const database = await databaseEngine.openDatabase(databaseName, config); - if (config.saveDatabase !== false) { - await this.dbRegistry.saveDb(database, false); - } + const instance = this + this.databaseCache[cacheKey] = new Promise(async (resolve, rejects) => { + try { + const databaseEngine = await instance.getDatabaseEngine( + config.did!, + config.createContext! + ); + + if (!config.signingContext) { + config.signingContext = instance; + } + + const database = await databaseEngine.openDatabase(databaseName, config); + if (config.saveDatabase !== false) { + await instance.dbRegistry.saveDb(database, false); + } + + instance.databaseCache[cacheKey] = database; + resolve(database); + } catch (err: any) { + rejects(err) + } + }) - return database; + return this.databaseCache[cacheKey] } /** @@ -342,8 +361,9 @@ class Context { config ); - config.saveDatabase = false; + config.isOwner = false; + config.saveDatabase = false; if (config.contextName && config.contextName != this.contextName) { // We are opening a database for a different context. // Open the new context @@ -358,7 +378,6 @@ class Context { } const databaseEngine = await this.getDatabaseEngine(did); - return databaseEngine.openDatabase(databaseName, config); } @@ -412,6 +431,17 @@ class Context { public getDbRegistry(): DbRegistry { return this.dbRegistry; } + + public async getAuthContext(authConfig?: AuthTypeConfig, authType?: string): Promise { + if (!this.account) { + throw new Error("No authenticated user"); + } + + const did = await this.account!.did() + const contextConfig = await this.getContextConfig(did, false) + + return this.account!.getAuthContext(this.contextName, contextConfig, authConfig, authType) + } } export default Context; diff --git a/packages/client-ts/src/context/database.ts b/packages/client-ts/src/context/database.ts index 430ec81c..25301d0d 100644 --- a/packages/client-ts/src/context/database.ts +++ b/packages/client-ts/src/context/database.ts @@ -16,5 +16,6 @@ import { DbRegistryEntry } from "./db-registry"; init(): Promise info(): Promise registryEntry(): Promise + close(): Promise } \ No newline at end of file diff --git a/packages/client-ts/src/context/datastore.ts b/packages/client-ts/src/context/datastore.ts index c4b4b0ba..8b033496 100644 --- a/packages/client-ts/src/context/datastore.ts +++ b/packages/client-ts/src/context/datastore.ts @@ -1,8 +1,7 @@ const _ = require("lodash"); -import { DatastoreOpenConfig } from "./interfaces"; +import { DatabaseOpenConfig, DatastoreOpenConfig } from "./interfaces"; import Context from "./context"; import Schema from "./schema"; -import { DIDDocument } from "@verida/did-document"; /** * A datastore wrapper around a given database and schema. @@ -219,10 +218,10 @@ class Datastore { this.db = await this.context.openExternalDatabase( dbName, this.config.did!, - this.config as any + this.config ); } else { - this.db = await this.context.openDatabase(dbName, this.config as any); + this.db = await this.context.openDatabase(dbName, this.config); } let indexes = schemaJson.database.indexes; diff --git a/packages/client-ts/src/context/engines/ContextNotFoundError.ts b/packages/client-ts/src/context/engines/ContextNotFoundError.ts new file mode 100644 index 00000000..6ff991ac --- /dev/null +++ b/packages/client-ts/src/context/engines/ContextNotFoundError.ts @@ -0,0 +1,7 @@ + +export default class ContextNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = "ContextNotFoundError" + } +} \ No newline at end of file diff --git a/packages/client-ts/src/context/engines/base.ts b/packages/client-ts/src/context/engines/base.ts index dcda1d16..56ccfea0 100644 --- a/packages/client-ts/src/context/engines/base.ts +++ b/packages/client-ts/src/context/engines/base.ts @@ -4,6 +4,8 @@ import { DatabaseOpenConfig, DatastoreOpenConfig } from "../interfaces"; import Database from "../database"; import Datastore from "../datastore"; import DbRegistry from "../db-registry"; +import ContextNotFoundError from "./ContextNotFoundError"; +import { Interfaces } from "@verida/storage-link"; /** * @category @@ -13,6 +15,7 @@ class BaseStorageEngine { protected storageContext: string; protected dbRegistry: DbRegistry; protected endpointUri: string; + protected contextConfig: Interfaces.SecureContextConfig; protected account?: Account; protected keyring?: Keyring; @@ -20,16 +23,26 @@ class BaseStorageEngine { constructor( storageContext: string, dbRegistry: DbRegistry, - endpointUri: string + contextConfig: Interfaces.SecureContextConfig, ) { this.storageContext = storageContext; this.dbRegistry = dbRegistry; - this.endpointUri = endpointUri; + this.endpointUri = contextConfig.services.databaseServer.endpointUri; + this.contextConfig = contextConfig } public async connectAccount(account: Account) { - this.account = account; - this.keyring = await account.keyring(this.storageContext); + try { + this.account = account; + this.keyring = await account.keyring(this.storageContext); + } catch (err: any) { + this.account = undefined + throw new ContextNotFoundError("Unable to generate Keyring") + } + } + + public getDbRegistry() { + return this.dbRegistry } public async openDatabase( diff --git a/packages/client-ts/src/context/engines/verida/database/base-db.ts b/packages/client-ts/src/context/engines/verida/database/base-db.ts index a7ce1ef2..e249ab31 100644 --- a/packages/client-ts/src/context/engines/verida/database/base-db.ts +++ b/packages/client-ts/src/context/engines/verida/database/base-db.ts @@ -11,6 +11,7 @@ import { Context } from "../../../.."; import { DbRegistryEntry } from "../../../db-registry"; import EncryptionUtils from "@verida/encryption-utils"; import { RecordSignature } from "../../../utils" +import StorageEngineVerida from "./engine" /** * @category @@ -20,7 +21,9 @@ class BaseDb extends EventEmitter implements Database { protected databaseName: string; protected did: string; protected dsn: string; + protected token?: string; protected storageContext: string; + protected engine: StorageEngineVerida protected permissions?: PermissionsConfig; protected isOwner?: boolean; @@ -36,13 +39,15 @@ class BaseDb extends EventEmitter implements Database { // PouchDb instance for this database protected db?: any; - constructor(config: VeridaDatabaseConfig) { + constructor(config: VeridaDatabaseConfig, engine: StorageEngineVerida) { super(); this.client = config.client; this.databaseName = config.databaseName; this.did = config.did.toLowerCase(); this.dsn = config.dsn; + this.token = config.token; this.storageContext = config.storageContext; + this.engine = engine this.isOwner = config.isOwner; this.signContext = config.signContext; @@ -71,6 +76,10 @@ class BaseDb extends EventEmitter implements Database { this.db = null; } + public getEngine() { + return this.engine + } + // DID + context name + DB Name + readPerm + writePerm private buildDatabaseHash() { let text = [ @@ -79,7 +88,7 @@ class BaseDb extends EventEmitter implements Database { this.databaseName, ].join("/"); - const hash = EncryptionUtils.hash(text).substr(2); + const hash = EncryptionUtils.hash(text).substring(2); // Database name in CouchDB must start with a letter, so prepend a `v` return "v" + hash; @@ -410,7 +419,7 @@ class BaseDb extends EventEmitter implements Database { }; try { - await this.client.createDatabase(this.did, this.databaseHash, options); + await this.client.createDatabase(this.did, this.databaseName, options); // There's an odd timing issue that needs a deeper investigation await Utils.sleep(1000); } catch (err) { @@ -418,9 +427,22 @@ class BaseDb extends EventEmitter implements Database { } } + public getAccessToken() { + return this.token + } + + public async setAccessToken(token: string): Promise { + this.token = token + } + public async info(): Promise { throw new Error("Not implemented"); } + + public async close(): Promise { + throw new Error("Not implemented"); + } + } export default BaseDb; diff --git a/packages/client-ts/src/context/engines/verida/database/client.ts b/packages/client-ts/src/context/engines/verida/database/client.ts index 00cd2427..1fd6033e 100644 --- a/packages/client-ts/src/context/engines/verida/database/client.ts +++ b/packages/client-ts/src/context/engines/verida/database/client.ts @@ -1,60 +1,37 @@ import Axios from "axios"; -import { Account } from "@verida/account"; +import { Account, VeridaDatabaseAuthContext } from "@verida/account"; /** * Interface for RemoteClientAuthentication */ -interface RemoteClientAuthentication { - username: string; - signature: string; +export interface ContextAuth { + refreshToken: string; + accessToken: string; + host: string; } /** * @category * Modules */ -class DatastoreServerClient { - private serverUrl: string; +export class DatastoreServerClient { + private authContext?: VeridaDatabaseAuthContext private storageContext: string; - private authentication?: RemoteClientAuthentication; - private account?: Account; + private serviceEndpoint: string; - constructor(storageContext: string, serverUrl: string) { + constructor(storageContext: string, serviceEndpoint: string, authContext?: VeridaDatabaseAuthContext) { + this.authContext = authContext this.storageContext = storageContext; - this.serverUrl = serverUrl; + this.serviceEndpoint = serviceEndpoint } - public async setAccount(account: Account) { - this.account = account; - const did = await account.did(); - const keyring = await account.keyring(this.storageContext); - - this.authentication = { - username: did.toLowerCase(), - signature: keyring.getSeed(), - }; - } - - public async getUser(did: string) { - return this.getAxios(true).get(this.serverUrl + "user/get?did=" + did); + public async setAuthContext(authContext: VeridaDatabaseAuthContext) { + this.authContext = authContext } public async getPublicUser() { - return this.getAxios(false).get(this.serverUrl + "user/public"); - } - - public async createUser() { - if (!this.account) { - throw new Error( - "Unable to create storage account. No Verida account connected." - ); - } - - const did = await this.account!.did(); - return this.getAxios(true).post(this.serverUrl + "user/create", { - did: did, - }); + return this.getAxios().get(this.serviceEndpoint + "auth/public"); } public async createDatabase( @@ -62,7 +39,7 @@ class DatastoreServerClient { databaseName: string, config: any = {} ) { - return this.getAxios(true).post(this.serverUrl + "user/createDatabase", { + return this.getAxios(this.authContext!.accessToken).post(this.serviceEndpoint + "user/createDatabase", { did: did, databaseName: databaseName, options: config, @@ -74,14 +51,14 @@ class DatastoreServerClient { databaseName: string, config: any = {} ) { - return this.getAxios(true).post(this.serverUrl + "user/updateDatabase", { + return this.getAxios(this.authContext!.accessToken).post(this.serviceEndpoint + "user/updateDatabase", { did: did, databaseName: databaseName, options: config, }); } - private getAxios(includeAuth: boolean) { + private getAxios(accessToken?: string) { let config: any = { headers: { // @todo: Application-Name needs to become Storage-Context @@ -89,21 +66,13 @@ class DatastoreServerClient { }, }; - if (includeAuth) { - if (!this.authentication) { - throw new Error( - "Unable to authenticate as there is no authentication defined" - ); - } - - config["auth"] = { - username: this.authentication.username.replace(/:/g, "_"), - password: this.authentication.signature, - }; + if (accessToken) { + config.headers['Authorization'] = `Bearer ${accessToken}` } return Axios.create(config); } + } export default DatastoreServerClient; diff --git a/packages/client-ts/src/context/engines/verida/database/db-encrypted.ts b/packages/client-ts/src/context/engines/verida/database/db-encrypted.ts index 3df25a0c..3fcf9b79 100644 --- a/packages/client-ts/src/context/engines/verida/database/db-encrypted.ts +++ b/packages/client-ts/src/context/engines/verida/database/db-encrypted.ts @@ -1,6 +1,7 @@ import { VeridaDatabaseConfig } from "./interfaces"; import BaseDb from "./base-db"; -import DbRegistry, { DbRegistryEntry } from "../../../db-registry"; +import { DbRegistryEntry } from "../../../db-registry"; +import StorageEngineVerida from "./engine" import EncryptionUtils from "@verida/encryption-utils"; import * as PouchDBCryptLib from "pouchdb"; @@ -19,8 +20,6 @@ PouchDBCrypt.plugin(PouchDBFind); PouchDB.plugin(PouchDBFind); PouchDBCrypt.plugin(CryptoPouch); -//db = new EncryptedDatabase(databaseName, did, this.dsn!, encryptionKey, config.permissions) - /** * @category * Modules @@ -29,7 +28,6 @@ class EncryptedDatabase extends BaseDb { protected encryptionKey: Buffer; protected password?: string; - private dbRegistry: DbRegistry; private _sync: any; private _localDbEncrypted: any; private _localDb: any; @@ -46,12 +44,11 @@ class EncryptedDatabase extends BaseDb { * @param {*} did * @param {*} permissions */ - //constructor(dbHumanName: string, dbName: string, dataserver: any, encryptionKey: string | Buffer, remoteDsn: string, did: string, permissions: PermissionsConfig) { - constructor(config: VeridaDatabaseConfig, dbRegistry: DbRegistry) { - super(config); + constructor(config: VeridaDatabaseConfig, engine: StorageEngineVerida) { + super(config, engine); - this.dbRegistry = dbRegistry; this.encryptionKey = config.encryptionKey!; + this.token = config.token!; // PouchDB sync object this._sync = null; @@ -83,8 +80,33 @@ class EncryptedDatabase extends BaseDb { // Setting to 1,000 -- Any higher and it takes too long on mobile devices }); - this._remoteDbEncrypted = new PouchDB(this.dsn + this.databaseHash, { + /* @ts-ignore */ + const instance = this + this._remoteDbEncrypted = new PouchDB(`${this.dsn}/${this.databaseHash}`, { skip_setup: true, + fetch: async function(url: string, opts: any) { + opts.headers.set('Authorization', `Bearer ${instance.getAccessToken()}`) + const result = await PouchDB.fetch(url, opts) + if (result.status == 401) { + // Unauthorized, most likely due to an invalid access token + // Fetch new credentials and try again + await instance.getEngine().reAuth(instance) + opts.headers.set('Authorization', `Bearer ${instance.getAccessToken()}`) + const result = await PouchDB.fetch(url, opts) + + if (result.status == 401) { + // Failed again, so the refresh token is likely also invalid an wasn't + // able to be re-authenticated + throw new Error(`Permission denied to access server: ${instance.dsn}`) + } + + // Return an authorized result + return result + } + + // Return an authorized result + return result + } }); let info; @@ -110,6 +132,7 @@ class EncryptedDatabase extends BaseDb { const databaseName = this.databaseName; const dsn = this.dsn; + /* @ts-ignore */ const instance = this; // Do a once off sync to ensure the local database pulls all data from remote server @@ -260,10 +283,10 @@ class EncryptedDatabase extends BaseDb { }; try { - this.client.updateDatabase(this.did, this.databaseHash, options); + this.client.updateDatabase(this.did, this.databaseName, options); if (this.config.saveDatabase !== false) { - await this.dbRegistry.saveDb(this); + await this.engine.getDbRegistry().saveDb(this); } } catch (err: any) { throw new Error("User doesn't exist or unable to create user database"); diff --git a/packages/client-ts/src/context/engines/verida/database/db-public.ts b/packages/client-ts/src/context/engines/verida/database/db-public.ts index 70d8d3f9..86ab12d8 100644 --- a/packages/client-ts/src/context/engines/verida/database/db-public.ts +++ b/packages/client-ts/src/context/engines/verida/database/db-public.ts @@ -13,7 +13,6 @@ PouchDB.plugin(PouchDBFind); * Modules */ class PublicDatabase extends BaseDb { - //constructor(dbHumanName: string, dbName: string, dataserver: any, did: string, permissions: PermissionsConfig, isOwner: boolean) { private _remoteDb: any; public async init() { @@ -24,10 +23,37 @@ class PublicDatabase extends BaseDb { await super.init(); const databaseName = this.databaseName; - - this._remoteDb = new PouchDB(this.dsn + this.databaseHash, { + const dbConfig: any = { skip_setup: true, - }); + } + + if (this.token) { + const instance = this + dbConfig['fetch'] = async function(url: string, opts: any) { + opts.headers.set('Authorization', `Bearer ${instance.getAccessToken()}`) + const result = await PouchDB.fetch(url, opts) + if (result.status == 401) { + // Unauthorized, most likely due to an invalid access token + // Fetch new credentials and try again + await instance.getEngine().reAuth(instance) + + opts.headers.set('Authorization', `Bearer ${instance.getAccessToken()}`) + const result = await PouchDB.fetch(url, opts) + + if (result.status == 401) { + throw new Error(`Permission denied to access server: ${this.dsn}`) + } + + // Return an authorized result + return result + } + + // Return an authorized result + return result + } + } + + this._remoteDb = new PouchDB(`${this.dsn}/${this.databaseHash}`, dbConfig); try { let info = await this._remoteDb.info(); diff --git a/packages/client-ts/src/context/engines/verida/database/engine.ts b/packages/client-ts/src/context/engines/verida/database/engine.ts index 5bd5986e..22462fa9 100644 --- a/packages/client-ts/src/context/engines/verida/database/engine.ts +++ b/packages/client-ts/src/context/engines/verida/database/engine.ts @@ -2,10 +2,13 @@ import BaseStorageEngine from "../../base"; import EncryptedDatabase from "./db-encrypted"; import Database from "../../../database"; import { DatabaseOpenConfig } from "../../../interfaces"; -import DatastoreServerClient from "./client"; +import { DatastoreServerClient } from "./client"; import { Account } from "@verida/account"; import PublicDatabase from "./db-public"; import DbRegistry from "../../../db-registry"; +import { Interfaces } from "@verida/storage-link"; +import { VeridaDatabaseAuthContext, VeridaDatabaseAuthTypeConfig } from "@verida/account" +import BaseDb from "./base-db"; const _ = require("lodash"); @@ -16,21 +19,20 @@ const _ = require("lodash"); class StorageEngineVerida extends BaseStorageEngine { private client: DatastoreServerClient; - private publicCredentials: any; // @todo - + private publicCredentials: any; // @todo fix typing private accountDid?: string; - private dsn?: string; + private auth?: VeridaDatabaseAuthContext - // @todo: dbmanager + // @todo: specify device id // deviceId: string="Test device" constructor( storageContext: string, dbRegistry: DbRegistry, - endpointUri: string + contextConfig: Interfaces.SecureContextConfig, ) { - super(storageContext, dbRegistry, endpointUri); + super(storageContext, dbRegistry, contextConfig); this.client = new DatastoreServerClient( this.storageContext, - this.endpointUri + contextConfig.services.databaseServer.endpointUri ); } @@ -38,38 +40,17 @@ class StorageEngineVerida extends BaseStorageEngine { try { await super.connectAccount(account); - await this.client.setAccount(account); - this.accountDid = await this.account!.did(); + const auth = await account.getAuthContext(this.storageContext, this.contextConfig) + this.auth = auth + await this.client.setAuthContext(this.auth) - // Fetch user details from server - let response; - try { - response = await this.client.getUser(this.accountDid!); - } catch (err: any) { - if ( - err.response && - err.response.data.data && - err.response.data.data.did == "Invalid DID specified" - ) { - // User doesn't exist, so create them - response = await this.client.createUser(); - } else if (err.response && err.response.statusText == "Unauthorized") { - throw new Error( - "Invalid signature or permission to access DID server" - ); - } else { - // Unknown error - throw err; - } + this.accountDid = await this.account!.did(); + } catch (err: any) { + if (err.name == "ContextNotFoundError") { + return } - const user = response.data.user; - this.dsn = user.dsn; - } catch (err: any) { - //console.log(err) - // Connecting the account may fail. - // For example, the user is connect via `account-web-vault` and doesn't have - // a keyring for the context associated with this storage engine + throw err } } @@ -88,29 +69,22 @@ class StorageEngineVerida extends BaseStorageEngine { * @param did * @returns {string} */ - protected async buildExternalDsn(endpointUri: string): Promise { - const client = new DatastoreServerClient(this.storageContext, endpointUri); - await client.setAccount(this.account!); - let response; - try { - response = await client.getUser(this.accountDid!); - } catch (err: any) { - if ( - err.response && - err.response.data.data && - err.response.data.data.did == "Invalid DID specified" - ) { - // User doesn't exist, so create on this endpointUri server - response = await client.createUser(); - } else if (err.response && err.response.statusText == "Unauthorized") { - throw new Error("Invalid signature or permission to access DID server"); - } else { - // Unknown error - throw err; - } + protected async buildExternalAuth(endpointUri: string): Promise { + if (!this.account) { + throw new Error('Unable to connect to external storage node. No account connected.') } - return response.data.user.dsn; + const auth = await this.account!.getAuthContext(this.storageContext, this.contextConfig, { + endpointUri + }) + + return auth + + /*const client = new DatastoreServerClient(this.storageContext, this.contextConfig); + await client.setAccount(this.account!); + + const auth = await client.getContextAuth(); + return auth*/ } /** @@ -136,8 +110,15 @@ class StorageEngineVerida extends BaseStorageEngine { options ); + const instance = this + + const contextName = config.contextName ? config.contextName : this.storageContext + // Default to user's account did if not specified - config.isOwner = config.did == this.accountDid; + if (typeof(config.isOwner) == 'undefined') { + config.isOwner = config.did == this.accountDid; + } + config.saveDatabase = config.isOwner; // always save this database to registry if user is the owner let did = config.did!.toLowerCase(); @@ -170,9 +151,14 @@ class StorageEngineVerida extends BaseStorageEngine { } } - let dsn = config.isOwner ? this.dsn! : config.dsn!; + let dsn = config.isOwner ? this.auth!.host! : config.dsn!; if (!dsn) { - throw new Error("Unable to determine DSN for this user and this context"); + throw new Error(`Unable to determine DSN for this user (${did}) and this context (${contextName})`); + } + + let token = config.isOwner ? this.auth!.accessToken : config.token!; + if (!dsn) { + throw new Error(`Unable to determine DSN for this user (${did}) and this context (${contextName})`); } // force read only access if the current user doesn't have write access @@ -208,9 +194,10 @@ class StorageEngineVerida extends BaseStorageEngine { { databaseName, did, - storageContext: this.storageContext, + storageContext: contextName, signContext: options.signingContext!, dsn, + token, permissions: config.permissions, readOnly: config.readOnly, encryptionKey, @@ -218,7 +205,7 @@ class StorageEngineVerida extends BaseStorageEngine { isOwner: config.isOwner, saveDatabase: config.saveDatabase, }, - this.dbRegistry + this ); await db.init(); @@ -239,14 +226,15 @@ class StorageEngineVerida extends BaseStorageEngine { databaseName, did, dsn, - storageContext: this.storageContext, + token, + storageContext: contextName, signContext: options.signingContext!, permissions: config.permissions, readOnly: config.readOnly, client: this.client, isOwner: config.isOwner, saveDatabase: config.saveDatabase, - }); + }, this); await db.init(); return db; @@ -276,9 +264,11 @@ class StorageEngineVerida extends BaseStorageEngine { * - Need to talk to the db hash for the did that owns the database */ - if (!config.isOwner) { + if (!config.isOwner && this.account) { // need to build a complete dsn - dsn = await this.buildExternalDsn(config.dsn!); + const auth = await this.buildExternalAuth(config.dsn!); + dsn = auth.host; + token = auth.accessToken; } const storageContextKey = await this.keyring!.getStorageContextKey( @@ -292,9 +282,10 @@ class StorageEngineVerida extends BaseStorageEngine { { databaseName, did, - storageContext: this.storageContext, + storageContext: contextName, signContext: options.signingContext!, dsn, + token, permissions: config.permissions, readOnly: config.readOnly, encryptionKey, @@ -302,7 +293,7 @@ class StorageEngineVerida extends BaseStorageEngine { isOwner: config.isOwner, saveDatabase: config.saveDatabase, }, - this.dbRegistry + this ); try { @@ -332,11 +323,49 @@ class StorageEngineVerida extends BaseStorageEngine { }*/ } + + /** + * Re-authenticate this storage engine and update the credentials + * for the database. + * + * This is called by the internal fetch() methods when they detect an invalid access token + */ + public async reAuth(db: BaseDb) { + if (!this.account) { + // No account connected, so can't reconnect database + const info = await db.info() + throw new Error(`No account connected. Access token expired, but unable to regenerate for database ${info.databaseName}`) + } + + let auth + try { + // Attempt to re-authenticate using the refresh token and ignoring the access token (its invalid) + auth = await this.account!.getAuthContext(this.storageContext, this.contextConfig, { + invalidAccessToken: true + }) + } catch (err: any) { + if (err.name == 'ContextAuthorizationError') { + // The refresh token is invalid + // Force a new connection, this will cause a new single sign in popup if in a web environment + // and using account-web-vault + auth = await this.account!.getAuthContext(this.storageContext, this.contextConfig, { + force: true + }) + } else { + throw err + } + } + + this.auth = auth + await this.client.setAuthContext(this.auth) + await db.setAccessToken(this.auth!.accessToken!) + } + public logout() { super.logout(); this.client = new DatastoreServerClient( this.storageContext, - this.endpointUri + this.contextConfig.services.databaseServer.endpointUri ); } diff --git a/packages/client-ts/src/context/engines/verida/database/interfaces.ts b/packages/client-ts/src/context/engines/verida/database/interfaces.ts index f97519c0..c297cb74 100644 --- a/packages/client-ts/src/context/engines/verida/database/interfaces.ts +++ b/packages/client-ts/src/context/engines/verida/database/interfaces.ts @@ -9,6 +9,7 @@ export interface VeridaDatabaseConfig { databaseName: string; did: string; dsn: string; + token?: string; storageContext: string; permissions?: PermissionsConfig; diff --git a/packages/client-ts/src/context/interfaces.ts b/packages/client-ts/src/context/interfaces.ts index 8d5bbb2c..f02261d4 100644 --- a/packages/client-ts/src/context/interfaces.ts +++ b/packages/client-ts/src/context/interfaces.ts @@ -17,10 +17,15 @@ export interface DatabaseOpenConfig { did?: string; /** - * Specify a specific database connection string to use when opening the database. + * Specify a database connection string to use when opening the database. */ dsn?: string; + /** + * Specify a JWT token to use when opening the database. + */ + token?: string; + /** * Save this database into the user's master list of opened databases. */ @@ -59,6 +64,11 @@ export interface DatabaseOpenConfig { * Optionally specify the context used to sign data */ signingContext?: Context; + + /** + * Ignore any cached instance already created + */ + ignoreCache?: boolean } // @todo: Same as DatabaseOpenConfig diff --git a/packages/client-ts/test/config.ts b/packages/client-ts/test/config.ts index 92f9977a..04f49952 100644 --- a/packages/client-ts/test/config.ts +++ b/packages/client-ts/test/config.ts @@ -18,5 +18,15 @@ export default { type: 'VeridaMessage', endpointUri: 'https://db.testnet.verida.io:5002/' }, + }, + INVALID_ENDPOINTS: { // endpoints that resolve to non-existant storage node + defaultDatabaseServer: { + type: 'VeridaDatabase', + endpointUri: 'http://localhost:6000/' + }, + defaultMessageServer: { + type: 'VeridaMessage', + endpointUri: 'http://localhost:6000/' + }, } } \ No newline at end of file diff --git a/packages/client-ts/test/storage.context.tests.ts b/packages/client-ts/test/storage.context.tests.ts index 9b70ff0f..8fec6d19 100644 --- a/packages/client-ts/test/storage.context.tests.ts +++ b/packages/client-ts/test/storage.context.tests.ts @@ -8,6 +8,8 @@ import { DIDDocument } from '@verida/did-document' import CONFIG from './config' const TEST_DB_NAME = 'TestDb_1' +const TEST_DB_NAME_2 = 'TestDb_2' +const TEST_DB_NAME_3 = 'TestDb_3' /** * @@ -57,6 +59,26 @@ describe('Storage context tests', () => { assert.ok(data.length && data.length > 0, 'Array returned with at least one row') assert.ok(data[0].hello == 'world', 'First result has expected value') }) + + it('can open same database at once and both return the same cache entry', async () => { + const database1 = context.openDatabase(TEST_DB_NAME_2) + const database2 = context.openDatabase(TEST_DB_NAME_2) + + await Promise.all([database1, database2]).then(([db1, db2]) => { + assert.ok(db1 === db2, 'Returned databases are the same') + }) + }) + + it('can respect ignore cache when opening a database', async () => { + const database1 = context.openDatabase(TEST_DB_NAME_3) + const database2 = context.openDatabase(TEST_DB_NAME_3, { + ignoreCache: true + }) + + await Promise.all([database1, database2]).then(([db1, db2]) => { + assert.ok(db1 !== db2, 'Returned databases are not the same') + }) + }) }) }) \ No newline at end of file diff --git a/packages/client-ts/test/verida/database.auth.tests.ts b/packages/client-ts/test/verida/database.auth.tests.ts new file mode 100644 index 00000000..96457637 --- /dev/null +++ b/packages/client-ts/test/verida/database.auth.tests.ts @@ -0,0 +1,160 @@ +'use strict' +const assert = require('assert') + +import { Client } from '../../src/index' +import { AutoAccount, AuthContextAccount } from '@verida/account-node' +import CONFIG from '../config' + +const DB_NAME_OWNER = 'OwnerTestDb_1' +const DB_NAME_OWNER_2 = 'OwnerTestDb_2' + +const VALID_CONTEXT = 'Verida Testing: Authentication' +const INVALID_CONTEXT = 'Verida Testing: Authentication - Invalid' + + +/** + * + * + */ +describe('Verida auth tests', () => { + let invalidContext, invalidDid + + const account = new AutoAccount(CONFIG.DEFAULT_ENDPOINTS, { + privateKey: CONFIG.VDA_PRIVATE_KEY, + didServerUrl: CONFIG.DID_SERVER_URL, + environment: CONFIG.ENVIRONMENT + }) + + const invalidAccount = new AutoAccount(CONFIG.INVALID_ENDPOINTS, { + privateKey: CONFIG.VDA_PRIVATE_KEY, + didServerUrl: CONFIG.DID_SERVER_URL, + environment: CONFIG.ENVIRONMENT + }) + + const invalidNetwork = new Client({ + didServerUrl: CONFIG.DID_SERVER_URL, + environment: CONFIG.ENVIRONMENT + }) + + describe('Handle authentication errors', function() { + this.timeout(10000) + + // Handle errors where the storage node API is down, so unable to authenticate + it('can handle authentication connection error', async function() { + invalidDid = await invalidAccount.did() + await invalidNetwork.connect(invalidAccount) + invalidContext = await invalidNetwork.openContext(INVALID_CONTEXT, true) + + const promise = new Promise(async (resolve, rejects) => { + try { + await invalidContext.openDatabase(DB_NAME_OWNER) + } catch (err) { + // Expect a connection error + resolve(err) + } + }) + + const result = await promise + const expectedMessage = `Unable to connect to storage node (http://localhost:6000/): connect ECONNREFUSED 127.0.0.1:6000` + assert.deepEqual(result, new Error(expectedMessage)) + }) + }) + + describe('Handle token expiry', function() { + this.timeout(2000) + + it('can handle accessToken expiry for an encrypted database', async function() { + // Create a working connection + const network = new Client({ + didServerUrl: CONFIG.DID_SERVER_URL, + environment: CONFIG.ENVIRONMENT + }) + + const network2 = new Client({ + didServerUrl: CONFIG.DID_SERVER_URL, + environment: CONFIG.ENVIRONMENT + }) + + await network.connect(account) + const context = await network.openContext(VALID_CONTEXT, true) + + // 1/ get the context auth + const authContext = await context.getAuthContext() + + // Manually set to an invalid access token so the refreshToken is needed to re-authenticate + authContext.accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImVIUTlLTFprbE9UVERjaU1TOXFOY1Nzb0hBUUJSbUxDbEQ3d2pDUnBsSXNSUE45emZMdHBSM0JlM3d0cXRVVHlFakVPcVhpS2NZeG5zZHBWWTlWRWE3TmUwNUtsUElPUTQxSlpJQktHWW1NYjE2ajRkWkZxVThlek1VZFR1SnRFYmNKUlY0M09JWXE4OTF0ZXY0TDhKY2xPdzBZV1BweTFoMmpKTHkzblNJNVZsZ3RaVG1HVmI5cXlDODRrVnd1NGsySzVvN1VuOFkyUmNWbGpEa2lZRXpaanBxbzlkc0l4Mkh0UGNQcE9TVDhVM05WU29TWjFibzNBdU5CRjkxbE4iLCJkaWQiOiJkaWQ6dmRhOjB4ZTcwZTYyODQyNzg4MGFiMDFiZTU1YTM0OWYxY2VmMDQ3OWIyMWJmOCIsInN1YiI6InZmNWYxOWYyYzcyYTFmNDllYjcwMDIxNWMwNGVhMGM4NjVmMjc4ZTA4MGNkMDU5Njc4NjVhMmE5MTA0YWUzNjUzIiwiY29udGV4dE5hbWUiOiJWZXJpZGEgVGVzdGluZzogQXV0aGVudGljYXRpb24iLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNjYxNDE5ODQ2LCJleHAiOjE2NjE0MTk5MDZ9.tHugqMHM_udE-eV0u2_2oCjzDSiljfK8DVieK-GcXcU' + + // 2/ initialise a new testing account that has a single context with an invalid access token + const account2 = new AuthContextAccount(CONFIG.DEFAULT_ENDPOINTS, { + privateKey: CONFIG.VDA_PRIVATE_KEY, + didServerUrl: CONFIG.DID_SERVER_URL, + environment: CONFIG.ENVIRONMENT + }, + VALID_CONTEXT, + authContext) + + // 3/ initiate a new context using the context auth + await network2.connect(account2) + const context2 = await network2.openContext(VALID_CONTEXT) + + // 4/ Create a new context which will use the refresh token to create a new access token + const db2 = await context2?.openDatabase(DB_NAME_OWNER, { + saveDatabase: false + }) + + assert.ok(db2, 'Have successfully opened a database') + + const rows = await db2!.getMany() + assert.ok(rows && rows.length, 'Have valid results returned') + }) + + it('can handle refreshToken expiry for an encrypted database', async function() { + // Create a working connection + const network = new Client({ + didServerUrl: CONFIG.DID_SERVER_URL, + environment: CONFIG.ENVIRONMENT + }) + + const network2 = new Client({ + didServerUrl: CONFIG.DID_SERVER_URL, + environment: CONFIG.ENVIRONMENT + }) + + await network.connect(account) + const context = await network.openContext(VALID_CONTEXT, true) + + // 1/ get the context auth + const authContext = await context.getAuthContext() + + // Manually set to an invalid access token so the refreshToken is needed to re-authenticate + authContext.accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImVIUTlLTFprbE9UVERjaU1TOXFOY1Nzb0hBUUJSbUxDbEQ3d2pDUnBsSXNSUE45emZMdHBSM0JlM3d0cXRVVHlFakVPcVhpS2NZeG5zZHBWWTlWRWE3TmUwNUtsUElPUTQxSlpJQktHWW1NYjE2ajRkWkZxVThlek1VZFR1SnRFYmNKUlY0M09JWXE4OTF0ZXY0TDhKY2xPdzBZV1BweTFoMmpKTHkzblNJNVZsZ3RaVG1HVmI5cXlDODRrVnd1NGsySzVvN1VuOFkyUmNWbGpEa2lZRXpaanBxbzlkc0l4Mkh0UGNQcE9TVDhVM05WU29TWjFibzNBdU5CRjkxbE4iLCJkaWQiOiJkaWQ6dmRhOjB4ZTcwZTYyODQyNzg4MGFiMDFiZTU1YTM0OWYxY2VmMDQ3OWIyMWJmOCIsInN1YiI6InZmNWYxOWYyYzcyYTFmNDllYjcwMDIxNWMwNGVhMGM4NjVmMjc4ZTA4MGNkMDU5Njc4NjVhMmE5MTA0YWUzNjUzIiwiY29udGV4dE5hbWUiOiJWZXJpZGEgVGVzdGluZzogQXV0aGVudGljYXRpb24iLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNjYxNDE5ODQ2LCJleHAiOjE2NjE0MTk5MDZ9.tHugqMHM_udE-eV0u2_2oCjzDSiljfK8DVieK-GcXcU' + authContext.refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImVIUTlLTFprbE9UVERjaU1TOXFOY1Nzb0hBUUJSbUxDbEQ3d2pDUnBsSXNSUE45emZMdHBSM0JlM3d0cXRVVHlFakVPcVhpS2NZeG5zZHBWWTlWRWE3TmUwNUtsUElPUTQxSlpJQktHWW1NYjE2ajRkWkZxVThlek1VZFR1SnRFYmNKUlY0M09JWXE4OTF0ZXY0TDhKY2xPdzBZV1BweTFoMmpKTHkzblNJNVZsZ3RaVG1HVmI5cXlDODRrVnd1NGsySzVvN1VuOFkyUmNWbGpEa2lZRXpaanBxbzlkc0l4Mkh0UGNQcE9TVDhVM05WU29TWjFibzNBdU5CRjkxbE4iLCJkaWQiOiJkaWQ6dmRhOjB4ZTcwZTYyODQyNzg4MGFiMDFiZTU1YTM0OWYxY2VmMDQ3OWIyMWJmOCIsInN1YiI6InZmNWYxOWYyYzcyYTFmNDllYjcwMDIxNWMwNGVhMGM4NjVmMjc4ZTA4MGNkMDU5Njc4NjVhMmE5MTA0YWUzNjUzIiwiY29udGV4dE5hbWUiOiJWZXJpZGEgVGVzdGluZzogQXV0aGVudGljYXRpb24iLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNjYxNDE5ODQ2LCJleHAiOjE2NjE0MTk5MDZ9.tHugqMHM_udE-eV0u2_2oCjzDSiljfK8DVieK-GcXcU' + + // 2/ initialise a new testing account that has a single context with an invalid access and refresh token + const account2 = new AuthContextAccount(CONFIG.DEFAULT_ENDPOINTS, { + privateKey: CONFIG.VDA_PRIVATE_KEY, + didServerUrl: CONFIG.DID_SERVER_URL, + environment: CONFIG.ENVIRONMENT + }, + VALID_CONTEXT, + authContext) + + // 3/ initiate a new context using the context auth + await network2.connect(account2) + const context2 = await network2.openContext(VALID_CONTEXT) + + // 4/ Create a new context which should re-authenticate the refresh token + const db2 = await context2?.openDatabase(DB_NAME_OWNER, { + saveDatabase: false + }) + + assert.ok(db2, 'Have successfully opened a database') + + const rows = await db2!.getMany() + assert.ok(rows && rows.length, 'Have valid results returned') + }) + + // @todo: test with public database, not encrypted database + + }) +}) \ No newline at end of file diff --git a/packages/client-ts/test/verida/database.context.tests.ts b/packages/client-ts/test/verida/database.context.tests.ts index 52b76c9a..396972d3 100644 --- a/packages/client-ts/test/verida/database.context.tests.ts +++ b/packages/client-ts/test/verida/database.context.tests.ts @@ -47,7 +47,6 @@ describe('Verida database tests relating to contexts', () => { did1 = await account1.did() await network.connect(account1) context = await network.openContext(CONTEXT_1, true) - const database = db1 = await context.openDatabase(DB_NAME_PUBLIC_WRITE, { permissions: { read: 'public', diff --git a/packages/client-ts/test/verida/messaging.tests.ts b/packages/client-ts/test/verida/messaging.tests.ts index df29623e..46706e3f 100644 --- a/packages/client-ts/test/verida/messaging.tests.ts +++ b/packages/client-ts/test/verida/messaging.tests.ts @@ -18,8 +18,8 @@ const MESSAGE_DATA = { }] } -const CONTEXT_1 = "Verida Testing: Messaging Application 1" -const CONTEXT_2 = "Verida Testing: Messaging Application 2" +const CONTEXT_1 = "Verida Tests: Messaging Application 1" +const CONTEXT_2 = "Verida Tests: Messaging Application 2" /** * diff --git a/yarn.lock b/yarn.lock index 95730783..422ca3ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1979,6 +1979,14 @@ axios@^0.23.0: dependencies: follow-redirects "^1.14.4" +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -2121,6 +2129,11 @@ buffer-alloc@^1.2.0: buffer-alloc-unsafe "^1.1.0" buffer-fill "^1.0.0" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-fill@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" @@ -2459,7 +2472,7 @@ columnify@^1.5.4: strip-ansi "^3.0.0" wcwidth "^1.0.0" -combined-stream@^1.0.6, combined-stream@~1.0.6: +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -3012,6 +3025,13 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + elliptic@6.5.4, elliptic@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" @@ -3423,6 +3443,11 @@ follow-redirects@^1.14.4: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379" integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g== +follow-redirects@^1.14.9: + version "1.15.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" + integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -3433,6 +3458,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -4506,6 +4540,22 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= +jsonwebtoken@^8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -4516,6 +4566,23 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -4735,11 +4802,46 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + lodash.ismatch@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc= +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + lodash.set@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"