diff --git a/apps/api-gateway/src/app.module.ts b/apps/api-gateway/src/app.module.ts index 42076ad14..3200a9219 100644 --- a/apps/api-gateway/src/app.module.ts +++ b/apps/api-gateway/src/app.module.ts @@ -25,6 +25,7 @@ import { LoggerModule } from '@credebl/logger/logger.module'; import { NotificationModule } from './notification/notification.module'; import { Oid4vcIssuanceModule } from './oid4vc-issuance/oid4vc-issuance.module'; import { Oid4vpModule } from './oid4vc-verification/oid4vc-verification.module'; + import { OrganizationModule } from './organization/organization.module'; import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; import { PlatformModule } from './platform/platform.module'; 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 index 32e5e34ab..ae64acd35 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts @@ -274,11 +274,13 @@ export class CredentialDto { @ApiProperty({ description: 'Credential format type', - enum: ['mso_mdoc', 'vc+sd-jwt'], + enum: ['mso_mdoc', 'vc+sd-jwt', 'jwt_vc_json-ld', 'ldp_vc'], example: 'mso_mdoc' }) @IsString() - @IsIn(['mso_mdoc', 'vc+sd-jwt'], { message: 'format must be either "mso_mdoc" or "vc+sd-jwt"' }) + @IsIn(['mso_mdoc', 'vc+sd-jwt', 'jwt_vc_json-ld', 'ldp_vc'], { + message: 'format must be either "mso_mdoc", "vc+sd-jwt", "jwt_vc_json-ld" or "ldp_vc"' + }) format: string; @ApiProperty({ 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 index a4300ee2a..857b7b719 100644 --- 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 @@ -196,6 +196,14 @@ export class SdJwtTemplateDto { @IsString() vct: string; + @ApiPropertyOptional({ + example: 'https://dev-schema.sovio.id/schemas/a99d55b6-c663-4af8-bcd3-4fe6699df91a', + description: 'JSON-LD schema URL for the credential' + }) + @IsString() + @IsOptional() + schemaUrl?: string; + @ApiProperty({ type: 'array', items: { $ref: getSchemaPath(CredentialAttributeDto) }, @@ -229,8 +237,13 @@ export class CreateCredentialTemplateDto { @IsEnum(CredentialFormat) format: CredentialFormat; - @ValidateIf((o: CreateCredentialTemplateDto) => CredentialFormat.SdJwtVc === o.format) - @IsEmpty({ message: 'doctype must not be provided when format is "dc+sd-jwt"' }) + @ValidateIf( + (o: CreateCredentialTemplateDto) => + CredentialFormat.SdJwtVc === o.format || + CredentialFormat.JwtVcJsonLd === o.format || + CredentialFormat.LdpVc === o.format + ) + @IsEmpty({ message: 'doctype must not be provided when format is "dc+sd-jwt", "jwt_vc_json-ld" or "ldp_vc"' }) readonly _doctypeAbsentGuard?: unknown; @ValidateIf((o: CreateCredentialTemplateDto) => CredentialFormat.Mdoc === o.format) @@ -246,7 +259,11 @@ export class CreateCredentialTemplateDto { @Type(({ object }) => { if (object.format === CredentialFormat.Mdoc) { return MdocTemplateDto; - } else if (object.format === CredentialFormat.SdJwtVc) { + } else if ( + object.format === CredentialFormat.SdJwtVc || + object.format === CredentialFormat.JwtVcJsonLd || + object.format === CredentialFormat.LdpVc + ) { return SdJwtTemplateDto; } }) diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts index f6833f4e8..ec5e70f91 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts @@ -15,7 +15,7 @@ import { HttpModule } from '@nestjs/axios'; name: 'NATS_CLIENT', transport: Transport.NATS, options: getNatsOptions( - CommonConstants.ISSUANCE_SERVICE, + CommonConstants.OIDC4VC_ISSUANCE_SERVICE, process.env.API_GATEWAY_NKEY_SEED, process.env.NATS_CREDS_FILE ) diff --git a/apps/api-gateway/src/oid4vc-verification/dtos/oid4vc-verifier-presentation.dto.ts b/apps/api-gateway/src/oid4vc-verification/dtos/oid4vc-verifier-presentation.dto.ts index c26e004ec..9c85706ca 100644 --- a/apps/api-gateway/src/oid4vc-verification/dtos/oid4vc-verifier-presentation.dto.ts +++ b/apps/api-gateway/src/oid4vc-verification/dtos/oid4vc-verifier-presentation.dto.ts @@ -289,7 +289,7 @@ export class DcqlDto { @ValidatorConstraint({ name: 'OnlyOneOf', async: false }) export class OnlyOneOfConstraint implements ValidatorConstraintInterface { // eslint-disable-next-line @typescript-eslint/no-explicit-any - validate(_: any, args: ValidationArguments): Promise | boolean { + validate(_: unknown, args: ValidationArguments): Promise | boolean { // eslint-disable-next-line @typescript-eslint/no-explicit-any const object = args.object as Record; const properties = args.constraints as string[]; @@ -356,10 +356,18 @@ export class PresentationRequestDto { @IsDefined() @IsEnum(ResponseMode) responseMode: ResponseMode; - //TODO: check e2e flow and add ResponseMode based restrictions @IsOptional() expectedOrigins: string[]; + @ApiPropertyOptional({ + description: 'OpenID4VP version to use', + enum: ['v1', 'v1.draft21', 'v1.draft24'], + example: 'v1.draft24' + }) + @IsOptional() + @IsEnum(['v1', 'v1.draft21', 'v1.draft24']) + version?: 'v1' | 'v1.draft21' | 'v1.draft24'; + /** * Dummy property used to run a class-level validation ensuring mutual exclusivity. * This property is not serialized into requests/responses but is required so `class-validator` diff --git a/apps/oid4vc-issuance/constant/issuance.ts b/apps/oid4vc-issuance/constant/issuance.ts index c1adf5aa2..2865e3c6c 100644 --- a/apps/oid4vc-issuance/constant/issuance.ts +++ b/apps/oid4vc-issuance/constant/issuance.ts @@ -7,3 +7,6 @@ export const accessTokenSignerKeyType = { kty: 'OKP', crv: 'Ed25519' } as { crv: AccessTokenSignerKeyType; }; export const batchCredentialIssuanceDefault = 0; + +export const CREDENTIALS_CONTEXT_V1_URL = 'https://www.w3.org/2018/credentials/v1'; +export const CREDENTIALS_CONTEXT_V2_URL = 'https://www.w3.org/ns/credentials/v2'; diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts index 9de39a0a8..05561af4d 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts @@ -5,7 +5,9 @@ import { OpenId4VcIssuanceSessionState } from '@credebl/enum/enum'; * --------------------------------------------------------- */ export enum CredentialFormat { SdJwtVc = 'vc+sd-jwt', - MsoMdoc = 'mso_mdoc' + MsoMdoc = 'mso_mdoc', + JwtVcJsonLd = 'jwt_vc_json-ld', + LdpVc = 'ldp_vc' } export enum SignerMethodOption { diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts index e6a6ce61b..9b7d72de0 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts @@ -2,6 +2,8 @@ import { Prisma, SignerOption } from '@prisma/client'; import { AttributeType, CredentialFormat } from '@credebl/enum/enum'; export interface SdJwtTemplate { vct: string; + schemaUrl?: string; + context?: string[]; attributes: CredentialAttribute[]; } diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts index 3fee5d82a..75f610c06 100644 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -150,14 +150,29 @@ function mapDbFormatToApiFormat(dbFormat: string): CredentialFormat { if (['sd-jwt', 'dc+sd-jwt', 'vc+sd-jwt', 'sdjwt', 'sd+jwt-vc'].includes(normalized)) { return CredentialFormat.SdJwtVc; } + if (['jwt_vc_json-ld', 'jwt-vc-json-ld', 'w3c-jwt-json-ld'].includes(normalized)) { + return CredentialFormat.JwtVcJsonLd; + } + if (['ldp_vc', 'ldp-vc'].includes(normalized)) { + return CredentialFormat.LdpVc; + } if ('mso_mdoc' === normalized || 'mso-mdoc' === normalized || 'mdoc' === normalized) { return CredentialFormat.Mdoc; } throw new UnprocessableEntityException(`Unsupported template format: ${dbFormat}`); } -function formatSuffix(apiFormat: CredentialFormat): 'sdjwt' | 'mdoc' { - return apiFormat === CredentialFormat.SdJwtVc ? 'sdjwt' : 'mdoc'; +function formatSuffix(apiFormat: CredentialFormat): 'sdjwt' | 'mdoc' | 'jwt-vc-json-ld' | 'ldp-vc' { + if (apiFormat === CredentialFormat.SdJwtVc) { + return 'sdjwt'; + } + if (apiFormat === CredentialFormat.JwtVcJsonLd) { + return 'jwt-vc-json-ld'; + } + if (apiFormat === CredentialFormat.LdpVc) { + return 'ldp-vc'; + } + return 'mdoc'; } export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer: GetAllCredentialOffer): string { @@ -213,7 +228,11 @@ export function validatePayloadAgainstTemplate(template: any, payload: any): { v } }; - if (CredentialFormat.SdJwtVc === template.format) { + if ( + CredentialFormat.SdJwtVc === template.format || + CredentialFormat.JwtVcJsonLd === template.format || + CredentialFormat.LdpVc === template.format + ) { validateAttributes((template.attributes as SdJwtTemplate).attributes ?? [], payload); } else if (CredentialFormat.Mdoc === template.format) { const namespaces = payload?.namespaces; @@ -456,6 +475,137 @@ function buildMdocCredential( }; } +function buildJwtVcJsonLdCredential( + credentialRequest: CredentialRequestDtoLike, + templateRecord: CredentialTemplateRecord, + signerOptions: ISignerOption[], + activeCertificateDetails?: X509CertificateRecord[] +): BuiltCredential { + const payloadCopy = { ...(credentialRequest.payload as Record) }; + + let expectedSignerMethod: SignerMethodOption; + if (templateRecord.signerOption === SignerOption.DID) { + expectedSignerMethod = SignerMethodOption.DID; + } else if ( + templateRecord.signerOption === SignerOption.X509_P256 || + templateRecord.signerOption === SignerOption.X509_ED25519 + ) { + expectedSignerMethod = SignerMethodOption.X5C; + } else { + throw new UnprocessableEntityException( + `Unknown signer option "${templateRecord.signerOption}" for template ${templateRecord.id}` + ); + } + + const templateSignerOption: ISignerOption | undefined = signerOptions?.find((x) => x.method === expectedSignerMethod); + if (!templateSignerOption) { + throw new UnprocessableEntityException( + `Signer option "${expectedSignerMethod}" is not configured for template ${templateRecord.id}` + ); + } + + if (expectedSignerMethod === SignerMethodOption.X5C && credentialRequest.validityInfo) { + if (!activeCertificateDetails?.length) { + throw new UnprocessableEntityException('Active x.509 certificate details are required for x5c signer templates.'); + } + const certificateDetail = activeCertificateDetails.find( + (x) => x.certificateBase64 === templateSignerOption.x5c?.[0] + ); + if (!certificateDetail) { + throw new UnprocessableEntityException('No active x.509 certificate matches the configured signer option.'); + } + + const validationResult = validateCredentialDatesInCertificateWindow( + credentialRequest.validityInfo, + certificateDetail + ); + if (!validationResult.isValid) { + throw new UnprocessableEntityException(`${JSON.stringify(validationResult.details)}`); + } + } + + let nbf: number | undefined; + let exp: number | undefined; + + if (credentialRequest.validityInfo) { + const credentialValidFrom = new Date(credentialRequest.validityInfo.validFrom); + const credentialValidTo = new Date(credentialRequest.validityInfo.validUntil); + const isCredentialDurationValid = credentialValidFrom <= credentialValidTo; + if (!isCredentialDurationValid) { + const errorDetails = { + credentialDurationValid: isCredentialDurationValid, + credentialValidFrom: credentialValidFrom.toISOString(), + credentialValidTo: credentialValidTo.toISOString() + }; + throw new UnprocessableEntityException(`${JSON.stringify(errorDetails)}`); + } + nbf = dateToSeconds(credentialValidFrom); + exp = dateToSeconds(credentialValidTo); + } + + const apiFormat = mapDbFormatToApiFormat(templateRecord.format); + const idSuffix = formatSuffix(apiFormat); + const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; + const vct = (templateRecord.attributes as any)?.vct; + let typeName = ''; + if ('string' === typeof vct) { + const cleanVct = vct.replace(/\/+$/, ''); + const lastSlash = cleanVct.lastIndexOf('/'); + typeName = -1 !== lastSlash ? cleanVct.substring(lastSlash + 1) : cleanVct; + } else { + typeName = templateRecord.name.replace(/\s+/g, ''); + } + + const templateContext = (templateRecord.attributes as any)?.context; + const context = Array.isArray(templateContext) ? [...templateContext] : ['https://www.w3.org/2018/credentials/v1']; + const requiredContextUrl = 'https://www.w3.org/2018/credentials/v1'; + const normalizeUrlForComparison = (value: unknown): string | null => { + if ('string' !== typeof value) { + return null; + } + try { + return new URL(value).toString(); + } catch { + return null; + } + }; + const normalizedRequiredContext = normalizeUrlForComparison(requiredContextUrl); + const hasRequiredContext = context.some( + (ctx) => null !== normalizedRequiredContext && normalizeUrlForComparison(ctx) === normalizedRequiredContext + ); + + if (!hasRequiredContext) { + context.unshift(requiredContextUrl); + } + + const wrappedPayload: Record = { + '@context': context, + type: ['VerifiableCredential', typeName], + credentialSubject: payloadCopy + }; + + if (nbf !== undefined) { + wrappedPayload.nbf = nbf; + } + if (exp !== undefined) { + wrappedPayload.exp = exp; + } + + if (credentialRequest.validityInfo) { + wrappedPayload.issuanceDate = new Date(credentialRequest.validityInfo.validFrom).toISOString(); + wrappedPayload.expirationDate = new Date(credentialRequest.validityInfo.validUntil).toISOString(); + } else { + wrappedPayload.issuanceDate = new Date().toISOString(); + } + + return { + credentialSupportedId, + signerOptions: templateSignerOption ? templateSignerOption : undefined, + format: apiFormat, + payload: wrappedPayload + }; +} + export function buildCredentialOfferPayload( dto: CreateOidcCredentialOfferDtoLike, templates: credential_templates[], @@ -489,6 +639,9 @@ export function buildCredentialOfferPayload( if (apiFormat === CredentialFormat.SdJwtVc) { return buildSdJwtCredential(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); } + if (apiFormat === CredentialFormat.JwtVcJsonLd || apiFormat === CredentialFormat.LdpVc) { + return buildJwtVcJsonLdCredential(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); + } if (apiFormat === CredentialFormat.Mdoc) { return buildMdocCredential(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); } diff --git a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts index 9a23223e3..d56d66df4 100644 --- a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -3,7 +3,13 @@ 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'; -import { Claim, MdocTemplate, SdJwtTemplate } from 'apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces'; +import { + Claim, + ClaimDisplay, + CredentialAttribute, + MdocTemplate, + SdJwtTemplate +} from 'apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces'; import { CredentialFormat } from '@credebl/enum/enum'; type AttributeDisplay = { name: string; locale: string }; @@ -30,13 +36,6 @@ type Appearance = { display: CredentialDisplayItem[]; }; -// type Claim = { -// mandatory?: boolean; -// // value_type: string; -// path: string[]; -// display?: AttributeDisplay[]; -// }; - type CredentialConfig = { format: string; vct?: string; @@ -45,6 +44,8 @@ type CredentialConfig = { credential_signing_alg_values_supported: string[] | number[]; cryptographic_binding_methods_supported: string[]; + proof_types_supported?: Record; + credential_definition?: Record; credential_metadata: { claims: Claim[]; @@ -56,110 +57,57 @@ type CredentialConfigurationsSupported = { credentialConfigurationsSupported: Record; }; -// ---- Static Lists (as requested) ---- -const STATIC_CREDENTIAL_ALGS_FOR_SDJWT = ['ES256', 'EdDSA'] as const; -const STATIC_CREDENTIAL_ALGS_FOR_MDOC = [-7, -9] as const; -const STATIC_BINDING_METHODS_FOR_SDJWT = ['did:key', 'did:web', 'did:jwk', 'jwk'] as const; -const STATIC_BINDING_METHODS_FOR_MDOC = ['cose_key'] as const; // We need to test 'did:key', 'did:web', 'did:jwk', 'jwk', +const ISSUER_DPOP_ALGS_DEFAULT = ['RS256', 'ES256']; -// 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; -// }; - -// Prisma row shape -//TODO: Fix this eslint issue -// eslint-disable-next-line @typescript-eslint/no-unused-vars -type TemplateRowPrisma = { - id: string; - name: string; - description?: string | null; - format?: string | null; - canBeRevoked?: boolean | null; - attributes: SdJwtTemplate | MdocTemplate; // JsonValue from DB - appearance: Prisma.JsonValue; // JsonValue from DB - issuerId: string; - createdAt?: Date | string; - updatedAt?: Date | string; -}; +const STATIC_CREDENTIAL_ALGS_FOR_SDJWT = ['ES256']; +const STATIC_BINDING_METHODS_FOR_SDJWT = ['did:key', 'did:web', 'did:jwk', 'jwk']; -// Default DPoP list for issuer-level metadata (match your example) -const ISSUER_DPOP_ALGS_DEFAULT = ['RS256', 'ES256'] as const; +const STATIC_CREDENTIAL_ALGS_FOR_MDOC = ['ES256']; +const STATIC_BINDING_METHODS_FOR_MDOC = ['jwk']; -// ---------- 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; +/** + * Checks if a value is an array of DisplayItem. + * (Simple runtime check, could be more elaborate) + */ +function isDisplayArray(val: unknown): val is DisplayItem[] { + return Array.isArray(val); } type DisplayItem = { - name: string; + name?: string; locale?: string; - description?: string; - logo?: { uri: string; alt_text?: 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 - ) - ); +/** + * Safely coerces a Prisma.JsonValue (from oidc_issuer.metadata) into a JS object. + */ +function coerceJson(val: Prisma.JsonValue): T { + if (null === val || undefined === val) { + return {} as T; + } + if ('string' === typeof val) { + try { + return JSON.parse(val) as T; + } catch { + return {} as T; + } + } + return val as T; } -// ---------- Builder you asked for ---------- /** - * Build issuer metadata payload from issuer row + credential configurations. + * Builder #1: Issuer Metadata Payload + * + * Builds the root OIDC Issuer metadata object (including "display", "dpop...", etc.). * - * @param credentialConfigurations Object with credentialConfigurationsSupported (from your template builder) - * @param oidcIssuer OID4VC issuer row (uses publicIssuerId and metadata -> display) + * @param credentialConfigurations The "credential_configurations_supported" block built by Builder #2. + * @param oidcIssuer The Prisma row for the OIDC Issuer. * @param opts Optional overrides: dpopAlgs[], accessTokenSignerKeyType */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function buildIssuerPayload( credentialConfigurations: CredentialConfigurationsSupported, oidcIssuer: oidc_issuer, @@ -167,7 +115,7 @@ export function buildIssuerPayload( dpopAlgs?: string[]; accessTokenSignerKeyType?: string; } -) { +): Record { if (!oidcIssuer?.publicIssuerId || 'string' !== typeof oidcIssuer.publicIssuerId) { throw new Error('Invalid issuer: missing publicIssuerId'); } @@ -175,14 +123,21 @@ export function buildIssuerPayload( const rawDisplay = coerceJson(oidcIssuer.metadata); const display: DisplayItem[] = isDisplayArray(rawDisplay) ? rawDisplay : []; - return { + const batchSize = oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault; + + const payload: Record = { display, dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], - credentialConfigurationsSupported: credentialConfigurations.credentialConfigurationsSupported ?? {}, - batchCredentialIssuance: { - batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault - } + credentialConfigurationsSupported: credentialConfigurations.credentialConfigurationsSupported ?? {} }; + + if (0 < batchSize) { + payload.batchCredentialIssuance = { + batchSize + }; + } + + return payload; } export function extractTemplateIds(offer: CreateOidcCredentialOffer): string[] { @@ -211,7 +166,7 @@ export function encodeIssuerPublicId(publicIssuerId: string): string { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -function generateClaims(attributes: any[], namespace?: string, parentPath: string[] = []): Claim[] { +function generateClaims(attributes: CredentialAttribute[], namespace?: string, parentPath: string[] = []): Claim[] { const result: Claim[] = []; for (const attr of attributes) { @@ -222,7 +177,7 @@ function generateClaims(attributes: any[], namespace?: string, parentPath: strin const claim: Claim = { path }; if (attr.display) { - claim.display = attr.display; + claim.display = attr.display as ClaimDisplay[]; } if (true === attr.mandatory) { @@ -266,9 +221,8 @@ function buildClaimsFromTemplate(template: SdJwtTemplate | MdocTemplate): Claim[ return claims; } -//TODO: Fix this eslint issue // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function buildSdJwtCredentialConfig(name: string, template: SdJwtTemplate) { +export function buildSdJwtCredentialConfig(name: string, template: SdJwtTemplate): Record { const formatSuffix = 'sdjwt'; // Determine the unique key for this credential configuration @@ -298,7 +252,7 @@ export function buildSdJwtCredentialConfig(name: string, template: SdJwtTemplate } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function buildMdocCredentialConfig(name: string, template: MdocTemplate) { +export function buildMdocCredentialConfig(name: string, template: MdocTemplate): Record { //const claims: Claim[] = []; const formatSuffix = 'mdoc'; @@ -332,12 +286,104 @@ export function buildMdocCredentialConfig(name: string, template: MdocTemplate) }; } +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function buildJwtVcJsonLdCredentialConfig( + name: string, + template: SdJwtTemplate +): Record { + const formatSuffix = 'jwt-vc-json-ld'; + + // Determine the unique key for this credential configuration + const configKey = `${name}-${formatSuffix}`; + const credentialScope = `openid4vc:${template.vct}-${formatSuffix}`; + + const claims = buildClaimsFromTemplate(template); + + const { vct } = template; + let typeName = name ? name.replace(/[^a-zA-Z0-9]/g, '') : 'GenericCredential'; + if ('string' === typeof vct && '' !== vct.trim()) { + const lastSlash = vct.lastIndexOf('/'); + typeName = -1 !== lastSlash ? vct.substring(lastSlash + 1) : vct; + } + + return { + [configKey]: { + format: CredentialFormat.JwtVcJsonLd, + scope: credentialScope, + vct: template.vct, + credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS_FOR_SDJWT], + cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS_FOR_SDJWT], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: ['ES256', 'EdDSA'] + } + }, + credential_definition: { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential', typeName] + }, + credential_metadata: { + claims, + display: [] + } + } + }; +} + +export function buildLdpVcCredentialConfig(name: string, template: SdJwtTemplate): Record { + const formatSuffix = 'ldp-vc'; + + // Determine the unique key for this credential configuration + const configKey = `${name}-${formatSuffix}`; + const credentialScope = `openid4vc:${template.vct}-${formatSuffix}`; + + const claims = buildClaimsFromTemplate(template); + + const { vct } = template; + let typeName = name ? name.replace(/[^a-zA-Z0-9]/g, '') : 'GenericCredential'; + if ('string' === typeof vct && '' !== vct.trim()) { + const lastSlash = vct.lastIndexOf('/'); + typeName = -1 !== lastSlash ? vct.substring(lastSlash + 1) : vct; + } + + return { + [configKey]: { + format: CredentialFormat.LdpVc, + scope: credentialScope, + vct: template.vct, + credential_signing_alg_values_supported: ['Ed25519Signature2018', 'Ed25519Signature2020', 'EdDSA'], + cryptographic_binding_methods_supported: ['did:key'], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: ['Ed25519Signature2018', 'Ed25519Signature2020', 'EdDSA'] + } + }, + credential_definition: { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential', typeName] + }, + credential_metadata: { + claims, + display: [] + } + } + }; +} + //TODO: Fix this eslint issue // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function buildCredentialConfig(name: string, template: SdJwtTemplate | MdocTemplate, format: CredentialFormat) { +export function buildCredentialConfig( + name: string, + template: SdJwtTemplate | MdocTemplate, + format: CredentialFormat +): Record { switch (format) { case CredentialFormat.SdJwtVc: return buildSdJwtCredentialConfig(name, template as SdJwtTemplate); + case CredentialFormat.JwtVcJsonLd: + return buildJwtVcJsonLdCredentialConfig(name, template as SdJwtTemplate); + case CredentialFormat.LdpVc: + return buildLdpVcCredentialConfig(name, template as SdJwtTemplate); case CredentialFormat.Mdoc: return buildMdocCredentialConfig(name, template as MdocTemplate); default: @@ -349,39 +395,33 @@ export function buildCredentialConfig(name: string, template: SdJwtTemplate | Md * Build agent payload from Prisma rows (attributes/appearance are Prisma.JsonValue). * Safely coerces JSON and then builds the same structure as Builder #2. */ -//TODO: Fix this eslint issue -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function buildCredentialConfigurationsSupported(templateRows: any): Record { +export function buildCredentialConfigurationsSupported(templateRows: unknown[]): Record { const credentialConfigMap: Record = {}; - for (const templateRow of templateRows) { - const { format } = templateRow; - const templateToBuild = templateRow.attributes; + for (const templateRow of templateRows as Record[]) { + const format = templateRow.format as string; + const templateToBuild = templateRow.attributes as SdJwtTemplate | MdocTemplate; const credentialConfig = buildCredentialConfig( - templateRow.name, + templateRow.name as string, templateToBuild, - format === CredentialFormat.Mdoc ? CredentialFormat.Mdoc : CredentialFormat.SdJwtVc + format === CredentialFormat.Mdoc + ? CredentialFormat.Mdoc + : format === CredentialFormat.JwtVcJsonLd + ? CredentialFormat.JwtVcJsonLd + : format === CredentialFormat.LdpVc + ? CredentialFormat.LdpVc + : CredentialFormat.SdJwtVc ); - const appearanceJson = coerceJsonObject(templateRow.appearance); - // Prepare the display configuration - const displayConfigurations = - (appearanceJson as Appearance).display?.map((displayEntry) => ({ + const appearance = coerceJson(templateRow.appearance as Prisma.JsonValue); + const displayConfigurations: CredentialDisplayItem[] = + appearance.display?.map((displayEntry) => ({ name: displayEntry.name, - description: displayEntry.description, locale: displayEntry.locale, - logo: displayEntry?.logo - ? { - uri: displayEntry.logo.uri, - alt_text: displayEntry.logo.alt_text - } - : undefined, - background_image: displayEntry?.background_image?.uri - ? { - uri: displayEntry.background_image.uri - } - : undefined, + logo: displayEntry.logo, + description: displayEntry.description, + background_image: displayEntry.background_image, background_color: displayEntry.background_color, text_color: displayEntry.text_color })) ?? []; diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 859373257..1ba61e870 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -40,8 +40,11 @@ import { accessTokenSignerKeyType, batchCredentialIssuanceDefault, credentialConfigurationsSupported, - dpopSigningAlgValuesSupported + dpopSigningAlgValuesSupported, + CREDENTIALS_CONTEXT_V1_URL, + CREDENTIALS_CONTEXT_V2_URL } from '../constant/issuance'; +import { uuidRegex } from '@credebl/common/common.constant'; import { buildCredentialConfigurationsSupported, buildIssuerPayload, @@ -86,6 +89,87 @@ export class Oid4vcIssuanceService { private readonly statusListAllocatorService: StatusListAllocatorService ) {} + private async validateTemplateAgainstSchema( + format: string | CredentialFormat, + template: any, + orgId: string + ): Promise { + if ((format === CredentialFormat.JwtVcJsonLd || format === CredentialFormat.LdpVc) && template) { + let internalSchemaId: string | undefined; + + if ('schemaUrl' in template && template.schemaUrl) { + let isInternal = false; + try { + const schemaUrlObj = new URL(template.schemaUrl); + const serverUrlStr = process.env.SCHEMA_FILE_SERVER_URL; + if (serverUrlStr) { + const serverUrlObj = new URL(serverUrlStr); + if (schemaUrlObj.hostname === serverUrlObj.hostname) { + isInternal = true; + } + } + } catch (error) { + if (process.env.SCHEMA_FILE_SERVER_URL && template.schemaUrl.startsWith(process.env.SCHEMA_FILE_SERVER_URL)) { + isInternal = true; + } + } + + if (isInternal) { + const parts = template.schemaUrl.split('/'); + const potentialUuid = parts[parts.length - 1]; + if (uuidRegex.test(potentialUuid)) { + internalSchemaId = potentialUuid; + } + } + } + + if (internalSchemaId) { + let schemaResponse; + try { + schemaResponse = await this.issuanceServiceProxy + .send({ cmd: 'get-schema-by-id' }, { schemaId: internalSchemaId, orgId }) + .toPromise(); + } catch (error) { + this.logger.error(`Error fetching schema ${internalSchemaId} from ledger: ${error.message}`); + throw new NotFoundException(`Schema with ID ${internalSchemaId} not found or unreachable: ${error.message}`); + } + + if (schemaResponse && schemaResponse.attributes) { + const schemaAttributes = + 'string' === typeof schemaResponse.attributes + ? JSON.parse(schemaResponse.attributes) + : schemaResponse.attributes; + const schemaAttributeNames = schemaAttributes.map((attr: any) => attr.attributeName); + const templateAttributes = template.attributes; + + const missingAttributes: string[] = []; + const checkAttributes = (attrs: any[]) => { + if (!attrs) { + return; + } + for (const attr of attrs) { + if (!schemaAttributeNames.includes(attr.key)) { + missingAttributes.push(attr.key); + } + if (attr.children) { + checkAttributes(attr.children); + } + } + }; + checkAttributes(templateAttributes); + + if (0 < missingAttributes.length) { + throw new BadRequestException( + `Attributes [ '${missingAttributes.join("', '")}' ] are not defined in the referenced schema.` + ); + } + } + } else { + this.logger.log('No internal schema reference found; skipping strict attribute validation.'); + } + } + } + async oidcIssuerCreate(issuerCreation: IssuerCreation, orgId: string, userDetails: user): Promise { try { const { issuerId, batchCredentialIssuanceSize } = issuerCreation; @@ -322,6 +406,16 @@ export class Oid4vcIssuanceService { //TODO: add revert mechanism if agent call fails const { name, description, format, canBeRevoked, appearance, signerOption, noticeUrl } = credentialTemplate; + if ( + (format === CredentialFormat.JwtVcJsonLd || format === CredentialFormat.LdpVc) && + 'schemaUrl' in credentialTemplate.template && + credentialTemplate.template.schemaUrl + ) { + credentialTemplate.template.context = [CREDENTIALS_CONTEXT_V1_URL, credentialTemplate.template.schemaUrl]; + } + + await this.validateTemplateAgainstSchema(format, credentialTemplate.template, orgId); + const checkNameExist = await this.oid4vcIssuanceRepository.getTemplateByNameForIssuer(name, issuerId); if (0 < checkNameExist.length) { throw new ConflictException(ResponseMessages.oidcTemplate.error.templateNameAlreadyExist); @@ -406,6 +500,21 @@ export class Oid4vcIssuanceService { ...(issuerId ? { issuerId } : {}) }; const { name, description, format, canBeRevoked, appearance, signerOption, noticeUrl } = normalized; + + if ( + normalized.template && + ((format || template.format) === CredentialFormat.JwtVcJsonLd || + (format || template.format) === CredentialFormat.LdpVc) && + 'schemaUrl' in normalized.template && + normalized.template.schemaUrl + ) { + normalized.template.context = [CREDENTIALS_CONTEXT_V1_URL, normalized.template.schemaUrl]; + } + + if (normalized.template && (format || template.format)) { + await this.validateTemplateAgainstSchema(format || template.format, normalized.template, orgId); + } + const attributes = instanceToPlain(normalized.template); const payload = { diff --git a/libs/common/src/common.utils.ts b/libs/common/src/common.utils.ts index 98f92aeac..03e646738 100644 --- a/libs/common/src/common.utils.ts +++ b/libs/common/src/common.utils.ts @@ -25,9 +25,11 @@ export function paginator(items: T[], current_page: number, items_per_page: n }; } -export function orderValues(key, order = 'asc') { - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types - return function innerSort(a, b) { +export function orderValues( + key: string, + order = 'asc' +): (a: Record, b: Record) => number { + return function innerSort(a: Record, b: Record): number { if (!a.hasOwnProperty(key) || !b.hasOwnProperty(key)) { // property doesn't exist on either object return 0; diff --git a/libs/enum/src/enum.ts b/libs/enum/src/enum.ts index e35b8c1bd..e0ec32df2 100755 --- a/libs/enum/src/enum.ts +++ b/libs/enum/src/enum.ts @@ -372,7 +372,8 @@ export enum X509ExtendedKeyUsage { export enum CredentialFormat { SdJwtVc = 'dc+sd-jwt', - Mdoc = 'mso_mdoc' + Mdoc = 'mso_mdoc', + JwtVcJsonLd = 'jwt_vc_json-ld' } export enum AttributeType {