diff --git a/apps/demo/public/assets/issuer-config.json b/apps/demo/public/assets/issuer-config.json index b97f4b57..7a8fce20 100644 --- a/apps/demo/public/assets/issuer-config.json +++ b/apps/demo/public/assets/issuer-config.json @@ -1,5 +1,5 @@ { - "backendUrlPP": "http://localhost:3001", + "backendUrl": "http://localhost:3001", "credentialId": "Identity", "oidcUrl": "http://host.docker.internal:8080/realms/wallet", "oidcClientId": "relying-party", diff --git a/apps/holder-app/src/app/scanner/scanner.component.ts b/apps/holder-app/src/app/scanner/scanner.component.ts index f752e41a..0c695c4d 100644 --- a/apps/holder-app/src/app/scanner/scanner.component.ts +++ b/apps/holder-app/src/app/scanner/scanner.component.ts @@ -3,7 +3,6 @@ import { CameraDevice, Html5Qrcode } from 'html5-qrcode'; import { MatMenuModule } from '@angular/material/menu'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; -import { HttpClient } from '@angular/common/http'; import { CommonModule } from '@angular/common'; import { VerifyRequestComponent, @@ -42,7 +41,7 @@ export class ScannerComponent implements OnInit, OnDestroy { loading = true; url?: string; - constructor(private httpClient: HttpClient) { + constructor() { if ( navigator.clipboard && typeof navigator.clipboard.readText !== 'undefined' @@ -92,7 +91,6 @@ export class ScannerComponent implements OnInit, OnDestroy { * Stop the scanner when leaving the page */ async ngOnDestroy(): Promise { - console.log('destroying'); await this.stopScanning(); } @@ -137,11 +135,8 @@ export class ScannerComponent implements OnInit, OnDestroy { // handle the scanned code as you like, for example: if (decodedText.startsWith('openid-credential-offer://')) { this.showRequest(decodedText, 'receive'); - // use a constant for the verification schema - await this.stopScanning(); } else if (decodedText.startsWith('openid://')) { this.showRequest(decodedText, 'send'); - await this.stopScanning(); } else { alert("Scanned text doesn't match the expected format"); } diff --git a/apps/holder-backend/src/app/auth/oidc-client/keycloak-oidc-client.ts b/apps/holder-backend/src/app/auth/oidc-client/keycloak-oidc-client.ts index 39edd285..df7dd394 100644 --- a/apps/holder-backend/src/app/auth/oidc-client/keycloak-oidc-client.ts +++ b/apps/holder-backend/src/app/auth/oidc-client/keycloak-oidc-client.ts @@ -1,7 +1,6 @@ import { OIDCClient } from './oidc-client'; import { ConfigService } from '@nestjs/config'; -import { OnEvent } from '@nestjs/event-emitter'; -import { USER_DELETED_EVENT, UserDeletedEvent } from '../auth.service'; +import { UserDeletedEvent } from '../auth.service'; import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { firstValueFrom } from 'rxjs'; diff --git a/apps/holder-backend/src/app/auth/webauthn/entities/passkey.entity.ts b/apps/holder-backend/src/app/auth/webauthn/entities/passkey.entity.ts index 9f23eb50..1e233490 100644 --- a/apps/holder-backend/src/app/auth/webauthn/entities/passkey.entity.ts +++ b/apps/holder-backend/src/app/auth/webauthn/entities/passkey.entity.ts @@ -1,10 +1,4 @@ -import { - BeforeInsert, - BeforeUpdate, - Column, - Entity, - PrimaryColumn, -} from 'typeorm'; +import { Column, Entity, PrimaryColumn } from 'typeorm'; import type { AuthenticatorTransportFuture, CredentialDeviceType, diff --git a/apps/holder-backend/src/app/oid4vc/oid4vci/oid4vci.controller.ts b/apps/holder-backend/src/app/oid4vc/oid4vci/oid4vci.controller.ts index a8d2dc16..cc88b43e 100644 --- a/apps/holder-backend/src/app/oid4vc/oid4vci/oid4vci.controller.ts +++ b/apps/holder-backend/src/app/oid4vc/oid4vci/oid4vci.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { ApiCreatedResponse, ApiOAuth2, diff --git a/apps/holder-backend/src/app/oid4vc/oid4vp/oid4vp.service.ts b/apps/holder-backend/src/app/oid4vc/oid4vp/oid4vp.service.ts index 9ab4113d..39386846 100644 --- a/apps/holder-backend/src/app/oid4vc/oid4vp/oid4vp.service.ts +++ b/apps/holder-backend/src/app/oid4vc/oid4vp/oid4vp.service.ts @@ -16,16 +16,14 @@ import { import { SdJwtDecodedVerifiableCredentialWithKbJwtInput } from '@sphereon/pex'; import { v4 as uuid } from 'uuid'; import { Oid4vpParseRepsonse } from './dto/parse-response.dto'; -import { - CredentialSelection, - SubmissionRequest, -} from './dto/submission-request.dto'; +import { CredentialSelection } from './dto/submission-request.dto'; import { Oid4vpParseRequest } from './dto/parse-request.dto'; import { Session } from './session'; import { CompactSdJwtVc } from '@sphereon/ssi-types'; import { CredentialsService } from '../../credentials/credentials.service'; import { HistoryService } from '../../history/history.service'; import { KeysService } from '../../keys/keys.service'; +import { JWkResolver } from '@credhub/relying-party-shared'; @Injectable() export class Oid4vpService { @@ -49,10 +47,13 @@ export class Oid4vpService { //parse the uri const parsedAuthReqURI = await op.parseAuthorizationRequestURI(data.url); + console.log('verify'); const verifiedAuthReqWithJWT: VerifiedAuthorizationRequest = await op.verifyAuthorizationRequest( - parsedAuthReqURI.requestObjectJwt as string + parsedAuthReqURI.requestObjectJwt as string, + {} ); + console.log('verified'); const issuer = ( verifiedAuthReqWithJWT.authorizationRequestPayload @@ -232,6 +233,7 @@ export class Oid4vpService { const alg = SigningAlgo.ES256; const withSuppliedSignature = async (data: string | Uint8Array) => { + console.log('sign'); const signature = await this.keysService.sign(kid, user, { data: data as string, }); @@ -242,7 +244,7 @@ export class Oid4vpService { .withExpiresIn(1000) .withHasher(digest) .withIssuer(ResponseIss.SELF_ISSUED_V2) - .addDidMethod('jwk') + .addResolver('jwk', new JWkResolver()) .withSuppliedSignature(withSuppliedSignature, did, kid, alg) .withSupportedVersions(SupportedVersion.SIOPv2_D12_OID4VP_D18) .build(); diff --git a/apps/issuer-backend/src/app/app.module.ts b/apps/issuer-backend/src/app/app.module.ts index 4c6633e2..8192abc7 100644 --- a/apps/issuer-backend/src/app/app.module.ts +++ b/apps/issuer-backend/src/app/app.module.ts @@ -8,7 +8,7 @@ import { KeyModule, OIDC_VALIDATION_SCHEMA, } from '@credhub/relying-party-shared'; -import { DB_VALIDATION_SCHEMA, DbModule } from './db/db.module'; +import { DB_VALIDATION_SCHEMA, DbModule } from '@credhub/relying-party-shared'; import { CredentialsModule } from './credentials/credentials.module'; import { StatusModule } from './status/status.module'; import { ScheduleModule } from '@nestjs/schedule'; @@ -26,6 +26,7 @@ import { IssuerModule } from './issuer/issuer.module'; .valid('development', 'production') .default('development'), CREDENTIALS_FOLDER: Joi.string().required(), + //TODO: we only need this, when we configured datbase type, not file type ...DB_VALIDATION_SCHEMA, ...KEY_VALIDATION_SCHEMA, ...OIDC_VALIDATION_SCHEMA, diff --git a/apps/issuer-backend/src/app/issuer/issuer-data.service.ts b/apps/issuer-backend/src/app/issuer/issuer-data.service.ts index ff477bbe..5fcc249e 100644 --- a/apps/issuer-backend/src/app/issuer/issuer-data.service.ts +++ b/apps/issuer-backend/src/app/issuer/issuer-data.service.ts @@ -1,9 +1,8 @@ import { CredentialIssuerMetadataOptsV1_0_13 } from '@sphereon/oid4vci-common'; -import { readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { CredentialSchema } from './types.js'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { TemplatesService } from '../templates/template.service'; +import { MetadataService } from '../templates/metadata.service'; /** * The issuer class is responsible for managing the credentials and the metadata of the issuer. @@ -16,46 +15,22 @@ export class IssuerDataService { */ private metadata!: CredentialIssuerMetadataOptsV1_0_13; - /** - * The credentials supported by the issuer. - */ - private credentials: Map = new Map(); - - constructor(private configSerivce: ConfigService) { + constructor( + private configSerivce: ConfigService, + private templatesService: TemplatesService, + private metadataService: MetadataService + ) { this.loadConfig(); } - public loadConfig() { - this.credentials.clear(); - const folder = this.configSerivce.get('CREDENTIALS_FOLDER'); - + public async loadConfig() { //instead of reading at the beginning, we could implement a read on demand. - this.metadata = JSON.parse( - readFileSync(join(folder, 'metadata.json'), 'utf-8') - ) as CredentialIssuerMetadataOptsV1_0_13; - this.metadata.credential_issuer = this.configSerivce.get('ISSUER_BASE_URL'); + this.metadata = await this.metadataService.getMetadata(); - if (!this.metadata.credential_configurations_supported) { - this.metadata.credential_configurations_supported = {}; - } + this.metadata.credential_issuer = this.configSerivce.get('ISSUER_BASE_URL'); - const files = readdirSync(join(folder, 'credentials')); - for (const file of files) { - //TODO: we should validate the schema - const content = JSON.parse( - readFileSync(join(folder, 'credentials', file), 'utf-8') - ) as CredentialSchema; - //check if an id is already used - if (this.credentials.has(content.schema.id as string)) { - throw new Error( - `The credential with the id ${content.schema.id} is already used.` - ); - } - this.credentials.set(content.schema.id as string, content); - this.metadata.credential_configurations_supported[ - content.schema.id as string - ] = content.schema; - } + this.metadata.credential_configurations_supported = + this.templatesService.getSupported(await this.templatesService.listAll()); } /** @@ -63,11 +38,11 @@ export class IssuerDataService { * @param id * @returns */ - getCredential(id: string) { + async getCredential(id: string) { if (this.configSerivce.get('CONFIG_RELOAD')) { this.loadConfig(); } - const credential = this.credentials.get(id); + const credential = await this.templatesService.getOne(id); if (!credential) { throw new Error(`The credential with the id ${id} is not supported.`); } @@ -79,11 +54,11 @@ export class IssuerDataService { * @param id * @returns */ - getDisclosureFrame(id: string) { + async getDisclosureFrame(id: string) { if (this.configSerivce.get('CONFIG_RELOAD')) { this.loadConfig(); } - const credential = this.credentials.get(id); + const credential = await this.templatesService.getOne(id); if (!credential) { throw new Error(`The credential with the id ${id} is not supported.`); } diff --git a/apps/issuer-backend/src/app/issuer/issuer.module.ts b/apps/issuer-backend/src/app/issuer/issuer.module.ts index f51c51a5..c63cc96d 100644 --- a/apps/issuer-backend/src/app/issuer/issuer.module.ts +++ b/apps/issuer-backend/src/app/issuer/issuer.module.ts @@ -5,9 +5,10 @@ import { IssuerController } from './issuer.controller'; import { WellKnownController } from './well-known/well-known.controller'; import { CredentialsModule } from '../credentials/credentials.module'; import { StatusModule } from '../status/status.module'; +import { TemplatesModule } from '../templates/templates.module'; @Module({ - imports: [CredentialsModule, StatusModule], + imports: [CredentialsModule, StatusModule, TemplatesModule], controllers: [IssuerController, WellKnownController], providers: [IssuerService, IssuerDataService], }) diff --git a/apps/issuer-backend/src/app/issuer/issuer.service.ts b/apps/issuer-backend/src/app/issuer/issuer.service.ts index 3b9cfa9f..4d72c056 100644 --- a/apps/issuer-backend/src/app/issuer/issuer.service.ts +++ b/apps/issuer-backend/src/app/issuer/issuer.service.ts @@ -83,7 +83,9 @@ export class IssuerService implements OnModuleInit { const credentialId = values.credentialId; const sessionId = v4(); try { - const credential = this.issuerDataService.getCredential(credentialId); + const credential = await this.issuerDataService.getCredential( + credentialId + ); let exp: number | undefined; // we either use the passed exp value or the ttl of the credential. If none is set, the credential will not expire. if (values.exp) { @@ -180,7 +182,7 @@ export class IssuerService implements OnModuleInit { ...(args.credentialDataSupplierInput as CredentialDataSupplierInput) .credentialSubject, //TODO: can be removed when correct type is set in PEX - status: status as any, + status: status as unknown as { idx: number; uri: string }, exp: args.credentialDataSupplierInput.exp, }; return Promise.resolve({ @@ -192,10 +194,9 @@ export class IssuerService implements OnModuleInit { /** * Signer callback for the access token. * @param jwt header and payload of the jwt - * @param kid key id that should be used for signing * @returns signed jwt */ - const signerCallback = async (jwt: Jwt, kid?: string): Promise => { + const signerCallback = async (jwt: Jwt): Promise => { return this.keyService.signJWT(jwt.payload, { ...jwt.header, alg: Alg.ES256, @@ -237,7 +238,7 @@ export class IssuerService implements OnModuleInit { > = async (args) => { const jwt = await sdjwt.issue( args.credential as unknown as SdJwtVcPayload, - this.issuerDataService.getDisclosureFrame( + await this.issuerDataService.getDisclosureFrame( args.credential.vct as string ), { header: { kid: await this.keyService.getKid() } } diff --git a/apps/issuer-backend/src/app/issuer/types.ts b/apps/issuer-backend/src/app/issuer/types.ts index 3b87f661..2443bd36 100644 --- a/apps/issuer-backend/src/app/issuer/types.ts +++ b/apps/issuer-backend/src/app/issuer/types.ts @@ -1,5 +1,3 @@ -import { DisclosureFrame } from '@sd-jwt/types'; -import { CredentialConfigurationSupportedV1_0_13 } from '@sphereon/oid4vci-common'; import { JWK } from 'jose'; /** @@ -12,13 +10,3 @@ export interface IssuerMetadata { keys: JWK[]; }; } - -/** - * The schema of the credential. - */ -export interface CredentialSchema { - schema: CredentialConfigurationSupportedV1_0_13; - sd: DisclosureFrame>; - // time to live in seconds, it will be added on the current time to get the expiration time. - ttl?: number; -} diff --git a/apps/issuer-backend/src/app/templates/dto/metadata.dto.ts b/apps/issuer-backend/src/app/templates/dto/metadata.dto.ts new file mode 100644 index 00000000..f463195f --- /dev/null +++ b/apps/issuer-backend/src/app/templates/dto/metadata.dto.ts @@ -0,0 +1,45 @@ +import { + ImageInfo as IImageInfo, + MetadataDisplay as IMetadataDisplay, +} from '@sphereon/oid4vci-common'; +import { Type } from 'class-transformer'; +import { IsString, IsOptional, ValidateNested, IsArray } from 'class-validator'; + +export class ImageInfo implements IImageInfo { + [key: string]: unknown; + @IsString() + @IsOptional() + url?: string; + @IsString() + @IsOptional() + alt_text?: string; +} + +class MetadataDisplay implements IMetadataDisplay { + [key: string]: unknown; + @IsString() + name: string; + @IsString() + @IsOptional() + locale?: string; + @ValidateNested() + @Type(() => ImageInfo) + logo?: ImageInfo; + @IsString() + @IsOptional() + description?: string; + @IsOptional() + @IsString() + background_color?: string; + @IsOptional() + @IsString() + text_color?: string; +} + +export class Metadata { + @IsOptional() + @ValidateNested({ each: true }) + @IsArray() + @Type(() => MetadataDisplay) + display?: MetadataDisplay[]; +} diff --git a/apps/issuer-backend/src/app/templates/dto/template.dto.ts b/apps/issuer-backend/src/app/templates/dto/template.dto.ts new file mode 100644 index 00000000..0efc5274 --- /dev/null +++ b/apps/issuer-backend/src/app/templates/dto/template.dto.ts @@ -0,0 +1,117 @@ +import { + IsNotEmpty, + IsOptional, + IsString, + IsInt, + ValidateNested, + IsArray, + IsObject, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + CredentialConfigurationSupportedV1_0_13 as ICredentialConfigurationSupportedV1_0_13, + CredentialDefinitionV1_0_13 as ICredentialDefinitionV1_0_13, + CredentialsSupportedDisplay as ICredentialSupportDisplay, + ImageInfo as IImageInfo, + IssuerCredentialSubject, + KeyProofType, + OID4VCICredentialFormat, + ProofType, +} from '@sphereon/oid4vci-common'; +import { DisclosureFrame } from '@sd-jwt/types'; + +export class ImageInfo implements IImageInfo { + [key: string]: unknown; + @IsString() + url: string; + @IsString() + alt_text: string; +} +class CredentialsSupportedDisplay implements ICredentialSupportDisplay { + [key: string]: unknown; + description?: string; + @IsString() + name: string; + @IsString() + locale: string; + @ValidateNested() + @Type(() => ImageInfo) + logo: ImageInfo; + @ValidateNested() + @Type(() => ImageInfo) + @IsOptional() + background_image?: ImageInfo; + @IsString() + @IsOptional() + background_color?: string; + @IsString() + @IsOptional() + text_color?: string; +} + +class CredentialDefinitionV1_0_13 implements ICredentialDefinitionV1_0_13 { + @IsString() + @IsOptional() + @IsArray() + type?: string[]; + + @IsOptional() + credentialSubject?: IssuerCredentialSubject; +} + +export class CredentialConfigurationSupportedV1_0_13 + implements ICredentialConfigurationSupportedV1_0_13 +{ + [x: string]: unknown; + + @ValidateNested() + @Type(() => CredentialDefinitionV1_0_13) + credential_definition: CredentialDefinitionV1_0_13; + + @IsOptional() + vct: string; + + @IsString() + id: string; + + @IsOptional() + @IsObject() + claims?: IssuerCredentialSubject; + + @IsNotEmpty() + format: OID4VCICredentialFormat; + + @IsOptional() + @IsString() + scope?: string; + + @IsOptional() + @IsArray() + cryptographic_binding_methods_supported?: string[]; + + @IsOptional() + @IsArray() + credential_signing_alg_values_supported?: string[]; + + @IsOptional() + @IsObject() + proof_types_supported?: Record; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CredentialsSupportedDisplay) + display?: CredentialsSupportedDisplay[]; +} + +export class Template { + @ValidateNested() + @Type(() => CredentialConfigurationSupportedV1_0_13) + schema: CredentialConfigurationSupportedV1_0_13; + + @IsObject() + sd: DisclosureFrame>; + + @IsInt() + ttl: number; +} diff --git a/apps/issuer-backend/src/app/templates/metadata.controller.ts b/apps/issuer-backend/src/app/templates/metadata.controller.ts new file mode 100644 index 00000000..31cf97be --- /dev/null +++ b/apps/issuer-backend/src/app/templates/metadata.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOAuth2, ApiOperation } from '@nestjs/swagger'; +import { AuthGuard } from 'nest-keycloak-connect'; +import { Metadata } from './dto/metadata.dto'; +import { MetadataService } from './metadata.service'; +import { CredentialIssuerMetadataOptsV1_0_13 } from '@sphereon/oid4vci-common'; + +@ApiTags('metadata') +@UseGuards(AuthGuard) +@ApiOAuth2([]) +@Controller('metadata') +export class MetadataController { + constructor(private metadataService: MetadataService) {} + + /** + * Get the metadata for the credential issuer. + * @returns + */ + @ApiOperation({ summary: 'Get the metadata for the credential issuer' }) + @Get() + getMetadata() { + //these are only the metadata values the user can change, it's not the whole metadata that are returned via the .well-known endpoint + return this.metadataService.getMetadata() as Metadata; + } + + @ApiOperation({ summary: 'Set the metadata for the credential issuer' }) + @Post() + setMetadata(@Body() metadata: Metadata) { + //we only want some specific values to be set, so we can not implement the whole interface + return this.metadataService.setMetadata( + metadata as CredentialIssuerMetadataOptsV1_0_13 + ); + } +} diff --git a/apps/issuer-backend/src/app/templates/metadata.service.ts b/apps/issuer-backend/src/app/templates/metadata.service.ts new file mode 100644 index 00000000..dbff0be0 --- /dev/null +++ b/apps/issuer-backend/src/app/templates/metadata.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { CredentialIssuerMetadataOptsV1_0_13 } from '@sphereon/oid4vci-common'; +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class MetadataService { + folder: string; + constructor(private configSerivce: ConfigService) { + this.folder = this.configSerivce.get('CREDENTIALS_FOLDER'); + } + + /** + * Get the metadata for the credential issuer + * @returns + */ + getMetadata(): Promise { + const content = JSON.parse( + readFileSync(join(this.folder, 'metadata.json'), 'utf-8') + ) as CredentialIssuerMetadataOptsV1_0_13; + return Promise.resolve(content); + } + + /** + * Set the metadata for the credential issuer + * @param metadata + * @returns + */ + setMetadata(metadata: CredentialIssuerMetadataOptsV1_0_13): Promise { + writeFileSync( + join(this.folder, 'metadata.json'), + JSON.stringify(metadata, null, 2) + ); + return Promise.resolve(null); + } +} diff --git a/apps/issuer-backend/src/app/templates/schemas/temoplate.entity.ts b/apps/issuer-backend/src/app/templates/schemas/temoplate.entity.ts new file mode 100644 index 00000000..15fc20e7 --- /dev/null +++ b/apps/issuer-backend/src/app/templates/schemas/temoplate.entity.ts @@ -0,0 +1,11 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { Template as TemplateDTO } from '../dto/template.dto'; + +@Entity() +export class Template { + @PrimaryColumn() + id: string; + + @Column({ type: 'json' }) + value: TemplateDTO; +} diff --git a/apps/issuer-backend/src/app/templates/template.service.ts b/apps/issuer-backend/src/app/templates/template.service.ts new file mode 100644 index 00000000..1344d4af --- /dev/null +++ b/apps/issuer-backend/src/app/templates/template.service.ts @@ -0,0 +1,92 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { Template } from './dto/template.dto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Template as TemplateEntity } from './schemas/temoplate.entity'; +import { Repository } from 'typeorm'; +import { CredentialConfigurationSupportedV1_0_13 } from '@sphereon/oid4vci-common'; +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { ConfigService } from '@nestjs/config'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +@Injectable() +export class TemplatesService { + folder: string; + constructor( + @InjectRepository(TemplateEntity) + private templateRepository: Repository, + private configSerivce: ConfigService + ) { + this.folder = this.configSerivce.get('CREDENTIALS_FOLDER'); + } + + /** + * Import templates for the local system when they are not already in the database. Do not overwrite existing templates. + */ + async import() { + const credentialFolder = join(this.folder, 'credentials'); + const files = readdirSync(credentialFolder); + for (const file of files) { + const template = plainToInstance( + Template, + JSON.parse(readFileSync(join(credentialFolder, file), 'utf-8')) + ); + const errors = await validate(template); + if (errors.length > 0) { + console.error(JSON.stringify(errors, null, 2)); + } else { + //check if an id is already used + await this.getOne(template.schema.id).catch(async () => { + await this.create(template); + }); + } + } + } + + getSupported(value: Map) { + //iterate over the map and change the value + const result: Record = {}; + value.forEach((v, k) => { + result[k] = v.schema; + }); + return result; + } + + async listAll() { + const rec: Map = new Map(); + const elements = await this.templateRepository.find(); + elements.forEach((element) => rec.set(element.id, element.value)); + return rec; + } + + async getOne(id: string) { + return this.templateRepository + .findOneByOrFail({ id }) + .then((res) => res.value); + } + + async create(data: Template) { + await this.templateRepository + .save( + this.templateRepository.create({ + id: data.schema.id, + value: data, + }) + ) + .catch((err) => { + throw new ConflictException(err.message); + }); + } + + async update(id: string, data: Template) { + if (id !== data.schema.id) { + throw new ConflictException('Id in path and in data do not match'); + } + await this.templateRepository.update({ id }, { value: data }); + } + + async delete(id: string) { + await this.templateRepository.delete({ id }); + } +} diff --git a/apps/issuer-backend/src/app/templates/templates.controller.ts b/apps/issuer-backend/src/app/templates/templates.controller.ts new file mode 100644 index 00000000..6677b7b7 --- /dev/null +++ b/apps/issuer-backend/src/app/templates/templates.controller.ts @@ -0,0 +1,52 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOAuth2, ApiOperation } from '@nestjs/swagger'; +import { AuthGuard } from 'nest-keycloak-connect'; +import { Template } from './dto/template.dto'; +import { TemplatesService } from './template.service'; + +@ApiTags('templates') +@UseGuards(AuthGuard) +@ApiOAuth2([]) +@Controller('templates') +export class TemplatesController { + constructor(private templatesService: TemplatesService) {} + + @ApiOperation({ summary: 'List all templates' }) + @Get() + async listAll() { + return Object.fromEntries(await this.templatesService.listAll()); + } + + @ApiOperation({ summary: 'Get one template' }) + @Get(':id') + getOne(@Param('id') id: string) { + return this.templatesService.getOne(id); + } + + @ApiOperation({ summary: 'Create a new template' }) + @Post() + create(@Body() data: Template) { + return this.templatesService.create(data); + } + + @ApiOperation({ summary: 'Update a template' }) + @Patch(':id') + update(@Param('id') id: string, @Body() data: Template) { + return this.templatesService.update(id, data); + } + + @ApiOperation({ summary: 'Delete a template' }) + @Delete(':id') + delete(@Param('id') id: string) { + return this.templatesService.delete(id); + } +} diff --git a/apps/issuer-backend/src/app/templates/templates.module.ts b/apps/issuer-backend/src/app/templates/templates.module.ts new file mode 100644 index 00000000..22087fe2 --- /dev/null +++ b/apps/issuer-backend/src/app/templates/templates.module.ts @@ -0,0 +1,22 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { TemplatesController } from './templates.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Template } from './schemas/temoplate.entity'; +import { TemplatesService } from './template.service'; +import { MetadataService } from './metadata.service'; +import { MetadataController } from './metadata.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Template])], + controllers: [TemplatesController, MetadataController], + providers: [TemplatesService, MetadataService], + exports: [TemplatesService, MetadataService], +}) +export class TemplatesModule implements OnModuleInit { + constructor(private readonly templatesService: TemplatesService) {} + + async onModuleInit() { + // import local templates to the database + await this.templatesService.import(); + } +} diff --git a/apps/issuer-frontend/src/app/app.config.ts b/apps/issuer-frontend/src/app/app.config.ts index 32b85d78..90c5bf39 100644 --- a/apps/issuer-frontend/src/app/app.config.ts +++ b/apps/issuer-frontend/src/app/app.config.ts @@ -5,8 +5,11 @@ import { } from '@angular/core'; import { HttpClient, provideHttpClient } from '@angular/common/http'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; -import { ApiModule, IssuerConfig, Configuration } from '@credhub/issuer-shared'; -import { ConfigService } from '@credhub/relying-party-frontend'; +import { + ApiModule, + Configuration, + IssuerConfigService, +} from '@credhub/issuer-shared'; export const appConfig: ApplicationConfig = { providers: [ @@ -14,18 +17,18 @@ export const appConfig: ApplicationConfig = { { provide: APP_INITIALIZER, useFactory: ( - configService: ConfigService, + configService: IssuerConfigService, httpClient: HttpClient - ) => configService.appConfigLoader(httpClient), - deps: [ConfigService, HttpClient], + ) => configService.appConfigLoader(httpClient, 'assets/config.json'), + deps: [IssuerConfigService, HttpClient], multi: true, }, provideAnimationsAsync(), importProvidersFrom(ApiModule), { provide: Configuration, - deps: [ConfigService], - useFactory: (configService: ConfigService) => { + deps: [IssuerConfigService], + useFactory: (configService: IssuerConfigService) => { return new Configuration({ basePath: configService.getConfig('backendUrl'), credentials: { diff --git a/apps/verifier-backend/.env.example b/apps/verifier-backend/.env.example index e73d9358..596fd078 100644 --- a/apps/verifier-backend/.env.example +++ b/apps/verifier-backend/.env.example @@ -3,6 +3,9 @@ PORT=3002 NODE_ENV=development VERIFIER_BASE_URL=http://localhost:3002 +DB_TYPE=sqlite +DB_NAME=tmp/verifier-db.sqlite + # Keycloak config OIDC_AUTH_URL=http://host.docker.internal:8080 OIDC_REALM=wallet diff --git a/apps/verifier-backend/src/app/app.module.ts b/apps/verifier-backend/src/app/app.module.ts index b4675654..cf6dc311 100644 --- a/apps/verifier-backend/src/app/app.module.ts +++ b/apps/verifier-backend/src/app/app.module.ts @@ -5,9 +5,12 @@ import * as Joi from 'joi'; import { VerifierModule } from './verifier/verifier.module'; import { AuthModule, + DB_VALIDATION_SCHEMA, + DbModule, KeyModule, OIDC_VALIDATION_SCHEMA, } from '@credhub/relying-party-shared'; +import { TemplatesModule } from './templates/templates.module'; @Module({ imports: [ @@ -23,10 +26,13 @@ import { .default('development'), CREDENTIALS_FOLDER: Joi.string().required(), ...OIDC_VALIDATION_SCHEMA, + ...DB_VALIDATION_SCHEMA, }), }), + DbModule, VerifierModule, KeyModule.forRoot(), + TemplatesModule, ], controllers: [AppController], }) diff --git a/apps/verifier-backend/src/app/templates/dto/template.dto.ts b/apps/verifier-backend/src/app/templates/dto/template.dto.ts new file mode 100644 index 00000000..219ac9f5 --- /dev/null +++ b/apps/verifier-backend/src/app/templates/dto/template.dto.ts @@ -0,0 +1,104 @@ +import { + IsString, + IsOptional, + ValidateNested, + IsArray, + IsEnum, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ConstraintsV2, + InputDescriptorV2, + Optionality, + PresentationDefinitionV2, +} from '@sphereon/pex-models'; + +export class Metadata { + @IsString() + clientId: string; + + @IsString() + clientName: string; + + @IsString() + @IsOptional() + logo_uri: string; +} + +export class VcSDJwt {} + +export class Format { + @ValidateNested() + @Type(() => VcSDJwt) + 'vc+sd-jwt': VcSDJwt; +} + +export class Request implements PresentationDefinitionV2 { + @IsString() + id: string; + + @IsString() + purpose: string; + + @ValidateNested() + @Type(() => Format) + format: Format; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InputDescriptor) + input_descriptors: InputDescriptor[]; +} +export class Template { + @ValidateNested() + @Type(() => Metadata) + metadata: Metadata; + + @ValidateNested() + @Type(() => Request) + request: Request; +} + +export class Filter { + @IsString() + type: string; + + @IsString() + @IsOptional() + const?: string; +} + +export class Field { + @IsArray() + @IsString({ each: true }) + path: string[]; + + @ValidateNested() + @Type(() => Filter) + filter: Filter; +} +export class Constraints implements ConstraintsV2 { + @IsEnum(Optionality) + limit_disclosure: Optionality; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Field) + fields: Field[]; +} + +export class InputDescriptor implements InputDescriptorV2 { + @IsString() + id: string; + + @IsString() + name: string; + + @IsString() + @IsOptional() + purpose: string; + + @ValidateNested() + @Type(() => Constraints) + constraints: Constraints; +} diff --git a/apps/verifier-backend/src/app/templates/schemas/temoplate.entity.ts b/apps/verifier-backend/src/app/templates/schemas/temoplate.entity.ts new file mode 100644 index 00000000..15fc20e7 --- /dev/null +++ b/apps/verifier-backend/src/app/templates/schemas/temoplate.entity.ts @@ -0,0 +1,11 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { Template as TemplateDTO } from '../dto/template.dto'; + +@Entity() +export class Template { + @PrimaryColumn() + id: string; + + @Column({ type: 'json' }) + value: TemplateDTO; +} diff --git a/apps/verifier-backend/src/app/templates/templates.controller.ts b/apps/verifier-backend/src/app/templates/templates.controller.ts new file mode 100644 index 00000000..9a41f264 --- /dev/null +++ b/apps/verifier-backend/src/app/templates/templates.controller.ts @@ -0,0 +1,52 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOAuth2, ApiOperation } from '@nestjs/swagger'; +import { AuthGuard } from 'nest-keycloak-connect'; +import { TemplatesService } from './templates.service'; +import { Template } from './dto/template.dto'; + +@ApiTags('templates') +@UseGuards(AuthGuard) +@ApiOAuth2([]) +@Controller('templates') +export class TemplatesController { + constructor(private templatesService: TemplatesService) {} + + @ApiOperation({ summary: 'List all templates' }) + @Get() + listAll() { + return this.templatesService.listAll(); + } + + @ApiOperation({ summary: 'Get one template' }) + @Get(':id') + getOne(@Param('id') id: string) { + return this.templatesService.getOne(id); + } + + @ApiOperation({ summary: 'Create a new template' }) + @Post() + create(@Body() data: Template) { + return this.templatesService.create(data); + } + + @ApiOperation({ summary: 'Update a template' }) + @Patch(':id') + update(@Param('id') id: string, @Body() data: Template) { + return this.templatesService.update(id, data); + } + + @ApiOperation({ summary: 'Delete a template' }) + @Delete(':id') + delete(@Param('id') id: string) { + return this.templatesService.delete(id); + } +} diff --git a/apps/verifier-backend/src/app/templates/templates.module.ts b/apps/verifier-backend/src/app/templates/templates.module.ts new file mode 100644 index 00000000..ca8e9a6d --- /dev/null +++ b/apps/verifier-backend/src/app/templates/templates.module.ts @@ -0,0 +1,20 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { TemplatesController } from './templates.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Template } from './schemas/temoplate.entity'; +import { TemplatesService } from './templates.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Template])], + controllers: [TemplatesController], + providers: [TemplatesService], + exports: [TemplatesService], +}) +export class TemplatesModule implements OnModuleInit { + constructor(private readonly templatesService: TemplatesService) {} + + async onModuleInit() { + // import local templates to the database + await this.templatesService.import(); + } +} diff --git a/apps/verifier-backend/src/app/templates/templates.service.ts b/apps/verifier-backend/src/app/templates/templates.service.ts new file mode 100644 index 00000000..14a433eb --- /dev/null +++ b/apps/verifier-backend/src/app/templates/templates.service.ts @@ -0,0 +1,80 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { Template } from './dto/template.dto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Template as TemplateEntity } from './schemas/temoplate.entity'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; + +@Injectable() +export class TemplatesService { + folder: string; + constructor( + @InjectRepository(TemplateEntity) + private templateRepository: Repository, + private configSerivce: ConfigService + ) { + this.folder = this.configSerivce.get('CREDENTIALS_FOLDER'); + } + + /** + * Import templates for the local system when they are not already in the database. Do not overwrite existing templates. + */ + async import() { + const credentialFolder = join(this.folder); + const files = readdirSync(credentialFolder); + for (const file of files) { + const template = plainToInstance( + Template, + JSON.parse(readFileSync(join(credentialFolder, file), 'utf-8')) + ); + const errors = await validate(template); + if (errors.length > 0) { + console.error(JSON.stringify(errors, null, 2)); + } else { + //check if an id is already used + await this.getOne(template.request.id).catch(async () => { + await this.create(template); + }); + } + } + } + + listAll(): Promise { + return this.templateRepository + .find() + .then((entries) => entries.map((entry) => entry.value)); + } + + async getOne(id: string): Promise