diff --git a/examples/wallet-connect.html b/examples/wallet-connect.html index ccaa699e7..f6f2e0cf8 100644 --- a/examples/wallet-connect.html +++ b/examples/wallet-connect.html @@ -121,19 +121,34 @@

Success

$actions: [ { who: "anyone", - can: ["create", "update"], + can: ["read"], }, ], }, }, }; + const fooProtocol = { + protocol: "http://foo-protocol.xyz", + published: true, + types: { + foo: { + schema: "http://foo-protocol.xyz/schema/foo", + dataFormats: ["application/json"], + }, + }, + structure: { + foo: {}, + }, + }; + try { - const { delegateDid } = await Web5.connect({ + + const { delegateDid, web5 } = await Web5.connect({ walletConnectOptions: { walletUri: "web5://connect", connectServerUrl: "http://localhost:3000/connect", - permissionRequests: [{ protocolDefinition: profileProtocol }], + permissionRequests: [{ protocolDefinition: profileProtocol }, { protocolDefinition: fooProtocol }], onWalletUriReady: generateQRCode, validatePin: async () => { goToPinScreen(); @@ -144,7 +159,18 @@

Success

}, }); - goToEndScreen(delegateDid); + // attempt to write to the foo protocol + const { record, status } = await web5.dwn.records.create({ + data: { fooData: 'Some Foo Data' }, + message: { + protocol: fooProtocol.protocol, + protocolPath: 'foo', + schema: fooProtocol.types.foo.schema, + dataFormat: fooProtocol.types.foo.dataFormats[0], + } + }); + + goToEndScreen(delegateDid, record, status); } catch (e) { document.getElementById( "errorMessage" @@ -187,12 +213,12 @@

Success

document.getElementById("pinScreen").style.display = "block"; } - function goToEndScreen(delegateDid) { + function goToEndScreen(delegateDid, record, status) { document.getElementById( "didInformation" ).innerText = `delegateDid\n:${JSON.stringify( delegateDid - )}`; + )}\n\n\nRecordsWrite Status:${JSON.stringify(status)}\nRecord:${JSON.stringify(record, null, 2)}`; document.getElementById("pinScreen").style.display = "none"; document.getElementById("endScreen").style.display = "block"; diff --git a/packages/agent/src/connect.ts b/packages/agent/src/connect.ts index fdaa35362..24baf15cc 100644 --- a/packages/agent/src/connect.ts +++ b/packages/agent/src/connect.ts @@ -1,6 +1,6 @@ import type { PushedAuthResponse } from './oidc.js'; -import type { DwnPermissionScope, DwnProtocolDefinition, Web5ConnectAuthResponse } from './index.js'; +import type { DwnPermissionScope, DwnProtocolDefinition, Web5Agent, Web5ConnectAuthResponse } from './index.js'; import { Oidc, diff --git a/packages/agent/src/oidc.ts b/packages/agent/src/oidc.ts index a05873393..3443aabe5 100644 --- a/packages/agent/src/oidc.ts +++ b/packages/agent/src/oidc.ts @@ -12,13 +12,10 @@ 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 type { - PermissionScope, - RecordsWriteMessage, -} from '@tbd54566975/dwn-sdk-js'; -import { DwnInterface, DwnProtocolDefinition } from './types/dwn.js'; +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'; /** * Sent to an OIDC server to authorize a client. Allows clients @@ -161,14 +158,10 @@ export type SIOPv2AuthResponse = { /** An auth response that is compatible with both Web5 Connect and (hopefully, WIP) OIDC SIOPv2 */ export type Web5ConnectAuthResponse = { - delegateGrants: DelegateGrant[]; + delegateGrants: DwnDataEncodedRecordsWriteMessage[]; delegatePortableDid: PortableDid; } & SIOPv2AuthResponse; -export type DelegateGrant = (RecordsWriteMessage & { - encodedData: string; -}) - /** Represents the different OIDC endpoint types. * 1. `pushedAuthorizationRequest`: client sends {@link PushedAuthRequest} receives {@link PushedAuthResponse} * 2. `authorize`: provider gets the {@link Web5ConnectAuthRequest} JWT that was stored by the PAR @@ -615,22 +608,26 @@ async function createPermissionGrants( selectedDid: string, delegateBearerDid: BearerDid, agent: Web5Agent, - scopes: PermissionScope[], + scopes: DwnPermissionScope[], ) { - const permissionsApi = new AgentPermissionsApi({ agent }); // TODO: cleanup all grants if one fails by deleting them from the DWN: https://github.com/TBD54566975/web5-js/issues/849 const permissionGrants = await Promise.all( - scopes.map((scope) => - permissionsApi.createGrant({ + scopes.map((scope) => { + + // check if the scope is a records permission scope, if so it is a delegated permission + const delegated = isRecordPermissionScope(scope); + return permissionsApi.createGrant({ + delegated, store : true, grantedTo : delegateBearerDid.uri, scope, - dateExpires : '2040-06-25T16:09:16.693356Z', + dateExpires : '2040-06-25T16:09:16.693356Z', // TODO: make dateExpires optional author : selectedDid, - }) - ) + }); + + }) ); const messagePromises = permissionGrants.map(async (grant) => { @@ -638,7 +635,7 @@ async function createPermissionGrants( const { encodedData, ...rawMessage } = grant.message; const data = Convert.base64Url(encodedData).toUint8Array(); - const { reply } = await agent.sendDwnRequest({ + const { reply } = await agent.sendDwnRequest({ author : selectedDid, target : selectedDid, messageType : DwnInterface.RecordsWrite, @@ -662,14 +659,14 @@ async function createPermissionGrants( } /** -* Installs the protocols required by the Client on the Provider -* if they don't already exist. -*/ -async function prepareProtocols( + * Installs the protocol required by the Client on the Provider if it doesn't already exist. + */ +async function prepareProtocol( selectedDid: string, agent: Web5Agent, protocolDefinition: DwnProtocolDefinition -) { +): Promise { + const queryMessage = await agent.processDwnRequest({ author : selectedDid, messageType : DwnInterface.ProtocolsQuery, @@ -677,32 +674,48 @@ async function prepareProtocols( messageParams : { filter: { protocol: protocolDefinition.protocol } }, }); - if (queryMessage.reply.status.code === 404) { - const configureMessage = await agent.processDwnRequest({ + 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) { + + // 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, - messageType : DwnInterface.ProtocolsConfigure, target : selectedDid, + messageType : DwnInterface.ProtocolsConfigure, messageParams : { definition: protocolDefinition }, }); - if (configureMessage.reply.status.code !== 202) { - throw new Error(`Could not install protocol: ${configureMessage.reply.status.detail}`); + // 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) { + throw new Error(`Could not send protocol: ${sendReply.status.detail}`); } - // send the configure message to the remote DWN so that the APP can immediately use it without waiting for a sync cycle from the wallet + // process the protocol locally, we don't have to check if it exists as this is just a convenience over waiting for sync. + await agent.processDwnRequest({ + author : selectedDid, + target : selectedDid, + messageType : DwnInterface.ProtocolsConfigure, + rawMessage : configureMessage + }); + + } else { + + // the protocol already exists, let's make sure it exists on the remote DWN as the requesting app will need it + const configureMessage = queryMessage.reply.entries![0]; const { reply: sendReply } = await agent.sendDwnRequest({ author : selectedDid, target : selectedDid, messageType : DwnInterface.ProtocolsConfigure, - rawMessage : configureMessage.message, + rawMessage : configureMessage, }); - // 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) { throw new Error(`Could not send protocol: ${sendReply.status.detail}`); } - } else if (queryMessage.reply.status.code !== 200) { - throw new Error(`Could not fetch protcol: ${queryMessage.reply.status.detail}`); } } @@ -719,20 +732,34 @@ async function submitAuthResponse( selectedDid: string, authRequest: Web5ConnectAuthRequest, randomPin: string, - agent: Web5Agent, + agent: Web5Agent ) { const delegateBearerDid = await DidJwk.create(); const delegatePortableDid = await delegateBearerDid.export(); - const delegateGrantPromises = authRequest.permissionRequests.map(async (permissionRequest) => { - // TODO: validate to make sure the scopes and definition are assigned to the same protocol - const { protocolDefinition, permissionScopes } = permissionRequest; + // 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; - await prepareProtocols(selectedDid, agent, protocolDefinition); - const permissionGrants = await Oidc.createPermissionGrants(selectedDid, delegateBearerDid, agent, permissionScopes); + // 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.'); + } - return permissionGrants; - }); + await prepareProtocol(selectedDid, agent, protocolDefinition); + + const permissionGrants = await Oidc.createPermissionGrants( + selectedDid, + delegateBearerDid, + agent, + permissionScopes + ); + + return permissionGrants; + } + ); const delegateGrants = (await Promise.all(delegateGrantPromises)).flat(); diff --git a/packages/agent/tests/connect.spec.ts b/packages/agent/tests/connect.spec.ts index 441f80bdd..c56dc8720 100644 --- a/packages/agent/tests/connect.spec.ts +++ b/packages/agent/tests/connect.spec.ts @@ -11,7 +11,7 @@ import { import { PlatformAgentTestHarness } from '../src/test-harness.js'; import { TestAgent } from './utils/test-agent.js'; import { testDwnUrl } from './utils/test-config.js'; -import { BearerIdentity, 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'; @@ -489,6 +489,323 @@ describe('web5 connect', function () { }); }); + describe('submitAuthResponse', () => { + it('should not attempt to configure the protocol if it already exists', async () => { + // scenario: the wallet gets a request for a protocol that it already has configured + // 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(CryptoUtils, 'randomBytes').returns(encryptionNonce); + sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); + + const callbackUrl = Oidc.buildOidcUrl({ + baseURL : 'http://localhost:3000', + endpoint : 'callback', + }); + + const options = { + client_id : clientEphemeralPortableDid.uri, + scope : 'openid did:jwk', + // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), + // code_challenge_method : 'S256' as const, + permissionRequests : [{ protocolDefinition, permissionScopes }], + redirect_uri : callbackUrl, + }; + authRequest = await Oidc.createAuthRequest(options); + + // stub the processDwnRequest method to return a protocol entry + 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 processDwnRequestStub = sinon + .stub(testHarness.agent, 'processDwnRequest') + .resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' }, entries: [ protocolMessage ]} }); + + // call submitAuthResponse + await Oidc.submitAuthResponse( + providerIdentity.did.uri, + authRequest, + randomPin, + 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); + + // 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); + }); + + it('should configure the protocol if it does not exist', async () => { + // scenario: the wallet gets a request for a protocol that it does not have configured + // the wallet should attempt to configure the protocol and then send the protocol to the remote DWN + + // looks for a response of 404, empty entries array or missing entries array + + sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); + sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); + sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); + + const callbackUrl = Oidc.buildOidcUrl({ + baseURL : 'http://localhost:3000', + endpoint : 'callback', + }); + + const options = { + client_id : clientEphemeralPortableDid.uri, + scope : 'openid did:jwk', + // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), + // code_challenge_method : 'S256' as const, + permissionRequests : [{ protocolDefinition, permissionScopes }], + redirect_uri : callbackUrl, + }; + authRequest = await Oidc.createAuthRequest(options); + + // spy send request + 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: [ ] } }); + + // call submitAuthResponse + await Oidc.submitAuthResponse( + providerIdentity.did.uri, + authRequest, + randomPin, + 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); + + // 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); + + // reset the spys + processDwnRequestStub.resetHistory(); + sendRequestSpy.resetHistory(); + + // processDwnRequestStub should resolve a 200 with no entires + processDwnRequestStub.resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' } } }); + + // call submitAuthResponse + await Oidc.submitAuthResponse( + providerIdentity.did.uri, + authRequest, + randomPin, + 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); + + // 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); + }); + + it('should fail if the send request fails for newly configured protocol', async () => { + sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); + sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); + sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); + + const callbackUrl = Oidc.buildOidcUrl({ + baseURL : 'http://localhost:3000', + endpoint : 'callback', + }); + + const options = { + client_id : clientEphemeralPortableDid.uri, + scope : 'openid did:jwk', + // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), + // code_challenge_method : 'S256' as const, + permissionRequests : [{ protocolDefinition, permissionScopes }], + redirect_uri : callbackUrl, + }; + 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 : '' + }); + + // return without any entries + const processDwnRequestStub = sinon + .stub(testHarness.agent, 'processDwnRequest') + .resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' } } }); + + try { + // call submitAuthResponse + await Oidc.submitAuthResponse( + providerIdentity.did.uri, + authRequest, + randomPin, + 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(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(CryptoUtils, 'randomBytes').returns(encryptionNonce); + sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); + + const callbackUrl = Oidc.buildOidcUrl({ + baseURL : 'http://localhost:3000', + endpoint : 'callback', + }); + + const options = { + client_id : clientEphemeralPortableDid.uri, + scope : 'openid did:jwk', + // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), + // code_challenge_method : 'S256' as const, + permissionRequests : [{ protocolDefinition, permissionScopes }], + redirect_uri : callbackUrl, + }; + authRequest = await Oidc.createAuthRequest(options); + + // stub the processDwnRequest method to return a protocol entry + 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 : '' + }); + + // mock returning the protocol entry + const processDwnRequestStub = sinon + .stub(testHarness.agent, 'processDwnRequest') + .resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' }, entries: [ protocolMessage ] } }); + + try { + // call submitAuthResponse + await Oidc.submitAuthResponse( + providerIdentity.did.uri, + authRequest, + randomPin, + 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(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(CryptoUtils, 'randomBytes').returns(encryptionNonce); + sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); + + const callbackUrl = Oidc.buildOidcUrl({ + baseURL : 'http://localhost:3000', + endpoint : 'callback', + }); + + const options = { + client_id : clientEphemeralPortableDid.uri, + scope : 'openid did:jwk', + // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), + // code_challenge_method : 'S256' as const, + permissionRequests : [{ protocolDefinition, permissionScopes }], + redirect_uri : callbackUrl, + }; + 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 : '' + }); + + // mock returning the protocol entry + const processDwnRequestStub = sinon + .stub(testHarness.agent, 'processDwnRequest') + .resolves({ messageCid: '', reply: { status: { code: 500, detail: 'Some Error'}, } }); + + try { + // call submitAuthResponse + await Oidc.submitAuthResponse( + providerIdentity.did.uri, + authRequest, + randomPin, + testHarness.agent + ); + + expect.fail('should have thrown an error'); + } catch (error: any) { + expect(error.message).to.equal('Could not fetch protocol: Some Error'); + expect(processDwnRequestStub.callCount).to.equal(1); + expect(sendRequestSpy.callCount).to.equal(0); + } + }); + + 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(CryptoUtils, 'randomBytes').returns(encryptionNonce); + sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); + + const callbackUrl = Oidc.buildOidcUrl({ + baseURL : 'http://localhost:3000', + endpoint : 'callback', + }); + + const mismatchedScopes = [...permissionScopes]; + mismatchedScopes[0].protocol = 'http://profile-protocol.xyz/other'; + + const options = { + client_id : clientEphemeralPortableDid.uri, + scope : 'openid did:jwk', + // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), + // code_challenge_method : 'S256' as const, + permissionRequests : [{ protocolDefinition, permissionScopes }], + redirect_uri : callbackUrl, + }; + authRequest = await Oidc.createAuthRequest(options); + + try { + // call submitAuthResponse + await Oidc.submitAuthResponse( + providerIdentity.did.uri, + authRequest, + randomPin, + 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.'); + } + }); + }); + describe('createPermissionRequestForProtocol', () => { it('should add sync permissions to all requests', async () => { const protocol:DwnProtocolDefinition = { diff --git a/packages/api/src/web5.ts b/packages/api/src/web5.ts index 6aba6ecef..75468f6d6 100644 --- a/packages/api/src/web5.ts +++ b/packages/api/src/web5.ts @@ -6,7 +6,6 @@ import type { BearerIdentity, - DelegateGrant, DwnDataEncodedRecordsWriteMessage, DwnMessagesPermissionScope, DwnProtocolDefinition, @@ -242,7 +241,6 @@ export class Web5 { walletConnectOptions, }: Web5ConnectOptions = {}): Promise { let delegateDid: string | undefined; - let delegateGrants: DelegateGrant[]; if (agent === undefined) { let registerSync = false; // A custom Web5Agent implementation was not specified, so use default managed user agent. @@ -292,11 +290,10 @@ export class Web5 { 'read', 'write', 'delete', 'query', 'subscribe' ])); - const { delegatePortableDid, connectedDid, delegateGrants: returnedGrants } = await WalletConnect.initClient({ + const { delegatePortableDid, connectedDid, delegateGrants } = await WalletConnect.initClient({ ...connectOptions, permissionRequests: walletPermissionRequests, }); - delegateGrants = returnedGrants; // 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.