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 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..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 @@ -2,26 +2,31 @@ 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 + // @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[]; + + @ApiProperty() + @IsNotEmpty({ message: 'please provide orgId' }) + orgId: 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 77c88b075..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,10 +17,12 @@ import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, - Validate + Validate, + IsDate } 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 +119,26 @@ function ExactlyOneOf(keys: string[], options?: ValidationOptions) { return Validate(ExactlyOneOfConstraint, keys, options); } +export class ValidityInfo { + @ApiProperty({ + example: '2025-04-23T14:34:09.188Z', + required: true + }) + @IsNotEmpty() + @Type(() => Date) + @IsDate() + validFrom: Date; + + @ApiProperty({ + example: '2026-05-03T14:34:09.188Z', + required: true + }) + @IsNotEmpty() + @Type(() => Date) + @IsDate() + validUntil: Date; +} + /* ========= Request DTOs ========= */ export class CredentialRequestDto { @ApiProperty({ @@ -137,13 +159,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 { @@ -157,25 +186,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 { @@ -266,20 +286,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/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/dtos/oid4vc-issuer-template.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts index 62800a31d..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 @@ -9,30 +9,63 @@ 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 { AttributeType, 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({ 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; - @ApiProperty({ description: 'Type of the attribute value (string, number, date, etc.)' }) - @IsString() - value_type: string; + // 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) + // TODO: changes value_type: AttributeType; + value_type: AttributeType; + + @ApiProperty({ description: 'Whether this attribute should be disclosed (for SD-JWT)' }) + @IsOptional() + @IsBoolean() + disclose?: boolean; - @ApiProperty({ type: [DisplayDto], required: false, description: 'Localized display values' }) + @ApiProperty({ type: [CredentialAttributeDisplayDto], required: false, description: 'Localized display values' }) @IsOptional() @ValidateNested({ each: true }) - @Type(() => DisplayDto) - display?: DisplayDto[]; -} + @Type(() => CredentialAttributeDisplayDto) + display?: CredentialAttributeDisplayDto[]; + @ApiProperty({ + description: 'Nested attributes if type is object or array', + required: false, + type: () => [CredentialAttributeDto] + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CredentialAttributeDto) + children?: CredentialAttributeDto[]; +} class LogoDto { @ApiPropertyOptional({ example: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg' @@ -74,6 +107,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 { @@ -105,7 +155,57 @@ 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() + @ValidateNested({ each: true }) + @Type(() => CredentialAttributeDto) + attributes: CredentialAttributeDto[]; +} + +@ApiExtraModels(CredentialAttributeDto, SdJwtTemplateDto, MdocTemplateDto) export class CreateCredentialTemplateDto { @ApiProperty({ description: 'Template name' }) @IsString() @@ -124,47 +224,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'; + @ApiProperty({ enum: CredentialFormat, description: 'Credential format type' }) + @IsEnum(CredentialFormat) + format: CredentialFormat; - @ApiPropertyOptional({ - description: 'Document type (required when format is "mso_mdoc"; must NOT be provided when format is "vc+sd-jwt")', - example: 'org.iso.23220.photoID.1' - }) - @ValidateIf((o: CreateCredentialTemplateDto) => 'mso_mdoc' === o.format) - @IsString() - doctype?: string; - - @ValidateIf((o: CreateCredentialTemplateDto) => 'vc+sd-jwt' === o.format) + @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: 'object', - additionalProperties: { $ref: getSchemaPath(CredentialAttributeDto) }, - description: 'Attributes included in the credential template' - }) - @IsObject() - attributes: Record; - @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 b9e67f423..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 @@ -1,41 +1,7 @@ /* eslint-disable camelcase */ -import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; -import { - IsString, - IsOptional, - IsBoolean, - IsArray, - ValidateNested, - IsObject, - IsUrl, - IsNotEmpty, - IsDefined, - IsInt -} from 'class-validator'; -import { plainToInstance, Transform, Type } from 'class-transformer'; - -export class ClaimDto { - @ApiProperty({ - description: 'The unique key for the claim (e.g. email, name)', - example: 'email' - }) - @IsString() - key: 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; -} +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsArray, ValidateNested, IsUrl, IsInt } from 'class-validator'; +import { Type } from 'class-transformer'; export class LogoDto { @ApiProperty({ @@ -53,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' @@ -68,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 @@ -85,101 +43,6 @@ export class DisplayDto { logo?: LogoDto; } -@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', @@ -217,18 +80,18 @@ export enum AccessTokenSignerKeyType { ED25519 = 'ed25519' } -@ApiExtraModels(CredentialConfigurationDto) +// @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() @@ -236,17 +99,53 @@ export class IssuerCreationDto { batchCredentialIssuanceSize?: number; @ApiProperty({ - description: 'Localized display information for the credential', - type: [DisplayDto] + 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(() => DisplayDto) - display: DisplayDto[]; + @Type(() => IssuerDisplayDto) + display: IssuerDisplayDto[]; + + @ApiProperty({ + 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' }) + 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() @@ -259,12 +158,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/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts index 70a86bcbf..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, @@ -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() @@ -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-issuance.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts index 1c55048af..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 { - key: string; - label: string; - required: boolean; -} - export interface Logo { uri: string; alt_text: string; @@ -40,7 +35,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[]; @@ -57,11 +52,11 @@ export interface AuthorizationServerConfig { } export interface IssuerCreation { + authorizationServerUrl: string; issuerId: string; accessTokenSignerKeyType?: AccessTokenSignerKeyType; display: Display[]; dpopSigningAlgValuesSupported?: string[]; - credentialConfigurationsSupported?: Record; authorizationServerConfigs: AuthorizationServerConfig; batchCredentialIssuanceSize: number; } @@ -79,6 +74,7 @@ export interface IssuerInitialConfig { } export interface IssuerMetadata { + authorizationServerUrl: string; publicIssuerId: string; createdById: string; orgAgentId: string; @@ -113,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-issuer-sessions.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts index 6fd33ead5..9a266a8ee 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts @@ -30,24 +30,20 @@ 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; [key: string]: unknown; // extensible for mDoc or other formats } export interface CredentialRequest { - credentialSupportedId?: string; templateId: string; - format: CredentialFormat; // "vc+sd-jwt" | "mso_mdoc" - payload: CredentialPayload; // user-supplied payload (without vct) - disclosureFrame?: DisclosureFrame; // only relevant for vc+sd-jwt + payload: CredentialPayload; + validityInfo?: { + validFrom: Date; + validUntil: Date; + }; } 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 7b36b8f18..2b33c19e1 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts @@ -1,25 +1,47 @@ import { Prisma, SignerOption } from '@prisma/client'; -import { Display } from './oid4vc-issuance.interfaces'; -import { CredentialFormat } from '@credebl/enum/enum'; +import { AttributeType, CredentialFormat } from '@credebl/enum/enum'; +export interface SdJwtTemplate { + vct: string; + attributes: CredentialAttribute[]; +} -export interface CredentialAttribute { - mandatory?: boolean; - value_type: string; - display?: Display[]; +export interface MdocTemplate { + doctype: string; + namespaces: { + namespace: string; + attributes: CredentialAttribute[]; + }[]; } export interface CreateCredentialTemplate { name: string; description?: string; - signerOption?: SignerOption; //SignerOption; + signerOption?: SignerOption; format: CredentialFormat; - issuer: string; canBeRevoked: boolean; - attributes: Prisma.JsonValue; appearance?: Prisma.JsonValue; issuerId: 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: AttributeType; + disclose?: boolean; + children?: CredentialAttribute[]; + display?: ClaimDisplay[]; +} 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/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts index b765bdf5a..f473c827c 100644 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -1,20 +1,30 @@ /* 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 { + CredentialAttribute, + MdocTemplate, + 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', @@ -23,19 +33,20 @@ export enum SignerMethodOption { export type DisclosureFrame = Record>; +export interface validityInfo { + validFrom: Date; + validUntil: Date; +} + 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; + validityInfo?: validityInfo; + // 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 +54,6 @@ export interface CreateOidcCredentialOfferDtoLike { authorizationCodeFlowConfig?: { authorizationServerUrl: string; }; - - // NOTE: issuerId is intentionally NOT emitted in the final payload publicIssuerId?: string; } @@ -59,27 +68,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,356 +98,360 @@ 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 ============================================================================ */ -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 { - 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 -============================================================================ */ +export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer: GetAllCredentialOffer): string { + const criteriaParams: string[] = []; -/** 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 (getAllCredentialOffer.publicIssuerId) { + criteriaParams.push(`publicIssuerId=${encodeURIComponent(getAllCredentialOffer.publicIssuerId)}`); } - if (missingClaims.length) { - throw new Error(`Missing mandatory claims for template "${context.templateId}": ${missingClaims.join(', ')}`); + + if (getAllCredentialOffer.preAuthorizedCode) { + criteriaParams.push(`preAuthorizedCode=${encodeURIComponent(getAllCredentialOffer.preAuthorizedCode)}`); } -} -/* ============================================================================ - JsonValue → TemplateAttributes Narrowing (Type Guards) -============================================================================ */ + if (getAllCredentialOffer.state) { + criteriaParams.push(`state=${encodeURIComponent(getAllCredentialOffer.state)}`); + } -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 - ) - ); -} + if (getAllCredentialOffer.credentialOfferUri) { + criteriaParams.push(`credentialOfferUri=${encodeURIComponent(getAllCredentialOffer.credentialOfferUri)}`); + } -/* ============================================================================ - Improved ensureTemplateAttributes: runtime assert with helpful errors -============================================================================ */ + if (getAllCredentialOffer.authorizationCode) { + criteriaParams.push(`authorizationCode=${encodeURIComponent(getAllCredentialOffer.authorizationCode)}`); + } -const ALLOWED_VALUE_TYPES: ValueType[] = ['string', 'date', 'number', 'boolean', 'integer']; + // Append query string if any params exist + return 0 < criteriaParams.length ? `${baseUrl}?${criteriaParams.join('&')}` : baseUrl; +} -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}` - ); - } +export function validatePayloadAgainstTemplate(template: any, payload: any): { valid: boolean; errors: string[] } { + const errors: string[] = []; - 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 validateAttributes = (attributes: CredentialAttribute[], data: any, path = '') => { + for (const attr of attributes) { + const currentPath = path ? `${path}.${attr.key}` : attr.key; + const value = data?.[attr.key]; - const problems: string[] = []; - const suggestedFixes: string[] = []; + // 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); - for (const attributeKey of attributeKeys) { - const rawAttributeDef = attributesMap[attributeKey]; + if (attr.mandatory && isEmpty) { + errors.push(`Missing mandatory attribute: ${currentPath}`); + } - 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; + // Recurse for nested attributes + if (attr.children && 'object' === typeof value && null !== value) { + validateAttributes(attr.children, value, currentPath); + } } + }; - // 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" }` - ); + 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); + } } + } - // 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" }` - ); + 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); } } - // 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" }] }` - ); + return frame; + }; + + Object.assign(disclosureFrame, buildFrame(template.attributes)); + + 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() } - } + }; +} - 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 - ); +function buildSdJwtCredential( + credentialRequest: CredentialRequestDtoLike, + templateRecord: any, + signerOptions: SignerOption[], + activeCertificateDetails?: X509CertificateRecord[] +): BuiltCredential { + // For SD-JWT format we expect payload to be a flat map of claims (no namespaces) + let payloadCopy = { ...(credentialRequest.payload as Record) }; - throw new Error( - `Invalid template.attributes shape. Problems found:\n- ${problems.join( - '\n- ' - )}\n\nExample attributes (truncated):\n${samplePreview}${fixesText}` + // // 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)}`); + } } - // Safe to cast to TemplateAttributes - return attributesMap as TemplateAttributes; -} + 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) + }; + } -/* ============================================================================ - Builders -============================================================================ */ + const sdJwtTemplate = templateRecord.attributes as SdJwtTemplate; + payloadCopy.vct = sdJwtTemplate.vct; -/** Build one credential block normalized to API format (using the template's format). */ -function buildOneCredential( + 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 buildMdocCredential( credentialRequest: CredentialRequestDtoLike, - templateRecord: credential_templates, - templateAttributes: TemplateAttributes, - signerOptions?: SignerOption[] + templateRecord: any, + signerOptions: SignerOption[], + activeCertificateDetails: X509CertificateRecord[] ): BuiltCredential { - // 1) Validate payload against template attributes - assertMandatoryClaims(credentialRequest.payload, templateAttributes, { templateId: credentialRequest.templateId }); + let incomingPayload = { ...(credentialRequest.payload as Record) }; + + if ( + !credentialRequest.validityInfo || + !credentialRequest.validityInfo.validFrom || + !credentialRequest.validityInfo.validUntil + ) { + throw new UnprocessableEntityException(`${ResponseMessages.oidcIssuerSession.error.missingValidityInfo}`); + } - // 2) Decide API format from DB format - const selectedApiFormat = mapDbFormatToApiFormat(templateRecord.format); + const certificateDetail = activeCertificateDetails.find((x) => x.certificateBase64 === signerOptions[0].x5c[0]); + const validationResult = validateCredentialDatesInCertificateWindow( + credentialRequest.validityInfo, + certificateDetail + ); - // 3) Build supportedId from template.name + suffix ("-sdjwt" | "-mdoc") - const idSuffix = formatSuffix(selectedApiFormat); - const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; + if (!validationResult.isValid) { + throw new UnprocessableEntityException(`${JSON.stringify(validationResult.details)}`); + } + incomingPayload = { + ...incomingPayload, + validityInfo: credentialRequest.validityInfo + }; - // 4) Strip vct ALWAYS (per requirement) - const normalizedPayload = { ...(credentialRequest.payload as Record) }; - delete (normalizedPayload as Record).vct; + const apiFormat = mapDbFormatToApiFormat(templateRecord.format); + const idSuffix = formatSuffix(apiFormat); + const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; return { - credentialSupportedId, // e.g., "BirthCertificateCredential-sdjwt" + credentialSupportedId, signerOptions: signerOptions ? signerOptions[0] : undefined, - format: selectedApiFormat, // 'vc+sd-jwt' | 'mdoc' - payload: normalizedPayload, // without vct - ...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {}) + format: apiFormat, + payload: incomingPayload }; } -/** - * 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[] + issuerDetails?: { + publicId: string; + authorizationServerUrl?: string; + }, + signerOptions?: SignerOption[], + activeCertificateDetails?: X509CertificateRecord[] ): 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); + + 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 buildSdJwtCredential(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); + } + if (apiFormat === CredentialFormat.Mdoc) { + return buildMdocCredential(credentialRequest, templateRecord, signerOptions, activeCertificateDetails); + } + 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 -// ----------------------------------------------------------------------------- -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 4f6bc6047..a8555607a 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,19 +32,19 @@ 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 }[]; @@ -63,122 +73,45 @@ 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; updatedAt?: Date | string; }; -/** - * Build agent payload from Prisma rows (attributes/appearance are Prisma.JsonValue). - * Safely coerces JSON and then builds the same structure as Builder #2. - */ -export function buildCredentialConfigurationsSupported( - templates: TemplateRowPrisma[], - opts?: { - vct?: string; - doctype?: string; - scopeVct?: string; - keyResolver?: (t: TemplateRowPrisma) => string; - format?: string; - } -): CredentialConfigurationsSupported { - const 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`); - } - if (!isAppearance(app)) { - throw new Error(`Template ${t.id}: invalid appearance JSON (missing display)`); - } - - // per-row format (allow column override) - // 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'; - - // key (allow override) - 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) - // - For sd-jwt: try opts.vct -> t.vct -> fallback to t.name - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let rowDoctype: string | undefined = opts?.doctype ?? (t as any).doctype; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rowVct: string = opts?.vct ?? (t as any).vct ?? t.name; - - if (isMdoc) { - 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`); - } - } - - // 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 display = - app.display?.map((d) => ({ - name: d.name, - description: d.description, - locale: d.locale - })) ?? []; - - credentialConfigurationsSupported[key] = { - format: rowFormat, - scope, - claims, - credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], - cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], - display, - ...(isMdoc ? { doctype: rowDoctype as string } : { vct: rowVct }) - }; - } - - return { credentialConfigurationsSupported }; -} - // Default DPoP list for issuer-level metadata (match your example) const ISSUER_DPOP_ALGS_DEFAULT = ['RS256', 'ES256'] as const; @@ -241,7 +174,7 @@ export function buildIssuerPayload( return { display, dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], - credentialConfigurationsSupported: credentialConfigurations.credentialConfigurationsSupported ?? {}, + credentialConfigurationsSupported: credentialConfigurations.credentialConfigurationsSupported ?? [], batchCredentialIssuance: { batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault } @@ -272,3 +205,213 @@ export function encodeIssuerPublicId(publicIssuerId: string): string { } return encodeURIComponent(publicIssuerId.trim()); } + +///--------------------------------------------------------- + +// function buildClaimsFromAttributesWithPath(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; +// } + +/** + * Recursively builds a nested claims object from a list of attributes. + */ +function buildNestedClaims(attributes: CredentialAttribute[]): Record { + const claims: Record = {}; + + 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 + })); + } + + // ✅ include mandatory flag + if (attr.mandatory) { + node.mandatory = true; + } + + // ✅ 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) { + const formatSuffix = 'sdjwt'; + + // Determine the unique key for this credential configuration + const configKey = `${name}-${formatSuffix}`; + const credentialScope = `openid4vc:${template.vct}-${formatSuffix}`; + + const claims = buildClaimsFromTemplate(template); + + 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}`; + + const claims = buildClaimsFromTemplate(template); + + // 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 buildCredentialConfigurationsSupported(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.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 d6906f9a3..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; } } @@ -121,6 +145,9 @@ export class Oid4vcIssuanceRepository { orgId } }, + // include: { + // templates: true + // }, orderBy: { createDateTime: 'desc' } @@ -174,14 +201,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 } }); @@ -302,6 +331,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 ccb7cedb8..921575f02 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, @@ -58,6 +58,9 @@ import { 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'; +import { Oid4vcCredentialOfferWebhookPayload } from '../interfaces/oid4vc-wh-interfaces'; type CredentialDisplayItem = { logo?: { uri: string; alt_text?: string }; @@ -121,6 +124,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, @@ -223,7 +227,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 +236,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'; @@ -269,13 +288,14 @@ export class Oid4vcIssuanceService { } async createTemplate( - CredentialTemplate: CreateCredentialTemplate, + credentialTemplate: CreateCredentialTemplate, orgId: string, issuerId: string ): Promise { try { - const { name, description, format, canBeRevoked, attributes, appearance, signerOption, vct, doctype } = - CredentialTemplate; + //TODO: add revert mechanism if agent call fails + 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); @@ -285,36 +305,51 @@ export class Oid4vcIssuanceService { description, format: format.toString(), canBeRevoked, - attributes, + attributes: instanceToPlain(credentialTemplate.template), appearance: appearance ?? {}, issuerId, signerOption }; - console.log(`service - createTemplate: `, issuerId); // Persist in DB const createdTemplate = await this.oid4vcIssuanceRepository.createTemplate(issuerId, metadata); if (!createdTemplate) { throw new InternalServerErrorException(ResponseMessages.oidcTemplate.error.createFailed); } - let opts = {}; - if (vct) { - opts = { ...opts, vct }; - } - if (doctype) { - opts = { ...opts, doctype }; - } - const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId, opts); - const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); - if (!agentDetails) { - throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); - } - const { agentEndPoint } = agentDetails; - const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); - if (!issuerDetails) { - throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + + 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); } @@ -341,7 +376,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); } } @@ -349,7 +384,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 } : {}), @@ -363,25 +399,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; @@ -463,6 +533,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({ @@ -484,6 +555,7 @@ export class Oid4vcIssuanceService { method: SignerMethodOption.X5C, x5c: [activeCertificate.certificateBase64] }); + activeCertificateDetails.push(activeCertificate); } if (template.signerOption == SignerOption.X509_ED25519) { @@ -499,14 +571,23 @@ export class Oid4vcIssuanceService { method: SignerMethodOption.X5C, x5c: [activeCertificate.certificateBase64] }); + activeCertificateDetails.push(activeCertificate); } } - console.log(`Setup signerOptions `, signerOptions); //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, - signerOptions + { + publicId: publicIssuerId, + authorizationServerUrl: `${authorizationServerUrl}/oid4vci/${publicIssuerId}` + }, + signerOptions as any, + activeCertificateDetails ); console.log('This is the buildOidcCredentialOffer:', JSON.stringify(buildOidcCredentialOffer, null, 2)); @@ -607,7 +688,6 @@ export class Oid4vcIssuanceService { url, orgId ); - console.log('This is the updateCredentialOfferOnAgent:', JSON.stringify(updateCredentialOfferOnAgent)); if (!updateCredentialOfferOnAgent) { throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorUpdateOffer); } @@ -676,14 +756,14 @@ 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); + const credentialConfigurationsSupported = buildCredentialConfigurationsSupported(templates); - return buildIssuerPayload(credentialConfigurationsSupported, issuerDetails); + return buildIssuerPayload({ credentialConfigurationsSupported }, issuerDetails); } catch (error) { this.logger.error(`[buildOidcIssuerPayload] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); @@ -874,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/common/src/date-only.ts b/libs/common/src/date-only.ts new file mode 100644 index 000000000..5cd199339 --- /dev/null +++ b/libs/common/src/date-only.ts @@ -0,0 +1,58 @@ +const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); +export class DateOnly { + private date: 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 { + 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; + if (isNaN(realDate.getTime())) { + throw new TypeError('dateToSeconds: invalid 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 9eb59b160..aec4459e7 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: { @@ -550,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: { diff --git a/libs/enum/src/enum.ts b/libs/enum/src/enum.ts index 0a52130df..59586a71b 100644 --- a/libs/enum/src/enum.ts +++ b/libs/enum/src/enum.ts @@ -322,3 +322,19 @@ export enum CredentialFormat { SdJwtVc = 'vc+sd-jwt', 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, +// X509_ED25519 +// } 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/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 f2baa0278..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,39 +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 - 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 { @@ -607,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 } - -