From de75f439491e812339409dfae758c2462068f165 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 24 Feb 2026 17:04:49 +0100 Subject: [PATCH 01/27] 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 8c02553c5c1f5e3953c7b3a42491cea0c4bb666d Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 24 Feb 2026 17:06:30 +0100 Subject: [PATCH 02/27] 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 423cb0cf03..231888e9e7 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -107,14 +107,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 { @@ -123,8 +123,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 } @@ -207,20 +207,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 @@ -230,34 +226,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. @@ -277,7 +281,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 { @@ -384,38 +388,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 @@ -427,12 +429,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) } @@ -443,8 +447,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 @@ -459,10 +475,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 { @@ -478,7 +495,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 }) @@ -487,13 +506,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 f81e25baca..cc8cde45e5 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 15654bb697c6c4157dfc8803135a2314644d8407 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 24 Feb 2026 17:07:33 +0100 Subject: [PATCH 03/27] 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 a8b0af6afde842b7c839b7151b594de9ad1978e5 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 24 Feb 2026 17:14:56 +0100 Subject: [PATCH 04/27] 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 39c93571b8..1fef1f20aa 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -201,8 +201,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: @@ -269,7 +271,7 @@ components: required: - credential_issuer - credential_endpoint - - credentials_supported + - credential_configurations_supported properties: credential_issuer: type: string @@ -278,24 +280,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: @@ -367,16 +373,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: @@ -390,9 +397,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", @@ -405,17 +412,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" @@ -440,12 +437,7 @@ components: example: 900 CredentialResponse: type: object - required: - - format properties: - format: - type: string - example: "ldp_vc" credential: type: object c_nonce: @@ -453,7 +445,6 @@ components: example: "fGFF7UkhLa" example: { - "format": "ldp_vc", "credential": { "@context": [ "https://www.w3.org/2018/credentials/v1", @@ -485,33 +476,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 6ee11b22e67c41b12f37ef316bd0ec0fc7643a66 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Fri, 6 Mar 2026 20:06:32 +0100 Subject: [PATCH 05/27] 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 | 27 +++---- 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, 145 insertions(+), 242 deletions(-) diff --git a/docs/_static/vcr/openid4vci_v0.yaml b/docs/_static/vcr/openid4vci_v0.yaml index 1fef1f20aa..5363ecef07 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -384,21 +384,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", @@ -413,9 +412,8 @@ components: example: { "credential_configuration_id": "NutsAuthorizationCredential_ldp_vc", - "proof": { - "proof_type": "jwt", - "jwt": "eyJraWQiOiJkaWQ6ZXhhbXBsZ...KPxgihac0aW9EkL1nOzM" + "proofs": { + "jwt": ["eyJraWQiOiJkaWQ6ZXhhbXBsZ...KPxgihac0aW9EkL1nOzM"] } } ErrorResponse: @@ -427,50 +425,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 231888e9e7..7c6d70f7c8 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -281,34 +281,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) }, jwt.WithAcceptableSkew(5*time.Second)) @@ -347,7 +340,7 @@ 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(request.Proof.Jwt) + message, err := jws.ParseString(proofJWT) if err != nil { // Should not fail return err diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index cc8cde45e5..3dab9c0402 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 a09c50a4cb222baa4fd83727bf65cea6ab773df7 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 9 Mar 2026 19:38:04 +0100 Subject: [PATCH 06/27] 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 0020fea550..841367dc00 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -38,6 +38,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" ) @@ -242,6 +243,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 { @@ -308,34 +335,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) @@ -357,15 +387,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 5016708d9d..397bae9810 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" @@ -52,8 +53,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 add1a059cd..71077fc996 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 0f9e370601..5b14e39ceb 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 e5c4ef6840..7422661bef 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" @@ -523,6 +524,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) } @@ -577,10 +579,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) { @@ -627,6 +635,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) @@ -680,40 +693,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 5363ecef07..94d8476532 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -3,8 +3,7 @@ info: title: OpenID4VCI Issuer API version: 0.0.0 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. @@ -217,10 +216,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: @@ -280,6 +306,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: | @@ -358,17 +390,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 @@ -502,6 +528,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 7c6d70f7c8..560706e7bd 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -73,21 +73,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/ @@ -121,6 +123,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. @@ -140,20 +143,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, @@ -162,12 +165,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 @@ -179,7 +177,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 { @@ -274,29 +272,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] @@ -306,20 +312,20 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o return i.keyResolver.ResolveKeyByID(kid, nil, resolver.NutsSigningKeyType) }, jwt.WithAcceptableSkew(5*time.Second)) 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 @@ -331,11 +337,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 @@ -351,51 +357,48 @@ 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 3dab9c0402..85ca69fe33 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 1aa3837118ae0b189f5b0103ad11725a09e8f820 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 9 Mar 2026 20:05:40 +0100 Subject: [PATCH 07/27] 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 560706e7bd..2de6895d78 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -387,7 +387,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 85ca69fe33..3b78f9263f 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 906dd0f1b1679294da8d77446457fa21cc9da501 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 9 Mar 2026 21:17:26 +0100 Subject: [PATCH 08/27] 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 e0dfecbd49c8d01a8965bc44ed5c06d755043814 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 13:47:58 +0100 Subject: [PATCH 09/27] 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 94d8476532..bd20845c64 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -366,6 +366,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 @@ -431,6 +437,7 @@ components: "kid": "did:nuts:ebfeb1f712ebc6f1c276e12ec21#keys-1" }. { + "iss": "did:nuts:ebfeb1f712ebc6f1c276e12ec21", "aud": "https://credential-issuer.example.com", "iat": 1659145924, "nonce": "tZignsnFbp" @@ -516,6 +523,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 301be91758530eac410da53c9227c9398f5c2eb3 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 15:39:15 +0100 Subject: [PATCH 10/27] 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 a0b4a31a59272c94e367b0bbca3bff9506774c4b Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 16:08:42 +0100 Subject: [PATCH 11/27] 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 bd20845c64..b6ac458d5e 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -534,19 +534,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 1d66b58597885489cfb2062f9c4345e31aac1b17 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 16:48:02 +0100 Subject: [PATCH 12/27] 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 2de6895d78..364db0c2c7 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -510,14 +510,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 c0e8347f2752626e09b67e0d61caa555895190d3 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 17:05:45 +0100 Subject: [PATCH 13/27] 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 364db0c2c7..01c560147a 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -510,13 +510,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 9cae8c83af546e84047b9580db0cae23af9945a9 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 17:44:07 +0100 Subject: [PATCH 14/27] 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 841367dc00..69dcc34a43 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -248,7 +248,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) } @@ -266,6 +266,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 01c560147a..7145246cf5 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -319,6 +319,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{ @@ -417,7 +426,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, }, @@ -510,9 +519,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 } @@ -587,7 +601,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 { @@ -608,7 +622,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 3b78f9263f..04db3d0433 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 355330a30cf578e16d5f0ac45cf8b1e0c0cf9505 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 19:04:54 +0100 Subject: [PATCH 15/27] 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 69dcc34a43..f5b6d40f13 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -340,7 +340,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"` } @@ -366,7 +366,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 b6ac458d5e..e9a8b1821c 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -408,8 +408,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 7145246cf5..6140360b82 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -205,9 +205,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, @@ -248,9 +248,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, } @@ -287,7 +287,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 @@ -425,7 +425,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 04db3d0433..965d5efb04 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 dfc9f6c97c4a18dbe7c9f710738ea03d192ac179 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Tue, 10 Mar 2026 21:11:45 +0100 Subject: [PATCH 16/27] 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 276bf0aa5d25f564dd10830b7d636df4e5796c3f Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Wed, 11 Mar 2026 14:20:55 +0100 Subject: [PATCH 17/27] 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 f5b6d40f13..db9694e4f2 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -338,36 +338,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}, }, } @@ -402,7 +381,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 397bae9810..5d55262aa7 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" ) @@ -56,7 +57,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 71077fc996..d8a3e4192e 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 5b14e39ceb..a3d56cb2dc 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 2474568958086659dec627650cf20fb52b4a64c5 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/27] 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 6140360b82..8dc6935313 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -109,14 +109,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 965d5efb04..2f82464016 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 74bfc6039e4edda3aae5260f8a79acb23465d0e9 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Wed, 11 Mar 2026 14:49:46 +0100 Subject: [PATCH 19/27] feat(openid4vci): validate authorization_details against metadata Validate authorization_details entries per v1.0 Section 5.1.1: - type must be "openid_credential" - credential_configuration_id is required and must exist in issuer credential_configurations_supported - Inject locations field when authorization_servers is present - Sanitize entries to only known keys to prevent arbitrary JSON passthrough - Reject multiple entries (single credential issuance only) Add CredentialConfigurationsSupported to OpenIDCredentialIssuerMetadata. Also fix nil context usage throughout openid4vci_test.go: use context.Background() for method calls and gomock.Any() for mock expectations. --- auth/api/iam/openid4vci.go | 47 +++++- auth/api/iam/openid4vci_test.go | 284 ++++++++++++++++++++++---------- auth/oauth/types.go | 11 +- 3 files changed, 248 insertions(+), 94 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index e9b3eb5daf..0f9715221b 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -76,13 +76,18 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques clientID := r.subjectToBaseURL(request.SubjectID) - // Read and parse the authorization details + // Validate and process 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 + var sanitized []map[string]interface{} + credentialConfigID, sanitized, err = validateAuthorizationDetails(request.Body.AuthorizationDetails, credentialIssuerMetadata) + if err != nil { + return nil, core.InvalidInputError("%s", err) + } + authorizationDetails, err = json.Marshal(sanitized) + if err != nil { + return nil, fmt.Errorf("failed to marshal authorization_details: %w", err) } } // Generate the state and PKCE @@ -226,3 +231,37 @@ func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audien } return proofJwt, nil } + +// validateAuthorizationDetails validates the authorization_details entries per v1.0 Section 5.1.1. +// It returns the credential_configuration_id and sanitized entries (only known keys, with locations injected). +// Only a single entry is supported; multiple entries are rejected. +func validateAuthorizationDetails(details []map[string]interface{}, metadata *oauth.OpenIDCredentialIssuerMetadata) (string, []map[string]interface{}, error) { + if len(details) != 1 { + return "", nil, errors.New("invalid authorization_details: exactly one entry is supported") + } + if len(metadata.CredentialConfigurationsSupported) == 0 { + return "", nil, errors.New("invalid authorization_details: issuer does not advertise any credential configurations") + } + entry := details[0] + typ, _ := entry["type"].(string) + if typ != "openid_credential" { + return "", nil, errors.New("invalid authorization_details: type must be \"openid_credential\"") + } + configID, ok := entry["credential_configuration_id"].(string) + if !ok || configID == "" { + return "", nil, errors.New("invalid authorization_details: credential_configuration_id is required") + } + if _, exists := metadata.CredentialConfigurationsSupported[configID]; !exists { + return "", nil, fmt.Errorf("invalid authorization_details: credential_configuration_id %q not found in issuer metadata", configID) + } + // Build sanitized entry with only known fields + sanitized := map[string]interface{}{ + "type": typ, + "credential_configuration_id": configID, + } + // Inject locations when authorization_servers is present (v1.0 Section 5.1.1) + if len(metadata.AuthorizationServers) > 0 { + sanitized["locations"] = []string{metadata.CredentialIssuer} + } + return configID, []map[string]interface{}{sanitized}, nil +} diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 7cdf016dfa..596a4b60b3 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -44,28 +44,31 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { CredentialIssuer: "issuer", CredentialEndpoint: "endpoint", AuthorizationServers: []string{authServer}, - Display: nil, + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc"}, + }, + Display: nil, } authzMetadata := oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: "https://auth.server/authorize", TokenEndpoint: "https://auth.server/token", ClientIdSchemesSupported: clientIdSchemesSupported, } - t.Run("ok", func(t *testing.T) { + t.Run("ok - locations injected when authorization_servers present", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{ + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "format": "vc+sd-jwt"}}, + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), }, }) require.NoError(t, err) - require.NotNil(t, response) //RequestOid4vciCredentialIssuanceResponseObject + require.NotNil(t, response) redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) require.NoError(t, err) assert.Equal(t, "auth.server", redirectUri.Host) @@ -76,7 +79,118 @@ 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":"NutsOrganizationCredential_ldp_vc","locations":["issuer"],"type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + }) + t.Run("ok - no locations when authorization_servers absent", func(t *testing.T) { + ctx := newTestClient(t) + metadataNoAS := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: issuerClientID, + CredentialEndpoint: "endpoint", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc"}, + }, + } + authzMetadataLocal := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadataNoAS, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), issuerClientID).Return(&authzMetadataLocal, nil) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, `[{"credential_configuration_id":"NutsOrganizationCredential_ldp_vc","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + }) + t.Run("ok - unknown keys in authorization_details are stripped", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc", "evil_key": "injected"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, `[{"credential_configuration_id":"NutsOrganizationCredential_ldp_vc","locations":["issuer"],"type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + }) + t.Run("error - multiple authorization_details entries", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{ + {"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}, + {"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}, + }, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: exactly one entry is supported") + }) + t.Run("error - authorization_details type is not openid_credential", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "invalid_type", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: type must be \"openid_credential\"") + }) + t.Run("error - authorization_details entry missing credential_configuration_id", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: credential_configuration_id is required") + }) + t.Run("error - credential_configuration_id not in issuer metadata", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "unknown_config"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: credential_configuration_id \"unknown_config\" not found in issuer metadata") }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { @@ -87,25 +201,25 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { AuthorizationServers: []string{}, // empty Display: nil, } - ctx.iamClient.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)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), issuerClientID).Return(nil, assert.AnError) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) 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.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerClientID).Return(nil, assert.AnError) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(nil, assert.AnError) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), issuerClientID).Return(nil, assert.AnError) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(nil, assert.AnError) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) 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) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(nil, assert.AnError) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) }) @@ -114,21 +228,21 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { req.Body.Issuer = "" ctx := newTestClient(t) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), req) assert.EqualError(t, err, "issuer is empty") }) 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.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) invalidAuthzMetadata := oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: ":", TokenEndpoint: "https://auth.server/token", ClientIdSchemesSupported: []string{"did"}, } - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&invalidAuthzMetadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&invalidAuthzMetadata, nil) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "failed to parse the authorization_endpoint: parse \":\": missing protocol scheme") }) @@ -136,27 +250,27 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { ctx := newTestClient(t) metadata := metadata metadata.CredentialEndpoint = "" - ctx.iamClient.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)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no credential_endpoint found") }) t.Run("error - missing authorization_endpoint", func(t *testing.T) { ctx := newTestClient(t) authzMetadata := authzMetadata authzMetadata.AuthorizationEndpoint = "" - ctx.iamClient.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)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no authorization_endpoint found") }) t.Run("error - missing token_endpoint", func(t *testing.T) { ctx := newTestClient(t) authzMetadata := authzMetadata authzMetadata.TokenEndpoint = "" - ctx.iamClient.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)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no token_endpoint found") }) } @@ -215,8 +329,8 @@ func TestWrapper_handleOpenID4VCICallback(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.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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,11 +343,11 @@ 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) - ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) - callback, err := ctx.client.Callback(nil, CallbackRequestObject{ + callback, err := ctx.client.Callback(context.Background(), CallbackRequestObject{ SubjectID: holderSubjectID, Params: CallbackParams{ Code: to.Ptr(code), @@ -248,18 +362,18 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) 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.iamClient.EXPECT().AccessToken(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) - ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionWithoutNonce) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionWithoutNonce) require.NoError(t, err) assert.NotNil(t, callback) @@ -269,20 +383,20 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.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) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) // retry with fresh nonce - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(freshNonce, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) - ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) require.NoError(t, err) assert.NotNil(t, callback) @@ -291,17 +405,17 @@ func TestWrapper_handleOpenID4VCICallback(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.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) // retry also fails - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("fresh-nonce", nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(nil, errors.New("still failing")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "error while fetching the credential from endpoint") @@ -310,34 +424,34 @@ func TestWrapper_handleOpenID4VCICallback(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.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, invalidNonceErr) // retry nonce fetch fails - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint down")) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("", errors.New("nonce endpoint down")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) 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")) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("", errors.New("nonce endpoint unavailable")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), 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")) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Error(t, err) assert.Nil(t, callback) @@ -345,65 +459,65 @@ 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.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, errors.New("FAIL")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.EqualError(t, err, "server_error - error while fetching the credential from endpoint https://auth.server/credz, error: FAIL") }) 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.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), 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) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) 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.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), 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) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.EqualError(t, err, "server_error - error while verifying the credential from issuer: did:web:example.com:iam:issuer, error: FAIL") }) 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.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("", nil, resolver.ErrKeyNotFound) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to resolve key for did (did:web:example.com:iam:holder): "+resolver.ErrKeyNotFound.Error()) }) 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.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to sign the JWT with kid (kid): signature failed") @@ -413,22 +527,22 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { sessionNilDID := session sessionNilDID.OwnDID = nil - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionNilDID) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), 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.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{}, }, nil) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "credential response does not contain any credentials") diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 4224c072ae..340ca3fae8 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -405,11 +405,12 @@ type Redirect struct { // 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"` + CredentialIssuer string `json:"credential_issuer"` + CredentialEndpoint string `json:"credential_endpoint"` + NonceEndpoint string `json:"nonce_endpoint,omitempty"` + AuthorizationServers []string `json:"authorization_servers,omitempty"` + CredentialConfigurationsSupported map[string]map[string]interface{} `json:"credential_configurations_supported,omitempty"` + Display []map[string]string `json:"display,omitempty"` } // OpenIDConfiguration represents the OpenID configuration From a2e52674ce06422c7b66206c8e5e7abcba915fb6 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Wed, 11 Mar 2026 15:24:59 +0100 Subject: [PATCH 20/27] feat(openid4vci): validate proof_signing_alg_values_supported Check holder's signing algorithm against the issuer's advertised proof_signing_alg_values_supported (v1.0 Appendix F.1) in both the authorization code flow and pre-authorized code flow. Shared validation logic extracted to openid4vci.ValidateProofSigningAlg. --- auth/api/iam/openid4vci.go | 32 +++++++-- auth/api/iam/openid4vci_test.go | 36 ++++++++++ auth/api/iam/session.go | 2 + vcr/holder/openid.go | 19 +++++- vcr/holder/openid_test.go | 114 ++++++++++++++++++++++++++++++++ vcr/openid4vci/types.go | 44 +++++++++++- vcr/openid4vci/types_test.go | 66 ++++++++++++++++++ 7 files changed, 307 insertions(+), 6 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 0f9715221b..f9726368f2 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -96,6 +96,16 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // Figure out our own redirect URL by parsing the did:web and extracting the host. redirectUri := clientID.JoinPath(oauth.CallbackPath) + // Extract proof_signing_alg_values_supported from the credential configuration (v1.0 Appendix F.1) + var proofSigningAlgValues []string + if credentialConfigID != "" { + if config, exists := credentialIssuerMetadata.CredentialConfigurationsSupported[credentialConfigID]; exists { + proofSigningAlgValues, err = openid4vci.ProofSigningAlgValues(config) + if err != nil { + return nil, core.Error(http.StatusFailedDependency, "%s", err) + } + } + } // Store the session err = r.oauthClientStateStore().Put(state, &OAuthSession{ AuthorizationServerMetadata: authzServerMetadata, @@ -111,6 +121,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, + ProofSigningAlgValuesSupported: proofSigningAlgValues, }) if err != nil { return nil, fmt.Errorf("failed to store session: %w", err) @@ -201,25 +212,38 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode } 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) + proofJWT, err := r.openid4vciProof(ctx, oauthSession, 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) +func (r *Wrapper) openid4vciProof(ctx context.Context, session *OAuthSession, nonce string) (string, error) { + if session.OwnDID == nil { + return "", errors.New("session has no holder DID") + } + holderDid := *session.OwnDID + kid, pubKey, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod) if err != nil { return "", fmt.Errorf("failed to resolve key for did (%s): %w", holderDid.String(), err) } + if len(session.ProofSigningAlgValuesSupported) > 0 { + alg, algErr := crypto.SignatureAlgorithm(pubKey) + if algErr != nil { + return "", fmt.Errorf("failed to determine signing algorithm: %w", algErr) + } + if err = openid4vci.ValidateProofSigningAlg(alg.String(), session.ProofSigningAlgValuesSupported); err != nil { + return "", 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": 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(), - jwt.AudienceKey: audience, // Credential Issuer Identifier + jwt.AudienceKey: session.IssuerURL, // Credential Issuer Identifier jwt.IssuedAtKey: timeFunc().Unix(), } if nonce != "" { diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 596a4b60b3..00092e441f 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -20,6 +20,9 @@ package iam import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "encoding/json" "errors" "net/url" @@ -532,6 +535,39 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "missing wallet DID in session") }) + t.Run("error - signing algorithm not supported by issuer", func(t *testing.T) { + ctx := newTestClient(t) + p256Key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + sessionAlgMismatch := session + sessionAlgMismatch.ProofSigningAlgValuesSupported = []string{"ES384"} + + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", &p256Key.PublicKey, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionAlgMismatch) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "signing algorithm ES256 is not supported by issuer (supported: ES384)") + }) + t.Run("ok - algorithm validation skipped when proof_signing_alg_values_supported absent", func(t *testing.T) { + ctx := newTestClient(t) + sessionNoAlg := session + sessionNoAlg.ProofSigningAlgValuesSupported = nil + + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionNoAlg) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) t.Run("error - empty credentials array", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 09ef6fcd9e..1fcebbdcc0 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -59,6 +59,8 @@ 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"` + // ProofSigningAlgValuesSupported: algorithms the issuer accepts for proof JWTs (v1.0 Appendix F.1) + ProofSigningAlgValuesSupported []string `json:"proof_signing_alg_values_supported,omitempty"` } // oauthClientFlow is used by a client to identify the flow a particular callback is part of diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index 3ae8b5a327..c4b23bf6c2 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -250,10 +250,27 @@ func (h *openidHandler) resolveCredentialConfiguration(metadata openid4vci.Crede } 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) + keyID, pubKey, err := h.resolver.ResolveKey(h.did, nil, resolver.NutsSigningKeyType) if err != nil { return nil, err } + if credentialConfigID != "" { + if config, exists := issuerClient.Metadata().CredentialConfigurationsSupported[credentialConfigID]; exists { + supportedAlgs, algErr := openid4vci.ProofSigningAlgValues(config) + if algErr != nil { + return nil, algErr + } + if len(supportedAlgs) > 0 { + alg, algErr := crypto.SignatureAlgorithm(pubKey) + if algErr != nil { + return nil, fmt.Errorf("failed to determine signing algorithm: %w", algErr) + } + if err = openid4vci.ValidateProofSigningAlg(alg.String(), supportedAlgs); err != nil { + return nil, err + } + } + } + } const maxAttempts = 2 for attempt := range maxAttempts { diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index c3e749bd99..d14256ee0a 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -20,6 +20,9 @@ package holder import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "errors" "net/http" "testing" @@ -568,6 +571,117 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { }) } +func Test_wallet_ProofSigningAlgValidation(t *testing.T) { + credentialOffer := openid4vci.CredentialOffer{ + CredentialIssuer: issuerDID.String(), + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "code", + }, + }, + } + t.Run("error - signing algorithm not supported by issuer", func(t *testing.T) { + ctrl := gomock.NewController(t) + metadataAlgRestricted := 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", + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{ + "proof_signing_alg_values_supported": []interface{}{"ES384"}, + }, + }, + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://example.com/credentials/v1"}, + "type": []interface{}{"VerifiableCredential", "ExampleCredential"}, + }, + }, + }, + } + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataAlgRestricted).AnyTimes() + 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) + + p256Key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", &p256Key.PublicKey, nil) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, crypto.NewMockJWTSigner(ctrl), 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: signing algorithm ES256 is not supported by issuer (supported: ES384)") + }) + t.Run("ok - algorithm validation skipped when proof_types_supported absent", func(t *testing.T) { + ctrl := gomock.NewController(t) + metadataNoProofTypes := 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"}, + }, + }, + }, + } + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataNoProofTypes).AnyTimes() + nonce := "nonce-from-endpoint" + 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, _ 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) + }) +} + // offeredCredential returns a resolved credential configuration for testing. func offeredCredential() []openid4vci.OfferedCredential { return []openid4vci.OfferedCredential{{ diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index d2afe68753..95d7712cc0 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -22,8 +22,12 @@ package openid4vci import ( "encoding/json" - ssi "github.com/nuts-foundation/go-did" + "fmt" + "slices" + "strings" "time" + + ssi "github.com/nuts-foundation/go-did" ) // PreAuthorizedCodeGrant is the grant type used for pre-authorized code grant from the OpenID4VCI specification. @@ -178,6 +182,44 @@ type CredentialResponseEntry struct { Credential json.RawMessage `json:"credential"` } +// ProofSigningAlgValues extracts proof_signing_alg_values_supported from a credential configuration's +// proof_types_supported.jwt section (v1.0 Appendix F.1). +// Returns nil if proof_types_supported is absent or does not contain a jwt key. +// Returns an error if jwt is present but proof_signing_alg_values_supported is missing (spec violation). +func ProofSigningAlgValues(config map[string]interface{}) ([]string, error) { + proofTypes, ok := config["proof_types_supported"].(map[string]interface{}) + if !ok { + return nil, nil + } + jwtConfig, ok := proofTypes["jwt"].(map[string]interface{}) + if !ok { + return nil, nil + } + algValues, ok := jwtConfig["proof_signing_alg_values_supported"].([]interface{}) + if !ok { + return nil, fmt.Errorf("issuer metadata has proof_types_supported.jwt but is missing proof_signing_alg_values_supported") + } + result := make([]string, 0, len(algValues)) + for _, v := range algValues { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + return result, nil +} + +// ValidateProofSigningAlg checks that the given algorithm is in the issuer's supported list. +// If supportedAlgs is empty, validation is skipped (issuer imposes no constraint). +func ValidateProofSigningAlg(alg string, supportedAlgs []string) error { + if len(supportedAlgs) == 0 { + return nil + } + if !slices.Contains(supportedAlgs, alg) { + return fmt.Errorf("signing algorithm %s is not supported by issuer (supported: %s)", alg, strings.Join(supportedAlgs, ", ")) + } + return nil +} + // Config holds the config for the OpenID4VCI credential issuer and wallet type Config struct { // DefinitionsDIR defines the directory where the additional credential definitions are stored diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go index aad0d70586..7987458248 100644 --- a/vcr/openid4vci/types_test.go +++ b/vcr/openid4vci/types_test.go @@ -232,6 +232,72 @@ func TestCredentialResponse_V1Spec(t *testing.T) { }) } +func TestProofSigningAlgValues(t *testing.T) { + t.Run("returns values when present", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{ + "proof_signing_alg_values_supported": []interface{}{"ES256", "ES384"}, + }, + }, + } + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Equal(t, []string{"ES256", "ES384"}, result) + }) + t.Run("returns nil when proof_types_supported absent", func(t *testing.T) { + config := map[string]interface{}{"format": "ldp_vc"} + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Nil(t, result) + }) + t.Run("returns nil when jwt absent in proof_types_supported", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "cwt": map[string]interface{}{}, + }, + } + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Nil(t, result) + }) + t.Run("error - jwt present but proof_signing_alg_values_supported absent", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{}, + }, + } + result, err := ProofSigningAlgValues(config) + assert.Nil(t, result) + assert.EqualError(t, err, "issuer metadata has proof_types_supported.jwt but is missing proof_signing_alg_values_supported") + }) + t.Run("skips non-string values in algorithm array", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{ + "proof_signing_alg_values_supported": []interface{}{"ES256", 42, "ES384"}, + }, + }, + } + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Equal(t, []string{"ES256", "ES384"}, result) + }) +} + +func TestValidateProofSigningAlg(t *testing.T) { + t.Run("ok - algorithm is supported", func(t *testing.T) { + assert.NoError(t, ValidateProofSigningAlg("ES256", []string{"ES256", "ES384"})) + }) + t.Run("ok - no constraint when supportedAlgs is empty", func(t *testing.T) { + assert.NoError(t, ValidateProofSigningAlg("ES256", nil)) + }) + t.Run("error - algorithm not supported", func(t *testing.T) { + err := ValidateProofSigningAlg("ES256", []string{"ES384", "ES512"}) + assert.EqualError(t, err, "signing algorithm ES256 is not supported by issuer (supported: ES384, ES512)") + }) +} + // TestCredentialDefinition_Validation tests credential definition validation func TestCredentialDefinition_Validation(t *testing.T) { t.Run("valid definition", func(t *testing.T) { From cbfd342aca8d5a641aa73562edf52e7379002e28 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Wed, 11 Mar 2026 15:40:47 +0100 Subject: [PATCH 21/27] feat(openid4vci): detect deferred credential issuance Detect transaction_id in credential responses (v1.0 Section 8.3) and return a clear error instead of a generic "no credentials" message. The transaction_id value is logged at warn level but excluded from error messages to prevent leaking issuer-internal state. --- auth/api/iam/openid4vci.go | 3 +++ auth/api/iam/openid4vci_test.go | 15 +++++++++++++++ vcr/openid4vci/issuer_client.go | 4 ++++ vcr/openid4vci/issuer_client_test.go | 11 +++++++++++ vcr/openid4vci/types.go | 4 +++- vcr/openid4vci/types_test.go | 26 ++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 1 deletion(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index f9726368f2..60ba965bbf 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -189,6 +189,9 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) } } + if credentialResponse.TransactionID != "" { + return nil, withCallbackURI(oauthError(oauth.ServerError, "deferred credential issuance is not supported"), appCallbackURI) + } if len(credentialResponse.Credentials) == 0 { return nil, withCallbackURI(oauthError(oauth.ServerError, "credential response does not contain any credentials"), appCallbackURI) } diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 00092e441f..36bbaca1e0 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -568,6 +568,21 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { require.NoError(t, err) assert.NotNil(t, callback) }) + t.Run("error - deferred issuance not supported", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + TransactionID: "txn-456", + }, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "deferred credential issuance is not supported") + }) t.Run("error - empty credentials array", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index d7f91dc484..a807a45b54 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -102,6 +102,10 @@ func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request C if err != nil { return nil, err } + if credentialResponse.TransactionID != "" { + log.Logger().Warnf("Issuer returned deferred credential response (transaction_id: %s)", credentialResponse.TransactionID) + return nil, errors.New("deferred credential issuance is not supported") + } if len(credentialResponse.Credentials) == 0 { return nil, errors.New("credential response does not contain any credentials") } diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index 8f4b07c7bc..cf56691c4e 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -101,6 +101,17 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { require.NoError(t, err) require.NotNil(t, credential) }) + t.Run("error - deferred issuance not supported", func(t *testing.T) { + setup := setupClientTest(t) + setup.credentialHandler = setup.httpPostHandler(CredentialResponse{TransactionID: "txn-123"}) + client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) + require.NoError(t, err) + + credential, err := client.RequestCredential(ctx, credentialRequest, "token") + + require.EqualError(t, err, "deferred credential issuance is not supported") + require.Nil(t, credential) + }) t.Run("error - no credentials in response", func(t *testing.T) { setup := setupClientTest(t) setup.credentialHandler = setup.httpPostHandler(CredentialResponse{}) diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index 95d7712cc0..60de3a395d 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -172,8 +172,10 @@ type CredentialRequestProofs struct { // 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. +// When deferred issuance is used (Section 8.3), the response contains a `transaction_id` instead of credentials. type CredentialResponse struct { - Credentials []CredentialResponseEntry `json:"credentials,omitempty"` + Credentials []CredentialResponseEntry `json:"credentials,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` } // CredentialResponseEntry is a single entry in the credentials array of a CredentialResponse. diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go index 7987458248..9b702a06f2 100644 --- a/vcr/openid4vci/types_test.go +++ b/vcr/openid4vci/types_test.go @@ -214,6 +214,32 @@ func TestCredentialResponse_V1Spec(t *testing.T) { assert.NotNil(t, entry["credential"], "each entry must have a credential key") }) + t.Run("deferred response with transaction_id", func(t *testing.T) { + responseJSON := `{"transaction_id": "txn-abc"}` + + var response CredentialResponse + err := json.Unmarshal([]byte(responseJSON), &response) + require.NoError(t, err) + + assert.Equal(t, "txn-abc", response.TransactionID) + assert.Empty(t, response.Credentials) + }) + t.Run("transaction_id omitted when empty", 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) + + _, hasTransactionID := parsed["transaction_id"] + assert.False(t, hasTransactionID, "transaction_id must be absent when empty") + }) 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{ From 26242a3018df52258acfb5f7b569b64ff279b3bd Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Wed, 11 Mar 2026 17:46:29 +0100 Subject: [PATCH 22/27] feat(openid4vci): add PAR support (RFC 9126) Use Pushed Authorization Requests when the AS metadata advertises a pushed_authorization_request_endpoint. All authorization parameters are POSTed server-to-server; the browser redirect carries only client_id and the returned request_uri. Falls back to query parameters when PAR is not advertised. --- auth/api/iam/openid4vci.go | 38 ++++++++++++++----- auth/api/iam/openid4vci_test.go | 62 +++++++++++++++++++++++++++++++ auth/client/iam/client.go | 32 ++++++++++++++++ auth/client/iam/interface.go | 9 +++++ auth/client/iam/mock.go | 16 ++++++++ auth/client/iam/openid4vp.go | 8 ++++ auth/client/iam/openid4vp_test.go | 56 ++++++++++++++++++++++++++++ auth/oauth/types.go | 3 ++ 8 files changed, 214 insertions(+), 10 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 60ba965bbf..24932859bc 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -131,16 +131,34 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques if err != nil { return nil, fmt.Errorf("failed to parse the authorization_endpoint: %w", err) } - redirectUrl := nutsHttp.AddQueryParams(*authorizationEndpoint, map[string]string{ - oauth.ResponseTypeParam: oauth.CodeResponseType, - oauth.StateParam: state, - oauth.ClientIDParam: clientID.String(), - oauth.ClientIDSchemeParam: entityClientIDScheme, - oauth.AuthorizationDetailsParam: string(authorizationDetails), - oauth.RedirectURIParam: redirectUri.String(), - oauth.CodeChallengeParam: pkceParams.Challenge, - oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, - }) + authzParams := url.Values{ + oauth.ResponseTypeParam: {oauth.CodeResponseType}, + oauth.StateParam: {state}, + oauth.ClientIDParam: {clientID.String()}, + oauth.ClientIDSchemeParam: {entityClientIDScheme}, + oauth.AuthorizationDetailsParam: {string(authorizationDetails)}, + oauth.RedirectURIParam: {redirectUri.String()}, + oauth.CodeChallengeParam: {pkceParams.Challenge}, + oauth.CodeChallengeMethodParam: {pkceParams.ChallengeMethod}, + } + + var redirectUrl url.URL + if authzServerMetadata.PushedAuthorizationRequestEndpoint != "" { + parResponse, parErr := r.auth.IAMClient().PushedAuthorizationRequest(ctx, authzServerMetadata.PushedAuthorizationRequestEndpoint, authzParams) + if parErr != nil { + return nil, fmt.Errorf("PAR request failed: %w", parErr) + } + redirectUrl = nutsHttp.AddQueryParams(*authorizationEndpoint, map[string]string{ + oauth.ClientIDParam: clientID.String(), + "request_uri": parResponse.RequestURI, + }) + } else { + params := make(map[string]string, len(authzParams)) + for k, v := range authzParams { + params[k] = v[0] + } + redirectUrl = nutsHttp.AddQueryParams(*authorizationEndpoint, params) + } return RequestOpenid4VCICredentialIssuance200JSONResponse{ RedirectURI: redirectUrl.String(), diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 36bbaca1e0..d316b371a7 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -31,6 +31,7 @@ import ( "github.com/nuts-foundation/nuts-node/core/to" + iamclient "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" @@ -195,6 +196,67 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { }) assert.EqualError(t, err, "invalid authorization_details: credential_configuration_id \"unknown_config\" not found in issuer metadata") }) + t.Run("ok - uses PAR when endpoint advertised", func(t *testing.T) { + ctx := newTestClient(t) + parEndpoint := "https://auth.server/par" + authzMetadataWithPAR := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + PushedAuthorizationRequestEndpoint: parEndpoint, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadataWithPAR, nil) + ctx.iamClient.EXPECT().PushedAuthorizationRequest(gomock.Any(), parEndpoint, gomock.Any()).DoAndReturn(func(_ context.Context, _ string, params url.Values) (*iamclient.PARResponse, error) { + assert.Equal(t, oauth.CodeResponseType, params.Get(oauth.ResponseTypeParam)) + assert.Equal(t, holderClientID, params.Get(oauth.ClientIDParam)) + assert.NotEmpty(t, params.Get(oauth.StateParam)) + assert.NotEmpty(t, params.Get(oauth.CodeChallengeParam)) + return &iamclient.PARResponse{RequestURI: "urn:ietf:params:oauth:request_uri:xyz", ExpiresIn: 60}, nil + }) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + require.NotNil(t, response) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, "auth.server", redirectUri.Host) + assert.Equal(t, "/authorize", redirectUri.Path) + assert.Equal(t, holderClientID, redirectUri.Query().Get("client_id")) + assert.Equal(t, "urn:ietf:params:oauth:request_uri:xyz", redirectUri.Query().Get("request_uri")) + assert.Empty(t, redirectUri.Query().Get("state"), "state should not be in redirect when using PAR") + assert.Empty(t, redirectUri.Query().Get("code_challenge"), "code_challenge should not be in redirect when using PAR") + }) + t.Run("error - PAR request fails", func(t *testing.T) { + ctx := newTestClient(t) + parEndpoint := "https://auth.server/par" + authzMetadataWithPAR := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + PushedAuthorizationRequestEndpoint: parEndpoint, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadataWithPAR, nil) + ctx.iamClient.EXPECT().PushedAuthorizationRequest(gomock.Any(), parEndpoint, gomock.Any()).Return(nil, errors.New("PAR failed")) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "PAR request failed: PAR failed") + }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { ctx := newTestClient(t) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index db9694e4f2..f5e7243bd1 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -338,6 +338,38 @@ func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc { } } +func (hb HTTPClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parEndpoint, strings.NewReader(params.Encode())) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := hb.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("PAR request failed: %w", err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("unable to read PAR response: %w", err) + } + if response.StatusCode != http.StatusCreated { + bodySnippet := string(data) + if len(bodySnippet) > core.HttpResponseBodyLogClipAt { + bodySnippet = bodySnippet[:core.HttpResponseBodyLogClipAt] + "...(clipped)" + } + return nil, fmt.Errorf("PAR endpoint returned HTTP %d (expected: 201): %s", response.StatusCode, bodySnippet) + } + var parResponse PARResponse + if err = json.Unmarshal(data, &parResponse); err != nil { + return nil, fmt.Errorf("unable to unmarshal PAR response: %w", err) + } + if !strings.HasPrefix(parResponse.RequestURI, "urn:ietf:params:oauth:request_uri:") { + return nil, fmt.Errorf("PAR response contains invalid request_uri: %q", parResponse.RequestURI) + } + return &parResponse, nil +} + 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 { diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5d55262aa7..43e0ced372 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -20,6 +20,7 @@ package iam import ( "context" + "net/url" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -27,6 +28,12 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/pe" ) +// PARResponse holds the response from a Pushed Authorization Request (RFC 9126). +type PARResponse struct { + RequestURI string `json:"request_uri"` + ExpiresIn int `json:"expires_in"` +} + // Client defines OpenID4VP client methods using the IAM OpenAPI Spec. type Client interface { // AccessToken requests an access token at the oauth2 token endpoint. @@ -58,6 +65,8 @@ type Client interface { 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) + // PushedAuthorizationRequest sends a Pushed Authorization Request (RFC 9126) to the given endpoint. + PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, 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 d8a3e4192e..8c7238ee14 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -11,6 +11,7 @@ package iam import ( context "context" + url "net/url" reflect "reflect" vc "github.com/nuts-foundation/go-did/vc" @@ -163,6 +164,21 @@ func (mr *MockClientMockRecorder) PresentationDefinition(ctx, endpoint any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockClient)(nil).PresentationDefinition), ctx, endpoint) } +// PushedAuthorizationRequest mocks base method. +func (m *MockClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PushedAuthorizationRequest", ctx, parEndpoint, params) + ret0, _ := ret[0].(*PARResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PushedAuthorizationRequest indicates an expected call of PushedAuthorizationRequest. +func (mr *MockClientMockRecorder) PushedAuthorizationRequest(ctx, parEndpoint, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushedAuthorizationRequest", reflect.TypeOf((*MockClient)(nil).PushedAuthorizationRequest), ctx, parEndpoint, params) +} + // RequestNonce mocks base method. func (m *MockClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { m.ctrl.T.Helper() diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index a3d56cb2dc..9f3977efcf 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -360,6 +360,14 @@ func (c *OpenID4VPClient) RequestNonce(ctx context.Context, nonceEndpoint string return c.httpClient.RequestNonce(ctx, nonceEndpoint) } +func (c *OpenID4VPClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) { + parsedURL, err := core.ParsePublicURL(parEndpoint, c.strictMode) + if err != nil { + return nil, fmt.Errorf("invalid PAR endpoint: %w", err) + } + return c.httpClient.PushedAuthorizationRequest(ctx, parsedURL.String(), params) +} + 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) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 7422661bef..a6ceeda018 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -527,6 +527,7 @@ type clientServerTestContext struct { nonce func(writer http.ResponseWriter) credentials func(writer http.ResponseWriter) requestObjectJWT func(writer http.ResponseWriter) + par func(writer http.ResponseWriter, request *http.Request) } func createClientServerTestContext(t *testing.T) *clientServerTestContext { @@ -650,6 +651,11 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { ctx.requestObjectJWT(writer) return } + case "/par": + if ctx.par != nil { + ctx.par(writer, request) + return + } } writer.WriteHeader(http.StatusNotFound) } @@ -715,6 +721,56 @@ func TestIAMClient_RequestNonce(t *testing.T) { }) } +func TestIAMClient_PushedAuthorizationRequest(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.par = func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "application/x-www-form-urlencoded", request.Header.Get("Content-Type")) + assert.NoError(t, request.ParseForm()) + assert.Equal(t, "value1", request.PostFormValue("key1")) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte(`{"request_uri":"urn:ietf:params:oauth:request_uri:abc123","expires_in":60}`)) + } + + params := url.Values{"key1": {"value1"}} + response, err := ctx.client.PushedAuthorizationRequest(context.Background(), ctx.tlsServer.URL+"/par", params) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "urn:ietf:params:oauth:request_uri:abc123", response.RequestURI) + assert.Equal(t, 60, response.ExpiresIn) + }) + t.Run("error - server returns error status", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.par = func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusBadRequest) + _, _ = writer.Write([]byte(`{"error":"invalid_request"}`)) + } + + response, err := ctx.client.PushedAuthorizationRequest(context.Background(), ctx.tlsServer.URL+"/par", url.Values{}) + + assert.Error(t, err) + assert.Nil(t, response) + assert.ErrorContains(t, err, "PAR endpoint returned HTTP 400 (expected: 201)") + }) + t.Run("error - response has invalid request_uri", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.par = func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte(`{"request_uri":"https://evil.com/steal","expires_in":60}`)) + } + + response, err := ctx.client.PushedAuthorizationRequest(context.Background(), ctx.tlsServer.URL+"/par", url.Values{}) + + assert.Error(t, err) + assert.Nil(t, response) + assert.ErrorContains(t, err, "PAR response contains invalid request_uri") + }) +} + func TestIAMClient_VerifiableCredentials(t *testing.T) { accessToken := "code" proofJWT := "top secret" diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 340ca3fae8..bfd36c5782 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -308,6 +308,9 @@ type AuthorizationServerMetadata struct { /* ******** JWT-Secured Authorization Request RFC9101 & OpenID Connect Core v1.0: ยง6. Passing Request Parameters as JWTs ******** */ + // PushedAuthorizationRequestEndpoint is the URL of the pushed authorization request endpoint (RFC 9126). + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint,omitempty"` + // RequireSignedRequestObject specifies if the authorization server requires the use of signed request objects. RequireSignedRequestObject bool `json:"require_signed_request_object,omitempty"` From aaef59bce989972f2fe8dc9e0cc9e000afaa6979 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Thu, 12 Mar 2026 13:35:26 +0100 Subject: [PATCH 23/27] feat(openid4vci): support credential_identifiers in token response When the token response includes authorization_details with credential_identifiers (v1.0 Section 6.2), use credential_identifier instead of credential_configuration_id in the credential request (Section 8.2). Adds GetRaw to TokenResponse for accessing non-string additional parameters. --- auth/api/iam/openid4vci.go | 44 ++++++++- auth/api/iam/openid4vci_test.go | 149 +++++++++++++++++++++++++++--- auth/client/iam/client.go | 3 +- auth/client/iam/interface.go | 3 +- auth/client/iam/mock.go | 8 +- auth/client/iam/openid4vp.go | 4 +- auth/client/iam/openid4vp_test.go | 10 +- auth/oauth/types.go | 9 ++ auth/oauth/types_test.go | 23 ++++- vcr/openid4vci/types.go | 4 + 10 files changed, 226 insertions(+), 31 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 24932859bc..e178f4f782 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -191,8 +191,11 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode } } + // Check for credential_identifiers in the token response (v1.0 Section 6.2) + credentialIdentifier := extractCredentialIdentifier(tokenResponse) + // 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 { // on invalid_nonce: fetch a fresh nonce and retry once var oidcErr openid4vci.Error @@ -201,7 +204,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) @@ -232,12 +235,16 @@ 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, 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) + credentialConfigID := oauthSession.IssuerCredentialConfigurationID + if credentialIdentifier != "" { + credentialConfigID = "" + } + return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) } func (r *Wrapper) openid4vciProof(ctx context.Context, session *OAuthSession, nonce string) (string, error) { @@ -277,6 +284,35 @@ func (r *Wrapper) openid4vciProof(ctx context.Context, session *OAuthSession, no return proofJwt, nil } +// extractCredentialIdentifier extracts the first credential_identifier from the token response's +// authorization_details (v1.0 Section 6.2). Returns empty string if not present. +// Only considers entries with type "openid_credential" per RFC 9396. +// When multiple credential_identifiers are present, the first one is used. +func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse) string { + authzDetails, ok := tokenResponse.GetRaw("authorization_details").([]interface{}) + if !ok { + return "" + } + for _, item := range authzDetails { + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + if typ, _ := entry["type"].(string); typ != "openid_credential" { + continue + } + identifiers, ok := entry["credential_identifiers"].([]interface{}) + if !ok || len(identifiers) == 0 { + continue + } + identifier, ok := identifiers[0].(string) + if ok { + return identifier + } + } + return "" +} + // validateAuthorizationDetails validates the authorization_details entries per v1.0 Section 5.1.1. // It returns the credential_configuration_id and sanitized entries (only known keys, with locations injected). // Only a single entry is supported; multiple entries are rejected. diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index d316b371a7..9c3ffd62ea 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -408,7 +408,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Equal(t, expectedClaims, claims) return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) @@ -434,7 +434,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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) @@ -453,11 +453,11 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { // 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-1").Return(nil, invalidNonceErr) // retry with fresh nonce ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(freshNonce, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-2").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) @@ -474,11 +474,11 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-1").Return(nil, invalidNonceErr) // retry also fails ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(nil, errors.New("still failing")) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-2").Return(nil, errors.New("still failing")) callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) @@ -493,7 +493,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(nil, invalidNonceErr) // retry nonce fetch fails ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("", errors.New("nonce endpoint down")) @@ -528,7 +528,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, errors.New("FAIL")) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(nil, errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) @@ -541,7 +541,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(`"super invalid"`)}}, }, nil) @@ -556,7 +556,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), 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(context.Background(), code, &session) @@ -621,7 +621,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) @@ -636,7 +636,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&openid4vci.CredentialResponse{ TransactionID: "txn-456", }, nil) @@ -651,7 +651,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{}, }, nil) @@ -660,4 +660,127 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "credential response does not contain any credentials") }) + t.Run("ok - uses credential_identifier from token response when present", func(t *testing.T) { + ctx := newTestClient(t) + tokenResponseWithIdentifier := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"} + tokenResponseWithIdentifier.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_configuration_id": credentialConfigID, + "credential_identifiers": []interface{}{"cred-id-1", "cred-id-2"}, + }, + }) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithIdentifier, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, "", "cred-id-1", "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) + t.Run("ok - falls back to credential_configuration_id when no credential_identifiers", func(t *testing.T) { + ctx := newTestClient(t) + tokenResponseNoIdentifiers := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"} + tokenResponseNoIdentifiers.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_configuration_id": credentialConfigID, + }, + }) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseNoIdentifiers, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) +} + +func TestExtractCredentialIdentifier(t *testing.T) { + t.Run("returns first identifier from openid_credential entry", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{"id-1", "id-2"}, + }, + }) + + assert.Equal(t, "id-1", extractCredentialIdentifier(tokenResponse)) + }) + t.Run("skips non-openid_credential entries", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "other_type", + "credential_identifiers": []interface{}{"wrong"}, + }, + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{"correct"}, + }, + }) + + assert.Equal(t, "correct", extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when authorization_details missing", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when authorization_details is not an array", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", "not-an-array") + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when credential_identifiers missing", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + }, + }) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when credential_identifiers is empty", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{}, + }, + }) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when identifier is not a string", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{42}, + }, + }) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when entry is not a map", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{"not-a-map"}) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) } diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index f5e7243bd1..7ff212c470 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -370,7 +370,7 @@ func (hb HTTPClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint return &parResponse, nil } -func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJwt string) (*openid4vci.CredentialResponse, error) { +func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJwt string) (*openid4vci.CredentialResponse, error) { credentialEndpointURL, err := url.Parse(credentialEndpoint) if err != nil { return nil, err @@ -378,6 +378,7 @@ func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoi credentialRequest := openid4vci.CredentialRequest{ CredentialConfigurationID: credentialConfigID, + CredentialIdentifier: credentialIdentifier, Proofs: &openid4vci.CredentialRequestProofs{ Jwt: []string{proofJwt}, }, diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 43e0ced372..1e757e9537 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -64,7 +64,8 @@ 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) (*openid4vci.CredentialResponse, error) + // Either credentialConfigID or credentialIdentifier must be non-empty (mutually exclusive per v1.0 Section 8.2). + VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJWT string) (*openid4vci.CredentialResponse, error) // PushedAuthorizationRequest sends a Pushed Authorization Request (RFC 9126) to the given endpoint. PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) // RequestObjectByGet retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a GET method as defined in RFC9101/OpenID4VP. diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 8c7238ee14..6d6111ace1 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -240,16 +240,16 @@ func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjec } // VerifiableCredentials mocks base method. -func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, proofJWT string) (*openid4vci.CredentialResponse, error) { +func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT string) (*openid4vci.CredentialResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) + ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, 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 { +func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, 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) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) } diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 9f3977efcf..747b8be668 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -368,9 +368,9 @@ func (c *OpenID4VPClient) PushedAuthorizationRequest(ctx context.Context, parEnd return c.httpClient.PushedAuthorizationRequest(ctx, parsedURL.String(), params) } -func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) { +func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJWT string) (*openid4vci.CredentialResponse, error) { iamClient := c.httpClient - rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) + rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) if err != nil { return nil, err } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index a6ceeda018..78d3a292c5 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -779,7 +779,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) require.NoError(t, err) require.NotNil(t, response) @@ -794,7 +794,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { _, _ = 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) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) require.NoError(t, err) require.NotNil(t, response) @@ -805,7 +805,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { ctx := createClientServerTestContext(t) ctx.credentials = nil - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) assert.Error(t, err) assert.Nil(t, response) @@ -818,7 +818,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { _, _ = writer.Write([]byte(`{"error": "invalid_nonce"}`)) } - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) assert.Nil(t, response) require.Error(t, err) @@ -834,7 +834,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { _, _ = writer.Write([]byte(`{"credentials": fail}`)) } - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + 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 bfd36c5782..ad258f1993 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -101,6 +101,15 @@ func (t *TokenResponse) With(key string, value interface{}) *TokenResponse { return t } +// GetRaw returns the raw value of an additional parameter. +// Returns nil if the key does not exist. +func (t TokenResponse) GetRaw(key string) interface{} { + if t.additionalParams == nil { + return nil + } + return t.additionalParams[key] +} + // Get returns the value of the additional parameter with the given key as a string. // If the key does not exist or the value is not a string, it returns an empty string. // It should not be used to get any of the base parameters (access_token, expires_in, token_type, scope). diff --git a/auth/oauth/types_test.go b/auth/oauth/types_test.go index e0932a7991..dddf1e69d7 100644 --- a/auth/oauth/types_test.go +++ b/auth/oauth/types_test.go @@ -20,10 +20,11 @@ package oauth import ( "encoding/json" + "testing" + "github.com/nuts-foundation/nuts-node/core/to" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" ) func TestIssuerIdToWellKnown(t *testing.T) { @@ -93,6 +94,26 @@ func TestTokenResponse_Get(t *testing.T) { }) } +func TestTokenResponse_GetRaw(t *testing.T) { + t.Run("nil map", func(t *testing.T) { + var tr TokenResponse + assert.Nil(t, tr.GetRaw("key")) + }) + t.Run("returns stored value", func(t *testing.T) { + tr := TokenResponse{} + expected := []interface{}{"a", "b"} + tr.With("details", expected) + + assert.Equal(t, expected, tr.GetRaw("details")) + }) + t.Run("returns nil for missing key", func(t *testing.T) { + tr := TokenResponse{} + tr.With("other", "value") + + assert.Nil(t, tr.GetRaw("missing")) + }) +} + func TestAuthorizationServerMetadata_SupportsClientIDScheme(t *testing.T) { m := AuthorizationServerMetadata{ ClientIdSchemesSupported: []string{"did"}, diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index 60de3a395d..c7150e57b8 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -156,7 +156,11 @@ type CredentialOfferResponse struct { // 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. + // Mutually exclusive with CredentialIdentifier. CredentialConfigurationID string `json:"credential_configuration_id,omitempty"` + // CredentialIdentifier references a specific credential from the token response's authorization_details. + // Mutually exclusive with CredentialConfigurationID. See v1.0 Section 8.2. + CredentialIdentifier string `json:"credential_identifier,omitempty"` // Proofs contains the proof(s) of possession of the key material. Proofs *CredentialRequestProofs `json:"proofs,omitempty"` } From 8643a6439558702669dca6764931c5ec8d6c9804 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 16 Mar 2026 13:48:03 +0100 Subject: [PATCH 24/27] feat(openid4vci): support scope-based credential requests Add scope as an alternative to authorization_details for requesting credentials (v1.0 Section 5.1.2). The scope is resolved against the issuer's credential_configurations_supported metadata. Both scope and authorization_details can be provided simultaneously per the spec. Only a single scope value is supported, consistent with the single-entry restriction for authorization_details. --- auth/api/iam/generated.go | 7 +- auth/api/iam/openid4vci.go | 68 +++++++++++--- auth/api/iam/openid4vci_test.go | 158 +++++++++++++++++++++++++++++--- docs/_static/auth/v2.yaml | 8 +- 4 files changed, 215 insertions(+), 26 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 5dbe21544d..6232594bf8 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -206,7 +206,7 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - AuthorizationDetails []map[string]interface{} `json:"authorization_details"` + AuthorizationDetails *[]map[string]interface{} `json:"authorization_details,omitempty"` // 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. @@ -215,6 +215,11 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // RedirectUri The URL to which the user-agent will be redirected after the authorization request. RedirectUri string `json:"redirect_uri"` + // Scope OAuth2 scope value mapped to a credential configuration in the issuer's metadata (v1.0 Section 5.1.2). + // The issuer's credential_configurations_supported must contain an entry with a matching 'scope' field. + // Can be used together with authorization_details; the issuer interprets them individually. + Scope *string `json:"scope,omitempty"` + // WalletDid The DID to which the Verifiable Credential must be issued. Must be owned by the given subject. WalletDid string `json:"wallet_did"` } diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index e178f4f782..0af04ff2b8 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -25,6 +25,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "time" "github.com/lestrrat-go/jwx/v2/jwt" @@ -76,12 +77,18 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques clientID := r.subjectToBaseURL(request.SubjectID) - // Validate and process authorization details - authorizationDetails := []byte("[]") + // Validate authorization_details and/or scope (at least one must be provided) + hasAuthzDetails := request.Body.AuthorizationDetails != nil && len(*request.Body.AuthorizationDetails) > 0 + hasScope := request.Body.Scope != nil && *request.Body.Scope != "" + if !hasAuthzDetails && !hasScope { + return nil, core.InvalidInputError("either authorization_details or scope is required") + } + + var authorizationDetails []byte var credentialConfigID string - if len(request.Body.AuthorizationDetails) > 0 { + if hasAuthzDetails { var sanitized []map[string]interface{} - credentialConfigID, sanitized, err = validateAuthorizationDetails(request.Body.AuthorizationDetails, credentialIssuerMetadata) + credentialConfigID, sanitized, err = validateAuthorizationDetails(*request.Body.AuthorizationDetails, credentialIssuerMetadata) if err != nil { return nil, core.InvalidInputError("%s", err) } @@ -90,6 +97,19 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques return nil, fmt.Errorf("failed to marshal authorization_details: %w", err) } } + + // Resolve credential_configuration_id from scope (v1.0 Section 5.1.2) + if hasScope { + scopeConfigID, scopeErr := resolveCredentialConfigIDByScope(*request.Body.Scope, credentialIssuerMetadata) + if scopeErr != nil { + return nil, core.InvalidInputError("%s", scopeErr) + } + // Use scope's credential_configuration_id when authorization_details didn't provide one + if credentialConfigID == "" { + credentialConfigID = scopeConfigID + } + } + // Generate the state and PKCE state := crypto.GenerateNonce() pkceParams := generatePKCEParams() @@ -132,14 +152,19 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques return nil, fmt.Errorf("failed to parse the authorization_endpoint: %w", err) } authzParams := url.Values{ - oauth.ResponseTypeParam: {oauth.CodeResponseType}, - oauth.StateParam: {state}, - oauth.ClientIDParam: {clientID.String()}, - oauth.ClientIDSchemeParam: {entityClientIDScheme}, - oauth.AuthorizationDetailsParam: {string(authorizationDetails)}, - oauth.RedirectURIParam: {redirectUri.String()}, - oauth.CodeChallengeParam: {pkceParams.Challenge}, - oauth.CodeChallengeMethodParam: {pkceParams.ChallengeMethod}, + oauth.ResponseTypeParam: {oauth.CodeResponseType}, + oauth.StateParam: {state}, + oauth.ClientIDParam: {clientID.String()}, + oauth.ClientIDSchemeParam: {entityClientIDScheme}, + oauth.RedirectURIParam: {redirectUri.String()}, + oauth.CodeChallengeParam: {pkceParams.Challenge}, + oauth.CodeChallengeMethodParam: {pkceParams.ChallengeMethod}, + } + if hasAuthzDetails { + authzParams.Set(oauth.AuthorizationDetailsParam, string(authorizationDetails)) + } + if hasScope { + authzParams.Set(oauth.ScopeParam, *request.Body.Scope) } var redirectUrl url.URL @@ -313,6 +338,25 @@ func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse) string { return "" } +// resolveCredentialConfigIDByScope finds the credential_configuration_id that matches the given scope +// in the issuer's credential_configurations_supported (v1.0 Section 5.1.2). +// Per the spec, scope is a space-separated list where each value maps to a credential configuration. +// Only a single scope value is supported; multiple values are rejected (consistent with the +// single-entry restriction for authorization_details). +func resolveCredentialConfigIDByScope(scope string, metadata *oauth.OpenIDCredentialIssuerMetadata) (string, error) { + scopeValues := strings.Fields(scope) + if len(scopeValues) != 1 { + return "", fmt.Errorf("invalid scope: exactly one scope value is supported, got %d", len(scopeValues)) + } + scopeValue := scopeValues[0] + for configID, config := range metadata.CredentialConfigurationsSupported { + if s, _ := config["scope"].(string); s == scopeValue { + return configID, nil + } + } + return "", fmt.Errorf("scope %q not found in issuer's credential configurations", scopeValue) +} + // validateAuthorizationDetails validates the authorization_details entries per v1.0 Section 5.1.1. // It returns the credential_configuration_id and sanitized entries (only known keys, with locations injected). // Only a single entry is supported; multiple entries are rejected. diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 9c3ffd62ea..58c6e9e328 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -65,7 +65,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -104,7 +104,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -122,7 +122,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc", "evil_key": "injected"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc", "evil_key": "injected"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -140,7 +140,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{ + AuthorizationDetails: &[]map[string]interface{}{ {"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}, {"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}, }, @@ -158,7 +158,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "invalid_type", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "invalid_type", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -173,7 +173,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -188,7 +188,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "unknown_config"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "unknown_config"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -217,7 +217,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -249,7 +249,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -257,6 +257,96 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { }) assert.EqualError(t, err, "PAR request failed: PAR failed") }) + t.Run("error - neither authorization_details nor scope provided", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "either authorization_details or scope is required") + }) + t.Run("ok - requests credential using scope", func(t *testing.T) { + ctx := newTestClient(t) + metadataWithScope := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "issuer", + CredentialEndpoint: "endpoint", + AuthorizationServers: []string{authServer}, + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc", "scope": "nuts_org_credential"}, + }, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadataWithScope, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + scope := "nuts_org_credential" + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + Scope: &scope, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + require.NotNil(t, response) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, "nuts_org_credential", redirectUri.Query().Get("scope")) + assert.Empty(t, redirectUri.Query().Get("authorization_details"), "authorization_details should not be present when only scope is used") + }) + t.Run("error - scope not found in issuer credential configurations", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + scope := "unknown_scope" + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + Scope: &scope, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, `scope "unknown_scope" not found in issuer's credential configurations`) + }) + t.Run("ok - both authorization_details and scope provided", func(t *testing.T) { + ctx := newTestClient(t) + metadataWithScope := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "issuer", + CredentialEndpoint: "endpoint", + AuthorizationServers: []string{authServer}, + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc"}, + "NutsOrganizationCredential_ldp_vc_v2": {"format": "ldp_vc", "scope": "nuts_org_credential_v2"}, + }, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadataWithScope, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + scope := "nuts_org_credential_v2" + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Scope: &scope, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + require.NotNil(t, response) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.NotEmpty(t, redirectUri.Query().Get("authorization_details"), "authorization_details should be present") + assert.Equal(t, "nuts_org_credential_v2", redirectUri.Query().Get("scope"), "scope should be present") + }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { ctx := newTestClient(t) @@ -344,9 +434,10 @@ func requestCredentials(subjectID string, issuer string, redirectURI string) Req return RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: subjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - Issuer: issuer, - RedirectUri: redirectURI, - WalletDid: holderDID.String(), + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuer, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), }, } } @@ -784,3 +875,46 @@ func TestExtractCredentialIdentifier(t *testing.T) { assert.Empty(t, extractCredentialIdentifier(tokenResponse)) }) } + +func TestResolveCredentialConfigIDByScope(t *testing.T) { + t.Run("ok - finds matching config", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "Degree": {"format": "jwt_vc_json", "scope": "UniversityDegree"}, + "Other": {"format": "ldp_vc"}, + }, + } + configID, err := resolveCredentialConfigIDByScope("UniversityDegree", metadata) + require.NoError(t, err) + assert.Equal(t, "Degree", configID) + }) + t.Run("error - scope not found", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "Other": {"format": "ldp_vc"}, + }, + } + _, err := resolveCredentialConfigIDByScope("unknown", metadata) + assert.EqualError(t, err, `scope "unknown" not found in issuer's credential configurations`) + }) + t.Run("error - no configurations at all", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{} + _, err := resolveCredentialConfigIDByScope("test", metadata) + assert.EqualError(t, err, `scope "test" not found in issuer's credential configurations`) + }) + t.Run("error - multiple scope values", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "Degree": {"format": "jwt_vc_json", "scope": "UniversityDegree"}, + "License": {"format": "jwt_vc_json", "scope": "DriverLicense"}, + }, + } + _, err := resolveCredentialConfigIDByScope("UniversityDegree DriverLicense", metadata) + assert.EqualError(t, err, "invalid scope: exactly one scope value is supported, got 2") + }) + t.Run("error - empty scope", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{} + _, err := resolveCredentialConfigIDByScope("", metadata) + assert.EqualError(t, err, "invalid scope: exactly one scope value is supported, got 0") + }) +} diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index c032a1ff64..78e6d9436c 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -129,7 +129,6 @@ paths: schema: required: - issuer - - authorization_details - redirect_uri - wallet_did properties: @@ -157,6 +156,13 @@ paths: "credential_configuration_id": "UniversityDegreeCredential" } ] + scope: + type: string + description: | + OAuth2 scope value mapped to a credential configuration in the issuer's metadata (v1.0 Section 5.1.2). + The issuer's credential_configurations_supported must contain an entry with a matching 'scope' field. + Can be used together with authorization_details; the issuer interprets them individually. + example: UniversityDegree redirect_uri: type: string description: | From 68a9a74ceb15fe74c7a5f30db33a870914188951 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 16 Mar 2026 14:44:08 +0100 Subject: [PATCH 25/27] feat(openid4vci): verify signed_metadata in issuer metadata When the credential issuer's metadata contains signed_metadata (v1.0 Section 12.2.3), verify the JWT signature using the issuer's key from the DID document. Validates typ header, required claims (sub, iat), and compares metadata claims against the unsigned metadata. Rejects metadata if verification fails; proceeds without it if absent (field is OPTIONAL). --- auth/client/iam/client.go | 48 +++++- auth/client/iam/openid4vp_test.go | 242 +++++++++++++++++++++++++++++- auth/oauth/types.go | 2 + crypto/jwx.go | 12 ++ 4 files changed, 301 insertions(+), 3 deletions(-) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 7ff212c470..de2412eba5 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -21,6 +21,7 @@ package iam import ( "bytes" "context" + stdcrypto "crypto" "encoding/json" "errors" "fmt" @@ -282,7 +283,52 @@ func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIs if err != nil { return nil, err } - return &metadata, err + if metadata.SignedMetadata != "" { + if err = hb.verifySignedMetadata(ctx, &metadata); err != nil { + return nil, fmt.Errorf("signed_metadata verification failed: %w", err) + } + } + return &metadata, nil +} + +// verifySignedMetadata verifies the signed_metadata JWT against the issuer's key (v1.0 Section 12.2.3). +// It validates the JWT signature, typ header, required claims (sub, iat), and compares +// key metadata claims (credential_issuer, credential_endpoint) against the unsigned metadata. +func (hb HTTPClient) verifySignedMetadata(ctx context.Context, metadata *oauth.OpenIDCredentialIssuerMetadata) error { + // Verify typ header to prevent JWT type confusion attacks + typ, err := crypto.JWTTyp(metadata.SignedMetadata) + if err != nil { + return fmt.Errorf("invalid JWT: %w", err) + } + if typ != "openidvci-issuer-metadata+jwt" { + return fmt.Errorf("typ header must be openidvci-issuer-metadata+jwt, got %q", typ) + } + // Parse, verify signature, and validate standard claims using shared infrastructure + token, err := crypto.ParseJWT(metadata.SignedMetadata, func(kid string) (stdcrypto.PublicKey, error) { + return hb.keyResolver.ResolveKeyByID(kid, nil, resolver.AssertionMethod) + }, jwt.WithValidate(true), jwt.WithAcceptableSkew(5*time.Second)) + if err != nil { + return fmt.Errorf("invalid JWT: %w", err) + } + // sub is REQUIRED, must match credential_issuer. iss is OPTIONAL per spec. + if token.Subject() != metadata.CredentialIssuer { + return fmt.Errorf("sub %q does not match credential_issuer %q", token.Subject(), metadata.CredentialIssuer) + } + if token.IssuedAt().IsZero() { + return fmt.Errorf("iat claim is required") + } + // Compare metadata claims from JWT payload against unsigned metadata + claims, err := token.AsMap(ctx) + if err != nil { + return fmt.Errorf("failed to extract claims: %w", err) + } + if ci, _ := claims["credential_issuer"].(string); ci != metadata.CredentialIssuer { + return fmt.Errorf("credential_issuer claim %q does not match metadata %q", ci, metadata.CredentialIssuer) + } + if ce, _ := claims["credential_endpoint"].(string); ce != "" && ce != metadata.CredentialEndpoint { + return fmt.Errorf("credential_endpoint claim %q does not match metadata %q", ce, metadata.CredentialEndpoint) + } + return nil } func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) (*oauth.OpenIDConfiguration, error) { diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 78d3a292c5..9b361b8dd2 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -20,10 +20,14 @@ package iam import ( "context" + "crypto/ecdsa" "crypto/tls" "encoding/json" "errors" "fmt" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/nuts-node/http/client" test2 "github.com/nuts-foundation/nuts-node/test" "github.com/nuts-foundation/nuts-node/vcr/credential" @@ -40,6 +44,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" + cryptoTest "github.com/nuts-foundation/nuts-node/crypto/test" 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" @@ -486,8 +491,9 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon wallet: wallet, subjectManager: subjectManager, httpClient: HTTPClient{ - strictMode: false, - httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), + strictMode: false, + httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), + keyResolver: keyResolver, }, jwtSigner: jwtSigner, keyResolver: keyResolver, @@ -697,6 +703,238 @@ func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) { assert.Nil(t, response) assert.EqualError(t, err, "failed to retrieve Openid credential issuer metadata: server returned HTTP 404 (expected: 200)") }) + t.Run("ok - signed_metadata is verified", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.openIDCredentialIssuerMetadata = issuerMetadata + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, "https://issuer.example.com", metadata.CredentialIssuer) + }) + t.Run("error - signed_metadata JWT signature invalid", func(t *testing.T) { + ctx := createClientServerTestContext(t) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: "invalid.jwt.token", + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "signed_metadata verification failed") + }) + t.Run("error - signed_metadata sub mismatch", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://other-issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "sub") + assert.ErrorContains(t, err, "does not match credential_issuer") + }) + t.Run("error - signed_metadata credential_endpoint mismatch", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://evil.example.com/credential", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "credential_endpoint claim") + assert.ErrorContains(t, err, "does not match metadata") + }) + t.Run("error - signed_metadata wrong typ header", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "jwt", map[string]interface{}{ + "iss": "https://issuer.example.com", + "sub": "https://issuer.example.com", + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }, true, true) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "typ header must be openidvci-issuer-metadata+jwt") + }) + t.Run("error - signed_metadata missing sub", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "openidvci-issuer-metadata+jwt", map[string]interface{}{ + "iss": "https://issuer.example.com", + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }, true, true) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "sub") + assert.ErrorContains(t, err, "does not match credential_issuer") + }) + t.Run("error - signed_metadata missing iat", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "openidvci-issuer-metadata+jwt", map[string]interface{}{ + "sub": "https://issuer.example.com", + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }, false, false) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "iat claim is required") + }) +} + +func createSignedMetadataJWT(t *testing.T, key *ecdsa.PrivateKey, kid string, claims map[string]interface{}) string { + t.Helper() + token := jwt.New() + for k, v := range claims { + require.NoError(t, token.Set(k, v)) + } + if iss, ok := claims["credential_issuer"].(string); ok { + require.NoError(t, token.Set(jwt.IssuerKey, iss)) + require.NoError(t, token.Set(jwt.SubjectKey, iss)) + } + require.NoError(t, token.Set(jwt.IssuedAtKey, time.Now().Unix())) + require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour).Unix())) + hdrs := jws.NewHeaders() + require.NoError(t, hdrs.Set(jws.KeyIDKey, kid)) + require.NoError(t, hdrs.Set(jws.TypeKey, "openidvci-issuer-metadata+jwt")) + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, key, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + return string(signed) +} + +// createSignedMetadataJWTCustom allows overriding specific JWT fields for negative tests. +func createSignedMetadataJWTCustom(t *testing.T, key *ecdsa.PrivateKey, kid string, typ string, claims map[string]interface{}, setIat bool, setExp bool) string { + t.Helper() + token := jwt.New() + for k, v := range claims { + require.NoError(t, token.Set(k, v)) + } + if setIat { + require.NoError(t, token.Set(jwt.IssuedAtKey, time.Now().Unix())) + } + if setExp { + require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour).Unix())) + } + hdrs := jws.NewHeaders() + require.NoError(t, hdrs.Set(jws.KeyIDKey, kid)) + if typ != "" { + require.NoError(t, hdrs.Set(jws.TypeKey, typ)) + } + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, key, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + return string(signed) } func TestIAMClient_RequestNonce(t *testing.T) { diff --git a/auth/oauth/types.go b/auth/oauth/types.go index ad258f1993..b92cf9630a 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -423,6 +423,8 @@ type OpenIDCredentialIssuerMetadata struct { AuthorizationServers []string `json:"authorization_servers,omitempty"` CredentialConfigurationsSupported map[string]map[string]interface{} `json:"credential_configurations_supported,omitempty"` Display []map[string]string `json:"display,omitempty"` + // SignedMetadata is a JWT containing signed issuer metadata for trust verification (v1.0 Section 12.2.3). + SignedMetadata string `json:"signed_metadata,omitempty"` } // OpenIDConfiguration represents the OpenID configuration diff --git a/crypto/jwx.go b/crypto/jwx.go index e9bd81fcd1..3670a4a000 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -165,6 +165,18 @@ func JWTKidAlg(tokenString string) (string, jwa.SignatureAlgorithm, error) { return hdrs.KeyID(), hdrs.Algorithm(), nil } +// JWTTyp parses a JWT without validation and returns the 'typ' header. +func JWTTyp(tokenString string) (string, error) { + j, err := jws.ParseString(tokenString) + if err != nil { + return "", err + } + if len(j.Signatures()) != 1 { + return "", errors.New("incorrect number of signatures in JWT") + } + return j.Signatures()[0].ProtectedHeaders().Type(), nil +} + // PublicKeyFunc defines a function that resolves a public key based on a kid type PublicKeyFunc func(kid string) (crypto.PublicKey, error) From 6b9902b82eed71052620469102856c425f86bea4 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 16 Mar 2026 15:40:01 +0100 Subject: [PATCH 26/27] fix(openid4vci): use credential issuer identifier as proof audience The proof JWT audience (aud) must be the Credential Issuer Identifier per v1.0 Section 8.2.1.1, not the Authorization Server issuer. These differ when the credential issuer delegates to a separate AS. --- auth/api/iam/openid4vci.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 0af04ff2b8..ede9d26104 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -137,7 +137,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // 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, + IssuerURL: credentialIssuerMetadata.CredentialIssuer, IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, From ba667e5f4cec3d83eb25c77d7fbacb586fb6cdda Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 16 Mar 2026 17:06:34 +0100 Subject: [PATCH 27/27] fix(openid4vci): require credential_endpoint in signed_metadata Require credential_endpoint to be present in the signed_metadata JWT rather than silently skipping validation when absent. The spec requires all metadata parameters to be present as top-level claims in the JWT. Based on Copilot PR review feedback. --- auth/client/iam/client.go | 6 +++++- auth/client/iam/openid4vp_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index de2412eba5..dd351ab485 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -325,7 +325,11 @@ func (hb HTTPClient) verifySignedMetadata(ctx context.Context, metadata *oauth.O if ci, _ := claims["credential_issuer"].(string); ci != metadata.CredentialIssuer { return fmt.Errorf("credential_issuer claim %q does not match metadata %q", ci, metadata.CredentialIssuer) } - if ce, _ := claims["credential_endpoint"].(string); ce != "" && ce != metadata.CredentialEndpoint { + ce, _ := claims["credential_endpoint"].(string) + if ce == "" { + return fmt.Errorf("credential_endpoint claim is required in signed metadata") + } + if ce != metadata.CredentialEndpoint { return fmt.Errorf("credential_endpoint claim %q does not match metadata %q", ce, metadata.CredentialEndpoint) } return nil diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 9b361b8dd2..9f58a9746f 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -892,6 +892,32 @@ func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) { assert.Nil(t, metadata) assert.ErrorContains(t, err, "iat claim is required") }) + t.Run("error - signed_metadata missing credential_endpoint", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://issuer.example.com", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "credential_endpoint claim is required in signed metadata") + }) } func createSignedMetadataJWT(t *testing.T, key *ecdsa.PrivateKey, kid string, claims map[string]interface{}) string {