From 031b0d914e98bc5bae1739b6a209defac6ae5d23 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 24 Feb 2026 17:04:49 +0100 Subject: [PATCH 01/44] feat(openid4vci): align error codes with v1.0 Section 8.3.1.2 Replace draft-era error codes (unsupported_credential_type, unsupported_credential_format) with the complete set of 7 Credential Endpoint error codes from OpenID4VCI v1.0 Section 8.3.1.2. --- vcr/openid4vci/error.go | 48 +++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/vcr/openid4vci/error.go b/vcr/openid4vci/error.go index 6c61c3dbc1..212f116987 100644 --- a/vcr/openid4vci/error.go +++ b/vcr/openid4vci/error.go @@ -22,32 +22,42 @@ package openid4vci type ErrorCode string const ( - // InvalidRequest is returned when: - // - the Authorization Server does not expect a PIN in the pre-authorized flow but the client provides a PIN - // - the Authorization Server expects a PIN in the pre-authorized flow but the client does not provide a PIN - // - Credential Request was malformed. One or more of the parameters (i.e. format, proof) are missing or malformed. + // OAuth2 Token Endpoint error codes (RFC 6749) + + // InvalidRequest is an OAuth2 error for malformed token requests. InvalidRequest ErrorCode = "invalid_request" - // InvalidClient is returned when: - // - the client tried to send a Token Request with a Pre-Authorized Code without Client ID but the Authorization Server does not support anonymous access + // InvalidClient is returned when the client is not authorized. InvalidClient ErrorCode = "invalid_client" - // InvalidGrant is returned when (in addition to cases defined by OAuth2): - // - the Authorization Server expects a PIN in the pre-authorized flow but the client provides the wrong PIN - // - the End-User provides the wrong Pre-Authorized Code or the Pre-Authorized Code has expired + // InvalidGrant is returned when the grant (e.g. pre-authorized code) is invalid or expired. InvalidGrant ErrorCode = "invalid_grant" - // InvalidToken is returned when (in addition to cases defined by OAuth2): - // - Credential Request contains the wrong Access Token or the Access Token is missing + // InvalidToken is returned when the access token is invalid or missing (RFC 6750). InvalidToken ErrorCode = "invalid_token" - // UnsupportedGrantType is returned when the Authorization Server does not support the requested grant type. + // UnsupportedGrantType is returned when the requested grant type is not supported. UnsupportedGrantType ErrorCode = "unsupported_grant_type" - // ServerError is returned when the Authorization Server encounters an unexpected condition that prevents it from fulfilling the request. + // ServerError is returned when the server encounters an unexpected condition. ServerError ErrorCode = "server_error" - // UnsupportedCredentialType is returned when the credential issuer does not support the requested credential type. - UnsupportedCredentialType ErrorCode = "unsupported_credential_type" - // UnsupportedCredentialFormat is returned when the credential issuer does not support the requested credential format. - UnsupportedCredentialFormat ErrorCode = "unsupported_credential_format" - // InvalidProof is returned when the Credential Request did not contain a proof, - // or proof was invalid, i.e. it was not bound to a Credential Issuer provided nonce + + // OpenID4VCI v1.0 Credential Endpoint error codes (Section 8.3.1.2) + + // InvalidCredentialRequest is returned when the Credential Request is missing a required parameter, + // includes an unsupported parameter or parameter value, or is otherwise malformed. + InvalidCredentialRequest ErrorCode = "invalid_credential_request" + // UnknownCredentialConfiguration is returned when the requested credential_configuration_id is unknown. + UnknownCredentialConfiguration ErrorCode = "unknown_credential_configuration" + // UnknownCredentialIdentifier is returned when the requested credential_identifier is unknown. + UnknownCredentialIdentifier ErrorCode = "unknown_credential_identifier" + // InvalidProof is returned when the proofs parameter is invalid: missing, one of the key proofs + // is invalid, or a key proof does not contain a c_nonce value. InvalidProof ErrorCode = "invalid_proof" + // InvalidNonce is returned when at least one of the key proofs contains an invalid c_nonce value. + // The wallet should retrieve a new c_nonce value from the Nonce Endpoint (Section 7). + InvalidNonce ErrorCode = "invalid_nonce" + // InvalidEncryptionParameters is returned when the encryption parameters in the Credential Request + // are either invalid or missing when the issuer requires encrypted responses. + InvalidEncryptionParameters ErrorCode = "invalid_encryption_parameters" + // CredentialRequestDenied is returned when the Credential Request has not been accepted by the + // issuer. The wallet SHOULD treat this as unrecoverable. + CredentialRequestDenied ErrorCode = "credential_request_denied" ) // Error is an error that signals the error was (probably) caused by the client (e.g. bad request), From 7eb1c9ca4e999d794fb0ff70e1581def29cc3ad0 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 24 Feb 2026 17:06:30 +0100 Subject: [PATCH 02/44] feat(openid4vci): update types and issuer for v1.0 metadata and offer Core structural changes for OpenID4VCI v1.0 alignment: - Metadata uses credential_configurations_supported map keyed by credential_configuration_id (replaces credentials_supported array) - Credential offers reference configuration IDs instead of inline credential definitions (credential_configuration_ids field) - Typed grant structs replace untyped maps in offers - Credential requests use credential_configuration_id - Issuer matches credentials to configurations via findCredentialConfigID - Config IDs generated as {CredentialType}_{format} - InvalidNonce used for nonce errors (was InvalidProof in draft) - server_error returns HTTP 500 (was incorrectly 400) --- .../NutsAuthorizationCredential.json | 2 +- .../NutsOrganizationCredential.json | 2 +- vcr/issuer/openid.go | 252 +++++++++--- vcr/issuer/openid_test.go | 272 ++++++++++--- vcr/issuer/test/valid/ExampleCredential.json | 2 +- vcr/openid4vci/issuer_client.go | 4 +- vcr/openid4vci/issuer_client_test.go | 6 +- vcr/openid4vci/test.go | 4 +- vcr/openid4vci/types.go | 51 ++- vcr/openid4vci/types_test.go | 379 ++++++++++++++++++ vcr/openid4vci/validators.go | 11 +- vcr/openid4vci/validators_test.go | 2 +- vcr/openid4vci/wallet_client_test.go | 36 +- 13 files changed, 854 insertions(+), 169 deletions(-) create mode 100644 vcr/openid4vci/types_test.go diff --git a/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json b/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json index 5ffa686f32..2118108624 100644 --- a/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json +++ b/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json @@ -6,7 +6,7 @@ "credential_definition": { "@context": [ "https://www.w3.org/2018/credentials/v1", - "https://www.nuts.nl/credentials/v1" + "https://nuts.nl/credentials/v1" ], "type": [ "VerifiableCredential", diff --git a/vcr/issuer/assets/definitions/NutsOrganizationCredential.json b/vcr/issuer/assets/definitions/NutsOrganizationCredential.json index f2482124bc..17c33f361a 100644 --- a/vcr/issuer/assets/definitions/NutsOrganizationCredential.json +++ b/vcr/issuer/assets/definitions/NutsOrganizationCredential.json @@ -6,7 +6,7 @@ "credential_definition": { "@context": [ "https://www.w3.org/2018/credentials/v1", - "https://www.nuts.nl/credentials/v1" + "https://nuts.nl/credentials/v1" ], "type": [ "VerifiableCredential", diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index c221083194..65b877f3c6 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -114,14 +114,14 @@ func NewOpenIDHandler(issuerDID did.DID, issuerIdentifierURL string, definitions } type openidHandler struct { - issuerIdentifierURL string - issuerDID did.DID - definitionsDIR string - credentialsSupported []map[string]interface{} - keyResolver resolver.KeyResolver - store OpenIDStore - walletClientCreator func(ctx context.Context, httpClient core.HTTPRequestDoer, walletMetadataURL string) (openid4vci.WalletAPIClient, error) - httpClient core.HTTPRequestDoer + issuerIdentifierURL string + issuerDID did.DID + definitionsDIR string + credentialConfigurationsSupported map[string]map[string]interface{} + keyResolver resolver.KeyResolver + store OpenIDStore + walletClientCreator func(ctx context.Context, httpClient core.HTTPRequestDoer, walletMetadataURL string) (openid4vci.WalletAPIClient, error) + httpClient core.HTTPRequestDoer } func (i *openidHandler) Metadata() openid4vci.CredentialIssuerMetadata { @@ -130,8 +130,8 @@ func (i *openidHandler) Metadata() openid4vci.CredentialIssuerMetadata { CredentialEndpoint: core.JoinURLPaths(i.issuerIdentifierURL, "/openid4vci/credential"), } - // deepcopy the i.credentialsSupported slice to prevent concurrent access to the slice. - metadata.CredentialsSupported = deepcopy(i.credentialsSupported) + // deepcopy the credentialConfigurationsSupported map to prevent concurrent access. + metadata.CredentialConfigurationsSupported = deepcopyMap(i.credentialConfigurationsSupported) return metadata } @@ -214,20 +214,16 @@ func (i *openidHandler) OfferCredential(ctx context.Context, credential vc.Verif } func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request openid4vci.CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) { - if request.Format != vc.JSONLDCredentialProofFormat { + // v1.0 Section 8.2 allows credential_configuration_id, credential_identifier, or format-based requests. + // This implementation only accepts credential_configuration_id as a policy choice. + if request.CredentialConfigurationId == "" { return nil, openid4vci.Error{ - Err: fmt.Errorf("credential request: unsupported format '%s'", request.Format), - Code: openid4vci.UnsupportedCredentialType, - StatusCode: http.StatusBadRequest, - } - } - if err := request.CredentialDefinition.Validate(false); err != nil { - return nil, openid4vci.Error{ - Err: fmt.Errorf("credential request: %w", err), - Code: openid4vci.InvalidRequest, + Err: errors.New("credential request must contain credential_configuration_id"), + Code: openid4vci.InvalidCredentialRequest, StatusCode: http.StatusBadRequest, } } + flow, err := i.store.FindByReference(ctx, accessTokenRefType, accessToken) if err != nil { return nil, err @@ -237,34 +233,42 @@ func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request ope return nil, openid4vci.Error{ Err: errors.New("unknown access token"), Code: openid4vci.InvalidToken, - StatusCode: http.StatusBadRequest, + StatusCode: http.StatusUnauthorized, } } credential := flow.Credentials[0] // there's always just one (at least for now) subjectDID, _ := credential.SubjectDID() - // check credential.Issuer against given issuer if credential.Issuer.String() != i.issuerDID.String() { return nil, openid4vci.Error{ Err: errors.New("credential issuer does not match given issuer"), - Code: openid4vci.InvalidRequest, + Code: openid4vci.InvalidCredentialRequest, StatusCode: http.StatusBadRequest, } } - if err = i.validateProof(ctx, flow, request); err != nil { - return nil, err + // Validate the credential_configuration_id matches what was offered + expectedConfigID, err := i.findCredentialConfigID(credential) + if err != nil { + return nil, openid4vci.Error{ + Err: fmt.Errorf("credential has no matching configuration: %w", err), + Code: openid4vci.UnknownCredentialConfiguration, + StatusCode: http.StatusBadRequest, + } } - - if err = openid4vci.ValidateDefinitionWithCredential(credential, *request.CredentialDefinition); err != nil { + if request.CredentialConfigurationId != expectedConfigID { return nil, openid4vci.Error{ - Err: fmt.Errorf("requested credential does not match offer: %w", err), - Code: openid4vci.InvalidRequest, + Err: fmt.Errorf("credential_configuration_id '%s' does not match offered '%s'", request.CredentialConfigurationId, expectedConfigID), + Code: openid4vci.UnknownCredentialConfiguration, StatusCode: http.StatusBadRequest, } } + if err = i.validateProof(ctx, flow, request); err != nil { + return nil, err + } + // Important: since we (for now) create the VC even before the wallet requests it, we don't know if every VC is actually retrieved by the wallet. // This is a temporary shortcut, since changing that requires a lot of refactoring. // To make actually retrieved VC traceable, we log it to the audit log. @@ -284,7 +288,7 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o credential := flow.Credentials[0] // there's always just one (at least for now) wallet, _ := credential.SubjectDID() - // augment invalid_proof errors according to §7.3.2 of openid4vci spec + // augment invalid_proof errors according to Section 8.3.2 of openid4vci spec generateProofError := func(err openid4vci.Error) error { cnonce := crypto.GenerateNonce() if err := i.store.StoreReference(ctx, flow.ID, cNonceRefType, cnonce); err != nil { @@ -364,38 +368,36 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o return err } if flowFromNonce == nil { - return openid4vci.Error{ + return generateProofError(openid4vci.Error{ Err: errors.New("unknown nonce"), - Code: openid4vci.InvalidProof, + Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest, - } + }) } if flowFromNonce.ID != flow.ID { - return openid4vci.Error{ + return generateProofError(openid4vci.Error{ Err: errors.New("nonce not valid for access token"), - Code: openid4vci.InvalidProof, + Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest, - } + }) } return nil } func (i *openidHandler) createOffer(ctx context.Context, credential vc.VerifiableCredential, preAuthorizedCode string) (*openid4vci.CredentialOffer, error) { - grantParams := map[string]interface{}{ - "pre-authorized_code": preAuthorizedCode, + credentialConfigID, err := i.findCredentialConfigID(credential) + if err != nil { + return nil, fmt.Errorf("unable to create credential offer: %w", err) } + offer := openid4vci.CredentialOffer{ - CredentialIssuer: i.issuerIdentifierURL, - Credentials: []openid4vci.OfferedCredential{{ - Format: vc.JSONLDCredentialProofFormat, - CredentialDefinition: &openid4vci.CredentialDefinition{ - Context: credential.Context, - Type: credential.Type, + CredentialIssuer: i.issuerIdentifierURL, + CredentialConfigurationIds: []string{credentialConfigID}, + Grants: openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: preAuthorizedCode, }, - }}, - Grants: map[string]interface{}{ - openid4vci.PreAuthorizedCodeGrant: grantParams, }, } subjectDID, _ := credential.SubjectDID() // succeeded in previous step, can't fail @@ -407,12 +409,14 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl Credentials: []vc.VerifiableCredential{credential}, Grants: []Grant{ { - Type: openid4vci.PreAuthorizedCodeGrant, - Params: grantParams, + Type: openid4vci.PreAuthorizedCodeGrant, + Params: map[string]interface{}{ + "pre-authorized_code": preAuthorizedCode, + }, }, }, } - err := i.store.Store(ctx, flow) + err = i.store.Store(ctx, flow) if err == nil { err = i.store.StoreReference(ctx, flow.ID, preAuthCodeRefType, preAuthorizedCode) } @@ -423,8 +427,20 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl } func (i *openidHandler) loadCredentialDefinitions() error { + i.credentialConfigurationsSupported = make(map[string]map[string]interface{}) + + addDefinition := func(source string, definitionMap map[string]interface{}) error { + configID, err := generateCredentialConfigID(definitionMap) + if err != nil { + return fmt.Errorf("invalid credential definition from %s: %w", source, err) + } + if _, exists := i.credentialConfigurationsSupported[configID]; exists { + return fmt.Errorf("duplicate credential_configuration_id '%s' from %s", configID, source) + } + i.credentialConfigurationsSupported[configID] = definitionMap + return nil + } - // retrieve the definitions from assets and add to the list of CredentialsSupported definitionsDir, err := assets.FS.ReadDir("definitions") if err != nil { return err @@ -439,10 +455,11 @@ func (i *openidHandler) loadCredentialDefinitions() error { if err != nil { return err } - i.credentialsSupported = append(i.credentialsSupported, definitionMap) + if err := addDefinition("assets/"+definition.Name(), definitionMap); err != nil { + return err + } } - // now add all credential definition from config.DefinitionsDIR if i.definitionsDIR != "" { err = filepath.WalkDir(i.definitionsDIR, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -458,7 +475,9 @@ func (i *openidHandler) loadCredentialDefinitions() error { if err != nil { return fmt.Errorf("failed to parse credential definition from %s: %w", path, err) } - i.credentialsSupported = append(i.credentialsSupported, definitionMap) + if err := addDefinition(path, definitionMap); err != nil { + return err + } } return nil }) @@ -467,13 +486,124 @@ func (i *openidHandler) loadCredentialDefinitions() error { return err } -func deepcopy(src []map[string]interface{}) []map[string]interface{} { - dst := make([]map[string]interface{}, len(src)) - for i := range src { - dst[i] = make(map[string]interface{}) - for k, v := range src[i] { - dst[i][k] = v - } +func deepcopyMap(src map[string]map[string]interface{}) map[string]map[string]interface{} { + // Safe to ignore errors: src is always built from JSON-deserialized data. + data, err := json.Marshal(src) + if err != nil { + panic("deepcopyMap: marshal failed: " + err.Error()) + } + var dst map[string]map[string]interface{} + if err = json.Unmarshal(data, &dst); err != nil { + panic("deepcopyMap: unmarshal failed: " + err.Error()) } return dst } + +// generateCredentialConfigID generates a credential_configuration_id from a credential definition. +// The ID is formed as "{MostSpecificType}_{format}" (e.g., "NutsOrganizationCredential_ldp_vc"). +// Returns an error if the definition is missing required fields to generate a unique ID. +func generateCredentialConfigID(definitionMap map[string]interface{}) (string, error) { + format, _ := definitionMap["format"].(string) + if format == "" { + return "", errors.New("credential definition missing 'format' field") + } + credDef, ok := definitionMap["credential_definition"].(map[string]interface{}) + if !ok { + return "", errors.New("credential definition missing 'credential_definition' field") + } + + types, ok := credDef["type"].([]interface{}) + if !ok || len(types) == 0 { + return "", errors.New("credential definition missing 'type' field") + } + + // Find the most specific type (typically the last one, excluding VerifiableCredential) + var specificType string + for _, t := range types { + if typeStr, ok := t.(string); ok && typeStr != "VerifiableCredential" { + specificType = typeStr + } + } + if specificType == "" { + specificType = "VerifiableCredential" + } + + return specificType + "_" + format, nil +} + +// findCredentialConfigID finds the credential configuration ID for the given credential +// by matching it against the loaded credential_configurations_supported. +// Returns an error if no matching configuration is found, since credential_configuration_ids +// in offers MUST reference entries in credential_configurations_supported (Section 4.1.1). +func (i *openidHandler) findCredentialConfigID(credential vc.VerifiableCredential) (string, error) { + for configID, config := range i.credentialConfigurationsSupported { + if matchesCredential(config, credential) { + return configID, nil + } + } + return "", fmt.Errorf("no matching credential configuration for type %s", credential.Type) +} + +// matchesCredential checks if a credential configuration matches the given credential +// by comparing format, type, and @context. +// Type matching is exact (count must be equal). Context matching is a subset check: +// all config contexts must appear in the credential, but the credential may have additional +// contexts (e.g., proof-related contexts added during signing). +func matchesCredential(config map[string]interface{}, credential vc.VerifiableCredential) bool { + format, _ := config["format"].(string) + if format != vc.JSONLDCredentialProofFormat { + return false + } + + credDef, ok := config["credential_definition"].(map[string]interface{}) + if !ok { + return false + } + + types, ok := credDef["type"].([]interface{}) + if !ok { + return false + } + if len(types) != len(credential.Type) { + return false + } + for _, configType := range types { + typeStr, ok := configType.(string) + if !ok { + continue + } + found := false + for _, credType := range credential.Type { + if credType.String() == typeStr { + found = true + break + } + } + if !found { + return false + } + } + + contexts, ok := credDef["@context"].([]interface{}) + if !ok { + return false + } + for _, configCtx := range contexts { + ctxStr, ok := configCtx.(string) + if !ok { + continue + } + found := false + for _, credCtx := range credential.Context { + if credCtx.String() == ctxStr { + found = true + break + } + } + if !found { + return false + } + } + + return true +} diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index 62ca9e0b6e..4461c4c930 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -34,6 +34,8 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "net/http" + "os" + "path/filepath" "testing" "time" ) @@ -54,11 +56,11 @@ var issuedVC = vc.VerifiableCredential{ }, Context: []ssi.URI{ ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - ssi.MustParseURI("http://example.org/credentials/V1"), + ssi.MustParseURI("https://example.com/credentials/v1"), }, Type: []ssi.URI{ ssi.MustParseURI("VerifiableCredential"), - ssi.MustParseURI("HumanCredential"), + ssi.MustParseURI("ExampleCredential"), }, } @@ -67,7 +69,7 @@ func TestNew(t *testing.T) { iss, err := NewOpenIDHandler(issuerDID, issuerIdentifier, "./test/valid", nil, nil, storage.NewTestInMemorySessionDatabase(t)) require.NoError(t, err) - assert.Len(t, iss.(*openidHandler).credentialsSupported, 3) + assert.Len(t, iss.(*openidHandler).credentialConfigurationsSupported, 3) }) t.Run("error - invalid json", func(t *testing.T) { @@ -93,15 +95,44 @@ func Test_memoryIssuer_Metadata(t *testing.T) { assert.Equal(t, "https://example.com/did:nuts:issuer", metadata.CredentialIssuer) assert.Equal(t, "https://example.com/did:nuts:issuer/openid4vci/credential", metadata.CredentialEndpoint) - require.Len(t, metadata.CredentialsSupported, 3) - assert.Equal(t, "ldp_vc", metadata.CredentialsSupported[0]["format"]) - require.Len(t, metadata.CredentialsSupported[0]["cryptographic_binding_methods_supported"], 1) - assert.Equal(t, metadata.CredentialsSupported[0]["credential_definition"], + require.Len(t, metadata.CredentialConfigurationsSupported, 3) + // Assert all 3 config IDs by name + for _, expectedID := range []string{ + "NutsAuthorizationCredential_ldp_vc", + "NutsOrganizationCredential_ldp_vc", + "ExampleCredential_ldp_vc", + } { + _, ok := metadata.CredentialConfigurationsSupported[expectedID] + assert.True(t, ok, "expected config ID %s to be present", expectedID) + } + // Spot-check NutsAuthorizationCredential details + authCredConfig := metadata.CredentialConfigurationsSupported["NutsAuthorizationCredential_ldp_vc"] + assert.Equal(t, "ldp_vc", authCredConfig["format"]) + require.Len(t, authCredConfig["cryptographic_binding_methods_supported"], 1) + assert.Equal(t, authCredConfig["credential_definition"], map[string]interface{}{ - "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://www.nuts.nl/credentials/v1"}, + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"}, "type": []interface{}{"VerifiableCredential", "NutsAuthorizationCredential"}, }) }) + t.Run("duplicate credential_configuration_id from external dir is rejected", func(t *testing.T) { + // Create a temp dir with a definition that duplicates a built-in config ID + tmpDir := t.TempDir() + duplicateDef := `{ + "format": "ldp_vc", + "cryptographic_binding_methods_supported": ["did:nuts"], + "credential_definition": { + "@context": ["https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"], + "type": ["VerifiableCredential", "NutsOrganizationCredential"] + } + }` + err := os.WriteFile(filepath.Join(tmpDir, "duplicate.json"), []byte(duplicateDef), 0644) + require.NoError(t, err) + + _, err = NewOpenIDHandler(issuerDID, issuerIdentifier, tmpDir, &http.Client{}, nil, storage.NewTestInMemorySessionDatabase(t)) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate credential_configuration_id 'NutsOrganizationCredential_ldp_vc'") + }) } func Test_memoryIssuer_ProviderMetadata(t *testing.T) { @@ -135,36 +166,30 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { "nonce": nonce, } } - createRequest := func(headers, claims map[string]interface{}) openid4vci.CredentialRequest { + createProof := func(headers, claims map[string]interface{}) *openid4vci.CredentialRequestProof { proof, err := keyStore.SignJWT(ctx, claims, headers, headers["kid"].(string)) require.NoError(t, err) + return &openid4vci.CredentialRequestProof{ + Jwt: proof, + ProofType: openid4vci.ProofTypeJWT, + } + } + createRequest := func(headers, claims map[string]interface{}, configID string) openid4vci.CredentialRequest { return openid4vci.CredentialRequest{ - Format: vc.JSONLDCredentialProofFormat, - CredentialDefinition: &openid4vci.CredentialDefinition{ - Context: []ssi.URI{ - ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - ssi.MustParseURI("http://example.org/credentials/V1"), - }, - Type: []ssi.URI{ - ssi.MustParseURI("VerifiableCredential"), - ssi.MustParseURI("HumanCredential"), - }, - }, - Proof: &openid4vci.CredentialRequestProof{ - Jwt: proof, - ProofType: openid4vci.ProofTypeJWT, - }, + CredentialConfigurationId: configID, + Proof: createProof(headers, claims), } } const preAuthCode = "some-secret-code" service := requireNewTestHandler(t, keyResolver) - _, err := service.createOffer(ctx, issuedVC, preAuthCode) + offer, err := service.createOffer(ctx, issuedVC, preAuthCode) require.NoError(t, err) accessToken, cNonce, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) - validRequest := createRequest(createHeaders(), createClaims(cNonce)) + configID := offer.CredentialConfigurationIds[0] + validRequest := createRequest(createHeaders(), createClaims(cNonce), configID) t.Run("ok", func(t *testing.T) { auditLogs := audit.CaptureAuditLogs(t) @@ -175,27 +200,28 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { assert.Equal(t, issuerDID.URI(), response.Issuer) auditLogs.AssertContains(t, "VCR", "VerifiableCredentialRetrievedEvent", audit.TestActor, "VC retrieved by wallet over OpenID4VCI") }) - t.Run("unsupported format", func(t *testing.T) { - request := createRequest(createHeaders(), createClaims(cNonce)) - request.Format = "unsupported format" + t.Run("error - missing credential_configuration_id", func(t *testing.T) { + request := openid4vci.CredentialRequest{ + Proof: createProof(createHeaders(), createClaims(cNonce)), + } response, err := service.HandleCredentialRequest(ctx, request, accessToken) assert.Nil(t, response) - assert.EqualError(t, err, "unsupported_credential_type - credential request: unsupported format 'unsupported format'") + assert.EqualError(t, err, "invalid_credential_request - credential request must contain credential_configuration_id") }) - t.Run("invalid credential_definition", func(t *testing.T) { - request := createRequest(createHeaders(), createClaims(cNonce)) - request.CredentialDefinition.Type = []ssi.URI{} + t.Run("error - unknown credential_configuration_id", func(t *testing.T) { + request := createRequest(createHeaders(), createClaims(cNonce), "NonExistent_ldp_vc") response, err := service.HandleCredentialRequest(ctx, request, accessToken) assert.Nil(t, response) - assert.EqualError(t, err, "invalid_request - credential request: invalid credential_definition: missing type field") + require.ErrorAs(t, err, new(openid4vci.Error)) + assert.Equal(t, openid4vci.UnknownCredentialConfiguration, err.(openid4vci.Error).Code) }) t.Run("proof validation", func(t *testing.T) { t.Run("unsupported proof type", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("")) + invalidRequest := createRequest(createHeaders(), createClaims(""), configID) invalidRequest.Proof.ProofType = "not-supported" response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -205,7 +231,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { }) t.Run("jwt", func(t *testing.T) { t.Run("missing proof", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("")) + invalidRequest := createRequest(createHeaders(), createClaims(""), configID) invalidRequest.Proof = nil response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -214,7 +240,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { assert.Nil(t, response) }) t.Run("missing proof returns error with new c_nonce", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("")) + invalidRequest := createRequest(createHeaders(), createClaims(""), configID) invalidRequest.Proof = nil _, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -229,7 +255,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { assert.NotNil(t, flow) }) t.Run("invalid JWT", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("")) + invalidRequest := createRequest(createHeaders(), createClaims(""), configID) invalidRequest.Proof.Jwt = "not a JWT" response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -239,7 +265,9 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { }) t.Run("not signed by intended wallet (DID differs)", func(t *testing.T) { otherIssuedVC := vc.VerifiableCredential{ - Issuer: issuerDID.URI(), + Issuer: issuerDID.URI(), + Context: issuedVC.Context, + Type: issuedVC.Type, CredentialSubject: []map[string]any{ { "id": "did:nuts:other-wallet", @@ -248,12 +276,13 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { } service := requireNewTestHandler(t, keyResolver) - _, err := service.createOffer(ctx, otherIssuedVC, preAuthCode) + otherOffer, err := service.createOffer(ctx, otherIssuedVC, preAuthCode) require.NoError(t, err) accessToken, _, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) - invalidRequest := createRequest(createHeaders(), createClaims("")) + otherConfigID := otherOffer.CredentialConfigurationIds[0] + invalidRequest := createRequest(createHeaders(), createClaims(""), otherConfigID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -269,7 +298,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { accessToken, _, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) - invalidRequest := createRequest(createHeaders(), createClaims("")) + invalidRequest := createRequest(createHeaders(), createClaims(""), configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -279,7 +308,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { t.Run("typ header missing", func(t *testing.T) { headers := createHeaders() headers["typ"] = "" - invalidRequest := createRequest(headers, createClaims("")) + invalidRequest := createRequest(headers, createClaims(""), configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -289,7 +318,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { t.Run("typ header invalid", func(t *testing.T) { headers := createHeaders() delete(headers, "typ") // causes JWT library to set it to default ("JWT") - invalidRequest := createRequest(headers, createClaims("")) + invalidRequest := createRequest(headers, createClaims(""), configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -299,7 +328,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { t.Run("aud header doesn't match issuer identifier", func(t *testing.T) { claims := createClaims("") claims["aud"] = "https://example.com/someone-else" - invalidRequest := createRequest(createHeaders(), claims) + invalidRequest := createRequest(createHeaders(), claims, configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -308,44 +337,36 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { }) }) t.Run("unknown nonce", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("other")) + invalidRequest := createRequest(createHeaders(), createClaims("other"), configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - unknown nonce") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_nonce - unknown nonce") assert.Nil(t, response) + // Per Section 8.3.1.2: invalid_nonce MUST include a fresh c_nonce + require.ErrorAs(t, err, new(openid4vci.Error)) + assert.NotNil(t, err.(openid4vci.Error).CNonce) + assert.NotNil(t, err.(openid4vci.Error).CNonceExpiresIn) }) t.Run("wrong nonce", func(t *testing.T) { _, err := service.createOffer(ctx, issuedVC, "other") require.NoError(t, err) _, cNonce, err := service.HandleAccessTokenRequest(ctx, "other") require.NoError(t, err) - invalidRequest := createRequest(createHeaders(), createClaims(cNonce)) + invalidRequest := createRequest(createHeaders(), createClaims(cNonce), configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - nonce not valid for access token") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_nonce - nonce not valid for access token") assert.Nil(t, response) }) - t.Run("request does not match offer", func(t *testing.T) { - request := createRequest(createHeaders(), createClaims(cNonce)) - request.CredentialDefinition.Type = []ssi.URI{ - ssi.MustParseURI("DifferentCredential"), - } - - response, err := service.HandleCredentialRequest(ctx, request, accessToken) - - assert.Nil(t, response) - assert.EqualError(t, err, "invalid_request - requested credential does not match offer: credential does not match credential_definition: type mismatch") - }) }) - t.Run("unknown access token", func(t *testing.T) { service := requireNewTestHandler(t, keyResolver) response, err := service.HandleCredentialRequest(ctx, validRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_token - unknown access token") + assertProtocolError(t, err, http.StatusUnauthorized, "invalid_token - unknown access token") assert.Nil(t, response) }) } @@ -436,3 +457,130 @@ func requireNewTestHandler(t *testing.T, keyResolver resolver.KeyResolver) *open require.NoError(t, err) return service.(*openidHandler) } + +func Test_deepcopyMap(t *testing.T) { + t.Run("mutation of copy does not affect original", func(t *testing.T) { + src := map[string]map[string]interface{}{ + "config1": { + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "type": []interface{}{"VerifiableCredential"}, + }, + }, + } + + dst := deepcopyMap(src) + credDef := dst["config1"]["credential_definition"].(map[string]interface{}) + credDef["type"] = []interface{}{"Mutated"} + + srcCredDef := src["config1"]["credential_definition"].(map[string]interface{}) + assert.Equal(t, []interface{}{"VerifiableCredential"}, srcCredDef["type"]) + }) +} + +func Test_matchesCredential(t *testing.T) { + t.Run("matches on type and context", func(t *testing.T) { + config := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"}, + "type": []interface{}{"VerifiableCredential", "NutsOrganizationCredential"}, + }, + } + cred := vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://nuts.nl/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("NutsOrganizationCredential")}, + } + + assert.True(t, matchesCredential(config, cred)) + }) + t.Run("does not match on type mismatch", func(t *testing.T) { + config := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, + "type": []interface{}{"VerifiableCredential", "OtherCredential"}, + }, + } + cred := vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("NutsOrganizationCredential")}, + } + + assert.False(t, matchesCredential(config, cred)) + }) + t.Run("does not match on context mismatch", func(t *testing.T) { + config := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://other.example.com/v1"}, + "type": []interface{}{"VerifiableCredential"}, + }, + } + cred := vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, + } + + assert.False(t, matchesCredential(config, cred)) + }) +} + +func Test_generateCredentialConfigID(t *testing.T) { + t.Run("ok", func(t *testing.T) { + defMap := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "type": []interface{}{"VerifiableCredential", "NutsOrganizationCredential"}, + }, + } + id, err := generateCredentialConfigID(defMap) + require.NoError(t, err) + assert.Equal(t, "NutsOrganizationCredential_ldp_vc", id) + }) + t.Run("missing format", func(t *testing.T) { + defMap := map[string]interface{}{ + "credential_definition": map[string]interface{}{ + "type": []interface{}{"VerifiableCredential"}, + }, + } + _, err := generateCredentialConfigID(defMap) + assert.EqualError(t, err, "credential definition missing 'format' field") + }) + t.Run("missing credential_definition", func(t *testing.T) { + defMap := map[string]interface{}{ + "format": "ldp_vc", + } + _, err := generateCredentialConfigID(defMap) + assert.EqualError(t, err, "credential definition missing 'credential_definition' field") + }) + t.Run("missing type", func(t *testing.T) { + defMap := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{}, + } + _, err := generateCredentialConfigID(defMap) + assert.EqualError(t, err, "credential definition missing 'type' field") + }) + t.Run("empty type array", func(t *testing.T) { + defMap := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "type": []interface{}{}, + }, + } + _, err := generateCredentialConfigID(defMap) + assert.EqualError(t, err, "credential definition missing 'type' field") + }) + t.Run("only VerifiableCredential type falls back", func(t *testing.T) { + defMap := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "type": []interface{}{"VerifiableCredential"}, + }, + } + id, err := generateCredentialConfigID(defMap) + require.NoError(t, err) + assert.Equal(t, "VerifiableCredential_ldp_vc", id) + }) +} diff --git a/vcr/issuer/test/valid/ExampleCredential.json b/vcr/issuer/test/valid/ExampleCredential.json index 36f08d26d8..7f0d460abf 100644 --- a/vcr/issuer/test/valid/ExampleCredential.json +++ b/vcr/issuer/test/valid/ExampleCredential.json @@ -6,7 +6,7 @@ "credential_definition": { "@context": [ "https://www.w3.org/2018/credentials/v1", - "https://www.nuts.nl/credentials/v1" + "https://example.com/credentials/v1" ], "type": [ "VerifiableCredential", diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index c355aa96d5..1439535e96 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -101,13 +101,13 @@ func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request C if err != nil { return nil, fmt.Errorf("get credential request failed: %w", err) } - // TODO: check format + // TODO: validate received credential matches the requested credential_configuration_id // See https://github.com/nuts-foundation/nuts-node/issues/2037 if credentialResponse.Credential == nil { return nil, errors.New("credential response does not contain a credential") } var credential vc.VerifiableCredential - credentialJSON, _ := json.Marshal(*credentialResponse.Credential) + credentialJSON, _ := json.Marshal(credentialResponse.Credential) err = json.Unmarshal(credentialJSON, &credential) if err != nil { return nil, fmt.Errorf("unable to unmarshal received credential: %w", err) diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index 72355f6d05..d62d90b6da 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -20,7 +20,6 @@ package openid4vci import ( "context" - "github.com/nuts-foundation/go-did/vc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "net/http" @@ -89,8 +88,7 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { ctx := context.Background() httpClient := &http.Client{} credentialRequest := CredentialRequest{ - CredentialDefinition: &CredentialDefinition{}, - Format: vc.JSONLDCredentialProofFormat, + CredentialConfigurationId: "NutsOrganizationCredential_ldp_vc", } t.Run("ok", func(t *testing.T) { setup := setupClientTest(t) @@ -115,7 +113,7 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { }) t.Run("error - invalid credentials in response", func(t *testing.T) { setup := setupClientTest(t) - setup.credentialHandler = setup.httpPostHandler(CredentialResponse{Credential: &map[string]interface{}{ + setup.credentialHandler = setup.httpPostHandler(CredentialResponse{Credential: map[string]interface{}{ "issuer": []string{"1", "2"}, // Invalid issuer }}) client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) diff --git a/vcr/openid4vci/test.go b/vcr/openid4vci/test.go index f105ff4674..e8f54a7028 100644 --- a/vcr/openid4vci/test.go +++ b/vcr/openid4vci/test.go @@ -22,7 +22,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/test" "net/http" @@ -36,8 +35,7 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { providerMetadata := new(ProviderMetadata) walletMetadata := new(OAuth2ClientMetadata) credentialResponse := CredentialResponse{ - Format: vc.JSONLDCredentialProofFormat, - Credential: &map[string]interface{}{ + Credential: map[string]interface{}{ "@context": []string{"https://www.w3.org/2018/credentials/v1"}, "type": []string{"VerifiableCredential"}, "issuer": "issuer", diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index e5d030b005..c4892d4996 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -62,8 +62,9 @@ type CredentialIssuerMetadata struct { // CredentialEndpoint defines where the wallet can send a request to retrieve a credential. CredentialEndpoint string `json:"credential_endpoint"` - // CredentialsSupported defines metadata about which credential types the credential issuer can issue. - CredentialsSupported []map[string]interface{} `json:"credentials_supported"` + // CredentialConfigurationsSupported defines metadata about which credential types the credential issuer can issue. + // The map is keyed by credential_configuration_id. + CredentialConfigurationsSupported map[string]map[string]interface{} `json:"credential_configurations_supported"` } // OAuth2ClientMetadata defines the OAuth2 Client Metadata, extended with OpenID4VCI parameters. @@ -93,15 +94,26 @@ type ProviderMetadata struct { type CredentialOffer struct { // CredentialIssuer defines the identifier of the credential issuer. CredentialIssuer string `json:"credential_issuer"` - // Credentials defines the credentials offered by the issuer to the wallet. - Credentials []OfferedCredential `json:"credentials"` + // CredentialConfigurationIds defines references to credential configurations offered by the issuer. + // These IDs reference entries in the credential_configurations_supported metadata. + CredentialConfigurationIds []string `json:"credential_configuration_ids"` // Grants defines the grants offered by the issuer to the wallet. - Grants map[string]interface{} `json:"grants"` + Grants CredentialOfferGrants `json:"grants"` } -// OfferedCredential defines a single entry in the credentials array of a CredentialOffer. We currently do not support 'JSON string' offers. +// CredentialOfferGrants defines the grant types in a credential offer. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer-parameters -// and https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-vc-secured-using-data-integ +type CredentialOfferGrants struct { + PreAuthorizedCode *PreAuthorizedCodeParams `json:"urn:ietf:params:oauth:grant-type:pre-authorized_code,omitempty"` +} + +// PreAuthorizedCodeParams defines the parameters for the pre-authorized code grant. +type PreAuthorizedCodeParams struct { + PreAuthorizedCode string `json:"pre-authorized_code"` +} + +// OfferedCredential represents a resolved credential configuration from issuer metadata. +// It is used internally by the holder to validate offered credentials after resolving a credential_configuration_id. type OfferedCredential struct { // Format specifies the credential format. Format string `json:"format"` @@ -110,11 +122,11 @@ type OfferedCredential struct { } // CredentialDefinition defines the 'credential_definition' for Format VerifiableCredentialJSONLDFormat -// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-vc-secured-using-data-integ +// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html Appendix A.1.2 type CredentialDefinition struct { Context []ssi.URI `json:"@context"` Type []ssi.URI `json:"type"` - CredentialSubject *map[string]interface{} `json:"credentialSubject,omitempty"` // optional and currently not used + CredentialSubject map[string]interface{} `json:"credentialSubject,omitempty"` // optional and currently not used } // CredentialOfferResponse defines the response for credential offer requests. @@ -126,10 +138,19 @@ type CredentialOfferResponse struct { // CredentialRequest defines the credential request sent by the wallet to the issuer. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request. +// Per v1.0 Section 8.2, the request MUST contain ONE of: +// - credential_configuration_id: references an entry in credential_configurations_supported +// - format + format-specific parameters (e.g., credential_definition for ldp_vc) type CredentialRequest struct { - Format string `json:"format"` - CredentialDefinition *CredentialDefinition `json:"credential_definition,omitempty"` - Proof *CredentialRequestProof `json:"proof,omitempty"` + // CredentialConfigurationId references a credential configuration from issuer metadata. + // When present, format and credential_definition should not be used. + CredentialConfigurationId string `json:"credential_configuration_id,omitempty"` + // Format specifies the credential format. Required when credential_configuration_id is not used. + Format string `json:"format,omitempty"` + // CredentialDefinition contains the credential definition for ldp_vc format. + CredentialDefinition *CredentialDefinition `json:"credential_definition,omitempty"` + // Proof contains the proof of possession of the key material. + Proof *CredentialRequestProof `json:"proof,omitempty"` } // CredentialRequestProof defines the proof of possession of key material when requesting a Credential. @@ -142,9 +163,9 @@ type CredentialRequestProof struct { // CredentialResponse defines the response for credential requests. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response type CredentialResponse struct { - Format string `json:"format,omitempty"` - Credential *map[string]interface{} `json:"credential,omitempty"` - CNonce *string `json:"c_nonce,omitempty"` + Credential map[string]interface{} `json:"credential,omitempty"` + CNonce *string `json:"c_nonce,omitempty"` + CNonceExpiresIn *int `json:"c_nonce_expires_in,omitempty"` } // Config holds the config for the OpenID4VCI credential issuer and wallet diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go new file mode 100644 index 0000000000..be51773e84 --- /dev/null +++ b/vcr/openid4vci/types_test.go @@ -0,0 +1,379 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package openid4vci + +import ( + "encoding/json" + "testing" + + ssi "github.com/nuts-foundation/go-did" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCredentialRequest_V1Spec tests that CredentialRequest conforms to OpenID4VCI v1.0 Section 8.2 +// The spec states that credential request MUST contain ONE of: +// - credential_configuration_id: string referencing metadata +// - format + format-specific parameters (e.g., credential_definition for ldp_vc) +func TestCredentialRequest_V1Spec(t *testing.T) { + t.Run("request with credential_configuration_id only (v1.0 preferred)", func(t *testing.T) { + // Per v1.0 Section 8.2: "credential_configuration_id: REQUIRED when the credential_configuration_id + // parameter was not present in the Credential Offer" + // This is the simpler approach - just reference the configuration by ID + requestJSON := `{ + "credential_configuration_id": "NutsAuthorizationCredential_ldp_vc", + "proof": { + "proof_type": "jwt", + "jwt": "eyJ..." + } + }` + + var request CredentialRequest + err := json.Unmarshal([]byte(requestJSON), &request) + require.NoError(t, err) + + assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", request.CredentialConfigurationId) + assert.Empty(t, request.Format, "format should not be required when using credential_configuration_id") + assert.NotNil(t, request.Proof) + }) + + t.Run("request with format + credential_definition (explicit approach)", func(t *testing.T) { + // Per v1.0 Appendix A.1.2 for ldp_vc format + requestJSON := `{ + "format": "ldp_vc", + "credential_definition": { + "@context": ["https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"], + "type": ["VerifiableCredential", "NutsAuthorizationCredential"] + }, + "proof": { + "proof_type": "jwt", + "jwt": "eyJ..." + } + }` + + var request CredentialRequest + err := json.Unmarshal([]byte(requestJSON), &request) + require.NoError(t, err) + + assert.Empty(t, request.CredentialConfigurationId) + assert.Equal(t, "ldp_vc", request.Format) + assert.NotNil(t, request.CredentialDefinition) + assert.Len(t, request.CredentialDefinition.Context, 2) + assert.Len(t, request.CredentialDefinition.Type, 2) + }) + + t.Run("marshaling request with credential_configuration_id omits format and credential_definition", func(t *testing.T) { + request := CredentialRequest{ + CredentialConfigurationId: "NutsAuthorizationCredential_ldp_vc", + Proof: &CredentialRequestProof{ + ProofType: "jwt", + Jwt: "eyJ...", + }, + } + + jsonBytes, err := json.Marshal(request) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", parsed["credential_configuration_id"]) + _, hasFormat := parsed["format"] + assert.False(t, hasFormat, "format must be absent when using credential_configuration_id") + _, hasCredDef := parsed["credential_definition"] + assert.False(t, hasCredDef, "credential_definition must be absent when using credential_configuration_id") + }) + + t.Run("marshaling request with format omits credential_configuration_id", func(t *testing.T) { + request := CredentialRequest{ + Format: "ldp_vc", + CredentialDefinition: &CredentialDefinition{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, + }, + Proof: &CredentialRequestProof{ + ProofType: "jwt", + Jwt: "eyJ...", + }, + } + + jsonBytes, err := json.Marshal(request) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + assert.Equal(t, "ldp_vc", parsed["format"]) + assert.NotNil(t, parsed["credential_definition"]) + _, hasConfigID := parsed["credential_configuration_id"] + assert.False(t, hasConfigID, "credential_configuration_id must be absent when using format") + }) +} + +// TestCredentialOffer_V1Spec tests that CredentialOffer conforms to OpenID4VCI v1.0 Section 4.1.1 +func TestCredentialOffer_V1Spec(t *testing.T) { + t.Run("v1.0 format with credential_configuration_ids", func(t *testing.T) { + // Per v1.0 Section 4.1.1 + offerJSON := `{ + "credential_issuer": "https://issuer.example.com", + "credential_configuration_ids": ["NutsAuthorizationCredential_ldp_vc"], + "grants": { + "urn:ietf:params:oauth:grant-type:pre-authorized_code": { + "pre-authorized_code": "secret123" + } + } + }` + + var offer CredentialOffer + err := json.Unmarshal([]byte(offerJSON), &offer) + require.NoError(t, err) + + assert.Equal(t, "https://issuer.example.com", offer.CredentialIssuer) + assert.Equal(t, []string{"NutsAuthorizationCredential_ldp_vc"}, offer.CredentialConfigurationIds) + require.NotNil(t, offer.Grants.PreAuthorizedCode) + assert.Equal(t, "secret123", offer.Grants.PreAuthorizedCode.PreAuthorizedCode) + }) + + t.Run("marshaling preserves v1.0 format", func(t *testing.T) { + offer := CredentialOffer{ + CredentialIssuer: "https://issuer.example.com", + CredentialConfigurationIds: []string{"NutsAuthorizationCredential_ldp_vc"}, + Grants: CredentialOfferGrants{ + PreAuthorizedCode: &PreAuthorizedCodeParams{ + PreAuthorizedCode: "secret123", + }, + }, + } + + jsonBytes, err := json.Marshal(offer) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + // Must use credential_configuration_ids (v1.0), NOT credentials (old format) + _, hasOldField := parsed["credentials"] + assert.False(t, hasOldField, "should not have old 'credentials' field") + + configIds, ok := parsed["credential_configuration_ids"].([]interface{}) + require.True(t, ok, "must have credential_configuration_ids array") + assert.Len(t, configIds, 1) + assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", configIds[0]) + + // Verify grants are serialized with the correct JSON key + grants, ok := parsed["grants"].(map[string]interface{}) + require.True(t, ok) + preAuth, ok := grants[PreAuthorizedCodeGrant].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "secret123", preAuth["pre-authorized_code"]) + }) +} + +// TestCredentialIssuerMetadata_V1Spec tests that metadata conforms to OpenID4VCI v1.0 Section 11.2.1 +func TestCredentialIssuerMetadata_V1Spec(t *testing.T) { + t.Run("v1.0 format with credential_configurations_supported map", func(t *testing.T) { + // Per v1.0 Section 11.2.1 + metadataJSON := `{ + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + "credential_configurations_supported": { + "NutsAuthorizationCredential_ldp_vc": { + "format": "ldp_vc", + "cryptographic_binding_methods_supported": ["did:nuts"], + "credential_definition": { + "@context": ["https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"], + "type": ["VerifiableCredential", "NutsAuthorizationCredential"] + } + } + } + }` + + var metadata CredentialIssuerMetadata + err := json.Unmarshal([]byte(metadataJSON), &metadata) + require.NoError(t, err) + + assert.Equal(t, "https://issuer.example.com", metadata.CredentialIssuer) + assert.Equal(t, "https://issuer.example.com/credential", metadata.CredentialEndpoint) + + // Must be a map keyed by credential_configuration_id + require.Len(t, metadata.CredentialConfigurationsSupported, 1) + config, ok := metadata.CredentialConfigurationsSupported["NutsAuthorizationCredential_ldp_vc"] + require.True(t, ok) + assert.Equal(t, "ldp_vc", config["format"]) + }) + + t.Run("marshaling preserves v1.0 format", func(t *testing.T) { + metadata := CredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsAuthorizationCredential_ldp_vc": { + "format": "ldp_vc", + }, + }, + } + + jsonBytes, err := json.Marshal(metadata) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + // Must use credential_configurations_supported (v1.0), NOT credentials_supported (old format) + _, hasOldField := parsed["credentials_supported"] + assert.False(t, hasOldField, "should not have old 'credentials_supported' field") + + configs, ok := parsed["credential_configurations_supported"].(map[string]interface{}) + require.True(t, ok, "must have credential_configurations_supported object") + assert.Contains(t, configs, "NutsAuthorizationCredential_ldp_vc") + }) +} + +// TestCredentialResponse_V1Spec tests that CredentialResponse conforms to OpenID4VCI v1.0 Section 8.3 +// v1.0 removed the format field from the response (it was REQUIRED in Draft 11, removed in Draft 12+) +func TestCredentialResponse_V1Spec(t *testing.T) { + t.Run("response does not contain format field", func(t *testing.T) { + cred := map[string]interface{}{"issuer": "did:nuts:issuer"} + response := CredentialResponse{ + Credential: cred, + } + + jsonBytes, err := json.Marshal(response) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + _, hasFormat := parsed["format"] + assert.False(t, hasFormat, "format must not be present in v1.0 credential response") + assert.NotNil(t, parsed["credential"]) + }) + + t.Run("c_nonce is absent when not set", func(t *testing.T) { + cred := map[string]interface{}{"issuer": "did:nuts:issuer"} + response := CredentialResponse{ + Credential: cred, + } + + jsonBytes, err := json.Marshal(response) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + _, hasCNonce := parsed["c_nonce"] + assert.False(t, hasCNonce, "c_nonce must be absent when not set") + }) + + t.Run("c_nonce is present when set", func(t *testing.T) { + cred := map[string]interface{}{"issuer": "did:nuts:issuer"} + nonce := "some-nonce" + response := CredentialResponse{ + Credential: cred, + CNonce: &nonce, + } + + jsonBytes, err := json.Marshal(response) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + assert.Equal(t, "some-nonce", parsed["c_nonce"]) + }) + t.Run("c_nonce_expires_in is present when set alongside c_nonce", func(t *testing.T) { + cred := map[string]interface{}{"issuer": "did:nuts:issuer"} + nonce := "some-nonce" + expiresIn := 300 + response := CredentialResponse{ + Credential: cred, + CNonce: &nonce, + CNonceExpiresIn: &expiresIn, + } + + jsonBytes, err := json.Marshal(response) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + assert.Equal(t, "some-nonce", parsed["c_nonce"]) + assert.Equal(t, float64(300), parsed["c_nonce_expires_in"]) + }) + t.Run("c_nonce_expires_in is absent when not set", func(t *testing.T) { + cred := map[string]interface{}{"issuer": "did:nuts:issuer"} + response := CredentialResponse{ + Credential: cred, + } + + jsonBytes, err := json.Marshal(response) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + _, hasExpiresIn := parsed["c_nonce_expires_in"] + assert.False(t, hasExpiresIn, "c_nonce_expires_in must be absent when not set") + }) +} + +// TestCredentialDefinition_Validation tests credential definition validation +func TestCredentialDefinition_Validation(t *testing.T) { + t.Run("valid definition", func(t *testing.T) { + def := &CredentialDefinition{ + Context: []ssi.URI{ + ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), + ssi.MustParseURI("https://nuts.nl/credentials/v1"), + }, + Type: []ssi.URI{ + ssi.MustParseURI("VerifiableCredential"), + ssi.MustParseURI("NutsAuthorizationCredential"), + }, + } + + err := def.Validate(true) + assert.NoError(t, err) + }) + + t.Run("credentialSubject not allowed in offer", func(t *testing.T) { + subject := map[string]interface{}{"id": "did:example:123"} + def := &CredentialDefinition{ + Context: []ssi.URI{ + ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), + }, + Type: []ssi.URI{ + ssi.MustParseURI("VerifiableCredential"), + }, + CredentialSubject: subject, + } + + err := def.Validate(true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "credentialSubject not allowed") + }) +} diff --git a/vcr/openid4vci/validators.go b/vcr/openid4vci/validators.go index b9f854fbb0..011122432b 100644 --- a/vcr/openid4vci/validators.go +++ b/vcr/openid4vci/validators.go @@ -24,8 +24,11 @@ import ( "github.com/nuts-foundation/go-did/vc" ) -// Validate the CredentialDefinition according to the VerifiableCredentialJSONLDFormat format -func (cd *CredentialDefinition) Validate(isOffer bool) error { +// Validate the CredentialDefinition according to the VerifiableCredentialJSONLDFormat format. +// When rejectCredentialSubject is true, the presence of credentialSubject causes a validation error. +// This should be set to true when validating credential offers (Section 4.1.1) where credentialSubject is not allowed, +// and false when validating metadata (Appendix A.1.2) where it is permitted. +func (cd *CredentialDefinition) Validate(rejectCredentialSubject bool) error { if cd == nil { return errors.New("invalid credential_definition: missing") } @@ -36,7 +39,7 @@ func (cd *CredentialDefinition) Validate(isOffer bool) error { return errors.New("invalid credential_definition: missing type field") } if cd.CredentialSubject != nil { - if isOffer { + if rejectCredentialSubject { return errors.New("invalid credential_definition: credentialSubject not allowed in offer") } // TODO: Add credentialSubject validation. @@ -49,7 +52,7 @@ func (cd *CredentialDefinition) Validate(isOffer bool) error { // CredentialDefinition is assumed to be valid, see ValidateCredentialDefinition. func ValidateDefinitionWithCredential(credential vc.VerifiableCredential, definition CredentialDefinition) error { // From spec: When the format value is ldp_vc, ..., including credential_definition object, MUST NOT be processed using JSON-LD rules. - // https://openid.bitbucket.io/connect/editors-draft/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-2 + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-A.1.2 // compare contexts. The credential may contain extra contexts for signatures or proofs if len(credential.Context) < len(definition.Context) || !isSubset(credential.Context, definition.Context) { diff --git a/vcr/openid4vci/validators_test.go b/vcr/openid4vci/validators_test.go index d5d3572bad..569f642b1e 100644 --- a/vcr/openid4vci/validators_test.go +++ b/vcr/openid4vci/validators_test.go @@ -50,7 +50,7 @@ func Test_ValidateCredentialDefinition(t *testing.T) { definition := &CredentialDefinition{ Context: []ssi.URI{ssi.MustParseURI("http://example.com")}, Type: []ssi.URI{ssi.MustParseURI("SomeCredentialType")}, - CredentialSubject: new(map[string]any), + CredentialSubject: map[string]any{}, } err := definition.Validate(true) diff --git a/vcr/openid4vci/wallet_client_test.go b/vcr/openid4vci/wallet_client_test.go index 5310eb4ec0..3348b119d6 100644 --- a/vcr/openid4vci/wallet_client_test.go +++ b/vcr/openid4vci/wallet_client_test.go @@ -66,10 +66,12 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { require.NoError(t, err) err = client.OfferCredential(ctx, CredentialOffer{ - CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - Credentials: []OfferedCredential{}, - Grants: map[string]interface{}{ - "grant_type": "pre-authorized_code", + CredentialIssuer: setup.issuerMetadata.CredentialIssuer, + CredentialConfigurationIds: []string{}, + Grants: CredentialOfferGrants{ + PreAuthorizedCode: &PreAuthorizedCodeParams{ + PreAuthorizedCode: "test-code", + }, }, }) @@ -84,8 +86,10 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { err = json.Unmarshal([]byte(credentialOfferJSON), &credentialOffer) require.NoError(t, err) require.Equal(t, setup.issuerMetadata.CredentialIssuer, credentialOffer["credential_issuer"]) - require.Equal(t, []interface{}{}, credentialOffer["credentials"]) - require.Equal(t, map[string]interface{}{"grant_type": "pre-authorized_code"}, credentialOffer["grants"]) + require.Equal(t, []interface{}{}, credentialOffer["credential_configuration_ids"]) + grants := credentialOffer["grants"].(map[string]interface{}) + preAuthGrant := grants[PreAuthorizedCodeGrant].(map[string]interface{}) + require.Equal(t, "test-code", preAuthGrant["pre-authorized_code"]) }) t.Run("error - invalid response from wallet", func(t *testing.T) { setup := setupClientTest(t) @@ -94,10 +98,12 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { require.NoError(t, err) err = client.OfferCredential(ctx, CredentialOffer{ - CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - Credentials: []OfferedCredential{}, - Grants: map[string]interface{}{ - "grant_type": "pre-authorized_code", + CredentialIssuer: setup.issuerMetadata.CredentialIssuer, + CredentialConfigurationIds: []string{}, + Grants: CredentialOfferGrants{ + PreAuthorizedCode: &PreAuthorizedCodeParams{ + PreAuthorizedCode: "test-code", + }, }, }) @@ -112,10 +118,12 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { require.NoError(t, err) err = client.OfferCredential(ctx, CredentialOffer{ - CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - Credentials: []OfferedCredential{}, - Grants: map[string]interface{}{ - "grant_type": "pre-authorized_code", + CredentialIssuer: setup.issuerMetadata.CredentialIssuer, + CredentialConfigurationIds: []string{}, + Grants: CredentialOfferGrants{ + PreAuthorizedCode: &PreAuthorizedCodeParams{ + PreAuthorizedCode: "test-code", + }, }, }) From 09e107809c2ec74c3bae27dddcbc0e1b60739878 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 24 Feb 2026 17:07:33 +0100 Subject: [PATCH 03/44] feat(openid4vci): update holder and API handler for v1.0 Wallet-side changes for v1.0 alignment: - Holder resolves credential_configuration_id from issuer metadata instead of using inline credential definitions from offers - Credential requests use credential_configuration_id (v1.0 preferred) - Typed grant structs replace untyped map access - ServerError used for upstream failures (not InvalidRequest) - API handler returns non-pointer Credential in response --- vcr/api/openid4vci/v0/holder_test.go | 20 +-- vcr/api/openid4vci/v0/issuer.go | 4 +- vcr/holder/openid.go | 123 ++++++++++++----- vcr/holder/openid_test.go | 171 +++++++++++++++++------- vcr/test/openid4vci_integration_test.go | 18 +-- 5 files changed, 230 insertions(+), 106 deletions(-) diff --git a/vcr/api/openid4vci/v0/holder_test.go b/vcr/api/openid4vci/v0/holder_test.go index 1601839982..778ba4c41c 100644 --- a/vcr/api/openid4vci/v0/holder_test.go +++ b/vcr/api/openid4vci/v0/holder_test.go @@ -21,9 +21,7 @@ package v0 import ( "context" "encoding/json" - ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" - "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -88,19 +86,11 @@ func TestWrapper_HandleCredentialOffer(t *testing.T) { api := Wrapper{VCR: service, VDR: vdr} credentialOffer := openid4vci.CredentialOffer{ - CredentialIssuer: issuerDID.String(), - Credentials: []openid4vci.OfferedCredential{ - { - Format: vc.JSONLDCredentialProofFormat, - CredentialDefinition: &openid4vci.CredentialDefinition{ - Context: []ssi.URI{ssi.MustParseURI("a"), ssi.MustParseURI("b")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("HumanCredential")}, - }, - }, - }, - Grants: map[string]interface{}{ - "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ - "pre-authorized_code": "code", + CredentialIssuer: issuerDID.String(), + CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}, + Grants: openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "code", }, }, } diff --git a/vcr/api/openid4vci/v0/issuer.go b/vcr/api/openid4vci/v0/issuer.go index 19d5325a0c..7e86fb5a54 100644 --- a/vcr/api/openid4vci/v0/issuer.go +++ b/vcr/api/openid4vci/v0/issuer.go @@ -23,7 +23,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -108,8 +107,7 @@ func (w Wrapper) RequestCredential(ctx context.Context, request RequestCredentia return nil, err } return RequestCredential200JSONResponse(CredentialResponse{ - Credential: &credentialMap, - Format: vc.JSONLDCredentialProofFormat, + Credential: credentialMap, }), nil } diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index fd975152d5..46556ac2b4 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -22,13 +22,14 @@ import ( "context" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/auth/oauth" "net/http" "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/log" @@ -82,27 +83,12 @@ func (h *openidHandler) Metadata() openid4vci.OAuth2ClientMetadata { // Error responses on the Credential Offer Endpoint are not defined in the OpenID4VCI spec, // so these are inferred of whatever makes sense. func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4vci.CredentialOffer) error { - // TODO: This check is too simplistic, there can be multiple credential offers, - // but the issuer should only request the one it's interested in. + // TODO: This check is too simplistic, there can be multiple credential_configuration_ids, + // but we only support one at a time. // See https://github.com/nuts-foundation/nuts-node/issues/2049 - if len(offer.Credentials) != 1 { - return openid4vci.Error{ - Err: errors.New("there must be exactly 1 credential in credential offer"), - Code: openid4vci.InvalidRequest, - StatusCode: http.StatusBadRequest, - } - } - offeredCredential := offer.Credentials[0] - if offeredCredential.Format != vc.JSONLDCredentialProofFormat { - return openid4vci.Error{ - Err: fmt.Errorf("credential offer: unsupported format '%s'", offeredCredential.Format), - Code: openid4vci.UnsupportedCredentialType, - StatusCode: http.StatusBadRequest, - } - } - if err := offeredCredential.CredentialDefinition.Validate(true); err != nil { + if len(offer.CredentialConfigurationIds) != 1 { return openid4vci.Error{ - Err: fmt.Errorf("credential offer: %w", err), + Err: errors.New("there must be exactly 1 credential_configuration_id in credential offer"), Code: openid4vci.InvalidRequest, StatusCode: http.StatusBadRequest, } @@ -126,13 +112,38 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } } + // Resolve the credential configuration from the issuer metadata + credentialConfigID := offer.CredentialConfigurationIds[0] + offeredCredential, err := h.resolveCredentialConfiguration(issuerClient.Metadata(), credentialConfigID) + if err != nil { + return openid4vci.Error{ + Err: fmt.Errorf("unable to resolve credential configuration: %w", err), + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + } + } + if offeredCredential.Format != vc.JSONLDCredentialProofFormat { + return openid4vci.Error{ + Err: fmt.Errorf("credential offer: unsupported format '%s'", offeredCredential.Format), + Code: openid4vci.ServerError, + StatusCode: http.StatusInternalServerError, + } + } + if err := offeredCredential.CredentialDefinition.Validate(false); err != nil { + return openid4vci.Error{ + Err: fmt.Errorf("credential offer: %w", err), + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + } + } + accessTokenResponse, err := issuerClient.RequestAccessToken(openid4vci.PreAuthorizedCodeGrant, map[string]string{ "pre-authorized_code": preAuthorizedCode, }) if err != nil { return openid4vci.Error{ Err: fmt.Errorf("unable to request access token: %w", err), - Code: openid4vci.InvalidToken, + Code: openid4vci.ServerError, StatusCode: http.StatusInternalServerError, } } @@ -140,7 +151,7 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 if accessTokenResponse.AccessToken == "" { return openid4vci.Error{ Err: errors.New("access_token is missing"), - Code: openid4vci.InvalidToken, + Code: openid4vci.ServerError, StatusCode: http.StatusInternalServerError, } } @@ -148,13 +159,13 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 if accessTokenResponse.Get(oauth.CNonceParam) == "" { return openid4vci.Error{ Err: fmt.Errorf("%s is missing", oauth.CNonceParam), - Code: openid4vci.InvalidToken, + Code: openid4vci.ServerError, StatusCode: http.StatusInternalServerError, } } retrieveCtx := audit.Context(ctx, "app-openid4vci", "VCR/OpenID4VCI", "RetrieveCredential") - credential, err := h.retrieveCredential(retrieveCtx, issuerClient, offeredCredential.CredentialDefinition, accessTokenResponse) + credential, err := h.retrieveCredential(retrieveCtx, issuerClient, credentialConfigID, accessTokenResponse) if err != nil { return openid4vci.Error{ Err: fmt.Errorf("unable to retrieve credential: %w", err), @@ -180,18 +191,66 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } func getPreAuthorizedCodeFromOffer(offer openid4vci.CredentialOffer) string { - params, ok := offer.Grants[openid4vci.PreAuthorizedCodeGrant].(map[string]interface{}) - if !ok { + if offer.Grants.PreAuthorizedCode == nil { return "" } - preAuthorizedCode, ok := params["pre-authorized_code"].(string) + return offer.Grants.PreAuthorizedCode.PreAuthorizedCode +} + +// resolveCredentialConfiguration resolves a credential_configuration_id to an OfferedCredential +// by looking it up in the issuer metadata. +func (h *openidHandler) resolveCredentialConfiguration(metadata openid4vci.CredentialIssuerMetadata, configID string) (*openid4vci.OfferedCredential, error) { + config, ok := metadata.CredentialConfigurationsSupported[configID] if !ok { - return "" + return nil, fmt.Errorf("credential_configuration_id '%s' not found in issuer metadata", configID) + } + + format, _ := config["format"].(string) + credDefMap, _ := config["credential_definition"].(map[string]interface{}) + + var credentialDef *openid4vci.CredentialDefinition + if credDefMap != nil { + credentialDef = &openid4vci.CredentialDefinition{} + + // Parse @context + if contextRaw, ok := credDefMap["@context"].([]interface{}); ok { + for _, c := range contextRaw { + if cStr, ok := c.(string); ok { + u, err := ssi.ParseURI(cStr) + if err != nil { + return nil, fmt.Errorf("invalid @context URI %q: %w", cStr, err) + } + credentialDef.Context = append(credentialDef.Context, *u) + } + } + } + + // Parse type + if typeRaw, ok := credDefMap["type"].([]interface{}); ok { + for _, t := range typeRaw { + if tStr, ok := t.(string); ok { + u, err := ssi.ParseURI(tStr) + if err != nil { + return nil, fmt.Errorf("invalid type URI %q: %w", tStr, err) + } + credentialDef.Type = append(credentialDef.Type, *u) + } + } + } + + // Parse credentialSubject (optional in v1.0 metadata) + if credSubject, ok := credDefMap["credentialSubject"].(map[string]interface{}); ok { + credentialDef.CredentialSubject = credSubject + } } - return preAuthorizedCode + + return &openid4vci.OfferedCredential{ + Format: format, + CredentialDefinition: credentialDef, + }, nil } -func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient openid4vci.IssuerAPIClient, offer *openid4vci.CredentialDefinition, tokenResponse *oauth.TokenResponse) (*vc.VerifiableCredential, error) { +func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient openid4vci.IssuerAPIClient, credentialConfigID string, tokenResponse *oauth.TokenResponse) (*vc.VerifiableCredential, error) { keyID, _, err := h.resolver.ResolveKey(h.did, nil, resolver.NutsSigningKeyType) if err != nil { return nil, err @@ -211,9 +270,9 @@ func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient ope return nil, fmt.Errorf("unable to sign request proof: %w", err) } + // Use credential_configuration_id (v1.0 preferred approach) instead of format + credential_definition credentialRequest := openid4vci.CredentialRequest{ - CredentialDefinition: offer, - Format: vc.JSONLDCredentialProofFormat, + CredentialConfigurationId: credentialConfigID, Proof: &openid4vci.CredentialRequestProof{ Jwt: proof, ProofType: "jwt", diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index a8a76ebe7c..c5c536e660 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -59,29 +59,52 @@ func Test_wallet_Metadata(t *testing.T) { func Test_wallet_HandleCredentialOffer(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ - CredentialIssuer: issuerDID.String(), - Credentials: offeredCredential(), - Grants: map[string]interface{}{ - "some-other-grant": map[string]interface{}{}, - "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ - "pre-authorized_code": "code", + CredentialIssuer: issuerDID.String(), + CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}, + Grants: openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "code", }, }, } metadata := openid4vci.CredentialIssuerMetadata{ CredentialIssuer: issuerDID.String(), CredentialEndpoint: "credential-endpoint", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "HumanCredential_ldp_vc": { + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{ + "https://www.w3.org/2018/credentials/v1", + "http://example.org/credentials/V1", + }, + "type": []interface{}{ + "VerifiableCredential", + "HumanCredential", + }, + }, + }, + }, } nonce := "nonsens" t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadata) + issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() tokenResponse := (&oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"}).With("c_nonce", nonce) issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ "pre-authorized_code": "code", }).Return(tokenResponse, nil) - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). + // Verify that the holder sends credential_configuration_id (v1.0 preferred approach) + // instead of format + credential_definition + expectedRequest := openid4vci.CredentialRequest{ + CredentialConfigurationId: "HumanCredential_ldp_vc", + Proof: &openid4vci.CredentialRequestProof{ + Jwt: "signed-jwt", + ProofType: "jwt", + }, + } + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), expectedRequest, "access-token"). Return(&vc.VerifiableCredential{ Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("http://example.org/credentials/V1")}, Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("HumanCredential")}, @@ -115,26 +138,24 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { t.Run("pre-authorized code grant", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) t.Run("no grants", func(t *testing.T) { - offer := openid4vci.CredentialOffer{Credentials: offeredCredential()} + offer := openid4vci.CredentialOffer{CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}} err := w.HandleCredentialOffer(audit.TestContext(), offer) require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) t.Run("no pre-authorized grant", func(t *testing.T) { offer := openid4vci.CredentialOffer{ - Credentials: offeredCredential(), - Grants: map[string]interface{}{ - "some-other-grant": nil, - }, + CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}, + Grants: openid4vci.CredentialOfferGrants{}, } err := w.HandleCredentialOffer(audit.TestContext(), offer) require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) - t.Run("invalid pre-authorized grant", func(t *testing.T) { + t.Run("empty pre-authorized code", func(t *testing.T) { offer := openid4vci.CredentialOffer{ - Credentials: offeredCredential(), - Grants: map[string]interface{}{ - "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ - "pre-authorized_code": nil, + CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}, + Grants: openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "", }, }, } @@ -142,23 +163,21 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) }) - t.Run("error - too many credentials in offer", func(t *testing.T) { + t.Run("error - too many credential_configuration_ids in offer", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) offer := openid4vci.CredentialOffer{ - Credentials: []openid4vci.OfferedCredential{ - offeredCredential()[0], - offeredCredential()[0], - }, + CredentialConfigurationIds: []string{"HumanCredential_ldp_vc", "OtherCredential_ldp_vc"}, } err := w.HandleCredentialOffer(audit.TestContext(), offer).(openid4vci.Error) - assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential in credential offer") + assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential_configuration_id in credential offer") assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) t.Run("error - access token request fails", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(nil, errors.New("request failed")) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) @@ -168,11 +187,12 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - require.EqualError(t, err, "invalid_token - unable to request access token: request failed") + require.EqualError(t, err, "server_error - unable to request access token: request failed") }) t.Run("error - empty access token", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{}, nil) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) @@ -182,11 +202,12 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - require.EqualError(t, err, "invalid_token - access_token is missing") + require.EqualError(t, err, "server_error - access_token is missing") }) t.Run("error - empty c_nonce", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "foo"}, nil) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) @@ -196,25 +217,25 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - require.EqualError(t, err, "invalid_token - c_nonce is missing") + require.EqualError(t, err, "server_error - c_nonce is missing") }) - t.Run("error - no credentials in offer", func(t *testing.T) { + t.Run("error - no credential_configuration_ids in offer", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{}).(openid4vci.Error) - assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential in credential offer") + assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential_configuration_id in credential offer") assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) t.Run("error - can't issuer client (metadata can't be loaded)", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ - CredentialIssuer: "http://localhost:87632", - Credentials: offeredCredential(), - Grants: map[string]interface{}{ - "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ - "pre-authorized_code": "foo", + CredentialIssuer: "http://localhost:87632", + CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}, + Grants: openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "foo", }, }, }) @@ -226,7 +247,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { offer := offeredCredential()[0] ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadata) + issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return((&oauth.TokenResponse{AccessToken: "access-token"}).With("c_nonce", nonce), nil) issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), gomock.Any()).Return(&vc.VerifiableCredential{ Context: offer.CredentialDefinition.Context, @@ -247,28 +268,84 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { require.EqualError(t, err, "invalid_request - received credential does not match offer: credential does not match credential_definition: type mismatch") }) t.Run("error - unsupported format", func(t *testing.T) { - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "TestCredential_unsupported": { + "format": "not supported", + }, + }, + }) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ - Credentials: []openid4vci.OfferedCredential{{Format: "not supported"}}, + CredentialConfigurationIds: []string{"TestCredential_unsupported"}, + Grants: openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "foo", + }, + }, }).(openid4vci.Error) - assert.EqualError(t, err, "unsupported_credential_type - credential offer: unsupported format 'not supported'") - assert.Equal(t, http.StatusBadRequest, err.StatusCode) + assert.EqualError(t, err, "server_error - credential offer: unsupported format 'not supported'") + assert.Equal(t, http.StatusInternalServerError, err.StatusCode) }) - t.Run("error - credentialSubject not allowed in offer", func(t *testing.T) { - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) - credentials := offeredCredential() - credentials[0].CredentialDefinition.CredentialSubject = new(map[string]interface{}) + t.Run("credentialSubject in metadata does not block offer processing", func(t *testing.T) { + // v1.0 Appendix A.1.2: credentialSubject is allowed in metadata credential_configurations_supported + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + metadataWithSubject := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "TestCredential_ldp_vc": { + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, + "type": []interface{}{"VerifiableCredential"}, + "credentialSubject": map[string]interface{}{}, + }, + }, + }, + } + issuerAPIClient.EXPECT().Metadata().Return(metadataWithSubject).AnyTimes() + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return((&oauth.TokenResponse{AccessToken: "access-token"}).With("c_nonce", nonce), nil) + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), gomock.Any()).Return(&vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, + Issuer: issuerDID.URI(), + }, nil) + jwtSigner := crypto.NewMockJWTSigner(ctrl) + jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-jwt", nil) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) + credentialStore := types.NewMockWriter(ctrl) + credentialStore.EXPECT().StoreCredential(gomock.Any(), nil).Return(nil) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } - err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{Credentials: credentials}).(openid4vci.Error) + err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ + CredentialConfigurationIds: []string{"TestCredential_ldp_vc"}, + Grants: openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "foo", + }, + }, + }) - assert.EqualError(t, err, "invalid_request - credential offer: invalid credential_definition: credentialSubject not allowed in offer") - assert.Equal(t, http.StatusBadRequest, err.StatusCode) + assert.NoError(t, err) }) } -// offeredCredential returns a structure that can be used as CredentialOffer.Credentials, +// offeredCredential returns a resolved credential configuration for testing. func offeredCredential() []openid4vci.OfferedCredential { return []openid4vci.OfferedCredential{{ Format: vc.JSONLDCredentialProofFormat, diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index 3e90ed0761..3b07a79fe0 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -21,13 +21,6 @@ package test import ( "bytes" "encoding/json" - "github.com/nuts-foundation/nuts-node/core" - "github.com/nuts-foundation/nuts-node/jsonld" - "github.com/nuts-foundation/nuts-node/vcr/issuer" - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "github.com/nuts-foundation/nuts-node/vdr/resolver" - "github.com/stretchr/testify/assert" "io" "net/http" "net/url" @@ -38,10 +31,16 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/test" "github.com/nuts-foundation/nuts-node/test/node" "github.com/nuts-foundation/nuts-node/vcr" credentialTypes "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/issuer" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -126,7 +125,7 @@ func TestOpenID4VCIErrorResponses(t *testing.T) { require.NoError(t, err) requestBody, _ := json.Marshal(openid4vci.CredentialRequest{ - Format: vc.JSONLDCredentialProofFormat, + CredentialConfigurationId: "NutsOrganizationCredential_ldp_vc", }) t.Run("error from API layer (missing access token)", func(t *testing.T) { @@ -158,10 +157,11 @@ func testCredential() vc.VerifiableCredential { issuanceDate := time.Now().Truncate(time.Second) return vc.VerifiableCredential{ Context: []ssi.URI{ - jsonld.JWS2020ContextV1URI(), + vc.VCContextV1URI(), credentialTypes.NutsV1ContextURI, }, Type: []ssi.URI{ + vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI("NutsAuthorizationCredential"), }, IssuanceDate: issuanceDate, From 8bf04d0635f80db4666ec552e230a9838cb4dd62 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 24 Feb 2026 17:14:56 +0100 Subject: [PATCH 04/44] feat(openid4vci): update OpenAPI spec and remove stale VP metadata - Update OpenAPI schemas for v1.0 field names and structures - Update error code documentation for Credential Endpoint - Remove PreAuthorizedGrantAnonymousAccessSupported from VP authorization server metadata (belongs in VCI issuer metadata only per Section 12.3) --- auth/api/iam/metadata.go | 27 ++++---- auth/api/iam/metadata_test.go | 31 ++++----- docs/_static/vcr/openid4vci_v0.yaml | 102 ++++++++++++---------------- 3 files changed, 70 insertions(+), 90 deletions(-) diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go index ec58fac866..796dc9d03f 100644 --- a/auth/api/iam/metadata.go +++ b/auth/api/iam/metadata.go @@ -33,20 +33,19 @@ import ( func authorizationServerMetadata(issuerURL *url.URL, supportedDIDMethods []string) oauth.AuthorizationServerMetadata { metadata := &oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: "openid4vp:", - ClientIdSchemesSupported: clientIdSchemesSupported, - DIDMethodsSupported: supportedDIDMethods, - DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), - GrantTypesSupported: grantTypesSupported, - Issuer: "https://self-issued.me/v2", - PreAuthorizedGrantAnonymousAccessSupported: true, - PresentationDefinitionUriSupported: to.Ptr(true), - RequireSignedRequestObject: true, - ResponseModesSupported: responseModesSupported, - ResponseTypesSupported: responseTypesSupported, - VPFormats: oauth.DefaultOpenIDSupportedFormats(), - VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), - RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), + AuthorizationEndpoint: "openid4vp:", + ClientIdSchemesSupported: clientIdSchemesSupported, + DIDMethodsSupported: supportedDIDMethods, + DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), + GrantTypesSupported: grantTypesSupported, + Issuer: "https://self-issued.me/v2", + PresentationDefinitionUriSupported: to.Ptr(true), + RequireSignedRequestObject: true, + ResponseModesSupported: responseModesSupported, + ResponseTypesSupported: responseTypesSupported, + VPFormats: oauth.DefaultOpenIDSupportedFormats(), + VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), + RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), } if issuerURL != nil { diff --git a/auth/api/iam/metadata_test.go b/auth/api/iam/metadata_test.go index 8f325b4576..5e6b183583 100644 --- a/auth/api/iam/metadata_test.go +++ b/auth/api/iam/metadata_test.go @@ -32,22 +32,21 @@ import ( func Test_authorizationServerMetadata(t *testing.T) { presentationDefinitionURISupported := true baseExpected := oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: "https://example.com/oauth2/example/authorize", - TokenEndpoint: "https://example.com/oauth2/example/token", - ClientIdSchemesSupported: []string{"entity_id"}, - DIDMethodsSupported: []string{"test"}, - DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), - GrantTypesSupported: []string{"authorization_code", "vp_token-bearer"}, - Issuer: "https://example.com/oauth2/example", - PreAuthorizedGrantAnonymousAccessSupported: true, - PresentationDefinitionEndpoint: "https://example.com/oauth2/example/presentation_definition", - PresentationDefinitionUriSupported: &presentationDefinitionURISupported, - RequireSignedRequestObject: true, - ResponseTypesSupported: []string{"code", "vp_token"}, - ResponseModesSupported: []string{"query", "direct_post"}, - VPFormats: oauth.DefaultOpenIDSupportedFormats(), - VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), - RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), + AuthorizationEndpoint: "https://example.com/oauth2/example/authorize", + TokenEndpoint: "https://example.com/oauth2/example/token", + ClientIdSchemesSupported: []string{"entity_id"}, + DIDMethodsSupported: []string{"test"}, + DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), + GrantTypesSupported: []string{"authorization_code", "vp_token-bearer"}, + Issuer: "https://example.com/oauth2/example", + PresentationDefinitionEndpoint: "https://example.com/oauth2/example/presentation_definition", + PresentationDefinitionUriSupported: &presentationDefinitionURISupported, + RequireSignedRequestObject: true, + ResponseTypesSupported: []string{"code", "vp_token"}, + ResponseModesSupported: []string{"query", "direct_post"}, + VPFormats: oauth.DefaultOpenIDSupportedFormats(), + VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), + RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), } authServerUrl := test.MustParseURL("https://example.com/oauth2/example") md := authorizationServerMetadata(authServerUrl, []string{"test"}) diff --git a/docs/_static/vcr/openid4vci_v0.yaml b/docs/_static/vcr/openid4vci_v0.yaml index a4aa8dfa9c..9758325cc2 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -204,8 +204,10 @@ paths: "$ref": "#/components/schemas/ErrorResponse" "400": description: > - Invalid request. Code can be "invalid_request", "unsupported_credential_type", "unsupported_credential_format" or "invalid_proof". - Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-error-response + Invalid request. Code can be "invalid_credential_request", "unknown_credential_configuration", + "unknown_credential_identifier", "invalid_proof", "invalid_nonce", "invalid_encryption_parameters", + or "credential_request_denied". + Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-8.3.1.2 content: application/json: schema: @@ -272,7 +274,7 @@ components: required: - credential_issuer - credential_endpoint - - credentials_supported + - credential_configurations_supported properties: credential_issuer: type: string @@ -281,24 +283,28 @@ components: credential_endpoint: type: string example: "https://issuer.example/credential" - credentials_supported: - type: array + credential_configurations_supported: + type: object description: | - A JSON array containing a list of JSON objects, each of them representing metadata about a separate credential type that the Credential Issuer can issue. - items: + A JSON object containing credential configurations supported by the Credential Issuer. + The keys are credential_configuration_ids that can be referenced in credential offers. + additionalProperties: type: object - example: + example: + NutsAuthorizationCredential_ldp_vc: { "format": "ldp_vc", - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" - ], - "type": [ - "VerifiableCredential", - "NutsAuthorizationCredential" - ], - "cryptographic_binding_methods_supported": "did:nuts" + "credential_definition": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1" + ], + "type": [ + "VerifiableCredential", + "NutsAuthorizationCredential" + ] + }, + "cryptographic_binding_methods_supported": ["did:nuts"] } OAuth2ClientMetadata: @@ -370,16 +376,17 @@ components: CredentialRequest: type: object required: - - format + - credential_configuration_id + description: | + Per OpenID4VCI v1.0 Section 8.2, the request identifies the credential using credential_configuration_id. + Note: the v1.0 spec also allows format-based requests and credential_identifier, but this implementation + only accepts credential_configuration_id. properties: - format: + credential_configuration_id: type: string description: | - The format of the credential request. This MUST be one of the values specified in the "credentials_supported" array in the Credential Issuer Metadata. - example: "ldp_vc" - credential_definition: - type: object - description: JSON-LD object describing the requested credential. + References a credential configuration from the issuer's credential_configurations_supported metadata. + example: "NutsAuthorizationCredential_ldp_vc" proof: type: object required: @@ -393,9 +400,9 @@ components: type: string description: | String with a JWS [RFC7515] as proof of possession. - + The fields of the JWT may look like this: - + { "typ": "openid4vci-proof+jwt", "alg": "ES256", @@ -408,17 +415,7 @@ components: } example: { - "format": "ldp_vc", - "credential_definition": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" - ], - "type": [ - "VerifiableCredential", - "NutsAuthorizationCredential" - ], - }, + "credential_configuration_id": "NutsAuthorizationCredential_ldp_vc", "proof": { "proof_type": "jwt", "jwt": "eyJraWQiOiJkaWQ6ZXhhbXBsZ...KPxgihac0aW9EkL1nOzM" @@ -443,12 +440,7 @@ components: example: 900 CredentialResponse: type: object - required: - - format properties: - format: - type: string - example: "ldp_vc" credential: type: object c_nonce: @@ -456,7 +448,6 @@ components: example: "fGFF7UkhLa" example: { - "format": "ldp_vc", "credential": { "@context": [ "https://www.w3.org/2018/credentials/v1", @@ -488,33 +479,24 @@ components: type: object required: - credential_issuer - - credentials - - grants # TODO: This should be optional according to https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer-parameters + - credential_configuration_ids properties: credential_issuer: type: string example: "https://issuer.example" - credentials: + credential_configuration_ids: type: array + description: | + Array of credential configuration IDs that reference entries in the issuer's credential_configurations_supported metadata. + items: + type: string grants: type: object example: { "credential_issuer": "https://issuer.example", - "credentials": [ - { - "format": "ldp_vc", - "credential_definition": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" - ], - "type": [ - "VerifiableCredential", - "NutsAuthorizationCredential" - ] - } - } + "credential_configuration_ids": [ + "NutsAuthorizationCredential_ldp_vc" ], "grants": { "urn:ietf:params:oauth:grant-type:pre-authorized_code": { From 31ed810082197e2036b11aba6aa9fc98843e5940 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 6 Mar 2026 20:06:32 +0100 Subject: [PATCH 05/44] fix(openid4vci): align wire formats with v1.0 spec review Three spec compliance fixes found during detailed v1.0 review: - Credential response: use `credentials` array of wrapper objects with `credential` key per Section 8.3 - Credential request: use `proofs` (plural) with `{"jwt": ["..."]}` structure per Section 8.2.1 - Error response: remove c_nonce/c_nonce_expires_in fields (wallet should use Nonce Endpoint), make c_nonce optional in holder --- docs/_static/vcr/openid4vci_v0.yaml | 103 ++++++++++++++------------- vcr/api/openid4vci/v0/issuer.go | 2 +- vcr/api/openid4vci/v0/issuer_test.go | 6 +- vcr/holder/openid.go | 24 +++---- vcr/holder/openid_test.go | 20 +----- vcr/issuer/openid.go | 53 +++++++++----- vcr/issuer/openid_test.go | 47 +++--------- vcr/openid4vci/error.go | 4 -- vcr/openid4vci/issuer_client.go | 7 +- vcr/openid4vci/issuer_client_test.go | 6 +- vcr/openid4vci/test.go | 16 +++-- vcr/openid4vci/types.go | 25 ++++--- vcr/openid4vci/types_test.go | 100 +++++++------------------- 13 files changed, 172 insertions(+), 241 deletions(-) diff --git a/docs/_static/vcr/openid4vci_v0.yaml b/docs/_static/vcr/openid4vci_v0.yaml index 9758325cc2..a793682f49 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -387,21 +387,20 @@ components: description: | References a credential configuration from the issuer's credential_configurations_supported metadata. example: "NutsAuthorizationCredential_ldp_vc" - proof: + proofs: type: object - required: - - proof_type - - jwt + description: | + Object providing one or more proof of possessions of the cryptographic key material. + The key is the proof type (e.g., "jwt") and the value is an array of proofs. properties: - proof_type: - type: string - example: "jwt" jwt: - type: string + type: array + items: + type: string description: | - String with a JWS [RFC7515] as proof of possession. + Array of JWS [RFC7515] strings as proof of possession. - The fields of the JWT may look like this: + The fields of each JWT may look like this: { "typ": "openid4vci-proof+jwt", @@ -416,9 +415,8 @@ components: example: { "credential_configuration_id": "NutsAuthorizationCredential_ldp_vc", - "proof": { - "proof_type": "jwt", - "jwt": "eyJraWQiOiJkaWQ6ZXhhbXBsZ...KPxgihac0aW9EkL1nOzM" + "proofs": { + "jwt": ["eyJraWQiOiJkaWQ6ZXhhbXBsZ...KPxgihac0aW9EkL1nOzM"] } } ErrorResponse: @@ -430,50 +428,53 @@ components: type: string description: Code identifying the error that occurred. example: "invalid_request" - c_nonce: - type: string - description: a string containing a new nonce value to be used for subsequent requests. - example: "tZignsnFbp" - c_nonce_expires_in: - type: integer - description: The lifetime in seconds of the nonce value. - example: 900 CredentialResponse: type: object + description: | + Per OpenID4VCI v1.0 Section 8.3, the response contains a credentials array where each entry + is a wrapper object with a credential key holding the issued credential. properties: - credential: - type: object - c_nonce: - type: string - example: "fGFF7UkhLa" + credentials: + type: array + items: + type: object + required: + - credential + properties: + credential: + type: object + description: Contains one issued Credential. example: { - "credential": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" - ], - "id": "did:nuts:#123", - "type": [ - "VerifiableCredential", - "NutsAuthorizationCredential" - ], - "issuer": "did:nuts:", - "issuanceDate": "2010-01-01T00:00:00Z", - "credentialSubject": { - "id": "did:nuts:", - "patient": "bsn:999992", - "purposeOfUse": "careviewer" - }, - "proof": { - "type": "Ed25519Signature2020", - "created": "2022-02-25T14:58:43Z", - "verificationMethod": "did:nuts:#key-1", - "proofPurpose": "assertionMethod", - "proofValue": "zeEdUoM7m9cY8ZyTpey83yBKeBcmcvbyrEQzJ19rD2UXArU2U1jPGoEtrRvGYppdiK37GU4NBeoPakxpWhAvsVSt" + "credentials": [ + { + "credential": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1" + ], + "id": "did:nuts:#123", + "type": [ + "VerifiableCredential", + "NutsAuthorizationCredential" + ], + "issuer": "did:nuts:", + "issuanceDate": "2010-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:nuts:", + "patient": "bsn:999992", + "purposeOfUse": "careviewer" + }, + "proof": { + "type": "Ed25519Signature2020", + "created": "2022-02-25T14:58:43Z", + "verificationMethod": "did:nuts:#key-1", + "proofPurpose": "assertionMethod", + "proofValue": "zeEdUoM7m9cY8ZyTpey83yBKeBcmcvbyrEQzJ19rD2UXArU2U1jPGoEtrRvGYppdiK37GU4NBeoPakxpWhAvsVSt" + } + } } - }, - "c_nonce": "fGFF7UkhLa" + ] } CredentialOffer: type: object diff --git a/vcr/api/openid4vci/v0/issuer.go b/vcr/api/openid4vci/v0/issuer.go index 7e86fb5a54..16e9c2531c 100644 --- a/vcr/api/openid4vci/v0/issuer.go +++ b/vcr/api/openid4vci/v0/issuer.go @@ -107,7 +107,7 @@ func (w Wrapper) RequestCredential(ctx context.Context, request RequestCredentia return nil, err } return RequestCredential200JSONResponse(CredentialResponse{ - Credential: credentialMap, + Credentials: []openid4vci.CredentialResponseEntry{{Credential: credentialMap}}, }), nil } diff --git a/vcr/api/openid4vci/v0/issuer_test.go b/vcr/api/openid4vci/v0/issuer_test.go index e1ee52f15d..7ef8d36c57 100644 --- a/vcr/api/openid4vci/v0/issuer_test.go +++ b/vcr/api/openid4vci/v0/issuer_test.go @@ -189,14 +189,12 @@ func TestWrapper_RequestCredential(t *testing.T) { Authorization: &authz, }, Body: &RequestCredentialJSONRequestBody{ - Format: "ldp_vc", - CredentialDefinition: &openid4vci.CredentialDefinition{}, - Proof: nil, + CredentialConfigurationId: "NutsOrganizationCredential_ldp_vc", }, }) require.NoError(t, err) - assert.NotNil(t, response.(RequestCredential200JSONResponse).Credential) + assert.NotEmpty(t, response.(RequestCredential200JSONResponse).Credentials) }) t.Run("unknown tenant", func(t *testing.T) { ctrl := gomock.NewController(t) diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index 46556ac2b4..b2cf741b44 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -156,13 +156,9 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } } - if accessTokenResponse.Get(oauth.CNonceParam) == "" { - return openid4vci.Error{ - Err: fmt.Errorf("%s is missing", oauth.CNonceParam), - Code: openid4vci.ServerError, - StatusCode: http.StatusInternalServerError, - } - } + // Note: in v1.0, c_nonce is no longer in the token response (moved to optional Nonce Endpoint). + // For now we still pass the c_nonce from the token response if present (backwards compat with + // issuers that still include it), but we no longer require it. retrieveCtx := audit.Context(ctx, "app-openid4vci", "VCR/OpenID4VCI", "RetrieveCredential") credential, err := h.retrieveCredential(retrieveCtx, issuerClient, credentialConfigID, accessTokenResponse) @@ -260,9 +256,12 @@ func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient ope "kid": keyID, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. } claims := map[string]interface{}{ - "aud": issuerClient.Metadata().CredentialIssuer, - "iat": nowFunc().Unix(), - "nonce": tokenResponse.Get(oauth.CNonceParam), + "aud": issuerClient.Metadata().CredentialIssuer, + "iat": nowFunc().Unix(), + } + // Include c_nonce in proof if available (from token response or future Nonce Endpoint) + if cNonce := tokenResponse.Get(oauth.CNonceParam); cNonce != "" { + claims["nonce"] = cNonce } proof, err := h.signer.SignJWT(ctx, claims, headers, keyID) @@ -273,9 +272,8 @@ func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient ope // Use credential_configuration_id (v1.0 preferred approach) instead of format + credential_definition credentialRequest := openid4vci.CredentialRequest{ CredentialConfigurationId: credentialConfigID, - Proof: &openid4vci.CredentialRequestProof{ - Jwt: proof, - ProofType: "jwt", + Proofs: &openid4vci.CredentialRequestProofs{ + Jwt: []string{proof}, }, } return issuerClient.RequestCredential(ctx, credentialRequest, tokenResponse.AccessToken) diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index c5c536e660..5027c41c88 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -99,9 +99,8 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { // instead of format + credential_definition expectedRequest := openid4vci.CredentialRequest{ CredentialConfigurationId: "HumanCredential_ldp_vc", - Proof: &openid4vci.CredentialRequestProof{ - Jwt: "signed-jwt", - ProofType: "jwt", + Proofs: &openid4vci.CredentialRequestProofs{ + Jwt: []string{"signed-jwt"}, }, } issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), expectedRequest, "access-token"). @@ -204,21 +203,6 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { require.EqualError(t, err, "server_error - access_token is missing") }) - t.Run("error - empty c_nonce", func(t *testing.T) { - ctrl := gomock.NewController(t) - issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "foo"}, nil) - - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) - w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { - return issuerAPIClient, nil - } - - err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - - require.EqualError(t, err, "server_error - c_nonce is missing") - }) t.Run("error - no credential_configuration_ids in offer", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index 65b877f3c6..5bc98d2eca 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -288,34 +288,27 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o credential := flow.Credentials[0] // there's always just one (at least for now) wallet, _ := credential.SubjectDID() - // augment invalid_proof errors according to Section 8.3.2 of openid4vci spec + // In v1.0, error responses no longer contain c_nonce (wallet should use Nonce Endpoint). + // We still store a new c_nonce server-side so the wallet can retry after obtaining one. generateProofError := func(err openid4vci.Error) error { cnonce := crypto.GenerateNonce() - if err := i.store.StoreReference(ctx, flow.ID, cNonceRefType, cnonce); err != nil { - return err + if storeErr := i.store.StoreReference(ctx, flow.ID, cNonceRefType, cnonce); storeErr != nil { + return storeErr } - expiry := int(TokenTTL.Seconds()) - err.CNonce = &cnonce - err.CNonceExpiresIn = &expiry return err } - if request.Proof == nil { - return generateProofError(openid4vci.Error{ - Err: errors.New("missing proof"), - Code: openid4vci.InvalidProof, - StatusCode: http.StatusBadRequest, - }) - } - if request.Proof.ProofType != openid4vci.ProofTypeJWT { + if request.Proofs == nil || len(request.Proofs.Jwt) == 0 { return generateProofError(openid4vci.Error{ - Err: errors.New("proof type not supported"), + Err: errors.New("missing proofs"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, }) } + // We only support single proof for now + proofJWT := request.Proofs.Jwt[0] var signingKeyID string - token, err := crypto.ParseJWT(request.Proof.Jwt, func(kid string) (crypt.PublicKey, error) { + token, err := crypto.ParseJWT(proofJWT, func(kid string) (crypt.PublicKey, error) { signingKeyID = kid return i.keyResolver.ResolveKeyByID(kid, nil, resolver.NutsSigningKeyType) }, openID4VCIProofProfile, nil) @@ -352,6 +345,34 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o }) } + // Validate JWT type + // jwt.Parse does not provide the JWS headers, we have to parse it again as JWS to access those + message, err := jws.ParseString(proofJWT) + if err != nil { + // Should not fail + return err + } + if len(message.Signatures()) != 1 { + // I think this is impossible + return errors.New("expected exactly one signature") + } + typ := message.Signatures()[0].ProtectedHeaders().Type() + if typ == "" { + return generateProofError(openid4vci.Error{ + Err: errors.New("missing typ header"), + Code: openid4vci.InvalidProof, + StatusCode: http.StatusBadRequest, + }) + } + if typ != openid4vci.JWTTypeOpenID4VCIProof { + return generateProofError(openid4vci.Error{ + Err: fmt.Errorf("invalid typ claim (expected: %s): %s", openid4vci.JWTTypeOpenID4VCIProof, typ), + Code: openid4vci.InvalidProof, + StatusCode: http.StatusBadRequest, + }) + } + + // given the JWT typ, the nonce is in the 'nonce' claim nonce, ok := token.Get("nonce") if !ok { diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index 4461c4c930..71774da2ce 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -166,18 +166,17 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { "nonce": nonce, } } - createProof := func(headers, claims map[string]interface{}) *openid4vci.CredentialRequestProof { + createProofs := func(headers, claims map[string]interface{}) *openid4vci.CredentialRequestProofs { proof, err := keyStore.SignJWT(ctx, claims, headers, headers["kid"].(string)) require.NoError(t, err) - return &openid4vci.CredentialRequestProof{ - Jwt: proof, - ProofType: openid4vci.ProofTypeJWT, + return &openid4vci.CredentialRequestProofs{ + Jwt: []string{proof}, } } createRequest := func(headers, claims map[string]interface{}, configID string) openid4vci.CredentialRequest { return openid4vci.CredentialRequest{ CredentialConfigurationId: configID, - Proof: createProof(headers, claims), + Proofs: createProofs(headers, claims), } } @@ -202,7 +201,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { }) t.Run("error - missing credential_configuration_id", func(t *testing.T) { request := openid4vci.CredentialRequest{ - Proof: createProof(createHeaders(), createClaims(cNonce)), + Proofs: createProofs(createHeaders(), createClaims(cNonce)), } response, err := service.HandleCredentialRequest(ctx, request, accessToken) @@ -220,43 +219,19 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { assert.Equal(t, openid4vci.UnknownCredentialConfiguration, err.(openid4vci.Error).Code) }) t.Run("proof validation", func(t *testing.T) { - t.Run("unsupported proof type", func(t *testing.T) { + t.Run("missing proofs", func(t *testing.T) { invalidRequest := createRequest(createHeaders(), createClaims(""), configID) - invalidRequest.Proof.ProofType = "not-supported" + invalidRequest.Proofs = nil response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - proof type not supported") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - missing proofs") assert.Nil(t, response) }) t.Run("jwt", func(t *testing.T) { - t.Run("missing proof", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims(""), configID) - invalidRequest.Proof = nil - - response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - missing proof") - assert.Nil(t, response) - }) - t.Run("missing proof returns error with new c_nonce", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims(""), configID) - invalidRequest.Proof = nil - - _, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - - require.ErrorAs(t, err, new(openid4vci.Error)) - cNonce := err.(openid4vci.Error).CNonce - assert.NotNil(t, cNonce) - assert.NotNil(t, err.(openid4vci.Error).CNonceExpiresIn) - - flow, err := service.store.FindByReference(ctx, cNonceRefType, *cNonce) - require.NoError(t, err) - assert.NotNil(t, flow) - }) t.Run("invalid JWT", func(t *testing.T) { invalidRequest := createRequest(createHeaders(), createClaims(""), configID) - invalidRequest.Proof.Jwt = "not a JWT" + invalidRequest.Proofs.Jwt = []string{"not a JWT"} response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -343,10 +318,6 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { assertProtocolError(t, err, http.StatusBadRequest, "invalid_nonce - unknown nonce") assert.Nil(t, response) - // Per Section 8.3.1.2: invalid_nonce MUST include a fresh c_nonce - require.ErrorAs(t, err, new(openid4vci.Error)) - assert.NotNil(t, err.(openid4vci.Error).CNonce) - assert.NotNil(t, err.(openid4vci.Error).CNonceExpiresIn) }) t.Run("wrong nonce", func(t *testing.T) { _, err := service.createOffer(ctx, issuedVC, "other") diff --git a/vcr/openid4vci/error.go b/vcr/openid4vci/error.go index 212f116987..b523bed891 100644 --- a/vcr/openid4vci/error.go +++ b/vcr/openid4vci/error.go @@ -64,10 +64,6 @@ const ( // or that the client can recover from the error (e.g. retry). Errors are specified by the OpenID4VCI specification. // Invalid proof errors may also add a new c_nonce that the client must use in the next credential request. type Error struct { - // CNonce is a random string that the client must send in the next credential request. - CNonce *string `json:"c_nonce,omitempty"` - // CNonceExpiresIn is the number of seconds until the c_nonce expires. - CNonceExpiresIn *int `json:"c_nonce_expires_in,omitempty"` // Code is the error code as defined by the OpenID4VCI spec. Code ErrorCode `json:"error"` // Err is the underlying error, may be omitted. It is not intended to be returned to the client. diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index 1439535e96..30d7d6ea56 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -103,11 +103,12 @@ func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request C } // TODO: validate received credential matches the requested credential_configuration_id // See https://github.com/nuts-foundation/nuts-node/issues/2037 - if credentialResponse.Credential == nil { - return nil, errors.New("credential response does not contain a credential") + if len(credentialResponse.Credentials) == 0 { + return nil, errors.New("credential response does not contain any credentials") } + // We only support single credential issuance for now var credential vc.VerifiableCredential - credentialJSON, _ := json.Marshal(credentialResponse.Credential) + credentialJSON, _ := json.Marshal(credentialResponse.Credentials[0].Credential) err = json.Unmarshal(credentialJSON, &credential) if err != nil { return nil, fmt.Errorf("unable to unmarshal received credential: %w", err) diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index d62d90b6da..3b2333b661 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -108,13 +108,13 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { credential, err := client.RequestCredential(ctx, credentialRequest, "token") - require.EqualError(t, err, "credential response does not contain a credential") + require.EqualError(t, err, "credential response does not contain any credentials") require.Nil(t, credential) }) t.Run("error - invalid credentials in response", func(t *testing.T) { setup := setupClientTest(t) - setup.credentialHandler = setup.httpPostHandler(CredentialResponse{Credential: map[string]interface{}{ - "issuer": []string{"1", "2"}, // Invalid issuer + setup.credentialHandler = setup.httpPostHandler(CredentialResponse{Credentials: []CredentialResponseEntry{ + {Credential: map[string]interface{}{"issuer": []string{"1", "2"}}}, // Invalid issuer }}) client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) require.NoError(t, err) diff --git a/vcr/openid4vci/test.go b/vcr/openid4vci/test.go index e8f54a7028..2b017c82dc 100644 --- a/vcr/openid4vci/test.go +++ b/vcr/openid4vci/test.go @@ -35,12 +35,16 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { providerMetadata := new(ProviderMetadata) walletMetadata := new(OAuth2ClientMetadata) credentialResponse := CredentialResponse{ - Credential: map[string]interface{}{ - "@context": []string{"https://www.w3.org/2018/credentials/v1"}, - "type": []string{"VerifiableCredential"}, - "issuer": "issuer", - "issuanceDate": time.Now().Format(time.RFC3339), - "credentialSubject": map[string]interface{}{"id": "id"}, + Credentials: []CredentialResponseEntry{ + { + Credential: map[string]interface{}{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential"}, + "issuer": "issuer", + "issuanceDate": time.Now().Format(time.RFC3339), + "credentialSubject": map[string]interface{}{"id": "id"}, + }, + }, }, } clientTest := &oidcClientTestContext{ diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index c4892d4996..124c11198e 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -149,23 +149,30 @@ type CredentialRequest struct { Format string `json:"format,omitempty"` // CredentialDefinition contains the credential definition for ldp_vc format. CredentialDefinition *CredentialDefinition `json:"credential_definition,omitempty"` - // Proof contains the proof of possession of the key material. - Proof *CredentialRequestProof `json:"proof,omitempty"` + // Proofs contains the proof(s) of possession of the key material. + // In v1.0 this uses `proofs` (plural) with a map of proof type to array of proofs. + Proofs *CredentialRequestProofs `json:"proofs,omitempty"` } -// CredentialRequestProof defines the proof of possession of key material when requesting a Credential. +// CredentialRequestProofs defines the proof(s) of possession of key material when requesting a Credential. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-proof-types -type CredentialRequestProof struct { - Jwt string `json:"jwt"` - ProofType string `json:"proof_type"` +// The structure is: {"jwt": ["eyJ...", ...]} where the key is the proof type and the value is an array. +type CredentialRequestProofs struct { + Jwt []string `json:"jwt"` } // CredentialResponse defines the response for credential requests. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response +// In v1.0, when proofs (plural) is used in the request, the response uses `credentials` (array of wrapper objects). +// Each element contains a `credential` key holding the actual issued credential. type CredentialResponse struct { - Credential map[string]interface{} `json:"credential,omitempty"` - CNonce *string `json:"c_nonce,omitempty"` - CNonceExpiresIn *int `json:"c_nonce_expires_in,omitempty"` + Credentials []CredentialResponseEntry `json:"credentials,omitempty"` +} + +// CredentialResponseEntry is a single entry in the credentials array of a CredentialResponse. +// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response +type CredentialResponseEntry struct { + Credential map[string]interface{} `json:"credential"` } // Config holds the config for the OpenID4VCI credential issuer and wallet diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go index be51773e84..849735659f 100644 --- a/vcr/openid4vci/types_test.go +++ b/vcr/openid4vci/types_test.go @@ -38,9 +38,8 @@ func TestCredentialRequest_V1Spec(t *testing.T) { // This is the simpler approach - just reference the configuration by ID requestJSON := `{ "credential_configuration_id": "NutsAuthorizationCredential_ldp_vc", - "proof": { - "proof_type": "jwt", - "jwt": "eyJ..." + "proofs": { + "jwt": ["eyJ..."] } }` @@ -50,7 +49,7 @@ func TestCredentialRequest_V1Spec(t *testing.T) { assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", request.CredentialConfigurationId) assert.Empty(t, request.Format, "format should not be required when using credential_configuration_id") - assert.NotNil(t, request.Proof) + assert.NotNil(t, request.Proofs) }) t.Run("request with format + credential_definition (explicit approach)", func(t *testing.T) { @@ -61,9 +60,8 @@ func TestCredentialRequest_V1Spec(t *testing.T) { "@context": ["https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"], "type": ["VerifiableCredential", "NutsAuthorizationCredential"] }, - "proof": { - "proof_type": "jwt", - "jwt": "eyJ..." + "proofs": { + "jwt": ["eyJ..."] } }` @@ -81,9 +79,8 @@ func TestCredentialRequest_V1Spec(t *testing.T) { t.Run("marshaling request with credential_configuration_id omits format and credential_definition", func(t *testing.T) { request := CredentialRequest{ CredentialConfigurationId: "NutsAuthorizationCredential_ldp_vc", - Proof: &CredentialRequestProof{ - ProofType: "jwt", - Jwt: "eyJ...", + Proofs: &CredentialRequestProofs{ + Jwt: []string{"eyJ..."}, }, } @@ -108,9 +105,8 @@ func TestCredentialRequest_V1Spec(t *testing.T) { Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, }, - Proof: &CredentialRequestProof{ - ProofType: "jwt", - Jwt: "eyJ...", + Proofs: &CredentialRequestProofs{ + Jwt: []string{"eyJ..."}, }, } @@ -250,12 +246,12 @@ func TestCredentialIssuerMetadata_V1Spec(t *testing.T) { } // TestCredentialResponse_V1Spec tests that CredentialResponse conforms to OpenID4VCI v1.0 Section 8.3 -// v1.0 removed the format field from the response (it was REQUIRED in Draft 11, removed in Draft 12+) +// v1.0 uses `credentials` (array of wrapper objects with `credential` key) and c_nonce is no longer in the response. func TestCredentialResponse_V1Spec(t *testing.T) { - t.Run("response does not contain format field", func(t *testing.T) { + t.Run("response uses credentials array with credential wrapper", func(t *testing.T) { cred := map[string]interface{}{"issuer": "did:nuts:issuer"} response := CredentialResponse{ - Credential: cred, + Credentials: []CredentialResponseEntry{{Credential: cred}}, } jsonBytes, err := json.Marshal(response) @@ -265,69 +261,23 @@ func TestCredentialResponse_V1Spec(t *testing.T) { err = json.Unmarshal(jsonBytes, &parsed) require.NoError(t, err) - _, hasFormat := parsed["format"] - assert.False(t, hasFormat, "format must not be present in v1.0 credential response") - assert.NotNil(t, parsed["credential"]) - }) - - t.Run("c_nonce is absent when not set", func(t *testing.T) { - cred := map[string]interface{}{"issuer": "did:nuts:issuer"} - response := CredentialResponse{ - Credential: cred, - } - - jsonBytes, err := json.Marshal(response) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal(jsonBytes, &parsed) - require.NoError(t, err) - - _, hasCNonce := parsed["c_nonce"] - assert.False(t, hasCNonce, "c_nonce must be absent when not set") - }) - - t.Run("c_nonce is present when set", func(t *testing.T) { - cred := map[string]interface{}{"issuer": "did:nuts:issuer"} - nonce := "some-nonce" - response := CredentialResponse{ - Credential: cred, - CNonce: &nonce, - } - - jsonBytes, err := json.Marshal(response) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal(jsonBytes, &parsed) - require.NoError(t, err) - - assert.Equal(t, "some-nonce", parsed["c_nonce"]) + // Must use credentials (plural), not credential (singular) at top level + _, hasSingular := parsed["credential"] + assert.False(t, hasSingular, "must use credentials (plural) not credential (singular) at top level") + + // Each element in credentials must be a wrapper with a "credential" key + credentialsArr, ok := parsed["credentials"].([]interface{}) + require.True(t, ok, "credentials must be an array") + require.Len(t, credentialsArr, 1) + entry, ok := credentialsArr[0].(map[string]interface{}) + require.True(t, ok, "each credentials entry must be an object") + assert.NotNil(t, entry["credential"], "each entry must have a credential key") }) - t.Run("c_nonce_expires_in is present when set alongside c_nonce", func(t *testing.T) { - cred := map[string]interface{}{"issuer": "did:nuts:issuer"} - nonce := "some-nonce" - expiresIn := 300 - response := CredentialResponse{ - Credential: cred, - CNonce: &nonce, - CNonceExpiresIn: &expiresIn, - } - jsonBytes, err := json.Marshal(response) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal(jsonBytes, &parsed) - require.NoError(t, err) - - assert.Equal(t, "some-nonce", parsed["c_nonce"]) - assert.Equal(t, float64(300), parsed["c_nonce_expires_in"]) - }) - t.Run("c_nonce_expires_in is absent when not set", func(t *testing.T) { + t.Run("response does not contain c_nonce fields", func(t *testing.T) { cred := map[string]interface{}{"issuer": "did:nuts:issuer"} response := CredentialResponse{ - Credential: cred, + Credentials: []CredentialResponseEntry{{Credential: cred}}, } jsonBytes, err := json.Marshal(response) From 1d3255b7a432223ca0bcb1e256aa1bbe1e5498e9 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 9 Mar 2026 19:38:04 +0100 Subject: [PATCH 06/44] feat(openid4vci): align auth module with v1.0 spec Update auth/ module OpenID4VCI client code for v1.0 compliance: - Use Nonce Endpoint instead of c_nonce from token response - Add credential_configuration_id to credential request and session - Change CredentialResponseEntry.Credential to json.RawMessage - Add invalid_nonce retry logic in callback - Add RequestNonce to IAM client interface - Remove c_nonce_expires_in from NonceResponse - Reject non-string @context/type entries in holder metadata parsing - Regenerate mocks and OpenAPI generated code --- auth/api/iam/openid4vci.go | 68 +++++-- auth/api/iam/openid4vci_test.go | 127 ++++++++++-- auth/api/iam/session.go | 4 + auth/client/iam/client.go | 78 ++++++-- auth/client/iam/interface.go | 5 +- auth/client/iam/mock.go | 24 ++- auth/client/iam/openid4vp.go | 10 +- auth/client/iam/openid4vp_test.go | 87 +++++++- auth/oauth/types.go | 13 +- codegen/configs/vcr_openid4vci_v0.yaml | 3 +- docs/_static/vcr/openid4vci_v0.yaml | 64 ++++-- vcr/api/openid4vci/v0/api.go | 3 + vcr/api/openid4vci/v0/generated.go | 91 ++++++++- vcr/api/openid4vci/v0/holder_test.go | 2 +- vcr/api/openid4vci/v0/issuer.go | 23 ++- vcr/api/openid4vci/v0/issuer_test.go | 38 +++- vcr/holder/openid.go | 97 +++++---- vcr/holder/openid_test.go | 188 +++++++++++++++--- .../NutsAuthorizationCredential.json | 5 + .../NutsOrganizationCredential.json | 5 + vcr/issuer/openid.go | 117 +++++------ vcr/issuer/openid_mock.go | 23 ++- vcr/issuer/openid_store.go | 22 ++ vcr/issuer/openid_store_test.go | 19 ++ vcr/issuer/openid_test.go | 169 ++++++++++++++-- vcr/issuer/test/valid/ExampleCredential.json | 5 + vcr/openid4vci/error.go | 1 - vcr/openid4vci/issuer_client.go | 51 ++++- vcr/openid4vci/issuer_client_mock.go | 17 +- vcr/openid4vci/issuer_client_test.go | 40 ++++ vcr/openid4vci/test.go | 6 + vcr/openid4vci/types.go | 10 + 32 files changed, 1145 insertions(+), 270 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 021c1b7463..789037efb0 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -30,10 +30,12 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" + iamclient "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" nutsHttp "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -81,8 +83,12 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // Read and parse the authorization details authorizationDetails := []byte("[]") + var credentialConfigID string if len(request.Body.AuthorizationDetails) > 0 { authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails) + if id, ok := request.Body.AuthorizationDetails[0]["credential_configuration_id"].(string); ok { + credentialConfigID = id + } } // Generate the state and PKCE state := crypto.GenerateNonce() @@ -102,7 +108,9 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // We must use the token_endpoint that corresponds to the same Authorization Server used for the authorization_endpoint TokenEndpoint: authzServerMetadata.TokenEndpoint, IssuerURL: authzServerMetadata.Issuer, - IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, + IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, + IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, + IssuerCredentialConfigurationId: credentialConfigID, }) if err != nil { return nil, fmt.Errorf("failed to store session: %w", err) @@ -129,8 +137,6 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques } func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode string, oauthSession *OAuthSession) (CallbackResponseObject, error) { - // extract callback URI at calling app from OAuthSession - // this is the URI where the user-agent will be redirected to appCallbackURI := oauthSession.redirectURI() baseURL := r.subjectToBaseURL(*oauthSession.OwnSubject) @@ -142,31 +148,49 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode } // use code to request access token from remote token endpoint - response, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, oauthSession.PKCEParams.Verifier, false) + tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, oauthSession.PKCEParams.Verifier, false) if err != nil { return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", oauthSession.TokenEndpoint, err.Error())), appCallbackURI) } - // make proof and collect credential - proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, response.Get(oauth.CNonceParam)) - if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error building proof to fetch the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) + // fetch nonce from the Nonce Endpoint (v1.0 Section 7) + var nonce string + if oauthSession.IssuerNonceEndpoint != "" { + nonce, err = r.auth.IAMClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI) + } } - credentials, err := r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, response.AccessToken, proofJWT) + + // build proof and request credential + credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) - } - // validate credential - // TODO: check that issued credential is bound to DID that requested it (OwnDID)??? - credential, err := vc.ParseVerifiableCredential(credentials.Credential) + // on invalid_nonce: fetch a fresh nonce and retry once + var oidcErr openid4vci.Error + if errors.As(err, &oidcErr) && oidcErr.Code == openid4vci.InvalidNonce && oauthSession.IssuerNonceEndpoint != "" { + nonce, err = r.auth.IAMClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce for retry from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI) + } + credentialResponse, err = r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) + } + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) + } + } + if len(credentialResponse.Credentials) == 0 { + return nil, withCallbackURI(oauthError(oauth.ServerError, "credential response does not contain any credentials"), appCallbackURI) + } + + credentialJSON := string(credentialResponse.Credentials[0].Credential) + credential, err := vc.ParseVerifiableCredential(credentialJSON) if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentials.Credential, err.Error())), appCallbackURI) + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentialJSON, err.Error())), appCallbackURI) } err = r.vcr.Verifier().Verify(*credential, true, true, nil) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while verifying the credential from issuer: %s, error: %s", credential.Issuer.String(), err.Error())), appCallbackURI) } - // store credential in wallet err = r.vcr.Wallet().Put(ctx, *credential) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while storing credential with id: %s, error: %s", credential.ID, err.Error())), appCallbackURI) @@ -176,6 +200,14 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode }, nil } +func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, nonce string) (*iamclient.CredentialResponse, error) { + proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, nonce) + if err != nil { + return nil, fmt.Errorf("error building proof: %w", err) + } + return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, oauthSession.IssuerCredentialConfigurationId, proofJWT) +} + func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) { kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod) if err != nil { @@ -185,10 +217,6 @@ func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audien "typ": jwtTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. "kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. } - if err != nil { - // can't fail or would have failed before - return "", err - } claims := map[string]interface{}{ jwt.IssuerKey: holderDid.String(), jwt.AudienceKey: audience, // Credential Issuer Identifier diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 957595cbc8..b4310eb9d1 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -20,6 +20,7 @@ package iam import ( "context" + "encoding/json" "errors" "net/url" "testing" @@ -30,6 +31,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -76,7 +78,6 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.Equal(t, "S256", redirectUri.Query().Get("code_challenge_method")) assert.Equal(t, "code", redirectUri.Query().Get("response_type")) assert.Equal(t, `[{"format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) - println(redirectUri.String()) }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { @@ -176,6 +177,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { redirectURI := "https://example.com/oauth2/holder/callback" authServer := "https://auth.server" tokenEndpoint := authServer + "/token" + nonceEndpoint := authServer + "/nonce" cNonce := crypto.GenerateNonce() credEndpoint := authServer + "/credz" pkceParams := generatePKCEParams() @@ -185,30 +187,37 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { verifiableCredential := createIssuerCredential(issuerDID, holderDID) redirectUrl := "https://client.service/issuance_is_done" + credentialConfigID := "NutsOrganizationCredential_ldp_vc" session := OAuthSession{ AuthorizationServerMetadata: &oauth.AuthorizationServerMetadata{ ClientIdSchemesSupported: clientIdSchemesSupported, }, - ClientFlow: "openid4vci_credential_request", - OwnSubject: &holderSubjectID, - OwnDID: &holderDID, - RedirectURI: redirectUrl, - PKCEParams: pkceParams, - TokenEndpoint: tokenEndpoint, - IssuerURL: issuerClientID, - IssuerCredentialEndpoint: credEndpoint, + ClientFlow: "openid4vci_credential_request", + OwnSubject: &holderSubjectID, + OwnDID: &holderDID, + RedirectURI: redirectUrl, + PKCEParams: pkceParams, + TokenEndpoint: tokenEndpoint, + IssuerURL: issuerClientID, + IssuerCredentialEndpoint: credEndpoint, + IssuerNonceEndpoint: nonceEndpoint, + IssuerCredentialConfigurationId: credentialConfigID, } - tokenResponse := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}).With("c_nonce", cNonce) + sessionWithoutNonce := session + sessionWithoutNonce.IssuerNonceEndpoint = "" + + tokenResponse := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"} credentialResponse := iam.CredentialResponse{ - Credential: verifiableCredential.Raw(), + Credentials: []iam.CredentialResponseEntry{{Credential: json.RawMessage(verifiableCredential.Raw())}}, } now := time.Now() timeFunc = func() time.Time { return now } defer func() { timeFunc = time.Now }() - t.Run("ok", func(t *testing.T) { + t.Run("ok - with nonce endpoint", func(t *testing.T) { ctx := newTestClient(t) require.NoError(t, ctx.client.oauthClientStateStore().Put(state, &session)) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { assert.Equal(t, map[string]interface{}{"typ": "openid4vci-proof+jwt", "kid": "kid"}, headers) @@ -221,7 +230,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Equal(t, expectedClaims, claims) return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(nil, *verifiableCredential) @@ -238,6 +247,83 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { actual := callback.(Callback302Response) assert.Equal(t, redirectUrl, actual.Headers.Location) }) + t.Run("ok - no nonce endpoint", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { + _, hasNonce := claims["nonce"] + assert.False(t, hasNonce, "nonce should not be set when no nonce endpoint is configured") + return "signed-proof", nil + }) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionWithoutNonce) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) + t.Run("ok - invalid_nonce retry succeeds", func(t *testing.T) { + ctx := newTestClient(t) + freshNonce := "fresh-nonce" + invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} + + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + // first attempt fails with invalid_nonce + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + // retry with fresh nonce + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(freshNonce, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) + t.Run("error - invalid_nonce retry also fails", func(t *testing.T) { + ctx := newTestClient(t) + invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} + + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + // retry also fails + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("fresh-nonce", nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(nil, errors.New("still failing")) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "error while fetching the credential from endpoint") + }) + t.Run("error - nonce endpoint fails during retry", func(t *testing.T) { + ctx := newTestClient(t) + invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} + + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, invalidNonceErr) + // retry nonce fetch fails + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint down")) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "error fetching nonce for retry") + }) t.Run("fail_access_token", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) @@ -251,9 +337,10 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("fail_credential_response", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(nil, errors.New("FAIL")) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -263,23 +350,25 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("err - invalid credential", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&iam.CredentialResponse{ - Credential: "super invalid", + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&iam.CredentialResponse{ + Credentials: []iam.CredentialResponseEntry{{Credential: json.RawMessage(`"super invalid"`)}}, }, nil) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) assert.Nil(t, callback) - assert.EqualError(t, err, "server_error - error while parsing the credential: super invalid, error: invalid JWT") + assert.ErrorContains(t, err, "error while parsing the credential") }) t.Run("fail_verify", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil).Return(errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -290,6 +379,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - key not found", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("", nil, resolver.ErrKeyNotFound) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -300,6 +390,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - signature failure", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed")) diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 2f4626429b..7629ff19f4 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -55,6 +55,10 @@ type OAuthSession struct { UseDPoP bool `json:"use_dpop,omitempty"` // IssuerCredentialEndpoint: endpoint to exchange the access_token for a credential in the OpenID4VCI flow IssuerCredentialEndpoint string `json:"issuer_credential_endpoint,omitempty"` + // IssuerNonceEndpoint: endpoint to request a fresh c_nonce in the OpenID4VCI flow (v1.0 Section 7) + IssuerNonceEndpoint string `json:"issuer_nonce_endpoint,omitempty"` + // IssuerCredentialConfigurationId: the credential_configuration_id for the credential request in the OpenID4VCI flow + IssuerCredentialConfigurationId string `json:"issuer_credential_configuration_id,omitempty"` } // oauthClientFlow is used by a client to identify the flow a particular callback is part of diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 348adaf354..45beb2be47 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -39,6 +39,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -241,6 +242,32 @@ func (hb HTTPClient) PostAuthorizationResponse(ctx context.Context, vp vc.Verifi return hb.postFormExpectRedirect(ctx, data, verifierResponseURI) } +func (hb HTTPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, nonceEndpoint, http.NoBody) + if err != nil { + return "", err + } + response, err := hb.httpClient.Do(request.WithContext(ctx)) + if err != nil { + return "", fmt.Errorf("nonce request failed: %w", err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("unable to read nonce response: %w", err) + } + if response.StatusCode < 200 || response.StatusCode > 299 { + return "", fmt.Errorf("nonce endpoint returned status %d", response.StatusCode) + } + var nonceResponse struct { + CNonce string `json:"c_nonce"` + } + if err = json.Unmarshal(data, &nonceResponse); err != nil { + return "", fmt.Errorf("unable to unmarshal nonce response: %w", err) + } + return nonceResponse.CNonce, nil +} + func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) { metadataURL, err := oauth.IssuerIdToWellKnown(oauthIssuerURI, oauth.OpenIdCredIssuerWellKnown, hb.strictMode) if err != nil { @@ -307,34 +334,37 @@ func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc { } } -// CredentialRequest represents ths request to fetch a credential, the JSON object holds the proof as -// CredentialRequestProof. +// CredentialRequest represents the request to fetch a credential per OpenID4VCI v1.0 Section 8.2. type CredentialRequest struct { - Proof CredentialRequestProof `json:"proof"` + CredentialConfigurationId string `json:"credential_configuration_id,omitempty"` + Proofs CredentialRequestProofs `json:"proofs"` } -// CredentialRequestProof holds the ProofType and Jwt for a credential request -type CredentialRequestProof struct { - ProofType string `json:"proof_type"` - Jwt string `json:"jwt"` +// CredentialRequestProofs holds the proof(s) of possession keyed by proof type per v1.0 Section 8.2. +type CredentialRequestProofs struct { + Jwt []string `json:"jwt"` } -// CredentialResponse represents the response of a verifiable credential request. -// It contains the Format and the actual Credential in JSON format. +// CredentialResponse represents the response of a verifiable credential request per OpenID4VCI v1.0 Section 8.3. type CredentialResponse struct { - Credential string `json:"credential"` + Credentials []CredentialResponseEntry `json:"credentials"` } -func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJwt string) (*CredentialResponse, error) { +// CredentialResponseEntry is a single entry in the credentials array. +type CredentialResponseEntry struct { + Credential json.RawMessage `json:"credential"` +} + +func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJwt string) (*CredentialResponse, error) { credentialEndpointURL, err := url.Parse(credentialEndpoint) if err != nil { return nil, err } credentialRequest := CredentialRequest{ - Proof: CredentialRequestProof{ - ProofType: "jwt", - Jwt: proofJwt, + CredentialConfigurationId: credentialConfigID, + Proofs: CredentialRequestProofs{ + Jwt: []string{proofJwt}, }, } jsonBody, _ := json.Marshal(credentialRequest) @@ -356,15 +386,23 @@ func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoi log.Logger().WithError(err).Warn("Trouble closing reader") } }(response.Body) - if err = core.TestResponseCode(http.StatusOK, response); err != nil { - return nil, err + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) } - var credential CredentialResponse - if err = json.NewDecoder(response.Body).Decode(&credential); err != nil { + if response.StatusCode < 200 || response.StatusCode > 299 { + var oidcError openid4vci.Error + if json.Unmarshal(responseBody, &oidcError) == nil && oidcError.Code != "" { + oidcError.StatusCode = response.StatusCode + return nil, oidcError + } + return nil, fmt.Errorf("credential request failed (status %d)", response.StatusCode) + } + var credentialResponse CredentialResponse + if err = json.Unmarshal(responseBody, &credentialResponse); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } - return &credential, nil - + return &credentialResponse, nil } func (hb HTTPClient) postFormExpectRedirect(ctx context.Context, form url.Values, redirectURL url.URL) (string, error) { request, err := http.NewRequestWithContext(ctx, http.MethodPost, redirectURL.String(), strings.NewReader(form.Encode())) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5ccf9caaf5..aefd782ef4 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -20,6 +20,7 @@ package iam import ( "context" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -54,8 +55,10 @@ type Client interface { OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) // OpenIDConfiguration returns the OpenID Configuration of the remote wallet. OpenIDConfiguration(ctx context.Context, issuer string) (*oauth.OpenIDConfiguration, error) + // RequestNonce requests a fresh c_nonce from the issuer's Nonce Endpoint (v1.0 Section 7). + RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) // VerifiableCredentials requests Verifiable Credentials from the issuer at the given endpoint. - VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, error) + VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*CredentialResponse, error) // RequestObjectByGet retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a GET method as defined in RFC9101/OpenID4VP. // This method is used when there is no 'request_uri_method', or its value is 'get'. RequestObjectByGet(ctx context.Context, requestURI string) (string, error) diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index b6ad933a61..a7a22e86ce 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -23,7 +23,6 @@ import ( type MockClient struct { ctrl *gomock.Controller recorder *MockClientMockRecorder - isgomock struct{} } // MockClientMockRecorder is the mock recorder for MockClient. @@ -163,6 +162,21 @@ func (mr *MockClientMockRecorder) PresentationDefinition(ctx, endpoint any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockClient)(nil).PresentationDefinition), ctx, endpoint) } +// RequestNonce mocks base method. +func (m *MockClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestNonce", ctx, nonceEndpoint) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestNonce indicates an expected call of RequestNonce. +func (mr *MockClientMockRecorder) RequestNonce(ctx, nonceEndpoint any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNonce", reflect.TypeOf((*MockClient)(nil).RequestNonce), ctx, nonceEndpoint) +} + // RequestObjectByGet mocks base method. func (m *MockClient) RequestObjectByGet(ctx context.Context, requestURI string) (string, error) { m.ctrl.T.Helper() @@ -209,16 +223,16 @@ func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjec } // VerifiableCredentials mocks base method. -func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, proofJWT string) (*CredentialResponse, error) { +func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, proofJWT string) (*CredentialResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, proofJWT) + ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) ret0, _ := ret[0].(*CredentialResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // VerifiableCredentials indicates an expected call of VerifiableCredentials. -func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, proofJWT any) *gomock.Call { +func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, proofJWT) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) } diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index bf7f8fef68..b3bc282a6e 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -355,11 +355,15 @@ func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oa return rsp, nil } -func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, error) { +func (c *OpenID4VPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + return c.httpClient.RequestNonce(ctx, nonceEndpoint) +} + +func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*CredentialResponse, error) { iamClient := c.httpClient - rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, proofJWT) + rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) if err != nil { - return nil, fmt.Errorf("remote server: failed to retrieve credentials: %w", err) + return nil, err } return rsp, nil } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index f4a725a09c..f706c08b15 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -42,6 +42,7 @@ import ( "github.com/nuts-foundation/nuts-node/crypto" http2 "github.com/nuts-foundation/nuts-node/test/http" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -524,6 +525,7 @@ type clientServerTestContext struct { presentationDefinition func(writer http.ResponseWriter) response func(writer http.ResponseWriter) token func(writer http.ResponseWriter) + nonce func(writer http.ResponseWriter) credentials func(writer http.ResponseWriter) requestObjectJWT func(writer http.ResponseWriter) } @@ -578,10 +580,16 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) return }, + nonce: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"c_nonce": "server-nonce"}`)) + return + }, credentials: func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write([]byte(`{"format": "format", "credential": "credential"}`)) + _, _ = writer.Write([]byte(`{"credentials": [{"credential": {"type": "VerifiableCredential"}}]}`)) return }, requestObjectJWT: func(writer http.ResponseWriter) { @@ -628,6 +636,11 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { ctx.token(writer) return } + case "/nonce": + if ctx.nonce != nil { + ctx.nonce(writer) + return + } case "/credentials": if ctx.credentials != nil { ctx.credentials(writer) @@ -681,40 +694,92 @@ func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) { }) } +func TestIAMClient_RequestNonce(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createClientServerTestContext(t) + nonceEndpoint := ctx.tlsServer.URL + "/nonce" + + nonce, err := ctx.client.RequestNonce(context.Background(), nonceEndpoint) + + require.NoError(t, err) + assert.Equal(t, "server-nonce", nonce) + }) + t.Run("error - endpoint not found", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.nonce = nil + nonceEndpoint := ctx.tlsServer.URL + "/nonce" + + nonce, err := ctx.client.RequestNonce(context.Background(), nonceEndpoint) + + assert.Error(t, err) + assert.Empty(t, nonce) + }) +} + func TestIAMClient_VerifiableCredentials(t *testing.T) { accessToken := "code" - proowJWT := "top secret" + proofJWT := "top secret" + credentialConfigID := "NutsOrganizationCredential_ldp_vc" t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) require.NoError(t, err) require.NotNil(t, response) - assert.Equal(t, "credential", response.Credential) + require.Len(t, response.Credentials, 1) + assert.JSONEq(t, `{"type": "VerifiableCredential"}`, string(response.Credentials[0].Credential)) }) - t.Run("error - failed to get access token", func(t *testing.T) { + t.Run("ok - json object credential (ldp_vc)", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.credentials = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"credentials": [{"credential": {"@context": ["https://www.w3.org/2018/credentials/v1"], "type": ["VerifiableCredential"]}}]}`)) + } + + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + require.NoError(t, err) + require.NotNil(t, response) + require.Len(t, response.Credentials, 1) + assert.Contains(t, string(response.Credentials[0].Credential), "VerifiableCredential") + }) + t.Run("error - credential endpoint returns 404", func(t *testing.T) { + ctx := createClientServerTestContext(t) ctx.credentials = nil - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) - assert.EqualError(t, err, "remote server: failed to retrieve credentials: server returned HTTP 404 (expected: 200)") + assert.Error(t, err) assert.Nil(t, response) }) - t.Run("error - invalid access token", func(t *testing.T) { + t.Run("error - structured error on 400", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.credentials = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusBadRequest) + _, _ = writer.Write([]byte(`{"error": "invalid_nonce"}`)) + } + + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + assert.Nil(t, response) + require.Error(t, err) + var oidcErr openid4vci.Error + require.ErrorAs(t, err, &oidcErr) + assert.Equal(t, openid4vci.InvalidNonce, oidcErr.Code) + }) + t.Run("error - invalid response body", func(t *testing.T) { + ctx := createClientServerTestContext(t) ctx.credentials = func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write([]byte(`{"format": "format", "credential": fail}`)) - return + _, _ = writer.Write([]byte(`{"credentials": fail}`)) } - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) assert.Error(t, err) assert.Nil(t, response) diff --git a/auth/oauth/types.go b/auth/oauth/types.go index c0a6d769d2..4224c072ae 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -405,14 +405,11 @@ type Redirect struct { // OpenIDCredentialIssuerMetadata represents the metadata of an OpenID credential issuer type OpenIDCredentialIssuerMetadata struct { - // - CredentialIssuer: an url representing the credential issuer - CredentialIssuer string `json:"credential_issuer"` - // - CredentialEndpoint: an url representing the credential endpoint - CredentialEndpoint string `json:"credential_endpoint"` - // - AuthorizationServers: a slice of urls representing the authorization servers (optional) - AuthorizationServers []string `json:"authorization_servers,omitempty"` - // - Display: a slice of maps where each map represents the display information (optional) - Display []map[string]string `json:"display,omitempty"` + CredentialIssuer string `json:"credential_issuer"` + CredentialEndpoint string `json:"credential_endpoint"` + NonceEndpoint string `json:"nonce_endpoint,omitempty"` + AuthorizationServers []string `json:"authorization_servers,omitempty"` + Display []map[string]string `json:"display,omitempty"` } // OpenIDConfiguration represents the OpenID configuration diff --git a/codegen/configs/vcr_openid4vci_v0.yaml b/codegen/configs/vcr_openid4vci_v0.yaml index 2185dbd020..dd7adee1d8 100644 --- a/codegen/configs/vcr_openid4vci_v0.yaml +++ b/codegen/configs/vcr_openid4vci_v0.yaml @@ -13,4 +13,5 @@ output-options: - CredentialRequest - CredentialResponse - TokenResponse - - ErrorResponse \ No newline at end of file + - ErrorResponse + - NonceResponse \ No newline at end of file diff --git a/docs/_static/vcr/openid4vci_v0.yaml b/docs/_static/vcr/openid4vci_v0.yaml index a793682f49..5710ea5a04 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -6,8 +6,7 @@ info: name: GPLv3 url: https://www.gnu.org/licenses/gpl-3.0.en.html description: > - This API implements OpenID 4 Verifiable Credential Issuance. - The specification is in draft and may change, thus this API might change as well. + This API implements OpenID 4 Verifiable Credential Issuance (v1.0). servers: - url: http://localhost:8081 description: For internal-facing endpoints. @@ -220,10 +219,37 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" - "403": - description: > - Insufficient privileges. Code will be "insufficient_scope". - Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-error-response + "/n2n/identity/{did}/openid4vci/nonce": + post: + tags: + - Issuer + summary: Request a fresh c_nonce value + description: > + Nonce Endpoint per OpenID4VCI v1.0 Section 7. + A Credential Issuer that requires c_nonce values MUST offer this endpoint. + The request has an empty body (Content-Length: 0) and requires no authentication. + operationId: requestNonce + parameters: + - name: did + in: path + required: true + schema: + type: string + example: did:nuts:123 + responses: + "200": + description: OK + headers: + Cache-Control: + schema: + type: string + example: no-store + content: + application/json: + schema: + "$ref": "#/components/schemas/NonceResponse" + "404": + description: Unknown issuer content: application/json: schema: @@ -283,6 +309,12 @@ components: credential_endpoint: type: string example: "https://issuer.example/credential" + nonce_endpoint: + type: string + description: > + URL of the Nonce Endpoint where wallets can request a fresh c_nonce. + Per v1.0 Section 7, a Credential Issuer that requires c_nonce values MUST offer this endpoint. + example: "https://issuer.example/nonce" credential_configurations_supported: type: object description: | @@ -361,17 +393,11 @@ components: description: | The lifetime in seconds of the access token. example: 3600 - c_nonce: - type: string - description: | - JSON string containing a nonce to be used to create a proof of possession of key material when requesting a Credential. When received, the Wallet MUST use this nonce value for its subsequent credential requests until the Credential Issuer provides a fresh nonce. - example: "tZignsnFbp" example: { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..sHQ", "token_type": "bearer", - "expires_in": 3600, - "c_nonce": "tZignsnFbp" + "expires_in": 3600 } CredentialRequest: type: object @@ -505,6 +531,18 @@ components: } } } + NonceResponse: + type: object + description: | + Response from the Nonce Endpoint per OpenID4VCI v1.0 Section 7. + required: + - c_nonce + properties: + c_nonce: + type: string + description: | + A fresh nonce value to be used in the proof of possession. + example: "wKI4LT17ac15ES9bw8ac4" CredentialOfferResponse: type: object description: | diff --git a/vcr/api/openid4vci/v0/api.go b/vcr/api/openid4vci/v0/api.go index 0297535816..94da1cdbd6 100644 --- a/vcr/api/openid4vci/v0/api.go +++ b/vcr/api/openid4vci/v0/api.go @@ -54,6 +54,9 @@ type CredentialResponse = openid4vci.CredentialResponse // OAuth2ClientMetadata is the metadata of the OAuth2 client type OAuth2ClientMetadata = openid4vci.OAuth2ClientMetadata +// NonceResponse is the response of the Nonce Endpoint +type NonceResponse = openid4vci.NonceResponse + type ErrorResponse = openid4vci.Error var _ core.ErrorWriter = (*protocolErrorWriter)(nil) diff --git a/vcr/api/openid4vci/v0/generated.go b/vcr/api/openid4vci/v0/generated.go index 19426c73b4..f70faf2669 100644 --- a/vcr/api/openid4vci/v0/generated.go +++ b/vcr/api/openid4vci/v0/generated.go @@ -57,6 +57,9 @@ type ServerInterface interface { // Used by the issuer to offer credentials to the wallet // (GET /n2n/identity/{did}/openid4vci/credential_offer) HandleCredentialOffer(ctx echo.Context, did string, params HandleCredentialOfferParams) error + // Request a fresh c_nonce value + // (POST /n2n/identity/{did}/openid4vci/nonce) + RequestNonce(ctx echo.Context, did string) error // Used by the wallet to request an access token // (POST /n2n/identity/{did}/token) RequestAccessToken(ctx echo.Context, did string) error @@ -192,6 +195,22 @@ func (w *ServerInterfaceWrapper) HandleCredentialOffer(ctx echo.Context) error { return err } +// RequestNonce converts echo context to params. +func (w *ServerInterfaceWrapper) RequestNonce(ctx echo.Context) error { + var err error + // ------------- Path parameter "did" ------------- + var did string + + err = runtime.BindStyledParameterWithOptions("simple", "did", ctx.Param("did"), &did, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.RequestNonce(ctx, did) + return err +} + // RequestAccessToken converts echo context to params. func (w *ServerInterfaceWrapper) RequestAccessToken(ctx echo.Context) error { var err error @@ -242,6 +261,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/n2n/identity/:did/.well-known/openid-credential-wallet", wrapper.GetOAuth2ClientMetadata) router.POST(baseURL+"/n2n/identity/:did/openid4vci/credential", wrapper.RequestCredential) router.GET(baseURL+"/n2n/identity/:did/openid4vci/credential_offer", wrapper.HandleCredentialOffer) + router.POST(baseURL+"/n2n/identity/:did/openid4vci/nonce", wrapper.RequestNonce) router.POST(baseURL+"/n2n/identity/:did/token", wrapper.RequestAccessToken) } @@ -385,15 +405,6 @@ func (response RequestCredential401JSONResponse) VisitRequestCredentialResponse( return json.NewEncoder(w).Encode(response) } -type RequestCredential403JSONResponse ErrorResponse - -func (response RequestCredential403JSONResponse) VisitRequestCredentialResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(403) - - return json.NewEncoder(w).Encode(response) -} - type RequestCredential404JSONResponse ErrorResponse func (response RequestCredential404JSONResponse) VisitRequestCredentialResponse(w http.ResponseWriter) error { @@ -439,6 +450,40 @@ func (response HandleCredentialOffer404JSONResponse) VisitHandleCredentialOfferR return json.NewEncoder(w).Encode(response) } +type RequestNonceRequestObject struct { + Did string `json:"did"` +} + +type RequestNonceResponseObject interface { + VisitRequestNonceResponse(w http.ResponseWriter) error +} + +type RequestNonce200ResponseHeaders struct { + CacheControl string +} + +type RequestNonce200JSONResponse struct { + Body NonceResponse + Headers RequestNonce200ResponseHeaders +} + +func (response RequestNonce200JSONResponse) VisitRequestNonceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", fmt.Sprint(response.Headers.CacheControl)) + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response.Body) +} + +type RequestNonce404JSONResponse ErrorResponse + +func (response RequestNonce404JSONResponse) VisitRequestNonceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + type RequestAccessTokenRequestObject struct { Did string `json:"did"` Body *RequestAccessTokenFormdataRequestBody @@ -495,6 +540,9 @@ type StrictServerInterface interface { // Used by the issuer to offer credentials to the wallet // (GET /n2n/identity/{did}/openid4vci/credential_offer) HandleCredentialOffer(ctx context.Context, request HandleCredentialOfferRequestObject) (HandleCredentialOfferResponseObject, error) + // Request a fresh c_nonce value + // (POST /n2n/identity/{did}/openid4vci/nonce) + RequestNonce(ctx context.Context, request RequestNonceRequestObject) (RequestNonceResponseObject, error) // Used by the wallet to request an access token // (POST /n2n/identity/{did}/token) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) @@ -670,6 +718,31 @@ func (sh *strictHandler) HandleCredentialOffer(ctx echo.Context, did string, par return nil } +// RequestNonce operation middleware +func (sh *strictHandler) RequestNonce(ctx echo.Context, did string) error { + var request RequestNonceRequestObject + + request.Did = did + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.RequestNonce(ctx.Request().Context(), request.(RequestNonceRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RequestNonce") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(RequestNonceResponseObject); ok { + return validResponse.VisitRequestNonceResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // RequestAccessToken operation middleware func (sh *strictHandler) RequestAccessToken(ctx echo.Context, did string) error { var request RequestAccessTokenRequestObject diff --git a/vcr/api/openid4vci/v0/holder_test.go b/vcr/api/openid4vci/v0/holder_test.go index 778ba4c41c..2e1be144e2 100644 --- a/vcr/api/openid4vci/v0/holder_test.go +++ b/vcr/api/openid4vci/v0/holder_test.go @@ -87,7 +87,7 @@ func TestWrapper_HandleCredentialOffer(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ CredentialIssuer: issuerDID.String(), - CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}, + CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, Grants: openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "code", diff --git a/vcr/api/openid4vci/v0/issuer.go b/vcr/api/openid4vci/v0/issuer.go index 16e9c2531c..3a3d36c7d8 100644 --- a/vcr/api/openid4vci/v0/issuer.go +++ b/vcr/api/openid4vci/v0/issuer.go @@ -23,7 +23,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "net/http" @@ -111,6 +110,22 @@ func (w Wrapper) RequestCredential(ctx context.Context, request RequestCredentia }), nil } +// RequestNonce handles a request to the Nonce Endpoint. +func (w Wrapper) RequestNonce(ctx context.Context, request RequestNonceRequestObject) (RequestNonceResponseObject, error) { + issuerHandler, err := w.getIssuerHandler(ctx, request.Did) + if err != nil { + return nil, err + } + nonce, err := issuerHandler.HandleNonceRequest(ctx) + if err != nil { + return nil, err + } + return RequestNonce200JSONResponse{ + Body: NonceResponse{CNonce: nonce}, + Headers: RequestNonce200ResponseHeaders{CacheControl: "no-store"}, + }, nil +} + // RequestAccessToken requests an OAuth2 access token from the given DID. func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) { issuerHandler, err := w.getIssuerHandler(ctx, request.Did) @@ -125,14 +140,14 @@ func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo StatusCode: http.StatusBadRequest, } } - accessToken, cNonce, err := issuerHandler.HandleAccessTokenRequest(ctx, request.Body.PreAuthorizedCode) + accessToken, err := issuerHandler.HandleAccessTokenRequest(ctx, request.Body.PreAuthorizedCode) if err != nil { return nil, err } expiresIn := int(issuer.TokenTTL.Seconds()) - return RequestAccessToken200JSONResponse(*(&TokenResponse{ + return RequestAccessToken200JSONResponse(TokenResponse{ AccessToken: accessToken, ExpiresIn: &expiresIn, TokenType: "bearer", - }).With(oauth.CNonceParam, cNonce)), nil + }), nil } diff --git a/vcr/api/openid4vci/v0/issuer_test.go b/vcr/api/openid4vci/v0/issuer_test.go index 7ef8d36c57..a14be6f572 100644 --- a/vcr/api/openid4vci/v0/issuer_test.go +++ b/vcr/api/openid4vci/v0/issuer_test.go @@ -22,7 +22,6 @@ import ( "context" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" - oauth2 "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -109,7 +108,7 @@ func TestWrapper_RequestAccessToken(t *testing.T) { t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) oidcIssuer := issuer.NewMockOpenIDHandler(ctrl) - oidcIssuer.EXPECT().HandleAccessTokenRequest(gomock.Any(), "code").Return("access-token", "c_nonce", nil) + oidcIssuer.EXPECT().HandleAccessTokenRequest(gomock.Any(), "code").Return("access-token", nil) documentOwner := didsubject.NewMockDocumentOwner(ctrl) documentOwner.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(true, nil) vdr := vdr.NewMockVDR(ctrl) @@ -127,7 +126,6 @@ func TestWrapper_RequestAccessToken(t *testing.T) { require.NoError(t, err) assert.Equal(t, "access-token", response.(RequestAccessToken200JSONResponse).AccessToken) - assert.Equal(t, "c_nonce", oauth2.TokenResponse(response.(RequestAccessToken200JSONResponse)).Get("c_nonce")) }) t.Run("unknown tenant", func(t *testing.T) { ctrl := gomock.NewController(t) @@ -169,6 +167,40 @@ func TestWrapper_RequestAccessToken(t *testing.T) { }) } +func TestWrapper_RequestNonce(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctrl := gomock.NewController(t) + oidcIssuer := issuer.NewMockOpenIDHandler(ctrl) + oidcIssuer.EXPECT().HandleNonceRequest(gomock.Any()).Return("test-nonce-value", nil) + documentOwner := didsubject.NewMockDocumentOwner(ctrl) + documentOwner.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(true, nil) + vdr := vdr.NewMockVDR(ctrl) + vdr.EXPECT().DocumentOwner().Return(documentOwner).AnyTimes() + service := vcr.NewMockVCR(ctrl) + service.EXPECT().GetOpenIDIssuer(gomock.Any(), issuerDID).Return(oidcIssuer, nil) + api := Wrapper{VCR: service, VDR: vdr} + + response, err := api.RequestNonce(context.Background(), RequestNonceRequestObject{Did: issuerDID.String()}) + + require.NoError(t, err) + jsonResponse := response.(RequestNonce200JSONResponse) + assert.Equal(t, "test-nonce-value", jsonResponse.Body.CNonce) + assert.Equal(t, "no-store", jsonResponse.Headers.CacheControl) + }) + t.Run("unknown tenant", func(t *testing.T) { + ctrl := gomock.NewController(t) + documentOwner := didsubject.NewMockDocumentOwner(ctrl) + documentOwner.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(false, nil) + vdr := vdr.NewMockVDR(ctrl) + vdr.EXPECT().DocumentOwner().Return(documentOwner).AnyTimes() + api := Wrapper{VDR: vdr} + + _, err := api.RequestNonce(context.Background(), RequestNonceRequestObject{Did: issuerDID.String()}) + + require.EqualError(t, err, "invalid_request - DID is not owned by this node") + }) +} + func TestWrapper_RequestCredential(t *testing.T) { t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index b2cf741b44..f89727bb9c 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -156,10 +156,6 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } } - // Note: in v1.0, c_nonce is no longer in the token response (moved to optional Nonce Endpoint). - // For now we still pass the c_nonce from the token response if present (backwards compat with - // issuers that still include it), but we no longer require it. - retrieveCtx := audit.Context(ctx, "app-openid4vci", "VCR/OpenID4VCI", "RetrieveCredential") credential, err := h.retrieveCredential(retrieveCtx, issuerClient, credentialConfigID, accessTokenResponse) if err != nil { @@ -211,26 +207,30 @@ func (h *openidHandler) resolveCredentialConfiguration(metadata openid4vci.Crede // Parse @context if contextRaw, ok := credDefMap["@context"].([]interface{}); ok { for _, c := range contextRaw { - if cStr, ok := c.(string); ok { - u, err := ssi.ParseURI(cStr) - if err != nil { - return nil, fmt.Errorf("invalid @context URI %q: %w", cStr, err) - } - credentialDef.Context = append(credentialDef.Context, *u) + cStr, ok := c.(string) + if !ok { + return nil, fmt.Errorf("invalid @context entry: expected string, got %T", c) + } + u, err := ssi.ParseURI(cStr) + if err != nil { + return nil, fmt.Errorf("invalid @context URI %q: %w", cStr, err) } + credentialDef.Context = append(credentialDef.Context, *u) } } // Parse type if typeRaw, ok := credDefMap["type"].([]interface{}); ok { for _, t := range typeRaw { - if tStr, ok := t.(string); ok { - u, err := ssi.ParseURI(tStr) - if err != nil { - return nil, fmt.Errorf("invalid type URI %q: %w", tStr, err) - } - credentialDef.Type = append(credentialDef.Type, *u) + tStr, ok := t.(string) + if !ok { + return nil, fmt.Errorf("invalid type entry: expected string, got %T", t) + } + u, err := ssi.ParseURI(tStr) + if err != nil { + return nil, fmt.Errorf("invalid type URI %q: %w", tStr, err) } + credentialDef.Type = append(credentialDef.Type, *u) } } @@ -251,30 +251,49 @@ func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient ope if err != nil { return nil, err } - headers := map[string]interface{}{ - "typ": openid4vci.JWTTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. - "kid": keyID, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. - } - claims := map[string]interface{}{ - "aud": issuerClient.Metadata().CredentialIssuer, - "iat": nowFunc().Unix(), - } - // Include c_nonce in proof if available (from token response or future Nonce Endpoint) - if cNonce := tokenResponse.Get(oauth.CNonceParam); cNonce != "" { - claims["nonce"] = cNonce - } - proof, err := h.signer.SignJWT(ctx, claims, headers, keyID) - if err != nil { - return nil, fmt.Errorf("unable to sign request proof: %w", err) - } + const maxAttempts = 2 + for attempt := range maxAttempts { + headers := map[string]interface{}{ + "typ": openid4vci.JWTTypeOpenID4VCIProof, + "kid": keyID, + } + claims := map[string]interface{}{ + "aud": issuerClient.Metadata().CredentialIssuer, + "iat": nowFunc().Unix(), + } - // Use credential_configuration_id (v1.0 preferred approach) instead of format + credential_definition - credentialRequest := openid4vci.CredentialRequest{ - CredentialConfigurationId: credentialConfigID, - Proofs: &openid4vci.CredentialRequestProofs{ - Jwt: []string{proof}, - }, + // Per v1.0 Section 7, fetch nonce from Nonce Endpoint when advertised + if issuerClient.Metadata().NonceEndpoint != "" { + nonceResponse, nonceErr := issuerClient.RequestNonce(ctx) + if nonceErr != nil { + return nil, fmt.Errorf("unable to request nonce: %w", nonceErr) + } + claims["nonce"] = nonceResponse.CNonce + } + + proof, signErr := h.signer.SignJWT(ctx, claims, headers, keyID) + if signErr != nil { + return nil, fmt.Errorf("unable to sign request proof: %w", signErr) + } + + credentialRequest := openid4vci.CredentialRequest{ + CredentialConfigurationId: credentialConfigID, + Proofs: &openid4vci.CredentialRequestProofs{ + Jwt: []string{proof}, + }, + } + credential, reqErr := issuerClient.RequestCredential(ctx, credentialRequest, tokenResponse.AccessToken) + if reqErr != nil { + // On invalid_nonce, fetch a fresh nonce and retry once (v1.0 Section 8.3.1.2) + var protocolErr openid4vci.Error + if attempt == 0 && errors.As(reqErr, &protocolErr) && protocolErr.Code == openid4vci.InvalidNonce { + log.Logger().Debug("Received invalid_nonce, retrying with fresh nonce") + continue + } + return nil, reqErr + } + return credential, nil } - return issuerClient.RequestCredential(ctx, credentialRequest, tokenResponse.AccessToken) + return nil, errors.New("credential request failed after nonce retry") } diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index 5027c41c88..f1fad4f577 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -21,6 +21,10 @@ package holder import ( "context" "errors" + "net/http" + "testing" + "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -34,9 +38,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "net/http" - "testing" - "time" ) var holderDID = did.MustParseDID("did:nuts:holder") @@ -60,7 +61,7 @@ func Test_wallet_Metadata(t *testing.T) { func Test_wallet_HandleCredentialOffer(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ CredentialIssuer: issuerDID.String(), - CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}, + CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, Grants: openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "code", @@ -71,50 +72,48 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { CredentialIssuer: issuerDID.String(), CredentialEndpoint: "credential-endpoint", CredentialConfigurationsSupported: map[string]map[string]interface{}{ - "HumanCredential_ldp_vc": { + "ExampleCredential_ldp_vc": { "format": "ldp_vc", "credential_definition": map[string]interface{}{ "@context": []interface{}{ "https://www.w3.org/2018/credentials/v1", - "http://example.org/credentials/V1", + "https://example.com/credentials/v1", }, "type": []interface{}{ "VerifiableCredential", - "HumanCredential", + "ExampleCredential", }, }, }, }, } - nonce := "nonsens" t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() - tokenResponse := (&oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"}).With("c_nonce", nonce) + tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ "pre-authorized_code": "code", }).Return(tokenResponse, nil) // Verify that the holder sends credential_configuration_id (v1.0 preferred approach) // instead of format + credential_definition expectedRequest := openid4vci.CredentialRequest{ - CredentialConfigurationId: "HumanCredential_ldp_vc", + CredentialConfigurationId: "ExampleCredential_ldp_vc", Proofs: &openid4vci.CredentialRequestProofs{ Jwt: []string{"signed-jwt"}, }, } issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), expectedRequest, "access-token"). Return(&vc.VerifiableCredential{ - Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("http://example.org/credentials/V1")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("HumanCredential")}, + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://example.com/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("ExampleCredential")}, Issuer: issuerDID.URI()}, nil) credentialStore := types.NewMockWriter(ctrl) jwtSigner := crypto.NewMockJWTSigner(ctrl) jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ - "aud": issuerDID.String(), - "iat": int64(1735689600), - "nonce": nonce, + "aud": issuerDID.String(), + "iat": int64(1735689600), }, gomock.Any(), "key-id").Return("signed-jwt", nil) keyResolver := resolver.NewMockKeyResolver(ctrl) keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) @@ -137,13 +136,13 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { t.Run("pre-authorized code grant", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) t.Run("no grants", func(t *testing.T) { - offer := openid4vci.CredentialOffer{CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}} + offer := openid4vci.CredentialOffer{CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}} err := w.HandleCredentialOffer(audit.TestContext(), offer) require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) t.Run("no pre-authorized grant", func(t *testing.T) { offer := openid4vci.CredentialOffer{ - CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}, + CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, Grants: openid4vci.CredentialOfferGrants{}, } err := w.HandleCredentialOffer(audit.TestContext(), offer) @@ -151,7 +150,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { }) t.Run("empty pre-authorized code", func(t *testing.T) { offer := openid4vci.CredentialOffer{ - CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}, + CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, Grants: openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "", @@ -166,7 +165,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) offer := openid4vci.CredentialOffer{ - CredentialConfigurationIds: []string{"HumanCredential_ldp_vc", "OtherCredential_ldp_vc"}, + CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc", "OtherCredential_ldp_vc"}, } err := w.HandleCredentialOffer(audit.TestContext(), offer).(openid4vci.Error) @@ -216,7 +215,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ CredentialIssuer: "http://localhost:87632", - CredentialConfigurationIds: []string{"HumanCredential_ldp_vc"}, + CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, Grants: openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "foo", @@ -232,7 +231,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return((&oauth.TokenResponse{AccessToken: "access-token"}).With("c_nonce", nonce), nil) + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "access-token"}, nil) issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), gomock.Any()).Return(&vc.VerifiableCredential{ Context: offer.CredentialDefinition.Context, Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, @@ -298,7 +297,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { }, } issuerAPIClient.EXPECT().Metadata().Return(metadataWithSubject).AnyTimes() - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return((&oauth.TokenResponse{AccessToken: "access-token"}).With("c_nonce", nonce), nil) + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "access-token"}, nil) issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), gomock.Any()).Return(&vc.VerifiableCredential{ Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, @@ -329,6 +328,147 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { }) } +func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { + credentialOffer := openid4vci.CredentialOffer{ + CredentialIssuer: issuerDID.String(), + CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, + Grants: openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "code", + }, + }, + } + nonce := "nonce-from-endpoint" + metadataWithNonce := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialEndpoint: "credential-endpoint", + NonceEndpoint: "https://issuer.example/nonce", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "ExampleCredential_ldp_vc": { + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{ + "https://www.w3.org/2018/credentials/v1", + "https://example.com/credentials/v1", + }, + "type": []interface{}{ + "VerifiableCredential", + "ExampleCredential", + }, + }, + }, + }, + } + + t.Run("uses Nonce Endpoint when advertised", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() + issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: nonce}, nil) + tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} + issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ + "pre-authorized_code": "code", + }).Return(tokenResponse, nil) + expectedRequest := openid4vci.CredentialRequest{ + CredentialConfigurationId: "ExampleCredential_ldp_vc", + Proofs: &openid4vci.CredentialRequestProofs{ + Jwt: []string{"signed-jwt"}, + }, + } + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), expectedRequest, "access-token"). + Return(&vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://example.com/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("ExampleCredential")}, + Issuer: issuerDID.URI()}, nil) + + credentialStore := types.NewMockWriter(ctrl) + jwtSigner := crypto.NewMockJWTSigner(ctrl) + nowFunc = func() time.Time { + return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + } + jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ + "aud": issuerDID.String(), + "iat": int64(1767225600), + "nonce": nonce, + }, gomock.Any(), "key-id").Return("signed-jwt", nil) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + credentialStore.EXPECT().StoreCredential(gomock.Any(), nil).Return(nil) + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.NoError(t, err) + }) + t.Run("retries on invalid_nonce", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() + // First nonce request → used in first attempt (which fails with invalid_nonce) + // Second nonce request → used in retry (which succeeds) + first := issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: "stale-nonce"}, nil) + issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: nonce}, nil).After(first) + tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} + issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ + "pre-authorized_code": "code", + }).Return(tokenResponse, nil) + // First credential request fails with invalid_nonce + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). + Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) + // Retry succeeds + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). + Return(&vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://example.com/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("ExampleCredential")}, + Issuer: issuerDID.URI()}, nil) + + credentialStore := types.NewMockWriter(ctrl) + jwtSigner := crypto.NewMockJWTSigner(ctrl) + nowFunc = func() time.Time { + return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + } + // Two sign calls: one for each attempt + jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "key-id").Return("signed-jwt", nil).Times(2) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + credentialStore.EXPECT().StoreCredential(gomock.Any(), nil).Return(nil) + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.NoError(t, err) + }) + t.Run("error - nonce endpoint request fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() + issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(nil, errors.New("nonce request failed")) + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "access-token"}, nil) + jwtSigner := crypto.NewMockJWTSigner(ctrl) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, jwtSigner, keyResolver).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.EqualError(t, err, "server_error - unable to retrieve credential: unable to request nonce: nonce request failed") + }) +} + // offeredCredential returns a resolved credential configuration for testing. func offeredCredential() []openid4vci.OfferedCredential { return []openid4vci.OfferedCredential{{ @@ -336,11 +476,11 @@ func offeredCredential() []openid4vci.OfferedCredential { CredentialDefinition: &openid4vci.CredentialDefinition{ Context: []ssi.URI{ ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - ssi.MustParseURI("http://example.org/credentials/V1"), + ssi.MustParseURI("https://example.com/credentials/v1"), }, Type: []ssi.URI{ ssi.MustParseURI("VerifiableCredential"), - ssi.MustParseURI("HumanCredential"), + ssi.MustParseURI("ExampleCredential"), }, }, }} diff --git a/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json b/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json index 2118108624..735330e19a 100644 --- a/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json +++ b/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json @@ -1,5 +1,10 @@ { "format": "ldp_vc", + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] + } + }, "cryptographic_binding_methods_supported": [ "did:nuts" ], diff --git a/vcr/issuer/assets/definitions/NutsOrganizationCredential.json b/vcr/issuer/assets/definitions/NutsOrganizationCredential.json index 17c33f361a..6bec97eaad 100644 --- a/vcr/issuer/assets/definitions/NutsOrganizationCredential.json +++ b/vcr/issuer/assets/definitions/NutsOrganizationCredential.json @@ -1,5 +1,10 @@ { "format": "ldp_vc", + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] + } + }, "cryptographic_binding_methods_supported": [ "did:nuts" ], diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index 5bc98d2eca..2c03cec720 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -80,21 +80,23 @@ const TokenTTL = 15 * time.Minute const preAuthCodeRefType = "preauthcode" const accessTokenRefType = "accesstoken" -const cNonceRefType = "c_nonce" // OpenIDHandler defines the interface for handling OpenID4VCI issuer operations. type OpenIDHandler interface { // ProviderMetadata returns the OpenID Connect provider metadata. ProviderMetadata() openid4vci.ProviderMetadata // HandleAccessTokenRequest handles an OAuth2 access token request for the given issuer and pre-authorized code. - // It returns the access token and a c_nonce. - HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, string, error) + // It returns the access token. + HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, error) // Metadata returns the OpenID4VCI credential issuer metadata for the given issuer. Metadata() openid4vci.CredentialIssuerMetadata // OfferCredential sends a credential offer to the specified wallet. It derives the issuer from the credential. OfferCredential(ctx context.Context, credential vc.VerifiableCredential, walletIdentifier string) error // HandleCredentialRequest requests a credential from the given issuer. HandleCredentialRequest(ctx context.Context, request openid4vci.CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) + // HandleNonceRequest handles a request to the Nonce Endpoint (v1.0 Section 7). + // It generates a standalone nonce and returns it. + HandleNonceRequest(ctx context.Context) (string, error) } // NewOpenIDHandler creates a new OpenIDHandler instance. The identifier is the Credential Issuer Identifier, e.g. https://example.com/issuer/ @@ -128,6 +130,7 @@ func (i *openidHandler) Metadata() openid4vci.CredentialIssuerMetadata { metadata := openid4vci.CredentialIssuerMetadata{ CredentialIssuer: i.issuerIdentifierURL, CredentialEndpoint: core.JoinURLPaths(i.issuerIdentifierURL, "/openid4vci/credential"), + NonceEndpoint: core.JoinURLPaths(i.issuerIdentifierURL, "/openid4vci/nonce"), } // deepcopy the credentialConfigurationsSupported map to prevent concurrent access. @@ -147,20 +150,20 @@ func (i *openidHandler) ProviderMetadata() openid4vci.ProviderMetadata { } } -func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, string, error) { +func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, error) { flow, err := i.store.FindByReference(ctx, preAuthCodeRefType, preAuthorizedCode) if err != nil { - return "", "", err + return "", err } if flow == nil { - return "", "", openid4vci.Error{ + return "", openid4vci.Error{ Err: errors.New("unknown pre-authorized code"), Code: openid4vci.InvalidGrant, StatusCode: http.StatusBadRequest, } } if flow.IssuerID != i.issuerDID.String() { - return "", "", openid4vci.Error{ + return "", openid4vci.Error{ Err: errors.New("pre-authorized code not issued by this issuer"), Code: openid4vci.InvalidGrant, StatusCode: http.StatusBadRequest, @@ -169,12 +172,7 @@ func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthori accessToken := crypto.GenerateNonce() err = i.store.StoreReference(ctx, flow.ID, accessTokenRefType, accessToken) if err != nil { - return "", "", err - } - cNonce := crypto.GenerateNonce() - err = i.store.StoreReference(ctx, flow.ID, cNonceRefType, cNonce) - if err != nil { - return "", "", err + return "", err } // PreAuthorizedCode is to be used just once @@ -186,7 +184,7 @@ func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthori // Just log it, nothing will break (since they'll be pruned after ttl anyway). log.Logger().WithError(err).Error("Failed to delete pre-authorized code") } - return accessToken, cNonce, nil + return accessToken, nil } func (i *openidHandler) OfferCredential(ctx context.Context, credential vc.VerifiableCredential, walletIdentifier string) error { @@ -281,29 +279,37 @@ func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request ope return &credential, nil } +func (i *openidHandler) HandleNonceRequest(ctx context.Context) (string, error) { + nonce := crypto.GenerateNonce() + if err := i.store.StoreNonce(ctx, nonce); err != nil { + return "", err + } + return nonce, nil +} + // validateProof validates the proof of the credential request. Aside from checks as specified by the spec, // it verifies the proof signature, and whether the signer is the intended wallet. +// The validation is metadata-driven: proof is only required if the credential configuration +// includes proof_types_supported. Nonce is only required if the issuer advertises a nonce_endpoint. // See https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-proof-types func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request openid4vci.CredentialRequest) error { - credential := flow.Credentials[0] // there's always just one (at least for now) - wallet, _ := credential.SubjectDID() - - // In v1.0, error responses no longer contain c_nonce (wallet should use Nonce Endpoint). - // We still store a new c_nonce server-side so the wallet can retry after obtaining one. - generateProofError := func(err openid4vci.Error) error { - cnonce := crypto.GenerateNonce() - if storeErr := i.store.StoreReference(ctx, flow.ID, cNonceRefType, cnonce); storeErr != nil { - return storeErr + // Check if the credential configuration requires proof + credConfig, ok := i.credentialConfigurationsSupported[request.CredentialConfigurationId] + if ok { + if _, hasProofTypes := credConfig["proof_types_supported"]; !hasProofTypes { + return nil // no proof required for this credential configuration } - return err } + credential := flow.Credentials[0] // there's always just one (at least for now) + wallet, _ := credential.SubjectDID() + if request.Proofs == nil || len(request.Proofs.Jwt) == 0 { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: errors.New("missing proofs"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } // We only support single proof for now proofJWT := request.Proofs.Jwt[0] @@ -313,20 +319,20 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o return i.keyResolver.ResolveKeyByID(kid, nil, resolver.NutsSigningKeyType) }, openID4VCIProofProfile, nil) if err != nil { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: err, Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } // Proof must be signed by wallet to which it was offered (proof signer == offer receiver) if signerDID, err := resolver.GetDIDFromURL(signingKeyID); err != nil || signerDID.String() != wallet.String() { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: fmt.Errorf("credential offer was signed by other DID than intended wallet: %s", signingKeyID), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } // Validate audience @@ -338,11 +344,11 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o } } if !audienceMatches { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: fmt.Errorf("audience doesn't match credential issuer (aud=%s)", token.Audience()), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } // Validate JWT type @@ -358,52 +364,49 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o } typ := message.Signatures()[0].ProtectedHeaders().Type() if typ == "" { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: errors.New("missing typ header"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } if typ != openid4vci.JWTTypeOpenID4VCIProof { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: fmt.Errorf("invalid typ claim (expected: %s): %s", openid4vci.JWTTypeOpenID4VCIProof, typ), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } + } + + // Nonce validation: only required if the issuer advertises a nonce_endpoint + metadata := i.Metadata() + if metadata.NonceEndpoint == "" { + return nil // no nonce required } // given the JWT typ, the nonce is in the 'nonce' claim nonce, ok := token.Get("nonce") if !ok { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: errors.New("missing nonce claim"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } - // check if the nonce matches the one we sent in the offer - flowFromNonce, err := i.store.FindByReference(ctx, cNonceRefType, nonce.(string)) - if err != nil { - return err - } - if flowFromNonce == nil { - return generateProofError(openid4vci.Error{ - Err: errors.New("unknown nonce"), - Code: openid4vci.InvalidNonce, - StatusCode: http.StatusBadRequest, - }) - } - if flowFromNonce.ID != flow.ID { - return generateProofError(openid4vci.Error{ - Err: errors.New("nonce not valid for access token"), - Code: openid4vci.InvalidNonce, - StatusCode: http.StatusBadRequest, - }) + nonceValue := nonce.(string) + + // Validate nonce from Nonce Endpoint (v1.0 Section 7) + if i.store.ConsumeNonce(ctx, nonceValue) { + return nil } - return nil + return openid4vci.Error{ + Err: errors.New("invalid or expired nonce"), + Code: openid4vci.InvalidNonce, + StatusCode: http.StatusBadRequest, + } } func (i *openidHandler) createOffer(ctx context.Context, credential vc.VerifiableCredential, preAuthorizedCode string) (*openid4vci.CredentialOffer, error) { diff --git a/vcr/issuer/openid_mock.go b/vcr/issuer/openid_mock.go index 1eb709fca9..959eaa1e44 100644 --- a/vcr/issuer/openid_mock.go +++ b/vcr/issuer/openid_mock.go @@ -22,7 +22,6 @@ import ( type MockOpenIDHandler struct { ctrl *gomock.Controller recorder *MockOpenIDHandlerMockRecorder - isgomock struct{} } // MockOpenIDHandlerMockRecorder is the mock recorder for MockOpenIDHandler. @@ -43,13 +42,12 @@ func (m *MockOpenIDHandler) EXPECT() *MockOpenIDHandlerMockRecorder { } // HandleAccessTokenRequest mocks base method. -func (m *MockOpenIDHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, string, error) { +func (m *MockOpenIDHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HandleAccessTokenRequest", ctx, preAuthorizedCode) ret0, _ := ret[0].(string) - ret1, _ := ret[1].(string) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 + ret1, _ := ret[1].(error) + return ret0, ret1 } // HandleAccessTokenRequest indicates an expected call of HandleAccessTokenRequest. @@ -73,6 +71,21 @@ func (mr *MockOpenIDHandlerMockRecorder) HandleCredentialRequest(ctx, request, a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleCredentialRequest", reflect.TypeOf((*MockOpenIDHandler)(nil).HandleCredentialRequest), ctx, request, accessToken) } +// HandleNonceRequest mocks base method. +func (m *MockOpenIDHandler) HandleNonceRequest(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleNonceRequest", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HandleNonceRequest indicates an expected call of HandleNonceRequest. +func (mr *MockOpenIDHandlerMockRecorder) HandleNonceRequest(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleNonceRequest", reflect.TypeOf((*MockOpenIDHandler)(nil).HandleNonceRequest), ctx) +} + // Metadata mocks base method. func (m *MockOpenIDHandler) Metadata() openid4vci.CredentialIssuerMetadata { m.ctrl.T.Helper() diff --git a/vcr/issuer/openid_store.go b/vcr/issuer/openid_store.go index 0471301164..1bb7df72dd 100644 --- a/vcr/issuer/openid_store.go +++ b/vcr/issuer/openid_store.go @@ -40,6 +40,12 @@ type OpenIDStore interface { // DeleteReference deletes the reference from the store. // It does not return an error if it doesn't exist anymore. DeleteReference(ctx context.Context, refType string, reference string) error + // StoreNonce stores a standalone nonce (not tied to a flow) with TTL. + // Used by the Nonce Endpoint (v1.0 Section 7). + StoreNonce(ctx context.Context, nonce string) error + // ConsumeNonce atomically checks whether a standalone nonce exists and deletes it (single-use). + // Returns true if the nonce was valid (existed and was consumed), false otherwise. + ConsumeNonce(ctx context.Context, nonce string) bool } var _ OpenIDStore = (*openidMemoryStore)(nil) @@ -101,3 +107,19 @@ func (o *openidMemoryStore) DeleteReference(_ context.Context, refType string, r refStore := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", refType) return refStore.Delete(reference) } + +const standaloneNonceStoreKey = "standalone_nonce" + +func (o *openidMemoryStore) StoreNonce(_ context.Context, nonce string) error { + store := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", standaloneNonceStoreKey) + return store.Put(nonce, true) +} + +func (o *openidMemoryStore) ConsumeNonce(_ context.Context, nonce string) bool { + store := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", standaloneNonceStoreKey) + var value bool + if err := store.GetAndDelete(nonce, &value); err != nil { + return false + } + return value +} diff --git a/vcr/issuer/openid_store_test.go b/vcr/issuer/openid_store_test.go index 9fcbf80109..fe4d64f20c 100644 --- a/vcr/issuer/openid_store_test.go +++ b/vcr/issuer/openid_store_test.go @@ -119,6 +119,25 @@ func Test_memoryStore_Store(t *testing.T) { }) } +func Test_memoryStore_StandaloneNonce(t *testing.T) { + ctx := context.Background() + t.Run("store and validate", func(t *testing.T) { + store := createStore(t) + err := store.StoreNonce(ctx, "test-nonce") + assert.NoError(t, err) + + // First check should succeed and consume the nonce + assert.True(t, store.ConsumeNonce(ctx, "test-nonce")) + + // Second check should fail (single-use) + assert.False(t, store.ConsumeNonce(ctx, "test-nonce")) + }) + t.Run("unknown nonce", func(t *testing.T) { + store := createStore(t) + assert.False(t, store.ConsumeNonce(ctx, "unknown")) + }) +} + func createStore(t *testing.T) *openidMemoryStore { storageDatabase := storage.NewTestInMemorySessionDatabase(t) store := NewOpenIDMemoryStore(storageDatabase).(*openidMemoryStore) diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index 71774da2ce..d52f05a90f 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -95,6 +95,7 @@ func Test_memoryIssuer_Metadata(t *testing.T) { assert.Equal(t, "https://example.com/did:nuts:issuer", metadata.CredentialIssuer) assert.Equal(t, "https://example.com/did:nuts:issuer/openid4vci/credential", metadata.CredentialEndpoint) + assert.Equal(t, "https://example.com/did:nuts:issuer/openid4vci/nonce", metadata.NonceEndpoint) require.Len(t, metadata.CredentialConfigurationsSupported, 3) // Assert all 3 config IDs by name for _, expectedID := range []string{ @@ -185,10 +186,12 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { service := requireNewTestHandler(t, keyResolver) offer, err := service.createOffer(ctx, issuedVC, preAuthCode) require.NoError(t, err) - accessToken, cNonce, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + nonce, err := service.HandleNonceRequest(ctx) require.NoError(t, err) configID := offer.CredentialConfigurationIds[0] - validRequest := createRequest(createHeaders(), createClaims(cNonce), configID) + validRequest := createRequest(createHeaders(), createClaims(nonce), configID) t.Run("ok", func(t *testing.T) { auditLogs := audit.CaptureAuditLogs(t) @@ -201,7 +204,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { }) t.Run("error - missing credential_configuration_id", func(t *testing.T) { request := openid4vci.CredentialRequest{ - Proofs: createProofs(createHeaders(), createClaims(cNonce)), + Proofs: createProofs(createHeaders(), createClaims(nonce)), } response, err := service.HandleCredentialRequest(ctx, request, accessToken) @@ -210,7 +213,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { assert.EqualError(t, err, "invalid_credential_request - credential request must contain credential_configuration_id") }) t.Run("error - unknown credential_configuration_id", func(t *testing.T) { - request := createRequest(createHeaders(), createClaims(cNonce), "NonExistent_ldp_vc") + request := createRequest(createHeaders(), createClaims(nonce), "NonExistent_ldp_vc") response, err := service.HandleCredentialRequest(ctx, request, accessToken) @@ -253,7 +256,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { service := requireNewTestHandler(t, keyResolver) otherOffer, err := service.createOffer(ctx, otherIssuedVC, preAuthCode) require.NoError(t, err) - accessToken, _, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) otherConfigID := otherOffer.CredentialConfigurationIds[0] @@ -270,7 +273,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { service := requireNewTestHandler(t, keyResolver) _, err := service.createOffer(ctx, issuedVC, preAuthCode) require.NoError(t, err) - accessToken, _, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) invalidRequest := createRequest(createHeaders(), createClaims(""), configID) @@ -316,19 +319,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_nonce - unknown nonce") - assert.Nil(t, response) - }) - t.Run("wrong nonce", func(t *testing.T) { - _, err := service.createOffer(ctx, issuedVC, "other") - require.NoError(t, err) - _, cNonce, err := service.HandleAccessTokenRequest(ctx, "other") - require.NoError(t, err) - invalidRequest := createRequest(createHeaders(), createClaims(cNonce), configID) - - response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - - assertProtocolError(t, err, http.StatusBadRequest, "invalid_nonce - nonce not valid for access token") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_nonce - invalid or expired nonce") assert.Nil(t, response) }) }) @@ -340,6 +331,25 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { assertProtocolError(t, err, http.StatusUnauthorized, "invalid_token - unknown access token") assert.Nil(t, response) }) + t.Run("credential issuer does not match", func(t *testing.T) { + store := storage.NewTestInMemorySessionDatabase(t) + service, err := NewOpenIDHandler(issuerDID, issuerIdentifier, definitionsDIR, &http.Client{}, keyResolver, store) + require.NoError(t, err) + _, err = service.(*openidHandler).createOffer(ctx, issuedVC, preAuthCode) + require.NoError(t, err) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + nonce, err := service.HandleNonceRequest(ctx) + require.NoError(t, err) + request := createRequest(createHeaders(), createClaims(nonce), configID) + + otherService, err := NewOpenIDHandler(did.MustParseDID("did:nuts:other"), "http://example.com/other", definitionsDIR, &http.Client{}, keyResolver, store) + require.NoError(t, err) + response, err := otherService.HandleCredentialRequest(ctx, request, accessToken) + + assertProtocolError(t, err, http.StatusBadRequest, "invalid_credential_request - credential issuer does not match given issuer") + assert.Nil(t, response) + }) } func Test_memoryIssuer_OfferCredential(t *testing.T) { @@ -379,7 +389,7 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { _, err := service.createOffer(ctx, issuedVC, "code") require.NoError(t, err) - accessToken, _, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") + accessToken, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") require.NoError(t, err) assert.NotEmpty(t, accessToken) @@ -393,7 +403,7 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { otherService, err := NewOpenIDHandler(did.MustParseDID("did:nuts:other"), "http://example.com/other", definitionsDIR, &http.Client{}, nil, store) require.NoError(t, err) - accessToken, _, err := otherService.HandleAccessTokenRequest(audit.TestContext(), "code") + accessToken, err := otherService.HandleAccessTokenRequest(audit.TestContext(), "code") var protocolError openid4vci.Error require.ErrorAs(t, err, &protocolError) @@ -406,7 +416,7 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { _, err := service.createOffer(ctx, issuedVC, "some-other-code") require.NoError(t, err) - accessToken, _, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") + accessToken, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") var protocolError openid4vci.Error require.ErrorAs(t, err, &protocolError) @@ -416,6 +426,121 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { }) } +func Test_memoryIssuer_HandleNonceRequest(t *testing.T) { + ctx := context.Background() + t.Run("ok", func(t *testing.T) { + service := requireNewTestHandler(t, nil) + + nonce, err := service.HandleNonceRequest(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, nonce) + }) +} + +func Test_memoryIssuer_validateProof_metadataDriven(t *testing.T) { + keyStore := crypto.NewMemoryCryptoInstance(t) + ctx := audit.TestContext() + _, signerKey, _ := keyStore.New(ctx, crypto.StringNamingFunc(keyID)) + ctrl := gomock.NewController(t) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKeyByID(keyID, nil, resolver.NutsSigningKeyType).AnyTimes().Return(signerKey, nil) + + createHeaders := func() map[string]interface{} { + return map[string]interface{}{ + "typ": openid4vci.JWTTypeOpenID4VCIProof, + "kid": keyID, + } + } + createClaims := func(nonce string) map[string]interface{} { + return map[string]interface{}{ + "aud": issuerIdentifier, + "iat": time.Now().Unix(), + "nonce": nonce, + } + } + createProofs := func(headers, claims map[string]interface{}) *openid4vci.CredentialRequestProofs { + proof, err := keyStore.SignJWT(ctx, claims, headers, headers["kid"].(string)) + require.NoError(t, err) + return &openid4vci.CredentialRequestProofs{ + Jwt: []string{proof}, + } + } + + const preAuthCode = "some-secret-code" + + t.Run("standalone nonce from Nonce Endpoint is accepted", func(t *testing.T) { + service := requireNewTestHandler(t, keyResolver) + _, err := service.createOffer(ctx, issuedVC, preAuthCode) + require.NoError(t, err) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + + // Get a standalone nonce + standaloneNonce, err := service.HandleNonceRequest(ctx) + require.NoError(t, err) + + configID := "ExampleCredential_ldp_vc" + request := openid4vci.CredentialRequest{ + CredentialConfigurationId: configID, + Proofs: createProofs(createHeaders(), createClaims(standaloneNonce)), + } + + response, err := service.HandleCredentialRequest(ctx, request, accessToken) + + require.NoError(t, err) + require.NotNil(t, response) + }) + t.Run("proof skipped when credential config has no proof_types_supported", func(t *testing.T) { + // Create a handler with a credential config that lacks proof_types_supported + tmpDir := t.TempDir() + noProofDef := `{ + "format": "ldp_vc", + "cryptographic_binding_methods_supported": ["did:nuts"], + "credential_definition": { + "@context": ["https://www.w3.org/2018/credentials/v1", "https://example.com/credentials/v1"], + "type": ["VerifiableCredential", "NoProofCredential"] + } + }` + err := os.WriteFile(filepath.Join(tmpDir, "NoProofCredential.json"), []byte(noProofDef), 0644) + require.NoError(t, err) + + service, err := NewOpenIDHandler(issuerDID, issuerIdentifier, tmpDir, &http.Client{}, keyResolver, storage.NewTestInMemorySessionDatabase(t)) + require.NoError(t, err) + handler := service.(*openidHandler) + + noProofVC := vc.VerifiableCredential{ + Issuer: issuerDID.URI(), + CredentialSubject: []map[string]any{ + {"id": holderDID.String()}, + }, + Context: []ssi.URI{ + ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), + ssi.MustParseURI("https://example.com/credentials/v1"), + }, + Type: []ssi.URI{ + ssi.MustParseURI("VerifiableCredential"), + ssi.MustParseURI("NoProofCredential"), + }, + } + + _, err = handler.createOffer(ctx, noProofVC, preAuthCode) + require.NoError(t, err) + accessToken, err := handler.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + + // Request without proof should succeed + request := openid4vci.CredentialRequest{ + CredentialConfigurationId: "NoProofCredential_ldp_vc", + } + + response, err := handler.HandleCredentialRequest(ctx, request, accessToken) + + require.NoError(t, err) + require.NotNil(t, response) + }) +} + func assertProtocolError(t *testing.T, err error, statusCode int, message string) { var protocolError openid4vci.Error require.ErrorAs(t, err, &protocolError) diff --git a/vcr/issuer/test/valid/ExampleCredential.json b/vcr/issuer/test/valid/ExampleCredential.json index 7f0d460abf..110a3a6948 100644 --- a/vcr/issuer/test/valid/ExampleCredential.json +++ b/vcr/issuer/test/valid/ExampleCredential.json @@ -1,5 +1,10 @@ { "format": "ldp_vc", + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] + } + }, "cryptographic_binding_methods_supported": [ "did:nuts" ], diff --git a/vcr/openid4vci/error.go b/vcr/openid4vci/error.go index b523bed891..02b9f355cc 100644 --- a/vcr/openid4vci/error.go +++ b/vcr/openid4vci/error.go @@ -62,7 +62,6 @@ const ( // Error is an error that signals the error was (probably) caused by the client (e.g. bad request), // or that the client can recover from the error (e.g. retry). Errors are specified by the OpenID4VCI specification. -// Invalid proof errors may also add a new c_nonce that the client must use in the next credential request. type Error struct { // Code is the error code as defined by the OpenID4VCI spec. Code ErrorCode `json:"error"` diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index 30d7d6ea56..04e3ae6d73 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -43,6 +43,8 @@ type IssuerAPIClient interface { Metadata() CredentialIssuerMetadata // RequestCredential requests a credential from the issuer. RequestCredential(ctx context.Context, request CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) + // RequestNonce requests a fresh c_nonce from the issuer's Nonce Endpoint (v1.0 Section 7). + RequestNonce(ctx context.Context) (*NonceResponse, error) } // NewIssuerAPIClient resolves the Credential Issuer Metadata from the well-known endpoint @@ -93,13 +95,12 @@ type defaultIssuerAPIClient struct { func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) { requestBody, _ := json.Marshal(request) - var credentialResponse CredentialResponse httpRequest, _ := http.NewRequestWithContext(ctx, "POST", h.metadata.CredentialEndpoint, bytes.NewReader(requestBody)) httpRequest.Header.Add("Authorization", "Bearer "+accessToken) httpRequest.Header.Add("Content-Type", "application/json") - err := httpDo(h.httpClient, httpRequest, &credentialResponse) + credentialResponse, err := doCredentialRequest(h.httpClient, httpRequest) if err != nil { - return nil, fmt.Errorf("get credential request failed: %w", err) + return nil, err } // TODO: validate received credential matches the requested credential_configuration_id // See https://github.com/nuts-foundation/nuts-node/issues/2037 @@ -116,6 +117,50 @@ func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request C return &credential, nil } +// doCredentialRequest performs the HTTP request to the credential endpoint. +// It returns structured OpenID4VCI errors when the server returns an error response, +// allowing callers to detect specific error codes like invalid_nonce. +func doCredentialRequest(httpClient core.HTTPRequestDoer, httpRequest *http.Request) (*CredentialResponse, error) { + if HttpClientTrace != nil { + httpRequest = httpRequest.WithContext(httptrace.WithClientTrace(httpRequest.Context(), HttpClientTrace)) + } + httpResponse, err := httpClient.Do(httpRequest) + if err != nil { + return nil, fmt.Errorf("credential request http error: %w", err) + } + defer httpResponse.Body.Close() + responseBody, err := io.ReadAll(httpResponse.Body) + if err != nil { + return nil, fmt.Errorf("credential request read error: %w", err) + } + if httpResponse.StatusCode < 200 || httpResponse.StatusCode > 299 { + var oidcError Error + if json.Unmarshal(responseBody, &oidcError) == nil && oidcError.Code != "" { + oidcError.StatusCode = httpResponse.StatusCode + return nil, oidcError + } + return nil, fmt.Errorf("credential request failed (status %d)", httpResponse.StatusCode) + } + var credentialResponse CredentialResponse + if err := json.Unmarshal(responseBody, &credentialResponse); err != nil { + return nil, fmt.Errorf("credential response unmarshal error: %w", err) + } + return &credentialResponse, nil +} + +func (h defaultIssuerAPIClient) RequestNonce(ctx context.Context) (*NonceResponse, error) { + if h.metadata.NonceEndpoint == "" { + return nil, errors.New("issuer does not advertise a nonce endpoint") + } + var nonceResponse NonceResponse + httpRequest, _ := http.NewRequestWithContext(ctx, "POST", h.metadata.NonceEndpoint, http.NoBody) + err := httpDo(h.httpClient, httpRequest, &nonceResponse) + if err != nil { + return nil, fmt.Errorf("nonce request failed: %w", err) + } + return &nonceResponse, nil +} + func (h defaultIssuerAPIClient) Metadata() CredentialIssuerMetadata { return h.metadata } diff --git a/vcr/openid4vci/issuer_client_mock.go b/vcr/openid4vci/issuer_client_mock.go index 370f86f84e..e6d7f49c45 100644 --- a/vcr/openid4vci/issuer_client_mock.go +++ b/vcr/openid4vci/issuer_client_mock.go @@ -22,7 +22,6 @@ import ( type MockIssuerAPIClient struct { ctrl *gomock.Controller recorder *MockIssuerAPIClientMockRecorder - isgomock struct{} } // MockIssuerAPIClientMockRecorder is the mock recorder for MockIssuerAPIClient. @@ -86,11 +85,25 @@ func (mr *MockIssuerAPIClientMockRecorder) RequestCredential(ctx, request, acces return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestCredential", reflect.TypeOf((*MockIssuerAPIClient)(nil).RequestCredential), ctx, request, accessToken) } +// RequestNonce mocks base method. +func (m *MockIssuerAPIClient) RequestNonce(ctx context.Context) (*NonceResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestNonce", ctx) + ret0, _ := ret[0].(*NonceResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestNonce indicates an expected call of RequestNonce. +func (mr *MockIssuerAPIClientMockRecorder) RequestNonce(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNonce", reflect.TypeOf((*MockIssuerAPIClient)(nil).RequestNonce), ctx) +} + // MockOAuth2Client is a mock of OAuth2Client interface. type MockOAuth2Client struct { ctrl *gomock.Controller recorder *MockOAuth2ClientMockRecorder - isgomock struct{} } // MockOAuth2ClientMockRecorder is the mock recorder for MockOAuth2Client. diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index 3b2333b661..f80880584d 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -126,6 +126,46 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { }) } +func Test_httpIssuerClient_RequestNonce(t *testing.T) { + ctx := context.Background() + httpClient := &http.Client{} + t.Run("ok", func(t *testing.T) { + setup := setupClientTest(t) + client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) + require.NoError(t, err) + + nonceResponse, err := client.RequestNonce(ctx) + + require.NoError(t, err) + require.NotNil(t, nonceResponse) + assert.Equal(t, "test-nonce", nonceResponse.CNonce) + }) + t.Run("error - no nonce endpoint in metadata", func(t *testing.T) { + setup := setupClientTest(t) + setup.issuerMetadata.NonceEndpoint = "" + client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) + require.NoError(t, err) + + nonceResponse, err := client.RequestNonce(ctx) + + require.EqualError(t, err, "issuer does not advertise a nonce endpoint") + assert.Nil(t, nonceResponse) + }) + t.Run("error - nonce endpoint returns error", func(t *testing.T) { + setup := setupClientTest(t) + setup.nonceHandler = func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + } + client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) + require.NoError(t, err) + + nonceResponse, err := client.RequestNonce(ctx) + + require.ErrorContains(t, err, "nonce request failed") + assert.Nil(t, nonceResponse) + }) +} + func Test_httpOAuth2Client_RequestAccessToken(t *testing.T) { httpClient := &http.Client{} params := map[string]string{"some-param": "some-value"} diff --git a/vcr/openid4vci/test.go b/vcr/openid4vci/test.go index 2b017c82dc..d7b6482628 100644 --- a/vcr/openid4vci/test.go +++ b/vcr/openid4vci/test.go @@ -58,6 +58,7 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { clientTest.tokenHandler = clientTest.httpPostHandler(oauth.TokenResponse{AccessToken: "secret"}) clientTest.walletMetadataHandler = clientTest.httpGetHandler(walletMetadata) clientTest.credentialOfferHandler = clientTest.httpGetHandler(CredentialOfferResponse{CredentialOfferStatusReceived}) + clientTest.nonceHandler = clientTest.httpPostHandler(NonceResponse{CNonce: "test-nonce"}) mux := http.NewServeMux() mux.HandleFunc("/issuer"+CredentialIssuerMetadataWellKnownPath, func(writer http.ResponseWriter, request *http.Request) { @@ -72,6 +73,9 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { mux.HandleFunc("/issuer/token", func(writer http.ResponseWriter, request *http.Request) { clientTest.tokenHandler(writer, request) }) + mux.HandleFunc("/issuer/nonce", func(writer http.ResponseWriter, request *http.Request) { + clientTest.nonceHandler(writer, request) + }) mux.HandleFunc("/wallet/metadata", func(writer http.ResponseWriter, request *http.Request) { clientTest.walletMetadataHandler(writer, request) }) @@ -85,6 +89,7 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { issuerIdentifier := serverURL + "/issuer" issuerMetadata.CredentialIssuer = issuerIdentifier issuerMetadata.CredentialEndpoint = issuerIdentifier + "/credential" + issuerMetadata.NonceEndpoint = issuerIdentifier + "/nonce" providerMetadata.Issuer = issuerIdentifier providerMetadata.TokenEndpoint = issuerIdentifier + "/token" return clientTest @@ -130,6 +135,7 @@ type oidcClientTestContext struct { credentialHandler http.HandlerFunc credentialOfferHandler http.HandlerFunc tokenHandler http.HandlerFunc + nonceHandler http.HandlerFunc walletMetadataHandler http.HandlerFunc requests []http.Request } diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index 124c11198e..60705a5823 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -62,11 +62,21 @@ type CredentialIssuerMetadata struct { // CredentialEndpoint defines where the wallet can send a request to retrieve a credential. CredentialEndpoint string `json:"credential_endpoint"` + // NonceEndpoint defines the URL of the Nonce Endpoint where wallets can request a fresh c_nonce. + // Per v1.0 Section 7, a Credential Issuer that requires c_nonce values MUST offer a Nonce Endpoint. + NonceEndpoint string `json:"nonce_endpoint,omitempty"` + // CredentialConfigurationsSupported defines metadata about which credential types the credential issuer can issue. // The map is keyed by credential_configuration_id. CredentialConfigurationsSupported map[string]map[string]interface{} `json:"credential_configurations_supported"` } +// NonceResponse defines the response from the Nonce Endpoint. +// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-nonce-endpoint +type NonceResponse struct { + CNonce string `json:"c_nonce"` +} + // OAuth2ClientMetadata defines the OAuth2 Client Metadata, extended with OpenID4VCI parameters. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-client-metadata. type OAuth2ClientMetadata struct { From 67240c68f5c38904bf2e42e2f042f71b26ac66da Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 9 Mar 2026 20:05:40 +0100 Subject: [PATCH 07/44] fix(openid4vci): harden input validation and add missing tests - Move nil body check before first field access in RequestOpenid4VCICredentialIssuance - Add comma-ok assertion on nonce claim type in issuer validateProof - Validate format field presence in holder resolveCredentialConfiguration - Add iss claim to holder proof JWT for consistency with auth module - Add tests: nil OwnDID, empty credentials, non-string nonce, missing format --- auth/api/iam/openid4vci.go | 9 ++++----- auth/api/iam/openid4vci_test.go | 25 +++++++++++++++++++++++++ vcr/holder/openid.go | 6 +++++- vcr/holder/openid_test.go | 28 ++++++++++++++++++++++++++++ vcr/issuer/openid.go | 9 ++++++++- vcr/issuer/openid_test.go | 26 ++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 7 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 789037efb0..aa2fa0e0bb 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -45,6 +45,10 @@ var timeFunc = time.Now const jwtTypeOpenID4VCIProof = "openid4vci-proof+jwt" func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, request RequestOpenid4VCICredentialIssuanceRequestObject) (RequestOpenid4VCICredentialIssuanceResponseObject, error) { + if request.Body == nil { + // why did oapi-codegen generate a pointer for the body?? + return nil, core.InvalidInputError("missing request body") + } walletDID, err := did.ParseDID(request.Body.WalletDid) if err != nil { return nil, core.InvalidInputError("invalid wallet DID") @@ -54,11 +58,6 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques } else if !owned { return nil, core.InvalidInputError("wallet DID does not belong to the subject") } - - if request.Body == nil { - // why did oapi-codegen generate a pointer for the body?? - return nil, core.InvalidInputError("missing request body") - } // Parse the issuer issuer := request.Body.Issuer if issuer == "" { diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index b4310eb9d1..b65d088d11 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -399,4 +399,29 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to sign the JWT with kid (kid): signature failed") }) + t.Run("error - nil OwnDID in session", func(t *testing.T) { + ctx := newTestClient(t) + sessionNilDID := session + sessionNilDID.OwnDID = nil + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionNilDID) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "missing wallet DID in session") + }) + t.Run("error - empty credentials array", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&iam.CredentialResponse{ + Credentials: []iam.CredentialResponseEntry{}, + }, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "credential response does not contain any credentials") + }) } diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index f89727bb9c..37792e9982 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -197,7 +197,10 @@ func (h *openidHandler) resolveCredentialConfiguration(metadata openid4vci.Crede return nil, fmt.Errorf("credential_configuration_id '%s' not found in issuer metadata", configID) } - format, _ := config["format"].(string) + format, ok := config["format"].(string) + if !ok || format == "" { + return nil, fmt.Errorf("credential configuration '%s' is missing 'format' field", configID) + } credDefMap, _ := config["credential_definition"].(map[string]interface{}) var credentialDef *openid4vci.CredentialDefinition @@ -259,6 +262,7 @@ func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient ope "kid": keyID, } claims := map[string]interface{}{ + "iss": h.did.String(), "aud": issuerClient.Metadata().CredentialIssuer, "iat": nowFunc().Unix(), } diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index f1fad4f577..e96bdcd3c7 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -112,6 +112,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { credentialStore := types.NewMockWriter(ctrl) jwtSigner := crypto.NewMockJWTSigner(ctrl) jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ + "iss": holderDID.String(), "aud": issuerDID.String(), "iat": int64(1735689600), }, gomock.Any(), "key-id").Return("signed-jwt", nil) @@ -172,6 +173,32 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential_configuration_id in credential offer") assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) + t.Run("error - credential configuration missing format", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + metadataNoFormat := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialEndpoint: "credential-endpoint", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "ExampleCredential_ldp_vc": { + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, + "type": []interface{}{"VerifiableCredential"}, + }, + }, + }, + } + issuerAPIClient.EXPECT().Metadata().Return(metadataNoFormat).AnyTimes() + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.ErrorContains(t, err, "credential configuration 'ExampleCredential_ldp_vc' is missing 'format' field") + }) t.Run("error - access token request fails", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) @@ -387,6 +414,7 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) } jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ + "iss": holderDID.String(), "aud": issuerDID.String(), "iat": int64(1767225600), "nonce": nonce, diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index 2c03cec720..63a5afce84 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -395,7 +395,14 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o } } - nonceValue := nonce.(string) + nonceValue, ok := nonce.(string) + if !ok { + return openid4vci.Error{ + Err: errors.New("nonce claim is not a string"), + Code: openid4vci.InvalidProof, + StatusCode: http.StatusBadRequest, + } + } // Validate nonce from Nonce Endpoint (v1.0 Section 7) if i.store.ConsumeNonce(ctx, nonceValue) { diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index d52f05a90f..62c33d0454 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -539,6 +539,32 @@ func Test_memoryIssuer_validateProof_metadataDriven(t *testing.T) { require.NoError(t, err) require.NotNil(t, response) }) + t.Run("non-string nonce claim returns invalid_proof", func(t *testing.T) { + service := requireNewTestHandler(t, keyResolver) + _, err := service.createOffer(ctx, issuedVC, preAuthCode) + require.NoError(t, err) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + + // Get a standalone nonce but put a number in the claim instead + _, err = service.HandleNonceRequest(ctx) + require.NoError(t, err) + + configID := "ExampleCredential_ldp_vc" + claimsWithNumericNonce := map[string]interface{}{ + "aud": issuerIdentifier, + "iat": time.Now().Unix(), + "nonce": 12345, // non-string + } + request := openid4vci.CredentialRequest{ + CredentialConfigurationId: configID, + Proofs: createProofs(createHeaders(), claimsWithNumericNonce), + } + + _, err = service.HandleCredentialRequest(ctx, request, accessToken) + + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - nonce claim is not a string") + }) } func assertProtocolError(t *testing.T, err error, statusCode int, message string) { From 86be9f40d53493dca49172d92409b174c2e56ff3 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 9 Mar 2026 21:17:26 +0100 Subject: [PATCH 08/44] fix(openid4vci): restore PreAuthorizedGrantAnonymousAccessSupported in AS metadata --- auth/api/iam/metadata.go | 27 ++++++++++++++------------- auth/api/iam/metadata_test.go | 31 ++++++++++++++++--------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go index 796dc9d03f..ec58fac866 100644 --- a/auth/api/iam/metadata.go +++ b/auth/api/iam/metadata.go @@ -33,19 +33,20 @@ import ( func authorizationServerMetadata(issuerURL *url.URL, supportedDIDMethods []string) oauth.AuthorizationServerMetadata { metadata := &oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: "openid4vp:", - ClientIdSchemesSupported: clientIdSchemesSupported, - DIDMethodsSupported: supportedDIDMethods, - DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), - GrantTypesSupported: grantTypesSupported, - Issuer: "https://self-issued.me/v2", - PresentationDefinitionUriSupported: to.Ptr(true), - RequireSignedRequestObject: true, - ResponseModesSupported: responseModesSupported, - ResponseTypesSupported: responseTypesSupported, - VPFormats: oauth.DefaultOpenIDSupportedFormats(), - VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), - RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), + AuthorizationEndpoint: "openid4vp:", + ClientIdSchemesSupported: clientIdSchemesSupported, + DIDMethodsSupported: supportedDIDMethods, + DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), + GrantTypesSupported: grantTypesSupported, + Issuer: "https://self-issued.me/v2", + PreAuthorizedGrantAnonymousAccessSupported: true, + PresentationDefinitionUriSupported: to.Ptr(true), + RequireSignedRequestObject: true, + ResponseModesSupported: responseModesSupported, + ResponseTypesSupported: responseTypesSupported, + VPFormats: oauth.DefaultOpenIDSupportedFormats(), + VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), + RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), } if issuerURL != nil { diff --git a/auth/api/iam/metadata_test.go b/auth/api/iam/metadata_test.go index 5e6b183583..8f325b4576 100644 --- a/auth/api/iam/metadata_test.go +++ b/auth/api/iam/metadata_test.go @@ -32,21 +32,22 @@ import ( func Test_authorizationServerMetadata(t *testing.T) { presentationDefinitionURISupported := true baseExpected := oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: "https://example.com/oauth2/example/authorize", - TokenEndpoint: "https://example.com/oauth2/example/token", - ClientIdSchemesSupported: []string{"entity_id"}, - DIDMethodsSupported: []string{"test"}, - DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), - GrantTypesSupported: []string{"authorization_code", "vp_token-bearer"}, - Issuer: "https://example.com/oauth2/example", - PresentationDefinitionEndpoint: "https://example.com/oauth2/example/presentation_definition", - PresentationDefinitionUriSupported: &presentationDefinitionURISupported, - RequireSignedRequestObject: true, - ResponseTypesSupported: []string{"code", "vp_token"}, - ResponseModesSupported: []string{"query", "direct_post"}, - VPFormats: oauth.DefaultOpenIDSupportedFormats(), - VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), - RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), + AuthorizationEndpoint: "https://example.com/oauth2/example/authorize", + TokenEndpoint: "https://example.com/oauth2/example/token", + ClientIdSchemesSupported: []string{"entity_id"}, + DIDMethodsSupported: []string{"test"}, + DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), + GrantTypesSupported: []string{"authorization_code", "vp_token-bearer"}, + Issuer: "https://example.com/oauth2/example", + PreAuthorizedGrantAnonymousAccessSupported: true, + PresentationDefinitionEndpoint: "https://example.com/oauth2/example/presentation_definition", + PresentationDefinitionUriSupported: &presentationDefinitionURISupported, + RequireSignedRequestObject: true, + ResponseTypesSupported: []string{"code", "vp_token"}, + ResponseModesSupported: []string{"query", "direct_post"}, + VPFormats: oauth.DefaultOpenIDSupportedFormats(), + VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), + RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), } authServerUrl := test.MustParseURL("https://example.com/oauth2/example") md := authorizationServerMetadata(authServerUrl, []string{"test"}) From 2ed126955685d7c224d3e33cc4003e8fdfaf0a5b Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 13:47:58 +0100 Subject: [PATCH 09/44] docs(openid4vci): improve OpenAPI spec v1.0 accuracy --- docs/_static/vcr/openid4vci_v0.yaml | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/_static/vcr/openid4vci_v0.yaml b/docs/_static/vcr/openid4vci_v0.yaml index 5710ea5a04..3a7b56d2bd 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -369,6 +369,12 @@ components: description: | URL of the authorization server's token endpoint [RFC6749]. example: https://issuer.example.com/token + pre-authorized_grant_anonymous_access_supported: + type: boolean + description: | + Indicates whether anonymous access (requests without client_id) is supported + for pre-authorized code grant flows. + example: true TokenResponse: type: object @@ -434,6 +440,7 @@ components: "kid": "did:nuts:ebfeb1f712ebc6f1c276e12ec21#keys-1" }. { + "iss": "did:nuts:ebfeb1f712ebc6f1c276e12ec21", "aud": "https://credential-issuer.example.com", "iat": 1659145924, "nonce": "tZignsnFbp" @@ -519,6 +526,30 @@ components: type: string grants: type: object + description: | + Grant types the issuer offers for this credential. Currently only pre-authorized code is supported. + properties: + "urn:ietf:params:oauth:grant-type:pre-authorized_code": + type: object + required: + - pre-authorized_code + properties: + pre-authorized_code: + type: string + description: The pre-authorized code for the credential offer. + tx_code: + type: object + description: | + Optional transaction code descriptor. When present, the wallet must provide a user-entered + PIN when exchanging the pre-authorized code at the token endpoint. + properties: + input_mode: + type: string + enum: [numeric, text] + length: + type: integer + description: + type: string example: { "credential_issuer": "https://issuer.example", From f56cba373ea95f077ba97504c33856b0207585b9 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 15:39:15 +0100 Subject: [PATCH 10/44] fix(openid4vci): correct holder error code for unsupported format --- vcr/holder/openid.go | 4 ++-- vcr/holder/openid_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index 37792e9982..48235b18dd 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -125,8 +125,8 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 if offeredCredential.Format != vc.JSONLDCredentialProofFormat { return openid4vci.Error{ Err: fmt.Errorf("credential offer: unsupported format '%s'", offeredCredential.Format), - Code: openid4vci.ServerError, - StatusCode: http.StatusInternalServerError, + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, } } if err := offeredCredential.CredentialDefinition.Validate(false); err != nil { diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index e96bdcd3c7..bb48da54c6 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -303,8 +303,8 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { }, }).(openid4vci.Error) - assert.EqualError(t, err, "server_error - credential offer: unsupported format 'not supported'") - assert.Equal(t, http.StatusInternalServerError, err.StatusCode) + assert.EqualError(t, err, "invalid_request - credential offer: unsupported format 'not supported'") + assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) t.Run("credentialSubject in metadata does not block offer processing", func(t *testing.T) { // v1.0 Appendix A.1.2: credentialSubject is allowed in metadata credential_configurations_supported From b2ede53ca0c61a5cc18bc9b1e1defd7484feb231 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 16:08:42 +0100 Subject: [PATCH 11/44] test(openid4vci): fix auth header bug and add missing test coverage --- auth/api/iam/openid4vci_test.go | 10 +++ docs/_static/vcr/openid4vci_v0.yaml | 13 ---- vcr/holder/openid_test.go | 84 +++++++++++++++++++++++-- vcr/test/openid4vci_integration_test.go | 2 +- 4 files changed, 89 insertions(+), 20 deletions(-) diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index b65d088d11..a15c03a749 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -324,6 +324,16 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "error fetching nonce for retry") }) + t.Run("error - initial nonce request fails", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint unavailable")) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "error fetching nonce from") + }) t.Run("fail_access_token", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) diff --git a/docs/_static/vcr/openid4vci_v0.yaml b/docs/_static/vcr/openid4vci_v0.yaml index 3a7b56d2bd..e288d29647 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -537,19 +537,6 @@ components: pre-authorized_code: type: string description: The pre-authorized code for the credential offer. - tx_code: - type: object - description: | - Optional transaction code descriptor. When present, the wallet must provide a user-entered - PIN when exchanging the pre-authorized code at the token endpoint. - properties: - input_mode: - type: string - enum: [numeric, text] - length: - type: integer - description: - type: string example: { "credential_issuer": "https://issuer.example", diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index bb48da54c6..73b4c94d9c 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -122,6 +122,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { nowFunc = func() time.Time { return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) } + t.Cleanup(func() { nowFunc = time.Now }) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { @@ -173,6 +174,25 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential_configuration_id in credential offer") assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) + t.Run("error - credential_configuration_id not found in metadata", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + emptyMetadata := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialEndpoint: "credential-endpoint", + CredentialConfigurationsSupported: map[string]map[string]interface{}{}, + } + issuerAPIClient.EXPECT().Metadata().Return(emptyMetadata).AnyTimes() + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.ErrorContains(t, err, "credential_configuration_id 'ExampleCredential_ldp_vc' not found in issuer metadata") + }) t.Run("error - credential configuration missing format", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) @@ -413,6 +433,7 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { nowFunc = func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) } + t.Cleanup(func() { nowFunc = time.Now }) jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ "iss": holderDID.String(), "aud": issuerDID.String(), @@ -445,11 +466,19 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ "pre-authorized_code": "code", }).Return(tokenResponse, nil) - // First credential request fails with invalid_nonce - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). + // First credential request (with stale nonce) fails with invalid_nonce + firstCredReq := openid4vci.CredentialRequest{ + CredentialConfigurationId: "ExampleCredential_ldp_vc", + Proofs: &openid4vci.CredentialRequestProofs{Jwt: []string{"signed-jwt-1"}}, + } + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), firstCredReq, "access-token"). Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) - // Retry succeeds - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). + // Retry with fresh nonce succeeds + retryCredReq := openid4vci.CredentialRequest{ + CredentialConfigurationId: "ExampleCredential_ldp_vc", + Proofs: &openid4vci.CredentialRequestProofs{Jwt: []string{"signed-jwt-2"}}, + } + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), retryCredReq, "access-token"). Return(&vc.VerifiableCredential{ Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://example.com/credentials/v1")}, Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("ExampleCredential")}, @@ -460,8 +489,21 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { nowFunc = func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) } - // Two sign calls: one for each attempt - jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "key-id").Return("signed-jwt", nil).Times(2) + t.Cleanup(func() { nowFunc = time.Now }) + // First attempt uses the stale nonce + firstSign := jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ + "iss": holderDID.String(), + "aud": issuerDID.String(), + "iat": int64(1767225600), + "nonce": "stale-nonce", + }, gomock.Any(), "key-id").Return("signed-jwt-1", nil) + // Retry uses the fresh nonce + jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ + "iss": holderDID.String(), + "aud": issuerDID.String(), + "iat": int64(1767225600), + "nonce": nonce, + }, gomock.Any(), "key-id").Return("signed-jwt-2", nil).After(firstSign) keyResolver := resolver.NewMockKeyResolver(ctrl) keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) @@ -476,6 +518,36 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { require.NoError(t, err) }) + t.Run("error - invalid_nonce retry also fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() + first := issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: "stale-nonce"}, nil) + issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: "also-stale"}, nil).After(first) + tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} + issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ + "pre-authorized_code": "code", + }).Return(tokenResponse, nil) + // Both credential requests fail + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). + Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). + Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) + + jwtSigner := crypto.NewMockJWTSigner(ctrl) + jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "key-id").Return("signed-jwt", nil).Times(2) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, jwtSigner, keyResolver).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.EqualError(t, err, "server_error - unable to retrieve credential: invalid_nonce") + }) t.Run("error - nonce endpoint request fails", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index 3b07a79fe0..5472894a09 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -142,7 +142,7 @@ func TestOpenID4VCIErrorResponses(t *testing.T) { t.Run("error from service layer (unknown access token)", func(t *testing.T) { httpRequest, _ := http.NewRequest("POST", issuer.Metadata().CredentialEndpoint, bytes.NewReader(requestBody)) httpRequest.Header.Set("Content-Type", "application/json") - httpRequest.Header.Set("Authentication", "Bearer not-a-valid-token") + httpRequest.Header.Set("Authorization", "Bearer not-a-valid-token") httpResponse, err := http.DefaultClient.Do(httpRequest) From e18dd4fe7a9b8cf22196050e4a09fa5dc61f618b Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 16:48:02 +0100 Subject: [PATCH 12/44] refactor(openid4vci): restore original error comments and simplify deepcopyMap Restore original comments for unchanged OAuth2 error codes. Replace JSON round-trip deepcopy with direct map copy matching the master approach. --- vcr/issuer/openid.go | 14 ++++++-------- vcr/openid4vci/error.go | 24 +++++++++++++----------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index 63a5afce84..60f3a706f2 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -518,14 +518,12 @@ func (i *openidHandler) loadCredentialDefinitions() error { } func deepcopyMap(src map[string]map[string]interface{}) map[string]map[string]interface{} { - // Safe to ignore errors: src is always built from JSON-deserialized data. - data, err := json.Marshal(src) - if err != nil { - panic("deepcopyMap: marshal failed: " + err.Error()) - } - var dst map[string]map[string]interface{} - if err = json.Unmarshal(data, &dst); err != nil { - panic("deepcopyMap: unmarshal failed: " + err.Error()) + dst := make(map[string]map[string]interface{}, len(src)) + for k, v := range src { + dst[k] = make(map[string]interface{}, len(v)) + for k2, v2 := range v { + dst[k][k2] = v2 + } } return dst } diff --git a/vcr/openid4vci/error.go b/vcr/openid4vci/error.go index 02b9f355cc..78fa896305 100644 --- a/vcr/openid4vci/error.go +++ b/vcr/openid4vci/error.go @@ -22,23 +22,25 @@ package openid4vci type ErrorCode string const ( - // OAuth2 Token Endpoint error codes (RFC 6749) - - // InvalidRequest is an OAuth2 error for malformed token requests. + // InvalidRequest is returned when: + // - the Authorization Server does not expect a PIN in the pre-authorized flow but the client provides a PIN + // - the Authorization Server expects a PIN in the pre-authorized flow but the client does not provide a PIN + // - Credential Request was malformed. One or more of the parameters (i.e. format, proof) are missing or malformed. InvalidRequest ErrorCode = "invalid_request" - // InvalidClient is returned when the client is not authorized. + // InvalidClient is returned when: + // - the client tried to send a Token Request with a Pre-Authorized Code without Client ID but the Authorization Server does not support anonymous access InvalidClient ErrorCode = "invalid_client" - // InvalidGrant is returned when the grant (e.g. pre-authorized code) is invalid or expired. + // InvalidGrant is returned when (in addition to cases defined by OAuth2): + // - the Authorization Server expects a PIN in the pre-authorized flow but the client provides the wrong PIN + // - the End-User provides the wrong Pre-Authorized Code or the Pre-Authorized Code has expired InvalidGrant ErrorCode = "invalid_grant" - // InvalidToken is returned when the access token is invalid or missing (RFC 6750). + // InvalidToken is returned when (in addition to cases defined by OAuth2): + // - Credential Request contains the wrong Access Token or the Access Token is missing InvalidToken ErrorCode = "invalid_token" - // UnsupportedGrantType is returned when the requested grant type is not supported. + // UnsupportedGrantType is returned when the Authorization Server does not support the requested grant type. UnsupportedGrantType ErrorCode = "unsupported_grant_type" - // ServerError is returned when the server encounters an unexpected condition. + // ServerError is returned when the Authorization Server encounters an unexpected condition that prevents it from fulfilling the request. ServerError ErrorCode = "server_error" - - // OpenID4VCI v1.0 Credential Endpoint error codes (Section 8.3.1.2) - // InvalidCredentialRequest is returned when the Credential Request is missing a required parameter, // includes an unsupported parameter or parameter value, or is otherwise malformed. InvalidCredentialRequest ErrorCode = "invalid_credential_request" From 757fd548d47e0da9f0123a052e514b166cddba1e Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 17:05:45 +0100 Subject: [PATCH 13/44] fix(openid4vci): restore JSON deep copy and remove resolved TODO Revert deepcopyMap to JSON round-trip: shallow copy is insufficient for nested maps like credential_definition. Remove resolved TODO about credential validation (already done via ValidateDefinitionWithCredential). --- vcr/issuer/openid.go | 10 +++------- vcr/openid4vci/issuer_client.go | 2 -- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index 60f3a706f2..3bc4fea83c 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -518,13 +518,9 @@ func (i *openidHandler) loadCredentialDefinitions() error { } func deepcopyMap(src map[string]map[string]interface{}) map[string]map[string]interface{} { - dst := make(map[string]map[string]interface{}, len(src)) - for k, v := range src { - dst[k] = make(map[string]interface{}, len(v)) - for k2, v2 := range v { - dst[k][k2] = v2 - } - } + data, _ := json.Marshal(src) + var dst map[string]map[string]interface{} + _ = json.Unmarshal(data, &dst) return dst } diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index 04e3ae6d73..2a21cbcf17 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -102,8 +102,6 @@ func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request C if err != nil { return nil, err } - // TODO: validate received credential matches the requested credential_configuration_id - // See https://github.com/nuts-foundation/nuts-node/issues/2037 if len(credentialResponse.Credentials) == 0 { return nil, errors.New("credential response does not contain any credentials") } From aa065025acd049dfaae5f8e15841db6f0c1e4ecb Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 17:44:07 +0100 Subject: [PATCH 14/44] fix(openid4vci): harden validation and fix spec compliance issues - Restore defensive panics in deepcopyMap for unmarshalable data - Make CredentialOffer.Grants a pointer with omitempty (OPTIONAL per Section 4.1.1) - Return false for non-string types in matchesCredential instead of silently skipping - Add iss claim validation in issuer proof verification per v1.0 Appendix F.1, with dedicated test case - Validate non-empty c_nonce from Nonce Endpoint responses - Remove redundant WithContext in RequestNonce --- auth/client/iam/client.go | 5 ++++- vcr/api/openid4vci/v0/holder_test.go | 2 +- vcr/holder/openid.go | 2 +- vcr/holder/openid_test.go | 14 +++++++------- vcr/issuer/openid.go | 24 +++++++++++++++++++----- vcr/issuer/openid_test.go | 25 ++++++++++++++++++++++++- vcr/openid4vci/issuer_client.go | 3 +++ vcr/openid4vci/types.go | 2 +- vcr/openid4vci/types_test.go | 2 +- vcr/openid4vci/wallet_client_test.go | 6 +++--- 10 files changed, 64 insertions(+), 21 deletions(-) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 45beb2be47..647644593f 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -247,7 +247,7 @@ func (hb HTTPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (st if err != nil { return "", err } - response, err := hb.httpClient.Do(request.WithContext(ctx)) + response, err := hb.httpClient.Do(request) if err != nil { return "", fmt.Errorf("nonce request failed: %w", err) } @@ -265,6 +265,9 @@ func (hb HTTPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (st if err = json.Unmarshal(data, &nonceResponse); err != nil { return "", fmt.Errorf("unable to unmarshal nonce response: %w", err) } + if nonceResponse.CNonce == "" { + return "", errors.New("nonce endpoint returned empty c_nonce") + } return nonceResponse.CNonce, nil } diff --git a/vcr/api/openid4vci/v0/holder_test.go b/vcr/api/openid4vci/v0/holder_test.go index 2e1be144e2..e2c161cc18 100644 --- a/vcr/api/openid4vci/v0/holder_test.go +++ b/vcr/api/openid4vci/v0/holder_test.go @@ -88,7 +88,7 @@ func TestWrapper_HandleCredentialOffer(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ CredentialIssuer: issuerDID.String(), CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, - Grants: openid4vci.CredentialOfferGrants{ + Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "code", }, diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index 48235b18dd..ebff5cf651 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -183,7 +183,7 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } func getPreAuthorizedCodeFromOffer(offer openid4vci.CredentialOffer) string { - if offer.Grants.PreAuthorizedCode == nil { + if offer.Grants == nil || offer.Grants.PreAuthorizedCode == nil { return "" } return offer.Grants.PreAuthorizedCode.PreAuthorizedCode diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index 73b4c94d9c..278239a4e0 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -62,7 +62,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ CredentialIssuer: issuerDID.String(), CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, - Grants: openid4vci.CredentialOfferGrants{ + Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "code", }, @@ -145,7 +145,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { t.Run("no pre-authorized grant", func(t *testing.T) { offer := openid4vci.CredentialOffer{ CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, - Grants: openid4vci.CredentialOfferGrants{}, + Grants: nil, } err := w.HandleCredentialOffer(audit.TestContext(), offer) require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") @@ -153,7 +153,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { t.Run("empty pre-authorized code", func(t *testing.T) { offer := openid4vci.CredentialOffer{ CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, - Grants: openid4vci.CredentialOfferGrants{ + Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "", }, @@ -263,7 +263,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ CredentialIssuer: "http://localhost:87632", CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, - Grants: openid4vci.CredentialOfferGrants{ + Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "foo", }, @@ -316,7 +316,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ CredentialConfigurationIds: []string{"TestCredential_unsupported"}, - Grants: openid4vci.CredentialOfferGrants{ + Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "foo", }, @@ -364,7 +364,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ CredentialConfigurationIds: []string{"TestCredential_ldp_vc"}, - Grants: openid4vci.CredentialOfferGrants{ + Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "foo", }, @@ -379,7 +379,7 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ CredentialIssuer: issuerDID.String(), CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, - Grants: openid4vci.CredentialOfferGrants{ + Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "code", }, diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index 3bc4fea83c..62a13b8efb 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -326,6 +326,15 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o } } + // Validate iss claim matches the expected wallet DID (v1.0 Appendix F.1) + if token.Issuer() != wallet.String() { + return openid4vci.Error{ + Err: fmt.Errorf("proof iss claim does not match expected wallet: %s", token.Issuer()), + Code: openid4vci.InvalidProof, + StatusCode: http.StatusBadRequest, + } + } + // Proof must be signed by wallet to which it was offered (proof signer == offer receiver) if signerDID, err := resolver.GetDIDFromURL(signingKeyID); err != nil || signerDID.String() != wallet.String() { return openid4vci.Error{ @@ -425,7 +434,7 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl offer := openid4vci.CredentialOffer{ CredentialIssuer: i.issuerIdentifierURL, CredentialConfigurationIds: []string{credentialConfigID}, - Grants: openid4vci.CredentialOfferGrants{ + Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: preAuthorizedCode, }, @@ -518,9 +527,14 @@ func (i *openidHandler) loadCredentialDefinitions() error { } func deepcopyMap(src map[string]map[string]interface{}) map[string]map[string]interface{} { - data, _ := json.Marshal(src) + data, err := json.Marshal(src) + if err != nil { + panic("deepcopyMap: marshal failed: " + err.Error()) + } var dst map[string]map[string]interface{} - _ = json.Unmarshal(data, &dst) + if err = json.Unmarshal(data, &dst); err != nil { + panic("deepcopyMap: unmarshal failed: " + err.Error()) + } return dst } @@ -595,7 +609,7 @@ func matchesCredential(config map[string]interface{}, credential vc.VerifiableCr for _, configType := range types { typeStr, ok := configType.(string) if !ok { - continue + return false } found := false for _, credType := range credential.Type { @@ -616,7 +630,7 @@ func matchesCredential(config map[string]interface{}, credential vc.VerifiableCr for _, configCtx := range contexts { ctxStr, ok := configCtx.(string) if !ok { - continue + return false } found := false for _, credCtx := range credential.Context { diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index 62c33d0454..3ffab277b1 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -162,6 +162,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { } createClaims := func(nonce string) map[string]interface{} { return map[string]interface{}{ + "iss": holderDID.String(), "aud": issuerIdentifier, "iat": time.Now().Unix(), "nonce": nonce, @@ -264,7 +265,27 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - credential offer was signed by other DID than intended wallet: did:nuts:holder#1") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - proof iss claim does not match expected wallet: did:nuts:holder") + assert.Nil(t, response) + }) + t.Run("iss claim does not match wallet DID", func(t *testing.T) { + service := requireNewTestHandler(t, keyResolver) + _, err := service.createOffer(ctx, issuedVC, preAuthCode) + require.NoError(t, err) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + + wrongIssClaims := map[string]interface{}{ + "iss": "did:nuts:wrong-issuer", + "aud": issuerIdentifier, + "iat": time.Now().Unix(), + "nonce": "", + } + invalidRequest := createRequest(createHeaders(), wrongIssClaims, configID) + + response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) + + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - proof iss claim does not match expected wallet: did:nuts:wrong-issuer") assert.Nil(t, response) }) t.Run("signing key is unknown", func(t *testing.T) { @@ -454,6 +475,7 @@ func Test_memoryIssuer_validateProof_metadataDriven(t *testing.T) { } createClaims := func(nonce string) map[string]interface{} { return map[string]interface{}{ + "iss": holderDID.String(), "aud": issuerIdentifier, "iat": time.Now().Unix(), "nonce": nonce, @@ -552,6 +574,7 @@ func Test_memoryIssuer_validateProof_metadataDriven(t *testing.T) { configID := "ExampleCredential_ldp_vc" claimsWithNumericNonce := map[string]interface{}{ + "iss": holderDID.String(), "aud": issuerIdentifier, "iat": time.Now().Unix(), "nonce": 12345, // non-string diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index 2a21cbcf17..b9dad18988 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -156,6 +156,9 @@ func (h defaultIssuerAPIClient) RequestNonce(ctx context.Context) (*NonceRespons if err != nil { return nil, fmt.Errorf("nonce request failed: %w", err) } + if nonceResponse.CNonce == "" { + return nil, errors.New("nonce endpoint returned empty c_nonce") + } return &nonceResponse, nil } diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index 60705a5823..475c413a44 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -108,7 +108,7 @@ type CredentialOffer struct { // These IDs reference entries in the credential_configurations_supported metadata. CredentialConfigurationIds []string `json:"credential_configuration_ids"` // Grants defines the grants offered by the issuer to the wallet. - Grants CredentialOfferGrants `json:"grants"` + Grants *CredentialOfferGrants `json:"grants,omitempty"` } // CredentialOfferGrants defines the grant types in a credential offer. diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go index 849735659f..e824f5368e 100644 --- a/vcr/openid4vci/types_test.go +++ b/vcr/openid4vci/types_test.go @@ -152,7 +152,7 @@ func TestCredentialOffer_V1Spec(t *testing.T) { offer := CredentialOffer{ CredentialIssuer: "https://issuer.example.com", CredentialConfigurationIds: []string{"NutsAuthorizationCredential_ldp_vc"}, - Grants: CredentialOfferGrants{ + Grants: &CredentialOfferGrants{ PreAuthorizedCode: &PreAuthorizedCodeParams{ PreAuthorizedCode: "secret123", }, diff --git a/vcr/openid4vci/wallet_client_test.go b/vcr/openid4vci/wallet_client_test.go index 3348b119d6..cd5d699c40 100644 --- a/vcr/openid4vci/wallet_client_test.go +++ b/vcr/openid4vci/wallet_client_test.go @@ -68,7 +68,7 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { err = client.OfferCredential(ctx, CredentialOffer{ CredentialIssuer: setup.issuerMetadata.CredentialIssuer, CredentialConfigurationIds: []string{}, - Grants: CredentialOfferGrants{ + Grants: &CredentialOfferGrants{ PreAuthorizedCode: &PreAuthorizedCodeParams{ PreAuthorizedCode: "test-code", }, @@ -100,7 +100,7 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { err = client.OfferCredential(ctx, CredentialOffer{ CredentialIssuer: setup.issuerMetadata.CredentialIssuer, CredentialConfigurationIds: []string{}, - Grants: CredentialOfferGrants{ + Grants: &CredentialOfferGrants{ PreAuthorizedCode: &PreAuthorizedCodeParams{ PreAuthorizedCode: "test-code", }, @@ -120,7 +120,7 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { err = client.OfferCredential(ctx, CredentialOffer{ CredentialIssuer: setup.issuerMetadata.CredentialIssuer, CredentialConfigurationIds: []string{}, - Grants: CredentialOfferGrants{ + Grants: &CredentialOfferGrants{ PreAuthorizedCode: &PreAuthorizedCodeParams{ PreAuthorizedCode: "test-code", }, From 9fe7d93b40ffc88eb39f05b45c9b4d78692b8e80 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 19:04:54 +0100 Subject: [PATCH 15/44] refactor(openid4vci): clean up CredentialRequest and rename Id to ID Remove draft-era Format and CredentialDefinition fields from CredentialRequest (v1.0 uses credential_configuration_id only). Rename CredentialConfigurationId(s) to CredentialConfigurationID(s) per Go naming convention for acronyms. JSON wire format unchanged. --- auth/api/iam/openid4vci.go | 4 +- auth/api/iam/openid4vci_test.go | 2 +- auth/api/iam/session.go | 4 +- auth/client/iam/client.go | 4 +- docs/_static/vcr/openid4vci_v0.yaml | 2 - vcr/api/openid4vci/v0/holder_test.go | 2 +- vcr/api/openid4vci/v0/issuer_test.go | 2 +- vcr/holder/openid.go | 6 +-- vcr/holder/openid_test.go | 29 +++++----- vcr/issuer/openid.go | 12 ++--- vcr/issuer/openid_test.go | 12 ++--- vcr/openid4vci/issuer_client_test.go | 2 +- vcr/openid4vci/types.go | 20 +++---- vcr/openid4vci/types_test.go | 72 +++---------------------- vcr/openid4vci/wallet_client_test.go | 6 +-- vcr/test/openid4vci_integration_test.go | 2 +- 16 files changed, 55 insertions(+), 126 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index aa2fa0e0bb..6d3dc9a1f5 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -109,7 +109,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques IssuerURL: authzServerMetadata.Issuer, IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, - IssuerCredentialConfigurationId: credentialConfigID, + IssuerCredentialConfigurationID: credentialConfigID, }) if err != nil { return nil, fmt.Errorf("failed to store session: %w", err) @@ -204,7 +204,7 @@ func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *O if err != nil { return nil, fmt.Errorf("error building proof: %w", err) } - return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, oauthSession.IssuerCredentialConfigurationId, proofJWT) + return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, oauthSession.IssuerCredentialConfigurationID, proofJWT) } func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) { diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index a15c03a749..3891c8a9ad 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -201,7 +201,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { IssuerURL: issuerClientID, IssuerCredentialEndpoint: credEndpoint, IssuerNonceEndpoint: nonceEndpoint, - IssuerCredentialConfigurationId: credentialConfigID, + IssuerCredentialConfigurationID: credentialConfigID, } sessionWithoutNonce := session sessionWithoutNonce.IssuerNonceEndpoint = "" diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 7629ff19f4..09ef6fcd9e 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -57,8 +57,8 @@ type OAuthSession struct { IssuerCredentialEndpoint string `json:"issuer_credential_endpoint,omitempty"` // IssuerNonceEndpoint: endpoint to request a fresh c_nonce in the OpenID4VCI flow (v1.0 Section 7) IssuerNonceEndpoint string `json:"issuer_nonce_endpoint,omitempty"` - // IssuerCredentialConfigurationId: the credential_configuration_id for the credential request in the OpenID4VCI flow - IssuerCredentialConfigurationId string `json:"issuer_credential_configuration_id,omitempty"` + // IssuerCredentialConfigurationID: the credential_configuration_id for the credential request in the OpenID4VCI flow + IssuerCredentialConfigurationID string `json:"issuer_credential_configuration_id,omitempty"` } // oauthClientFlow is used by a client to identify the flow a particular callback is part of diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 647644593f..31693464a9 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -339,7 +339,7 @@ func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc { // CredentialRequest represents the request to fetch a credential per OpenID4VCI v1.0 Section 8.2. type CredentialRequest struct { - CredentialConfigurationId string `json:"credential_configuration_id,omitempty"` + CredentialConfigurationID string `json:"credential_configuration_id,omitempty"` Proofs CredentialRequestProofs `json:"proofs"` } @@ -365,7 +365,7 @@ func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoi } credentialRequest := CredentialRequest{ - CredentialConfigurationId: credentialConfigID, + CredentialConfigurationID: credentialConfigID, Proofs: CredentialRequestProofs{ Jwt: []string{proofJwt}, }, diff --git a/docs/_static/vcr/openid4vci_v0.yaml b/docs/_static/vcr/openid4vci_v0.yaml index e288d29647..944f6dfcb2 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -411,8 +411,6 @@ components: - credential_configuration_id description: | Per OpenID4VCI v1.0 Section 8.2, the request identifies the credential using credential_configuration_id. - Note: the v1.0 spec also allows format-based requests and credential_identifier, but this implementation - only accepts credential_configuration_id. properties: credential_configuration_id: type: string diff --git a/vcr/api/openid4vci/v0/holder_test.go b/vcr/api/openid4vci/v0/holder_test.go index e2c161cc18..557aa60f2a 100644 --- a/vcr/api/openid4vci/v0/holder_test.go +++ b/vcr/api/openid4vci/v0/holder_test.go @@ -87,7 +87,7 @@ func TestWrapper_HandleCredentialOffer(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ CredentialIssuer: issuerDID.String(), - CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "code", diff --git a/vcr/api/openid4vci/v0/issuer_test.go b/vcr/api/openid4vci/v0/issuer_test.go index a14be6f572..3b82fda1ce 100644 --- a/vcr/api/openid4vci/v0/issuer_test.go +++ b/vcr/api/openid4vci/v0/issuer_test.go @@ -221,7 +221,7 @@ func TestWrapper_RequestCredential(t *testing.T) { Authorization: &authz, }, Body: &RequestCredentialJSONRequestBody{ - CredentialConfigurationId: "NutsOrganizationCredential_ldp_vc", + CredentialConfigurationID: "NutsOrganizationCredential_ldp_vc", }, }) diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index ebff5cf651..3ae8b5a327 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -86,7 +86,7 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 // TODO: This check is too simplistic, there can be multiple credential_configuration_ids, // but we only support one at a time. // See https://github.com/nuts-foundation/nuts-node/issues/2049 - if len(offer.CredentialConfigurationIds) != 1 { + if len(offer.CredentialConfigurationIDs) != 1 { return openid4vci.Error{ Err: errors.New("there must be exactly 1 credential_configuration_id in credential offer"), Code: openid4vci.InvalidRequest, @@ -113,7 +113,7 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } // Resolve the credential configuration from the issuer metadata - credentialConfigID := offer.CredentialConfigurationIds[0] + credentialConfigID := offer.CredentialConfigurationIDs[0] offeredCredential, err := h.resolveCredentialConfiguration(issuerClient.Metadata(), credentialConfigID) if err != nil { return openid4vci.Error{ @@ -282,7 +282,7 @@ func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient ope } credentialRequest := openid4vci.CredentialRequest{ - CredentialConfigurationId: credentialConfigID, + CredentialConfigurationID: credentialConfigID, Proofs: &openid4vci.CredentialRequestProofs{ Jwt: []string{proof}, }, diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index 278239a4e0..c3e749bd99 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -61,7 +61,7 @@ func Test_wallet_Metadata(t *testing.T) { func Test_wallet_HandleCredentialOffer(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ CredentialIssuer: issuerDID.String(), - CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "code", @@ -95,10 +95,9 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ "pre-authorized_code": "code", }).Return(tokenResponse, nil) - // Verify that the holder sends credential_configuration_id (v1.0 preferred approach) - // instead of format + credential_definition + // Verify that the holder sends credential_configuration_id in the credential request expectedRequest := openid4vci.CredentialRequest{ - CredentialConfigurationId: "ExampleCredential_ldp_vc", + CredentialConfigurationID: "ExampleCredential_ldp_vc", Proofs: &openid4vci.CredentialRequestProofs{ Jwt: []string{"signed-jwt"}, }, @@ -138,13 +137,13 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { t.Run("pre-authorized code grant", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) t.Run("no grants", func(t *testing.T) { - offer := openid4vci.CredentialOffer{CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}} + offer := openid4vci.CredentialOffer{CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}} err := w.HandleCredentialOffer(audit.TestContext(), offer) require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) t.Run("no pre-authorized grant", func(t *testing.T) { offer := openid4vci.CredentialOffer{ - CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, Grants: nil, } err := w.HandleCredentialOffer(audit.TestContext(), offer) @@ -152,7 +151,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { }) t.Run("empty pre-authorized code", func(t *testing.T) { offer := openid4vci.CredentialOffer{ - CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "", @@ -167,7 +166,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) offer := openid4vci.CredentialOffer{ - CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc", "OtherCredential_ldp_vc"}, + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc", "OtherCredential_ldp_vc"}, } err := w.HandleCredentialOffer(audit.TestContext(), offer).(openid4vci.Error) @@ -262,7 +261,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ CredentialIssuer: "http://localhost:87632", - CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "foo", @@ -315,7 +314,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { } err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ - CredentialConfigurationIds: []string{"TestCredential_unsupported"}, + CredentialConfigurationIDs: []string{"TestCredential_unsupported"}, Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "foo", @@ -363,7 +362,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { } err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ - CredentialConfigurationIds: []string{"TestCredential_ldp_vc"}, + CredentialConfigurationIDs: []string{"TestCredential_ldp_vc"}, Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "foo", @@ -378,7 +377,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ CredentialIssuer: issuerDID.String(), - CredentialConfigurationIds: []string{"ExampleCredential_ldp_vc"}, + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: "code", @@ -417,7 +416,7 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { "pre-authorized_code": "code", }).Return(tokenResponse, nil) expectedRequest := openid4vci.CredentialRequest{ - CredentialConfigurationId: "ExampleCredential_ldp_vc", + CredentialConfigurationID: "ExampleCredential_ldp_vc", Proofs: &openid4vci.CredentialRequestProofs{ Jwt: []string{"signed-jwt"}, }, @@ -468,14 +467,14 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { }).Return(tokenResponse, nil) // First credential request (with stale nonce) fails with invalid_nonce firstCredReq := openid4vci.CredentialRequest{ - CredentialConfigurationId: "ExampleCredential_ldp_vc", + CredentialConfigurationID: "ExampleCredential_ldp_vc", Proofs: &openid4vci.CredentialRequestProofs{Jwt: []string{"signed-jwt-1"}}, } issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), firstCredReq, "access-token"). Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) // Retry with fresh nonce succeeds retryCredReq := openid4vci.CredentialRequest{ - CredentialConfigurationId: "ExampleCredential_ldp_vc", + CredentialConfigurationID: "ExampleCredential_ldp_vc", Proofs: &openid4vci.CredentialRequestProofs{Jwt: []string{"signed-jwt-2"}}, } issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), retryCredReq, "access-token"). diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index 62a13b8efb..11b28c8343 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -212,9 +212,9 @@ func (i *openidHandler) OfferCredential(ctx context.Context, credential vc.Verif } func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request openid4vci.CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) { - // v1.0 Section 8.2 allows credential_configuration_id, credential_identifier, or format-based requests. + // v1.0 Section 8.2 requires credential_configuration_id or credential_identifier (mutually exclusive). // This implementation only accepts credential_configuration_id as a policy choice. - if request.CredentialConfigurationId == "" { + if request.CredentialConfigurationID == "" { return nil, openid4vci.Error{ Err: errors.New("credential request must contain credential_configuration_id"), Code: openid4vci.InvalidCredentialRequest, @@ -255,9 +255,9 @@ func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request ope StatusCode: http.StatusBadRequest, } } - if request.CredentialConfigurationId != expectedConfigID { + if request.CredentialConfigurationID != expectedConfigID { return nil, openid4vci.Error{ - Err: fmt.Errorf("credential_configuration_id '%s' does not match offered '%s'", request.CredentialConfigurationId, expectedConfigID), + Err: fmt.Errorf("credential_configuration_id '%s' does not match offered '%s'", request.CredentialConfigurationID, expectedConfigID), Code: openid4vci.UnknownCredentialConfiguration, StatusCode: http.StatusBadRequest, } @@ -294,7 +294,7 @@ func (i *openidHandler) HandleNonceRequest(ctx context.Context) (string, error) // See https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-proof-types func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request openid4vci.CredentialRequest) error { // Check if the credential configuration requires proof - credConfig, ok := i.credentialConfigurationsSupported[request.CredentialConfigurationId] + credConfig, ok := i.credentialConfigurationsSupported[request.CredentialConfigurationID] if ok { if _, hasProofTypes := credConfig["proof_types_supported"]; !hasProofTypes { return nil // no proof required for this credential configuration @@ -433,7 +433,7 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl offer := openid4vci.CredentialOffer{ CredentialIssuer: i.issuerIdentifierURL, - CredentialConfigurationIds: []string{credentialConfigID}, + CredentialConfigurationIDs: []string{credentialConfigID}, Grants: &openid4vci.CredentialOfferGrants{ PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ PreAuthorizedCode: preAuthorizedCode, diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index 3ffab277b1..74e3d50112 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -177,7 +177,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { } createRequest := func(headers, claims map[string]interface{}, configID string) openid4vci.CredentialRequest { return openid4vci.CredentialRequest{ - CredentialConfigurationId: configID, + CredentialConfigurationID: configID, Proofs: createProofs(headers, claims), } } @@ -191,7 +191,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { require.NoError(t, err) nonce, err := service.HandleNonceRequest(ctx) require.NoError(t, err) - configID := offer.CredentialConfigurationIds[0] + configID := offer.CredentialConfigurationIDs[0] validRequest := createRequest(createHeaders(), createClaims(nonce), configID) t.Run("ok", func(t *testing.T) { @@ -260,7 +260,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) - otherConfigID := otherOffer.CredentialConfigurationIds[0] + otherConfigID := otherOffer.CredentialConfigurationIDs[0] invalidRequest := createRequest(createHeaders(), createClaims(""), otherConfigID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -504,7 +504,7 @@ func Test_memoryIssuer_validateProof_metadataDriven(t *testing.T) { configID := "ExampleCredential_ldp_vc" request := openid4vci.CredentialRequest{ - CredentialConfigurationId: configID, + CredentialConfigurationID: configID, Proofs: createProofs(createHeaders(), createClaims(standaloneNonce)), } @@ -553,7 +553,7 @@ func Test_memoryIssuer_validateProof_metadataDriven(t *testing.T) { // Request without proof should succeed request := openid4vci.CredentialRequest{ - CredentialConfigurationId: "NoProofCredential_ldp_vc", + CredentialConfigurationID: "NoProofCredential_ldp_vc", } response, err := handler.HandleCredentialRequest(ctx, request, accessToken) @@ -580,7 +580,7 @@ func Test_memoryIssuer_validateProof_metadataDriven(t *testing.T) { "nonce": 12345, // non-string } request := openid4vci.CredentialRequest{ - CredentialConfigurationId: configID, + CredentialConfigurationID: configID, Proofs: createProofs(createHeaders(), claimsWithNumericNonce), } diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index f80880584d..ee1328adc8 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -88,7 +88,7 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { ctx := context.Background() httpClient := &http.Client{} credentialRequest := CredentialRequest{ - CredentialConfigurationId: "NutsOrganizationCredential_ldp_vc", + CredentialConfigurationID: "NutsOrganizationCredential_ldp_vc", } t.Run("ok", func(t *testing.T) { setup := setupClientTest(t) diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index 475c413a44..f7fa606a4a 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -104,9 +104,9 @@ type ProviderMetadata struct { type CredentialOffer struct { // CredentialIssuer defines the identifier of the credential issuer. CredentialIssuer string `json:"credential_issuer"` - // CredentialConfigurationIds defines references to credential configurations offered by the issuer. + // CredentialConfigurationIDs defines references to credential configurations offered by the issuer. // These IDs reference entries in the credential_configurations_supported metadata. - CredentialConfigurationIds []string `json:"credential_configuration_ids"` + CredentialConfigurationIDs []string `json:"credential_configuration_ids"` // Grants defines the grants offered by the issuer to the wallet. Grants *CredentialOfferGrants `json:"grants,omitempty"` } @@ -147,20 +147,12 @@ type CredentialOfferResponse struct { } // CredentialRequest defines the credential request sent by the wallet to the issuer. -// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request. -// Per v1.0 Section 8.2, the request MUST contain ONE of: -// - credential_configuration_id: references an entry in credential_configurations_supported -// - format + format-specific parameters (e.g., credential_definition for ldp_vc) +// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request +// Per v1.0 Section 8.2, the request identifies the credential using credential_configuration_id. type CredentialRequest struct { - // CredentialConfigurationId references a credential configuration from issuer metadata. - // When present, format and credential_definition should not be used. - CredentialConfigurationId string `json:"credential_configuration_id,omitempty"` - // Format specifies the credential format. Required when credential_configuration_id is not used. - Format string `json:"format,omitempty"` - // CredentialDefinition contains the credential definition for ldp_vc format. - CredentialDefinition *CredentialDefinition `json:"credential_definition,omitempty"` + // CredentialConfigurationID references a credential configuration from issuer metadata. + CredentialConfigurationID string `json:"credential_configuration_id,omitempty"` // Proofs contains the proof(s) of possession of the key material. - // In v1.0 this uses `proofs` (plural) with a map of proof type to array of proofs. Proofs *CredentialRequestProofs `json:"proofs,omitempty"` } diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go index e824f5368e..80b30fd6a3 100644 --- a/vcr/openid4vci/types_test.go +++ b/vcr/openid4vci/types_test.go @@ -28,14 +28,8 @@ import ( ) // TestCredentialRequest_V1Spec tests that CredentialRequest conforms to OpenID4VCI v1.0 Section 8.2 -// The spec states that credential request MUST contain ONE of: -// - credential_configuration_id: string referencing metadata -// - format + format-specific parameters (e.g., credential_definition for ldp_vc) func TestCredentialRequest_V1Spec(t *testing.T) { - t.Run("request with credential_configuration_id only (v1.0 preferred)", func(t *testing.T) { - // Per v1.0 Section 8.2: "credential_configuration_id: REQUIRED when the credential_configuration_id - // parameter was not present in the Credential Offer" - // This is the simpler approach - just reference the configuration by ID + t.Run("request with credential_configuration_id", func(t *testing.T) { requestJSON := `{ "credential_configuration_id": "NutsAuthorizationCredential_ldp_vc", "proofs": { @@ -47,38 +41,13 @@ func TestCredentialRequest_V1Spec(t *testing.T) { err := json.Unmarshal([]byte(requestJSON), &request) require.NoError(t, err) - assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", request.CredentialConfigurationId) - assert.Empty(t, request.Format, "format should not be required when using credential_configuration_id") + assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", request.CredentialConfigurationID) assert.NotNil(t, request.Proofs) }) - t.Run("request with format + credential_definition (explicit approach)", func(t *testing.T) { - // Per v1.0 Appendix A.1.2 for ldp_vc format - requestJSON := `{ - "format": "ldp_vc", - "credential_definition": { - "@context": ["https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"], - "type": ["VerifiableCredential", "NutsAuthorizationCredential"] - }, - "proofs": { - "jwt": ["eyJ..."] - } - }` - - var request CredentialRequest - err := json.Unmarshal([]byte(requestJSON), &request) - require.NoError(t, err) - - assert.Empty(t, request.CredentialConfigurationId) - assert.Equal(t, "ldp_vc", request.Format) - assert.NotNil(t, request.CredentialDefinition) - assert.Len(t, request.CredentialDefinition.Context, 2) - assert.Len(t, request.CredentialDefinition.Type, 2) - }) - - t.Run("marshaling request with credential_configuration_id omits format and credential_definition", func(t *testing.T) { + t.Run("marshaling only includes non-empty fields", func(t *testing.T) { request := CredentialRequest{ - CredentialConfigurationId: "NutsAuthorizationCredential_ldp_vc", + CredentialConfigurationID: "NutsAuthorizationCredential_ldp_vc", Proofs: &CredentialRequestProofs{ Jwt: []string{"eyJ..."}, }, @@ -92,35 +61,6 @@ func TestCredentialRequest_V1Spec(t *testing.T) { require.NoError(t, err) assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", parsed["credential_configuration_id"]) - _, hasFormat := parsed["format"] - assert.False(t, hasFormat, "format must be absent when using credential_configuration_id") - _, hasCredDef := parsed["credential_definition"] - assert.False(t, hasCredDef, "credential_definition must be absent when using credential_configuration_id") - }) - - t.Run("marshaling request with format omits credential_configuration_id", func(t *testing.T) { - request := CredentialRequest{ - Format: "ldp_vc", - CredentialDefinition: &CredentialDefinition{ - Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, - }, - Proofs: &CredentialRequestProofs{ - Jwt: []string{"eyJ..."}, - }, - } - - jsonBytes, err := json.Marshal(request) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal(jsonBytes, &parsed) - require.NoError(t, err) - - assert.Equal(t, "ldp_vc", parsed["format"]) - assert.NotNil(t, parsed["credential_definition"]) - _, hasConfigID := parsed["credential_configuration_id"] - assert.False(t, hasConfigID, "credential_configuration_id must be absent when using format") }) } @@ -143,7 +83,7 @@ func TestCredentialOffer_V1Spec(t *testing.T) { require.NoError(t, err) assert.Equal(t, "https://issuer.example.com", offer.CredentialIssuer) - assert.Equal(t, []string{"NutsAuthorizationCredential_ldp_vc"}, offer.CredentialConfigurationIds) + assert.Equal(t, []string{"NutsAuthorizationCredential_ldp_vc"}, offer.CredentialConfigurationIDs) require.NotNil(t, offer.Grants.PreAuthorizedCode) assert.Equal(t, "secret123", offer.Grants.PreAuthorizedCode.PreAuthorizedCode) }) @@ -151,7 +91,7 @@ func TestCredentialOffer_V1Spec(t *testing.T) { t.Run("marshaling preserves v1.0 format", func(t *testing.T) { offer := CredentialOffer{ CredentialIssuer: "https://issuer.example.com", - CredentialConfigurationIds: []string{"NutsAuthorizationCredential_ldp_vc"}, + CredentialConfigurationIDs: []string{"NutsAuthorizationCredential_ldp_vc"}, Grants: &CredentialOfferGrants{ PreAuthorizedCode: &PreAuthorizedCodeParams{ PreAuthorizedCode: "secret123", diff --git a/vcr/openid4vci/wallet_client_test.go b/vcr/openid4vci/wallet_client_test.go index cd5d699c40..e8c5a5fabd 100644 --- a/vcr/openid4vci/wallet_client_test.go +++ b/vcr/openid4vci/wallet_client_test.go @@ -67,7 +67,7 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { err = client.OfferCredential(ctx, CredentialOffer{ CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - CredentialConfigurationIds: []string{}, + CredentialConfigurationIDs: []string{}, Grants: &CredentialOfferGrants{ PreAuthorizedCode: &PreAuthorizedCodeParams{ PreAuthorizedCode: "test-code", @@ -99,7 +99,7 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { err = client.OfferCredential(ctx, CredentialOffer{ CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - CredentialConfigurationIds: []string{}, + CredentialConfigurationIDs: []string{}, Grants: &CredentialOfferGrants{ PreAuthorizedCode: &PreAuthorizedCodeParams{ PreAuthorizedCode: "test-code", @@ -119,7 +119,7 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { err = client.OfferCredential(ctx, CredentialOffer{ CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - CredentialConfigurationIds: []string{}, + CredentialConfigurationIDs: []string{}, Grants: &CredentialOfferGrants{ PreAuthorizedCode: &PreAuthorizedCodeParams{ PreAuthorizedCode: "test-code", diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index 5472894a09..605e71efe4 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -125,7 +125,7 @@ func TestOpenID4VCIErrorResponses(t *testing.T) { require.NoError(t, err) requestBody, _ := json.Marshal(openid4vci.CredentialRequest{ - CredentialConfigurationId: "NutsOrganizationCredential_ldp_vc", + CredentialConfigurationID: "NutsOrganizationCredential_ldp_vc", }) t.Run("error from API layer (missing access token)", func(t *testing.T) { From 5562e99420d1040acd26c55d500c976b2249b975 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 21:11:45 +0100 Subject: [PATCH 16/44] fix(openid4vci): use json.RawMessage for CredentialResponseEntry Change Credential field from map[string]interface{} to json.RawMessage to support any credential format (JSON-LD objects, JWT strings). Fixes Copilot review finding and aligns vcr module with auth module's type. --- vcr/api/openid4vci/v0/issuer.go | 8 +------- vcr/openid4vci/issuer_client.go | 3 +-- vcr/openid4vci/issuer_client_test.go | 4 +++- vcr/openid4vci/test.go | 17 ++++++++--------- vcr/openid4vci/types.go | 3 ++- vcr/openid4vci/types_test.go | 8 ++++---- 6 files changed, 19 insertions(+), 24 deletions(-) diff --git a/vcr/api/openid4vci/v0/issuer.go b/vcr/api/openid4vci/v0/issuer.go index 3a3d36c7d8..0e501a92e9 100644 --- a/vcr/api/openid4vci/v0/issuer.go +++ b/vcr/api/openid4vci/v0/issuer.go @@ -20,7 +20,6 @@ package v0 import ( "context" - "encoding/json" "errors" "fmt" "github.com/nuts-foundation/nuts-node/vcr/issuer" @@ -100,13 +99,8 @@ func (w Wrapper) RequestCredential(ctx context.Context, request RequestCredentia return nil, err } credentialJSON, _ := credential.MarshalJSON() - credentialMap := make(map[string]interface{}) - err = json.Unmarshal(credentialJSON, &credentialMap) - if err != nil { - return nil, err - } return RequestCredential200JSONResponse(CredentialResponse{ - Credentials: []openid4vci.CredentialResponseEntry{{Credential: credentialMap}}, + Credentials: []openid4vci.CredentialResponseEntry{{Credential: credentialJSON}}, }), nil } diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index b9dad18988..d7f91dc484 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -107,8 +107,7 @@ func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request C } // We only support single credential issuance for now var credential vc.VerifiableCredential - credentialJSON, _ := json.Marshal(credentialResponse.Credentials[0].Credential) - err = json.Unmarshal(credentialJSON, &credential) + err = json.Unmarshal(credentialResponse.Credentials[0].Credential, &credential) if err != nil { return nil, fmt.Errorf("unable to unmarshal received credential: %w", err) } diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index ee1328adc8..8f4b07c7bc 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -20,6 +20,7 @@ package openid4vci import ( "context" + "encoding/json" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "net/http" @@ -113,8 +114,9 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { }) t.Run("error - invalid credentials in response", func(t *testing.T) { setup := setupClientTest(t) + invalidCredJSON, _ := json.Marshal(map[string]interface{}{"issuer": []string{"1", "2"}}) setup.credentialHandler = setup.httpPostHandler(CredentialResponse{Credentials: []CredentialResponseEntry{ - {Credential: map[string]interface{}{"issuer": []string{"1", "2"}}}, // Invalid issuer + {Credential: invalidCredJSON}, // Invalid issuer }}) client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) require.NoError(t, err) diff --git a/vcr/openid4vci/test.go b/vcr/openid4vci/test.go index d7b6482628..128ee5151e 100644 --- a/vcr/openid4vci/test.go +++ b/vcr/openid4vci/test.go @@ -34,17 +34,16 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { issuerMetadata := new(CredentialIssuerMetadata) providerMetadata := new(ProviderMetadata) walletMetadata := new(OAuth2ClientMetadata) + credentialJSON, _ := json.Marshal(map[string]interface{}{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential"}, + "issuer": "issuer", + "issuanceDate": time.Now().Format(time.RFC3339), + "credentialSubject": map[string]interface{}{"id": "id"}, + }) credentialResponse := CredentialResponse{ Credentials: []CredentialResponseEntry{ - { - Credential: map[string]interface{}{ - "@context": []string{"https://www.w3.org/2018/credentials/v1"}, - "type": []string{"VerifiableCredential"}, - "issuer": "issuer", - "issuanceDate": time.Now().Format(time.RFC3339), - "credentialSubject": map[string]interface{}{"id": "id"}, - }, - }, + {Credential: credentialJSON}, }, } clientTest := &oidcClientTestContext{ diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index f7fa606a4a..d67873270a 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -21,6 +21,7 @@ package openid4vci import ( + "encoding/json" ssi "github.com/nuts-foundation/go-did" "time" ) @@ -174,7 +175,7 @@ type CredentialResponse struct { // CredentialResponseEntry is a single entry in the credentials array of a CredentialResponse. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response type CredentialResponseEntry struct { - Credential map[string]interface{} `json:"credential"` + Credential json.RawMessage `json:"credential"` } // Config holds the config for the OpenID4VCI credential issuer and wallet diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go index 80b30fd6a3..aad0d70586 100644 --- a/vcr/openid4vci/types_test.go +++ b/vcr/openid4vci/types_test.go @@ -189,9 +189,9 @@ func TestCredentialIssuerMetadata_V1Spec(t *testing.T) { // v1.0 uses `credentials` (array of wrapper objects with `credential` key) and c_nonce is no longer in the response. func TestCredentialResponse_V1Spec(t *testing.T) { t.Run("response uses credentials array with credential wrapper", func(t *testing.T) { - cred := map[string]interface{}{"issuer": "did:nuts:issuer"} + credJSON, _ := json.Marshal(map[string]interface{}{"issuer": "did:nuts:issuer"}) response := CredentialResponse{ - Credentials: []CredentialResponseEntry{{Credential: cred}}, + Credentials: []CredentialResponseEntry{{Credential: credJSON}}, } jsonBytes, err := json.Marshal(response) @@ -215,9 +215,9 @@ func TestCredentialResponse_V1Spec(t *testing.T) { }) t.Run("response does not contain c_nonce fields", func(t *testing.T) { - cred := map[string]interface{}{"issuer": "did:nuts:issuer"} + credJSON, _ := json.Marshal(map[string]interface{}{"issuer": "did:nuts:issuer"}) response := CredentialResponse{ - Credentials: []CredentialResponseEntry{{Credential: cred}}, + Credentials: []CredentialResponseEntry{{Credential: credJSON}}, } jsonBytes, err := json.Marshal(response) From 0c1f57462929391f28add6aa19562259def56fac Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Wed, 11 Mar 2026 14:20:55 +0100 Subject: [PATCH 17/44] refactor(openid4vci): unify duplicate types across packages Delete duplicate CredentialRequest, CredentialResponse, and CredentialResponseEntry types from auth/client/iam. Use the canonical types from vcr/openid4vci as single source of truth. Replace local jwtTypeOpenID4VCIProof const with openid4vci.JWTTypeOpenID4VCIProof. --- auth/api/iam/openid4vci.go | 8 ++------ auth/api/iam/openid4vci_test.go | 13 ++++++------- auth/client/iam/client.go | 29 ++++------------------------- auth/client/iam/interface.go | 3 ++- auth/client/iam/mock.go | 5 +++-- auth/client/iam/openid4vp.go | 3 ++- 6 files changed, 19 insertions(+), 42 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 6d3dc9a1f5..8e7b7afa2b 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -30,7 +30,6 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" - iamclient "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" @@ -41,9 +40,6 @@ import ( var timeFunc = time.Now -// jwtTypeOpenID4VCIProof defines the OpenID4VCI JWT-subtype (used as typ claim in the JWT). -const jwtTypeOpenID4VCIProof = "openid4vci-proof+jwt" - func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, request RequestOpenid4VCICredentialIssuanceRequestObject) (RequestOpenid4VCICredentialIssuanceResponseObject, error) { if request.Body == nil { // why did oapi-codegen generate a pointer for the body?? @@ -199,7 +195,7 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode }, nil } -func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, nonce string) (*iamclient.CredentialResponse, error) { +func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, nonce string) (*openid4vci.CredentialResponse, error) { proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, nonce) if err != nil { return nil, fmt.Errorf("error building proof: %w", err) @@ -213,7 +209,7 @@ func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audien return "", fmt.Errorf("failed to resolve key for did (%s): %w", holderDid.String(), err) } headers := map[string]interface{}{ - "typ": jwtTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. + "typ": openid4vci.JWTTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. "kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. } claims := map[string]interface{}{ diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 3891c8a9ad..7cdf016dfa 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -28,7 +28,6 @@ import ( "github.com/nuts-foundation/nuts-node/core/to" - "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -207,8 +206,8 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { sessionWithoutNonce.IssuerNonceEndpoint = "" tokenResponse := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"} - credentialResponse := iam.CredentialResponse{ - Credentials: []iam.CredentialResponseEntry{{Credential: json.RawMessage(verifiableCredential.Raw())}}, + credentialResponse := openid4vci.CredentialResponse{ + Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(verifiableCredential.Raw())}}, } now := time.Now() timeFunc = func() time.Time { return now } @@ -363,8 +362,8 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&iam.CredentialResponse{ - Credentials: []iam.CredentialResponseEntry{{Credential: json.RawMessage(`"super invalid"`)}}, + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(`"super invalid"`)}}, }, nil) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -425,8 +424,8 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&iam.CredentialResponse{ - Credentials: []iam.CredentialResponseEntry{}, + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + Credentials: []openid4vci.CredentialResponseEntry{}, }, nil) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 31693464a9..f41956f112 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -337,36 +337,15 @@ func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc { } } -// CredentialRequest represents the request to fetch a credential per OpenID4VCI v1.0 Section 8.2. -type CredentialRequest struct { - CredentialConfigurationID string `json:"credential_configuration_id,omitempty"` - Proofs CredentialRequestProofs `json:"proofs"` -} - -// CredentialRequestProofs holds the proof(s) of possession keyed by proof type per v1.0 Section 8.2. -type CredentialRequestProofs struct { - Jwt []string `json:"jwt"` -} - -// CredentialResponse represents the response of a verifiable credential request per OpenID4VCI v1.0 Section 8.3. -type CredentialResponse struct { - Credentials []CredentialResponseEntry `json:"credentials"` -} - -// CredentialResponseEntry is a single entry in the credentials array. -type CredentialResponseEntry struct { - Credential json.RawMessage `json:"credential"` -} - -func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJwt string) (*CredentialResponse, error) { +func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJwt string) (*openid4vci.CredentialResponse, error) { credentialEndpointURL, err := url.Parse(credentialEndpoint) if err != nil { return nil, err } - credentialRequest := CredentialRequest{ + credentialRequest := openid4vci.CredentialRequest{ CredentialConfigurationID: credentialConfigID, - Proofs: CredentialRequestProofs{ + Proofs: &openid4vci.CredentialRequestProofs{ Jwt: []string{proofJwt}, }, } @@ -401,7 +380,7 @@ func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoi } return nil, fmt.Errorf("credential request failed (status %d)", response.StatusCode) } - var credentialResponse CredentialResponse + var credentialResponse openid4vci.CredentialResponse if err = json.Unmarshal(responseBody, &credentialResponse); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index aefd782ef4..b7575988c0 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -23,6 +23,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -58,7 +59,7 @@ type Client interface { // RequestNonce requests a fresh c_nonce from the issuer's Nonce Endpoint (v1.0 Section 7). RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) // VerifiableCredentials requests Verifiable Credentials from the issuer at the given endpoint. - VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*CredentialResponse, error) + VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) // RequestObjectByGet retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a GET method as defined in RFC9101/OpenID4VP. // This method is used when there is no 'request_uri_method', or its value is 'get'. RequestObjectByGet(ctx context.Context, requestURI string) (string, error) diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index a7a22e86ce..4640fe39d3 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -15,6 +15,7 @@ import ( vc "github.com/nuts-foundation/go-did/vc" oauth "github.com/nuts-foundation/nuts-node/auth/oauth" + openid4vci "github.com/nuts-foundation/nuts-node/vcr/openid4vci" pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -223,10 +224,10 @@ func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjec } // VerifiableCredentials mocks base method. -func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, proofJWT string) (*CredentialResponse, error) { +func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, proofJWT string) (*openid4vci.CredentialResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) - ret0, _ := ret[0].(*CredentialResponse) + ret0, _ := ret[0].(*openid4vci.CredentialResponse) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index b3bc282a6e..370929cc50 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -43,6 +43,7 @@ import ( "github.com/nuts-foundation/nuts-node/crypto/dpop" nutsHttp "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -359,7 +360,7 @@ func (c *OpenID4VPClient) RequestNonce(ctx context.Context, nonceEndpoint string return c.httpClient.RequestNonce(ctx, nonceEndpoint) } -func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*CredentialResponse, error) { +func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) { iamClient := c.httpClient rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) if err != nil { From b505e65f558cd09e9bfd9a8290c6d68fc8366a6d Mon Sep 17 00:00:00 2001 From: "qltysh[bot]" <168846912+qltysh[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:27:05 +0000 Subject: [PATCH 18/44] qlty fmt --- auth/api/iam/openid4vci.go | 6 +++--- vcr/issuer/openid.go | 14 +++++++------- vcr/issuer/openid_test.go | 2 +- vcr/openid4vci/types.go | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 8e7b7afa2b..e9b3eb5daf 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -101,8 +101,8 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques PKCEParams: pkceParams, // OpenID4VCI issuers may use multiple Authorization Servers // We must use the token_endpoint that corresponds to the same Authorization Server used for the authorization_endpoint - TokenEndpoint: authzServerMetadata.TokenEndpoint, - IssuerURL: authzServerMetadata.Issuer, + TokenEndpoint: authzServerMetadata.TokenEndpoint, + IssuerURL: authzServerMetadata.Issuer, IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, @@ -210,7 +210,7 @@ func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audien } headers := map[string]interface{}{ "typ": openid4vci.JWTTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. - "kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. + "kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. } claims := map[string]interface{}{ jwt.IssuerKey: holderDid.String(), diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index 11b28c8343..1c4a5f1c43 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -116,14 +116,14 @@ func NewOpenIDHandler(issuerDID did.DID, issuerIdentifierURL string, definitions } type openidHandler struct { - issuerIdentifierURL string - issuerDID did.DID - definitionsDIR string + issuerIdentifierURL string + issuerDID did.DID + definitionsDIR string credentialConfigurationsSupported map[string]map[string]interface{} - keyResolver resolver.KeyResolver - store OpenIDStore - walletClientCreator func(ctx context.Context, httpClient core.HTTPRequestDoer, walletMetadataURL string) (openid4vci.WalletAPIClient, error) - httpClient core.HTTPRequestDoer + keyResolver resolver.KeyResolver + store OpenIDStore + walletClientCreator func(ctx context.Context, httpClient core.HTTPRequestDoer, walletMetadataURL string) (openid4vci.WalletAPIClient, error) + httpClient core.HTTPRequestDoer } func (i *openidHandler) Metadata() openid4vci.CredentialIssuerMetadata { diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index 74e3d50112..62acabf80f 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -701,7 +701,7 @@ func Test_generateCredentialConfigID(t *testing.T) { }) t.Run("missing type", func(t *testing.T) { defMap := map[string]interface{}{ - "format": "ldp_vc", + "format": "ldp_vc", "credential_definition": map[string]interface{}{}, } _, err := generateCredentialConfigID(defMap) diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index d67873270a..d2afe68753 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -135,8 +135,8 @@ type OfferedCredential struct { // CredentialDefinition defines the 'credential_definition' for Format VerifiableCredentialJSONLDFormat // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html Appendix A.1.2 type CredentialDefinition struct { - Context []ssi.URI `json:"@context"` - Type []ssi.URI `json:"type"` + Context []ssi.URI `json:"@context"` + Type []ssi.URI `json:"type"` CredentialSubject map[string]interface{} `json:"credentialSubject,omitempty"` // optional and currently not used } From 7dc69bf65c4140cd1bf5584da75307c6130e8f77 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Thu, 7 May 2026 19:42:11 +0200 Subject: [PATCH 19/44] refactor(openid4vci): introduce auth/openid4vci package for v1.0 Move OpenID4VCI v1.0 wire types, the Error type with InvalidNonce, and OpenIDCredentialIssuerMetadata into a new isolated auth/openid4vci package. Auth-side (auth/api/iam, auth/client/iam) now imports protocol types from there; no import edges to vcr/openid4vci remain. Isolates the user/browser OpenID4VCI v1.0 flow from the unrelated internal node-to-node draft-11 implementation in vcr/openid4vci. --- auth/api/iam/api.go | 3 +- auth/api/iam/openid4vci.go | 2 +- auth/api/iam/openid4vci_test.go | 6 +- auth/client/iam/client.go | 8 +-- auth/client/iam/interface.go | 4 +- auth/client/iam/mock.go | 6 +- auth/client/iam/openid4vp.go | 4 +- auth/client/iam/openid4vp_test.go | 6 +- auth/oauth/types.go | 9 --- auth/openid4vci/error.go | 46 +++++++++++++++ auth/openid4vci/types.go | 96 +++++++++++++++++++++++++++++++ 11 files changed, 162 insertions(+), 28 deletions(-) create mode 100644 auth/openid4vci/error.go create mode 100644 auth/openid4vci/types.go diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index bedbba113d..6f6cf81c7a 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -47,6 +47,7 @@ import ( iamclient "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" nutsHttp "github.com/nuts-foundation/nuts-node/http" @@ -867,7 +868,7 @@ func (r Wrapper) StatusList(ctx context.Context, request StatusListRequestObject return StatusList200JSONResponse(*cred), nil } -func (r Wrapper) openid4vciMetadata(ctx context.Context, issuer string) (*oauth.OpenIDCredentialIssuerMetadata, *oauth.AuthorizationServerMetadata, error) { +func (r Wrapper) openid4vciMetadata(ctx context.Context, issuer string) (*openid4vci.OpenIDCredentialIssuerMetadata, *oauth.AuthorizationServerMetadata, error) { credentialIssuerMetadata, err := r.auth.IAMClient().OpenIdCredentialIssuerMetadata(ctx, issuer) if err != nil { return nil, nil, err diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index e9b3eb5daf..a3f938d5df 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -31,10 +31,10 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" nutsHttp "github.com/nuts-foundation/nuts-node/http" - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 7cdf016dfa..eb4e691e29 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -29,8 +29,8 @@ import ( "github.com/nuts-foundation/nuts-node/core/to" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/crypto" - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -40,7 +40,7 @@ import ( func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { redirectURI := "https://test.test/iam/123/cb" authServer := "https://auth.server/" - metadata := oauth.OpenIDCredentialIssuerMetadata{ + metadata := openid4vci.OpenIDCredentialIssuerMetadata{ CredentialIssuer: "issuer", CredentialEndpoint: "endpoint", AuthorizationServers: []string{authServer}, @@ -81,7 +81,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { ctx := newTestClient(t) - metadata := oauth.OpenIDCredentialIssuerMetadata{ + metadata := openid4vci.OpenIDCredentialIssuerMetadata{ CredentialIssuer: "issuer", CredentialEndpoint: "endpoint", AuthorizationServers: []string{}, // empty diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index f41956f112..9b8a254c00 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -38,8 +38,8 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/core" - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -271,12 +271,12 @@ func (hb HTTPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (st return nonceResponse.CNonce, nil } -func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) { +func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*openid4vci.OpenIDCredentialIssuerMetadata, error) { metadataURL, err := oauth.IssuerIdToWellKnown(oauthIssuerURI, oauth.OpenIdCredIssuerWellKnown, hb.strictMode) if err != nil { return nil, err } - var metadata oauth.OpenIDCredentialIssuerMetadata + var metadata openid4vci.OpenIDCredentialIssuerMetadata err = hb.doGet(ctx, metadataURL.String(), &metadata) if err != nil { return nil, err @@ -346,7 +346,7 @@ func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoi credentialRequest := openid4vci.CredentialRequest{ CredentialConfigurationID: credentialConfigID, Proofs: &openid4vci.CredentialRequestProofs{ - Jwt: []string{proofJwt}, + JWT: []string{proofJwt}, }, } jsonBody, _ := json.Marshal(credentialRequest) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index b7575988c0..fd6b644128 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -23,7 +23,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -53,7 +53,7 @@ type Client interface { // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. // oauthIssuer is the URL of the issuer as specified by RFC 8414 (OAuth 2.0 Authorization Server Metadata). - OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) + OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*openid4vci.OpenIDCredentialIssuerMetadata, error) // OpenIDConfiguration returns the OpenID Configuration of the remote wallet. OpenIDConfiguration(ctx context.Context, issuer string) (*oauth.OpenIDConfiguration, error) // RequestNonce requests a fresh c_nonce from the issuer's Nonce Endpoint (v1.0 Section 7). diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 4640fe39d3..34bf74e8c2 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -15,7 +15,7 @@ import ( vc "github.com/nuts-foundation/go-did/vc" oauth "github.com/nuts-foundation/nuts-node/auth/oauth" - openid4vci "github.com/nuts-foundation/nuts-node/vcr/openid4vci" + openid4vci "github.com/nuts-foundation/nuts-node/auth/openid4vci" pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -104,10 +104,10 @@ func (mr *MockClientMockRecorder) OpenIDConfiguration(ctx, issuer any) *gomock.C } // OpenIdCredentialIssuerMetadata mocks base method. -func (m *MockClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) { +func (m *MockClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*openid4vci.OpenIDCredentialIssuerMetadata, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OpenIdCredentialIssuerMetadata", ctx, oauthIssuerURI) - ret0, _ := ret[0].(*oauth.OpenIDCredentialIssuerMetadata) + ret0, _ := ret[0].(*openid4vci.OpenIDCredentialIssuerMetadata) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 370929cc50..c7c64af4df 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -38,12 +38,12 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/crypto/dpop" nutsHttp "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/vcr/holder" - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -347,7 +347,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return &tokenResponse, nil } -func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) { +func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*openid4vci.OpenIDCredentialIssuerMetadata, error) { iamClient := c.httpClient rsp, err := iamClient.OpenIdCredentialIssuerMetadata(ctx, oauthIssuerURI) if err != nil { diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index f706c08b15..f2d37c3419 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -39,10 +39,10 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/crypto" http2 "github.com/nuts-foundation/nuts-node/test/http" "github.com/nuts-foundation/nuts-node/vcr/holder" - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -513,7 +513,7 @@ type clientTestContext struct { type clientServerTestContext struct { *clientTestContext authzServerMetadata *oauth.AuthorizationServerMetadata - openIDCredentialIssuerMetadata *oauth.OpenIDCredentialIssuerMetadata + openIDCredentialIssuerMetadata *openid4vci.OpenIDCredentialIssuerMetadata handler http.HandlerFunc tlsServer *httptest.Server verifierDID did.DID @@ -531,7 +531,7 @@ type clientServerTestContext struct { } func createClientServerTestContext(t *testing.T) *clientServerTestContext { - credentialIssuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{} + credentialIssuerMetadata := &openid4vci.OpenIDCredentialIssuerMetadata{} metadata := &oauth.AuthorizationServerMetadata{VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), DIDMethodsSupported: []string{"test"}} ctx := &clientServerTestContext{ clientTestContext: createClientTestContext(t, nil), diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 4224c072ae..db294e95ae 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -403,15 +403,6 @@ type Redirect struct { RedirectURI string `json:"redirect_uri"` } -// OpenIDCredentialIssuerMetadata represents the metadata of an OpenID credential issuer -type OpenIDCredentialIssuerMetadata struct { - CredentialIssuer string `json:"credential_issuer"` - CredentialEndpoint string `json:"credential_endpoint"` - NonceEndpoint string `json:"nonce_endpoint,omitempty"` - AuthorizationServers []string `json:"authorization_servers,omitempty"` - Display []map[string]string `json:"display,omitempty"` -} - // OpenIDConfiguration represents the OpenID configuration // It contains the minimal information required for OpenID4VP, the required `jwks` is also omitted // see https://openid.net/specs/openid-connect-federation-1_0-29.html#entity-statement diff --git a/auth/openid4vci/error.go b/auth/openid4vci/error.go new file mode 100644 index 0000000000..9675549e0f --- /dev/null +++ b/auth/openid4vci/error.go @@ -0,0 +1,46 @@ +/* + * Nuts node + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package openid4vci + +// ErrorCode is an OpenID4VCI 1.0 (ID-1) error code as defined in §8.3.1.2 +// (Credential Endpoint) and §6.4 (Token Endpoint). +type ErrorCode string + +const ( + // InvalidNonce means at least one of the key proofs in the Credential + // Request contained an invalid c_nonce. Per §8.3.1.2 the wallet should + // retrieve a new c_nonce from the Nonce Endpoint (§7) and may retry. + InvalidNonce ErrorCode = "invalid_nonce" +) + +// Error is a wire-format error returned by an OpenID4VCI endpoint. +// Specified by §6.4 and §8.3. +type Error struct { + Code ErrorCode `json:"error"` + Err error `json:"-"` + StatusCode int `json:"-"` +} + +// Error implements the error interface. +func (e Error) Error() string { + if e.Err == nil { + return string(e.Code) + } + return string(e.Code) + " - " + e.Err.Error() +} diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go new file mode 100644 index 0000000000..4a27da78f7 --- /dev/null +++ b/auth/openid4vci/types.go @@ -0,0 +1,96 @@ +/* + * Nuts node + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Package openid4vci implements the OpenID for Verifiable Credential Issuance +// 1.0 (ID-1) protocol surface used by the user/browser flow in auth/api/iam. +// +// This package owns the v1.0 protocol types, error codes, and the HTTP client +// used to talk to a Credential Issuer. Consumers in auth/api/iam (HTTP +// handlers) and auth/client/iam (low-level HTTP plumbing) import from here. +// +// This package is independent of vcr/openid4vci, which is an internal +// node-to-node draft-11 issuance flow that diverges from v1.0 in several +// material ways and is not consumed from auth/. +// +// Reference: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html +package openid4vci + +import ( + "encoding/json" +) + +// JWTTypeOpenID4VCIProof is the JWT typ claim value used in OpenID4VCI key +// proofs (Section 8.2.1.1). +const JWTTypeOpenID4VCIProof = "openid4vci-proof+jwt" + +// OpenIDCredentialIssuerMetadata describes the OpenID4VCI Credential Issuer +// Metadata document published at /.well-known/openid-credential-issuer +// (Section 12.2). The document is OpenID4VCI-defined; it is not an OAuth +// authorization-server metadata document. +type OpenIDCredentialIssuerMetadata struct { + CredentialIssuer string `json:"credential_issuer"` + CredentialEndpoint string `json:"credential_endpoint"` + NonceEndpoint string `json:"nonce_endpoint,omitempty"` + AuthorizationServers []string `json:"authorization_servers,omitempty"` + Display []map[string]string `json:"display,omitempty"` +} + +// NonceResponse is the body returned by the Nonce Endpoint (Section 7.2). +type NonceResponse struct { + CNonce string `json:"c_nonce"` +} + +// CredentialRequest is the body of a Credential Request (Section 8.2). +// +// Either CredentialConfigurationID or CredentialIdentifier identifies the +// requested credential — see §5.1.1: when the Token Response carried +// authorization_details with credential_identifiers, the wallet sends +// CredentialIdentifier; otherwise it sends CredentialConfigurationID. +// Today the auth-side flow only emits CredentialConfigurationID; the field +// for CredentialIdentifier is present so future support is non-breaking. +type CredentialRequest struct { + CredentialConfigurationID string `json:"credential_configuration_id,omitempty"` + CredentialIdentifier string `json:"credential_identifier,omitempty"` + Proofs *CredentialRequestProofs `json:"proofs,omitempty"` +} + +// CredentialRequestProofs carries one or more key proofs in a Credential +// Request (Section 8.2.1). +type CredentialRequestProofs struct { + JWT []string `json:"jwt,omitempty"` +} + +// CredentialResponse is the body returned by the Credential Endpoint +// (Section 8.3). +// +// TransactionID, Interval, and NotificationID are present for forward +// compatibility (deferred issuance via HTTP 202 with a transaction id, and +// notification ids per §10). The auth-side flow today consumes only +// Credentials; the other fields are populated when the issuer sends them +// so they are available without a wire-format change later. +type CredentialResponse struct { + Credentials []CredentialResponseEntry `json:"credentials,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + Interval int `json:"interval,omitempty"` + NotificationID string `json:"notification_id,omitempty"` +} + +// CredentialResponseEntry is one issued credential in a Credential Response. +type CredentialResponseEntry struct { + Credential json.RawMessage `json:"credential"` +} From a0af85b7386f01785c5f71528f147c569de27c4f Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Thu, 7 May 2026 19:42:35 +0200 Subject: [PATCH 20/44] refactor(openid4vci): restore vcr internal flow to master (draft-11) Revert the vcr-side OpenID4VCI changes (vcr/openid4vci, vcr/issuer/openid*, vcr/holder/openid*, vcr/api/openid4vci/v0, the OpenAPI spec, codegen config, credential definition JSON, and the integration test) to their master state. The internal node-to-node HTTP issuance flow stays on draft-11 unchanged. The user/browser OpenID4VCI v1.0 logic now lives entirely in auth/openid4vci (introduced in the previous commit). --- codegen/configs/vcr_openid4vci_v0.yaml | 3 +- docs/_static/vcr/openid4vci_v0.yaml | 285 +++++----- vcr/api/openid4vci/v0/api.go | 3 - vcr/api/openid4vci/v0/generated.go | 91 +--- vcr/api/openid4vci/v0/holder_test.go | 20 +- vcr/api/openid4vci/v0/issuer.go | 31 +- vcr/api/openid4vci/v0/issuer_test.go | 44 +- vcr/holder/openid.go | 196 ++----- vcr/holder/openid_test.go | 432 +++------------ .../NutsAuthorizationCredential.json | 7 +- .../NutsOrganizationCredential.json | 7 +- vcr/issuer/openid.go | 395 ++++---------- vcr/issuer/openid_mock.go | 23 +- vcr/issuer/openid_store.go | 22 - vcr/issuer/openid_store_test.go | 19 - vcr/issuer/openid_test.go | 503 ++++-------------- vcr/issuer/test/valid/ExampleCredential.json | 7 +- vcr/openid4vci/error.go | 29 +- vcr/openid4vci/issuer_client.go | 64 +-- vcr/openid4vci/issuer_client_mock.go | 17 +- vcr/openid4vci/issuer_client_test.go | 52 +- vcr/openid4vci/test.go | 23 +- vcr/openid4vci/types.go | 75 +-- vcr/openid4vci/types_test.go | 269 ---------- vcr/openid4vci/validators.go | 11 +- vcr/openid4vci/validators_test.go | 2 +- vcr/openid4vci/wallet_client_test.go | 36 +- vcr/test/openid4vci_integration_test.go | 20 +- 28 files changed, 605 insertions(+), 2081 deletions(-) delete mode 100644 vcr/openid4vci/types_test.go diff --git a/codegen/configs/vcr_openid4vci_v0.yaml b/codegen/configs/vcr_openid4vci_v0.yaml index dd7adee1d8..2185dbd020 100644 --- a/codegen/configs/vcr_openid4vci_v0.yaml +++ b/codegen/configs/vcr_openid4vci_v0.yaml @@ -13,5 +13,4 @@ output-options: - CredentialRequest - CredentialResponse - TokenResponse - - ErrorResponse - - NonceResponse \ No newline at end of file + - ErrorResponse \ No newline at end of file diff --git a/docs/_static/vcr/openid4vci_v0.yaml b/docs/_static/vcr/openid4vci_v0.yaml index 944f6dfcb2..a4aa8dfa9c 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -6,7 +6,8 @@ info: name: GPLv3 url: https://www.gnu.org/licenses/gpl-3.0.en.html description: > - This API implements OpenID 4 Verifiable Credential Issuance (v1.0). + This API implements OpenID 4 Verifiable Credential Issuance. + The specification is in draft and may change, thus this API might change as well. servers: - url: http://localhost:8081 description: For internal-facing endpoints. @@ -203,10 +204,8 @@ paths: "$ref": "#/components/schemas/ErrorResponse" "400": description: > - Invalid request. Code can be "invalid_credential_request", "unknown_credential_configuration", - "unknown_credential_identifier", "invalid_proof", "invalid_nonce", "invalid_encryption_parameters", - or "credential_request_denied". - Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-8.3.1.2 + Invalid request. Code can be "invalid_request", "unsupported_credential_type", "unsupported_credential_format" or "invalid_proof". + Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-error-response content: application/json: schema: @@ -219,37 +218,10 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" - "/n2n/identity/{did}/openid4vci/nonce": - post: - tags: - - Issuer - summary: Request a fresh c_nonce value - description: > - Nonce Endpoint per OpenID4VCI v1.0 Section 7. - A Credential Issuer that requires c_nonce values MUST offer this endpoint. - The request has an empty body (Content-Length: 0) and requires no authentication. - operationId: requestNonce - parameters: - - name: did - in: path - required: true - schema: - type: string - example: did:nuts:123 - responses: - "200": - description: OK - headers: - Cache-Control: - schema: - type: string - example: no-store - content: - application/json: - schema: - "$ref": "#/components/schemas/NonceResponse" - "404": - description: Unknown issuer + "403": + description: > + Insufficient privileges. Code will be "insufficient_scope". + Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-error-response content: application/json: schema: @@ -300,7 +272,7 @@ components: required: - credential_issuer - credential_endpoint - - credential_configurations_supported + - credentials_supported properties: credential_issuer: type: string @@ -309,34 +281,24 @@ components: credential_endpoint: type: string example: "https://issuer.example/credential" - nonce_endpoint: - type: string - description: > - URL of the Nonce Endpoint where wallets can request a fresh c_nonce. - Per v1.0 Section 7, a Credential Issuer that requires c_nonce values MUST offer this endpoint. - example: "https://issuer.example/nonce" - credential_configurations_supported: - type: object + credentials_supported: + type: array description: | - A JSON object containing credential configurations supported by the Credential Issuer. - The keys are credential_configuration_ids that can be referenced in credential offers. - additionalProperties: + A JSON array containing a list of JSON objects, each of them representing metadata about a separate credential type that the Credential Issuer can issue. + items: type: object - example: - NutsAuthorizationCredential_ldp_vc: + example: { "format": "ldp_vc", - "credential_definition": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" - ], - "type": [ - "VerifiableCredential", - "NutsAuthorizationCredential" - ] - }, - "cryptographic_binding_methods_supported": ["did:nuts"] + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1" + ], + "type": [ + "VerifiableCredential", + "NutsAuthorizationCredential" + ], + "cryptographic_binding_methods_supported": "did:nuts" } OAuth2ClientMetadata: @@ -369,12 +331,6 @@ components: description: | URL of the authorization server's token endpoint [RFC6749]. example: https://issuer.example.com/token - pre-authorized_grant_anonymous_access_supported: - type: boolean - description: | - Indicates whether anonymous access (requests without client_id) is supported - for pre-authorized code grant flows. - example: true TokenResponse: type: object @@ -399,55 +355,73 @@ components: description: | The lifetime in seconds of the access token. example: 3600 + c_nonce: + type: string + description: | + JSON string containing a nonce to be used to create a proof of possession of key material when requesting a Credential. When received, the Wallet MUST use this nonce value for its subsequent credential requests until the Credential Issuer provides a fresh nonce. + example: "tZignsnFbp" example: { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..sHQ", "token_type": "bearer", - "expires_in": 3600 + "expires_in": 3600, + "c_nonce": "tZignsnFbp" } CredentialRequest: type: object required: - - credential_configuration_id - description: | - Per OpenID4VCI v1.0 Section 8.2, the request identifies the credential using credential_configuration_id. + - format properties: - credential_configuration_id: + format: type: string description: | - References a credential configuration from the issuer's credential_configurations_supported metadata. - example: "NutsAuthorizationCredential_ldp_vc" - proofs: + The format of the credential request. This MUST be one of the values specified in the "credentials_supported" array in the Credential Issuer Metadata. + example: "ldp_vc" + credential_definition: type: object - description: | - Object providing one or more proof of possessions of the cryptographic key material. - The key is the proof type (e.g., "jwt") and the value is an array of proofs. + description: JSON-LD object describing the requested credential. + proof: + type: object + required: + - proof_type + - jwt properties: + proof_type: + type: string + example: "jwt" jwt: - type: array - items: - type: string + type: string description: | - Array of JWS [RFC7515] strings as proof of possession. - - The fields of each JWT may look like this: - + String with a JWS [RFC7515] as proof of possession. + + The fields of the JWT may look like this: + { "typ": "openid4vci-proof+jwt", "alg": "ES256", "kid": "did:nuts:ebfeb1f712ebc6f1c276e12ec21#keys-1" }. { - "iss": "did:nuts:ebfeb1f712ebc6f1c276e12ec21", "aud": "https://credential-issuer.example.com", "iat": 1659145924, "nonce": "tZignsnFbp" } example: { - "credential_configuration_id": "NutsAuthorizationCredential_ldp_vc", - "proofs": { - "jwt": ["eyJraWQiOiJkaWQ6ZXhhbXBsZ...KPxgihac0aW9EkL1nOzM"] + "format": "ldp_vc", + "credential_definition": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1" + ], + "type": [ + "VerifiableCredential", + "NutsAuthorizationCredential" + ], + }, + "proof": { + "proof_type": "jwt", + "jwt": "eyJraWQiOiJkaWQ6ZXhhbXBsZ...KPxgihac0aW9EkL1nOzM" } } ErrorResponse: @@ -459,87 +433,88 @@ components: type: string description: Code identifying the error that occurred. example: "invalid_request" + c_nonce: + type: string + description: a string containing a new nonce value to be used for subsequent requests. + example: "tZignsnFbp" + c_nonce_expires_in: + type: integer + description: The lifetime in seconds of the nonce value. + example: 900 CredentialResponse: type: object - description: | - Per OpenID4VCI v1.0 Section 8.3, the response contains a credentials array where each entry - is a wrapper object with a credential key holding the issued credential. + required: + - format properties: - credentials: - type: array - items: - type: object - required: - - credential - properties: - credential: - type: object - description: Contains one issued Credential. + format: + type: string + example: "ldp_vc" + credential: + type: object + c_nonce: + type: string + example: "fGFF7UkhLa" example: { - "credentials": [ - { - "credential": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" - ], - "id": "did:nuts:#123", - "type": [ - "VerifiableCredential", - "NutsAuthorizationCredential" - ], - "issuer": "did:nuts:", - "issuanceDate": "2010-01-01T00:00:00Z", - "credentialSubject": { - "id": "did:nuts:", - "patient": "bsn:999992", - "purposeOfUse": "careviewer" - }, - "proof": { - "type": "Ed25519Signature2020", - "created": "2022-02-25T14:58:43Z", - "verificationMethod": "did:nuts:#key-1", - "proofPurpose": "assertionMethod", - "proofValue": "zeEdUoM7m9cY8ZyTpey83yBKeBcmcvbyrEQzJ19rD2UXArU2U1jPGoEtrRvGYppdiK37GU4NBeoPakxpWhAvsVSt" - } - } + "format": "ldp_vc", + "credential": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1" + ], + "id": "did:nuts:#123", + "type": [ + "VerifiableCredential", + "NutsAuthorizationCredential" + ], + "issuer": "did:nuts:", + "issuanceDate": "2010-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:nuts:", + "patient": "bsn:999992", + "purposeOfUse": "careviewer" + }, + "proof": { + "type": "Ed25519Signature2020", + "created": "2022-02-25T14:58:43Z", + "verificationMethod": "did:nuts:#key-1", + "proofPurpose": "assertionMethod", + "proofValue": "zeEdUoM7m9cY8ZyTpey83yBKeBcmcvbyrEQzJ19rD2UXArU2U1jPGoEtrRvGYppdiK37GU4NBeoPakxpWhAvsVSt" } - ] + }, + "c_nonce": "fGFF7UkhLa" } CredentialOffer: type: object required: - credential_issuer - - credential_configuration_ids + - credentials + - grants # TODO: This should be optional according to https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer-parameters properties: credential_issuer: type: string example: "https://issuer.example" - credential_configuration_ids: + credentials: type: array - description: | - Array of credential configuration IDs that reference entries in the issuer's credential_configurations_supported metadata. - items: - type: string grants: type: object - description: | - Grant types the issuer offers for this credential. Currently only pre-authorized code is supported. - properties: - "urn:ietf:params:oauth:grant-type:pre-authorized_code": - type: object - required: - - pre-authorized_code - properties: - pre-authorized_code: - type: string - description: The pre-authorized code for the credential offer. example: { "credential_issuer": "https://issuer.example", - "credential_configuration_ids": [ - "NutsAuthorizationCredential_ldp_vc" + "credentials": [ + { + "format": "ldp_vc", + "credential_definition": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1" + ], + "type": [ + "VerifiableCredential", + "NutsAuthorizationCredential" + ] + } + } ], "grants": { "urn:ietf:params:oauth:grant-type:pre-authorized_code": { @@ -547,18 +522,6 @@ components: } } } - NonceResponse: - type: object - description: | - Response from the Nonce Endpoint per OpenID4VCI v1.0 Section 7. - required: - - c_nonce - properties: - c_nonce: - type: string - description: | - A fresh nonce value to be used in the proof of possession. - example: "wKI4LT17ac15ES9bw8ac4" CredentialOfferResponse: type: object description: | diff --git a/vcr/api/openid4vci/v0/api.go b/vcr/api/openid4vci/v0/api.go index 94da1cdbd6..0297535816 100644 --- a/vcr/api/openid4vci/v0/api.go +++ b/vcr/api/openid4vci/v0/api.go @@ -54,9 +54,6 @@ type CredentialResponse = openid4vci.CredentialResponse // OAuth2ClientMetadata is the metadata of the OAuth2 client type OAuth2ClientMetadata = openid4vci.OAuth2ClientMetadata -// NonceResponse is the response of the Nonce Endpoint -type NonceResponse = openid4vci.NonceResponse - type ErrorResponse = openid4vci.Error var _ core.ErrorWriter = (*protocolErrorWriter)(nil) diff --git a/vcr/api/openid4vci/v0/generated.go b/vcr/api/openid4vci/v0/generated.go index f70faf2669..19426c73b4 100644 --- a/vcr/api/openid4vci/v0/generated.go +++ b/vcr/api/openid4vci/v0/generated.go @@ -57,9 +57,6 @@ type ServerInterface interface { // Used by the issuer to offer credentials to the wallet // (GET /n2n/identity/{did}/openid4vci/credential_offer) HandleCredentialOffer(ctx echo.Context, did string, params HandleCredentialOfferParams) error - // Request a fresh c_nonce value - // (POST /n2n/identity/{did}/openid4vci/nonce) - RequestNonce(ctx echo.Context, did string) error // Used by the wallet to request an access token // (POST /n2n/identity/{did}/token) RequestAccessToken(ctx echo.Context, did string) error @@ -195,22 +192,6 @@ func (w *ServerInterfaceWrapper) HandleCredentialOffer(ctx echo.Context) error { return err } -// RequestNonce converts echo context to params. -func (w *ServerInterfaceWrapper) RequestNonce(ctx echo.Context) error { - var err error - // ------------- Path parameter "did" ------------- - var did string - - err = runtime.BindStyledParameterWithOptions("simple", "did", ctx.Param("did"), &did, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) - } - - // Invoke the callback with all the unmarshaled arguments - err = w.Handler.RequestNonce(ctx, did) - return err -} - // RequestAccessToken converts echo context to params. func (w *ServerInterfaceWrapper) RequestAccessToken(ctx echo.Context) error { var err error @@ -261,7 +242,6 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/n2n/identity/:did/.well-known/openid-credential-wallet", wrapper.GetOAuth2ClientMetadata) router.POST(baseURL+"/n2n/identity/:did/openid4vci/credential", wrapper.RequestCredential) router.GET(baseURL+"/n2n/identity/:did/openid4vci/credential_offer", wrapper.HandleCredentialOffer) - router.POST(baseURL+"/n2n/identity/:did/openid4vci/nonce", wrapper.RequestNonce) router.POST(baseURL+"/n2n/identity/:did/token", wrapper.RequestAccessToken) } @@ -405,6 +385,15 @@ func (response RequestCredential401JSONResponse) VisitRequestCredentialResponse( return json.NewEncoder(w).Encode(response) } +type RequestCredential403JSONResponse ErrorResponse + +func (response RequestCredential403JSONResponse) VisitRequestCredentialResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type RequestCredential404JSONResponse ErrorResponse func (response RequestCredential404JSONResponse) VisitRequestCredentialResponse(w http.ResponseWriter) error { @@ -450,40 +439,6 @@ func (response HandleCredentialOffer404JSONResponse) VisitHandleCredentialOfferR return json.NewEncoder(w).Encode(response) } -type RequestNonceRequestObject struct { - Did string `json:"did"` -} - -type RequestNonceResponseObject interface { - VisitRequestNonceResponse(w http.ResponseWriter) error -} - -type RequestNonce200ResponseHeaders struct { - CacheControl string -} - -type RequestNonce200JSONResponse struct { - Body NonceResponse - Headers RequestNonce200ResponseHeaders -} - -func (response RequestNonce200JSONResponse) VisitRequestNonceResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", fmt.Sprint(response.Headers.CacheControl)) - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response.Body) -} - -type RequestNonce404JSONResponse ErrorResponse - -func (response RequestNonce404JSONResponse) VisitRequestNonceResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(404) - - return json.NewEncoder(w).Encode(response) -} - type RequestAccessTokenRequestObject struct { Did string `json:"did"` Body *RequestAccessTokenFormdataRequestBody @@ -540,9 +495,6 @@ type StrictServerInterface interface { // Used by the issuer to offer credentials to the wallet // (GET /n2n/identity/{did}/openid4vci/credential_offer) HandleCredentialOffer(ctx context.Context, request HandleCredentialOfferRequestObject) (HandleCredentialOfferResponseObject, error) - // Request a fresh c_nonce value - // (POST /n2n/identity/{did}/openid4vci/nonce) - RequestNonce(ctx context.Context, request RequestNonceRequestObject) (RequestNonceResponseObject, error) // Used by the wallet to request an access token // (POST /n2n/identity/{did}/token) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) @@ -718,31 +670,6 @@ func (sh *strictHandler) HandleCredentialOffer(ctx echo.Context, did string, par return nil } -// RequestNonce operation middleware -func (sh *strictHandler) RequestNonce(ctx echo.Context, did string) error { - var request RequestNonceRequestObject - - request.Did = did - - handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.RequestNonce(ctx.Request().Context(), request.(RequestNonceRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "RequestNonce") - } - - response, err := handler(ctx, request) - - if err != nil { - return err - } else if validResponse, ok := response.(RequestNonceResponseObject); ok { - return validResponse.VisitRequestNonceResponse(ctx.Response()) - } else if response != nil { - return fmt.Errorf("unexpected response type: %T", response) - } - return nil -} - // RequestAccessToken operation middleware func (sh *strictHandler) RequestAccessToken(ctx echo.Context, did string) error { var request RequestAccessTokenRequestObject diff --git a/vcr/api/openid4vci/v0/holder_test.go b/vcr/api/openid4vci/v0/holder_test.go index 557aa60f2a..1601839982 100644 --- a/vcr/api/openid4vci/v0/holder_test.go +++ b/vcr/api/openid4vci/v0/holder_test.go @@ -21,7 +21,9 @@ package v0 import ( "context" "encoding/json" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -86,11 +88,19 @@ func TestWrapper_HandleCredentialOffer(t *testing.T) { api := Wrapper{VCR: service, VDR: vdr} credentialOffer := openid4vci.CredentialOffer{ - CredentialIssuer: issuerDID.String(), - CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, - Grants: &openid4vci.CredentialOfferGrants{ - PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ - PreAuthorizedCode: "code", + CredentialIssuer: issuerDID.String(), + Credentials: []openid4vci.OfferedCredential{ + { + Format: vc.JSONLDCredentialProofFormat, + CredentialDefinition: &openid4vci.CredentialDefinition{ + Context: []ssi.URI{ssi.MustParseURI("a"), ssi.MustParseURI("b")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("HumanCredential")}, + }, + }, + }, + Grants: map[string]interface{}{ + "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ + "pre-authorized_code": "code", }, }, } diff --git a/vcr/api/openid4vci/v0/issuer.go b/vcr/api/openid4vci/v0/issuer.go index 0e501a92e9..19d5325a0c 100644 --- a/vcr/api/openid4vci/v0/issuer.go +++ b/vcr/api/openid4vci/v0/issuer.go @@ -20,8 +20,11 @@ package v0 import ( "context" + "encoding/json" "errors" "fmt" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "net/http" @@ -99,25 +102,15 @@ func (w Wrapper) RequestCredential(ctx context.Context, request RequestCredentia return nil, err } credentialJSON, _ := credential.MarshalJSON() - return RequestCredential200JSONResponse(CredentialResponse{ - Credentials: []openid4vci.CredentialResponseEntry{{Credential: credentialJSON}}, - }), nil -} - -// RequestNonce handles a request to the Nonce Endpoint. -func (w Wrapper) RequestNonce(ctx context.Context, request RequestNonceRequestObject) (RequestNonceResponseObject, error) { - issuerHandler, err := w.getIssuerHandler(ctx, request.Did) + credentialMap := make(map[string]interface{}) + err = json.Unmarshal(credentialJSON, &credentialMap) if err != nil { return nil, err } - nonce, err := issuerHandler.HandleNonceRequest(ctx) - if err != nil { - return nil, err - } - return RequestNonce200JSONResponse{ - Body: NonceResponse{CNonce: nonce}, - Headers: RequestNonce200ResponseHeaders{CacheControl: "no-store"}, - }, nil + return RequestCredential200JSONResponse(CredentialResponse{ + Credential: &credentialMap, + Format: vc.JSONLDCredentialProofFormat, + }), nil } // RequestAccessToken requests an OAuth2 access token from the given DID. @@ -134,14 +127,14 @@ func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo StatusCode: http.StatusBadRequest, } } - accessToken, err := issuerHandler.HandleAccessTokenRequest(ctx, request.Body.PreAuthorizedCode) + accessToken, cNonce, err := issuerHandler.HandleAccessTokenRequest(ctx, request.Body.PreAuthorizedCode) if err != nil { return nil, err } expiresIn := int(issuer.TokenTTL.Seconds()) - return RequestAccessToken200JSONResponse(TokenResponse{ + return RequestAccessToken200JSONResponse(*(&TokenResponse{ AccessToken: accessToken, ExpiresIn: &expiresIn, TokenType: "bearer", - }), nil + }).With(oauth.CNonceParam, cNonce)), nil } diff --git a/vcr/api/openid4vci/v0/issuer_test.go b/vcr/api/openid4vci/v0/issuer_test.go index 3b82fda1ce..e1ee52f15d 100644 --- a/vcr/api/openid4vci/v0/issuer_test.go +++ b/vcr/api/openid4vci/v0/issuer_test.go @@ -22,6 +22,7 @@ import ( "context" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" + oauth2 "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -108,7 +109,7 @@ func TestWrapper_RequestAccessToken(t *testing.T) { t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) oidcIssuer := issuer.NewMockOpenIDHandler(ctrl) - oidcIssuer.EXPECT().HandleAccessTokenRequest(gomock.Any(), "code").Return("access-token", nil) + oidcIssuer.EXPECT().HandleAccessTokenRequest(gomock.Any(), "code").Return("access-token", "c_nonce", nil) documentOwner := didsubject.NewMockDocumentOwner(ctrl) documentOwner.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(true, nil) vdr := vdr.NewMockVDR(ctrl) @@ -126,6 +127,7 @@ func TestWrapper_RequestAccessToken(t *testing.T) { require.NoError(t, err) assert.Equal(t, "access-token", response.(RequestAccessToken200JSONResponse).AccessToken) + assert.Equal(t, "c_nonce", oauth2.TokenResponse(response.(RequestAccessToken200JSONResponse)).Get("c_nonce")) }) t.Run("unknown tenant", func(t *testing.T) { ctrl := gomock.NewController(t) @@ -167,40 +169,6 @@ func TestWrapper_RequestAccessToken(t *testing.T) { }) } -func TestWrapper_RequestNonce(t *testing.T) { - t.Run("ok", func(t *testing.T) { - ctrl := gomock.NewController(t) - oidcIssuer := issuer.NewMockOpenIDHandler(ctrl) - oidcIssuer.EXPECT().HandleNonceRequest(gomock.Any()).Return("test-nonce-value", nil) - documentOwner := didsubject.NewMockDocumentOwner(ctrl) - documentOwner.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(true, nil) - vdr := vdr.NewMockVDR(ctrl) - vdr.EXPECT().DocumentOwner().Return(documentOwner).AnyTimes() - service := vcr.NewMockVCR(ctrl) - service.EXPECT().GetOpenIDIssuer(gomock.Any(), issuerDID).Return(oidcIssuer, nil) - api := Wrapper{VCR: service, VDR: vdr} - - response, err := api.RequestNonce(context.Background(), RequestNonceRequestObject{Did: issuerDID.String()}) - - require.NoError(t, err) - jsonResponse := response.(RequestNonce200JSONResponse) - assert.Equal(t, "test-nonce-value", jsonResponse.Body.CNonce) - assert.Equal(t, "no-store", jsonResponse.Headers.CacheControl) - }) - t.Run("unknown tenant", func(t *testing.T) { - ctrl := gomock.NewController(t) - documentOwner := didsubject.NewMockDocumentOwner(ctrl) - documentOwner.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(false, nil) - vdr := vdr.NewMockVDR(ctrl) - vdr.EXPECT().DocumentOwner().Return(documentOwner).AnyTimes() - api := Wrapper{VDR: vdr} - - _, err := api.RequestNonce(context.Background(), RequestNonceRequestObject{Did: issuerDID.String()}) - - require.EqualError(t, err, "invalid_request - DID is not owned by this node") - }) -} - func TestWrapper_RequestCredential(t *testing.T) { t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) @@ -221,12 +189,14 @@ func TestWrapper_RequestCredential(t *testing.T) { Authorization: &authz, }, Body: &RequestCredentialJSONRequestBody{ - CredentialConfigurationID: "NutsOrganizationCredential_ldp_vc", + Format: "ldp_vc", + CredentialDefinition: &openid4vci.CredentialDefinition{}, + Proof: nil, }, }) require.NoError(t, err) - assert.NotEmpty(t, response.(RequestCredential200JSONResponse).Credentials) + assert.NotNil(t, response.(RequestCredential200JSONResponse).Credential) }) t.Run("unknown tenant", func(t *testing.T) { ctrl := gomock.NewController(t) diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index 3ae8b5a327..fd975152d5 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -22,14 +22,13 @@ import ( "context" "errors" "fmt" + "github.com/nuts-foundation/nuts-node/auth/oauth" "net/http" "time" - ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" - "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/log" @@ -83,12 +82,27 @@ func (h *openidHandler) Metadata() openid4vci.OAuth2ClientMetadata { // Error responses on the Credential Offer Endpoint are not defined in the OpenID4VCI spec, // so these are inferred of whatever makes sense. func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4vci.CredentialOffer) error { - // TODO: This check is too simplistic, there can be multiple credential_configuration_ids, - // but we only support one at a time. + // TODO: This check is too simplistic, there can be multiple credential offers, + // but the issuer should only request the one it's interested in. // See https://github.com/nuts-foundation/nuts-node/issues/2049 - if len(offer.CredentialConfigurationIDs) != 1 { + if len(offer.Credentials) != 1 { return openid4vci.Error{ - Err: errors.New("there must be exactly 1 credential_configuration_id in credential offer"), + Err: errors.New("there must be exactly 1 credential in credential offer"), + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + } + } + offeredCredential := offer.Credentials[0] + if offeredCredential.Format != vc.JSONLDCredentialProofFormat { + return openid4vci.Error{ + Err: fmt.Errorf("credential offer: unsupported format '%s'", offeredCredential.Format), + Code: openid4vci.UnsupportedCredentialType, + StatusCode: http.StatusBadRequest, + } + } + if err := offeredCredential.CredentialDefinition.Validate(true); err != nil { + return openid4vci.Error{ + Err: fmt.Errorf("credential offer: %w", err), Code: openid4vci.InvalidRequest, StatusCode: http.StatusBadRequest, } @@ -112,38 +126,13 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } } - // Resolve the credential configuration from the issuer metadata - credentialConfigID := offer.CredentialConfigurationIDs[0] - offeredCredential, err := h.resolveCredentialConfiguration(issuerClient.Metadata(), credentialConfigID) - if err != nil { - return openid4vci.Error{ - Err: fmt.Errorf("unable to resolve credential configuration: %w", err), - Code: openid4vci.InvalidRequest, - StatusCode: http.StatusBadRequest, - } - } - if offeredCredential.Format != vc.JSONLDCredentialProofFormat { - return openid4vci.Error{ - Err: fmt.Errorf("credential offer: unsupported format '%s'", offeredCredential.Format), - Code: openid4vci.InvalidRequest, - StatusCode: http.StatusBadRequest, - } - } - if err := offeredCredential.CredentialDefinition.Validate(false); err != nil { - return openid4vci.Error{ - Err: fmt.Errorf("credential offer: %w", err), - Code: openid4vci.InvalidRequest, - StatusCode: http.StatusBadRequest, - } - } - accessTokenResponse, err := issuerClient.RequestAccessToken(openid4vci.PreAuthorizedCodeGrant, map[string]string{ "pre-authorized_code": preAuthorizedCode, }) if err != nil { return openid4vci.Error{ Err: fmt.Errorf("unable to request access token: %w", err), - Code: openid4vci.ServerError, + Code: openid4vci.InvalidToken, StatusCode: http.StatusInternalServerError, } } @@ -151,13 +140,21 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 if accessTokenResponse.AccessToken == "" { return openid4vci.Error{ Err: errors.New("access_token is missing"), - Code: openid4vci.ServerError, + Code: openid4vci.InvalidToken, + StatusCode: http.StatusInternalServerError, + } + } + + if accessTokenResponse.Get(oauth.CNonceParam) == "" { + return openid4vci.Error{ + Err: fmt.Errorf("%s is missing", oauth.CNonceParam), + Code: openid4vci.InvalidToken, StatusCode: http.StatusInternalServerError, } } retrieveCtx := audit.Context(ctx, "app-openid4vci", "VCR/OpenID4VCI", "RetrieveCredential") - credential, err := h.retrieveCredential(retrieveCtx, issuerClient, credentialConfigID, accessTokenResponse) + credential, err := h.retrieveCredential(retrieveCtx, issuerClient, offeredCredential.CredentialDefinition, accessTokenResponse) if err != nil { return openid4vci.Error{ Err: fmt.Errorf("unable to retrieve credential: %w", err), @@ -183,121 +180,44 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } func getPreAuthorizedCodeFromOffer(offer openid4vci.CredentialOffer) string { - if offer.Grants == nil || offer.Grants.PreAuthorizedCode == nil { + params, ok := offer.Grants[openid4vci.PreAuthorizedCodeGrant].(map[string]interface{}) + if !ok { return "" } - return offer.Grants.PreAuthorizedCode.PreAuthorizedCode -} - -// resolveCredentialConfiguration resolves a credential_configuration_id to an OfferedCredential -// by looking it up in the issuer metadata. -func (h *openidHandler) resolveCredentialConfiguration(metadata openid4vci.CredentialIssuerMetadata, configID string) (*openid4vci.OfferedCredential, error) { - config, ok := metadata.CredentialConfigurationsSupported[configID] + preAuthorizedCode, ok := params["pre-authorized_code"].(string) if !ok { - return nil, fmt.Errorf("credential_configuration_id '%s' not found in issuer metadata", configID) - } - - format, ok := config["format"].(string) - if !ok || format == "" { - return nil, fmt.Errorf("credential configuration '%s' is missing 'format' field", configID) - } - credDefMap, _ := config["credential_definition"].(map[string]interface{}) - - var credentialDef *openid4vci.CredentialDefinition - if credDefMap != nil { - credentialDef = &openid4vci.CredentialDefinition{} - - // Parse @context - if contextRaw, ok := credDefMap["@context"].([]interface{}); ok { - for _, c := range contextRaw { - cStr, ok := c.(string) - if !ok { - return nil, fmt.Errorf("invalid @context entry: expected string, got %T", c) - } - u, err := ssi.ParseURI(cStr) - if err != nil { - return nil, fmt.Errorf("invalid @context URI %q: %w", cStr, err) - } - credentialDef.Context = append(credentialDef.Context, *u) - } - } - - // Parse type - if typeRaw, ok := credDefMap["type"].([]interface{}); ok { - for _, t := range typeRaw { - tStr, ok := t.(string) - if !ok { - return nil, fmt.Errorf("invalid type entry: expected string, got %T", t) - } - u, err := ssi.ParseURI(tStr) - if err != nil { - return nil, fmt.Errorf("invalid type URI %q: %w", tStr, err) - } - credentialDef.Type = append(credentialDef.Type, *u) - } - } - - // Parse credentialSubject (optional in v1.0 metadata) - if credSubject, ok := credDefMap["credentialSubject"].(map[string]interface{}); ok { - credentialDef.CredentialSubject = credSubject - } + return "" } - - return &openid4vci.OfferedCredential{ - Format: format, - CredentialDefinition: credentialDef, - }, nil + return preAuthorizedCode } -func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient openid4vci.IssuerAPIClient, credentialConfigID string, tokenResponse *oauth.TokenResponse) (*vc.VerifiableCredential, error) { +func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient openid4vci.IssuerAPIClient, offer *openid4vci.CredentialDefinition, tokenResponse *oauth.TokenResponse) (*vc.VerifiableCredential, error) { keyID, _, err := h.resolver.ResolveKey(h.did, nil, resolver.NutsSigningKeyType) if err != nil { return nil, err } + headers := map[string]interface{}{ + "typ": openid4vci.JWTTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. + "kid": keyID, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. + } + claims := map[string]interface{}{ + "aud": issuerClient.Metadata().CredentialIssuer, + "iat": nowFunc().Unix(), + "nonce": tokenResponse.Get(oauth.CNonceParam), + } - const maxAttempts = 2 - for attempt := range maxAttempts { - headers := map[string]interface{}{ - "typ": openid4vci.JWTTypeOpenID4VCIProof, - "kid": keyID, - } - claims := map[string]interface{}{ - "iss": h.did.String(), - "aud": issuerClient.Metadata().CredentialIssuer, - "iat": nowFunc().Unix(), - } - - // Per v1.0 Section 7, fetch nonce from Nonce Endpoint when advertised - if issuerClient.Metadata().NonceEndpoint != "" { - nonceResponse, nonceErr := issuerClient.RequestNonce(ctx) - if nonceErr != nil { - return nil, fmt.Errorf("unable to request nonce: %w", nonceErr) - } - claims["nonce"] = nonceResponse.CNonce - } - - proof, signErr := h.signer.SignJWT(ctx, claims, headers, keyID) - if signErr != nil { - return nil, fmt.Errorf("unable to sign request proof: %w", signErr) - } + proof, err := h.signer.SignJWT(ctx, claims, headers, keyID) + if err != nil { + return nil, fmt.Errorf("unable to sign request proof: %w", err) + } - credentialRequest := openid4vci.CredentialRequest{ - CredentialConfigurationID: credentialConfigID, - Proofs: &openid4vci.CredentialRequestProofs{ - Jwt: []string{proof}, - }, - } - credential, reqErr := issuerClient.RequestCredential(ctx, credentialRequest, tokenResponse.AccessToken) - if reqErr != nil { - // On invalid_nonce, fetch a fresh nonce and retry once (v1.0 Section 8.3.1.2) - var protocolErr openid4vci.Error - if attempt == 0 && errors.As(reqErr, &protocolErr) && protocolErr.Code == openid4vci.InvalidNonce { - log.Logger().Debug("Received invalid_nonce, retrying with fresh nonce") - continue - } - return nil, reqErr - } - return credential, nil + credentialRequest := openid4vci.CredentialRequest{ + CredentialDefinition: offer, + Format: vc.JSONLDCredentialProofFormat, + Proof: &openid4vci.CredentialRequestProof{ + Jwt: proof, + ProofType: "jwt", + }, } - return nil, errors.New("credential request failed after nonce retry") + return issuerClient.RequestCredential(ctx, credentialRequest, tokenResponse.AccessToken) } diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index c3e749bd99..a8a76ebe7c 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -21,10 +21,6 @@ package holder import ( "context" "errors" - "net/http" - "testing" - "time" - ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -38,6 +34,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "net/http" + "testing" + "time" ) var holderDID = did.MustParseDID("did:nuts:holder") @@ -60,60 +59,40 @@ func Test_wallet_Metadata(t *testing.T) { func Test_wallet_HandleCredentialOffer(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ - CredentialIssuer: issuerDID.String(), - CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, - Grants: &openid4vci.CredentialOfferGrants{ - PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ - PreAuthorizedCode: "code", + CredentialIssuer: issuerDID.String(), + Credentials: offeredCredential(), + Grants: map[string]interface{}{ + "some-other-grant": map[string]interface{}{}, + "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ + "pre-authorized_code": "code", }, }, } metadata := openid4vci.CredentialIssuerMetadata{ CredentialIssuer: issuerDID.String(), CredentialEndpoint: "credential-endpoint", - CredentialConfigurationsSupported: map[string]map[string]interface{}{ - "ExampleCredential_ldp_vc": { - "format": "ldp_vc", - "credential_definition": map[string]interface{}{ - "@context": []interface{}{ - "https://www.w3.org/2018/credentials/v1", - "https://example.com/credentials/v1", - }, - "type": []interface{}{ - "VerifiableCredential", - "ExampleCredential", - }, - }, - }, - }, } + nonce := "nonsens" t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() - tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} + issuerAPIClient.EXPECT().Metadata().Return(metadata) + tokenResponse := (&oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"}).With("c_nonce", nonce) issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ "pre-authorized_code": "code", }).Return(tokenResponse, nil) - // Verify that the holder sends credential_configuration_id in the credential request - expectedRequest := openid4vci.CredentialRequest{ - CredentialConfigurationID: "ExampleCredential_ldp_vc", - Proofs: &openid4vci.CredentialRequestProofs{ - Jwt: []string{"signed-jwt"}, - }, - } - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), expectedRequest, "access-token"). + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). Return(&vc.VerifiableCredential{ - Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://example.com/credentials/v1")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("ExampleCredential")}, + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("http://example.org/credentials/V1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("HumanCredential")}, Issuer: issuerDID.URI()}, nil) credentialStore := types.NewMockWriter(ctrl) jwtSigner := crypto.NewMockJWTSigner(ctrl) jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ - "iss": holderDID.String(), - "aud": issuerDID.String(), - "iat": int64(1735689600), + "aud": issuerDID.String(), + "iat": int64(1735689600), + "nonce": nonce, }, gomock.Any(), "key-id").Return("signed-jwt", nil) keyResolver := resolver.NewMockKeyResolver(ctrl) keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) @@ -121,7 +100,6 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { nowFunc = func() time.Time { return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) } - t.Cleanup(func() { nowFunc = time.Now }) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { @@ -137,24 +115,26 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { t.Run("pre-authorized code grant", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) t.Run("no grants", func(t *testing.T) { - offer := openid4vci.CredentialOffer{CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}} + offer := openid4vci.CredentialOffer{Credentials: offeredCredential()} err := w.HandleCredentialOffer(audit.TestContext(), offer) require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) t.Run("no pre-authorized grant", func(t *testing.T) { offer := openid4vci.CredentialOffer{ - CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, - Grants: nil, + Credentials: offeredCredential(), + Grants: map[string]interface{}{ + "some-other-grant": nil, + }, } err := w.HandleCredentialOffer(audit.TestContext(), offer) require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) - t.Run("empty pre-authorized code", func(t *testing.T) { + t.Run("invalid pre-authorized grant", func(t *testing.T) { offer := openid4vci.CredentialOffer{ - CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, - Grants: &openid4vci.CredentialOfferGrants{ - PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ - PreAuthorizedCode: "", + Credentials: offeredCredential(), + Grants: map[string]interface{}{ + "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ + "pre-authorized_code": nil, }, }, } @@ -162,67 +142,38 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) }) - t.Run("error - too many credential_configuration_ids in offer", func(t *testing.T) { + t.Run("error - too many credentials in offer", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) offer := openid4vci.CredentialOffer{ - CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc", "OtherCredential_ldp_vc"}, + Credentials: []openid4vci.OfferedCredential{ + offeredCredential()[0], + offeredCredential()[0], + }, } err := w.HandleCredentialOffer(audit.TestContext(), offer).(openid4vci.Error) - assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential_configuration_id in credential offer") + assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential in credential offer") assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) - t.Run("error - credential_configuration_id not found in metadata", func(t *testing.T) { - ctrl := gomock.NewController(t) - issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - emptyMetadata := openid4vci.CredentialIssuerMetadata{ - CredentialIssuer: issuerDID.String(), - CredentialEndpoint: "credential-endpoint", - CredentialConfigurationsSupported: map[string]map[string]interface{}{}, - } - issuerAPIClient.EXPECT().Metadata().Return(emptyMetadata).AnyTimes() - - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) - w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { - return issuerAPIClient, nil - } - - err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - - require.ErrorContains(t, err, "credential_configuration_id 'ExampleCredential_ldp_vc' not found in issuer metadata") - }) - t.Run("error - credential configuration missing format", func(t *testing.T) { + t.Run("error - access token request fails", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - metadataNoFormat := openid4vci.CredentialIssuerMetadata{ - CredentialIssuer: issuerDID.String(), - CredentialEndpoint: "credential-endpoint", - CredentialConfigurationsSupported: map[string]map[string]interface{}{ - "ExampleCredential_ldp_vc": { - "credential_definition": map[string]interface{}{ - "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, - "type": []interface{}{"VerifiableCredential"}, - }, - }, - }, - } - issuerAPIClient.EXPECT().Metadata().Return(metadataNoFormat).AnyTimes() + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(nil, errors.New("request failed")) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) - w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { return issuerAPIClient, nil } err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - require.ErrorContains(t, err, "credential configuration 'ExampleCredential_ldp_vc' is missing 'format' field") + require.EqualError(t, err, "invalid_token - unable to request access token: request failed") }) - t.Run("error - access token request fails", func(t *testing.T) { + t.Run("error - empty access token", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(nil, errors.New("request failed")) + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{}, nil) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { @@ -231,13 +182,12 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - require.EqualError(t, err, "server_error - unable to request access token: request failed") + require.EqualError(t, err, "invalid_token - access_token is missing") }) - t.Run("error - empty access token", func(t *testing.T) { + t.Run("error - empty c_nonce", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{}, nil) + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "foo"}, nil) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { @@ -246,25 +196,25 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - require.EqualError(t, err, "server_error - access_token is missing") + require.EqualError(t, err, "invalid_token - c_nonce is missing") }) - t.Run("error - no credential_configuration_ids in offer", func(t *testing.T) { + t.Run("error - no credentials in offer", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{}).(openid4vci.Error) - assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential_configuration_id in credential offer") + assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential in credential offer") assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) t.Run("error - can't issuer client (metadata can't be loaded)", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ - CredentialIssuer: "http://localhost:87632", - CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, - Grants: &openid4vci.CredentialOfferGrants{ - PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ - PreAuthorizedCode: "foo", + CredentialIssuer: "http://localhost:87632", + Credentials: offeredCredential(), + Grants: map[string]interface{}{ + "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ + "pre-authorized_code": "foo", }, }, }) @@ -276,8 +226,8 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { offer := offeredCredential()[0] ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "access-token"}, nil) + issuerAPIClient.EXPECT().Metadata().Return(metadata) + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return((&oauth.TokenResponse{AccessToken: "access-token"}).With("c_nonce", nonce), nil) issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), gomock.Any()).Return(&vc.VerifiableCredential{ Context: offer.CredentialDefinition.Context, Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, @@ -297,289 +247,39 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { require.EqualError(t, err, "invalid_request - received credential does not match offer: credential does not match credential_definition: type mismatch") }) t.Run("error - unsupported format", func(t *testing.T) { - ctrl := gomock.NewController(t) - issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(openid4vci.CredentialIssuerMetadata{ - CredentialIssuer: issuerDID.String(), - CredentialConfigurationsSupported: map[string]map[string]interface{}{ - "TestCredential_unsupported": { - "format": "not supported", - }, - }, - }) - - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) - w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { - return issuerAPIClient, nil - } + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ - CredentialConfigurationIDs: []string{"TestCredential_unsupported"}, - Grants: &openid4vci.CredentialOfferGrants{ - PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ - PreAuthorizedCode: "foo", - }, - }, + Credentials: []openid4vci.OfferedCredential{{Format: "not supported"}}, }).(openid4vci.Error) - assert.EqualError(t, err, "invalid_request - credential offer: unsupported format 'not supported'") + assert.EqualError(t, err, "unsupported_credential_type - credential offer: unsupported format 'not supported'") assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) - t.Run("credentialSubject in metadata does not block offer processing", func(t *testing.T) { - // v1.0 Appendix A.1.2: credentialSubject is allowed in metadata credential_configurations_supported - ctrl := gomock.NewController(t) - issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - metadataWithSubject := openid4vci.CredentialIssuerMetadata{ - CredentialIssuer: issuerDID.String(), - CredentialConfigurationsSupported: map[string]map[string]interface{}{ - "TestCredential_ldp_vc": { - "format": "ldp_vc", - "credential_definition": map[string]interface{}{ - "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, - "type": []interface{}{"VerifiableCredential"}, - "credentialSubject": map[string]interface{}{}, - }, - }, - }, - } - issuerAPIClient.EXPECT().Metadata().Return(metadataWithSubject).AnyTimes() - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "access-token"}, nil) - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), gomock.Any()).Return(&vc.VerifiableCredential{ - Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, - Issuer: issuerDID.URI(), - }, nil) - jwtSigner := crypto.NewMockJWTSigner(ctrl) - jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-jwt", nil) - keyResolver := resolver.NewMockKeyResolver(ctrl) - keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) - credentialStore := types.NewMockWriter(ctrl) - credentialStore.EXPECT().StoreCredential(gomock.Any(), nil).Return(nil) - - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) - w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { - return issuerAPIClient, nil - } - - err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ - CredentialConfigurationIDs: []string{"TestCredential_ldp_vc"}, - Grants: &openid4vci.CredentialOfferGrants{ - PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ - PreAuthorizedCode: "foo", - }, - }, - }) - - assert.NoError(t, err) - }) -} - -func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { - credentialOffer := openid4vci.CredentialOffer{ - CredentialIssuer: issuerDID.String(), - CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, - Grants: &openid4vci.CredentialOfferGrants{ - PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ - PreAuthorizedCode: "code", - }, - }, - } - nonce := "nonce-from-endpoint" - metadataWithNonce := openid4vci.CredentialIssuerMetadata{ - CredentialIssuer: issuerDID.String(), - CredentialEndpoint: "credential-endpoint", - NonceEndpoint: "https://issuer.example/nonce", - CredentialConfigurationsSupported: map[string]map[string]interface{}{ - "ExampleCredential_ldp_vc": { - "format": "ldp_vc", - "credential_definition": map[string]interface{}{ - "@context": []interface{}{ - "https://www.w3.org/2018/credentials/v1", - "https://example.com/credentials/v1", - }, - "type": []interface{}{ - "VerifiableCredential", - "ExampleCredential", - }, - }, - }, - }, - } - - t.Run("uses Nonce Endpoint when advertised", func(t *testing.T) { - ctrl := gomock.NewController(t) - issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() - issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: nonce}, nil) - tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} - issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ - "pre-authorized_code": "code", - }).Return(tokenResponse, nil) - expectedRequest := openid4vci.CredentialRequest{ - CredentialConfigurationID: "ExampleCredential_ldp_vc", - Proofs: &openid4vci.CredentialRequestProofs{ - Jwt: []string{"signed-jwt"}, - }, - } - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), expectedRequest, "access-token"). - Return(&vc.VerifiableCredential{ - Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://example.com/credentials/v1")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("ExampleCredential")}, - Issuer: issuerDID.URI()}, nil) - - credentialStore := types.NewMockWriter(ctrl) - jwtSigner := crypto.NewMockJWTSigner(ctrl) - nowFunc = func() time.Time { - return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - } - t.Cleanup(func() { nowFunc = time.Now }) - jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ - "iss": holderDID.String(), - "aud": issuerDID.String(), - "iat": int64(1767225600), - "nonce": nonce, - }, gomock.Any(), "key-id").Return("signed-jwt", nil) - keyResolver := resolver.NewMockKeyResolver(ctrl) - keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) - - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) - w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { - return issuerAPIClient, nil - } - - credentialStore.EXPECT().StoreCredential(gomock.Any(), nil).Return(nil) - - err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - - require.NoError(t, err) - }) - t.Run("retries on invalid_nonce", func(t *testing.T) { - ctrl := gomock.NewController(t) - issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() - // First nonce request → used in first attempt (which fails with invalid_nonce) - // Second nonce request → used in retry (which succeeds) - first := issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: "stale-nonce"}, nil) - issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: nonce}, nil).After(first) - tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} - issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ - "pre-authorized_code": "code", - }).Return(tokenResponse, nil) - // First credential request (with stale nonce) fails with invalid_nonce - firstCredReq := openid4vci.CredentialRequest{ - CredentialConfigurationID: "ExampleCredential_ldp_vc", - Proofs: &openid4vci.CredentialRequestProofs{Jwt: []string{"signed-jwt-1"}}, - } - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), firstCredReq, "access-token"). - Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) - // Retry with fresh nonce succeeds - retryCredReq := openid4vci.CredentialRequest{ - CredentialConfigurationID: "ExampleCredential_ldp_vc", - Proofs: &openid4vci.CredentialRequestProofs{Jwt: []string{"signed-jwt-2"}}, - } - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), retryCredReq, "access-token"). - Return(&vc.VerifiableCredential{ - Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://example.com/credentials/v1")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("ExampleCredential")}, - Issuer: issuerDID.URI()}, nil) - - credentialStore := types.NewMockWriter(ctrl) - jwtSigner := crypto.NewMockJWTSigner(ctrl) - nowFunc = func() time.Time { - return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - } - t.Cleanup(func() { nowFunc = time.Now }) - // First attempt uses the stale nonce - firstSign := jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ - "iss": holderDID.String(), - "aud": issuerDID.String(), - "iat": int64(1767225600), - "nonce": "stale-nonce", - }, gomock.Any(), "key-id").Return("signed-jwt-1", nil) - // Retry uses the fresh nonce - jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ - "iss": holderDID.String(), - "aud": issuerDID.String(), - "iat": int64(1767225600), - "nonce": nonce, - }, gomock.Any(), "key-id").Return("signed-jwt-2", nil).After(firstSign) - keyResolver := resolver.NewMockKeyResolver(ctrl) - keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) - - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) - w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { - return issuerAPIClient, nil - } - - credentialStore.EXPECT().StoreCredential(gomock.Any(), nil).Return(nil) - - err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - - require.NoError(t, err) - }) - t.Run("error - invalid_nonce retry also fails", func(t *testing.T) { - ctrl := gomock.NewController(t) - issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() - first := issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: "stale-nonce"}, nil) - issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: "also-stale"}, nil).After(first) - tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} - issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ - "pre-authorized_code": "code", - }).Return(tokenResponse, nil) - // Both credential requests fail - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). - Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). - Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) - - jwtSigner := crypto.NewMockJWTSigner(ctrl) - jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "key-id").Return("signed-jwt", nil).Times(2) - keyResolver := resolver.NewMockKeyResolver(ctrl) - keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) - - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, jwtSigner, keyResolver).(*openidHandler) - w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { - return issuerAPIClient, nil - } - - err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - - require.EqualError(t, err, "server_error - unable to retrieve credential: invalid_nonce") - }) - t.Run("error - nonce endpoint request fails", func(t *testing.T) { - ctrl := gomock.NewController(t) - issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() - issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(nil, errors.New("nonce request failed")) - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "access-token"}, nil) - jwtSigner := crypto.NewMockJWTSigner(ctrl) - keyResolver := resolver.NewMockKeyResolver(ctrl) - keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) - - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, jwtSigner, keyResolver).(*openidHandler) - w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { - return issuerAPIClient, nil - } + t.Run("error - credentialSubject not allowed in offer", func(t *testing.T) { + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) + credentials := offeredCredential() + credentials[0].CredentialDefinition.CredentialSubject = new(map[string]interface{}) - err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{Credentials: credentials}).(openid4vci.Error) - require.EqualError(t, err, "server_error - unable to retrieve credential: unable to request nonce: nonce request failed") + assert.EqualError(t, err, "invalid_request - credential offer: invalid credential_definition: credentialSubject not allowed in offer") + assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) } -// offeredCredential returns a resolved credential configuration for testing. +// offeredCredential returns a structure that can be used as CredentialOffer.Credentials, func offeredCredential() []openid4vci.OfferedCredential { return []openid4vci.OfferedCredential{{ Format: vc.JSONLDCredentialProofFormat, CredentialDefinition: &openid4vci.CredentialDefinition{ Context: []ssi.URI{ ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - ssi.MustParseURI("https://example.com/credentials/v1"), + ssi.MustParseURI("http://example.org/credentials/V1"), }, Type: []ssi.URI{ ssi.MustParseURI("VerifiableCredential"), - ssi.MustParseURI("ExampleCredential"), + ssi.MustParseURI("HumanCredential"), }, }, }} diff --git a/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json b/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json index 735330e19a..5ffa686f32 100644 --- a/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json +++ b/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json @@ -1,17 +1,12 @@ { "format": "ldp_vc", - "proof_types_supported": { - "jwt": { - "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] - } - }, "cryptographic_binding_methods_supported": [ "did:nuts" ], "credential_definition": { "@context": [ "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" + "https://www.nuts.nl/credentials/v1" ], "type": [ "VerifiableCredential", diff --git a/vcr/issuer/assets/definitions/NutsOrganizationCredential.json b/vcr/issuer/assets/definitions/NutsOrganizationCredential.json index 6bec97eaad..f2482124bc 100644 --- a/vcr/issuer/assets/definitions/NutsOrganizationCredential.json +++ b/vcr/issuer/assets/definitions/NutsOrganizationCredential.json @@ -1,17 +1,12 @@ { "format": "ldp_vc", - "proof_types_supported": { - "jwt": { - "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] - } - }, "cryptographic_binding_methods_supported": [ "did:nuts" ], "credential_definition": { "@context": [ "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" + "https://www.nuts.nl/credentials/v1" ], "type": [ "VerifiableCredential", diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index 1c4a5f1c43..c221083194 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -80,23 +80,21 @@ const TokenTTL = 15 * time.Minute const preAuthCodeRefType = "preauthcode" const accessTokenRefType = "accesstoken" +const cNonceRefType = "c_nonce" // OpenIDHandler defines the interface for handling OpenID4VCI issuer operations. type OpenIDHandler interface { // ProviderMetadata returns the OpenID Connect provider metadata. ProviderMetadata() openid4vci.ProviderMetadata // HandleAccessTokenRequest handles an OAuth2 access token request for the given issuer and pre-authorized code. - // It returns the access token. - HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, error) + // It returns the access token and a c_nonce. + HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, string, error) // Metadata returns the OpenID4VCI credential issuer metadata for the given issuer. Metadata() openid4vci.CredentialIssuerMetadata // OfferCredential sends a credential offer to the specified wallet. It derives the issuer from the credential. OfferCredential(ctx context.Context, credential vc.VerifiableCredential, walletIdentifier string) error // HandleCredentialRequest requests a credential from the given issuer. HandleCredentialRequest(ctx context.Context, request openid4vci.CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) - // HandleNonceRequest handles a request to the Nonce Endpoint (v1.0 Section 7). - // It generates a standalone nonce and returns it. - HandleNonceRequest(ctx context.Context) (string, error) } // NewOpenIDHandler creates a new OpenIDHandler instance. The identifier is the Credential Issuer Identifier, e.g. https://example.com/issuer/ @@ -116,25 +114,24 @@ func NewOpenIDHandler(issuerDID did.DID, issuerIdentifierURL string, definitions } type openidHandler struct { - issuerIdentifierURL string - issuerDID did.DID - definitionsDIR string - credentialConfigurationsSupported map[string]map[string]interface{} - keyResolver resolver.KeyResolver - store OpenIDStore - walletClientCreator func(ctx context.Context, httpClient core.HTTPRequestDoer, walletMetadataURL string) (openid4vci.WalletAPIClient, error) - httpClient core.HTTPRequestDoer + issuerIdentifierURL string + issuerDID did.DID + definitionsDIR string + credentialsSupported []map[string]interface{} + keyResolver resolver.KeyResolver + store OpenIDStore + walletClientCreator func(ctx context.Context, httpClient core.HTTPRequestDoer, walletMetadataURL string) (openid4vci.WalletAPIClient, error) + httpClient core.HTTPRequestDoer } func (i *openidHandler) Metadata() openid4vci.CredentialIssuerMetadata { metadata := openid4vci.CredentialIssuerMetadata{ CredentialIssuer: i.issuerIdentifierURL, CredentialEndpoint: core.JoinURLPaths(i.issuerIdentifierURL, "/openid4vci/credential"), - NonceEndpoint: core.JoinURLPaths(i.issuerIdentifierURL, "/openid4vci/nonce"), } - // deepcopy the credentialConfigurationsSupported map to prevent concurrent access. - metadata.CredentialConfigurationsSupported = deepcopyMap(i.credentialConfigurationsSupported) + // deepcopy the i.credentialsSupported slice to prevent concurrent access to the slice. + metadata.CredentialsSupported = deepcopy(i.credentialsSupported) return metadata } @@ -150,20 +147,20 @@ func (i *openidHandler) ProviderMetadata() openid4vci.ProviderMetadata { } } -func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, error) { +func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, string, error) { flow, err := i.store.FindByReference(ctx, preAuthCodeRefType, preAuthorizedCode) if err != nil { - return "", err + return "", "", err } if flow == nil { - return "", openid4vci.Error{ + return "", "", openid4vci.Error{ Err: errors.New("unknown pre-authorized code"), Code: openid4vci.InvalidGrant, StatusCode: http.StatusBadRequest, } } if flow.IssuerID != i.issuerDID.String() { - return "", openid4vci.Error{ + return "", "", openid4vci.Error{ Err: errors.New("pre-authorized code not issued by this issuer"), Code: openid4vci.InvalidGrant, StatusCode: http.StatusBadRequest, @@ -172,7 +169,12 @@ func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthori accessToken := crypto.GenerateNonce() err = i.store.StoreReference(ctx, flow.ID, accessTokenRefType, accessToken) if err != nil { - return "", err + return "", "", err + } + cNonce := crypto.GenerateNonce() + err = i.store.StoreReference(ctx, flow.ID, cNonceRefType, cNonce) + if err != nil { + return "", "", err } // PreAuthorizedCode is to be used just once @@ -184,7 +186,7 @@ func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthori // Just log it, nothing will break (since they'll be pruned after ttl anyway). log.Logger().WithError(err).Error("Failed to delete pre-authorized code") } - return accessToken, nil + return accessToken, cNonce, nil } func (i *openidHandler) OfferCredential(ctx context.Context, credential vc.VerifiableCredential, walletIdentifier string) error { @@ -212,16 +214,20 @@ func (i *openidHandler) OfferCredential(ctx context.Context, credential vc.Verif } func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request openid4vci.CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) { - // v1.0 Section 8.2 requires credential_configuration_id or credential_identifier (mutually exclusive). - // This implementation only accepts credential_configuration_id as a policy choice. - if request.CredentialConfigurationID == "" { + if request.Format != vc.JSONLDCredentialProofFormat { return nil, openid4vci.Error{ - Err: errors.New("credential request must contain credential_configuration_id"), - Code: openid4vci.InvalidCredentialRequest, + Err: fmt.Errorf("credential request: unsupported format '%s'", request.Format), + Code: openid4vci.UnsupportedCredentialType, + StatusCode: http.StatusBadRequest, + } + } + if err := request.CredentialDefinition.Validate(false); err != nil { + return nil, openid4vci.Error{ + Err: fmt.Errorf("credential request: %w", err), + Code: openid4vci.InvalidRequest, StatusCode: http.StatusBadRequest, } } - flow, err := i.store.FindByReference(ctx, accessTokenRefType, accessToken) if err != nil { return nil, err @@ -231,42 +237,34 @@ func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request ope return nil, openid4vci.Error{ Err: errors.New("unknown access token"), Code: openid4vci.InvalidToken, - StatusCode: http.StatusUnauthorized, + StatusCode: http.StatusBadRequest, } } credential := flow.Credentials[0] // there's always just one (at least for now) subjectDID, _ := credential.SubjectDID() + // check credential.Issuer against given issuer if credential.Issuer.String() != i.issuerDID.String() { return nil, openid4vci.Error{ Err: errors.New("credential issuer does not match given issuer"), - Code: openid4vci.InvalidCredentialRequest, + Code: openid4vci.InvalidRequest, StatusCode: http.StatusBadRequest, } } - // Validate the credential_configuration_id matches what was offered - expectedConfigID, err := i.findCredentialConfigID(credential) - if err != nil { - return nil, openid4vci.Error{ - Err: fmt.Errorf("credential has no matching configuration: %w", err), - Code: openid4vci.UnknownCredentialConfiguration, - StatusCode: http.StatusBadRequest, - } + if err = i.validateProof(ctx, flow, request); err != nil { + return nil, err } - if request.CredentialConfigurationID != expectedConfigID { + + if err = openid4vci.ValidateDefinitionWithCredential(credential, *request.CredentialDefinition); err != nil { return nil, openid4vci.Error{ - Err: fmt.Errorf("credential_configuration_id '%s' does not match offered '%s'", request.CredentialConfigurationID, expectedConfigID), - Code: openid4vci.UnknownCredentialConfiguration, + Err: fmt.Errorf("requested credential does not match offer: %w", err), + Code: openid4vci.InvalidRequest, StatusCode: http.StatusBadRequest, } } - if err = i.validateProof(ctx, flow, request); err != nil { - return nil, err - } - // Important: since we (for now) create the VC even before the wallet requests it, we don't know if every VC is actually retrieved by the wallet. // This is a temporary shortcut, since changing that requires a lot of refactoring. // To make actually retrieved VC traceable, we log it to the audit log. @@ -279,69 +277,59 @@ func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request ope return &credential, nil } -func (i *openidHandler) HandleNonceRequest(ctx context.Context) (string, error) { - nonce := crypto.GenerateNonce() - if err := i.store.StoreNonce(ctx, nonce); err != nil { - return "", err - } - return nonce, nil -} - // validateProof validates the proof of the credential request. Aside from checks as specified by the spec, // it verifies the proof signature, and whether the signer is the intended wallet. -// The validation is metadata-driven: proof is only required if the credential configuration -// includes proof_types_supported. Nonce is only required if the issuer advertises a nonce_endpoint. // See https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-proof-types func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request openid4vci.CredentialRequest) error { - // Check if the credential configuration requires proof - credConfig, ok := i.credentialConfigurationsSupported[request.CredentialConfigurationID] - if ok { - if _, hasProofTypes := credConfig["proof_types_supported"]; !hasProofTypes { - return nil // no proof required for this credential configuration - } - } - credential := flow.Credentials[0] // there's always just one (at least for now) wallet, _ := credential.SubjectDID() - if request.Proofs == nil || len(request.Proofs.Jwt) == 0 { - return openid4vci.Error{ - Err: errors.New("missing proofs"), + // augment invalid_proof errors according to §7.3.2 of openid4vci spec + generateProofError := func(err openid4vci.Error) error { + cnonce := crypto.GenerateNonce() + if err := i.store.StoreReference(ctx, flow.ID, cNonceRefType, cnonce); err != nil { + return err + } + expiry := int(TokenTTL.Seconds()) + err.CNonce = &cnonce + err.CNonceExpiresIn = &expiry + return err + } + + if request.Proof == nil { + return generateProofError(openid4vci.Error{ + Err: errors.New("missing proof"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - } + }) + } + if request.Proof.ProofType != openid4vci.ProofTypeJWT { + return generateProofError(openid4vci.Error{ + Err: errors.New("proof type not supported"), + Code: openid4vci.InvalidProof, + StatusCode: http.StatusBadRequest, + }) } - // We only support single proof for now - proofJWT := request.Proofs.Jwt[0] var signingKeyID string - token, err := crypto.ParseJWT(proofJWT, func(kid string) (crypt.PublicKey, error) { + token, err := crypto.ParseJWT(request.Proof.Jwt, func(kid string) (crypt.PublicKey, error) { signingKeyID = kid return i.keyResolver.ResolveKeyByID(kid, nil, resolver.NutsSigningKeyType) }, openID4VCIProofProfile, nil) if err != nil { - return openid4vci.Error{ + return generateProofError(openid4vci.Error{ Err: err, Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - } - } - - // Validate iss claim matches the expected wallet DID (v1.0 Appendix F.1) - if token.Issuer() != wallet.String() { - return openid4vci.Error{ - Err: fmt.Errorf("proof iss claim does not match expected wallet: %s", token.Issuer()), - Code: openid4vci.InvalidProof, - StatusCode: http.StatusBadRequest, - } + }) } // Proof must be signed by wallet to which it was offered (proof signer == offer receiver) if signerDID, err := resolver.GetDIDFromURL(signingKeyID); err != nil || signerDID.String() != wallet.String() { - return openid4vci.Error{ + return generateProofError(openid4vci.Error{ Err: fmt.Errorf("credential offer was signed by other DID than intended wallet: %s", signingKeyID), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - } + }) } // Validate audience @@ -353,91 +341,61 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o } } if !audienceMatches { - return openid4vci.Error{ + return generateProofError(openid4vci.Error{ Err: fmt.Errorf("audience doesn't match credential issuer (aud=%s)", token.Audience()), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - } + }) } - // Validate JWT type - // jwt.Parse does not provide the JWS headers, we have to parse it again as JWS to access those - message, err := jws.ParseString(proofJWT) - if err != nil { - // Should not fail - return err - } - if len(message.Signatures()) != 1 { - // I think this is impossible - return errors.New("expected exactly one signature") - } - typ := message.Signatures()[0].ProtectedHeaders().Type() - if typ == "" { - return openid4vci.Error{ - Err: errors.New("missing typ header"), - Code: openid4vci.InvalidProof, - StatusCode: http.StatusBadRequest, - } - } - if typ != openid4vci.JWTTypeOpenID4VCIProof { - return openid4vci.Error{ - Err: fmt.Errorf("invalid typ claim (expected: %s): %s", openid4vci.JWTTypeOpenID4VCIProof, typ), + // given the JWT typ, the nonce is in the 'nonce' claim + nonce, ok := token.Get("nonce") + if !ok { + return generateProofError(openid4vci.Error{ + Err: errors.New("missing nonce claim"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - } + }) } - // Nonce validation: only required if the issuer advertises a nonce_endpoint - metadata := i.Metadata() - if metadata.NonceEndpoint == "" { - return nil // no nonce required + // check if the nonce matches the one we sent in the offer + flowFromNonce, err := i.store.FindByReference(ctx, cNonceRefType, nonce.(string)) + if err != nil { + return err } - - - // given the JWT typ, the nonce is in the 'nonce' claim - nonce, ok := token.Get("nonce") - if !ok { + if flowFromNonce == nil { return openid4vci.Error{ - Err: errors.New("missing nonce claim"), + Err: errors.New("unknown nonce"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, } } - - nonceValue, ok := nonce.(string) - if !ok { + if flowFromNonce.ID != flow.ID { return openid4vci.Error{ - Err: errors.New("nonce claim is not a string"), + Err: errors.New("nonce not valid for access token"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, } } - // Validate nonce from Nonce Endpoint (v1.0 Section 7) - if i.store.ConsumeNonce(ctx, nonceValue) { - return nil - } - - return openid4vci.Error{ - Err: errors.New("invalid or expired nonce"), - Code: openid4vci.InvalidNonce, - StatusCode: http.StatusBadRequest, - } + return nil } func (i *openidHandler) createOffer(ctx context.Context, credential vc.VerifiableCredential, preAuthorizedCode string) (*openid4vci.CredentialOffer, error) { - credentialConfigID, err := i.findCredentialConfigID(credential) - if err != nil { - return nil, fmt.Errorf("unable to create credential offer: %w", err) + grantParams := map[string]interface{}{ + "pre-authorized_code": preAuthorizedCode, } - offer := openid4vci.CredentialOffer{ - CredentialIssuer: i.issuerIdentifierURL, - CredentialConfigurationIDs: []string{credentialConfigID}, - Grants: &openid4vci.CredentialOfferGrants{ - PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ - PreAuthorizedCode: preAuthorizedCode, + CredentialIssuer: i.issuerIdentifierURL, + Credentials: []openid4vci.OfferedCredential{{ + Format: vc.JSONLDCredentialProofFormat, + CredentialDefinition: &openid4vci.CredentialDefinition{ + Context: credential.Context, + Type: credential.Type, }, + }}, + Grants: map[string]interface{}{ + openid4vci.PreAuthorizedCodeGrant: grantParams, }, } subjectDID, _ := credential.SubjectDID() // succeeded in previous step, can't fail @@ -449,14 +407,12 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl Credentials: []vc.VerifiableCredential{credential}, Grants: []Grant{ { - Type: openid4vci.PreAuthorizedCodeGrant, - Params: map[string]interface{}{ - "pre-authorized_code": preAuthorizedCode, - }, + Type: openid4vci.PreAuthorizedCodeGrant, + Params: grantParams, }, }, } - err = i.store.Store(ctx, flow) + err := i.store.Store(ctx, flow) if err == nil { err = i.store.StoreReference(ctx, flow.ID, preAuthCodeRefType, preAuthorizedCode) } @@ -467,20 +423,8 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl } func (i *openidHandler) loadCredentialDefinitions() error { - i.credentialConfigurationsSupported = make(map[string]map[string]interface{}) - - addDefinition := func(source string, definitionMap map[string]interface{}) error { - configID, err := generateCredentialConfigID(definitionMap) - if err != nil { - return fmt.Errorf("invalid credential definition from %s: %w", source, err) - } - if _, exists := i.credentialConfigurationsSupported[configID]; exists { - return fmt.Errorf("duplicate credential_configuration_id '%s' from %s", configID, source) - } - i.credentialConfigurationsSupported[configID] = definitionMap - return nil - } + // retrieve the definitions from assets and add to the list of CredentialsSupported definitionsDir, err := assets.FS.ReadDir("definitions") if err != nil { return err @@ -495,11 +439,10 @@ func (i *openidHandler) loadCredentialDefinitions() error { if err != nil { return err } - if err := addDefinition("assets/"+definition.Name(), definitionMap); err != nil { - return err - } + i.credentialsSupported = append(i.credentialsSupported, definitionMap) } + // now add all credential definition from config.DefinitionsDIR if i.definitionsDIR != "" { err = filepath.WalkDir(i.definitionsDIR, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -515,9 +458,7 @@ func (i *openidHandler) loadCredentialDefinitions() error { if err != nil { return fmt.Errorf("failed to parse credential definition from %s: %w", path, err) } - if err := addDefinition(path, definitionMap); err != nil { - return err - } + i.credentialsSupported = append(i.credentialsSupported, definitionMap) } return nil }) @@ -526,123 +467,13 @@ func (i *openidHandler) loadCredentialDefinitions() error { return err } -func deepcopyMap(src map[string]map[string]interface{}) map[string]map[string]interface{} { - data, err := json.Marshal(src) - if err != nil { - panic("deepcopyMap: marshal failed: " + err.Error()) - } - var dst map[string]map[string]interface{} - if err = json.Unmarshal(data, &dst); err != nil { - panic("deepcopyMap: unmarshal failed: " + err.Error()) - } - return dst -} - -// generateCredentialConfigID generates a credential_configuration_id from a credential definition. -// The ID is formed as "{MostSpecificType}_{format}" (e.g., "NutsOrganizationCredential_ldp_vc"). -// Returns an error if the definition is missing required fields to generate a unique ID. -func generateCredentialConfigID(definitionMap map[string]interface{}) (string, error) { - format, _ := definitionMap["format"].(string) - if format == "" { - return "", errors.New("credential definition missing 'format' field") - } - credDef, ok := definitionMap["credential_definition"].(map[string]interface{}) - if !ok { - return "", errors.New("credential definition missing 'credential_definition' field") - } - - types, ok := credDef["type"].([]interface{}) - if !ok || len(types) == 0 { - return "", errors.New("credential definition missing 'type' field") - } - - // Find the most specific type (typically the last one, excluding VerifiableCredential) - var specificType string - for _, t := range types { - if typeStr, ok := t.(string); ok && typeStr != "VerifiableCredential" { - specificType = typeStr +func deepcopy(src []map[string]interface{}) []map[string]interface{} { + dst := make([]map[string]interface{}, len(src)) + for i := range src { + dst[i] = make(map[string]interface{}) + for k, v := range src[i] { + dst[i][k] = v } } - if specificType == "" { - specificType = "VerifiableCredential" - } - - return specificType + "_" + format, nil -} - -// findCredentialConfigID finds the credential configuration ID for the given credential -// by matching it against the loaded credential_configurations_supported. -// Returns an error if no matching configuration is found, since credential_configuration_ids -// in offers MUST reference entries in credential_configurations_supported (Section 4.1.1). -func (i *openidHandler) findCredentialConfigID(credential vc.VerifiableCredential) (string, error) { - for configID, config := range i.credentialConfigurationsSupported { - if matchesCredential(config, credential) { - return configID, nil - } - } - return "", fmt.Errorf("no matching credential configuration for type %s", credential.Type) -} - -// matchesCredential checks if a credential configuration matches the given credential -// by comparing format, type, and @context. -// Type matching is exact (count must be equal). Context matching is a subset check: -// all config contexts must appear in the credential, but the credential may have additional -// contexts (e.g., proof-related contexts added during signing). -func matchesCredential(config map[string]interface{}, credential vc.VerifiableCredential) bool { - format, _ := config["format"].(string) - if format != vc.JSONLDCredentialProofFormat { - return false - } - - credDef, ok := config["credential_definition"].(map[string]interface{}) - if !ok { - return false - } - - types, ok := credDef["type"].([]interface{}) - if !ok { - return false - } - if len(types) != len(credential.Type) { - return false - } - for _, configType := range types { - typeStr, ok := configType.(string) - if !ok { - return false - } - found := false - for _, credType := range credential.Type { - if credType.String() == typeStr { - found = true - break - } - } - if !found { - return false - } - } - - contexts, ok := credDef["@context"].([]interface{}) - if !ok { - return false - } - for _, configCtx := range contexts { - ctxStr, ok := configCtx.(string) - if !ok { - return false - } - found := false - for _, credCtx := range credential.Context { - if credCtx.String() == ctxStr { - found = true - break - } - } - if !found { - return false - } - } - - return true + return dst } diff --git a/vcr/issuer/openid_mock.go b/vcr/issuer/openid_mock.go index 959eaa1e44..1eb709fca9 100644 --- a/vcr/issuer/openid_mock.go +++ b/vcr/issuer/openid_mock.go @@ -22,6 +22,7 @@ import ( type MockOpenIDHandler struct { ctrl *gomock.Controller recorder *MockOpenIDHandlerMockRecorder + isgomock struct{} } // MockOpenIDHandlerMockRecorder is the mock recorder for MockOpenIDHandler. @@ -42,12 +43,13 @@ func (m *MockOpenIDHandler) EXPECT() *MockOpenIDHandlerMockRecorder { } // HandleAccessTokenRequest mocks base method. -func (m *MockOpenIDHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, error) { +func (m *MockOpenIDHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HandleAccessTokenRequest", ctx, preAuthorizedCode) ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // HandleAccessTokenRequest indicates an expected call of HandleAccessTokenRequest. @@ -71,21 +73,6 @@ func (mr *MockOpenIDHandlerMockRecorder) HandleCredentialRequest(ctx, request, a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleCredentialRequest", reflect.TypeOf((*MockOpenIDHandler)(nil).HandleCredentialRequest), ctx, request, accessToken) } -// HandleNonceRequest mocks base method. -func (m *MockOpenIDHandler) HandleNonceRequest(ctx context.Context) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HandleNonceRequest", ctx) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// HandleNonceRequest indicates an expected call of HandleNonceRequest. -func (mr *MockOpenIDHandlerMockRecorder) HandleNonceRequest(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleNonceRequest", reflect.TypeOf((*MockOpenIDHandler)(nil).HandleNonceRequest), ctx) -} - // Metadata mocks base method. func (m *MockOpenIDHandler) Metadata() openid4vci.CredentialIssuerMetadata { m.ctrl.T.Helper() diff --git a/vcr/issuer/openid_store.go b/vcr/issuer/openid_store.go index 1bb7df72dd..0471301164 100644 --- a/vcr/issuer/openid_store.go +++ b/vcr/issuer/openid_store.go @@ -40,12 +40,6 @@ type OpenIDStore interface { // DeleteReference deletes the reference from the store. // It does not return an error if it doesn't exist anymore. DeleteReference(ctx context.Context, refType string, reference string) error - // StoreNonce stores a standalone nonce (not tied to a flow) with TTL. - // Used by the Nonce Endpoint (v1.0 Section 7). - StoreNonce(ctx context.Context, nonce string) error - // ConsumeNonce atomically checks whether a standalone nonce exists and deletes it (single-use). - // Returns true if the nonce was valid (existed and was consumed), false otherwise. - ConsumeNonce(ctx context.Context, nonce string) bool } var _ OpenIDStore = (*openidMemoryStore)(nil) @@ -107,19 +101,3 @@ func (o *openidMemoryStore) DeleteReference(_ context.Context, refType string, r refStore := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", refType) return refStore.Delete(reference) } - -const standaloneNonceStoreKey = "standalone_nonce" - -func (o *openidMemoryStore) StoreNonce(_ context.Context, nonce string) error { - store := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", standaloneNonceStoreKey) - return store.Put(nonce, true) -} - -func (o *openidMemoryStore) ConsumeNonce(_ context.Context, nonce string) bool { - store := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", standaloneNonceStoreKey) - var value bool - if err := store.GetAndDelete(nonce, &value); err != nil { - return false - } - return value -} diff --git a/vcr/issuer/openid_store_test.go b/vcr/issuer/openid_store_test.go index fe4d64f20c..9fcbf80109 100644 --- a/vcr/issuer/openid_store_test.go +++ b/vcr/issuer/openid_store_test.go @@ -119,25 +119,6 @@ func Test_memoryStore_Store(t *testing.T) { }) } -func Test_memoryStore_StandaloneNonce(t *testing.T) { - ctx := context.Background() - t.Run("store and validate", func(t *testing.T) { - store := createStore(t) - err := store.StoreNonce(ctx, "test-nonce") - assert.NoError(t, err) - - // First check should succeed and consume the nonce - assert.True(t, store.ConsumeNonce(ctx, "test-nonce")) - - // Second check should fail (single-use) - assert.False(t, store.ConsumeNonce(ctx, "test-nonce")) - }) - t.Run("unknown nonce", func(t *testing.T) { - store := createStore(t) - assert.False(t, store.ConsumeNonce(ctx, "unknown")) - }) -} - func createStore(t *testing.T) *openidMemoryStore { storageDatabase := storage.NewTestInMemorySessionDatabase(t) store := NewOpenIDMemoryStore(storageDatabase).(*openidMemoryStore) diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index 62acabf80f..62ca9e0b6e 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -34,8 +34,6 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "net/http" - "os" - "path/filepath" "testing" "time" ) @@ -56,11 +54,11 @@ var issuedVC = vc.VerifiableCredential{ }, Context: []ssi.URI{ ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - ssi.MustParseURI("https://example.com/credentials/v1"), + ssi.MustParseURI("http://example.org/credentials/V1"), }, Type: []ssi.URI{ ssi.MustParseURI("VerifiableCredential"), - ssi.MustParseURI("ExampleCredential"), + ssi.MustParseURI("HumanCredential"), }, } @@ -69,7 +67,7 @@ func TestNew(t *testing.T) { iss, err := NewOpenIDHandler(issuerDID, issuerIdentifier, "./test/valid", nil, nil, storage.NewTestInMemorySessionDatabase(t)) require.NoError(t, err) - assert.Len(t, iss.(*openidHandler).credentialConfigurationsSupported, 3) + assert.Len(t, iss.(*openidHandler).credentialsSupported, 3) }) t.Run("error - invalid json", func(t *testing.T) { @@ -95,45 +93,15 @@ func Test_memoryIssuer_Metadata(t *testing.T) { assert.Equal(t, "https://example.com/did:nuts:issuer", metadata.CredentialIssuer) assert.Equal(t, "https://example.com/did:nuts:issuer/openid4vci/credential", metadata.CredentialEndpoint) - assert.Equal(t, "https://example.com/did:nuts:issuer/openid4vci/nonce", metadata.NonceEndpoint) - require.Len(t, metadata.CredentialConfigurationsSupported, 3) - // Assert all 3 config IDs by name - for _, expectedID := range []string{ - "NutsAuthorizationCredential_ldp_vc", - "NutsOrganizationCredential_ldp_vc", - "ExampleCredential_ldp_vc", - } { - _, ok := metadata.CredentialConfigurationsSupported[expectedID] - assert.True(t, ok, "expected config ID %s to be present", expectedID) - } - // Spot-check NutsAuthorizationCredential details - authCredConfig := metadata.CredentialConfigurationsSupported["NutsAuthorizationCredential_ldp_vc"] - assert.Equal(t, "ldp_vc", authCredConfig["format"]) - require.Len(t, authCredConfig["cryptographic_binding_methods_supported"], 1) - assert.Equal(t, authCredConfig["credential_definition"], + require.Len(t, metadata.CredentialsSupported, 3) + assert.Equal(t, "ldp_vc", metadata.CredentialsSupported[0]["format"]) + require.Len(t, metadata.CredentialsSupported[0]["cryptographic_binding_methods_supported"], 1) + assert.Equal(t, metadata.CredentialsSupported[0]["credential_definition"], map[string]interface{}{ - "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"}, + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://www.nuts.nl/credentials/v1"}, "type": []interface{}{"VerifiableCredential", "NutsAuthorizationCredential"}, }) }) - t.Run("duplicate credential_configuration_id from external dir is rejected", func(t *testing.T) { - // Create a temp dir with a definition that duplicates a built-in config ID - tmpDir := t.TempDir() - duplicateDef := `{ - "format": "ldp_vc", - "cryptographic_binding_methods_supported": ["did:nuts"], - "credential_definition": { - "@context": ["https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"], - "type": ["VerifiableCredential", "NutsOrganizationCredential"] - } - }` - err := os.WriteFile(filepath.Join(tmpDir, "duplicate.json"), []byte(duplicateDef), 0644) - require.NoError(t, err) - - _, err = NewOpenIDHandler(issuerDID, issuerIdentifier, tmpDir, &http.Client{}, nil, storage.NewTestInMemorySessionDatabase(t)) - require.Error(t, err) - assert.Contains(t, err.Error(), "duplicate credential_configuration_id 'NutsOrganizationCredential_ldp_vc'") - }) } func Test_memoryIssuer_ProviderMetadata(t *testing.T) { @@ -162,37 +130,41 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { } createClaims := func(nonce string) map[string]interface{} { return map[string]interface{}{ - "iss": holderDID.String(), "aud": issuerIdentifier, "iat": time.Now().Unix(), "nonce": nonce, } } - createProofs := func(headers, claims map[string]interface{}) *openid4vci.CredentialRequestProofs { + createRequest := func(headers, claims map[string]interface{}) openid4vci.CredentialRequest { proof, err := keyStore.SignJWT(ctx, claims, headers, headers["kid"].(string)) require.NoError(t, err) - return &openid4vci.CredentialRequestProofs{ - Jwt: []string{proof}, - } - } - createRequest := func(headers, claims map[string]interface{}, configID string) openid4vci.CredentialRequest { return openid4vci.CredentialRequest{ - CredentialConfigurationID: configID, - Proofs: createProofs(headers, claims), + Format: vc.JSONLDCredentialProofFormat, + CredentialDefinition: &openid4vci.CredentialDefinition{ + Context: []ssi.URI{ + ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), + ssi.MustParseURI("http://example.org/credentials/V1"), + }, + Type: []ssi.URI{ + ssi.MustParseURI("VerifiableCredential"), + ssi.MustParseURI("HumanCredential"), + }, + }, + Proof: &openid4vci.CredentialRequestProof{ + Jwt: proof, + ProofType: openid4vci.ProofTypeJWT, + }, } } const preAuthCode = "some-secret-code" service := requireNewTestHandler(t, keyResolver) - offer, err := service.createOffer(ctx, issuedVC, preAuthCode) + _, err := service.createOffer(ctx, issuedVC, preAuthCode) require.NoError(t, err) - accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + accessToken, cNonce, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) - nonce, err := service.HandleNonceRequest(ctx) - require.NoError(t, err) - configID := offer.CredentialConfigurationIDs[0] - validRequest := createRequest(createHeaders(), createClaims(nonce), configID) + validRequest := createRequest(createHeaders(), createClaims(cNonce)) t.Run("ok", func(t *testing.T) { auditLogs := audit.CaptureAuditLogs(t) @@ -203,39 +175,62 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { assert.Equal(t, issuerDID.URI(), response.Issuer) auditLogs.AssertContains(t, "VCR", "VerifiableCredentialRetrievedEvent", audit.TestActor, "VC retrieved by wallet over OpenID4VCI") }) - t.Run("error - missing credential_configuration_id", func(t *testing.T) { - request := openid4vci.CredentialRequest{ - Proofs: createProofs(createHeaders(), createClaims(nonce)), - } + t.Run("unsupported format", func(t *testing.T) { + request := createRequest(createHeaders(), createClaims(cNonce)) + request.Format = "unsupported format" response, err := service.HandleCredentialRequest(ctx, request, accessToken) assert.Nil(t, response) - assert.EqualError(t, err, "invalid_credential_request - credential request must contain credential_configuration_id") + assert.EqualError(t, err, "unsupported_credential_type - credential request: unsupported format 'unsupported format'") }) - t.Run("error - unknown credential_configuration_id", func(t *testing.T) { - request := createRequest(createHeaders(), createClaims(nonce), "NonExistent_ldp_vc") + t.Run("invalid credential_definition", func(t *testing.T) { + request := createRequest(createHeaders(), createClaims(cNonce)) + request.CredentialDefinition.Type = []ssi.URI{} response, err := service.HandleCredentialRequest(ctx, request, accessToken) assert.Nil(t, response) - require.ErrorAs(t, err, new(openid4vci.Error)) - assert.Equal(t, openid4vci.UnknownCredentialConfiguration, err.(openid4vci.Error).Code) + assert.EqualError(t, err, "invalid_request - credential request: invalid credential_definition: missing type field") }) t.Run("proof validation", func(t *testing.T) { - t.Run("missing proofs", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims(""), configID) - invalidRequest.Proofs = nil + t.Run("unsupported proof type", func(t *testing.T) { + invalidRequest := createRequest(createHeaders(), createClaims("")) + invalidRequest.Proof.ProofType = "not-supported" response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - missing proofs") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - proof type not supported") assert.Nil(t, response) }) t.Run("jwt", func(t *testing.T) { + t.Run("missing proof", func(t *testing.T) { + invalidRequest := createRequest(createHeaders(), createClaims("")) + invalidRequest.Proof = nil + + response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) + + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - missing proof") + assert.Nil(t, response) + }) + t.Run("missing proof returns error with new c_nonce", func(t *testing.T) { + invalidRequest := createRequest(createHeaders(), createClaims("")) + invalidRequest.Proof = nil + + _, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) + + require.ErrorAs(t, err, new(openid4vci.Error)) + cNonce := err.(openid4vci.Error).CNonce + assert.NotNil(t, cNonce) + assert.NotNil(t, err.(openid4vci.Error).CNonceExpiresIn) + + flow, err := service.store.FindByReference(ctx, cNonceRefType, *cNonce) + require.NoError(t, err) + assert.NotNil(t, flow) + }) t.Run("invalid JWT", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims(""), configID) - invalidRequest.Proofs.Jwt = []string{"not a JWT"} + invalidRequest := createRequest(createHeaders(), createClaims("")) + invalidRequest.Proof.Jwt = "not a JWT" response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -244,9 +239,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { }) t.Run("not signed by intended wallet (DID differs)", func(t *testing.T) { otherIssuedVC := vc.VerifiableCredential{ - Issuer: issuerDID.URI(), - Context: issuedVC.Context, - Type: issuedVC.Type, + Issuer: issuerDID.URI(), CredentialSubject: []map[string]any{ { "id": "did:nuts:other-wallet", @@ -255,37 +248,16 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { } service := requireNewTestHandler(t, keyResolver) - otherOffer, err := service.createOffer(ctx, otherIssuedVC, preAuthCode) + _, err := service.createOffer(ctx, otherIssuedVC, preAuthCode) require.NoError(t, err) - accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + accessToken, _, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) - otherConfigID := otherOffer.CredentialConfigurationIDs[0] - invalidRequest := createRequest(createHeaders(), createClaims(""), otherConfigID) + invalidRequest := createRequest(createHeaders(), createClaims("")) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - proof iss claim does not match expected wallet: did:nuts:holder") - assert.Nil(t, response) - }) - t.Run("iss claim does not match wallet DID", func(t *testing.T) { - service := requireNewTestHandler(t, keyResolver) - _, err := service.createOffer(ctx, issuedVC, preAuthCode) - require.NoError(t, err) - accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) - require.NoError(t, err) - - wrongIssClaims := map[string]interface{}{ - "iss": "did:nuts:wrong-issuer", - "aud": issuerIdentifier, - "iat": time.Now().Unix(), - "nonce": "", - } - invalidRequest := createRequest(createHeaders(), wrongIssClaims, configID) - - response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - proof iss claim does not match expected wallet: did:nuts:wrong-issuer") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - credential offer was signed by other DID than intended wallet: did:nuts:holder#1") assert.Nil(t, response) }) t.Run("signing key is unknown", func(t *testing.T) { @@ -294,10 +266,10 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { service := requireNewTestHandler(t, keyResolver) _, err := service.createOffer(ctx, issuedVC, preAuthCode) require.NoError(t, err) - accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + accessToken, _, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) - invalidRequest := createRequest(createHeaders(), createClaims(""), configID) + invalidRequest := createRequest(createHeaders(), createClaims("")) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -307,7 +279,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { t.Run("typ header missing", func(t *testing.T) { headers := createHeaders() headers["typ"] = "" - invalidRequest := createRequest(headers, createClaims(""), configID) + invalidRequest := createRequest(headers, createClaims("")) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -317,7 +289,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { t.Run("typ header invalid", func(t *testing.T) { headers := createHeaders() delete(headers, "typ") // causes JWT library to set it to default ("JWT") - invalidRequest := createRequest(headers, createClaims(""), configID) + invalidRequest := createRequest(headers, createClaims("")) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -327,7 +299,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { t.Run("aud header doesn't match issuer identifier", func(t *testing.T) { claims := createClaims("") claims["aud"] = "https://example.com/someone-else" - invalidRequest := createRequest(createHeaders(), claims, configID) + invalidRequest := createRequest(createHeaders(), claims) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -336,39 +308,44 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { }) }) t.Run("unknown nonce", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("other"), configID) + invalidRequest := createRequest(createHeaders(), createClaims("other")) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_nonce - invalid or expired nonce") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - unknown nonce") assert.Nil(t, response) }) + t.Run("wrong nonce", func(t *testing.T) { + _, err := service.createOffer(ctx, issuedVC, "other") + require.NoError(t, err) + _, cNonce, err := service.HandleAccessTokenRequest(ctx, "other") + require.NoError(t, err) + invalidRequest := createRequest(createHeaders(), createClaims(cNonce)) + + response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) + + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - nonce not valid for access token") + assert.Nil(t, response) + }) + t.Run("request does not match offer", func(t *testing.T) { + request := createRequest(createHeaders(), createClaims(cNonce)) + request.CredentialDefinition.Type = []ssi.URI{ + ssi.MustParseURI("DifferentCredential"), + } + + response, err := service.HandleCredentialRequest(ctx, request, accessToken) + + assert.Nil(t, response) + assert.EqualError(t, err, "invalid_request - requested credential does not match offer: credential does not match credential_definition: type mismatch") + }) }) + t.Run("unknown access token", func(t *testing.T) { service := requireNewTestHandler(t, keyResolver) response, err := service.HandleCredentialRequest(ctx, validRequest, accessToken) - assertProtocolError(t, err, http.StatusUnauthorized, "invalid_token - unknown access token") - assert.Nil(t, response) - }) - t.Run("credential issuer does not match", func(t *testing.T) { - store := storage.NewTestInMemorySessionDatabase(t) - service, err := NewOpenIDHandler(issuerDID, issuerIdentifier, definitionsDIR, &http.Client{}, keyResolver, store) - require.NoError(t, err) - _, err = service.(*openidHandler).createOffer(ctx, issuedVC, preAuthCode) - require.NoError(t, err) - accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) - require.NoError(t, err) - nonce, err := service.HandleNonceRequest(ctx) - require.NoError(t, err) - request := createRequest(createHeaders(), createClaims(nonce), configID) - - otherService, err := NewOpenIDHandler(did.MustParseDID("did:nuts:other"), "http://example.com/other", definitionsDIR, &http.Client{}, keyResolver, store) - require.NoError(t, err) - response, err := otherService.HandleCredentialRequest(ctx, request, accessToken) - - assertProtocolError(t, err, http.StatusBadRequest, "invalid_credential_request - credential issuer does not match given issuer") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_token - unknown access token") assert.Nil(t, response) }) } @@ -410,7 +387,7 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { _, err := service.createOffer(ctx, issuedVC, "code") require.NoError(t, err) - accessToken, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") + accessToken, _, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") require.NoError(t, err) assert.NotEmpty(t, accessToken) @@ -424,7 +401,7 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { otherService, err := NewOpenIDHandler(did.MustParseDID("did:nuts:other"), "http://example.com/other", definitionsDIR, &http.Client{}, nil, store) require.NoError(t, err) - accessToken, err := otherService.HandleAccessTokenRequest(audit.TestContext(), "code") + accessToken, _, err := otherService.HandleAccessTokenRequest(audit.TestContext(), "code") var protocolError openid4vci.Error require.ErrorAs(t, err, &protocolError) @@ -437,7 +414,7 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { _, err := service.createOffer(ctx, issuedVC, "some-other-code") require.NoError(t, err) - accessToken, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") + accessToken, _, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") var protocolError openid4vci.Error require.ErrorAs(t, err, &protocolError) @@ -447,149 +424,6 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { }) } -func Test_memoryIssuer_HandleNonceRequest(t *testing.T) { - ctx := context.Background() - t.Run("ok", func(t *testing.T) { - service := requireNewTestHandler(t, nil) - - nonce, err := service.HandleNonceRequest(ctx) - - require.NoError(t, err) - assert.NotEmpty(t, nonce) - }) -} - -func Test_memoryIssuer_validateProof_metadataDriven(t *testing.T) { - keyStore := crypto.NewMemoryCryptoInstance(t) - ctx := audit.TestContext() - _, signerKey, _ := keyStore.New(ctx, crypto.StringNamingFunc(keyID)) - ctrl := gomock.NewController(t) - keyResolver := resolver.NewMockKeyResolver(ctrl) - keyResolver.EXPECT().ResolveKeyByID(keyID, nil, resolver.NutsSigningKeyType).AnyTimes().Return(signerKey, nil) - - createHeaders := func() map[string]interface{} { - return map[string]interface{}{ - "typ": openid4vci.JWTTypeOpenID4VCIProof, - "kid": keyID, - } - } - createClaims := func(nonce string) map[string]interface{} { - return map[string]interface{}{ - "iss": holderDID.String(), - "aud": issuerIdentifier, - "iat": time.Now().Unix(), - "nonce": nonce, - } - } - createProofs := func(headers, claims map[string]interface{}) *openid4vci.CredentialRequestProofs { - proof, err := keyStore.SignJWT(ctx, claims, headers, headers["kid"].(string)) - require.NoError(t, err) - return &openid4vci.CredentialRequestProofs{ - Jwt: []string{proof}, - } - } - - const preAuthCode = "some-secret-code" - - t.Run("standalone nonce from Nonce Endpoint is accepted", func(t *testing.T) { - service := requireNewTestHandler(t, keyResolver) - _, err := service.createOffer(ctx, issuedVC, preAuthCode) - require.NoError(t, err) - accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) - require.NoError(t, err) - - // Get a standalone nonce - standaloneNonce, err := service.HandleNonceRequest(ctx) - require.NoError(t, err) - - configID := "ExampleCredential_ldp_vc" - request := openid4vci.CredentialRequest{ - CredentialConfigurationID: configID, - Proofs: createProofs(createHeaders(), createClaims(standaloneNonce)), - } - - response, err := service.HandleCredentialRequest(ctx, request, accessToken) - - require.NoError(t, err) - require.NotNil(t, response) - }) - t.Run("proof skipped when credential config has no proof_types_supported", func(t *testing.T) { - // Create a handler with a credential config that lacks proof_types_supported - tmpDir := t.TempDir() - noProofDef := `{ - "format": "ldp_vc", - "cryptographic_binding_methods_supported": ["did:nuts"], - "credential_definition": { - "@context": ["https://www.w3.org/2018/credentials/v1", "https://example.com/credentials/v1"], - "type": ["VerifiableCredential", "NoProofCredential"] - } - }` - err := os.WriteFile(filepath.Join(tmpDir, "NoProofCredential.json"), []byte(noProofDef), 0644) - require.NoError(t, err) - - service, err := NewOpenIDHandler(issuerDID, issuerIdentifier, tmpDir, &http.Client{}, keyResolver, storage.NewTestInMemorySessionDatabase(t)) - require.NoError(t, err) - handler := service.(*openidHandler) - - noProofVC := vc.VerifiableCredential{ - Issuer: issuerDID.URI(), - CredentialSubject: []map[string]any{ - {"id": holderDID.String()}, - }, - Context: []ssi.URI{ - ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - ssi.MustParseURI("https://example.com/credentials/v1"), - }, - Type: []ssi.URI{ - ssi.MustParseURI("VerifiableCredential"), - ssi.MustParseURI("NoProofCredential"), - }, - } - - _, err = handler.createOffer(ctx, noProofVC, preAuthCode) - require.NoError(t, err) - accessToken, err := handler.HandleAccessTokenRequest(ctx, preAuthCode) - require.NoError(t, err) - - // Request without proof should succeed - request := openid4vci.CredentialRequest{ - CredentialConfigurationID: "NoProofCredential_ldp_vc", - } - - response, err := handler.HandleCredentialRequest(ctx, request, accessToken) - - require.NoError(t, err) - require.NotNil(t, response) - }) - t.Run("non-string nonce claim returns invalid_proof", func(t *testing.T) { - service := requireNewTestHandler(t, keyResolver) - _, err := service.createOffer(ctx, issuedVC, preAuthCode) - require.NoError(t, err) - accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) - require.NoError(t, err) - - // Get a standalone nonce but put a number in the claim instead - _, err = service.HandleNonceRequest(ctx) - require.NoError(t, err) - - configID := "ExampleCredential_ldp_vc" - claimsWithNumericNonce := map[string]interface{}{ - "iss": holderDID.String(), - "aud": issuerIdentifier, - "iat": time.Now().Unix(), - "nonce": 12345, // non-string - } - request := openid4vci.CredentialRequest{ - CredentialConfigurationID: configID, - Proofs: createProofs(createHeaders(), claimsWithNumericNonce), - } - - _, err = service.HandleCredentialRequest(ctx, request, accessToken) - - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - nonce claim is not a string") - }) -} - func assertProtocolError(t *testing.T, err error, statusCode int, message string) { var protocolError openid4vci.Error require.ErrorAs(t, err, &protocolError) @@ -602,130 +436,3 @@ func requireNewTestHandler(t *testing.T, keyResolver resolver.KeyResolver) *open require.NoError(t, err) return service.(*openidHandler) } - -func Test_deepcopyMap(t *testing.T) { - t.Run("mutation of copy does not affect original", func(t *testing.T) { - src := map[string]map[string]interface{}{ - "config1": { - "format": "ldp_vc", - "credential_definition": map[string]interface{}{ - "type": []interface{}{"VerifiableCredential"}, - }, - }, - } - - dst := deepcopyMap(src) - credDef := dst["config1"]["credential_definition"].(map[string]interface{}) - credDef["type"] = []interface{}{"Mutated"} - - srcCredDef := src["config1"]["credential_definition"].(map[string]interface{}) - assert.Equal(t, []interface{}{"VerifiableCredential"}, srcCredDef["type"]) - }) -} - -func Test_matchesCredential(t *testing.T) { - t.Run("matches on type and context", func(t *testing.T) { - config := map[string]interface{}{ - "format": "ldp_vc", - "credential_definition": map[string]interface{}{ - "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"}, - "type": []interface{}{"VerifiableCredential", "NutsOrganizationCredential"}, - }, - } - cred := vc.VerifiableCredential{ - Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://nuts.nl/credentials/v1")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("NutsOrganizationCredential")}, - } - - assert.True(t, matchesCredential(config, cred)) - }) - t.Run("does not match on type mismatch", func(t *testing.T) { - config := map[string]interface{}{ - "format": "ldp_vc", - "credential_definition": map[string]interface{}{ - "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, - "type": []interface{}{"VerifiableCredential", "OtherCredential"}, - }, - } - cred := vc.VerifiableCredential{ - Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("NutsOrganizationCredential")}, - } - - assert.False(t, matchesCredential(config, cred)) - }) - t.Run("does not match on context mismatch", func(t *testing.T) { - config := map[string]interface{}{ - "format": "ldp_vc", - "credential_definition": map[string]interface{}{ - "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://other.example.com/v1"}, - "type": []interface{}{"VerifiableCredential"}, - }, - } - cred := vc.VerifiableCredential{ - Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, - } - - assert.False(t, matchesCredential(config, cred)) - }) -} - -func Test_generateCredentialConfigID(t *testing.T) { - t.Run("ok", func(t *testing.T) { - defMap := map[string]interface{}{ - "format": "ldp_vc", - "credential_definition": map[string]interface{}{ - "type": []interface{}{"VerifiableCredential", "NutsOrganizationCredential"}, - }, - } - id, err := generateCredentialConfigID(defMap) - require.NoError(t, err) - assert.Equal(t, "NutsOrganizationCredential_ldp_vc", id) - }) - t.Run("missing format", func(t *testing.T) { - defMap := map[string]interface{}{ - "credential_definition": map[string]interface{}{ - "type": []interface{}{"VerifiableCredential"}, - }, - } - _, err := generateCredentialConfigID(defMap) - assert.EqualError(t, err, "credential definition missing 'format' field") - }) - t.Run("missing credential_definition", func(t *testing.T) { - defMap := map[string]interface{}{ - "format": "ldp_vc", - } - _, err := generateCredentialConfigID(defMap) - assert.EqualError(t, err, "credential definition missing 'credential_definition' field") - }) - t.Run("missing type", func(t *testing.T) { - defMap := map[string]interface{}{ - "format": "ldp_vc", - "credential_definition": map[string]interface{}{}, - } - _, err := generateCredentialConfigID(defMap) - assert.EqualError(t, err, "credential definition missing 'type' field") - }) - t.Run("empty type array", func(t *testing.T) { - defMap := map[string]interface{}{ - "format": "ldp_vc", - "credential_definition": map[string]interface{}{ - "type": []interface{}{}, - }, - } - _, err := generateCredentialConfigID(defMap) - assert.EqualError(t, err, "credential definition missing 'type' field") - }) - t.Run("only VerifiableCredential type falls back", func(t *testing.T) { - defMap := map[string]interface{}{ - "format": "ldp_vc", - "credential_definition": map[string]interface{}{ - "type": []interface{}{"VerifiableCredential"}, - }, - } - id, err := generateCredentialConfigID(defMap) - require.NoError(t, err) - assert.Equal(t, "VerifiableCredential_ldp_vc", id) - }) -} diff --git a/vcr/issuer/test/valid/ExampleCredential.json b/vcr/issuer/test/valid/ExampleCredential.json index 110a3a6948..36f08d26d8 100644 --- a/vcr/issuer/test/valid/ExampleCredential.json +++ b/vcr/issuer/test/valid/ExampleCredential.json @@ -1,17 +1,12 @@ { "format": "ldp_vc", - "proof_types_supported": { - "jwt": { - "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] - } - }, "cryptographic_binding_methods_supported": [ "did:nuts" ], "credential_definition": { "@context": [ "https://www.w3.org/2018/credentials/v1", - "https://example.com/credentials/v1" + "https://www.nuts.nl/credentials/v1" ], "type": [ "VerifiableCredential", diff --git a/vcr/openid4vci/error.go b/vcr/openid4vci/error.go index 78fa896305..6c61c3dbc1 100644 --- a/vcr/openid4vci/error.go +++ b/vcr/openid4vci/error.go @@ -41,30 +41,23 @@ const ( UnsupportedGrantType ErrorCode = "unsupported_grant_type" // ServerError is returned when the Authorization Server encounters an unexpected condition that prevents it from fulfilling the request. ServerError ErrorCode = "server_error" - // InvalidCredentialRequest is returned when the Credential Request is missing a required parameter, - // includes an unsupported parameter or parameter value, or is otherwise malformed. - InvalidCredentialRequest ErrorCode = "invalid_credential_request" - // UnknownCredentialConfiguration is returned when the requested credential_configuration_id is unknown. - UnknownCredentialConfiguration ErrorCode = "unknown_credential_configuration" - // UnknownCredentialIdentifier is returned when the requested credential_identifier is unknown. - UnknownCredentialIdentifier ErrorCode = "unknown_credential_identifier" - // InvalidProof is returned when the proofs parameter is invalid: missing, one of the key proofs - // is invalid, or a key proof does not contain a c_nonce value. + // UnsupportedCredentialType is returned when the credential issuer does not support the requested credential type. + UnsupportedCredentialType ErrorCode = "unsupported_credential_type" + // UnsupportedCredentialFormat is returned when the credential issuer does not support the requested credential format. + UnsupportedCredentialFormat ErrorCode = "unsupported_credential_format" + // InvalidProof is returned when the Credential Request did not contain a proof, + // or proof was invalid, i.e. it was not bound to a Credential Issuer provided nonce InvalidProof ErrorCode = "invalid_proof" - // InvalidNonce is returned when at least one of the key proofs contains an invalid c_nonce value. - // The wallet should retrieve a new c_nonce value from the Nonce Endpoint (Section 7). - InvalidNonce ErrorCode = "invalid_nonce" - // InvalidEncryptionParameters is returned when the encryption parameters in the Credential Request - // are either invalid or missing when the issuer requires encrypted responses. - InvalidEncryptionParameters ErrorCode = "invalid_encryption_parameters" - // CredentialRequestDenied is returned when the Credential Request has not been accepted by the - // issuer. The wallet SHOULD treat this as unrecoverable. - CredentialRequestDenied ErrorCode = "credential_request_denied" ) // Error is an error that signals the error was (probably) caused by the client (e.g. bad request), // or that the client can recover from the error (e.g. retry). Errors are specified by the OpenID4VCI specification. +// Invalid proof errors may also add a new c_nonce that the client must use in the next credential request. type Error struct { + // CNonce is a random string that the client must send in the next credential request. + CNonce *string `json:"c_nonce,omitempty"` + // CNonceExpiresIn is the number of seconds until the c_nonce expires. + CNonceExpiresIn *int `json:"c_nonce_expires_in,omitempty"` // Code is the error code as defined by the OpenID4VCI spec. Code ErrorCode `json:"error"` // Err is the underlying error, may be omitted. It is not intended to be returned to the client. diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index d7f91dc484..c355aa96d5 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -43,8 +43,6 @@ type IssuerAPIClient interface { Metadata() CredentialIssuerMetadata // RequestCredential requests a credential from the issuer. RequestCredential(ctx context.Context, request CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) - // RequestNonce requests a fresh c_nonce from the issuer's Nonce Endpoint (v1.0 Section 7). - RequestNonce(ctx context.Context) (*NonceResponse, error) } // NewIssuerAPIClient resolves the Credential Issuer Metadata from the well-known endpoint @@ -95,72 +93,28 @@ type defaultIssuerAPIClient struct { func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) { requestBody, _ := json.Marshal(request) + var credentialResponse CredentialResponse httpRequest, _ := http.NewRequestWithContext(ctx, "POST", h.metadata.CredentialEndpoint, bytes.NewReader(requestBody)) httpRequest.Header.Add("Authorization", "Bearer "+accessToken) httpRequest.Header.Add("Content-Type", "application/json") - credentialResponse, err := doCredentialRequest(h.httpClient, httpRequest) + err := httpDo(h.httpClient, httpRequest, &credentialResponse) if err != nil { - return nil, err + return nil, fmt.Errorf("get credential request failed: %w", err) } - if len(credentialResponse.Credentials) == 0 { - return nil, errors.New("credential response does not contain any credentials") + // TODO: check format + // See https://github.com/nuts-foundation/nuts-node/issues/2037 + if credentialResponse.Credential == nil { + return nil, errors.New("credential response does not contain a credential") } - // We only support single credential issuance for now var credential vc.VerifiableCredential - err = json.Unmarshal(credentialResponse.Credentials[0].Credential, &credential) + credentialJSON, _ := json.Marshal(*credentialResponse.Credential) + err = json.Unmarshal(credentialJSON, &credential) if err != nil { return nil, fmt.Errorf("unable to unmarshal received credential: %w", err) } return &credential, nil } -// doCredentialRequest performs the HTTP request to the credential endpoint. -// It returns structured OpenID4VCI errors when the server returns an error response, -// allowing callers to detect specific error codes like invalid_nonce. -func doCredentialRequest(httpClient core.HTTPRequestDoer, httpRequest *http.Request) (*CredentialResponse, error) { - if HttpClientTrace != nil { - httpRequest = httpRequest.WithContext(httptrace.WithClientTrace(httpRequest.Context(), HttpClientTrace)) - } - httpResponse, err := httpClient.Do(httpRequest) - if err != nil { - return nil, fmt.Errorf("credential request http error: %w", err) - } - defer httpResponse.Body.Close() - responseBody, err := io.ReadAll(httpResponse.Body) - if err != nil { - return nil, fmt.Errorf("credential request read error: %w", err) - } - if httpResponse.StatusCode < 200 || httpResponse.StatusCode > 299 { - var oidcError Error - if json.Unmarshal(responseBody, &oidcError) == nil && oidcError.Code != "" { - oidcError.StatusCode = httpResponse.StatusCode - return nil, oidcError - } - return nil, fmt.Errorf("credential request failed (status %d)", httpResponse.StatusCode) - } - var credentialResponse CredentialResponse - if err := json.Unmarshal(responseBody, &credentialResponse); err != nil { - return nil, fmt.Errorf("credential response unmarshal error: %w", err) - } - return &credentialResponse, nil -} - -func (h defaultIssuerAPIClient) RequestNonce(ctx context.Context) (*NonceResponse, error) { - if h.metadata.NonceEndpoint == "" { - return nil, errors.New("issuer does not advertise a nonce endpoint") - } - var nonceResponse NonceResponse - httpRequest, _ := http.NewRequestWithContext(ctx, "POST", h.metadata.NonceEndpoint, http.NoBody) - err := httpDo(h.httpClient, httpRequest, &nonceResponse) - if err != nil { - return nil, fmt.Errorf("nonce request failed: %w", err) - } - if nonceResponse.CNonce == "" { - return nil, errors.New("nonce endpoint returned empty c_nonce") - } - return &nonceResponse, nil -} - func (h defaultIssuerAPIClient) Metadata() CredentialIssuerMetadata { return h.metadata } diff --git a/vcr/openid4vci/issuer_client_mock.go b/vcr/openid4vci/issuer_client_mock.go index e6d7f49c45..370f86f84e 100644 --- a/vcr/openid4vci/issuer_client_mock.go +++ b/vcr/openid4vci/issuer_client_mock.go @@ -22,6 +22,7 @@ import ( type MockIssuerAPIClient struct { ctrl *gomock.Controller recorder *MockIssuerAPIClientMockRecorder + isgomock struct{} } // MockIssuerAPIClientMockRecorder is the mock recorder for MockIssuerAPIClient. @@ -85,25 +86,11 @@ func (mr *MockIssuerAPIClientMockRecorder) RequestCredential(ctx, request, acces return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestCredential", reflect.TypeOf((*MockIssuerAPIClient)(nil).RequestCredential), ctx, request, accessToken) } -// RequestNonce mocks base method. -func (m *MockIssuerAPIClient) RequestNonce(ctx context.Context) (*NonceResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestNonce", ctx) - ret0, _ := ret[0].(*NonceResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// RequestNonce indicates an expected call of RequestNonce. -func (mr *MockIssuerAPIClientMockRecorder) RequestNonce(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNonce", reflect.TypeOf((*MockIssuerAPIClient)(nil).RequestNonce), ctx) -} - // MockOAuth2Client is a mock of OAuth2Client interface. type MockOAuth2Client struct { ctrl *gomock.Controller recorder *MockOAuth2ClientMockRecorder + isgomock struct{} } // MockOAuth2ClientMockRecorder is the mock recorder for MockOAuth2Client. diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index 8f4b07c7bc..72355f6d05 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -20,7 +20,7 @@ package openid4vci import ( "context" - "encoding/json" + "github.com/nuts-foundation/go-did/vc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "net/http" @@ -89,7 +89,8 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { ctx := context.Background() httpClient := &http.Client{} credentialRequest := CredentialRequest{ - CredentialConfigurationID: "NutsOrganizationCredential_ldp_vc", + CredentialDefinition: &CredentialDefinition{}, + Format: vc.JSONLDCredentialProofFormat, } t.Run("ok", func(t *testing.T) { setup := setupClientTest(t) @@ -109,14 +110,13 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { credential, err := client.RequestCredential(ctx, credentialRequest, "token") - require.EqualError(t, err, "credential response does not contain any credentials") + require.EqualError(t, err, "credential response does not contain a credential") require.Nil(t, credential) }) t.Run("error - invalid credentials in response", func(t *testing.T) { setup := setupClientTest(t) - invalidCredJSON, _ := json.Marshal(map[string]interface{}{"issuer": []string{"1", "2"}}) - setup.credentialHandler = setup.httpPostHandler(CredentialResponse{Credentials: []CredentialResponseEntry{ - {Credential: invalidCredJSON}, // Invalid issuer + setup.credentialHandler = setup.httpPostHandler(CredentialResponse{Credential: &map[string]interface{}{ + "issuer": []string{"1", "2"}, // Invalid issuer }}) client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) require.NoError(t, err) @@ -128,46 +128,6 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { }) } -func Test_httpIssuerClient_RequestNonce(t *testing.T) { - ctx := context.Background() - httpClient := &http.Client{} - t.Run("ok", func(t *testing.T) { - setup := setupClientTest(t) - client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) - require.NoError(t, err) - - nonceResponse, err := client.RequestNonce(ctx) - - require.NoError(t, err) - require.NotNil(t, nonceResponse) - assert.Equal(t, "test-nonce", nonceResponse.CNonce) - }) - t.Run("error - no nonce endpoint in metadata", func(t *testing.T) { - setup := setupClientTest(t) - setup.issuerMetadata.NonceEndpoint = "" - client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) - require.NoError(t, err) - - nonceResponse, err := client.RequestNonce(ctx) - - require.EqualError(t, err, "issuer does not advertise a nonce endpoint") - assert.Nil(t, nonceResponse) - }) - t.Run("error - nonce endpoint returns error", func(t *testing.T) { - setup := setupClientTest(t) - setup.nonceHandler = func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - } - client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) - require.NoError(t, err) - - nonceResponse, err := client.RequestNonce(ctx) - - require.ErrorContains(t, err, "nonce request failed") - assert.Nil(t, nonceResponse) - }) -} - func Test_httpOAuth2Client_RequestAccessToken(t *testing.T) { httpClient := &http.Client{} params := map[string]string{"some-param": "some-value"} diff --git a/vcr/openid4vci/test.go b/vcr/openid4vci/test.go index 128ee5151e..f105ff4674 100644 --- a/vcr/openid4vci/test.go +++ b/vcr/openid4vci/test.go @@ -22,6 +22,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/test" "net/http" @@ -34,16 +35,14 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { issuerMetadata := new(CredentialIssuerMetadata) providerMetadata := new(ProviderMetadata) walletMetadata := new(OAuth2ClientMetadata) - credentialJSON, _ := json.Marshal(map[string]interface{}{ - "@context": []string{"https://www.w3.org/2018/credentials/v1"}, - "type": []string{"VerifiableCredential"}, - "issuer": "issuer", - "issuanceDate": time.Now().Format(time.RFC3339), - "credentialSubject": map[string]interface{}{"id": "id"}, - }) credentialResponse := CredentialResponse{ - Credentials: []CredentialResponseEntry{ - {Credential: credentialJSON}, + Format: vc.JSONLDCredentialProofFormat, + Credential: &map[string]interface{}{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential"}, + "issuer": "issuer", + "issuanceDate": time.Now().Format(time.RFC3339), + "credentialSubject": map[string]interface{}{"id": "id"}, }, } clientTest := &oidcClientTestContext{ @@ -57,7 +56,6 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { clientTest.tokenHandler = clientTest.httpPostHandler(oauth.TokenResponse{AccessToken: "secret"}) clientTest.walletMetadataHandler = clientTest.httpGetHandler(walletMetadata) clientTest.credentialOfferHandler = clientTest.httpGetHandler(CredentialOfferResponse{CredentialOfferStatusReceived}) - clientTest.nonceHandler = clientTest.httpPostHandler(NonceResponse{CNonce: "test-nonce"}) mux := http.NewServeMux() mux.HandleFunc("/issuer"+CredentialIssuerMetadataWellKnownPath, func(writer http.ResponseWriter, request *http.Request) { @@ -72,9 +70,6 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { mux.HandleFunc("/issuer/token", func(writer http.ResponseWriter, request *http.Request) { clientTest.tokenHandler(writer, request) }) - mux.HandleFunc("/issuer/nonce", func(writer http.ResponseWriter, request *http.Request) { - clientTest.nonceHandler(writer, request) - }) mux.HandleFunc("/wallet/metadata", func(writer http.ResponseWriter, request *http.Request) { clientTest.walletMetadataHandler(writer, request) }) @@ -88,7 +83,6 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { issuerIdentifier := serverURL + "/issuer" issuerMetadata.CredentialIssuer = issuerIdentifier issuerMetadata.CredentialEndpoint = issuerIdentifier + "/credential" - issuerMetadata.NonceEndpoint = issuerIdentifier + "/nonce" providerMetadata.Issuer = issuerIdentifier providerMetadata.TokenEndpoint = issuerIdentifier + "/token" return clientTest @@ -134,7 +128,6 @@ type oidcClientTestContext struct { credentialHandler http.HandlerFunc credentialOfferHandler http.HandlerFunc tokenHandler http.HandlerFunc - nonceHandler http.HandlerFunc walletMetadataHandler http.HandlerFunc requests []http.Request } diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index d2afe68753..e5d030b005 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -21,7 +21,6 @@ package openid4vci import ( - "encoding/json" ssi "github.com/nuts-foundation/go-did" "time" ) @@ -63,19 +62,8 @@ type CredentialIssuerMetadata struct { // CredentialEndpoint defines where the wallet can send a request to retrieve a credential. CredentialEndpoint string `json:"credential_endpoint"` - // NonceEndpoint defines the URL of the Nonce Endpoint where wallets can request a fresh c_nonce. - // Per v1.0 Section 7, a Credential Issuer that requires c_nonce values MUST offer a Nonce Endpoint. - NonceEndpoint string `json:"nonce_endpoint,omitempty"` - - // CredentialConfigurationsSupported defines metadata about which credential types the credential issuer can issue. - // The map is keyed by credential_configuration_id. - CredentialConfigurationsSupported map[string]map[string]interface{} `json:"credential_configurations_supported"` -} - -// NonceResponse defines the response from the Nonce Endpoint. -// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-nonce-endpoint -type NonceResponse struct { - CNonce string `json:"c_nonce"` + // CredentialsSupported defines metadata about which credential types the credential issuer can issue. + CredentialsSupported []map[string]interface{} `json:"credentials_supported"` } // OAuth2ClientMetadata defines the OAuth2 Client Metadata, extended with OpenID4VCI parameters. @@ -105,26 +93,15 @@ type ProviderMetadata struct { type CredentialOffer struct { // CredentialIssuer defines the identifier of the credential issuer. CredentialIssuer string `json:"credential_issuer"` - // CredentialConfigurationIDs defines references to credential configurations offered by the issuer. - // These IDs reference entries in the credential_configurations_supported metadata. - CredentialConfigurationIDs []string `json:"credential_configuration_ids"` + // Credentials defines the credentials offered by the issuer to the wallet. + Credentials []OfferedCredential `json:"credentials"` // Grants defines the grants offered by the issuer to the wallet. - Grants *CredentialOfferGrants `json:"grants,omitempty"` + Grants map[string]interface{} `json:"grants"` } -// CredentialOfferGrants defines the grant types in a credential offer. +// OfferedCredential defines a single entry in the credentials array of a CredentialOffer. We currently do not support 'JSON string' offers. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer-parameters -type CredentialOfferGrants struct { - PreAuthorizedCode *PreAuthorizedCodeParams `json:"urn:ietf:params:oauth:grant-type:pre-authorized_code,omitempty"` -} - -// PreAuthorizedCodeParams defines the parameters for the pre-authorized code grant. -type PreAuthorizedCodeParams struct { - PreAuthorizedCode string `json:"pre-authorized_code"` -} - -// OfferedCredential represents a resolved credential configuration from issuer metadata. -// It is used internally by the holder to validate offered credentials after resolving a credential_configuration_id. +// and https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-vc-secured-using-data-integ type OfferedCredential struct { // Format specifies the credential format. Format string `json:"format"` @@ -133,11 +110,11 @@ type OfferedCredential struct { } // CredentialDefinition defines the 'credential_definition' for Format VerifiableCredentialJSONLDFormat -// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html Appendix A.1.2 +// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-vc-secured-using-data-integ type CredentialDefinition struct { - Context []ssi.URI `json:"@context"` - Type []ssi.URI `json:"type"` - CredentialSubject map[string]interface{} `json:"credentialSubject,omitempty"` // optional and currently not used + Context []ssi.URI `json:"@context"` + Type []ssi.URI `json:"type"` + CredentialSubject *map[string]interface{} `json:"credentialSubject,omitempty"` // optional and currently not used } // CredentialOfferResponse defines the response for credential offer requests. @@ -148,34 +125,26 @@ type CredentialOfferResponse struct { } // CredentialRequest defines the credential request sent by the wallet to the issuer. -// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request -// Per v1.0 Section 8.2, the request identifies the credential using credential_configuration_id. +// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request. type CredentialRequest struct { - // CredentialConfigurationID references a credential configuration from issuer metadata. - CredentialConfigurationID string `json:"credential_configuration_id,omitempty"` - // Proofs contains the proof(s) of possession of the key material. - Proofs *CredentialRequestProofs `json:"proofs,omitempty"` + Format string `json:"format"` + CredentialDefinition *CredentialDefinition `json:"credential_definition,omitempty"` + Proof *CredentialRequestProof `json:"proof,omitempty"` } -// CredentialRequestProofs defines the proof(s) of possession of key material when requesting a Credential. +// CredentialRequestProof defines the proof of possession of key material when requesting a Credential. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-proof-types -// The structure is: {"jwt": ["eyJ...", ...]} where the key is the proof type and the value is an array. -type CredentialRequestProofs struct { - Jwt []string `json:"jwt"` +type CredentialRequestProof struct { + Jwt string `json:"jwt"` + ProofType string `json:"proof_type"` } // CredentialResponse defines the response for credential requests. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response -// In v1.0, when proofs (plural) is used in the request, the response uses `credentials` (array of wrapper objects). -// Each element contains a `credential` key holding the actual issued credential. type CredentialResponse struct { - Credentials []CredentialResponseEntry `json:"credentials,omitempty"` -} - -// CredentialResponseEntry is a single entry in the credentials array of a CredentialResponse. -// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response -type CredentialResponseEntry struct { - Credential json.RawMessage `json:"credential"` + Format string `json:"format,omitempty"` + Credential *map[string]interface{} `json:"credential,omitempty"` + CNonce *string `json:"c_nonce,omitempty"` } // Config holds the config for the OpenID4VCI credential issuer and wallet diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go deleted file mode 100644 index aad0d70586..0000000000 --- a/vcr/openid4vci/types_test.go +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright (C) 2023 Nuts community - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package openid4vci - -import ( - "encoding/json" - "testing" - - ssi "github.com/nuts-foundation/go-did" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestCredentialRequest_V1Spec tests that CredentialRequest conforms to OpenID4VCI v1.0 Section 8.2 -func TestCredentialRequest_V1Spec(t *testing.T) { - t.Run("request with credential_configuration_id", func(t *testing.T) { - requestJSON := `{ - "credential_configuration_id": "NutsAuthorizationCredential_ldp_vc", - "proofs": { - "jwt": ["eyJ..."] - } - }` - - var request CredentialRequest - err := json.Unmarshal([]byte(requestJSON), &request) - require.NoError(t, err) - - assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", request.CredentialConfigurationID) - assert.NotNil(t, request.Proofs) - }) - - t.Run("marshaling only includes non-empty fields", func(t *testing.T) { - request := CredentialRequest{ - CredentialConfigurationID: "NutsAuthorizationCredential_ldp_vc", - Proofs: &CredentialRequestProofs{ - Jwt: []string{"eyJ..."}, - }, - } - - jsonBytes, err := json.Marshal(request) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal(jsonBytes, &parsed) - require.NoError(t, err) - - assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", parsed["credential_configuration_id"]) - }) -} - -// TestCredentialOffer_V1Spec tests that CredentialOffer conforms to OpenID4VCI v1.0 Section 4.1.1 -func TestCredentialOffer_V1Spec(t *testing.T) { - t.Run("v1.0 format with credential_configuration_ids", func(t *testing.T) { - // Per v1.0 Section 4.1.1 - offerJSON := `{ - "credential_issuer": "https://issuer.example.com", - "credential_configuration_ids": ["NutsAuthorizationCredential_ldp_vc"], - "grants": { - "urn:ietf:params:oauth:grant-type:pre-authorized_code": { - "pre-authorized_code": "secret123" - } - } - }` - - var offer CredentialOffer - err := json.Unmarshal([]byte(offerJSON), &offer) - require.NoError(t, err) - - assert.Equal(t, "https://issuer.example.com", offer.CredentialIssuer) - assert.Equal(t, []string{"NutsAuthorizationCredential_ldp_vc"}, offer.CredentialConfigurationIDs) - require.NotNil(t, offer.Grants.PreAuthorizedCode) - assert.Equal(t, "secret123", offer.Grants.PreAuthorizedCode.PreAuthorizedCode) - }) - - t.Run("marshaling preserves v1.0 format", func(t *testing.T) { - offer := CredentialOffer{ - CredentialIssuer: "https://issuer.example.com", - CredentialConfigurationIDs: []string{"NutsAuthorizationCredential_ldp_vc"}, - Grants: &CredentialOfferGrants{ - PreAuthorizedCode: &PreAuthorizedCodeParams{ - PreAuthorizedCode: "secret123", - }, - }, - } - - jsonBytes, err := json.Marshal(offer) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal(jsonBytes, &parsed) - require.NoError(t, err) - - // Must use credential_configuration_ids (v1.0), NOT credentials (old format) - _, hasOldField := parsed["credentials"] - assert.False(t, hasOldField, "should not have old 'credentials' field") - - configIds, ok := parsed["credential_configuration_ids"].([]interface{}) - require.True(t, ok, "must have credential_configuration_ids array") - assert.Len(t, configIds, 1) - assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", configIds[0]) - - // Verify grants are serialized with the correct JSON key - grants, ok := parsed["grants"].(map[string]interface{}) - require.True(t, ok) - preAuth, ok := grants[PreAuthorizedCodeGrant].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "secret123", preAuth["pre-authorized_code"]) - }) -} - -// TestCredentialIssuerMetadata_V1Spec tests that metadata conforms to OpenID4VCI v1.0 Section 11.2.1 -func TestCredentialIssuerMetadata_V1Spec(t *testing.T) { - t.Run("v1.0 format with credential_configurations_supported map", func(t *testing.T) { - // Per v1.0 Section 11.2.1 - metadataJSON := `{ - "credential_issuer": "https://issuer.example.com", - "credential_endpoint": "https://issuer.example.com/credential", - "credential_configurations_supported": { - "NutsAuthorizationCredential_ldp_vc": { - "format": "ldp_vc", - "cryptographic_binding_methods_supported": ["did:nuts"], - "credential_definition": { - "@context": ["https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"], - "type": ["VerifiableCredential", "NutsAuthorizationCredential"] - } - } - } - }` - - var metadata CredentialIssuerMetadata - err := json.Unmarshal([]byte(metadataJSON), &metadata) - require.NoError(t, err) - - assert.Equal(t, "https://issuer.example.com", metadata.CredentialIssuer) - assert.Equal(t, "https://issuer.example.com/credential", metadata.CredentialEndpoint) - - // Must be a map keyed by credential_configuration_id - require.Len(t, metadata.CredentialConfigurationsSupported, 1) - config, ok := metadata.CredentialConfigurationsSupported["NutsAuthorizationCredential_ldp_vc"] - require.True(t, ok) - assert.Equal(t, "ldp_vc", config["format"]) - }) - - t.Run("marshaling preserves v1.0 format", func(t *testing.T) { - metadata := CredentialIssuerMetadata{ - CredentialIssuer: "https://issuer.example.com", - CredentialEndpoint: "https://issuer.example.com/credential", - CredentialConfigurationsSupported: map[string]map[string]interface{}{ - "NutsAuthorizationCredential_ldp_vc": { - "format": "ldp_vc", - }, - }, - } - - jsonBytes, err := json.Marshal(metadata) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal(jsonBytes, &parsed) - require.NoError(t, err) - - // Must use credential_configurations_supported (v1.0), NOT credentials_supported (old format) - _, hasOldField := parsed["credentials_supported"] - assert.False(t, hasOldField, "should not have old 'credentials_supported' field") - - configs, ok := parsed["credential_configurations_supported"].(map[string]interface{}) - require.True(t, ok, "must have credential_configurations_supported object") - assert.Contains(t, configs, "NutsAuthorizationCredential_ldp_vc") - }) -} - -// TestCredentialResponse_V1Spec tests that CredentialResponse conforms to OpenID4VCI v1.0 Section 8.3 -// v1.0 uses `credentials` (array of wrapper objects with `credential` key) and c_nonce is no longer in the response. -func TestCredentialResponse_V1Spec(t *testing.T) { - t.Run("response uses credentials array with credential wrapper", func(t *testing.T) { - credJSON, _ := json.Marshal(map[string]interface{}{"issuer": "did:nuts:issuer"}) - response := CredentialResponse{ - Credentials: []CredentialResponseEntry{{Credential: credJSON}}, - } - - jsonBytes, err := json.Marshal(response) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal(jsonBytes, &parsed) - require.NoError(t, err) - - // Must use credentials (plural), not credential (singular) at top level - _, hasSingular := parsed["credential"] - assert.False(t, hasSingular, "must use credentials (plural) not credential (singular) at top level") - - // Each element in credentials must be a wrapper with a "credential" key - credentialsArr, ok := parsed["credentials"].([]interface{}) - require.True(t, ok, "credentials must be an array") - require.Len(t, credentialsArr, 1) - entry, ok := credentialsArr[0].(map[string]interface{}) - require.True(t, ok, "each credentials entry must be an object") - assert.NotNil(t, entry["credential"], "each entry must have a credential key") - }) - - t.Run("response does not contain c_nonce fields", func(t *testing.T) { - credJSON, _ := json.Marshal(map[string]interface{}{"issuer": "did:nuts:issuer"}) - response := CredentialResponse{ - Credentials: []CredentialResponseEntry{{Credential: credJSON}}, - } - - jsonBytes, err := json.Marshal(response) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal(jsonBytes, &parsed) - require.NoError(t, err) - - _, hasExpiresIn := parsed["c_nonce_expires_in"] - assert.False(t, hasExpiresIn, "c_nonce_expires_in must be absent when not set") - }) -} - -// TestCredentialDefinition_Validation tests credential definition validation -func TestCredentialDefinition_Validation(t *testing.T) { - t.Run("valid definition", func(t *testing.T) { - def := &CredentialDefinition{ - Context: []ssi.URI{ - ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - ssi.MustParseURI("https://nuts.nl/credentials/v1"), - }, - Type: []ssi.URI{ - ssi.MustParseURI("VerifiableCredential"), - ssi.MustParseURI("NutsAuthorizationCredential"), - }, - } - - err := def.Validate(true) - assert.NoError(t, err) - }) - - t.Run("credentialSubject not allowed in offer", func(t *testing.T) { - subject := map[string]interface{}{"id": "did:example:123"} - def := &CredentialDefinition{ - Context: []ssi.URI{ - ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - }, - Type: []ssi.URI{ - ssi.MustParseURI("VerifiableCredential"), - }, - CredentialSubject: subject, - } - - err := def.Validate(true) - assert.Error(t, err) - assert.Contains(t, err.Error(), "credentialSubject not allowed") - }) -} diff --git a/vcr/openid4vci/validators.go b/vcr/openid4vci/validators.go index 011122432b..b9f854fbb0 100644 --- a/vcr/openid4vci/validators.go +++ b/vcr/openid4vci/validators.go @@ -24,11 +24,8 @@ import ( "github.com/nuts-foundation/go-did/vc" ) -// Validate the CredentialDefinition according to the VerifiableCredentialJSONLDFormat format. -// When rejectCredentialSubject is true, the presence of credentialSubject causes a validation error. -// This should be set to true when validating credential offers (Section 4.1.1) where credentialSubject is not allowed, -// and false when validating metadata (Appendix A.1.2) where it is permitted. -func (cd *CredentialDefinition) Validate(rejectCredentialSubject bool) error { +// Validate the CredentialDefinition according to the VerifiableCredentialJSONLDFormat format +func (cd *CredentialDefinition) Validate(isOffer bool) error { if cd == nil { return errors.New("invalid credential_definition: missing") } @@ -39,7 +36,7 @@ func (cd *CredentialDefinition) Validate(rejectCredentialSubject bool) error { return errors.New("invalid credential_definition: missing type field") } if cd.CredentialSubject != nil { - if rejectCredentialSubject { + if isOffer { return errors.New("invalid credential_definition: credentialSubject not allowed in offer") } // TODO: Add credentialSubject validation. @@ -52,7 +49,7 @@ func (cd *CredentialDefinition) Validate(rejectCredentialSubject bool) error { // CredentialDefinition is assumed to be valid, see ValidateCredentialDefinition. func ValidateDefinitionWithCredential(credential vc.VerifiableCredential, definition CredentialDefinition) error { // From spec: When the format value is ldp_vc, ..., including credential_definition object, MUST NOT be processed using JSON-LD rules. - // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-A.1.2 + // https://openid.bitbucket.io/connect/editors-draft/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-2 // compare contexts. The credential may contain extra contexts for signatures or proofs if len(credential.Context) < len(definition.Context) || !isSubset(credential.Context, definition.Context) { diff --git a/vcr/openid4vci/validators_test.go b/vcr/openid4vci/validators_test.go index 569f642b1e..d5d3572bad 100644 --- a/vcr/openid4vci/validators_test.go +++ b/vcr/openid4vci/validators_test.go @@ -50,7 +50,7 @@ func Test_ValidateCredentialDefinition(t *testing.T) { definition := &CredentialDefinition{ Context: []ssi.URI{ssi.MustParseURI("http://example.com")}, Type: []ssi.URI{ssi.MustParseURI("SomeCredentialType")}, - CredentialSubject: map[string]any{}, + CredentialSubject: new(map[string]any), } err := definition.Validate(true) diff --git a/vcr/openid4vci/wallet_client_test.go b/vcr/openid4vci/wallet_client_test.go index e8c5a5fabd..5310eb4ec0 100644 --- a/vcr/openid4vci/wallet_client_test.go +++ b/vcr/openid4vci/wallet_client_test.go @@ -66,12 +66,10 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { require.NoError(t, err) err = client.OfferCredential(ctx, CredentialOffer{ - CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - CredentialConfigurationIDs: []string{}, - Grants: &CredentialOfferGrants{ - PreAuthorizedCode: &PreAuthorizedCodeParams{ - PreAuthorizedCode: "test-code", - }, + CredentialIssuer: setup.issuerMetadata.CredentialIssuer, + Credentials: []OfferedCredential{}, + Grants: map[string]interface{}{ + "grant_type": "pre-authorized_code", }, }) @@ -86,10 +84,8 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { err = json.Unmarshal([]byte(credentialOfferJSON), &credentialOffer) require.NoError(t, err) require.Equal(t, setup.issuerMetadata.CredentialIssuer, credentialOffer["credential_issuer"]) - require.Equal(t, []interface{}{}, credentialOffer["credential_configuration_ids"]) - grants := credentialOffer["grants"].(map[string]interface{}) - preAuthGrant := grants[PreAuthorizedCodeGrant].(map[string]interface{}) - require.Equal(t, "test-code", preAuthGrant["pre-authorized_code"]) + require.Equal(t, []interface{}{}, credentialOffer["credentials"]) + require.Equal(t, map[string]interface{}{"grant_type": "pre-authorized_code"}, credentialOffer["grants"]) }) t.Run("error - invalid response from wallet", func(t *testing.T) { setup := setupClientTest(t) @@ -98,12 +94,10 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { require.NoError(t, err) err = client.OfferCredential(ctx, CredentialOffer{ - CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - CredentialConfigurationIDs: []string{}, - Grants: &CredentialOfferGrants{ - PreAuthorizedCode: &PreAuthorizedCodeParams{ - PreAuthorizedCode: "test-code", - }, + CredentialIssuer: setup.issuerMetadata.CredentialIssuer, + Credentials: []OfferedCredential{}, + Grants: map[string]interface{}{ + "grant_type": "pre-authorized_code", }, }) @@ -118,12 +112,10 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { require.NoError(t, err) err = client.OfferCredential(ctx, CredentialOffer{ - CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - CredentialConfigurationIDs: []string{}, - Grants: &CredentialOfferGrants{ - PreAuthorizedCode: &PreAuthorizedCodeParams{ - PreAuthorizedCode: "test-code", - }, + CredentialIssuer: setup.issuerMetadata.CredentialIssuer, + Credentials: []OfferedCredential{}, + Grants: map[string]interface{}{ + "grant_type": "pre-authorized_code", }, }) diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index 605e71efe4..3e90ed0761 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -21,6 +21,13 @@ package test import ( "bytes" "encoding/json" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vcr/issuer" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/stretchr/testify/assert" "io" "net/http" "net/url" @@ -31,16 +38,10 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" - "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/test" "github.com/nuts-foundation/nuts-node/test/node" "github.com/nuts-foundation/nuts-node/vcr" credentialTypes "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vcr/issuer" - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "github.com/nuts-foundation/nuts-node/vdr/resolver" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -125,7 +126,7 @@ func TestOpenID4VCIErrorResponses(t *testing.T) { require.NoError(t, err) requestBody, _ := json.Marshal(openid4vci.CredentialRequest{ - CredentialConfigurationID: "NutsOrganizationCredential_ldp_vc", + Format: vc.JSONLDCredentialProofFormat, }) t.Run("error from API layer (missing access token)", func(t *testing.T) { @@ -142,7 +143,7 @@ func TestOpenID4VCIErrorResponses(t *testing.T) { t.Run("error from service layer (unknown access token)", func(t *testing.T) { httpRequest, _ := http.NewRequest("POST", issuer.Metadata().CredentialEndpoint, bytes.NewReader(requestBody)) httpRequest.Header.Set("Content-Type", "application/json") - httpRequest.Header.Set("Authorization", "Bearer not-a-valid-token") + httpRequest.Header.Set("Authentication", "Bearer not-a-valid-token") httpResponse, err := http.DefaultClient.Do(httpRequest) @@ -157,11 +158,10 @@ func testCredential() vc.VerifiableCredential { issuanceDate := time.Now().Truncate(time.Second) return vc.VerifiableCredential{ Context: []ssi.URI{ - vc.VCContextV1URI(), + jsonld.JWS2020ContextV1URI(), credentialTypes.NutsV1ContextURI, }, Type: []ssi.URI{ - vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI("NutsAuthorizationCredential"), }, IssuanceDate: issuanceDate, From cabaa7863f7e749ae11fce3dea44fd0607947934 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Thu, 7 May 2026 20:04:26 +0200 Subject: [PATCH 21/44] feat(auth/openid4vci): add Client with metadata, nonce, and credential request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone OpenID4VCI 1.0 HTTP client owning the three wire interactions the user/browser flow makes against a Credential Issuer: - OpenIDCredentialIssuerMetadata fetches the metadata document at the RFC 8615 well-known URL. - RequestNonce retrieves a fresh c_nonce from the Nonce Endpoint (§7.2). - RequestCredential posts a Credential Request (§8.2) and parses the response, returning a structured Error on a recognized OpenID4VCI error body or a generic error otherwise. RequestCredential takes a struct parameter (RequestCredentialOpts) so future spec fields like CredentialIdentifier or response encryption are non-breaking additions. The client only depends on stdlib net/http; key proofs are built upstream in auth/api/iam where signing keys live. --- auth/openid4vci/client.go | 180 +++++++++++++++++++++++++++ auth/openid4vci/client_test.go | 217 +++++++++++++++++++++++++++++++++ auth/openid4vci/mock.go | 86 +++++++++++++ makefile | 1 + 4 files changed, 484 insertions(+) create mode 100644 auth/openid4vci/client.go create mode 100644 auth/openid4vci/client_test.go create mode 100644 auth/openid4vci/mock.go diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go new file mode 100644 index 0000000000..20ad2c17fe --- /dev/null +++ b/auth/openid4vci/client.go @@ -0,0 +1,180 @@ +/* + * Nuts node + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package openid4vci + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// wellKnownPath is the path segment defined in OpenID4VCI 1.0 §12.2 for the +// Credential Issuer Metadata document. +const wellKnownPath = "/.well-known/openid-credential-issuer" + +// RequestCredentialOpts carries all parameters for a Credential Request. +// Using a struct means future spec fields (CredentialIdentifier, +// CredentialResponseEncryption) are non-breaking additions. +type RequestCredentialOpts struct { + CredentialEndpoint string + AccessToken string + CredentialConfigurationID string + ProofJWT string +} + +// Client is the OpenID4VCI 1.0 HTTP client interface. +// It covers the three wire interactions a wallet makes against a Credential +// Issuer: fetching issuer metadata, obtaining a fresh nonce, and requesting +// a credential. +type Client interface { + // OpenIDCredentialIssuerMetadata fetches and parses the Credential Issuer + // Metadata document. The well-known URL is constructed from issuerURL per + // RFC 8615 (well-known segment inserted at the authority root, with the + // issuer path appended after). + OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL string) (*OpenIDCredentialIssuerMetadata, error) + + // RequestNonce retrieves a fresh c_nonce from the Nonce Endpoint (§7.2). + RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) + + // RequestCredential posts a Credential Request (§8.2) and returns the + // Credential Response (§8.3). On non-2xx the method returns a structured + // Error when the body is a valid OpenID4VCI error object; otherwise a + // generic error. + RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) +} + +// NewClient returns a Client backed by the provided *http.Client. +func NewClient(httpClient *http.Client) Client { + return &client{httpClient: httpClient} +} + +type client struct { + httpClient *http.Client +} + +func (c *client) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL string) (*OpenIDCredentialIssuerMetadata, error) { + wellKnownURL, err := credentialIssuerWellKnown(issuerURL) + if err != nil { + return nil, fmt.Errorf("openid4vci: invalid issuer URL: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, http.NoBody) + if err != nil { + return nil, err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("openid4vci: fetching issuer metadata returned status %d", resp.StatusCode) + } + var metadata OpenIDCredentialIssuerMetadata + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { + return nil, fmt.Errorf("openid4vci: decoding issuer metadata: %w", err) + } + return &metadata, nil +} + +func (c *client) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, nonceEndpoint, http.NoBody) + if err != nil { + return "", err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return "", fmt.Errorf("openid4vci: nonce endpoint returned status %d", resp.StatusCode) + } + var nonceResp NonceResponse + if err := json.NewDecoder(resp.Body).Decode(&nonceResp); err != nil { + return "", fmt.Errorf("openid4vci: decoding nonce response: %w", err) + } + if nonceResp.CNonce == "" { + return "", fmt.Errorf("openid4vci: nonce endpoint returned empty c_nonce") + } + return nonceResp.CNonce, nil +} + +func (c *client) RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) { + body := CredentialRequest{ + CredentialConfigurationID: opts.CredentialConfigurationID, + Proofs: &CredentialRequestProofs{ + JWT: []string{opts.ProofJWT}, + }, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, opts.CredentialEndpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+opts.AccessToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Buffer the body once so the non-2xx path can attempt structured-error + // parsing before falling back to a generic error. + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + var oidcErr Error + if jsonErr := json.Unmarshal(respBody, &oidcErr); jsonErr == nil && oidcErr.Code != "" { + oidcErr.StatusCode = resp.StatusCode + return nil, oidcErr + } + return nil, fmt.Errorf("openid4vci: credential endpoint returned status %d", resp.StatusCode) + } + var credResp CredentialResponse + if err := json.Unmarshal(respBody, &credResp); err != nil { + return nil, fmt.Errorf("openid4vci: decoding credential response: %w", err) + } + return &credResp, nil +} + +// credentialIssuerWellKnown returns the Credential Issuer Metadata URL for +// the given issuer identifier per RFC 8615: the well-known segment is +// inserted at the authority root, and the issuer's path is appended after. +// +// Example: https://example.com/oauth2/alice +// -> https://example.com/.well-known/openid-credential-issuer/oauth2/alice +func credentialIssuerWellKnown(issuerURL string) (string, error) { + u, err := url.Parse(issuerURL) + if err != nil { + return "", err + } + u.Path = wellKnownPath + u.EscapedPath() + return u.String(), nil +} diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go new file mode 100644 index 0000000000..504fb14276 --- /dev/null +++ b/auth/openid4vci/client_test.go @@ -0,0 +1,217 @@ +/* + * Nuts node + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package openid4vci + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ---- RequestNonce ---- + +func TestClient_RequestNonce(t *testing.T) { + t.Run("returns c_nonce from response", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(NonceResponse{CNonce: "test-nonce-123"}) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + nonce, err := client.RequestNonce(context.Background(), srv.URL) + require.NoError(t, err) + assert.Equal(t, "test-nonce-123", nonce) + }) + + t.Run("error on non-2xx", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + _, err := client.RequestNonce(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") + }) + + t.Run("error on empty c_nonce", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(NonceResponse{CNonce: ""}) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + _, err := client.RequestNonce(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty c_nonce") + }) +} + +// ---- OpenIDCredentialIssuerMetadata ---- + +func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { + t.Run("fetches and parses metadata from well-known path", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/.well-known/openid-credential-issuer", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + NonceEndpoint: "https://issuer.example.com/nonce", + }) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + metadata, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, "https://issuer.example.com", metadata.CredentialIssuer) + assert.Equal(t, "https://issuer.example.com/credential", metadata.CredentialEndpoint) + assert.Equal(t, "https://issuer.example.com/nonce", metadata.NonceEndpoint) + }) + + t.Run("appends issuer path after well-known segment per RFC 8615", func(t *testing.T) { + var capturedPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{CredentialIssuer: "x"}) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL+"/oauth2/alice") + require.NoError(t, err) + assert.Equal(t, "/.well-known/openid-credential-issuer/oauth2/alice", capturedPath) + }) + + t.Run("error on non-2xx", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "404") + }) + + t.Run("error on bad JSON body", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{not valid json")) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "decoding issuer metadata") + }) +} + +// ---- RequestCredential ---- + +func TestClient_RequestCredential(t *testing.T) { + t.Run("posts request and parses response", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + var credReq CredentialRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&credReq)) + assert.Equal(t, "SomeCredentialConfig", credReq.CredentialConfigurationID) + require.NotNil(t, credReq.Proofs) + assert.Equal(t, []string{"proof-jwt-value"}, credReq.Proofs.JWT) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(CredentialResponse{ + Credentials: []CredentialResponseEntry{ + {Credential: json.RawMessage(`"eyJhbGciOiJFUzI1NiJ9"`)}, + }, + }) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + resp, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ + CredentialEndpoint: srv.URL, + AccessToken: "test-token", + CredentialConfigurationID: "SomeCredentialConfig", + ProofJWT: "proof-jwt-value", + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Credentials, 1) + assert.JSONEq(t, `"eyJhbGciOiJFUzI1NiJ9"`, string(resp.Credentials[0].Credential)) + }) + + t.Run("returns structured Error on invalid_nonce", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(Error{Code: InvalidNonce}) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ + CredentialEndpoint: srv.URL, + AccessToken: "test-token", + }) + require.Error(t, err) + + var oidcErr Error + require.True(t, errors.As(err, &oidcErr)) + assert.Equal(t, InvalidNonce, oidcErr.Code) + assert.Equal(t, http.StatusBadRequest, oidcErr.StatusCode) + }) + + t.Run("returns generic error on non-2xx with no structured body", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "something went wrong", http.StatusServiceUnavailable) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ + CredentialEndpoint: srv.URL, + AccessToken: "test-token", + }) + require.Error(t, err) + + var oidcErr Error + assert.False(t, errors.As(err, &oidcErr)) + assert.Contains(t, err.Error(), "503") + }) +} diff --git a/auth/openid4vci/mock.go b/auth/openid4vci/mock.go new file mode 100644 index 0000000000..85369a8fee --- /dev/null +++ b/auth/openid4vci/mock.go @@ -0,0 +1,86 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: auth/openid4vci/client.go +// +// Generated by this command: +// +// mockgen -destination=auth/openid4vci/mock.go -package=openid4vci -source=auth/openid4vci/client.go +// + +// Package openid4vci is a generated GoMock package. +package openid4vci + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// OpenIDCredentialIssuerMetadata mocks base method. +func (m *MockClient) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL string) (*OpenIDCredentialIssuerMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OpenIDCredentialIssuerMetadata", ctx, issuerURL) + ret0, _ := ret[0].(*OpenIDCredentialIssuerMetadata) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// OpenIDCredentialIssuerMetadata indicates an expected call of OpenIDCredentialIssuerMetadata. +func (mr *MockClientMockRecorder) OpenIDCredentialIssuerMetadata(ctx, issuerURL any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenIDCredentialIssuerMetadata", reflect.TypeOf((*MockClient)(nil).OpenIDCredentialIssuerMetadata), ctx, issuerURL) +} + +// RequestCredential mocks base method. +func (m *MockClient) RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestCredential", ctx, opts) + ret0, _ := ret[0].(*CredentialResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestCredential indicates an expected call of RequestCredential. +func (mr *MockClientMockRecorder) RequestCredential(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestCredential", reflect.TypeOf((*MockClient)(nil).RequestCredential), ctx, opts) +} + +// RequestNonce mocks base method. +func (m *MockClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestNonce", ctx, nonceEndpoint) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestNonce indicates an expected call of RequestNonce. +func (mr *MockClientMockRecorder) RequestNonce(ctx, nonceEndpoint any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNonce", reflect.TypeOf((*MockClient)(nil).RequestNonce), ctx, nonceEndpoint) +} diff --git a/makefile b/makefile index fd9a0cfcac..bec1275f5a 100644 --- a/makefile +++ b/makefile @@ -14,6 +14,7 @@ gen-mocks: mockgen -destination=auth/api/iam/jar_mock.go -package=iam -source=auth/api/iam/jar.go mockgen -destination=auth/contract/signer_mock.go -package=contract -source=auth/contract/signer.go mockgen -destination=auth/client/iam/mock.go -package=iam -source=auth/client/iam/interface.go + mockgen -destination=auth/openid4vci/mock.go -package=openid4vci -source=auth/openid4vci/client.go mockgen -destination=auth/services/mock.go -package=services -source=auth/services/services.go mockgen -destination=auth/services/oauth/mock.go -package=oauth -source=auth/services/oauth/interface.go mockgen -destination=auth/services/selfsigned/types/mock.go -package=types -source=auth/services/selfsigned/types/types.go From 458bf8064468299bf95a2b1838e6edeb4a45ab78 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Thu, 7 May 2026 20:30:51 +0200 Subject: [PATCH 22/44] feat(auth): expose OpenID4VCIClient() accessor on auth.AuthenticationServices Parallel to IAMClient(). The OpenID4VCI client is constructed once in Configure() with the same caching HTTP transport and timeout as the IAM plumbing, and shared by reference. Consumers in auth/api/iam will switch to this accessor in the next commit. --- auth/api/auth/v1/api_test.go | 5 +++++ auth/auth.go | 13 +++++++++++++ auth/interface.go | 3 +++ auth/mock.go | 15 +++++++++++++++ 4 files changed, 36 insertions(+) diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index 3310ad2ba9..c4c309869c 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -28,6 +28,7 @@ import ( pkg2 "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/contract" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" oauth2 "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/dummy" @@ -86,6 +87,10 @@ func (m *mockAuthClient) IAMClient() iam.Client { return m.iamClient } +func (m *mockAuthClient) OpenID4VCIClient() openid4vci.Client { + return nil +} + func (m *mockAuthClient) ContractNotary() services.ContractNotary { return m.contractNotary } diff --git a/auth/auth.go b/auth/auth.go index f135335c01..dfaf5c3cff 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -22,6 +22,7 @@ import ( "crypto/tls" "errors" "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/didjwk" "github.com/nuts-foundation/nuts-node/vdr/didkey" @@ -30,6 +31,7 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/didx509" "github.com/nuts-foundation/nuts-node/vdr/resolver" + "net/http" "net/url" "path" "slices" @@ -41,6 +43,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/didman" + httpclient "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/pki" "github.com/nuts-foundation/nuts-node/vcr" @@ -68,6 +71,7 @@ type Auth struct { httpClientTimeout time.Duration tlsConfig *tls.Config subjectManager didsubject.Manager + openID4VCIClient openid4vci.Client // configuredDIDMethods contains the DID methods that are configured in the Nuts node, // of which VDR will create DIDs. configuredDIDMethods []string @@ -129,6 +133,11 @@ func (auth *Auth) IAMClient() iam.Client { return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.strictMode, auth.httpClientTimeout) } +// OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client. +func (auth *Auth) OpenID4VCIClient() openid4vci.Client { + return auth.openID4VCIClient +} + // Configure the Auth struct by creating a validator and create an Irma server func (auth *Auth) Configure(config core.ServerConfig) error { if auth.config.Irma.SchemeManager == "" { @@ -173,6 +182,10 @@ func (auth *Auth) Configure(config core.ServerConfig) error { // auth.http.config got deprecated in favor of httpclient.timeout auth.httpClientTimeout = config.HTTPClient.Timeout } + auth.openID4VCIClient = openid4vci.NewClient(&http.Client{ + Transport: httpclient.DefaultCachingTransport, + Timeout: auth.httpClientTimeout, + }) // V1 API related stuff accessTokenLifeSpan := time.Duration(auth.config.AccessTokenLifeSpan) * time.Second auth.authzServer = oauth.NewAuthorizationServer(auth.vdrInstance.Resolver(), auth.vcr, auth.vcr.Verifier(), auth.serviceResolver, diff --git a/auth/interface.go b/auth/interface.go index 6a0cd7eecb..894911652a 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -20,6 +20,7 @@ package auth import ( "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/oauth" "net/url" @@ -34,6 +35,8 @@ type AuthenticationServices interface { AuthzServer() oauth.AuthorizationServer // IAMClient returns the IAM Client API IAMClient() iam.Client + // OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client. + OpenID4VCIClient() openid4vci.Client // RelyingParty returns the oauth.RelyingParty RelyingParty() oauth.RelyingParty // ContractNotary returns an instance of ContractNotary diff --git a/auth/mock.go b/auth/mock.go index e92db3f34a..a04365018a 100644 --- a/auth/mock.go +++ b/auth/mock.go @@ -14,6 +14,7 @@ import ( reflect "reflect" iam "github.com/nuts-foundation/nuts-node/auth/client/iam" + openid4vci "github.com/nuts-foundation/nuts-node/auth/openid4vci" services "github.com/nuts-foundation/nuts-node/auth/services" oauth "github.com/nuts-foundation/nuts-node/auth/services/oauth" gomock "go.uber.org/mock/gomock" @@ -99,6 +100,20 @@ func (mr *MockAuthenticationServicesMockRecorder) IAMClient() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IAMClient", reflect.TypeOf((*MockAuthenticationServices)(nil).IAMClient)) } +// OpenID4VCIClient mocks base method. +func (m *MockAuthenticationServices) OpenID4VCIClient() openid4vci.Client { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OpenID4VCIClient") + ret0, _ := ret[0].(openid4vci.Client) + return ret0 +} + +// OpenID4VCIClient indicates an expected call of OpenID4VCIClient. +func (mr *MockAuthenticationServicesMockRecorder) OpenID4VCIClient() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenID4VCIClient", reflect.TypeOf((*MockAuthenticationServices)(nil).OpenID4VCIClient)) +} + // PublicURL mocks base method. func (m *MockAuthenticationServices) PublicURL() *url.URL { m.ctrl.T.Helper() From 83af375c2b5892d7740e2b84082bbd9e44889f43 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Thu, 7 May 2026 20:39:46 +0200 Subject: [PATCH 23/44] refactor(auth/iam): use auth.OpenID4VCIClient() for OpenID4VCI calls Switch the three OpenID4VCI call sites in the user/browser flow handler and the openid4vciMetadata helper from r.auth.IAMClient() to the new r.auth.OpenID4VCIClient() accessor. RequestCredential takes the new RequestCredentialOpts struct; the metadata method is renamed to OpenIDCredentialIssuerMetadata. --- auth/api/iam/api.go | 2 +- auth/api/iam/api_test.go | 5 +++ auth/api/iam/openid4vci.go | 11 ++++-- auth/api/iam/openid4vci_test.go | 66 ++++++++++++++++----------------- 4 files changed, 47 insertions(+), 37 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 6f6cf81c7a..6ef8405d44 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -869,7 +869,7 @@ func (r Wrapper) StatusList(ctx context.Context, request StatusListRequestObject } func (r Wrapper) openid4vciMetadata(ctx context.Context, issuer string) (*openid4vci.OpenIDCredentialIssuerMetadata, *oauth.AuthorizationServerMetadata, error) { - credentialIssuerMetadata, err := r.auth.IAMClient().OpenIdCredentialIssuerMetadata(ctx, issuer) + credentialIssuerMetadata, err := r.auth.OpenID4VCIClient().OpenIDCredentialIssuerMetadata(ctx, issuer) if err != nil { return nil, nil, err } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 5be4de20e1..57fe97570b 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -43,6 +43,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" oauthServices "github.com/nuts-foundation/nuts-node/auth/services/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/core/to" @@ -1588,6 +1589,7 @@ type testCtx struct { wallet *holder.MockWallet subjectManager *didsubject.MockManager jar *MockJAR + openid4vciClient *openid4vci.MockClient } func newTestClient(t testing.TB) *testCtx { @@ -1605,6 +1607,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b vcIssuer := issuer.NewMockIssuer(ctrl) vcVerifier := verifier.NewMockVerifier(ctrl) iamClient := iam.NewMockClient(ctrl) + openid4vciClient := openid4vci.NewMockClient(ctrl) mockDocumentOwner := didsubject.NewMockDocumentOwner(ctrl) subjectManager := didsubject.NewMockManager(ctrl) mockVCR := vcr.NewMockVCR(ctrl) @@ -1620,6 +1623,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b mockVCR.EXPECT().Verifier().Return(vcVerifier).AnyTimes() mockVCR.EXPECT().Wallet().Return(mockWallet).AnyTimes() authnServices.EXPECT().IAMClient().Return(iamClient).AnyTimes() + authnServices.EXPECT().OpenID4VCIClient().Return(openid4vciClient).AnyTimes() authnServices.EXPECT().AuthorizationEndpointEnabled().Return(authEndpointEnabled).AnyTimes() subjectManager.EXPECT().ListDIDs(gomock.Any(), holderSubjectID).Return([]did.DID{holderDID}, nil).AnyTimes() @@ -1657,5 +1661,6 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b jwtSigner: jwtSigner, jar: mockJAR, client: client, + openid4vciClient: openid4vciClient, } } diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index a3f938d5df..ce52eafeaf 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -151,7 +151,7 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode // fetch nonce from the Nonce Endpoint (v1.0 Section 7) var nonce string if oauthSession.IssuerNonceEndpoint != "" { - nonce, err = r.auth.IAMClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint) + nonce, err = r.auth.OpenID4VCIClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI) } @@ -163,7 +163,7 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode // on invalid_nonce: fetch a fresh nonce and retry once var oidcErr openid4vci.Error if errors.As(err, &oidcErr) && oidcErr.Code == openid4vci.InvalidNonce && oauthSession.IssuerNonceEndpoint != "" { - nonce, err = r.auth.IAMClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint) + nonce, err = r.auth.OpenID4VCIClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce for retry from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI) } @@ -200,7 +200,12 @@ func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *O if err != nil { return nil, fmt.Errorf("error building proof: %w", err) } - return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, oauthSession.IssuerCredentialConfigurationID, proofJWT) + return r.auth.OpenID4VCIClient().RequestCredential(ctx, openid4vci.RequestCredentialOpts{ + CredentialEndpoint: oauthSession.IssuerCredentialEndpoint, + AccessToken: accessToken, + CredentialConfigurationID: oauthSession.IssuerCredentialConfigurationID, + ProofJWT: proofJWT, + }) } func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) { diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index eb4e691e29..0e56d4fc94 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -53,7 +53,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { } t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, @@ -87,7 +87,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { AuthorizationServers: []string{}, // empty Display: nil, } - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerClientID).Return(nil, assert.AnError) _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) @@ -95,7 +95,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { t.Run("error - none of the authorization servers can be reached", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerClientID).Return(nil, assert.AnError) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(nil, assert.AnError) @@ -104,7 +104,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { }) t.Run("error - fetching credential issuer metadata fails", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(nil, assert.AnError) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(nil, assert.AnError) _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) @@ -120,7 +120,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { }) t.Run("error - invalid authorization endpoint in metadata", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) invalidAuthzMetadata := oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: ":", TokenEndpoint: "https://auth.server/token", @@ -136,7 +136,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { ctx := newTestClient(t) metadata := metadata metadata.CredentialEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no credential_endpoint found") @@ -145,7 +145,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { ctx := newTestClient(t) authzMetadata := authzMetadata authzMetadata.AuthorizationEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no authorization_endpoint found") @@ -154,7 +154,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { ctx := newTestClient(t) authzMetadata := authzMetadata authzMetadata.TokenEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no token_endpoint found") @@ -216,7 +216,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx := newTestClient(t) require.NoError(t, ctx.client.oauthClientStateStore().Put(state, &session)) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { assert.Equal(t, map[string]interface{}{"typ": "openid4vci-proof+jwt", "kid": "kid"}, headers) @@ -229,7 +229,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Equal(t, expectedClaims, claims) return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(nil, *verifiableCredential) @@ -255,7 +255,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.False(t, hasNonce, "nonce should not be set when no nonce endpoint is configured") return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(nil, *verifiableCredential) @@ -270,15 +270,15 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) // first attempt fails with invalid_nonce ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-1"}).Return(nil, invalidNonceErr) // retry with fresh nonce - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(freshNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(freshNonce, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(&credentialResponse, nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-2"}).Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(nil, *verifiableCredential) @@ -292,14 +292,14 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-1"}).Return(nil, invalidNonceErr) // retry also fails - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("fresh-nonce", nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("fresh-nonce", nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(nil, errors.New("still failing")) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-2"}).Return(nil, errors.New("still failing")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -311,12 +311,12 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, invalidNonceErr) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(nil, invalidNonceErr) // retry nonce fetch fails - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint down")) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint down")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -326,7 +326,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - initial nonce request fails", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint unavailable")) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint unavailable")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -346,10 +346,10 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("fail_credential_response", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, errors.New("FAIL")) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(nil, errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -359,10 +359,10 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("err - invalid credential", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(`"super invalid"`)}}, }, nil) @@ -374,10 +374,10 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("fail_verify", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil).Return(errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -388,7 +388,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - key not found", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("", nil, resolver.ErrKeyNotFound) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -399,7 +399,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - signature failure", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed")) @@ -421,10 +421,10 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - empty credentials array", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{}, }, nil) From 74ddb0d4dbaecf4114a8e59cc68bcdd40f408d9b Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Thu, 7 May 2026 20:56:00 +0200 Subject: [PATCH 24/44] refactor(auth/client/iam): drop OpenID4VCI methods from OpenID4VPClient Methods are reachable via auth.OpenID4VCIClient() now (introduced in the previous commits). Keeping them on the OpenID4VP client conflated two protocols on the same type. --- auth/client/iam/client.go | 93 ------------------------ auth/client/iam/interface.go | 8 --- auth/client/iam/mock.go | 47 +------------ auth/client/iam/openid4vp.go | 23 ------ auth/client/iam/openid4vp_test.go | 113 ------------------------------ 5 files changed, 1 insertion(+), 283 deletions(-) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 9b8a254c00..3112ede9bf 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -19,7 +19,6 @@ package iam import ( - "bytes" "context" "encoding/json" "errors" @@ -38,7 +37,6 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" - "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -242,48 +240,6 @@ func (hb HTTPClient) PostAuthorizationResponse(ctx context.Context, vp vc.Verifi return hb.postFormExpectRedirect(ctx, data, verifierResponseURI) } -func (hb HTTPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodPost, nonceEndpoint, http.NoBody) - if err != nil { - return "", err - } - response, err := hb.httpClient.Do(request) - if err != nil { - return "", fmt.Errorf("nonce request failed: %w", err) - } - defer response.Body.Close() - data, err := io.ReadAll(response.Body) - if err != nil { - return "", fmt.Errorf("unable to read nonce response: %w", err) - } - if response.StatusCode < 200 || response.StatusCode > 299 { - return "", fmt.Errorf("nonce endpoint returned status %d", response.StatusCode) - } - var nonceResponse struct { - CNonce string `json:"c_nonce"` - } - if err = json.Unmarshal(data, &nonceResponse); err != nil { - return "", fmt.Errorf("unable to unmarshal nonce response: %w", err) - } - if nonceResponse.CNonce == "" { - return "", errors.New("nonce endpoint returned empty c_nonce") - } - return nonceResponse.CNonce, nil -} - -func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*openid4vci.OpenIDCredentialIssuerMetadata, error) { - metadataURL, err := oauth.IssuerIdToWellKnown(oauthIssuerURI, oauth.OpenIdCredIssuerWellKnown, hb.strictMode) - if err != nil { - return nil, err - } - var metadata openid4vci.OpenIDCredentialIssuerMetadata - err = hb.doGet(ctx, metadataURL.String(), &metadata) - if err != nil { - return nil, err - } - return &metadata, err -} - func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) (*oauth.OpenIDConfiguration, error) { metadataURL, err := oauth.IssuerIdToWellKnown(issuerURL, oauth.OpenIdConfigurationWellKnown, hb.strictMode) if err != nil { @@ -337,55 +293,6 @@ func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc { } } -func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJwt string) (*openid4vci.CredentialResponse, error) { - credentialEndpointURL, err := url.Parse(credentialEndpoint) - if err != nil { - return nil, err - } - - credentialRequest := openid4vci.CredentialRequest{ - CredentialConfigurationID: credentialConfigID, - Proofs: &openid4vci.CredentialRequestProofs{ - JWT: []string{proofJwt}, - }, - } - jsonBody, _ := json.Marshal(credentialRequest) - request, err := http.NewRequestWithContext(ctx, http.MethodPost, credentialEndpointURL.String(), bytes.NewBuffer(jsonBody)) - if err != nil { - return nil, err - } - request.Header.Add("Accept", "application/json") - request.Header.Add("Content-Type", "application/json") - request.Header.Add("Authorization", "Bearer "+accessToken) - - response, err := hb.httpClient.Do(request.WithContext(ctx)) - if err != nil { - return nil, fmt.Errorf("failed to call endpoint: %w", err) - } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - log.Logger().WithError(err).Warn("Trouble closing reader") - } - }(response.Body) - responseBody, err := io.ReadAll(response.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - if response.StatusCode < 200 || response.StatusCode > 299 { - var oidcError openid4vci.Error - if json.Unmarshal(responseBody, &oidcError) == nil && oidcError.Code != "" { - oidcError.StatusCode = response.StatusCode - return nil, oidcError - } - return nil, fmt.Errorf("credential request failed (status %d)", response.StatusCode) - } - var credentialResponse openid4vci.CredentialResponse - if err = json.Unmarshal(responseBody, &credentialResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - return &credentialResponse, nil -} func (hb HTTPClient) postFormExpectRedirect(ctx context.Context, form url.Values, redirectURL url.URL) (string, error) { request, err := http.NewRequestWithContext(ctx, http.MethodPost, redirectURL.String(), strings.NewReader(form.Encode())) if err != nil { diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index fd6b644128..b5ab97a03b 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -23,7 +23,6 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" - "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -51,15 +50,8 @@ type Client interface { RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) - // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. - // oauthIssuer is the URL of the issuer as specified by RFC 8414 (OAuth 2.0 Authorization Server Metadata). - OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*openid4vci.OpenIDCredentialIssuerMetadata, error) // OpenIDConfiguration returns the OpenID Configuration of the remote wallet. OpenIDConfiguration(ctx context.Context, issuer string) (*oauth.OpenIDConfiguration, error) - // RequestNonce requests a fresh c_nonce from the issuer's Nonce Endpoint (v1.0 Section 7). - RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) - // VerifiableCredentials requests Verifiable Credentials from the issuer at the given endpoint. - VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) // RequestObjectByGet retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a GET method as defined in RFC9101/OpenID4VP. // This method is used when there is no 'request_uri_method', or its value is 'get'. RequestObjectByGet(ctx context.Context, requestURI string) (string, error) diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 34bf74e8c2..6587d7f3af 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -15,7 +15,6 @@ import ( vc "github.com/nuts-foundation/go-did/vc" oauth "github.com/nuts-foundation/nuts-node/auth/oauth" - openid4vci "github.com/nuts-foundation/nuts-node/auth/openid4vci" pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -24,6 +23,7 @@ import ( type MockClient struct { ctrl *gomock.Controller recorder *MockClientMockRecorder + isgomock struct{} } // MockClientMockRecorder is the mock recorder for MockClient. @@ -103,21 +103,6 @@ func (mr *MockClientMockRecorder) OpenIDConfiguration(ctx, issuer any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenIDConfiguration", reflect.TypeOf((*MockClient)(nil).OpenIDConfiguration), ctx, issuer) } -// OpenIdCredentialIssuerMetadata mocks base method. -func (m *MockClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*openid4vci.OpenIDCredentialIssuerMetadata, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OpenIdCredentialIssuerMetadata", ctx, oauthIssuerURI) - ret0, _ := ret[0].(*openid4vci.OpenIDCredentialIssuerMetadata) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// OpenIdCredentialIssuerMetadata indicates an expected call of OpenIdCredentialIssuerMetadata. -func (mr *MockClientMockRecorder) OpenIdCredentialIssuerMetadata(ctx, oauthIssuerURI any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenIdCredentialIssuerMetadata", reflect.TypeOf((*MockClient)(nil).OpenIdCredentialIssuerMetadata), ctx, oauthIssuerURI) -} - // PostAuthorizationResponse mocks base method. func (m *MockClient) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI, state string) (string, error) { m.ctrl.T.Helper() @@ -163,21 +148,6 @@ func (mr *MockClientMockRecorder) PresentationDefinition(ctx, endpoint any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockClient)(nil).PresentationDefinition), ctx, endpoint) } -// RequestNonce mocks base method. -func (m *MockClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestNonce", ctx, nonceEndpoint) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// RequestNonce indicates an expected call of RequestNonce. -func (mr *MockClientMockRecorder) RequestNonce(ctx, nonceEndpoint any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNonce", reflect.TypeOf((*MockClient)(nil).RequestNonce), ctx, nonceEndpoint) -} - // RequestObjectByGet mocks base method. func (m *MockClient) RequestObjectByGet(ctx context.Context, requestURI string) (string, error) { m.ctrl.T.Helper() @@ -222,18 +192,3 @@ func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjec mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection) } - -// VerifiableCredentials mocks base method. -func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, proofJWT string) (*openid4vci.CredentialResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) - ret0, _ := ret[0].(*openid4vci.CredentialResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// VerifiableCredentials indicates an expected call of VerifiableCredentials. -func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) -} diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index c7c64af4df..7116dec09d 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -38,7 +38,6 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" - "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/crypto/dpop" @@ -347,28 +346,6 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return &tokenResponse, nil } -func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*openid4vci.OpenIDCredentialIssuerMetadata, error) { - iamClient := c.httpClient - rsp, err := iamClient.OpenIdCredentialIssuerMetadata(ctx, oauthIssuerURI) - if err != nil { - return nil, fmt.Errorf("failed to retrieve Openid credential issuer metadata: %w", err) - } - return rsp, nil -} - -func (c *OpenID4VPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { - return c.httpClient.RequestNonce(ctx, nonceEndpoint) -} - -func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) { - iamClient := c.httpClient - rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) - if err != nil { - return nil, err - } - return rsp, nil -} - func (c *OpenID4VPClient) dpop(ctx context.Context, requester did.DID, request http.Request) (string, string, error) { // find the key to sign the DPoP token with keyID, _, err := c.keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index f2d37c3419..99cc6f305d 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -672,116 +672,3 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { return ctx } -func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) { - t.Run("ok", func(t *testing.T) { - ctx := createClientServerTestContext(t) - - metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") - - require.NoError(t, err) - require.NotNil(t, metadata) - assert.Equal(t, *ctx.openIDCredentialIssuerMetadata, *metadata) - }) - t.Run("error - failed to get metadata", func(t *testing.T) { - ctx := createClientServerTestContext(t) - ctx.credentialIssuerMetadata = nil - - response, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") - - require.Error(t, err) - assert.Nil(t, response) - assert.EqualError(t, err, "failed to retrieve Openid credential issuer metadata: server returned HTTP 404 (expected: 200)") - }) -} - -func TestIAMClient_RequestNonce(t *testing.T) { - t.Run("ok", func(t *testing.T) { - ctx := createClientServerTestContext(t) - nonceEndpoint := ctx.tlsServer.URL + "/nonce" - - nonce, err := ctx.client.RequestNonce(context.Background(), nonceEndpoint) - - require.NoError(t, err) - assert.Equal(t, "server-nonce", nonce) - }) - t.Run("error - endpoint not found", func(t *testing.T) { - ctx := createClientServerTestContext(t) - ctx.nonce = nil - nonceEndpoint := ctx.tlsServer.URL + "/nonce" - - nonce, err := ctx.client.RequestNonce(context.Background(), nonceEndpoint) - - assert.Error(t, err) - assert.Empty(t, nonce) - }) -} - -func TestIAMClient_VerifiableCredentials(t *testing.T) { - accessToken := "code" - proofJWT := "top secret" - credentialConfigID := "NutsOrganizationCredential_ldp_vc" - - t.Run("ok", func(t *testing.T) { - ctx := createClientServerTestContext(t) - - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) - - require.NoError(t, err) - require.NotNil(t, response) - require.Len(t, response.Credentials, 1) - assert.JSONEq(t, `{"type": "VerifiableCredential"}`, string(response.Credentials[0].Credential)) - }) - t.Run("ok - json object credential (ldp_vc)", func(t *testing.T) { - ctx := createClientServerTestContext(t) - ctx.credentials = func(writer http.ResponseWriter) { - writer.Header().Add("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write([]byte(`{"credentials": [{"credential": {"@context": ["https://www.w3.org/2018/credentials/v1"], "type": ["VerifiableCredential"]}}]}`)) - } - - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) - - require.NoError(t, err) - require.NotNil(t, response) - require.Len(t, response.Credentials, 1) - assert.Contains(t, string(response.Credentials[0].Credential), "VerifiableCredential") - }) - t.Run("error - credential endpoint returns 404", func(t *testing.T) { - ctx := createClientServerTestContext(t) - ctx.credentials = nil - - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) - - assert.Error(t, err) - assert.Nil(t, response) - }) - t.Run("error - structured error on 400", func(t *testing.T) { - ctx := createClientServerTestContext(t) - ctx.credentials = func(writer http.ResponseWriter) { - writer.Header().Add("Content-Type", "application/json") - writer.WriteHeader(http.StatusBadRequest) - _, _ = writer.Write([]byte(`{"error": "invalid_nonce"}`)) - } - - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) - - assert.Nil(t, response) - require.Error(t, err) - var oidcErr openid4vci.Error - require.ErrorAs(t, err, &oidcErr) - assert.Equal(t, openid4vci.InvalidNonce, oidcErr.Code) - }) - t.Run("error - invalid response body", func(t *testing.T) { - ctx := createClientServerTestContext(t) - ctx.credentials = func(writer http.ResponseWriter) { - writer.Header().Add("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write([]byte(`{"credentials": fail}`)) - } - - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) - - assert.Error(t, err) - assert.Nil(t, response) - }) -} From 1b04b19a87aa059e2a1f1d7cb42f26bf0814820f Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 13:59:27 +0200 Subject: [PATCH 25/44] chore: regenerate generated.go from current OpenAPI spec The generated files were stale relative to docs/_static/auth/v2.yaml and docs/_static/auth/iam.partial.yaml. Re-running oapi-codegen syncs comment text (no schema or behavior change). --- auth/api/iam/generated.go | 9 ++++++--- e2e-tests/browser/client/iam/generated.go | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 859cff5efd..5400be09ca 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -106,7 +106,7 @@ type ExtendedTokenIntrospectionResponse struct { // PresentationSubmissions Mapping of Presentation Definition IDs that were fulfilled to Presentation Submissions. PresentationSubmissions *map[string]PresentationSubmission `json:"presentation_submissions,omitempty"` - // Scope granted scopes + // Scope Granted scopes, as a space-separated list. Scope *string `json:"scope,omitempty"` Vps *[]VerifiablePresentation `json:"vps,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` @@ -135,12 +135,15 @@ type ServiceAccessTokenRequest struct { AuthorizationServer string `json:"authorization_server"` // CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple - // credentials matching a single input descriptor. Each key must match a field id declared + // credentials matching a single input descriptor. Each key must match a field ID declared // in the Presentation Definition's input descriptor constraints. The value narrows the // match to credentials where that field equals the given value. // // The selection must narrow to exactly one credential per input descriptor. // Zero matches or multiple matches will result in an error. + // + // When omitted and multiple credentials match an input descriptor, + // the first matching credential is used. CredentialSelection *map[string]string `json:"credential_selection,omitempty"` // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. @@ -197,7 +200,7 @@ type UserAccessTokenRequestTokenType string // UserDetails Claims about the authorized user. type UserDetails struct { - // Id Machine-readable identifier, uniquely identifying the user in the issuing system. + // Id Machine-readable identifier, uniquely identifying the user in the issuing system. The format is not specified; it could be a username, email address, employee number, etc. Id string `json:"id"` // Name Human-readable name of the user. diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 51c1f31772..62964b50f7 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -107,7 +107,7 @@ type ExtendedTokenIntrospectionResponse struct { // PresentationSubmissions Mapping of Presentation Definition IDs that were fulfilled to Presentation Submissions. PresentationSubmissions *map[string]PresentationSubmission `json:"presentation_submissions,omitempty"` - // Scope granted scopes + // Scope Granted scopes, as a space-separated list. Scope *string `json:"scope,omitempty"` Vps *[]VerifiablePresentation `json:"vps,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` @@ -129,12 +129,15 @@ type ServiceAccessTokenRequest struct { AuthorizationServer string `json:"authorization_server"` // CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple - // credentials matching a single input descriptor. Each key must match a field id declared + // credentials matching a single input descriptor. Each key must match a field ID declared // in the Presentation Definition's input descriptor constraints. The value narrows the // match to credentials where that field equals the given value. // // The selection must narrow to exactly one credential per input descriptor. // Zero matches or multiple matches will result in an error. + // + // When omitted and multiple credentials match an input descriptor, + // the first matching credential is used. CredentialSelection *map[string]string `json:"credential_selection,omitempty"` // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. @@ -191,7 +194,7 @@ type UserAccessTokenRequestTokenType string // UserDetails Claims about the authorized user. type UserDetails struct { - // Id Machine-readable identifier, uniquely identifying the user in the issuing system. + // Id Machine-readable identifier, uniquely identifying the user in the issuing system. The format is not specified; it could be a username, email address, employee number, etc. Id string `json:"id"` // Name Human-readable name of the user. From cb6b60096ee1580a94407f5b29f1fb6157b46167 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 14:00:57 +0200 Subject: [PATCH 26/44] feat(auth/iam): type authorization_details in OpenAPI schema Resolves a typing concern from Rein's review of PR #4057: the field was []map[string]interface{} and indexed by string keys at the call site. Now a typed AuthorizationDetail schema covering the fields used by the OpenID4VCI flow. --- auth/api/iam/generated.go | 17 ++++++++++- auth/api/iam/openid4vci.go | 4 +-- auth/api/iam/openid4vci_test.go | 2 +- docs/_static/auth/v2.yaml | 35 ++++++++++++++++------- e2e-tests/browser/client/iam/generated.go | 17 ++++++++++- 5 files changed, 59 insertions(+), 16 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 5400be09ca..041cb46b05 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -31,6 +31,21 @@ const ( UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP" ) +// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1. +// Only the fields used by the user/browser issuance flow are modeled. +type AuthorizationDetail struct { + // CredentialConfigurationId References a credential configuration from the issuer's + // credential_configurations_supported metadata. + CredentialConfigurationId *string `json:"credential_configuration_id,omitempty"` + + // Format Optional credential format hint (e.g. "vc+sd-jwt"). + Format *string `json:"format,omitempty"` + + // Type The authorization details type. For OpenID4VCI flows this is + // "openid_credential" per §5.1.1. + Type string `json:"type"` +} + // DPoPRequest defines model for DPoPRequest. type DPoPRequest struct { // Htm The HTTP method for which the DPoP proof is requested. @@ -218,7 +233,7 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - AuthorizationDetails []map[string]interface{} `json:"authorization_details"` + AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), // used to locate the OAuth2 Authorization Server metadata. diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index ce52eafeaf..39aa834e8d 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -81,8 +81,8 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques var credentialConfigID string if len(request.Body.AuthorizationDetails) > 0 { authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails) - if id, ok := request.Body.AuthorizationDetails[0]["credential_configuration_id"].(string); ok { - credentialConfigID = id + if id := request.Body.AuthorizationDetails[0].CredentialConfigurationId; id != nil { + credentialConfigID = *id } } // Generate the state and PKCE diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 0e56d4fc94..44d51ddb8f 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -58,7 +58,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "format": "vc+sd-jwt"}}, + AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", Format: to.Ptr("vc+sd-jwt")}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 01cf6ba826..e3129fdbab 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -167,17 +167,7 @@ paths: authorization_details: type: array items: - type: object - description: | - The request parameter authorization_details defined in Section 2 of [RFC9396] MUST be used to convey the details about the Credentials the Wallet wants to obtain. - See the RFC9396/OpenID4VCI for the format of an authorization_details object, and consult the Credential Issuer for requestable credentials. - example: | - [ - { - "type": "openid_credential", - "credential_configuration_id": "UniversityDegreeCredential" - } - ] + $ref: '#/components/schemas/AuthorizationDetail' redirect_uri: type: string description: | @@ -721,6 +711,29 @@ components: description: | Presentation Definitions, as described in Presentation Exchange specification, fulfilled to obtain the access token The map key is the wallet owner (user/organization) + AuthorizationDetail: + description: | + A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1. + Only the fields used by the user/browser issuance flow are modeled. + type: object + required: + - type + properties: + type: + type: string + example: openid_credential + description: | + The authorization details type. For OpenID4VCI flows this is + "openid_credential" per §5.1.1. + credential_configuration_id: + type: string + description: | + References a credential configuration from the issuer's + credential_configurations_supported metadata. + format: + type: string + description: | + Optional credential format hint (e.g. "vc+sd-jwt"). securitySchemes: jwtBearerAuth: type: http diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 62964b50f7..7db741dd62 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -32,6 +32,21 @@ const ( UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP" ) +// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1. +// Only the fields used by the user/browser issuance flow are modeled. +type AuthorizationDetail struct { + // CredentialConfigurationId References a credential configuration from the issuer's + // credential_configurations_supported metadata. + CredentialConfigurationId *string `json:"credential_configuration_id,omitempty"` + + // Format Optional credential format hint (e.g. "vc+sd-jwt"). + Format *string `json:"format,omitempty"` + + // Type The authorization details type. For OpenID4VCI flows this is + // "openid_credential" per §5.1.1. + Type string `json:"type"` +} + // DPoPRequest defines model for DPoPRequest. type DPoPRequest struct { // Htm The HTTP method for which the DPoP proof is requested. @@ -212,7 +227,7 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - AuthorizationDetails []map[string]interface{} `json:"authorization_details"` + AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), // used to locate the OAuth2 Authorization Server metadata. From a9545b65e9a9b02246db6c5115ae56fe8f8e4742 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 12:27:34 +0200 Subject: [PATCH 27/44] =?UTF-8?q?docs(auth/iam):=20cite=20OpenID4VCI=201.0?= =?UTF-8?q?=20=C2=A78.3.1.2=20on=20invalid=5Fnonce=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves a question from Rein's review of PR #4057: clarify that the retry-once-with-fresh-nonce behavior is rooted in §8.3.1.2 (the spec mandates fetching a new c_nonce; retrying once is local recovery policy). --- auth/api/iam/openid4vci.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 39aa834e8d..b837363b27 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -160,7 +160,9 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode // build proof and request credential credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) if err != nil { - // on invalid_nonce: fetch a fresh nonce and retry once + // Per OpenID4VCI 1.0 §8.3.1.2: on invalid_nonce the wallet retrieves a + // new c_nonce. Retrying once is local policy to bound recovery; a + // second invalid_nonce surfaces as a generic ServerError below. var oidcErr openid4vci.Error if errors.As(err, &oidcErr) && oidcErr.Code == openid4vci.InvalidNonce && oauthSession.IssuerNonceEndpoint != "" { nonce, err = r.auth.OpenID4VCIClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint) From 7971cbea30809d4e85011224dacce310aa753fe5 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 12:29:54 +0200 Subject: [PATCH 28/44] docs(vcr/openid4vci): clarify package purpose and divergence from v1.0 The header comment claimed v1.0 conformance which was misleading: this package is an internal node-to-node draft-11 flow used to issue NutsAuthorizationCredentials between Nuts nodes (HTTP replacement of the v5 gRPC network), not a Wallet implementation. Replace the header with package godoc that names the divergences from v1.0 and points to auth/openid4vci for the user/browser flow. Resolves a documentation request from Rein's review of PR #4057. --- vcr/openid4vci/types.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index e5d030b005..e425f0e6c0 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -16,8 +16,28 @@ * */ -// This file defines types specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html - +// Package openid4vci implements an internal node-to-node credential +// issuance flow between Nuts nodes. It is the HTTP replacement of the +// Nuts v5 gRPC network for issuing NutsAuthorizationCredentials. +// +// This package is based on a subset of OpenID for Verifiable Credential +// Issuance draft-11. It is NOT a standards-conformant Wallet implementation +// per OpenID4VCI 1.0 §2 and diverges from v1.0 in several ways: +// +// - Single credential per request only. +// - No credential_offer_uri (offers are inline only). +// - No Nonce Endpoint (c_nonce delivered with token/credential responses, +// draft-11 style). +// - No authorization_details with type "openid_credential". +// - Draft-11 wire format for Credential Request and Response. +// +// For the standards-conformant OpenID4VCI 1.0 user/browser flow, see +// the auth/openid4vci package and auth/api/iam/openid4vci.go. +// +// This package is in production. Do not extend or migrate it without a +// deliberate decision — the next migration is the right time to also +// question whether OpenID4VCI is the right protocol for an internal +// node-to-node flow at all. package openid4vci import ( From 9a52e066c2d3e024c72bc9f03b242cd384724511 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 14:10:43 +0200 Subject: [PATCH 29/44] fix(auth/openid4vci): validate credential_issuer matches requested URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per OpenID4VCI 1.0 §12.2.4, mismatched issuer metadata MUST NOT be used. Reject responses where credential_issuer does not match the URL the metadata was retrieved for. Test covers the rejection path. --- auth/openid4vci/client.go | 5 +++++ auth/openid4vci/client_test.go | 36 +++++++++++++++++++++++++--------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index 20ad2c17fe..17c2d7ff41 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -93,6 +93,11 @@ func (c *client) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL s if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { return nil, fmt.Errorf("openid4vci: decoding issuer metadata: %w", err) } + // Per §12.2.4: the credential_issuer value MUST match the issuer identifier + // the metadata document was retrieved for. Mismatched metadata MUST NOT be used. + if metadata.CredentialIssuer != issuerURL { + return nil, fmt.Errorf("openid4vci: credential_issuer %q does not match requested issuer %q", metadata.CredentialIssuer, issuerURL) + } return &metadata, nil } diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go index 504fb14276..5614f1cdb1 100644 --- a/auth/openid4vci/client_test.go +++ b/auth/openid4vci/client_test.go @@ -77,14 +77,15 @@ func TestClient_RequestNonce(t *testing.T) { func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { t.Run("fetches and parses metadata from well-known path", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/.well-known/openid-credential-issuer", r.URL.Path) assert.Equal(t, http.MethodGet, r.Method) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{ - CredentialIssuer: "https://issuer.example.com", - CredentialEndpoint: "https://issuer.example.com/credential", - NonceEndpoint: "https://issuer.example.com/nonce", + CredentialIssuer: srv.URL, + CredentialEndpoint: srv.URL + "/credential", + NonceEndpoint: srv.URL + "/nonce", }) })) defer srv.Close() @@ -93,17 +94,18 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { metadata, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) require.NoError(t, err) require.NotNil(t, metadata) - assert.Equal(t, "https://issuer.example.com", metadata.CredentialIssuer) - assert.Equal(t, "https://issuer.example.com/credential", metadata.CredentialEndpoint) - assert.Equal(t, "https://issuer.example.com/nonce", metadata.NonceEndpoint) + assert.Equal(t, srv.URL, metadata.CredentialIssuer) + assert.Equal(t, srv.URL+"/credential", metadata.CredentialEndpoint) + assert.Equal(t, srv.URL+"/nonce", metadata.NonceEndpoint) }) t.Run("appends issuer path after well-known segment per RFC 8615", func(t *testing.T) { var capturedPath string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{CredentialIssuer: "x"}) + _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{CredentialIssuer: srv.URL + "/oauth2/alice"}) })) defer srv.Close() @@ -113,6 +115,22 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { assert.Equal(t, "/.well-known/openid-credential-issuer/oauth2/alice", capturedPath) }) + t.Run("rejects metadata when credential_issuer mismatches requested issuer", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://attacker.example/", + }) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "credential_issuer") + assert.Contains(t, err.Error(), "does not match") + }) + t.Run("error on non-2xx", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", http.StatusNotFound) From 23764246ef97381f70c7f365fae24f29daccadcb Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 14:54:32 +0200 Subject: [PATCH 30/44] fix(auth/iam): unwrap JSON-string credentials in CredentialResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenID4VCI 1.0 §8.3 allows the credential field to be either a JSON string (JWT-VC, SD-JWT-VC) or a JSON object (JSON-LD). Stringifying a json.RawMessage that holds a JSON string yields the value with surrounding quotes, which vc.ParseVerifiableCredential cannot consume. Unmarshal as a string first; on failure, treat the raw bytes as a JSON-LD object. --- auth/api/iam/openid4vci.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index b837363b27..f2b8b35bf9 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -179,7 +179,14 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode return nil, withCallbackURI(oauthError(oauth.ServerError, "credential response does not contain any credentials"), appCallbackURI) } - credentialJSON := string(credentialResponse.Credentials[0].Credential) + // Per OpenID4VCI 1.0 §8.3, the credential field can be either a JSON string + // (JWT-VC, SD-JWT-VC) or a JSON object (JSON-LD). Try unmarshalling as a + // string first; if that fails, treat the raw bytes as the JSON-LD object. + rawCredential := credentialResponse.Credentials[0].Credential + var credentialJSON string + if err := json.Unmarshal(rawCredential, &credentialJSON); err != nil { + credentialJSON = string(rawCredential) + } credential, err := vc.ParseVerifiableCredential(credentialJSON) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentialJSON, err.Error())), appCallbackURI) From f67150cec06b9e9278e03ccdd90b2d5447af7dad Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 15:02:25 +0200 Subject: [PATCH 31/44] =?UTF-8?q?feat(auth/iam):=20use=20credential=5Fiden?= =?UTF-8?q?tifier=20from=20token=20response=20per=20=C2=A78.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenID4VCI 1.0 §3.3.4 / §8.2 require the wallet to send credential_identifier (not credential_configuration_id) in the Credential Request when the Token Response carries authorization_details with credential_identifiers. The two parameters are mutually exclusive. - TokenResponse.GetAny exposes structured extension parameters - RequestCredentialOpts adds CredentialIdentifier; Client.RequestCredential picks credential_identifier when set and omits credential_configuration_id - handleOpenID4VCICallback extracts credential_identifier from the token response's authorization_details and threads it through Falls back to credential_configuration_id when the AS does not return authorization_details, matching the §3.3.4 scope-flow alternative. --- auth/api/iam/openid4vci.go | 51 +++++++++++++++++++++++++++++++-- auth/api/iam/openid4vci_test.go | 30 +++++++++++++++++++ auth/oauth/types.go | 11 +++++++ auth/openid4vci/client.go | 18 ++++++++++-- auth/openid4vci/client_test.go | 24 ++++++++++++++++ 5 files changed, 128 insertions(+), 6 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index f2b8b35bf9..0ad23e284a 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -148,6 +148,12 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", oauthSession.TokenEndpoint, err.Error())), appCallbackURI) } + // Per §3.3.4 / §8.2: when the Token Response carries authorization_details + // with credential_identifiers, the Credential Request MUST use a + // credential_identifier (not credential_configuration_id). Falls back to + // "" if the AS did not return authorization_details. + credentialIdentifier := extractCredentialIdentifier(tokenResponse, oauthSession.IssuerCredentialConfigurationID) + // fetch nonce from the Nonce Endpoint (v1.0 Section 7) var nonce string if oauthSession.IssuerNonceEndpoint != "" { @@ -158,7 +164,7 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode } // build proof and request credential - credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) + credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, credentialIdentifier, nonce) if err != nil { // Per OpenID4VCI 1.0 §8.3.1.2: on invalid_nonce the wallet retrieves a // new c_nonce. Retrying once is local policy to bound recovery; a @@ -169,7 +175,7 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce for retry from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI) } - credentialResponse, err = r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) + credentialResponse, err = r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, credentialIdentifier, nonce) } if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) @@ -204,7 +210,7 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode }, nil } -func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, nonce string) (*openid4vci.CredentialResponse, error) { +func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, credentialIdentifier string, nonce string) (*openid4vci.CredentialResponse, error) { proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, nonce) if err != nil { return nil, fmt.Errorf("error building proof: %w", err) @@ -213,10 +219,49 @@ func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *O CredentialEndpoint: oauthSession.IssuerCredentialEndpoint, AccessToken: accessToken, CredentialConfigurationID: oauthSession.IssuerCredentialConfigurationID, + CredentialIdentifier: credentialIdentifier, ProofJWT: proofJWT, }) } +// extractCredentialIdentifier reads authorization_details from the Token +// Response and returns a credential_identifier matching the requested +// configuration. Per OpenID4VCI 1.0 §3.3.4 / §8.2, when the AS returns +// authorization_details with credential_identifiers, the wallet MUST use a +// credential_identifier in the Credential Request. Returns "" when the +// Token Response did not carry authorization_details (in which case the +// wallet falls back to credential_configuration_id). +func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialConfigurationID string) string { + raw, ok := tokenResponse.GetAny(oauth.AuthorizationDetailsParam) + if !ok { + return "" + } + bytes, err := json.Marshal(raw) + if err != nil { + return "" + } + var details []struct { + Type string `json:"type"` + CredentialConfigurationID string `json:"credential_configuration_id"` + CredentialIdentifiers []string `json:"credential_identifiers"` + } + if err := json.Unmarshal(bytes, &details); err != nil { + return "" + } + for _, d := range details { + if d.Type != "openid_credential" { + continue + } + if credentialConfigurationID != "" && d.CredentialConfigurationID != credentialConfigurationID { + continue + } + if len(d.CredentialIdentifiers) > 0 { + return d.CredentialIdentifiers[0] + } + } + return "" +} + func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) { kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod) if err != nil { diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 44d51ddb8f..5d9d5a46fa 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -323,6 +323,36 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "error fetching nonce for retry") }) + t.Run("ok - uses credential_identifier from token response authorization_details", func(t *testing.T) { + ctx := newTestClient(t) + // Per §3.3.4 / §8.2: when the AS returns authorization_details with + // credential_identifiers, the wallet MUST send credential_identifier + // in the Credential Request. + tokenResponseWithAuthDetails := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}). + With(oauth.AuthorizationDetailsParam, []map[string]interface{}{{ + "type": "openid_credential", + "credential_configuration_id": credentialConfigID, + "credential_identifiers": []string{"CivilEngineeringDegree-2023"}, + }}) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithAuthDetails, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{ + CredentialEndpoint: credEndpoint, + AccessToken: accessToken, + CredentialConfigurationID: credentialConfigID, + CredentialIdentifier: "CivilEngineeringDegree-2023", + ProofJWT: "signed-proof", + }).Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + require.NoError(t, err) + require.NotNil(t, callback) + }) t.Run("error - initial nonce request fails", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) diff --git a/auth/oauth/types.go b/auth/oauth/types.go index db294e95ae..f039884c92 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -116,6 +116,17 @@ func (t TokenResponse) Get(key string) string { return "" } +// GetAny returns the value of the additional parameter with the given key, untyped. +// Use this for structured extension parameters such as authorization_details (RFC 9396). +// The boolean indicates whether the key was present. +func (t TokenResponse) GetAny(key string) (interface{}, bool) { + if t.additionalParams == nil { + return nil, false + } + val, ok := t.additionalParams[key] + return val, ok +} + const ( // AccessTokenRequestStatusPending is the status for a pending access token AccessTokenRequestStatusPending = "pending" diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index 17c2d7ff41..6abe916562 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -33,12 +33,18 @@ import ( const wellKnownPath = "/.well-known/openid-credential-issuer" // RequestCredentialOpts carries all parameters for a Credential Request. -// Using a struct means future spec fields (CredentialIdentifier, -// CredentialResponseEncryption) are non-breaking additions. +// +// CredentialIdentifier and CredentialConfigurationID are mutually exclusive +// per §8.2: when the Token Response carried authorization_details with +// credential_identifiers, the wallet MUST set CredentialIdentifier (and +// CredentialConfigurationID MUST NOT be present); otherwise the wallet sets +// CredentialConfigurationID. If both are non-empty, CredentialIdentifier +// takes precedence to enforce the spec rule. type RequestCredentialOpts struct { CredentialEndpoint string AccessToken string CredentialConfigurationID string + CredentialIdentifier string ProofJWT string } @@ -126,11 +132,17 @@ func (c *client) RequestNonce(ctx context.Context, nonceEndpoint string) (string func (c *client) RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) { body := CredentialRequest{ - CredentialConfigurationID: opts.CredentialConfigurationID, Proofs: &CredentialRequestProofs{ JWT: []string{opts.ProofJWT}, }, } + // Per §8.2: CredentialIdentifier and CredentialConfigurationID are mutually + // exclusive. CredentialIdentifier wins when set. + if opts.CredentialIdentifier != "" { + body.CredentialIdentifier = opts.CredentialIdentifier + } else { + body.CredentialConfigurationID = opts.CredentialConfigurationID + } bodyBytes, err := json.Marshal(body) if err != nil { return nil, err diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go index 5614f1cdb1..b3c1371030 100644 --- a/auth/openid4vci/client_test.go +++ b/auth/openid4vci/client_test.go @@ -194,6 +194,30 @@ func TestClient_RequestCredential(t *testing.T) { assert.JSONEq(t, `"eyJhbGciOiJFUzI1NiJ9"`, string(resp.Credentials[0].Credential)) }) + t.Run("uses credential_identifier when provided and omits credential_configuration_id", func(t *testing.T) { + var credReq CredentialRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&credReq)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(CredentialResponse{ + Credentials: []CredentialResponseEntry{{Credential: json.RawMessage(`"vc"`)}}, + }) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ + CredentialEndpoint: srv.URL, + AccessToken: "t", + CredentialConfigurationID: "ignored-when-identifier-set", + CredentialIdentifier: "CivilEngineeringDegree-2023", + ProofJWT: "p", + }) + require.NoError(t, err) + assert.Equal(t, "CivilEngineeringDegree-2023", credReq.CredentialIdentifier) + assert.Empty(t, credReq.CredentialConfigurationID, "credential_configuration_id MUST NOT be present when credential_identifier is used (§8.2)") + }) + t.Run("returns structured Error on invalid_nonce", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") From a72d50ae94871b3b81ebfd30a6609e07d33c9490 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 15:16:01 +0200 Subject: [PATCH 32/44] =?UTF-8?q?fix(auth/iam):=20tighten=20authorization?= =?UTF-8?q?=5Fdetails=20validation=20per=20=C2=A75.1.1,=20=C2=A76.2,=20?= =?UTF-8?q?=C2=A78.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related strictness fixes for the OpenID4VCI v1.0 authorization_details flow: - OpenAPI: AuthorizationDetail requires credential_configuration_id and type=openid_credential (enum) per §5.1.1. The generated CredentialConfigurationId is now a non-optional string. - Authorization Request handler: credentialConfigID assignment simplified (no nil check needed thanks to schema enforcement). - Token Response processing: extractCredentialIdentifier now returns an error when authorization_details is present but does not yield a credential_identifier matching the requested credential_configuration_id (§6.2 makes credential_identifiers REQUIRED in that case; silently falling back to credential_configuration_id was incorrect). --- auth/api/iam/generated.go | 20 ++++++++--- auth/api/iam/openid4vci.go | 42 +++++++++++++---------- auth/api/iam/openid4vci_test.go | 22 ++++++++++-- docs/_static/auth/v2.yaml | 10 +++--- e2e-tests/browser/client/iam/generated.go | 20 ++++++++--- 5 files changed, 80 insertions(+), 34 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 041cb46b05..eb150c9fff 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -19,6 +19,11 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) +// Defines values for AuthorizationDetailType. +const ( + OpenidCredential AuthorizationDetailType = "openid_credential" +) + // Defines values for ServiceAccessTokenRequestTokenType. const ( ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer" @@ -35,17 +40,22 @@ const ( // Only the fields used by the user/browser issuance flow are modeled. type AuthorizationDetail struct { // CredentialConfigurationId References a credential configuration from the issuer's - // credential_configurations_supported metadata. - CredentialConfigurationId *string `json:"credential_configuration_id,omitempty"` + // credential_configurations_supported metadata. REQUIRED for + // type=openid_credential per §5.1.1. + CredentialConfigurationId string `json:"credential_configuration_id"` // Format Optional credential format hint (e.g. "vc+sd-jwt"). Format *string `json:"format,omitempty"` - // Type The authorization details type. For OpenID4VCI flows this is - // "openid_credential" per §5.1.1. - Type string `json:"type"` + // Type The authorization details type. For OpenID4VCI flows this MUST + // be "openid_credential" per §5.1.1. + Type AuthorizationDetailType `json:"type"` } +// AuthorizationDetailType The authorization details type. For OpenID4VCI flows this MUST +// be "openid_credential" per §5.1.1. +type AuthorizationDetailType string + // DPoPRequest defines model for DPoPRequest. type DPoPRequest struct { // Htm The HTTP method for which the DPoP proof is requested. diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 0ad23e284a..04c37fa893 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -76,14 +76,14 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques clientID := r.subjectToBaseURL(request.SubjectID) - // Read and parse the authorization details + // Read and parse the authorization details. Per §5.1.1, type and + // credential_configuration_id are required for the openid_credential + // authorization_details flow; the OpenAPI schema enforces both. authorizationDetails := []byte("[]") var credentialConfigID string if len(request.Body.AuthorizationDetails) > 0 { authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails) - if id := request.Body.AuthorizationDetails[0].CredentialConfigurationId; id != nil { - credentialConfigID = *id - } + credentialConfigID = request.Body.AuthorizationDetails[0].CredentialConfigurationId } // Generate the state and PKCE state := crypto.GenerateNonce() @@ -150,9 +150,13 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode // Per §3.3.4 / §8.2: when the Token Response carries authorization_details // with credential_identifiers, the Credential Request MUST use a - // credential_identifier (not credential_configuration_id). Falls back to - // "" if the AS did not return authorization_details. - credentialIdentifier := extractCredentialIdentifier(tokenResponse, oauthSession.IssuerCredentialConfigurationID) + // credential_identifier (not credential_configuration_id). When the AS + // did not return authorization_details, fall back to + // credential_configuration_id (§3.3.4 scope-flow alternative). + credentialIdentifier, err := extractCredentialIdentifier(tokenResponse, oauthSession.IssuerCredentialConfigurationID) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, err.Error()), appCallbackURI) + } // fetch nonce from the Nonce Endpoint (v1.0 Section 7) var nonce string @@ -228,17 +232,18 @@ func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *O // Response and returns a credential_identifier matching the requested // configuration. Per OpenID4VCI 1.0 §3.3.4 / §8.2, when the AS returns // authorization_details with credential_identifiers, the wallet MUST use a -// credential_identifier in the Credential Request. Returns "" when the -// Token Response did not carry authorization_details (in which case the -// wallet falls back to credential_configuration_id). -func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialConfigurationID string) string { +// credential_identifier in the Credential Request — silently falling back +// to credential_configuration_id is not allowed. Returns ("", nil) only +// when the Token Response did not carry authorization_details at all +// (which permits the §3.3.4 scope-flow fallback to credential_configuration_id). +func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialConfigurationID string) (string, error) { raw, ok := tokenResponse.GetAny(oauth.AuthorizationDetailsParam) if !ok { - return "" + return "", nil } bytes, err := json.Marshal(raw) if err != nil { - return "" + return "", fmt.Errorf("token response authorization_details: %w", err) } var details []struct { Type string `json:"type"` @@ -246,20 +251,21 @@ func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialC CredentialIdentifiers []string `json:"credential_identifiers"` } if err := json.Unmarshal(bytes, &details); err != nil { - return "" + return "", fmt.Errorf("token response authorization_details malformed: %w", err) } for _, d := range details { if d.Type != "openid_credential" { continue } - if credentialConfigurationID != "" && d.CredentialConfigurationID != credentialConfigurationID { + if d.CredentialConfigurationID != credentialConfigurationID { continue } - if len(d.CredentialIdentifiers) > 0 { - return d.CredentialIdentifiers[0] + if len(d.CredentialIdentifiers) == 0 { + return "", fmt.Errorf("token response authorization_details for %q is missing credential_identifiers", credentialConfigurationID) } + return d.CredentialIdentifiers[0], nil } - return "" + return "", fmt.Errorf("token response authorization_details has no entry for credential_configuration_id %q", credentialConfigurationID) } func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) { diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 5d9d5a46fa..695c7ea98c 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -58,7 +58,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", Format: to.Ptr("vc+sd-jwt")}}, + AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential", Format: to.Ptr("vc+sd-jwt")}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -76,7 +76,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.Equal(t, holderClientID, redirectUri.Query().Get("client_id")) assert.Equal(t, "S256", redirectUri.Query().Get("code_challenge_method")) assert.Equal(t, "code", redirectUri.Query().Get("response_type")) - assert.Equal(t, `[{"format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + assert.Equal(t, `[{"credential_configuration_id":"UniversityDegreeCredential","format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { @@ -353,6 +353,24 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { require.NoError(t, err) require.NotNil(t, callback) }) + t.Run("error - authorization_details present but missing credential_identifiers", func(t *testing.T) { + ctx := newTestClient(t) + // Per §6.2 / §8.2: when the AS returns authorization_details for the + // requested credential_configuration_id, credential_identifiers is + // REQUIRED. Silent fallback to credential_configuration_id is not + // permitted; the wallet must surface an error. + tokenResponseWithBadDetails := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}). + With(oauth.AuthorizationDetailsParam, []map[string]interface{}{{ + "type": "openid_credential", + "credential_configuration_id": credentialConfigID, + // credential_identifiers omitted + }}) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithBadDetails, nil) + + _, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + require.Error(t, err) + assert.Contains(t, err.Error(), "credential_identifiers") + }) t.Run("error - initial nonce request fails", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index e3129fdbab..47f4f01471 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -718,18 +718,20 @@ components: type: object required: - type + - credential_configuration_id properties: type: type: string - example: openid_credential + enum: [openid_credential] description: | - The authorization details type. For OpenID4VCI flows this is - "openid_credential" per §5.1.1. + The authorization details type. For OpenID4VCI flows this MUST + be "openid_credential" per §5.1.1. credential_configuration_id: type: string description: | References a credential configuration from the issuer's - credential_configurations_supported metadata. + credential_configurations_supported metadata. REQUIRED for + type=openid_credential per §5.1.1. format: type: string description: | diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 7db741dd62..0ac4822eef 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -20,6 +20,11 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) +// Defines values for AuthorizationDetailType. +const ( + OpenidCredential AuthorizationDetailType = "openid_credential" +) + // Defines values for ServiceAccessTokenRequestTokenType. const ( ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer" @@ -36,17 +41,22 @@ const ( // Only the fields used by the user/browser issuance flow are modeled. type AuthorizationDetail struct { // CredentialConfigurationId References a credential configuration from the issuer's - // credential_configurations_supported metadata. - CredentialConfigurationId *string `json:"credential_configuration_id,omitempty"` + // credential_configurations_supported metadata. REQUIRED for + // type=openid_credential per §5.1.1. + CredentialConfigurationId string `json:"credential_configuration_id"` // Format Optional credential format hint (e.g. "vc+sd-jwt"). Format *string `json:"format,omitempty"` - // Type The authorization details type. For OpenID4VCI flows this is - // "openid_credential" per §5.1.1. - Type string `json:"type"` + // Type The authorization details type. For OpenID4VCI flows this MUST + // be "openid_credential" per §5.1.1. + Type AuthorizationDetailType `json:"type"` } +// AuthorizationDetailType The authorization details type. For OpenID4VCI flows this MUST +// be "openid_credential" per §5.1.1. +type AuthorizationDetailType string + // DPoPRequest defines model for DPoPRequest. type DPoPRequest struct { // Htm The HTTP method for which the DPoP proof is requested. From 6549487a3c84fdcfdc0e8d7025da62c4dad88d30 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 15:57:20 +0200 Subject: [PATCH 33/44] fix(auth/openid4vci): validate target URLs to prevent SSRF (CodeQL) The Credential Issuer URL is user-controlled (via the OpenAPI request body), and the Nonce/Credential Endpoints come transitively from the issuer's metadata. CodeQL flagged this as uncontrolled data flowing into a network request. Two layers of protection, mirroring the master security stack: - NewClient now takes a core.HTTPRequestDoer (in production: httpclient.StrictHTTPClient via NewWithCache). That gives us the HTTPS-in-strict check, User-Agent, and 1MB response body limit for free. - A strictMode flag on the client validates each target URL via core.ParsePublicURL on entry to OpenIDCredentialIssuerMetadata, RequestNonce, and RequestCredential. Strict mode adds rejection of IP hosts and reserved hostnames on top of HTTPS-only. The validateURL helper localises the validation pattern; if rules ever change, one place to update. --- auth/auth.go | 6 +----- auth/openid4vci/client.go | 36 ++++++++++++++++++++++++++++++---- auth/openid4vci/client_test.go | 31 +++++++++++++++++------------ 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index dfaf5c3cff..52528e4f0e 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -31,7 +31,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/didx509" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/http" "net/url" "path" "slices" @@ -182,10 +181,7 @@ func (auth *Auth) Configure(config core.ServerConfig) error { // auth.http.config got deprecated in favor of httpclient.timeout auth.httpClientTimeout = config.HTTPClient.Timeout } - auth.openID4VCIClient = openid4vci.NewClient(&http.Client{ - Transport: httpclient.DefaultCachingTransport, - Timeout: auth.httpClientTimeout, - }) + auth.openID4VCIClient = openid4vci.NewClient(httpclient.NewWithCache(auth.httpClientTimeout), auth.strictMode) // V1 API related stuff accessTokenLifeSpan := time.Duration(auth.config.AccessTokenLifeSpan) * time.Second auth.authzServer = oauth.NewAuthorizationServer(auth.vdrInstance.Resolver(), auth.vcr, auth.vcr.Verifier(), auth.serviceResolver, diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index 6abe916562..095d839523 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -26,6 +26,8 @@ import ( "io" "net/http" "net/url" + + "github.com/nuts-foundation/nuts-node/core" ) // wellKnownPath is the path segment defined in OpenID4VCI 1.0 §12.2 for the @@ -69,16 +71,36 @@ type Client interface { RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) } -// NewClient returns a Client backed by the provided *http.Client. -func NewClient(httpClient *http.Client) Client { - return &client{httpClient: httpClient} +// NewClient returns a Client backed by the provided HTTP request doer. +// In production callers should pass *httpclient.StrictHTTPClient so the +// shared transport policies apply (HTTPS-in-strict, body size limit, +// User-Agent header). +// +// When strictMode is true, target URLs are additionally validated via +// core.ParsePublicURL: HTTPS scheme, no IP hosts, no reserved hostnames. +func NewClient(httpClient core.HTTPRequestDoer, strictMode bool) Client { + return &client{httpClient: httpClient, strictMode: strictMode} } type client struct { - httpClient *http.Client + httpClient core.HTTPRequestDoer + strictMode bool +} + +// validateURL guards against SSRF by rejecting target URLs that fail +// core.ParsePublicURL (in strict mode: HTTPS only, no IP/reserved hosts). +// Called at the entry of every method that makes outbound HTTP. +func (c *client) validateURL(name, target string) error { + if _, err := core.ParsePublicURL(target, c.strictMode); err != nil { + return fmt.Errorf("openid4vci: invalid %s URL: %w", name, err) + } + return nil } func (c *client) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL string) (*OpenIDCredentialIssuerMetadata, error) { + if err := c.validateURL("issuer", issuerURL); err != nil { + return nil, err + } wellKnownURL, err := credentialIssuerWellKnown(issuerURL) if err != nil { return nil, fmt.Errorf("openid4vci: invalid issuer URL: %w", err) @@ -108,6 +130,9 @@ func (c *client) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL s } func (c *client) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + if err := c.validateURL("nonce endpoint", nonceEndpoint); err != nil { + return "", err + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, nonceEndpoint, http.NoBody) if err != nil { return "", err @@ -131,6 +156,9 @@ func (c *client) RequestNonce(ctx context.Context, nonceEndpoint string) (string } func (c *client) RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) { + if err := c.validateURL("credential endpoint", opts.CredentialEndpoint); err != nil { + return nil, err + } body := CredentialRequest{ Proofs: &CredentialRequestProofs{ JWT: []string{opts.ProofJWT}, diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go index b3c1371030..513577f34f 100644 --- a/auth/openid4vci/client_test.go +++ b/auth/openid4vci/client_test.go @@ -41,7 +41,7 @@ func TestClient_RequestNonce(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) nonce, err := client.RequestNonce(context.Background(), srv.URL) require.NoError(t, err) assert.Equal(t, "test-nonce-123", nonce) @@ -53,7 +53,7 @@ func TestClient_RequestNonce(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) _, err := client.RequestNonce(context.Background(), srv.URL) require.Error(t, err) assert.Contains(t, err.Error(), "500") @@ -66,7 +66,7 @@ func TestClient_RequestNonce(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) _, err := client.RequestNonce(context.Background(), srv.URL) require.Error(t, err) assert.Contains(t, err.Error(), "empty c_nonce") @@ -90,7 +90,7 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) metadata, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) require.NoError(t, err) require.NotNil(t, metadata) @@ -109,7 +109,7 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL+"/oauth2/alice") require.NoError(t, err) assert.Equal(t, "/.well-known/openid-credential-issuer/oauth2/alice", capturedPath) @@ -124,7 +124,7 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) require.Error(t, err) assert.Contains(t, err.Error(), "credential_issuer") @@ -137,12 +137,19 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) require.Error(t, err) assert.Contains(t, err.Error(), "404") }) + t.Run("rejects non-https issuer URL in strict mode", func(t *testing.T) { + client := NewClient(http.DefaultClient, true) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), "http://issuer.example/") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid issuer URL") + }) + t.Run("error on bad JSON body", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -150,7 +157,7 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) require.Error(t, err) assert.Contains(t, err.Error(), "decoding issuer metadata") @@ -181,7 +188,7 @@ func TestClient_RequestCredential(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) resp, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ CredentialEndpoint: srv.URL, AccessToken: "test-token", @@ -205,7 +212,7 @@ func TestClient_RequestCredential(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ CredentialEndpoint: srv.URL, AccessToken: "t", @@ -226,7 +233,7 @@ func TestClient_RequestCredential(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ CredentialEndpoint: srv.URL, AccessToken: "test-token", @@ -245,7 +252,7 @@ func TestClient_RequestCredential(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.Client()) + client := NewClient(srv.Client(), false) _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ CredentialEndpoint: srv.URL, AccessToken: "test-token", From 2d032b2f97d84927b71e340ae637ffce0fedbe84 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 17:20:29 +0200 Subject: [PATCH 34/44] fix(auth/iam): require non-empty authorization_details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per OpenID4VCI 1.0 §8.2 a Credential Request needs either credential_configuration_id or credential_identifier. The OpenAPI schema required the authorization_details field but allowed an empty array, which would let a request reach the issuer with neither identifier set. Add minItems:1 to the array so the empty case is rejected at request validation time. --- docs/_static/auth/v2.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 47f4f01471..8cfc6d17fb 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -166,6 +166,7 @@ paths: example: did:web:issuer.example.com authorization_details: type: array + minItems: 1 items: $ref: '#/components/schemas/AuthorizationDetail' redirect_uri: From 0d580fc74275a56aa85b33ce37bbded8e23ded85 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 17:20:55 +0200 Subject: [PATCH 35/44] fix(auth/openid4vci): reject query/fragment in Credential Issuer Identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per OpenID4VCI 1.0 §12.2.1, the Credential Issuer Identifier is a URL that "contains scheme, host and, optionally, port number and path components, but no query or fragment components". core.ParsePublicURL did not enforce this; an issuer URL with a query or fragment slipped through and was forwarded into the constructed .well-known URL via url.URL.String(). Reject these explicitly in OpenIDCredentialIssuerMetadata. --- auth/openid4vci/client.go | 5 +++++ auth/openid4vci/client_test.go | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index 095d839523..f35135f761 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -101,6 +101,11 @@ func (c *client) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL s if err := c.validateURL("issuer", issuerURL); err != nil { return nil, err } + // Per §12.2.1, the Credential Issuer Identifier MUST NOT contain query + // or fragment components. + if parsed, _ := url.Parse(issuerURL); parsed != nil && (parsed.RawQuery != "" || parsed.Fragment != "") { + return nil, fmt.Errorf("openid4vci: invalid issuer URL: query and fragment components are not allowed") + } wellKnownURL, err := credentialIssuerWellKnown(issuerURL) if err != nil { return nil, fmt.Errorf("openid4vci: invalid issuer URL: %w", err) diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go index 513577f34f..2a5d43e405 100644 --- a/auth/openid4vci/client_test.go +++ b/auth/openid4vci/client_test.go @@ -150,6 +150,17 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { assert.Contains(t, err.Error(), "invalid issuer URL") }) + t.Run("rejects issuer URL with query or fragment per §12.2.1", func(t *testing.T) { + client := NewClient(http.DefaultClient, false) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), "https://issuer.example/?foo=bar") + require.Error(t, err) + assert.Contains(t, err.Error(), "query and fragment") + + _, err = client.OpenIDCredentialIssuerMetadata(context.Background(), "https://issuer.example/#section") + require.Error(t, err) + assert.Contains(t, err.Error(), "query and fragment") + }) + t.Run("error on bad JSON body", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") From 1e1fa33d81ad3ac19b73fa758e7790d4dc447e57 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 17:21:21 +0200 Subject: [PATCH 36/44] fix(auth/iam): use Credential Issuer Identifier as proof JWT aud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per OpenID4VCI 1.0 §F.1, the proof JWT aud claim MUST be the Credential Issuer Identifier. The handler stored authzServerMetadata.Issuer (the Authorization Server issuer) and used it as the proof audience, which is only correct when the metadata's authorization_servers field is empty (implicit AS == CI). When the issuer delegates to a separate AS, the AS issuer URL differs from the Credential Issuer Identifier and the proof is rejected. Persist credentialIssuerMetadata.CredentialIssuer separately on the session and use it for the proof audience. IssuerURL keeps its meaning as the AS issuer (used elsewhere in the OpenID4VP flow). --- auth/api/iam/openid4vci.go | 5 ++++- auth/api/iam/openid4vci_test.go | 1 + auth/api/iam/session.go | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 04c37fa893..000294cf09 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -106,6 +106,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, + IssuerCredentialIssuer: credentialIssuerMetadata.CredentialIssuer, }) if err != nil { return nil, fmt.Errorf("failed to store session: %w", err) @@ -215,7 +216,9 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode } func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, credentialIdentifier string, nonce string) (*openid4vci.CredentialResponse, error) { - proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, nonce) + // Per §F.1, the proof JWT `aud` MUST be the Credential Issuer Identifier, + // not the Authorization Server issuer URL. + proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerCredentialIssuer, nonce) if err != nil { return nil, fmt.Errorf("error building proof: %w", err) } diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 695c7ea98c..779e341717 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -201,6 +201,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { IssuerCredentialEndpoint: credEndpoint, IssuerNonceEndpoint: nonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, + IssuerCredentialIssuer: issuerClientID, } sessionWithoutNonce := session sessionWithoutNonce.IssuerNonceEndpoint = "" diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 09ef6fcd9e..00c71b4544 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -59,6 +59,11 @@ type OAuthSession struct { IssuerNonceEndpoint string `json:"issuer_nonce_endpoint,omitempty"` // IssuerCredentialConfigurationID: the credential_configuration_id for the credential request in the OpenID4VCI flow IssuerCredentialConfigurationID string `json:"issuer_credential_configuration_id,omitempty"` + // IssuerCredentialIssuer is the Credential Issuer Identifier (`credential_issuer` + // from the metadata, §12.2.1). It is used as the `aud` claim in the proof JWT + // per §F.1; this can differ from IssuerURL (the AS issuer) when the metadata + // declares `authorization_servers`. + IssuerCredentialIssuer string `json:"issuer_credential_issuer,omitempty"` } // oauthClientFlow is used by a client to identify the flow a particular callback is part of From 461d770fe6c9cbf0cec708dd210e2c397d0c52bb Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 17:37:55 +0200 Subject: [PATCH 37/44] refactor(auth/openid4vci): reuse oauth.OAuth2Error for wire errors Drop the local Error/ErrorCode types in auth/openid4vci. The OpenID4VCI spec error response shape (`{"error": ..., "error_description": ...}`) matches RFC 6749 / oauth.OAuth2Error one-to-one. Add the OpenID4VCI-only extension code (invalid_nonce) to oauth.ErrorCode and let the client and its callers use the existing oauth types directly. Per Rein's review of PR #4057. --- auth/api/iam/openid4vci.go | 4 +-- auth/api/iam/openid4vci_test.go | 6 ++--- auth/oauth/error.go | 4 +++ auth/openid4vci/client.go | 8 +++--- auth/openid4vci/client_test.go | 16 ++++++------ auth/openid4vci/error.go | 46 --------------------------------- 6 files changed, 21 insertions(+), 63 deletions(-) delete mode 100644 auth/openid4vci/error.go diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 000294cf09..b43dfb1b57 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -174,8 +174,8 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode // Per OpenID4VCI 1.0 §8.3.1.2: on invalid_nonce the wallet retrieves a // new c_nonce. Retrying once is local policy to bound recovery; a // second invalid_nonce surfaces as a generic ServerError below. - var oidcErr openid4vci.Error - if errors.As(err, &oidcErr) && oidcErr.Code == openid4vci.InvalidNonce && oauthSession.IssuerNonceEndpoint != "" { + var oauthErr oauth.OAuth2Error + if errors.As(err, &oauthErr) && oauthErr.Code == oauth.InvalidNonce && oauthSession.IssuerNonceEndpoint != "" { nonce, err = r.auth.OpenID4VCIClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce for retry from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI) diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 779e341717..d48db0ab21 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -268,7 +268,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("ok - invalid_nonce retry succeeds", func(t *testing.T) { ctx := newTestClient(t) freshNonce := "fresh-nonce" - invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} + invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce} ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) @@ -290,7 +290,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("error - invalid_nonce retry also fails", func(t *testing.T) { ctx := newTestClient(t) - invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} + invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce} ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) @@ -309,7 +309,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("error - nonce endpoint fails during retry", func(t *testing.T) { ctx := newTestClient(t) - invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} + invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce} ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) diff --git a/auth/oauth/error.go b/auth/oauth/error.go index 905a67798e..fcb92da65e 100644 --- a/auth/oauth/error.go +++ b/auth/oauth/error.go @@ -63,6 +63,10 @@ const ( InvalidRequestURI ErrorCode = "invalid_request_uri" // InvalidRequestURIMethod is returned when the request_uri_method is not 'get' or 'post'. (OpenID4VP) InvalidRequestURIMethod ErrorCode = "invalid_request_uri_method" + // InvalidNonce is returned when at least one of the key proofs in a Credential + // Request contains an invalid c_nonce. The wallet should fetch a new c_nonce + // from the Nonce Endpoint (OpenID4VCI 1.0 §8.3.1.2). + InvalidNonce ErrorCode = "invalid_nonce" ) // Make sure the error implements core.HTTPStatusCodeError, so the HTTP request logger can log the correct status code. diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index f35135f761..5b241f8c39 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -27,6 +27,7 @@ import ( "net/http" "net/url" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" ) @@ -200,10 +201,9 @@ func (c *client) RequestCredential(ctx context.Context, opts RequestCredentialOp return nil, err } if resp.StatusCode < 200 || resp.StatusCode > 299 { - var oidcErr Error - if jsonErr := json.Unmarshal(respBody, &oidcErr); jsonErr == nil && oidcErr.Code != "" { - oidcErr.StatusCode = resp.StatusCode - return nil, oidcErr + var oauthErr oauth.OAuth2Error + if jsonErr := json.Unmarshal(respBody, &oauthErr); jsonErr == nil && oauthErr.Code != "" { + return nil, oauthErr } return nil, fmt.Errorf("openid4vci: credential endpoint returned status %d", resp.StatusCode) } diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go index 2a5d43e405..4ac372ccbc 100644 --- a/auth/openid4vci/client_test.go +++ b/auth/openid4vci/client_test.go @@ -26,6 +26,7 @@ import ( "net/http/httptest" "testing" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -236,11 +237,11 @@ func TestClient_RequestCredential(t *testing.T) { assert.Empty(t, credReq.CredentialConfigurationID, "credential_configuration_id MUST NOT be present when credential_identifier is used (§8.2)") }) - t.Run("returns structured Error on invalid_nonce", func(t *testing.T) { + t.Run("returns structured oauth.OAuth2Error on invalid_nonce", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(Error{Code: InvalidNonce}) + _ = json.NewEncoder(w).Encode(oauth.OAuth2Error{Code: oauth.InvalidNonce}) })) defer srv.Close() @@ -251,10 +252,9 @@ func TestClient_RequestCredential(t *testing.T) { }) require.Error(t, err) - var oidcErr Error - require.True(t, errors.As(err, &oidcErr)) - assert.Equal(t, InvalidNonce, oidcErr.Code) - assert.Equal(t, http.StatusBadRequest, oidcErr.StatusCode) + var oauthErr oauth.OAuth2Error + require.True(t, errors.As(err, &oauthErr)) + assert.Equal(t, oauth.InvalidNonce, oauthErr.Code) }) t.Run("returns generic error on non-2xx with no structured body", func(t *testing.T) { @@ -270,8 +270,8 @@ func TestClient_RequestCredential(t *testing.T) { }) require.Error(t, err) - var oidcErr Error - assert.False(t, errors.As(err, &oidcErr)) + var oauthErr oauth.OAuth2Error + assert.False(t, errors.As(err, &oauthErr)) assert.Contains(t, err.Error(), "503") }) } diff --git a/auth/openid4vci/error.go b/auth/openid4vci/error.go deleted file mode 100644 index 9675549e0f..0000000000 --- a/auth/openid4vci/error.go +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Nuts node - * Copyright (C) 2026 Nuts community - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package openid4vci - -// ErrorCode is an OpenID4VCI 1.0 (ID-1) error code as defined in §8.3.1.2 -// (Credential Endpoint) and §6.4 (Token Endpoint). -type ErrorCode string - -const ( - // InvalidNonce means at least one of the key proofs in the Credential - // Request contained an invalid c_nonce. Per §8.3.1.2 the wallet should - // retrieve a new c_nonce from the Nonce Endpoint (§7) and may retry. - InvalidNonce ErrorCode = "invalid_nonce" -) - -// Error is a wire-format error returned by an OpenID4VCI endpoint. -// Specified by §6.4 and §8.3. -type Error struct { - Code ErrorCode `json:"error"` - Err error `json:"-"` - StatusCode int `json:"-"` -} - -// Error implements the error interface. -func (e Error) Error() string { - if e.Err == nil { - return string(e.Code) - } - return string(e.Code) + " - " + e.Err.Error() -} From 44ccc6ee863aee77e48be8dc2cfbecd7bb220216 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 18:06:20 +0200 Subject: [PATCH 38/44] docs(auth/openid4vci): note follow-up to move URL validation to HTTPClient Per Rein's review: this validation conceptually belongs on the shared HTTP transport (httpclient.StrictHTTPClient) so every outbound call gets the IP/reserved-host check, not just OpenID4VCI. The current placement preserves parity with master (which validated via oauth.IssuerIdToWellKnown) and addresses the CodeQL SSRF finding for this PR. Mark as a follow-up to consolidate the check in the shared client. --- auth/openid4vci/client.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index 5b241f8c39..5c0f1ef88a 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -91,6 +91,15 @@ type client struct { // validateURL guards against SSRF by rejecting target URLs that fail // core.ParsePublicURL (in strict mode: HTTPS only, no IP/reserved hosts). // Called at the entry of every method that makes outbound HTTP. +// +// TODO: this validation belongs on httpclient.StrictHTTPClient so every +// outbound HTTP call (not just OpenID4VCI) gets the IP/reserved-host check, +// not only the HTTPS scheme check that StrictHTTPClient.Do enforces today. +// Placed here for now to preserve parity with master, where the equivalent +// caller (auth/client/iam.HTTPClient) validated via oauth.IssuerIdToWellKnown +// → core.ParsePublicURL before issuing the request, and to address a CodeQL +// SSRF finding on this PR. Tracked as a follow-up to consolidate the check +// in the shared HTTP client. func (c *client) validateURL(name, target string) error { if _, err := core.ParsePublicURL(target, c.strictMode); err != nil { return fmt.Errorf("openid4vci: invalid %s URL: %w", name, err) From 4f9541437d1f65c5fee944019bf8160e6aa334f1 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 18:24:20 +0200 Subject: [PATCH 39/44] docs(auth/iam): explain why JSON-string credential needs unwrapping Make the comment on the credential parsing block explicit about the reason: json.RawMessage keeps the raw JSON encoding, which for a JWT includes the surrounding quotes. ParseVerifiableCredential rejects that as invalid base64, so the unmarshal-as-string step is required to strip the quotes (and is a no-op for JSON-LD objects via the error fallback). --- auth/api/iam/openid4vci.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index b43dfb1b57..f57bd0d3b7 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -190,9 +190,13 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode return nil, withCallbackURI(oauthError(oauth.ServerError, "credential response does not contain any credentials"), appCallbackURI) } - // Per OpenID4VCI 1.0 §8.3, the credential field can be either a JSON string - // (JWT-VC, SD-JWT-VC) or a JSON object (JSON-LD). Try unmarshalling as a - // string first; if that fails, treat the raw bytes as the JSON-LD object. + // Per OpenID4VCI 1.0 §8.3 the credential field is either a JSON string + // (JWT-VC, SD-JWT-VC) or a JSON object (JSON-LD). Because Credential is + // typed as json.RawMessage, the field keeps the raw JSON encoding — for + // a JWT that includes the surrounding quotes, which ParseVerifiableCredential + // would reject as invalid base64. Unmarshal the bytes as a Go string first + // to strip those quotes; on failure (the JSON-LD object case) fall back to + // the raw bytes as-is. rawCredential := credentialResponse.Credentials[0].Credential var credentialJSON string if err := json.Unmarshal(rawCredential, &credentialJSON); err != nil { From 09422fb56ef6bd93bb6a72f54c7db08cce499a8e Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 19:16:34 +0200 Subject: [PATCH 40/44] fix(auth/openid4vci): preserve percent-encoded path segments in well-known URL credentialIssuerWellKnown was assigning u.EscapedPath() (already encoded) back into u.Path; url.URL.String reescapes via EscapedPath, turning %2F into %252F. Prepend the well-known segment to u.Path (decoded) and to u.RawPath when the latter is set, so original encoding is preserved without double-escaping. --- auth/openid4vci/client.go | 8 +++++++- auth/openid4vci/client_test.go | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index 5c0f1ef88a..99f7905158 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -234,6 +234,12 @@ func credentialIssuerWellKnown(issuerURL string) (string, error) { if err != nil { return "", err } - u.Path = wellKnownPath + u.EscapedPath() + // Prepend the well-known segment to both Path (decoded) and RawPath + // (encoded) when the latter is set, so u.String() does not double-escape + // pre-encoded characters like %2F via EscapedPath's reescaping pass. + u.Path = wellKnownPath + u.Path + if u.RawPath != "" { + u.RawPath = wellKnownPath + u.RawPath + } return u.String(), nil } diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go index 4ac372ccbc..c88034d64e 100644 --- a/auth/openid4vci/client_test.go +++ b/auth/openid4vci/client_test.go @@ -116,6 +116,22 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { assert.Equal(t, "/.well-known/openid-credential-issuer/oauth2/alice", capturedPath) }) + t.Run("preserves percent-encoded path segments without double-escaping", func(t *testing.T) { + var capturedRawPath string + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRawPath = r.URL.EscapedPath() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{CredentialIssuer: srv.URL + "/foo%2Fbar"}) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL+"/foo%2Fbar") + require.NoError(t, err) + assert.Equal(t, "/.well-known/openid-credential-issuer/foo%2Fbar", capturedRawPath) + }) + t.Run("rejects metadata when credential_issuer mismatches requested issuer", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") From fe2d563aae7e30a9fafc1257a711ddb3b55b7fe9 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 19:22:11 +0200 Subject: [PATCH 41/44] fix(auth/iam): reject empty authorization_details before metadata fetch The OpenAPI schema declares minItems: 1 but the StrictServer middleware does not enforce minItems at runtime, so an empty array passed through silently. The handler used a defensive 'if len > 0' guard that left IssuerCredentialConfigurationID empty, which would later produce a malformed Credential Request. Replace the guard with an explicit core.InvalidInputError positioned before the issuer/auth-server metadata fetches, so an invalid local request does not trigger any outbound work. Update requestCredentials test helper to populate AuthorizationDetails, and add a regression test. --- auth/api/iam/openid4vci.go | 22 +++++++++++++--------- auth/api/iam/openid4vci_test.go | 21 ++++++++++++++++++--- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index f57bd0d3b7..09ef23506f 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -59,6 +59,13 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques if issuer == "" { return nil, core.InvalidInputError("issuer is empty") } + // Per §5.1.1 the openid_credential authorization_details flow requires at + // least one entry. The OpenAPI schema documents this with minItems: 1, but + // the StrictServer middleware does not enforce minItems at runtime, so + // reject here before any outbound metadata fetches. + if len(request.Body.AuthorizationDetails) == 0 { + return nil, core.InvalidInputError("authorization_details must contain at least one entry") + } // Fetch metadata containing the endpoints credentialIssuerMetadata, authzServerMetadata, err := r.openid4vciMetadata(ctx, request.Body.Issuer) if err != nil { @@ -76,15 +83,12 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques clientID := r.subjectToBaseURL(request.SubjectID) - // Read and parse the authorization details. Per §5.1.1, type and - // credential_configuration_id are required for the openid_credential - // authorization_details flow; the OpenAPI schema enforces both. - authorizationDetails := []byte("[]") - var credentialConfigID string - if len(request.Body.AuthorizationDetails) > 0 { - authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails) - credentialConfigID = request.Body.AuthorizationDetails[0].CredentialConfigurationId - } + // Per §5.1.1, type and credential_configuration_id are required on each + // openid_credential authorization_details entry; the OpenAPI schema + // enforces both. Non-emptiness was checked above before any outbound + // metadata fetches. + authorizationDetails, _ := json.Marshal(request.Body.AuthorizationDetails) + credentialConfigID := request.Body.AuthorizationDetails[0].CredentialConfigurationId // Generate the state and PKCE state := crypto.GenerateNonce() pkceParams := generatePKCEParams() diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index d48db0ab21..bda75d9e10 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -118,6 +118,20 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.EqualError(t, err, "issuer is empty") }) + t.Run("error - empty authorization_details", func(t *testing.T) { + // Schema declares minItems: 1 but the StrictServer middleware does not + // enforce minItems at runtime; the handler must reject empty arrays + // before any outbound metadata fetches. + req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) + req.Body.AuthorizationDetails = []AuthorizationDetail{} + ctx := newTestClient(t) + // Deliberately no mock expectations: rejection must happen before + // metadata is fetched. + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + assert.ErrorContains(t, err, "authorization_details must contain at least one entry") + }) t.Run("error - invalid authorization endpoint in metadata", func(t *testing.T) { ctx := newTestClient(t) ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) @@ -165,9 +179,10 @@ func requestCredentials(subjectID string, issuer string, redirectURI string) Req return RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: subjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - Issuer: issuer, - RedirectUri: redirectURI, - WalletDid: holderDID.String(), + AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential"}}, + Issuer: issuer, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), }, } } From f92440ce5f576bd25de11a4203d59ff1b68c1b70 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 19:35:54 +0200 Subject: [PATCH 42/44] fix(auth/iam): enforce single authorization_details entry per call The handler only consumes the first entry, so accepting an array with multiple entries silently dropped extras. Add maxItems: 1 to the schema to make the contract explicit, and tighten the handler guard from 'len > 0' to 'len == 1' so both bounds are checked at runtime (the StrictServer middleware enforces neither). --- auth/api/iam/generated.go | 3 +++ auth/api/iam/openid4vci.go | 12 +++++++----- auth/api/iam/openid4vci_test.go | 17 +++++++++++++++-- docs/_static/auth/v2.yaml | 5 +++++ e2e-tests/browser/client/iam/generated.go | 3 +++ 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index eb150c9fff..2ca64879c3 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -243,6 +243,9 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { + // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1. + // The current implementation processes a single credential + // issuance per call and only consumes the first entry. AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 09ef23506f..c799895cd1 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -60,11 +60,13 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques return nil, core.InvalidInputError("issuer is empty") } // Per §5.1.1 the openid_credential authorization_details flow requires at - // least one entry. The OpenAPI schema documents this with minItems: 1, but - // the StrictServer middleware does not enforce minItems at runtime, so - // reject here before any outbound metadata fetches. - if len(request.Body.AuthorizationDetails) == 0 { - return nil, core.InvalidInputError("authorization_details must contain at least one entry") + // least one entry; the current implementation issues a single credential + // per call and only consumes the first entry. The OpenAPI schema declares + // both minItems: 1 and maxItems: 1, but the StrictServer middleware does + // not enforce array bounds at runtime, so reject here before any outbound + // metadata fetches. + if len(request.Body.AuthorizationDetails) != 1 { + return nil, core.InvalidInputError("authorization_details must contain exactly one entry") } // Fetch metadata containing the endpoints credentialIssuerMetadata, authzServerMetadata, err := r.openid4vciMetadata(ctx, request.Body.Issuer) diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index bda75d9e10..4e03d107c2 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -120,7 +120,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { }) t.Run("error - empty authorization_details", func(t *testing.T) { // Schema declares minItems: 1 but the StrictServer middleware does not - // enforce minItems at runtime; the handler must reject empty arrays + // enforce array bounds at runtime; the handler must reject empty arrays // before any outbound metadata fetches. req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) req.Body.AuthorizationDetails = []AuthorizationDetail{} @@ -130,7 +130,20 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) - assert.ErrorContains(t, err, "authorization_details must contain at least one entry") + assert.ErrorContains(t, err, "must contain exactly one entry") + }) + t.Run("error - multiple authorization_details", func(t *testing.T) { + // Schema declares maxItems: 1; same StrictServer gap as minItems. + req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) + req.Body.AuthorizationDetails = []AuthorizationDetail{ + {Type: "openid_credential", CredentialConfigurationId: "First"}, + {Type: "openid_credential", CredentialConfigurationId: "Second"}, + } + ctx := newTestClient(t) + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + assert.ErrorContains(t, err, "must contain exactly one entry") }) t.Run("error - invalid authorization endpoint in metadata", func(t *testing.T) { ctx := newTestClient(t) diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 8cfc6d17fb..c263535fa5 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -165,8 +165,13 @@ paths: used to locate the OAuth2 Authorization Server metadata. example: did:web:issuer.example.com authorization_details: + description: | + Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1. + The current implementation processes a single credential + issuance per call and only consumes the first entry. type: array minItems: 1 + maxItems: 1 items: $ref: '#/components/schemas/AuthorizationDetail' redirect_uri: diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 0ac4822eef..0f21fde90b 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -237,6 +237,9 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { + // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1. + // The current implementation processes a single credential + // issuance per call and only consumes the first entry. AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), From 32cb26cc07a86593bc1346a4efa488d67b6bcfc8 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 8 May 2026 20:49:36 +0200 Subject: [PATCH 43/44] docs: correct OpenID4VCI 1.0 spec section references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit pass against the spec. Several section pointers in code and YAML descriptions were imprecise or wrong: - 'Section 8.2.1.1' for the JWT typ value -> Appendix F.1 (jwt Proof Type) - 'Section 8.2.1' for proofs parameter -> Section 8.2 (with proof type formats listed in Appendix F) - '§10' for notification_id -> §11 (Notification Endpoint) - '§12.2' for the .well-known retrieval -> §12.2.2 (Credential Issuer Metadata Retrieval) - '§5.1' for authorization_details usage in YAML descriptions -> §5.1.1 (Using Authorization Details Parameter) Documentation only; no behaviour change. --- auth/api/iam/generated.go | 4 ++-- auth/openid4vci/client.go | 4 ++-- auth/openid4vci/types.go | 12 +++++++----- docs/_static/auth/v2.yaml | 4 ++-- e2e-tests/browser/client/iam/generated.go | 4 ++-- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 2ca64879c3..9e214e72fc 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -36,7 +36,7 @@ const ( UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP" ) -// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1. +// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1. // Only the fields used by the user/browser issuance flow are modeled. type AuthorizationDetail struct { // CredentialConfigurationId References a credential configuration from the issuer's @@ -243,7 +243,7 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1. + // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1. // The current implementation processes a single credential // issuance per call and only consumes the first entry. AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index 99f7905158..23f34b70e0 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -31,8 +31,8 @@ import ( "github.com/nuts-foundation/nuts-node/core" ) -// wellKnownPath is the path segment defined in OpenID4VCI 1.0 §12.2 for the -// Credential Issuer Metadata document. +// wellKnownPath is the path segment defined in OpenID4VCI 1.0 §12.2.2 for +// retrieving the Credential Issuer Metadata document. const wellKnownPath = "/.well-known/openid-credential-issuer" // RequestCredentialOpts carries all parameters for a Credential Request. diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go index 4a27da78f7..c763664246 100644 --- a/auth/openid4vci/types.go +++ b/auth/openid4vci/types.go @@ -35,7 +35,7 @@ import ( ) // JWTTypeOpenID4VCIProof is the JWT typ claim value used in OpenID4VCI key -// proofs (Section 8.2.1.1). +// proofs (Appendix F.1). const JWTTypeOpenID4VCIProof = "openid4vci-proof+jwt" // OpenIDCredentialIssuerMetadata describes the OpenID4VCI Credential Issuer @@ -70,7 +70,8 @@ type CredentialRequest struct { } // CredentialRequestProofs carries one or more key proofs in a Credential -// Request (Section 8.2.1). +// Request (the proofs parameter defined in Section 8.2; proof type formats +// are listed in Appendix F). type CredentialRequestProofs struct { JWT []string `json:"jwt,omitempty"` } @@ -80,9 +81,10 @@ type CredentialRequestProofs struct { // // TransactionID, Interval, and NotificationID are present for forward // compatibility (deferred issuance via HTTP 202 with a transaction id, and -// notification ids per §10). The auth-side flow today consumes only -// Credentials; the other fields are populated when the issuer sends them -// so they are available without a wire-format change later. +// notification ids consumed by the Notification Endpoint in §11). The +// auth-side flow today consumes only Credentials; the other fields are +// populated when the issuer sends them so they are available without a +// wire-format change later. type CredentialResponse struct { Credentials []CredentialResponseEntry `json:"credentials,omitempty"` TransactionID string `json:"transaction_id,omitempty"` diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index c263535fa5..ce1419e30f 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -166,7 +166,7 @@ paths: example: did:web:issuer.example.com authorization_details: description: | - Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1. + Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1. The current implementation processes a single credential issuance per call and only consumes the first entry. type: array @@ -719,7 +719,7 @@ components: The map key is the wallet owner (user/organization) AuthorizationDetail: description: | - A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1. + A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1. Only the fields used by the user/browser issuance flow are modeled. type: object required: diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 0f21fde90b..95a69b3541 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -37,7 +37,7 @@ const ( UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP" ) -// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1. +// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1. // Only the fields used by the user/browser issuance flow are modeled. type AuthorizationDetail struct { // CredentialConfigurationId References a credential configuration from the issuer's @@ -237,7 +237,7 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1. + // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1. // The current implementation processes a single credential // issuance per call and only consumes the first entry. AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` From 0050aece75ee126f0cb0fdd179ea041a4632bb99 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 11 May 2026 12:31:26 +0200 Subject: [PATCH 44/44] docs(deployment): add OpenID4VCI section to OAuth profile page List OpenID4VCI 1.0 among the implemented specs and document the wallet flow: Authorization Code with authorization_details, PKCE, Nonce Endpoint, proof JWT, and invalid_nonce recovery. Note the unimplemented operational features (deferred issuance, Notification Endpoint, multiple credentials per call) and the unrelated draft-11 internal flow in vcr/openid4vci. --- docs/pages/deployment/oauth.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/pages/deployment/oauth.rst b/docs/pages/deployment/oauth.rst index 61891dae61..71b7dcbb6c 100644 --- a/docs/pages/deployment/oauth.rst +++ b/docs/pages/deployment/oauth.rst @@ -14,6 +14,7 @@ The Nuts node implements (parts of) the following RFCs: - `RFC 9449 `_ - OAuth 2.0 Demonstrating Proof of Possession (DPoP) - `Nuts RFC021 `_ - RFC021 VP Token Grant Type - `OpenID4VP `_ - OpenID for Verifiable Presentations - draft 20 +- `OpenID4VCI `_ - OpenID for Verifiable Credential Issuance 1.0 (ID-1) - `StatusList2021 `_ - Status List 2021 - `Presentation Exchange `_ - Presentation Exchange @@ -49,6 +50,31 @@ The Nuts node implements the following: DPoP is optional, usage is determined by the client. +OpenID4VCI +********** + +The Nuts node implements the OpenID for Verifiable Credential Issuance 1.0 wallet flow. +On behalf of a user, the node requests a Verifiable Credential from a remote Credential Issuer over the Authorization Code Flow: + +- Authorization Request with ``authorization_details`` of type ``openid_credential`` (RFC 9396 / OpenID4VCI §5.1.1). +- PKCE for the authorization code, as in the OpenID4VP flow. +- Token Response with ``credential_identifiers`` per the requested ``credential_configuration_id`` (§6.2 and §3.3.4). +- Nonce Endpoint to obtain a fresh ``c_nonce`` before requesting a Credential (§7). +- Credential Request with a key proof JWT bound to the holder's DID (Appendix F.1). +- On an ``invalid_nonce`` response, the wallet fetches a fresh ``c_nonce`` and retries the Credential Request once (§8.3.1.2 prescribes fetching a new ``c_nonce``; retrying once is local policy). + +The relevant API: + +- ``POST /internal/auth/v2/{subjectID}/request-credential`` + +Not implemented: + +- Deferred issuance (HTTP 202 with ``transaction_id`` / ``interval``). +- The Notification Endpoint (§11). ``notification_id`` returned by the issuer is ignored. +- Multiple credentials per call: only a single ``authorization_details`` entry is accepted and only the first credential in the response is processed. + +Note: the unrelated internal flow in the ``vcr/openid4vci`` package is used by Nuts nodes to issue ``NutsAuthorizationCredential`` to each other over HTTP. That flow is based on a subset of OpenID4VCI draft-11 and is not a wallet implementation per §2. + DPoP ****