Skip to content

Commit

Permalink
Feature/181 support updated auth storage node (#225)
Browse files Browse the repository at this point in the history
* Better handling opening a context without an account. Update storage node consent message.
* Support CouchDB token auth for public and encrypted databases.
* Include token with user permissions
* Add more progress
* Adding context auth interface
* Remove redundant contextAuth
* Core authJWT refactor complete. Still need edge cases and fixing some tests.
* Fix deprecated substr() reference
* Update context name
* Support manual injection of contxt name
* Move getAuthContext into account. Refactor.
* Support caching auth contexts
* Refactor auth context to be implemented in account instance. Better separation of auth context and client operations.
* Support managing auth contexts and disconnecting devices. Support Auth Context Account type to help with testing.
* Refactor to support authentication retry when access / refresh token expires.
* Support getAuthContext in vault-account (untested)
* Fix typescript issues.
* Cache opened databases to avoid opening multiple and creating lots of open sockets.
* Support account-web-vault handling invalid access token or refresh token.
* Fix error handling with promises
* Fix refresh token expiry with account-node
* Support deviceId and wallet connect params when logging in.
* Validate access and refresh tokens when auto-logging in.
* Update HTTP status code checks to match changes in storage-node.
* Add INVALID_ENDPOINTS to testing config
* Replace deviceId with userAgent
* Point to live docs
  • Loading branch information
tahpot authored Oct 10, 2022
1 parent 574d872 commit 5099c6d
Show file tree
Hide file tree
Showing 31 changed files with 1,058 additions and 202 deletions.
3 changes: 2 additions & 1 deletion packages/account-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
165 changes: 165 additions & 0 deletions packages/account-node/src/authTypes/VeridaDatabase.ts
Original file line number Diff line number Diff line change
@@ -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 = <AutoAccount> account
}

public async getAuthContext(config: VeridaDatabaseAuthTypeConfig = {
deviceId: "Test device",
force: false
}): Promise<VeridaDatabaseAuthContext> {
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<boolean> {
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);
}

}
44 changes: 44 additions & 0 deletions packages/account-node/src/authcontext.ts
Original file line number Diff line number Diff line change
@@ -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<AuthContext> {
return super.getAuthContext(contextName, contextConfig, authConfig, authType)
}

}
33 changes: 32 additions & 1 deletion packages/account-node/src/auto.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +18,7 @@ export default class AutoAccount extends Account {

private wallet: Wallet
protected accountConfig: AccountConfig
protected contextAuths: Record<string, AuthType> = {}

constructor(accountConfig: AccountConfig, autoConfig: NodeAccountConfig) {
super()
Expand Down Expand Up @@ -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<AuthContext> {
// 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<boolean> {
if (!this.contextAuths[contextName]) {
throw new Error(`Context not connected ${contextName}`)
}

return this.contextAuths[contextName].disconnectDevice(deviceId)
}

}
6 changes: 5 additions & 1 deletion packages/account-node/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion packages/account-node/src/limited.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion packages/account-web-vault/README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@

@todo
See https://developers.verida.io/docs/single-sign-on-sdk
1 change: 1 addition & 0 deletions packages/account-web-vault/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions packages/account-web-vault/src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Loading

0 comments on commit 5099c6d

Please sign in to comment.