Skip to content

Commit

Permalink
feat: added new subscriptions module
Browse files Browse the repository at this point in the history
  • Loading branch information
r-marques committed Feb 28, 2023
1 parent d9e5a39 commit 704a814
Show file tree
Hide file tree
Showing 10 changed files with 437 additions and 174 deletions.
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@
"@nestjs/swagger": "^5.2.0",
"@nestjs/typeorm": "^8.0.3",
"@nevermined-io/argo-workflows-api": "^0.1.3",
"@nevermined-io/sdk-dtp": "0.3.0",
"@nevermined-io/sdk": "1.0.0",
"@nevermined-io/passport-nevermined": "^0.1.1",
"@nevermined-io/sdk": "1.0.0",
"@nevermined-io/sdk-dtp": "0.3.0",
"@sideway/address": "^4.1.3",
"@sideway/formula": "^3.0.1",
"@sideway/pinpoint": "^2.0.0",
Expand All @@ -56,7 +56,7 @@
"formdata-polyfill": "^4.0.10",
"ipfs-http-client-lite": "^0.3.0",
"joi": "^17.6.0",
"jose": "^4.11.2",
"jose": "^4.13.0",
"js-yaml": "4.1.0",
"jsonwebtoken": "^9.0.0",
"lodash": "^4.17.21",
Expand All @@ -79,7 +79,8 @@
"@commitlint/config-conventional": "^17.4.2",
"@faker-js/faker": "^6.0.0-beta.0",
"@golevelup/ts-jest": "0.3.4",
"@nestjs/cli": "^8.2.2",
"@nestjs/cli": "^9.2.0",
"@nestjs/schematics": "^9.0.4",
"@nestjs/testing": "^8.4.0",
"@types/jest": "^29.2.3",
"@types/jsonwebtoken": "^8.5.8",
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AccessModule } from './access/access.module'
import { NeverminedModule } from './shared/nevermined/nvm.module'
import { ComputeModule } from './compute/compute.module'
import { HttpLoggerMiddleware } from './common/middlewares/http-logger/http-logger.middleware'
import { SubscriptionsModule } from './subscriptions/subscriptions.module'

@Module({
imports: [
Expand All @@ -23,6 +24,7 @@ import { HttpLoggerMiddleware } from './common/middlewares/http-logger/http-logg
AuthModule,
NeverminedModule,
ComputeModule,
SubscriptionsModule,
],
})
export class ApplicationModule {
Expand Down
2 changes: 2 additions & 0 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { EncryptModule } from './encrypt/encrypt.module'
import { InfoModule } from './info/info.module'
import { AccessModule } from './access/access.module'
import { ComputeModule } from './compute/compute.module'
import { SubscriptionsModule } from './subscriptions/subscriptions.module'

const exposeCompute: boolean = process.env.ENABLE_COMPUTE === 'true'

export const routes: Routes = [
{ path: '/api/v1/node/services/encrypt', module: EncryptModule },
{ path: '/api/v1/node/services/oauth', module: AuthModule },
{ path: '/api/v1/node/services/subscription', module: SubscriptionsModule },
{ path: '/api/v1/node/services', module: AccessModule },
{ path: '/', module: InfoModule },
]
Expand Down
17 changes: 14 additions & 3 deletions src/shared/nevermined/nvm.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,8 @@ export class NeverminedService {
const name = file_attributes.name
const auth_method = asset.findServiceByType('authorization').service || 'RSAES-OAEP'
if (auth_method === 'RSAES-OAEP') {
const filelist = JSON.parse(
await decrypt(this.config.cryptoConfig(), service.attributes.encryptedFiles, 'PSK-RSA'),
)
const filelist = this.decrypt(service.attributes.encryptedFiles, 'PSK-RSA')

// download url or what?
const url: string = filelist[index].url
return { url, content_type, dtp: this.isDTP(service.attributes.main), name }
Expand All @@ -98,6 +97,18 @@ export class NeverminedService {
throw new BadRequestException()
}

/**
* Decrypts a an encrypted JSON object
*
* @param encryptedJson - The encrypted json object as a string
* @param encryptionMethod - The encryption method used. Currently only PSK-RSA is supported
*
* @returns The decrypted JSON object
*/
async decrypt(encryptedJson: string, encryptionMethod: string): Promise<any> {
return JSON.parse(await decrypt(this.config.cryptoConfig(), encryptedJson, encryptionMethod))
}

async downloadAsset(
did: string,
index: number,
Expand Down
18 changes: 18 additions & 0 deletions src/subscriptions/subscriptions.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { SubscriptionsController } from './subscriptions.controller'

describe('SubscriptionsController', () => {
let controller: SubscriptionsController

beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [SubscriptionsController],
}).compile()

controller = moduleRef.get<SubscriptionsController>(SubscriptionsController)
})

it('should be defined', () => {
expect(controller).toBeDefined()
})
})
50 changes: 50 additions & 0 deletions src/subscriptions/subscriptions.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Controller, ForbiddenException, Get, Param, Req } from '@nestjs/common'
import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'
import { LoginDto } from '../auth/dto/login.dto'
import { SubscriptionsService } from './subscriptions.service'

