From e7ea29718d98e7bcc3d66e27d9c7229c9f19f1e3 Mon Sep 17 00:00:00 2001 From: mishasizov-SK <109598497+mishasizov-SK@users.noreply.github.com> Date: Mon, 25 Nov 2024 17:03:12 +0200 Subject: [PATCH] feat(sdk): support separate ack calls for OIDC4CI flow (#833) Signed-off-by: Misha Sizov --- .../openid4ci/acknowledgment.go | 29 ++++++++++++--- .../issuerinitiatedinteraction_test.go | 31 +++++++++++----- pkg/openid4ci/acknowledgment.go | 35 ++++++++++++++----- pkg/openid4ci/interaction.go | 9 +++-- pkg/openid4ci/issuerinitiatedinteraction.go | 6 ++-- .../issuerinitiatedinteraction_test.go | 31 ++++++++++++++-- pkg/openid4ci/models.go | 29 +++++++++------ test/integration/fixtures/.env | 2 +- test/integration/openid4ci_test.go | 2 +- 9 files changed, 131 insertions(+), 43 deletions(-) diff --git a/cmd/wallet-sdk-gomobile/openid4ci/acknowledgment.go b/cmd/wallet-sdk-gomobile/openid4ci/acknowledgment.go index 8d25fd36..1c64c388 100644 --- a/cmd/wallet-sdk-gomobile/openid4ci/acknowledgment.go +++ b/cmd/wallet-sdk-gomobile/openid4ci/acknowledgment.go @@ -35,26 +35,45 @@ func (a *Acknowledgment) Serialize() (string, error) { return string(data), nil } -// SetInteractionDetails extends acknowledgment request with serializedInteractionDetails. +// SetInteractionDetails extends next acknowledgment request with serializedInteractionDetails. func (a *Acknowledgment) SetInteractionDetails(serializedInteractionDetails string) error { - if err := json.Unmarshal([]byte(serializedInteractionDetails), &a.acknowledgment.InteractionDetails); err != nil { + var interactionDetails map[string]interface{} + + if err := json.Unmarshal([]byte(serializedInteractionDetails), &interactionDetails); err != nil { return fmt.Errorf("decode ci ack interaction details: %w", err) } + a.acknowledgment.InteractionDetails = interactionDetails + return nil } -// Success acknowledge issuer that client accepts credentials. +// Success acknowledges the client's acceptance of credentials. Each call to this function +// acknowledges the client's acceptance of the next credential in the list of issued credentials. +// +// The first call acknowledges the first credential, the second call acknowledges the second credential, +// the third call acknowledges the third, and so on. If the number of function calls exceeds the number +// of credentials issued in the current session, the function returns an error "ack list is empty". +// +// Between the calls caller might set different interaction details using SetInteractionDetails. func (a *Acknowledgment) Success() error { return a.acknowledgment.AcknowledgeIssuer(openid4cigoapi.EventStatusCredentialAccepted, &http.Client{}) } -// Reject acknowledge issuer that client rejects credentials. +// Reject acknowledges the client's rejection of credentials. Each call to this function +// acknowledges the client's rejection of the next credential in the list of issued credentials. +// +// The first call acknowledges the first credential, the second call acknowledges the second credential, +// the third call acknowledges the third, and so on. If the number of function calls exceeds the number +// of credentials issued in the current session, the function returns an error "ack list is empty". +// +// Between the calls caller might set different interaction details using SetInteractionDetails. func (a *Acknowledgment) Reject() error { return a.acknowledgment.AcknowledgeIssuer(openid4cigoapi.EventStatusCredentialFailure, &http.Client{}) } -// RejectWithCode sends rejection message to issuer with a reject code. +// RejectWithCode acknowledges the client's rejection of credentials with specific code. +// See Reject for details. func (a *Acknowledgment) RejectWithCode(code string) error { return a.acknowledgment.AcknowledgeIssuer(openid4cigoapi.EventStatus(code), &http.Client{}) } diff --git a/cmd/wallet-sdk-gomobile/openid4ci/issuerinitiatedinteraction_test.go b/cmd/wallet-sdk-gomobile/openid4ci/issuerinitiatedinteraction_test.go index 4c34923c..bd070c6b 100644 --- a/cmd/wallet-sdk-gomobile/openid4ci/issuerinitiatedinteraction_test.go +++ b/cmd/wallet-sdk-gomobile/openid4ci/issuerinitiatedinteraction_test.go @@ -136,6 +136,7 @@ type mockIssuerServerHandler struct { credentialRequestShouldFail bool credentialRequestShouldGiveUnmarshallableResponse bool ackRequestExpectInteractionDetails bool + ackRequestExpectedAmount int credentialResponse []byte headersToCheck *api.Headers } @@ -178,6 +179,8 @@ func (m *mockIssuerServerHandler) ServeHTTP(writer http.ResponseWriter, //nolint _, err = writer.Write(m.credentialResponse) } case "/oidc/ack_endpoint": + m.ackRequestExpectedAmount-- + var payload map[string]interface{} err = json.NewDecoder(request.Body).Decode(&payload) require.NoError(m.t, err) @@ -614,6 +617,7 @@ func doRequestCredentialTestExt(t *testing.T, additionalHeaders *api.Headers, credentialResponse: sampleCredentialResponse, headersToCheck: additionalHeaders, ackRequestExpectInteractionDetails: expectAckInteractionDetails, + ackRequestExpectedAmount: 1, } server := httptest.NewServer(issuerServerHandler) @@ -670,17 +674,28 @@ func doRequestCredentialTestExt(t *testing.T, additionalHeaders *api.Headers, require.NoError(t, err) } - if acknowledgeReject { - if rejectCode != "" { - err = acknowledgmentRestored.RejectWithCode(rejectCode) - } else { - err = acknowledgmentRestored.Reject() - } - } else { + switch { + case acknowledgeReject && rejectCode != "": + err = acknowledgmentRestored.RejectWithCode(rejectCode) + require.NoError(t, err) + + err = acknowledgmentRestored.RejectWithCode(rejectCode) + require.ErrorContains(t, err, "ack list is empty") + case acknowledgeReject: + err = acknowledgmentRestored.Reject() + require.NoError(t, err) + + err = acknowledgmentRestored.Reject() + require.ErrorContains(t, err, "ack list is empty") + default: err = acknowledgmentRestored.Success() + require.NoError(t, err) + + err = acknowledgmentRestored.Success() + require.ErrorContains(t, err, "ack list is empty") } - require.NoError(t, err) + require.Zero(t, issuerServerHandler.ackRequestExpectedAmount, 0) numberOfActivitiesLogged := activityLogger.Length() require.Equal(t, 1, numberOfActivitiesLogged) diff --git a/pkg/openid4ci/acknowledgment.go b/pkg/openid4ci/acknowledgment.go index 40745a93..0be73622 100644 --- a/pkg/openid4ci/acknowledgment.go +++ b/pkg/openid4ci/acknowledgment.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -20,24 +21,40 @@ type Acknowledgment struct { InteractionDetails map[string]interface{} `json:"interaction_details,omitempty"` } -// AcknowledgeIssuer acknowledge issuer that client accepts or rejects credentials. +// AcknowledgeIssuer acknowledge issuer that client accepts or rejects credentials using first existing AckIDs. func (a *Acknowledgment) AcknowledgeIssuer( eventStatus EventStatus, httpClient *http.Client, +) error { + if len(a.AckIDs) == 0 { + return errors.New("ack list is empty") + } + + // Pull first ackID + ackID := a.AckIDs[0] + + // Reduce slice size + a.AckIDs = a.AckIDs[1:] + + return a.sendAcknowledge(ackID, eventStatus, httpClient) +} + +func (a *Acknowledgment) sendAcknowledge( + ackID string, eventStatus EventStatus, httpClient *http.Client, ) error { ackRequest := acknowledgementRequest{ + Event: eventStatus, + EventDescription: nil, + IssuerIdentifier: a.IssuerURI, + NotificationID: ackID, InteractionDetails: a.InteractionDetails, } - for _, ackID := range a.AckIDs { - ackRequest.Credentials = append(ackRequest.Credentials, credentialAcknowledgement{ - NotificationID: ackID, - Event: eventStatus, - EventDescription: nil, - IssuerIdentifier: a.IssuerURI, - }) + err := a.sendAcknowledgeRequest(ackRequest, httpClient) + if err != nil { + return fmt.Errorf("send acknowledge request id %s: %w", ackID, err) } - return a.sendAcknowledgeRequest(ackRequest, httpClient) + return nil } func (a *Acknowledgment) sendAcknowledgeRequest( diff --git a/pkg/openid4ci/interaction.go b/pkg/openid4ci/interaction.go index 7b9c7e37..63c60d9e 100644 --- a/pkg/openid4ci/interaction.go +++ b/pkg/openid4ci/interaction.go @@ -73,6 +73,9 @@ type interaction struct { } type requestedAcknowledgment struct { + //TODO: after update to the latest OIDC4CI this variable can be changed to string + // since notification_id should be the same for given session. + // spec: https://openid.github.io/OpenID4VCI/openid-4-verifiable-credential-issuance-wg-draft.html#section-8.3-14 ackIDs []string } @@ -384,7 +387,7 @@ func (i *interaction) getCredentialResponsesWithAuth(signer api.JWTSigner, crede credentialResponses[index] = credentialResponse - i.storeAcknowledgmentID(credentialResponse.AscID) + i.storeAcknowledgmentID(credentialResponse.AckID) } return credentialResponses, nil @@ -727,10 +730,10 @@ func (i *interaction) requireAcknowledgment() (bool, error) { return i.requestedAcknowledgment != nil && i.issuerMetadata.NotificationEndpoint != "", nil } -func (i *interaction) storeAcknowledgmentID(id string) { +func (i *interaction) storeAcknowledgmentID(ackID string) { if i.requestedAcknowledgment == nil { i.requestedAcknowledgment = &requestedAcknowledgment{} } - i.requestedAcknowledgment.ackIDs = append(i.requestedAcknowledgment.ackIDs, id) + i.requestedAcknowledgment.ackIDs = append(i.requestedAcknowledgment.ackIDs, ackID) } diff --git a/pkg/openid4ci/issuerinitiatedinteraction.go b/pkg/openid4ci/issuerinitiatedinteraction.go index 90149fe0..3b16ea0e 100644 --- a/pkg/openid4ci/issuerinitiatedinteraction.go +++ b/pkg/openid4ci/issuerinitiatedinteraction.go @@ -448,7 +448,7 @@ func (i *IssuerInitiatedInteraction) getCredentialResponsesWithPreAuth( credentialResponses[index] = credentialResponse - i.interaction.storeAcknowledgmentID(credentialResponse.AscID) + i.interaction.storeAcknowledgmentID(credentialResponse.AckID) } return credentialResponses, nil @@ -517,10 +517,10 @@ func (i *IssuerInitiatedInteraction) getCredentialResponsesBatch( TransactionID: credentialResp.TransactionID, CNonce: *response.CNonce, CNonceExpiresIn: *response.CNonceExpiresIn, - AscID: credentialResp.AscID, + AckID: credentialResp.AckID, } - i.interaction.storeAcknowledgmentID(credentialResp.AscID) + i.interaction.storeAcknowledgmentID(credentialResp.AckID) } return credentialResponses, nil diff --git a/pkg/openid4ci/issuerinitiatedinteraction_test.go b/pkg/openid4ci/issuerinitiatedinteraction_test.go index 5c2466af..50294c30 100644 --- a/pkg/openid4ci/issuerinitiatedinteraction_test.go +++ b/pkg/openid4ci/issuerinitiatedinteraction_test.go @@ -87,6 +87,7 @@ type mockIssuerServerHandler struct { httpStatusCode int ackRequestErrorResponse string ackRequestExpectInteractionDetails bool + ackRequestExpectedCalls int } //nolint:gocyclo // test file @@ -160,6 +161,8 @@ func (m *mockIssuerServerHandler) ServeHTTP(writer http.ResponseWriter, request _, err = writer.Write(m.batchCredentialResponse) } case "/oidc/ack_endpoint": + m.ackRequestExpectedCalls-- + statusCode := http.StatusNoContent if m.httpStatusCode != 0 { @@ -647,9 +650,10 @@ func TestIssuerInitiatedInteraction_RequestCredential(t *testing.T) { }) t.Run("Batch credential endpoint", func(t *testing.T) { issuerServerHandler := &mockIssuerServerHandler{ - t: t, - batchCredentialResponse: sampleCredentialResponseBatch, - httpStatusCode: http.StatusOK, + t: t, + batchCredentialResponse: sampleCredentialResponseBatch, + ackRequestExpectInteractionDetails: true, + ackRequestExpectedCalls: 2, } server := httptest.NewServer(issuerServerHandler) @@ -677,6 +681,24 @@ func TestIssuerInitiatedInteraction_RequestCredential(t *testing.T) { require.Len(t, credentials, 2) require.NotEmpty(t, credentials[0]) require.NotEmpty(t, credentials[1]) + + requestedAcknowledgment, err := interaction.Acknowledgment() + require.NoError(t, err) + require.NotNil(t, requestedAcknowledgment) + + requestedAcknowledgment.InteractionDetails = map[string]interface{}{"key1": "value1"} + + err = requestedAcknowledgment.AcknowledgeIssuer(openid4ci.EventStatusCredentialAccepted, &http.Client{}) + require.NoError(t, err) + + err = requestedAcknowledgment.AcknowledgeIssuer(openid4ci.EventStatusCredentialAccepted, &http.Client{}) + require.NoError(t, err) + + require.Zero(t, issuerServerHandler.ackRequestExpectedCalls) + require.Empty(t, requestedAcknowledgment.AckIDs) + + err = requestedAcknowledgment.AcknowledgeIssuer(openid4ci.EventStatusCredentialAccepted, &http.Client{}) + require.ErrorContains(t, err, "ack list is empty") }) }) t.Run("Issuer require acknowledgment", func(t *testing.T) { @@ -698,6 +720,7 @@ func TestIssuerInitiatedInteraction_RequestCredential(t *testing.T) { t: t, credentialResponse: sampleCredentialResponseAsk, ackRequestExpectInteractionDetails: true, + ackRequestExpectedCalls: 2, } server := httptest.NewServer(issuerServerHandler) @@ -731,6 +754,8 @@ func TestIssuerInitiatedInteraction_RequestCredential(t *testing.T) { } require.NoError(t, err) } + + require.Zero(t, issuerServerHandler.ackRequestExpectedCalls) }) t.Run("Using credential_offer_uri", func(t *testing.T) { diff --git a/pkg/openid4ci/models.go b/pkg/openid4ci/models.go index ee55d431..d0b2da7d 100644 --- a/pkg/openid4ci/models.go +++ b/pkg/openid4ci/models.go @@ -75,17 +75,28 @@ type CredentialResponse struct { // OPTIONAL. Contains issued Credential. // It MUST be present when transaction_id is not returned. // It MAY be a string or an object, depending on the Credential format. + // Deprecated. Use Credentials instead. Credential interface{} `json:"credential,omitempty"` // OPTIONAL. String identifying a Deferred Issuance transaction. // This claim is contained in the response if the Credential Issuer was unable to immediately issue the Credential. + // Deprecated. TransactionID string `json:"transaction_id"` // OPTIONAL. String containing a nonce to be used to create a proof of possession of key material // when requesting a Credential. + // Deprecated. CNonce string `json:"c_nonce"` // OPTIONAL. Number denoting the lifetime in seconds of the c_nonce. + // Deprecated. CNonceExpiresIn int `json:"c_nonce_expires_in"` // OPTIONAL. String identifying an issued Credential that the Wallet includes in the Notification Request. - AscID string `json:"notification_id"` + AckID string `json:"notification_id"` + // Contains an array of one or more issued Credentials. + Credentials []CredentialResponseCredentialObject `json:"credentials"` +} + +// CredentialResponseCredentialObject is a model for credentials field from credential response. +type CredentialResponseCredentialObject struct { + Credential interface{} `json:"credential"` } // SerializeToCredentialsBytes serializes underlying credential to proper bytes representation depending on @@ -151,14 +162,6 @@ type errorResponse struct { } type acknowledgementRequest struct { - Credentials []credentialAcknowledgement `json:"credentials"` - InteractionDetails map[string]interface{} `json:"interaction_details,omitempty"` -} - -type credentialAcknowledgement struct { - // String received in the Credential Response or the Batch Credential Response. - NotificationID string `json:"notification_id"` - // Type of the notification event. // It MUST be a case-sensitive string whose value is either `credential_accepted`, `credential_failure`, // or `credential_deleted`. @@ -174,6 +177,12 @@ type credentialAcknowledgement struct { // developer in understanding the event that occurred. EventDescription *string `json:"event_description,omitempty"` - // Additional field that out of the spec. + // Optional issuer identifier. IssuerIdentifier string `json:"issuer_identifier"` + + // Ack ID. + // String received in the Credential Response or the Batch Credential Response. + NotificationID string `json:"notification_id"` + + InteractionDetails map[string]interface{} `json:"interaction_details,omitempty"` } diff --git a/test/integration/fixtures/.env b/test/integration/fixtures/.env index d9f5e88a..cf7b9ef9 100644 --- a/test/integration/fixtures/.env +++ b/test/integration/fixtures/.env @@ -6,7 +6,7 @@ # vc services VC_REST_IMAGE=ghcr.io/trustbloc-cicd/vc-server -VC_REST_IMAGE_TAG=v1.11.1-snapshot-3616094 +VC_REST_IMAGE_TAG=v1.11.1-snapshot-c0362a2 # Remote JSON-LD context provider CONTEXT_PROVIDER_URL=https://file-server.trustbloc.local:10096/ld-contexts.json diff --git a/test/integration/openid4ci_test.go b/test/integration/openid4ci_test.go index 21a74625..a97993ef 100644 --- a/test/integration/openid4ci_test.go +++ b/test/integration/openid4ci_test.go @@ -635,7 +635,7 @@ func doAuthCodeFlowTest(t *testing.T, useDynamicClientRegistration bool) { require.Equal(t, 1, credentials.Length()) requestedAcknowledgment, err := interaction.Acknowledgment() - require.NotNil(t, requestedAcknowledgment) + require.NoError(t, err) require.NoError(t, requestedAcknowledgment.Success()) }