Skip to content

Commit c8bf361

Browse files
authored
Transfer credential offers over requests (#885)
* fix: remove hacky key solution * feat: add ShareCredentialOfferRequestItem * feat: add empty ShareCredentialOfferRequestItemProcessor * feat: add event * refactor: move code to OpenId4VciCredentialResponseJSON * feat: implement processor * feat: add ShareCredentialOfferRequestItemDVO * test: add test * fix: build schemas * chore: remove comments * fix: add request item to check * refactor: naming * fix: use nameof * refactor: types * chore: use property directly * fix: add error cause to ValidationResult error message * chore: naming * fix: event namespace + wording * fix: use typed property * fix: stuff * chore: one-liner
1 parent 825b9ce commit c8bf361

26 files changed

Lines changed: 511 additions & 136 deletions

packages/consumption/src/consumption/ConsumptionController.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ProposeAttributeRequestItem,
1212
ReadAttributeRequestItem,
1313
ShareAttributeRequestItem,
14+
ShareCredentialOfferRequestItem,
1415
TransferFileOwnershipRequestItem
1516
} from "@nmshd/content";
1617
import { CoreAddress, CoreId } from "@nmshd/core-types";
@@ -41,6 +42,7 @@ import {
4142
RequestItemProcessorRegistry,
4243
SettingsController,
4344
ShareAttributeRequestItemProcessor,
45+
ShareCredentialOfferRequestItemProcessor,
4446
TransferFileOwnershipRequestItemProcessor
4547
} from "../modules";
4648
import { ConsumptionConfig } from "./ConsumptionConfig";
@@ -160,7 +162,8 @@ export class ConsumptionController {
160162
[ConsentRequestItem, GenericRequestItemProcessor],
161163
[AuthenticationRequestItem, GenericRequestItemProcessor],
162164
[FormFieldRequestItem, FormFieldRequestItemProcessor],
163-
[TransferFileOwnershipRequestItem, TransferFileOwnershipRequestItemProcessor]
165+
[TransferFileOwnershipRequestItem, TransferFileOwnershipRequestItemProcessor],
166+
[ShareCredentialOfferRequestItem, ShareCredentialOfferRequestItemProcessor]
164167
]);
165168
}
166169

packages/consumption/src/modules/openid4vc/OpenId4VcController.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,52 @@
1-
import { ClaimFormat, W3cJsonCredential } from "@credo-ts/core";
2-
import { OpenId4VciCredentialResponse, OpenId4VciResolvedCredentialOffer, OpenId4VpResolvedAuthorizationRequest } from "@credo-ts/openid4vc";
1+
import { OpenId4VciResolvedCredentialOffer, OpenId4VpResolvedAuthorizationRequest } from "@credo-ts/openid4vc";
32
import { VerifiableCredential } from "@nmshd/content";
43
import { ConsumptionBaseController } from "../../consumption/ConsumptionBaseController";
54
import { ConsumptionController } from "../../consumption/ConsumptionController";
65
import { ConsumptionControllerName } from "../../consumption/ConsumptionControllerName";
76
import { OwnIdentityAttribute } from "../attributes";
87
import { Holder } from "./local/Holder";
98
import { KeyStorage } from "./local/KeyStorage";
9+
import { OpenId4VciCredentialResponseJSON } from "./local/OpenId4VciCredentialResponseJSON";
10+
import { RequestedCredentialCache } from "./local/RequestedCredentialCache";
1011

