diff --git a/README.md b/README.md index 73b20471..4eecccd0 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,15 @@ By default, `ENABLE_AUTHENTICATION` is set to off/`false`. To enable external Ve 5. **Miscellaneous** 1. `COOKIE_SECRET`: Secret for cookie encryption. +#### Faucet settings + +This section describes bootstrapping things for newcomers accounts. If it's enabled the CredentialService auto-populates some tokens on the testnet for making the process simpler. + +1. `FAUCET_ENABLED` - enable/disable such functionality (`false` by default) +2. `FAUCET_URI` - URI when the faucet service is located (`https://faucet-api.cheqd.network/credit` by default) +3. `FAUCET_DENOM` - the denom of token to assign (`ncheq` by default) +4. `TESTNET_MINIMUM_BALANCE` - the minimum amount of tokens for being on testnet account. Be default it's amount, required for creating a DID + ### 3rd Party Connectors The app supports 3rd party connectors for credential storage and delivery. diff --git a/docker/Dockerfile b/docker/Dockerfile index c226cdf0..9efa5007 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -75,6 +75,10 @@ ARG VERIDA_NETWORK=testnet ARG POLYGON_RPC_URL=https://rpc-mumbai.maticvigil.com ARG VERIDA_PRIVATE_KEY ARG POLYGON_PRIVATE_KEY +ARG FAUCET_ENABLED=false +ARG FAUCET_URI=https://faucet-api.cheqd.network/credit +ARG FAUCET_DENOM=ncheq +ARG TESTNET_MINIMUM_BALANCE=50000000000 # Environment variables: base configuration ENV NPM_CONFIG_LOGLEVEL ${NPM_CONFIG_LOGLEVEL} @@ -106,6 +110,12 @@ ENV LOGTO_MANAGEMENT_API ${LOGTO_MANAGEMENT_API} ENV LOGTO_DEFAULT_ROLE_ID ${LOGTO_DEFAULT_ROLE_ID} ENV LOGTO_WEBHOOK_SECRET ${LOGTO_WEBHOOK_SECRET} +# Faucet setup +ENV FAUCET_ENABLED ${FAUCET_ENABLED} +ENV FAUCET_URI ${FAUCET_URI} +ENV FAUCET_DENOM ${FAUCET_DENOM} +ENV TESTNET_MINIMUM_BALANCE ${TESTNET_MINIMUM_BALANCE} + # Environment variables: Verida connector ENV ENABLE_VERIDA_CONNECTOR ${ENABLE_VERIDA_CONNECTOR} ENV VERIDA_NETWORK ${VERIDA_NETWORK} diff --git a/example.env b/example.env index f6e2989a..3491fea4 100644 --- a/example.env +++ b/example.env @@ -24,6 +24,12 @@ LOGTO_DEFAULT_ROLE_ID="sdf...sdf" LOGTO_WEBHOOK_SECRET="sdf...sdf" COOKIE_SECRET="sdf...sdf" +# Faucet settings +FAUCET_ENABLED="false" +FAUCET_URI="https://faucet-api.cheqd.network/credit" +FAUCET_DENOM="ncheq" +TESTNET_MINIMUM_BALANCE="50000000000" + # Verida ENABLE_VERIDA_CONNECTOR="false" VERIDA_PRIVATE_KEY="akjvncanv....avoa" diff --git a/package-lock.json b/package-lock.json index 41f833c4..05676e77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@cheqd/did-provider-cheqd": "^3.6.1", + "@cheqd/sdk": "^3.6.1", "@cosmjs/amino": "^0.31.0", "@cosmjs/encoding": "^0.31.0", "@logto/express": "^2.0.2", @@ -2566,9 +2567,9 @@ } }, "node_modules/@cheqd/sdk": { - "version": "3.5.8", - "resolved": "https://registry.npmjs.org/@cheqd/sdk/-/sdk-3.5.8.tgz", - "integrity": "sha512-MZx+2XfHlGW5i1NMJ4X9RvfsSZ33fdrFaHypO45o6sfd/42vGlQN9bN/aaS+gUPw6VvwI+OV5XMjcq3ZpciOtg==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@cheqd/sdk/-/sdk-3.6.1.tgz", + "integrity": "sha512-i6NIkENo1rwxtptFR0C0xtPQj1JpZSGfDm0yk+adyoQzMqHxqz62eJG+8Vwh/h1UHjxvjjEghy+Rtvr4io9hFQ==", "dependencies": { "@cheqd/ts-proto": "^3.3.0", "@cosmjs/amino": "^0.31.0", @@ -2580,11 +2581,13 @@ "@cosmjs/tendermint-rpc": "^0.31.0", "@cosmjs/utils": "^0.31.0", "@stablelib/ed25519": "^1.0.3", + "@types/secp256k1": "^4.0.3", "cosmjs-types": "^0.8.0", "did-jwt": "^7.2.4", "did-resolver": "^4.1.0", "file-type": "^18.5.0", "multiformats": "^12.0.1", + "secp256k1": "^5.0.0", "uuid": "^9.0.0" }, "engines": { @@ -9595,7 +9598,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/secp256k1/-/secp256k1-4.0.3.tgz", "integrity": "sha512-Da66lEIFeIz9ltsdMZcpQvmrmmoqrfju8pm1BH8WbYjZSwUgCwXLb9C+9XYogwBITnbsSaMdVPb2ekf7TV+03w==", - "dev": true, "dependencies": { "@types/node": "*" } diff --git a/package.json b/package.json index d9ad83e1..7b5ae33f 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "README.md" ], "dependencies": { + "@cheqd/sdk": "^3.6.1", "@cheqd/did-provider-cheqd": "^3.6.1", "@cosmjs/amino": "^0.31.0", "@cosmjs/encoding": "^0.31.0", diff --git a/src/app.ts b/src/app.ts index e776b5fa..dc3dd978 100644 --- a/src/app.ts +++ b/src/app.ts @@ -125,7 +125,7 @@ class App { app.get(`/account`, new AccountController().get) // LogTo webhooks - app.post(`/account/set-default-role`, LogToWebHook.verifyHookSignature, new AccountController().setupDefaultRole) + app.post(`/account/bootstrap`, LogToWebHook.verifyHookSignature, new AccountController().bootstrap) // LogTo user info app.get('/auth/user-info', async (req, res) => { diff --git a/src/controllers/customer.ts b/src/controllers/customer.ts index ed6cdf28..b4eb703d 100644 --- a/src/controllers/customer.ts +++ b/src/controllers/customer.ts @@ -1,8 +1,11 @@ import type { Request, Response } from 'express' +import { checkBalance } from '@cheqd/sdk' import { CustomerService } from '../services/customer.js' import { LogToHelper } from '../middleware/auth/logto.js' +import { FaucetHelper } from '../helpers/faucet.js' import { StatusCodes } from 'http-status-codes' +import { LogToWebHook } from '../middleware/hook.js' export class AccountController { @@ -36,8 +39,17 @@ export class AccountController { error: `Error creating customer. Please try again` }) } + // Send some tokens for testnet + if (process.env.FAUCET_ENABLED === 'true') { + const resp = await FaucetHelper.delegateTokens(customer.address) + if (resp.status !== StatusCodes.OK) { + return response.status(resp.status).json({ + error: resp.error}) + } + } return response.status(StatusCodes.OK).json({ customerId: customer.customerId, + address: customer.address, }) } catch (error) { return response.status(StatusCodes. INTERNAL_SERVER_ERROR).json({ @@ -106,4 +118,74 @@ export class AccountController { } return response.status(StatusCodes.BAD_REQUEST).json({}) } + + public async bootstrap(request: Request, response: Response) { + // 1. Check that the customer exists + // 1.1 If not, create it + // 2. Get the user Roles + // 2.1 If list of roles is empty - assign default role + // 2.2 Create custom_data and update the userInfo (send it to the LogTo) + // 3. Check the token balance for Testnet account + // 3.1 If it's less then required for DID creation - assign new portion from testnet-faucet + const customerId: string = response.locals.customerId || LogToWebHook.getCustomerId(request); + const logToHelper = new LogToHelper() + const _r = await logToHelper.setup() + if (_r.status !== StatusCodes.OK) { + return response.status(StatusCodes.BAD_GATEWAY).json({ + error: _r.error}) + } + // 1. Check that the customer exists + let customer : any = await CustomerService.instance.get(customerId) + if (!customer) { + customer = await CustomerService.instance.create(customerId) + } + // 2. Get the user's roles + const roles = await logToHelper.getRolesForUser(customerId) + if (roles.status !== StatusCodes.OK) { + return response.status(StatusCodes.BAD_GATEWAY).json({ + error: roles.error}) + } + + // 2.1 If list of roles is empty and the user is not suspended - assign default role + if (roles.data.length === 0 && !LogToWebHook.isUserSuspended(request)) { + const _r = await logToHelper.setDefaultRoleForUser(customerId) + if (_r.status !== StatusCodes.OK) { + return response.status(StatusCodes.BAD_GATEWAY).json({ + error: _r.error}) + } + } + + const customDataFromLogTo = await logToHelper.getCustomData(customerId) + + // 2.2 Create custom_data and update the userInfo (send it to the LogTo) + if (Object.keys(customDataFromLogTo.data).length === 0 && customer.address) { + const customData = { + cosmosAccounts: { + testnet: customer.address + } + } + const _r = await logToHelper.updateCustomData(customerId, customData) + if (_r.status !== 200) { + return response.status(_r.status).json({ + error: _r.error}) + } + } + + // 3. Check the token balance for Testnet account + if (customer.address && process.env.FAUCET_ENABLED === 'true') { + const balances = await checkBalance(customer.address, process.env.TESTNET_RPC_URL) + if (balances.length === 0) { + const balance = balances[0] + if (!balance || +balance.amount < process.env.TESTNET_MINIMUM_BALANCE) { + // 3.1 If it's less then required for DID creation - assign new portion from testnet-faucet + const resp = await FaucetHelper.delegateTokens(customer.address) + if (resp.status !== StatusCodes.OK) { + return response.status(StatusCodes.BAD_GATEWAY).json({ + error: resp.error}) + } + } + } + } + return response.status(StatusCodes.OK).json({}) + } } diff --git a/src/helpers/faucet.ts b/src/helpers/faucet.ts new file mode 100644 index 00000000..dbd75e00 --- /dev/null +++ b/src/helpers/faucet.ts @@ -0,0 +1,27 @@ +import { ICommonErrorResponse } from "../types/authentication.js"; +import { DEFAULT_FAUCET_DENOM, DEFAULT_FAUCET_URI } from "../types/constants.js"; + +export class FaucetHelper { + + // ... + static async delegateTokens(address: string): Promise { + const faucetURI = DEFAULT_FAUCET_URI + const faucetBody = { + "denom": DEFAULT_FAUCET_DENOM, + "address": address, + } + const response = await fetch(faucetURI, { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(faucetBody), + method: "POST" + }); + return { + status: response.status, + error: await response.text(), + data: {} + } + } + // ... +} \ No newline at end of file diff --git a/src/helpers/helpers.ts b/src/helpers/helpers.ts index 4e293a99..4f0f9755 100644 --- a/src/helpers/helpers.ts +++ b/src/helpers/helpers.ts @@ -66,11 +66,6 @@ export function generateDidDoc(options: IDidDocOptions) { return createDidPayload(verificationMethods, [verificationKeys]) } -export function getCosmosAccount(kid: string) { - const { publicKeyConvert } = pkg - return toBech32('cheqd', rawSecp256k1PubkeyToRawAddress(publicKeyConvert(fromString(kid, 'hex'), true))) -} - export function verifyHookSignature(signingKey: string, rawBody: string, expectedSignature: string): boolean { const hmac = createHmac('sha256', signingKey); hmac.update(rawBody); diff --git a/src/middleware/auth/base-auth.ts b/src/middleware/auth/base-auth.ts index 64713bae..d0a863a5 100644 --- a/src/middleware/auth/base-auth.ts +++ b/src/middleware/auth/base-auth.ts @@ -22,8 +22,8 @@ export abstract class AbstractAuthHandler implements IAuthResourceHandler private static pathSkip = [ '/swagger', '/static', - '/logto', - '/account/set-default-role', + '/logto', + '/account/bootstrap', '/auth/user-info'] constructor () { diff --git a/src/middleware/auth/logto.ts b/src/middleware/auth/logto.ts index 1ab1517f..7d5489a2 100644 --- a/src/middleware/auth/logto.ts +++ b/src/middleware/auth/logto.ts @@ -1,5 +1,5 @@ import * as dotenv from 'dotenv' -import { ILogToErrorResponse } from '../../types/authentication' +import { ICommonErrorResponse } from '../../types/authentication' import { StatusCodes } from 'http-status-codes' import jwt from 'jsonwebtoken' dotenv.config() @@ -19,8 +19,8 @@ export class LogToHelper { this.allResourceWithNames = [] } - public async setup(): Promise{ - let _r = {} as ILogToErrorResponse + public async setup(): Promise{ + let _r = {} as ICommonErrorResponse _r = await this.setM2MToken() if (_r.status !== StatusCodes.OK) { return _r @@ -66,7 +66,7 @@ export class LogToHelper { return this.allResourceWithNames } - public async setDefaultRoleForUser(userId: string): Promise { + public async setDefaultRoleForUser(userId: string): Promise { const roles = await this.getRolesForUser(userId) if (roles.status !== StatusCodes.OK) { return this.returnError(StatusCodes.BAD_GATEWAY, roles.error) @@ -82,7 +82,7 @@ export class LogToHelper { return await this.assignDefaultRoleForUser(userId, process.env.LOGTO_DEFAULT_ROLE_ID) } - private returnOk(data: any): ILogToErrorResponse { + private returnOk(data: any): ICommonErrorResponse { return { status: StatusCodes.OK, error: '', @@ -90,7 +90,7 @@ export class LogToHelper { } } - private returnError(status: number, error: string, data: any = {}): ILogToErrorResponse { + private returnError(status: number, error: string, data: any = {}): ICommonErrorResponse { return { status: status, error: error, @@ -98,7 +98,7 @@ export class LogToHelper { } } - public async getUserScopes(userId: string): Promise { + public async getUserScopes(userId: string): Promise { const scopes = [] as string[] const roles = await this.getRolesForUser(userId) if (roles.status !== StatusCodes.OK) { @@ -114,7 +114,7 @@ export class LogToHelper { return this.returnOk(scopes) } - private async assignDefaultRoleForUser(userId: string, roleId: string): Promise { + private async assignDefaultRoleForUser(userId: string, roleId: string): Promise { const userInfo = await this.getUserInfo(userId) const uri = new URL(`/api/users/${userId}/roles`, process.env.LOGTO_ENDPOINT); @@ -141,7 +141,7 @@ export class LogToHelper { } } - private async getRolesForUser(userId: string): Promise { + public async getRolesForUser(userId: string): Promise { const uri = new URL(`/api/users/${userId}/roles`, process.env.LOGTO_ENDPOINT); try { // Note: By default, the API returns first 20 roles. @@ -152,7 +152,35 @@ export class LogToHelper { } } - private async postToLogto(uri: URL, body: any, headers: any = {}): Promise { + public async updateCustomData(userId: string, customData: any): Promise { + const uri = new URL(`/api/users/${userId}/custom-data`, process.env.LOGTO_ENDPOINT); + try { + const body = { + customData: customData, + }; + return await this.patchToLogto(uri, body, {'Content-Type': 'application/json'}) + } catch (err) { + return this.returnError(500, `updateCustomData ${err}`) + } + } + + private async patchToLogto(uri: URL, body: any, headers: any = {}): Promise { + const response = await fetch(uri, { + headers: { + ...headers, + Authorization: 'Bearer ' + this.m2mToken, + }, + body: JSON.stringify(body), + method: "PATCH" + }); + + if (!response.ok) { + return this.returnError(response.status, await response.json()) + } + return this.returnOk({}) + } + + private async postToLogto(uri: URL, body: any, headers: any = {}): Promise { const response = await fetch(uri, { headers: { ...headers, @@ -168,7 +196,7 @@ export class LogToHelper { return this.returnOk({}) } - private async getToLogto(uri: URL, headers: any = {}): Promise { + private async getToLogto(uri: URL, headers: any = {}): Promise { const response = await fetch(uri, { headers: { ...headers, @@ -184,7 +212,7 @@ export class LogToHelper { return this.returnOk(metadata) } - private async getUserInfo(userId: string): Promise { + private async getUserInfo(userId: string): Promise { const uri = new URL(`/api/users/${userId}`, process.env.LOGTO_ENDPOINT); try { return await this.getToLogto(uri, 'GET') @@ -193,7 +221,16 @@ export class LogToHelper { } } - private async getRoleInfo(roleId: string): Promise { + public async getCustomData(userId: string): Promise { + const uri = new URL(`/api/users/${userId}/custom-data`, process.env.LOGTO_ENDPOINT); + try { + return await this.getToLogto(uri, 'GET') + } catch (err) { + return this.returnError(StatusCodes.BAD_GATEWAY, `getCustomData ${err}`) + } + } + + private async getRoleInfo(roleId: string): Promise { const uri = new URL(`/api/roles/${roleId}`, process.env.LOGTO_ENDPOINT); try { return await this.getToLogto(uri, 'GET') @@ -202,7 +239,7 @@ export class LogToHelper { } } - private async setDefaultScopes(): Promise{ + private async setDefaultScopes(): Promise{ const _r = await this.getAllResources() if ( _r.status !== StatusCodes.OK) { return this.returnError(StatusCodes.BAD_GATEWAY, `Looks like ${process.env.LOGTO_DEFAULT_RESOURCE_URL} is not setup on LogTo side`) @@ -222,7 +259,7 @@ export class LogToHelper { return this.returnError(StatusCodes.BAD_GATEWAY, `Looks like resource with id ${process.env.LOGTO_DEFAULT_RESOURCE_URL} is not placed on LogTo`) } - private async setM2MToken() : Promise { + private async setM2MToken() : Promise { const searchParams = new URLSearchParams({ grant_type: 'client_credentials', resource: process.env.LOGTO_MANAGEMENT_API as string, @@ -253,7 +290,7 @@ export class LogToHelper { } } - private async setAllScopes(): Promise { + private async setAllScopes(): Promise { const allResources = await this.getAllResources() if (allResources.status !== StatusCodes.OK) { @@ -271,7 +308,7 @@ export class LogToHelper { return this.returnOk({}) } - private async setAllResourcesWithNames(): Promise { + private async setAllResourcesWithNames(): Promise { const allResources = await this.getAllResources() if (allResources.status !== StatusCodes.OK) { return this.returnError(StatusCodes.BAD_GATEWAY, `setAllResourcesWithNames: Error while getting all resources`) @@ -282,7 +319,7 @@ export class LogToHelper { return this.returnOk({}) } - private async askRoleForScopes(roleId: string): Promise { + private async askRoleForScopes(roleId: string): Promise { const uri = new URL(`/api/roles/${roleId}/scopes`, process.env.LOGTO_ENDPOINT); const scopes = [] @@ -300,7 +337,7 @@ export class LogToHelper { } } - private async askResourceForScopes(resourceId: string): Promise { + private async askResourceForScopes(resourceId: string): Promise { const uri = new URL(`/api/resources/${resourceId}/scopes`, process.env.LOGTO_ENDPOINT); const scopes = [] @@ -318,7 +355,7 @@ export class LogToHelper { } } - private async getAllResources(): Promise { + private async getAllResources(): Promise { const uri = new URL(`/api/resources`, process.env.LOGTO_ENDPOINT); try { diff --git a/src/middleware/hook.ts b/src/middleware/hook.ts index ab30b617..50c078ac 100644 --- a/src/middleware/hook.ts +++ b/src/middleware/hook.ts @@ -16,4 +16,14 @@ export class LogToWebHook { } next() } + + static getCustomerId(request: Request): string { + const { body } = request + return body.user.id + } + + static isUserSuspended(request: Request): boolean { + const { body } = request + return body.user.isSuspended + } } diff --git a/src/services/customer.ts b/src/services/customer.ts index a9f3edab..521c4dcc 100644 --- a/src/services/customer.ts +++ b/src/services/customer.ts @@ -2,7 +2,7 @@ import { ArrayContains, Repository } from 'typeorm' import { Connection } from '../database/connection/connection.js' import { CustomerEntity } from '../database/entities/customer.entity.js' -import { getCosmosAccount } from '../helpers/helpers.js' +import { getCosmosAccount } from '@cheqd/sdk' import { Identity } from './identity/index.js' import * as dotenv from 'dotenv' @@ -22,8 +22,14 @@ export class CustomerService { throw new Error('Customer exists') } const kid = (await new Identity(customerId).agent.createKey('Secp256k1', customerId)).kid + const address = getCosmosAccount(kid) const customer = new CustomerEntity(customerId, kid, getCosmosAccount(kid)) - return (await this.customerRepository.insert(customer)).identifiers[0] + const customerEntity = (await this.customerRepository.insert(customer)).identifiers[0] + return { + customerId: customerEntity.customerId, + address: address + } + } public async update(customerId: string, { kids=[], dids=[], claimIds=[], presentationIds=[]} : { kids?: string[], dids?: string[], claimIds?: string[], presentationIds?: string[] }) { diff --git a/src/types/authentication.ts b/src/types/authentication.ts index ed9f1e22..1132bbce 100644 --- a/src/types/authentication.ts +++ b/src/types/authentication.ts @@ -70,7 +70,7 @@ export interface IAuthResponse{ error: string } -export interface ILogToErrorResponse { +export interface ICommonErrorResponse { status: number, error: string, data: any diff --git a/src/types/constants.ts b/src/types/constants.ts index 7120598e..e55558fa 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -35,4 +35,10 @@ export const configLogToExpress = { appSecret: LOGTO_APP_SECRET, baseUrl: APPLICATION_BASE_URL, getAccessToken: true, -} \ No newline at end of file + fetchUserInfo: true, +} + +export const DEFAULT_FAUCET_DENOM = process.env.FAUCET_DENOM || 'ncheq' +export const DEFAULT_FAUCET_URI = process.env.FAUCET_URI || 'https://faucet-api.cheqd.network/credit' +// Amount for creating DID +export const TESTNET_MINIMUM_BALANCE = process.env.TESTNET_MINIMUM_BALANCE || 50000000000 \ No newline at end of file diff --git a/src/types/environment.d.ts b/src/types/environment.d.ts index d988d848..fc404d2a 100644 --- a/src/types/environment.d.ts +++ b/src/types/environment.d.ts @@ -47,6 +47,12 @@ declare global { ISSUER_PUBLIC_KEY_HEX: string DEFAULT_FEE_PAYER_MNEMONIC: string ISSUER_DID: string + + // Faucet + FAUCET_ENABLED: string | "false" + FAUCET_URI: string | "https://faucet-api.cheqd.network/credit" + FAUCET_DENOM: string | "ncheq" + TESTNET_MINIMUM_BALANCE: number | 50000000000 } }