Repository: TBD54566975/ssi-service (go)

Issue:
Add callback URIs to Presentation and Manifest Request objects
Any request object the service creates should have a URI letting a viewer/submitter/responder know where to respond back to.

This could be a new property in the JWT such as `callbackUri` or we could make the `id` of the requests fully qualified.

Pull request description:
Added callbackURLs for presentation/manifest requests
# Overview
Fixed #564 by adding a callbackURL. The new input will be included in the JWT as an additional claim. It will also be stored as a separate field and included in the response for ease of use purposes.

# Description
An example of what the response of `v1/presentations/requests` looks like is below:

```json
{
  "presentationRequest": {
    "audience": [
      "my_audience"
    ],
    "callbackUrl": "my_callback_url",
    "id": "d1e5c5e6-3a91-4a8b-a222-02d05d98a0f7",
    "issuerId": "did:key:z6MkunvYqJ91RBAmp2oUezNkK1zTkPYPYRQ1wmxzgk12KNAE",
    "presentationDefinitionId": "77157e25-599c-4c7b-887b-05c8b3eb39a1",
    "presentationRequestJwt": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3VudllxSjkxUkJBbXAyb1Vlek5rSzF6VGtQWVBZUlExd214emdrMTJLTkFFI3o2TWt1bnZZcUo5MVJCQW1wMm9VZXpOa0sxelRrUFlQWVJRMXdteHpnazEyS05BRSIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsibXlfYXVkaWVuY2UiXSwiY2FsbGJhY2tVcmwiOiJteV9jYWxsYmFja191cmwiLCJpYXQiOjE2ODkxNjkzMzEsImlzcyI6ImRpZDprZXk6ejZNa3VudllxSjkxUkJBbXAyb1Vlek5rSzF6VGtQWVBZUlExd214emdrMTJLTkFFIiwianRpIjoiZDFlNWM1ZTYtM2E5MS00YThiLWEyMjItMDJkMDVkOThhMGY3IiwibmJmIjoxNjg5MTY5MzMxLCJwcmVzZW50YXRpb25fZGVmaW5pdGlvbiI6eyJpZCI6Ijc3MTU3ZTI1LTU5OWMtNGM3Yi04ODdiLTA1YzhiM2ViMzlhMSIsImlucHV0X2Rlc2NyaXB0b3JzIjpbeyJjb25zdHJhaW50cyI6eyJmaWVsZHMiOlt7ImlkIjoiZGF0ZV9vZl9iaXJ0aCIsInBhdGgiOlsiJC5jcmVkZW50aWFsU3ViamVjdC5kYXRlT2ZCaXJ0aCIsIiQuY3JlZGVudGlhbFN1YmplY3QuZG9iIiwiJC52Yy5jcmVkZW50aWFsU3ViamVjdC5kYXRlT2ZCaXJ0aCIsIiQudmMuY3JlZGVudGlhbFN1YmplY3QuZG9iIl19XX0sImlkIjoid2FfZHJpdmVyX2xpY2Vuc2UiLCJuYW1lIjoid2FzaGluZ3RvbiBzdGF0ZSBidXNpbmVzcyBsaWNlbnNlIiwicHVycG9zZSI6InNvbWUgdGVzdGluZyBzdHVmZiJ9XSwibmFtZSI6Im5hbWUiLCJwdXJwb3NlIjoicHVycG9zZSJ9fQ.o3hftlsPfMGF5_rAINZJaGe3_IcTdtS_1-INK6ZT1sU0vpo7b0zBypaTtAngv-hdWLzisAEywxPe7EUtScgCDw",
    "verificationMethodId": "did:key:z6MkunvYqJ91RBAmp2oUezNkK1zTkPYPYRQ1wmxzgk12KNAE#z6MkunvYqJ91RBAmp2oUezNkK1zTkPYPYRQ1wmxzgk12KNAE"
  }
} 
```

# How Has This Been Tested?
Added an integration test and some unit test that verify that the `callbackURL` field is set to the expected value.

Patch:
diff --git a/doc/swagger.yaml b/doc/swagger.yaml
index 4378172..478798a 100644
--- a/doc/swagger.yaml
+++ b/doc/swagger.yaml
@@ -695,10 +695,16 @@ definitions:
         items:
           type: string
         type: array
+      callbackUrl:
+        description: |-
+          The URL that the presenter should be submitting the presentation submission to.
+          Optional.
+        example: https://example.com
+        type: string
       credentialManifestJwt:
         description: |-