1112
export class OpenId4VcController extends ConsumptionBaseController {
1213
private holder: Holder;
14+
private requestedCredentialCache: RequestedCredentialCache;
1315

1416
public constructor(parent: ConsumptionController) {
1517
super(ConsumptionControllerName.OpenId4VcController, parent);
1618
}
1719

1820
public override async init(): Promise<this> {
19-
const collection = await this.parent.accountController.getSynchronizedCollection("openid4vc-keys");
20-
const keyStorage = new KeyStorage(collection, this._log);
21+
const keyCollection = await this.parent.accountController.getSynchronizedCollection("openid4vc-keys");
22+
const keyStorage = new KeyStorage(keyCollection, this._log);
2123

2224
this.holder = new Holder(keyStorage, this.parent.accountController, this.parent.attributes, this.fetchInstance);
2325
await this.holder.initializeAgent("96213c3d7fc8d4d6754c7a0fd969598e");
2426

27+
const requestedCredentialsCacheCollection = await this.parent.accountController.getSynchronizedCollection("openid4vc-requested-credentials-cache");
28+
this.requestedCredentialCache = new RequestedCredentialCache(requestedCredentialsCacheCollection);
29+
2530
return this;
2631
}
2732

2833
private get fetchInstance(): typeof fetch {
2934
return this.parent.consumptionConfig.fetchInstance ?? fetch;
3035
}
3136

37+
public async requestAllCredentialsFromCredentialOfferUrl(credentialOfferUrl: string): Promise<OpenId4VciCredentialResponseJSON[]> {
38+
const cachedCredentialResponses = await this.requestedCredentialCache.get(credentialOfferUrl);
39+
if (cachedCredentialResponses) return cachedCredentialResponses;
40+
41+
const offer = await this.resolveCredentialOffer(credentialOfferUrl);
42+
const credentialResponses = await this.requestCredentials(offer, offer.credentialOfferPayload.credential_configuration_ids);
43+
44+
await this.requestedCredentialCache.set(credentialOfferUrl, credentialResponses);
45+
await this.parent.accountController.syncDatawallet();
46+
47+
return credentialResponses;
48+
}
49+
3250
public async resolveCredentialOffer(credentialOfferUrl: string): Promise<OpenId4VciResolvedCredentialOffer> {
3351
return await this.holder.resolveCredentialOffer(credentialOfferUrl);
3452
}
@@ -37,14 +55,19 @@ export class OpenId4VcController extends ConsumptionBaseController {
3755
credentialOffer: OpenId4VciResolvedCredentialOffer,
3856
credentialConfigurationIds: string[],
3957
pinCode?: string
40-
): Promise<OpenId4VciCredentialResponse[]> {
41-
const credentialsResponses = await this.holder.requestCredentials(credentialOffer, { credentialConfigurationIds: credentialConfigurationIds, txCode: pinCode });
42-
return credentialsResponses;
58+
): Promise<OpenId4VciCredentialResponseJSON[]> {
59+
const credentialResponses = await this.holder.requestCredentials(credentialOffer, { credentialConfigurationIds: credentialConfigurationIds, txCode: pinCode });
60+
61+
const mappedResponses = credentialResponses.map((response) => ({
62+
claimFormat: response.record.firstCredential.claimFormat,
63+
encoded: response.record.firstCredential.encoded,
64+
displayInformation: response.credentialConfiguration.credential_metadata?.display ?? (response.credentialConfiguration.display as Record<string, unknown>[] | undefined)
65+
}));
66+
67+
return mappedResponses;
4368
}
4469

45-
public async storeCredentials(
46-
credentialResponses: (Omit<OpenId4VciCredentialResponse, "record"> & { record: { claimFormat: ClaimFormat; encoded: string | W3cJsonCredential } })[]
47-
): Promise<OwnIdentityAttribute> {
70+
public async storeCredentials(credentialResponses: OpenId4VciCredentialResponseJSON[]): Promise<OwnIdentityAttribute> {
4871
const credentials = await this.holder.storeCredentials(credentialResponses);
4972

5073
// TODO: support multiple credentials

packages/consumption/src/modules/openid4vc/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export * from "./local/EnmeshedHolderFileSystem";
33
export * from "./local/EnmeshedHolderKeyManagmentService";
44
export * from "./local/EnmeshedStorageService";
55
export * from "./local/Holder";
6+
export * from "./local/OpenId4VciCredentialResponseJSON";
67
export * from "./OpenId4VcController";

packages/consumption/src/modules/openid4vc/local/EnmeshedStorageService.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,22 +87,10 @@ export class EnmeshedStorageService<T extends BaseRecord> implements StorageServ
8787
"content.value.type": correspondingCredentialType
8888
});
8989

90-
return await Promise.all(
91-
attributes.map(async (attribute) => {
92-
const attributeValue = attribute.content.value as VerifiableCredential;
93-
if (attributeValue.key !== undefined) {
94-
// TODO: Remove as this is only a workaround for demo purposes
95-
_agentContext.config.logger.info("Found keys to possibly import");
96-
97-
const parsed = JSON.parse(attributeValue.key) as Map<string, any>;
98-
for (const [k, v] of Object.entries(parsed)) {
99-
await this.keyStorage.storeKey(k, v);
100-
}
101-
}
102-
103-
return this.fromEncoded(correspondingCredentialType, (attribute.content.value as VerifiableCredential).value) as T;
104-
})
105-
);
90+
return attributes.map((attribute) => {
91+
const attributeValue = attribute.content.value as VerifiableCredential;
92+
return this.fromEncoded(correspondingCredentialType, attributeValue.value) as T;
93+
});
10694
}
10795

