Skip to content

Commit a3f6667

Browse files
authored
DCKA 5575 web wallet delegation support (#570)
* Add delegation support to web wallet * address PR comments * address PR comments
1 parent bf2b63b commit a3f6667

2 files changed

Lines changed: 303 additions & 2 deletions

File tree

packages/web/src/index.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@ import {createCredentialProvider} from '@docknetwork/wallet-sdk-core/src/credent
2828
import {createDIDProvider} from '@docknetwork/wallet-sdk-core/src/did-provider';
2929
import {createMessageProvider} from '@docknetwork/wallet-sdk-core/src/message-provider';
3030
import {createVerificationController} from '@docknetwork/wallet-sdk-core/src/verification-controller';
31+
import {
32+
createDelegationOffer,
33+
createOOBInvitation,
34+
acceptDelegationOffer,
35+
handleMessage,
36+
decodeMessage,
37+
} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-offer';
38+
import {
39+
issueCredential,
40+
delegateCredential,
41+
} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-issuance';
42+
import {getDelegationDetails} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-policy';
43+
import {getDelegationChain} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-chain';
44+
import {
45+
revokeDelegatableCredential,
46+
unrevokeDelegatableCredential,
47+
isDelegatableCredentialRevoked,
48+
} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-revocation';
3149
import {blockchainService} from '@docknetwork/wallet-sdk-wasm/src/services/blockchain';
3250
import {
3351
checkPasskeySupport,
@@ -637,6 +655,112 @@ async function initialize({
637655
},
638656
};
639657
},
658+
659+
/**
660+
* Routes incoming DIDComm delegation messages (offers, credential
661+
* requests, issuance, acks) to the delegation handlers automatically.
662+
*
663+
* Without this, delegation messages must be dispatched manually via
664+
* `handleMessage`. Call once after initialization to participate in
665+
* delegation flows as issuer and/or holder.
666+
*
667+
* Pass optional callbacks to react in the UI without reaching into the
668+
* core wallet's event manager:
669+
* - `onOfferReceived(offer)` — a delegation offer arrived (accept it with
670+
* `acceptDelegationOffer(offer)`)
671+
* - `onCredentialReceived(payload)` — a delegated credential was issued
672+
* and stored
673+
*
674+
* @param {Object} [handlers]
675+
* @param {Function} [handlers.onOfferReceived]
676+
* @param {Function} [handlers.onCredentialReceived]
677+
* @returns {Function} Unsubscribe function that stops routing and removes
678+
* the event listeners.
679+
*
680+
* @example
681+
* const stop = wallet.enableDelegation({
682+
* onOfferReceived: offer => wallet.acceptDelegationOffer(offer),
683+
* onCredentialReceived: ({credentials}) => refreshUI(credentials),
684+
* });
685+
*/
686+
enableDelegation: ({onOfferReceived, onCredentialReceived} = {}) => {
687+
const removeMessageListener = messageProvider.addMessageListener(
688+
message => handleMessage(message, {wallet, messageProvider}),
689+
);
690+
691+
const subscriptions = [];
692+
if (onOfferReceived) {
693+
wallet.eventManager.addListener(
694+
'delegationOfferReceived',
695+
onOfferReceived,
696+
);
697+
subscriptions.push(['delegationOfferReceived', onOfferReceived]);
698+
}
699+
if (onCredentialReceived) {
700+
wallet.eventManager.addListener(
701+
'delegatedCredentialReceived',
702+
onCredentialReceived,
703+
);
704+
subscriptions.push([
705+
'delegatedCredentialReceived',
706+
onCredentialReceived,
707+
]);
708+
}
709+
710+
return () => {
711+
removeMessageListener();
712+
subscriptions.forEach(([event, handler]) =>
713+
wallet.eventManager.removeListener(event, handler),
714+
);
715+
};
716+
},
717+
718+
/**
719+
* Creates a delegation offer for a role and persists it on the wallet.
720+
* Pass the returned offer to `createOOBInvitation` to produce a shareable
721+
* URL. Works for both root issuance (no `credentialId`) and re-delegation
722+
* (pass the delegatable credential's `credentialId`).
723+
*
724+
* @async
725+
* @param {Object} params - see core createDelegationOffer; `wallet` is bound automatically
726+
* @returns {Promise<Object>} The delegation offer
727+
*/
728+
createDelegationOffer: (params = {}) =>
729+
createDelegationOffer({wallet, ...params}),
730+
731+
/**
732+
* Accepts a received delegation offer, sending a credential request back
733+
* to the issuer.
734+
*
735+
* @async
736+
* @param {Object} delegationOffer - The offer from a `delegationOfferReceived` event
737+
* @returns {Promise<void>}
738+
*/
739+
acceptDelegationOffer: delegationOffer =>
740+
acceptDelegationOffer({delegationOffer, wallet, messageProvider}),
741+
742+
/**
743+
* Resolves the full delegation details for a stored delegatable credential:
744+
* policy, role, remaining delegation depth, chain, and the roles the holder
745+
* may re-delegate to.
746+
*
747+
* @async
748+
* @param {Object} credential - A stored delegatable credential
749+
* @returns {Promise<Object>} Delegation details
750+
*/
751+
getDelegationDetails: credential =>
752+
getDelegationDetails(credential, wallet),
753+
754+
/**
755+
* Manually dispatch a DIDComm delegation message (e.g. an OOB invitation
756+
* URL scanned from a QR code) to the delegation handlers. Prefer
757+
* `enableDelegation()` for automatic routing of relayed messages.
758+
*
759+
* @async
760+
* @param {string|Object} message - OOB URL string or DIDComm message object
761+
* @returns {Promise<void>}
762+
*/
763+
handleMessage: message => handleMessage(message, {wallet, messageProvider}),
640764
};
641765

