From e5b12d49cd007687986966e349ed0b3f5e75dc56 Mon Sep 17 00:00:00 2001 From: Krishna Waske Date: Wed, 22 Oct 2025 16:01:33 +0530 Subject: [PATCH 01/13] namespace changes Signed-off-by: Krishna Waske --- .../agent-service/dto/create-schema.dto.ts | 43 +++++++------- .../dtos/oid4vc-issuer-template.dto.ts | 12 +++- .../oid4vc-issuance/dtos/oid4vc-issuer.dto.ts | 50 ++++++++-------- .../interfaces/oid4vc-issuance.interfaces.ts | 10 ++-- .../libs/helpers/issuer.metadata.ts | 59 ++++++++----------- 5 files changed, 91 insertions(+), 83 deletions(-) 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/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts index 62800a31d..c4cf5c7f5 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 @@ -22,10 +22,20 @@ export class CredentialAttributeDto { @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 }) @@ -163,7 +173,7 @@ export class CreateCredentialTemplateDto { description: 'Attributes included in the credential template' }) @IsObject() - attributes: Record; + attributes: CredentialAttributeDto[]; @ApiProperty({ type: Object, 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 index b9e67f423..fee4af086 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts @@ -1,26 +1,27 @@ /* eslint-disable camelcase */ -import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; +import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, - IsObject, IsUrl, IsNotEmpty, IsDefined, IsInt } from 'class-validator'; -import { plainToInstance, Transform, Type } from 'class-transformer'; +import { Type } from 'class-transformer'; export class ClaimDto { @ApiProperty({ - description: 'The unique key for the claim (e.g. email, name)', - example: 'email' + description: 'The path for nested claims', + example: ['address', 'street_number'], + type: [String] }) - @IsString() - key: string; + @Type(() => String) + @IsArray() + path: string[]; @ApiProperty({ description: 'The display label for the claim', @@ -85,6 +86,7 @@ export class DisplayDto { logo?: LogoDto; } +// TODO: Check where it is used, coz no reference found @ApiExtraModels(ClaimDto) export class CredentialConfigurationDto { @ApiProperty({ @@ -110,25 +112,25 @@ export class CredentialConfigurationDto { @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) } + description: 'List of claims supported in this credential', + type: [ClaimDto] }) - @IsObject() + @IsArray() @ValidateNested({ each: true }) - @Transform(({ value }) => - Object.fromEntries(Object.entries(value || {}).map(([k, v]) => [k, plainToInstance(ClaimDto, v)])) - ) - claims: Record; + @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() @@ -217,7 +219,7 @@ export enum AccessTokenSignerKeyType { ED25519 = 'ed25519' } -@ApiExtraModels(CredentialConfigurationDto) +// @ApiExtraModels(CredentialConfigurationDto) export class IssuerCreationDto { @ApiProperty({ description: 'Name of the issuer', diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts index 1c55048af..04ff5ff3e 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts @@ -18,9 +18,9 @@ export interface OrgAgent { } export interface Claim { - key: string; - label: string; - required: boolean; + path: string[]; + label?: string; + required?: boolean; } export interface Logo { @@ -40,7 +40,7 @@ export interface CredentialConfiguration { vct?: string; doctype?: string; scope: string; - claims: Record; + claims: Claim[]; credential_signing_alg_values_supported: string[]; cryptographic_binding_methods_supported: string[]; display: Display[]; @@ -61,7 +61,7 @@ export interface IssuerCreation { accessTokenSignerKeyType?: AccessTokenSignerKeyType; display: Display[]; dpopSigningAlgValuesSupported?: string[]; - credentialConfigurationsSupported?: Record; + // credentialConfigurationsSupported?: Record; // Not used authorizationServerConfigs: AuthorizationServerConfig; batchCredentialIssuanceSize: number; } diff --git a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts index 4f6bc6047..d2e126026 100644 --- a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -22,27 +22,25 @@ 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: Record< - string, - { - mandatory?: boolean; - value_type: string; - display?: AttributeDisplay[]; - } - >; + claims: Claim[]; credential_signing_alg_values_supported: string[]; cryptographic_binding_methods_supported: string[]; display: { name: string; description?: string; locale?: string }[]; }; -type CredentialConfigurationsSupported = { - credentialConfigurationsSupported: Record; -}; +type CredentialConfigurationsSupported = CredentialConfig[]; // ---- Static Lists (as requested) ---- const STATIC_CREDENTIAL_ALGS = ['ES256', 'EdDSA'] as const; @@ -101,8 +99,7 @@ export function buildCredentialConfigurationsSupported( } ): CredentialConfigurationsSupported { const defaultFormat = opts?.format ?? 'vc+sd-jwt'; - const credentialConfigurationsSupported: Record = {}; - + const credentialConfigurationsSupported: CredentialConfigurationsSupported = []; for (const t of templates) { const attrs = coerceJsonObject(t.attributes); const app = coerceJsonObject(t.appearance); @@ -120,8 +117,8 @@ export function buildCredentialConfigurationsSupported( const isMdoc = 'mso_mdoc' === rowFormat; const suffix = isMdoc ? 'mdoc' : 'sdjwt'; - // key (allow override) - const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; + // key: keep your keyResolver override; otherwise include suffix + // const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; // Resolve doctype/vct: // - For mdoc: try opts.doctype -> t.doctype -> fallback to t.name (or throw if you prefer) @@ -143,20 +140,15 @@ export function buildCredentialConfigurationsSupported( // Choose scope base: prefer opts.scopeVct, otherwise for mdoc use doctype, else vct const scopeBase = opts?.scopeVct ?? (isMdoc ? rowDoctype : rowVct); const scope = `openid4vc:credential:${scopeBase}-${suffix}`; - - const claims = Object.fromEntries( - Object.entries(attrs).map(([claimName, def]) => { - const d = def as AttributeDef; - return [ - claimName, - { - value_type: d.value_type, - mandatory: d.mandatory ?? false, - display: Array.isArray(d.display) ? d.display.map((x) => ({ name: x.name, locale: x.locale })) : undefined - } - ]; - }) - ); + 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) => ({ @@ -165,7 +157,8 @@ export function buildCredentialConfigurationsSupported( locale: d.locale })) ?? []; - credentialConfigurationsSupported[key] = { + // assemble per-template config + credentialConfigurationsSupported.push({ format: rowFormat, scope, claims, @@ -173,10 +166,10 @@ export function buildCredentialConfigurationsSupported( cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], display, ...(isMdoc ? { doctype: rowDoctype as string } : { vct: rowVct }) - }; + }); } - return { credentialConfigurationsSupported }; + return credentialConfigurationsSupported; } // Default DPoP list for issuer-level metadata (match your example) @@ -241,7 +234,7 @@ export function buildIssuerPayload( return { display, dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], - credentialConfigurationsSupported: credentialConfigurations.credentialConfigurationsSupported ?? {}, + credentialConfigurationsSupported: credentialConfigurations ?? [], batchCredentialIssuance: { batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault } From 994203230912bcebe0e4804002ef75f01afc7c64 Mon Sep 17 00:00:00 2001 From: Rinkal Bhojani Date: Tue, 21 Oct 2025 17:55:19 +0530 Subject: [PATCH 02/13] added x509 cert for mdoc Signed-off-by: Rinkal Bhojani --- .../src/oid4vc-issuance.repository.ts | 3 +++ .../src/oid4vc-issuance.service.ts | 17 ++++++++++++++++- libs/enum/src/enum.ts | 6 ++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts index d6906f9a3..f4d9b829b 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts @@ -121,6 +121,9 @@ export class Oid4vcIssuanceRepository { orgId } }, + // include: { + // templates: true + // }, orderBy: { createDateTime: 'desc' } diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index ccb7cedb8..4aa65ee7f 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -223,7 +223,8 @@ export class Oid4vcIssuanceService { } } - async oidcIssuers(orgId: string): Promise { + async oidcIssuers(orgId: string): Promise { + //Promise { try { const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails?.agentEndPoint) { @@ -231,6 +232,20 @@ export class Oid4vcIssuanceService { } const getIssuers = await this.oid4vcIssuanceRepository.getAllOidcIssuersByOrg(orgId); + // 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; return getIssuers; } catch (error: any) { const msg = error?.message ?? 'unknown error'; diff --git a/libs/enum/src/enum.ts b/libs/enum/src/enum.ts index 0a52130df..043ca4e1f 100644 --- a/libs/enum/src/enum.ts +++ b/libs/enum/src/enum.ts @@ -322,3 +322,9 @@ export enum CredentialFormat { SdJwtVc = 'vc+sd-jwt', Mdoc = 'mso_mdoc' } + +// export enum SignerOption { +// DID, +// X509_P256, +// X509_ED25519 +// } From 69b66857e3c6af141a0ec0a1a59a6f121d45ef26 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Sun, 26 Oct 2025 15:44:28 +0530 Subject: [PATCH 03/13] feat: seperate mdoc builder method Signed-off-by: Tipu_Singh --- .../dtos/issuer-sessions.dto.ts | 25 +- .../oid4vc-issuance/dtos/oid4vc-issuer.dto.ts | 4 + .../oid4vc-issuance.controller.ts | 2 +- .../interfaces/oid4vc-issuance.interfaces.ts | 3 +- .../credential-sessions-mdoc.builder.ts | 449 ++++++++++++++++++ .../libs/helpers/issuer.metadata.ts | 30 +- .../src/oid4vc-issuance.repository.ts | 6 +- .../src/oid4vc-issuance.service.ts | 4 +- .../prisma/data/credebl-master-table.json | 10 +- .../migration.sql | 8 + libs/prisma-service/prisma/schema.prisma | 1 + 11 files changed, 508 insertions(+), 34 deletions(-) create mode 100644 apps/oid4vc-issuance/libs/helpers/credential-sessions-mdoc.builder.ts create mode 100644 libs/prisma-service/prisma/migrations/20251023120134_added_authorization_server_url/migration.sql 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 77c88b075..8d9630964 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 @@ -157,25 +157,16 @@ export class CreateOidcCredentialOfferDto { @Type(() => CredentialRequestDto) credentials!: CredentialRequestDto[]; - // XOR: exactly one present - @ApiPropertyOptional({ type: PreAuthorizedCodeFlowConfigDto }) - @IsOptional() - @ValidateNested() - @Type(() => PreAuthorizedCodeFlowConfigDto) - preAuthorizedCodeFlowConfig?: PreAuthorizedCodeFlowConfigDto; - - @IsOptional() - @ValidateNested() - @Type(() => AuthorizationCodeFlowConfigDto) - authorizationCodeFlowConfig?: AuthorizationCodeFlowConfigDto; + @ApiProperty({ + example: 'preAuthorizedCodeFlow', + enum: ['preAuthorizedCodeFlow', 'authorizationCodeFlow'], + description: 'Authorization type' + }) + @IsString() + @IsIn(['preAuthorizedCodeFlow', 'authorizationCodeFlow']) + authorizationType!: 'preAuthorizedCodeFlow' | 'authorizationCodeFlow'; issuerId?: string; - - // host XOR rule - @ExactlyOneOf(['preAuthorizedCodeFlowConfig', 'authorizationCodeFlowConfig'], { - message: 'Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.' - }) - private readonly _exactlyOne?: unknown; } export class GetAllCredentialOfferDto { 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 index fee4af086..c52ad5136 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts @@ -246,6 +246,10 @@ export class IssuerCreationDto { @Type(() => DisplayDto) display: DisplayDto[]; + @ApiProperty({ example: 'https://auth.example.org', description: 'Authorization URL' }) + @IsUrl({ require_tld: false }) + authorizationServerUrl: string; + @ApiProperty({ description: 'Configuration of the authorization server', type: AuthorizationServerConfigDto diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts index 70a86bcbf..66b050146 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts @@ -437,7 +437,7 @@ export class Oid4vcIssuanceController { @Post('/orgs/:orgId/oid4vc/:issuerId/create-offer') @ApiOperation({ summary: 'Create OID4VC Credential Offer', - description: 'Creates a new OIDC4VCI credential-offer for a given issuer.' + description: 'Creates a new OID4VC credential-offer for a given issuer.' }) @ApiResponse({ status: HttpStatus.CREATED, description: 'Credential offer created successfully.' }) @ApiBearerAuth() diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts index 04ff5ff3e..9ecefedfb 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts @@ -57,11 +57,11 @@ export interface AuthorizationServerConfig { } export interface IssuerCreation { + authorizationServerUrl: string; issuerId: string; accessTokenSignerKeyType?: AccessTokenSignerKeyType; display: Display[]; dpopSigningAlgValuesSupported?: string[]; - // credentialConfigurationsSupported?: Record; // Not used authorizationServerConfigs: AuthorizationServerConfig; batchCredentialIssuanceSize: number; } @@ -79,6 +79,7 @@ export interface IssuerInitialConfig { } export interface IssuerMetadata { + authorizationServerUrl: string; publicIssuerId: string; createdById: string; orgAgentId: string; diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions-mdoc.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions-mdoc.builder.ts new file mode 100644 index 000000000..7d892a89f --- /dev/null +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions-mdoc.builder.ts @@ -0,0 +1,449 @@ +/* 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 { Prisma, credential_templates } from '@prisma/client'; +import { GetAllCredentialOffer, SignerOption } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; +import { CredentialFormat } from '@credebl/enum/enum'; +/* ============================================================================ + Domain Types +============================================================================ */ + +type ValueType = 'string' | 'date' | 'number' | 'boolean' | 'integer' | string; + +interface TemplateAttribute { + display: { name: string; locale: string }[]; + mandatory: boolean; + value_type: ValueType; +} +type TemplateAttributes = Record; + +export enum SignerMethodOption { + DID = 'did', + X5C = 'x5c' +} + +export type DisclosureFrame = Record>; + +export interface CredentialRequestDtoLike { + templateId: string; + payload: Record; + 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 = (value: unknown): value is null | undefined => null == value; +const isEmptyString = (value: unknown): boolean => 'string' === typeof value && '' === value.trim(); +const isPlainRecord = (value: unknown): value is Record => + Boolean(value) && 'object' === typeof value && !Array.isArray(value); + +/** Map DB format string -> API enum */ +function mapDbFormatToApiFormat(dbFormat: string): CredentialFormat { + if ('sd-jwt' === dbFormat || 'vc+sd-jwt' === dbFormat || 'sdjwt' === dbFormat || 'sd+jwt-vc' === dbFormat) { + return CredentialFormat.SdJwtVc; + } + if ('mso_mdoc' === dbFormat) { + return CredentialFormat.Mdoc; + } + throw new Error(`Unsupported template format: ${dbFormat}`); +} + +/** Map API enum -> id suffix required for credentialSupportedId */ +function formatSuffix(apiFormat: CredentialFormat): 'sdjwt' | 'mdoc' { + return apiFormat === 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, + context: { templateId: string } +): void { + const missingClaims: string[] = []; + for (const [claimName, attributeDefinition] of Object.entries(attributes)) { + if (!attributeDefinition?.mandatory) { + continue; + } + const claimValue = payload[claimName]; + if (isNil(claimValue) || isEmptyString(claimValue)) { + missingClaims.push(claimName); + } + } + if (missingClaims.length) { + throw new Error(`Missing mandatory claims for template "${context.templateId}": ${missingClaims.join(', ')}`); + } +} + +/* ============================================================================ + JsonValue → TemplateAttributes Narrowing (Type Guards) +============================================================================ */ + +function isDisplayArray(value: unknown): value is { name: string; locale: string }[] { + return ( + Array.isArray(value) && + value.every( + (entry) => + isPlainRecord(entry) && 'string' === typeof (entry as any).name && 'string' === typeof (entry as any).locale + ) + ); +} + +/* ============================================================================ + Improved ensureTemplateAttributes: runtime assert with helpful errors +============================================================================ */ + +const ALLOWED_VALUE_TYPES: ValueType[] = ['string', 'date', 'number', 'boolean', 'integer']; + +function ensureTemplateAttributes(jsonValue: Prisma.JsonValue): TemplateAttributes { + if (!isPlainRecord(jsonValue)) { + throw new Error( + `Invalid template.attributes: expected an object map but received ${ + null === jsonValue ? 'null' : typeof jsonValue + }.\n\nFix: provide an object whose keys are attribute names and whose values are attribute definitions, e.g.\n{\n "given_name": { "mandatory": true, "value_type": "string" }\n}` + ); + } + + const attributesMap = jsonValue as Record; + const attributeKeys = Object.keys(attributesMap); + if (0 === attributeKeys.length) { + throw new Error( + 'Invalid template.attributes: object is empty (no attributes defined).\n\nFix: add at least one attribute definition, for example:\n{\n "given_name": { "mandatory": true, "value_type": "string" }\n}' + ); + } + + const problems: string[] = []; + const suggestedFixes: string[] = []; + + for (const attributeKey of attributeKeys) { + const rawAttributeDef = attributesMap[attributeKey]; + + if (!isPlainRecord(rawAttributeDef)) { + problems.push( + `${attributeKey}: expected an object but got ${null === rawAttributeDef ? 'null' : typeof rawAttributeDef}` + ); + suggestedFixes.push( + `Replace attribute "${attributeKey}" value with an object, e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` + ); + continue; + } + + // mandatory checks + if (!('mandatory' in rawAttributeDef)) { + problems.push(`${attributeKey}.mandatory: missing`); + suggestedFixes.push( + `Add mandatory boolean for "${attributeKey}":\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` + ); + } else if ('boolean' !== typeof (rawAttributeDef as any).mandatory) { + problems.push(`${attributeKey}.mandatory: expected boolean but got ${typeof (rawAttributeDef as any).mandatory}`); + suggestedFixes.push( + `Set "mandatory" to a boolean for "${attributeKey}", e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` + ); + } + + // value_type checks + if (!('value_type' in rawAttributeDef)) { + problems.push(`${attributeKey}.value_type: missing`); + suggestedFixes.push( + `Add value_type for "${attributeKey}", for example:\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` + ); + } else if ('string' !== typeof (rawAttributeDef as any).value_type) { + problems.push( + `${attributeKey}.value_type: expected string but got ${typeof (rawAttributeDef as any).value_type}` + ); + suggestedFixes.push( + `Make sure "value_type" is a string for "${attributeKey}", e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` + ); + } else { + const declaredType = (rawAttributeDef as any).value_type as string; + if (!ALLOWED_VALUE_TYPES.includes(declaredType as ValueType)) { + problems.push( + `${attributeKey}.value_type: unsupported value_type "${declaredType}". Allowed types: ${ALLOWED_VALUE_TYPES.join(', ')}` + ); + suggestedFixes.push( + `Use one of the allowed types for "${attributeKey}", e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` + ); + } + } + + // display checks (optional) + if ('display' in rawAttributeDef && !isDisplayArray((rawAttributeDef as any).display)) { + problems.push(`${attributeKey}.display: expected array of { name: string, locale: string }`); + suggestedFixes.push( + `Fix "display" for "${attributeKey}" to be an array of objects with name/locale, e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string", "display": [{ "name": "Given Name", "locale": "en-US" }] }` + ); + } + } + + if (0 < problems.length) { + // Build a user-friendly message: problems + suggested fixes (unique) + const uniqueFixes = Array.from(new Set(suggestedFixes)).slice(0, 20); + const fixesText = uniqueFixes.length + ? `\n\nSuggested fixes (copy-paste examples):\n- ${uniqueFixes.join('\n- ')}` + : ''; + + // Include a small truncated sample of the attributes to help debugging + const samplePreview = JSON.stringify( + Object.fromEntries(attributeKeys.slice(0, 10).map((key) => [key, attributesMap[key]])), + (_, value) => { + if ('string' === typeof value && 200 < value.length) { + return `${value.slice(0, 200)}...`; + } + return value; + }, + 2 + ); + + throw new Error( + `Invalid template.attributes shape. Problems found:\n- ${problems.join( + '\n- ' + )}\n\nExample attributes (truncated):\n${samplePreview}${fixesText}` + ); + } + + // Safe to cast to TemplateAttributes + return attributesMap as TemplateAttributes; +} + +/* ============================================================================ + Builders +============================================================================ */ + +/** Build one credential block normalized to API format (using the template's format). */ +function buildOneCredential( + credentialRequest: CredentialRequestDtoLike, + templateRecord: credential_templates, + templateAttributes: TemplateAttributes, + signerOptions?: SignerOption[] +): BuiltCredential { + // 1) Validate payload against template attributes + assertMandatoryClaims(credentialRequest.payload, templateAttributes, { templateId: credentialRequest.templateId }); + + // 2) Decide API format from DB format + const selectedApiFormat = mapDbFormatToApiFormat(templateRecord.format); + + // 3) Build supportedId from template.name + suffix ("-sdjwt" | "-mdoc") + const idSuffix = formatSuffix(selectedApiFormat); + const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; + + // 4) Strip vct ALWAYS (per requirement) + const normalizedPayload = { ...(credentialRequest.payload as Record) }; + delete (normalizedPayload as Record).vct; + + return { + credentialSupportedId, // e.g., "BirthCertificateCredential-sdjwt" + signerOptions: signerOptions ? signerOptions[0] : undefined, + format: selectedApiFormat, // 'vc+sd-jwt' | 'mdoc' + payload: normalizedPayload, // without vct + ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.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 templatesById = new Map(templates.map((template) => [template.id, template])); + + // Verify all requested templateIds exist + const unknownTemplateIds = dto.credentials + .map((c) => c.templateId) + .filter((requestedId) => !templatesById.has(requestedId)); + if (unknownTemplateIds.length) { + throw new Error(`Unknown template ids: ${unknownTemplateIds.join(', ')}`); + } + + // Build credentials + const builtCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { + const templateRecord = templatesById.get(credentialRequest.templateId)!; + const resolvedAttributes = ensureTemplateAttributes(templateRecord.attributes); // narrow JsonValue safely + return buildOneCredential(credentialRequest, templateRecord, resolvedAttributes, signerOptions); + }); + + // --- Base envelope (issuerId deliberately NOT included) --- + const baseEnvelope: BuiltCredentialOfferBase = { + credentials: builtCredentials, + ...(dto.publicIssuerId ? { publicIssuerId: dto.publicIssuerId } : {}) + }; + + // XOR flow selection (defensive) + const hasPreAuthFlow = Boolean(dto.preAuthorizedCodeFlowConfig); + const hasAuthCodeFlow = Boolean(dto.authorizationCodeFlowConfig); + if (hasPreAuthFlow === hasAuthCodeFlow) { + throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); + } + + if (hasPreAuthFlow) { + return { + ...baseEnvelope, + preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! + }; + } + + return { + ...baseEnvelope, + authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! + }; +} + +// ----------------------------------------------------------------------------- +// Builder: Update Credential Offer +// ----------------------------------------------------------------------------- +export function buildUpdateCredentialOfferPayload( + dto: CreateOidcCredentialOfferDtoLike, + templates: credential_templates[] +): { credentials: BuiltCredential[] } { + // Index templates by id + const templatesById = new Map(templates.map((template) => [template.id, template])); + + // Validate all templateIds exist + const unknownTemplateIds = dto.credentials + .map((c) => c.templateId) + .filter((requestedId) => !templatesById.has(requestedId)); + if (unknownTemplateIds.length) { + throw new Error(`Unknown template ids: ${unknownTemplateIds.join(', ')}`); + } + + // Validate each credential against its template + const normalizedCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { + const templateRecord = templatesById.get(credentialRequest.templateId)!; + const resolvedAttributes = ensureTemplateAttributes(templateRecord.attributes); // safely narrow JsonValue + + // check that all payload keys exist in template attributes + const payloadKeys = Object.keys(credentialRequest.payload); + const invalidPayloadKeys = payloadKeys.filter((payloadKey) => !resolvedAttributes[payloadKey]); + if (invalidPayloadKeys.length) { + throw new Error( + `Invalid attributes for template "${credentialRequest.templateId}": ${invalidPayloadKeys.join(', ')}` + ); + } + + // also validate mandatory fields are present + assertMandatoryClaims(credentialRequest.payload, resolvedAttributes, { templateId: credentialRequest.templateId }); + + // build minimal normalized credential (no vct, issuerId, etc.) + const selectedApiFormat = mapDbFormatToApiFormat(templateRecord.format); + const idSuffix = formatSuffix(selectedApiFormat); + const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; + + return { + credentialSupportedId, + format: selectedApiFormat, + payload: credentialRequest.payload, + ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) + }; + }); + + // Only return credentials array here (update flow doesn't need preAuth/auth configs) + return { + credentials: normalizedCredentials + }; +} + +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 index d2e126026..baa272cc4 100644 --- a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -1,4 +1,5 @@ /* eslint-disable camelcase */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { oidc_issuer, Prisma } from '@prisma/client'; import { batchCredentialIssuanceDefault } from '../../constant/issuance'; import { CreateOidcCredentialOffer } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; @@ -40,7 +41,9 @@ type CredentialConfig = { display: { name: string; description?: string; locale?: string }[]; }; -type CredentialConfigurationsSupported = CredentialConfig[]; +type CredentialConfigurationsSupported = { + credentialConfigurationsSupported: Record; +}; // ---- Static Lists (as requested) ---- const STATIC_CREDENTIAL_ALGS = ['ES256', 'EdDSA'] as const; @@ -99,7 +102,7 @@ export function buildCredentialConfigurationsSupported( } ): CredentialConfigurationsSupported { const defaultFormat = opts?.format ?? 'vc+sd-jwt'; - const credentialConfigurationsSupported: CredentialConfigurationsSupported = []; + const credentialConfigurationsSupported: Record = {}; for (const t of templates) { const attrs = coerceJsonObject(t.attributes); const app = coerceJsonObject(t.appearance); @@ -116,6 +119,7 @@ export function buildCredentialConfigurationsSupported( const rowFormat: string = (t as any).format ?? defaultFormat; const isMdoc = 'mso_mdoc' === rowFormat; const suffix = isMdoc ? 'mdoc' : 'sdjwt'; + const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; // key: keep your keyResolver override; otherwise include suffix // const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; @@ -158,7 +162,7 @@ export function buildCredentialConfigurationsSupported( })) ?? []; // assemble per-template config - credentialConfigurationsSupported.push({ + credentialConfigurationsSupported[key] = { format: rowFormat, scope, claims, @@ -166,10 +170,10 @@ export function buildCredentialConfigurationsSupported( cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], display, ...(isMdoc ? { doctype: rowDoctype as string } : { vct: rowVct }) - }); + }; } - return credentialConfigurationsSupported; + return { credentialConfigurationsSupported }; } // Default DPoP list for issuer-level metadata (match your example) @@ -217,7 +221,7 @@ function isDisplayArray(x: unknown): x is DisplayItem[] { */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function buildIssuerPayload( - credentialConfigurations: CredentialConfigurationsSupported, + credentialConfigurations: CredentialConfigurationsSupported | Record | null | undefined, oidcIssuer: oidc_issuer, opts?: { dpopAlgs?: string[]; @@ -231,10 +235,22 @@ export function buildIssuerPayload( const rawDisplay = coerceJson(oidcIssuer.metadata); const display: DisplayItem[] = isDisplayArray(rawDisplay) ? rawDisplay : []; + // Accept both shapes: + // 1) { credentialConfigurationsSupported: Record } + // 2) directly the Record + let credentialConfigMap: Record = {}; + if (!credentialConfigurations) { + credentialConfigMap = {}; + } else if ('credentialConfigurationsSupported' in (credentialConfigurations as any)) { + credentialConfigMap = (credentialConfigurations as any).credentialConfigurationsSupported ?? {}; + } else { + credentialConfigMap = credentialConfigurations as Record; + } + return { display, dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], - credentialConfigurationsSupported: credentialConfigurations ?? [], + credentialConfigurationsSupported: credentialConfigMap, batchCredentialIssuance: { batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault } diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts index f4d9b829b..e2e6a3715 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts @@ -177,14 +177,16 @@ export class Oid4vcIssuanceRepository { // eslint-disable-next-line @typescript-eslint/no-explicit-any async addOidcIssuerDetails(issuerMetadata: IssuerMetadata, issuerProfileJson): Promise { try { - const { publicIssuerId, createdById, orgAgentId, batchCredentialIssuanceSize } = issuerMetadata; + const { publicIssuerId, createdById, orgAgentId, batchCredentialIssuanceSize, authorizationServerUrl } = + issuerMetadata; const oidcIssuerDetails = await this.prisma.oidc_issuer.create({ data: { metadata: issuerProfileJson, publicIssuerId, createdBy: createdById, orgAgentId, - batchCredentialIssuanceSize + batchCredentialIssuanceSize, + authorizationServerUrl } }); diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 4aa65ee7f..cc7a9c44e 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -121,6 +121,7 @@ export class Oid4vcIssuanceService { throw new InternalServerErrorException('Issuer ID missing from agent response'); } const issuerMetadata: IssuerMetadata = { + authorizationServerUrl: issuerCreation.authorizationServerUrl, publicIssuerId: issuerIdFromAgent, createdById: userDetails.id, orgAgentId, @@ -319,6 +320,7 @@ export class Oid4vcIssuanceService { opts = { ...opts, doctype }; } const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId, opts); + console.log(`service - createTemplate: `, JSON.stringify(issuerTemplateConfig)); const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); @@ -330,6 +332,7 @@ export class Oid4vcIssuanceService { } const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_TEMPLATE, issuerDetails.publicIssuerId); const createTemplateOnAgent = await this._createOIDCTemplate(issuerTemplateConfig, url, orgId); + console.log('createTemplateOnAgent::::::::::::::', createTemplateOnAgent); if (!createTemplateOnAgent) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } @@ -622,7 +625,6 @@ export class Oid4vcIssuanceService { url, orgId ); - console.log('This is the updateCredentialOfferOnAgent:', JSON.stringify(updateCredentialOfferOnAgent)); if (!updateCredentialOfferOnAgent) { throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorUpdateOffer); } diff --git a/libs/prisma-service/prisma/data/credebl-master-table.json b/libs/prisma-service/prisma/data/credebl-master-table.json index c4167aa18..5fd866632 100644 --- a/libs/prisma-service/prisma/data/credebl-master-table.json +++ b/libs/prisma-service/prisma/data/credebl-master-table.json @@ -1,12 +1,12 @@ { "platformConfigData": { - "externalIp": "##Machine Ip Address/Domain for agent setup##", - "inboundEndpoint": "##Machine Ip Address/Domain for agent setup##", + "externalIp": "192.168.1.25", + "inboundEndpoint": "192.168.1.25", "username": "credebl", "sgApiKey": "###Sendgrid Key###", - "emailFrom": "##Senders Mail ID##", - "apiEndpoint": "## Platform API Ip Address##", - "tailsFileServer": "##Machine Ip Address for agent setup##" + "emailFrom": "info@blockster.global", + "apiEndpoint": "http://0.0.0.0:5000", + "tailsFileServer": "" }, "platformAdminData": { "firstName": "CREDEBL", diff --git a/libs/prisma-service/prisma/migrations/20251023120134_added_authorization_server_url/migration.sql b/libs/prisma-service/prisma/migrations/20251023120134_added_authorization_server_url/migration.sql new file mode 100644 index 000000000..8e6325394 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20251023120134_added_authorization_server_url/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `authorizationServerUrl` to the `oidc_issuer` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "oidc_issuer" ADD COLUMN "authorizationServerUrl" TEXT NOT NULL; diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index f2baa0278..eebca4da4 100644 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -578,6 +578,7 @@ model oidc_issuer { createdBy String @db.Uuid publicIssuerId String metadata Json + authorizationServerUrl String orgAgentId String @db.Uuid orgAgent org_agents @relation(fields: [orgAgentId], references: [id]) templates credential_templates[] From ee5c472562affb4ada752964ae2f43d7abc77667 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Mon, 27 Oct 2025 12:51:59 +0530 Subject: [PATCH 04/13] feat: credential path changes Signed-off-by: Tipu_Singh --- .../dtos/oid4vc-issuer-template.dto.ts | 6 +- .../helpers/credential-sessions.builder.ts | 454 ++++++++++-------- .../libs/helpers/issuer.metadata.ts | 166 ++++--- .../src/oid4vc-issuance.service.ts | 10 +- 4 files changed, 353 insertions(+), 283 deletions(-) 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 c4cf5c7f5..1f58372d0 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 @@ -168,11 +168,11 @@ export class CreateCredentialTemplateDto { canBeRevoked = false; @ApiProperty({ - type: 'object', - additionalProperties: { $ref: getSchemaPath(CredentialAttributeDto) }, + type: 'array', + items: { $ref: getSchemaPath(CredentialAttributeDto) }, description: 'Attributes included in the credential template' }) - @IsObject() + @IsArray() attributes: CredentialAttributeDto[]; @ApiProperty({ diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts index b765bdf5a..2824940bd 100644 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -3,6 +3,7 @@ import { Prisma, credential_templates } from '@prisma/client'; import { GetAllCredentialOffer, SignerOption } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; import { CredentialFormat } from '@credebl/enum/enum'; + /* ============================================================================ Domain Types ============================================================================ */ @@ -10,9 +11,9 @@ import { CredentialFormat } from '@credebl/enum/enum'; type ValueType = 'string' | 'date' | 'number' | 'boolean' | 'integer' | string; interface TemplateAttribute { - display: { name: string; locale: string }[]; - mandatory: boolean; - value_type: ValueType; + display?: { name: string; locale: string }[]; + mandatory?: boolean; + value_type?: ValueType; } type TemplateAttributes = Record; @@ -24,18 +25,13 @@ export enum SignerMethodOption { 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; @@ -43,8 +39,6 @@ export interface CreateOidcCredentialOfferDtoLike { authorizationCodeFlowConfig?: { authorizationServerUrl: string; }; - - // NOTE: issuerId is intentionally NOT emitted in the final payload publicIssuerId?: string; } @@ -59,27 +53,19 @@ export interface ResolvedSignerOption { ============================================================================ */ 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 & ( | { @@ -97,6 +83,20 @@ export type CredentialOfferPayload = BuiltCredentialOfferBase & } ); +/* ============================================================================ + Constants +============================================================================ */ + +/** + * Default txCode constant requested (used for pre-authorized flow). + * The user requested this as a constant to be used by the builder. + */ +export const DEFAULT_TXCODE = { + description: 'test abc', + length: 4, + input_mode: 'numeric' as const +}; + /* ============================================================================ Small Utilities ============================================================================ */ @@ -108,25 +108,86 @@ const isPlainRecord = (value: unknown): value is Record => /** Map DB format string -> API enum */ function mapDbFormatToApiFormat(dbFormat: string): CredentialFormat { - if ('sd-jwt' === dbFormat || 'vc+sd-jwt' === dbFormat || 'sdjwt' === dbFormat || 'sd+jwt-vc' === dbFormat) { + const normalized = (dbFormat ?? '').toLowerCase(); + if (['sd-jwt', 'vc+sd-jwt', 'sdjwt', 'sd+jwt-vc'].includes(normalized)) { return CredentialFormat.SdJwtVc; } - if ('mso_mdoc' === dbFormat) { + if ('mso_mdoc' === normalized || 'mso-mdoc' === normalized || 'mdoc' === normalized) { return CredentialFormat.Mdoc; } throw new Error(`Unsupported template format: ${dbFormat}`); } -/** Map API enum -> id suffix required for credentialSupportedId */ function formatSuffix(apiFormat: CredentialFormat): 'sdjwt' | 'mdoc' { return apiFormat === CredentialFormat.SdJwtVc ? 'sdjwt' : 'mdoc'; } /* ============================================================================ - Validation of Payload vs Template Attributes + Template Attributes Normalization + - draft-13 used map: { given_name: { mandatory:true, value_type: "string" } } + - draft-15 returns attributes as array of attribute objects (with path) + This helper accepts both and normalizes to TemplateAttributes map. +============================================================================ */ + +/** + * Normalize attributes from DB/template into TemplateAttributes map. + * Accepts: + * - map: Record + * - array: Array<{ path: string[], mandatory?: boolean, value_type?: string, display?: ... }> + */ +function normalizeTemplateAttributes(rawAttributes: Prisma.JsonValue): TemplateAttributes { + // if already a plain record keyed by claim name, cast and return + if (isPlainRecord(rawAttributes) && !Array.isArray(rawAttributes)) { + // We still guard that values look like TemplateAttribute, but be permissive. + return rawAttributes as TemplateAttributes; + } + + // If attributes are an array (draft-15 style), convert to map + if (Array.isArray(rawAttributes)) { + const attributesArray = rawAttributes as unknown as any[]; + const normalizedMap: TemplateAttributes = {}; + for (const attributeEntry of attributesArray) { + if (!isPlainRecord(attributeEntry)) { + continue; // skip invalid entries + } + + // draft-15: path is array like ["org.iso.23220.photoID.1","given_name"] or ["name"] + const pathValue = attributeEntry.path; + if (!Array.isArray(pathValue) || 0 === pathValue.length) { + continue; + } + + // prefer last path element as local claim name (keeps namespace support) + const claimName = String(pathValue[pathValue.length - 1]); + + normalizedMap[claimName] = { + mandatory: Boolean(attributeEntry.mandatory), + value_type: attributeEntry.value_type ? String(attributeEntry.value_type) : undefined, + display: Array.isArray(attributeEntry.display) + ? attributeEntry.display.map((d: any) => ({ name: d.name, locale: d.locale })) + : undefined + }; + } + return normalizedMap; + } + + // if it's a JSON string, try parse + if ('string' === typeof rawAttributes) { + try { + const parsed = JSON.parse(rawAttributes); + return normalizeTemplateAttributes(parsed as Prisma.JsonValue); + } catch { + throw new Error('Invalid template.attributes JSON string'); + } + } + + throw new Error('Unrecognized template.attributes shape'); +} + +/* ============================================================================ + Validation: Mandatory claims ============================================================================ */ -/** Throw if any template-mandatory claim is missing/empty in payload. */ function assertMandatoryClaims( payload: Record, attributes: TemplateAttributes, @@ -148,264 +209,248 @@ function assertMandatoryClaims( } /* ============================================================================ - JsonValue → TemplateAttributes Narrowing (Type Guards) + Per-format credential builders (separated for readability) + - buildSdJwtCredential + - buildMdocCredential ============================================================================ */ -function isDisplayArray(value: unknown): value is { name: string; locale: string }[] { - return ( - Array.isArray(value) && - value.every( - (entry) => - isPlainRecord(entry) && 'string' === typeof (entry as any).name && 'string' === typeof (entry as any).locale - ) - ); -} +/** Build an SD-JWT credential object */ +function buildSdJwtCredential( + credentialRequest: CredentialRequestDtoLike, + templateRecord: credential_templates, + signerOptions?: SignerOption[] +): BuiltCredential { + // For SD-JWT format we expect payload to be a flat map of claims (no namespaces) + const payloadCopy = { ...(credentialRequest.payload as Record) }; + // Validate mandatory claims using normalized attributes from templateRecord + const normalizedAttributes = normalizeTemplateAttributes(templateRecord.attributes); + assertMandatoryClaims(payloadCopy, normalizedAttributes, { templateId: credentialRequest.templateId }); -/* ============================================================================ - Improved ensureTemplateAttributes: runtime assert with helpful errors -============================================================================ */ + // strip vct if present per requirement + delete payloadCopy.vct; -const ALLOWED_VALUE_TYPES: ValueType[] = ['string', 'date', 'number', 'boolean', 'integer']; + const apiFormat = mapDbFormatToApiFormat(templateRecord.format); + const idSuffix = formatSuffix(apiFormat); + const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; -function ensureTemplateAttributes(jsonValue: Prisma.JsonValue): TemplateAttributes { - if (!isPlainRecord(jsonValue)) { - throw new Error( - `Invalid template.attributes: expected an object map but received ${ - null === jsonValue ? 'null' : typeof jsonValue - }.\n\nFix: provide an object whose keys are attribute names and whose values are attribute definitions, e.g.\n{\n "given_name": { "mandatory": true, "value_type": "string" }\n}` - ); - } + return { + credentialSupportedId, + signerOptions: signerOptions ? signerOptions[0] : undefined, + format: apiFormat, + payload: payloadCopy, + ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) + }; +} - const attributesMap = jsonValue as Record; - const attributeKeys = Object.keys(attributesMap); - if (0 === attributeKeys.length) { - throw new Error( - 'Invalid template.attributes: object is empty (no attributes defined).\n\nFix: add at least one attribute definition, for example:\n{\n "given_name": { "mandatory": true, "value_type": "string" }\n}' - ); +/** Build an MSO mdoc credential object + * - For mdocs we expect the payload to include a `namespaces` map (draft-15 style) + */ +function buildMdocCredential( + credentialRequest: CredentialRequestDtoLike, + templateRecord: credential_templates, + signerOptions?: SignerOption[] +): BuiltCredential { + const incomingPayload = { ...(credentialRequest.payload as Record) }; + + // Normalize attributes and ensure we know the expected claim names + const normalizedAttributes = normalizeTemplateAttributes(templateRecord.attributes); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const templateDoctype: string | undefined = (templateRecord as any).doctype ?? undefined; + const defaultNamespace = templateDoctype ?? templateRecord.name; + + // If caller provided already-namespaced payload, keep it; otherwise build a namespaces map + const workingPayload = { ...incomingPayload }; + if (!workingPayload.namespaces) { + const namespacesMap: Record> = {}; + // collect claims that match attribute names into the chosen namespace + for (const claimName of Object.keys(normalizedAttributes)) { + if (Object.prototype.hasOwnProperty.call(incomingPayload, claimName)) { + namespacesMap[defaultNamespace] = namespacesMap[defaultNamespace] ?? {}; + namespacesMap[defaultNamespace][claimName] = (incomingPayload as any)[claimName]; + // remove original flattened claim to avoid duplication + delete (workingPayload as any)[claimName]; + } + } + if (0 < Object.keys(namespacesMap).length) { + (workingPayload as any).namespaces = namespacesMap; + } + } else { + // ensure namespaces is a plain object + if (!isPlainRecord((workingPayload as any).namespaces)) { + throw new Error(`Invalid mdoc payload: 'namespaces' must be an object`); + } } - const problems: string[] = []; - const suggestedFixes: string[] = []; - - for (const attributeKey of attributeKeys) { - const rawAttributeDef = attributesMap[attributeKey]; - - if (!isPlainRecord(rawAttributeDef)) { - problems.push( - `${attributeKey}: expected an object but got ${null === rawAttributeDef ? 'null' : typeof rawAttributeDef}` - ); - suggestedFixes.push( - `Replace attribute "${attributeKey}" value with an object, e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); + // Validate mandatory claims exist somewhere inside namespaces + const missingMandatoryClaims: string[] = []; + for (const [claimName, attributeDef] of Object.entries(normalizedAttributes)) { + if (!attributeDef?.mandatory) { continue; } - // mandatory checks - if (!('mandatory' in rawAttributeDef)) { - problems.push(`${attributeKey}.mandatory: missing`); - suggestedFixes.push( - `Add mandatory boolean for "${attributeKey}":\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); - } else if ('boolean' !== typeof (rawAttributeDef as any).mandatory) { - problems.push(`${attributeKey}.mandatory: expected boolean but got ${typeof (rawAttributeDef as any).mandatory}`); - suggestedFixes.push( - `Set "mandatory" to a boolean for "${attributeKey}", e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); - } - - // value_type checks - if (!('value_type' in rawAttributeDef)) { - problems.push(`${attributeKey}.value_type: missing`); - suggestedFixes.push( - `Add value_type for "${attributeKey}", for example:\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); - } else if ('string' !== typeof (rawAttributeDef as any).value_type) { - problems.push( - `${attributeKey}.value_type: expected string but got ${typeof (rawAttributeDef as any).value_type}` - ); - suggestedFixes.push( - `Make sure "value_type" is a string for "${attributeKey}", e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); - } else { - const declaredType = (rawAttributeDef as any).value_type as string; - if (!ALLOWED_VALUE_TYPES.includes(declaredType as ValueType)) { - problems.push( - `${attributeKey}.value_type: unsupported value_type "${declaredType}". Allowed types: ${ALLOWED_VALUE_TYPES.join(', ')}` - ); - suggestedFixes.push( - `Use one of the allowed types for "${attributeKey}", e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); + let found = false; + const namespacesObj = (workingPayload as any).namespaces as Record; + if (namespacesObj && isPlainRecord(namespacesObj)) { + for (const nsKey of Object.keys(namespacesObj)) { + const nsContent = namespacesObj[nsKey]; + if (nsContent && Object.prototype.hasOwnProperty.call(nsContent, claimName)) { + const value = nsContent[claimName]; + if (!isNil(value) && !('string' === typeof value && '' === value.trim())) { + found = true; + break; + } + } } } - - // display checks (optional) - if ('display' in rawAttributeDef && !isDisplayArray((rawAttributeDef as any).display)) { - problems.push(`${attributeKey}.display: expected array of { name: string, locale: string }`); - suggestedFixes.push( - `Fix "display" for "${attributeKey}" to be an array of objects with name/locale, e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string", "display": [{ "name": "Given Name", "locale": "en-US" }] }` - ); + if (!found) { + missingMandatoryClaims.push(claimName); } } - - if (0 < problems.length) { - // Build a user-friendly message: problems + suggested fixes (unique) - const uniqueFixes = Array.from(new Set(suggestedFixes)).slice(0, 20); - const fixesText = uniqueFixes.length - ? `\n\nSuggested fixes (copy-paste examples):\n- ${uniqueFixes.join('\n- ')}` - : ''; - - // Include a small truncated sample of the attributes to help debugging - const samplePreview = JSON.stringify( - Object.fromEntries(attributeKeys.slice(0, 10).map((key) => [key, attributesMap[key]])), - (_, value) => { - if ('string' === typeof value && 200 < value.length) { - return `${value.slice(0, 200)}...`; - } - return value; - }, - 2 - ); - + if (missingMandatoryClaims.length) { throw new Error( - `Invalid template.attributes shape. Problems found:\n- ${problems.join( - '\n- ' - )}\n\nExample attributes (truncated):\n${samplePreview}${fixesText}` + `Missing mandatory namespaced claims for template "${credentialRequest.templateId}": ${missingMandatoryClaims.join( + ', ' + )}` ); } - // Safe to cast to TemplateAttributes - return attributesMap as TemplateAttributes; -} - -/* ============================================================================ - Builders -============================================================================ */ - -/** Build one credential block normalized to API format (using the template's format). */ -function buildOneCredential( - credentialRequest: CredentialRequestDtoLike, - templateRecord: credential_templates, - templateAttributes: TemplateAttributes, - signerOptions?: SignerOption[] -): BuiltCredential { - // 1) Validate payload against template attributes - assertMandatoryClaims(credentialRequest.payload, templateAttributes, { templateId: credentialRequest.templateId }); + // strip vct if present + delete (workingPayload as Record).vct; - // 2) Decide API format from DB format - const selectedApiFormat = mapDbFormatToApiFormat(templateRecord.format); - - // 3) Build supportedId from template.name + suffix ("-sdjwt" | "-mdoc") - const idSuffix = formatSuffix(selectedApiFormat); + const apiFormat = mapDbFormatToApiFormat(templateRecord.format); + const idSuffix = formatSuffix(apiFormat); const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - // 4) Strip vct ALWAYS (per requirement) - const normalizedPayload = { ...(credentialRequest.payload as Record) }; - delete (normalizedPayload as Record).vct; - return { - credentialSupportedId, // e.g., "BirthCertificateCredential-sdjwt" + credentialSupportedId, signerOptions: signerOptions ? signerOptions[0] : undefined, - format: selectedApiFormat, // 'vc+sd-jwt' | 'mdoc' - payload: normalizedPayload, // without vct + format: apiFormat, + payload: workingPayload, ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.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" - */ +/* ============================================================================ + Main Builder: buildCredentialOfferPayload + - Now delegates per-format build to the two helpers above + - Accepts `authorizationServerUrl` parameter; txCode is a constant above +============================================================================ */ + export function buildCredentialOfferPayload( dto: CreateOidcCredentialOfferDtoLike, templates: credential_templates[], + issuerDetails?: { + publicId: string; + authorizationServerUrl?: string; + }, signerOptions?: SignerOption[] ): CredentialOfferPayload { - // Index templates + // Index templates by id const templatesById = new Map(templates.map((template) => [template.id, template])); - // Verify all requested templateIds exist - const unknownTemplateIds = dto.credentials - .map((c) => c.templateId) - .filter((requestedId) => !templatesById.has(requestedId)); - if (unknownTemplateIds.length) { - throw new Error(`Unknown template ids: ${unknownTemplateIds.join(', ')}`); + // Validate template ids + const missingTemplateIds = dto.credentials.map((c) => c.templateId).filter((id) => !templatesById.has(id)); + if (missingTemplateIds.length) { + throw new Error(`Unknown template ids: ${missingTemplateIds.join(', ')}`); } - // Build credentials + // Build each credential using the template's format const builtCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { const templateRecord = templatesById.get(credentialRequest.templateId)!; - const resolvedAttributes = ensureTemplateAttributes(templateRecord.attributes); // narrow JsonValue safely - return buildOneCredential(credentialRequest, templateRecord, resolvedAttributes, signerOptions); + // we normalize attributes to support both draft-13 (map) and draft-15 (array) shapes + normalizeTemplateAttributes(templateRecord.attributes); + + const templateFormat = (templateRecord as any).format ?? 'vc+sd-jwt'; + const apiFormat = mapDbFormatToApiFormat(templateFormat); + + if (apiFormat === CredentialFormat.SdJwtVc) { + return buildSdJwtCredential(credentialRequest, templateRecord, signerOptions); + } + if (apiFormat === CredentialFormat.Mdoc) { + return buildMdocCredential(credentialRequest, templateRecord, signerOptions); + } + throw new Error(`Unsupported template format for ${templateFormat}`); }); - // --- Base envelope (issuerId deliberately NOT included) --- + // Base envelope: allow explicit publicIssuerId from DTO or fallback to issuerDetails.publicId + const publicIssuerIdFromDto = dto.publicIssuerId; + const publicIssuerIdFromIssuerDetails = issuerDetails?.publicId; + const finalPublicIssuerId = publicIssuerIdFromDto ?? publicIssuerIdFromIssuerDetails; + const baseEnvelope: BuiltCredentialOfferBase = { credentials: builtCredentials, - ...(dto.publicIssuerId ? { publicIssuerId: dto.publicIssuerId } : {}) + ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}) }; - // XOR flow selection (defensive) - const hasPreAuthFlow = Boolean(dto.preAuthorizedCodeFlowConfig); - const hasAuthCodeFlow = Boolean(dto.authorizationCodeFlowConfig); - if (hasPreAuthFlow === hasAuthCodeFlow) { - throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); + // Determine which authorization flow to return: + // Priority: + // 1) If issuerDetails.authorizationServerUrl is provided, return preAuthorizedCodeFlowConfig using DEFAULT_TXCODE + // 2) Else fall back to flows present in DTO (still enforce XOR) + const overrideAuthorizationServerUrl = issuerDetails?.authorizationServerUrl; + if (overrideAuthorizationServerUrl) { + if ('string' !== typeof overrideAuthorizationServerUrl || '' === overrideAuthorizationServerUrl.trim()) { + throw new Error('issuerDetails.authorizationServerUrl must be a non-empty string when provided'); + } + return { + ...baseEnvelope, + preAuthorizedCodeFlowConfig: { + txCode: DEFAULT_TXCODE, + authorizationServerUrl: overrideAuthorizationServerUrl + } + }; } - if (hasPreAuthFlow) { + // No override provided — use what DTO carries (must be XOR) + const hasPreAuthFromDto = Boolean(dto.preAuthorizedCodeFlowConfig); + const hasAuthCodeFromDto = Boolean(dto.authorizationCodeFlowConfig); + if (hasPreAuthFromDto === hasAuthCodeFromDto) { + throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); + } + if (hasPreAuthFromDto) { return { ...baseEnvelope, preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! }; } - return { ...baseEnvelope, authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! }; } -// ----------------------------------------------------------------------------- -// Builder: Update Credential Offer -// ----------------------------------------------------------------------------- +/* ============================================================================ + Update Credential Offer builder (keeps behavior, clearer names) +============================================================================ */ + export function buildUpdateCredentialOfferPayload( dto: CreateOidcCredentialOfferDtoLike, templates: credential_templates[] ): { credentials: BuiltCredential[] } { - // Index templates by id const templatesById = new Map(templates.map((template) => [template.id, template])); - // Validate all templateIds exist - const unknownTemplateIds = dto.credentials - .map((c) => c.templateId) - .filter((requestedId) => !templatesById.has(requestedId)); - if (unknownTemplateIds.length) { - throw new Error(`Unknown template ids: ${unknownTemplateIds.join(', ')}`); + const missingTemplateIds = dto.credentials.map((c) => c.templateId).filter((id) => !templatesById.has(id)); + if (missingTemplateIds.length) { + throw new Error(`Unknown template ids: ${missingTemplateIds.join(', ')}`); } - // Validate each credential against its template const normalizedCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { const templateRecord = templatesById.get(credentialRequest.templateId)!; - const resolvedAttributes = ensureTemplateAttributes(templateRecord.attributes); // safely narrow JsonValue - // check that all payload keys exist in template attributes + // Normalize attributes shape and ensure it's valid + const attributesMap = normalizeTemplateAttributes(templateRecord.attributes); + + // ensure payload keys match known attributes const payloadKeys = Object.keys(credentialRequest.payload); - const invalidPayloadKeys = payloadKeys.filter((payloadKey) => !resolvedAttributes[payloadKey]); + const invalidPayloadKeys = payloadKeys.filter((payloadKey) => !attributesMap[payloadKey]); if (invalidPayloadKeys.length) { throw new Error( `Invalid attributes for template "${credentialRequest.templateId}": ${invalidPayloadKeys.join(', ')}` ); } - // also validate mandatory fields are present - assertMandatoryClaims(credentialRequest.payload, resolvedAttributes, { templateId: credentialRequest.templateId }); + // Validate mandatory claims + assertMandatoryClaims(credentialRequest.payload, attributesMap, { templateId: credentialRequest.templateId }); - // build minimal normalized credential (no vct, issuerId, etc.) const selectedApiFormat = mapDbFormatToApiFormat(templateRecord.format); const idSuffix = formatSuffix(selectedApiFormat); const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; @@ -418,7 +463,6 @@ export function buildUpdateCredentialOfferPayload( }; }); - // Only return credentials array here (update flow doesn't need preAuth/auth configs) return { credentials: normalizedCredentials }; diff --git a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts index baa272cc4..1e9bfda49 100644 --- a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -1,5 +1,4 @@ /* eslint-disable camelcase */ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { oidc_issuer, Prisma } from '@prisma/client'; import { batchCredentialIssuanceDefault } from '../../constant/issuance'; import { CreateOidcCredentialOffer } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; @@ -65,7 +64,7 @@ function coerceJsonObject(v: Prisma.JsonValue): T | null { } // eslint-disable-next-line @typescript-eslint/no-explicit-any function isAttributesMap(x: any): x is AttributesMap { - return x && 'object' === typeof x && !Array.isArray(x); + return x && 'object' === typeof x && Array.isArray(x); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function isAppearance(x: any): x is Appearance { @@ -92,88 +91,121 @@ type TemplateRowPrisma = { * Safely coerces JSON and then builds the same structure as Builder #2. */ export function buildCredentialConfigurationsSupported( - templates: TemplateRowPrisma[], - opts?: { + templateRows: TemplateRowPrisma[], + options?: { vct?: string; doctype?: string; scopeVct?: string; - keyResolver?: (t: TemplateRowPrisma) => string; + keyResolver?: (templateRow: TemplateRowPrisma) => string; format?: string; } -): CredentialConfigurationsSupported { - const defaultFormat = opts?.format ?? 'vc+sd-jwt'; - const credentialConfigurationsSupported: Record = {}; - for (const t of templates) { - const attrs = coerceJsonObject(t.attributes); - const app = coerceJsonObject(t.appearance); - - if (!isAttributesMap(attrs)) { - throw new Error(`Template ${t.id}: invalid attributes JSON`); +): Record { + const defaultFormat = options?.format ?? 'vc+sd-jwt'; + const credentialConfigMap: Record = {}; + + for (const templateRow of templateRows) { + // Extract and validate attributes (claims) and appearance (display configuration) + const attributesJson = templateRow.attributes; + const appearanceJson = coerceJsonObject(templateRow.appearance); + + if (!isAttributesMap(attributesJson)) { + throw new Error(`Template ${templateRow.id}: invalid attributes JSON`); } - if (!isAppearance(app)) { - throw new Error(`Template ${t.id}: invalid appearance JSON (missing display)`); + + if (!isAppearance(appearanceJson)) { + throw new Error(`Template ${templateRow.id}: invalid appearance JSON (missing display array)`); } - // per-row format (allow column override) + // Determine credential format (either sd-jwt or mso_mdoc) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rowFormat: string = (t as any).format ?? defaultFormat; - const isMdoc = 'mso_mdoc' === rowFormat; - const suffix = isMdoc ? 'mdoc' : 'sdjwt'; - const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; + const templateFormat: string = (templateRow as any).format ?? defaultFormat; + const isMdocFormat = 'mso_mdoc' === templateFormat; + const formatSuffix = isMdocFormat ? 'mdoc' : 'sdjwt'; - // key: keep your keyResolver override; otherwise include suffix - // const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; + // Determine the unique key for this credential configuration + const configKey = + 'function' === typeof options?.keyResolver + ? options.keyResolver(templateRow) + : `${templateRow.name}-${formatSuffix}`; - // Resolve doctype/vct: - // - For mdoc: try opts.doctype -> t.doctype -> fallback to t.name (or throw if you prefer) - // - For sd-jwt: try opts.vct -> t.vct -> fallback to t.name + // Resolve Doctype and VCT based on format type // eslint-disable-next-line @typescript-eslint/no-explicit-any - let rowDoctype: string | undefined = opts?.doctype ?? (t as any).doctype; + let resolvedDoctype: string | undefined = options?.doctype ?? (templateRow 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) { - if (!rowDoctype) { - // Fallback strategy: use template's name as doctype (change to throw if you want strictness) - rowDoctype = t.name; - // If you want to fail instead of fallback, uncomment next line: - // throw new Error(`Template ${t.id}: doctype is required for mdoc format`); - } + const resolvedVct: string = options?.vct ?? (templateRow as any).vct ?? templateRow.name; + + if (isMdocFormat && !resolvedDoctype) { + resolvedDoctype = templateRow.name; // fallback to template name } - // Choose scope base: prefer opts.scopeVct, otherwise for mdoc use doctype, else vct - const scopeBase = opts?.scopeVct ?? (isMdoc ? rowDoctype : rowVct); - const scope = `openid4vc:credential:${scopeBase}-${suffix}`; - const claims = Object.entries(attrs).map(([claimName, def]) => { - const d = def as AttributeDef; - return { + // Construct OIDC4VC scope + const scopeBaseValue = options?.scopeVct ?? (isMdocFormat ? resolvedDoctype : resolvedVct); + const credentialScope = `openid4vc:credential:${scopeBaseValue}-${formatSuffix}`; + + // Convert each attribute into a claim definition (map shape) + const claimsObject: Record = {}; + for (const [claimName, attributeDefinition] of Object.entries(attributesJson)) { + const parsedAttribute = attributeDefinition as AttributeDef; + + claimsObject[claimName] = { 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 + mandatory: parsedAttribute.mandatory ?? false, + display: Array.isArray(parsedAttribute.display) + ? parsedAttribute.display.map((displayItem) => ({ + name: displayItem.name, + locale: displayItem.locale + })) + : undefined, + value_type: parsedAttribute.value_type }; - }); + } - const display = - app.display?.map((d) => ({ - name: d.name, - description: d.description, - locale: d.locale + // Prepare the display configuration + const displayConfigurations = + (appearanceJson as 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 })) ?? []; - // assemble per-template config - credentialConfigurationsSupported[key] = { - format: rowFormat, - scope, - claims, + // Assemble final credential configuration + credentialConfigMap[configKey] = { + format: templateFormat, + scope: credentialScope, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + claims: Object.entries(claimsObject).map(([claimName, claimDef]) => ({ + path: claimDef.path, + mandatory: claimDef.mandatory, + display: claimDef.display + // you can optionally expose claimDef.value_type here if your API schema allows + })), credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], - display, - ...(isMdoc ? { doctype: rowDoctype as string } : { vct: rowVct }) + display: displayConfigurations, + ...(isMdocFormat ? { doctype: resolvedDoctype as string } : { vct: resolvedVct }) }; } - return { credentialConfigurationsSupported }; + return credentialConfigMap; // ✅ Return flat map, not nested object +} + +/** + * Helper — Optional + * Wraps the credential configurations map into the expected schema + * for issuer metadata JSON: + * + * { "credentialConfigurationsSupported": { ... } } + */ +export function wrapCredentialConfigurationsSupported( + configsMap: Record +): CredentialConfigurationsSupported { + return { credentialConfigurationsSupported: configsMap }; } // Default DPoP list for issuer-level metadata (match your example) @@ -221,7 +253,7 @@ function isDisplayArray(x: unknown): x is DisplayItem[] { */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function buildIssuerPayload( - credentialConfigurations: CredentialConfigurationsSupported | Record | null | undefined, + credentialConfigurations: CredentialConfigurationsSupported, oidcIssuer: oidc_issuer, opts?: { dpopAlgs?: string[]; @@ -235,22 +267,10 @@ export function buildIssuerPayload( const rawDisplay = coerceJson(oidcIssuer.metadata); const display: DisplayItem[] = isDisplayArray(rawDisplay) ? rawDisplay : []; - // Accept both shapes: - // 1) { credentialConfigurationsSupported: Record } - // 2) directly the Record - let credentialConfigMap: Record = {}; - if (!credentialConfigurations) { - credentialConfigMap = {}; - } else if ('credentialConfigurationsSupported' in (credentialConfigurations as any)) { - credentialConfigMap = (credentialConfigurations as any).credentialConfigurationsSupported ?? {}; - } else { - credentialConfigMap = credentialConfigurations as Record; - } - return { display, dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], - credentialConfigurationsSupported: credentialConfigMap, + credentialConfigurationsSupported: credentialConfigurations ?? [], batchCredentialIssuance: { batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault } diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index cc7a9c44e..3cae5052c 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -290,6 +290,7 @@ export class Oid4vcIssuanceService { issuerId: string ): Promise { try { + //TODO: add revert mechanism if agent call fails const { name, description, format, canBeRevoked, attributes, appearance, signerOption, vct, doctype } = CredentialTemplate; const checkNameExist = await this.oid4vcIssuanceRepository.getTemplateByNameForIssuer(name, issuerId); @@ -519,12 +520,17 @@ export class Oid4vcIssuanceService { }); } } - console.log(`Setup signerOptions `, signerOptions); //TODO: Implement x509 support and discuss with team + //TODO: add logic to pass the issuer info const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayload( createOidcCredentialOffer, + // getAllOfferTemplates, - signerOptions + { + publicId: 'photoIdIssuer', + authorizationServerUrl: 'http://localhost:4002/oid4vci/photoIdIssuer' + }, + signerOptions as any ); console.log('This is the buildOidcCredentialOffer:', JSON.stringify(buildOidcCredentialOffer, null, 2)); From a1c03352c573cca078776f8880c79eb935e2e136 Mon Sep 17 00:00:00 2001 From: Rinkal Bhojani Date: Mon, 27 Oct 2025 23:18:56 +0530 Subject: [PATCH 05/13] fix: added support for nested attribute and updated builder function Signed-off-by: Rinkal Bhojani --- .../dtos/oid4vc-issuer-template.dto.ts | 207 +++++++-- .../oid4vc-issuance/dtos/oid4vc-issuer.dto.ts | 173 +++----- .../interfaces/oid4vc-issuance.interfaces.ts | 18 +- .../interfaces/oid4vc-template.interfaces.ts | 51 ++- .../libs/helpers/issuer.metadata.ts | 418 +++++++++++++----- .../src/oid4vc-issuance.service.ts | 43 +- 6 files changed, 590 insertions(+), 320 deletions(-) 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 1f58372d0..f3578d4f4 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 @@ -9,40 +9,100 @@ import { IsNotEmpty, IsArray, ValidateIf, - IsEmpty + IsEmpty, + ArrayNotEmpty } from 'class-validator'; import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { DisplayDto } from './oid4vc-issuer.dto'; import { SignerOption } from '@prisma/client'; +import { CredentialFormat } from '@credebl/enum/enum'; + +class CredentialAttributeDisplayDto { + @ApiPropertyOptional({ example: 'First Name' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ example: 'en' }) + @IsString() + @IsOptional() + locale?: string; +} + +// 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: [CredentialAttributeDisplayDto], required: false, description: 'Localized display values' }) +// @IsOptional() +// @ValidateNested({ each: true }) +// @Type(() => CredentialAttributeDisplayDto) +// display?: CredentialAttributeDisplayDto[]; +// } + +export enum AttributeType { + STRING = 'string', + NUMBER = 'number', + BOOLEAN = 'boolean', + DATE = 'date', + OBJECT = 'object', + ARRAY = 'array', + IMAGE = 'image' +} export class CredentialAttributeDto { + @ApiProperty({ description: 'Unique key for this attribute (e.g., full_name, org.iso.23220.photoID.1.birth_date)' }) + @IsString() + key: string; + @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() + @ApiProperty({ enum: AttributeType, description: 'Type of the attribute value (string, number, date, etc.)' }) + @IsEnum(AttributeType) value_type: string; + @ApiProperty({ description: 'Whether this attribute should be disclosed (for SD-JWT)' }) + @IsOptional() + @IsBoolean() + disclose?: boolean; + + @ApiProperty({ type: [CredentialAttributeDisplayDto], required: false, description: 'Localized display values' }) + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => CredentialAttributeDisplayDto) + display?: CredentialAttributeDisplayDto[]; + @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' + description: 'Nested attributes if type is object or array', + required: false, + type: () => [CredentialAttributeDto] }) - @IsArray() - @IsString({ each: true }) - path: string[]; - - @ApiProperty({ type: [DisplayDto], required: false, description: 'Localized display values' }) @IsOptional() + @IsArray() @ValidateNested({ each: true }) - @Type(() => DisplayDto) - display?: DisplayDto[]; + @Type(() => CredentialAttributeDto) + children?: CredentialAttributeDto[]; } - class LogoDto { @ApiPropertyOptional({ example: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg' @@ -84,6 +144,23 @@ class CredentialDisplayDto { @ValidateNested() @Type(() => LogoDto) logo?: LogoDto; + + @ApiPropertyOptional({ example: '#12107c' }) + @IsString() + @IsOptional() + background_color?: string; + + @ApiPropertyOptional({ example: '#FFFFFF' }) + @IsString() + @IsOptional() + text_color?: string; + + @ApiPropertyOptional({ example: { uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg' } }) + @IsObject() + @IsOptional() + background_image?: { + uri: string; + }; } export class AppearanceDto { @@ -115,7 +192,55 @@ export class AppearanceDto { display: CredentialDisplayDto[]; } -@ApiExtraModels(CredentialAttributeDto) +export class MdocNamespaceDto { + @ApiProperty({ description: 'Namespace key (e.g., org.iso.23220.photoID.1)' }) + @IsString() + namespace: string; + + @ApiProperty({ type: () => [CredentialAttributeDto] }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => CredentialAttributeDto) + attributes: CredentialAttributeDto[]; +} +export class MdocTemplateDto { + @ApiProperty({ + 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; + + @ApiProperty({ type: () => [MdocNamespaceDto] }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => MdocNamespaceDto) + namespaces: MdocNamespaceDto[]; +} + +export class SdJwtTemplateDto { + @ApiProperty({ + description: + 'Verifiable Credential Type (required when format is "vc+sd-jwt"; must NOT be provided when format is "mso_mdoc")', + example: 'BirthCertificateCredential-sdjwt' + }) + // @ValidateIf((o: CreateCredentialTemplateDto) => 'vc+sd-jwt' === o.format) + @IsString() + vct: string; + + @ApiProperty({ + type: 'array', + items: { $ref: getSchemaPath(CredentialAttributeDto) }, + description: 'Attributes included in the credential template' + }) + @IsArray() + attributes: CredentialAttributeDto[]; +} + +@ApiExtraModels(CredentialAttributeDto, SdJwtTemplateDto, MdocTemplateDto) export class CreateCredentialTemplateDto { @ApiProperty({ description: 'Template name' }) @IsString() @@ -134,47 +259,37 @@ export class CreateCredentialTemplateDto { @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; + @ApiProperty({ enum: CredentialFormat, description: 'Credential format type' }) + @IsEnum(CredentialFormat) + format: CredentialFormat; - @ValidateIf((o: CreateCredentialTemplateDto) => 'vc+sd-jwt' === o.format) + @ValidateIf((o: CreateCredentialTemplateDto) => CredentialFormat.SdJwtVc === 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: 'BirthCertificateCredential-sdjwt' - }) - @ValidateIf((o: CreateCredentialTemplateDto) => 'vc+sd-jwt' === o.format) - @IsString() - vct?: string; - - @ValidateIf((o: CreateCredentialTemplateDto) => 'mso_mdoc' === o.format) + @ValidateIf((o: CreateCredentialTemplateDto) => CredentialFormat.Mdoc === o.format) @IsEmpty({ message: 'vct must not be provided when format is "mso_mdoc"' }) readonly _vctAbsentGuard?: unknown; + @ApiProperty({ + type: Object, + oneOf: [{ $ref: getSchemaPath(SdJwtTemplateDto) }, { $ref: getSchemaPath(MdocTemplateDto) }], + description: 'Credential template definition (depends on credentialFormat)' + }) + @ValidateNested() + @Type(({ object }) => { + if (object.format === CredentialFormat.Mdoc) { + return MdocTemplateDto; + } else if (object.format === CredentialFormat.SdJwtVc) { + return SdJwtTemplateDto; + } + }) + template: SdJwtTemplateDto | MdocTemplateDto; + @ApiProperty({ default: false, description: 'Indicates whether credentials can be revoked' }) @IsBoolean() canBeRevoked = false; - @ApiProperty({ - type: 'array', - items: { $ref: getSchemaPath(CredentialAttributeDto) }, - description: 'Attributes included in the credential template' - }) - @IsArray() - attributes: CredentialAttributeDto[]; - @ApiProperty({ type: Object, required: false, 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 index c52ad5136..e16ddcb05 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts @@ -1,43 +1,8 @@ /* eslint-disable camelcase */ -import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsString, - IsOptional, - IsBoolean, - IsArray, - ValidateNested, - IsUrl, - IsNotEmpty, - IsDefined, - IsInt -} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsArray, ValidateNested, IsUrl, 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', @@ -54,7 +19,7 @@ export class LogoDto { alt_text: string; } -export class DisplayDto { +export class IssuerDisplayDto { @ApiProperty({ description: 'The locale for display text', example: 'en-US' @@ -69,14 +34,6 @@ export class DisplayDto { @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 @@ -87,68 +44,68 @@ export class DisplayDto { } // 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; +// @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() - doctype?: string; +// @ApiProperty({ required: false }) +// @IsOptional() +// @IsString() +// vct?: string; - @ApiProperty() - @IsString() - scope: string; +// @ApiProperty({ required: false }) +// @IsOptional() +// @IsString() +// doctype?: 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() +// @IsString() +// scope: string; - @ApiProperty({ type: [String] }) - @IsArray() - credential_signing_alg_values_supported: 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({ 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[]; -} +// @ApiProperty({ +// description: 'Localized display information for the credential', +// type: [DisplayDto] +// }) +// @IsArray() +// @ValidateNested({ each: true }) +// @Type(() => DisplayDto) +// display: DisplayDto[]; +// } // export class AuthorizationServerConfigDto { // @ApiProperty({ @@ -239,12 +196,12 @@ export class IssuerCreationDto { @ApiProperty({ description: 'Localized display information for the credential', - type: [DisplayDto] + type: [IssuerDisplayDto] }) @IsArray() @ValidateNested({ each: true }) - @Type(() => DisplayDto) - display: DisplayDto[]; + @Type(() => IssuerDisplayDto) + display: IssuerDisplayDto[]; @ApiProperty({ example: 'https://auth.example.org', description: 'Authorization URL' }) @IsUrl({ require_tld: false }) @@ -265,12 +222,12 @@ export class IssuerUpdationDto { @ApiProperty({ description: 'Localized display information for the credential', - type: [DisplayDto] + type: [IssuerDisplayDto] }) @IsArray() @ValidateNested({ each: true }) - @Type(() => DisplayDto) - display: DisplayDto[]; + @Type(() => IssuerDisplayDto) + display: IssuerDisplayDto[]; @ApiProperty({ description: 'batchCredentialIssuanceSize', diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts index 9ecefedfb..eb5a59ad9 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts @@ -1,4 +1,5 @@ import { organisation } from '@prisma/client'; +import { Claim } from './oid4vc-template.interfaces'; export interface OrgAgent { organisation: organisation; @@ -17,12 +18,6 @@ export interface OrgAgent { tenantId: string; } -export interface Claim { - path: string[]; - label?: string; - required?: boolean; -} - export interface Logo { uri: string; alt_text: string; @@ -114,17 +109,6 @@ 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; diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts index 7b36b8f18..0e5c32b0b 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts @@ -1,11 +1,23 @@ import { Prisma, SignerOption } from '@prisma/client'; -import { Display } from './oid4vc-issuance.interfaces'; import { CredentialFormat } from '@credebl/enum/enum'; -export interface CredentialAttribute { - mandatory?: boolean; - value_type: string; - display?: Display[]; +// export interface CredentialAttribute { +// mandatory?: boolean; +// value_type: string; +// display?: Display[]; +// } + +export interface SdJwtTemplate { + vct: string; + attributes: CredentialAttribute[]; +} + +export interface MdocTemplate { + doctype: string; + namespaces: { + namespace: string; + attributes: CredentialAttribute[]; + }[]; } export interface CreateCredentialTemplate { @@ -13,13 +25,34 @@ export interface CreateCredentialTemplate { description?: string; signerOption?: SignerOption; //SignerOption; format: CredentialFormat; - issuer: string; canBeRevoked: boolean; - attributes: Prisma.JsonValue; + // attributes: Prisma.JsonValue; appearance?: Prisma.JsonValue; issuerId: string; - vct?: string; - doctype?: string; + // vct?: string; + // doctype?: string; + + template: SdJwtTemplate | MdocTemplate; } export interface UpdateCredentialTemplate extends Partial {} + +export interface ClaimDisplay { + name: string; + locale?: string; +} + +export interface Claim { + path: string[]; + display?: ClaimDisplay[]; + mandatory?: boolean; +} + +export interface CredentialAttribute { + key: string; + mandatory?: boolean; + value_type: string; + disclose?: boolean; + children?: CredentialAttribute[]; + display?: ClaimDisplay[]; +} diff --git a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts index 1e9bfda49..597b2fd06 100644 --- a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -3,14 +3,24 @@ 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, + CredentialAttribute, + MdocTemplate, + SdJwtTemplate +} from 'apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces'; +import { CredentialFormat } from '@credebl/enum/enum'; type AttributeDisplay = { name: string; locale: string }; + +//TODO: Fix this eslint issue +// eslint-disable-next-line @typescript-eslint/no-unused-vars type AttributeDef = { display?: AttributeDisplay[]; mandatory?: boolean; value_type: 'string' | 'date' | 'number' | 'boolean' | string; }; -type AttributesMap = Record; +// type AttributesMap = Record; type CredentialDisplayItem = { logo?: { uri: string; alt_text?: string }; @@ -22,12 +32,12 @@ type Appearance = { display: CredentialDisplayItem[]; }; -type Claim = { - mandatory?: boolean; - // value_type: string; - path: string[]; - display?: AttributeDisplay[]; -}; +// type Claim = { +// mandatory?: boolean; +// // value_type: string; +// path: string[]; +// display?: AttributeDisplay[]; +// }; type CredentialConfig = { format: string; @@ -63,23 +73,39 @@ function coerceJsonObject(v: Prisma.JsonValue): T | 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); -} +// 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: Prisma.JsonValue; // JsonValue from DB + attributes: SdJwtTemplate | MdocTemplate; // JsonValue from DB appearance: Prisma.JsonValue; // JsonValue from DB issuerId: string; createdAt?: Date | string; @@ -90,110 +116,112 @@ type TemplateRowPrisma = { * 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( - templateRows: TemplateRowPrisma[], - options?: { - vct?: string; - doctype?: string; - scopeVct?: string; - keyResolver?: (templateRow: TemplateRowPrisma) => string; - format?: string; - } -): Record { - const defaultFormat = options?.format ?? 'vc+sd-jwt'; - const credentialConfigMap: Record = {}; - - for (const templateRow of templateRows) { - // Extract and validate attributes (claims) and appearance (display configuration) - const attributesJson = templateRow.attributes; - const appearanceJson = coerceJsonObject(templateRow.appearance); - - if (!isAttributesMap(attributesJson)) { - throw new Error(`Template ${templateRow.id}: invalid attributes JSON`); - } - - if (!isAppearance(appearanceJson)) { - throw new Error(`Template ${templateRow.id}: invalid appearance JSON (missing display array)`); - } - - // Determine credential format (either sd-jwt or mso_mdoc) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const templateFormat: string = (templateRow as any).format ?? defaultFormat; - const isMdocFormat = 'mso_mdoc' === templateFormat; - const formatSuffix = isMdocFormat ? 'mdoc' : 'sdjwt'; - - // Determine the unique key for this credential configuration - const configKey = - 'function' === typeof options?.keyResolver - ? options.keyResolver(templateRow) - : `${templateRow.name}-${formatSuffix}`; - - // Resolve Doctype and VCT based on format type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let resolvedDoctype: string | undefined = options?.doctype ?? (templateRow as any).doctype; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resolvedVct: string = options?.vct ?? (templateRow as any).vct ?? templateRow.name; - - if (isMdocFormat && !resolvedDoctype) { - resolvedDoctype = templateRow.name; // fallback to template name - } - - // Construct OIDC4VC scope - const scopeBaseValue = options?.scopeVct ?? (isMdocFormat ? resolvedDoctype : resolvedVct); - const credentialScope = `openid4vc:credential:${scopeBaseValue}-${formatSuffix}`; - - // Convert each attribute into a claim definition (map shape) - const claimsObject: Record = {}; - for (const [claimName, attributeDefinition] of Object.entries(attributesJson)) { - const parsedAttribute = attributeDefinition as AttributeDef; - - claimsObject[claimName] = { - path: [claimName], - mandatory: parsedAttribute.mandatory ?? false, - display: Array.isArray(parsedAttribute.display) - ? parsedAttribute.display.map((displayItem) => ({ - name: displayItem.name, - locale: displayItem.locale - })) - : undefined, - value_type: parsedAttribute.value_type - }; - } - - // Prepare the display configuration - const displayConfigurations = - (appearanceJson as 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 - })) ?? []; - - // Assemble final credential configuration - credentialConfigMap[configKey] = { - format: templateFormat, - scope: credentialScope, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - claims: Object.entries(claimsObject).map(([claimName, claimDef]) => ({ - path: claimDef.path, - mandatory: claimDef.mandatory, - display: claimDef.display - // you can optionally expose claimDef.value_type here if your API schema allows - })), - credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], - cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], - display: displayConfigurations, - ...(isMdocFormat ? { doctype: resolvedDoctype as string } : { vct: resolvedVct }) - }; - } - - return credentialConfigMap; // ✅ Return flat map, not nested object -} +// export function buildCredentialConfigurationsSupported( +// templateRows: TemplateRowPrisma[], +// options?: { +// vct?: string; +// doctype?: string; +// scopeVct?: string; +// keyResolver?: (templateRow: TemplateRowPrisma) => string; +// format?: string; +// } +// ): Record { +// const defaultFormat = options?.format ?? 'vc+sd-jwt'; +// const credentialConfigMap: Record = {}; + +// for (const templateRow of templateRows) { +// // Extract and validate attributes (claims) and appearance (display configuration) +// const attributesJson = templateRow.attributes; +// const appearanceJson = coerceJsonObject(templateRow.appearance); + +// if (!isAttributesMap(attributesJson)) { +// throw new Error(`Template ${templateRow.id}: invalid attributes JSON`); +// } + +// if (!isAppearance(appearanceJson)) { +// throw new Error(`Template ${templateRow.id}: invalid appearance JSON (missing display array)`); +// } + +// // Determine credential format (either sd-jwt or mso_mdoc) +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// const templateFormat: string = (templateRow as any).format ?? defaultFormat; +// const isMdocFormat = 'mso_mdoc' === templateFormat; +// const formatSuffix = isMdocFormat ? 'mdoc' : 'sdjwt'; + +// // Determine the unique key for this credential configuration +// const configKey = +// 'function' === typeof options?.keyResolver +// ? options.keyResolver(templateRow) +// : `${templateRow.name}-${formatSuffix}`; + +// // Resolve Doctype and VCT based on format type +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// let resolvedDoctype: string | undefined = options?.doctype ?? (templateRow as any).doctype; +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// const resolvedVct: string = options?.vct ?? (templateRow as any).vct ?? templateRow.name; + +// if (isMdocFormat && !resolvedDoctype) { +// resolvedDoctype = templateRow.name; // fallback to template name +// } + +// // Construct OIDC4VC scope +// const scopeBaseValue = options?.scopeVct ?? (isMdocFormat ? resolvedDoctype : resolvedVct); +// const credentialScope = `openid4vc:credential:${scopeBaseValue}-${formatSuffix}`; + +// // Convert each attribute into a claim definition (map shape) +// const claimsObject: Record = {}; +// for (const [claimName, attributeDefinition] of Object.entries(attributesJson)) { +// console.log(`claimName, attributeDefinition`, claimName, attributeDefinition); +// console.log(`attributesJson`, attributesJson); +// const parsedAttribute = attributeDefinition as AttributeDef; + +// claimsObject[claimName] = { +// path: [claimName], +// mandatory: parsedAttribute.mandatory ?? false, +// display: Array.isArray(parsedAttribute.display) +// ? parsedAttribute.display.map((displayItem) => ({ +// name: displayItem.name, +// locale: displayItem.locale +// })) +// : undefined, +// value_type: parsedAttribute.value_type +// }; +// } + +// // Prepare the display configuration +// const displayConfigurations = +// (appearanceJson as 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 +// })) ?? []; + +// // Assemble final credential configuration +// credentialConfigMap[configKey] = { +// format: templateFormat, +// scope: credentialScope, +// // eslint-disable-next-line @typescript-eslint/no-unused-vars +// claims: Object.entries(claimsObject).map(([claimName, claimDef]) => ({ +// path: claimDef.path, +// mandatory: claimDef.mandatory, +// display: claimDef.display +// // you can optionally expose claimDef.value_type here if your API schema allows +// })), +// credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], +// cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], +// display: displayConfigurations, +// ...(isMdocFormat ? { doctype: resolvedDoctype as string } : { vct: resolvedVct }) +// }; +// } + +// return credentialConfigMap; // ✅ Return flat map, not nested object +// } /** * Helper — Optional @@ -270,7 +298,7 @@ export function buildIssuerPayload( return { display, dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], - credentialConfigurationsSupported: credentialConfigurations ?? [], + credentialConfigurationsSupported: credentialConfigurations.credentialConfigurationsSupported ?? [], batchCredentialIssuance: { batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault } @@ -301,3 +329,151 @@ export function encodeIssuerPublicId(publicIssuerId: string): string { } return encodeURIComponent(publicIssuerId.trim()); } + +///--------------------------------------------------------- + +function buildClaimsFromAttributes(attributes: CredentialAttribute[], parentPath: string[] = []): Claim[] { + const claims: Claim[] = []; + + for (const attr of attributes) { + const currentPath = [...parentPath, attr.key]; + + // 1️⃣ Add the parent attribute itself if it has display or mandatory metadata + if ((attr.display && 0 < attr.display.length) || attr.mandatory) { + const parentClaim: Claim = { path: currentPath }; + + if (attr.display?.length) { + parentClaim.display = attr.display.map((d) => ({ + name: d.name, + locale: d.locale + })); + } + + if (attr.mandatory) { + parentClaim.mandatory = true; + } + + claims.push(parentClaim); + } + + // 2️⃣ If this attribute has nested children, recurse into them + if (attr.children && 0 < attr.children.length) { + claims.push(...buildClaimsFromAttributes(attr.children, currentPath)); + } + } + return claims; +} + +//TODO: Fix this eslint issue +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function buildSdJwtCredentialConfig(name: string, template: SdJwtTemplate) { + const formatSuffix = 'sdjwt'; + + // Determine the unique key for this credential configuration + const configKey = `${name}-${formatSuffix}`; + const credentialScope = `openid4vc:${template.vct}-${formatSuffix}`; + + const claims = buildClaimsFromAttributes(template.attributes); + + return { + [configKey]: { + format: CredentialFormat.SdJwtVc, + scope: credentialScope, + vct: template.vct, + credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], + cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], + // proof_types_supported: { + // jwt: { + // proof_signing_alg_values_supported: ['ES256'] + // } + // }, + claims + } + }; +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function buildMdocCredentialConfig(name: string, template: MdocTemplate) { + const claims: Claim[] = []; + + const formatSuffix = 'mdoc'; + + // Determine the unique key for this credential configuration + const configKey = `${name}-${formatSuffix}`; + const credentialScope = `openid4vc:${template.doctype}-${formatSuffix}`; + + for (const ns of template.namespaces) { + claims.push(...buildClaimsFromAttributes(ns.attributes, [ns.namespace])); + } + + return { + [configKey]: { + format: CredentialFormat.Mdoc, + scope: credentialScope, + doctype: template.doctype, + credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], + cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], + claims + } + }; +} + +//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) { + switch (format) { + case CredentialFormat.SdJwtVc: + return buildSdJwtCredentialConfig(name, template as SdJwtTemplate); + case CredentialFormat.Mdoc: + return buildMdocCredentialConfig(name, template as MdocTemplate); + default: + throw new Error(`Unsupported credential format: ${format}`); + } +} + +/** + * 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 buildCredentialConfigurationsSupportedNew(templateRows: any): Record { + const credentialConfigMap: Record = {}; + + for (const templateRow of templateRows) { + const { format } = templateRow; + const templateToBuild = templateRow.attributes; + + const credentialConfig = buildCredentialConfig( + templateRow.name, + templateToBuild, + format === CredentialFormat.Mdoc ? CredentialFormat.Mdoc : CredentialFormat.SdJwtVc + ); + + const appearanceJson = coerceJsonObject(templateRow.appearance); + + // Prepare the display configuration + const displayConfigurations = + (appearanceJson as 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 + })) ?? []; + + // eslint-disable-next-line prefer-destructuring + const dynamicKey = Object.keys(credentialConfig)[0]; + Object.assign(credentialConfig[dynamicKey], { + display: displayConfigurations + }); + + Object.assign(credentialConfigMap, credentialConfig); + } + + return credentialConfigMap; // ✅ Return flat map, not nested object +} diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 3cae5052c..21ec91436 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -23,7 +23,7 @@ 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, SignerOption, user } from '@prisma/client'; +import { credential_templates, oidc_issuer, Prisma, SignerOption, user } from '@prisma/client'; import { IAgentOIDCIssuerCreate, IssuerCreation, @@ -40,7 +40,7 @@ import { dpopSigningAlgValuesSupported } from '../constant/issuance'; import { - buildCredentialConfigurationsSupported, + buildCredentialConfigurationsSupportedNew, buildIssuerPayload, encodeIssuerPublicId, extractTemplateIds, @@ -58,6 +58,7 @@ import { CredentialOfferPayload } from '../libs/helpers/credential-sessions.builder'; import { x5cKeyType } from '@credebl/enum/enum'; +import { instanceToPlain, plainToInstance } from 'class-transformer'; type CredentialDisplayItem = { logo?: { uri: string; alt_text?: string }; @@ -285,14 +286,14 @@ export class Oid4vcIssuanceService { } async createTemplate( - CredentialTemplate: CreateCredentialTemplate, + credentialTemplate: CreateCredentialTemplate, orgId: string, issuerId: string ): Promise { try { //TODO: add revert mechanism if agent call fails - const { name, description, format, canBeRevoked, attributes, appearance, signerOption, vct, doctype } = - CredentialTemplate; + const { name, description, format, canBeRevoked, appearance, signerOption } = credentialTemplate; + const checkNameExist = await this.oid4vcIssuanceRepository.getTemplateByNameForIssuer(name, issuerId); if (0 < checkNameExist.length) { throw new ConflictException(ResponseMessages.oidcTemplate.error.templateNameAlreadyExist); @@ -302,7 +303,7 @@ export class Oid4vcIssuanceService { description, format: format.toString(), canBeRevoked, - attributes, + attributes: instanceToPlain(credentialTemplate.template), appearance: appearance ?? {}, issuerId, signerOption @@ -313,14 +314,14 @@ export class Oid4vcIssuanceService { 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); + // let opts = {}; + // if (vct) { + // opts = { ...opts, vct }; + // } + // if (doctype) { + // opts = { ...opts, doctype }; + // } + const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); console.log(`service - createTemplate: `, JSON.stringify(issuerTemplateConfig)); const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { @@ -360,7 +361,7 @@ export class Oid4vcIssuanceService { updateCredentialTemplate.name, issuerId ); - if (0 < checkNameExist.length) { + if (0 < checkNameExist.length && !checkNameExist.some((item) => item.id === templateId)) { throw new ConflictException(ResponseMessages.oidcTemplate.error.templateNameAlreadyExist); } } @@ -368,7 +369,8 @@ export class Oid4vcIssuanceService { ...updateCredentialTemplate, ...(issuerId ? { issuerId } : {}) }; - const { name, description, format, canBeRevoked, attributes, appearance } = normalized; + const { name, description, format, canBeRevoked, appearance } = normalized; + const attributes = instanceToPlain(normalized.template); const payload = { ...(name !== undefined ? { name } : {}), @@ -699,14 +701,17 @@ export class Oid4vcIssuanceService { } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - async buildOidcIssuerConfig(issuerId: string, configMetadata?) { + async buildOidcIssuerConfig(issuerId: string) { try { const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); const templates = await this.oid4vcIssuanceRepository.getTemplatesByIssuerId(issuerId); - const credentialConfigurationsSupported = buildCredentialConfigurationsSupported(templates, configMetadata); + console.log(`---------------- emplates, configMetadata`, templates); + const credentialConfigurationsSupported = buildCredentialConfigurationsSupportedNew(templates); + + console.log(`-------------------credentialConfigurationsSupported`, credentialConfigurationsSupported); - return buildIssuerPayload(credentialConfigurationsSupported, issuerDetails); + return buildIssuerPayload({ credentialConfigurationsSupported }, issuerDetails); } catch (error) { this.logger.error(`[buildOidcIssuerPayload] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); From 83c6de74a73e2bf4a3590a482ff47b0f75a9feba Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Tue, 28 Oct 2025 00:56:59 +0530 Subject: [PATCH 06/13] refactor:updated example for issuer and added logic to fetch issuer details Signed-off-by: Tipu_Singh --- .../oid4vc-issuance/dtos/oid4vc-issuer.dto.ts | 54 +++++++++++++++---- .../src/oid4vc-issuance.repository.ts | 11 ++++ .../src/oid4vc-issuance.service.ts | 6 ++- 3 files changed, 58 insertions(+), 13 deletions(-) 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 index e16ddcb05..45b360cae 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts @@ -179,15 +179,15 @@ export enum AccessTokenSignerKeyType { // @ApiExtraModels(CredentialConfigurationDto) export class IssuerCreationDto { @ApiProperty({ - description: 'Name of the issuer', - example: 'Credebl University' + description: 'Unique identifier of the issuer (usually a short code or DID-based identifier)', + example: 'credebl-university' }) - @IsString({ message: 'issuerId from IssuerCreationDto -> issuerId, must be a string' }) + @IsString({ message: 'issuerId must be a string' }) issuerId: string; @ApiPropertyOptional({ - description: 'Maximum number of credentials that can be issued in a batch', - example: 50, + description: 'Maximum number of credentials that can be issued in a single batch issuance operation', + example: 100, type: Number }) @IsOptional() @@ -195,21 +195,53 @@ export class IssuerCreationDto { batchCredentialIssuanceSize?: number; @ApiProperty({ - description: 'Localized display information for the credential', - type: [IssuerDisplayDto] + description: + 'Localized display information for the issuer — shown in wallet apps or credential metadata display (multi-lingual supported)', + type: [IssuerDisplayDto], + example: [ + { + locale: 'en', + name: 'Credebl University', + description: 'Accredited institution issuing verified student credentials', + logo: { + uri: 'https://university.credebl.io/assets/logo-en.svg', + alt_text: 'Credebl University logo' + } + }, + { + locale: 'de', + name: 'Credebl Universität', + description: 'Akkreditierte Institution für digitale Studentenausweise', + logo: { + uri: 'https://university.credebl.io/assets/logo-de.svg', + alt_text: 'Credebl Universität Logo' + } + } + ] }) @IsArray() @ValidateNested({ each: true }) @Type(() => IssuerDisplayDto) display: IssuerDisplayDto[]; - @ApiProperty({ example: 'https://auth.example.org', description: 'Authorization URL' }) - @IsUrl({ require_tld: false }) + @ApiProperty({ + example: 'https://issuer.credebl.io/oid4vci', + description: 'Base URL of the Authorization Server supporting OID4VC issuance flows' + }) + @IsUrl({ require_tld: false }, { message: 'authorizationServerUrl must be a valid URL' }) authorizationServerUrl: string; @ApiProperty({ - description: 'Configuration of the authorization server', - type: AuthorizationServerConfigDto + description: + 'Additional configuration details for the authorization server (token endpoint, credential endpoint, grant types, etc.)', + type: AuthorizationServerConfigDto, + example: { + issuer: 'https://id.sovio.ae:8443/realms/sovioid', + clientAuthentication: { + clientId: 'issuer-server', + clientSecret: '1qKMWulZpMBzXIdfPO5AEs0xaTaKs1ym' + } + } }) @IsOptional() @ValidateNested() diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts index e2e6a3715..d7651172b 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts @@ -307,6 +307,17 @@ export class Oid4vcIssuanceRepository { } } + async getIssuerDetailsByIssuerId(issuerId: string): Promise { + try { + return await this.prisma.oidc_issuer.findUnique({ + where: { id: issuerId } + }); + } catch (error) { + this.logger.error(`Error in getIssuerDetailsByIssuerId: ${error.message}`); + throw error; + } + } + async updateTemplate(templateId: string, data: Partial): Promise { try { return await this.prisma.credential_templates.update({ diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 21ec91436..973558471 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -524,13 +524,15 @@ export class Oid4vcIssuanceService { } //TODO: Implement x509 support and discuss with team //TODO: add logic to pass the issuer info + const issuerDetailsFromDb = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); + const { publicIssuerId, authorizationServerUrl } = issuerDetailsFromDb || {}; const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayload( createOidcCredentialOffer, // getAllOfferTemplates, { - publicId: 'photoIdIssuer', - authorizationServerUrl: 'http://localhost:4002/oid4vci/photoIdIssuer' + publicId: publicIssuerId, + authorizationServerUrl: `${authorizationServerUrl}/oid4vci/${publicIssuerId}` }, signerOptions as any ); From acafc89671359133c7502baeb9b14976ae4ad1ea Mon Sep 17 00:00:00 2001 From: Rinkal Bhojani Date: Tue, 28 Oct 2025 03:28:01 +0530 Subject: [PATCH 07/13] fix: correction in create offer functions for validations and builder method Signed-off-by: Rinkal Bhojani --- .../oid4vc-issuer-sessions.interfaces.ts | 8 +- .../helpers/credential-sessions.builder.ts | 240 ++++++++++++++++++ .../libs/helpers/issuer.metadata.ts | 112 ++++++-- .../src/oid4vc-issuance.service.ts | 3 +- 4 files changed, 333 insertions(+), 30 deletions(-) diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts index 6fd33ead5..69e8f97d1 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts @@ -30,10 +30,10 @@ export enum AuthenticationType { export type DisclosureFrame = Record>; export interface CredentialPayload { - full_name?: string; - birth_date?: string; // YYYY-MM-DD if present - birth_place?: string; - parent_names?: string; + validityInfo: { + validFrom: Date; + validUntil: Date; + }; [key: string]: unknown; // extensible for mDoc or other formats } diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts index 2824940bd..2abc31e68 100644 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -3,6 +3,12 @@ import { Prisma, credential_templates } from '@prisma/client'; import { GetAllCredentialOffer, SignerOption } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; import { CredentialFormat } from '@credebl/enum/enum'; +import { + CredentialAttribute, + MdocTemplate, + SdJwtTemplate +} from 'apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces'; +import { UnprocessableEntityException } from '@nestjs/common'; /* ============================================================================ Domain Types @@ -494,3 +500,237 @@ export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer: // Append query string if any params exist return 0 < criteriaParams.length ? `${baseUrl}?${criteriaParams.join('&')}` : baseUrl; } + +export function validatePayloadAgainstTemplate(template: any, payload: any): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + const validateAttributes = (attributes: CredentialAttribute[], data: any, path = '') => { + for (const attr of attributes) { + const currentPath = path ? `${path}.${attr.key}` : attr.key; + const value = data?.[attr.key]; + + // Check for missing mandatory value + const isEmpty = + value === undefined || + null === value || + ('string' === typeof value && '' === value.trim()) || + ('object' === typeof value && !Array.isArray(value) && 0 === Object.keys(value).length); + + if (attr.mandatory && isEmpty) { + errors.push(`Missing mandatory attribute: ${currentPath}`); + } + + // Recurse for nested attributes + if (attr.children && 'object' === typeof value && null !== value) { + validateAttributes(attr.children, value, currentPath); + } + } + }; + + if (CredentialFormat.SdJwtVc === template.format) { + validateAttributes((template.attributes as SdJwtTemplate).attributes ?? [], payload); + } else if (CredentialFormat.Mdoc === template.format) { + const namespaces = payload?.namespaces; + if (!namespaces) { + errors.push('Missing namespaces object in mdoc payload.'); + } else { + const templateNamespaces = (template.attributes as MdocTemplate).namespaces; + for (const ns of templateNamespaces ?? []) { + const nsData = namespaces[ns.namespace]; + if (!nsData) { + errors.push(`Missing namespace: ${ns.namespace}`); + continue; + } + validateAttributes(ns.attributes, nsData, ns.namespace); + } + } + } + + return { valid: 0 === errors.length, errors }; +} + +function buildDisclosureFrameFromTemplate(template: { attributes: CredentialAttribute[] }) { + const disclosureFrame: DisclosureFrame = {}; + + const buildFrame = (attributes: CredentialAttribute[]) => { + const frame: Record = {}; + + for (const attr of attributes) { + if (attr.children?.length) { + // Handle nested attributes recursively + const subFrame = buildFrame(attr.children); + // Include parent only if disclose is true or it has children with disclosure + if (attr.disclose || 0 < Object.keys(subFrame).length) { + frame[attr.key] = subFrame; + } + } else if (attr.disclose !== undefined) { + frame[attr.key] = Boolean(attr.disclose); + } + } + + return frame; + }; + + Object.assign(disclosureFrame, buildFrame(template.attributes)); + + return disclosureFrame; +} + +function buildSdJwtCredentialNew( + credentialRequest: CredentialRequestDtoLike, + templateRecord: any, + signerOptions?: SignerOption[] +): BuiltCredential { + // For SD-JWT format we expect payload to be a flat map of claims (no namespaces) + const payloadCopy = { ...(credentialRequest.payload as Record) }; + + // // strip vct if present per requirement + // delete payloadCopy.vct; + + const sdJwtTemplate = templateRecord.attributes as SdJwtTemplate; + payloadCopy.vct = sdJwtTemplate.vct; + + const apiFormat = mapDbFormatToApiFormat(templateRecord.format); + const idSuffix = formatSuffix(apiFormat); + const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; + const disclosureFrame = buildDisclosureFrameFromTemplate({ attributes: sdJwtTemplate.attributes }); + + return { + credentialSupportedId, + signerOptions: signerOptions ? signerOptions[0] : undefined, + format: apiFormat, + payload: payloadCopy, + ...(disclosureFrame ? { disclosureFrame } : {}) + }; +} + +/** Build an MSO mdoc credential object + * - For mdocs we expect the payload to include a `namespaces` map (draft-15 style) + */ +function buildMdocCredentialNew( + credentialRequest: CredentialRequestDtoLike, + templateRecord: any, + signerOptions?: SignerOption[] +): BuiltCredential { + const incomingPayload = { ...(credentialRequest.payload as Record) }; + + // // If caller provided already-namespaced payload, keep it; otherwise build a namespaces map + // const workingPayload = { ...incomingPayload }; + // if (!workingPayload.namespaces) { + // const namespacesMap: Record> = {}; + // // collect claims that match attribute names into the chosen namespace + // for (const claimName of Object.keys(normalizedAttributes)) { + // if (Object.prototype.hasOwnProperty.call(incomingPayload, claimName)) { + // namespacesMap[defaultNamespace] = namespacesMap[defaultNamespace] ?? {}; + // namespacesMap[defaultNamespace][claimName] = (incomingPayload as any)[claimName]; + // // remove original flattened claim to avoid duplication + // delete (workingPayload as any)[claimName]; + // } + // } + // if (0 < Object.keys(namespacesMap).length) { + // (workingPayload as any).namespaces = namespacesMap; + // } + // } else { + // // ensure namespaces is a plain object + // if (!isPlainRecord((workingPayload as any).namespaces)) { + // throw new Error(`Invalid mdoc payload: 'namespaces' must be an object`); + // } + // } + + const apiFormat = mapDbFormatToApiFormat(templateRecord.format); + const idSuffix = formatSuffix(apiFormat); + const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; + + return { + credentialSupportedId, + signerOptions: signerOptions ? signerOptions[0] : undefined, + format: apiFormat, + payload: incomingPayload, + ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) + }; +} + +export function buildCredentialOfferPayloadNew( + dto: CreateOidcCredentialOfferDtoLike, + templates: credential_templates[], + issuerDetails?: { + publicId: string; + authorizationServerUrl?: string; + }, + signerOptions?: SignerOption[] +): CredentialOfferPayload { + // Index templates by id + const templatesById = new Map(templates.map((template) => [template.id, template])); + + // Validate template ids + const missingTemplateIds = dto.credentials.map((c) => c.templateId).filter((id) => !templatesById.has(id)); + if (missingTemplateIds.length) { + throw new Error(`Unknown template ids: ${missingTemplateIds.join(', ')}`); + } + + // Build each credential using the template's format + const builtCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { + const templateRecord = templatesById.get(credentialRequest.templateId)!; + + const validationError = validatePayloadAgainstTemplate(templateRecord, credentialRequest.payload); + if (!validationError.valid) { + throw new UnprocessableEntityException(`${validationError.errors.join(', ')}`); + } + + const templateFormat = (templateRecord as any).format ?? 'vc+sd-jwt'; + const apiFormat = mapDbFormatToApiFormat(templateFormat); + + if (apiFormat === CredentialFormat.SdJwtVc) { + return buildSdJwtCredentialNew(credentialRequest, templateRecord, signerOptions); + } + if (apiFormat === CredentialFormat.Mdoc) { + return buildMdocCredentialNew(credentialRequest, templateRecord, signerOptions); + } + throw new Error(`Unsupported template format for ${templateFormat}`); + }); + + // Base envelope: allow explicit publicIssuerId from DTO or fallback to issuerDetails.publicId + const publicIssuerIdFromDto = dto.publicIssuerId; + const publicIssuerIdFromIssuerDetails = issuerDetails?.publicId; + const finalPublicIssuerId = publicIssuerIdFromDto ?? publicIssuerIdFromIssuerDetails; + + const baseEnvelope: BuiltCredentialOfferBase = { + credentials: builtCredentials, + ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}) + }; + + // Determine which authorization flow to return: + // Priority: + // 1) If issuerDetails.authorizationServerUrl is provided, return preAuthorizedCodeFlowConfig using DEFAULT_TXCODE + // 2) Else fall back to flows present in DTO (still enforce XOR) + const overrideAuthorizationServerUrl = issuerDetails?.authorizationServerUrl; + if (overrideAuthorizationServerUrl) { + if ('string' !== typeof overrideAuthorizationServerUrl || '' === overrideAuthorizationServerUrl.trim()) { + throw new Error('issuerDetails.authorizationServerUrl must be a non-empty string when provided'); + } + return { + ...baseEnvelope, + preAuthorizedCodeFlowConfig: { + txCode: DEFAULT_TXCODE, + authorizationServerUrl: overrideAuthorizationServerUrl + } + }; + } + + // No override provided — use what DTO carries (must be XOR) + const hasPreAuthFromDto = Boolean(dto.preAuthorizedCodeFlowConfig); + const hasAuthCodeFromDto = Boolean(dto.authorizationCodeFlowConfig); + if (hasPreAuthFromDto === hasAuthCodeFromDto) { + throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); + } + if (hasPreAuthFromDto) { + return { + ...baseEnvelope, + preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! + }; + } + return { + ...baseEnvelope, + authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! + }; +} diff --git a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts index 597b2fd06..ee4ae452c 100644 --- a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -332,38 +332,98 @@ export function encodeIssuerPublicId(publicIssuerId: string): string { ///--------------------------------------------------------- -function buildClaimsFromAttributes(attributes: CredentialAttribute[], parentPath: string[] = []): Claim[] { - const claims: Claim[] = []; +// function buildClaimsFromAttributesWithPath(attributes: CredentialAttribute[], parentPath: string[] = []): Claim[] { +// const claims: Claim[] = []; - for (const attr of attributes) { - const currentPath = [...parentPath, attr.key]; +// for (const attr of attributes) { +// const currentPath = [...parentPath, attr.key]; + +// // 1️⃣ Add the parent attribute itself if it has display or mandatory metadata +// if ((attr.display && 0 < attr.display.length) || attr.mandatory) { +// const parentClaim: Claim = { path: currentPath }; + +// if (attr.display?.length) { +// parentClaim.display = attr.display.map((d) => ({ +// name: d.name, +// locale: d.locale +// })); +// } + +// if (attr.mandatory) { +// parentClaim.mandatory = true; +// } - // 1️⃣ Add the parent attribute itself if it has display or mandatory metadata - if ((attr.display && 0 < attr.display.length) || attr.mandatory) { - const parentClaim: Claim = { path: currentPath }; +// claims.push(parentClaim); +// } + +// // 2️⃣ If this attribute has nested children, recurse into them +// if (attr.children && 0 < attr.children.length) { +// claims.push(...buildClaimsFromAttributes(attr.children, currentPath)); +// } +// } +// return claims; +// } - if (attr.display?.length) { - parentClaim.display = attr.display.map((d) => ({ - name: d.name, - locale: d.locale - })); - } +/** + * Recursively builds a nested claims object from a list of attributes. + */ +function buildNestedClaims(attributes: CredentialAttribute[]): Record { + const claims: Record = {}; - if (attr.mandatory) { - parentClaim.mandatory = true; - } + for (const attr of attributes) { + const node: Claim = {}; + + // ✅ include display info + if (attr.display?.length) { + node.display = attr.display.map((d) => ({ + name: d.name, + locale: d.locale + })); + } - claims.push(parentClaim); + // ✅ include mandatory flag + if (attr.mandatory) { + node.mandatory = true; } - // 2️⃣ If this attribute has nested children, recurse into them - if (attr.children && 0 < attr.children.length) { - claims.push(...buildClaimsFromAttributes(attr.children, currentPath)); + // ✅ handle nested children recursively + if (attr.children?.length) { + const childClaims = buildNestedClaims(attr.children); + Object.assign(node, childClaims); // merge children into current node } + + claims[attr.key] = node; } + return claims; } +/** + * Builds claims object for both SD-JWT and MDOC credential templates. + */ +//TODO: Remove any type +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function buildClaimsFromTemplate(template: SdJwtTemplate | MdocTemplate): Record { + // ✅ MDOC case — handle namespaces + if ((template as MdocTemplate).namespaces) { + const mdocTemplate = template as MdocTemplate; + + //TODO: Remove any type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const claims: Record = {}; + + for (const ns of mdocTemplate.namespaces) { + claims[ns.namespace] = buildNestedClaims(ns.attributes); + } + + return claims; + } + + // ✅ SD-JWT case — flat attributes + const sdjwtTemplate = template as SdJwtTemplate; + return buildNestedClaims(sdjwtTemplate.attributes); +} + //TODO: Fix this eslint issue // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function buildSdJwtCredentialConfig(name: string, template: SdJwtTemplate) { @@ -373,7 +433,7 @@ export function buildSdJwtCredentialConfig(name: string, template: SdJwtTemplate const configKey = `${name}-${formatSuffix}`; const credentialScope = `openid4vc:${template.vct}-${formatSuffix}`; - const claims = buildClaimsFromAttributes(template.attributes); + const claims = buildClaimsFromTemplate(template); return { [configKey]: { @@ -394,7 +454,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) { - const claims: Claim[] = []; + //const claims: Claim[] = []; const formatSuffix = 'mdoc'; @@ -402,9 +462,11 @@ export function buildMdocCredentialConfig(name: string, template: MdocTemplate) const configKey = `${name}-${formatSuffix}`; const credentialScope = `openid4vc:${template.doctype}-${formatSuffix}`; - for (const ns of template.namespaces) { - claims.push(...buildClaimsFromAttributes(ns.attributes, [ns.namespace])); - } + const claims = buildClaimsFromTemplate(template); + + // for (const ns of template.namespaces) { + // claims.push(...buildClaimsFromAttributes(ns.attributes, [ns.namespace])); + // } return { [configKey]: { diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 973558471..68b84ef85 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -54,6 +54,7 @@ import { } from '../interfaces/oid4vc-issuer-sessions.interfaces'; import { buildCredentialOfferPayload, + buildCredentialOfferPayloadNew, buildCredentialOfferUrl, CredentialOfferPayload } from '../libs/helpers/credential-sessions.builder'; @@ -526,7 +527,7 @@ export class Oid4vcIssuanceService { //TODO: add logic to pass the issuer info const issuerDetailsFromDb = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); const { publicIssuerId, authorizationServerUrl } = issuerDetailsFromDb || {}; - const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayload( + const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayloadNew( createOidcCredentialOffer, // getAllOfferTemplates, From eaf18d410075e44eeb32f7ce2099ee237dfacca2 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Tue, 28 Oct 2025 12:59:49 +0530 Subject: [PATCH 08/13] refactor: create and update template Signed-off-by: Tipu_Singh --- .../oid4vc-issuance/dtos/oid4vc-issuer.dto.ts | 2 +- .../src/oid4vc-issuance.service.ts | 114 +++++++++++++----- libs/common/src/response-messages/index.ts | 6 +- 3 files changed, 89 insertions(+), 33 deletions(-) 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 index 45b360cae..cd99e117b 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts @@ -225,7 +225,7 @@ export class IssuerCreationDto { display: IssuerDisplayDto[]; @ApiProperty({ - example: 'https://issuer.credebl.io/oid4vci', + example: 'https://issuer.credebl.io', description: 'Base URL of the Authorization Server supporting OID4VC issuance flows' }) @IsUrl({ require_tld: false }, { message: 'authorizationServerUrl must be a valid URL' }) diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 68b84ef85..d4f8f193e 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -322,19 +322,39 @@ export class Oid4vcIssuanceService { // if (doctype) { // opts = { ...opts, doctype }; // } - const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); - console.log(`service - createTemplate: `, JSON.stringify(issuerTemplateConfig)); - 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); + let createTemplateOnAgent; + try { + const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); + console.log(`service - createTemplate: `, JSON.stringify(issuerTemplateConfig)); + 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 + ); + createTemplateOnAgent = await this._createOIDCTemplate(issuerTemplateConfig, url, orgId); + } catch (agentError) { + try { + await this.oid4vcIssuanceRepository.deleteTemplate(createdTemplate.id); + this.logger.log(`${ResponseMessages.oidcTemplate.success.deleteTemplate}${createdTemplate.id}`); + throw new RpcException(agentError?.response ?? agentError); + } catch (cleanupError) { + this.logger.error( + `${ResponseMessages.oidcTemplate.error.failedDeleteTemplate}${createdTemplate.id} deleteError=${JSON.stringify( + cleanupError + )} originalAgentError=${JSON.stringify(agentError)}` + ); + throw new RpcException('Template creation failed and cleanup also failed'); + } } - const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_TEMPLATE, issuerDetails.publicIssuerId); - const createTemplateOnAgent = await this._createOIDCTemplate(issuerTemplateConfig, url, orgId); console.log('createTemplateOnAgent::::::::::::::', createTemplateOnAgent); if (!createTemplateOnAgent) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); @@ -385,25 +405,59 @@ export class Oid4vcIssuanceService { 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); + try { + 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); + const createTemplateOnAgent = await this._createOIDCTemplate(issuerTemplateConfig, url, orgId); + if (!createTemplateOnAgent) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + } catch (agentError) { + this.logger.error(`[updateTemplate] - error updating template on agent: ${JSON.stringify(agentError)}`); + try { + const rollbackPayload = { + name: template.name, + description: template.description, + format: template.format, + canBeRevoked: template.canBeRevoked, + attributes: template.attributes, + appearance: template.appearance, + issuerId: template.issuerId + }; + await this.oid4vcIssuanceRepository.updateTemplate(templateId, rollbackPayload); + this.logger.log(`Rolled back template ${templateId} to previous state after agent error`); + throw new RpcException(agentError?.response ?? agentError); + } catch (revertError) { + this.logger.error( + `[updateTemplate] - rollback failed for template ${templateId}: ${JSON.stringify(revertError)} originalAgentError=${JSON.stringify( + agentError + )}` + ); + const wrappedError = { + message: 'Template update failed and rollback also failed', + agentError: agentError?.response ?? agentError, + rollbackError: revertError?.response ?? revertError + }; + throw new RpcException(wrappedError); + } } return updatedTemplate; diff --git a/libs/common/src/response-messages/index.ts b/libs/common/src/response-messages/index.ts index 9eb59b160..e96cedc22 100644 --- a/libs/common/src/response-messages/index.ts +++ b/libs/common/src/response-messages/index.ts @@ -525,7 +525,8 @@ export const ResponseMessages = { update: 'OID4VC template updated successfully.', delete: 'OID4VC template deleted successfully.', fetch: 'OID4VC template(s) fetched successfully.', - getById: 'OID4VC template details fetched successfully.' + getById: 'OID4VC template details fetched successfully.', + deleteTemplate: '[createTemplate] compensating delete succeeded for templateId=${templateId}' }, error: { notFound: 'OID4VC template not found.', @@ -536,7 +537,8 @@ export const ResponseMessages = { issuerDisplayNotFound: 'Issuer display not found.', issuerDetailsNotFound: 'Issuer details not found.', templateNameAlreadyExist: 'Template name already exists for this issuer.', - deleteTemplate: 'Error while deleting template.' + deleteTemplate: 'Error while deleting template.', + failedDeleteTemplate: '[createTemplate] compensating delete FAILED for templateId=' } }, oidcIssuerSession: { From b304eaf6fc5106e38ebf23411644125d4c9acbd8 Mon Sep 17 00:00:00 2001 From: Rinkal Bhojani Date: Tue, 28 Oct 2025 15:36:24 +0530 Subject: [PATCH 09/13] fix: added validityInfo for credential validity check Signed-off-by: Rinkal Bhojani --- .../dtos/issuer-sessions.dto.ts | 69 +- .../oid4vc-issuer-sessions.interfaces.ts | 14 +- .../interfaces/oid4vc-template.interfaces.ts | 2 +- .../helpers/credential-sessions.builder.ts | 750 ++++++++++-------- .../src/oid4vc-issuance.service.ts | 13 +- libs/common/src/date-only.ts | 38 + libs/common/src/response-messages/index.ts | 3 +- 7 files changed, 529 insertions(+), 360 deletions(-) create mode 100644 libs/common/src/date-only.ts 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 8d9630964..2325de600 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 @@ -21,6 +21,7 @@ import { } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; +import { dateToSeconds } from '@credebl/common/date-only'; /* ========= disclosureFrame custom validator ========= */ function isDisclosureFrameValue(v: unknown): boolean { @@ -117,6 +118,24 @@ function ExactlyOneOf(keys: string[], options?: ValidationOptions) { return Validate(ExactlyOneOfConstraint, keys, options); } +export class ValidityInfo { + @ApiProperty({ + example: '2025-04-23T14:34:09.188Z', + required: true + }) + @IsString() + @IsNotEmpty() + validFrom: Date; + + @ApiProperty({ + example: '2026-05-03T14:34:09.188Z', + required: true + }) + @IsString() + @IsNotEmpty() + validUntil: Date; +} + /* ========= Request DTOs ========= */ export class CredentialRequestDto { @ApiProperty({ @@ -137,13 +156,20 @@ export class CredentialRequestDto { payload!: Record; @ApiPropertyOptional({ - description: 'Selective disclosure: claim -> boolean (or nested map)', - example: { name: true, DOB: true, additionalProp3: false }, + example: { validFrom: '2025-04-23T14:34:09.188Z', validUntil: '2026-05-03T14:34:09.188Z' }, required: false }) @IsOptional() - @IsDisclosureFrame() - disclosureFrame?: Record>; + validityInfo?: ValidityInfo; + + // @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 { @@ -257,20 +283,33 @@ export class CredentialDto { @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' + 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' } }, - validityInfo: { - validFrom: '2025-04-23T14:34:09.188Z', - validUntil: '2026-05-03T14:34:09.188Z' + { + full_name: 'Garry', + address: { + street_address: 'M.G. Road', + locality: 'Pune', + country: 'India' + }, + iat: 1698151532, + nbf: dateToSeconds(new Date()), + exp: dateToSeconds(new Date(Date.now() + 5 * 365 * 24 * 60 * 60 * 1000)) } - } + ] }) @ValidateNested() payload: object; diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts index 69e8f97d1..afe54b472 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts @@ -30,19 +30,19 @@ export enum AuthenticationType { export type DisclosureFrame = Record>; export interface CredentialPayload { - validityInfo: { - validFrom: Date; - validUntil: Date; - }; [key: string]: unknown; // extensible for mDoc or other formats } export interface CredentialRequest { - credentialSupportedId?: string; + // credentialSupportedId?: string; templateId: string; - format: CredentialFormat; // "vc+sd-jwt" | "mso_mdoc" + // format: CredentialFormat; // "vc+sd-jwt" | "mso_mdoc" payload: CredentialPayload; // user-supplied payload (without vct) - disclosureFrame?: DisclosureFrame; // only relevant for vc+sd-jwt + // disclosureFrame?: DisclosureFrame; // only relevant for vc+sd-jwt + validityInfo?: { + validFrom: Date; + validUntil: Date; + }; } export interface CreateOidcCredentialOffer { diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts index 0e5c32b0b..1d1ee9510 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts @@ -43,7 +43,7 @@ export interface ClaimDisplay { } export interface Claim { - path: string[]; + path?: string[]; display?: ClaimDisplay[]; mandatory?: boolean; } diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts index 2abc31e68..52cf788c6 100644 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -1,6 +1,6 @@ /* 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 { Prisma, credential_templates } from '@prisma/client'; +import { credential_templates } from '@prisma/client'; import { GetAllCredentialOffer, SignerOption } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; import { CredentialFormat } from '@credebl/enum/enum'; import { @@ -9,19 +9,22 @@ import { SdJwtTemplate } from 'apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces'; import { UnprocessableEntityException } from '@nestjs/common'; +import { ResponseMessages } from '@credebl/common/response-messages'; +import { X509CertificateRecord } from '@credebl/common/interfaces/x509.interface'; +import { dateToSeconds } from '@credebl/common/date-only'; /* ============================================================================ Domain Types ============================================================================ */ -type ValueType = 'string' | 'date' | 'number' | 'boolean' | 'integer' | string; +// type ValueType = 'string' | 'date' | 'number' | 'boolean' | 'integer' | string; -interface TemplateAttribute { - display?: { name: string; locale: string }[]; - mandatory?: boolean; - value_type?: ValueType; -} -type TemplateAttributes = Record; +// interface TemplateAttribute { +// display?: { name: string; locale: string }[]; +// mandatory?: boolean; +// value_type?: ValueType; +// } +// type TemplateAttributes = Record; export enum SignerMethodOption { DID = 'did', @@ -30,10 +33,16 @@ export enum SignerMethodOption { export type DisclosureFrame = Record>; +export interface validityInfo { + validFrom: Date; + validUntil: Date; +} + export interface CredentialRequestDtoLike { templateId: string; payload: Record; - disclosureFrame?: DisclosureFrame; + validityInfo?: validityInfo; + // disclosureFrame?: DisclosureFrame; } export interface CreateOidcCredentialOfferDtoLike { @@ -107,10 +116,10 @@ export const DEFAULT_TXCODE = { Small Utilities ============================================================================ */ -const isNil = (value: unknown): value is null | undefined => null == value; -const isEmptyString = (value: unknown): boolean => 'string' === typeof value && '' === value.trim(); -const isPlainRecord = (value: unknown): value is Record => - Boolean(value) && 'object' === typeof value && !Array.isArray(value); +// const isNil = (value: unknown): value is null | undefined => null == value; +// const isEmptyString = (value: unknown): boolean => 'string' === typeof value && '' === value.trim(); +// const isPlainRecord = (value: unknown): value is Record => +// Boolean(value) && 'object' === typeof value && !Array.isArray(value); /** Map DB format string -> API enum */ function mapDbFormatToApiFormat(dbFormat: string): CredentialFormat { @@ -141,78 +150,78 @@ function formatSuffix(apiFormat: CredentialFormat): 'sdjwt' | 'mdoc' { * - map: Record * - array: Array<{ path: string[], mandatory?: boolean, value_type?: string, display?: ... }> */ -function normalizeTemplateAttributes(rawAttributes: Prisma.JsonValue): TemplateAttributes { - // if already a plain record keyed by claim name, cast and return - if (isPlainRecord(rawAttributes) && !Array.isArray(rawAttributes)) { - // We still guard that values look like TemplateAttribute, but be permissive. - return rawAttributes as TemplateAttributes; - } - - // If attributes are an array (draft-15 style), convert to map - if (Array.isArray(rawAttributes)) { - const attributesArray = rawAttributes as unknown as any[]; - const normalizedMap: TemplateAttributes = {}; - for (const attributeEntry of attributesArray) { - if (!isPlainRecord(attributeEntry)) { - continue; // skip invalid entries - } - - // draft-15: path is array like ["org.iso.23220.photoID.1","given_name"] or ["name"] - const pathValue = attributeEntry.path; - if (!Array.isArray(pathValue) || 0 === pathValue.length) { - continue; - } - - // prefer last path element as local claim name (keeps namespace support) - const claimName = String(pathValue[pathValue.length - 1]); - - normalizedMap[claimName] = { - mandatory: Boolean(attributeEntry.mandatory), - value_type: attributeEntry.value_type ? String(attributeEntry.value_type) : undefined, - display: Array.isArray(attributeEntry.display) - ? attributeEntry.display.map((d: any) => ({ name: d.name, locale: d.locale })) - : undefined - }; - } - return normalizedMap; - } - - // if it's a JSON string, try parse - if ('string' === typeof rawAttributes) { - try { - const parsed = JSON.parse(rawAttributes); - return normalizeTemplateAttributes(parsed as Prisma.JsonValue); - } catch { - throw new Error('Invalid template.attributes JSON string'); - } - } - - throw new Error('Unrecognized template.attributes shape'); -} +// function normalizeTemplateAttributes(rawAttributes: Prisma.JsonValue): TemplateAttributes { +// // if already a plain record keyed by claim name, cast and return +// if (isPlainRecord(rawAttributes) && !Array.isArray(rawAttributes)) { +// // We still guard that values look like TemplateAttribute, but be permissive. +// return rawAttributes as TemplateAttributes; +// } + +// // If attributes are an array (draft-15 style), convert to map +// if (Array.isArray(rawAttributes)) { +// const attributesArray = rawAttributes as unknown as any[]; +// const normalizedMap: TemplateAttributes = {}; +// for (const attributeEntry of attributesArray) { +// if (!isPlainRecord(attributeEntry)) { +// continue; // skip invalid entries +// } + +// // draft-15: path is array like ["org.iso.23220.photoID.1","given_name"] or ["name"] +// const pathValue = attributeEntry.path; +// if (!Array.isArray(pathValue) || 0 === pathValue.length) { +// continue; +// } + +// // prefer last path element as local claim name (keeps namespace support) +// const claimName = String(pathValue[pathValue.length - 1]); + +// normalizedMap[claimName] = { +// mandatory: Boolean(attributeEntry.mandatory), +// value_type: attributeEntry.value_type ? String(attributeEntry.value_type) : undefined, +// display: Array.isArray(attributeEntry.display) +// ? attributeEntry.display.map((d: any) => ({ name: d.name, locale: d.locale })) +// : undefined +// }; +// } +// return normalizedMap; +// } + +// // if it's a JSON string, try parse +// if ('string' === typeof rawAttributes) { +// try { +// const parsed = JSON.parse(rawAttributes); +// return normalizeTemplateAttributes(parsed as Prisma.JsonValue); +// } catch { +// throw new Error('Invalid template.attributes JSON string'); +// } +// } + +// throw new Error('Unrecognized template.attributes shape'); +// } /* ============================================================================ Validation: Mandatory claims ============================================================================ */ -function assertMandatoryClaims( - payload: Record, - attributes: TemplateAttributes, - context: { templateId: string } -): void { - const missingClaims: string[] = []; - for (const [claimName, attributeDefinition] of Object.entries(attributes)) { - if (!attributeDefinition?.mandatory) { - continue; - } - const claimValue = payload[claimName]; - if (isNil(claimValue) || isEmptyString(claimValue)) { - missingClaims.push(claimName); - } - } - if (missingClaims.length) { - throw new Error(`Missing mandatory claims for template "${context.templateId}": ${missingClaims.join(', ')}`); - } -} +// function assertMandatoryClaims( +// payload: Record, +// attributes: TemplateAttributes, +// context: { templateId: string } +// ): void { +// const missingClaims: string[] = []; +// for (const [claimName, attributeDefinition] of Object.entries(attributes)) { +// if (!attributeDefinition?.mandatory) { +// continue; +// } +// const claimValue = payload[claimName]; +// if (isNil(claimValue) || isEmptyString(claimValue)) { +// missingClaims.push(claimName); +// } +// } +// if (missingClaims.length) { +// throw new Error(`Missing mandatory claims for template "${context.templateId}": ${missingClaims.join(', ')}`); +// } +// } /* ============================================================================ Per-format credential builders (separated for readability) @@ -221,120 +230,120 @@ function assertMandatoryClaims( ============================================================================ */ /** Build an SD-JWT credential object */ -function buildSdJwtCredential( - credentialRequest: CredentialRequestDtoLike, - templateRecord: credential_templates, - signerOptions?: SignerOption[] -): BuiltCredential { - // For SD-JWT format we expect payload to be a flat map of claims (no namespaces) - const payloadCopy = { ...(credentialRequest.payload as Record) }; - // Validate mandatory claims using normalized attributes from templateRecord - const normalizedAttributes = normalizeTemplateAttributes(templateRecord.attributes); - assertMandatoryClaims(payloadCopy, normalizedAttributes, { templateId: credentialRequest.templateId }); - - // strip vct if present per requirement - delete payloadCopy.vct; - - const apiFormat = mapDbFormatToApiFormat(templateRecord.format); - const idSuffix = formatSuffix(apiFormat); - const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - - return { - credentialSupportedId, - signerOptions: signerOptions ? signerOptions[0] : undefined, - format: apiFormat, - payload: payloadCopy, - ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) - }; -} - -/** Build an MSO mdoc credential object - * - For mdocs we expect the payload to include a `namespaces` map (draft-15 style) - */ -function buildMdocCredential( - credentialRequest: CredentialRequestDtoLike, - templateRecord: credential_templates, - signerOptions?: SignerOption[] -): BuiltCredential { - const incomingPayload = { ...(credentialRequest.payload as Record) }; - - // Normalize attributes and ensure we know the expected claim names - const normalizedAttributes = normalizeTemplateAttributes(templateRecord.attributes); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const templateDoctype: string | undefined = (templateRecord as any).doctype ?? undefined; - const defaultNamespace = templateDoctype ?? templateRecord.name; - - // If caller provided already-namespaced payload, keep it; otherwise build a namespaces map - const workingPayload = { ...incomingPayload }; - if (!workingPayload.namespaces) { - const namespacesMap: Record> = {}; - // collect claims that match attribute names into the chosen namespace - for (const claimName of Object.keys(normalizedAttributes)) { - if (Object.prototype.hasOwnProperty.call(incomingPayload, claimName)) { - namespacesMap[defaultNamespace] = namespacesMap[defaultNamespace] ?? {}; - namespacesMap[defaultNamespace][claimName] = (incomingPayload as any)[claimName]; - // remove original flattened claim to avoid duplication - delete (workingPayload as any)[claimName]; - } - } - if (0 < Object.keys(namespacesMap).length) { - (workingPayload as any).namespaces = namespacesMap; - } - } else { - // ensure namespaces is a plain object - if (!isPlainRecord((workingPayload as any).namespaces)) { - throw new Error(`Invalid mdoc payload: 'namespaces' must be an object`); - } - } - - // Validate mandatory claims exist somewhere inside namespaces - const missingMandatoryClaims: string[] = []; - for (const [claimName, attributeDef] of Object.entries(normalizedAttributes)) { - if (!attributeDef?.mandatory) { - continue; - } - - let found = false; - const namespacesObj = (workingPayload as any).namespaces as Record; - if (namespacesObj && isPlainRecord(namespacesObj)) { - for (const nsKey of Object.keys(namespacesObj)) { - const nsContent = namespacesObj[nsKey]; - if (nsContent && Object.prototype.hasOwnProperty.call(nsContent, claimName)) { - const value = nsContent[claimName]; - if (!isNil(value) && !('string' === typeof value && '' === value.trim())) { - found = true; - break; - } - } - } - } - if (!found) { - missingMandatoryClaims.push(claimName); - } - } - if (missingMandatoryClaims.length) { - throw new Error( - `Missing mandatory namespaced claims for template "${credentialRequest.templateId}": ${missingMandatoryClaims.join( - ', ' - )}` - ); - } - - // strip vct if present - delete (workingPayload as Record).vct; - - const apiFormat = mapDbFormatToApiFormat(templateRecord.format); - const idSuffix = formatSuffix(apiFormat); - const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - - return { - credentialSupportedId, - signerOptions: signerOptions ? signerOptions[0] : undefined, - format: apiFormat, - payload: workingPayload, - ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) - }; -} +// function buildSdJwtCredential( +// credentialRequest: CredentialRequestDtoLike, +// templateRecord: credential_templates, +// signerOptions?: SignerOption[] +// ): BuiltCredential { +// // For SD-JWT format we expect payload to be a flat map of claims (no namespaces) +// const payloadCopy = { ...(credentialRequest.payload as Record) }; +// // Validate mandatory claims using normalized attributes from templateRecord +// const normalizedAttributes = normalizeTemplateAttributes(templateRecord.attributes); +// assertMandatoryClaims(payloadCopy, normalizedAttributes, { templateId: credentialRequest.templateId }); + +// // strip vct if present per requirement +// delete payloadCopy.vct; + +// const apiFormat = mapDbFormatToApiFormat(templateRecord.format); +// const idSuffix = formatSuffix(apiFormat); +// const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; + +// return { +// credentialSupportedId, +// signerOptions: signerOptions ? signerOptions[0] : undefined, +// format: apiFormat, +// payload: payloadCopy, +// ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) +// }; +// } + +// /** Build an MSO mdoc credential object +// * - For mdocs we expect the payload to include a `namespaces` map (draft-15 style) +// */ +// function buildMdocCredential( +// credentialRequest: CredentialRequestDtoLike, +// templateRecord: credential_templates, +// signerOptions?: SignerOption[] +// ): BuiltCredential { +// const incomingPayload = { ...(credentialRequest.payload as Record) }; + +// // Normalize attributes and ensure we know the expected claim names +// const normalizedAttributes = normalizeTemplateAttributes(templateRecord.attributes); +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// const templateDoctype: string | undefined = (templateRecord as any).doctype ?? undefined; +// const defaultNamespace = templateDoctype ?? templateRecord.name; + +// // If caller provided already-namespaced payload, keep it; otherwise build a namespaces map +// const workingPayload = { ...incomingPayload }; +// if (!workingPayload.namespaces) { +// const namespacesMap: Record> = {}; +// // collect claims that match attribute names into the chosen namespace +// for (const claimName of Object.keys(normalizedAttributes)) { +// if (Object.prototype.hasOwnProperty.call(incomingPayload, claimName)) { +// namespacesMap[defaultNamespace] = namespacesMap[defaultNamespace] ?? {}; +// namespacesMap[defaultNamespace][claimName] = (incomingPayload as any)[claimName]; +// // remove original flattened claim to avoid duplication +// delete (workingPayload as any)[claimName]; +// } +// } +// if (0 < Object.keys(namespacesMap).length) { +// (workingPayload as any).namespaces = namespacesMap; +// } +// } else { +// // ensure namespaces is a plain object +// if (!isPlainRecord((workingPayload as any).namespaces)) { +// throw new Error(`Invalid mdoc payload: 'namespaces' must be an object`); +// } +// } + +// // Validate mandatory claims exist somewhere inside namespaces +// const missingMandatoryClaims: string[] = []; +// for (const [claimName, attributeDef] of Object.entries(normalizedAttributes)) { +// if (!attributeDef?.mandatory) { +// continue; +// } + +// let found = false; +// const namespacesObj = (workingPayload as any).namespaces as Record; +// if (namespacesObj && isPlainRecord(namespacesObj)) { +// for (const nsKey of Object.keys(namespacesObj)) { +// const nsContent = namespacesObj[nsKey]; +// if (nsContent && Object.prototype.hasOwnProperty.call(nsContent, claimName)) { +// const value = nsContent[claimName]; +// if (!isNil(value) && !('string' === typeof value && '' === value.trim())) { +// found = true; +// break; +// } +// } +// } +// } +// if (!found) { +// missingMandatoryClaims.push(claimName); +// } +// } +// if (missingMandatoryClaims.length) { +// throw new Error( +// `Missing mandatory namespaced claims for template "${credentialRequest.templateId}": ${missingMandatoryClaims.join( +// ', ' +// )}` +// ); +// } + +// // strip vct if present +// delete (workingPayload as Record).vct; + +// const apiFormat = mapDbFormatToApiFormat(templateRecord.format); +// const idSuffix = formatSuffix(apiFormat); +// const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; + +// return { +// credentialSupportedId, +// signerOptions: signerOptions ? signerOptions[0] : undefined, +// format: apiFormat, +// payload: workingPayload, +// ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) +// }; +// } /* ============================================================================ Main Builder: buildCredentialOfferPayload @@ -342,137 +351,137 @@ function buildMdocCredential( - Accepts `authorizationServerUrl` parameter; txCode is a constant above ============================================================================ */ -export function buildCredentialOfferPayload( - dto: CreateOidcCredentialOfferDtoLike, - templates: credential_templates[], - issuerDetails?: { - publicId: string; - authorizationServerUrl?: string; - }, - signerOptions?: SignerOption[] -): CredentialOfferPayload { - // Index templates by id - const templatesById = new Map(templates.map((template) => [template.id, template])); - - // Validate template ids - const missingTemplateIds = dto.credentials.map((c) => c.templateId).filter((id) => !templatesById.has(id)); - if (missingTemplateIds.length) { - throw new Error(`Unknown template ids: ${missingTemplateIds.join(', ')}`); - } - - // Build each credential using the template's format - const builtCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { - const templateRecord = templatesById.get(credentialRequest.templateId)!; - // we normalize attributes to support both draft-13 (map) and draft-15 (array) shapes - normalizeTemplateAttributes(templateRecord.attributes); - - const templateFormat = (templateRecord as any).format ?? 'vc+sd-jwt'; - const apiFormat = mapDbFormatToApiFormat(templateFormat); - - if (apiFormat === CredentialFormat.SdJwtVc) { - return buildSdJwtCredential(credentialRequest, templateRecord, signerOptions); - } - if (apiFormat === CredentialFormat.Mdoc) { - return buildMdocCredential(credentialRequest, templateRecord, signerOptions); - } - throw new Error(`Unsupported template format for ${templateFormat}`); - }); - - // Base envelope: allow explicit publicIssuerId from DTO or fallback to issuerDetails.publicId - const publicIssuerIdFromDto = dto.publicIssuerId; - const publicIssuerIdFromIssuerDetails = issuerDetails?.publicId; - const finalPublicIssuerId = publicIssuerIdFromDto ?? publicIssuerIdFromIssuerDetails; - - const baseEnvelope: BuiltCredentialOfferBase = { - credentials: builtCredentials, - ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}) - }; - - // Determine which authorization flow to return: - // Priority: - // 1) If issuerDetails.authorizationServerUrl is provided, return preAuthorizedCodeFlowConfig using DEFAULT_TXCODE - // 2) Else fall back to flows present in DTO (still enforce XOR) - const overrideAuthorizationServerUrl = issuerDetails?.authorizationServerUrl; - if (overrideAuthorizationServerUrl) { - if ('string' !== typeof overrideAuthorizationServerUrl || '' === overrideAuthorizationServerUrl.trim()) { - throw new Error('issuerDetails.authorizationServerUrl must be a non-empty string when provided'); - } - return { - ...baseEnvelope, - preAuthorizedCodeFlowConfig: { - txCode: DEFAULT_TXCODE, - authorizationServerUrl: overrideAuthorizationServerUrl - } - }; - } - - // No override provided — use what DTO carries (must be XOR) - const hasPreAuthFromDto = Boolean(dto.preAuthorizedCodeFlowConfig); - const hasAuthCodeFromDto = Boolean(dto.authorizationCodeFlowConfig); - if (hasPreAuthFromDto === hasAuthCodeFromDto) { - throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); - } - if (hasPreAuthFromDto) { - return { - ...baseEnvelope, - preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! - }; - } - return { - ...baseEnvelope, - authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! - }; -} +// export function buildCredentialOfferPayload( +// dto: CreateOidcCredentialOfferDtoLike, +// templates: credential_templates[], +// issuerDetails?: { +// publicId: string; +// authorizationServerUrl?: string; +// }, +// signerOptions?: SignerOption[] +// ): CredentialOfferPayload { +// // Index templates by id +// const templatesById = new Map(templates.map((template) => [template.id, template])); + +// // Validate template ids +// const missingTemplateIds = dto.credentials.map((c) => c.templateId).filter((id) => !templatesById.has(id)); +// if (missingTemplateIds.length) { +// throw new Error(`Unknown template ids: ${missingTemplateIds.join(', ')}`); +// } + +// // Build each credential using the template's format +// const builtCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { +// const templateRecord = templatesById.get(credentialRequest.templateId)!; +// // we normalize attributes to support both draft-13 (map) and draft-15 (array) shapes +// normalizeTemplateAttributes(templateRecord.attributes); + +// const templateFormat = (templateRecord as any).format ?? 'vc+sd-jwt'; +// const apiFormat = mapDbFormatToApiFormat(templateFormat); + +// if (apiFormat === CredentialFormat.SdJwtVc) { +// return buildSdJwtCredential(credentialRequest, templateRecord, signerOptions); +// } +// if (apiFormat === CredentialFormat.Mdoc) { +// return buildMdocCredential(credentialRequest, templateRecord, signerOptions); +// } +// throw new Error(`Unsupported template format for ${templateFormat}`); +// }); + +// // Base envelope: allow explicit publicIssuerId from DTO or fallback to issuerDetails.publicId +// const publicIssuerIdFromDto = dto.publicIssuerId; +// const publicIssuerIdFromIssuerDetails = issuerDetails?.publicId; +// const finalPublicIssuerId = publicIssuerIdFromDto ?? publicIssuerIdFromIssuerDetails; + +// const baseEnvelope: BuiltCredentialOfferBase = { +// credentials: builtCredentials, +// ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}) +// }; + +// // Determine which authorization flow to return: +// // Priority: +// // 1) If issuerDetails.authorizationServerUrl is provided, return preAuthorizedCodeFlowConfig using DEFAULT_TXCODE +// // 2) Else fall back to flows present in DTO (still enforce XOR) +// const overrideAuthorizationServerUrl = issuerDetails?.authorizationServerUrl; +// if (overrideAuthorizationServerUrl) { +// if ('string' !== typeof overrideAuthorizationServerUrl || '' === overrideAuthorizationServerUrl.trim()) { +// throw new Error('issuerDetails.authorizationServerUrl must be a non-empty string when provided'); +// } +// return { +// ...baseEnvelope, +// preAuthorizedCodeFlowConfig: { +// txCode: DEFAULT_TXCODE, +// authorizationServerUrl: overrideAuthorizationServerUrl +// } +// }; +// } + +// // No override provided — use what DTO carries (must be XOR) +// const hasPreAuthFromDto = Boolean(dto.preAuthorizedCodeFlowConfig); +// const hasAuthCodeFromDto = Boolean(dto.authorizationCodeFlowConfig); +// if (hasPreAuthFromDto === hasAuthCodeFromDto) { +// throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); +// } +// if (hasPreAuthFromDto) { +// return { +// ...baseEnvelope, +// preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! +// }; +// } +// return { +// ...baseEnvelope, +// authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! +// }; +// } /* ============================================================================ Update Credential Offer builder (keeps behavior, clearer names) ============================================================================ */ -export function buildUpdateCredentialOfferPayload( - dto: CreateOidcCredentialOfferDtoLike, - templates: credential_templates[] -): { credentials: BuiltCredential[] } { - const templatesById = new Map(templates.map((template) => [template.id, template])); - - const missingTemplateIds = dto.credentials.map((c) => c.templateId).filter((id) => !templatesById.has(id)); - if (missingTemplateIds.length) { - throw new Error(`Unknown template ids: ${missingTemplateIds.join(', ')}`); - } - - const normalizedCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { - const templateRecord = templatesById.get(credentialRequest.templateId)!; - - // Normalize attributes shape and ensure it's valid - const attributesMap = normalizeTemplateAttributes(templateRecord.attributes); - - // ensure payload keys match known attributes - const payloadKeys = Object.keys(credentialRequest.payload); - const invalidPayloadKeys = payloadKeys.filter((payloadKey) => !attributesMap[payloadKey]); - if (invalidPayloadKeys.length) { - throw new Error( - `Invalid attributes for template "${credentialRequest.templateId}": ${invalidPayloadKeys.join(', ')}` - ); - } - - // Validate mandatory claims - assertMandatoryClaims(credentialRequest.payload, attributesMap, { templateId: credentialRequest.templateId }); - - const selectedApiFormat = mapDbFormatToApiFormat(templateRecord.format); - const idSuffix = formatSuffix(selectedApiFormat); - const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - - return { - credentialSupportedId, - format: selectedApiFormat, - payload: credentialRequest.payload, - ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) - }; - }); - - return { - credentials: normalizedCredentials - }; -} +// export function buildUpdateCredentialOfferPayload( +// dto: CreateOidcCredentialOfferDtoLike, +// templates: credential_templates[] +// ): { credentials: BuiltCredential[] } { +// const templatesById = new Map(templates.map((template) => [template.id, template])); + +// const missingTemplateIds = dto.credentials.map((c) => c.templateId).filter((id) => !templatesById.has(id)); +// if (missingTemplateIds.length) { +// throw new Error(`Unknown template ids: ${missingTemplateIds.join(', ')}`); +// } + +// const normalizedCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { +// const templateRecord = templatesById.get(credentialRequest.templateId)!; + +// // Normalize attributes shape and ensure it's valid +// const attributesMap = normalizeTemplateAttributes(templateRecord.attributes); + +// // ensure payload keys match known attributes +// const payloadKeys = Object.keys(credentialRequest.payload); +// const invalidPayloadKeys = payloadKeys.filter((payloadKey) => !attributesMap[payloadKey]); +// if (invalidPayloadKeys.length) { +// throw new Error( +// `Invalid attributes for template "${credentialRequest.templateId}": ${invalidPayloadKeys.join(', ')}` +// ); +// } + +// // Validate mandatory claims +// assertMandatoryClaims(credentialRequest.payload, attributesMap, { templateId: credentialRequest.templateId }); + +// const selectedApiFormat = mapDbFormatToApiFormat(templateRecord.format); +// const idSuffix = formatSuffix(selectedApiFormat); +// const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; + +// return { +// credentialSupportedId, +// format: selectedApiFormat, +// payload: credentialRequest.payload, +// ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) +// }; +// }); + +// return { +// credentials: normalizedCredentials +// }; +// } export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer: GetAllCredentialOffer): string { const criteriaParams: string[] = []; @@ -576,17 +585,76 @@ function buildDisclosureFrameFromTemplate(template: { attributes: CredentialAttr return disclosureFrame; } +function validateCredentialDatesInCertificateWindow(credentialValidityInfo: validityInfo, certificate) { + // Extract dates from credential + const credentialValidFrom = new Date(credentialValidityInfo.validFrom); + const credentialValidTo = new Date(credentialValidityInfo.validUntil); + + // Extract dates from certificate + const certNotBefore = new Date(certificate.validFrom); + const certNotAfter = new Date(certificate.expiry); + + // Validate that credential dates are within certificate validity period + const isCredentialStartValid = credentialValidFrom >= certNotBefore; + const isCredentialEndValid = credentialValidTo <= certNotAfter; + const isCredentialDurationValid = credentialValidFrom <= credentialValidTo; + + return { + isValid: isCredentialStartValid && isCredentialEndValid && isCredentialDurationValid, + details: { + credentialStartValid: isCredentialStartValid, + credentialEndValid: isCredentialEndValid, + credentialDurationValid: isCredentialDurationValid, + credentialValidFrom: credentialValidFrom.toISOString(), + credentialValidTo: credentialValidTo.toISOString(), + certificateNotBefore: certNotBefore.toISOString(), + certificateNotAfter: certNotAfter.toISOString() + } + }; +} + function buildSdJwtCredentialNew( credentialRequest: CredentialRequestDtoLike, templateRecord: any, - signerOptions?: SignerOption[] + signerOptions: SignerOption[], + activeCertificateDetails?: X509CertificateRecord[] ): BuiltCredential { // For SD-JWT format we expect payload to be a flat map of claims (no namespaces) - const payloadCopy = { ...(credentialRequest.payload as Record) }; + let payloadCopy = { ...(credentialRequest.payload as Record) }; // // strip vct if present per requirement // delete payloadCopy.vct; + if (signerOptions[0].method === SignerMethodOption.X5C && credentialRequest.validityInfo) { + const certificateDetail = activeCertificateDetails.find((x) => x.certificateBase64 === signerOptions[0].x5c[0]); + const validationResult = validateCredentialDatesInCertificateWindow( + credentialRequest.validityInfo, + certificateDetail + ); + if (!validationResult.isValid) { + throw new UnprocessableEntityException(`${JSON.stringify(validationResult.details)}`); + } + } + + 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)}`); + } + payloadCopy = { + ...payloadCopy, + nbf: dateToSeconds(credentialValidFrom), + exp: dateToSeconds(credentialValidTo) + }; + } + const sdJwtTemplate = templateRecord.attributes as SdJwtTemplate; payloadCopy.vct = sdJwtTemplate.vct; @@ -610,9 +678,32 @@ function buildSdJwtCredentialNew( function buildMdocCredentialNew( credentialRequest: CredentialRequestDtoLike, templateRecord: any, - signerOptions?: SignerOption[] + signerOptions: SignerOption[], + activeCertificateDetails: X509CertificateRecord[] ): BuiltCredential { - const incomingPayload = { ...(credentialRequest.payload as Record) }; + let incomingPayload = { ...(credentialRequest.payload as Record) }; + + if ( + !credentialRequest.validityInfo || + !credentialRequest.validityInfo.validFrom || + !credentialRequest.validityInfo.validUntil + ) { + throw new UnprocessableEntityException(`${ResponseMessages.oidcIssuerSession.error.missingValidityInfo}`); + } + + const certificateDetail = activeCertificateDetails.find((x) => x.certificateBase64 === signerOptions[0].x5c[0]); + const validationResult = validateCredentialDatesInCertificateWindow( + credentialRequest.validityInfo, + certificateDetail + ); + + if (!validationResult.isValid) { + throw new UnprocessableEntityException(`${JSON.stringify(validationResult.details)}`); + } + incomingPayload = { + ...incomingPayload, + validityInfo: credentialRequest.validityInfo + }; // // If caller provided already-namespaced payload, keep it; otherwise build a namespaces map // const workingPayload = { ...incomingPayload }; @@ -645,8 +736,7 @@ function buildMdocCredentialNew( credentialSupportedId, signerOptions: signerOptions ? signerOptions[0] : undefined, format: apiFormat, - payload: incomingPayload, - ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) + payload: incomingPayload }; } @@ -657,7 +747,8 @@ export function buildCredentialOfferPayloadNew( publicId: string; authorizationServerUrl?: string; }, - signerOptions?: SignerOption[] + signerOptions?: SignerOption[], + activeCertificateDetails?: X509CertificateRecord[] ): CredentialOfferPayload { // Index templates by id const templatesById = new Map(templates.map((template) => [template.id, template])); @@ -679,12 +770,11 @@ export function buildCredentialOfferPayloadNew( const templateFormat = (templateRecord as any).format ?? 'vc+sd-jwt'; const apiFormat = mapDbFormatToApiFormat(templateFormat); - if (apiFormat === CredentialFormat.SdJwtVc) { - return buildSdJwtCredentialNew(credentialRequest, templateRecord, signerOptions); + return buildSdJwtCredentialNew(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); } if (apiFormat === CredentialFormat.Mdoc) { - return buildMdocCredentialNew(credentialRequest, templateRecord, signerOptions); + return buildMdocCredentialNew(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); } throw new Error(`Unsupported template format for ${templateFormat}`); }); diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index d4f8f193e..d877de735 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -53,13 +53,14 @@ import { UpdateCredentialRequest } from '../interfaces/oid4vc-issuer-sessions.interfaces'; import { - buildCredentialOfferPayload, + // buildCredentialOfferPayload, buildCredentialOfferPayloadNew, buildCredentialOfferUrl, CredentialOfferPayload } from '../libs/helpers/credential-sessions.builder'; import { x5cKeyType } from '@credebl/enum/enum'; import { instanceToPlain, plainToInstance } from 'class-transformer'; +import { X509CertificateRecord } from '@credebl/common/interfaces/x509.interface'; type CredentialDisplayItem = { logo?: { uri: string; alt_text?: string }; @@ -309,7 +310,6 @@ export class Oid4vcIssuanceService { issuerId, signerOption }; - console.log(`service - createTemplate: `, issuerId); // Persist in DB const createdTemplate = await this.oid4vcIssuanceRepository.createTemplate(issuerId, metadata); if (!createdTemplate) { @@ -539,6 +539,7 @@ export class Oid4vcIssuanceService { //TDOD: signerOption should be under credentials change this with x509 support const signerOptions = []; + const activeCertificateDetails: X509CertificateRecord[] = []; for (const template of getAllOfferTemplates) { if (template.signerOption === SignerOption.DID) { signerOptions.push({ @@ -560,6 +561,7 @@ export class Oid4vcIssuanceService { method: SignerMethodOption.X5C, x5c: [activeCertificate.certificateBase64] }); + activeCertificateDetails.push(activeCertificate); } if (template.signerOption == SignerOption.X509_ED25519) { @@ -575,6 +577,7 @@ export class Oid4vcIssuanceService { method: SignerMethodOption.X5C, x5c: [activeCertificate.certificateBase64] }); + activeCertificateDetails.push(activeCertificate); } } //TODO: Implement x509 support and discuss with team @@ -589,7 +592,8 @@ export class Oid4vcIssuanceService { publicId: publicIssuerId, authorizationServerUrl: `${authorizationServerUrl}/oid4vci/${publicIssuerId}` }, - signerOptions as any + signerOptions as any, + activeCertificateDetails ); console.log('This is the buildOidcCredentialOffer:', JSON.stringify(buildOidcCredentialOffer, null, 2)); @@ -763,11 +767,8 @@ export class Oid4vcIssuanceService { const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); const templates = await this.oid4vcIssuanceRepository.getTemplatesByIssuerId(issuerId); - console.log(`---------------- emplates, configMetadata`, templates); const credentialConfigurationsSupported = buildCredentialConfigurationsSupportedNew(templates); - console.log(`-------------------credentialConfigurationsSupported`, credentialConfigurationsSupported); - return buildIssuerPayload({ credentialConfigurationsSupported }, issuerDetails); } catch (error) { this.logger.error(`[buildOidcIssuerPayload] - error: ${JSON.stringify(error)}`); diff --git a/libs/common/src/date-only.ts b/libs/common/src/date-only.ts new file mode 100644 index 000000000..969a4ce2f --- /dev/null +++ b/libs/common/src/date-only.ts @@ -0,0 +1,38 @@ +const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); +export class DateOnly { + private date: Date; + + public constructor(date?: string) { + this.date = date ? new Date(date) : new Date(); + } + + get [Symbol.toStringTag](): string { + return DateOnly.name; + } + + toString(): string { + return this.toISOString(); + } + + toJSON(): string { + return this.toISOString(); + } + + toISOString(): string { + return this.date.toISOString().split('T')[0]; + } + + [customInspectSymbol](): string { + return this.toISOString(); + } +} + +export const oneDayInMilliseconds = 24 * 60 * 60 * 1000; +export const tenDaysInMilliseconds = 10 * oneDayInMilliseconds; +export const oneYearInMilliseconds = 365 * oneDayInMilliseconds; +export const serverStartupTimeInMilliseconds = Date.now(); + +export function dateToSeconds(date: Date | DateOnly): number { + const realDate = date instanceof DateOnly ? new Date(date.toISOString()) : date; + return Math.floor(realDate.getTime() / 1000); +} diff --git a/libs/common/src/response-messages/index.ts b/libs/common/src/response-messages/index.ts index e96cedc22..aec4459e7 100644 --- a/libs/common/src/response-messages/index.ts +++ b/libs/common/src/response-messages/index.ts @@ -552,7 +552,8 @@ export const ResponseMessages = { 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.' + deleteFailed: 'Failed to delete OID4VC credential offer.', + missingValidityInfo: 'Validity Info(validFrom, validTo) is required for validity of credential' } }, x509: { From 2eaaeee81c80991c208e80b8c3de954d1b0ab686 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Tue, 28 Oct 2025 19:09:52 +0530 Subject: [PATCH 10/13] feat: added docker file Signed-off-by: Tipu_Singh --- Dockerfiles/Dockerfile.x509 | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 Dockerfiles/Dockerfile.x509 diff --git a/Dockerfiles/Dockerfile.x509 b/Dockerfiles/Dockerfile.x509 new file mode 100644 index 000000000..6d15a054f --- /dev/null +++ b/Dockerfiles/Dockerfile.x509 @@ -0,0 +1,45 @@ +# Stage 1: Build the application +FROM node:18-alpine as build +# Install OpenSSL +RUN apk add --no-cache openssl +RUN npm install -g pnpm +# Set the working directory +WORKDIR /app + +# Copy package.json and package-lock.json +COPY package.json ./ +COPY pnpm-workspace.yaml ./ +#COPY package-lock.json ./ + +ENV PUPPETEER_SKIP_DOWNLOAD=true + +# Install dependencies while ignoring scripts (including Puppeteer's installation) +RUN pnpm i --ignore-scripts + +# Copy the rest of the application code +COPY . . +# RUN cd libs/prisma-service && npx prisma migrate deploy && npx prisma generate +RUN cd libs/prisma-service && npx prisma generate + +# Build the x509 service +RUN npm run build x509 + + +# Stage 2: Create the final image +FROM node:18-alpine +# Install OpenSSL +RUN apk add --no-cache openssl +# RUN npm install -g pnpm +# Set the working directory +WORKDIR /app + +# Copy the compiled code from the build stage +COPY --from=build /app/dist/apps/x509/ ./dist/apps/x509/ + +# Copy the libs folder from the build stage +COPY --from=build /app/libs/ ./libs/ +#COPY --from=build /app/package.json ./ +COPY --from=build /app/node_modules ./node_modules + +# Set the command to run the microservice +CMD ["sh", "-c", "cd libs/prisma-service && npx prisma migrate deploy && npx prisma generate && cd ../.. && node dist/apps/x509/main.js"] \ No newline at end of file From cbcaae9e9b60e238064c23f23dd0fca53d13f27d Mon Sep 17 00:00:00 2001 From: Rinkal Bhojani Date: Wed, 29 Oct 2025 17:06:13 +0530 Subject: [PATCH 11/13] removed commented code and refactored Signed-off-by: Rinkal Bhojani --- .../dtos/oid4vc-issuer-template.dto.ts | 27 -- .../oid4vc-issuance/dtos/oid4vc-issuer.dto.ts | 96 ---- .../oid4vc-issuer-sessions.interfaces.ts | 6 +- .../interfaces/oid4vc-template.interfaces.ts | 13 +- .../credential-sessions-mdoc.builder.ts | 449 ------------------ .../helpers/credential-sessions.builder.ts | 379 +-------------- .../libs/helpers/issuer.metadata.ts | 126 +---- .../src/oid4vc-issuance.service.ts | 17 +- 8 files changed, 13 insertions(+), 1100 deletions(-) delete mode 100644 apps/oid4vc-issuance/libs/helpers/credential-sessions-mdoc.builder.ts 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 f3578d4f4..13a6a98e6 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 @@ -29,33 +29,6 @@ class CredentialAttributeDisplayDto { locale?: string; } -// 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: [CredentialAttributeDisplayDto], required: false, description: 'Localized display values' }) -// @IsOptional() -// @ValidateNested({ each: true }) -// @Type(() => CredentialAttributeDisplayDto) -// display?: CredentialAttributeDisplayDto[]; -// } - export enum AttributeType { STRING = 'string', NUMBER = 'number', 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 index cd99e117b..c0c340d38 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts @@ -43,102 +43,6 @@ export class IssuerDisplayDto { 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', diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts index afe54b472..9a266a8ee 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts @@ -34,11 +34,8 @@ export interface CredentialPayload { } 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 + payload: CredentialPayload; validityInfo?: { validFrom: Date; validUntil: Date; @@ -47,7 +44,6 @@ export interface CredentialRequest { export interface CreateOidcCredentialOffer { // e.g. "abc-gov" - // signerMethod: SignerMethodOption; // only option selector authenticationType: AuthenticationType; // only option selector credentials: CredentialRequest[]; // one or more credentials } diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts index 1d1ee9510..5f590389c 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts @@ -1,12 +1,5 @@ import { Prisma, SignerOption } from '@prisma/client'; import { CredentialFormat } from '@credebl/enum/enum'; - -// export interface CredentialAttribute { -// mandatory?: boolean; -// value_type: string; -// display?: Display[]; -// } - export interface SdJwtTemplate { vct: string; attributes: CredentialAttribute[]; @@ -23,15 +16,11 @@ export interface MdocTemplate { export interface CreateCredentialTemplate { name: string; description?: string; - signerOption?: SignerOption; //SignerOption; + signerOption?: SignerOption; format: CredentialFormat; canBeRevoked: boolean; - // attributes: Prisma.JsonValue; appearance?: Prisma.JsonValue; issuerId: string; - // vct?: string; - // doctype?: string; - template: SdJwtTemplate | MdocTemplate; } diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions-mdoc.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions-mdoc.builder.ts deleted file mode 100644 index 7d892a89f..000000000 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions-mdoc.builder.ts +++ /dev/null @@ -1,449 +0,0 @@ -/* 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 { Prisma, credential_templates } from '@prisma/client'; -import { GetAllCredentialOffer, SignerOption } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; -import { CredentialFormat } from '@credebl/enum/enum'; -/* ============================================================================ - Domain Types -============================================================================ */ - -type ValueType = 'string' | 'date' | 'number' | 'boolean' | 'integer' | string; - -interface TemplateAttribute { - display: { name: string; locale: string }[]; - mandatory: boolean; - value_type: ValueType; -} -type TemplateAttributes = Record; - -export enum SignerMethodOption { - DID = 'did', - X5C = 'x5c' -} - -export type DisclosureFrame = Record>; - -export interface CredentialRequestDtoLike { - templateId: string; - payload: Record; - 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 = (value: unknown): value is null | undefined => null == value; -const isEmptyString = (value: unknown): boolean => 'string' === typeof value && '' === value.trim(); -const isPlainRecord = (value: unknown): value is Record => - Boolean(value) && 'object' === typeof value && !Array.isArray(value); - -/** Map DB format string -> API enum */ -function mapDbFormatToApiFormat(dbFormat: string): CredentialFormat { - if ('sd-jwt' === dbFormat || 'vc+sd-jwt' === dbFormat || 'sdjwt' === dbFormat || 'sd+jwt-vc' === dbFormat) { - return CredentialFormat.SdJwtVc; - } - if ('mso_mdoc' === dbFormat) { - return CredentialFormat.Mdoc; - } - throw new Error(`Unsupported template format: ${dbFormat}`); -} - -/** Map API enum -> id suffix required for credentialSupportedId */ -function formatSuffix(apiFormat: CredentialFormat): 'sdjwt' | 'mdoc' { - return apiFormat === 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, - context: { templateId: string } -): void { - const missingClaims: string[] = []; - for (const [claimName, attributeDefinition] of Object.entries(attributes)) { - if (!attributeDefinition?.mandatory) { - continue; - } - const claimValue = payload[claimName]; - if (isNil(claimValue) || isEmptyString(claimValue)) { - missingClaims.push(claimName); - } - } - if (missingClaims.length) { - throw new Error(`Missing mandatory claims for template "${context.templateId}": ${missingClaims.join(', ')}`); - } -} - -/* ============================================================================ - JsonValue → TemplateAttributes Narrowing (Type Guards) -============================================================================ */ - -function isDisplayArray(value: unknown): value is { name: string; locale: string }[] { - return ( - Array.isArray(value) && - value.every( - (entry) => - isPlainRecord(entry) && 'string' === typeof (entry as any).name && 'string' === typeof (entry as any).locale - ) - ); -} - -/* ============================================================================ - Improved ensureTemplateAttributes: runtime assert with helpful errors -============================================================================ */ - -const ALLOWED_VALUE_TYPES: ValueType[] = ['string', 'date', 'number', 'boolean', 'integer']; - -function ensureTemplateAttributes(jsonValue: Prisma.JsonValue): TemplateAttributes { - if (!isPlainRecord(jsonValue)) { - throw new Error( - `Invalid template.attributes: expected an object map but received ${ - null === jsonValue ? 'null' : typeof jsonValue - }.\n\nFix: provide an object whose keys are attribute names and whose values are attribute definitions, e.g.\n{\n "given_name": { "mandatory": true, "value_type": "string" }\n}` - ); - } - - const attributesMap = jsonValue as Record; - const attributeKeys = Object.keys(attributesMap); - if (0 === attributeKeys.length) { - throw new Error( - 'Invalid template.attributes: object is empty (no attributes defined).\n\nFix: add at least one attribute definition, for example:\n{\n "given_name": { "mandatory": true, "value_type": "string" }\n}' - ); - } - - const problems: string[] = []; - const suggestedFixes: string[] = []; - - for (const attributeKey of attributeKeys) { - const rawAttributeDef = attributesMap[attributeKey]; - - if (!isPlainRecord(rawAttributeDef)) { - problems.push( - `${attributeKey}: expected an object but got ${null === rawAttributeDef ? 'null' : typeof rawAttributeDef}` - ); - suggestedFixes.push( - `Replace attribute "${attributeKey}" value with an object, e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); - continue; - } - - // mandatory checks - if (!('mandatory' in rawAttributeDef)) { - problems.push(`${attributeKey}.mandatory: missing`); - suggestedFixes.push( - `Add mandatory boolean for "${attributeKey}":\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); - } else if ('boolean' !== typeof (rawAttributeDef as any).mandatory) { - problems.push(`${attributeKey}.mandatory: expected boolean but got ${typeof (rawAttributeDef as any).mandatory}`); - suggestedFixes.push( - `Set "mandatory" to a boolean for "${attributeKey}", e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); - } - - // value_type checks - if (!('value_type' in rawAttributeDef)) { - problems.push(`${attributeKey}.value_type: missing`); - suggestedFixes.push( - `Add value_type for "${attributeKey}", for example:\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); - } else if ('string' !== typeof (rawAttributeDef as any).value_type) { - problems.push( - `${attributeKey}.value_type: expected string but got ${typeof (rawAttributeDef as any).value_type}` - ); - suggestedFixes.push( - `Make sure "value_type" is a string for "${attributeKey}", e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); - } else { - const declaredType = (rawAttributeDef as any).value_type as string; - if (!ALLOWED_VALUE_TYPES.includes(declaredType as ValueType)) { - problems.push( - `${attributeKey}.value_type: unsupported value_type "${declaredType}". Allowed types: ${ALLOWED_VALUE_TYPES.join(', ')}` - ); - suggestedFixes.push( - `Use one of the allowed types for "${attributeKey}", e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string" }` - ); - } - } - - // display checks (optional) - if ('display' in rawAttributeDef && !isDisplayArray((rawAttributeDef as any).display)) { - problems.push(`${attributeKey}.display: expected array of { name: string, locale: string }`); - suggestedFixes.push( - `Fix "display" for "${attributeKey}" to be an array of objects with name/locale, e.g.\n"${attributeKey}": { "mandatory": true, "value_type": "string", "display": [{ "name": "Given Name", "locale": "en-US" }] }` - ); - } - } - - if (0 < problems.length) { - // Build a user-friendly message: problems + suggested fixes (unique) - const uniqueFixes = Array.from(new Set(suggestedFixes)).slice(0, 20); - const fixesText = uniqueFixes.length - ? `\n\nSuggested fixes (copy-paste examples):\n- ${uniqueFixes.join('\n- ')}` - : ''; - - // Include a small truncated sample of the attributes to help debugging - const samplePreview = JSON.stringify( - Object.fromEntries(attributeKeys.slice(0, 10).map((key) => [key, attributesMap[key]])), - (_, value) => { - if ('string' === typeof value && 200 < value.length) { - return `${value.slice(0, 200)}...`; - } - return value; - }, - 2 - ); - - throw new Error( - `Invalid template.attributes shape. Problems found:\n- ${problems.join( - '\n- ' - )}\n\nExample attributes (truncated):\n${samplePreview}${fixesText}` - ); - } - - // Safe to cast to TemplateAttributes - return attributesMap as TemplateAttributes; -} - -/* ============================================================================ - Builders -============================================================================ */ - -/** Build one credential block normalized to API format (using the template's format). */ -function buildOneCredential( - credentialRequest: CredentialRequestDtoLike, - templateRecord: credential_templates, - templateAttributes: TemplateAttributes, - signerOptions?: SignerOption[] -): BuiltCredential { - // 1) Validate payload against template attributes - assertMandatoryClaims(credentialRequest.payload, templateAttributes, { templateId: credentialRequest.templateId }); - - // 2) Decide API format from DB format - const selectedApiFormat = mapDbFormatToApiFormat(templateRecord.format); - - // 3) Build supportedId from template.name + suffix ("-sdjwt" | "-mdoc") - const idSuffix = formatSuffix(selectedApiFormat); - const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - - // 4) Strip vct ALWAYS (per requirement) - const normalizedPayload = { ...(credentialRequest.payload as Record) }; - delete (normalizedPayload as Record).vct; - - return { - credentialSupportedId, // e.g., "BirthCertificateCredential-sdjwt" - signerOptions: signerOptions ? signerOptions[0] : undefined, - format: selectedApiFormat, // 'vc+sd-jwt' | 'mdoc' - payload: normalizedPayload, // without vct - ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.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 templatesById = new Map(templates.map((template) => [template.id, template])); - - // Verify all requested templateIds exist - const unknownTemplateIds = dto.credentials - .map((c) => c.templateId) - .filter((requestedId) => !templatesById.has(requestedId)); - if (unknownTemplateIds.length) { - throw new Error(`Unknown template ids: ${unknownTemplateIds.join(', ')}`); - } - - // Build credentials - const builtCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { - const templateRecord = templatesById.get(credentialRequest.templateId)!; - const resolvedAttributes = ensureTemplateAttributes(templateRecord.attributes); // narrow JsonValue safely - return buildOneCredential(credentialRequest, templateRecord, resolvedAttributes, signerOptions); - }); - - // --- Base envelope (issuerId deliberately NOT included) --- - const baseEnvelope: BuiltCredentialOfferBase = { - credentials: builtCredentials, - ...(dto.publicIssuerId ? { publicIssuerId: dto.publicIssuerId } : {}) - }; - - // XOR flow selection (defensive) - const hasPreAuthFlow = Boolean(dto.preAuthorizedCodeFlowConfig); - const hasAuthCodeFlow = Boolean(dto.authorizationCodeFlowConfig); - if (hasPreAuthFlow === hasAuthCodeFlow) { - throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); - } - - if (hasPreAuthFlow) { - return { - ...baseEnvelope, - preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! - }; - } - - return { - ...baseEnvelope, - authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! - }; -} - -// ----------------------------------------------------------------------------- -// Builder: Update Credential Offer -// ----------------------------------------------------------------------------- -export function buildUpdateCredentialOfferPayload( - dto: CreateOidcCredentialOfferDtoLike, - templates: credential_templates[] -): { credentials: BuiltCredential[] } { - // Index templates by id - const templatesById = new Map(templates.map((template) => [template.id, template])); - - // Validate all templateIds exist - const unknownTemplateIds = dto.credentials - .map((c) => c.templateId) - .filter((requestedId) => !templatesById.has(requestedId)); - if (unknownTemplateIds.length) { - throw new Error(`Unknown template ids: ${unknownTemplateIds.join(', ')}`); - } - - // Validate each credential against its template - const normalizedCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { - const templateRecord = templatesById.get(credentialRequest.templateId)!; - const resolvedAttributes = ensureTemplateAttributes(templateRecord.attributes); // safely narrow JsonValue - - // check that all payload keys exist in template attributes - const payloadKeys = Object.keys(credentialRequest.payload); - const invalidPayloadKeys = payloadKeys.filter((payloadKey) => !resolvedAttributes[payloadKey]); - if (invalidPayloadKeys.length) { - throw new Error( - `Invalid attributes for template "${credentialRequest.templateId}": ${invalidPayloadKeys.join(', ')}` - ); - } - - // also validate mandatory fields are present - assertMandatoryClaims(credentialRequest.payload, resolvedAttributes, { templateId: credentialRequest.templateId }); - - // build minimal normalized credential (no vct, issuerId, etc.) - const selectedApiFormat = mapDbFormatToApiFormat(templateRecord.format); - const idSuffix = formatSuffix(selectedApiFormat); - const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - - return { - credentialSupportedId, - format: selectedApiFormat, - payload: credentialRequest.payload, - ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) - }; - }); - - // Only return credentials array here (update flow doesn't need preAuth/auth configs) - return { - credentials: normalizedCredentials - }; -} - -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/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts index 52cf788c6..f473c827c 100644 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -137,352 +137,6 @@ function formatSuffix(apiFormat: CredentialFormat): 'sdjwt' | 'mdoc' { return apiFormat === CredentialFormat.SdJwtVc ? 'sdjwt' : 'mdoc'; } -/* ============================================================================ - Template Attributes Normalization - - draft-13 used map: { given_name: { mandatory:true, value_type: "string" } } - - draft-15 returns attributes as array of attribute objects (with path) - This helper accepts both and normalizes to TemplateAttributes map. -============================================================================ */ - -/** - * Normalize attributes from DB/template into TemplateAttributes map. - * Accepts: - * - map: Record - * - array: Array<{ path: string[], mandatory?: boolean, value_type?: string, display?: ... }> - */ -// function normalizeTemplateAttributes(rawAttributes: Prisma.JsonValue): TemplateAttributes { -// // if already a plain record keyed by claim name, cast and return -// if (isPlainRecord(rawAttributes) && !Array.isArray(rawAttributes)) { -// // We still guard that values look like TemplateAttribute, but be permissive. -// return rawAttributes as TemplateAttributes; -// } - -// // If attributes are an array (draft-15 style), convert to map -// if (Array.isArray(rawAttributes)) { -// const attributesArray = rawAttributes as unknown as any[]; -// const normalizedMap: TemplateAttributes = {}; -// for (const attributeEntry of attributesArray) { -// if (!isPlainRecord(attributeEntry)) { -// continue; // skip invalid entries -// } - -// // draft-15: path is array like ["org.iso.23220.photoID.1","given_name"] or ["name"] -// const pathValue = attributeEntry.path; -// if (!Array.isArray(pathValue) || 0 === pathValue.length) { -// continue; -// } - -// // prefer last path element as local claim name (keeps namespace support) -// const claimName = String(pathValue[pathValue.length - 1]); - -// normalizedMap[claimName] = { -// mandatory: Boolean(attributeEntry.mandatory), -// value_type: attributeEntry.value_type ? String(attributeEntry.value_type) : undefined, -// display: Array.isArray(attributeEntry.display) -// ? attributeEntry.display.map((d: any) => ({ name: d.name, locale: d.locale })) -// : undefined -// }; -// } -// return normalizedMap; -// } - -// // if it's a JSON string, try parse -// if ('string' === typeof rawAttributes) { -// try { -// const parsed = JSON.parse(rawAttributes); -// return normalizeTemplateAttributes(parsed as Prisma.JsonValue); -// } catch { -// throw new Error('Invalid template.attributes JSON string'); -// } -// } - -// throw new Error('Unrecognized template.attributes shape'); -// } - -/* ============================================================================ - Validation: Mandatory claims -============================================================================ */ - -// function assertMandatoryClaims( -// payload: Record, -// attributes: TemplateAttributes, -// context: { templateId: string } -// ): void { -// const missingClaims: string[] = []; -// for (const [claimName, attributeDefinition] of Object.entries(attributes)) { -// if (!attributeDefinition?.mandatory) { -// continue; -// } -// const claimValue = payload[claimName]; -// if (isNil(claimValue) || isEmptyString(claimValue)) { -// missingClaims.push(claimName); -// } -// } -// if (missingClaims.length) { -// throw new Error(`Missing mandatory claims for template "${context.templateId}": ${missingClaims.join(', ')}`); -// } -// } - -/* ============================================================================ - Per-format credential builders (separated for readability) - - buildSdJwtCredential - - buildMdocCredential -============================================================================ */ - -/** Build an SD-JWT credential object */ -// function buildSdJwtCredential( -// credentialRequest: CredentialRequestDtoLike, -// templateRecord: credential_templates, -// signerOptions?: SignerOption[] -// ): BuiltCredential { -// // For SD-JWT format we expect payload to be a flat map of claims (no namespaces) -// const payloadCopy = { ...(credentialRequest.payload as Record) }; -// // Validate mandatory claims using normalized attributes from templateRecord -// const normalizedAttributes = normalizeTemplateAttributes(templateRecord.attributes); -// assertMandatoryClaims(payloadCopy, normalizedAttributes, { templateId: credentialRequest.templateId }); - -// // strip vct if present per requirement -// delete payloadCopy.vct; - -// const apiFormat = mapDbFormatToApiFormat(templateRecord.format); -// const idSuffix = formatSuffix(apiFormat); -// const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - -// return { -// credentialSupportedId, -// signerOptions: signerOptions ? signerOptions[0] : undefined, -// format: apiFormat, -// payload: payloadCopy, -// ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) -// }; -// } - -// /** Build an MSO mdoc credential object -// * - For mdocs we expect the payload to include a `namespaces` map (draft-15 style) -// */ -// function buildMdocCredential( -// credentialRequest: CredentialRequestDtoLike, -// templateRecord: credential_templates, -// signerOptions?: SignerOption[] -// ): BuiltCredential { -// const incomingPayload = { ...(credentialRequest.payload as Record) }; - -// // Normalize attributes and ensure we know the expected claim names -// const normalizedAttributes = normalizeTemplateAttributes(templateRecord.attributes); -// // eslint-disable-next-line @typescript-eslint/no-explicit-any -// const templateDoctype: string | undefined = (templateRecord as any).doctype ?? undefined; -// const defaultNamespace = templateDoctype ?? templateRecord.name; - -// // If caller provided already-namespaced payload, keep it; otherwise build a namespaces map -// const workingPayload = { ...incomingPayload }; -// if (!workingPayload.namespaces) { -// const namespacesMap: Record> = {}; -// // collect claims that match attribute names into the chosen namespace -// for (const claimName of Object.keys(normalizedAttributes)) { -// if (Object.prototype.hasOwnProperty.call(incomingPayload, claimName)) { -// namespacesMap[defaultNamespace] = namespacesMap[defaultNamespace] ?? {}; -// namespacesMap[defaultNamespace][claimName] = (incomingPayload as any)[claimName]; -// // remove original flattened claim to avoid duplication -// delete (workingPayload as any)[claimName]; -// } -// } -// if (0 < Object.keys(namespacesMap).length) { -// (workingPayload as any).namespaces = namespacesMap; -// } -// } else { -// // ensure namespaces is a plain object -// if (!isPlainRecord((workingPayload as any).namespaces)) { -// throw new Error(`Invalid mdoc payload: 'namespaces' must be an object`); -// } -// } - -// // Validate mandatory claims exist somewhere inside namespaces -// const missingMandatoryClaims: string[] = []; -// for (const [claimName, attributeDef] of Object.entries(normalizedAttributes)) { -// if (!attributeDef?.mandatory) { -// continue; -// } - -// let found = false; -// const namespacesObj = (workingPayload as any).namespaces as Record; -// if (namespacesObj && isPlainRecord(namespacesObj)) { -// for (const nsKey of Object.keys(namespacesObj)) { -// const nsContent = namespacesObj[nsKey]; -// if (nsContent && Object.prototype.hasOwnProperty.call(nsContent, claimName)) { -// const value = nsContent[claimName]; -// if (!isNil(value) && !('string' === typeof value && '' === value.trim())) { -// found = true; -// break; -// } -// } -// } -// } -// if (!found) { -// missingMandatoryClaims.push(claimName); -// } -// } -// if (missingMandatoryClaims.length) { -// throw new Error( -// `Missing mandatory namespaced claims for template "${credentialRequest.templateId}": ${missingMandatoryClaims.join( -// ', ' -// )}` -// ); -// } - -// // strip vct if present -// delete (workingPayload as Record).vct; - -// const apiFormat = mapDbFormatToApiFormat(templateRecord.format); -// const idSuffix = formatSuffix(apiFormat); -// const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - -// return { -// credentialSupportedId, -// signerOptions: signerOptions ? signerOptions[0] : undefined, -// format: apiFormat, -// payload: workingPayload, -// ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) -// }; -// } - -/* ============================================================================ - Main Builder: buildCredentialOfferPayload - - Now delegates per-format build to the two helpers above - - Accepts `authorizationServerUrl` parameter; txCode is a constant above -============================================================================ */ - -// export function buildCredentialOfferPayload( -// dto: CreateOidcCredentialOfferDtoLike, -// templates: credential_templates[], -// issuerDetails?: { -// publicId: string; -// authorizationServerUrl?: string; -// }, -// signerOptions?: SignerOption[] -// ): CredentialOfferPayload { -// // Index templates by id -// const templatesById = new Map(templates.map((template) => [template.id, template])); - -// // Validate template ids -// const missingTemplateIds = dto.credentials.map((c) => c.templateId).filter((id) => !templatesById.has(id)); -// if (missingTemplateIds.length) { -// throw new Error(`Unknown template ids: ${missingTemplateIds.join(', ')}`); -// } - -// // Build each credential using the template's format -// const builtCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { -// const templateRecord = templatesById.get(credentialRequest.templateId)!; -// // we normalize attributes to support both draft-13 (map) and draft-15 (array) shapes -// normalizeTemplateAttributes(templateRecord.attributes); - -// const templateFormat = (templateRecord as any).format ?? 'vc+sd-jwt'; -// const apiFormat = mapDbFormatToApiFormat(templateFormat); - -// if (apiFormat === CredentialFormat.SdJwtVc) { -// return buildSdJwtCredential(credentialRequest, templateRecord, signerOptions); -// } -// if (apiFormat === CredentialFormat.Mdoc) { -// return buildMdocCredential(credentialRequest, templateRecord, signerOptions); -// } -// throw new Error(`Unsupported template format for ${templateFormat}`); -// }); - -// // Base envelope: allow explicit publicIssuerId from DTO or fallback to issuerDetails.publicId -// const publicIssuerIdFromDto = dto.publicIssuerId; -// const publicIssuerIdFromIssuerDetails = issuerDetails?.publicId; -// const finalPublicIssuerId = publicIssuerIdFromDto ?? publicIssuerIdFromIssuerDetails; - -// const baseEnvelope: BuiltCredentialOfferBase = { -// credentials: builtCredentials, -// ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}) -// }; - -// // Determine which authorization flow to return: -// // Priority: -// // 1) If issuerDetails.authorizationServerUrl is provided, return preAuthorizedCodeFlowConfig using DEFAULT_TXCODE -// // 2) Else fall back to flows present in DTO (still enforce XOR) -// const overrideAuthorizationServerUrl = issuerDetails?.authorizationServerUrl; -// if (overrideAuthorizationServerUrl) { -// if ('string' !== typeof overrideAuthorizationServerUrl || '' === overrideAuthorizationServerUrl.trim()) { -// throw new Error('issuerDetails.authorizationServerUrl must be a non-empty string when provided'); -// } -// return { -// ...baseEnvelope, -// preAuthorizedCodeFlowConfig: { -// txCode: DEFAULT_TXCODE, -// authorizationServerUrl: overrideAuthorizationServerUrl -// } -// }; -// } - -// // No override provided — use what DTO carries (must be XOR) -// const hasPreAuthFromDto = Boolean(dto.preAuthorizedCodeFlowConfig); -// const hasAuthCodeFromDto = Boolean(dto.authorizationCodeFlowConfig); -// if (hasPreAuthFromDto === hasAuthCodeFromDto) { -// throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); -// } -// if (hasPreAuthFromDto) { -// return { -// ...baseEnvelope, -// preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! -// }; -// } -// return { -// ...baseEnvelope, -// authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! -// }; -// } - -/* ============================================================================ - Update Credential Offer builder (keeps behavior, clearer names) -============================================================================ */ - -// export function buildUpdateCredentialOfferPayload( -// dto: CreateOidcCredentialOfferDtoLike, -// templates: credential_templates[] -// ): { credentials: BuiltCredential[] } { -// const templatesById = new Map(templates.map((template) => [template.id, template])); - -// const missingTemplateIds = dto.credentials.map((c) => c.templateId).filter((id) => !templatesById.has(id)); -// if (missingTemplateIds.length) { -// throw new Error(`Unknown template ids: ${missingTemplateIds.join(', ')}`); -// } - -// const normalizedCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => { -// const templateRecord = templatesById.get(credentialRequest.templateId)!; - -// // Normalize attributes shape and ensure it's valid -// const attributesMap = normalizeTemplateAttributes(templateRecord.attributes); - -// // ensure payload keys match known attributes -// const payloadKeys = Object.keys(credentialRequest.payload); -// const invalidPayloadKeys = payloadKeys.filter((payloadKey) => !attributesMap[payloadKey]); -// if (invalidPayloadKeys.length) { -// throw new Error( -// `Invalid attributes for template "${credentialRequest.templateId}": ${invalidPayloadKeys.join(', ')}` -// ); -// } - -// // Validate mandatory claims -// assertMandatoryClaims(credentialRequest.payload, attributesMap, { templateId: credentialRequest.templateId }); - -// const selectedApiFormat = mapDbFormatToApiFormat(templateRecord.format); -// const idSuffix = formatSuffix(selectedApiFormat); -// const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - -// return { -// credentialSupportedId, -// format: selectedApiFormat, -// payload: credentialRequest.payload, -// ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) -// }; -// }); - -// return { -// credentials: normalizedCredentials -// }; -// } - export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer: GetAllCredentialOffer): string { const criteriaParams: string[] = []; @@ -613,7 +267,7 @@ function validateCredentialDatesInCertificateWindow(credentialValidityInfo: vali }; } -function buildSdJwtCredentialNew( +function buildSdJwtCredential( credentialRequest: CredentialRequestDtoLike, templateRecord: any, signerOptions: SignerOption[], @@ -675,7 +329,7 @@ function buildSdJwtCredentialNew( /** Build an MSO mdoc credential object * - For mdocs we expect the payload to include a `namespaces` map (draft-15 style) */ -function buildMdocCredentialNew( +function buildMdocCredential( credentialRequest: CredentialRequestDtoLike, templateRecord: any, signerOptions: SignerOption[], @@ -705,29 +359,6 @@ function buildMdocCredentialNew( validityInfo: credentialRequest.validityInfo }; - // // If caller provided already-namespaced payload, keep it; otherwise build a namespaces map - // const workingPayload = { ...incomingPayload }; - // if (!workingPayload.namespaces) { - // const namespacesMap: Record> = {}; - // // collect claims that match attribute names into the chosen namespace - // for (const claimName of Object.keys(normalizedAttributes)) { - // if (Object.prototype.hasOwnProperty.call(incomingPayload, claimName)) { - // namespacesMap[defaultNamespace] = namespacesMap[defaultNamespace] ?? {}; - // namespacesMap[defaultNamespace][claimName] = (incomingPayload as any)[claimName]; - // // remove original flattened claim to avoid duplication - // delete (workingPayload as any)[claimName]; - // } - // } - // if (0 < Object.keys(namespacesMap).length) { - // (workingPayload as any).namespaces = namespacesMap; - // } - // } else { - // // ensure namespaces is a plain object - // if (!isPlainRecord((workingPayload as any).namespaces)) { - // throw new Error(`Invalid mdoc payload: 'namespaces' must be an object`); - // } - // } - const apiFormat = mapDbFormatToApiFormat(templateRecord.format); const idSuffix = formatSuffix(apiFormat); const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; @@ -740,7 +371,7 @@ function buildMdocCredentialNew( }; } -export function buildCredentialOfferPayloadNew( +export function buildCredentialOfferPayload( dto: CreateOidcCredentialOfferDtoLike, templates: credential_templates[], issuerDetails?: { @@ -771,10 +402,10 @@ export function buildCredentialOfferPayloadNew( const templateFormat = (templateRecord as any).format ?? 'vc+sd-jwt'; const apiFormat = mapDbFormatToApiFormat(templateFormat); if (apiFormat === CredentialFormat.SdJwtVc) { - return buildSdJwtCredentialNew(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); + return buildSdJwtCredential(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); } if (apiFormat === CredentialFormat.Mdoc) { - return buildMdocCredentialNew(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); + return buildMdocCredential(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); } throw new Error(`Unsupported template format for ${templateFormat}`); }); diff --git a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts index ee4ae452c..a8555607a 100644 --- a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -112,130 +112,6 @@ type TemplateRowPrisma = { 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( -// templateRows: TemplateRowPrisma[], -// options?: { -// vct?: string; -// doctype?: string; -// scopeVct?: string; -// keyResolver?: (templateRow: TemplateRowPrisma) => string; -// format?: string; -// } -// ): Record { -// const defaultFormat = options?.format ?? 'vc+sd-jwt'; -// const credentialConfigMap: Record = {}; - -// for (const templateRow of templateRows) { -// // Extract and validate attributes (claims) and appearance (display configuration) -// const attributesJson = templateRow.attributes; -// const appearanceJson = coerceJsonObject(templateRow.appearance); - -// if (!isAttributesMap(attributesJson)) { -// throw new Error(`Template ${templateRow.id}: invalid attributes JSON`); -// } - -// if (!isAppearance(appearanceJson)) { -// throw new Error(`Template ${templateRow.id}: invalid appearance JSON (missing display array)`); -// } - -// // Determine credential format (either sd-jwt or mso_mdoc) -// // eslint-disable-next-line @typescript-eslint/no-explicit-any -// const templateFormat: string = (templateRow as any).format ?? defaultFormat; -// const isMdocFormat = 'mso_mdoc' === templateFormat; -// const formatSuffix = isMdocFormat ? 'mdoc' : 'sdjwt'; - -// // Determine the unique key for this credential configuration -// const configKey = -// 'function' === typeof options?.keyResolver -// ? options.keyResolver(templateRow) -// : `${templateRow.name}-${formatSuffix}`; - -// // Resolve Doctype and VCT based on format type -// // eslint-disable-next-line @typescript-eslint/no-explicit-any -// let resolvedDoctype: string | undefined = options?.doctype ?? (templateRow as any).doctype; -// // eslint-disable-next-line @typescript-eslint/no-explicit-any -// const resolvedVct: string = options?.vct ?? (templateRow as any).vct ?? templateRow.name; - -// if (isMdocFormat && !resolvedDoctype) { -// resolvedDoctype = templateRow.name; // fallback to template name -// } - -// // Construct OIDC4VC scope -// const scopeBaseValue = options?.scopeVct ?? (isMdocFormat ? resolvedDoctype : resolvedVct); -// const credentialScope = `openid4vc:credential:${scopeBaseValue}-${formatSuffix}`; - -// // Convert each attribute into a claim definition (map shape) -// const claimsObject: Record = {}; -// for (const [claimName, attributeDefinition] of Object.entries(attributesJson)) { -// console.log(`claimName, attributeDefinition`, claimName, attributeDefinition); -// console.log(`attributesJson`, attributesJson); -// const parsedAttribute = attributeDefinition as AttributeDef; - -// claimsObject[claimName] = { -// path: [claimName], -// mandatory: parsedAttribute.mandatory ?? false, -// display: Array.isArray(parsedAttribute.display) -// ? parsedAttribute.display.map((displayItem) => ({ -// name: displayItem.name, -// locale: displayItem.locale -// })) -// : undefined, -// value_type: parsedAttribute.value_type -// }; -// } - -// // Prepare the display configuration -// const displayConfigurations = -// (appearanceJson as 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 -// })) ?? []; - -// // Assemble final credential configuration -// credentialConfigMap[configKey] = { -// format: templateFormat, -// scope: credentialScope, -// // eslint-disable-next-line @typescript-eslint/no-unused-vars -// claims: Object.entries(claimsObject).map(([claimName, claimDef]) => ({ -// path: claimDef.path, -// mandatory: claimDef.mandatory, -// display: claimDef.display -// // you can optionally expose claimDef.value_type here if your API schema allows -// })), -// credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], -// cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], -// display: displayConfigurations, -// ...(isMdocFormat ? { doctype: resolvedDoctype as string } : { vct: resolvedVct }) -// }; -// } - -// return credentialConfigMap; // ✅ Return flat map, not nested object -// } - -/** - * Helper — Optional - * Wraps the credential configurations map into the expected schema - * for issuer metadata JSON: - * - * { "credentialConfigurationsSupported": { ... } } - */ -export function wrapCredentialConfigurationsSupported( - configsMap: Record -): CredentialConfigurationsSupported { - return { credentialConfigurationsSupported: configsMap }; -} - // Default DPoP list for issuer-level metadata (match your example) const ISSUER_DPOP_ALGS_DEFAULT = ['RS256', 'ES256'] as const; @@ -499,7 +375,7 @@ export function buildCredentialConfig(name: string, template: SdJwtTemplate | Md */ //TODO: Fix this eslint issue // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function buildCredentialConfigurationsSupportedNew(templateRows: any): Record { +export function buildCredentialConfigurationsSupported(templateRows: any): Record { const credentialConfigMap: Record = {}; for (const templateRow of templateRows) { diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index d877de735..368afd678 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -40,7 +40,7 @@ import { dpopSigningAlgValuesSupported } from '../constant/issuance'; import { - buildCredentialConfigurationsSupportedNew, + buildCredentialConfigurationsSupported, buildIssuerPayload, encodeIssuerPublicId, extractTemplateIds, @@ -53,8 +53,7 @@ import { UpdateCredentialRequest } from '../interfaces/oid4vc-issuer-sessions.interfaces'; import { - // buildCredentialOfferPayload, - buildCredentialOfferPayloadNew, + buildCredentialOfferPayload, buildCredentialOfferUrl, CredentialOfferPayload } from '../libs/helpers/credential-sessions.builder'; @@ -315,13 +314,7 @@ export class Oid4vcIssuanceService { if (!createdTemplate) { throw new InternalServerErrorException(ResponseMessages.oidcTemplate.error.createFailed); } - // let opts = {}; - // if (vct) { - // opts = { ...opts, vct }; - // } - // if (doctype) { - // opts = { ...opts, doctype }; - // } + let createTemplateOnAgent; try { const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); @@ -584,7 +577,7 @@ export class Oid4vcIssuanceService { //TODO: add logic to pass the issuer info const issuerDetailsFromDb = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); const { publicIssuerId, authorizationServerUrl } = issuerDetailsFromDb || {}; - const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayloadNew( + const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayload( createOidcCredentialOffer, // getAllOfferTemplates, @@ -767,7 +760,7 @@ export class Oid4vcIssuanceService { const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); const templates = await this.oid4vcIssuanceRepository.getTemplatesByIssuerId(issuerId); - const credentialConfigurationsSupported = buildCredentialConfigurationsSupportedNew(templates); + const credentialConfigurationsSupported = buildCredentialConfigurationsSupported(templates); return buildIssuerPayload({ credentialConfigurationsSupported }, issuerDetails); } catch (error) { From 48a53462d9a271119623398e79baeee6487a91a3 Mon Sep 17 00:00:00 2001 From: Rinkal Bhojani Date: Wed, 29 Oct 2025 19:31:58 +0530 Subject: [PATCH 12/13] fix: resolved coderabbit suggested chnages Signed-off-by: Rinkal Bhojani --- .../agent-service/dto/create-schema.dto.ts | 2 ++ .../dtos/issuer-sessions.dto.ts | 9 ++++--- .../dtos/oid4vc-issuer-template.dto.ts | 18 ++++---------- .../interfaces/oid4vc-template.interfaces.ts | 4 ++-- libs/common/src/date-only.ts | 24 +++++++++++++++++-- libs/enum/src/enum.ts | 10 ++++++++ 6 files changed, 47 insertions(+), 20 deletions(-) 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 21dd0cf1b..e8a8a1a0c 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 @@ -21,6 +21,8 @@ export class CreateTenantSchemaDto { @IsArray({ message: 'attributes must be an array' }) @IsString({ each: true }) // TODO: IsNotEmpty won't work for array. Must use @ArrayNotEmpty() instead + // @ArrayNotEmpty({ message: 'please provide at least one attribute' }) + // @IsNotEmpty({ each: true, message: 'attribute must not be empty' }) @IsNotEmpty({ message: 'please provide valid attributes' }) attributes: string[]; 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 2325de600..fc2996c6f 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 @@ -17,7 +17,8 @@ import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, - Validate + Validate, + IsDate } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -123,16 +124,18 @@ export class ValidityInfo { example: '2025-04-23T14:34:09.188Z', required: true }) - @IsString() @IsNotEmpty() + @Type(() => Date) + @IsDate() validFrom: Date; @ApiProperty({ example: '2026-05-03T14:34:09.188Z', required: true }) - @IsString() @IsNotEmpty() + @Type(() => Date) + @IsDate() validUntil: Date; } 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 13a6a98e6..5397d6524 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 @@ -15,7 +15,7 @@ import { import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { SignerOption } from '@prisma/client'; -import { CredentialFormat } from '@credebl/enum/enum'; +import { AttributeType, CredentialFormat } from '@credebl/enum/enum'; class CredentialAttributeDisplayDto { @ApiPropertyOptional({ example: 'First Name' }) @@ -28,17 +28,6 @@ class CredentialAttributeDisplayDto { @IsOptional() locale?: string; } - -export enum AttributeType { - STRING = 'string', - NUMBER = 'number', - BOOLEAN = 'boolean', - DATE = 'date', - OBJECT = 'object', - ARRAY = 'array', - IMAGE = 'image' -} - export class CredentialAttributeDto { @ApiProperty({ description: 'Unique key for this attribute (e.g., full_name, org.iso.23220.photoID.1.birth_date)' }) @IsString() @@ -52,7 +41,8 @@ export class CredentialAttributeDto { // TODO: Check how do we handle claims with only path rpoperty like email, etc. @ApiProperty({ enum: AttributeType, description: 'Type of the attribute value (string, number, date, etc.)' }) @IsEnum(AttributeType) - value_type: string; + // TODO: changes value_type: AttributeType; + value_type: AttributeType; @ApiProperty({ description: 'Whether this attribute should be disclosed (for SD-JWT)' }) @IsOptional() @@ -210,6 +200,8 @@ export class SdJwtTemplateDto { description: 'Attributes included in the credential template' }) @IsArray() + @ValidateNested({ each: true }) + @Type(() => CredentialAttributeDto) attributes: CredentialAttributeDto[]; } diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts index 5f590389c..2b33c19e1 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts @@ -1,5 +1,5 @@ import { Prisma, SignerOption } from '@prisma/client'; -import { CredentialFormat } from '@credebl/enum/enum'; +import { AttributeType, CredentialFormat } from '@credebl/enum/enum'; export interface SdJwtTemplate { vct: string; attributes: CredentialAttribute[]; @@ -40,7 +40,7 @@ export interface Claim { export interface CredentialAttribute { key: string; mandatory?: boolean; - value_type: string; + value_type: AttributeType; disclose?: boolean; children?: CredentialAttribute[]; display?: ClaimDisplay[]; diff --git a/libs/common/src/date-only.ts b/libs/common/src/date-only.ts index 969a4ce2f..5cd199339 100644 --- a/libs/common/src/date-only.ts +++ b/libs/common/src/date-only.ts @@ -2,8 +2,25 @@ const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); export class DateOnly { private date: Date; - public constructor(date?: string) { - this.date = date ? new Date(date) : new Date(); + public constructor(date?: string | Date) { + if (date instanceof Date) { + if (isNaN(date.getTime())) { + throw new TypeError('Invalid Date'); + } + this.date = date; + return; + } + if (!date) { + this.date = new Date(); + return; + } + // Accept only YYYY-MM-DD or full ISO strings + const iso = /^\d{4}-\d{2}-\d{2}(T.*Z)?$/.test(date) ? date : ''; + const d = new Date(iso || date); + if (isNaN(d.getTime())) { + throw new TypeError('Invalid date string'); + } + this.date = d; } get [Symbol.toStringTag](): string { @@ -34,5 +51,8 @@ export const serverStartupTimeInMilliseconds = Date.now(); export function dateToSeconds(date: Date | DateOnly): number { const realDate = date instanceof DateOnly ? new Date(date.toISOString()) : date; + if (isNaN(realDate.getTime())) { + throw new TypeError('dateToSeconds: invalid date'); + } return Math.floor(realDate.getTime() / 1000); } diff --git a/libs/enum/src/enum.ts b/libs/enum/src/enum.ts index 043ca4e1f..59586a71b 100644 --- a/libs/enum/src/enum.ts +++ b/libs/enum/src/enum.ts @@ -323,6 +323,16 @@ export enum CredentialFormat { Mdoc = 'mso_mdoc' } +export enum AttributeType { + STRING = 'string', + NUMBER = 'number', + BOOLEAN = 'boolean', + DATE = 'date', + OBJECT = 'object', + ARRAY = 'array', + IMAGE = 'image' +} + // export enum SignerOption { // DID, // X509_P256, From 2f84577afb76668f5b1dc19cff1808b5f6c0bbf8 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Thu, 30 Oct 2025 11:42:58 +0530 Subject: [PATCH 13/13] feat: added webhook Signed-off-by: Tipu_Singh --- .../dtos/oid4vc-credential-wh.dto.ts | 50 +++-- .../oid4vc-issuance.controller.ts | 4 +- .../interfaces/oid4vc-wh-interfaces.ts | 44 +---- .../src/oid4vc-issuance.controller.ts | 4 +- .../src/oid4vc-issuance.repository.ts | 42 ++++- .../src/oid4vc-issuance.service.ts | 62 ++++-- .../migration.sql | 22 +++ libs/prisma-service/prisma/schema.prisma | 176 +++++++++--------- 8 files changed, 240 insertions(+), 164 deletions(-) create mode 100644 libs/prisma-service/prisma/migrations/20251029164125_updated_table_oid4vc_credentials/migration.sql 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 index d4a02f669..54e4a8406 100644 --- 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 @@ -2,23 +2,10 @@ 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 { @@ -40,11 +27,48 @@ export class OidcIssueCredentialDto { @IsString() credentialOfferId!: string; + @ApiProperty({ type: [Object] }) + @IsArray() + issuedCredentials!: Record[]; + + @ApiProperty({ type: CredentialOfferPayloadDto }) + @IsObject() + credentialOfferPayload!: CredentialOfferPayloadDto; + @ApiProperty() @IsString() state!: string; + @ApiProperty() + @IsString() + createdAt!: string; + + @ApiProperty() + @IsString() + updatedAt!: string; + @ApiProperty() @IsString() contextCorrelationId!: string; } + +/** + * Utility: return only credential_configuration_ids from a webhook payload + */ +export function extractCredentialConfigurationIds(payload: Partial): string[] { + const cfg = payload?.credentialOfferPayload?.credential_configuration_ids; + return Array.isArray(cfg) ? cfg : []; +} + +export function sanitizeOidcIssueCredentialDto( + payload: Partial +): Partial { + const ids = extractCredentialConfigurationIds(payload); + return { + ...payload, + credentialOfferPayload: { + // eslint-disable-next-line camelcase + credential_configuration_ids: ids + } + }; +} diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts index 66b050146..b7ea3892b 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts @@ -45,7 +45,7 @@ import { CustomExceptionFilter } from 'apps/api-gateway/common/exception-handler 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 { OidcIssueCredentialDto, sanitizeOidcIssueCredentialDto } from './dtos/oid4vc-credential-wh.dto'; import { Oid4vcIssuanceService } from './oid4vc-issuance.service'; import { CreateCredentialOfferD2ADto, @@ -630,7 +630,7 @@ export class Oid4vcIssuanceController { @Param('id') id: string, @Res() res: Response ): Promise { - console.log('Webhook received:', JSON.stringify(oidcIssueCredentialDto, null, 2)); + // const sanitized = sanitizeOidcIssueCredentialDto(oidcIssueCredentialDto); const getCredentialDetails = await this.oid4vcIssuanceService.oidcIssueCredentialWebhook( oidcIssueCredentialDto, id diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts index 3f878528e..1e48e4c0f 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts @@ -1,38 +1,14 @@ -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; +export interface Oid4vcCredentialOfferWebhookPayload { id: string; - State: string; - contextCorrelationId: string; + credentialOfferId?: string; + issuedCredentials?: Record[]; + createdAt?: string; + updatedAt?: string; + credentialOfferPayload?: { + credential_configuration_ids?: string[]; + }; + state?: string; + contextCorrelationId?: string; } export interface CredentialPayload { diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts index 1812e3c90..01b69f5d6 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts @@ -10,7 +10,7 @@ import { GetAllCredentialOffer } from '../interfaces/oid4vc-issuer-sessions.interfaces'; import { CreateCredentialTemplate, UpdateCredentialTemplate } from '../interfaces/oid4vc-template.interfaces'; -import { CredentialOfferWebhookPayload } from '../interfaces/oid4vc-wh-interfaces'; +import { Oid4vcCredentialOfferWebhookPayload } from '../interfaces/oid4vc-wh-interfaces'; @Controller() export class Oid4vcIssuanceController { @@ -176,7 +176,7 @@ export class Oid4vcIssuanceController { } @MessagePattern({ cmd: 'webhook-oid4vc-issue-credential' }) - async oidcIssueCredentialWebhook(payload: CredentialOfferWebhookPayload): Promise { + async oidcIssueCredentialWebhook(payload: Oid4vcCredentialOfferWebhookPayload): Promise { return this.oid4vcIssuanceService.storeOidcCredentialWebhook(payload); } } diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts index d7651172b..959cccb56 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts @@ -65,18 +65,40 @@ export class Oid4vcIssuanceRepository { } } - async storeOidcCredentialDetails(credentialPayload): Promise { + async storeOidcCredentialDetails( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + credentialPayload: + | { + id: string; + credentialOfferId?: string; + state?: string; + contextCorrelationId?: string; + credentialConfigurationIds?: string[]; + issuedCredentials?: string[]; + } + | any, + orgId: string + ): Promise { try { - const { credentialOfferId, state, offerId, contextCorrelationId, orgId } = credentialPayload; + const payload = credentialPayload?.oidcIssueCredentialDto ?? credentialPayload ?? {}; + const { + credentialOfferId, + state, + id: issuanceSessionId, + contextCorrelationId, + credentialOfferPayload, + issuedCredentials + } = payload; + const credentialDetails = await this.prisma.oid4vc_credentials.upsert({ where: { - offerId + issuanceSessionId }, update: { lastChangedBy: orgId, - credentialOfferId, - contextCorrelationId, - state + state, + credentialConfigurationIds: credentialOfferPayload.credential_configuration_ids ?? [], + ...(issuedCredentials !== undefined ? { issuedCredentials } : {}) }, create: { lastChangedBy: orgId, @@ -84,14 +106,16 @@ export class Oid4vcIssuanceRepository { state, orgId, credentialOfferId, - offerId, - contextCorrelationId + contextCorrelationId, + issuanceSessionId, + credentialConfigurationIds: credentialOfferPayload.credential_configuration_ids ?? [], + ...(issuedCredentials !== undefined ? { issuedCredentials } : {}) } }); return credentialDetails; } catch (error) { - this.logger.error(`Error in get storeOidcCredentialDetails: ${error.message} `); + this.logger.error(`Error in storeOidcCredentialDetails in issuance repository: ${error.message} `); throw error; } } diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 368afd678..921575f02 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -60,6 +60,7 @@ import { import { x5cKeyType } from '@credebl/enum/enum'; import { instanceToPlain, plainToInstance } from 'class-transformer'; import { X509CertificateRecord } from '@credebl/common/interfaces/x509.interface'; +import { Oid4vcCredentialOfferWebhookPayload } from '../interfaces/oid4vc-wh-interfaces'; type CredentialDisplayItem = { logo?: { uri: string; alt_text?: string }; @@ -953,33 +954,60 @@ export class Oid4vcIssuanceService { return agentDetails.agentEndPoint; } - async storeOidcCredentialWebhook(CredentialOfferWebhookPayload): Promise { + async storeOidcCredentialWebhook( + CredentialOfferWebhookPayload: Oid4vcCredentialOfferWebhookPayload + ): Promise { try { - console.log('Storing OID4VC Credential Webhook:', CredentialOfferWebhookPayload); - const { credentialOfferId, state, id, contextCorrelationId } = CredentialOfferWebhookPayload; + // pick fields + const { + credentialOfferId, + state, + id: issuanceSessionId, + contextCorrelationId, + credentialOfferPayload, + issuedCredentials + } = CredentialOfferWebhookPayload ?? {}; + + // ensure we only store credential_configuration_ids in the payload for logging and storage + const cfgIds: string[] = Array.isArray(credentialOfferPayload?.credential_configuration_ids) + ? credentialOfferPayload.credential_configuration_ids + : []; + + // convert issuedCredentials to string[] when schema expects string[] + const issuedCredentialsArr: string[] | undefined = + Array.isArray(issuedCredentials) && 0 < issuedCredentials.length + ? issuedCredentials.map((c: any) => ('string' === typeof c ? c : JSON.stringify(c))) + : issuedCredentials && Array.isArray(issuedCredentials) && 0 === issuedCredentials.length + ? [] + : undefined; + + const sanitized = { + ...CredentialOfferWebhookPayload, + credentialOfferPayload: { + credential_configuration_ids: cfgIds + } + }; + + console.log('Storing OID4VC Credential Webhook:', JSON.stringify(sanitized, null, 2)); + + // resolve orgId (unchanged logic) let orgId: string; if ('default' !== contextCorrelationId) { const getOrganizationId = await this.oid4vcIssuanceRepository.getOrganizationByTenantId(contextCorrelationId); orgId = getOrganizationId?.orgId; } else { - orgId = id; + orgId = issuanceSessionId; } - const credentialPayload = { - orgId, - offerId: id, - credentialOfferId, - state, - contextCorrelationId - }; - - const agentDetails = await this.oid4vcIssuanceRepository.storeOidcCredentialDetails(credentialPayload); + // hand off to repository for persistence (repository will perform the upsert) + const agentDetails = await this.oid4vcIssuanceRepository.storeOidcCredentialDetails( + CredentialOfferWebhookPayload, + orgId + ); return agentDetails; } catch (error) { - this.logger.error( - `[getIssueCredentialsbyCredentialRecordId] - error in get credentials : ${JSON.stringify(error)}` - ); - throw new RpcException(error.response ? error.response : error); + this.logger.error(`[storeOidcCredentialWebhook] - error: ${JSON.stringify(error)}`); + throw error; } } } diff --git a/libs/prisma-service/prisma/migrations/20251029164125_updated_table_oid4vc_credentials/migration.sql b/libs/prisma-service/prisma/migrations/20251029164125_updated_table_oid4vc_credentials/migration.sql new file mode 100644 index 000000000..c87bd9258 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20251029164125_updated_table_oid4vc_credentials/migration.sql @@ -0,0 +1,22 @@ +/* + Warnings: + + - You are about to drop the column `offerId` on the `oid4vc_credentials` table. All the data in the column will be lost. + - A unique constraint covering the columns `[issuanceSessionId]` on the table `oid4vc_credentials` will be added. If there are existing duplicate values, this will fail. + - Added the required column `issuanceSessionId` to the `oid4vc_credentials` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "oid4vc_credentials_offerId_key"; + +-- AlterTable +ALTER TABLE "oid4vc_credentials" DROP COLUMN "offerId", +ADD COLUMN "credentialConfigurationIds" TEXT[], +ADD COLUMN "issuanceSessionId" TEXT NOT NULL, +ADD COLUMN "issuedCredentials" TEXT[]; + +-- CreateIndex +CREATE UNIQUE INDEX "oid4vc_credentials_issuanceSessionId_key" ON "oid4vc_credentials"("issuanceSessionId"); + +-- CreateIndex +CREATE INDEX "oid4vc_credentials_credentialConfigurationIds_idx" ON "oid4vc_credentials" USING GIN ("credentialConfigurationIds"); diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index eebca4da4..5d3408b53 100644 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -39,35 +39,35 @@ model user { } model account { - id String @id @default(uuid()) @db.Uuid - userId String @unique @db.Uuid + id String @id @default(uuid()) @db.Uuid + userId String @unique @db.Uuid type String? provider String - providerAccountId String + providerAccountId String tokenType String? scope String? idToken String? sessionState String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user user @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user user @relation(fields: [userId], references: [id]) sessions session[] - } +} model session { - id String @id @default(uuid()) @db.Uuid - sessionToken String - userId String @db.Uuid - expires Int - refreshToken String? - user user @relation(fields: [userId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - accountId String? @db.Uuid - sessionType String? - account account? @relation(fields: [accountId], references:[id]) - expiresAt DateTime? @db.Timestamp(6) - clientInfo Json? + id String @id @default(uuid()) @db.Uuid + sessionToken String + userId String @db.Uuid + expires Int + refreshToken String? + user user @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accountId String? @db.Uuid + sessionType String? + account account? @relation(fields: [accountId], references: [id]) + expiresAt DateTime? @db.Timestamp(6) + clientInfo Json? } model token { @@ -151,7 +151,7 @@ model organisation { agent_invitations agent_invitations[] credential_definition credential_definition[] file_upload file_upload[] - oid4vc_credentials oid4vc_credentials[] + oid4vc_credentials oid4vc_credentials[] } model org_invitations { @@ -231,7 +231,7 @@ model org_agents { webhookUrl String? @db.VarChar org_dids org_dids[] oidc_issuer oidc_issuer[] - x509_certificates x509_certificates[] + x509_certificates x509_certificates[] } model org_dids { @@ -307,7 +307,7 @@ model schema { type String? @db.VarChar isSchemaArchived Boolean @default(false) credential_definition credential_definition[] - alias String? + alias String? } model credential_definition { @@ -353,16 +353,16 @@ model agent_invitations { } model connections { - id String @id @default(uuid()) @db.Uuid - createDateTime DateTime @default(now()) @db.Timestamptz(6) - createdBy String @db.Uuid - lastChangedDateTime DateTime @default(now()) @db.Timestamptz(6) - lastChangedBy String @db.Uuid - connectionId String @unique - theirLabel String @default("") + id String @id @default(uuid()) @db.Uuid + createDateTime DateTime @default(now()) @db.Timestamptz(6) + createdBy String @db.Uuid + lastChangedDateTime DateTime @default(now()) @db.Timestamptz(6) + lastChangedBy String @db.Uuid + connectionId String @unique + theirLabel String @default("") state String - orgId String? @db.Uuid - organisation organisation? @relation(fields: [orgId], references: [id]) + orgId String? @db.Uuid + organisation organisation? @relation(fields: [orgId], references: [id]) presentations presentations[] credentials credentials[] } @@ -390,7 +390,7 @@ model presentations { createdBy String @db.Uuid lastChangedDateTime DateTime @default(now()) @db.Timestamptz(6) lastChangedBy String @db.Uuid - connectionId String? + connectionId String? state String? threadId String @unique isVerified Boolean? @@ -401,7 +401,6 @@ model presentations { orgId String? @db.Uuid organisation organisation? @relation(fields: [orgId], references: [id]) connections connections? @relation(fields: [connectionId], references: [connectionId]) - } model file_upload { @@ -418,7 +417,7 @@ model file_upload { organisation organisation? @relation(fields: [orgId], references: [id]) orgId String? @db.Uuid credential_type String? - templateId String? @db.VarChar + templateId String? @db.VarChar } model file_data { @@ -551,12 +550,12 @@ model cloud_wallet_user_info { createdBy String @db.Uuid lastChangedDateTime DateTime @default(now()) @db.Timestamptz(6) lastChangedBy String @db.Uuid - userId String? @db.Uuid + userId String? @db.Uuid agentEndpoint String? agentApiKey String? key String? connectionImageUrl String? - user user? @relation(fields: [userId], references: [id]) + user user? @relation(fields: [userId], references: [id]) } enum CloudWalletType { @@ -565,40 +564,45 @@ enum CloudWalletType { } model client_aliases { - id String @id @default(uuid()) @db.Uuid - createDateTime DateTime @default(now()) @db.Timestamptz(6) - lastChangedDateTime DateTime @default(now()) @db.Timestamptz(6) - clientAlias String? - clientUrl String + id String @id @default(uuid()) @db.Uuid + createDateTime DateTime @default(now()) @db.Timestamptz(6) + lastChangedDateTime DateTime @default(now()) @db.Timestamptz(6) + 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 - authorizationServerUrl String - orgAgentId String @db.Uuid - orgAgent org_agents @relation(fields: [orgAgentId], references: [id]) - templates credential_templates[] - batchCredentialIssuanceSize Int @default(0) + id String @id @default(uuid()) @db.Uuid + createDateTime DateTime @default(now()) @db.Timestamptz(6) + createdBy String @db.Uuid + publicIssuerId String + metadata Json + authorizationServerUrl String + 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 + id String @id @default(uuid()) @db.Uuid + orgId String @db.Uuid + issuanceSessionId 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 + createdBy String @db.Uuid + createDateTime DateTime @default(now()) @db.Timestamptz(6) + lastChangedDateTime DateTime @default(now()) @db.Timestamptz(6) + lastChangedBy String @db.Uuid + credentialConfigurationIds String[] + issuedCredentials String[] + + organisation organisation @relation(fields: [orgId], references: [id]) - organisation organisation @relation(fields: [orgId], references: [id]) + @@index([credentialConfigurationIds], type: Gin) } enum SignerOption { @@ -608,41 +612,39 @@ enum SignerOption { } model credential_templates { - id String @id @default(uuid()) - name String - description String? - format String // e.g. "sd_jwt", "mso_mdoc" - canBeRevoked Boolean @default(false) + 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 + attributes Json + appearance Json - issuerId String @db.Uuid - issuer oidc_issuer @relation(fields: [issuerId], references: [id]) + issuerId String @db.Uuid + issuer oidc_issuer @relation(fields: [issuerId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - signerOption SignerOption + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + signerOption SignerOption } model x509_certificates { - id String @id @default(uuid()) + id String @id @default(uuid()) - orgAgentId String @db.Uuid - org_agents org_agents @relation(fields: [orgAgentId], references: [id]) + orgAgentId String @db.Uuid + org_agents org_agents @relation(fields: [orgAgentId], references: [id]) - keyType String // "p256", "ed25519" - status String //e.g "Active", "Pending activation", "InActive" - validFrom DateTime + keyType String // "p256", "ed25519" + status String //e.g "Active", "Pending activation", "InActive" + validFrom DateTime - expiry DateTime - certificateBase64 String - isImported Boolean @default(false) + expiry DateTime + certificateBase64 String + isImported Boolean @default(false) - createdAt DateTime @default(now()) - createdBy String @db.Uuid - lastChangedDateTime DateTime @updatedAt - lastChangedBy String @db.Uuid + createdAt DateTime @default(now()) + createdBy String @db.Uuid + lastChangedDateTime DateTime @updatedAt + lastChangedBy String @db.Uuid } - -