Skip to content

Commit

Permalink
feat(sdk): support separate ack calls for OIDC4CI flow (#833)
Browse files Browse the repository at this point in the history
Signed-off-by: Misha Sizov <[email protected]>
  • Loading branch information
mishasizov-SK authored Nov 25, 2024
1 parent 3d2b5ec commit e7ea297
Show file tree
Hide file tree
Showing 9 changed files with 131 additions and 43 deletions.
29 changes: 24 additions & 5 deletions cmd/wallet-sdk-gomobile/openid4ci/acknowledgment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ type mockIssuerServerHandler struct {
credentialRequestShouldFail bool
credentialRequestShouldGiveUnmarshallableResponse bool
ackRequestExpectInteractionDetails bool
ackRequestExpectedAmount int
credentialResponse []byte
headersToCheck *api.Headers
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -614,6 +617,7 @@ func doRequestCredentialTestExt(t *testing.T, additionalHeaders *api.Headers,
credentialResponse: sampleCredentialResponse,
headersToCheck: additionalHeaders,
ackRequestExpectInteractionDetails: expectAckInteractionDetails,
ackRequestExpectedAmount: 1,
}

server := httptest.NewServer(issuerServerHandler)
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 26 additions & 9 deletions pkg/openid4ci/acknowledgment.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -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(
Expand Down
9 changes: 6 additions & 3 deletions pkg/openid4ci/interaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
6 changes: 3 additions & 3 deletions pkg/openid4ci/issuerinitiatedinteraction.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ func (i *IssuerInitiatedInteraction) getCredentialResponsesWithPreAuth(

credentialResponses[index] = credentialResponse

i.interaction.storeAcknowledgmentID(credentialResponse.AscID)
i.interaction.storeAcknowledgmentID(credentialResponse.AckID)
}

return credentialResponses, nil
Expand Down Expand Up @@ -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
Expand Down
31 changes: 28 additions & 3 deletions pkg/openid4ci/issuerinitiatedinteraction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type mockIssuerServerHandler struct {
httpStatusCode int
ackRequestErrorResponse string
ackRequestExpectInteractionDetails bool
ackRequestExpectedCalls int
}

//nolint:gocyclo // test file
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -698,6 +720,7 @@ func TestIssuerInitiatedInteraction_RequestCredential(t *testing.T) {
t: t,
credentialResponse: sampleCredentialResponseAsk,
ackRequestExpectInteractionDetails: true,
ackRequestExpectedCalls: 2,
}

server := httptest.NewServer(issuerServerHandler)
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 19 additions & 10 deletions pkg/openid4ci/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand All @@ -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"`
}
2 changes: 1 addition & 1 deletion test/integration/fixtures/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/integration/openid4ci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down

0 comments on commit e7ea297

Please sign in to comment.