diff --git a/.changeset/tidy-ladybugs-lick.md b/.changeset/tidy-ladybugs-lick.md new file mode 100644 index 000000000..c9ec3b1d0 --- /dev/null +++ b/.changeset/tidy-ladybugs-lick.md @@ -0,0 +1,8 @@ +--- +"@web5/agent": minor +"@web5/identity-agent": minor +"@web5/proxy-agent": minor +"@web5/user-agent": minor +--- + +Break apart OIDC `submitAuthResponse` diff --git a/packages/agent/src/oidc.ts b/packages/agent/src/oidc.ts index 076a0b72a..2e82778e5 100644 --- a/packages/agent/src/oidc.ts +++ b/packages/agent/src/oidc.ts @@ -624,7 +624,7 @@ function shouldUseDelegatePermission(scope: DwnPermissionScope): boolean { */ async function createPermissionGrants( selectedDid: string, - delegateBearerDid: BearerDid, + delegatedPortableDid: PortableDid, agent: Web5Agent, scopes: DwnPermissionScope[], ) { @@ -639,7 +639,7 @@ async function createPermissionGrants( return permissionsApi.createGrant({ delegated, store : true, - grantedTo : delegateBearerDid.uri, + grantedTo : delegatedPortableDid.uri, scope, dateExpires : '2040-06-25T16:09:16.693356Z', // TODO: make dateExpires optional author : selectedDid, @@ -745,6 +745,36 @@ async function prepareProtocol( } } +async function createAuthResponseGrants( + delegatedPortableDid: PortableDid, + selectedDid: string, + permissionRequests: ConnectPermissionRequest[], + agent: Web5Agent +) { + // TODO: roll back permissions and protocol configurations if an error occurs. Need a way to delete protocols to achieve this. + const processGrant = async (permissionRequest: ConnectPermissionRequest): Promise => { + 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); + + return await Oidc.createPermissionGrants( + selectedDid, + delegatedPortableDid, + agent, + permissionScopes + ); + }; + + const delegateGrants = await Promise.all(permissionRequests.map(processGrant)); + return delegateGrants .flat(); +} + /** * Creates a delegate did which the web app will use as its future indentity. * Assigns to that DID the level of permissions that the web app requested in @@ -758,37 +788,11 @@ async function submitAuthResponse( selectedDid: string, authRequest: Web5ConnectAuthRequest, randomPin: string, - agent: Web5Agent + delegateBearerDid: BearerDid, + delegateGrants: DwnDataEncodedRecordsWriteMessage[] ) { - 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.'); - } - - await prepareProtocol(selectedDid, agent, protocolDefinition); - - const permissionGrants = await Oidc.createPermissionGrants( - selectedDid, - delegateBearerDid, - agent, - permissionScopes - ); - - return permissionGrants; - } - ); - - 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 @@ -853,5 +857,6 @@ export const Oidc = { verifyJwt, buildOidcUrl, generateCodeChallenge, + createAuthResponseGrants, submitAuthResponse, }; diff --git a/packages/agent/tests/connect.spec.ts b/packages/agent/tests/connect.spec.ts index 9f34a8e10..61d5c4082 100644 --- a/packages/agent/tests/connect.spec.ts +++ b/packages/agent/tests/connect.spec.ts @@ -363,7 +363,6 @@ describe('web5 connect', function () { it('should send the encrypted jwe authresponse to the server', async () => { sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); - sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); const formEncodedRequest = new URLSearchParams({ id_token : authResponseJwe, @@ -388,11 +387,23 @@ describe('web5 connect', function () { ); const selectedDid = providerIdentity.did.uri; + + // generate the DID + const delegatePortableDid = await delegateBearerDid.export(); + + const delegatedGrants = await Oidc.createAuthResponseGrants( + delegatePortableDid, + selectedDid, + authRequest.permissionRequests, + testHarness.agent + ); + await Oidc.submitAuthResponse( selectedDid, authRequest, randomPin, - testHarness.agent + delegateBearerDid, + delegatedGrants ); expect(fetchSpy.calledOnce).to.be.true; }); @@ -491,6 +502,147 @@ describe('web5 connect', function () { }); }); + + describe('createAuthResponseGrants', () => { + 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); + + const permissionRequests = [{ protocolDefinition, permissionScopes }]; + + // spy send request + const sendRequestSpy = sinon.stub(testHarness.agent, 'sendDwnRequest').resolves({ + reply : { status: { code: 500, detail: 'Internal Server Error' } }, + messageCid : '' + }); + + // return without any entries + sinon + .stub(testHarness.agent, 'processDwnRequest') + .resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' } } }); + + try { + // generate the DID + const delegatePortableDid = await delegateBearerDid.export(); + + await Oidc.createAuthResponseGrants( + delegatePortableDid, + providerIdentity.did.uri, + permissionRequests, + 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); + + const permissionRequests = [{ protocolDefinition, permissionScopes }]; + + // 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 { + // generate the DID + const delegatePortableDid = await delegateBearerDid.export(); + + await Oidc.createAuthResponseGrants( + delegatePortableDid, + providerIdentity.did.uri, + permissionRequests, + 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 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); + + const callbackUrl = Oidc.buildOidcUrl({ + baseURL : 'http://localhost:3000', + endpoint : 'callback', + }); + + const mismatchedScopes = permissionScopes.map((scope) => ({ ...scope })) as RecordsPermissionScope[]; + mismatchedScopes[0].protocol = 'http://profile-protocol.xyz/other'; + const permissionRequests = [{ protocolDefinition, permissionScopes: mismatchedScopes }]; + + try { + // generate the DID + const delegatePortableDid = await delegateBearerDid.export(); + + await Oidc.createAuthResponseGrants( + delegatePortableDid, + providerIdentity.did.uri, + permissionRequests, + 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.'); + } + }); + + 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); + + const permissionRequests = [{ protocolDefinition, permissionScopes }]; + + // 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 { + // generate the DID + const delegatePortableDid = await delegateBearerDid.export(); + + await Oidc.createAuthResponseGrants( + delegatePortableDid, + providerIdentity.did.uri, + permissionRequests, + 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); + } + }); + }); + 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 @@ -499,7 +651,6 @@ describe('web5 connect', function () { 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', @@ -530,12 +681,22 @@ describe('web5 connect', function () { .stub(testHarness.agent, 'processDwnRequest') .resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' }, entries: [ protocolMessage ]} }); + const delegatePortableDid = await delegateBearerDid.export(); + + const delegatedGrants = await Oidc.createAuthResponseGrants( + delegatePortableDid, + providerIdentity.did.uri, + authRequest.permissionRequests, + testHarness.agent + ); + // call submitAuthResponse await Oidc.submitAuthResponse( providerIdentity.did.uri, authRequest, randomPin, - testHarness.agent + delegateBearerDid, + delegatedGrants ); // expect the process request to only be called once for ProtocolsQuery @@ -555,7 +716,6 @@ describe('web5 connect', function () { 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', @@ -583,12 +743,23 @@ describe('web5 connect', function () { .stub(testHarness.agent, 'processDwnRequest') .resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' }, entries: [ ] } }); + // generate the DID + const delegatePortableDid = await delegateBearerDid.export(); + + const delegatedGrants = await Oidc.createAuthResponseGrants( + delegatePortableDid, + providerIdentity.did.uri, + authRequest.permissionRequests, + testHarness.agent + ); + // call submitAuthResponse await Oidc.submitAuthResponse( providerIdentity.did.uri, authRequest, randomPin, - testHarness.agent + delegateBearerDid, + delegatedGrants ); // expect the process request to be called for query and configure @@ -607,12 +778,25 @@ describe('web5 connect', function () { // processDwnRequestStub should resolve a 200 with no entires processDwnRequestStub.resolves({ messageCid: '', reply: { status: { code: 200, detail: 'OK' } } }); + + // generate the DID + const delegateBearerDid2 = await DidJwk.create(); + const delegatePortableDid2 = await delegateBearerDid2.export(); + + const delegatedGrants2 = await Oidc.createAuthResponseGrants( + delegatePortableDid2, + providerIdentity.did.uri, + authRequest.permissionRequests, + testHarness.agent + ); + // call submitAuthResponse await Oidc.submitAuthResponse( providerIdentity.did.uri, authRequest, randomPin, - testHarness.agent + delegateBearerDid2, + delegatedGrants2 ); // expect the process request to be called for query and configure @@ -624,194 +808,6 @@ describe('web5 connect', function () { 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 = { - displayName : 'Sample App', - 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 = { - displayName : 'Sample App', - 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 = { - displayName : 'Sample App', - 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 = { - displayName : 'Sample App', - 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', () => {