diff --git a/packages/data-store/src/__tests__/issuanceBranding.entities.test.ts b/packages/data-store/src/__tests__/issuanceBranding.entities.test.ts index 264268f8a..23f880774 100644 --- a/packages/data-store/src/__tests__/issuanceBranding.entities.test.ts +++ b/packages/data-store/src/__tests__/issuanceBranding.entities.test.ts @@ -74,6 +74,16 @@ describe('Database entities tests', (): void => { text: { color: '#000000', }, + claims: [ + { + key: 'given_name', + name: 'Given Name' + }, + { + key: 'family_name', + name: 'Surname' + } + ] }, ], } @@ -112,6 +122,8 @@ describe('Database entities tests', (): void => { ) expect(fromDb?.localeBranding[0].text).toBeDefined() expect(fromDb?.localeBranding[0].text!.color).toEqual(credentialBranding.localeBranding[0].text!.color) + expect(fromDb?.localeBranding[0].claims).toBeDefined() + expect(fromDb?.localeBranding[0].claims.length).toEqual(credentialBranding.localeBranding[0].claims!.length) expect(fromDb?.createdAt).toBeDefined() expect(fromDb?.lastUpdatedAt).toBeDefined() }) @@ -302,8 +314,8 @@ describe('Database entities tests', (): void => { const credentialLocaleBrandingEntity: CredentialLocaleBrandingEntity = credentialLocaleBrandingEntityFrom(localeBranding) const fromDb: CredentialLocaleBrandingEntity = await dbConnection - .getRepository(CredentialLocaleBrandingEntity) - .save(credentialLocaleBrandingEntity) + .getRepository(CredentialLocaleBrandingEntity) + .save(credentialLocaleBrandingEntity) expect(fromDb).toBeDefined() expect(fromDb?.alias).toEqual(localeBranding.alias) @@ -453,13 +465,122 @@ describe('Database entities tests', (): void => { } const result: CredentialLocaleBrandingEntity = await dbConnection - .getRepository(CredentialLocaleBrandingEntity) - .save(updatedCredentialLocaleBranding) + .getRepository(CredentialLocaleBrandingEntity) + .save(updatedCredentialLocaleBranding) expect(result).toBeDefined() expect(result?.lastUpdatedAt).not.toEqual(fromDb?.localeBranding[0].lastUpdatedAt) }) + it('Should save only claims branding to database', async (): Promise => { + const credentialBranding: IBasicCredentialBranding = { + issuerCorrelationId: 'issuerCorrelationId', + vcHash: 'vcHash', + localeBranding: [ + { + claims: [ + { + key: 'given_name', + name: 'Given Name' + }, + { + key: 'family_name', + name: 'Surname' + } + ] + }, + ], + } + + const credentialBrandingEntity: CredentialBrandingEntity = credentialBrandingEntityFrom(credentialBranding) + const fromDb: CredentialBrandingEntity = await dbConnection.getRepository(CredentialBrandingEntity).save(credentialBrandingEntity) + + expect(fromDb).toBeDefined() + expect(fromDb?.id).toBeDefined() + expect(fromDb?.issuerCorrelationId).toEqual(credentialBranding.issuerCorrelationId) + expect(fromDb?.vcHash).toEqual(credentialBranding.vcHash) + expect(fromDb?.localeBranding).toBeDefined() + expect(fromDb?.localeBranding.length).toEqual(1) + expect(fromDb?.localeBranding[0].claims).toBeDefined() + expect(fromDb?.localeBranding[0].claims.length).toEqual(credentialBranding.localeBranding[0].claims!.length) + expect(fromDb?.createdAt).toBeDefined() + expect(fromDb?.lastUpdatedAt).toBeDefined() + }) + + it('Should enforce unique locale for a credential claim key', async (): Promise => { + const credentialBranding: IBasicCredentialBranding = { + issuerCorrelationId: 'issuerCorrelationId', + vcHash: 'vcHash', + localeBranding: [ + { + locale: 'en-US', + claims: [ + { + key: 'given_name', + name: 'Given Name' + }, + { + key: 'given_name', + name: 'Given Name' + }, + ], + } + ], + } + + const credentialBrandingEntity: CredentialBrandingEntity = credentialBrandingEntityFrom(credentialBranding) + + await expect(dbConnection.getRepository(CredentialBrandingEntity).save(credentialBrandingEntity)).rejects.toThrowError( + 'SQLITE_CONSTRAINT: UNIQUE constraint failed: CredentialClaims.credentialLocaleBrandingId, CredentialClaims.key', + ) + }) + + it('should throw error when saving credential branding with blank claim key', async (): Promise => { + const credentialBranding: IBasicCredentialBranding = { + issuerCorrelationId: 'issuerCorrelationId', + vcHash: 'vcHash', + localeBranding: [ + { + claims: [ + { + key: '', + name: 'Given Name' + } + ] + }, + ], + } + + const credentialBrandingEntity: CredentialBrandingEntity = credentialBrandingEntityFrom(credentialBranding) + + await expect(dbConnection.getRepository(CredentialBrandingEntity).save(credentialBrandingEntity)).rejects.toThrowError( + 'Blank claim keys are not allowed', + ) + }) + + it('should throw error when saving credential branding with blank claim name', async (): Promise => { + const credentialBranding: IBasicCredentialBranding = { + issuerCorrelationId: 'issuerCorrelationId', + vcHash: 'vcHash', + localeBranding: [ + { + claims: [ + { + key: 'given_name', + name: '' + } + ] + }, + ], + } + + const credentialBrandingEntity: CredentialBrandingEntity = credentialBrandingEntityFrom(credentialBranding) + + await expect(dbConnection.getRepository(CredentialBrandingEntity).save(credentialBrandingEntity)).rejects.toThrowError( + 'Blank claim names are not allowed', + ) + }) + // Issuer tests it('Should save issuer branding to database', async (): Promise => { diff --git a/packages/data-store/src/entities/issuanceBranding/CredentialClaimsEntity.ts b/packages/data-store/src/entities/issuanceBranding/CredentialClaimsEntity.ts new file mode 100644 index 000000000..370d506f4 --- /dev/null +++ b/packages/data-store/src/entities/issuanceBranding/CredentialClaimsEntity.ts @@ -0,0 +1,44 @@ +import { + BaseEntity, + BeforeInsert, + BeforeUpdate, + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn +} from 'typeorm' +import { CredentialLocaleBrandingEntity } from './CredentialLocaleBrandingEntity' +import { validate, Validate, ValidationError } from 'class-validator' +import { IsNonEmptyStringConstraint } from '../validators' + +@Entity('CredentialClaims') +@Index('IDX_CredentialClaimsEntity_credentialLocaleBranding_locale', ['credentialLocaleBranding', 'key'], { unique: true }) +export class CredentialClaimsEntity extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column('varchar', { name: 'key', length: 255, nullable: false, unique: false }) + @Validate(IsNonEmptyStringConstraint, { message: 'Blank claim keys are not allowed' }) + key!: string + + @Column('varchar', { name: 'name', length: 255, nullable: false, unique: false }) + @Validate(IsNonEmptyStringConstraint, { message: 'Blank claim names are not allowed' }) + name!: string + + @ManyToOne(() => CredentialLocaleBrandingEntity, (credentialLocaleBranding: CredentialLocaleBrandingEntity) => credentialLocaleBranding.claims, { + cascade: ['insert', 'update'], + onDelete: 'CASCADE' + }) + credentialLocaleBranding!: CredentialLocaleBrandingEntity + + @BeforeInsert() + @BeforeUpdate() + async validate(): Promise { + const validation: Array = await validate(this) + if (validation.length > 0) { + return Promise.reject(Error(Object.values(validation[0].constraints!)[0])) + } + return + } +} diff --git a/packages/data-store/src/entities/issuanceBranding/CredentialLocaleBrandingEntity.ts b/packages/data-store/src/entities/issuanceBranding/CredentialLocaleBrandingEntity.ts index de0b17662..9c41e3c26 100644 --- a/packages/data-store/src/entities/issuanceBranding/CredentialLocaleBrandingEntity.ts +++ b/packages/data-store/src/entities/issuanceBranding/CredentialLocaleBrandingEntity.ts @@ -1,6 +1,7 @@ -import { ChildEntity, Column, JoinColumn, ManyToOne, Index } from 'typeorm' +import { ChildEntity, Column, JoinColumn, ManyToOne, Index, OneToMany } from 'typeorm' import { CredentialBrandingEntity } from './CredentialBrandingEntity' import { BaseLocaleBrandingEntity } from './BaseLocaleBrandingEntity' +import { CredentialClaimsEntity } from './CredentialClaimsEntity' @ChildEntity('CredentialLocaleBranding') @Index('IDX_CredentialLocaleBrandingEntity_credentialBranding_locale', ['credentialBranding', 'locale'], { unique: true }) @@ -11,6 +12,15 @@ export class CredentialLocaleBrandingEntity extends BaseLocaleBrandingEntity { @JoinColumn({ name: 'credentialBrandingId' }) credentialBranding!: CredentialBrandingEntity + @OneToMany(() => CredentialClaimsEntity, (claims: CredentialClaimsEntity) => claims.credentialLocaleBranding, { + cascade: true, + onDelete: 'CASCADE', + eager: true, + nullable: false, + }) + @JoinColumn({ name: 'claim_id' }) + claims!: Array + @Column('text', { name: 'credentialBrandingId', nullable: false }) credentialBrandingId!: string } diff --git a/packages/data-store/src/index.ts b/packages/data-store/src/index.ts index 96beb74c3..3e919e3a8 100644 --- a/packages/data-store/src/index.ts +++ b/packages/data-store/src/index.ts @@ -26,6 +26,12 @@ import { OrganizationEntity } from './entities/contact/OrganizationEntity' import { NaturalPersonEntity } from './entities/contact/NaturalPersonEntity' import { ElectronicAddressEntity } from './entities/contact/ElectronicAddressEntity' import { PhysicalAddressEntity } from './entities/contact/PhysicalAddressEntity' +import { AuditEventEntity } from './entities/eventLogger/AuditEventEntity' +import { DigitalCredentialEntity } from './entities/digitalCredential/DigitalCredentialEntity' +import { PresentationDefinitionItemEntity } from './entities/presentationDefinition/PresentationDefinitionItemEntity' +import { ContactMetadataItemEntity } from './entities/contact/ContactMetadataItemEntity' +import { CredentialClaimsEntity } from './entities/issuanceBranding/CredentialClaimsEntity' + export { ContactStore } from './contact/ContactStore' export { AbstractContactStore } from './contact/AbstractContactStore' export { AbstractDigitalCredentialStore } from './digitalCredential/AbstractDigitalCredentialStore' @@ -33,17 +39,12 @@ export { DigitalCredentialStore } from './digitalCredential/DigitalCredentialSto export { AbstractIssuanceBrandingStore } from './issuanceBranding/AbstractIssuanceBrandingStore' export { IssuanceBrandingStore } from './issuanceBranding/IssuanceBrandingStore' export { StatusListStore } from './statusList/StatusListStore' -import { AuditEventEntity } from './entities/eventLogger/AuditEventEntity' -import { DigitalCredentialEntity } from './entities/digitalCredential/DigitalCredentialEntity' -import { PresentationDefinitionItemEntity } from './entities/presentationDefinition/PresentationDefinitionItemEntity' -import { ContactMetadataItemEntity } from './entities/contact/ContactMetadataItemEntity' export { AbstractEventLoggerStore } from './eventLogger/AbstractEventLoggerStore' export { EventLoggerStore } from './eventLogger/EventLoggerStore' export { IAbstractMachineStateStore } from './machineState/IAbstractMachineStateStore' export { MachineStateStore } from './machineState/MachineStateStore' export { AbstractPDStore } from './presentationDefinition/AbstractPDStore' export { PDStore } from './presentationDefinition/PDStore' - export { DataStoreMigrations, DataStoreEventLoggerMigrations, @@ -91,6 +92,7 @@ export const DataStoreIssuanceBrandingEntities = [ TextAttributesEntity, CredentialLocaleBrandingEntity, IssuerLocaleBrandingEntity, + CredentialClaimsEntity ] export const DataStorePresentationDefinitionEntities = [PresentationDefinitionItemEntity] @@ -144,4 +146,5 @@ export { MachineStateInfoEntity, PresentationDefinitionItemEntity, ContactMetadataItemEntity, + CredentialClaimsEntity } diff --git a/packages/data-store/src/migrations/postgres/1685628974232-CreateIssuanceBranding.ts b/packages/data-store/src/migrations/postgres/1685628974232-CreateIssuanceBranding.ts index 5fe2e7e04..221813488 100644 --- a/packages/data-store/src/migrations/postgres/1685628974232-CreateIssuanceBranding.ts +++ b/packages/data-store/src/migrations/postgres/1685628974232-CreateIssuanceBranding.ts @@ -28,6 +28,12 @@ export class CreateIssuanceBranding1685628974232 implements MigrationInterface { `CREATE UNIQUE INDEX "IDX_IssuerLocaleBrandingEntity_issuerBranding_locale" ON "BaseLocaleBranding" ("issuerBrandingId", "locale")`, ) await queryRunner.query(`CREATE INDEX "IDX_BaseLocaleBranding_type" ON "BaseLocaleBranding" ("type")`) + + await queryRunner.query(`CREATE TABLE "CredentialClaims" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" character varying(255) NOT NULL, "name" character varying(255) NOT NULL, "credentialLocaleBrandingId" character varying, CONSTRAINT "PK_CredentialClaims_id" PRIMARY KEY ("id"))`) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_CredentialClaimsEntity_credentialLocaleBranding_locale" ON "CredentialClaims" ("credentialLocaleBrandingId", "key")` + ) + await queryRunner.query( `CREATE TABLE "CredentialBranding" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "vcHash" character varying(255) NOT NULL, "issuerCorrelationId" character varying(255) NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "last_updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_vcHash" UNIQUE ("vcHash"), CONSTRAINT "PK_CredentialBranding_id" PRIMARY KEY ("id"))`, ) @@ -76,6 +82,8 @@ export class CreateIssuanceBranding1685628974232 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "BaseLocaleBranding" DROP INDEX "IDX_BaseLocaleBranding_type"`) await queryRunner.query(`ALTER TABLE "BaseLocaleBranding" DROP INDEX "IDX_IssuerLocaleBrandingEntity_issuerBranding_locale"`) await queryRunner.query(`ALTER TABLE "BaseLocaleBranding" DROP INDEX "IDX_CredentialLocaleBrandingEntity_credentialBranding_locale"`) + await queryRunner.query(`ALTER TABLE "CredentialClaims" DROP INDEX "IDX_CredentialClaimsEntity_credentialLocaleBranding_locale"`) + await queryRunner.query(`DROP TABLE "CredentialClaims"`) await queryRunner.query(`DROP TABLE "BaseLocaleBranding"`) await queryRunner.query(`DROP TABLE "TextAttributes"`) await queryRunner.query(`DROP TABLE "BackgroundAttributes"`) diff --git a/packages/data-store/src/migrations/sqlite/1685628973231-CreateIssuanceBranding.ts b/packages/data-store/src/migrations/sqlite/1685628973231-CreateIssuanceBranding.ts index 8516a6052..82cf63d7c 100644 --- a/packages/data-store/src/migrations/sqlite/1685628973231-CreateIssuanceBranding.ts +++ b/packages/data-store/src/migrations/sqlite/1685628973231-CreateIssuanceBranding.ts @@ -22,6 +22,10 @@ export class CreateIssuanceBranding1685628973231 implements MigrationInterface { `CREATE UNIQUE INDEX "IDX_IssuerLocaleBrandingEntity_issuerBranding_locale" ON "BaseLocaleBranding" ("issuerBrandingId", "locale")`, ) await queryRunner.query(`CREATE INDEX "IDX_BaseLocaleBranding_type" ON "BaseLocaleBranding" ("type")`) + await queryRunner.query(`CREATE TABLE "CredentialClaims" ("id" varchar PRIMARY KEY NOT NULL, "key" varchar(255) NOT NULL, "name" varchar(255) NOT NULL, "credentialLocaleBrandingId" varchar)`) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_CredentialClaimsEntity_credentialLocaleBranding_locale" ON "CredentialClaims" ("credentialLocaleBrandingId", "key")` + ) await queryRunner.query( `CREATE TABLE "CredentialBranding" ("id" varchar PRIMARY KEY NOT NULL, "vcHash" varchar(255) NOT NULL, "issuerCorrelationId" varchar(255) NOT NULL, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "last_updated_at" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_vcHash" UNIQUE ("vcHash"))`, ) @@ -108,6 +112,8 @@ export class CreateIssuanceBranding1685628973231 implements MigrationInterface { await queryRunner.query(`DROP INDEX "IDX_CredentialBrandingEntity_issuerCorrelationId"`) await queryRunner.query(`DROP TABLE "CredentialBranding"`) await queryRunner.query(`DROP INDEX "IDX_BaseLocaleBranding_type"`) + await queryRunner.query(`DROP INDEX "IDX_CredentialClaimsEntity_credentialLocaleBranding_locale"`) + await queryRunner.query(`DROP TABLE "CredentialClaims"`) await queryRunner.query(`DROP INDEX "IDX_IssuerLocaleBrandingEntity_issuerBranding_locale"`) await queryRunner.query(`DROP INDEX "IDX_CredentialLocaleBrandingEntity_credentialBranding_locale"`) await queryRunner.query(`DROP TABLE "BaseLocaleBranding"`) diff --git a/packages/data-store/src/types/issuanceBranding/issuanceBranding.ts b/packages/data-store/src/types/issuanceBranding/issuanceBranding.ts index ee0449172..490265569 100644 --- a/packages/data-store/src/types/issuanceBranding/issuanceBranding.ts +++ b/packages/data-store/src/types/issuanceBranding/issuanceBranding.ts @@ -52,17 +52,29 @@ export interface IImageDimensions { export interface IBasicImageDimensions extends Omit {} export interface IPartialImageDimensions extends Partial {} -export interface ICredentialLocaleBranding extends ILocaleBranding {} +export interface ICredentialClaim { + id: string + key: string + name: string +} +export interface IBasicCredentialClaim extends Omit {} +export interface IPartialCredentialClaim extends Partial {} + +export interface ICredentialLocaleBranding extends ILocaleBranding { + claims?: Array +} export interface IBasicCredentialLocaleBranding - extends Omit { + extends Omit { logo?: IBasicImageAttributes background?: IBasicBackgroundAttributes text?: IBasicTextAttributes + claims?: Array } -export interface IPartialCredentialLocaleBranding extends Partial> { +export interface IPartialCredentialLocaleBranding extends Partial> { logo?: IPartialImageAttributes background?: IPartialBackgroundAttributes text?: IPartialTextAttributes + claims?: IPartialCredentialClaim } export interface ICredentialBranding { diff --git a/packages/data-store/src/utils/issuanceBranding/MappingUtils.ts b/packages/data-store/src/utils/issuanceBranding/MappingUtils.ts index ed3417aa5..23ffcfcce 100644 --- a/packages/data-store/src/utils/issuanceBranding/MappingUtils.ts +++ b/packages/data-store/src/utils/issuanceBranding/MappingUtils.ts @@ -9,9 +9,10 @@ import { TextAttributesEntity } from '../../entities/issuanceBranding/TextAttrib import { IssuerLocaleBrandingEntity } from '../../entities/issuanceBranding/IssuerLocaleBrandingEntity' import { CredentialLocaleBrandingEntity } from '../../entities/issuanceBranding/CredentialLocaleBrandingEntity' import { ImageDimensionsEntity } from '../../entities/issuanceBranding/ImageDimensionsEntity' +import { CredentialClaimsEntity } from '../../entities/issuanceBranding/CredentialClaimsEntity' import { IBasicBackgroundAttributes, - IBasicCredentialBranding, + IBasicCredentialBranding, IBasicCredentialClaim, IBasicCredentialLocaleBranding, IBasicImageAttributes, IBasicImageDimensions, @@ -94,6 +95,9 @@ export const credentialLocaleBrandingEntityFrom = (args: IBasicCredentialLocaleB credentialLocaleBrandingEntity.description = isEmptyString(args.description) ? undefined : args.description credentialLocaleBrandingEntity.background = args.background ? backgroundAttributesEntityFrom(args.background) : undefined credentialLocaleBrandingEntity.text = args.text ? textAttributesEntityFrom(args.text) : undefined + credentialLocaleBrandingEntity.claims = args.claims + ? args.claims.map((claim) => credentialClaimsEntityFrom(claim)) + : [] return credentialLocaleBrandingEntity } @@ -133,3 +137,12 @@ export const textAttributesEntityFrom = (args: IBasicTextAttributes): TextAttrib return textAttributesEntity } + +export const credentialClaimsEntityFrom = (args: IBasicCredentialClaim): CredentialClaimsEntity => { + const credentialClaimsEntity: CredentialClaimsEntity = new CredentialClaimsEntity() + credentialClaimsEntity.key = args.key + credentialClaimsEntity.name = args.name + + return credentialClaimsEntity +} + diff --git a/packages/issuance-branding/__tests__/shared/issuanceBrandingAgentLogic.ts b/packages/issuance-branding/__tests__/shared/issuanceBrandingAgentLogic.ts index 9b1276048..411546d59 100644 --- a/packages/issuance-branding/__tests__/shared/issuanceBrandingAgentLogic.ts +++ b/packages/issuance-branding/__tests__/shared/issuanceBrandingAgentLogic.ts @@ -62,6 +62,16 @@ export default (testContext: { getAgent: () => ConfiguredAgent; setup: () => Pro text: { color: '#000000', }, + claims: [ + { + key: 'given_name', + name: 'Given Name' + }, + { + key: 'family_name', + name: 'Surname' + } + ] }, ], } @@ -76,6 +86,8 @@ export default (testContext: { getAgent: () => ConfiguredAgent; setup: () => Pro expect(result?.localeBranding[0].background?.image?.dataUri).toBeUndefined() expect(result?.localeBranding[0].background?.image?.mediaType).toBeDefined() expect(result?.localeBranding[0].background?.image?.dimensions).toBeDefined() + expect(result?.localeBranding[0].claims).toBeDefined() + expect(result?.localeBranding[0].claims?.length).toEqual(credentialBranding.localeBranding[0].claims?.length) }) it('should add credential branding with no images', async (): Promise => { @@ -138,6 +150,16 @@ export default (testContext: { getAgent: () => ConfiguredAgent; setup: () => Pro text: { color: '#000000', }, + claims: [ + { + key: 'given_name', + name: 'Given Name' + }, + { + key: 'family_name', + name: 'Surname' + } + ] }, ], } @@ -159,6 +181,8 @@ export default (testContext: { getAgent: () => ConfiguredAgent; setup: () => Pro expect(result[0].localeBranding[0].background?.image?.mediaType).toBeDefined() expect(result[0].localeBranding[0].createdAt).toBeDefined() expect(result[0].localeBranding[0].lastUpdatedAt).toBeDefined() + expect(result[0].localeBranding[0].claims).toBeDefined() + expect(result[0].localeBranding[0].claims?.length).toEqual(credentialBranding.localeBranding[0].claims?.length) }) it('should get no credential branding with no matching filter', async (): Promise => { diff --git a/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts b/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts index e8f99f104..4c41f2507 100644 --- a/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts +++ b/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts @@ -3,7 +3,6 @@ import { CredentialConfigurationSupported, CredentialOfferFormatV1_0_11, CredentialResponse, - CredentialsSupportedDisplay, getSupportedCredentials, getTypesFromCredentialSupported, getTypesFromObject, @@ -55,17 +54,26 @@ import { VerificationResult, VerifyCredentialToAcceptArgs, } from '../types/IOID4VCIHolder' -import { credentialLocaleBrandingFrom, issuerLocaleBrandingFrom } from './OIDC4VCIBrandingMapper' +import { + getCredentialBrandingFrom, + issuerLocaleBrandingFrom +} from './OIDC4VCIBrandingMapper' export const getCredentialBranding = async (args: GetCredentialBrandingArgs): Promise>> => { const { credentialsSupported, context } = args const credentialBranding: Record> = {} await Promise.all( Object.entries(credentialsSupported).map(async ([configId, credentialsConfigSupported]) => { - const localeBranding: Array = await Promise.all( - (credentialsConfigSupported.display ?? []).map( - async (display: CredentialsSupportedDisplay): Promise => - await context.agent.ibCredentialLocaleBrandingFrom({ localeBranding: await credentialLocaleBrandingFrom(display) }), + const mappedLocaleBranding = await getCredentialBrandingFrom({ + credentialDisplay: credentialsConfigSupported.display, + // @ts-ignore // FIXME SPRIND-123 add proper support for type recognition as claim display can be located elsewhere for v13 + issuerCredentialSubject: credentialsSupported.claims !== undefined ? credentialsConfigSupported.claims : credentialsConfigSupported.credentialSubject + }) + + // TODO we should make the mapper part of the plugin, so that the logic for getting the branding becomes more clear and easier to use + const localeBranding = await Promise.all( + (mappedLocaleBranding ?? []).map(async (localeBranding): Promise => + await context.agent.ibCredentialLocaleBrandingFrom({ localeBranding }), ), ) @@ -84,8 +92,8 @@ export const getCredentialBranding = async (args: GetCredentialBrandingArgs): Pr export const getBasicIssuerLocaleBranding = async (args: GetBasicIssuerLocaleBrandingArgs): Promise> => { //IBasicIssuerLocaleBranding const { display, context } = args return await Promise.all( - display.map(async (displayItem: MetadataDisplay): Promise => { - const branding = await issuerLocaleBrandingFrom(displayItem) + display.map(async (metadataDisplay: MetadataDisplay): Promise => { + const branding = await issuerLocaleBrandingFrom({ issuerDisplay: metadataDisplay }) return context.agent.ibIssuerLocaleBrandingFrom({ localeBranding: branding }) }), ) diff --git a/packages/oid4vci-holder/src/agent/OIDC4VCIBrandingMapper.ts b/packages/oid4vci-holder/src/agent/OIDC4VCIBrandingMapper.ts index bb6167d3f..f2c7d505d 100644 --- a/packages/oid4vci-holder/src/agent/OIDC4VCIBrandingMapper.ts +++ b/packages/oid4vci-holder/src/agent/OIDC4VCIBrandingMapper.ts @@ -1,9 +1,22 @@ -import { CredentialsSupportedDisplay } from '@sphereon/oid4vci-common' -import { IBasicCredentialLocaleBranding, IBasicIssuerLocaleBranding } from '@sphereon/ssi-sdk.data-store' -import { MetadataDisplay } from '@sphereon/oid4vci-common' +import { CredentialsSupportedDisplay, NameAndLocale } from '@sphereon/oid4vci-common' +import { + IBasicCredentialClaim, + IBasicCredentialLocaleBranding, + IBasicIssuerLocaleBranding +} from '@sphereon/ssi-sdk.data-store' +import { + CredentialLocaleBrandingFromArgs, + IssuerLocaleBrandingFromArgs, + CredentialBrandingFromArgs, + CredentialDisplayLocalesFromArgs, + IssuerCredentialSubjectLocalesFromArgs, + CombineLocalesFromArgs +} from '../types/IOID4VCIHolder' // FIXME should we not move this to the branding plugin? -export const credentialLocaleBrandingFrom = async (credentialDisplay: CredentialsSupportedDisplay): Promise => { +export const credentialLocaleBrandingFrom = async (args: CredentialLocaleBrandingFromArgs): Promise => { + const { credentialDisplay } = args + return { ...(credentialDisplay.name && { alias: credentialDisplay.name, @@ -50,7 +63,9 @@ export const credentialLocaleBrandingFrom = async (credentialDisplay: Credential } } -export const issuerLocaleBrandingFrom = async (issuerDisplay: MetadataDisplay): Promise => { +export const issuerLocaleBrandingFrom = async (args: IssuerLocaleBrandingFromArgs): Promise => { + const { issuerDisplay } = args + return { ...(issuerDisplay.name && { alias: issuerDisplay.name, @@ -71,7 +86,6 @@ export const issuerLocaleBrandingFrom = async (issuerDisplay: MetadataDisplay): ...(issuerDisplay.description && { description: issuerDisplay.description, }), - ...(issuerDisplay.text_color && { text: { color: issuerDisplay.text_color, @@ -79,3 +93,76 @@ export const issuerLocaleBrandingFrom = async (issuerDisplay: MetadataDisplay): }), } } + +export const getCredentialBrandingFrom = async (args: CredentialBrandingFromArgs): Promise> => { + const { credentialDisplay, issuerCredentialSubject } = args + + return combineDisplayLocalesFrom({ + ...(issuerCredentialSubject && { issuerCredentialSubjectLocales: await issuerCredentialSubjectLocalesFrom({ issuerCredentialSubject }) }), + ...(credentialDisplay && { credentialDisplayLocales: await credentialDisplayLocalesFrom({ credentialDisplay }) }), + }) +} + +const credentialDisplayLocalesFrom = async (args: CredentialDisplayLocalesFromArgs): Promise> => { + const { credentialDisplay } = args + return credentialDisplay.reduce((localeDisplays, display) => { + const localeKey = display.locale || ''; + localeDisplays.set(localeKey, display); + return localeDisplays; + }, new Map()); +} + +const issuerCredentialSubjectLocalesFrom = async (args: IssuerCredentialSubjectLocalesFromArgs): Promise>> => { + const { issuerCredentialSubject } = args + const localeClaims = new Map>(); + + const processClaimObject = (claim: any, parentKey: string = ''): void => { + Object.entries(claim).forEach(([key, value]): void => { + if (key === 'mandatory' || key === 'value_type') { + return; + } + + if (key === 'display' && Array.isArray(value)) { + value.forEach(({ name, locale }: NameAndLocale): void => { + if (!name) { + return; + } + + const localeKey = locale || ''; + if (!localeClaims.has(localeKey)) { + localeClaims.set(localeKey, []); + } + localeClaims.get(localeKey)!.push({ key: parentKey, name }); + }); + } else if (typeof value === 'object' && value !== null) { + processClaimObject(value, parentKey ? `${parentKey}.${key}` : key); + } + }); + }; + + processClaimObject(issuerCredentialSubject); + return localeClaims; +}; + +const combineDisplayLocalesFrom = async (args: CombineLocalesFromArgs): Promise> => { + const { + credentialDisplayLocales = new Map(), + issuerCredentialSubjectLocales = new Map>() + } = args + + const locales: Array = Array.from(new Set([ + ...issuerCredentialSubjectLocales.keys(), + ...credentialDisplayLocales.keys() + ])); + + return Promise.all(locales.map(async (locale: string): Promise => { + const display = credentialDisplayLocales.get(locale) + const claims = issuerCredentialSubjectLocales.get(locale) + + return { + ...(display && await credentialLocaleBrandingFrom({ credentialDisplay: display })), + ...(locale.length > 0 && { locale }), + claims + } + })) +} diff --git a/packages/oid4vci-holder/src/types/IOID4VCIHolder.ts b/packages/oid4vci-holder/src/types/IOID4VCIHolder.ts index cb751a659..125cc9042 100644 --- a/packages/oid4vci-holder/src/types/IOID4VCIHolder.ts +++ b/packages/oid4vci-holder/src/types/IOID4VCIHolder.ts @@ -11,6 +11,8 @@ import { ExperimentalSubjectIssuance, MetadataDisplay, NotificationRequest, + CredentialsSupportedDisplay, + IssuerCredentialSubject, } from '@sphereon/oid4vci-common' import { CreateOrGetIdentifierOpts, @@ -28,6 +30,7 @@ import { IContactManager } from '@sphereon/ssi-sdk.contact-manager' import { ICredentialStore } from '@sphereon/ssi-sdk.credential-store' import { DigitalCredential, + IBasicCredentialClaim, IBasicCredentialLocaleBranding, IBasicIssuerLocaleBranding, Identity, @@ -665,4 +668,35 @@ export type VerifyEBSICredentialIssuerResult = { attributes: Attribute[] } +export type CredentialLocaleBrandingFromArgs = { + credentialDisplay: CredentialsSupportedDisplay +} + +export type IssuerCredentialSubjectLocaleBrandingFromArgs = { + issuerCredentialSubject: IssuerCredentialSubject + locale?: string +} + +export type IssuerLocaleBrandingFromArgs = { + issuerDisplay: MetadataDisplay +} + +export type CredentialBrandingFromArgs = { + credentialDisplay?: Array + issuerCredentialSubject?: IssuerCredentialSubject +} + +export type CredentialDisplayLocalesFromArgs = { + credentialDisplay: Array +} + +export type IssuerCredentialSubjectLocalesFromArgs = { + issuerCredentialSubject: IssuerCredentialSubject +} + +export type CombineLocalesFromArgs = { + credentialDisplayLocales?: Map + issuerCredentialSubjectLocales?: Map> +} + export type DidAgents = TAgent