diff --git a/.env.example b/.env.example index f1d59352d..16e6c68c7 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,7 @@ export MAX_CHECKSUM_LENGTH= export LOG_LEVEL= export HTTP_API_PORT= export VALIDATE_UNSIGNED_DDO= +export JWT_SECRET= ## p2p diff --git a/docs/env.md b/docs/env.md index aba2718d6..a8b29df22 100644 --- a/docs/env.md +++ b/docs/env.md @@ -33,6 +33,7 @@ Environmental variables are also tracked in `ENVIRONMENT_VARIABLES` within `src/ - `AUTHORIZED_PUBLISHERS`: Authorized list of publishers. If present, Node will only index assets published by the accounts in the list. Example: `"[\"0x967da4048cD07aB37855c090aAF366e4ce1b9F48\",\"0x388C818CA8B9251b393131C08a736A67ccB19297\"]"` - `AUTHORIZED_PUBLISHERS_LIST`: AccessList contract addresses (per chain). If present, Node will only index assets published by the accounts present on the given access lists. Example: `"{ \"8996\": [\"0x967da4048cD07aB37855c090aAF366e4ce1b9F48\",\"0x388C818CA8B9251b393131C08a736A67ccB19297\"] }"` - `VALIDATE_UNSIGNED_DDO`: If set to `false`, the node will not validate unsigned DDOs and will request a signed message with the publisher address, nonce and signature. Default is `true`. Example: `false` +- `JWT_SECRET`: Secret used to sign JWT tokens. Default is `ocean-node-secret`. Example: `"my-secret-jwt-token"` ## Payments diff --git a/package-lock.json b/package-lock.json index d458135c2..07f7aa707 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "hyperdiff": "^2.0.16", "ip": "^2.0.1", "it-pipe": "^3.0.1", + "jsonwebtoken": "^9.0.2", "libp2p": "^1.8.0", "lodash.clonedeep": "^4.5.0", "lzma-purejs-requirejs": "^1.0.0", @@ -73,6 +74,7 @@ "@types/dockerode": "^3.3.31", "@types/express": "^4.17.17", "@types/ip": "^1.1.3", + "@types/jsonwebtoken": "^9.0.9", "@types/lzma-native": "^4.0.4", "@types/mocha": "^10.0.10", "@types/node": "^20.14.2", @@ -5352,6 +5354,17 @@ "version": "1.5.13", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.14.200", "license": "MIT" @@ -5382,6 +5395,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/multer": { "version": "1.4.9", "license": "MIT", @@ -12321,6 +12341,38 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "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": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "dev": true, @@ -12599,14 +12651,36 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", - "dev": true, "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -12614,6 +12688,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.uniqby": { "version": "4.7.0", "dev": true, diff --git a/package.json b/package.json index 67e7da726..b44d30f04 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "hyperdiff": "^2.0.16", "ip": "^2.0.1", "it-pipe": "^3.0.1", + "jsonwebtoken": "^9.0.2", "libp2p": "^1.8.0", "lodash.clonedeep": "^4.5.0", "lzma-purejs-requirejs": "^1.0.0", @@ -112,6 +113,7 @@ "@types/dockerode": "^3.3.31", "@types/express": "^4.17.17", "@types/ip": "^1.1.3", + "@types/jsonwebtoken": "^9.0.9", "@types/lzma-native": "^4.0.4", "@types/mocha": "^10.0.10", "@types/node": "^20.14.2", diff --git a/src/@types/OceanNode.ts b/src/@types/OceanNode.ts index 8b5c2ecbe..55b4db5ff 100644 --- a/src/@types/OceanNode.ts +++ b/src/@types/OceanNode.ts @@ -113,6 +113,7 @@ export interface OceanNodeConfig { unsafeURLs?: string[] isBootstrap?: boolean validateUnsignedDDO?: boolean + jwtSecret?: string } export interface P2PStatusResponse { diff --git a/src/@types/commands.ts b/src/@types/commands.ts index e7c101192..7494c27b2 100644 --- a/src/@types/commands.ts +++ b/src/@types/commands.ts @@ -19,6 +19,7 @@ import { export interface Command { command: string // command name node?: string // if not present it means current node + authorization?: string } export interface GetP2PPeerCommand extends Command { @@ -82,6 +83,7 @@ export interface ValidateDDOCommand extends Command { publisherAddress?: string nonce?: string signature?: string + message?: string } export interface StatusCommand extends Command { @@ -261,3 +263,15 @@ export interface StartStopIndexingCommand extends AdminCommand { export interface PolicyServerPassthroughCommand extends Command { policyServerPassthrough?: any } + +export interface CreateAuthTokenCommand extends Command { + address: string + signature: string + validUntil?: number | null +} + +export interface InvalidateAuthTokenCommand extends Command { + address: string + signature: string + token: string +} diff --git a/src/OceanNode.ts b/src/OceanNode.ts index 762bf5d44..9e7846c04 100644 --- a/src/OceanNode.ts +++ b/src/OceanNode.ts @@ -12,6 +12,7 @@ import { pipe } from 'it-pipe' import { GENERIC_EMOJIS, LOG_LEVELS_STR } from './utils/logging/Logger.js' import { BaseHandler } from './components/core/handler/handler.js' import { C2DEngines } from './components/c2d/compute_engines.js' +import { Auth } from './components/Auth/index.js' export interface RequestLimiter { requester: string | string[] // IP address or peer ID @@ -35,6 +36,7 @@ export class OceanNode { // requester private remoteCaller: string | string[] private requestMap: Map + private auth: Auth // eslint-disable-next-line no-useless-constructor private constructor( @@ -47,6 +49,9 @@ export class OceanNode { this.coreHandlers = CoreHandlersRegistry.getInstance(this) this.requestMap = new Map() this.config = config + if (this.db && this.db?.authToken) { + this.auth = new Auth(this.db.authToken) + } if (node) { node.setCoreHandlers(this.coreHandlers) } @@ -64,9 +69,10 @@ export class OceanNode { db?: Database, node?: OceanP2P, provider?: OceanProvider, - indexer?: OceanIndexer + indexer?: OceanIndexer, + newInstance: boolean = false ): OceanNode { - if (!OceanNode.instance) { + if (!OceanNode.instance || newInstance) { // prepare compute engines this.instance = new OceanNode(config, db, node, provider, indexer) } @@ -136,6 +142,10 @@ export class OceanNode { return this.requestMap } + public getAuth(): Auth { + return this.auth + } + /** * Use this method to direct calls to the node as node cannot dial into itself * @param message command message diff --git a/src/components/Auth/index.ts b/src/components/Auth/index.ts new file mode 100644 index 000000000..fbc8f726f --- /dev/null +++ b/src/components/Auth/index.ts @@ -0,0 +1,120 @@ +import { AuthToken, AuthTokenDatabase } from '../database/AuthTokenDatabase.js' +import jwt from 'jsonwebtoken' +import { checkNonce, NonceResponse } from '../core/utils/nonceHandler.js' +import { OceanNode } from '../../OceanNode.js' +import { getConfiguration } from '../../utils/index.js' + +export interface CommonValidation { + valid: boolean + error: string +} + +export class Auth { + private authTokenDatabase: AuthTokenDatabase + + public constructor(authTokenDatabase: AuthTokenDatabase) { + this.authTokenDatabase = authTokenDatabase + } + + public async getJwtSecret(): Promise { + const config = await getConfiguration() + return config.jwtSecret + } + + public getMessage(address: string, nonce: string): string { + return address + nonce + } + + async getJWTToken(address: string, nonce: string, createdAt: number): Promise { + const jwtToken = jwt.sign( + { + address, + nonce, + createdAt + }, + await this.getJwtSecret() + ) + + return jwtToken + } + + async insertToken( + address: string, + jwtToken: string, + validUntil: number, + createdAt: number + ): Promise { + await this.authTokenDatabase.createToken(jwtToken, address, validUntil, createdAt) + } + + async invalidateToken(jwtToken: string): Promise { + await this.authTokenDatabase.invalidateToken(jwtToken) + } + + async validateToken(token: string): Promise { + const tokenEntry = await this.authTokenDatabase.validateToken(token) + if (!tokenEntry) { + return null + } + return tokenEntry + } + + /** + * Validates the authentication or token + * You need to provider either a token or an address, signature and message + * @param {string} token - The token to validate + * @param {string} address - The address to validate + * @param {string} signature - The signature to validate + * @param {string} message - The message to validate + * @returns The validation result + */ + async validateAuthenticationOrToken({ + token, + address, + nonce, + signature + }: { + token?: string + address?: string + nonce?: string + signature?: string + }): Promise { + try { + if (signature && address && nonce) { + const oceanNode = OceanNode.getInstance() + const nonceCheckResult: NonceResponse = await checkNonce( + oceanNode.getDatabase().nonce, + address, + parseInt(nonce), + signature, + this.getMessage(address, nonce) + ) + + if (!nonceCheckResult.valid) { + return { valid: false, error: nonceCheckResult.error } + } + + if (nonceCheckResult.valid) { + return { valid: true, error: '' } + } + } + + if (token) { + const authToken = await this.validateToken(token) + if (authToken) { + return { valid: true, error: '' } + } + + return { valid: false, error: 'Invalid token' } + } + + return { + valid: false, + error: + 'Invalid authentication, you need to provide either a token or an address, signature, message and nonce' + } + } catch (e) { + return { valid: false, error: `Error during authentication validation: ${e}` } + } + } +} diff --git a/src/components/core/handler/authHandler.ts b/src/components/core/handler/authHandler.ts new file mode 100644 index 000000000..b1400a10f --- /dev/null +++ b/src/components/core/handler/authHandler.ts @@ -0,0 +1,120 @@ +import { CommandHandler } from './handler.js' +import { P2PCommandResponse } from '../../../@types/OceanNode.js' +import { + ValidateParams, + validateCommandParameters +} from '../../httpRoutes/validateCommands.js' +import { ReadableString } from '../../P2P/handlers.js' +import { Command } from '../../../@types/commands.js' +import { Readable } from 'stream' +import { checkNonce, NonceResponse } from '../utils/nonceHandler.js' + +export interface AuthMessage { + address: string + nonce: string + signature: string +} + +export interface CreateAuthTokenCommand extends AuthMessage, Command { + validUntil?: number | null +} + +export interface InvalidateAuthTokenCommand extends AuthMessage, Command { + token: string +} + +export class CreateAuthTokenHandler extends CommandHandler { + validate(command: CreateAuthTokenCommand): ValidateParams { + return validateCommandParameters(command, ['address', 'signature']) + } + + async handle(task: CreateAuthTokenCommand): Promise { + const { address, nonce, signature } = task + const nonceDb = this.getOceanNode().getDatabase().nonce + const auth = this.getOceanNode().getAuth() + const validationResponse = await this.verifyParamsAndRateLimits(task) + if (this.shouldDenyTaskHandling(validationResponse)) { + return validationResponse + } + + try { + const nonceCheckResult: NonceResponse = await checkNonce( + nonceDb, + address, + parseInt(nonce), + signature, + auth.getMessage(address, nonce) + ) + + if (!nonceCheckResult.valid) { + return { + stream: null, + status: { httpStatus: 401, error: nonceCheckResult.error } + } + } + + const createdAt = Date.now() + const jwtToken = await this.getOceanNode() + .getAuth() + .getJWTToken(task.address, task.nonce, createdAt) + + await this.getOceanNode() + .getAuth() + .insertToken(task.address, jwtToken, task.validUntil, createdAt) + + return { + stream: Readable.from(JSON.stringify({ token: jwtToken })), + status: { httpStatus: 200, error: null } + } + } catch (error) { + return { + stream: null, + status: { httpStatus: 500, error: `Error creating auth token: ${error}` } + } + } + } +} + +export class InvalidateAuthTokenHandler extends CommandHandler { + validate(command: InvalidateAuthTokenCommand): ValidateParams { + return validateCommandParameters(command, ['address', 'signature', 'token']) + } + + async handle(task: InvalidateAuthTokenCommand): Promise { + const { address, nonce, signature, token } = task + const nonceDb = this.getOceanNode().getDatabase().nonce + const auth = this.getOceanNode().getAuth() + const validationResponse = await this.verifyParamsAndRateLimits(task) + if (this.shouldDenyTaskHandling(validationResponse)) { + return validationResponse + } + + try { + const isValid = await checkNonce( + nonceDb, + address, + parseInt(nonce), + signature, + auth.getMessage(address, nonce) + ) + if (!isValid) { + return { + stream: null, + status: { httpStatus: 400, error: 'Invalid signature' } + } + } + + await this.getOceanNode().getAuth().invalidateToken(token) + + return { + stream: new ReadableString(JSON.stringify({ success: true })), + status: { httpStatus: 200, error: null } + } + } catch (error) { + return { + stream: null, + status: { httpStatus: 500, error: `Error invalidating auth token: ${error}` } + } + } + } +} diff --git a/src/components/core/handler/coreHandlersRegistry.ts b/src/components/core/handler/coreHandlersRegistry.ts index 4656b8727..9f76a6b60 100644 --- a/src/components/core/handler/coreHandlersRegistry.ts +++ b/src/components/core/handler/coreHandlersRegistry.ts @@ -42,6 +42,8 @@ import { GetP2PNetworkStatsHandler, FindPeerHandler } from './p2p.js' +import { CreateAuthTokenHandler, InvalidateAuthTokenHandler } from './authHandler.js' + export type HandlerRegistry = { handlerName: string // name of the handler handlerImpl: BaseHandler // class that implements it @@ -149,10 +151,21 @@ export class CoreHandlersRegistry { new GetP2PNetworkStatsHandler(node) ) this.registerCoreHandler(PROTOCOL_COMMANDS.FIND_PEER, new FindPeerHandler(node)) + this.registerCoreHandler( + PROTOCOL_COMMANDS.CREATE_AUTH_TOKEN, + new CreateAuthTokenHandler(node) + ) + this.registerCoreHandler( + PROTOCOL_COMMANDS.INVALIDATE_AUTH_TOKEN, + new InvalidateAuthTokenHandler(node) + ) } - public static getInstance(node: OceanNode): CoreHandlersRegistry { - if (!CoreHandlersRegistry.instance) { + public static getInstance( + node: OceanNode, + newInstance: boolean = false + ): CoreHandlersRegistry { + if (!CoreHandlersRegistry.instance || newInstance) { this.instance = new CoreHandlersRegistry(node) } return this.instance diff --git a/src/components/core/handler/ddoHandler.ts b/src/components/core/handler/ddoHandler.ts index 8e2a2f8cf..a5d30466e 100644 --- a/src/components/core/handler/ddoHandler.ts +++ b/src/components/core/handler/ddoHandler.ts @@ -40,6 +40,7 @@ import { import { deleteIndexedMetadataIfExists, validateDDOHash } from '../../../utils/asset.js' import { Asset, DDO, DDOManager } from '@oceanprotocol/ddo-js' import { checkCredentialOnAccessList } from '../../../utils/credentials.js' +import { ValidateTokenOrSignature } from '../../../utils/decorators/validate-token.decorator.js' const MAX_NUM_PROVIDERS = 5 // after 60 seconds it returns whatever info we have available @@ -790,6 +791,11 @@ export class FindDdoHandler extends CommandHandler { } } +export async function skipValidation(): Promise { + const configuration = await getConfiguration() + return configuration.validateUnsignedDDO +} + export class ValidateDDOHandler extends CommandHandler { validate(command: ValidateDDOCommand): ValidateParams { let validation = validateCommandParameters(command, ['ddo']) @@ -800,8 +806,9 @@ export class ValidateDDOHandler extends CommandHandler { return validation } + // Skip validation if allowed by env variable + @ValidateTokenOrSignature(skipValidation) async handle(task: ValidateDDOCommand): Promise { - const configuration = await getConfiguration() const validationResponse = await this.verifyParamsAndRateLimits(task) if (this.shouldDenyTaskHandling(validationResponse)) { return validationResponse @@ -810,35 +817,6 @@ export class ValidateDDOHandler extends CommandHandler { const ddoInstance = DDOManager.getDDOClass(task.ddo) const validation = await ddoInstance.validate() - const { ddo, publisherAddress, nonce, signature: signatureFromRequest } = task - if (configuration.validateUnsignedDDO === false) { - if (!publisherAddress || !nonce || !signatureFromRequest) { - return { - stream: null, - status: { - httpStatus: 400, - error: - 'A signature is required to validate a DDO, please provide a signed message with the publisher address, nonce and signature' - } - } - } - } - - if (publisherAddress && nonce && signatureFromRequest) { - const isValid = validateDdoSignedByPublisher( - ddo, - nonce, - signatureFromRequest, - publisherAddress - ) - if (!isValid) { - return { - stream: null, - status: { httpStatus: 400, error: 'Invalid signature' } - } - } - } - if (validation[0] === false) { CORE_LOGGER.logMessageWithEmoji( `Validation failed with error: ${validation[1]}`, diff --git a/src/components/database/AuthTokenDatabase.ts b/src/components/database/AuthTokenDatabase.ts new file mode 100644 index 000000000..159506124 --- /dev/null +++ b/src/components/database/AuthTokenDatabase.ts @@ -0,0 +1,57 @@ +import { DATABASE_LOGGER } from '../../utils/logging/common.js' +import { AbstractDatabase } from './BaseDatabase.js' +import { OceanNodeDBConfig } from '../../@types/OceanNode.js' +import path from 'path' +import * as fs from 'fs' +import { SQLiteAuthToken } from './sqliteAuthToken.js' + +export interface AuthToken { + token: string + address: string + created: Date + validUntil: Date | null + isValid: boolean +} + +export class AuthTokenDatabase extends AbstractDatabase { + private provider: SQLiteAuthToken + + private constructor(config: OceanNodeDBConfig, provider?: SQLiteAuthToken) { + super(config) + this.provider = provider + } + + static async create(config: OceanNodeDBConfig): Promise { + DATABASE_LOGGER.info('Creating AuthTokenDatabase with SQLite') + const dbDir = path.dirname('databases/authTokenDatabase.sqlite') + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }) + } + const provider = new SQLiteAuthToken('databases/authTokenDatabase.sqlite') + await provider.createTable() + return new AuthTokenDatabase(config, provider) + } + + async createToken( + token: string, + address: string, + validUntil: number | null = null, + createdAt: number + ): Promise { + await this.provider.createToken(token, address, createdAt, validUntil) + return token + } + + async validateToken(token: string): Promise { + const tokenEntry = await this.provider.validateTokenEntry(token) + if (!tokenEntry) { + return null + } + + return tokenEntry + } + + async invalidateToken(token: string): Promise { + await this.provider.invalidateTokenEntry(token) + } +} diff --git a/src/components/database/DatabaseFactory.ts b/src/components/database/DatabaseFactory.ts index 628aeb604..e83549f9d 100644 --- a/src/components/database/DatabaseFactory.ts +++ b/src/components/database/DatabaseFactory.ts @@ -32,6 +32,7 @@ import { DB_TYPES } from '../../utils/index.js' import { C2DDatabase } from './C2DDatabase.js' import { SQLLiteNonceDatabase } from './SQLLiteNonceDatabase.js' import { SQLLiteConfigDatabase } from './SQLLiteConfigDatabase.js' +import { AuthTokenDatabase } from './AuthTokenDatabase.js' export class DatabaseFactory { private static databaseMap = { @@ -91,6 +92,12 @@ export class DatabaseFactory { return await new C2DDatabase(config, typesenseSchemas.c2dSchemas) } + static async createAuthTokenDatabase( + config: OceanNodeDBConfig + ): Promise { + return await AuthTokenDatabase.create(config) + } + static createIndexerDatabase( config: OceanNodeDBConfig ): Promise { diff --git a/src/components/database/index.ts b/src/components/database/index.ts index 863d3cad3..9a01f22ef 100644 --- a/src/components/database/index.ts +++ b/src/components/database/index.ts @@ -18,6 +18,7 @@ import { ElasticsearchSchema } from './ElasticSchemas.js' import { SQLLiteConfigDatabase } from './SQLLiteConfigDatabase.js' import { SQLLiteNonceDatabase } from './SQLLiteNonceDatabase.js' import { TypesenseSchema } from './TypesenseSchemas.js' +import { AuthTokenDatabase } from './AuthTokenDatabase.js' export type Schema = ElasticsearchSchema | TypesenseSchema @@ -30,14 +31,17 @@ export class Database { ddoState: AbstractDdoStateDatabase sqliteConfig: SQLLiteConfigDatabase c2d: C2DDatabase + authToken: AuthTokenDatabase constructor(private config: OceanNodeDBConfig) { return (async (): Promise => { try { - // these 2 are using SQL Lite provider + // these databases use SQLite provider this.nonce = await DatabaseFactory.createNonceDatabase(this.config) this.sqliteConfig = await DatabaseFactory.createConfigDatabase() this.c2d = await DatabaseFactory.createC2DDatabase(this.config) + this.authToken = await DatabaseFactory.createAuthTokenDatabase(this.config) + // only for Typesense or Elasticsearch if (hasValidDBConfiguration(this.config)) { // add this DB transport too @@ -57,7 +61,7 @@ export class Database { this.ddoState = await DatabaseFactory.createDdoStateDatabase(this.config) } else { DATABASE_LOGGER.info( - 'Invalid DB URL. Only Nonce and C2D Databases are initialized. Other databases are not available.' + 'Invalid DB URL. Only Nonce, C2D, Auth Token and Config Databases are initialized. Other databases are not available.' ) } return this diff --git a/src/components/database/sqliteAuthToken.ts b/src/components/database/sqliteAuthToken.ts new file mode 100644 index 000000000..ac526dbe3 --- /dev/null +++ b/src/components/database/sqliteAuthToken.ts @@ -0,0 +1,113 @@ +import { AuthToken } from './AuthTokenDatabase.js' +import sqlite3 from 'sqlite3' +import { DATABASE_LOGGER } from '../../utils/logging/common.js' + +interface AuthTokenDatabaseProvider { + createToken( + token: string, + address: string, + createdAt: number, + validUntil: number | null + ): Promise + validateTokenEntry(token: string): Promise + invalidateTokenEntry(token: string): Promise +} + +export class SQLiteAuthToken implements AuthTokenDatabaseProvider { + private db: sqlite3.Database + + constructor(dbFilePath: string) { + this.db = new sqlite3.Database(dbFilePath) + } + + async createTable(): Promise { + await this.db.exec(` + CREATE TABLE IF NOT EXISTS authTokens ( + token TEXT PRIMARY KEY, + address TEXT NOT NULL, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + validUntil DATETIME, + isValid BOOLEAN DEFAULT TRUE + ) + `) + } + + createToken( + token: string, + address: string, + createdAt: number, + validUntil: number | null = null + ): Promise { + const insertSQL = ` + INSERT INTO authTokens (token, address, createdAt, validUntil) VALUES (?, ?, ?, ?) + ` + return new Promise((resolve, reject) => { + this.db.run(insertSQL, [token, address, createdAt, validUntil], (err) => { + if (err) { + DATABASE_LOGGER.error(`Error creating auth token: ${err}`) + reject(err) + } else { + resolve() + } + }) + }) + } + + validateTokenEntry(token: string): Promise { + const selectSQL = ` + SELECT * FROM authTokens WHERE token = ? + ` + return new Promise((resolve, reject) => { + this.db.get(selectSQL, [token], async (err, row: AuthToken) => { + if (err) { + DATABASE_LOGGER.error(`Error validating auth token: ${err}`) + reject(err) + return + } + + if (!row) { + resolve(null) + return + } + + if (!row.isValid) { + resolve(null) + return + } + + if (row.validUntil === null) { + resolve(row) + return + } + + const validUntilDate = new Date(row.validUntil).getTime() + const now = Date.now() + + if (validUntilDate < now) { + resolve(null) + DATABASE_LOGGER.info(`Auth token ${token} is invalid`) + await this.invalidateTokenEntry(token) + return + } + + resolve(row) + }) + }) + } + + invalidateTokenEntry(token: string): Promise { + const deleteSQL = ` + UPDATE authTokens SET isValid = FALSE WHERE token = ? + ` + return new Promise((resolve, reject) => { + this.db.run(deleteSQL, [token], (err) => { + if (err) { + DATABASE_LOGGER.error(`Error invalidating auth token: ${err}`) + reject(err) + } else { + resolve() + } + }) + }) + } +} diff --git a/src/components/httpRoutes/aquarius.ts b/src/components/httpRoutes/aquarius.ts index e95171373..ebaf87e7b 100644 --- a/src/components/httpRoutes/aquarius.ts +++ b/src/components/httpRoutes/aquarius.ts @@ -141,6 +141,7 @@ aquariusRoutes.post(`${AQUARIUS_API_BASE_PATH}/assets/ddo/validate`, async (req, } const requestBody = JSON.parse(req.body) + const authorization = req.headers?.authorization const { publisherAddress, nonce, signature } = requestBody // This is for backward compatibility with the old way of sending the DDO @@ -154,8 +155,10 @@ aquariusRoutes.post(`${AQUARIUS_API_BASE_PATH}/assets/ddo/validate`, async (req, const result = await new ValidateDDOHandler(node).handle({ ddo, publisherAddress, + authorization, nonce, signature, + message: ddo.id + nonce, command: PROTOCOL_COMMANDS.VALIDATE_DDO }) diff --git a/src/components/httpRoutes/auth.ts b/src/components/httpRoutes/auth.ts new file mode 100644 index 000000000..ac2b0ef0a --- /dev/null +++ b/src/components/httpRoutes/auth.ts @@ -0,0 +1,79 @@ +import express from 'express' +import { SERVICES_API_BASE_PATH, PROTOCOL_COMMANDS } from '../../utils/constants.js' +import { HTTP_LOGGER } from '../../utils/logging/common.js' +import { + CreateAuthTokenHandler, + InvalidateAuthTokenHandler +} from '../core/handler/authHandler.js' +import { streamToString } from '../../utils/util.js' +import { Readable } from 'stream' + +export const authRoutes = express.Router() + +authRoutes.post( + `${SERVICES_API_BASE_PATH}/auth/token`, + express.json(), + async (req, res) => { + try { + const { signature, address, nonce, validUntil } = req.body + + if (!signature || !address) { + return res.status(400).json({ error: 'Missing required parameters' }) + } + + const response = await new CreateAuthTokenHandler(req.oceanNode).handle({ + command: PROTOCOL_COMMANDS.CREATE_AUTH_TOKEN, + signature, + address, + nonce, + validUntil + }) + + if (response.status.error) { + return res + .status(response.status.httpStatus) + .json({ error: response.status.error }) + } + + const result = JSON.parse(await streamToString(response.stream as Readable)) + res.json(result) + } catch (error) { + HTTP_LOGGER.error(`Error creating auth token: ${error}`) + res.status(500).json({ error: 'Internal server error' }) + } + } +) + +authRoutes.post( + `${SERVICES_API_BASE_PATH}/auth/token/invalidate`, + express.json(), + async (req, res) => { + try { + const { signature, address, nonce, token } = req.body + + if (!signature || !address || !token) { + return res.status(400).json({ error: 'Missing required parameters' }) + } + + const response = await new InvalidateAuthTokenHandler(req.oceanNode).handle({ + command: PROTOCOL_COMMANDS.INVALIDATE_AUTH_TOKEN, + signature, + address, + nonce, + token + }) + + if (response.status.error) { + return res + .status(response.status.httpStatus) + .json({ error: response.status.error }) + } + + const result = JSON.parse(await streamToString(response.stream as Readable)) + res.json(result) + } catch (error) { + HTTP_LOGGER.error(`Error invalidating auth token: ${error}`) + res.status(500).json({ error: 'Internal server error' }) + } + } +) diff --git a/src/components/httpRoutes/index.ts b/src/components/httpRoutes/index.ts index d93e460f2..a40da0466 100644 --- a/src/components/httpRoutes/index.ts +++ b/src/components/httpRoutes/index.ts @@ -13,8 +13,10 @@ import { queueRoutes } from './queue.js' import { jobsRoutes } from './jobs.js' import { addMapping, allRoutesMapping, findPathName } from './routeUtils.js' import { PolicyServerPassthroughRoute } from './policyServer.js' +import { authRoutes } from './auth.js' export * from './getOceanPeers.js' +export * from './auth.js' export const httpRoutes = express.Router() @@ -56,6 +58,9 @@ httpRoutes.use(queueRoutes) httpRoutes.use(jobsRoutes) // policy server passthrough httpRoutes.use(PolicyServerPassthroughRoute) +// auth routes +httpRoutes.use(authRoutes) + export function getAllServiceEndpoints() { httpRoutes.stack.forEach(addMapping.bind(null, [])) const data: any = {} diff --git a/src/components/httpRoutes/routeUtils.ts b/src/components/httpRoutes/routeUtils.ts index a889c95d3..7615e298c 100644 --- a/src/components/httpRoutes/routeUtils.ts +++ b/src/components/httpRoutes/routeUtils.ts @@ -189,6 +189,16 @@ routesNames.set('PolicyServerPassthrough', { method: 'post' }) +routesNames.set('generateAuthToken', { + path: `${SERVICES_API_BASE_PATH}/auth/token`, + method: 'post' +}) + +routesNames.set('invalidateAuthToken', { + path: `${SERVICES_API_BASE_PATH}/auth/token/invalidate`, + method: 'post' +}) + export function addMapping(path: any, layer: any) { if (layer.route) { layer.route.stack.forEach(addMapping.bind(null, path.concat(split(layer.route.path)))) diff --git a/src/test/integration/auth.test.ts b/src/test/integration/auth.test.ts new file mode 100644 index 000000000..417247ca1 --- /dev/null +++ b/src/test/integration/auth.test.ts @@ -0,0 +1,245 @@ +import { JsonRpcProvider, Signer, Wallet } from 'ethers' +import { Database } from '../../components/database/index.js' +import { Auth } from '../../components/Auth/index.js' +import { getConfiguration, getMessageHash } from '../../utils/index.js' +import { + DEFAULT_TEST_TIMEOUT, + OverrideEnvConfig, + TEST_ENV_CONFIG_FILE, + buildEnvOverrideConfig, + setupEnvironment, + tearDownEnvironment, + getMockSupportedNetworks +} from '../utils/utils.js' +import { ENVIRONMENT_VARIABLES, PROTOCOL_COMMANDS } from '../../utils/constants.js' +import { OceanNodeConfig } from '../../@types/OceanNode.js' +import { RPCS } from '../../@types/blockchain.js' +import { OceanNode } from '../../OceanNode.js' +import { + CreateAuthTokenHandler, + InvalidateAuthTokenHandler +} from '../../components/core/handler/authHandler.js' +import { streamToObject } from '../../utils/util.js' +import { Readable } from 'stream' +import { expect } from 'chai' +import { ValidateDDOHandler } from '../../components/core/handler/ddoHandler.js' + +describe('Auth Token Integration Tests', () => { + let config: OceanNodeConfig + let database: Database + let auth: Auth + let provider: JsonRpcProvider + let consumerAccount: Signer + let previousConfiguration: OverrideEnvConfig[] + let oceanNode: OceanNode + + const mockSupportedNetworks: RPCS = getMockSupportedNetworks() + + before(async () => { + previousConfiguration = await setupEnvironment( + TEST_ENV_CONFIG_FILE, + buildEnvOverrideConfig( + [ + ENVIRONMENT_VARIABLES.RPCS, + ENVIRONMENT_VARIABLES.INDEXER_NETWORKS, + ENVIRONMENT_VARIABLES.VALIDATE_UNSIGNED_DDO + ], + [JSON.stringify(mockSupportedNetworks), JSON.stringify([8996]), 'false'] + ) + ) + + config = await getConfiguration(true) + database = await new Database(config.dbConfig) + auth = new Auth(database.authToken) + oceanNode = await OceanNode.getInstance(config, database) + + provider = new JsonRpcProvider(mockSupportedNetworks['8996'].rpc) + + const consumerPrivateKey = + '0xef4b441145c1d0f3b4bc6d61d29f5c6e502359481152f869247c7a4244d45209' + consumerAccount = new Wallet(consumerPrivateKey, provider) + }) + + after(async () => { + await tearDownEnvironment(previousConfiguration) + }) + + const getRandomNonce = () => { + return Date.now().toString() + } + + const ddoValiationRequest = async (token: string) => { + try { + const validateHandler = new ValidateDDOHandler(oceanNode) + const validateResponse = await validateHandler.handle({ + command: PROTOCOL_COMMANDS.VALIDATE_DDO, + authorization: token, + ddo: { + id: 'did:op:f00896cc6f5f9f2c17be06dd28bd6be085e1406bb55274cbd2b65b7271e7b104', + '@context': [], + version: '4.1.0', + nftAddress: '0x3357cCd4e75536422b61F6aeda3ad38545b9b01F', + chainId: 11155111, + metadata: { + created: new Date().toISOString(), + updated: new Date().toISOString(), + type: 'dataset', + name: 'Test DDO', + description: 'Test DDO', + tags: [], + author: 'Test Author', + license: 'https://market.oceanprotocol.com/terms', + additionalInformation: { + termsAndConditions: true + } + }, + services: [ + { + id: 'ccb398c50d6abd5b456e8d7242bd856a1767a890b537c2f8c10ba8b8a10e6025', + type: 'compute', + files: '0x0', + datatokenAddress: '0x0Cf4BE72EAD0583deD382589aFcbF34F3E860Bdc', + serviceEndpoint: '', + timeout: 86400 + } + ] + } + }) + + return validateResponse + } catch (error) { + console.log(`Error validating DDO: ${error}`) + return { status: error.response.status, data: error.response.data } + } + } + + describe('Token Management Tests', () => { + it('should create and validate token', async function () { + this.timeout(DEFAULT_TEST_TIMEOUT) + + const consumerAddress = await consumerAccount.getAddress() + const nonce = getRandomNonce() + const message = auth.getMessage(consumerAddress, nonce) + const messageHash = getMessageHash(message) + const signature = await consumerAccount.signMessage(messageHash) + + const handlerResponse = await new CreateAuthTokenHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.CREATE_AUTH_TOKEN, + address: consumerAddress, + signature, + nonce + }) + + const token = await streamToObject(handlerResponse.stream as Readable) + const testEndpointResponse = await ddoValiationRequest(token.token) + expect(testEndpointResponse.status.httpStatus).to.equal(200) + }) + + it('should handle token expiry', async function () { + this.timeout(DEFAULT_TEST_TIMEOUT) + + const consumerAddress = await consumerAccount.getAddress() + const nonce = getRandomNonce() + const message = auth.getMessage(consumerAddress, nonce) + const messageHash = getMessageHash(message) + const signature = await consumerAccount.signMessage(messageHash) + + const validUntil = Date.now() + 1000 + const handlerResponse = await new CreateAuthTokenHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.CREATE_AUTH_TOKEN, + address: consumerAddress, + signature, + nonce, + validUntil + }) + + const token = await streamToObject(handlerResponse.stream as Readable) + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + const testEndpointResponse = await ddoValiationRequest(token.token) + expect(testEndpointResponse.status.httpStatus).to.equal(401) + }) + + it('should invalidate token', async function () { + this.timeout(DEFAULT_TEST_TIMEOUT) + + const consumerAddress = await consumerAccount.getAddress() + const nonce = getRandomNonce() + const message = auth.getMessage(consumerAddress, nonce) + const messageHash = getMessageHash(message) + const signature = await consumerAccount.signMessage(messageHash) + + const handlerResponse = await new CreateAuthTokenHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.CREATE_AUTH_TOKEN, + address: consumerAddress, + signature, + nonce + }) + + const token = await streamToObject(handlerResponse.stream as Readable) + const newNonce = getRandomNonce() + + await new InvalidateAuthTokenHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.INVALIDATE_AUTH_TOKEN, + address: consumerAddress, + signature, + nonce: newNonce, + token: token.token + }) + + const testEndpointResponse = await ddoValiationRequest(token.token) + expect(testEndpointResponse.status.httpStatus).to.equal(401) + }) + + describe('Error Cases', () => { + it('should handle invalid signatures', async function () { + this.timeout(DEFAULT_TEST_TIMEOUT) + + const consumerAddress = await consumerAccount.getAddress() + + const response = await new CreateAuthTokenHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.CREATE_AUTH_TOKEN, + address: consumerAddress, + signature: '0xinvalid', + nonce: getRandomNonce() + }) + expect(response.status.httpStatus).to.equal(401) + }) + + it('should handle invalid tokens', async function () { + this.timeout(DEFAULT_TEST_TIMEOUT) + + const testEndpointResponse = await ddoValiationRequest('invalid-token') + expect(testEndpointResponse.status.httpStatus).to.equal(401) + }) + + it('should handle missing parameters', async function () { + this.timeout(DEFAULT_TEST_TIMEOUT) + + // Missing signature + const response = await new CreateAuthTokenHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.CREATE_AUTH_TOKEN, + address: await consumerAccount.getAddress(), + signature: undefined, + nonce: getRandomNonce() + }) + expect(response.status.httpStatus).to.equal(400) + + // Missing address + const nonce = getRandomNonce() + const message = auth.getMessage(await consumerAccount.getAddress(), nonce) + const messageHash = getMessageHash(message) + const signature = await consumerAccount.signMessage(messageHash) + + const response2 = await new CreateAuthTokenHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.CREATE_AUTH_TOKEN, + address: undefined, + signature, + nonce: getRandomNonce() + }) + expect(response2.status.httpStatus).to.equal(400) + }) + }) + }) +}) diff --git a/src/test/integration/compute.test.ts b/src/test/integration/compute.test.ts index 4dd35103c..2aab6d135 100644 --- a/src/test/integration/compute.test.ts +++ b/src/test/integration/compute.test.ts @@ -146,7 +146,7 @@ describe('Compute', () => { ) config = await getConfiguration(true) dbconn = await new Database(config.dbConfig) - oceanNode = await OceanNode.getInstance(config, dbconn, null, null, null) + oceanNode = await OceanNode.getInstance(config, dbconn, null, null, null, true) indexer = new OceanIndexer(dbconn, config.indexingNetworks) oceanNode.addIndexer(indexer) oceanNode.addC2DEngines() diff --git a/src/test/integration/operationsDashboard.test.ts b/src/test/integration/operationsDashboard.test.ts index c382e0f8c..3c402cd2d 100644 --- a/src/test/integration/operationsDashboard.test.ts +++ b/src/test/integration/operationsDashboard.test.ts @@ -352,7 +352,8 @@ describe('Should test admin operations', () => { // ----------------------------------------- // IndexingThreadHandler const indexingHandler: IndexingThreadHandler = CoreHandlersRegistry.getInstance( - oceanNode + oceanNode, + true ).getHandler(PROTOCOL_COMMANDS.HANDLE_INDEXING_THREAD) as IndexingThreadHandler const signature = await getSignature(expiryTimestamp.toString()) @@ -379,6 +380,7 @@ describe('Should test admin operations', () => { // should exist a running thread for this network atm const response = await indexingHandler.handle(indexingStopCommand) + console.log({ responseStoppingThread: response }) assert(response.stream, 'Failed to get stream when stoping thread') expect(response.status.httpStatus).to.be.equal(200) diff --git a/src/test/unit/auth/token.test.ts b/src/test/unit/auth/token.test.ts new file mode 100644 index 000000000..f185de0ba --- /dev/null +++ b/src/test/unit/auth/token.test.ts @@ -0,0 +1,68 @@ +import { OceanNodeConfig } from '../../../@types/OceanNode.js' +import { getConfiguration } from '../../../utils/index.js' +import { expect } from 'chai' +import { Wallet } from 'ethers' +import { Auth } from '../../../components/Auth/index.js' +import { AuthTokenDatabase } from '../../../components/database/AuthTokenDatabase.js' + +describe('Auth Token Tests', () => { + let wallet: Wallet + let authTokenDatabase: AuthTokenDatabase + let config: OceanNodeConfig + let auth: Auth + + before(async () => { + config = await getConfiguration(true) + authTokenDatabase = await AuthTokenDatabase.create(config.dbConfig) + wallet = new Wallet(process.env.PRIVATE_KEY) + auth = new Auth(authTokenDatabase) + }) + + const getRandomNonce = () => { + return Math.floor(Math.random() * 1000000).toString() + } + + it('should create and validate a token', async () => { + const jwtToken = await auth.getJWTToken(wallet.address, getRandomNonce(), Date.now()) + await auth.insertToken(wallet.address, jwtToken, Date.now() + 1000, Date.now()) + + const result = await auth.validateAuthenticationOrToken({ token: jwtToken }) + expect(result.valid).to.be.equal(true) + }) + + it('should fail validation with invalid token', async () => { + const result = await auth.validateAuthenticationOrToken({ token: 'invalid-token' }) + expect(result.valid).to.be.equal(false) + }) + + it('should fail validation with invalid signature', async () => { + const invalidSignature = '0x' + '0'.repeat(130) + + const result = await auth.validateAuthenticationOrToken({ + signature: invalidSignature, + nonce: getRandomNonce(), + address: wallet.address + }) + expect(result.valid).to.be.equal(false) + }) + + it('should respect token expiry', async () => { + const jwtToken = await auth.getJWTToken(wallet.address, getRandomNonce(), Date.now()) + await auth.insertToken(wallet.address, jwtToken, Date.now() + 1000, Date.now()) + + await new Promise((resolve) => setTimeout(resolve, 1500)) + + const validationResult = await auth.validateToken(jwtToken) + expect(validationResult).to.be.equal(null) + }) + + it('should invalidate a token', async () => { + const jwtToken = await auth.getJWTToken(wallet.address, getRandomNonce(), Date.now()) + await auth.insertToken(wallet.address, jwtToken, Date.now() + 1000, Date.now()) + + await auth.invalidateToken(jwtToken) + + const validationResult = await auth.validateToken(jwtToken) + expect(validationResult).to.be.equal(null) + }) +}) diff --git a/src/test/unit/indexer/validation.test.ts b/src/test/unit/indexer/validation.test.ts index 11a4b2b69..aa7edbb21 100644 --- a/src/test/unit/indexer/validation.test.ts +++ b/src/test/unit/indexer/validation.test.ts @@ -1,6 +1,6 @@ import { DDOExample, ddov5, ddov7, ddoValidationSignature } from '../../data/ddo.js' import { getValidationSignature } from '../../../components/core/utils/validateDdoHandler.js' -import { ENVIRONMENT_VARIABLES } from '../../../utils/index.js' +import { ENVIRONMENT_VARIABLES, getConfiguration } from '../../../utils/index.js' import { expect } from 'chai' import { setupEnvironment, @@ -13,13 +13,20 @@ import { DDOManager, DDO } from '@oceanprotocol/ddo-js' import { ValidateDDOHandler } from '../../../components/core/handler/ddoHandler.js' import { OceanNode } from '../../../OceanNode.js' import { PROTOCOL_COMMANDS } from '../../../utils/constants.js' -import { ethers } from 'ethers' +import { ethers, Wallet } from 'ethers' import { RPCS } from '../../../@types/blockchain.js' - +import { Database } from '../../../components/database/index.js' +import { OceanNodeConfig } from '../../../@types/OceanNode.js' describe('Schema validation tests', () => { - let envOverrides: OverrideEnvConfig[] + const privateKey = process.env.PRIVATE_KEY const mockSupportedNetworks: RPCS = getMockSupportedNetworks() + let envOverrides: OverrideEnvConfig[] + let wallet: Wallet + let database: Database + let config: OceanNodeConfig + let oceanNode: OceanNode + before(async () => { envOverrides = buildEnvOverrideConfig( [ @@ -38,6 +45,17 @@ describe('Schema validation tests', () => { ] ) envOverrides = await setupEnvironment(null, envOverrides) + wallet = new ethers.Wallet(privateKey) + config = await getConfiguration() + database = await new Database(config.dbConfig) + oceanNode = await OceanNode.getInstance( + config, + database, + undefined, + undefined, + undefined, + true + ) }) after(() => { @@ -104,8 +122,7 @@ describe('Schema validation tests', () => { }) it('should fail validation when signature is missing', async () => { - const node = OceanNode.getInstance() - const handler = new ValidateDDOHandler(node) + const handler = new ValidateDDOHandler(oceanNode) const ddoInstance = DDOManager.getDDOClass(DDOExample) const task = { ddo: ddoInstance.getDDOData() as DDO, @@ -115,12 +132,11 @@ describe('Schema validation tests', () => { } const result = await handler.handle(task) - expect(result.status.httpStatus).to.equal(400) + expect(result.status.httpStatus).to.equal(401) }) it('should fail validation when signature is invalid', async () => { - const node = OceanNode.getInstance() - const handler = new ValidateDDOHandler(node) + const handler = new ValidateDDOHandler(oceanNode) const ddoInstance = DDOManager.getDDOClass(DDOExample) const ddo: DDO = { ...(ddoInstance.getDDOData() as DDO) @@ -135,29 +151,27 @@ describe('Schema validation tests', () => { const result = await handler.handle(task) - expect(result.status.httpStatus).to.equal(400) + expect(result.status.httpStatus).to.equal(401) }) it('should pass validation with valid signature', async () => { - const node = OceanNode.getInstance() - const handler = new ValidateDDOHandler(node) + const handler = new ValidateDDOHandler(oceanNode) const ddoInstance = DDOManager.getDDOClass(ddoValidationSignature) const ddo = ddoInstance.getDDOData() as DDO - - const privateKey = process.env.PRIVATE_KEY - const wallet = new ethers.Wallet(privateKey) + const auth = oceanNode.getAuth() + const publisherAddress = await wallet.getAddress() const nonce = Date.now().toString() - const message = ddo.id + nonce + const message = auth.getMessage(publisherAddress, nonce) const messageHash = ethers.solidityPackedKeccak256( ['bytes'], [ethers.hexlify(ethers.toUtf8Bytes(message))] ) - const messageHashBytes = ethers.getBytes(messageHash) + const messageHashBytes = ethers.toBeArray(messageHash) const signature = await wallet.signMessage(messageHashBytes) const task = { ddo, - publisherAddress: await wallet.getAddress(), + publisherAddress, nonce, signature, command: PROTOCOL_COMMANDS.VALIDATE_DDO diff --git a/src/utils/blockchain.ts b/src/utils/blockchain.ts index 3a175d04e..72f4f9d31 100644 --- a/src/utils/blockchain.ts +++ b/src/utils/blockchain.ts @@ -270,6 +270,15 @@ export async function verifyMessage( } } +export function getMessageHash(message: string): Uint8Array { + const messageHash = ethers.solidityPackedKeccak256( + ['bytes'], + [ethers.hexlify(ethers.toUtf8Bytes(message))] + ) + const messageHashBytes = ethers.toBeArray(messageHash) + return messageHashBytes +} + export async function checkSupportedChainId(chainId: number): Promise { const config = await getConfiguration() if (!chainId || !(`${chainId.toString()}` in config.supportedNetworks)) { diff --git a/src/utils/config.ts b/src/utils/config.ts index 8f64f4c6d..cbaf3ecc4 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -832,7 +832,8 @@ async function getEnvConfig(isStartup?: boolean): Promise { ), isBootstrap: getBoolEnvValue('IS_BOOTSTRAP', false), claimDurationTimeout: getIntEnvValue(process.env.ESCROW_CLAIM_TIMEOUT, 600), - validateUnsignedDDO: getBoolEnvValue('VALIDATE_UNSIGNED_DDO', true) + validateUnsignedDDO: getBoolEnvValue('VALIDATE_UNSIGNED_DDO', true), + jwtSecret: getEnvValue(process.env.JWT_SECRET, 'ocean-node-secret') } if (!previousConfiguration) { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 4a4316452..edd438f28 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -33,7 +33,9 @@ export const PROTOCOL_COMMANDS = { GET_P2P_PEER: 'getP2PPeer', GET_P2P_PEERS: 'getP2PPeers', GET_P2P_NETWORK_STATS: 'getP2PNetworkStats', - FIND_PEER: 'findPeer' + FIND_PEER: 'findPeer', + CREATE_AUTH_TOKEN: 'createAuthToken', + INVALIDATE_AUTH_TOKEN: 'invalidateAuthToken' } // more visible, keep then close to make sure we always update both export const SUPPORTED_PROTOCOL_COMMANDS: string[] = [ @@ -67,7 +69,9 @@ export const SUPPORTED_PROTOCOL_COMMANDS: string[] = [ PROTOCOL_COMMANDS.GET_P2P_PEER, PROTOCOL_COMMANDS.GET_P2P_PEERS, PROTOCOL_COMMANDS.GET_P2P_NETWORK_STATS, - PROTOCOL_COMMANDS.FIND_PEER + PROTOCOL_COMMANDS.FIND_PEER, + PROTOCOL_COMMANDS.CREATE_AUTH_TOKEN, + PROTOCOL_COMMANDS.INVALIDATE_AUTH_TOKEN ] export const MetadataStates = { diff --git a/src/utils/decorators/validate-token.decorator.ts b/src/utils/decorators/validate-token.decorator.ts new file mode 100644 index 000000000..16b0b60a1 --- /dev/null +++ b/src/utils/decorators/validate-token.decorator.ts @@ -0,0 +1,61 @@ +import { P2PCommandResponse } from '../../@types' + +/** + * This decorator validates the token or signature of the request + * You can use it by adding @ValidateTokenOrSignature above the handler method + * @param skipValidation - If true, the validation will be skipped. You can also pass a function that returns a boolean. + */ +export function ValidateTokenOrSignature( + skipValidation?: boolean | (() => Promise) +) { + return function ( + _target: Object, + _propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor<(...args: any[]) => Promise> + ) { + const originalMethod = descriptor.value + + descriptor.value = async function (...args: any[]): Promise { + let shouldSkip = skipValidation + if (typeof skipValidation === 'function') { + shouldSkip = await skipValidation() + } + + if (shouldSkip) { + return originalMethod.apply(this, args) + } + + const task = args[0] + const { authorization, signature, nonce } = task + const address = task.address || task.publisherAddress + const jwt = authorization?.includes('Bearer') + ? authorization.split(' ')[1] + : authorization + const oceanNode = this.getOceanNode() + + const auth = oceanNode.getAuth() + const isAuthRequestValid = await auth.validateAuthenticationOrToken({ + token: jwt, + signature, + nonce, + address + }) + if (!isAuthRequestValid.valid) { + console.log( + `Error validating token or signature while executing command: ${task.command}` + ) + return { + status: { + httpStatus: 401, + error: 'Invalid token or signature' + }, + stream: null + } + } + + return await originalMethod.apply(this, args) + } + + return descriptor + } +}