diff --git a/.changeset/chilled-paws-stare.md b/.changeset/chilled-paws-stare.md new file mode 100644 index 000000000..413d35615 --- /dev/null +++ b/.changeset/chilled-paws-stare.md @@ -0,0 +1,29 @@ +--- +"@web5/agent": patch +--- + +security enhancements: use separate DIDs for signing, encryption, verification of web5 connect +feature enhancements: prepare code for the option of exporting the DWA's DID to a wallet + +breaking changes for wallet authors. + +web5 connect's `getAuthRequest()` now returns an object which include both the authRequest and a DID: + +```ts +{ + authRequest: Web5ConnectAuthRequest; + clientEcdhDid: DidResolutionResult; +} +``` + +web5 connect's `submitAuthResponse()` now requires that the did received from `getAuthRequest()` is passed in to the method at position 4: + +```ts +async function submitAuthResponse( + selectedDid: string, + authRequest: Web5ConnectAuthRequest, + randomPin: string, + clientEcdhDid: DidResolutionResult, + agent: Web5Agent +) { ... } +``` diff --git a/.changeset/weak-shirts-draw.md b/.changeset/weak-shirts-draw.md new file mode 100644 index 000000000..1853d1bae --- /dev/null +++ b/.changeset/weak-shirts-draw.md @@ -0,0 +1,5 @@ +--- +"@web5/api": patch +--- + +added enhancements and optionality in preparation for export connect diff --git a/packages/agent/src/connect.ts b/packages/agent/src/connect.ts index fb19b9983..8798ed4b3 100644 --- a/packages/agent/src/connect.ts +++ b/packages/agent/src/connect.ts @@ -1,10 +1,11 @@ - import type { PushedAuthResponse } from './oidc.js'; -import type { DwnPermissionScope, DwnProtocolDefinition, Web5Agent, Web5ConnectAuthResponse } from './index.js'; +import type { + DwnPermissionScope, + DwnProtocolDefinition, + Web5ConnectAuthResponse, +} from './index.js'; -import { - Oidc, -} from './oidc.js'; +import { Oidc } from './oidc.js'; import { pollWithTtl } from './utils.js'; import { Convert, logger } from '@web5/common'; @@ -13,8 +14,95 @@ import { DidJwk } from '@web5/dids'; import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js'; /** - * Initiates the wallet connect process. Used when a client wants to obtain - * a did from a provider. + * Settings provided by users who wish to allow their DWA to connect to a wallet + * and either transfer their DID to that wallet (when `exported: true`) + * or transfer a DID from their wallet (without `exported: true`). + */ +export type WalletConnectOptions = { + /** The user friendly name of the app to be displayed when prompting end-user with permission requests. */ + displayName: string; + + /** The URL of the intermediary server which relays messages between the client and provider */ + connectServerUrl: string; + + /** + * The URI of the Provider (wallet).The `onWalletUriReady` will take this wallet + * uri and add a payload to it which will be used to obtain and decrypt from the `request_uri`. + * @example `web5://` or `http://localhost:3000/`. + */ + walletUri: string; + + /** + * The protocols of permissions requested, along with the definition and + * permission scopes for each protocol. + * If `exported` is true these will be created automatically. + */ + permissionRequests: ConnectUserPermissionRequest[]; + + /** + * Can be set to true if the DWA wants to transfer its identity to the wallet + * instead of get an identity from the wallet + */ + exported?: boolean; + + /** + * The Web5 API provides a URI to the wallet based on the `walletUri` plus a query params payload valid for 5 minutes. + * The link can either be used as a deep link on the same device or a QR code for cross device or both. + * The query params are `{ request_uri: string; encryption_key: string; }` + * The wallet will use the `request_uri to contact the intermediary server's `authorize` endpoint + * and pull down the {@link Web5ConnectAuthRequest} and use the `encryption_key` to decrypt it. + * + * @param uri - The URI returned by the web5 connect API to be passed to a provider. + */ + onWalletUriReady: (uri: string) => void; + + /** + * Function that must be provided to submit the pin entered by the user on the client. + * The pin is used to decrypt the {@link Web5ConnectAuthResponse} that was retrieved from the + * token endpoint by the client inside of web5 connect. + * + * @returns A promise that resolves to the PIN as a string. + */ + validatePin: () => Promise; +}; + +/** Used by the WalletConnect protocol to provision a Wallet for the exact permissions its needs */ +export type ConnectPermissionRequest = { + /** + * The definition of the protocol the permissions are being requested for. + * In the event that the protocol is not already installed, the wallet will install this given protocol definition. + */ + protocolDefinition: DwnProtocolDefinition; + + /** The scope of the permissions being requested for the given protocol */ + permissionScopes: DwnPermissionScope[]; +}; + +/** Convenience object passed in by users and normalized to the internally used {@link ConnectPermissionRequest} */ +export type ConnectUserPermissionRequest = Omit< + ConnectPermissionRequest, + 'permissionScopes' +> & { + /** + * Used to create a {@link DwnPermissionScope} for each option provided in this param. + * If undefined defaults to requesting all permissions. + * `configure` is not included by default, as this gives the application a lot of control over the protocol. + */ + permissions?: Permission[]; +}; + +/** Shorthand for the types of permissions that can be requested. */ +type Permission = + | 'write' + | 'read' + | 'delete' + | 'query' + | 'subscribe' + | 'configure'; + +/** + * Called by the DWA. In this workflow the wallet provisions a DID to the DWA. + * The DWA will have access to the data of the DID and be able to act as that DID. */ async function initClient({ displayName, @@ -24,9 +112,19 @@ async function initClient({ onWalletUriReady, validatePin, }: WalletConnectOptions) { - // ephemeral client did for ECDH, signing, verification - // TODO: use separate keys for ECDH vs. sign/verify. could maybe use secp256k1. - const clientDid = await DidJwk.create(); + const normalizedPermissionRequests = permissionRequests.map( + ({ protocolDefinition, permissions }) => + WalletConnect.createPermissionRequestForProtocol({ + definition: protocolDefinition, + permissions, + }) + ); + + // ephemeral did used for signing, verification + const clientSigningDid = await DidJwk.create(); + + // ephemeral did used for ECDH only + const clientEcdhDid = await DidJwk.create(); // TODO: properly implement PKCE. this implementation is lacking server side validations and more. // https://github.com/TBD54566975/web5-js/issues/829 @@ -43,28 +141,30 @@ async function initClient({ // build the PAR request const request = await Oidc.createAuthRequest({ - client_id : clientDid.uri, + client_id : clientSigningDid.uri, scope : 'openid did:jwk', redirect_uri : callbackEndpoint, - // custom properties: + client_name : displayName, // code_challenge : codeChallengeBase64Url, // code_challenge_method : 'S256', - permissionRequests : permissionRequests, - displayName, + // custom properties: + permissionRequests : normalizedPermissionRequests, }); // Sign the Request Object using the Client DID's signing key. const requestJwt = await Oidc.signJwt({ - did : clientDid, + did : clientSigningDid, data : request, }); if (!requestJwt) { throw new Error('Unable to sign requestObject'); } - // Encrypt the Request Object JWT using the code challenge. + + // Encrypt with symmetric randomBytes and tell counterparty about the future ecdh pub did kid const requestObjectJwe = await Oidc.encryptAuthRequest({ - jwt: requestJwt, + jwt : requestJwt, + kid : clientEcdhDid.document.verificationMethod![0].id, encryptionKey, }); @@ -119,11 +219,13 @@ async function initClient({ // get the pin from the user and use it as AAD to decrypt const pin = await validatePin(); - const jwt = await Oidc.decryptAuthResponse(clientDid, jwe, pin); + const jwt = await Oidc.decryptWithPin(clientEcdhDid, jwe, pin); const verifiedAuthResponse = (await Oidc.verifyJwt({ jwt, })) as Web5ConnectAuthResponse; + // TODO: export insertion point + return { delegateGrants : verifiedAuthResponse.delegateGrants, delegatePortableDid : verifiedAuthResponse.delegatePortableDid, @@ -133,88 +235,22 @@ async function initClient({ } /** - * Initiates the wallet connect process. Used when a client wants to obtain - * a did from a provider. + * An internal utility that simplifies the API for permission requests by allowing + * users to pass simple strings (any of {@link Permission}) and will create the + * appropriate {@link DwnPermissionScope} for each string provided. */ -export type WalletConnectOptions = { - /** The user friendly name of the client/app to be displayed when prompting end-user with permission requests. */ - displayName: string; - - /** The URL of the intermediary server which relays messages between the client and provider. */ - connectServerUrl: string; - - /** - * The URI of the Provider (wallet).The `onWalletUriReady` will take this wallet - * uri and add a payload to it which will be used to obtain and decrypt from the `request_uri`. - * @example `web5://` or `http://localhost:3000/`. - */ - walletUri: string; - - /** - * The protocols of permissions requested, along with the definition and - * permission scopes for each protocol. The key is the protocol URL and - * the value is an object with the protocol definition and the permission scopes. - */ - permissionRequests: ConnectPermissionRequest[]; - - /** - * The Web5 API provides a URI to the wallet based on the `walletUri` plus a query params payload valid for 5 minutes. - * The link can either be used as a deep link on the same device or a QR code for cross device or both. - * The query params are `{ request_uri: string; encryption_key: string; }` - * The wallet will use the `request_uri to contact the intermediary server's `authorize` endpoint - * and pull down the {@link Web5ConnectAuthRequest} and use the `encryption_key` to decrypt it. - * - * @param uri - The URI returned by the web5 connect API to be passed to a provider. - */ - onWalletUriReady: (uri: string) => void; - - /** - * Function that must be provided to submit the pin entered by the user on the client. - * The pin is used to decrypt the {@link Web5ConnectAuthResponse} that was retrieved from the - * token endpoint by the client inside of web5 connect. - * - * @returns A promise that resolves to the PIN as a string. - */ - validatePin: () => Promise; -}; - -/** - * The protocols of permissions requested, along with the definition and permission scopes for each protocol. - */ -export type ConnectPermissionRequest = { - /** - * The definition of the protocol the permissions are being requested for. - * In the event that the protocol is not already installed, the wallet will install this given protocol definition. - */ - protocolDefinition: DwnProtocolDefinition; - - /** The scope of the permissions being requested for the given protocol */ - permissionScopes: DwnPermissionScope[]; -}; - -/** - * Shorthand for the types of permissions that can be requested. - */ -export type Permission = 'write' | 'read' | 'delete' | 'query' | 'subscribe' | 'configure'; - -/** - * The options for creating a permission request for a given protocol. - */ -export type ProtocolPermissionOptions = { +function createPermissionRequestForProtocol({ + definition, + permissions, +}: { /** The protocol definition for the protocol being requested */ definition: DwnProtocolDefinition; - /** The permissions being requested for the protocol */ - permissions: Permission[]; -}; + /** The permissions being requested for the protocol. Defaults to all. */ + permissions?: Permission[]; +}) { + permissions ??= ['read', 'write', 'delete', 'query', 'subscribe']; -/** - * Creates a set of Dwn Permission Scopes to request for a given protocol. - * - * If no permissions are provided, the default is to request all relevant record permissions (write, read, delete, query, subscribe). - * 'configure' is not included by default, as this gives the application a lot of control over the protocol. - */ -function createPermissionRequestForProtocol({ definition, permissions }: ProtocolPermissionOptions): ConnectPermissionRequest { const requests: DwnPermissionScope[] = []; // Add the ability to query for the specific protocol @@ -225,19 +261,23 @@ function createPermissionRequestForProtocol({ definition, permissions }: Protoco }); // In order to enable sync, we must request permissions for `MessagesQuery`, `MessagesRead` and `MessagesSubscribe` - requests.push({ - protocol : definition.protocol, - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Read, - }, { - protocol : definition.protocol, - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Query, - }, { - protocol : definition.protocol, - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Subscribe, - }); + requests.push( + { + protocol : definition.protocol, + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Read, + }, + { + protocol : definition.protocol, + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Query, + }, + { + protocol : definition.protocol, + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Subscribe, + } + ); // We also request any additional permissions the user has requested for this protocol for (const permission of permissions) { @@ -293,4 +333,7 @@ function createPermissionRequestForProtocol({ definition, permissions }: Protoco }; } -export const WalletConnect = { initClient, createPermissionRequestForProtocol }; +export const WalletConnect = { + initClient, + createPermissionRequestForProtocol, +}; diff --git a/packages/agent/src/oidc.ts b/packages/agent/src/oidc.ts index 076a0b72a..36c392d94 100644 --- a/packages/agent/src/oidc.ts +++ b/packages/agent/src/oidc.ts @@ -7,12 +7,24 @@ import { Sha256, X25519, CryptoUtils, + JweHeaderParams, } from '@web5/crypto'; import { concatenateUrl } from './utils.js'; import { xchacha20poly1305 } from '@noble/ciphers/chacha'; import type { ConnectPermissionRequest } from './connect.js'; -import { DidDocument, DidJwk, PortableDid, type BearerDid } from '@web5/dids'; -import { DwnDataEncodedRecordsWriteMessage, DwnInterface, DwnPermissionScope, DwnProtocolDefinition } from './types/dwn.js'; +import { + DidDocument, + DidJwk, + DidResolutionResult, + PortableDid, + type BearerDid, +} from '@web5/dids'; +import { + DwnDataEncodedRecordsWriteMessage, + DwnInterface, + DwnPermissionScope, + DwnProtocolDefinition, +} from './types/dwn.js'; import { AgentPermissionsApi } from './permissions-api.js'; import type { Web5Agent } from './types/agent.js'; import { isRecordPermissionScope } from './dwn-api.js'; @@ -52,6 +64,9 @@ export type SIOPv2AuthRequest = { /** The DID of the client (RP) */ client_id: string; + /** The user friendly name of the client (RP) */ + client_name?: string; + /** The scope of the access request (e.g., `openid profile`). */ scope: string; @@ -128,11 +143,10 @@ export type SIOPv2AuthRequest = { * The contents of this are inserted into a JWT inside of the {@link PushedAuthRequest}. */ export type Web5ConnectAuthRequest = { - /** The user friendly name of the client/app to be displayed when prompting end-user with permission requests. */ - displayName: string; - /** PermissionGrants that are to be sent to the provider */ - permissionRequests: ConnectPermissionRequest[]; + permissionRequests?: ConnectPermissionRequest[]; + /** Instead of receiving a DID from the wallet the DWA will export its DID to the wallet */ + exported?: boolean; } & SIOPv2AuthRequest; /** The fields for an OIDC SIOPv2 Auth Repsonse */ @@ -171,12 +185,20 @@ export type Web5ConnectAuthResponse = { * 2. `authorize`: provider gets the {@link Web5ConnectAuthRequest} JWT that was stored by the PAR * 3. `callback`: provider sends {@link Web5ConnectAuthResponse} to this endpoint * 4. `token`: client gets {@link Web5ConnectAuthResponse} from this endpoint + * 5. `export`: (if `exported` is true) client will POST a {@link PortableDid} + * 6. `retrieve`: (if `exported` is true) wallet will GET the {@link PortableDid} + * 7. `export-token`: (if `exported` is true) wallet will POST the grants in order to finalize the flow. + * 8. `retrieve-token`: (if `exported` is true) client will GET the grants in order to finalize the flow. */ type OidcEndpoint = | 'pushedAuthorizationRequest' | 'authorize' | 'callback' - | 'token'; + | 'token' + | 'export' + | 'retrieve' + | 'export-token' + | 'retrieve-token'; /** * Gets the correct OIDC endpoint out of the {@link OidcEndpoint} options provided. @@ -213,13 +235,16 @@ function buildOidcUrl({ /** 3. provider sends {@link Web5ConnectAuthResponse} */ case 'callback': return concatenateUrl(baseURL, `callback`); - /** 4. client gets {@link Web5ConnectAuthResponse */ + /** 4. client gets {@link Web5ConnectAuthResponse} */ case 'token': if (!tokenParam) throw new Error( `tokenParam must be providied when building a token URL` ); return concatenateUrl(baseURL, `token/${tokenParam}.jwt`); + /** 5. (if `exported` is true) client will POST a {@link PortableDid} for import to the wallet. */ + case 'export': + return concatenateUrl(baseURL, `export`); // TODO: metadata endpoints? default: throw new Error(`No matches for endpoint specified: ${endpoint}`); @@ -245,7 +270,7 @@ async function generateCodeChallenge() { async function createAuthRequest( options: RequireOnly< Web5ConnectAuthRequest, - 'client_id' | 'scope' | 'redirect_uri' | 'permissionRequests' | 'displayName' + 'client_id' | 'scope' | 'redirect_uri' > ) { // Generate a random state value to associate the authorization request with the response. @@ -272,15 +297,18 @@ async function createAuthRequest( async function encryptAuthRequest({ jwt, encryptionKey, + kid, }: { jwt: string; encryptionKey: Uint8Array; + kid: string; }) { - const protectedHeader = { + const protectedHeader: JweHeaderParams = { alg : 'dir', cty : 'JWT', enc : 'XC20P', typ : 'JWT', + kid, }; const nonce = CryptoUtils.randomBytes(24); const additionalData = Convert.object(protectedHeader).toUint8Array(); @@ -400,6 +428,7 @@ async function verifyJwt({ jwt }: { jwt: string }) { } /** + * Called by the wallet first to get the authRequest. * Fetches the {@Web5ConnectAuthRequest} from the authorize endpoint and decrypts it * using the encryption key passed via QR code. */ @@ -414,7 +443,14 @@ const getAuthRequest = async (request_uri: string, encryption_key: string) => { jwt, })) as Web5ConnectAuthRequest; - return web5ConnectAuthRequest; + // get the pub DID that represents the client in ECDH and deriving a shared key + const header = Convert.base64Url( + jwe.split('.')[0] + ).toObject() as JweHeaderParams; + + const clientEcdhDid = await DidJwk.resolve(header.kid!.split('#')[0]); + + return { authRequest: web5ConnectAuthRequest, clientEcdhDid }; }; /** Take the encrypted JWE, decrypt using the code challenge and return a JWT string which will need to be verified */ @@ -457,17 +493,8 @@ function decryptAuthRequest({ /** * The client uses to decrypt the jwe obtained from the auth server which contains * the {@link Web5ConnectAuthResponse} that was sent by the provider to the auth server. - * - * @async - * @param {BearerDid} clientDid - The did that was initially used by the client for ECDH at connect init. - * @param {string} jwe - The encrypted data as a jwe. - * @param {string} pin - The pin that was obtained from the user. */ -async function decryptAuthResponse( - clientDid: BearerDid, - jwe: string, - pin: string -) { +async function decryptWithPin(clientDid: BearerDid, jwe: string, pin: string) { const [ protectedHeaderB64U, , @@ -478,12 +505,20 @@ async function decryptAuthResponse( // get the delegatedid public key from the header const header = Convert.base64Url(protectedHeaderB64U).toObject() as Jwk; - const delegateResolvedDid = await DidJwk.resolve(header.kid!.split('#')[0]); + + // get ECDH pub did kid for provider encrypted jwe + const jweProviderEcdhDidKid = await DidJwk.resolve(header.kid!.split('#')[0]); + + if (!jweProviderEcdhDidKid.didDocument) { + throw new Error( + 'Could not resolve provider\'s DID document for shared key derivation' + ); + } // derive ECDH shared key using the provider's public key and our clientDid private key const sharedKey = await Oidc.deriveSharedKey( clientDid, - delegateResolvedDid.didDocument! + jweProviderEcdhDidKid.didDocument ); // add the pin to the AAD @@ -557,26 +592,30 @@ async function deriveSharedKey( /** * Encrypts the auth response jwt. Requires a randomPin is added to the AAD of the * encryption algorithm in order to prevent man in the middle and eavesdropping attacks. - * The keyid of the delegate did is used to pass the public key to the client in order + * The keyid of the encrypting did is used to pass the public key to the client in order * for the client to derive the shared ECDH private key. */ -function encryptAuthResponse({ +function encryptWithPin({ jwt, encryptionKey, - delegateDidKeyId, + pubDidKid, randomPin, }: { + /** JWT of data to encrypt */ jwt: string; + /** the ECDH did priv key */ encryptionKey: Uint8Array; - delegateDidKeyId: string; + /** the DID URI of the encrypting DID, NOT the shared key */ + pubDidKid: string; + /** cryptographically secure pin */ randomPin: string; }) { - const protectedHeader = { + const protectedHeader: JweHeaderParams = { alg : 'dir', cty : 'JWT', enc : 'XC20P', typ : 'JWT', - kid : delegateDidKeyId, + kid : pubDidKid, }; const nonce = CryptoUtils.randomBytes(24); const additionalData = Convert.object({ @@ -609,7 +648,10 @@ function shouldUseDelegatePermission(scope: DwnPermissionScope): boolean { // In the future only methods that modify state will be delegated and the rest will be normal permissions if (isRecordPermissionScope(scope)) { return true; - } else if (scope.interface === DwnInterfaceName.Protocols && scope.method === DwnMethodName.Configure) { + } else if ( + scope.interface === DwnInterfaceName.Protocols && + scope.method === DwnMethodName.Configure + ) { // ProtocolConfigure messages are also delegated, as they modify state return true; } @@ -626,7 +668,7 @@ async function createPermissionGrants( selectedDid: string, delegateBearerDid: BearerDid, agent: Web5Agent, - scopes: DwnPermissionScope[], + scopes: DwnPermissionScope[] ) { const permissionsApi = new AgentPermissionsApi({ agent }); @@ -647,7 +689,9 @@ async function createPermissionGrants( }) ); - logger.log(`Sending ${permissionGrants.length} permission grants to remote DWN...`); + logger.log( + `Sending ${permissionGrants.length} permission grants to remote DWN...` + ); const messagePromises = permissionGrants.map(async (grant) => { // Quirk: we have to pull out encodedData out of the message the schema validator doesn't want it there const { encodedData, ...rawMessage } = grant.message; @@ -690,7 +734,6 @@ async function prepareProtocol( agent: Web5Agent, protocolDefinition: DwnProtocolDefinition ): Promise { - const queryMessage = await agent.processDwnRequest({ author : selectedDid, messageType : DwnInterface.ProtocolsQuery, @@ -698,21 +741,27 @@ async function prepareProtocol( messageParams : { filter: { protocol: protocolDefinition.protocol } }, }); - if ( queryMessage.reply.status.code !== 200) { + if (queryMessage.reply.status.code !== 200) { // if the query failed, throw an error throw new Error( `Could not fetch protocol: ${queryMessage.reply.status.detail}` ); - } else if (queryMessage.reply.entries === undefined || queryMessage.reply.entries.length === 0) { - logger.log(`Protocol does not exist, creating: ${protocolDefinition.protocol}`); + } else if ( + queryMessage.reply.entries === undefined || + queryMessage.reply.entries.length === 0 + ) { + logger.log( + `Protocol does not exist, creating: ${protocolDefinition.protocol}` + ); // send the protocol definition to the remote DWN first, if it passes we can process it locally - const { reply: sendReply, message: configureMessage } = await agent.sendDwnRequest({ - author : selectedDid, - target : selectedDid, - messageType : DwnInterface.ProtocolsConfigure, - messageParams : { definition: protocolDefinition }, - }); + const { reply: sendReply, message: configureMessage } = + await agent.sendDwnRequest({ + author : selectedDid, + target : selectedDid, + messageType : DwnInterface.ProtocolsConfigure, + messageParams : { definition: protocolDefinition }, + }); // check if the message was sent successfully, if the remote returns 409 the message may have come through already via sync if (sendReply.status.code !== 202 && sendReply.status.code !== 409) { @@ -724,9 +773,8 @@ async function prepareProtocol( author : selectedDid, target : selectedDid, messageType : DwnInterface.ProtocolsConfigure, - rawMessage : configureMessage + rawMessage : configureMessage, }); - } else { logger.log(`Protocol already exists: ${protocolDefinition.protocol}`); @@ -758,85 +806,106 @@ async function submitAuthResponse( selectedDid: string, authRequest: Web5ConnectAuthRequest, randomPin: string, + clientEcdhDid: DidResolutionResult, agent: Web5Agent ) { + // ephemeral provider did for signing + const providerSigningDid = await DidJwk.create(); + + // ephemeral provider did for ECDH + const providerEcdhDid = await DidJwk.create(); + + // delegate did for persistent use + // not used for signing or encryption during wallet connect const delegateBearerDid = await DidJwk.create(); const delegatePortableDid = await delegateBearerDid.export(); // TODO: roll back permissions and protocol configurations if an error occurs. Need a way to delete protocols to achieve this. - const delegateGrantPromises = authRequest.permissionRequests.map( - async (permissionRequest) => { - const { protocolDefinition, permissionScopes } = permissionRequest; - - // We validate that all permission scopes match the protocol uri of the protocol definition they are provided with. - const grantsMatchProtocolUri = permissionScopes.every(scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol); - if (!grantsMatchProtocolUri) { - throw new Error('All permission scopes must match the protocol uri they are provided with.'); + if (authRequest.permissionRequests) { + const delegateGrantPromises = authRequest.permissionRequests.map( + async (permissionRequest) => { + const { protocolDefinition, permissionScopes } = permissionRequest; + + // We validate that all permission scopes match the protocol uri of the protocol definition they are provided with. + const grantsMatchProtocolUri = permissionScopes.every( + (scope) => + 'protocol' in scope && + scope.protocol === protocolDefinition.protocol + ); + if (!grantsMatchProtocolUri) { + throw new Error( + 'All permission scopes must match the protocol uri they are provided with.' + ); + } + + await prepareProtocol(selectedDid, agent, protocolDefinition); + + const permissionGrants = await Oidc.createPermissionGrants( + selectedDid, + delegateBearerDid, + agent, + permissionScopes + ); + + return permissionGrants; } + ); - await prepareProtocol(selectedDid, agent, protocolDefinition); + const delegateGrants = (await Promise.all(delegateGrantPromises)).flat(); + const responseObject = await Oidc.createResponseObject({ + //* the IDP's did that was selected to be connected + iss : selectedDid, + //* the client's new identity + sub : delegateBearerDid.uri, + //* the client's temporary ephemeral did used for connect + aud : authRequest.client_id, + //* the nonce of the original auth request + nonce : authRequest.nonce, + delegateGrants, + delegatePortableDid, + }); - const permissionGrants = await Oidc.createPermissionGrants( - selectedDid, - delegateBearerDid, - agent, - permissionScopes - ); + logger.log('Signing auth response object...'); + const responseObjectJwt = await Oidc.signJwt({ + did : providerSigningDid, + data : responseObject, + }); - return permissionGrants; + if (!clientEcdhDid.didDocument?.verificationMethod?.[0].id) { + throw new Error( + 'Unable to resolve the encryption DID used by the client for ECDH' + ); } - ); - - const delegateGrants = (await Promise.all(delegateGrantPromises)).flat(); - - logger.log('Generating auth response object...'); - const responseObject = await Oidc.createResponseObject({ - //* the IDP's did that was selected to be connected - iss : selectedDid, - //* the client's new identity - sub : delegateBearerDid.uri, - //* the client's temporary ephemeral did used for connect - aud : authRequest.client_id, - //* the nonce of the original auth request - nonce : authRequest.nonce, - delegateGrants, - delegatePortableDid, - }); - // Sign the Response Object using the ephemeral DID's signing key. - logger.log('Signing auth response object...'); - const responseObjectJwt = await Oidc.signJwt({ - did : delegateBearerDid, - data : responseObject, - }); - const clientDid = await DidJwk.resolve(authRequest.client_id); + const sharedKey = await Oidc.deriveSharedKey( + providerEcdhDid, + clientEcdhDid?.didDocument + ); - const sharedKey = await Oidc.deriveSharedKey( - delegateBearerDid, - clientDid?.didDocument! - ); + logger.log('Encrypting auth response object...'); + const encryptedResponse = Oidc.encryptWithPin({ + jwt : responseObjectJwt, + encryptionKey : sharedKey, + pubDidKid : providerEcdhDid.document.verificationMethod![0].id, + randomPin, + }); - logger.log('Encrypting auth response object...'); - const encryptedResponse = Oidc.encryptAuthResponse({ - jwt : responseObjectJwt!, - encryptionKey : sharedKey, - delegateDidKeyId : delegateBearerDid.document.verificationMethod![0].id, - randomPin, - }); + const formEncodedRequest = new URLSearchParams({ + id_token : encryptedResponse, + state : authRequest.state, + }).toString(); - const formEncodedRequest = new URLSearchParams({ - id_token : encryptedResponse, - state : authRequest.state, - }).toString(); - - logger.log(`Sending auth response object to Web5 Connect server: ${authRequest.redirect_uri}`); - await fetch(authRequest.redirect_uri, { - body : formEncodedRequest, - method : 'POST', - headers : { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); + logger.log( + `Sending auth response object to Web5 Connect server: ${authRequest.redirect_uri}` + ); + await fetch(authRequest.redirect_uri, { + body : formEncodedRequest, + method : 'POST', + headers : { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + } } export const Oidc = { @@ -846,8 +915,8 @@ export const Oidc = { decryptAuthRequest, createPermissionGrants, createResponseObject, - encryptAuthResponse, - decryptAuthResponse, + encryptWithPin, + decryptWithPin, deriveSharedKey, signJwt, verifyJwt, diff --git a/packages/agent/tests/connect.spec.ts b/packages/agent/tests/connect.spec.ts index 9f34a8e10..3ba2d9bec 100644 --- a/packages/agent/tests/connect.spec.ts +++ b/packages/agent/tests/connect.spec.ts @@ -1,7 +1,13 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { CryptoUtils } from '@web5/crypto'; -import { type BearerDid, DidDht, DidJwk, PortableDid } from '@web5/dids'; +import { + type BearerDid, + DidDht, + DidJwk, + type DidResolutionResult, + type PortableDid, +} from '@web5/dids'; import { Convert } from '@web5/common'; import { Oidc, @@ -11,16 +17,28 @@ import { import { PlatformAgentTestHarness } from '../src/test-harness.js'; import { TestAgent } from './utils/test-agent.js'; import { testDwnUrl } from './utils/test-config.js'; -import { BearerIdentity, DwnInterface, DwnMessage, DwnProtocolDefinition, WalletConnect } from '../src/index.js'; +import { + BearerIdentity, + DwnInterface, + DwnMessage, + DwnProtocolDefinition, + WalletConnect, +} from '../src/index.js'; import { RecordsPermissionScope } from '@tbd54566975/dwn-sdk-js'; import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js'; describe('web5 connect', function () { this.timeout(20000); - /** The temporary DID that web5 connect created on behalf of the client */ - let clientEphemeralBearerDid: BearerDid; - let clientEphemeralPortableDid: PortableDid; + let clientSigningBearerDid: BearerDid; + let clientSigningPortableDid: PortableDid; + + let clientEcdhBearerDid: BearerDid; + let clientEcdhDidAsResolvedByWallet: DidResolutionResult; + + let providerSigningBearerDid: BearerDid; + + let providerEcdhBearerDid: BearerDid; /** The real tenant (identity) of the DWN that the provider had chosen to connect */ let providerIdentity: BearerIdentity; @@ -172,7 +190,11 @@ describe('web5 connect', function () { const encryptionNonce = CryptoUtils.randomBytes(24); const randomPin = '9999'; + let clock: sinon.SinonFakeTimers; + before(async () => { + clock = sinon.useFakeTimers(new Date('2050-01-01T06:36:37.675Z').getTime()); + providerIdentityBearerDid = await DidDht.import({ portableDid: providerIdentityPortableDid, }); @@ -189,14 +211,20 @@ describe('web5 connect', function () { testDwnUrls : [testDwnUrl], }); - clientEphemeralBearerDid = await DidJwk.create(); - clientEphemeralPortableDid = await clientEphemeralBearerDid.export(); + clientEcdhBearerDid = await DidJwk.create(); + providerEcdhBearerDid = await DidJwk.create(); + + clientSigningBearerDid = await DidJwk.create(); + clientSigningPortableDid = await clientSigningBearerDid.export(); + + providerSigningBearerDid = await DidJwk.create(); delegateBearerDid = await DidJwk.create(); delegatePortableDid = await delegateBearerDid.export(); }); after(async () => { + clock.restore(); sinon.restore(); await testHarness.clearStorage(); await testHarness.closeStorage(); @@ -225,7 +253,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -243,16 +271,17 @@ describe('web5 connect', function () { it('should construct a signed jwt of an authrequest', async () => { authRequestJwt = await Oidc.signJwt({ - did : clientEphemeralBearerDid, + did : clientSigningBearerDid, data : authRequest, }); expect(authRequestJwt).to.be.a('string'); }); - it('should encrypt an authrequest using the code challenge', async () => { + it('should encrypt an authrequest using the random static encryptionKey', async () => { authRequestJwe = await Oidc.encryptAuthRequest({ jwt : authRequestJwt, - encryptionKey : authRequestEncryptionKey + encryptionKey : authRequestEncryptionKey, + kid : clientEcdhBearerDid.document.verificationMethod![0].id, }); expect(authRequestJwe).to.be.a('string'); expect(authRequestJwe.split('.')).to.have.lengthOf(5); @@ -282,7 +311,11 @@ describe('web5 connect', function () { authorizeUrl, Convert.uint8Array(authRequestEncryptionKey).toBase64Url() ); - expect(result).to.deep.equal(authRequest); + expect(result.authRequest).to.deep.equal(authRequest); + expect(result.clientEcdhDid.didDocument?.id).to.equal( + clientEcdhBearerDid.uri + ); + clientEcdhDidAsResolvedByWallet = result.clientEcdhDid; }); // TODO: waiting for DWN feature complete @@ -317,20 +350,21 @@ describe('web5 connect', function () { it('should sign the authresponse with its provider did', async () => { authResponseJwt = await Oidc.signJwt({ - did : delegateBearerDid, + did : providerSigningBearerDid, data : authResponse, }); + expect(authResponseJwt).to.be.a('string'); }); it('should derive a valid ECDH private key for both provider and client which is identical', async () => { const providerECDHDerivedPrivateKey = await Oidc.deriveSharedKey( - delegateBearerDid, - clientEphemeralBearerDid.document + providerEcdhBearerDid, + clientEcdhBearerDid.document ); const clientECDHDerivedPrivateKey = await Oidc.deriveSharedKey( - clientEphemeralBearerDid, - delegateBearerDid.document + clientEcdhBearerDid, + providerEcdhBearerDid.document ); expect(providerECDHDerivedPrivateKey).to.be.instanceOf(Uint8Array); @@ -350,20 +384,27 @@ describe('web5 connect', function () { const randomBytesStub = sinon .stub(CryptoUtils, 'randomBytes') .returns(encryptionNonce); - authResponseJwe = Oidc.encryptAuthResponse({ - jwt : authResponseJwt, - encryptionKey : sharedECDHPrivateKey, + + authResponseJwe = Oidc.encryptWithPin({ + jwt : authResponseJwt, + encryptionKey : sharedECDHPrivateKey, randomPin, - delegateDidKeyId : delegateBearerDid.document.verificationMethod![0].id, + pubDidKid : providerEcdhBearerDid.document.verificationMethod![0].id, }); expect(authResponseJwe).to.be.a('string'); expect(randomBytesStub.calledOnce).to.be.true; }); it('should send the encrypted jwe authresponse to the server', async () => { - sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); + sinon + .stub(Oidc, 'createPermissionGrants') + .resolves(permissionGrants as any); sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); - sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); + + const didJwkStub = sinon.stub(DidJwk, 'create'); + didJwkStub.onFirstCall().resolves(providerSigningBearerDid); + didJwkStub.onSecondCall().resolves(providerEcdhBearerDid); + didJwkStub.onThirdCall().resolves(delegateBearerDid); const formEncodedRequest = new URLSearchParams({ id_token : authResponseJwe, @@ -392,6 +433,7 @@ describe('web5 connect', function () { selectedDid, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); expect(fetchSpy.calledOnce).to.be.true; @@ -400,8 +442,8 @@ describe('web5 connect', function () { describe('client pin entry final phase', function () { it('should get the authresponse from server and decrypt the jwe using the pin', async () => { - const result = await Oidc.decryptAuthResponse( - clientEphemeralBearerDid, + const result = await Oidc.decryptWithPin( + clientEcdhBearerDid, authResponseJwe, randomPin ); @@ -411,8 +453,8 @@ describe('web5 connect', function () { it('should fail decrypting the jwe if the wrong pin is entered', async () => { try { - await Oidc.decryptAuthResponse( - clientEphemeralBearerDid, + await Oidc.decryptWithPin( + clientEcdhBearerDid, authResponseJwe, '87383837583757835737537734783' ); @@ -435,7 +477,9 @@ describe('web5 connect', function () { it('should complete the whole connect flow with the correct pin', async function () { const fetchStub = sinon.stub(globalThis, 'fetch'); const onWalletUriReadySpy = sinon.spy(); - sinon.stub(DidJwk, 'create').resolves(clientEphemeralBearerDid); + const didJwkStub = sinon.stub(DidJwk, 'create'); + didJwkStub.onFirstCall().resolves(clientSigningBearerDid); + didJwkStub.onSecondCall().resolves(clientEcdhBearerDid); const par = { expires_in : 3600000, @@ -463,8 +507,7 @@ describe('web5 connect', function () { connectServerUrl : 'http://localhost:3000/connect', permissionRequests : [ { - protocolDefinition : {} as any, - permissionScopes : {} as any, + protocolDefinition: {} as any, }, ], onWalletUriReady : (uri) => onWalletUriReadySpy(uri), @@ -497,7 +540,9 @@ describe('web5 connect', function () { // the wallet should not attempt to re-configure, but instead ensure that the protocol is // sent to the remote DWN for the requesting client to be able to sync it down later - sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); + sinon + .stub(Oidc, 'createPermissionGrants') + .resolves(permissionGrants as any); sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); @@ -508,7 +553,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -521,30 +566,43 @@ describe('web5 connect', function () { const protocolMessage = {} as DwnMessage[DwnInterface.ProtocolsConfigure]; // spy send request - const sendRequestSpy = sinon.stub(testHarness.agent, 'sendDwnRequest').resolves({ - messageCid : '', - reply : { status: { code: 202, detail: 'OK' } } - }); + const sendRequestSpy = sinon + .stub(testHarness.agent, 'sendDwnRequest') + .resolves({ + messageCid : '', + reply : { status: { code: 202, detail: 'OK' } }, + }); const processDwnRequestStub = sinon .stub(testHarness.agent, 'processDwnRequest') - .resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' }, entries: [ protocolMessage ]} }); + .resolves({ + messageCid : '', + reply : { + status : { code: 200, detail: 'OK' }, + entries : [protocolMessage], + }, + }); // call submitAuthResponse await Oidc.submitAuthResponse( providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); // expect the process request to only be called once for ProtocolsQuery expect(processDwnRequestStub.callCount).to.equal(1); - expect(processDwnRequestStub.firstCall.args[0].messageType).to.equal(DwnInterface.ProtocolsQuery); + expect(processDwnRequestStub.firstCall.args[0].messageType).to.equal( + DwnInterface.ProtocolsQuery + ); // send request should be called once as a ProtocolsConfigure expect(sendRequestSpy.callCount).to.equal(1); - expect(sendRequestSpy.firstCall.args[0].messageType).to.equal(DwnInterface.ProtocolsConfigure); + expect(sendRequestSpy.firstCall.args[0].messageType).to.equal( + DwnInterface.ProtocolsConfigure + ); }); it('should configure the protocol if it does not exist', async () => { @@ -553,7 +611,9 @@ describe('web5 connect', function () { // looks for a response of 404, empty entries array or missing entries array - sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); + sinon + .stub(Oidc, 'createPermissionGrants') + .resolves(permissionGrants as any); sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); @@ -564,7 +624,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -574,59 +634,83 @@ describe('web5 connect', function () { authRequest = await Oidc.createAuthRequest(options); // spy send request - const sendRequestSpy = sinon.stub(testHarness.agent, 'sendDwnRequest').resolves({ - messageCid : '', - reply : { status: { code: 202, detail: 'OK' } } - }); + const sendRequestSpy = sinon + .stub(testHarness.agent, 'sendDwnRequest') + .resolves({ + messageCid : '', + reply : { status: { code: 202, detail: 'OK' } }, + }); const processDwnRequestStub = sinon .stub(testHarness.agent, 'processDwnRequest') - .resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' }, entries: [ ] } }); + .resolves({ + messageCid : '', + reply : { status: { code: 200, detail: 'OK' }, entries: [] }, + }); // call submitAuthResponse await Oidc.submitAuthResponse( providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); // expect the process request to be called for query and configure expect(processDwnRequestStub.callCount).to.equal(2); - expect(processDwnRequestStub.firstCall.args[0].messageType).to.equal(DwnInterface.ProtocolsQuery); - expect(processDwnRequestStub.secondCall.args[0].messageType).to.equal(DwnInterface.ProtocolsConfigure); + expect(processDwnRequestStub.firstCall.args[0].messageType).to.equal( + DwnInterface.ProtocolsQuery + ); + expect(processDwnRequestStub.secondCall.args[0].messageType).to.equal( + DwnInterface.ProtocolsConfigure + ); // send request should be called once as a ProtocolsConfigure expect(sendRequestSpy.callCount).to.equal(1); - expect(sendRequestSpy.firstCall.args[0].messageType).to.equal(DwnInterface.ProtocolsConfigure); + expect(sendRequestSpy.firstCall.args[0].messageType).to.equal( + DwnInterface.ProtocolsConfigure + ); // reset the spys processDwnRequestStub.resetHistory(); sendRequestSpy.resetHistory(); // processDwnRequestStub should resolve a 200 with no entires - processDwnRequestStub.resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' } } }); + processDwnRequestStub.resolves({ + messageCid : '', + reply : { status: { code: 200, detail: 'OK' } }, + }); // call submitAuthResponse await Oidc.submitAuthResponse( providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); // expect the process request to be called for query and configure expect(processDwnRequestStub.callCount).to.equal(2); - expect(processDwnRequestStub.firstCall.args[0].messageType).to.equal(DwnInterface.ProtocolsQuery); - expect(processDwnRequestStub.secondCall.args[0].messageType).to.equal(DwnInterface.ProtocolsConfigure); + expect(processDwnRequestStub.firstCall.args[0].messageType).to.equal( + DwnInterface.ProtocolsQuery + ); + expect(processDwnRequestStub.secondCall.args[0].messageType).to.equal( + DwnInterface.ProtocolsConfigure + ); // send request should be called once as a ProtocolsConfigure expect(sendRequestSpy.callCount).to.equal(1); - expect(sendRequestSpy.firstCall.args[0].messageType).to.equal(DwnInterface.ProtocolsConfigure); + expect(sendRequestSpy.firstCall.args[0].messageType).to.equal( + DwnInterface.ProtocolsConfigure + ); }); it('should fail if the send request fails for newly configured protocol', async () => { - sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); + sinon + .stub(Oidc, 'createPermissionGrants') + .resolves(permissionGrants as any); sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); @@ -637,7 +721,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -647,15 +731,20 @@ describe('web5 connect', function () { authRequest = await Oidc.createAuthRequest(options); // spy send request - const sendRequestSpy = sinon.stub(testHarness.agent, 'sendDwnRequest').resolves({ - reply : { status: { code: 500, detail: 'Internal Server Error' } }, - messageCid : '' - }); + const sendRequestSpy = sinon + .stub(testHarness.agent, 'sendDwnRequest') + .resolves({ + reply : { status: { code: 500, detail: 'Internal Server Error' } }, + messageCid : '', + }); // return without any entries const processDwnRequestStub = sinon .stub(testHarness.agent, 'processDwnRequest') - .resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' } } }); + .resolves({ + messageCid : '', + reply : { status: { code: 200, detail: 'OK' } }, + }); try { // call submitAuthResponse @@ -663,18 +752,23 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); expect.fail('should have thrown an error'); } catch (error: any) { - expect(error.message).to.equal('Could not send protocol: Internal Server Error'); + expect(error.message).to.equal( + 'Could not send protocol: Internal Server Error' + ); expect(sendRequestSpy.callCount).to.equal(1); } }); it('should fail if the send request fails for existing protocol', async () => { - sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); + sinon + .stub(Oidc, 'createPermissionGrants') + .resolves(permissionGrants as any); sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); @@ -685,7 +779,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -698,15 +792,23 @@ describe('web5 connect', function () { const protocolMessage = {} as DwnMessage[DwnInterface.ProtocolsConfigure]; // spy send request - const sendRequestSpy = sinon.stub(testHarness.agent, 'sendDwnRequest').resolves({ - reply : { status: { code: 500, detail: 'Internal Server Error' } }, - messageCid : '' - }); + const sendRequestSpy = sinon + .stub(testHarness.agent, 'sendDwnRequest') + .resolves({ + reply : { status: { code: 500, detail: 'Internal Server Error' } }, + messageCid : '', + }); // mock returning the protocol entry const processDwnRequestStub = sinon .stub(testHarness.agent, 'processDwnRequest') - .resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' }, entries: [ protocolMessage ] } }); + .resolves({ + messageCid : '', + reply : { + status : { code: 200, detail: 'OK' }, + entries : [protocolMessage], + }, + }); try { // call submitAuthResponse @@ -714,19 +816,24 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); expect.fail('should have thrown an error'); } catch (error: any) { - expect(error.message).to.equal('Could not send protocol: Internal Server Error'); + expect(error.message).to.equal( + 'Could not send protocol: Internal Server Error' + ); expect(processDwnRequestStub.callCount).to.equal(1); expect(sendRequestSpy.callCount).to.equal(1); } }); it('should throw if protocol could not be fetched at all', async () => { - sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); + sinon + .stub(Oidc, 'createPermissionGrants') + .resolves(permissionGrants as any); sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); @@ -737,7 +844,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -747,15 +854,20 @@ describe('web5 connect', function () { authRequest = await Oidc.createAuthRequest(options); // spy send request - const sendRequestSpy = sinon.stub(testHarness.agent, 'sendDwnRequest').resolves({ - reply : { status: { code: 500, detail: 'Internal Server Error' } }, - messageCid : '' - }); + const sendRequestSpy = sinon + .stub(testHarness.agent, 'sendDwnRequest') + .resolves({ + reply : { status: { code: 500, detail: 'Internal Server Error' } }, + messageCid : '', + }); // mock returning the protocol entry const processDwnRequestStub = sinon .stub(testHarness.agent, 'processDwnRequest') - .resolves({ messageCid: '', reply: { status: { code: 500, detail: 'Some Error'}, } }); + .resolves({ + messageCid : '', + reply : { status: { code: 500, detail: 'Some Error' } }, + }); try { // call submitAuthResponse @@ -763,6 +875,7 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); @@ -775,7 +888,9 @@ describe('web5 connect', function () { }); it('should throw if a grant that is included in the request does not match the protocol definition', async () => { - sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); + sinon + .stub(Oidc, 'createPermissionGrants') + .resolves(permissionGrants as any); sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); @@ -789,7 +904,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -804,101 +919,195 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); expect.fail('should have thrown an error'); } catch (error: any) { - expect(error.message).to.equal('All permission scopes must match the protocol uri they are provided with.'); + expect(error.message).to.equal( + 'All permission scopes must match the protocol uri they are provided with.' + ); } }); }); describe('createPermissionRequestForProtocol', () => { it('should add sync permissions to all requests', async () => { - const protocol:DwnProtocolDefinition = { + const protocol: DwnProtocolDefinition = { published : true, protocol : 'https://exmaple.org/protocols/social', types : { note: { schema : 'https://example.org/schemas/note', - dataFormats : [ 'application/json', 'text/plain' ], - } + dataFormats : ['application/json', 'text/plain'], + }, }, structure: { - note: {} - } + note: {}, + }, }; - const permissionRequests = WalletConnect.createPermissionRequestForProtocol({ - definition: protocol, permissions: [] - }); + const permissionRequests = + WalletConnect.createPermissionRequestForProtocol({ + definition : protocol, + permissions : [], + }); expect(permissionRequests.protocolDefinition).to.deep.equal(protocol); expect(permissionRequests.permissionScopes.length).to.equal(4); // only includes the sync permissions + protocol query permission - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Messages && scope.method === DwnMethodName.Read)).to.not.be.undefined; - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Messages && scope.method === DwnMethodName.Query)).to.not.be.undefined; - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Messages && scope.method === DwnMethodName.Subscribe)).to.not.be.undefined; - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Protocols && scope.method === DwnMethodName.Query)).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Messages && + scope.method === DwnMethodName.Read + ) + ).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Messages && + scope.method === DwnMethodName.Query + ) + ).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Messages && + scope.method === DwnMethodName.Subscribe + ) + ).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Protocols && + scope.method === DwnMethodName.Query + ) + ).to.not.be.undefined; }); it('should add requested permissions to the request', async () => { - const protocol:DwnProtocolDefinition = { + const protocol: DwnProtocolDefinition = { published : true, protocol : 'https://exmaple.org/protocols/social', types : { note: { schema : 'https://example.org/schemas/note', - dataFormats : [ 'application/json', 'text/plain' ], - } + dataFormats : ['application/json', 'text/plain'], + }, }, structure: { - note: {} - } + note: {}, + }, }; - const permissionRequests = WalletConnect.createPermissionRequestForProtocol({ - definition: protocol, permissions: ['write', 'read'] - }); + const permissionRequests = + WalletConnect.createPermissionRequestForProtocol({ + definition : protocol, + permissions : ['write', 'read'], + }); expect(permissionRequests.protocolDefinition).to.deep.equal(protocol); // the 3 sync permissions plus the 2 requested permissions, and a protocol query permission expect(permissionRequests.permissionScopes.length).to.equal(6); - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Read)).to.not.be.undefined; - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Write)).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Records && + scope.method === DwnMethodName.Read + ) + ).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Records && + scope.method === DwnMethodName.Write + ) + ).to.not.be.undefined; }); it('supports requesting `read`, `write`, `delete`, `query`, `subscribe` and `configure` permissions', async () => { - const protocol:DwnProtocolDefinition = { + const protocol: DwnProtocolDefinition = { published : true, protocol : 'https://exmaple.org/protocols/social', types : { note: { schema : 'https://example.org/schemas/note', - dataFormats : [ 'application/json', 'text/plain' ], - } + dataFormats : ['application/json', 'text/plain'], + }, }, structure: { - note: {} - } + note: {}, + }, }; - const permissionRequests = WalletConnect.createPermissionRequestForProtocol({ - definition: protocol, permissions: ['write', 'read', 'delete', 'query', 'subscribe', 'configure'] - }); + const permissionRequests = + WalletConnect.createPermissionRequestForProtocol({ + definition : protocol, + permissions : [ + 'write', + 'read', + 'delete', + 'query', + 'subscribe', + 'configure', + ], + }); expect(permissionRequests.protocolDefinition).to.deep.equal(protocol); // the 3 sync permissions plus the 5 requested permissions expect(permissionRequests.permissionScopes.length).to.equal(10); - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Read)).to.not.be.undefined; - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Write)).to.not.be.undefined; - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Delete)).to.not.be.undefined; - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Query)).to.not.be.undefined; - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Subscribe)).to.not.be.undefined; - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Protocols && scope.method === DwnMethodName.Query)).to.not.be.undefined; - expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Protocols && scope.method === DwnMethodName.Configure)).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Records && + scope.method === DwnMethodName.Read + ) + ).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Records && + scope.method === DwnMethodName.Write + ) + ).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Records && + scope.method === DwnMethodName.Delete + ) + ).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Records && + scope.method === DwnMethodName.Query + ) + ).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Records && + scope.method === DwnMethodName.Subscribe + ) + ).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Protocols && + scope.method === DwnMethodName.Query + ) + ).to.not.be.undefined; + expect( + permissionRequests.permissionScopes.find( + (scope) => + scope.interface === DwnInterfaceName.Protocols && + scope.method === DwnMethodName.Configure + ) + ).to.not.be.undefined; }); }); }); diff --git a/packages/api/src/web5.ts b/packages/api/src/web5.ts index bc5ee95bd..2d3ffd2a3 100644 --- a/packages/api/src/web5.ts +++ b/packages/api/src/web5.ts @@ -8,16 +8,14 @@ import type { BearerIdentity, DwnDataEncodedRecordsWriteMessage, DwnMessagesPermissionScope, - DwnProtocolDefinition, DwnRecordsPermissionScope, HdIdentityVault, - Permission, WalletConnectOptions, Web5Agent, } from '@web5/agent'; import { Web5UserAgent } from '@web5/user-agent'; -import { DwnInterface, DwnRegistrar, WalletConnect } from '@web5/agent'; +import { DwnRegistrar, WalletConnect } from '@web5/agent'; import { DidApi } from './did-api.js'; import { DwnApi } from './dwn-api.js'; @@ -34,38 +32,7 @@ export type TechPreviewOptions = { export type DidCreateOptions = { /** Override default dwnEndpoints provided during DID creation. */ dwnEndpoints?: string[]; -} - -/** - * Represents a permission request for a protocol definition. - */ -export type ConnectPermissionRequest = { - /** - * The protocol definition for the protocol being requested. - */ - protocolDefinition: DwnProtocolDefinition; - - /** - * The permissions being requested for the protocol. If none are provided, the default is to request all permissions. - */ - permissions?: Permission[]; -} - -/** - * Options for connecting to a Web5 agent. This includes the ability to connect to an external wallet. - * - * NOTE: the returned `ConnectPermissionRequest` type is different to the `ConnectPermissionRequest` type in the `@web5/agent` package. - */ -export type ConnectOptions = Omit & { - /** The user friendly name of the client/app to be displayed when prompting end-user with permission requests. */ - displayName: string; - - /** - * The permissions that are being requested for the connected DID. - * This is used to create the {@link ConnectPermissionRequest} for the wallet connect flow. - */ - permissionRequests: ConnectPermissionRequest[]; -} +}; /** Optional overrides that can be provided when calling {@link Web5.connect}. */ export type Web5ConnectOptions = { @@ -74,7 +41,7 @@ export type Web5ConnectOptions = { * This param currently will not work in apps that are currently connected. * It must only be invoked at registration with a reset and empty DWN and agent. */ - walletConnectOptions?: ConnectOptions; + walletConnectOptions?: WalletConnectOptions; /** * Provide a {@link Web5Agent} implementation. Defaults to creating a local @@ -159,13 +126,13 @@ export type Web5ConnectOptions = { * If registration fails, the `onFailure` callback will be called with the error. * If registration is successful, the `onSuccess` callback will be called. */ - registration? : { + registration?: { /** Called when all of the DWN registrations are successful */ onSuccess: () => void; /** Called when any of the DWN registrations fail */ onFailure: (error: any) => void; - } -} + }; +}; /** * Represents the result of the Web5 connection process, including the Web5 instance, @@ -274,31 +241,42 @@ export class Web5 { password = 'insecure-static-phrase'; console.warn( '%cSECURITY WARNING:%c ' + - 'You have not set a password, which defaults to a static, guessable value. ' + - 'This significantly compromises the security of your data. ' + - 'Please configure a secure, unique password.', + 'You have not set a password, which defaults to a static, guessable value. ' + + 'This significantly compromises the security of your data. ' + + 'Please configure a secure, unique password.', 'font-weight: bold; color: red;', 'font-weight: normal; color: inherit;' ); } // Use the specified DWN endpoints or the latest TBD hosted DWN - const serviceEndpointNodes = techPreview?.dwnEndpoints ?? didCreateOptions?.dwnEndpoints ?? ['https://dwn.tbddev.org/beta']; + const serviceEndpointNodes = techPreview?.dwnEndpoints ?? + didCreateOptions?.dwnEndpoints ?? ['https://dwn.tbddev.org/beta']; // Initialize, if necessary, and start the agent. if (await userAgent.firstLaunch()) { - recoveryPhrase = await userAgent.initialize({ password, recoveryPhrase, dwnEndpoints: serviceEndpointNodes }); + recoveryPhrase = await userAgent.initialize({ + password, + recoveryPhrase, + dwnEndpoints: serviceEndpointNodes, + }); } await userAgent.start({ password }); - // Attempt to retrieve the connected Identity if it exists. - const connectedIdentity: BearerIdentity = await userAgent.identity.connectedIdentity(); + + // Attempt to retrieve the connected Identity if it exists + const connectedIdentity = await userAgent.identity.connectedIdentity(); let identity: BearerIdentity; let connectedProtocols: string[] = []; + + const isWalletConnect = + walletConnectOptions && !walletConnectOptions.exported; + const isWalletExportedConnect = + walletConnectOptions && walletConnectOptions.exported; + if (connectedIdentity) { - // if a connected identity is found, use it // TODO: In the future, implement a way to re-connect an already connected identity and apply additional grants/protocols identity = connectedIdentity; - } else if (walletConnectOptions) { + } else if (isWalletConnect) { if (sync === 'off') { // Currently we require sync to be enabled when using WalletConnect // This is to ensure a connected app is not in a disjointed state from any other clients/app using the connectedDid @@ -308,43 +286,42 @@ export class Web5 { // Since we are connecting a new identity, we will want to register sync for the connectedDid registerSync = true; - // No connected identity found and connectOptions are provided, attempt to import a delegated DID from an external wallet try { - const { permissionRequests, ...connectOptions } = walletConnectOptions; - const walletPermissionRequests = permissionRequests.map(({ protocolDefinition, permissions }) => WalletConnect.createPermissionRequestForProtocol({ - definition : protocolDefinition, - permissions : permissions ?? [ - 'read', 'write', 'delete', 'query', 'subscribe' - ]} - )); - - const { delegatePortableDid, connectedDid, delegateGrants } = await WalletConnect.initClient({ - ...connectOptions, - permissionRequests: walletPermissionRequests, - }); + const { delegatePortableDid, connectedDid, delegateGrants } = + await WalletConnect.initClient(walletConnectOptions); // Import the delegated DID as an Identity in the User Agent. // Setting the connectedDID in the metadata applies a relationship between the signer identity and the one it is impersonating. - identity = await userAgent.identity.import({ portableIdentity: { - portableDid : delegatePortableDid, - metadata : { - connectedDid, - name : 'Default', - uri : delegatePortableDid.uri, - tenant : agent.agentDid.uri, - } - }}); + identity = await userAgent.identity.import({ + portableIdentity: { + portableDid : delegatePortableDid, + metadata : { + connectedDid, + name : 'Default', + uri : delegatePortableDid.uri, + tenant : agent.agentDid.uri, + }, + }, + }); // Attempts to process the connected grants to be used by the delegateDID // If the process fails, we want to clean up the identity // the connected grants will return a de-duped array of protocol URIs that are used to register sync for those protocols - connectedProtocols = await this.processConnectedGrants({ agent, delegateDid: delegatePortableDid.uri, grants: delegateGrants }); - } catch (error:any) { + connectedProtocols = await this.processConnectedGrants({ + agent, + delegateDid : delegatePortableDid.uri, + grants : delegateGrants, + }); + } catch (error: any) { // clean up the DID and Identity if import fails and throw // TODO: Implement the ability to purge all of our messages as a tenant await this.cleanUpIdentity({ identity, userAgent }); throw new Error(`Failed to connect to wallet: ${error.message}`); } + } else if (isWalletExportedConnect) { + throw new Error( + 'Exported connect will be implemented in a separate PR' + ); } else { // No connected identity found and no connectOptions provided, use local Identities // Query the Agent's DWN tenant for identity records. @@ -368,23 +345,22 @@ export class Web5 { serviceEndpoint : serviceEndpointNodes, enc : '#enc', sig : '#sig', - } + }, ], verificationMethods: [ { algorithm : 'Ed25519', id : 'sig', - purposes : ['assertionMethod', 'authentication'] + purposes : ['assertionMethod', 'authentication'], }, { algorithm : 'secp256k1', id : 'enc', - purposes : ['keyAgreement'] - } - ] - } + purposes : ['keyAgreement'], + }, + ], + }, }); - } else { // If multiple identities are found, use the first one. // TODO: Implement selecting a connectedDid from multiple identities @@ -395,7 +371,9 @@ export class Web5 { // If the stored identity has a connected DID, use it as the connected DID, otherwise use the identity's DID. connectedDid = identity.metadata.connectedDid ?? identity.did.uri; // If the stored identity has a connected DID, use the identity DID as the delegated DID, otherwise it is undefined. - delegateDid = identity.metadata.connectedDid ? identity.did.uri : undefined; + delegateDid = identity.metadata.connectedDid + ? identity.did.uri + : undefined; if (registration !== undefined) { // If a registration object is passed, we attempt to register the AgentDID and the ConnectedDID with the DWN endpoints provided try { @@ -416,7 +394,7 @@ export class Web5 { // If no failures occurred, call the onSuccess callback registration.onSuccess(); - } catch(error) { + } catch (error) { // for any failure, call the onFailure callback with the error registration.onFailure(error); } @@ -432,11 +410,11 @@ export class Web5 { did : connectedDid, options : { delegateDid, - protocols: connectedProtocols - } + protocols: connectedProtocols, + }, }); - if(walletConnectOptions !== undefined) { + if (walletConnectOptions !== undefined) { // If we are using WalletConnect, we should do a one-shot sync to pull down any messages that are associated with the connectedDid await userAgent.sync.sync('pull'); } @@ -444,10 +422,9 @@ export class Web5 { // Enable sync using the specified interval or default. sync ??= '2m'; - userAgent.sync.startSync({ interval: sync }) - .catch((error: any) => { - console.error(`Sync failed: ${error}`); - }); + userAgent.sync.startSync({ interval: sync }).catch((error: any) => { + console.error(`Sync failed: ${error}`); + }); } } @@ -460,9 +437,12 @@ export class Web5 { * Cleans up the DID, Keys and Identity. Primarily used by a failed WalletConnect import. * Does not throw on error, but logs to console. */ - private static async cleanUpIdentity({ identity, userAgent }:{ - identity: BearerIdentity, - userAgent: Web5UserAgent + private static async cleanUpIdentity({ + identity, + userAgent, + }: { + identity: BearerIdentity; + userAgent: Web5UserAgent; }): Promise { try { // Delete the DID and the Associated Keys @@ -471,39 +451,54 @@ export class Web5 { tenant : identity.metadata.tenant, deleteKey : true, }); - } catch(error: any) { - console.error(`Failed to delete DID ${identity.did.uri}: ${error.message}`); + } catch (error: any) { + console.error( + `Failed to delete DID ${identity.did.uri}: ${error.message}` + ); } try { // Delete the Identity await userAgent.identity.delete({ didUri: identity.did.uri }); - } catch(error: any) { - console.error(`Failed to delete Identity ${identity.metadata.name}: ${error.message}`); + } catch (error: any) { + console.error( + `Failed to delete Identity ${identity.metadata.name}: ${error.message}` + ); } } /** - * A static method to process connected grants for a delegate DID. - * - * This will store the grants as the DWN owner to be used later when impersonating the connected DID. + * Processes connected grants for a delegate DID. + * Stores the grants as the DWN owner to be used later when impersonating the connected DID. */ - static async processConnectedGrants({ grants, agent, delegateDid }: { - grants: DwnDataEncodedRecordsWriteMessage[], - agent: Web5Agent, - delegateDid: string, + static async processConnectedGrants({ + grants, + agent, + delegateDid, + }: { + grants: DwnDataEncodedRecordsWriteMessage[]; + agent: Web5Agent; + delegateDid: string; }): Promise { const connectedProtocols = new Set(); for (const grantMessage of grants) { // use the delegateDid as the connectedDid of the grant as they do not yet support impersonation/delegation - const grant = await PermissionGrant.parse({ connectedDid: delegateDid, agent, message: grantMessage }); + const grant = await PermissionGrant.parse({ + connectedDid : delegateDid, + agent, + message : grantMessage, + }); // store the grant as the owner of the DWN, this will allow the delegateDid to use the grant when impersonating the connectedDid const { status } = await grant.store(true); if (status.code !== 202) { - throw new Error(`AgentDwnApi: Failed to process connected grant: ${status.detail}`); + throw new Error( + `AgentDwnApi: Failed to process connected grant: ${status.detail}` + ); } - const protocol = (grant.scope as DwnMessagesPermissionScope | DwnRecordsPermissionScope).protocol; + const protocol = ( + grant.scope as DwnMessagesPermissionScope | DwnRecordsPermissionScope + ).protocol; if (protocol) { connectedProtocols.add(protocol); } diff --git a/packages/api/tests/web5.spec.ts b/packages/api/tests/web5.spec.ts index 4576989c4..7a971e6c7 100644 --- a/packages/api/tests/web5.spec.ts +++ b/packages/api/tests/web5.spec.ts @@ -795,15 +795,9 @@ describe('web5 api', () => { }); it('should request all permissions for a protocol if no specific permissions are provided', async () => { - sinon.stub(Web5UserAgent, 'create').resolves(testHarness.agent as Web5UserAgent); - // spy on the WalletConnect createPermissionRequestForProtocol method - const requestPermissionsSpy = sinon.spy(WalletConnect, 'createPermissionRequestForProtocol'); - - // We throw and spy on the initClient method to avoid the actual WalletConnect initialization - // but to still be able to spy on the passed parameters - sinon.stub(WalletConnect, 'initClient').throws('Error'); + const createPermissionRequestForProtocolSpy = sinon.spy(WalletConnect, 'createPermissionRequestForProtocol'); // stub the cleanUpIdentity method to avoid actual cleanup sinon.stub(Web5 as any, 'cleanUpIdentity').resolves(); @@ -823,7 +817,6 @@ describe('web5 api', () => { }; try { - await Web5.connect({ walletConnectOptions: { displayName : 'Sample App', @@ -837,30 +830,27 @@ describe('web5 api', () => { expect.fail('Should have thrown an error'); } catch(error: any) { - // we expect an error because we stubbed the initClient method to throw it - expect(error.message).to.include('Sinon-provided Error'); - - // The `createPermissionRequestForProtocol` method should have been called once for the provided protocol - expect(requestPermissionsSpy.callCount).to.equal(1); - const call = requestPermissionsSpy.getCall(0); - - // since no explicit permissions were provided, all permissions should be requested - expect(call.args[0].permissions).to.have.members([ - 'read', 'write', 'delete', 'query', 'subscribe' + // we expect an error because we aren't testing the whole e2e flow + expect(error.message).to.include('Failed to connect to wallet'); + + expect(createPermissionRequestForProtocolSpy.callCount).to.equal(1); + const result = createPermissionRequestForProtocolSpy.getCall(0).returnValue; + + // Check if all permissions are included in the result + expect(result.permissionScopes).to.deep.include.members([ + { protocol: protocolDefinition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Read }, + { protocol: protocolDefinition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Write }, + { protocol: protocolDefinition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Delete }, + { protocol: protocolDefinition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Query }, + { protocol: protocolDefinition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Subscribe } ]); } }); it('should only request the specified permissions for a protocol', async () => { - sinon.stub(Web5UserAgent, 'create').resolves(testHarness.agent as Web5UserAgent); - // spy on the WalletConnect createPermissionRequestForProtocol method - const requestPermissionsSpy = sinon.spy(WalletConnect, 'createPermissionRequestForProtocol'); - - // We throw and spy on the initClient method to avoid the actual WalletConnect initialization - // but to still be able to spy on the passed parameters - sinon.stub(WalletConnect, 'initClient').throws('Error'); + const createPermissionRequestForProtocolSpy = sinon.spy(WalletConnect, 'createPermissionRequestForProtocol'); // stub the cleanUpIdentity method to avoid actual cleanup sinon.stub(Web5 as any, 'cleanUpIdentity').resolves(); @@ -893,9 +883,7 @@ describe('web5 api', () => { } }; - try { - await Web5.connect({ walletConnectOptions: { displayName : 'Sample App', @@ -904,31 +892,39 @@ describe('web5 api', () => { validatePin : async () => { return '1234'; }, onWalletUriReady : (_walletUri: string) => {}, permissionRequests : [ - { protocolDefinition: protocol1Definition }, // no permissions provided, expect all permissions to be requested - { protocolDefinition: protocol2Definition, permissions: ['read', 'write'] } // only read and write permissions provided + { protocolDefinition: protocol1Definition }, + { protocolDefinition: protocol2Definition, permissions: ['read', 'write'] } ] } }); expect.fail('Should have thrown an error'); } catch(error: any) { - // we expect an error because we stubbed the initClient method to throw it - expect(error.message).to.include('Sinon-provided Error'); - - // The `createPermissionRequestForProtocol` method should have been called once for each provided request - expect(requestPermissionsSpy.callCount).to.equal(2); - const call1 = requestPermissionsSpy.getCall(0); - - // since no explicit permissions were provided for the first protocol, all permissions should be requested - expect(call1.args[0].permissions).to.have.members([ - 'read', 'write', 'delete', 'query', 'subscribe' + // we expect an error because we aren't testing the whole e2e flow + expect(error.message).to.include('Failed to connect to wallet'); + + expect(createPermissionRequestForProtocolSpy.callCount).to.equal(2); + const result1 = createPermissionRequestForProtocolSpy.getCall(0).returnValue; + const result2 = createPermissionRequestForProtocolSpy.getCall(1).returnValue; + + // Check if all permissions are included for the first protocol + expect(result1.permissionScopes).to.deep.include.members([ + { protocol: protocol1Definition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Read }, + { protocol: protocol1Definition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Write }, + { protocol: protocol1Definition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Delete }, + { protocol: protocol1Definition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Query }, + { protocol: protocol1Definition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Subscribe } ]); - const call2 = requestPermissionsSpy.getCall(1); - - // only the provided permissions should be requested for the second protocol - expect(call2.args[0].permissions).to.have.members([ - 'read', 'write' + // Check if only read and write permissions are included for the second protocol + expect(result2.permissionScopes).to.deep.include.members([ + { protocol: protocol2Definition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Read }, + { protocol: protocol2Definition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Write } + ]); + expect(result2.permissionScopes).to.not.deep.include.members([ + { protocol: protocol2Definition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Delete }, + { protocol: protocol2Definition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Query }, + { protocol: protocol2Definition.protocol, interface: DwnInterfaceName.Records, method: DwnMethodName.Subscribe } ]); } });