Skip to content

Commit acafc89

Browse files
committed
fix: correction in create offer functions for validations and builder method
Signed-off-by: Rinkal Bhojani <[email protected]>
1 parent 83c6de7 commit acafc89

File tree

4 files changed

+333
-30
lines changed

4 files changed

+333
-30
lines changed

apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ export enum AuthenticationType {
3030
export type DisclosureFrame = Record<string, boolean | Record<string, boolean>>;
3131

3232
export interface CredentialPayload {
33-
full_name?: string;
34-
birth_date?: string; // YYYY-MM-DD if present
35-
birth_place?: string;
36-
parent_names?: string;
33+
validityInfo: {
34+
validFrom: Date;
35+
validUntil: Date;
36+
};
3737
[key: string]: unknown; // extensible for mDoc or other formats
3838
}
3939

apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
import { Prisma, credential_templates } from '@prisma/client';
44
import { GetAllCredentialOffer, SignerOption } from '../../interfaces/oid4vc-issuer-sessions.interfaces';
55
import { CredentialFormat } from '@credebl/enum/enum';
6+
import {
7+
CredentialAttribute,
8+
MdocTemplate,
9+
SdJwtTemplate
10+
} from 'apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces';
11+
import { UnprocessableEntityException } from '@nestjs/common';
612

713
/* ============================================================================
814
Domain Types
@@ -494,3 +500,237 @@ export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer:
494500
// Append query string if any params exist
495501
return 0 < criteriaParams.length ? `${baseUrl}?${criteriaParams.join('&')}` : baseUrl;
496502
}
503+
504+
export function validatePayloadAgainstTemplate(template: any, payload: any): { valid: boolean; errors: string[] } {
505+
const errors: string[] = [];
506+
507+
const validateAttributes = (attributes: CredentialAttribute[], data: any, path = '') => {
508+
for (const attr of attributes) {
509+
const currentPath = path ? `${path}.${attr.key}` : attr.key;
510+
const value = data?.[attr.key];
511+
512+
// Check for missing mandatory value
513+
const isEmpty =
514+
value === undefined ||
515+
null === value ||
516+
('string' === typeof value && '' === value.trim()) ||
517+
('object' === typeof value && !Array.isArray(value) && 0 === Object.keys(value).length);
518+
519+
if (attr.mandatory && isEmpty) {
520+
errors.push(`Missing mandatory attribute: ${currentPath}`);
521+
}
522+
523+
// Recurse for nested attributes
524+
if (attr.children && 'object' === typeof value && null !== value) {
525+
validateAttributes(attr.children, value, currentPath);
526+
}
527+
}
528+
};
529+
530+
if (CredentialFormat.SdJwtVc === template.format) {
531+
validateAttributes((template.attributes as SdJwtTemplate).attributes ?? [], payload);
532+
} else if (CredentialFormat.Mdoc === template.format) {
533+
const namespaces = payload?.namespaces;
534+
if (!namespaces) {
535+
errors.push('Missing namespaces object in mdoc payload.');
536+
} else {
537+
const templateNamespaces = (template.attributes as MdocTemplate).namespaces;
538+
for (const ns of templateNamespaces ?? []) {
539+
const nsData = namespaces[ns.namespace];
540+
if (!nsData) {
541+
errors.push(`Missing namespace: ${ns.namespace}`);
542+
continue;
543+
}
544+
validateAttributes(ns.attributes, nsData, ns.namespace);
545+
}
546+
}
547+
}
548+
549+
return { valid: 0 === errors.length, errors };
550+
}
551+
552+
function buildDisclosureFrameFromTemplate(template: { attributes: CredentialAttribute[] }) {
553+
const disclosureFrame: DisclosureFrame = {};
554+
555+
const buildFrame = (attributes: CredentialAttribute[]) => {
556+
const frame: Record<string, any> = {};
557+
558+
for (const attr of attributes) {
559+
if (attr.children?.length) {
560+
// Handle nested attributes recursively
561+
const subFrame = buildFrame(attr.children);
562+
// Include parent only if disclose is true or it has children with disclosure
563+
if (attr.disclose || 0 < Object.keys(subFrame).length) {
564+
frame[attr.key] = subFrame;
565+
}
566+
} else if (attr.disclose !== undefined) {
567+
frame[attr.key] = Boolean(attr.disclose);
568+
}
569+
}
570+
571+
return frame;
572+
};
573+
574+
Object.assign(disclosureFrame, buildFrame(template.attributes));
575+
576+
return disclosureFrame;
577+
}
578+
579+
function buildSdJwtCredentialNew(
580+
credentialRequest: CredentialRequestDtoLike,
581+
templateRecord: any,
582+
signerOptions?: SignerOption[]
583+
): BuiltCredential {
584+
// For SD-JWT format we expect payload to be a flat map of claims (no namespaces)
585+
const payloadCopy = { ...(credentialRequest.payload as Record<string, unknown>) };
586+
587+
// // strip vct if present per requirement
588+
// delete payloadCopy.vct;
589+
590+
const sdJwtTemplate = templateRecord.attributes as SdJwtTemplate;
591+
payloadCopy.vct = sdJwtTemplate.vct;
592+
593+
const apiFormat = mapDbFormatToApiFormat(templateRecord.format);
594+
const idSuffix = formatSuffix(apiFormat);
595+
const credentialSupportedId = `${templateRecord.name}-${idSuffix}`;
596+
const disclosureFrame = buildDisclosureFrameFromTemplate({ attributes: sdJwtTemplate.attributes });
597+
598+
return {
599+
credentialSupportedId,
600+
signerOptions: signerOptions ? signerOptions[0] : undefined,
601+
format: apiFormat,
602+
payload: payloadCopy,
603+
...(disclosureFrame ? { disclosureFrame } : {})
604+
};
605+
}
606+
607+
/** Build an MSO mdoc credential object
608+
* - For mdocs we expect the payload to include a `namespaces` map (draft-15 style)
609+
*/
610+
function buildMdocCredentialNew(
611+
credentialRequest: CredentialRequestDtoLike,
612+
templateRecord: any,
613+
signerOptions?: SignerOption[]
614+
): BuiltCredential {
615+
const incomingPayload = { ...(credentialRequest.payload as Record<string, unknown>) };
616+
617+
// // If caller provided already-namespaced payload, keep it; otherwise build a namespaces map
618+
// const workingPayload = { ...incomingPayload };
619+
// if (!workingPayload.namespaces) {
620+
// const namespacesMap: Record<string, Record<string, unknown>> = {};
621+
// // collect claims that match attribute names into the chosen namespace
622+
// for (const claimName of Object.keys(normalizedAttributes)) {
623+
// if (Object.prototype.hasOwnProperty.call(incomingPayload, claimName)) {
624+
// namespacesMap[defaultNamespace] = namespacesMap[defaultNamespace] ?? {};
625+
// namespacesMap[defaultNamespace][claimName] = (incomingPayload as any)[claimName];
626+
// // remove original flattened claim to avoid duplication
627+
// delete (workingPayload as any)[claimName];
628+
// }
629+
// }
630+
// if (0 < Object.keys(namespacesMap).length) {
631+
// (workingPayload as any).namespaces = namespacesMap;
632+
// }
633+
// } else {
634+
// // ensure namespaces is a plain object
635+
// if (!isPlainRecord((workingPayload as any).namespaces)) {
636+
// throw new Error(`Invalid mdoc payload: 'namespaces' must be an object`);
637+
// }
638+
// }
639+
640+
const apiFormat = mapDbFormatToApiFormat(templateRecord.format);
641+
const idSuffix = formatSuffix(apiFormat);
642+
const credentialSupportedId = `${templateRecord.name}-${idSuffix}`;
643+
644+
return {
645+
credentialSupportedId,
646+
signerOptions: signerOptions ? signerOptions[0] : undefined,
647+
format: apiFormat,
648+
payload: incomingPayload,
649+
...(credentialRequest.disclosureFrame ? { disclosureFrame: credentialRequest.disclosureFrame } : {})
650+
};
651+
}
652+
653+
export function buildCredentialOfferPayloadNew(
654+
dto: CreateOidcCredentialOfferDtoLike,
655+
templates: credential_templates[],
656+
issuerDetails?: {
657+
publicId: string;
658+
authorizationServerUrl?: string;
659+
},
660+
signerOptions?: SignerOption[]
661+
): CredentialOfferPayload {
662+
// Index templates by id
663+
const templatesById = new Map(templates.map((template) => [template.id, template]));
664+
665+
// Validate template ids
666+
const missingTemplateIds = dto.credentials.map((c) => c.templateId).filter((id) => !templatesById.has(id));
667+
if (missingTemplateIds.length) {
668+
throw new Error(`Unknown template ids: ${missingTemplateIds.join(', ')}`);
669+
}
670+
671+
// Build each credential using the template's format
672+
const builtCredentials: BuiltCredential[] = dto.credentials.map((credentialRequest) => {
673+
const templateRecord = templatesById.get(credentialRequest.templateId)!;
674+
675+
const validationError = validatePayloadAgainstTemplate(templateRecord, credentialRequest.payload);
676+
if (!validationError.valid) {
677+
throw new UnprocessableEntityException(`${validationError.errors.join(', ')}`);
678+
}
679+
680+
const templateFormat = (templateRecord as any).format ?? 'vc+sd-jwt';
681+
const apiFormat = mapDbFormatToApiFormat(templateFormat);
682+
683+
if (apiFormat === CredentialFormat.SdJwtVc) {
684+
return buildSdJwtCredentialNew(credentialRequest, templateRecord, signerOptions);
685+
}
686+
if (apiFormat === CredentialFormat.Mdoc) {
687+
return buildMdocCredentialNew(credentialRequest, templateRecord, signerOptions);
688+
}
689+
throw new Error(`Unsupported template format for ${templateFormat}`);
690+
});
691+
692+
// Base envelope: allow explicit publicIssuerId from DTO or fallback to issuerDetails.publicId
693+
const publicIssuerIdFromDto = dto.publicIssuerId;
694+
const publicIssuerIdFromIssuerDetails = issuerDetails?.publicId;
695+
const finalPublicIssuerId = publicIssuerIdFromDto ?? publicIssuerIdFromIssuerDetails;
696+
697+
const baseEnvelope: BuiltCredentialOfferBase = {
698+
credentials: builtCredentials,
699+
...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {})
700+
};
701+
702+
// Determine which authorization flow to return:
703+
// Priority:
704+
// 1) If issuerDetails.authorizationServerUrl is provided, return preAuthorizedCodeFlowConfig using DEFAULT_TXCODE
705+
// 2) Else fall back to flows present in DTO (still enforce XOR)
706+
const overrideAuthorizationServerUrl = issuerDetails?.authorizationServerUrl;
707+
if (overrideAuthorizationServerUrl) {
708+
if ('string' !== typeof overrideAuthorizationServerUrl || '' === overrideAuthorizationServerUrl.trim()) {
709+
throw new Error('issuerDetails.authorizationServerUrl must be a non-empty string when provided');
710+
}
711+
return {
712+
...baseEnvelope,
713+
preAuthorizedCodeFlowConfig: {
714+
txCode: DEFAULT_TXCODE,
715+
authorizationServerUrl: overrideAuthorizationServerUrl
716+
}
717+
};
718+
}
719+
720+
// No override provided — use what DTO carries (must be XOR)
721+
const hasPreAuthFromDto = Boolean(dto.preAuthorizedCodeFlowConfig);
722+
const hasAuthCodeFromDto = Boolean(dto.authorizationCodeFlowConfig);
723+
if (hasPreAuthFromDto === hasAuthCodeFromDto) {
724+
throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.');
725+
}
726+
if (hasPreAuthFromDto) {
727+
return {
728+
...baseEnvelope,
729+
preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig!
730+
};
731+
}
732+
return {
733+
...baseEnvelope,
734+
authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig!
735+
};
736+
}

0 commit comments

Comments
 (0)