diff --git a/apps/agent-service/src/agent-service.controller.ts b/apps/agent-service/src/agent-service.controller.ts index d368781cd..0c4d7c81b 100644 --- a/apps/agent-service/src/agent-service.controller.ts +++ b/apps/agent-service/src/agent-service.controller.ts @@ -323,4 +323,61 @@ export class AgentServiceController { async agentdetailsByOrgId(payload: { orgId: string }): Promise { return this.agentServiceService.getAgentDetails(payload.orgId); } + + @MessagePattern({ cmd: 'agent-create-oid4vc-issuer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcIssuerCreate(payload: { issuerCreation; url: string; orgId: string }): Promise { + return this.agentServiceService.oidcIssuerCreate(payload.issuerCreation, payload.url, payload.orgId); + } + @MessagePattern({ cmd: 'delete-oid4vc-issuer' }) + async oidcDeleteIssuer(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.deleteOidcIssuer(payload.url, payload.orgId); + } + @MessagePattern({ cmd: 'agent-create-oid4vc-template' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcIssuerTemplate(payload: { templatePayload; url: string; orgId: string }): Promise { + return this.agentServiceService.oidcIssuerTemplate(payload.templatePayload, payload.url, payload.orgId); + } + //TODO: change message for oid4vc + @MessagePattern({ cmd: 'oid4vc-get-issuer-by-id' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcGetIssuerById(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.oidcGetIssuerById(payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'oid4vc-get-issuers-agent-service' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcGetIssuers(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.oidcGetIssuers(payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'agent-service-oid4vc-create-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcCreateCredentialOffer(payload: { credentialPayload; url: string; orgId: string }): Promise { + return this.agentServiceService.oidcCreateCredentialOffer(payload.credentialPayload, payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'agent-service-oid4vc-update-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcUpdateCredentialOffer(payload: { issuanceMetadata; url: string; orgId: string }): Promise { + return this.agentServiceService.oidcUpdateCredentialOffer(payload.issuanceMetadata, payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'agent-service-oid4vc-get-credential-offer-by-id' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcGetCredentialOfferById(payload: { url: string; orgId: string; offerId: string }): Promise { + return this.agentServiceService.oidcGetCredentialOfferById(payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'agent-service-oid4vc-get-all-credential-offers' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcGetAllCredentialOffers(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.oidcGetAllCredentialOffers(payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'agent-service-oid4vc-delete-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcDeleteCredentialOffer(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.oidcDeleteCredentialOffer(payload.url, payload.orgId); + } } diff --git a/apps/agent-service/src/agent-service.service.ts b/apps/agent-service/src/agent-service.service.ts index ffa860199..4f4e877cb 100644 --- a/apps/agent-service/src/agent-service.service.ts +++ b/apps/agent-service/src/agent-service.service.ts @@ -1410,6 +1410,138 @@ export class AgentServiceService { } } + async oidcIssuerCreate(issueData, url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPost(url, issueData, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in oidcIssuerCreate in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async deleteOidcIssuer(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const response = await this.commonService.httpDelete(url, { + headers: { authorization: getApiKey } + }); + if (response?.status === 204) { + return 'Data deleted successfully'; + } + } catch (error) { + this.logger.error(`Error in deleteOidcIssuer in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcGetIssuerById(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpGet(url, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in oidcGetIssuerById in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcGetIssuers(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpGet(url, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in oidcGetIssuers in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcCreateCredentialOffer(credentialPayload, url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPost(url, credentialPayload, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in oidcCreateCredentialOffer in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcUpdateCredentialOffer(issuanceMetadata, url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPut(url, issuanceMetadata, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in _oidcUpdateCredentialOffer in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcGetCredentialOfferById(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpGet(url, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in _oidcGetCredentialOfferById in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcGetAllCredentialOffers(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpGet(url, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in _oidcGetAllCredentialOffers in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcDeleteCredentialOffer(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpDelete(`${url}`, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in _oidcDeleteCredentialOffer in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcIssuerTemplate(templatePayload, url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPut(url, templatePayload, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in oidcIssuerTemplate in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + async getIssueCredentials(url: string, apiKey: string): Promise { try { const data = await this.commonService diff --git a/apps/api-gateway/src/agent-service/dto/create-schema.dto.ts b/apps/api-gateway/src/agent-service/dto/create-schema.dto.ts index f34d802ae..21dd0cf1b 100644 --- a/apps/api-gateway/src/agent-service/dto/create-schema.dto.ts +++ b/apps/api-gateway/src/agent-service/dto/create-schema.dto.ts @@ -2,26 +2,29 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsNotEmpty, IsArray } from 'class-validator'; export class CreateTenantSchemaDto { - @ApiProperty() - @IsString({ message: 'tenantId must be a string' }) @IsNotEmpty({ message: 'please provide valid tenantId' }) - tenantId: string; - - @ApiProperty() - @IsString({ message: 'schema version must be a string' }) @IsNotEmpty({ message: 'please provide valid schema version' }) - schemaVersion: string; + @ApiProperty() + @IsString({ message: 'tenantId must be a string' }) + @IsNotEmpty({ message: 'please provide valid tenantId' }) + tenantId: string; - @ApiProperty() - @IsString({ message: 'schema name must be a string' }) @IsNotEmpty({ message: 'please provide valid schema name' }) - schemaName: string; + @ApiProperty() + @IsString({ message: 'schema version must be a string' }) + @IsNotEmpty({ message: 'please provide valid schema version' }) + schemaVersion: string; - @ApiProperty() - @IsArray({ message: 'attributes must be an array' }) - @IsString({ each: true }) - @IsNotEmpty({ message: 'please provide valid attributes' }) - attributes: string[]; + @ApiProperty() + @IsString({ message: 'schema name must be a string' }) + @IsNotEmpty({ message: 'please provide valid schema name' }) + schemaName: string; - @ApiProperty() - - @IsNotEmpty({ message: 'please provide orgId' }) - orgId: string; -} \ No newline at end of file + @ApiProperty() + @IsArray({ message: 'attributes must be an array' }) + @IsString({ each: true }) + // TODO: IsNotEmpty won't work for array. Must use @ArrayNotEmpty() instead + @IsNotEmpty({ message: 'please provide valid attributes' }) + attributes: string[]; + + @ApiProperty() + @IsNotEmpty({ message: 'please provide orgId' }) + orgId: string; +} diff --git a/apps/api-gateway/src/app.module.ts b/apps/api-gateway/src/app.module.ts index 9994df54d..fe70c182f 100644 --- a/apps/api-gateway/src/app.module.ts +++ b/apps/api-gateway/src/app.module.ts @@ -32,6 +32,7 @@ import { ContextModule } from '@credebl/context/contextModule'; import { LoggerModule } from '@credebl/logger/logger.module'; import { GlobalConfigModule } from '@credebl/config/global-config.module'; import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; +import { Oid4vcIssuanceModule } from './oid4vc-issuance/oid4vc-issuance.module'; @Module({ imports: [ @@ -64,7 +65,8 @@ import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; GlobalConfigModule, CacheModule.register({ store: redisStore, host: process.env.REDIS_HOST, port: process.env.REDIS_PORT }), GeoLocationModule, - CloudWalletModule + CloudWalletModule, + Oid4vcIssuanceModule ], controllers: [AppController], providers: [ diff --git a/apps/api-gateway/src/issuance/issuance.controller.ts b/apps/api-gateway/src/issuance/issuance.controller.ts index b94ff1777..65014d79d 100644 --- a/apps/api-gateway/src/issuance/issuance.controller.ts +++ b/apps/api-gateway/src/issuance/issuance.controller.ts @@ -960,7 +960,6 @@ export class IssuanceController { @Res() res: Response ): Promise { issueCredentialDto.type = 'Issuance'; - if (id && 'default' === issueCredentialDto.contextCorrelationId) { issueCredentialDto.orgId = id; } diff --git a/apps/api-gateway/src/issuance/issuance.service.ts b/apps/api-gateway/src/issuance/issuance.service.ts index dd791f7e8..6012e20e2 100644 --- a/apps/api-gateway/src/issuance/issuance.service.ts +++ b/apps/api-gateway/src/issuance/issuance.service.ts @@ -1,4 +1,6 @@ /* eslint-disable camelcase */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// TODO: Remove this import { Injectable, Inject } from '@nestjs/common'; import { ClientProxy } from '@nestjs/microservices'; import { BaseService } from 'libs/service/base.service'; @@ -26,8 +28,8 @@ import { IIssuedCredential } from '@credebl/common/interfaces/issuance.interface'; import { IssueCredentialDto } from './dtos/multi-connection.dto'; -import { user } from '@prisma/client'; import { NATSClient } from '@credebl/common/NATSClient'; +import { user } from '@prisma/client'; @Injectable() export class IssuanceService extends BaseService { constructor( diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts new file mode 100644 index 000000000..4d056d2fc --- /dev/null +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts @@ -0,0 +1,380 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ +import { + IsArray, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + ValidateNested, + registerDecorator, + ValidationOptions, + ArrayMinSize, + IsInt, + Min, + IsIn, + IsUrl, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, + Validate +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +/* ========= Enums ========= */ +export enum CredentialFormat { + SdJwtVc = 'vc+sd-jwt', + Mdoc = 'mdoc' +} + +/* ========= disclosureFrame custom validator ========= */ +function isDisclosureFrameValue(v: unknown): boolean { + if ('boolean' === typeof v) { + return true; + } + if (v && 'object' === typeof v && !Array.isArray(v)) { + return Object.values(v as Record).every((x) => 'boolean' === typeof x); + } + return false; +} + +export function IsDisclosureFrame(options?: ValidationOptions) { + return function (object: unknown, propertyName: string) { + registerDecorator({ + name: 'IsDisclosureFrame', + target: (object as object).constructor, + propertyName, + options, + validator: { + validate(value: unknown) { + if (value === undefined) { + return true; + } // optional + if (!value || 'object' !== typeof value || Array.isArray(value)) { + return false; + } + return Object.values(value as Record).every(isDisclosureFrameValue); + }, + defaultMessage() { + return 'disclosureFrame must be a map of booleans or nested maps of booleans'; + } + } + }); + }; +} + +/* ========= Auth flow DTOs ========= */ +export class TxCodeDto { + @ApiPropertyOptional({ example: 'test abc' }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ example: 4 }) + @IsInt() + @Min(1) + length!: number; + + @ApiProperty({ example: 'numeric', enum: ['numeric'] }) + @IsString() + @IsIn(['numeric']) + input_mode!: 'numeric'; +} + +export class PreAuthorizedCodeFlowConfigDto { + @ApiProperty({ type: TxCodeDto }) + @ValidateNested() + @Type(() => TxCodeDto) + txCode!: TxCodeDto; + + @ApiProperty({ + example: 'http://localhost:4001/oid4vci/abc-gov', + description: 'AS (Authorization Server) base URL' + }) + @IsUrl({ require_tld: false }) + authorizationServerUrl!: string; +} + +export class AuthorizationCodeFlowConfigDto { + @ApiProperty({ + example: 'https://id.example.com/realms/issuer', + description: 'AS (Authorization Server) base URL' + }) + @IsUrl({ require_tld: false }) + authorizationServerUrl!: string; +} + +/* ========= XOR class-level validator (exactly one config) ========= */ +@ValidatorConstraint({ name: 'ExactlyOneOf', async: false }) +class ExactlyOneOfConstraint implements ValidatorConstraintInterface { + validate(_: unknown, args: ValidationArguments) { + const obj = args.object as Record; + const keys = (args.constraints ?? []) as string[]; + const present = keys.filter((k) => obj[k] !== undefined && null !== obj[k]); + return 1 === present.length; + } + defaultMessage(args: ValidationArguments) { + const keys = (args.constraints ?? []) as string[]; + return `Exactly one of [${keys.join(', ')}] must be provided (not both, not neither).`; + } +} +function ExactlyOneOf(keys: string[], options?: ValidationOptions) { + return Validate(ExactlyOneOfConstraint, keys, options); +} + +/* ========= Request DTOs ========= */ +export class CredentialRequestDto { + @ApiProperty({ + example: 'c49bdee0-d028-4595-85dc-177c85ea391c', + description: 'Must match credential template id' + }) + @IsString() + @IsNotEmpty() + templateId!: string; + + @ApiProperty({ + description: 'Dynamic claims object', + example: { name: 'Garry', DOB: '2000-01-01', additionalProp3: 'Africa' }, + type: 'object', + additionalProperties: true + }) + @IsObject() + payload!: Record; + + @ApiPropertyOptional({ + description: 'Selective disclosure: claim -> boolean (or nested map)', + example: { name: true, DOB: true, additionalProp3: false }, + required: false + }) + @IsOptional() + @IsDisclosureFrame() + disclosureFrame?: Record>; +} + +export class CreateOidcCredentialOfferDto { + @ApiProperty({ + type: [CredentialRequestDto], + description: 'At least one credential to be issued.' + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CredentialRequestDto) + credentials!: CredentialRequestDto[]; + + // XOR: exactly one present + @ApiPropertyOptional({ type: PreAuthorizedCodeFlowConfigDto }) + @IsOptional() + @ValidateNested() + @Type(() => PreAuthorizedCodeFlowConfigDto) + preAuthorizedCodeFlowConfig?: PreAuthorizedCodeFlowConfigDto; + + @IsOptional() + @ValidateNested() + @Type(() => AuthorizationCodeFlowConfigDto) + authorizationCodeFlowConfig?: AuthorizationCodeFlowConfigDto; + + issuerId?: string; + + // host XOR rule + @ExactlyOneOf(['preAuthorizedCodeFlowConfig', 'authorizationCodeFlowConfig'], { + message: 'Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.' + }) + private readonly _exactlyOne?: unknown; +} + +export class GetAllCredentialOfferDto { + @ApiProperty({ required: false, example: 'credebl university' }) + @IsOptional() + publicIssuerId: string = ''; + + @ApiProperty({ required: false, example: '568345' }) + @IsOptional() + preAuthorizedCode: string = ''; + + // @ApiPropertyOptional({ + // example: OpenId4VcIssuanceSessionState.OfferCreated, + // enum: OpenId4VcIssuanceSessionState, + // required: false, + // }) + // @IsOptional() + // @IsEnum(OpenId4VcIssuanceSessionState) + // state?: OpenId4VcIssuanceSessionState; + + @ApiProperty({ required: false, example: 'openid-credential-offer://?credential_offer_uri=http%3A%2F%2.....' }) + @IsOptional() + credentialOfferUri: string = ''; + + @ApiProperty({ required: false, example: 'Bob' }) + @IsOptional() + authorizationCode: string = ''; +} + +export class UpdateCredentialRequestDto { + @ApiPropertyOptional({ + description: 'Issuer metadata (any valid JSON object)', + type: 'object', + additionalProperties: true + }) + @IsOptional() + @IsObject() + issuerMetadata?: Record; + + issuerId?: string; + + credentialOfferId?: string; +} + +export class SignerOptionsDto { + @IsString() + @IsIn(['did', 'x5c'], { message: 'method must be either "did" or "x5c"' }) + method: string; + + @IsString() + @IsOptional() + did?: string; + + @IsArray() + @IsOptional() + x5c?: string[]; +} + +export class CredentialDto { + @ApiProperty({ + description: 'Unique ID of the supported credential', + example: 'DrivingLicenseCredential-mdoc' + }) + @IsString() + credentialSupportedId: string; + + @ApiProperty({ + description: 'Signer options for credential issuance', + example: { + method: 'x5c', + x5c: [ + 'MIIB3jCCAZCgAwIBAgIQQfBdIK9v3TIzKR+1HjlixDAFBgMrZXAwJDEUMBIGA1UEAxMLRFkgdGVzdCBvcmcxDDAKBgNVBAYTA0lORDAeFw0yNTA5MjQwMDAwMDBaFw0yODA5MjQwMDAwMDBaMCQxFDASBgNVBAMTC0RZIHRlc3Qgb3JnMQwwCgYDVQQGEwNJTkQwKjAFBgMrZXADIQDIkLycOlkHP6+MG4rprj8fyxRfwqhH8Xx9v0XxCd175aOB1zCB1DAdBgNVHQ4EFgQUbqjjbQgbAx3lPjkPBVQwvvF14agwDgYDVR0PAQH/BAQDAgGGMBUGA1UdJQEB/wQLMAkGByiBjF0FAQIwOwYDVR0SBDQwMoIXaHR0cDovL3Rlc3QuZXhhbXBsZS5jb22GF2h0dHA6Ly90ZXN0LmV4YW1wbGUuY29tMDsGA1UdEQQ0MDKCF2h0dHA6Ly90ZXN0LmV4YW1wbGUuY29thhdodHRwOi8vdGVzdC5leGFtcGxlLmNvbTASBgNVHRMBAf8ECDAGAQH/AgEAMAUGAytlcANBALTqC64XSTRUoMmwYbCD/z46U/Je6IeQsh6qq4qXh+wfnMIfJMvLQnG+nMkfeAs3zYAwjK6sCZ/7lHkEJnYObQ4=' + ] + } + }) + @ValidateNested() + @Type(() => SignerOptionsDto) + signerOptions: SignerOptionsDto; + + @ApiProperty({ + description: 'Credential format type', + enum: ['mso_mdoc', 'vc+sd-jwt'], + example: 'mso_mdoc' + }) + @IsString() + @IsIn(['mso_mdoc', 'vc+sd-jwt'], { message: 'format must be either "mso_mdoc" or "vc+sd-jwt"' }) + format: string; + + @ApiProperty({ + description: 'Credential payload (namespace data, validity info, etc.)', + example: { + namespaces: { + 'org.iso.23220.photoID.1': { + birth_date: '1970-02-14', + family_name: 'Müller-Lüdenscheid', + given_name: 'Ford Praxibetel', + document_number: 'LA001801M' + } + }, + validityInfo: { + validFrom: '2025-04-23T14:34:09.188Z', + validUntil: '2026-05-03T14:34:09.188Z' + } + } + }) + @ValidateNested() + payload: object; +} + +export class CreateCredentialOfferD2ADto { + @ApiProperty({ + description: 'Public identifier of the issuer visible to verifiers and wallets.', + example: 'dy-gov' + }) + @IsString() + publicIssuerId: string; + + @ApiProperty({ + description: 'List of credentials to be issued under this offer.', + type: [CredentialDto], + example: [ + { + credentialSupportedId: 'DrivingLicenseCredential-mdoc', + signerOptions: { + method: 'x5c', + x5c: [ + 'MIIB3jCCAZCgAwIBAgIQQfBdIK9v3TIzKR+1HjlixDAFBgMrZXAwJDEUMBIGA1UEAxMLRFkgdGVzdCBvcmcxDDAKBgNVBAYTA0lORDAeFw0yNTA5MjQwMDAwMDBaFw0yODA5MjQwMDAwMDBaMCQxFDASBgNVBAMTC0RZIHRlc3Qgb3JnMQwwCgYDVQQGEwNJTkQwKjAFBgMrZXADIQDIkLycOlkHP6+MG4rprj8fyxRfwqhH8Xx9v0XxCd175aOB1zCB1DAdBgNVHQ4EFgQUbqjjbQgbAx3lPjkPBVQwvvF14agwDgYDVR0PAQH/BAQDAgGGMBUGA1UdJQEB/wQLMAkGByiBjF0FAQIwOwYDVR0SBDQwMoIXaHR0cDovL3Rlc3QuZXhhbXBsZS5jb22GF2h0dHA6Ly90ZXN0LmV4YW1wbGUuY29tMDsGA1UdEQQ0MDKCF2h0dHA6Ly90ZXN0LmV4YW1wbGUuY29thhdodHRwOi8vdGVzdC5leGFtcGxlLmNvbTASBgNVHRMBAf8ECDAGAQH/AgEAMAUGAytlcANBALTqC64XSTRUoMmwYbCD/z46U/Je6IeQsh6qq4qXh+wfnMIfJMvLQnG+nMkfeAs3zYAwjK6sCZ/7lHkEJnYObQ4=' + ] + }, + format: 'mso_mdoc', + payload: { + namespaces: { + 'org.iso.23220.photoID.1': { + birth_date: '1970-02-14', + family_name: 'Müller-Lüdenscheid', + given_name: 'Ford Praxibetel', + document_number: 'LA001801M' + } + }, + validityInfo: { + validFrom: '2025-04-23T14:34:09.188Z', + validUntil: '2026-05-03T14:34:09.188Z' + } + } + } + ] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CredentialDto) + credentials: CredentialDto[]; + + @ApiPropertyOptional({ + description: 'Pre-Authorized Code Flow configuration. Provide this OR authorizationCodeFlowConfig (XOR rule).', + type: PreAuthorizedCodeFlowConfigDto, + example: { + preAuthorizedCode: 'abcd1234xyz', + txCode: { + length: 8, + inputMode: 'numeric' + }, + userPinRequired: true + } + }) + @IsOptional() + @ValidateNested() + @Type(() => PreAuthorizedCodeFlowConfigDto) + preAuthorizedCodeFlowConfig?: PreAuthorizedCodeFlowConfigDto; + + @ApiPropertyOptional({ + description: 'Authorization Code Flow configuration. Provide this OR preAuthorizedCodeFlowConfig (XOR rule).', + type: AuthorizationCodeFlowConfigDto, + example: { + clientId: 'wallet-app', + redirectUri: 'https://wallet.example.org/callback', + scope: 'openid vc_authn', + state: 'xyz-987654321' + } + }) + @IsOptional() + @ValidateNested() + @Type(() => AuthorizationCodeFlowConfigDto) + authorizationCodeFlowConfig?: AuthorizationCodeFlowConfigDto; + + @ApiPropertyOptional({ + description: 'Internal identifier of the issuer (optional, for backend use).', + example: 'issuer-12345' + }) + @IsOptional() + issuerId?: string; + + @ExactlyOneOf(['preAuthorizedCodeFlowConfig', 'authorizationCodeFlowConfig'], { + message: 'Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.' + }) + private readonly _exactlyOne?: unknown; +} diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-credential-wh.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-credential-wh.dto.ts new file mode 100644 index 000000000..d4a02f669 --- /dev/null +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-credential-wh.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsObject, IsString } from 'class-validator'; + +export class CredentialOfferPayloadDto { + @ApiProperty() + @IsString() + // eslint-disable-next-line camelcase + credential_issuer!: string; + + @ApiProperty({ type: [String] }) + @IsArray() + // eslint-disable-next-line camelcase + credential_configuration_ids!: string[]; + + @ApiProperty({ type: 'object', additionalProperties: true }) + @IsObject() + grants!: Record; + + @ApiProperty({ type: [Object] }) + @IsArray() + credentials!: Record[]; +} + +export class IssuanceMetadataDto { + @ApiProperty() + @IsString() + issuerDid!: string; + + @ApiProperty({ type: [Object] }) + @IsArray() + credentials!: Record[]; +} + +export class OidcIssueCredentialDto { + @ApiProperty() + @IsString() + id!: string; + + @ApiProperty() + @IsString() + credentialOfferId!: string; + + @ApiProperty() + @IsString() + state!: string; + + @ApiProperty() + @IsString() + contextCorrelationId!: string; +} diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts new file mode 100644 index 000000000..45e2a5fb7 --- /dev/null +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts @@ -0,0 +1,219 @@ +/* eslint-disable camelcase */ +import { + IsString, + IsBoolean, + IsOptional, + IsEnum, + ValidateNested, + IsObject, + IsNotEmpty, + IsArray, + ValidateIf, + IsEmpty +} from 'class-validator'; +import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath, PartialType } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { DisplayDto } from './oid4vc-issuer.dto'; + +export class CredentialAttributeDto { + @ApiProperty({ required: false, description: 'Whether the attribute is mandatory' }) + @IsOptional() + @IsBoolean() + mandatory?: boolean; + + // TODO: Check how do we handle claims with only path rpoperty like email, etc. + @ApiProperty({ description: 'Type of the attribute value (string, number, date, etc.)' }) + @IsString() + value_type: string; + + @ApiProperty({ + type: [String], + description: + 'Claims path pointer as per the draft 15 - https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-ID2.html#name-claims-path-pointer' + }) + @IsArray() + @IsString({ each: true }) + path: string[]; + + @ApiProperty({ type: [DisplayDto], required: false, description: 'Localized display values' }) + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => DisplayDto) + display?: DisplayDto[]; +} + +class LogoDto { + @ApiPropertyOptional({ + example: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg' + }) + @IsString() + @IsNotEmpty() + uri: string; + + @ApiPropertyOptional({ example: 'abc_logo' }) + @IsString() + @IsOptional() + alt_text?: string; +} + +class CredentialDisplayDto { + @ApiPropertyOptional({ example: 'Birth Certificate' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ example: 'Official record of birth' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ example: 'en' }) + @IsString() + @IsOptional() + locale?: string; + + @ApiPropertyOptional({ + example: { + uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg', + alt_text: 'abc_logo' + } + }) + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => LogoDto) + logo?: LogoDto; +} + +export class AppearanceDto { + @ApiPropertyOptional({ + example: [ + { + locale: 'en', + name: 'Birth Certificate', + description: 'Official record of birth', + logo: { + uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg', + alt_text: 'abc_logo' + } + }, + { + locale: 'ar', + name: 'شهادة الميلاد', + description: 'سجل رسمي للولادة', + logo: { + uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg', + alt_text: 'شعار abc' + } + } + ] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CredentialDisplayDto) + display: CredentialDisplayDto[]; +} + +export enum SignerOption { + DID = 'did', + X509 = 'x509' +} +@ApiExtraModels(CredentialAttributeDto) +export class CreateCredentialTemplateDto { + @ApiProperty({ description: 'Template name' }) + @IsString() + name: string; + + @ApiProperty({ required: false, description: 'Optional description for the template' }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + description: 'Signer option (did or x509)', + enum: SignerOption, + example: SignerOption.DID + }) + @IsEnum(SignerOption) + signerOption!: SignerOption; + + @ApiProperty({ enum: ['mso_mdoc', 'vc+sd-jwt'], description: 'Credential format type' }) + @IsEnum(['mso_mdoc', 'vc+sd-jwt']) + format: 'mso_mdoc' | 'vc+sd-jwt'; + + @ApiPropertyOptional({ + description: 'Document type (required when format is "mso_mdoc"; must NOT be provided when format is "vc+sd-jwt")', + example: 'org.iso.23220.photoID.1' + }) + @ValidateIf((o: CreateCredentialTemplateDto) => 'mso_mdoc' === o.format) + @IsString() + doctype?: string; + + @ValidateIf((o: CreateCredentialTemplateDto) => 'vc+sd-jwt' === o.format) + @IsEmpty({ message: 'doctype must not be provided when format is "vc+sd-jwt"' }) + readonly _doctypeAbsentGuard?: unknown; + + @ApiPropertyOptional({ + description: + 'Verifiable Credential Type (required when format is "vc+sd-jwt"; must NOT be provided when format is "mso_mdoc")', + example: 'org.iso.18013.5.1.mDL' + }) + @ValidateIf((o: CreateCredentialTemplateDto) => 'vc+sd-jwt' === o.format) + @IsString() + vct?: string; + + @ValidateIf((o: CreateCredentialTemplateDto) => 'mso_mdoc' === o.format) + @IsEmpty({ message: 'vct must not be provided when format is "mso_mdoc"' }) + readonly _vctAbsentGuard?: unknown; + + @ApiProperty({ default: false, description: 'Indicates whether credentials can be revoked' }) + @IsBoolean() + canBeRevoked = false; + + @ApiProperty({ + type: 'object', + additionalProperties: { $ref: getSchemaPath(CredentialAttributeDto) }, + description: 'Attributes included in the credential template' + }) + @IsObject() + attributes: CredentialAttributeDto[]; + + @ApiProperty({ + type: Object, + required: false, + description: 'Appearance configuration for credentials (branding, colors, etc.)' + }) + @ApiPropertyOptional({ + type: AppearanceDto, + example: { + display: [ + { + locale: 'en', + name: 'Birth Certificate', + description: 'Official record of birth', + logo: { + uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg', + alt_text: 'abc_logo' + } + }, + { + locale: 'ar', + name: 'شهادة الميلاد', + description: 'سجل رسمي للولادة', + logo: { + uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg', + alt_text: 'شعار abc' + } + } + ] + } + }) + @IsOptional() + @ValidateNested() + @Type(() => AppearanceDto) + appearance?: AppearanceDto; + + issuerId: string; +} + +export class UpdateCredentialTemplateDto extends PartialType(CreateCredentialTemplateDto) {} diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts new file mode 100644 index 000000000..fee4af086 --- /dev/null +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts @@ -0,0 +1,283 @@ +/* eslint-disable camelcase */ +import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsBoolean, + IsArray, + ValidateNested, + IsUrl, + IsNotEmpty, + IsDefined, + IsInt +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ClaimDto { + @ApiProperty({ + description: 'The path for nested claims', + example: ['address', 'street_number'], + type: [String] + }) + @Type(() => String) + @IsArray() + path: string[]; + + @ApiProperty({ + description: 'The display label for the claim', + example: 'Email Address' + }) + @IsString() + label: string; + + @ApiProperty({ + description: 'Whether this claim is required for issuance', + example: true + }) + @IsBoolean() + required: boolean; +} + +export class LogoDto { + @ApiProperty({ + description: 'URI pointing to the logo image', + example: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg' + }) + @IsUrl() + uri: string; + + @ApiProperty({ + description: 'Alternative text for the logo (for accessibility)', + example: 'ABC Company Logo' + }) + @IsString() + alt_text: string; +} + +export class DisplayDto { + @ApiProperty({ + description: 'The locale for display text', + example: 'en-US' + }) + @IsString() + locale: string; + + @ApiProperty({ + description: 'The display name for the credential/claim', + example: 'Student ID Card' + }) + @IsString({ message: 'Error from DisplayDto -> name' }) + name: string; + + @ApiPropertyOptional({ + description: 'A short description for the credential/claim', + example: 'Digital credential issued to enrolled students' + }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ + description: 'Logo display information for the issuer', + type: LogoDto + }) + @IsOptional() + @Type(() => LogoDto) + logo?: LogoDto; +} + +// TODO: Check where it is used, coz no reference found +@ApiExtraModels(ClaimDto) +export class CredentialConfigurationDto { + @ApiProperty({ + description: 'The format of the credential', + example: 'jwt_vc_json' + }) + @IsString() + @IsDefined({ message: 'format field is required' }) + @IsNotEmpty({ message: 'format property is required' }) + format: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + vct?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + doctype?: string; + + @ApiProperty() + @IsString() + scope: string; + + @ApiProperty({ + description: 'List of claims supported in this credential', + type: [ClaimDto] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ClaimDto) + claims: ClaimDto[]; + // @ApiProperty({ + // description: 'Claims supported by this credential', + // type: 'object', + // additionalProperties: { $ref: getSchemaPath(ClaimDto) } + // }) + // @IsObject() + // @ValidateNested({ each: true }) + // @Transform(({ value }) => + // Object.fromEntries(Object.entries(value || {}).map(([k, v]) => [k, plainToInstance(ClaimDto, v)])) + // ) + // claims: Record; + + @ApiProperty({ type: [String] }) + @IsArray() + credential_signing_alg_values_supported: string[]; + + @ApiProperty({ type: [String] }) + @IsArray() + cryptographic_binding_methods_supported: string[]; + + @ApiProperty({ + description: 'Localized display information for the credential', + type: [DisplayDto] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DisplayDto) + display: DisplayDto[]; +} + +// export class AuthorizationServerConfigDto { +// @ApiProperty({ +// description: 'Authorization server issuer URL', +// example: 'https://auth.credebl.com', +// }) +// @IsUrl() +// issuer: string + +// @ApiPropertyOptional({ +// description: 'Token endpoint of the authorization server', +// example: 'https://auth.credebl.com/oauth/token', +// }) +// @IsOptional() +// @IsUrl() +// token_endpoint: string + +// @ApiProperty({ +// description: 'Authorization endpoint of the server', +// example: 'https://auth.credebl.com/oauth/authorize', +// }) +// @IsUrl() +// authorization_endpoint: string + +// @ApiProperty({ +// description: 'Supported scopes', +// example: ['openid', 'profile', 'email'], +// }) +// @IsArray() +// @IsString({ each: true }) +// scopes_supported: string[] +// } + +export class ClientAuthenticationDto { + @ApiProperty({ + description: 'OAuth2 client ID for the authorization server', + example: 'issuer-server' + }) + @IsString() + clientId: string; + + @ApiProperty({ + description: 'OAuth2 client secret for the authorization server', + example: '2qKMWulZpMBzXIdfPO5AEs0xaTaKs1uk' + }) + @IsString() + clientSecret: string; +} + +export class AuthorizationServerConfigDto { + @ApiProperty({ + description: 'Authorization server issuer URL', + example: 'https://example-oid4vc-provider.com' + }) + @IsString() + issuer: string; + + @ApiProperty({ + description: 'Client authentication configuration', + type: () => ClientAuthenticationDto + }) + @ValidateNested() + @Type(() => ClientAuthenticationDto) + clientAuthentication: ClientAuthenticationDto; +} + +export enum AccessTokenSignerKeyType { + ED25519 = 'ed25519' +} + +// @ApiExtraModels(CredentialConfigurationDto) +export class IssuerCreationDto { + @ApiProperty({ + description: 'Name of the issuer', + example: 'Credebl University' + }) + @IsString({ message: 'issuerId from IssuerCreationDto -> issuerId, must be a string' }) + issuerId: string; + + @ApiPropertyOptional({ + description: 'Maximum number of credentials that can be issued in a batch', + example: 50, + type: Number + }) + @IsOptional() + @IsInt({ message: 'batchCredentialIssuanceSize must be an integer' }) + batchCredentialIssuanceSize?: number; + + @ApiProperty({ + description: 'Localized display information for the credential', + type: [DisplayDto] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DisplayDto) + display: DisplayDto[]; + + @ApiProperty({ + description: 'Configuration of the authorization server', + type: AuthorizationServerConfigDto + }) + @IsOptional() + @ValidateNested() + @Type(() => AuthorizationServerConfigDto) + authorizationServerConfigs?: AuthorizationServerConfigDto; +} + +export class IssuerUpdationDto { + issuerId?: string; + + @ApiProperty({ + description: 'Localized display information for the credential', + type: [DisplayDto] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DisplayDto) + display: DisplayDto[]; + + @ApiProperty({ + description: 'batchCredentialIssuanceSize', + example: '50' + }) + @ApiPropertyOptional({ + description: 'Maximum number of credentials that can be issued in a batch', + example: 50, + type: Number + }) + @IsOptional() + @IsInt({ message: 'batchCredentialIssuanceSize must be an integer' }) + batchCredentialIssuanceSize?: number; +} diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts new file mode 100644 index 000000000..f88b8718a --- /dev/null +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts @@ -0,0 +1,648 @@ +/* eslint-disable default-param-last */ +/* eslint-disable no-param-reassign */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-console */ +/* eslint-disable camelcase */ +import { + Controller, + Post, + Body, + UseGuards, + HttpStatus, + Res, + Get, + Param, + UseFilters, + BadRequestException, + ParseUUIDPipe, + Delete, + Patch, + Query, + Put +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiForbiddenResponse, + ApiUnauthorizedResponse, + ApiExcludeEndpoint +} from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiResponseDto } from '../dtos/apiResponse.dto'; +import { UnauthorizedErrorDto } from '../dtos/unauthorized-error.dto'; +import { ForbiddenErrorDto } from '../dtos/forbidden-error.dto'; +import { Response } from 'express'; +import IResponseType, { IResponse } from '@credebl/common/interfaces/response.interface'; +import { User } from '../authz/decorators/user.decorator'; +import { ResponseMessages } from '@credebl/common/response-messages'; +import { Roles } from '../authz/decorators/roles.decorator'; +import { OrgRoles } from 'libs/org-roles/enums'; +import { OrgRolesGuard } from '../authz/guards/org-roles.guard'; +import { CustomExceptionFilter } from 'apps/api-gateway/common/exception-handler'; +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { user } from '@prisma/client'; +import { IssuerCreationDto, IssuerUpdationDto } from './dtos/oid4vc-issuer.dto'; +import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos/oid4vc-issuer-template.dto'; +import { OidcIssueCredentialDto } from './dtos/oid4vc-credential-wh.dto'; +import { Oid4vcIssuanceService } from './oid4vc-issuance.service'; +import { + CreateCredentialOfferD2ADto, + CreateOidcCredentialOfferDto, + GetAllCredentialOfferDto, + UpdateCredentialRequestDto +} from './dtos/issuer-sessions.dto'; +@Controller() +@UseFilters(CustomExceptionFilter) +@ApiTags('OID4VC') +@ApiUnauthorizedResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Unauthorized', type: UnauthorizedErrorDto }) +@ApiForbiddenResponse({ status: HttpStatus.FORBIDDEN, description: 'Forbidden', type: ForbiddenErrorDto }) +export class Oid4vcIssuanceController { + constructor(private readonly oid4vcIssuanceService: Oid4vcIssuanceService) {} + /** + * Create issuer against a org(tenant) + * @param orgId The ID of the organization + * @param user The user making the request + * @param res The response object + * @returns The status of the deletion operation + */ + + @Post('/orgs/:orgId/oid4vc/issuers') + @ApiOperation({ + summary: 'Create OID4VC issuer', + description: 'Creates a new OID4VC issuer for the specified organization.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Issuer created successfully.', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async oidcIssuerCreate( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @User() user: user, + @Body() issueCredentialDto: IssuerCreationDto, + @Res() res: Response + ): Promise { + const createIssuer = await this.oid4vcIssuanceService.oidcIssuerCreate(issueCredentialDto, orgId, user); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcIssuer.success.issuerConfig, + data: createIssuer + }; + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Post('/orgs/:orgId/oid4vc/issuers/:issuerId') + @ApiOperation({ + summary: 'Update OID4VC issuer', + description: 'Updates an existing OID4VC issuer for the specified organization.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Issuer updated successfully.', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async oidcIssuerUpdate( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @User() user: user, + @Param('issuerId') + issuerId: string, + @Body() issueCredentialDto: IssuerUpdationDto, + @Res() res: Response + ): Promise { + issueCredentialDto.issuerId = issuerId; + const createIssuer = await this.oid4vcIssuanceService.oidcIssuerUpdate(issueCredentialDto, orgId, user); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcIssuer.success.issuerConfigUpdate, + data: createIssuer + }; + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Get('/orgs/:orgId/oid4vc/issuers/:issuerId') + @ApiOperation({ summary: 'Get OID4VC issuer', description: 'Retrieves an OID4VC issuer by issuerId.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Issuer fetched successfully.', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async oidcGetIssuerById( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param('issuerId') + issuerId: string, + @Res() res: Response + ): Promise { + const oidcIssuer = await this.oid4vcIssuanceService.oidcGetIssuerById(issuerId, orgId); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcIssuer.success.fetch, + data: oidcIssuer + }; + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Get('/orgs/:orgId/oid4vc/issuers') + @ApiOperation({ + summary: 'Get OID4VC issuers', + description: 'Retrieves all OID4VC issuers for the specified organization.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Issuers fetched successfully.', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async oidcGetIssuers( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Res() res: Response + ): Promise { + const oidcIssuer = await this.oid4vcIssuanceService.oidcGetIssuers(orgId); + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcIssuer.success.fetch, + data: oidcIssuer + }; + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Delete('/orgs/:orgId/oid4vc/:issuerId') + @ApiOperation({ + summary: 'Delete OID4VC issuer', + description: 'Deletes an OID4VC issuer for the specified organization.' + }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async deleteOidcIssuer( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param( + 'issuerId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + issuerId: string, + @User() user: user, + @Res() res: Response + ): Promise { + await this.oid4vcIssuanceService.oidcDeleteIssuer(user, orgId, issuerId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcIssuer.success.delete + }; + + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Post('/orgs/:orgId/oid4vc/:issuerId/template') + @ApiOperation({ + summary: 'Create credential template', + description: 'Creates a new credential template for the specified issuer.' + }) + @ApiResponse({ status: HttpStatus.CREATED, description: 'Template created successfully.' }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async createTemplate( + @Param('orgId') + orgId: string, + @Param('issuerId') + issuerId: string, + @User() user: user, + @Body() CredentialTemplate: CreateCredentialTemplateDto, + @Res() res: Response + ): Promise { + CredentialTemplate.issuerId = issuerId; + console.log('THis is dto', JSON.stringify(CredentialTemplate, null, 2)); + const template = await this.oid4vcIssuanceService.createTemplate(CredentialTemplate, user, orgId, issuerId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcTemplate.success.create, + data: template + }; + + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Get('/orgs/:orgId/oid4vc/:issuerId/template') + @ApiOperation({ + summary: 'List credential templates', + description: 'Lists all credential templates for the specified issuer.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'List of templates.' }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async listTemplates( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param( + 'issuerId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.oidcTemplate.error.invalidId); + } + }) + ) + issuerId: string, + @Res() res: Response + ): Promise { + const templates = await this.oid4vcIssuanceService.findAllTemplate(orgId, issuerId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcTemplate.success.fetch, + data: templates + }; + + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Get('/orgs/:orgId/oid4vc/:issuerId/template/:templateId') + @ApiOperation({ summary: 'Get credential template by ID', description: 'Retrieves a credential template by its ID.' }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async getTemplateById( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param('templateId') + templateId: string, + @Res() res: Response + ): Promise { + const template = await this.oid4vcIssuanceService.findByIdTemplate(orgId, templateId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcTemplate.success.getById, + data: template + }; + + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Patch('/orgs/:orgId/oid4vc/:issuerId/template/:templateId') + @ApiOperation({ + summary: 'Update credential template', + description: 'Updates a credential template for the specified issuer.' + }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async updateTemplate( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param( + 'issuerId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + issuerId: string, + @Param( + 'templateId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + templateId: string, + @User() user: user, + @Body() dto: UpdateCredentialTemplateDto, + @Res() res: Response + ): Promise { + const updated = await this.oid4vcIssuanceService.updateTemplate(user, orgId, templateId, dto, issuerId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcTemplate.success.update, + data: updated + }; + + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Delete('/orgs/:orgId/oid4vc/:issuerId/template/:templateId') + @ApiOperation({ + summary: 'Delete credential template', + description: 'Deletes a credential template for the specified issuer.' + }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async deleteTemplate( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param( + 'issuerId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + issuerId: string, + @Param( + 'templateId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + templateId: string, + @User() user: user, + @Res() res: Response + ): Promise { + await this.oid4vcIssuanceService.deleteTemplate(user, orgId, templateId); + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcTemplate.success.delete + }; + + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Post('/orgs/:orgId/oid4vc/:issuerId/create-offer') + @ApiOperation({ + summary: 'Create OID4VC Credential Offer', + description: 'Creates a new OIDC4VCI credential-offer for a given issuer.' + }) + @ApiResponse({ status: HttpStatus.CREATED, description: 'Credential offer created successfully.' }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async createOidcCredentialOffer( + @Param('orgId') + orgId: string, + @Param('issuerId') + issuerId: string, + @User() user: user, + @Body() oidcCredentialPayload: CreateOidcCredentialOfferDto, + @Res() res: Response + ): Promise { + oidcCredentialPayload.issuerId = issuerId; + const template = await this.oid4vcIssuanceService.createOidcCredentialOffer( + oidcCredentialPayload, + user, + orgId, + issuerId + ); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcIssuerSession.success.create, + data: template + }; + + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Put('/orgs/:orgId/oid4vc/:issuerId/:credentialId/update-offer') + @ApiOperation({ + summary: 'Update OID4VC Credential Offer', + description: 'Updates an existing OIDC4VCI credential-offer.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Credential offer updated successfully.', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async updateCredentialOffers( + @Body() oidcUpdateCredentialPayload: UpdateCredentialRequestDto, + @Param('orgId') orgId: string, + @Param('issuerId') issuerId: string, + @Param('credentialId') credentialId: string, + @Res() res: Response + ): Promise { + oidcUpdateCredentialPayload.issuerId = issuerId; + oidcUpdateCredentialPayload.credentialOfferId = credentialId; + const updateCredentialOffer = await this.oid4vcIssuanceService.updateOidcCredentialOffer( + oidcUpdateCredentialPayload, + orgId, + issuerId + ); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcIssuerSession.success.update, + data: updateCredentialOffer + }; + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Get('/orgs/:orgId/oid4vc/credential-offer/:id') + @ApiOperation({ + summary: 'Get OID4VC credential offer', + description: 'Retrieves an OID4VC credential offer by its ID.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Credential offer fetched successfully.', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async getCredentialOfferDetailsById( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param('id') + id: string, + @Res() res: Response + ): Promise { + const oidcIssuer = await this.oid4vcIssuanceService.getCredentialOfferDetailsById(id, orgId); + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcIssuerSession.success.getById, + data: oidcIssuer + }; + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Get('/orgs/:orgId/oid4vc/credential-offer') + @ApiOperation({ + summary: 'Get all OID4VC credential offers', + description: 'Retrieves all OID4VC credential offers for the specified organization.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'All credential offers fetched successfully.', + type: ApiResponseDto + }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async getAllCredentialOffers( + @Query() getAllCredentialOffer: GetAllCredentialOfferDto, + @Param('orgId') orgId: string, + @Res() res: Response + ): Promise { + const connectionDetails = await this.oid4vcIssuanceService.getAllCredentialOffers(orgId, getAllCredentialOffer); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcIssuerSession.success.getAll, + data: connectionDetails + }; + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Delete('/orgs/:orgId/oid4vc/:credentialId/delete-offer') + @ApiOperation({ + summary: 'Delete OID4VC credential offer', + description: 'Deletes an OID4VC credential offer for the specified organization.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Credential offer deleted successfully.', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async deleteCredentialOffers( + @Param('orgId') orgId: string, + @Param('credentialId') credentialId: string, + @Res() res: Response + ): Promise { + const deletedofferDetails = await this.oid4vcIssuanceService.deleteCredentialOffers(orgId, credentialId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.NO_CONTENT, + message: ResponseMessages.oidcIssuerSession.success.delete, + data: deletedofferDetails + }; + return res.status(HttpStatus.NO_CONTENT).json(finalResponse); + } + + @Post('/orgs/:orgId/oid4vc/create-offer/agent') + @ApiOperation({ + summary: 'Create OID4VC Credential Offer direct to agent', + description: 'Creates a new OIDC4VCI credential-offer for a given issuer.' + }) + @ApiResponse({ status: HttpStatus.CREATED, description: 'Credential offer created successfully.' }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async createOidcCredentialOfferD2A( + @Param('orgId') + orgId: string, + @Body() oidcCredentialD2APayload: CreateCredentialOfferD2ADto, + @Res() res: Response + ): Promise { + const credentialOffer = await this.oid4vcIssuanceService.createOidcCredentialOfferD2A( + oidcCredentialD2APayload, + orgId + ); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcIssuerSession.success.create, + data: credentialOffer + }; + + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + /** + * Catch issue credential webhook responses + * @param oidcIssueCredentialDto The details of the oid4vc issued credential + * @param id The ID of the organization + * @param res The response object + * @returns The details of the oid4vc issued credential + */ + @Post('wh/:id/openid4vc-issuance') + @ApiExcludeEndpoint() + @ApiOperation({ + summary: 'Catch OID4VC credential states', + description: 'Handles webhook responses for OID4VC credential issuance.' + }) + async getIssueCredentialWebhook( + @Body() oidcIssueCredentialDto: OidcIssueCredentialDto, + @Param('id') id: string, + @Res() res: Response + ): Promise { + console.log('Webhook received:', JSON.stringify(oidcIssueCredentialDto, null, 2)); + const getCredentialDetails = await this.oid4vcIssuanceService.oidcIssueCredentialWebhook( + oidcIssueCredentialDto, + id + ); + + const finalResponse: IResponseType = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.issuance.success.create, + data: getCredentialDetails + }; + + return res.status(HttpStatus.CREATED).json(finalResponse); + } +} diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts new file mode 100644 index 000000000..61470bce8 --- /dev/null +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { Oid4vcIssuanceService } from './oid4vc-issuance.service'; +import { Oid4vcIssuanceController } from './oid4vc-issuance.controller'; +import { NATSClient } from '@credebl/common/NATSClient'; +import { getNatsOptions } from '@credebl/common/nats.config'; +import { CommonConstants } from '@credebl/common/common.constant'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { HttpModule } from '@nestjs/axios'; + +@Module({ + imports: [ + HttpModule, + ClientsModule.register([ + { + name: 'NATS_CLIENT', + transport: Transport.NATS, + options: getNatsOptions(CommonConstants.ISSUANCE_SERVICE, process.env.API_GATEWAY_NKEY_SEED) + } + ]) + ], + controllers: [Oid4vcIssuanceController], + providers: [Oid4vcIssuanceService, NATSClient] +}) +export class Oid4vcIssuanceModule {} diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts new file mode 100644 index 000000000..b5282d9dc --- /dev/null +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts @@ -0,0 +1,138 @@ +import { NATSClient } from '@credebl/common/NATSClient'; +import { Inject, Injectable } from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +import { IssuerCreationDto, IssuerUpdationDto } from './dtos/oid4vc-issuer.dto'; +import { BaseService } from 'libs/service/base.service'; +// eslint-disable-next-line camelcase +import { oidc_issuer, user } from '@prisma/client'; +import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos/oid4vc-issuer-template.dto'; +import { + CreateOidcCredentialOfferDto, + GetAllCredentialOfferDto, + UpdateCredentialRequestDto +} from './dtos/issuer-sessions.dto'; +import { OidcIssueCredentialDto } from './dtos/oid4vc-credential-wh.dto'; + +@Injectable() +export class Oid4vcIssuanceService extends BaseService { + constructor( + @Inject('NATS_CLIENT') private readonly issuanceProxy: ClientProxy, + private readonly natsClient: NATSClient + ) { + super('Oid4vcIssuanceService'); + } + + async oidcIssuerCreate( + issueCredentialDto: IssuerCreationDto, + orgId: string, + userDetails: user + // eslint-disable-next-line camelcase + ): Promise { + const payload = { issueCredentialDto, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-issuer-create', payload); + } + + async oidcIssuerUpdate(issueUpdationDto: IssuerUpdationDto, orgId: string, userDetails: user): Promise { + const payload = { issueUpdationDto, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-issuer-update', payload); + } + + async oidcGetIssuerById(issuerId: string, orgId: string): Promise { + const payload = { issuerId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-issuer-get-by-id', payload); + } + + async oidcGetIssuers(orgId: string): Promise { + const payload = { orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-get-issuers-issuance', payload); + } + + async oidcDeleteIssuer(userDetails: user, orgId: string, issuerId: string): Promise { + const payload = { issuerId, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-delete-issuer', payload); + } + + async deleteTemplate(userDetails: user, orgId: string, templateId: string): Promise { + const payload = { templateId, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-template-delete', payload); + } + + async updateTemplate( + userDetails: user, + orgId: string, + templateId: string, + dto: UpdateCredentialTemplateDto, + issuerId: string + ): Promise { + const payload = { templateId, orgId, userDetails, dto, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-template-update', payload); + } + + async findByIdTemplate(orgId: string, templateId: string): Promise { + const payload = { templateId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-template-find-id', payload); + } + + async findAllTemplate(orgId: string, issuerId: string): Promise { + const payload = { orgId, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-template-find-all', payload); + } + + async createTemplate( + CredentialTemplate: CreateCredentialTemplateDto, + userDetails: user, + orgId: string, + issuerId: string + ): Promise { + const payload = { CredentialTemplate, orgId, userDetails, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-template-create', payload); + } + + async createOidcCredentialOffer( + oidcCredentialPayload: CreateOidcCredentialOfferDto, + userDetails: user, + orgId: string, + issuerId: string + ): Promise { + const payload = { oidcCredentialPayload, orgId, userDetails, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-create-credential-offer', payload); + } + + async createOidcCredentialOfferD2A(oidcCredentialD2APayload, orgId: string): Promise { + const payload = { oidcCredentialD2APayload, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-create-credential-offer-D2A', payload); + } + + async updateOidcCredentialOffer( + oidcUpdateCredentialPayload: UpdateCredentialRequestDto, + orgId: string, + issuerId: string + ): Promise { + const payload = { oidcUpdateCredentialPayload, orgId, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-update-credential-offer', payload); + } + + async getCredentialOfferDetailsById(offerId: string, orgId: string): Promise { + const payload = { offerId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-credential-offer-get-by-id', payload); + } + async getAllCredentialOffers(orgId: string, getAllCredentialOffer: GetAllCredentialOfferDto): Promise { + const payload = { orgId, getAllCredentialOffer }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-credential-offer-get-all', payload); + } + + async deleteCredentialOffers(orgId: string, credentialId: string): Promise { + const payload = { orgId, credentialId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-credential-offer-delete', payload); + } + + oidcIssueCredentialWebhook( + oidcIssueCredentialDto: OidcIssueCredentialDto, + id: string + ): Promise<{ + response: object; + }> { + const payload = { oidcIssueCredentialDto, id }; + return this.natsClient.sendNats(this.issuanceProxy, 'webhook-oid4vc-issue-credential', payload); + } +} diff --git a/apps/issuance/constant/issuance.ts b/apps/issuance/constant/issuance.ts new file mode 100644 index 000000000..d32498997 --- /dev/null +++ b/apps/issuance/constant/issuance.ts @@ -0,0 +1,6 @@ +import { AccessTokenSignerKeyType } from '../interfaces/oid4vc-issuance.interfaces'; + +export const dpopSigningAlgValuesSupported = ['RS256', 'ES256', 'EdDSA']; +export const credentialConfigurationsSupported = {}; +export const accessTokenSignerKeyType = 'ed25519' as AccessTokenSignerKeyType; +export const batchCredentialIssuanceDefault = 0; diff --git a/apps/issuance/src/issuance.controller.ts b/apps/issuance/src/issuance.controller.ts index a1c0ade71..0f1339cd6 100644 --- a/apps/issuance/src/issuance.controller.ts +++ b/apps/issuance/src/issuance.controller.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ +// TODO: Remove this import { IClientDetails, IIssuance, diff --git a/apps/issuance/src/issuance.repository.ts b/apps/issuance/src/issuance.repository.ts index 8fa95635a..0c0ae7182 100644 --- a/apps/issuance/src/issuance.repository.ts +++ b/apps/issuance/src/issuance.repository.ts @@ -239,24 +239,7 @@ export class IssuanceRepository { organisationId = id; } - let schemaId = ''; - - if ( - issueCredentialDto?.metadata?.['_anoncreds/credential']?.schemaId || - issueCredentialDto?.['credentialData']?.offer?.jsonld?.credential?.['@context'][1] || - (issueCredentialDto?.state && - issueCredentialDto?.['credentialData']?.proposal?.jsonld?.credential?.['@context'][1]) - ) { - schemaId = - issueCredentialDto?.metadata?.['_anoncreds/credential']?.schemaId || - issueCredentialDto?.['credentialData']?.offer?.jsonld?.credential?.['@context'][1] || - issueCredentialDto?.['credentialData']?.proposal?.jsonld?.credential?.['@context'][1]; - } - - let credDefId = ''; - if (issueCredentialDto?.metadata?.['_anoncreds/credential']?.credentialDefinitionId) { - credDefId = issueCredentialDto?.metadata?.['_anoncreds/credential']?.credentialDefinitionId; - } + const schemaId = ''; const credentialDetails = await this.prisma.credentials.upsert({ where: { @@ -267,9 +250,7 @@ export class IssuanceRepository { createDateTime: issueCredentialDto?.createDateTime, threadId: issueCredentialDto?.threadId, connectionId: issueCredentialDto?.connectionId, - state: issueCredentialDto?.state, - credDefId, - ...(schemaId ? { schemaId } : {}) + state: issueCredentialDto?.state }, create: { createDateTime: issueCredentialDto?.createDateTime, @@ -279,7 +260,6 @@ export class IssuanceRepository { state: issueCredentialDto?.state, threadId: issueCredentialDto?.threadId, schemaId, - credDefId, credentialExchangeId: issueCredentialDto?.id, orgId: organisationId } diff --git a/apps/oid4vc-issuance/constant/issuance.ts b/apps/oid4vc-issuance/constant/issuance.ts new file mode 100644 index 000000000..d32498997 --- /dev/null +++ b/apps/oid4vc-issuance/constant/issuance.ts @@ -0,0 +1,6 @@ +import { AccessTokenSignerKeyType } from '../interfaces/oid4vc-issuance.interfaces'; + +export const dpopSigningAlgValuesSupported = ['RS256', 'ES256', 'EdDSA']; +export const credentialConfigurationsSupported = {}; +export const accessTokenSignerKeyType = 'ed25519' as AccessTokenSignerKeyType; +export const batchCredentialIssuanceDefault = 0; diff --git a/apps/oid4vc-issuance/enum/issuance.enum.ts b/apps/oid4vc-issuance/enum/issuance.enum.ts new file mode 100644 index 000000000..2d25b147f --- /dev/null +++ b/apps/oid4vc-issuance/enum/issuance.enum.ts @@ -0,0 +1,29 @@ +export enum SortFields { + CREATED_DATE_TIME = 'createDateTime', + SCHEMA_ID = 'schemaId', + CONNECTION_ID = 'connectionId', + STATE = 'state' +} + +export enum IssueCredentials { + proposalSent = 'proposal-sent', + proposalReceived = 'proposal-received', + offerSent = 'offer-sent', + offerReceived = 'offer-received', + declined = 'decliend', + requestSent = 'request-sent', + requestReceived = 'request-received', + credentialIssued = 'credential-issued', + credentialReceived = 'credential-received', + done = 'done', + abandoned = 'abandoned' +} + +export enum IssuedCredentialStatus { + offerSent = 'Offered', + done = 'Accepted', + abandoned = 'Declined', + received = 'Pending', + proposalReceived = 'Proposal Received', + credIssued = 'Credential Issued' +} diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts new file mode 100644 index 000000000..04ff5ff3e --- /dev/null +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts @@ -0,0 +1,166 @@ +import { organisation } from '@prisma/client'; + +export interface OrgAgent { + organisation: organisation; + id: string; + createDateTime: Date; + createdBy: string; + lastChangedDateTime: Date; + lastChangedBy: string; + orgDid: string; + verkey: string; + agentEndPoint: string; + agentId: string; + isDidPublic: boolean; + ledgerId: string; + orgAgentTypeId: string; + tenantId: string; +} + +export interface Claim { + path: string[]; + label?: string; + required?: boolean; +} + +export interface Logo { + uri: string; + alt_text: string; +} + +export interface Display { + locale: string; + name: string; + description?: string; + logo?: Logo; +} + +export interface CredentialConfiguration { + format: string; + vct?: string; + doctype?: string; + scope: string; + claims: Claim[]; + credential_signing_alg_values_supported: string[]; + cryptographic_binding_methods_supported: string[]; + display: Display[]; +} + +export interface ClientAuthentication { + clientId: string; + clientSecret: string; +} + +export interface AuthorizationServerConfig { + issuer: string; + clientAuthentication: ClientAuthentication; +} + +export interface IssuerCreation { + issuerId: string; + accessTokenSignerKeyType?: AccessTokenSignerKeyType; + display: Display[]; + dpopSigningAlgValuesSupported?: string[]; + // credentialConfigurationsSupported?: Record; // Not used + authorizationServerConfigs: AuthorizationServerConfig; + batchCredentialIssuanceSize: number; +} + +export interface IssuerInitialConfig { + issuerId: string; + // eslint-disable-next-line @typescript-eslint/ban-types + display: Display[] | {}; + // eslint-disable-next-line @typescript-eslint/ban-types + authorizationServerConfigs: AuthorizationServerConfig | {}; + accessTokenSignerKeyType: AccessTokenSignerKeyType; + dpopSigningAlgValuesSupported: string[]; + batchCredentialIssuance: object; + credentialConfigurationsSupported: object; +} + +export interface IssuerMetadata { + publicIssuerId: string; + createdById: string; + orgAgentId: string; + batchCredentialIssuanceSize?: number; +} + +export interface initialIssuerDetails { + metadata: Display[]; + publicIssuerId: string; +} + +export enum AccessTokenSignerKeyType { + ED25519 = 'ed25519' +} + +export interface IssuerUpdation { + issuerId: string; + accessTokenSignerKeyType: AccessTokenSignerKeyType; + display; + batchCredentialIssuanceSize?: number; +} + +export interface IAgentNatsPayload { + url: string; + apiKey?: string; + orgId?: string; +} + +export type IAgentOIDCIssuerCreate = IAgentNatsPayload & { issuerCreation: IssuerCreation }; + +export interface TagMap { + [key: string]: string; +} + +export interface ClaimDisplay { + name: string; + locale: string; + description?: string; +} + +export interface ClaimDefinition { + value_type: string; + mandatory: boolean; + display: ClaimDisplay[]; +} +export interface Logo { + uri: string; + alt_text: string; +} + +export interface DisplayInfo { + logo?: Logo; + name: string; + locale: string; + description: string; +} + +export interface ClientAuthentication { + clientId: string; + clientSecret: string; +} + +export interface AuthorizationServerConfig { + issuer: string; + clientAuthentication: ClientAuthentication; +} + +export interface BatchCredentialIssuance { + batchSize: number; +} + +export interface IssuerResponse { + _tags: TagMap; + metadata: Record; + id: string; + createdAt: string; + issuerId: string; + accessTokenPublicKeyFingerprint: string; + credentialConfigurationsSupported?: Record; + dpopSigningAlgValuesSupported: string[]; + display?: DisplayInfo[]; + authorizationServerConfigs?: AuthorizationServerConfig[]; + batchCredentialIssuance: BatchCredentialIssuance; + updatedAt: string; +} diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts new file mode 100644 index 000000000..6fd33ead5 --- /dev/null +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts @@ -0,0 +1,67 @@ +import { OpenId4VcIssuanceSessionState } from '@credebl/enum/enum'; + +/* --------------------------------------------------------- + * Enums + * --------------------------------------------------------- */ +export enum CredentialFormat { + SdJwtVc = 'vc+sd-jwt', + MsoMdoc = 'mso_mdoc' +} + +export enum SignerMethodOption { + DID = 'did', + X5C = 'x5c' +} + +export interface SignerOption { + method: SignerMethodOption; + did?: string; + x5c?: string[]; +} + +export enum AuthenticationType { + PRE_AUTHORIZED_CODE = 'pre-authorized_code', + AUTHORIZATION_CODE = 'authorization_code' +} + +/* --------------------------------------------------------- + * Interfaces + * --------------------------------------------------------- */ +export type DisclosureFrame = Record>; + +export interface CredentialPayload { + full_name?: string; + birth_date?: string; // YYYY-MM-DD if present + birth_place?: string; + parent_names?: string; + [key: string]: unknown; // extensible for mDoc or other formats +} + +export interface CredentialRequest { + credentialSupportedId?: string; + templateId: string; + format: CredentialFormat; // "vc+sd-jwt" | "mso_mdoc" + payload: CredentialPayload; // user-supplied payload (without vct) + disclosureFrame?: DisclosureFrame; // only relevant for vc+sd-jwt +} + +export interface CreateOidcCredentialOffer { + // e.g. "abc-gov" + // signerMethod: SignerMethodOption; // only option selector + authenticationType: AuthenticationType; // only option selector + credentials: CredentialRequest[]; // one or more credentials +} + +export interface GetAllCredentialOffer { + publicIssuerId?: string; + preAuthorizedCode?: string; + state?: OpenId4VcIssuanceSessionState; + credentialOfferUri?: string; + authorizationCode?: string; +} + +export interface UpdateCredentialRequest { + issuerId: string; + credentialOfferId: string; + issuerMetadata: object; +} diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts new file mode 100644 index 000000000..5d2984414 --- /dev/null +++ b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts @@ -0,0 +1,28 @@ +import { Prisma } from '@prisma/client'; +import { Display } from './oid4vc-issuance.interfaces'; + +export interface CredentialAttribute { + mandatory?: boolean; + value_type: string; + display?: Display[]; +} + +export enum SignerOption { + DID = 'did', + X509 = 'x509' +} +export interface CreateCredentialTemplate { + name: string; + description?: string; + signerOption?: SignerOption; + format: 'sd-jwt-vc' | 'mdoc'; + issuer: string; + canBeRevoked: boolean; + attributes: Prisma.JsonValue; + appearance?: Prisma.JsonValue; + issuerId: string; + vct?: string; + doctype?: string; +} + +export interface UpdateCredentialTemplate extends Partial {} diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts new file mode 100644 index 000000000..3f878528e --- /dev/null +++ b/apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts @@ -0,0 +1,48 @@ +export interface CredentialOfferPayload { + credential_issuer: string; + credential_configuration_ids: string[]; + grants: Record; + credentials: Record[]; +} + +export interface IssuanceMetadata { + issuerDid: string; + credentials: Record[]; +} + +export interface OidcIssueCredential { + _tags: Record; + metadata: Record; + issuedCredentials: Record[]; + id: string; + createdAt: string; // ISO date string + issuerId: string; + userPin: string; + preAuthorizedCode: string; + credentialOfferUri: string; + credentialOfferId: string; + credentialOfferPayload: CredentialOfferPayload; + issuanceMetadata: IssuanceMetadata; + state: string; + updatedAt: string; // ISO date string + contextCorrelationId: string; +} + +export interface CredentialOfferWebhookPayload { + credentialOfferId: string; + id: string; + State: string; + contextCorrelationId: string; +} + +export interface CredentialPayload { + orgId: string; + schemaId?: string; + connectionId?: string; + credDefid?: string; + threadId: string; + createdBy: string; + lastChangedBy: string; + state: string; + credentialExchangeId: string; +} diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts new file mode 100644 index 000000000..85cf77e23 --- /dev/null +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -0,0 +1,349 @@ +// builder/credential-offer.builder.ts +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ +import { Prisma, credential_templates } from '@prisma/client'; +import { GetAllCredentialOffer, SignerOption } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; +/* ============================================================================ + Domain Types +============================================================================ */ + +type ValueType = 'string' | 'date' | 'number' | 'boolean' | string; + +interface TemplateAttribute { + display: { name: string; locale: string }[]; + mandatory: boolean; + value_type: ValueType; +} +type TemplateAttributes = Record; + +export enum CredentialFormat { + SdJwtVc = 'vc+sd-jwt', + Mdoc = 'mdoc' +} + +export enum SignerMethodOption { + DID = 'did', + X5C = 'x5c' +} + +export type DisclosureFrame = Record>; + +export interface CredentialRequestDtoLike { + /** maps to credential_templates.id (the template to use) */ + templateId: string; + /** per-template claims */ + payload: Record; + /** optional selective disclosure map */ + disclosureFrame?: DisclosureFrame; +} + +export interface CreateOidcCredentialOfferDtoLike { + credentials: CredentialRequestDtoLike[]; + + // Exactly one of the two must be provided (XOR) + preAuthorizedCodeFlowConfig?: { + txCode: { description?: string; length: number; input_mode: 'numeric' | 'text' | 'alphanumeric' }; + authorizationServerUrl: string; + }; + authorizationCodeFlowConfig?: { + authorizationServerUrl: string; + }; + + // NOTE: issuerId is intentionally NOT emitted in the final payload + publicIssuerId?: string; +} + +export interface ResolvedSignerOption { + method: 'did' | 'x5c'; + did?: string; + x5c?: string[]; +} + +/* ============================================================================ + Strong return types +============================================================================ */ + +export interface BuiltCredential { + /** e.g., "BirthCertificateCredential-sdjwt" or "DrivingLicenseCredential-mdoc" */ + credentialSupportedId: string; + signerOptions?: ResolvedSignerOption; + /** Derived from template.format ("vc+sd-jwt" | "mdoc") */ + format: CredentialFormat; + /** User-provided payload (validated, with vct removed) */ + payload: Record; + /** Optional disclosure frame (usually for SD-JWT) */ + disclosureFrame?: DisclosureFrame; +} + +export interface BuiltCredentialOfferBase { + /** Resolved signer option (DID or x5c) */ + signerOption?: ResolvedSignerOption; + /** Normalized credential entries */ + credentials: BuiltCredential[]; + /** Optional public issuer id to include */ + publicIssuerId?: string; +} + +/** Final payload = base + EXACTLY ONE of the two flows */ +export type CredentialOfferPayload = BuiltCredentialOfferBase & + ( + | { + preAuthorizedCodeFlowConfig: { + txCode: { description?: string; length: number; input_mode: 'numeric' | 'text' | 'alphanumeric' }; + authorizationServerUrl: string; + }; + authorizationCodeFlowConfig?: never; + } + | { + authorizationCodeFlowConfig: { + authorizationServerUrl: string; + }; + preAuthorizedCodeFlowConfig?: never; + } + ); + +/* ============================================================================ + Small Utilities +============================================================================ */ + +const isNil = (v: unknown): v is null | undefined => null == v; +const isEmptyString = (v: unknown): boolean => 'string' === typeof v && '' === v.trim(); +const isRecord = (v: unknown): v is Record => Boolean(v) && 'object' === typeof v && !Array.isArray(v); + +/** Map DB format string -> API enum */ +function mapDbFormatToApiFormat(db: string): CredentialFormat { + const norm = db.toLowerCase().replace(/_/g, '-'); + if ('sd-jwt' === norm || 'vc+sd-jwt' === norm || 'sdjwt' === norm || 'sd+jwt-vc' === norm) { + return CredentialFormat.SdJwtVc; + } + if ('mdoc' === norm || 'mso-mdoc' === norm || 'mso-mdoc' === norm) { + return CredentialFormat.Mdoc; + } + throw new Error(`Unsupported template format: ${db}`); +} + +/** Map API enum -> id suffix required for credentialSupportedId */ +function formatSuffix(api: CredentialFormat): 'sdjwt' | 'mdoc' { + return api === CredentialFormat.SdJwtVc ? 'sdjwt' : 'mdoc'; +} + +/* ============================================================================ + Validation of Payload vs Template Attributes +============================================================================ */ + +/** Throw if any template-mandatory claim is missing/empty in payload. */ +function assertMandatoryClaims( + payload: Record, + attributes: TemplateAttributes, + ctx: { templateId: string } +): void { + const missing: string[] = []; + for (const [claim, def] of Object.entries(attributes)) { + if (!def?.mandatory) { + continue; + } + const val = payload[claim]; + if (isNil(val) || isEmptyString(val)) { + missing.push(claim); + } + } + if (missing.length) { + throw new Error(`Missing mandatory claims for template "${ctx.templateId}": ${missing.join(', ')}`); + } +} + +/* ============================================================================ + JsonValue → TemplateAttributes Narrowing (Type Guards) +============================================================================ */ + +function isDisplayArray(v: unknown): v is { name: string; locale: string }[] { + return Array.isArray(v) && v.every((d) => isRecord(d) && 'string' === typeof d.name && 'string' === typeof d.locale); +} + +function isTemplateAttribute(v: unknown): v is TemplateAttribute { + return ( + isRecord(v) && isDisplayArray(v.display) && 'boolean' === typeof v.mandatory && 'string' === typeof v.value_type + ); +} + +/** Accept `unknown` so predicate type (TemplateAttributes) is assignable to parameter type. */ +function isTemplateAttributes(v: unknown): v is TemplateAttributes { + if (!isRecord(v)) { + return false; + } + return Object.values(v).every(isTemplateAttribute); +} + +/** Runtime assert + narrow Prisma.JsonValue → TemplateAttributes */ +function ensureTemplateAttributes(v: Prisma.JsonValue): TemplateAttributes { + if (!isTemplateAttributes(v)) { + throw new Error('Invalid template.attributes shape. Expecting TemplateAttributes map.'); + } + return v; +} + +/* ============================================================================ + Builders +============================================================================ */ + +/** Build one credential block normalized to API format (using the template's format). */ +function buildOneCredential( + cred: CredentialRequestDtoLike, + template: credential_templates, + attrs: TemplateAttributes, + signerOptions?: SignerOption[] +): BuiltCredential { + // 1) Validate payload against template attributes + assertMandatoryClaims(cred.payload, attrs, { templateId: cred.templateId }); + + // 2) Decide API format from DB format + const apiFormat = mapDbFormatToApiFormat(template.format); + + // 3) Build supportedId from template.name + suffix ("-sdjwt" | "-mdoc") + const suffix = formatSuffix(apiFormat); + const credentialSupportedId = `${template.name}-${suffix}`; + + // 4) Strip vct ALWAYS (per requirement) + const payload = { ...(cred.payload as Record) }; + delete (payload as Record).vct; + + return { + credentialSupportedId, // e.g., "BirthCertificateCredential-sdjwt" + signerOptions: signerOptions[0], + format: apiFormat, // 'vc+sd-jwt' | 'mdoc' + payload, // without vct + ...(cred.disclosureFrame ? { disclosureFrame: cred.disclosureFrame } : {}) + }; +} + +/** + * Build the full OID4VC credential offer payload. + * - Verifies template IDs + * - Validates mandatory claims per template + * - Normalizes formats & IDs + * - Enforces XOR of flow configs + * - Removes issuerId from the final envelope + * - Removes vct from all payloads + * - Sets credentialSupportedId = "-sdjwt|mdoc" + */ +export function buildCredentialOfferPayload( + dto: CreateOidcCredentialOfferDtoLike, + templates: credential_templates[], + signerOptions?: SignerOption[] +): CredentialOfferPayload { + // Index templates + const byId = new Map(templates.map((t) => [t.id, t])); + + // Verify all requested templateIds exist + const unknown = dto.credentials.map((c) => c.templateId).filter((id) => !byId.has(id)); + if (unknown.length) { + throw new Error(`Unknown template ids: ${unknown.join(', ')}`); + } + + // Build credentials + const credentials: BuiltCredential[] = dto.credentials.map((cred) => { + const template = byId.get(cred.templateId)!; + const attrs = ensureTemplateAttributes(template.attributes); // narrow JsonValue safely + return buildOneCredential(cred, template, attrs, signerOptions); + }); + + // --- Base envelope (issuerId deliberately NOT included) --- + const base: BuiltCredentialOfferBase = { + credentials, + ...(dto.publicIssuerId ? { publicIssuerId: dto.publicIssuerId } : {}) + }; + + // XOR flow selection (defensive) + const hasPre = Boolean(dto.preAuthorizedCodeFlowConfig); + const hasAuth = Boolean(dto.authorizationCodeFlowConfig); + if (hasPre === hasAuth) { + throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); + } + + if (hasPre) { + return { + ...base, + preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! // definite since hasPre + }; + } + + return { + ...base, + authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! // definite since !hasPre + }; +} + +// ----------------------------------------------------------------------------- +// Builder: Update Credential Offer +// ----------------------------------------------------------------------------- +export function buildUpdateCredentialOfferPayload( + dto: CreateOidcCredentialOfferDtoLike, + templates: credential_templates[] +): { credentials: BuiltCredential[] } { + // Index templates by id + const byId = new Map(templates.map((t) => [t.id, t])); + + // Validate all templateIds exist + const unknown = dto.credentials.map((c) => c.templateId).filter((id) => !byId.has(id)); + if (unknown.length) { + throw new Error(`Unknown template ids: ${unknown.join(', ')}`); + } + + // Validate each credential against its template + const credentials: BuiltCredential[] = dto.credentials.map((cred) => { + const template = byId.get(cred.templateId)!; + const attrs = ensureTemplateAttributes(template.attributes); // safely narrow JsonValue + + // check that all payload keys exist in template attributes + const payloadKeys = Object.keys(cred.payload); + const invalidKeys = payloadKeys.filter((k) => !attrs[k]); + if (invalidKeys.length) { + throw new Error(`Invalid attributes for template "${cred.templateId}": ${invalidKeys.join(', ')}`); + } + + // also validate mandatory fields are present + assertMandatoryClaims(cred.payload, attrs, { templateId: cred.templateId }); + + // build minimal normalized credential (no vct, issuerId, etc.) + const apiFormat = mapDbFormatToApiFormat(template.format); + const suffix = formatSuffix(apiFormat); + const credentialSupportedId = `${template.name}-${suffix}`; + return { + credentialSupportedId, + format: apiFormat, + payload: cred.payload, + ...(cred.disclosureFrame ? { disclosureFrame: cred.disclosureFrame } : {}) + }; + }); + + // Only return credentials array here (update flow doesn't need preAuth/auth configs) + return { + credentials + }; +} + +export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer: GetAllCredentialOffer): string { + const criteriaParams: string[] = []; + + if (getAllCredentialOffer.publicIssuerId) { + criteriaParams.push(`publicIssuerId=${encodeURIComponent(getAllCredentialOffer.publicIssuerId)}`); + } + + if (getAllCredentialOffer.preAuthorizedCode) { + criteriaParams.push(`preAuthorizedCode=${encodeURIComponent(getAllCredentialOffer.preAuthorizedCode)}`); + } + + if (getAllCredentialOffer.state) { + criteriaParams.push(`state=${encodeURIComponent(getAllCredentialOffer.state)}`); + } + + if (getAllCredentialOffer.credentialOfferUri) { + criteriaParams.push(`credentialOfferUri=${encodeURIComponent(getAllCredentialOffer.credentialOfferUri)}`); + } + + if (getAllCredentialOffer.authorizationCode) { + criteriaParams.push(`authorizationCode=${encodeURIComponent(getAllCredentialOffer.authorizationCode)}`); + } + + // Append query string if any params exist + return 0 < criteriaParams.length ? `${baseUrl}?${criteriaParams.join('&')}` : baseUrl; +} diff --git a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts new file mode 100644 index 000000000..d00e2c78e --- /dev/null +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -0,0 +1,261 @@ +/* eslint-disable camelcase */ +import { oidc_issuer, Prisma } from '@prisma/client'; +import { batchCredentialIssuanceDefault } from '../../constant/issuance'; +import { CreateOidcCredentialOffer } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; +import { IssuerResponse } from 'apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces'; + +type AttributeDisplay = { name: string; locale: string }; +type AttributeDef = { + display?: AttributeDisplay[]; + mandatory?: boolean; + value_type: 'string' | 'date' | 'number' | 'boolean' | string; +}; +type AttributesMap = Record; + +type CredentialDisplayItem = { + logo?: { uri: string; alt_text?: string }; + name: string; + locale?: string; + description?: string; +}; +type Appearance = { + display: CredentialDisplayItem[]; +}; + +type Claim = { + mandatory?: boolean; + // value_type: string; + path: string[]; + display?: AttributeDisplay[]; +}; + +type CredentialConfig = { + format: string; + vct?: string; + scope: string; + doctype?: string; + claims: Claim[]; + credential_signing_alg_values_supported: string[]; + cryptographic_binding_methods_supported: string[]; + display: { name: string; description?: string; locale?: string }[]; +}; + +type CredentialConfigurationsSupported = CredentialConfig[]; + +// ---- Static Lists (as requested) ---- +const STATIC_CREDENTIAL_ALGS = ['ES256', 'EdDSA'] as const; +const STATIC_BINDING_METHODS = ['did:key'] as const; +const MSO_MDOC = 'mso_mdoc'; // alternative format value + +// Safe coercion helpers +function coerceJsonObject(v: Prisma.JsonValue): T | null { + if (null == v) { + return null; + } + if ('string' === typeof v) { + try { + return JSON.parse(v) as T; + } catch { + return null; + } + } + return v as unknown as T; // already a JsonObject/JsonArray +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isAttributesMap(x: any): x is AttributesMap { + return x && 'object' === typeof x && !Array.isArray(x); +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isAppearance(x: any): x is Appearance { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return x && 'object' === typeof x && Array.isArray((x as any).display); +} + +// Prisma row shape +type TemplateRowPrisma = { + id: string; + name: string; + description?: string | null; + format?: string | null; + canBeRevoked?: boolean | null; + attributes: Prisma.JsonValue; // JsonValue from DB + appearance: Prisma.JsonValue; // JsonValue from DB + issuerId: string; + createdAt?: Date | string; + updatedAt?: Date | string; +}; + +/** + * Build agent payload from Prisma rows (attributes/appearance are Prisma.JsonValue). + * Safely coerces JSON and then builds the same structure as Builder #2. + */ +export function buildCredentialConfigurationsSupported( + templates: TemplateRowPrisma[], + opts?: { + vct?: string; + doctype?: string; + scopeVct?: string; + keyResolver?: (t: TemplateRowPrisma) => string; + format?: string; + } +): CredentialConfigurationsSupported { + const format = opts?.format ?? 'vc+sd-jwt'; + const credentialConfigurationsSupported: CredentialConfigurationsSupported = []; + for (const t of templates) { + // Coerce JSON fields + const attrs = coerceJsonObject(t.attributes); + const app = coerceJsonObject(t.appearance); + + if (!isAttributesMap(attrs)) { + throw new Error(`Template ${t.id}: invalid attributes JSON`); + } + if (!isAppearance(app)) { + throw new Error(`Template ${t.id}: invalid appearance JSON (missing display)`); + } + + // ---- dynamic format per row ---- + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rowFormat = (t as any).format ?? format; + const isMdoc = rowFormat === `${MSO_MDOC}`; + const suffix = rowFormat === `${MSO_MDOC}` ? 'mdoc' : 'sdjwt'; + + // key: keep your keyResolver override; otherwise include suffix + // const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; + + // vct/scope: vct only for non-mdoc; scope always uses suffix + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rowDoctype: string | undefined = opts?.doctype ?? (t as any).doctype; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rowVct: string = opts?.vct ?? (t as any).vct ?? t.name; + + if (isMdoc && !rowDoctype) { + throw new Error(`Template ${t.id}: doctype is required for mdoc format`); + } + + const scopeBase = opts?.scopeVct ?? rowVct; + const scope = `openid4vc:credential:${scopeBase}-${suffix}`; + const claims = Object.entries(attrs).map(([claimName, def]) => { + const d = def as AttributeDef; + return { + path: [claimName], + // value_type: d.value_type, // Didn't find this in draft 15 + mandatory: d.mandatory ?? false, // always include, default to false + display: Array.isArray(d.display) ? d.display.map((x) => ({ name: x.name, locale: x.locale })) : undefined + }; + }); + + const display = + app.display.map((d) => ({ + name: d.name, + description: d.description, + locale: d.locale + })) ?? []; + + // assemble per-template config + credentialConfigurationsSupported.push({ + format: rowFormat, + scope, + claims, + credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], + cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], + display, + ...(isMdoc ? { doctype: rowDoctype as string } : { vct: rowVct }) + }); + } + + return credentialConfigurationsSupported; +} + +// Default DPoP list for issuer-level metadata (match your example) +const ISSUER_DPOP_ALGS_DEFAULT = ['RS256', 'ES256'] as const; + +// ---------- Safe coercion ---------- +function coerceJson(v: Prisma.JsonValue): T | null { + if (null == v) { + return null; + } + if ('string' === typeof v) { + try { + return JSON.parse(v) as T; + } catch { + return null; + } + } + return v as unknown as T; +} + +type DisplayItem = { + name: string; + locale?: string; + description?: string; + logo?: { uri: string; alt_text?: string }; +}; + +function isDisplayArray(x: unknown): x is DisplayItem[] { + return ( + Array.isArray(x) && + x.every( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (i) => i && 'object' === typeof i && 'string' === typeof (i as any).name + ) + ); +} + +// ---------- Builder you asked for ---------- +/** + * Build issuer metadata payload from issuer row + credential configurations. + * + * @param credentialConfigurations Object with credentialConfigurationsSupported (from your template builder) + * @param oidcIssuer OID4VC issuer row (uses publicIssuerId and metadata -> display) + * @param opts Optional overrides: dpopAlgs[], accessTokenSignerKeyType + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function buildIssuerPayload( + credentialConfigurations: CredentialConfigurationsSupported, + oidcIssuer: oidc_issuer, + opts?: { + dpopAlgs?: string[]; + accessTokenSignerKeyType?: string; + } +) { + if (!oidcIssuer?.publicIssuerId || 'string' !== typeof oidcIssuer.publicIssuerId) { + throw new Error('Invalid issuer: missing publicIssuerId'); + } + + const rawDisplay = coerceJson(oidcIssuer.metadata); + const display: DisplayItem[] = isDisplayArray(rawDisplay) ? rawDisplay : []; + + return { + display, + dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], + credentialConfigurationsSupported: credentialConfigurations ?? [], + batchCredentialIssuance: { + batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault + } + }; +} + +export function extractTemplateIds(offer: CreateOidcCredentialOffer): string[] { + if (!offer?.credentials || !Array.isArray(offer.credentials)) { + return []; + } + + return offer.credentials.map((c) => c.templateId).filter((id): id is string => Boolean(id)); +} + +export function normalizeJson(input: unknown): IssuerResponse { + if ('string' === typeof input) { + return JSON.parse(input) as IssuerResponse; + } + if (input && 'object' === typeof input) { + return input as IssuerResponse; + } + throw new Error('Expected a JSON object or JSON string'); +} + +export function encodeIssuerPublicId(publicIssuerId: string): string { + if (!publicIssuerId) { + throw new Error('issuerPublicId is required'); + } + return encodeURIComponent(publicIssuerId.trim()); +} diff --git a/apps/oid4vc-issuance/src/main.ts b/apps/oid4vc-issuance/src/main.ts new file mode 100644 index 000000000..3c6933fc9 --- /dev/null +++ b/apps/oid4vc-issuance/src/main.ts @@ -0,0 +1,23 @@ +import { NestFactory } from '@nestjs/core'; +import { HttpExceptionFilter } from 'libs/http-exception.filter'; +import { Logger } from '@nestjs/common'; +import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { getNatsOptions } from '@credebl/common/nats.config'; +import { CommonConstants } from '@credebl/common/common.constant'; +import NestjsLoggerServiceAdapter from '@credebl/logger/nestjsLoggerServiceAdapter'; +import { Oid4vcIssuanceModule } from './oid4vc-issuance.module'; + +const logger = new Logger(); + +async function bootstrap(): Promise { + const app = await NestFactory.createMicroservice(Oid4vcIssuanceModule, { + transport: Transport.NATS, + options: getNatsOptions(CommonConstants.ISSUANCE_SERVICE, process.env.ISSUANCE_NKEY_SEED) + }); + app.useLogger(app.get(NestjsLoggerServiceAdapter)); + app.useGlobalFilters(new HttpExceptionFilter()); + + await app.listen(); + logger.log('OID4VC-Issuance-Service Microservice is listening to NATS '); +} +bootstrap(); diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts new file mode 100644 index 000000000..7d9f476f7 --- /dev/null +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts @@ -0,0 +1,182 @@ +/* eslint-disable camelcase */ +import { Controller } from '@nestjs/common'; +import { Oid4vcIssuanceService } from './oid4vc-issuance.service'; +import { IssuerCreation, IssuerUpdation } from '../interfaces/oid4vc-issuance.interfaces'; +import { MessagePattern } from '@nestjs/microservices'; +import { user, oidc_issuer, credential_templates } from '@prisma/client'; +import { + CreateOidcCredentialOffer, + UpdateCredentialRequest, + GetAllCredentialOffer +} from '../interfaces/oid4vc-issuer-sessions.interfaces'; +import { CreateCredentialTemplate, UpdateCredentialTemplate } from '../interfaces/oid4vc-template.interfaces'; +import { CredentialOfferWebhookPayload } from '../interfaces/oid4vc-wh-interfaces'; + +@Controller() +export class Oid4vcIssuanceController { + constructor(private readonly oid4vcIssuanceService: Oid4vcIssuanceService) {} + + @MessagePattern({ cmd: 'oid4vc-issuer-create' }) + async oidcIssuerCreate(payload: { + issueCredentialDto: IssuerCreation; + orgId: string; + userDetails: user; + }): Promise { + const { issueCredentialDto, orgId, userDetails } = payload; + return this.oid4vcIssuanceService.oidcIssuerCreate(issueCredentialDto, orgId, userDetails); + } + + @MessagePattern({ cmd: 'oid4vc-issuer-update' }) + async oidcIssuerUpdate(payload: { + issueUpdationDto: IssuerUpdation; + orgId: string; + userDetails: user; + }): Promise { + const { issueUpdationDto, orgId, userDetails } = payload; + return this.oid4vcIssuanceService.oidcIssuerUpdate(issueUpdationDto, orgId, userDetails); + } + + @MessagePattern({ cmd: 'oid4vc-issuer-get-by-id' }) + async oidcGetIssuerById(payload: { + issuerId: string; + orgId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { issuerId, orgId } = payload; + return this.oid4vcIssuanceService.oidcIssuerGetById(issuerId, orgId); + } + + @MessagePattern({ cmd: 'oid4vc-get-issuers-issuance' }) + async oidcGetIssuers(payload: { orgId: string }): Promise { + const { orgId } = payload; + return this.oid4vcIssuanceService.oidcIssuers(orgId); + } + + @MessagePattern({ cmd: 'oid4vc-delete-issuer' }) + async deleteOidcIssuer(payload: { + orgId: string; + userDetails: user; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { orgId, userDetails, issuerId } = payload; + return this.oid4vcIssuanceService.deleteOidcIssuer(orgId, userDetails, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-template-create' }) + async oidcTemplateCreate(payload: { + CredentialTemplate: CreateCredentialTemplate; + orgId: string; + issuerId: string; + }): Promise { + const { CredentialTemplate, orgId, issuerId } = payload; + return this.oid4vcIssuanceService.createTemplate(CredentialTemplate, orgId, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-template-update' }) + async oidcTemplateUpdate(payload: { + templateId: string; + dto: UpdateCredentialTemplate; + orgId: string; + issuerId: string; + }): Promise { + const { templateId, dto, orgId, issuerId } = payload; + return this.oid4vcIssuanceService.updateTemplate(templateId, dto, orgId, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-template-delete' }) + async oidcTemplateDelete(payload: { + templateId: string; + orgId: string; + userDetails: user; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { templateId, orgId, userDetails, issuerId } = payload; + return this.oid4vcIssuanceService.deleteTemplate(templateId, orgId, userDetails, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-template-find-id' }) + async oidcTemplateFindById(payload: { templateId: string; orgId: string }): Promise { + const { templateId, orgId } = payload; + return this.oid4vcIssuanceService.findByIdTemplate(templateId, orgId); + } + + @MessagePattern({ cmd: 'oid4vc-template-find-all' }) + async oidcTemplateFindAll(payload: { orgId: string; issuerId: string }): Promise { + const { orgId, issuerId } = payload; + return this.oid4vcIssuanceService.findAllTemplate(orgId, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-create-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async createOidcCredentialOffer(payload: { + oidcCredentialPayload: CreateOidcCredentialOffer; + orgId: string; + userDetails: user; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { oidcCredentialPayload, orgId, userDetails, issuerId } = payload; + return this.oid4vcIssuanceService.createOidcCredentialOffer(oidcCredentialPayload, orgId, userDetails, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-create-credential-offer-D2A' }) + async createOidcCredentialOfferD2A(payload: { + oidcCredentialD2APayload; + orgId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { oidcCredentialD2APayload, orgId } = payload; + return this.oid4vcIssuanceService.createOidcCredentialOfferD2A(oidcCredentialD2APayload, orgId); + } + + @MessagePattern({ cmd: 'oid4vc-update-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async updateOidcCredentialOffer(payload: { + oidcUpdateCredentialPayload: UpdateCredentialRequest; + orgId: string; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { oidcUpdateCredentialPayload, orgId, issuerId } = payload; + return this.oid4vcIssuanceService.updateOidcCredentialOffer(oidcUpdateCredentialPayload, orgId, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-credential-offer-get-by-id' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getCredentialOfferDetailsById(payload: { + offerId: string; + orgId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { offerId, orgId } = payload; + return this.oid4vcIssuanceService.getCredentialOfferDetailsById(offerId, orgId); + } + @MessagePattern({ cmd: 'oid4vc-credential-offer-get-all' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getAllCredentialOffers(payload: { + orgId: string; + getAllCredentialOffer: GetAllCredentialOffer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { orgId, getAllCredentialOffer } = payload; + return this.oid4vcIssuanceService.getCredentialOffers(orgId, getAllCredentialOffer); + } + + @MessagePattern({ cmd: 'oid4vc-credential-offer-delete' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async deleteCredentialOffers(payload: { + orgId: string; + credentialId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { orgId, credentialId } = payload; + return this.oid4vcIssuanceService.deleteCredentialOffers(orgId, credentialId); + } + + @MessagePattern({ cmd: 'webhook-oid4vc-issue-credential' }) + async oidcIssueCredentialWebhook(payload: CredentialOfferWebhookPayload): Promise { + return this.oid4vcIssuanceService.storeOidcCredentialWebhook(payload); + } +} diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts new file mode 100644 index 000000000..b882032cb --- /dev/null +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts @@ -0,0 +1,36 @@ +import { Logger, Module } from '@nestjs/common'; +import { Oid4vcIssuanceController } from './oid4vc-issuance.controller'; +import { Oid4vcIssuanceService } from './oid4vc-issuance.service'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { getNatsOptions } from '@credebl/common/nats.config'; +import { CommonModule } from '@credebl/common'; +import { CommonConstants } from '@credebl/common/common.constant'; +import { GlobalConfigModule } from '@credebl/config'; +import { ContextInterceptorModule } from '@credebl/context'; +import { LoggerModule } from '@credebl/logger'; +import { CacheModule } from '@nestjs/cache-manager'; +import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; +import { Oid4vcIssuanceRepository } from './oid4vc-issuance.repository'; +import { NATSClient } from '@credebl/common/NATSClient'; +import { PrismaService } from '@credebl/prisma-service'; + +@Module({ + imports: [ + ClientsModule.register([ + { + name: 'NATS_CLIENT', + transport: Transport.NATS, + options: getNatsOptions(CommonConstants.OIDC4VC_ISSUANCE_SERVICE, process.env.OIDC4VC_ISSUANCE_NKEY_SEED) + } + ]), + CommonModule, + GlobalConfigModule, + LoggerModule, + PlatformConfig, + ContextInterceptorModule, + CacheModule.register() + ], + controllers: [Oid4vcIssuanceController], + providers: [Oid4vcIssuanceService, Oid4vcIssuanceRepository, PrismaService, Logger, NATSClient] +}) +export class Oid4vcIssuanceModule {} diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts new file mode 100644 index 000000000..6c12475fb --- /dev/null +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts @@ -0,0 +1,278 @@ +/* eslint-disable camelcase */ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +// eslint-disable-next-line camelcase +import { Prisma, credential_templates, oidc_issuer, org_agents } from '@prisma/client'; +import { PrismaService } from '@credebl/prisma-service'; +import { IssuerMetadata, IssuerUpdation, OrgAgent } from '../interfaces/oid4vc-issuance.interfaces'; +import { ResponseMessages } from '@credebl/common/response-messages'; + +@Injectable() +export class Oid4vcIssuanceRepository { + constructor( + private readonly prisma: PrismaService, + private readonly logger: Logger + ) {} + + async getAgentEndPoint(orgId: string): Promise { + try { + const agentDetails = await this.prisma.org_agents.findFirst({ + where: { + orgId + }, + include: { + organisation: true + } + }); + + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + + return agentDetails; + } catch (error) { + this.logger.error(`Error in get getAgentEndPoint: ${error.message} `); + throw error; + } + } + + async getOrgAgentType(orgAgentId: string): Promise { + try { + const { agent } = await this.prisma.org_agents_type.findFirst({ + where: { + id: orgAgentId + } + }); + + return agent; + } catch (error) { + this.logger.error(`[getOrgAgentType] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async getOrganizationByTenantId(tenantId: string): Promise { + try { + return this.prisma.org_agents.findFirst({ + where: { + tenantId + } + }); + } catch (error) { + this.logger.error(`Error in getOrganization in issuance repository: ${error.message} `); + throw error; + } + } + + async storeOidcCredentialDetails(credentialPayload): Promise { + try { + const { credentialOfferId, state, offerId, contextCorrelationId, orgId } = credentialPayload; + const credentialDetails = await this.prisma.oid4vc_credentials.upsert({ + where: { + offerId + }, + update: { + lastChangedBy: orgId, + credentialOfferId, + contextCorrelationId, + state + }, + create: { + lastChangedBy: orgId, + createdBy: orgId, + state, + orgId, + credentialOfferId, + offerId, + contextCorrelationId + } + }); + + return credentialDetails; + } catch (error) { + this.logger.error(`Error in get storeOidcCredentialDetails: ${error.message} `); + throw error; + } + } + + async getOidcIssuerByOrg(orgId: string): Promise { + try { + return await this.prisma.oidc_issuer.findMany({ + where: { createdBy: orgId }, + include: { + templates: true + }, + orderBy: { + createDateTime: 'desc' + } + }); + } catch (error) { + this.logger.error(`Error in getOidcIssuerByOrg: ${error.message}`); + throw error; + } + } + + async getOidcIssuerDetailsById(issuerId: string): Promise { + try { + return await this.prisma.oidc_issuer.findFirstOrThrow({ + where: { id: issuerId } + }); + } catch (error) { + this.logger.error(`Error in getOidcIssuerDetailsById: ${error.message}`); + throw error; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async addOidcIssuerDetails(issuerMetadata: IssuerMetadata, issuerProfileJson): Promise { + try { + const { publicIssuerId, createdById, orgAgentId, batchCredentialIssuanceSize } = issuerMetadata; + const oidcIssuerDetails = await this.prisma.oidc_issuer.create({ + data: { + metadata: issuerProfileJson, + publicIssuerId, + createdBy: createdById, + orgAgentId, + batchCredentialIssuanceSize + } + }); + + return oidcIssuerDetails; + } catch (error) { + this.logger.error(`[addOidcIssuerDetails] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async updateOidcIssuerDetails(createdById: string, issuerConfig: IssuerUpdation): Promise { + try { + const { issuerId, display, batchCredentialIssuanceSize } = issuerConfig; + const oidcIssuerDetails = await this.prisma.oidc_issuer.update({ + where: { id: issuerId }, + data: { + metadata: display as unknown as Prisma.InputJsonValue, + createdBy: createdById, + ...(batchCredentialIssuanceSize !== undefined ? { batchCredentialIssuanceSize } : {}) + } + }); + + return oidcIssuerDetails; + } catch (error) { + this.logger.error(`[addOidcIssuerDetails] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async deleteOidcIssuer(issuerId: string): Promise { + try { + return await this.prisma.oidc_issuer.delete({ + where: { id: issuerId } + }); + } catch (error) { + this.logger.error(`[deleteOidcIssuer] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async createTemplate( + issuerId: string, + data: Omit + ): Promise { + try { + return await this.prisma.credential_templates.create({ + data: { + ...data, + issuerId + } + }); + } catch (error) { + this.logger.error(`Error in createTemplate: ${error.message}`); + throw error; + } + } + + async getTemplateById(templateId: string): Promise { + try { + return await this.prisma.credential_templates.findUnique({ + where: { id: templateId } + }); + } catch (error) { + this.logger.error(`Error in getTemplateById: ${error.message}`); + throw error; + } + } + + async getTemplateByIds(templateIds: string[], issuerId: string): Promise { + try { + // Early return if empty input (avoids full table scan if someone passes []) + if (!Array.isArray(templateIds) || 0 === templateIds.length) { + return []; + } + + this.logger.debug(`getTemplateByIds templateIds=${JSON.stringify(templateIds)} issuerId=${issuerId}`); + + return await this.prisma.credential_templates.findMany({ + where: { + id: { in: templateIds }, + issuerId + } + }); + } catch (error) { + this.logger.error(`Error in getTemplateByIds: ${error?.message}`, error?.stack); + throw error; + } + } + + async getTemplateByNameForIssuer(name: string, issuerId: string): Promise { + try { + return await this.prisma.credential_templates.findMany({ + where: { + issuerId, + name: { + equals: name, + mode: 'insensitive' + } + } + }); + } catch (error) { + this.logger.error(`Error in getTemplateByNameForIssuer: ${error.message}`); + throw error; + } + } + + async getTemplatesByIssuerId(issuerId: string): Promise { + try { + return await this.prisma.credential_templates.findMany({ + where: { issuerId }, + orderBy: { + createdAt: 'desc' + } + }); + } catch (error) { + this.logger.error(`Error in getTemplatesByIssuer: ${error.message}`); + throw error; + } + } + + async updateTemplate(templateId: string, data: Partial): Promise { + try { + return await this.prisma.credential_templates.update({ + where: { id: templateId }, + data + }); + } catch (error) { + this.logger.error(`Error in updateTemplate: ${error.message}`); + throw error; + } + } + + async deleteTemplate(templateId: string): Promise { + try { + return await this.prisma.credential_templates.delete({ + where: { id: templateId } + }); + } catch (error) { + this.logger.error(`Error in deleteTemplate: ${error.message}`); + throw error; + } + } +} diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts new file mode 100644 index 000000000..afdf46be6 --- /dev/null +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -0,0 +1,889 @@ +/* eslint-disable quotes */ +/* eslint-disable no-useless-catch */ +/* eslint-disable camelcase */ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ + +import { + BadRequestException, + ConflictException, + HttpException, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, + Scope +} from '@nestjs/common'; +import { Oid4vcIssuanceRepository } from './oid4vc-issuance.repository'; +import { CommonConstants } from '@credebl/common/common.constant'; +import { ResponseMessages } from '@credebl/common/response-messages'; +import { ClientProxy, RpcException } from '@nestjs/microservices'; +import { map } from 'rxjs'; +import { getAgentUrl } from '@credebl/common/common.utils'; +import { credential_templates, oidc_issuer, user } from '@prisma/client'; +import { + IAgentOIDCIssuerCreate, + IssuerCreation, + IssuerInitialConfig, + IssuerMetadata, + IssuerResponse, + IssuerUpdation +} from '../interfaces/oid4vc-issuance.interfaces'; +import { CreateCredentialTemplate, UpdateCredentialTemplate } from '../interfaces/oid4vc-template.interfaces'; +import { + accessTokenSignerKeyType, + batchCredentialIssuanceDefault, + credentialConfigurationsSupported, + dpopSigningAlgValuesSupported +} from '../constant/issuance'; +import { + buildCredentialConfigurationsSupported, + buildIssuerPayload, + encodeIssuerPublicId, + extractTemplateIds, + normalizeJson +} from '../libs/helpers/issuer.metadata'; +import { + CreateOidcCredentialOffer, + GetAllCredentialOffer, + SignerMethodOption, + UpdateCredentialRequest +} from '../interfaces/oid4vc-issuer-sessions.interfaces'; +import { + buildCredentialOfferPayload, + buildCredentialOfferUrl, + CredentialOfferPayload +} from '../libs/helpers/credential-sessions.builder'; + +type CredentialDisplayItem = { + logo?: { uri: string; alt_text?: string }; + name: string; + locale?: string; + description?: string; +}; + +type Appearance = { + display: CredentialDisplayItem[]; +}; +@Injectable() +export class Oid4vcIssuanceService { + private readonly logger = new Logger('IssueCredentialService'); + constructor( + @Inject('NATS_CLIENT') private readonly issuanceServiceProxy: ClientProxy, + private readonly oid4vcIssuanceRepository: Oid4vcIssuanceRepository + ) {} + + async oidcIssuerCreate(issuerCreation: IssuerCreation, orgId: string, userDetails: user): Promise { + try { + const { issuerId, batchCredentialIssuanceSize } = issuerCreation; + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint, id: orgAgentId, orgAgentTypeId } = agentDetails; + const orgAgentType = await this.oid4vcIssuanceRepository.getOrgAgentType(orgAgentTypeId); + if (!orgAgentType) { + throw new NotFoundException(ResponseMessages.issuance.error.orgAgentTypeNotFound); + } + const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_CREATE); + const issuerInitialConfig: IssuerInitialConfig = { + issuerId, + display: issuerCreation?.display || {}, + authorizationServerConfigs: issuerCreation?.authorizationServerConfigs || undefined, + accessTokenSignerKeyType, + dpopSigningAlgValuesSupported, + batchCredentialIssuance: { + batchSize: batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault + }, + credentialConfigurationsSupported + }; + let createdIssuer; + try { + createdIssuer = await this._createOIDCIssuer(issuerInitialConfig, url, orgId); + } catch (error) { + this.logger.error(`[oidcIssuerCreate] - error in oidcIssuerCreate issuance records: ${JSON.stringify(error)}`); + const status409 = + 409 === error?.status?.message?.statusCode || 409 === error?.response?.status || 409 === error?.statusCode; + + if (status409) { + throw new ConflictException(`Issuer with id '${issuerCreation.issuerId}' already exists`); + } + throw error; + } + const issuerConfigJson = createdIssuer?.response ?? createdIssuer ?? {}; + const issuerIdFromAgent = issuerConfigJson?.issuerId; + + if (!issuerIdFromAgent) { + throw new InternalServerErrorException('Issuer ID missing from agent response'); + } + const issuerMetadata: IssuerMetadata = { + publicIssuerId: issuerIdFromAgent, + createdById: userDetails.id, + orgAgentId, + batchCredentialIssuanceSize: issuerCreation?.batchCredentialIssuanceSize + }; + const addOidcIssuerDetails = await this.oid4vcIssuanceRepository.addOidcIssuerDetails( + issuerMetadata, + issuerCreation?.display + ); + + if (!addOidcIssuerDetails) { + throw new InternalServerErrorException('Error in adding OID4VC Issuer details in DB'); + } + return addOidcIssuerDetails; + } catch (error) { + this.logger.error(`[oidcIssuerCreate] - error in oidcIssuerCreate issuance records: ${JSON.stringify(error)}`); + throw new RpcException(error?.response ?? error); + } + } + + async oidcIssuerUpdate(issuerUpdationConfig: IssuerUpdation, orgId: string, userDetails: user): Promise { + try { + const getIssuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById( + issuerUpdationConfig.issuerId + ); + if (!getIssuerDetails) { + throw new NotFoundException(ResponseMessages.oidcIssuer.error.notFound); + } + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint, orgAgentTypeId } = agentDetails; + const orgAgentType = await this.oid4vcIssuanceRepository.getOrgAgentType(orgAgentTypeId); + if (!orgAgentType) { + throw new NotFoundException(ResponseMessages.issuance.error.orgAgentTypeNotFound); + } + + const addOidcIssuerDetails = await this.oid4vcIssuanceRepository.updateOidcIssuerDetails( + userDetails.id, + issuerUpdationConfig + ); + + if (!addOidcIssuerDetails) { + throw new InternalServerErrorException('Error in updating OID4VC Issuer details in DB'); + } + + const url = await getAgentUrl( + agentEndPoint, + CommonConstants.OIDC_ISSUER_TEMPLATE, + getIssuerDetails.publicIssuerId + ); + const issuerConfig = await this.buildOidcIssuerConfig(issuerUpdationConfig.issuerId); + console.log('This is the issuerConfig:', JSON.stringify(issuerConfig, null, 2)); + const updatedIssuer = await this._createOIDCTemplate(issuerConfig, url, orgId); + if (updatedIssuer?.response?.statusCode && 200 !== updatedIssuer?.response?.statusCode) { + throw new InternalServerErrorException( + `Error from agent while updating issuer: ${updatedIssuer?.response?.message ?? 'Unknown error'}` + ); + } + + return addOidcIssuerDetails; + } catch (error) { + this.logger.error(`[oidcIssuerUpdate] - error in oidcIssuerUpdate issuance records: ${JSON.stringify(error)}`); + throw new RpcException(error?.response ?? error); + } + } + + async oidcIssuerGetById(issuerId: string, orgId: string): Promise { + try { + const getIssuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); + console.log('This is the getIssuerDetails:', JSON.stringify(getIssuerDetails, null, 2)); + if (!getIssuerDetails && getIssuerDetails.publicIssuerId) { + throw new NotFoundException(ResponseMessages.oidcIssuer.error.notFound); + } + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + console.log('This is the agentDetails:', getIssuerDetails?.publicIssuerId); + const encodedId = encodeIssuerPublicId(getIssuerDetails?.publicIssuerId); + const url = await getAgentUrl(agentDetails?.agentEndPoint, CommonConstants.OIDC_ISSUER_BY_ID, encodedId); + console.log('This is the oidcIssuerGetById url:', url); + const issuerDetailsRaw = await this._oidcGetIssuerById(url, orgId); + console.log('This is the issuerDetailsRaw:', JSON.stringify(issuerDetailsRaw, null, 2)); + if (!issuerDetailsRaw) { + throw new InternalServerErrorException(`Error from agent while getting issuer`); + } + const issuerDetails = { + response: normalizeJson(issuerDetailsRaw.response) + }; + + return issuerDetails.response; + } catch (error) { + const errorStack = error?.status?.message?.error; + if (errorStack) { + throw new RpcException({ + message: errorStack?.reason ? errorStack?.reason : errorStack?.message, + statusCode: error?.status?.code + }); + } else { + throw new RpcException(error.response ? error.response : error); + } + } + } + + async oidcIssuers(orgId: string): Promise { + try { + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails?.agentEndPoint) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + + const url = await getAgentUrl(agentDetails.agentEndPoint, CommonConstants.OIDC_GET_ALL_ISSUERS); + const issuersDetails = await this._oidcGetIssuers(url, orgId); + if (!issuersDetails || null == issuersDetails.response) { + throw new InternalServerErrorException('Error from agent while oidcIssuers'); + } + //TODO: Fix the response type from agent + const raw = issuersDetails.response as unknown; + const response: IssuerResponse[] = + 'string' === typeof raw ? (JSON.parse(raw) as IssuerResponse[]) : (raw as IssuerResponse[]); + + if (!Array.isArray(response)) { + throw new InternalServerErrorException('Invalid issuer payload from agent'); + } + return response; + } catch (error: any) { + const msg = error?.message ?? 'unknown error'; + this.logger.error(`[oidcIssuers] - error in oidcIssuers: ${msg}`); + throw new RpcException(error?.response ?? error); + } + } + + async deleteOidcIssuer(orgId: string, userDetails: user, issuerId: string) { + try { + const deleteOidcIssuer = await this.oid4vcIssuanceRepository.deleteOidcIssuer(issuerId); + if (!deleteOidcIssuer) { + throw new NotFoundException(ResponseMessages.oidcIssuer.error.deleteFailed); + } + const issuerRecordId = await this.oidcIssuerGetById(issuerId, orgId); + if (!issuerRecordId.id) { + throw new NotFoundException(ResponseMessages.oidcIssuer.error.notFound); + } + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint } = agentDetails; + const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_DELETE, issuerRecordId.id); + const createTemplateOnAgent = await this._deleteOidcIssuer(url, orgId); + if (!createTemplateOnAgent) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + return deleteOidcIssuer; + } catch (error) { + if ('PrismaClientKnownRequestError' === error.name) { + throw new BadRequestException(error.meta?.cause ?? ResponseMessages.oidcIssuer.error.deleteFailed); + } + throw new Error(error.response ? error.response : error); + } + } + + async createTemplate( + CredentialTemplate: CreateCredentialTemplate, + orgId: string, + issuerId: string + ): Promise { + try { + const { name, description, format, canBeRevoked, attributes, appearance, signerOption, vct, doctype } = + CredentialTemplate; + const checkNameExist = await this.oid4vcIssuanceRepository.getTemplateByNameForIssuer(name, issuerId); + if (0 < checkNameExist.length) { + throw new ConflictException(ResponseMessages.oidcTemplate.error.templateNameAlreadyExist); + } + const metadata = { + name, + description, + format, + canBeRevoked, + attributes, + appearance: appearance ?? {}, + issuerId, + signerOption + }; + // Persist in DB + const createdTemplate = await this.oid4vcIssuanceRepository.createTemplate(issuerId, metadata); + if (!createdTemplate) { + throw new InternalServerErrorException(ResponseMessages.oidcTemplate.error.createFailed); + } + let opts = {}; + if (vct) { + opts = { ...opts, vct }; + } + if (doctype) { + opts = { ...opts, doctype }; + } + const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId, opts); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint } = agentDetails; + const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); + if (!issuerDetails) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + } + const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_TEMPLATE, issuerDetails.publicIssuerId); + const createTemplateOnAgent = await this._createOIDCTemplate(issuerTemplateConfig, url, orgId); + if (!createTemplateOnAgent) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + return createdTemplate; + } catch (error) { + this.logger.error(`[createTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async updateTemplate( + templateId: string, + updateCredentialTemplate: UpdateCredentialTemplate, + orgId: string, + issuerId: string + ): Promise { + try { + const template = await this.oid4vcIssuanceRepository.getTemplateById(templateId); + if (!template) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.notFound); + } + if (updateCredentialTemplate.name) { + const checkNameExist = await this.oid4vcIssuanceRepository.getTemplateByNameForIssuer( + updateCredentialTemplate.name, + issuerId + ); + if (0 < checkNameExist.length) { + throw new ConflictException(ResponseMessages.oidcTemplate.error.templateNameAlreadyExist); + } + } + const normalized = { + ...updateCredentialTemplate, + ...(issuerId ? { issuerId } : {}) + }; + const { name, description, format, canBeRevoked, attributes, appearance } = normalized; + + const payload = { + ...(name !== undefined ? { name } : {}), + ...(description !== undefined ? { description } : {}), + ...(format !== undefined ? { format } : {}), + ...(canBeRevoked !== undefined ? { canBeRevoked } : {}), + ...(attributes !== undefined ? { attributes } : {}), + ...(appearance !== undefined ? { appearance } : {}), + ...(issuerId ? { issuerId } : {}) + }; + + const updatedTemplate = await this.oid4vcIssuanceRepository.updateTemplate(templateId, payload); + + const templates = await this.oid4vcIssuanceRepository.getTemplatesByIssuerId(issuerId); + if (!templates || 0 === templates.length) { + throw new NotFoundException(ResponseMessages.issuance.error.notFound); + } + const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint } = agentDetails; + const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); + if (!issuerDetails) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + } + const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_TEMPLATE, issuerDetails.publicIssuerId); + + const createTemplateOnAgent = await this._createOIDCTemplate(issuerTemplateConfig, url, orgId); + if (!createTemplateOnAgent) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + + return updatedTemplate; + } catch (error) { + this.logger.error(`[updateTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async deleteTemplate(templateId: string, orgId: string, userDetails: user, issuerId: string): Promise { + try { + const template = await this.oid4vcIssuanceRepository.getTemplateById(templateId); + if (!template) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.notFound); + } + const deleteTemplate = await this.oid4vcIssuanceRepository.deleteTemplate(templateId); + if (!deleteTemplate) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.deleteTemplate); + } + + const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint } = agentDetails; + const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); + if (!issuerDetails) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + } + const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_TEMPLATE, issuerDetails.publicIssuerId); + + const createTemplateOnAgent = await this._createOIDCTemplate(issuerTemplateConfig, url, orgId); + if (!createTemplateOnAgent) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + return deleteTemplate; + } catch (error) { + this.logger.error(`[deleteTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async findByIdTemplate(templateId: string, orgId: string): Promise { + try { + if (!orgId || !templateId) { + throw new BadRequestException(ResponseMessages.oidcTemplate.error.invalidId); + } + const template = await this.oid4vcIssuanceRepository.getTemplateById(templateId); + if (!template) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.notFound); + } + return template; + } catch (error) { + this.logger.error(`[findByIdTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async createOidcCredentialOffer( + createOidcCredentialOffer: CreateOidcCredentialOffer, + orgId: string, + userDetails: user, + issuerId: string + ): Promise { + try { + const filterTemplateIds = extractTemplateIds(createOidcCredentialOffer); + if (!filterTemplateIds) { + throw new BadRequestException('Please provide a valid id'); + } + const getAllOfferTemplates = await this.oid4vcIssuanceRepository.getTemplateByIds(filterTemplateIds, issuerId); + if (!getAllOfferTemplates) { + throw new NotFoundException('No templates found for the issuer'); + } + + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + //TDOD: signerOption should be under credentials change this with x509 support + + //TDOD: signerOption should be under credentials change this with x509 support + const signerOptions = []; + getAllOfferTemplates.forEach((template) => { + if (template.signerOption === SignerMethodOption.DID) { + signerOptions.push({ + method: SignerMethodOption.DID, + did: agentDetails.orgDid + }); + } + }); + //TODO: Implement x509 support and discuss with team + const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayload( + createOidcCredentialOffer, + getAllOfferTemplates, + signerOptions + ); + console.log('This is the buildOidcCredentialOffer:', JSON.stringify(buildOidcCredentialOffer, null, 2)); + + if (!buildOidcCredentialOffer) { + throw new BadRequestException('Error while creating oid4vc credential offer'); + } + const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); + if (!issuerDetails) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + } + buildOidcCredentialOffer.publicIssuerId = issuerDetails.publicIssuerId; + const url = await getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER, + issuerDetails.publicIssuerId + ); + + const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(buildOidcCredentialOffer, url, orgId); + if (!createCredentialOfferOnAgent) { + throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); + } + + return createCredentialOfferOnAgent.response; + } catch (error) { + this.logger.error(`[createOidcCredentialOffer] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async createOidcCredentialOfferD2A(oidcCredentialD2APayload, orgId: string): Promise { + try { + for (const credential of oidcCredentialD2APayload.credentials) { + const { signerOptions } = credential; + + if (!signerOptions?.method) { + throw new BadRequestException(`signerOptions.method is required`); + } + if (signerOptions.method === SignerMethodOption.X5C) { + if (!signerOptions.x5c || 0 === signerOptions.x5c.length) { + // const x5cFromDb = await this.oid4vcIssuanceRepository.getIssuerX5c( + const x5cFromDb = 'Test'; + // If you want to use the actual DB call, uncomment and use: + // const x5cFromDb = await this.oid4vcIssuanceRepository.getIssuerX5c( + // oidcCredentialD2APayload.publicIssuerId, + // orgId + // ); + if (!x5cFromDb || 0 === x5cFromDb.length) { + throw new BadRequestException(`No x5c found for issuer`); + } + signerOptions.x5c = x5cFromDb; + } + } + + if (signerOptions.method === SignerMethodOption.DID) { + if (!signerOptions.did) { + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new BadRequestException(`No DID found for issuer`); + } + signerOptions.did = agentDetails.orgDid; + } + } + } + + const url = await getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER + ); + const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(oidcCredentialD2APayload, url, orgId); + if (!createCredentialOfferOnAgent) { + throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); + } + console.log('This is the createCredentialOfferOnAgent:', JSON.stringify(createCredentialOfferOnAgent, null, 2)); + + return createCredentialOfferOnAgent.response; + } catch (error) { + this.logger.error(`[createOidcCredentialOffer] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async updateOidcCredentialOffer( + updateOidcCredentialOffer: UpdateCredentialRequest, + orgId: string, + issuerId: string + ): Promise { + try { + if (!updateOidcCredentialOffer.issuerMetadata) { + throw new BadRequestException('Please provide a valid issuerMetadata'); + } + const url = await getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_UPDATE_OFFER, + updateOidcCredentialOffer.credentialOfferId + ); + const updateCredentialOfferOnAgent = await this._oidcUpdateCredentialOffer( + updateOidcCredentialOffer.issuerMetadata, + url, + orgId + ); + console.log('This is the updateCredentialOfferOnAgent:', JSON.stringify(updateCredentialOfferOnAgent)); + if (!updateCredentialOfferOnAgent) { + throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorUpdateOffer); + } + + return updateCredentialOfferOnAgent.response; + } catch (error) { + this.logger.error(`[createOidcCredentialOffer] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async getCredentialOfferDetailsById(offerId: string, orgId: string): Promise { + try { + const url = await getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_BY_ID, + offerId + ); + const offer = await this._oidcGetCredentialOfferById(url, orgId); + if ('string' === typeof offer.response) { + offer.response = JSON.parse(offer.response); + } + return offer.response; + } catch (error) { + this.logger.error(`[getCredentialOfferDetailsById] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async getCredentialOffers(orgId: string, getAllCredentialOffer: GetAllCredentialOffer): Promise { + try { + const url = await getAgentUrl(await this.getAgentEndpoint(orgId), CommonConstants.OIDC_ISSUER_SESSIONS); + const credentialOfferUrl = buildCredentialOfferUrl(url, getAllCredentialOffer); + const offers = await this._oidcGetCredentialOfferById(credentialOfferUrl, orgId); + if ('string' === typeof offers.response) { + offers.response = JSON.parse(offers.response); + } + return offers.response; + } catch (error) { + this.logger.error(`[getCredentialOffers] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + async deleteCredentialOffers(orgId: string, credentialId: string): Promise { + try { + if (!credentialId) { + throw new BadRequestException('Please provide a valid credentialId'); + } + const url = await getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_DELETE_CREDENTIAL_OFFER, + credentialId + ); + const deletedCredentialOffer = await this._oidcDeleteCredentialOffer(url, orgId); + if (!deletedCredentialOffer) { + throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.deleteFailed); + } + if ('string' === typeof deletedCredentialOffer.response) { + deletedCredentialOffer.response = JSON.parse(deletedCredentialOffer.response); + } + return deletedCredentialOffer.response; + } catch (error) { + this.logger.error(`[getCredentialOffers] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async buildOidcIssuerConfig(issuerId: string, configMetadata?) { + try { + const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); + const templates = await this.oid4vcIssuanceRepository.getTemplatesByIssuerId(issuerId); + + const credentialConfigurationsSupported = buildCredentialConfigurationsSupported(templates, configMetadata); + + return buildIssuerPayload(credentialConfigurationsSupported, issuerDetails); + } catch (error) { + this.logger.error(`[buildOidcIssuerPayload] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async findAllTemplate(orgId: string, issuerId: string): Promise { + try { + if (!orgId || !issuerId) { + throw new BadRequestException(ResponseMessages.oidcTemplate.error.invalidId); + } + return this.oid4vcIssuanceRepository.getTemplatesByIssuerId(issuerId); + } catch (error) { + this.logger.error(`[findAllTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async _createOIDCIssuer(issuerCreation, url: string, orgId: string): Promise { + try { + const pattern = { cmd: 'agent-create-oid4vc-issuer' }; + const payload: IAgentOIDCIssuerCreate = { issuerCreation, url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error(`[_createOIDCIssuer] [NATS call]- error in create OID4VC Issuer : ${JSON.stringify(error)}`); + throw error; + } + } + + async _createOIDCTemplate(templatePayload, url: string, orgId: string): Promise { + try { + const pattern = { cmd: 'agent-create-oid4vc-template' }; + const payload = { templatePayload, url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_createOIDCTemplate] [NATS call]- error in create OID4VC Template : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async _deleteOidcIssuer(url: string, orgId: string): Promise { + try { + const pattern = { cmd: 'delete-oid4vc-issuer' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_createOIDCTemplate] [NATS call]- error in create OID4VC Template : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async _oidcGetIssuerById(url: string, orgId: string) { + try { + const pattern = { cmd: 'oid4vc-get-issuer-by-id' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcGetIssuerById] [NATS call]- error in oid4vc get issuer by id : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async _oidcGetIssuers(url: string, orgId: string) { + try { + const pattern = { cmd: 'oid4vc-get-issuers-agent-service' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error(`[_oidcGetIssuers] [NATS call]- error in oid4vc get issuers : ${JSON.stringify(error)}`); + throw error; + } + } + + async _oidcCreateCredentialOffer(credentialPayload: CredentialOfferPayload, url: string, orgId: string) { + try { + const pattern = { cmd: 'agent-service-oid4vc-create-credential-offer' }; + const payload = { credentialPayload, url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcCreateCredentialOffer] [NATS call]- error in oid4vc create credential offer : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async _oidcUpdateCredentialOffer(issuanceMetadata, url: string, orgId: string) { + try { + const pattern = { cmd: 'agent-service-oid4vc-update-credential-offer' }; + const payload = { issuanceMetadata, url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcUpdateCredentialOffer] [NATS call]- error in oid4vc update credential offer : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async _oidcGetCredentialOfferById(url: string, orgId: string) { + try { + const pattern = { cmd: 'agent-service-oid4vc-get-credential-offer-by-id' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcGetCredentialOfferById] [NATS call]- error in oid4vc get credential offer by id : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async _oidcGetCredentialOffers(url: string, orgId: string) { + try { + const pattern = { cmd: 'agent-service-oid4vc-get-credential-offers' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcGetCredentialOffers] [NATS call]- error in oid4vc get credential offers : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async _oidcDeleteCredentialOffer(url: string, orgId: string) { + try { + const pattern = { cmd: 'agent-service-oid4vc-delete-credential-offer' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcDeleteCredentialOffer] [NATS call]- error in oid4vc delete credential offer : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async natsCall( + pattern: object, + payload: object + ): Promise<{ + response: string; + }> { + try { + return this.issuanceServiceProxy + .send(pattern, payload) + .pipe( + map((response) => ({ + response + })) + ) + .toPromise() + .catch((error) => { + this.logger.error(`catch: ${JSON.stringify(error)}`); + throw new HttpException( + { + status: error.statusCode, + error: error.message + }, + error.error + ); + }); + } catch (error) { + this.logger.error(`[natsCall] - error in nats call : ${JSON.stringify(error)}`); + throw error; + } + } + + async getAgentEndpoint(orgId: string): Promise { + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + + if (!agentDetails.agentEndPoint || '' === agentDetails.agentEndPoint.trim()) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + + return agentDetails.agentEndPoint; + } + + async storeOidcCredentialWebhook(CredentialOfferWebhookPayload): Promise { + try { + console.log('Storing OID4VC Credential Webhook:', CredentialOfferWebhookPayload); + const { credentialOfferId, state, id, contextCorrelationId } = CredentialOfferWebhookPayload; + let orgId: string; + if ('default' !== contextCorrelationId) { + const getOrganizationId = await this.oid4vcIssuanceRepository.getOrganizationByTenantId(contextCorrelationId); + orgId = getOrganizationId?.orgId; + } else { + orgId = id; + } + + const credentialPayload = { + orgId, + offerId: id, + credentialOfferId, + state, + contextCorrelationId + }; + + const agentDetails = await this.oid4vcIssuanceRepository.storeOidcCredentialDetails(credentialPayload); + return agentDetails; + } catch (error) { + this.logger.error( + `[getIssueCredentialsbyCredentialRecordId] - error in get credentials : ${JSON.stringify(error)}` + ); + throw new RpcException(error.response ? error.response : error); + } + } +} diff --git a/apps/oid4vc-issuance/test/app.e2e-spec.ts b/apps/oid4vc-issuance/test/app.e2e-spec.ts new file mode 100644 index 000000000..37bf09beb --- /dev/null +++ b/apps/oid4vc-issuance/test/app.e2e-spec.ts @@ -0,0 +1,19 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { Oid4vcIssuanceModule } from './../src/oid4vc-issuance.module'; + +describe('Oid4vcIssuanceController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [Oid4vcIssuanceModule] + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => request(app.getHttpServer()).get('/').expect(200).expect('Hello World!')); +}); diff --git a/apps/oid4vc-issuance/test/jest-e2e.json b/apps/oid4vc-issuance/test/jest-e2e.json new file mode 100644 index 000000000..e9d912f3e --- /dev/null +++ b/apps/oid4vc-issuance/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/apps/oid4vc-issuance/tsconfig.app.json b/apps/oid4vc-issuance/tsconfig.app.json new file mode 100644 index 000000000..b5f8c422e --- /dev/null +++ b/apps/oid4vc-issuance/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false, + "outDir": "../../dist/apps/oid4vc-issuance" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/common/src/common.constant.ts b/libs/common/src/common.constant.ts index 02c6efd05..cd28289d3 100644 --- a/libs/common/src/common.constant.ts +++ b/libs/common/src/common.constant.ts @@ -117,6 +117,15 @@ export enum CommonConstants { // CREATE KEYS CREATE_POLYGON_SECP256k1_KEY = '/polygon/create-keys', + // OID4VC URLs + URL_OIDC_ISSUER_CREATE = '/openid4vc/issuer', + /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase, @typescript-eslint/no-duplicate-enum-values */ + URL_OIDC_GET_ISSUES = '/openid4vc/issuer', + URL_OIDC_ISSUER_UPDATE = '/openid4vc/issuer/#', + URL_OIDC_ISSUER_SESSIONS_CREATE = '/openid4vc/issuance-sessions/create-credential-offer', + URL_OIDC_ISSUER_SESSIONS_GET = '/openid4vc/issuance-sessions/#', + URL_OIDC_ISSUER_SESSIONS_GET_ALL = '/openid4vc/issuance-sessions', + // Nested attribute separator NESTED_ATTRIBUTE_SEPARATOR = '~', @@ -364,6 +373,7 @@ export enum CommonConstants { NOTIFICATION_SERVICE = 'notification', GEO_LOCATION_SERVICE = 'geo-location', CLOUD_WALLET_SERVICE = 'cloud-wallet', + OIDC4VC_ISSUANCE_SERVICE = 'oid4vc-issuance', ACCEPT_OFFER = '/didcomm/credentials/accept-offer', SEED_LENGTH = 32, @@ -383,7 +393,19 @@ export enum CommonConstants { GET_VERIFIED_PROOF = 'get-verified-proof', GET_QUESTION_ANSWER_RECORD = 'get-question-answer-record', SEND_QUESTION = 'send-question', - SEND_BASIC_MESSAGE = 'send-basic-message' + SEND_BASIC_MESSAGE = 'send-basic-message', + + // OID4VC + OIDC_ISSUER_CREATE = 'create-oid4vc-issuer', + OIDC_ISSUER_DELETE = 'delete-oid4vc-issuer', + OIDC_GET_ALL_ISSUERS = 'get-all-oid4vc-issuers', + OIDC_ISSUER_BY_ID = 'get-issuer-by-id', + OIDC_ISSUER_TEMPLATE = 'create-oid4vc-template', + OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER = 'create-oid4vc-credential-offer', + OIDC_ISSUER_SESSIONS_UPDATE_OFFER = 'update-oid4vc-credential-offer', + OIDC_ISSUER_SESSIONS_BY_ID = 'get-oid4vc-session-by-id', + OIDC_ISSUER_SESSIONS = 'get-oid4vc-sessions', + OIDC_DELETE_CREDENTIAL_OFFER = 'delete-oid4vc-credential-offer' } export const MICRO_SERVICE_NAME = Symbol('MICRO_SERVICE_NAME'); export const ATTRIBUTE_NAME_REGEX = /\['(.*?)'\]/; diff --git a/libs/common/src/common.utils.ts b/libs/common/src/common.utils.ts index 938e795c3..5091239fc 100644 --- a/libs/common/src/common.utils.ts +++ b/libs/common/src/common.utils.ts @@ -93,7 +93,20 @@ export const getAgentUrl = async (agentEndPoint: string, urlFlag: string, paramI [String(CommonConstants.GET_VERIFIED_PROOF), String(CommonConstants.URL_PROOF_FORM_DATA)], [String(CommonConstants.GET_QUESTION_ANSWER_RECORD), String(CommonConstants.URL_QUESTION_ANSWER_RECORD)], [String(CommonConstants.SEND_QUESTION), String(CommonConstants.URL_SEND_QUESTION)], - [String(CommonConstants.SEND_BASIC_MESSAGE), String(CommonConstants.URL_SEND_BASIC_MESSAGE)] + [String(CommonConstants.SEND_BASIC_MESSAGE), String(CommonConstants.URL_SEND_BASIC_MESSAGE)], + [String(CommonConstants.OIDC_ISSUER_CREATE), String(CommonConstants.URL_OIDC_ISSUER_CREATE)], + [String(CommonConstants.OIDC_GET_ALL_ISSUERS), String(CommonConstants.URL_OIDC_GET_ISSUES)], + [String(CommonConstants.OIDC_ISSUER_DELETE), String(CommonConstants.URL_OIDC_ISSUER_UPDATE)], + [String(CommonConstants.OIDC_ISSUER_BY_ID), String(CommonConstants.URL_OIDC_ISSUER_UPDATE)], + [String(CommonConstants.OIDC_ISSUER_TEMPLATE), String(CommonConstants.URL_OIDC_ISSUER_UPDATE)], + [ + String(CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER), + String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_CREATE) + ], + [String(CommonConstants.OIDC_ISSUER_SESSIONS_UPDATE_OFFER), String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_GET)], + [String(CommonConstants.OIDC_ISSUER_SESSIONS_BY_ID), String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_GET)], + [String(CommonConstants.OIDC_ISSUER_SESSIONS), String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_GET_ALL)], + [String(CommonConstants.OIDC_DELETE_CREDENTIAL_OFFER), String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_GET_ALL)] ]); const urlSuffix = agentUrlMap.get(urlFlag); diff --git a/libs/common/src/nats.config.ts b/libs/common/src/nats.config.ts index 7fd1ff011..984459979 100644 --- a/libs/common/src/nats.config.ts +++ b/libs/common/src/nats.config.ts @@ -14,8 +14,8 @@ export const getNatsOptions = ( const baseOptions = { servers: `${process.env.NATS_URL}`.split(','), maxReconnectAttempts: NATSReconnects.maxReconnectAttempts, - reconnectTimeWait: NATSReconnects.reconnectTimeWait, - queue: serviceName + reconnectTimeWait: NATSReconnects.reconnectTimeWait + // queue: serviceName }; if (nkeySeed) { diff --git a/libs/common/src/response-messages/index.ts b/libs/common/src/response-messages/index.ts index 6174734e8..edae74634 100644 --- a/libs/common/src/response-messages/index.ts +++ b/libs/common/src/response-messages/index.ts @@ -497,6 +497,57 @@ export const ResponseMessages = { walletRecordNotFound: 'Wallet record not found.' } }, + oidcIssuer: { + success: { + create: 'OID4VC issuer created successfully.', + update: 'OID4VC issuer updated successfully.', + delete: 'OID4VC issuer deleted successfully.', + fetch: 'OID4VC issuer(s) fetched successfully.', + issuerConfig: 'Issuer config details created successfully', + issuerConfigUpdate: 'Issuer config details updated successfully' + }, + error: { + notFound: 'OID4VC issuer not found.', + invalidId: 'Invalid OID4VC issuer ID.', + createFailed: 'Failed to create OID4VC issuer.', + updateFailed: 'Failed to update OID4VC issuer.', + deleteFailed: 'Failed to delete OID4VC issuer.' + } + }, + oidcTemplate: { + success: { + create: 'OID4VC template created successfully.', + update: 'OID4VC template updated successfully.', + delete: 'OID4VC template deleted successfully.', + fetch: 'OID4VC template(s) fetched successfully.', + getById: 'OID4VC template details fetched successfully.' + }, + error: { + notFound: 'OID4VC template not found.', + invalidId: 'Invalid OID4VC template ID.', + createFailed: 'Failed to create OID4VC template.', + updateFailed: 'Failed to update OID4VC template.', + deleteFailed: 'Failed to delete OID4VC template.', + issuerDisplayNotFound: 'Issuer display not found.', + issuerDetailsNotFound: 'Issuer details not found.', + templateNameAlreadyExist: 'Template name already exists for this issuer.', + deleteTemplate: 'Error while deleting template.' + } + }, + oidcIssuerSession: { + success: { + create: 'OID4VC Credential offer created successfully.', + getById: 'OID4VC Credential offer details fetched successfully.', + getAll: 'OID4VC Credential offers fetched successfully.', + update: 'OID4VC Credential offer updated successfully.', + delete: 'OID4VC Credential offer deleted successfully.' + }, + error: { + errorCreateOffer: 'Error while creating OID4VC credential offer on agent.', + errorUpdateOffer: 'Error while updating OID4VC credential offer on agent.', + deleteFailed: 'Failed to delete OID4VC credential offer.' + } + }, nats: { success: {}, error: { diff --git a/libs/enum/src/enum.ts b/libs/enum/src/enum.ts index 9dc8b31ca..787fc1f92 100644 --- a/libs/enum/src/enum.ts +++ b/libs/enum/src/enum.ts @@ -271,3 +271,16 @@ export enum ProviderType { KEYCLOAK = 'keycloak', SUPABASE = 'supabase' } + +export declare enum OpenId4VcIssuanceSessionState { + OfferCreated = 'OfferCreated', + OfferUriRetrieved = 'OfferUriRetrieved', + AuthorizationInitiated = 'AuthorizationInitiated', + AuthorizationGranted = 'AuthorizationGranted', + AccessTokenRequested = 'AccessTokenRequested', + AccessTokenCreated = 'AccessTokenCreated', + CredentialRequestReceived = 'CredentialRequestReceived', + CredentialsPartiallyIssued = 'CredentialsPartiallyIssued', + Completed = 'Completed', + Error = 'Error' +} diff --git a/libs/prisma-service/prisma/migrations/20250814141522_add_supported_protocol/migration.sql b/libs/prisma-service/prisma/migrations/20250814141522_add_supported_protocol/migration.sql new file mode 100644 index 000000000..1c42976ca --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250814141522_add_supported_protocol/migration.sql @@ -0,0 +1,94 @@ +-- Create enum type +CREATE TYPE "CredentialExchangeProtocol" AS ENUM ('OIDC', 'DIDCOMM'); + +-- Add column +ALTER TABLE "organisation" +ADD COLUMN "supported_protocol" "CredentialExchangeProtocol"[]; + +-- Set default for new rows +ALTER TABLE "organisation" +ALTER COLUMN "supported_protocol" +SET DEFAULT ARRAY['DIDCOMM']::"CredentialExchangeProtocol"[]; + +-- Clean existing duplicates before constraint +-- UPDATE "organisation" +-- SET "supported_protocol" = ARRAY( +-- SELECT DISTINCT unnest("supported_protocol") +-- ) +-- WHERE "supported_protocol" IS NOT NULL; + +-- Backfill missing/empty with DIDCOMM +UPDATE "organisation" +SET "supported_protocol" = ARRAY['DIDCOMM']::"CredentialExchangeProtocol"[] +WHERE "supported_protocol" IS NULL OR cardinality("supported_protocol") = 0; + +-- Add no-duplicates constraint +-- ALTER TABLE "organisation" +-- ADD CONSTRAINT supported_protocol_unique +-- CHECK ( +-- cardinality(supported_protocol) = cardinality( +-- ARRAY(SELECT DISTINCT unnest(supported_protocol)) +-- ) +-- ); +-- trigger function to check unique values +CREATE OR REPLACE FUNCTION check_unique_protocols() +RETURNS trigger AS $$ +BEGIN + IF (SELECT COUNT(*) FROM unnest(NEW.supported_protocol) v + GROUP BY v HAVING COUNT(*) > 1 LIMIT 1) IS NOT NULL THEN + RAISE EXCEPTION 'supported_protocol contains duplicates'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER enforce_unique_protocols +BEFORE INSERT OR UPDATE ON organisation +FOR EACH ROW EXECUTE FUNCTION check_unique_protocols(); + + + +-- contraints, triggers and migrations for ledger table +ALTER TABLE "ledgers" +ADD COLUMN "supported_protocol" "CredentialExchangeProtocol"[]; + +-- Set default for new rows +ALTER TABLE "ledgers" +ALTER COLUMN "supported_protocol" +SET DEFAULT ARRAY['DIDCOMM']::"CredentialExchangeProtocol"[]; + +-- Clean existing duplicates before constraint +-- UPDATE "ledgers" +-- SET "supported_protocol" = ARRAY( +-- SELECT DISTINCT unnest("supported_protocol") +-- ) +-- WHERE "supported_protocol" IS NOT NULL; + +-- Backfill missing/empty with DIDCOMM +UPDATE "ledgers" +SET "supported_protocol" = ARRAY['DIDCOMM']::"CredentialExchangeProtocol"[] +WHERE "supported_protocol" IS NULL OR cardinality("supported_protocol") = 0; + +-- Add no-duplicates constraint +-- ALTER TABLE "ledgers" +-- ADD CONSTRAINT supported_protocol_unique +-- CHECK ( +-- cardinality(supported_protocol) = cardinality( +-- ARRAY(SELECT DISTINCT unnest(supported_protocol)) +-- ) +-- ); +-- trigger function to check unique values +CREATE OR REPLACE FUNCTION check_unique_protocols() +RETURNS trigger AS $$ +BEGIN + IF (SELECT COUNT(*) FROM unnest(NEW.supported_protocol) v + GROUP BY v HAVING COUNT(*) > 1 LIMIT 1) IS NOT NULL THEN + RAISE EXCEPTION 'supported_protocol contains duplicates'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER enforce_unique_protocols +BEFORE INSERT OR UPDATE ON ledgers +FOR EACH ROW EXECUTE FUNCTION check_unique_protocols(); \ No newline at end of file diff --git a/libs/prisma-service/prisma/migrations/20250822104325_oidc_issuer_templates/migration.sql b/libs/prisma-service/prisma/migrations/20250822104325_oidc_issuer_templates/migration.sql new file mode 100644 index 000000000..4118c6b20 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250822104325_oidc_issuer_templates/migration.sql @@ -0,0 +1,17 @@ +-- Drop the table if it exists (safe for dev) +DROP TABLE IF EXISTS "public"."credential_templates"; + +-- Create table +CREATE TABLE "public"."credential_templates" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "format" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "canBeRevoked" BOOLEAN NOT NULL DEFAULT false, + "attributes" JSON NOT NULL, + "appearance" JSON NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + CONSTRAINT "credential_templates_pkey" PRIMARY KEY ("id") +); diff --git a/libs/prisma-service/prisma/migrations/20250822145801_fix_issuer_relation/migration.sql b/libs/prisma-service/prisma/migrations/20250822145801_fix_issuer_relation/migration.sql new file mode 100644 index 000000000..643f2dbf2 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250822145801_fix_issuer_relation/migration.sql @@ -0,0 +1,32 @@ +/* + Warnings: + + - You are about to drop the column `issuer` on the `credential_templates` table. All the data in the column will be lost. + - Added the required column `issuerId` to the `credential_templates` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "credential_templates" DROP COLUMN "issuer", +ADD COLUMN "issuerId" UUID NOT NULL, +ALTER COLUMN "attributes" SET DATA TYPE JSONB, +ALTER COLUMN "appearance" SET DATA TYPE JSONB, +ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "updatedAt" DROP DEFAULT, +ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "oidc_issuer" ( + "id" UUID NOT NULL, + "createDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" UUID NOT NULL, + "agentIssuerId" TEXT NOT NULL, + "metadata" JSONB NOT NULL, + + CONSTRAINT "oidc_issuer_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "oidc_issuer" ADD CONSTRAINT "oidc_issuer_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "org_agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "credential_templates" ADD CONSTRAINT "credential_templates_issuerId_fkey" FOREIGN KEY ("issuerId") REFERENCES "oidc_issuer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/libs/prisma-service/prisma/migrations/20250902082321_add_org_agent_id_to_oidc_issuer/migration.sql b/libs/prisma-service/prisma/migrations/20250902082321_add_org_agent_id_to_oidc_issuer/migration.sql new file mode 100644 index 000000000..b39a42c37 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250902082321_add_org_agent_id_to_oidc_issuer/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - You are about to drop the column `agentIssuerId` on the `oidc_issuer` table. All the data in the column will be lost. + - Added the required column `orgAgentId` to the `oidc_issuer` table without a default value. This is not possible if the table is not empty. + - Added the required column `publicIssuerId` to the `oidc_issuer` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "oidc_issuer" DROP CONSTRAINT "oidc_issuer_createdBy_fkey"; + +-- AlterTable +ALTER TABLE "oidc_issuer" DROP COLUMN "agentIssuerId", +ADD COLUMN "orgAgentId" UUID NOT NULL, +ADD COLUMN "publicIssuerId" TEXT NOT NULL; + +-- CreateIndex +CREATE INDEX "oidc_issuer_orgAgentId_idx" ON "oidc_issuer"("orgAgentId"); + +-- AddForeignKey +ALTER TABLE "oidc_issuer" ADD CONSTRAINT "oidc_issuer_orgAgentId_fkey" FOREIGN KEY ("orgAgentId") REFERENCES "org_agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/libs/prisma-service/prisma/migrations/20250904181430_added_fields_in_oidc_issuer/migration.sql b/libs/prisma-service/prisma/migrations/20250904181430_added_fields_in_oidc_issuer/migration.sql new file mode 100644 index 000000000..f8517c3c7 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250904181430_added_fields_in_oidc_issuer/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "AccessTokenSignerKeyType" AS ENUM ('ed25519'); + +-- AlterTable +ALTER TABLE "oidc_issuer" ADD COLUMN "accessTokenSignerKeyType" "AccessTokenSignerKeyType"[] DEFAULT ARRAY['ed25519']::"AccessTokenSignerKeyType"[], +ADD COLUMN "batchCredentialIssuanceSize" INTEGER NOT NULL DEFAULT 0; diff --git a/libs/prisma-service/prisma/migrations/20250911102632_added_field_signer_option_in_credential_templates/migration.sql b/libs/prisma-service/prisma/migrations/20250911102632_added_field_signer_option_in_credential_templates/migration.sql new file mode 100644 index 000000000..58d8d3ebf --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250911102632_added_field_signer_option_in_credential_templates/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - You are about to drop the column `accessTokenSignerKeyType` on the `oidc_issuer` table. All the data in the column will be lost. + - Added the required column `signerOption` to the `credential_templates` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "SignerOption" AS ENUM ('did', 'x509'); + +-- AlterTable +ALTER TABLE "credential_templates" ADD COLUMN "signerOption" "SignerOption" NOT NULL; + +-- AlterTable +ALTER TABLE "oidc_issuer" DROP COLUMN "accessTokenSignerKeyType"; + +-- DropEnum +DROP TYPE "AccessTokenSignerKeyType"; diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index 337c953a2..74bf6189f 100644 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -149,6 +149,7 @@ model organisation { agent_invitations agent_invitations[] credential_definition credential_definition[] file_upload file_upload[] + oid4vc_credentials oid4vc_credentials[] } model org_invitations { @@ -227,6 +228,7 @@ model org_agents { organisation organisation? @relation(fields: [orgId], references: [id]) webhookUrl String? @db.VarChar org_dids org_dids[] + oidc_issuer oidc_issuer[] } model org_dids { @@ -566,3 +568,55 @@ model client_aliases { clientAlias String? clientUrl String } + +model oidc_issuer { + id String @id @default(uuid()) @db.Uuid + createDateTime DateTime @default(now()) @db.Timestamptz(6) + createdBy String @db.Uuid + publicIssuerId String + metadata Json + orgAgentId String @db.Uuid + orgAgent org_agents @relation(fields: [orgAgentId], references: [id]) + templates credential_templates[] + batchCredentialIssuanceSize Int @default(0) + @@index([orgAgentId]) +} + +model oid4vc_credentials { + id String @id @default(uuid()) @db.Uuid + orgId String @db.Uuid + offerId String @unique + credentialOfferId String + state String + contextCorrelationId String + createdBy String @db.Uuid + createDateTime DateTime @default(now()) @db.Timestamptz(6) + lastChangedDateTime DateTime @default(now()) @db.Timestamptz(6) + lastChangedBy String @db.Uuid + + organisation organisation @relation(fields: [orgId], references: [id]) +} + +enum SignerOption { + did + x509 +} + +model credential_templates { + id String @id @default(uuid()) + name String + description String? + format String // e.g. "sd_jwt", "mso_mdoc" + canBeRevoked Boolean @default(false) + + attributes Json + appearance Json + + issuerId String @db.Uuid + issuer oidc_issuer @relation(fields: [issuerId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + signerOption SignerOption +} + diff --git a/nest-cli.json b/nest-cli.json index ee0cc7122..819afa8b7 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -286,6 +286,15 @@ "compilerOptions": { "tsConfigPath": "libs/logger/tsconfig.lib.json" } + }, + "oid4vc-issuance": { + "type": "application", + "root": "apps/oid4vc-issuance", + "entryFile": "main", + "sourceRoot": "apps/oid4vc-issuance/src", + "compilerOptions": { + "tsConfigPath": "apps/oid4vc-issuance/tsconfig.app.json" + } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 214141f89..8839ec129 100644 --- a/package.json +++ b/package.json @@ -205,4 +205,4 @@ "engines": { "node": ">=18" } -} +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 74a5fa904..d0da5851f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -104,7 +104,8 @@ "@credebl/context/*": [ "libs/context/src/*" ] - } + }, + "baseUrl": "./" }, "exclude": [ "node_modules",