-          CredentialManifestJWT is a JWT token with a "presentation_definition" claim within it. The
-          value of the field named "presentation_definition.id" matches PresentationDefinitionID.
+          CredentialManifestJWT is a JWT token with a "presentation_definition" claim and an optional "callbackUrl" claim
+          within it. The value of the field named "presentation_definition.id" matches PresentationDefinitionID.
           This is an output only field.
         type: string
       expiration:
@@ -734,6 +740,12 @@ definitions:
         items:
           type: string
         type: array
+      callbackUrl:
+        description: |-
+          The URL that the presenter should be submitting the presentation submission to.
+          Optional.
+        example: https://example.com
+        type: string
       expiration:
         description: Expiration as defined in https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4
         type: string
@@ -750,8 +762,8 @@ definitions:
         type: string
       presentationRequestJwt:
         description: |-
-          PresentationDefinitionJWT is a JWT token with a "presentation_definition" claim within it. The
-          value of the field named "presentation_definition.id" matches PresentationDefinitionID.
+          PresentationDefinitionJWT is a JWT token with a "presentation_definition" claim and an optional "callbackUrl" claim
+          within it. The value of the field named "presentation_definition.id" matches PresentationDefinitionID.
           This is an output only field.
         type: string
       verificationMethodId:
@@ -1233,6 +1245,12 @@ definitions:
         items:
           type: string
         type: array
+      callbackUrl:
+        description: |-
+          The URL that the presenter should be submitting the presentation submission to.
+          Optional.
+        example: https://example.com
+        type: string
       credentialManifestId:
         description: ID of the credential manifest to use for this request.
         type: string
@@ -1306,6 +1324,12 @@ definitions:
         items:
           type: string
         type: array
+      callbackUrl:
+        description: |-
+          The URL that the presenter should be submitting the presentation submission to.
+          Optional.
+        example: https://example.com
+        type: string
       expiration:
         description: |-
           Expiration as defined in https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4
diff --git a/integration/common.go b/integration/common.go
index 889735a..239d16d 100644
--- a/integration/common.go
+++ b/integration/common.go
@@ -324,6 +324,26 @@ func CreateCredentialApplicationJWT(credApplication credApplicationParams, crede
 	return signed.String(), nil
 }
 