10896
private recordTypeToCredentialType(recordType: string): string {

packages/consumption/src/modules/openid4vc/local/Holder.ts

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,11 @@
1-
import {
2-
BaseRecord,
3-
ClaimFormat,
4-
DidJwk,
5-
DidKey,
6-
InjectionSymbols,
7-
JwkDidCreateOptions,
8-
KeyDidCreateOptions,
9-
Kms,
10-
MdocRecord,
11-
SdJwtVcRecord,
12-
W3cJsonCredential,
13-
X509Module
14-
} from "@credo-ts/core";
1+
import { BaseRecord, ClaimFormat, DidJwk, DidKey, InjectionSymbols, JwkDidCreateOptions, KeyDidCreateOptions, Kms, MdocRecord, SdJwtVcRecord, X509Module } from "@credo-ts/core";
152
import { OpenId4VciCredentialResponse, OpenId4VcModule, type OpenId4VciResolvedCredentialOffer, type OpenId4VpResolvedAuthorizationRequest } from "@credo-ts/openid4vc";
163
import { AccountController } from "@nmshd/transport";
174
import { AttributesController, OwnIdentityAttribute } from "../../attributes";
185
import { BaseAgent } from "./BaseAgent";
196
import { EnmeshedStorageService } from "./EnmeshedStorageService";
207
import { KeyStorage } from "./KeyStorage";
8+
import { OpenId4VciCredentialResponseJSON } from "./OpenId4VciCredentialResponseJSON";
219

2210
function getOpenIdHolderModules() {
2311
return {
@@ -110,22 +98,21 @@ export class Holder extends BaseAgent<ReturnType<typeof getOpenIdHolderModules>>
11098
return credentialResponse.credentials;
11199
}
112100

113-
public async storeCredentials(
114-
credentialResponses: (Omit<OpenId4VciCredentialResponse, "record"> & { record: { claimFormat: ClaimFormat; encoded: string | W3cJsonCredential } })[]
115-
): Promise<OwnIdentityAttribute[]> {
101+
public async storeCredentials(credentialResponses: OpenId4VciCredentialResponseJSON[]): Promise<OwnIdentityAttribute[]> {
116102
const storedCredentials = await Promise.all(
117103
credentialResponses.map((credentialResponse) => {
118-
const credential = credentialResponse.record;
119-
120-
if (![ClaimFormat.SdJwtW3cVc, ClaimFormat.SdJwtDc, ClaimFormat.MsoMdoc].includes(credential.claimFormat)) {
104+
if (![ClaimFormat.SdJwtW3cVc, ClaimFormat.SdJwtDc, ClaimFormat.MsoMdoc].includes(credentialResponse.claimFormat)) {
121105
throw new Error("Unsupported credential format");
122106
}
123107

124108
const enmeshedStorageService = this.agent.dependencyManager.resolve<EnmeshedStorageService<BaseRecord>>(InjectionSymbols.StorageService);
125109

126-
const displayInfo = credentialResponse.credentialConfiguration.display as Record<string, any>[] | undefined;
127-
128-
return enmeshedStorageService.saveWithDisplay(this.agent.context, credential.encoded, credential.claimFormat, displayInfo);
110+
return enmeshedStorageService.saveWithDisplay(
111+
this.agent.context,
112+
credentialResponse.encoded,
113+
credentialResponse.claimFormat,
114+
credentialResponse.displayInformation
115+
);
129116
})
130117
);
131118

packages/consumption/src/modules/openid4vc/local/KeyStorage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { ILogger } from "@js-soft/logging-abstractions";
22
import { serialize, validate } from "@js-soft/ts-serval";
33
import { CoreId } from "@nmshd/core-types";
44
import { CoreSynchronizable, ICoreSynchronizable, SynchronizedCollection } from "@nmshd/transport";
5+
import { nameof } from "ts-simple-nameof";
56

67
interface IKeyStorageEntry extends ICoreSynchronizable {
78
key: any;
89
}
910

1011
class KeyStorageEntry extends CoreSynchronizable {
11-
public override technicalProperties: string[] = ["key"];
12+
public override technicalProperties: string[] = [nameof<KeyStorageEntry>((r) => r.key)];
1213

1314
@serialize({ any: true })
1415
@validate()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { ClaimFormat, W3cJsonCredential } from "@credo-ts/core";
2+
3+
export interface OpenId4VciCredentialResponseJSON {
4+
claimFormat: ClaimFormat;
5+
encoded: string | W3cJsonCredential;
6+
displayInformation?: Record<string, any>[];
7+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { serialize, validate } from "@js-soft/ts-serval";
2+
import { CoreId } from "@nmshd/core-types";
3+
import { CoreSynchronizable, SynchronizedCollection } from "@nmshd/transport";
4+
import { nameof } from "ts-simple-nameof";
5+
import { OpenId4VciCredentialResponseJSON } from "./OpenId4VciCredentialResponseJSON";
6+
7+
class RequestedCredentialCacheEntry extends CoreSynchronizable {
8+
public override technicalProperties: string[] = [nameof<RequestedCredentialCacheEntry>((r) => r.credentialResponses)];
9+
10+
@serialize({ any: true })
11+
@validate()
12+
public credentialResponses: OpenId4VciCredentialResponseJSON[];
13+
14+
public static create(credentialOfferUrl: string, credentialResponses: OpenId4VciCredentialResponseJSON[]): RequestedCredentialCacheEntry {
15+
return this.fromAny<RequestedCredentialCacheEntry>({ id: CoreId.from(credentialOfferUrl), credentialResponses });
16+
}
17+
}
18+
19+
export class RequestedCredentialCache {
20+
public constructor(private readonly collection: SynchronizedCollection) {}
21+
22+
public async get(credentialOfferUrl: string): Promise<OpenId4VciCredentialResponseJSON[] | undefined> {
23+
const doc = await this.collection.read(credentialOfferUrl);
24+
return doc ? RequestedCredentialCacheEntry.fromAny(doc).credentialResponses : undefined;
25+
}
26+
27+
public async set(credentialOfferUrl: string, credentialResponses: OpenId4VciCredentialResponseJSON[]): Promise<void> {
28+
await this.collection.create(RequestedCredentialCacheEntry.create(credentialOfferUrl, credentialResponses));
29+
}
30+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { CoreAddress } from "@nmshd/core-types";
2+
import { TransportDataEvent } from "@nmshd/transport";
3+
4+
export interface ShareCredentialOfferRequestItemProcessedByRecipientEventData {
5+
credentialOfferUrl: string;
6+
accepted: boolean;
7+
peer: CoreAddress;
8+
}
9+
10+
export class ShareCredentialOfferRequestItemProcessedByRecipientEvent extends TransportDataEvent<ShareCredentialOfferRequestItemProcessedByRecipientEventData> {
11+
public static readonly namespace = "consumption.shareCredentialOfferRequestItemProcessedByRecipient";
12+
13+
public constructor(eventTargetAddress: string, data: ShareCredentialOfferRequestItemProcessedByRecipientEventData) {
14+
super(ShareCredentialOfferRequestItemProcessedByRecipientEvent.namespace, eventTargetAddress, data);
15+
}
16+
}

packages/consumption/src/modules/requests/events/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./IncomingRequestStatusChangedEvent";
33
export * from "./OutgoingRequestCreatedAndCompletedEvent";
44
export * from "./OutgoingRequestCreatedEvent";
55
export * from "./OutgoingRequestStatusChangedEvent";
6+
export * from "./ShareCredentialOfferRequestItemProcessedByRecipientEvent";

0 commit comments

Comments
 (0)