|
3 | 3 | import { Prisma, credential_templates } from '@prisma/client'; |
4 | 4 | import { GetAllCredentialOffer, SignerOption } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; |
5 | 5 | 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'; |
6 | 12 |
|
7 | 13 | /* ============================================================================ |
8 | 14 | Domain Types |
@@ -494,3 +500,237 @@ export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer: |
494 | 500 | // Append query string if any params exist |
495 | 501 | return 0 < criteriaParams.length ? `${baseUrl}?${criteriaParams.join('&')}` : baseUrl; |
496 | 502 | } |
| 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