@Controller('subscriptions')
export class SubscriptionsController {
constructor(private subscriptionService: SubscriptionsService) {}

@Get(':did')
@ApiOperation({
description: 'Get and access token for a subscription',
})
@ApiResponse({
status: 200,
description: 'Returns the access token',
type: LoginDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized access',
})
@ApiBearerAuth('Authorization')
async getAccessToken(@Req() req, @Param('did') did: string): Promise<LoginDto> {
// get subscription data
const { contractAddress, numberNfts, endpoints, headers } =
await this.subscriptionService.validateDid(did)

// validate that the subscription is valid
const isValid = this.subscriptionService.isSubscriptionValid(
contractAddress,
numberNfts,
req.user.iss,
)

if (!isValid) {
throw new ForbiddenException(`user ${req.user.iss} has not access to subscription ${did}`)
}

// get access token
const accessToken = await this.subscriptionService.generateToken(
did,
req.user.iss,
endpoints,
headers,
)

return { access_token: accessToken }
}
}
11 changes: 11 additions & 0 deletions src/subscriptions/subscriptions.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common'
import { NeverminedModule } from '../shared/nevermined/nvm.module'
import { SubscriptionsController } from './subscriptions.controller'
import { SubscriptionsService } from './subscriptions.service'

@Module({
controllers: [SubscriptionsController],
providers: [SubscriptionsService],
imports: [NeverminedModule],
})
export class SubscriptionsModule {}
18 changes: 18 additions & 0 deletions src/subscriptions/subscriptions.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { SubscriptionsService } from './subscriptions.service'

describe('SubscriptionsService', () => {
let service: SubscriptionsService

beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [SubscriptionsService],
}).compile()

service = moduleRef.get<SubscriptionsService>(SubscriptionsService)
})

it('should be defined', () => {
expect(service).toBeDefined()
})
})
111 changes: 111 additions & 0 deletions src/subscriptions/subscriptions.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { BadRequestException, Injectable } from '@nestjs/common'
import { DDOServiceNotFoundError, findServiceConditionByName, Service } from '@nevermined-io/sdk'
import { NeverminedService } from '../shared/nevermined/nvm.service'
import jose from 'jose'

export interface SubscriptionData {
numberNfts: number
contractAddress: string
endpoints: string[]
headers: { [key: string]: string }[]
}

@Injectable()
export class SubscriptionsService {
constructor(private nvmService: NeverminedService) {}

/**
* Validates if a DID has an associated subscription
*
* @param did - The DID of the asset with an associated subscription
*
* @throws {@link BadRequestException}
* @returns {@link SubscriptionData}
*/
public async validateDid(did: string): Promise<SubscriptionData> {
// get the DDO
const ddo = await this.nvmService.nevermined.assets.resolve(did)
if (!ddo) {
throw new BadRequestException(`${did} not found.`)
}

// get the nft-access service
let nftAccessService: Service<'nft-access'>
try {
nftAccessService = ddo.findServiceByType('nft-access')
} catch (e) {
if (e instanceof DDOServiceNotFoundError) {
throw new BadRequestException(`${did} does not contain an 'nft-access' service`)
} else {
throw e
}
}

// get the nft-holder condition
const nftHolderCondition = findServiceConditionByName(nftAccessService, 'nftHolder')
const numberNfts = Number(
nftHolderCondition.parameters.find((p) => p.name === '_numberNfts').value,
)
const contractAddress = nftHolderCondition.parameters.find((p) => p.name === '_contractAddress')
.value as string

// get the web-service endpoints
const metadata = ddo.findServiceByType('metadata')
if (!metadata.attributes.main.webService) {
throw new BadRequestException(`${did} does not contain any web services`)
}
const endpoints = metadata.attributes.main.webService.endpoints.flatMap((e) => Object.values(e))

// decrypt the headers
const headers = await this.nvmService.decrypt(
metadata.attributes.main.webService.encryptedAttributes,
'PSK-RSA',
)

return {
numberNfts,
contractAddress,
endpoints,
headers,
}
}

/**
* Validates if a subscription is valid or not
*
* @param contractAddress - The NFT-721 contract address
* @param numberNfts - Amount of `contractAddress` nfts a user needs to hold in order to get access to the subscription
* @param userAddress - The ethereum address of the user
*
* @returns {@link boolean}
*/
public async isSubscriptionValid(
contractAddress: string,
numberNfts: number,
userAddress: string,
): Promise<boolean> {
const nft = await this.nvmService.nevermined.contracts.loadNft721(contractAddress)
const balance = await nft.balanceOf(userAddress)
return balance.toNumber() >= numberNfts
}

public async generateToken(
did: string,
userAddress: string,
endpoints: any,
headers?: any,
): Promise<string> {
const JWT_SECRET_PHRASE = process.env.JWT_SECRET_PHRASE || '12345678901234567890123456789012'
const JWT_SECRET = Uint8Array.from(JWT_SECRET_PHRASE.split('').map((x) => parseInt(x)))
return await new jose.EncryptJWT({
did: did,
userId: userAddress,
endpoints,
headers,
})
.setProtectedHeader({ alg: 'dir', enc: 'A128CBC-HS256' })
.setIssuedAt()
.setExpirationTime('1w')
.encrypt(JWT_SECRET)
}
}

0 comments on commit 704a814

Please sign in to comment.