+type presentationRequestParams struct {
+	DefinitionID         string
+	IssuerID             string
+	VerificationMethodID string
+}
+
+func CreatePresentationRequest(params presentationRequestParams) (string, error) {
+	logrus.Println("\n\nCreate our Presentation Request:")
+	pRequestJSON, err := resolveTemplate(params, "presentation-request-input.json")
+	if err != nil {
+		return "", err
+	}
+	output, err := put(endpoint+version+"presentations/requests", pRequestJSON)
+	if err != nil {
+		return "", errors.Wrapf(err, "presentation request endpoint with output: %s", output)
+	}
+
+	return output, nil
+}
+
 type definitionParams struct {
 	Author    string
 	AuthorKID string
diff --git a/pkg/server/router/manifest.go b/pkg/server/router/manifest.go
index 91a0e6b..8ac6820 100644
--- a/pkg/server/router/manifest.go
+++ b/pkg/server/router/manifest.go
@@ -602,7 +602,7 @@ func (mr ManifestRouter) ReviewApplication(c *gin.Context) {
 }
 
 type CreateManifestRequestRequest struct {
-	*CommonCreateRequestRequest
+	*CommonCreateRequestRequest `validate:"required,dive"`
 
 	// ID of the credential manifest to use for this request.
 	CredentialManifestID string `json:"credentialManifestId" validate:"required"`
@@ -755,6 +755,7 @@ func commonRequestToServiceRequest(request *CommonCreateRequestRequest) (*common
 		Audience:             request.Audience,
 		IssuerDID:            request.IssuerDID,
 		VerificationMethodID: request.VerificationMethodID,
+		CallbackURL:          request.CallbackURL,
 	}
 	if request.Expiration != "" {
 		expiration, err := time.Parse(time.RFC3339, request.Expiration)
diff --git a/pkg/server/router/model.go b/pkg/server/router/model.go
index 9f91af7..7cb27b8 100644
--- a/pkg/server/router/model.go
+++ b/pkg/server/router/model.go
@@ -17,4 +17,8 @@ type CommonCreateRequestRequest struct {
 	// The private key associated with the verificationMethod's publicKey will be used to sign an envelope that contains
 	// the created presentation definition.
 	VerificationMethodID string `json:"verificationMethodId" validate:"required" example:"did:key:z6MkkZDjunoN4gyPMx5TSy7Mfzw22D2RZQZUcx46bii53Ex3#z6MkkZDjunoN4gyPMx5TSy7Mfzw22D2RZQZUcx46bii53Ex3"`
+
+	// The URL that the presenter should be submitting the presentation submission to.
+	// Optional.
+	CallbackURL string `json:"callbackUrl" example:"https://example.com"`
 }
diff --git a/pkg/server/router/presentation.go b/pkg/server/router/presentation.go
index b4c5ecc..5b8fa2b 100644
--- a/pkg/server/router/presentation.go
+++ b/pkg/server/router/presentation.go
@@ -480,7 +480,7 @@ func (pr PresentationRouter) ReviewSubmission(c *gin.Context) {
 }
 
 type CreateRequestRequest struct {
-	*CommonCreateRequestRequest
+	*CommonCreateRequestRequest `validate:"required,dive"`
 	// ID of the presentation definition to use for this request.
 	PresentationDefinitionID string `json:"presentationDefinitionId" validate:"required"`
 }
@@ -557,13 +557,13 @@ func (pr PresentationRouter) serviceRequestFromRequest(request CreateRequestRequ
 func (pr PresentationRouter) GetRequest(c *gin.Context) {
 	id := framework.GetParam(c, IDParam)
 	if id == nil {
-		framework.LoggingRespondErrMsg(c, "cannot get issuance template without an ID", http.StatusBadRequest)
+		framework.LoggingRespondErrMsg(c, "cannot get presentation request without an ID", http.StatusBadRequest)
 		return
 	}
 
 	request, err := pr.service.GetRequest(c, &model.GetRequestRequest{ID: *id})
 	if err != nil {
-		framework.LoggingRespondErrWithMsg(c, err, "getting issuance template", http.StatusInternalServerError)
+		framework.LoggingRespondErrWithMsg(c, err, "getting presentation request", http.StatusInternalServerError)
 		return
 	}
 	framework.Respond(c, GetRequestResponse{Request: request}, http.StatusOK)
diff --git a/pkg/service/common/request.go b/pkg/service/common/request.go
index 455ff04..b628d50 100644
--- a/pkg/service/common/request.go
+++ b/pkg/service/common/request.go
@@ -29,6 +29,10 @@ type Request struct {
 	// stored in ssi-service. The verificationMethod must be part of the did document associated with `issuer`.
 	// The private key associated with the verificationMethod's publicKey will be used to sign the JWT.
 	VerificationMethodID string `json:"verificationMethodId" validate:"required" example:"did:key:z6MkkZDjunoN4gyPMx5TSy7Mfzw22D2RZQZUcx46bii53Ex3#z6MkkZDjunoN4gyPMx5TSy7Mfzw22D2RZQZUcx46bii53Ex3"`
+
+	// The URL that the presenter should be submitting the presentation submission to.
+	// Optional.
+	CallbackURL string `json:"callbackUrl,omitempty" example:"https://example.com"`
 }
 
 // ToServiceModel converts a storage model to a service model.
@@ -38,6 +42,7 @@ func ToServiceModel(stored *StoredRequest) (*Request, error) {
 		Audience:             stored.Audience,
 		IssuerDID:            stored.IssuerDID,
 		VerificationMethodID: stored.VerificationMethodID,
+		CallbackURL:          stored.CallbackURL,
 	}
 	if stored.Expiration != "" {
 		expiration, err := time.Parse(time.RFC3339, stored.Expiration)
@@ -64,6 +69,9 @@ func CreateStoredRequest(ctx context.Context, keyStore *keystore.Service, claimN
 		builder.Expiration(*request.Expiration)
 		expirationString = request.Expiration.Format(time.RFC3339)
 	}
+	if request.CallbackURL != "" {
+		builder.Claim("callbackUrl", request.CallbackURL)
+	}
 	token, err := builder.Build()
 	if err != nil {
 		return nil, errors.Wrap(err, "building jwt")
@@ -83,6 +91,7 @@ func CreateStoredRequest(ctx context.Context, keyStore *keystore.Service, claimN
 		VerificationMethodID: request.VerificationMethodID,
 		ReferenceID:          id,
 		JWT:                  signedToken.String(),
+		CallbackURL:          request.CallbackURL,
 	}
 	return stored, nil
 }
diff --git a/pkg/service/common/storage.go b/pkg/service/common/storage.go
index 90cd4be..cef1ae5 100644
--- a/pkg/service/common/storage.go
+++ b/pkg/service/common/storage.go
@@ -17,6 +17,7 @@ type StoredRequest struct {
 	VerificationMethodID string   `json:"verificationMethodId"`
 	ReferenceID          string   `json:"referenceId"`
 	JWT                  string   `json:"jwt"`
+	CallbackURL          string   `json:"callbackUrl"`
 }
 
 type RequestStorage interface {
diff --git a/pkg/service/manifest/model/model.go b/pkg/service/manifest/model/model.go
index 51bf2de..e1602ea 100644
--- a/pkg/service/manifest/model/model.go
+++ b/pkg/service/manifest/model/model.go
@@ -181,8 +181,8 @@ type Request struct {
 	// ID of the credential manifest used for this request.
 	ManifestID string `json:"manifestId" validate:"required"`
 
-	// CredentialManifestJWT is a JWT token with a "presentation_definition" claim within it. The
-	// value of the field named "presentation_definition.id" matches PresentationDefinitionID.
+	// CredentialManifestJWT is a JWT token with a "presentation_definition" claim and an optional "callbackUrl" claim
+	// within it. The value of the field named "presentation_definition.id" matches PresentationDefinitionID.
 	// This is an output only field.
 	CredentialManifestJWT keyaccess.JWT `json:"credentialManifestJwt"`
 }
diff --git a/pkg/service/presentation/model/model.go b/pkg/service/presentation/model/model.go
index fc4fa97..5952f0f 100644
--- a/pkg/service/presentation/model/model.go
+++ b/pkg/service/presentation/model/model.go
@@ -155,8 +155,8 @@ type Request struct {
 	// ID of the presentation definition used for this request.
 	PresentationDefinitionID string `json:"presentationDefinitionId" validate:"required"`
 
-	// PresentationDefinitionJWT is a JWT token with a "presentation_definition" claim within it. The
-	// value of the field named "presentation_definition.id" matches PresentationDefinitionID.
+	// PresentationDefinitionJWT is a JWT token with a "presentation_definition" claim and an optional "callbackUrl" claim
+	// within it. The value of the field named "presentation_definition.id" matches PresentationDefinitionID.
 	// This is an output only field.
 	PresentationDefinitionJWT keyaccess.JWT `json:"presentationRequestJwt"`
 }


Test patch:
diff --git a/integration/presentation_exchange_integration_test.go b/integration/presentation_exchange_integration_test.go
index 420e9ae..53b8cdc 100644
--- a/integration/presentation_exchange_integration_test.go
+++ b/integration/presentation_exchange_integration_test.go
@@ -7,6 +7,8 @@ import (
 	"github.com/TBD54566975/ssi-sdk/did/key"
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
+	"github.com/tbd54566975/ssi-service/internal/keyaccess"
+	"github.com/tbd54566975/ssi-service/internal/util"
 
 	"github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
 )
@@ -82,6 +84,44 @@ func TestCreatePresentationDefinition(t *testing.T) {
 	SetValue(presentationExchangeContext, "definitionID", definitionID)
 }
 
+func TestCreatePresentationRequest(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping integration test")
+	}
+
+	verifierDID, err := GetValue(presentationExchangeContext, "verifierDID")
+	assert.NoError(t, err)
+
+	verifierKID, err := GetValue(presentationExchangeContext, "verifierKID")
+	assert.NoError(t, err)
+
+	definitionID, err := GetValue(presentationExchangeContext, "definitionID")
+	assert.NoError(t, err)
+
+	pRequest, err := CreatePresentationRequest(presentationRequestParams{
+		DefinitionID:         definitionID.(string),
+		IssuerID:             verifierDID.(string),
+		VerificationMethodID: verifierKID.(string),
+	})
+	assert.NoError(t, err)
+
+	callbackURL, err := getJSONElement(pRequest, "$.presentationRequest.callbackUrl")
+	assert.NoError(t, err)
+	assert.Equal(t, "my_callback_url", callbackURL)
+
+	requestJWT, err := getJSONElement(pRequest, "$.presentationRequest.presentationRequestJwt")
+	assert.NoError(t, err)
+
+	_, token, err := util.ParseJWT(keyaccess.JWT(requestJWT))
+	assert.NoError(t, err)
+
+	tokenCallbackURL, found := token.Get("callbackUrl")
+	assert.True(t, found)
+	assert.Equal(t, "my_callback_url", tokenCallbackURL)
+
+	assert.Contains(t, token.Audience(), "my_audience")
+}
+
 func TestSubmissionFlow(t *testing.T) {
 	if testing.Short() {
 		t.Skip("skipping integration test")
diff --git a/integration/testdata/presentation-request-input.json b/integration/testdata/presentation-request-input.json
new file mode 100644
index 0000000..19bc504
--- /dev/null
+++ b/integration/testdata/presentation-request-input.json
@@ -0,0 +1,7 @@
+{
+  "presentationDefinitionId": "{{.DefinitionID}}",
+  "audience": ["my_audience"],
+  "issuerId": "{{.IssuerID}}",
+  "verificationMethodId": "{{.VerificationMethodID}}",
+  "callbackUrl": "my_callback_url"
+}
\ No newline at end of file
diff --git a/pkg/server/server_manifest_test.go b/pkg/server/server_manifest_test.go
index a4aa631..f2c5e2a 100644
--- a/pkg/server/server_manifest_test.go
+++ b/pkg/server/server_manifest_test.go
@@ -100,6 +100,7 @@ func TestManifestAPI(t *testing.T) {
 				err = json.NewDecoder(w.Body).Decode(&reqResp)
 				assert.NoError(tt, err)
 				assert.NotEmpty(tt, reqResp.Request)
+				assert.Equal(tt, "my_callback_url", reqResp.Request.CallbackURL)
 
 				// verify the manifest
 				verificationResponse, err := manifestService.VerifyManifest(context.Background(), manifestsvc.VerifyManifestRequest{ManifestJWT: reqResp.Request.CredentialManifestJWT})
@@ -1120,6 +1121,7 @@ func getValidManifestRequestRequest(issuerDID *did.CreateDIDResponse, kid string
 			Audience:             []string{"mario"},
 			IssuerDID:            issuerDID.DID.ID,
 			VerificationMethodID: kid,
+			CallbackURL:          "my_callback_url",
 		},
 		CredentialManifestID: credentialManifest.ID,
 	}
diff --git a/pkg/server/server_presentation_test.go b/pkg/server/server_presentation_test.go
index ed4e3a7..77ac15c 100644
--- a/pkg/server/server_presentation_test.go
+++ b/pkg/server/server_presentation_test.go
@@ -135,6 +135,28 @@ func TestPresentationAPI(t *testing.T) {
 				assert.Empty(tt, resp.Requests)
 			})
 
+			t.Run("Get presentation requests returns created request", func(tt *testing.T) {
+				s := test.ServiceStorage(t)
+				pRouter, didService := setupPresentationRouter(tt, s)
+				issuerDID := createDID(tt, didService)
+				def := createPresentationDefinition(tt, pRouter)
+				req1 := createPresentationRequest(tt, pRouter, def.PresentationDefinition.ID, issuerDID.DID)
+
+				req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/presentations/requests/"+req1.Request.ID, nil)
+				w := httptest.NewRecorder()
+				params := map[string]string{
+					"id": req1.Request.ID,
+				}
+				c := newRequestContextWithParams(w, req, params)
+				pRouter.GetRequest(c)
+				assert.True(tt, util.Is2xxResponse(w.Code))
+
+				var resp router.GetRequestResponse
+				assert.NoError(tt, json.NewDecoder(w.Body).Decode(&resp))
+				assert.Equal(tt, req1.Request, resp.Request)
+				assert.Equal(tt, "my_callback_url", resp.Request.CallbackURL)
+			})
+
 			t.Run("List presentation requests returns many requests", func(tt *testing.T) {
 				s := test.ServiceStorage(t)
 				pRouter, didService := setupPresentationRouter(tt, s)
@@ -156,6 +178,7 @@ func TestPresentationAPI(t *testing.T) {
 					*req1.Request,
 					*req2.Request,
 				})
+				assert.Equal(tt, "my_callback_url", resp.Requests[0].CallbackURL)
 			})
 
 			t.Run("List definitions returns empty", func(tt *testing.T) {
@@ -713,6 +736,7 @@ func createPresentationRequest(t *testing.T, pRouter *router.PresentationRouter,
 		CommonCreateRequestRequest: &router.CommonCreateRequestRequest{
 			IssuerDID:            issuerDID.ID,
 			VerificationMethodID: issuerDID.VerificationMethod[0].ID,
+			CallbackURL:          "my_callback_url",
 		},
 		PresentationDefinitionID: definitionID,
 	}