642766
if (passkeyMnemonic) {
@@ -735,6 +859,18 @@ export {
735859
enrollUserWithPasskey,
736860
authenticateWithPasskey,
737861
initializeCloudWalletWithPasskey,
862+
createDelegationOffer,
863+
createOOBInvitation,
864+
acceptDelegationOffer,
865+
handleMessage,
866+
decodeMessage,
867+
issueCredential,
868+
delegateCredential,
869+
getDelegationDetails,
870+
getDelegationChain,
871+
revokeDelegatableCredential,
872+
unrevokeDelegatableCredential,
873+
isDelegatableCredentialRevoked,
738874
};
739875

740876
export default {
@@ -759,4 +895,16 @@ export default {
759895
enrollUserWithPasskey,
760896
authenticateWithPasskey,
761897
initializeCloudWalletWithPasskey,
898+
createDelegationOffer,
899+
createOOBInvitation,
900+
acceptDelegationOffer,
901+
handleMessage,
902+
decodeMessage,
903+
issueCredential,
904+
delegateCredential,
905+
getDelegationDetails,
906+
getDelegationChain,
907+
revokeDelegatableCredential,
908+
unrevokeDelegatableCredential,
909+
isDelegatableCredentialRevoked,
762910
};

packages/web/src/index.test.js

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ jest.mock('./passkey', () => ({
5454
jest.mock('@docknetwork/wallet-sdk-core/src/wallet', () => ({
5555
createWallet: jest.fn().mockImplementation(async () => {
5656
mockCallOrder.push('createWallet');
57-
return {mockWallet: true};
57+
return {
58+
mockWallet: true,
59+
eventManager: {addListener: jest.fn(), removeListener: jest.fn()},
60+
};
5861
}),
5962
}));
6063

@@ -75,7 +78,10 @@ jest.mock('@docknetwork/wallet-sdk-core/src/did-provider', () => ({
7578
jest.mock('@docknetwork/wallet-sdk-core/src/message-provider', () => ({
7679
createMessageProvider: jest.fn().mockImplementation(() => {
7780
mockCallOrder.push('createMessageProvider');
78-
return {mockMessageProvider: true};
81+
return {
82+
mockMessageProvider: true,
83+
addMessageListener: jest.fn().mockReturnValue(() => {}),
84+
};
7985
}),
8086
}));
8187

@@ -87,6 +93,39 @@ jest.mock('@docknetwork/wallet-sdk-wasm/src/services/blockchain', () => ({
8793
blockchainService: {ensureBlockchainReady: jest.fn()},
8894
}));
8995

96+
jest.mock(
97+
'@docknetwork/wallet-sdk-core/src/delegation/delegation-offer',
98+
() => ({
99+
createDelegationOffer: jest.fn().mockResolvedValue({id: 'offer-1'}),
100+
createOOBInvitation: jest.fn(),
101+
acceptDelegationOffer: jest.fn().mockResolvedValue(undefined),
102+
handleMessage: jest.fn().mockResolvedValue(undefined),
103+
decodeMessage: jest.fn(),
104+
}),
105+
);
106+
jest.mock(
107+
'@docknetwork/wallet-sdk-core/src/delegation/delegation-issuance',
108+
() => ({issueCredential: jest.fn(), delegateCredential: jest.fn()}),
109+
);
110+
jest.mock(
111+
'@docknetwork/wallet-sdk-core/src/delegation/delegation-policy',
112+
() => ({getDelegationDetails: jest.fn().mockResolvedValue({role: {}})}),
113+
);
114+
jest.mock(
115+
'@docknetwork/wallet-sdk-core/src/delegation/delegation-chain',
116+
() => ({
117+
getDelegationChain: jest.fn(),
118+
}),
119+
);
120+
jest.mock(
121+
'@docknetwork/wallet-sdk-core/src/delegation/delegation-revocation',
122+
() => ({
123+
revokeDelegatableCredential: jest.fn(),
124+
unrevokeDelegatableCredential: jest.fn(),
125+
isDelegatableCredentialRevoked: jest.fn(),
126+
}),
127+
);
128+
90129
const {
91130
initializeCloudWallet,
92131
} = require('@docknetwork/wallet-sdk-core/src/cloud-wallet');
@@ -390,4 +429,118 @@ describe('WalletSDK initialize', () => {
390429
);
391430
});
392431
});
432+
433+
describe('delegation', () => {
434+
const {
435+
createDelegationOffer,
436+
acceptDelegationOffer,
437+
handleMessage,
438+
} = require('@docknetwork/wallet-sdk-core/src/delegation/delegation-offer');
439+
const {
440+
getDelegationDetails,
441+
} = require('@docknetwork/wallet-sdk-core/src/delegation/delegation-policy');
442+
const config = {
443+
edvUrl: 'https://edv.example.com',
444+
edvAuthKey: 'auth-key',
445+
masterKey: new Uint8Array([1, 2, 3]),
446+
networkId: 'testnet',
447+
};
448+
449+
it('re-exports delegation functions', () => {
450+
[
451+
'createDelegationOffer',
452+
'createOOBInvitation',
453+
'acceptDelegationOffer',
454+
'handleMessage',
455+
'decodeMessage',
456+
'issueCredential',
457+
'delegateCredential',
458+
'getDelegationDetails',
459+
'getDelegationChain',
460+
'revokeDelegatableCredential',
461+
'unrevokeDelegatableCredential',
462+
'isDelegatableCredentialRevoked',
463+
].forEach(name => expect(typeof WalletSDK[name]).toBe('function'));
464+
});
465+
466+
it('enableDelegation registers a message listener that routes to handleMessage', async () => {
467+
const wallet = await WalletSDK.initialize(config);
468+
const stop = wallet.enableDelegation();
469+
470+
expect(wallet.messageProvider.addMessageListener).toHaveBeenCalledTimes(
471+
1,
472+
);
473+
// Invoke the registered listener and confirm it delegates to handleMessage.
474+
const listener =
475+
wallet.messageProvider.addMessageListener.mock.calls[0][0];
476+
await listener('some-oob-url');
477+
expect(handleMessage).toHaveBeenCalledWith('some-oob-url', {
478+
wallet: wallet.wallet,
479+
messageProvider: wallet.messageProvider,
480+
});
481+
expect(typeof stop).toBe('function');
482+
});
483+
484+
it('enableDelegation subscribes callbacks to wallet events and unsubscribes on stop', async () => {
485+
const wallet = await WalletSDK.initialize(config);
486+
const onOfferReceived = jest.fn();
487+
const onCredentialReceived = jest.fn();
488+
489+
const removeMessageListener = jest.fn();
490+
wallet.messageProvider.addMessageListener.mockReturnValueOnce(
491+
removeMessageListener,
492+
);
493+
494+
const stop = wallet.enableDelegation({
495+
onOfferReceived,
496+
onCredentialReceived,
497+
});
498+
499+
expect(wallet.wallet.eventManager.addListener).toHaveBeenCalledWith(
500+
'delegationOfferReceived',
501+
onOfferReceived,
502+
);
503+
expect(wallet.wallet.eventManager.addListener).toHaveBeenCalledWith(
504+
'delegatedCredentialReceived',
505+
onCredentialReceived,
506+
);
507+
508+
stop();
509+
510+
expect(removeMessageListener).toHaveBeenCalledTimes(1);
511+
expect(wallet.wallet.eventManager.removeListener).toHaveBeenCalledWith(
512+
'delegationOfferReceived',
513+
onOfferReceived,
514+
);
515+
expect(wallet.wallet.eventManager.removeListener).toHaveBeenCalledWith(
516+
'delegatedCredentialReceived',
517+
onCredentialReceived,
518+
);
519+
});
520+
521+
it('binds wallet/messageProvider to the delegation methods', async () => {
522+
const wallet = await WalletSDK.initialize(config);
523+
524+
await wallet.createDelegationOffer({
525+
issuerDID: 'did:x',
526+
delegationRole: 'r',
527+
});
528+
expect(createDelegationOffer).toHaveBeenCalledWith(
529+
expect.objectContaining({wallet: wallet.wallet, issuerDID: 'did:x'}),
530+
);
531+
532+
await wallet.acceptDelegationOffer({id: 'offer-1'});
533+
expect(acceptDelegationOffer).toHaveBeenCalledWith({
534+
delegationOffer: {id: 'offer-1'},
535+
wallet: wallet.wallet,
536+
messageProvider: wallet.messageProvider,
537+
});
538+
539+
await wallet.getDelegationDetails({id: 'cred-1'});
540+
expect(getDelegationDetails).toHaveBeenCalledWith(
541+
{id: 'cred-1'},
542+
wallet.wallet,
543+
);
544+
});
545+
});
393546
});

0 commit comments

Comments
 (0)