diff --git a/component/wallet-cli/pkg/oidc4vci/oidc4vci_flow.go b/component/wallet-cli/pkg/oidc4vci/oidc4vci_flow.go index 9656f3716..0aba09a6c 100644 --- a/component/wallet-cli/pkg/oidc4vci/oidc4vci_flow.go +++ b/component/wallet-cli/pkg/oidc4vci/oidc4vci_flow.go @@ -676,7 +676,7 @@ func (f *Flow) getAttestationVP() (string, error) { return "", fmt.Errorf("marshal presentation definition: %w", err) } - presentations, err := f.wallet.Query(b, false) + presentations, _, err := f.wallet.Query(b, false, false) if err != nil { return "", fmt.Errorf("query wallet: %w", err) } diff --git a/component/wallet-cli/pkg/oidc4vp/oidc4vp_flow.go b/component/wallet-cli/pkg/oidc4vp/oidc4vp_flow.go index 005fbec11..931c31011 100644 --- a/component/wallet-cli/pkg/oidc4vp/oidc4vp_flow.go +++ b/component/wallet-cli/pkg/oidc4vp/oidc4vp_flow.go @@ -82,6 +82,7 @@ type Flow struct { disableDomainMatching bool disableSchemaValidation bool perfInfo *PerfInfo + useMultiVPs bool } type provider interface { @@ -150,6 +151,7 @@ func NewFlow(p provider, opts ...Opt) (*Flow, error) { enableLinkedDomainVerification: o.enableLinkedDomainVerification, disableDomainMatching: o.disableDomainMatching, disableSchemaValidation: o.disableSchemaValidation, + useMultiVPs: o.useMultiVPs, perfInfo: &PerfInfo{}, }, nil } @@ -194,32 +196,54 @@ func (f *Flow) Run(ctx context.Context) error { requestObject.PresentationDefinition.InputDescriptors[0].Schema = nil } - vp, err := f.queryWallet(&pd, requestObject.ClientMetadata.VPFormats) + vps, presentationSubmission, err := f.queryWallet(&pd, requestObject.ClientMetadata.VPFormats) if err != nil { return fmt.Errorf("query wallet: %w", err) } - var attestationRequired bool + vpFormats := requestObject.ClientMetadata.VPFormats - if f.trustRegistry != nil && !reflect.ValueOf(f.trustRegistry).IsNil() { - attestationRequired, err = f.trustRegistry.ValidateVerifier(ctx, requestObject.ClientID, "", vp.Credentials()) - if err != nil { - return fmt.Errorf("validate verifier: %w", err) + for i := range presentationSubmission.DescriptorMap { + if vpFormats.JwtVP != nil { + presentationSubmission.DescriptorMap[i].Format = "jwt_vp" + } else if vpFormats.LdpVP != nil { + presentationSubmission.DescriptorMap[i].Format = "ldp_vp" } } - if !f.disableDomainMatching { - credentials := vp.Credentials() + var credentials []*verifiable.Credential + + for _, vp := range vps { + vpCredentials := vp.Credentials() - for i := len(credentials) - 1; i >= 0; i-- { - credential := credentials[i] - if !sameDIDWebDomain(credential.Contents().Issuer.ID, requestObject.ClientID) { - credentials = append(credentials[:i], credentials[i+1:]...) + if !f.disableDomainMatching { + for i := len(vpCredentials) - 1; i >= 0; i-- { + credential := vpCredentials[i] + if !sameDIDWebDomain(credential.Contents().Issuer.ID, requestObject.ClientID) { + vpCredentials = append(vpCredentials[:i], vpCredentials[i+1:]...) + } } } + + credentials = append(credentials, vpCredentials...) } - if err = f.sendAuthorizationResponse(ctx, requestObject, vp, attestationRequired); err != nil { + var attestationRequired bool + + if f.trustRegistry != nil && !reflect.ValueOf(f.trustRegistry).IsNil() { + attestationRequired, err = f.trustRegistry.ValidateVerifier(ctx, requestObject.ClientID, "", credentials) + if err != nil { + return fmt.Errorf("validate verifier: %w", err) + } + } + + if err = f.sendAuthorizationResponse( + ctx, + requestObject, + vps, + presentationSubmission, + attestationRequired, + ); err != nil { return fmt.Errorf("send authorization response: %w", err) } @@ -361,7 +385,7 @@ func getServiceType(serviceType interface{}) string { func (f *Flow) queryWallet( pd *presexch.PresentationDefinition, vpFormat *presexch.Format, -) (*verifiable.Presentation, error) { +) ([]*verifiable.Presentation, *presexch.PresentationSubmission, error) { slog.Info("Querying wallet") start := time.Now() @@ -371,19 +395,19 @@ func (f *Flow) queryWallet( b, err := json.Marshal(pd) if err != nil { - return nil, fmt.Errorf("marshal presentation definition: %w", err) + return nil, nil, fmt.Errorf("marshal presentation definition: %w", err) } - presentations, err := f.wallet.Query(b, vpFormat.JwtVP != nil) + presentations, submission, err := f.wallet.Query(b, vpFormat.JwtVP != nil, f.useMultiVPs) if err != nil { - return nil, err + return nil, nil, err } if len(presentations) == 0 || len(presentations[0].Credentials()) == 0 { - return nil, fmt.Errorf("no matching credentials found") + return nil, nil, fmt.Errorf("no matching credentials found") } - return presentations[0], nil + return presentations, submission, nil } func sameDIDWebDomain(did1, did2 string) bool { @@ -401,7 +425,8 @@ func sameDIDWebDomain(did1, did2 string) bool { func (f *Flow) sendAuthorizationResponse( ctx context.Context, requestObject *RequestObject, - vp *verifiable.Presentation, + presentations []*verifiable.Presentation, + presentationSubmission *presexch.PresentationSubmission, attestationRequired bool, ) error { slog.Info("Sending authorization response", @@ -410,25 +435,7 @@ func (f *Flow) sendAuthorizationResponse( start := time.Now() - presentationSubmission, ok := vp.CustomFields["presentation_submission"].(*presexch.PresentationSubmission) - if !ok { - return fmt.Errorf("missing or invalid presentation_submission") - } - - vpFormats := requestObject.ClientMetadata.VPFormats - - for i := range presentationSubmission.DescriptorMap { - if vpFormats.JwtVP != nil { - presentationSubmission.DescriptorMap[i].Format = "jwt_vp" - } else if vpFormats.LdpVP != nil { - presentationSubmission.DescriptorMap[i].Format = "ldp_vp" - } - } - - vpToken, err := f.createVPToken(vp, requestObject) - if err != nil { - return fmt.Errorf("create vp token: %w", err) - } + v := url.Values{} idToken, err := f.createIDToken( ctx, @@ -439,17 +446,31 @@ func (f *Flow) sendAuthorizationResponse( return fmt.Errorf("create id token: %w", err) } + v.Add("id_token", idToken) + + vpTokens, err := f.createVPToken(presentations, requestObject) + if err != nil { + return fmt.Errorf("create vp token: %w", err) + } + + if len(vpTokens) == 1 { + v.Add("vp_token", vpTokens[0]) + } else { + b, marshalErr := json.Marshal(vpTokens) + if marshalErr != nil { + return fmt.Errorf("marshal vp tokens: %w", marshalErr) + } + + v.Add("vp_token", string(b)) + } + presentationSubmissionJSON, err := json.Marshal(presentationSubmission) if err != nil { return fmt.Errorf("marshal presentation submission: %w", err) } - v := url.Values{ - "id_token": {idToken}, - "vp_token": {vpToken}, - "presentation_submission": {string(presentationSubmissionJSON)}, - "state": {requestObject.State}, - } + v.Add("presentation_submission", string(presentationSubmissionJSON)) + v.Add("state", requestObject.State) f.perfInfo.CreateAuthorizedResponse = time.Since(start) @@ -457,37 +478,54 @@ func (f *Flow) sendAuthorizationResponse( } func (f *Flow) createVPToken( - presentation *verifiable.Presentation, + presentations []*verifiable.Presentation, requestObject *RequestObject, -) (string, error) { - credential := presentation.Credentials()[0] +) ([]string, error) { + credential := presentations[0].Credentials()[0] subjectDID, err := verifiable.SubjectID(credential.Contents().Subject) if err != nil { - return "", fmt.Errorf("get subject did: %w", err) + return nil, fmt.Errorf("get subject did: %w", err) } vpFormats := requestObject.ClientMetadata.VPFormats - switch { - case vpFormats.JwtVP != nil: - return f.signPresentationJWT( - presentation, - subjectDID, - requestObject.ClientID, - requestObject.Nonce, - ) - case vpFormats.LdpVP != nil: - return f.signPresentationLDP( - presentation, - vcs.SignatureType(vpFormats.LdpVP.ProofType[0]), - subjectDID, - requestObject.ClientID, - requestObject.Nonce, + var vpTokens []string + + for _, presentation := range presentations { + var ( + vpToken string + signErr error ) - default: - return "", fmt.Errorf("no supported vp formats: %v", vpFormats) + + switch { + case vpFormats.JwtVP != nil: + if vpToken, signErr = f.signPresentationJWT( + presentation, + subjectDID, + requestObject.ClientID, + requestObject.Nonce, + ); signErr != nil { + return nil, signErr + } + case vpFormats.LdpVP != nil: + if vpToken, signErr = f.signPresentationLDP( + presentation, + vcs.SignatureType(vpFormats.LdpVP.ProofType[0]), + subjectDID, + requestObject.ClientID, + requestObject.Nonce, + ); signErr != nil { + return nil, signErr + } + default: + return nil, fmt.Errorf("unsupported vp formats: %v", vpFormats) + } + + vpTokens = append(vpTokens, vpToken) } + + return vpTokens, nil } func (f *Flow) signPresentationJWT( @@ -743,6 +781,7 @@ type options struct { enableLinkedDomainVerification bool disableDomainMatching bool disableSchemaValidation bool + useMultiVPs bool } type Opt func(opts *options) @@ -776,3 +815,9 @@ func WithSchemaValidationDisabled() Opt { opts.disableSchemaValidation = true } } + +func WithMultiVPs() Opt { + return func(opts *options) { + opts.useMultiVPs = true + } +} diff --git a/component/wallet-cli/pkg/wallet/wallet.go b/component/wallet-cli/pkg/wallet/wallet.go index 8edaddb1a..6ede72af7 100644 --- a/component/wallet-cli/pkg/wallet/wallet.go +++ b/component/wallet-cli/pkg/wallet/wallet.go @@ -423,25 +423,29 @@ func (w *Wallet) GetAll() (map[string]json.RawMessage, error) { } // Query runs the given presentation definition on the stored credentials. -func (w *Wallet) Query(pdBytes []byte, jwtVPFormat bool) ([]*verifiable.Presentation, error) { +func (w *Wallet) Query( + pdBytes []byte, + jwtVPFormat bool, + useMultiVPs bool, +) ([]*verifiable.Presentation, *presexch.PresentationSubmission, error) { vcContent, err := w.GetAll() if err != nil { - return nil, fmt.Errorf("query credentials: %w", err) + return nil, nil, fmt.Errorf("query credentials: %w", err) } if len(vcContent) == 0 { - return nil, fmt.Errorf("no credentials found in wallet") + return nil, nil, fmt.Errorf("no credentials found in wallet") } credentials, err := parseCredentialContents(vcContent, w.documentLoader) if err != nil { - return nil, err + return nil, nil, err } var pd presexch.PresentationDefinition if err = json.Unmarshal(pdBytes, &pd); err != nil { - return nil, err + return nil, nil, err } opts := []presexch.MatchRequirementsOpt{ @@ -455,16 +459,36 @@ func (w *Wallet) Query(pdBytes []byte, jwtVPFormat bool) ([]*verifiable.Presenta opts = append(opts, presexch.WithDefaultPresentationFormat(presexch.FormatJWTVP)) } - vp, err := pd.CreateVP(credentials, w.documentLoader, opts...) + if useMultiVPs { + vps, presentationSubmission, createErr := pd.CreateVPArray(credentials, w.documentLoader, opts...) + if createErr != nil { + if errors.Is(createErr, presexch.ErrNoCredentials) { + return nil, nil, fmt.Errorf("no matching credentials found") + } + + return nil, nil, createErr + } + + return vps, presentationSubmission, nil + } + + var vp *verifiable.Presentation + + vp, err = pd.CreateVP(credentials, w.documentLoader, opts...) if err != nil { if errors.Is(err, presexch.ErrNoCredentials) { - return nil, fmt.Errorf("no matching credentials found") + return nil, nil, fmt.Errorf("no matching credentials found") } - return nil, err + return nil, nil, err + } + + presentationSubmission, ok := vp.CustomFields["presentation_submission"].(*presexch.PresentationSubmission) + if !ok { + return nil, nil, fmt.Errorf("missing or invalid presentation_submission") } - return []*verifiable.Presentation{vp}, nil + return []*verifiable.Presentation{vp}, presentationSubmission, nil } func parseCredentialContents(m map[string]json.RawMessage, loader ld.DocumentLoader) ([]*verifiable.Credential, error) { diff --git a/test/bdd/features/oidc4vc_api.feature b/test/bdd/features/oidc4vc_api.feature index 2e9fbd3b9..c9d7cf490 100644 --- a/test/bdd/features/oidc4vc_api.feature +++ b/test/bdd/features/oidc4vc_api.feature @@ -400,3 +400,18 @@ Feature: OIDC4VC REST API And Verifier with profile "v_myprofile_jwt_client_attestation/v1.0" retrieves interactions claims Then we wait 2 seconds And Verifier with profile "v_myprofile_jwt_client_attestation/v1.0" requests deleted interactions claims + + @oidc4vc_rest_multi_vp + Scenario: OIDC credential pre-authorized code flow issuance and verification with multiple VPs + Given Profile "bank_issuer/v1.0" issuer has been authorized with username "profile-user-issuer-1" and password "profile-user-issuer-1-pwd" + And User holds credential "UniversityDegreeCredential,VerifiedEmployee" with templateID "nil" + And User wants to make credentials request based on credential offer "false" + And Profile "v_myprofile_multivp_jwt/v1.0" verifier has been authorized with username "profile-user-verifier-1" and password "profile-user-verifier-1-pwd" + + When User interacts with Wallet to initiate batch credential issuance using pre authorization code flow + Then "2" credentials are issued + Then expected credential count for vp flow is "2" + Then User interacts with Verifier and initiate OIDC4VP interaction under "v_myprofile_multivp_jwt/v1.0" profile with presentation definition ID "8bc45260-ed00-4c23-a32a-b70e5aef3d92" and fields "degree_type_id,verified_employee_id" using multi vps + And Verifier with profile "v_myprofile_multivp_jwt/v1.0" retrieves interactions claims + Then we wait 2 seconds + And Verifier with profile "v_myprofile_multivp_jwt/v1.0" requests deleted interactions claims diff --git a/test/bdd/fixtures/profile/profiles.json b/test/bdd/fixtures/profile/profiles.json index b3d05919e..ee072ce78 100644 --- a/test/bdd/fixtures/profile/profiles.json +++ b/test/bdd/fixtures/profile/profiles.json @@ -2393,12 +2393,12 @@ }, "presentationDefinitions": [ { - "id": "32f54163-7166-48f1-93d8-ff217bdb0654", + "id": "8bc45260-ed00-4c23-a32a-b70e5aef3d92", "input_descriptors": [ { - "id": "type", - "name": "type", - "purpose": "We can only interact with specific status information for Verifiable Credentials", + "id": "degree", + "name": "degree", + "purpose": "We can only hire with bachelor degree.", "schema": [ { "uri": "https://www.w3.org/2018/credentials#VerifiableCredential" @@ -2408,42 +2408,37 @@ "fields": [ { "path": [ - "$.credentialStatus.type", - "$.vc.credentialStatus.type" + "$.credentialSubject.degree.type", + "$.vc.credentialSubject.degree.type" ], - "purpose": "We can only interact with specific status information for Verifiable Credentials", + "id": "degree_type_id", + "purpose": "We can only hire with bachelor degree.", "filter": { "type": "string", - "enum": [ - "StatusList2021Entry", - "RevocationList2021Status", - "RevocationList2020Status" - ] + "const": "BachelorDegree" } } ] } }, { - "id": "degree", - "name": "degree", - "purpose": "We can only hire with bachelor degree.", - "schema": [ - { - "uri": "https://www.w3.org/2018/credentials#VerifiableCredential" - } - ], + "id": "verified employee", + "name": "verified employee", "constraints": { "fields": [ { "path": [ - "$.credentialSubject.degree.type", - "$.vc.credentialSubject.degree.type" + "$.type", + "$.vc.type" ], - "purpose": "We can only hire with bachelor degree.", + "id": "verified_employee_id", + "purpose": "We can only work with verified employee.", "filter": { - "type": "string", - "const": "BachelorDegree" + "type": "array", + "contains": { + "const": "VerifiedEmployee", + "type": "string" + } } } ] diff --git a/test/bdd/pkg/v1/oidc4vc/oidc4vp.go b/test/bdd/pkg/v1/oidc4vc/oidc4vp.go index 694afa142..526792c75 100644 --- a/test/bdd/pkg/v1/oidc4vc/oidc4vp.go +++ b/test/bdd/pkg/v1/oidc4vc/oidc4vp.go @@ -231,15 +231,19 @@ func (s *Steps) validateRetrievedCredentialClaims(claims retrievedCredentialClai } func (s *Steps) runOIDC4VPFlow(profileVersionedID, pdID, fields string) error { - return s.runOIDC4VPFlowWithOpts(profileVersionedID, pdID, fields, nil) + return s.runOIDC4VPFlowWithOpts(profileVersionedID, pdID, fields, nil, false) } func (s *Steps) runOIDC4VPFlowWithCustomScopes(profileVersionedID, pdID, fields, customScopes string) error { - return s.runOIDC4VPFlowWithOpts(profileVersionedID, pdID, fields, strings.Split(customScopes, ",")) + return s.runOIDC4VPFlowWithOpts(profileVersionedID, pdID, fields, strings.Split(customScopes, ","), false) +} + +func (s *Steps) runOIDC4VPFlowWithMultiVPs(profileVersionedID, pdID, fields string) error { + return s.runOIDC4VPFlowWithOpts(profileVersionedID, pdID, fields, nil, true) } func (s *Steps) runOIDC4VPFlowWithError(profileVersionedID, pdID, fields, errorContains string) error { - err := s.runOIDC4VPFlowWithOpts(profileVersionedID, pdID, fields, nil) + err := s.runOIDC4VPFlowWithOpts(profileVersionedID, pdID, fields, nil, false) if err == nil { return errors.New("error expected") } @@ -251,7 +255,11 @@ func (s *Steps) runOIDC4VPFlowWithError(profileVersionedID, pdID, fields, errorC return nil } -func (s *Steps) runOIDC4VPFlowWithOpts(profileVersionedID, pdID, fields string, scopes []string) error { +func (s *Steps) runOIDC4VPFlowWithOpts( + profileVersionedID, pdID, fields string, + scopes []string, + useMultiVPs bool, +) error { s.verifierProfile = s.bddContext.VerifierProfiles[profileVersionedID] s.presentationDefinitionID = pdID @@ -278,11 +286,17 @@ func (s *Steps) runOIDC4VPFlowWithOpts(profileVersionedID, pdID, fields string, return fmt.Errorf("invalid AuthorizationRequest format: %s", initiateInteractionResult.AuthorizationRequest) } - flow, err := oidc4vp.NewFlow(s.oidc4vpProvider, + opts := []oidc4vp.Opt{ oidc4vp.WithRequestURI(requestURI[1]), oidc4vp.WithDomainMatchingDisabled(), oidc4vp.WithSchemaValidationDisabled(), - ) + } + + if useMultiVPs { + opts = append(opts, oidc4vp.WithMultiVPs()) + } + + flow, err := oidc4vp.NewFlow(s.oidc4vpProvider, opts...) if err != nil { return fmt.Errorf("init flow: %w", err) } diff --git a/test/bdd/pkg/v1/oidc4vc/steps.go b/test/bdd/pkg/v1/oidc4vc/steps.go index b715c2d8c..c9e7fa9ff 100644 --- a/test/bdd/pkg/v1/oidc4vc/steps.go +++ b/test/bdd/pkg/v1/oidc4vc/steps.go @@ -120,6 +120,7 @@ func (s *Steps) RegisterSteps(sc *godog.ScenarioContext) { // OIDC4VP sc.Step(`^User interacts with Verifier and initiate OIDC4VP interaction under "([^"]*)" profile with presentation definition ID "([^"]*)" and fields "([^"]*)"$`, s.runOIDC4VPFlow) + sc.Step(`^User interacts with Verifier and initiate OIDC4VP interaction under "([^"]*)" profile with presentation definition ID "([^"]*)" and fields "([^"]*)" using multi vps$`, s.runOIDC4VPFlowWithMultiVPs) sc.Step(`^User interacts with Verifier and initiate OIDC4VP interaction under "([^"]*)" profile with presentation definition ID "([^"]*)" and fields "([^"]*)" and custom scopes "([^"]*)"$`, s.runOIDC4VPFlowWithCustomScopes) sc.Step(`^Verifier with profile "([^"]*)" retrieves interactions claims$`, s.retrieveInteractionsClaim) sc.Step(`^Verifier with profile "([^"]*)" retrieves interactions claims with additional claims associated with custom scopes "([^"]*)"$`, s.retrieveInteractionsClaimWithCustomScopes)