From 36218747781a9a773ed510ee7cef445541515d7d Mon Sep 17 00:00:00 2001 From: general-agent-3 Date: Thu, 4 Jun 2026 11:59:25 +0000 Subject: [PATCH 1/9] Add implementation plan for TIR v5 support 4-step plan to extend VCVerifier with EBSI Trusted Issuers Registry v5 API support: config type extension, v5 client implementation, verifier dispatch wiring, and end-to-end verification. Co-Authored-By: Claude Opus 4.6 --- IMPLEMENTATION_PLAN.md | 161 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 IMPLEMENTATION_PLAN.md diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..56926a1 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,161 @@ +# Implementation Plan: VCVerifier support for TIR v5 + +## Overview + +Extend VCVerifier to support EBSI Trusted Issuers Registry (TIR) v5 API alongside existing v3/v4 support. The v5 API returns attribute references (URLs) instead of inline attributes, requiring multi-step fetching (get issuer, list attributes with pagination, fetch each attribute). Configuration is extended with a `type` parameter on `TrustedIssuersLists` (mirroring the existing `TrustedParticipantsList` pattern): type `"ebsi"` selects v3/v4 auto-detection (default, backward-compatible), type `"ebsi-v5"` selects the v5 flow. + +## Steps + +### Step 1: Extend TrustedIssuersLists config type from []string to structured type + +**Goal:** Change `TrustedIssuersLists` from a plain `[]string` (URL-only) to a structured type with `Type` + `Url` fields, mirroring the existing `TrustedParticipantsList`/`TrustedParticipantsLists` pattern. All existing configs must continue to work unchanged (backward compatibility). + +**Files to modify:** + +- **`config/configClient.go`:** + - Create `TrustedIssuersList` struct with `Type string` and `Url string` fields (JSON/mapstructure tags), analogous to `TrustedParticipantsList` (line 197). + - Create `TrustedIssuersLists` type (slice of `TrustedIssuersList`) with a custom `UnmarshalJSON` method: try structured format first, fall back to plain string array (each string becomes `{Type: DEFAULT_LIST_TYPE, Url: url}`). This mirrors `TrustedParticipantsLists.UnmarshalJSON` (lines 204-232). + - Update the `Credential` struct field `TrustedIssuersLists` from `[]string` to `TrustedIssuersLists` (line 113). + +- **`verifier/credentialsConfig.go`:** + - Update `CredentialsConfig` interface: change `GetTrustedIssuersLists` return type from `[]string` to `[]config.TrustedIssuersList` (line 51). + - Update `cacheBasedCredentialsConfig.GetTrustedIssuersLists` implementation to return `[]config.TrustedIssuersList` (lines 287-299). + +- **`verifier/verifier.go`:** + - Update `TrustRegistriesValidationContext.trustedIssuersLists` field type from `map[string][]string` to `map[string][]configModel.TrustedIssuersList` (line 194). + - Update `GetTrustedIssuersLists()` return type accordingly (line 198). + - Update `getTrustRegistriesValidationContext` and `getTrustRegistriesValidationContextFromScope` to use the new type (lines 1125, 1139, 1142, 1189, 1217, 1220). + +- **`verifier/trustedissuer.go`:** + - Update `ValidateVC` to work with `[]TrustedIssuersList` instead of `[]string`. For backward compatibility in this step, extract URLs from entries where `Type == "ebsi"` (or empty/default) and pass them to the existing `tirClient.GetTrustedIssuer`. The `isWildcardTil` helper must be updated to check `Url` field instead of raw strings. + - Update `isWildcardTil` signature from `[]string` to `[]config.TrustedIssuersList`. + +- **`database/models.go`:** + - Update `CredentialDB.VO()` to construct `config.TrustedIssuersList{Type: listType, Url: endpoint}` entries instead of plain strings for `TrustedIssuers` endpoints (lines 286-287). Pass through `ListType` from `EndpointEntry`. + - Update `CredentialDB.FromVO()` to read `Type` and `Url` from `TrustedIssuersList` entries when building `EndpointEntry` values (lines 315-320). + +**Tests to add/update:** + +- **`config/configClient_test.go`:** Add tests for `TrustedIssuersLists` custom `UnmarshalJSON`: structured format, plain string array fallback, mixed validation. +- **`verifier/trustedissuer_test.go`:** Update `getVerificationContext()`, `getWildcardVerificationContext()`, `getInvalidMixedVerificationContext()`, and `getWildcardAndNormalVerificationContext()` helpers to construct `TrustedIssuersList` values instead of raw strings. +- **`verifier/trustedparticipant_test.go`:** Update any test helpers that construct `TrustRegistriesValidationContext` with `trustedIssuersLists`. +- **`database/models_test.go`:** Update round-trip tests for `CredentialDB.VO()`/`FromVO()` to verify `TrustedIssuersList` type and URL are preserved. + +**Acceptance criteria:** +- All existing YAML/JSON configs with `trustedIssuersLists: ["https://..."]` continue to work (parsed as `[{type: "ebsi", url: "https://..."}]`). +- New structured format `trustedIssuersLists: [{type: "ebsi-v5", url: "https://..."}]` is accepted. +- All existing tests pass (with updated test helpers). +- `go build ./...` succeeds with no compilation errors. + +--- + +### Step 2: Implement TIR v5 client methods in tir/tirClient.go + +**Goal:** Add v5 API support to the TIR HTTP client. The v5 API differs from v3/v4: `GET /v5/issuers/{did}` returns `{did, attributes: "", hasAttributes: true/false}` (attributes is a URL reference, not inline). Fetching attributes requires: `GET /v5/issuers/{did}/attributes` (paginated list of attribute IDs/hrefs) then `GET /v5/issuers/{did}/attributes/{id}` (individual attribute with the same `hash`/`body`/`issuerType`/`tao`/`rootTao` fields as v3/v4). The final result must be assembled into the existing `TrustedIssuer` struct for downstream compatibility. + +**Files to modify:** + +- **`tir/tirClient.go`:** + - Add path constant `ISSUERS_V5_PATH = "v5/issuers"`. + - Add v5-specific response structs: + - `TrustedIssuerV5Response` — `{Did string, Attributes string, HasAttributes bool}` (the `Attributes` field is a URL). + - `AttributesListV5Response` — paginated list: `{Items []AttributeListItem, Links PaginationLinks, Total int, PageSize int, Self string}`. + - `AttributeListItem` — `{ID string, Href string}`. + - `PaginationLinks` — `{First, Last, Next, Prev string}`. + - `AttributeV5Response` — single attribute: `{Attribute IssuerAttribute, Did string}`. + - Add `getIssuerV5Url(did string) string` helper returning `ISSUERS_V5_PATH + "/" + did`. + - Add `getAttributesV5Url(did string) string` helper returning `ISSUERS_V5_PATH + "/" + did + "/attributes"`. + - Extend the `TirClient` interface with two new methods: + - `IsTrustedParticipantV5(tirEndpoint string, did string) bool` — calls `GET /v5/issuers/{did}`, returns true if 200. + - `GetTrustedIssuerV5(tirEndpoints []string, did string) (bool, TrustedIssuer, error)` — for each endpoint: (1) `GET /v5/issuers/{did}`, (2) if `hasAttributes`, follow pagination on the attributes URL to collect all attribute IDs, (3) fetch each attribute individually, (4) assemble into `TrustedIssuer{Did, Attributes}`. Cache the assembled result in `tirCache`/`tilCache`. + - Implement pagination loop for `AttributesListV5Response`: follow `Links.Next` until empty or equal to current page. + - Reuse existing `HttpGetClient` interface for all HTTP calls. + +**Tests to add:** + +- **`tir/tirClient_test.go`:** + - Add table-driven tests for `IsTrustedParticipantV5`: mock HTTP responses for 200 (found), 404 (not found), network error. + - Add table-driven tests for `GetTrustedIssuerV5`: mock multi-step flow (issuer response → attributes list → individual attributes), verify assembled `TrustedIssuer` matches expected. Include cases for: single attribute, multiple attributes, pagination (multiple pages), issuer with `hasAttributes: false`, issuer not found (404), attribute fetch error (partial failure). + - Test caching behavior: second call for same DID should hit cache. + +**Acceptance criteria:** +- `TirClient` interface has `IsTrustedParticipantV5` and `GetTrustedIssuerV5` methods. +- V5 multi-step attribute fetching correctly assembles a `TrustedIssuer` with all attributes. +- Pagination is handled (follows `next` links). +- Results are cached using existing `tirCache`/`tilCache`. +- All new and existing tests pass. + +--- + +### Step 3: Wire v5 dispatch in verifier layer (trustedissuer.go and trustedparticipant.go) + +**Goal:** Route "ebsi-v5" typed entries to the new v5 TIR client methods. The existing "ebsi" type continues to use v3/v4 auto-detection. The dispatch pattern follows the existing `trustedparticipant.go` model (type-based `if` checks). + +**Files to modify:** + +- **`verifier/trustedparticipant.go`:** + - Add constant `typeEbsiV5 = "ebsi-v5"` alongside existing `typeGaiaX` and `typeEbsi` (line 17). + - In `ValidateVC`, add a new dispatch branch: `if participantList.Type == typeEbsiV5` calls `tpvs.tirClient.IsTrustedParticipantV5(participantList.Url, ...)` (after line 57). + +- **`verifier/trustedissuer.go`:** + - Add constant `typeEbsiV5 = "ebsi-v5"` and `typeEbsi = "ebsi"`. + - Refactor `ValidateVC` to dispatch based on `TrustedIssuersList.Type`: + - For entries with type `"ebsi"` (or empty/default): collect URLs into a `[]string` slice and call `tpvs.tirClient.GetTrustedIssuer(urls, did)` (preserving existing v3/v4 behavior). + - For entries with type `"ebsi-v5"`: collect URLs into a `[]string` slice and call `tpvs.tirClient.GetTrustedIssuerV5(urls, did)`. + - The wildcard check (`isWildcardTil`) should apply across all entries regardless of type. + - Ensure that if a credential type has a mix of "ebsi" and "ebsi-v5" entries, both are tried (ebsi entries via v3/v4, ebsi-v5 entries via v5), and the first successful match wins. + +**Tests to add/update:** + +- **`verifier/trustedparticipant_test.go`:** + - Add test cases for "ebsi-v5" type: participant found via v5, participant not found via v5, mixed ebsi + ebsi-v5 lists. + - Update mock `TirClient` to implement `IsTrustedParticipantV5`. + +- **`verifier/trustedissuer_test.go`:** + - Add test cases for "ebsi-v5" type: issuer found via v5, issuer not found via v5. + - Add test case for mixed types: one "ebsi" entry and one "ebsi-v5" entry for the same credential type. + - Update mock `TirClient` to implement `GetTrustedIssuerV5`. + - Update existing test helper functions to use `TrustedIssuersList` struct with explicit `Type: "ebsi"`. + +**Acceptance criteria:** +- "ebsi-v5" type in `TrustedParticipantsList` routes to `IsTrustedParticipantV5`. +- "ebsi-v5" type in `TrustedIssuersLists` routes to `GetTrustedIssuerV5`. +- "ebsi" (and empty/default) type continues to use v3/v4 auto-detection. +- Mixed type lists work correctly. +- All tests pass. + +--- + +### Step 4: End-to-end verification, test fixtures, and documentation + +**Goal:** Add integration-level test scenarios covering the complete flow from config parsing through TIR v5 verification. Update test fixture files and config examples. Ensure all tests pass and the build is clean. + +**Files to modify/add:** + +- **`config/data/`:** Add or update YAML/JSON test fixtures: + - A fixture with `trustedIssuersLists` in the new structured format (type "ebsi-v5"). + - A fixture with mixed old-format (string array) and new-format entries to verify backward compatibility. + - A fixture with `trustedParticipantsLists` including "ebsi-v5" type entry. + +- **`verifier/verifier_test.go`:** + - Add or update integration-level tests that exercise the full `AuthenticationResponse` or `GenerateToken` flow with "ebsi-v5" configured trust registries (using mocked TIR client). + - Verify that `getTrustRegistriesValidationContext` correctly propagates type information from config through to the validation context. + +- **`database/models_test.go`:** + - Add round-trip tests for `CredentialDB` with "ebsi-v5" typed trusted issuers lists: `FromVO()` → `VO()` preserves type and URL. + +- **`config/configClient_test.go`:** + - Add test for `ReadConfig()` / config parsing with the new structured `trustedIssuersLists` format in YAML. + - Verify that YAML `trustedIssuersLists: ["https://url"]` still parses correctly (backward compat via mapstructure). + +**Verification steps:** +- Run `go build ./...` — must succeed with no errors. +- Run `go vet ./...` — must succeed with no warnings. +- Run `go test ./... -v` — all tests must pass. +- Run `go test ./... -v -coverprofile=profile.cov` to verify coverage of new code paths. + +**Acceptance criteria:** +- All new and existing tests pass. +- Config backward compatibility is verified by tests (old `[]string` format, new structured format, database round-trip). +- The full verification chain works end-to-end with "ebsi-v5" configuration. +- No compilation warnings or vet issues. From a4cc4f0ec4e1962965dde513ac597da0bd6967d7 Mon Sep 17 00:00:00 2001 From: general-agent-3 Date: Thu, 4 Jun 2026 12:28:46 +0000 Subject: [PATCH 2/9] Extend TrustedIssuersLists config from []string to structured type Change TrustedIssuersLists from a plain string slice to a structured type with Type and Url fields, mirroring TrustedParticipantsList. This enables distinguishing between TIR v3/v4 ("ebsi") and v5 ("ebsi-v5") endpoints in configuration. - Add TrustedIssuersList struct and TrustedIssuersLists type with custom UnmarshalJSON for backward-compatible JSON parsing - Add TrustedIssuersListsDecodeHook for YAML/mapstructure compat - Update Credential struct, CredentialsConfig interface, verifier context types, database models, and trusted issuer validation - Add parameterized tests for UnmarshalJSON and decode hook - All existing tests pass with updated test helpers Co-Authored-By: Claude Opus 4.6 --- config/configClient.go | 99 +++++++++++++++++- config/configClient_test.go | 135 ++++++++++++++++++++++++- config/provider.go | 12 +++ config/provider_test.go | 2 +- database/integration_test.go | 12 +-- database/migration_compat_test.go | 5 +- database/models.go | 26 ++++- database/repository_test.go | 2 +- verifier/credentialsConfig.go | 10 +- verifier/db_credentials_config.go | 6 +- verifier/db_credentials_config_test.go | 6 +- verifier/trustedissuer.go | 33 +++++- verifier/trustedissuer_test.go | 38 ++++++- verifier/verifier.go | 9 +- verifier/verifier_test.go | 4 +- 15 files changed, 357 insertions(+), 42 deletions(-) diff --git a/config/configClient.go b/config/configClient.go index 0724e29..02164ce 100644 --- a/config/configClient.go +++ b/config/configClient.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "net/http" + "reflect" "strings" "github.com/fiware/VCVerifier/logging" + "github.com/mitchellh/mapstructure" ) type EndpointType int @@ -110,7 +112,7 @@ type Credential struct { // A list of (EBSI Trusted Issuers Registry compatible) endpoints to retrieve the trusted participants from. TrustedParticipantsLists TrustedParticipantsLists `json:"trustedParticipantsLists,omitempty" mapstructure:"trustedParticipantsLists,omitempty"` // A list of (EBSI Trusted Issuers Registry compatible) endpoints to retrieve the trusted issuers from. The attributes need to be formatted to comply with the verifiers requirements. - TrustedIssuersLists []string `json:"trustedIssuersLists,omitempty" mapstructure:"trustedIssuersLists,omitempty"` + TrustedIssuersLists TrustedIssuersLists `json:"trustedIssuersLists,omitempty" mapstructure:"trustedIssuersLists,omitempty"` // Configuration of Holder Verification HolderVerification HolderVerification `json:"holderVerification" mapstructure:"holderVerification"` // Does the given credential require a compliancy credential @@ -231,6 +233,101 @@ func (t *TrustedParticipantsLists) UnmarshalJSON(data []byte) error { return nil } +// TrustedIssuersList represents a single trusted issuers registry endpoint +// with an associated type (e.g. "ebsi", "ebsi-v5"). Mirrors +// TrustedParticipantsList for issuers. +type TrustedIssuersList struct { + // Type of issuers list to be used — "ebsi" for v3/v4, "ebsi-v5" for v5. + Type string `json:"type" mapstructure:"type"` + // Url of the trusted issuers registry endpoint. + Url string `json:"url" mapstructure:"url"` +} + +// TrustedIssuersLists is a slice of TrustedIssuersList with a custom JSON +// unmarshaler that accepts both the new structured format and the legacy +// plain string array format for backward compatibility. +type TrustedIssuersLists []TrustedIssuersList + +// UnmarshalJSON supports two JSON formats: +// - Structured: [{"type":"ebsi-v5","url":"https://..."}] +// - Legacy string array: ["https://..."] — each URL defaults to type "ebsi". +func (t *TrustedIssuersLists) UnmarshalJSON(data []byte) error { + // Try structured format first + var structured []TrustedIssuersList + if err := json.Unmarshal(data, &structured); err == nil { + *t = structured + return nil + } + + // Fallback to string array format + var urls []string + if err := json.Unmarshal(data, &urls); err != nil { + return err + } + + result := make([]TrustedIssuersList, len(urls)) + + for i, url := range urls { + result[i] = TrustedIssuersList{ + Type: DEFAULT_LIST_TYPE, + Url: url, + } + } + + *t = result + + return nil +} + +// trustedIssuersListsType is the reflect.Type for TrustedIssuersLists, cached +// to avoid repeated reflect calls in the decode hook. +var trustedIssuersListsType = reflect.TypeOf(TrustedIssuersLists{}) + +// TrustedIssuersListsDecodeHook returns a mapstructure DecodeHookFuncType that +// converts a legacy plain-string slice (from YAML) into a TrustedIssuersLists +// value. Each bare URL string becomes a TrustedIssuersList entry with the +// default type ("ebsi"). Structured entries (maps) are decoded inline. +// This mirrors the JSON backward-compatibility provided by UnmarshalJSON but +// for the YAML/mapstructure code path. +func TrustedIssuersListsDecodeHook() mapstructure.DecodeHookFuncType { + return func(from reflect.Type, to reflect.Type, data interface{}) (interface{}, error) { + if to != trustedIssuersListsType { + return data, nil + } + + slice, ok := data.([]interface{}) + if !ok { + return data, nil + } + + result := make(TrustedIssuersLists, 0, len(slice)) + for _, item := range slice { + switch v := item.(type) { + case string: + // Legacy plain-URL format → default type. + result = append(result, TrustedIssuersList{ + Type: DEFAULT_LIST_TYPE, + Url: v, + }) + case map[string]interface{}: + // Structured format — extract type and url. + entry := TrustedIssuersList{} + if t, ok := v["type"]; ok { + entry.Type = fmt.Sprintf("%v", t) + } + if u, ok := v["url"]; ok { + entry.Url = fmt.Sprintf("%v", u) + } + result = append(result, entry) + default: + // Unrecognised element — let mapstructure surface an error. + return data, nil + } + } + return result, nil + } +} + // EndpointEntry describes a single trust-registry endpoint together with its // type and the list format it exposes. type EndpointEntry struct { diff --git a/config/configClient_test.go b/config/configClient_test.go index 941ab2a..1c14f52 100644 --- a/config/configClient_test.go +++ b/config/configClient_test.go @@ -221,7 +221,7 @@ func Test_getServices(t *testing.T) { { Type: "VerifiableCredential", TrustedParticipantsLists: []TrustedParticipantsList{{Type: "ebsi", Url: "https://tir-pdc.ebsi.fiware.dev"}}, - TrustedIssuersLists: []string{"https://til-pdc.ebsi.fiware.dev"}, + TrustedIssuersLists: TrustedIssuersLists{{Type: "ebsi", Url: "https://til-pdc.ebsi.fiware.dev"}}, HolderVerification: HolderVerification{Enabled: false, Claim: "subject"}, }, }, @@ -262,3 +262,136 @@ func Test_getServices(t *testing.T) { } assert.Equal(t, expectedScopesVO, scopesVO) } + +func TestTrustedIssuersLists_UnmarshalJSON(t *testing.T) { + type testCase struct { + name string + input string + expected TrustedIssuersLists + wantErr bool + } + + tests := []testCase{ + { + name: "structured format with explicit types", + input: `[{"type":"ebsi-v5","url":"https://v5.example.com"},{"type":"ebsi","url":"https://v3.example.com"}]`, + expected: TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://v5.example.com"}, + {Type: "ebsi", Url: "https://v3.example.com"}, + }, + }, + { + name: "legacy plain string array", + input: `["https://tir-a.example.com","https://tir-b.example.com"]`, + expected: TrustedIssuersLists{ + {Type: DEFAULT_LIST_TYPE, Url: "https://tir-a.example.com"}, + {Type: DEFAULT_LIST_TYPE, Url: "https://tir-b.example.com"}, + }, + }, + { + name: "empty array", + input: `[]`, + expected: TrustedIssuersLists{}, + }, + { + name: "single legacy string", + input: `["https://only.example.com"]`, + expected: TrustedIssuersLists{ + {Type: DEFAULT_LIST_TYPE, Url: "https://only.example.com"}, + }, + }, + { + name: "single structured entry", + input: `[{"type":"ebsi-v5","url":"https://only-v5.example.com"}]`, + expected: TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://only-v5.example.com"}, + }, + }, + { + name: "invalid JSON", + input: `not-json`, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var got TrustedIssuersLists + err := json.Unmarshal([]byte(tc.input), &got) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestTrustedIssuersListsDecodeHook(t *testing.T) { + hook := TrustedIssuersListsDecodeHook() + + type testCase struct { + name string + input interface{} + expected interface{} + } + + tests := []testCase{ + { + name: "legacy plain string slice", + input: []interface{}{ + "https://tir-a.example.com", + "https://tir-b.example.com", + }, + expected: TrustedIssuersLists{ + {Type: DEFAULT_LIST_TYPE, Url: "https://tir-a.example.com"}, + {Type: DEFAULT_LIST_TYPE, Url: "https://tir-b.example.com"}, + }, + }, + { + name: "structured map entries", + input: []interface{}{ + map[string]interface{}{"type": "ebsi-v5", "url": "https://v5.example.com"}, + map[string]interface{}{"type": "ebsi", "url": "https://v3.example.com"}, + }, + expected: TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://v5.example.com"}, + {Type: "ebsi", Url: "https://v3.example.com"}, + }, + }, + { + name: "mixed strings and maps", + input: []interface{}{ + "https://legacy.example.com", + map[string]interface{}{"type": "ebsi-v5", "url": "https://v5.example.com"}, + }, + expected: TrustedIssuersLists{ + {Type: DEFAULT_LIST_TYPE, Url: "https://legacy.example.com"}, + {Type: "ebsi-v5", Url: "https://v5.example.com"}, + }, + }, + { + name: "empty slice", + input: []interface{}{}, + expected: TrustedIssuersLists{}, + }, + } + + targetType := reflect.TypeOf(TrustedIssuersLists{}) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := hook(reflect.TypeOf(tc.input), targetType, tc.input) + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } + + t.Run("passthrough for non-target type", func(t *testing.T) { + input := []interface{}{"https://example.com"} + result, err := hook(reflect.TypeOf(input), reflect.TypeOf(""), input) + assert.NoError(t, err) + // Should return the input unchanged since target type doesn't match. + assert.Equal(t, input, result) + }) +} diff --git a/config/provider.go b/config/provider.go index 2386a7d..c00dff7 100644 --- a/config/provider.go +++ b/config/provider.go @@ -6,6 +6,7 @@ import ( "github.com/gookit/config/v2" "github.com/gookit/config/v2/yaml" + "github.com/mitchellh/mapstructure" ) // read the config from the config file @@ -14,6 +15,17 @@ func ReadConfig(configFile string) (configuration Configuration, err error) { opt.ParseDefault = true opt.ParseEnv = true opt.TagName = "mapstructure" + // Compose a custom decode hook for TrustedIssuersLists backward + // compatibility (plain string array → structured entries) with the + // default gookit/config hooks for env-var and time-duration parsing. + opt.DecoderConfig = &mapstructure.DecoderConfig{ + TagName: "mapstructure", + WeaklyTypedInput: true, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + TrustedIssuersListsDecodeHook(), + config.ValDecodeHookFunc(true, false), + ), + } }) config.AddDriver(yaml.Driver) usuario := os.Getenv("DB_USER") diff --git a/config/provider_test.go b/config/provider_test.go index 5d85bd0..074b998 100644 --- a/config/provider_test.go +++ b/config/provider_test.go @@ -82,7 +82,7 @@ func Test_ReadConfig(t *testing.T) { { Type: "VerifiableCredential", TrustedParticipantsLists: []TrustedParticipantsList{{Type: "ebsi", Url: "https://tir-pdc.ebsi.fiware.dev"}}, - TrustedIssuersLists: []string{"https://til-pdc.ebsi.fiware.dev"}, + TrustedIssuersLists: TrustedIssuersLists{{Type: "ebsi", Url: "https://til-pdc.ebsi.fiware.dev"}}, }, }, PresentationDefinition: &PresentationDefinition{ diff --git a/database/integration_test.go b/database/integration_test.go index db063a6..5571f30 100644 --- a/database/integration_test.go +++ b/database/integration_test.go @@ -85,7 +85,7 @@ func TestIntegration_FullCRUDToCacheFlow(t *testing.T) { Credentials: []config.Credential{ { Type: "VerifiableCredential", - TrustedIssuersLists: []string{"https://tir.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://tir.example.com"}}, HolderVerification: config.HolderVerification{Enabled: true, Claim: "sub"}, RequireCompliance: true, JwtInclusion: config.JwtInclusion{ @@ -212,7 +212,7 @@ func TestIntegration_FullCRUDToCacheFlow(t *testing.T) { // GetTrustedIssuersLists issuersLists, err := credConfig.GetTrustedIssuersLists(serviceID, "defaultScope", "VerifiableCredential") require.NoError(t, err) - assert.Equal(t, []string{"https://tir.example.com"}, issuersLists) + assert.Equal(t, []config.TrustedIssuersList{{Type: "ebsi", Url: "https://tir.example.com"}}, issuersLists) // --- Step 4: Update the service → verify changes propagate --- updatedScopes := map[string]config.ScopeEntry{ @@ -220,7 +220,7 @@ func TestIntegration_FullCRUDToCacheFlow(t *testing.T) { Credentials: []config.Credential{ { Type: "UpdatedCredential", - TrustedIssuersLists: []string{"https://tir-updated.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://tir-updated.example.com"}}, RequireCompliance: false, }, }, @@ -435,7 +435,7 @@ func TestIntegration_CredentialTypeLookupsFullChain(t *testing.T) { Credentials: []config.Credential{ { Type: "CredTypeA", - TrustedIssuersLists: []string{"https://til-a.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://til-a.example.com"}}, TrustedParticipantsLists: []config.TrustedParticipantsList{{Type: "ebsi", Url: "https://tpl-a.example.com"}}, HolderVerification: config.HolderVerification{Enabled: true, Claim: "holderId"}, RequireCompliance: true, @@ -443,7 +443,7 @@ func TestIntegration_CredentialTypeLookupsFullChain(t *testing.T) { }, { Type: "CredTypeB", - TrustedIssuersLists: []string{"https://til-b.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://til-b.example.com"}}, RequireCompliance: false, JwtInclusion: config.JwtInclusion{Enabled: &FALSE_OPTION}, }, @@ -566,7 +566,7 @@ func TestIntegration_CredentialTypeLookupsFullChain(t *testing.T) { // Trusted issuers lists tilA, err := credConfig.GetTrustedIssuersLists(serviceID, "scopeA", "CredTypeA") require.NoError(t, err) - assert.Equal(t, []string{"https://til-a.example.com"}, tilA) + assert.Equal(t, []config.TrustedIssuersList{{Type: "ebsi", Url: "https://til-a.example.com"}}, tilA) // Trusted participants lists tplA, err := credConfig.GetTrustedParticipantLists(serviceID, "scopeA", "CredTypeA") diff --git a/database/migration_compat_test.go b/database/migration_compat_test.go index c1c7052..03eb236 100644 --- a/database/migration_compat_test.go +++ b/database/migration_compat_test.go @@ -100,7 +100,8 @@ func TestMigrationCompat_CCSJavaFormatRoundTrip(t *testing.T) { require.Len(t, cred.TrustedIssuersLists, 1) - assert.Equal(t, "https://tir.dsba.fiware.dev/v3/issuers", cred.TrustedIssuersLists[0]) + assert.Equal(t, "ebsi", cred.TrustedIssuersLists[0].Type) + assert.Equal(t, "https://tir.dsba.fiware.dev/v3/issuers", cred.TrustedIssuersLists[0].Url) assert.True(t, cred.HolderVerification.Enabled) assert.Equal(t, "sub", cred.HolderVerification.Claim) @@ -152,7 +153,7 @@ func TestMigrationCompat_GoWriteCCSRead(t *testing.T) { Credentials: []config.Credential{ { Type: "VerifiableCredential", - TrustedIssuersLists: []string{"https://til.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://til.example.com"}}, TrustedParticipantsLists: []config.TrustedParticipantsList{{Type: "gaia-x", Url: "https://tpl.example.com"}}, HolderVerification: config.HolderVerification{Enabled: false, Claim: ""}, RequireCompliance: true, diff --git a/database/models.go b/database/models.go index e710636..071d6f9 100644 --- a/database/models.go +++ b/database/models.go @@ -269,8 +269,10 @@ type CredentialDB struct { CredentialStatus config.CredentialStatus `json:"credentialStatus,omitempty" mapstructure:"credentialStatus,omitempty"` } +// VO converts a CredentialDB into its config.Credential value object, mapping +// the unified EndpointEntry list back into separate participants and issuers lists. func (cred CredentialDB) VO() config.Credential { - trustedIssuerList := make([]string, 0, len(cred.TrustedIssuersLists)) + trustedIssuerList := make(config.TrustedIssuersLists, 0, len(cred.TrustedIssuersLists)) trustedParticipantsList := make([]config.TrustedParticipantsList, 0, len(cred.TrustedIssuersLists)) for _, trustedIssuer := range cred.TrustedIssuersLists { switch trustedIssuer.Type { @@ -284,7 +286,14 @@ func (cred CredentialDB) VO() config.Credential { Url: trustedIssuer.Endpoint, }) case config.TrustedIssuers: - trustedIssuerList = append(trustedIssuerList, trustedIssuer.Endpoint) + listType := trustedIssuer.ListType + if listType == "" { + listType = config.DEFAULT_LIST_TYPE + } + trustedIssuerList = append(trustedIssuerList, config.TrustedIssuersList{ + Type: listType, + Url: trustedIssuer.Endpoint, + }) } } @@ -299,6 +308,9 @@ func (cred CredentialDB) VO() config.Credential { } } +// FromVO converts a config.Credential value object into a CredentialDB, +// merging the separate participants and issuers lists into a unified +// EndpointEntry slice. func (c CredentialDB) FromVO(cv config.Credential) CredentialDB { trustedLists := make([]config.EndpointEntry, 0, len(cv.TrustedParticipantsLists)+len(cv.TrustedIssuersLists)) for _, tp := range cv.TrustedParticipantsLists { @@ -312,11 +324,15 @@ func (c CredentialDB) FromVO(cv config.Credential) CredentialDB { Endpoint: tp.Url, }) } - for _, endpoint := range cv.TrustedIssuersLists { + for _, issuer := range cv.TrustedIssuersLists { + listType := issuer.Type + if listType == "" { + listType = config.DEFAULT_LIST_TYPE + } trustedLists = append(trustedLists, config.EndpointEntry{ Type: config.TrustedIssuers, - ListType: config.DEFAULT_LIST_TYPE, - Endpoint: endpoint, + ListType: listType, + Endpoint: issuer.Url, }) } return CredentialDB{ diff --git a/database/repository_test.go b/database/repository_test.go index d1cb7b2..16ffad1 100644 --- a/database/repository_test.go +++ b/database/repository_test.go @@ -29,7 +29,7 @@ func sampleService(id string) config.ConfiguredService { Credentials: []config.Credential{ { Type: "VerifiableCredential", - TrustedIssuersLists: []string{"https://tir.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://tir.example.com"}}, HolderVerification: config.HolderVerification{Enabled: true, Claim: "sub"}, }, }, diff --git a/verifier/credentialsConfig.go b/verifier/credentialsConfig.go index 0d2c0b4..6c9eda7 100644 --- a/verifier/credentialsConfig.go +++ b/verifier/credentialsConfig.go @@ -49,7 +49,7 @@ type CredentialsConfig interface { // GetTrustedIssuersLists returns (EBSI TrustedIssuersRegistry compliant) endpoints for the // given service/credential combination, to check that credentials are issued by trusted issuers // and that the issuer has permission to issue such claims. - GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []string, err error) + GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []config.TrustedIssuersList, err error) // RequiredCredentialTypes returns the credential types that are required for the given service and scope. RequiredCredentialTypes(serviceIdentifier string, scope string) (credentialTypes []string, err error) // GetHolderVerification returns holder verification configuration. @@ -177,7 +177,9 @@ func updateCacheFromServices(services []config.ConfiguredService) { if err != nil { logging.Log().Errorf("failed caching issuers lists in fillCache(): %v", err) } else { - tirEndpoints = append(tirEndpoints, serviceIssuersLists...) + for _, entry := range serviceIssuersLists { + tirEndpoints = append(tirEndpoints, entry.Url) + } } } } @@ -284,7 +286,7 @@ func (cc cacheBasedCredentialsConfig) GetTrustedParticipantLists(serviceIdentifi } // GetTrustedIssuersLists returns trusted issuers list endpoints for the given service, scope, and credential type. -func (cc cacheBasedCredentialsConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []string, err error) { +func (cc cacheBasedCredentialsConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []config.TrustedIssuersList, err error) { logging.Log().Debugf("Get issuers list for %s - %s - %s.", serviceIdentifier, scope, credentialType) cacheEntry, hit := common.GlobalCache.ServiceCache.Get(serviceIdentifier) if hit { @@ -295,7 +297,7 @@ func (cc cacheBasedCredentialsConfig) GetTrustedIssuersLists(serviceIdentifier s } } logging.Log().Debugf("No trusted issuers for %s - %s", serviceIdentifier, credentialType) - return []string{}, nil + return []config.TrustedIssuersList{}, nil } // GetComplianceRequired returns whether compliance is required for the given credential type. diff --git a/verifier/db_credentials_config.go b/verifier/db_credentials_config.go index 5d9defa..a7bec8a 100644 --- a/verifier/db_credentials_config.go +++ b/verifier/db_credentials_config.go @@ -145,14 +145,14 @@ func (dbc DbBackedCredentialsConfig) GetTrustedParticipantLists(serviceIdentifie // GetTrustedIssuersLists returns trusted issuers list endpoints for the given // service, scope, and credential type. -func (dbc DbBackedCredentialsConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) ([]string, error) { +func (dbc DbBackedCredentialsConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) ([]config.TrustedIssuersList, error) { svc, err := dbc.getService(serviceIdentifier) if err != nil { - return []string{}, nil + return []config.TrustedIssuersList{}, nil } credential, ok := svc.GetCredential(scope, credentialType) if !ok { - return []string{}, nil + return []config.TrustedIssuersList{}, nil } return credential.TrustedIssuersLists, nil } diff --git a/verifier/db_credentials_config_test.go b/verifier/db_credentials_config_test.go index 716e706..e5102a5 100644 --- a/verifier/db_credentials_config_test.go +++ b/verifier/db_credentials_config_test.go @@ -108,7 +108,7 @@ func testService(id, scopeName, credentialType string) config.ConfiguredService Credentials: []config.Credential{ { Type: credentialType, - TrustedIssuersLists: []string{"https://tir.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://tir.example.com"}}, TrustedParticipantsLists: []config.TrustedParticipantsList{{Type: "ebsi", Url: "https://tpl.example.com"}}, HolderVerification: config.HolderVerification{Enabled: true, Claim: "sub"}, RequireCompliance: true, @@ -131,7 +131,7 @@ func testServiceVO(id, scopeName, credentialType string) config.ConfiguredServic Credentials: []config.Credential{ { Type: credentialType, - TrustedIssuersLists: []string{"https://tir.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://tir.example.com"}}, TrustedParticipantsLists: []config.TrustedParticipantsList{{Type: "ebsi", Url: "https://tpl.example.com"}}, }, }, @@ -221,7 +221,7 @@ func TestDbBackedCredentialsConfig_AllInterfaceMethods(t *testing.T) { t.Run("GetTrustedIssuersLists", func(t *testing.T) { til, err := cc.GetTrustedIssuersLists("test-svc", "myScope", "TestCredential") require.NoError(t, err) - assert.Equal(t, []string{"https://tir.example.com"}, til) + assert.Equal(t, []config.TrustedIssuersList{{Type: "ebsi", Url: "https://tir.example.com"}}, til) }) t.Run("GetHolderVerification", func(t *testing.T) { diff --git a/verifier/trustedissuer.go b/verifier/trustedissuer.go index c22f89b..de5e883 100644 --- a/verifier/trustedissuer.go +++ b/verifier/trustedissuer.go @@ -7,6 +7,7 @@ import ( "github.com/PaesslerAG/jsonpath" "github.com/fiware/VCVerifier/common" + configModel "github.com/fiware/VCVerifier/config" "github.com/fiware/VCVerifier/logging" tir "github.com/fiware/VCVerifier/tir" "github.com/google/go-cmp/cmp" @@ -63,13 +64,17 @@ func (tpvs *TrustedIssuerValidationService) ValidateVC(verifiableCredential *com return false, err } - tilAddress, credentialSupported := til[credentialType] + tilEntries, credentialSupported := til[credentialType] if !credentialSupported { logging.Log().Debugf("No trusted issuers list configured for type %s", credentialType) return false, ErrorNoTilForType } - exist, trustedIssuer, err := tpvs.tirClient.GetTrustedIssuer(tilAddress, verifiableCredential.Contents().Issuer.ID) + // Extract URLs from entries with ebsi type (or empty/default) for v3/v4 lookup. + // V5 dispatch will be added in a later step. + tilURLs := extractTilURLs(tilEntries) + + exist, trustedIssuer, err := tpvs.tirClient.GetTrustedIssuer(tilURLs, verifiableCredential.Contents().Issuer.ID) if err != nil { logging.Log().Warnf("Was not able to validate trusted issuer. Err: %v", err) @@ -93,16 +98,34 @@ func (tpvs *TrustedIssuerValidationService) ValidateVC(verifiableCredential *com return true, err } -func isWildcardTil(tilList []string) (isWildcard bool, err error) { - if len(tilList) == 1 && tilList[0] == WILDCARD_TIL { +// isWildcardTil checks whether the given TIL list contains the wildcard +// entry ("*"). A wildcard must be the only entry; mixing it with other +// entries is considered invalid configuration. +func isWildcardTil(tilList []configModel.TrustedIssuersList) (isWildcard bool, err error) { + if len(tilList) == 1 && tilList[0].Url == WILDCARD_TIL { return true, err } - if len(tilList) > 1 && slices.Contains(tilList, WILDCARD_TIL) { //nolint:govet + urls := make([]string, len(tilList)) + for i, entry := range tilList { + urls[i] = entry.Url + } + if len(tilList) > 1 && slices.Contains(urls, WILDCARD_TIL) { //nolint:govet return false, ErrorInvalidTil } return false, err } +// extractTilURLs collects the URL strings from TrustedIssuersList entries. +// In this step all entries are passed through; v5-specific dispatch will be +// added in a later step. +func extractTilURLs(entries []configModel.TrustedIssuersList) []string { + urls := make([]string, 0, len(entries)) + for _, entry := range entries { + urls = append(urls, entry.Url) + } + return urls +} + func verifyWithCredentialsConfig(verifiableCredential *common.Credential, credentials []tir.Credential) (result bool, err error) { credentialsConfigMap := map[string][]tir.Credential{} diff --git a/verifier/trustedissuer_test.go b/verifier/trustedissuer_test.go index 1b3ddc5..d35d694 100644 --- a/verifier/trustedissuer_test.go +++ b/verifier/trustedissuer_test.go @@ -249,19 +249,49 @@ func getTrustedIssuer(attributes []tir.IssuerAttribute) tir.TrustedIssuer { } func getVerificationContext() ValidationContext { - return TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}, trustedIssuersLists: map[string][]string{"VerifiableCredential": {"http://my-til.org"}}} + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-til.org"}}, + }, + } } func getWildcardVerificationContext() ValidationContext { - return TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}, trustedIssuersLists: map[string][]string{"VerifiableCredential": {"*"}}} + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": {{Type: "ebsi", Url: "*"}}, + }, + } } func getInvalidMixedVerificationContext() ValidationContext { - return TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}, trustedIssuersLists: map[string][]string{"VerifiableCredential": {"*", "http://my-til.org"}}} + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": {{Type: "ebsi", Url: "*"}, {Type: "ebsi", Url: "http://my-til.org"}}, + }, + } } func getWildcardAndNormalVerificationContext() ValidationContext { - return TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, "SecondType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}, trustedIssuersLists: map[string][]string{"VerifiableCredential": {"*"}, "SecondType": {"http://my-til.org"}}} + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + "SecondType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": {{Type: "ebsi", Url: "*"}}, + "SecondType": {{Type: "ebsi", Url: "http://my-til.org"}}, + }, + } } func getMultiTypeCredential(types []string, claimName string, value interface{}) common.Credential { diff --git a/verifier/verifier.go b/verifier/verifier.go index 5695c39..e861c64 100644 --- a/verifier/verifier.go +++ b/verifier/verifier.go @@ -191,11 +191,12 @@ var localFileAccessor common.FileAccessor = common.DiskFileAccessor{} type ValidationContext interface{} type TrustRegistriesValidationContext struct { - trustedIssuersLists map[string][]string + trustedIssuersLists map[string][]configModel.TrustedIssuersList trustedParticipantsRegistries map[string][]configModel.TrustedParticipantsList } -func (trvc TrustRegistriesValidationContext) GetTrustedIssuersLists() map[string][]string { +// GetTrustedIssuersLists returns the per-credential-type trusted issuers list configuration. +func (trvc TrustRegistriesValidationContext) GetTrustedIssuersLists() map[string][]configModel.TrustedIssuersList { return trvc.trustedIssuersLists } @@ -1122,7 +1123,7 @@ func (v *CredentialVerifier) getHolderValidationContext(clientId string, scope s func (v *CredentialVerifier) getTrustRegistriesValidationContext(clientId string, credentialTypes []string, scope string) (verificationContext TrustRegistriesValidationContext, err error) { logging.Log().Debugf("Create trust registry validation context for client '%s', scope '%s' and credential types %s", clientId, scope, credentialTypes) - trustedIssuersLists := map[string][]string{} + trustedIssuersLists := map[string][]configModel.TrustedIssuersList{} trustedParticipantsRegistries := map[string][]configModel.TrustedParticipantsList{} for _, credentialType := range credentialTypes { @@ -1186,7 +1187,7 @@ func selectValidationContext(service ValidationService, trustContext TrustRegist } func (v *CredentialVerifier) getTrustRegistriesValidationContextFromScope(clientId string, scope string, credentialTypes []string) (verificationContext TrustRegistriesValidationContext, err error) { - trustedIssuersLists := map[string][]string{} + trustedIssuersLists := map[string][]configModel.TrustedIssuersList{} trustedParticipantsRegistries := map[string][]configModel.TrustedParticipantsList{} requiredCredentialTypes, err := v.credentialsConfig.RequiredCredentialTypes(clientId, scope) diff --git a/verifier/verifier_test.go b/verifier/verifier_test.go index 4bfb40c..036ec53 100644 --- a/verifier/verifier_test.go +++ b/verifier/verifier_test.go @@ -107,7 +107,7 @@ func createMockCredentials(serviceId, scope, credentialType, url, holderClaim st credential := configModel.Credential{ Type: credentialType, TrustedParticipantsLists: []configModel.TrustedParticipantsList{{Type: "ebsi", Url: url}}, - TrustedIssuersLists: []string{url}, + TrustedIssuersLists: configModel.TrustedIssuersLists{{Type: "ebsi", Url: url}}, HolderVerification: configModel.HolderVerification{Enabled: holderVerfication, Claim: holderClaim}, } @@ -153,7 +153,7 @@ func (mcc mockCredentialConfig) GetTrustedParticipantLists(serviceIdentifier str } return trustedIssuersRegistryUrl, err } -func (mcc mockCredentialConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []string, err error) { +func (mcc mockCredentialConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []configModel.TrustedIssuersList, err error) { if mcc.mockError != nil { return trustedIssuersRegistryUrl, mcc.mockError } From 418e2549fec8e36cc297b051aa8d102f874a2bc2 Mon Sep 17 00:00:00 2001 From: general-agent-3 Date: Thu, 4 Jun 2026 13:07:39 +0000 Subject: [PATCH 3/9] Add DB migration to backfill empty listType in credentials JSON - Add MigrateCredentialsTrustedListType() that scans scope_entry rows and backfills empty listType fields with the default value ("ebsi") - Hook migration into InitSchema so it runs automatically on startup - Add comprehensive tests: backfill, idempotency, empty DB, placeholder adaptation Co-Authored-By: Claude Opus 4.6 --- database/migrations.go | 112 ++++++++++++++++++ database/migrations_test.go | 226 ++++++++++++++++++++++++++++++++++++ database/schema.go | 6 + 3 files changed, 344 insertions(+) create mode 100644 database/migrations.go create mode 100644 database/migrations_test.go diff --git a/database/migrations.go b/database/migrations.go new file mode 100644 index 0000000..18c5f2a --- /dev/null +++ b/database/migrations.go @@ -0,0 +1,112 @@ +package database + +import ( + "database/sql" + "encoding/json" + "fmt" + + "github.com/fiware/VCVerifier/config" + "github.com/fiware/VCVerifier/logging" +) + +// adaptPlaceholders rewrites `?` placeholders to `$N` when dbType is +// PostgreSQL. MySQL and SQLite use `?` natively. +func adaptPlaceholders(query, dbType string) string { + if dbType != DriverTypePostgres { + return query + } + var out []byte + n := 1 + for i := 0; i < len(query); i++ { + if query[i] == '?' { + out = append(out, []byte(fmt.Sprintf("$%d", n))...) + n++ + } else { + out = append(out, query[i]) + } + } + return string(out) +} + +// SQL statements used by the migration. Written with `?` placeholders so +// they can be adapted per driver. +const ( + sqlMigSelectScopeCredentials = `SELECT id, credentials FROM scope_entry` + sqlMigUpdateScopeCredentials = `UPDATE scope_entry SET credentials = ? WHERE id = ?` +) + +// MigrateCredentialsTrustedListType scans all scope_entry rows and ensures +// that every EndpointEntry in the credentials JSON column has a non-empty +// listType field. Entries with an empty listType are backfilled with the +// default value ("ebsi"). This handles data written by older code versions +// that may not have populated the listType for TRUSTED_ISSUERS entries. +// +// The migration is idempotent — calling it multiple times is safe because +// rows that already have a populated listType are left untouched. +func MigrateCredentialsTrustedListType(db *sql.DB, dbType string) error { + logging.Log().Info("Running migration: backfill empty listType in credentials JSON") + + rows, err := db.Query(sqlMigSelectScopeCredentials) + if err != nil { + return fmt.Errorf("migration: failed to query scope_entry: %w", err) + } + defer func() { _ = rows.Close() }() + + type pendingUpdate struct { + id int64 + credJSON string + } + + var updates []pendingUpdate + + for rows.Next() { + var id int64 + var credJSON string + if err := rows.Scan(&id, &credJSON); err != nil { + return fmt.Errorf("migration: failed to scan scope_entry row: %w", err) + } + + var creds []CredentialDB + if err := json.Unmarshal([]byte(credJSON), &creds); err != nil { + logging.Log().Warnf("Migration: skipping scope_entry id=%d with invalid credentials JSON: %v", id, err) + continue + } + + modified := false + for i, cred := range creds { + for j, entry := range cred.TrustedIssuersLists { + if entry.ListType == "" { + creds[i].TrustedIssuersLists[j].ListType = config.DEFAULT_LIST_TYPE + modified = true + } + } + } + + if modified { + newJSON, err := json.Marshal(creds) + if err != nil { + return fmt.Errorf("migration: failed to marshal updated credentials for scope_entry id=%d: %w", id, err) + } + updates = append(updates, pendingUpdate{id: id, credJSON: string(newJSON)}) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("migration: error iterating scope_entry rows: %w", err) + } + + updateSQL := adaptPlaceholders(sqlMigUpdateScopeCredentials, dbType) + for _, u := range updates { + if _, err := db.Exec(updateSQL, u.credJSON, u.id); err != nil { + return fmt.Errorf("migration: failed to update scope_entry id=%d: %w", u.id, err) + } + logging.Log().Infof("Migration: backfilled listType for scope_entry id=%d", u.id) + } + + if len(updates) > 0 { + logging.Log().Infof("Migration: updated %d scope_entry row(s) with missing listType", len(updates)) + } else { + logging.Log().Info("Migration: no scope_entry rows required listType backfill") + } + + return nil +} diff --git a/database/migrations_test.go b/database/migrations_test.go new file mode 100644 index 0000000..6e34a6e --- /dev/null +++ b/database/migrations_test.go @@ -0,0 +1,226 @@ +package database + +import ( + "database/sql" + "encoding/json" + "testing" + + "github.com/fiware/VCVerifier/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// insertScopeEntryRaw inserts a scope_entry row with raw credentials JSON +// for migration testing. +func insertScopeEntryRaw(t *testing.T, db *sql.DB, serviceID, scopeKey, credJSON string) int64 { + t.Helper() + // Ensure the parent service exists. + _, _ = db.Exec(`INSERT OR IGNORE INTO service (id) VALUES (?)`, serviceID) + + res, err := db.Exec( + `INSERT INTO scope_entry (service_id, scope_key, credentials, flat_claims) VALUES (?, ?, ?, 0)`, + serviceID, scopeKey, credJSON, + ) + require.NoError(t, err) + id, err := res.LastInsertId() + require.NoError(t, err) + return id +} + +// readCredentials reads and unmarshals the credentials JSON for a given +// scope_entry row. +func readCredentials(t *testing.T, db *sql.DB, rowID int64) []CredentialDB { + t.Helper() + var raw string + err := db.QueryRow(`SELECT credentials FROM scope_entry WHERE id = ?`, rowID).Scan(&raw) + require.NoError(t, err) + + var creds []CredentialDB + require.NoError(t, json.Unmarshal([]byte(raw), &creds)) + return creds +} + +func TestMigrateCredentialsTrustedListType(t *testing.T) { + type testCase struct { + name string + credJSON string + expectModified bool + // expectedListType checks the listType of the first TrustedIssuersLists + // entry in the first credential after migration. + expectedListType string + } + + tests := []testCase{ + { + name: "backfills empty listType with default", + credJSON: mustJSON(t, []CredentialDB{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "", Endpoint: "https://tir.example.com"}, + }, + }, + }), + expectModified: true, + expectedListType: config.DEFAULT_LIST_TYPE, + }, + { + name: "leaves existing listType untouched", + credJSON: mustJSON(t, []CredentialDB{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://v5.example.com"}, + }, + }, + }), + expectModified: false, + expectedListType: "ebsi-v5", + }, + { + name: "skips rows with no trusted issuers entries", + credJSON: mustJSON(t, []CredentialDB{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: []config.EndpointEntry{}, + }, + }), + expectModified: false, + expectedListType: "", // no entry to check + }, + { + name: "backfills only empty entries in mixed list", + credJSON: mustJSON(t, []CredentialDB{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://v5.example.com"}, + {Type: config.TrustedIssuers, ListType: "", Endpoint: "https://legacy.example.com"}, + }, + }, + }), + expectModified: true, + expectedListType: "ebsi-v5", // first entry stays unchanged + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := openTestDB(t) + err := InitSchema(db, DriverTypeSQLite) + require.NoError(t, err) + + // Disable the migration that InitSchema calls so we can test it + // explicitly. Re-run with a fresh DB where InitSchema already ran, + // then insert test data and run the migration manually. + // Actually, since InitSchema already called the migration on an + // empty DB, we just insert our test data and call the migration + // again (it's idempotent). + rowID := insertScopeEntryRaw(t, db, "svc-"+tc.name, "scope", tc.credJSON) + + err = MigrateCredentialsTrustedListType(db, DriverTypeSQLite) + require.NoError(t, err) + + creds := readCredentials(t, db, rowID) + require.Len(t, creds, 1) + + if tc.expectedListType == "" { + // No entries to check. + return + } + + require.NotEmpty(t, creds[0].TrustedIssuersLists) + assert.Equal(t, tc.expectedListType, creds[0].TrustedIssuersLists[0].ListType) + + if tc.name == "backfills only empty entries in mixed list" { + require.Len(t, creds[0].TrustedIssuersLists, 2) + assert.Equal(t, config.DEFAULT_LIST_TYPE, creds[0].TrustedIssuersLists[1].ListType, + "second entry should have been backfilled") + } + }) + } +} + +func TestMigrateCredentialsTrustedListType_Idempotent(t *testing.T) { + db := openTestDB(t) + err := InitSchema(db, DriverTypeSQLite) + require.NoError(t, err) + + credJSON := mustJSON(t, []CredentialDB{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "", Endpoint: "https://tir.example.com"}, + }, + }, + }) + rowID := insertScopeEntryRaw(t, db, "svc-idem", "scope", credJSON) + + // Run migration twice. + require.NoError(t, MigrateCredentialsTrustedListType(db, DriverTypeSQLite)) + require.NoError(t, MigrateCredentialsTrustedListType(db, DriverTypeSQLite)) + + creds := readCredentials(t, db, rowID) + require.Len(t, creds, 1) + require.Len(t, creds[0].TrustedIssuersLists, 1) + assert.Equal(t, config.DEFAULT_LIST_TYPE, creds[0].TrustedIssuersLists[0].ListType) +} + +func TestMigrateCredentialsTrustedListType_EmptyDB(t *testing.T) { + db := openTestDB(t) + // InitSchema already calls the migration, which should succeed on an + // empty database with no scope_entry rows. + err := InitSchema(db, DriverTypeSQLite) + require.NoError(t, err) +} + +func TestAdaptPlaceholders(t *testing.T) { + type testCase struct { + name string + query string + dbType string + expected string + } + + tests := []testCase{ + { + name: "postgres replaces placeholders", + query: "UPDATE t SET a = ? WHERE b = ?", + dbType: DriverTypePostgres, + expected: "UPDATE t SET a = $1 WHERE b = $2", + }, + { + name: "sqlite keeps placeholders", + query: "UPDATE t SET a = ? WHERE b = ?", + dbType: DriverTypeSQLite, + expected: "UPDATE t SET a = ? WHERE b = ?", + }, + { + name: "mysql keeps placeholders", + query: "UPDATE t SET a = ? WHERE b = ?", + dbType: DriverTypeMySQL, + expected: "UPDATE t SET a = ? WHERE b = ?", + }, + { + name: "no placeholders", + query: "SELECT * FROM t", + dbType: DriverTypePostgres, + expected: "SELECT * FROM t", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := adaptPlaceholders(tc.query, tc.dbType) + assert.Equal(t, tc.expected, result) + }) + } +} + +// mustJSON marshals v and returns the JSON string, failing the test on error. +func mustJSON(t *testing.T, v interface{}) string { + t.Helper() + data, err := json.Marshal(v) + require.NoError(t, err) + return string(data) +} diff --git a/database/schema.go b/database/schema.go index ebb2116..2f3b204 100644 --- a/database/schema.go +++ b/database/schema.go @@ -135,5 +135,11 @@ func InitSchema(db *sql.DB, dbType string) error { } logging.Log().Info("Database schema initialized successfully") + + // Run data migrations after schema is in place. + if err := MigrateCredentialsTrustedListType(db, dbType); err != nil { + return fmt.Errorf("failed to run credentials listType migration: %w", err) + } + return nil } From 27d4746c6c6a048cbf08b338ccdc5f808fc313f3 Mon Sep 17 00:00:00 2001 From: general-agent-3 Date: Thu, 4 Jun 2026 13:42:42 +0000 Subject: [PATCH 4/9] Address review feedback on PR #2: replace Go migrations with Liquibase - Remove database/migrations.go and database/migrations_test.go (Go code migrations) - Remove migration call from database/schema.go InitSchema() - Add db/migration/changelog.xml (Liquibase root changelog) - Add db/migration/v3/changelog-v3_0_0.xml (backfill empty listType with "ebsi") - Liquibase format is compatible with CCS migration chain (v0..v2) Co-Authored-By: Claude Opus 4.6 --- database/migrations.go | 112 ------------- database/migrations_test.go | 226 --------------------------- database/schema.go | 6 - db/migration/changelog.xml | 10 ++ db/migration/v3/changelog-v3_0_0.xml | 41 +++++ 5 files changed, 51 insertions(+), 344 deletions(-) delete mode 100644 database/migrations.go delete mode 100644 database/migrations_test.go create mode 100644 db/migration/changelog.xml create mode 100644 db/migration/v3/changelog-v3_0_0.xml diff --git a/database/migrations.go b/database/migrations.go deleted file mode 100644 index 18c5f2a..0000000 --- a/database/migrations.go +++ /dev/null @@ -1,112 +0,0 @@ -package database - -import ( - "database/sql" - "encoding/json" - "fmt" - - "github.com/fiware/VCVerifier/config" - "github.com/fiware/VCVerifier/logging" -) - -// adaptPlaceholders rewrites `?` placeholders to `$N` when dbType is -// PostgreSQL. MySQL and SQLite use `?` natively. -func adaptPlaceholders(query, dbType string) string { - if dbType != DriverTypePostgres { - return query - } - var out []byte - n := 1 - for i := 0; i < len(query); i++ { - if query[i] == '?' { - out = append(out, []byte(fmt.Sprintf("$%d", n))...) - n++ - } else { - out = append(out, query[i]) - } - } - return string(out) -} - -// SQL statements used by the migration. Written with `?` placeholders so -// they can be adapted per driver. -const ( - sqlMigSelectScopeCredentials = `SELECT id, credentials FROM scope_entry` - sqlMigUpdateScopeCredentials = `UPDATE scope_entry SET credentials = ? WHERE id = ?` -) - -// MigrateCredentialsTrustedListType scans all scope_entry rows and ensures -// that every EndpointEntry in the credentials JSON column has a non-empty -// listType field. Entries with an empty listType are backfilled with the -// default value ("ebsi"). This handles data written by older code versions -// that may not have populated the listType for TRUSTED_ISSUERS entries. -// -// The migration is idempotent — calling it multiple times is safe because -// rows that already have a populated listType are left untouched. -func MigrateCredentialsTrustedListType(db *sql.DB, dbType string) error { - logging.Log().Info("Running migration: backfill empty listType in credentials JSON") - - rows, err := db.Query(sqlMigSelectScopeCredentials) - if err != nil { - return fmt.Errorf("migration: failed to query scope_entry: %w", err) - } - defer func() { _ = rows.Close() }() - - type pendingUpdate struct { - id int64 - credJSON string - } - - var updates []pendingUpdate - - for rows.Next() { - var id int64 - var credJSON string - if err := rows.Scan(&id, &credJSON); err != nil { - return fmt.Errorf("migration: failed to scan scope_entry row: %w", err) - } - - var creds []CredentialDB - if err := json.Unmarshal([]byte(credJSON), &creds); err != nil { - logging.Log().Warnf("Migration: skipping scope_entry id=%d with invalid credentials JSON: %v", id, err) - continue - } - - modified := false - for i, cred := range creds { - for j, entry := range cred.TrustedIssuersLists { - if entry.ListType == "" { - creds[i].TrustedIssuersLists[j].ListType = config.DEFAULT_LIST_TYPE - modified = true - } - } - } - - if modified { - newJSON, err := json.Marshal(creds) - if err != nil { - return fmt.Errorf("migration: failed to marshal updated credentials for scope_entry id=%d: %w", id, err) - } - updates = append(updates, pendingUpdate{id: id, credJSON: string(newJSON)}) - } - } - if err := rows.Err(); err != nil { - return fmt.Errorf("migration: error iterating scope_entry rows: %w", err) - } - - updateSQL := adaptPlaceholders(sqlMigUpdateScopeCredentials, dbType) - for _, u := range updates { - if _, err := db.Exec(updateSQL, u.credJSON, u.id); err != nil { - return fmt.Errorf("migration: failed to update scope_entry id=%d: %w", u.id, err) - } - logging.Log().Infof("Migration: backfilled listType for scope_entry id=%d", u.id) - } - - if len(updates) > 0 { - logging.Log().Infof("Migration: updated %d scope_entry row(s) with missing listType", len(updates)) - } else { - logging.Log().Info("Migration: no scope_entry rows required listType backfill") - } - - return nil -} diff --git a/database/migrations_test.go b/database/migrations_test.go deleted file mode 100644 index 6e34a6e..0000000 --- a/database/migrations_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package database - -import ( - "database/sql" - "encoding/json" - "testing" - - "github.com/fiware/VCVerifier/config" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// insertScopeEntryRaw inserts a scope_entry row with raw credentials JSON -// for migration testing. -func insertScopeEntryRaw(t *testing.T, db *sql.DB, serviceID, scopeKey, credJSON string) int64 { - t.Helper() - // Ensure the parent service exists. - _, _ = db.Exec(`INSERT OR IGNORE INTO service (id) VALUES (?)`, serviceID) - - res, err := db.Exec( - `INSERT INTO scope_entry (service_id, scope_key, credentials, flat_claims) VALUES (?, ?, ?, 0)`, - serviceID, scopeKey, credJSON, - ) - require.NoError(t, err) - id, err := res.LastInsertId() - require.NoError(t, err) - return id -} - -// readCredentials reads and unmarshals the credentials JSON for a given -// scope_entry row. -func readCredentials(t *testing.T, db *sql.DB, rowID int64) []CredentialDB { - t.Helper() - var raw string - err := db.QueryRow(`SELECT credentials FROM scope_entry WHERE id = ?`, rowID).Scan(&raw) - require.NoError(t, err) - - var creds []CredentialDB - require.NoError(t, json.Unmarshal([]byte(raw), &creds)) - return creds -} - -func TestMigrateCredentialsTrustedListType(t *testing.T) { - type testCase struct { - name string - credJSON string - expectModified bool - // expectedListType checks the listType of the first TrustedIssuersLists - // entry in the first credential after migration. - expectedListType string - } - - tests := []testCase{ - { - name: "backfills empty listType with default", - credJSON: mustJSON(t, []CredentialDB{ - { - Type: "VerifiableCredential", - TrustedIssuersLists: []config.EndpointEntry{ - {Type: config.TrustedIssuers, ListType: "", Endpoint: "https://tir.example.com"}, - }, - }, - }), - expectModified: true, - expectedListType: config.DEFAULT_LIST_TYPE, - }, - { - name: "leaves existing listType untouched", - credJSON: mustJSON(t, []CredentialDB{ - { - Type: "VerifiableCredential", - TrustedIssuersLists: []config.EndpointEntry{ - {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://v5.example.com"}, - }, - }, - }), - expectModified: false, - expectedListType: "ebsi-v5", - }, - { - name: "skips rows with no trusted issuers entries", - credJSON: mustJSON(t, []CredentialDB{ - { - Type: "VerifiableCredential", - TrustedIssuersLists: []config.EndpointEntry{}, - }, - }), - expectModified: false, - expectedListType: "", // no entry to check - }, - { - name: "backfills only empty entries in mixed list", - credJSON: mustJSON(t, []CredentialDB{ - { - Type: "VerifiableCredential", - TrustedIssuersLists: []config.EndpointEntry{ - {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://v5.example.com"}, - {Type: config.TrustedIssuers, ListType: "", Endpoint: "https://legacy.example.com"}, - }, - }, - }), - expectModified: true, - expectedListType: "ebsi-v5", // first entry stays unchanged - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - db := openTestDB(t) - err := InitSchema(db, DriverTypeSQLite) - require.NoError(t, err) - - // Disable the migration that InitSchema calls so we can test it - // explicitly. Re-run with a fresh DB where InitSchema already ran, - // then insert test data and run the migration manually. - // Actually, since InitSchema already called the migration on an - // empty DB, we just insert our test data and call the migration - // again (it's idempotent). - rowID := insertScopeEntryRaw(t, db, "svc-"+tc.name, "scope", tc.credJSON) - - err = MigrateCredentialsTrustedListType(db, DriverTypeSQLite) - require.NoError(t, err) - - creds := readCredentials(t, db, rowID) - require.Len(t, creds, 1) - - if tc.expectedListType == "" { - // No entries to check. - return - } - - require.NotEmpty(t, creds[0].TrustedIssuersLists) - assert.Equal(t, tc.expectedListType, creds[0].TrustedIssuersLists[0].ListType) - - if tc.name == "backfills only empty entries in mixed list" { - require.Len(t, creds[0].TrustedIssuersLists, 2) - assert.Equal(t, config.DEFAULT_LIST_TYPE, creds[0].TrustedIssuersLists[1].ListType, - "second entry should have been backfilled") - } - }) - } -} - -func TestMigrateCredentialsTrustedListType_Idempotent(t *testing.T) { - db := openTestDB(t) - err := InitSchema(db, DriverTypeSQLite) - require.NoError(t, err) - - credJSON := mustJSON(t, []CredentialDB{ - { - Type: "VerifiableCredential", - TrustedIssuersLists: []config.EndpointEntry{ - {Type: config.TrustedIssuers, ListType: "", Endpoint: "https://tir.example.com"}, - }, - }, - }) - rowID := insertScopeEntryRaw(t, db, "svc-idem", "scope", credJSON) - - // Run migration twice. - require.NoError(t, MigrateCredentialsTrustedListType(db, DriverTypeSQLite)) - require.NoError(t, MigrateCredentialsTrustedListType(db, DriverTypeSQLite)) - - creds := readCredentials(t, db, rowID) - require.Len(t, creds, 1) - require.Len(t, creds[0].TrustedIssuersLists, 1) - assert.Equal(t, config.DEFAULT_LIST_TYPE, creds[0].TrustedIssuersLists[0].ListType) -} - -func TestMigrateCredentialsTrustedListType_EmptyDB(t *testing.T) { - db := openTestDB(t) - // InitSchema already calls the migration, which should succeed on an - // empty database with no scope_entry rows. - err := InitSchema(db, DriverTypeSQLite) - require.NoError(t, err) -} - -func TestAdaptPlaceholders(t *testing.T) { - type testCase struct { - name string - query string - dbType string - expected string - } - - tests := []testCase{ - { - name: "postgres replaces placeholders", - query: "UPDATE t SET a = ? WHERE b = ?", - dbType: DriverTypePostgres, - expected: "UPDATE t SET a = $1 WHERE b = $2", - }, - { - name: "sqlite keeps placeholders", - query: "UPDATE t SET a = ? WHERE b = ?", - dbType: DriverTypeSQLite, - expected: "UPDATE t SET a = ? WHERE b = ?", - }, - { - name: "mysql keeps placeholders", - query: "UPDATE t SET a = ? WHERE b = ?", - dbType: DriverTypeMySQL, - expected: "UPDATE t SET a = ? WHERE b = ?", - }, - { - name: "no placeholders", - query: "SELECT * FROM t", - dbType: DriverTypePostgres, - expected: "SELECT * FROM t", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result := adaptPlaceholders(tc.query, tc.dbType) - assert.Equal(t, tc.expected, result) - }) - } -} - -// mustJSON marshals v and returns the JSON string, failing the test on error. -func mustJSON(t *testing.T, v interface{}) string { - t.Helper() - data, err := json.Marshal(v) - require.NoError(t, err) - return string(data) -} diff --git a/database/schema.go b/database/schema.go index 2f3b204..ebb2116 100644 --- a/database/schema.go +++ b/database/schema.go @@ -135,11 +135,5 @@ func InitSchema(db *sql.DB, dbType string) error { } logging.Log().Info("Database schema initialized successfully") - - // Run data migrations after schema is in place. - if err := MigrateCredentialsTrustedListType(db, dbType); err != nil { - return fmt.Errorf("failed to run credentials listType migration: %w", err) - } - return nil } diff --git a/db/migration/changelog.xml b/db/migration/changelog.xml new file mode 100644 index 0000000..8bc5b28 --- /dev/null +++ b/db/migration/changelog.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/db/migration/v3/changelog-v3_0_0.xml b/db/migration/v3/changelog-v3_0_0.xml new file mode 100644 index 0000000..39bd4eb --- /dev/null +++ b/db/migration/v3/changelog-v3_0_0.xml @@ -0,0 +1,41 @@ + + + + + + + Backfill empty listType in scope_entry credentials JSON with default value "ebsi" + + UPDATE scope_entry + SET credentials = REPLACE(credentials, '"listType":""', '"listType":"ebsi"') + WHERE credentials LIKE '%"listType":""%' + + + + + + + + From 9cd9369db44d55d961196c45d852368b833f29b4 Mon Sep 17 00:00:00 2001 From: general-agent-3 Date: Thu, 4 Jun 2026 13:57:27 +0000 Subject: [PATCH 5/9] Add TIR v5 client methods with multi-step attribute fetching Implement IsTrustedParticipantV5 and GetTrustedIssuerV5 on TirHttpClient to support the EBSI TIR v5 API. The v5 API returns attribute references (URLs) instead of inline data, requiring a multi-step flow: get issuer, paginate attribute list, fetch each attribute individually. Results are assembled into the existing TrustedIssuer struct and cached via tilCache. Includes 13 parameterized tests covering single/multiple attributes, pagination, hasAttributes=false, 404s, network errors, partial failures, multi-endpoint fallback, and caching behavior. Co-Authored-By: Claude Opus 4.6 --- tir/tirClient.go | 242 +++++++++++++++++ tir/tirClient_test.go | 387 ++++++++++++++++++++++++++++ verifier/trustedparticipant_test.go | 8 + 3 files changed, 637 insertions(+) diff --git a/tir/tirClient.go b/tir/tirClient.go index 98ac77a..6834763 100644 --- a/tir/tirClient.go +++ b/tir/tirClient.go @@ -3,6 +3,7 @@ package tir import ( "encoding/json" "errors" + "fmt" "net/http" "time" @@ -16,15 +17,27 @@ import ( const ISSUERS_V4_PATH = "v4/issuers" const ISSUERS_V3_PATH = "v3/issuers" +const ISSUERS_V5_PATH = "v5/issuers" const DID_V4_Path = "v4/identifiers" +// maxPaginationPages limits the number of pages fetched during v5 attribute +// list pagination to prevent infinite loops or excessively long request chains. +const maxPaginationPages = 100 + var ErrorTirNoResponse = errors.New("no_response_from_tir") var ErrorTirEmptyResponse = errors.New("empty_response_from_tir") type TirClient interface { IsTrustedParticipant(tirEndpoints string, did string) (trusted bool) GetTrustedIssuer(tirEndpoints []string, did string) (exists bool, trustedIssuer TrustedIssuer, err error) + // IsTrustedParticipantV5 checks whether the given DID is registered as a + // trusted participant at the specified v5 TIR endpoint. + IsTrustedParticipantV5(tirEndpoint string, did string) (trusted bool) + // GetTrustedIssuerV5 fetches a trusted issuer from v5 TIR endpoints. The v5 + // API requires multi-step fetching: get issuer, list attributes (with + // pagination), then fetch each attribute individually. + GetTrustedIssuerV5(tirEndpoints []string, did string) (exists bool, trustedIssuer TrustedIssuer, err error) } /** @@ -75,6 +88,47 @@ type Claim struct { AllowedValues []interface{} `json:"allowedValues"` } +// TrustedIssuerV5Response represents the response from GET /v5/issuers/{did}. +// The Attributes field contains a URL reference to the paginated attributes list, +// rather than inline attribute data as in v3/v4. +type TrustedIssuerV5Response struct { + Did string `json:"did"` + Attributes string `json:"attributes"` + HasAttributes bool `json:"hasAttributes"` +} + +// AttributesListV5Response represents a paginated list of attribute references +// returned by GET /v5/issuers/{did}/attributes. +type AttributesListV5Response struct { + Items []AttributeListItem `json:"items"` + Links PaginationLinks `json:"links"` + Total int `json:"total"` + PageSize int `json:"pageSize"` + Self string `json:"self"` +} + +// AttributeListItem represents a single entry in the paginated attributes list, +// containing the attribute's ID and an href to fetch its full details. +type AttributeListItem struct { + ID string `json:"id"` + Href string `json:"href"` +} + +// PaginationLinks holds the pagination navigation links returned by the v5 API. +type PaginationLinks struct { + First string `json:"first"` + Last string `json:"last"` + Next string `json:"next"` + Prev string `json:"prev"` +} + +// AttributeV5Response represents the response from fetching a single attribute +// via GET /v5/issuers/{did}/attributes/{id}. +type AttributeV5Response struct { + Attribute IssuerAttribute `json:"attribute"` + Did string `json:"did"` +} + func NewTirHttpClient(tokenProvider TokenProvider, m2mConfig config.M2M, verifierConfig config.Verifier) (client TirClient, err error) { // disable keep alive, to avoid EOFs due to race conditions @@ -219,5 +273,193 @@ func getIssuerV4Url(did string) string { func getIssuerV3Url(did string) string { return ISSUERS_V3_PATH + "/" + did +} + +// getIssuerV5Url returns the v5 API path for fetching issuer information by DID. +func getIssuerV5Url(did string) string { + return ISSUERS_V5_PATH + "/" + did +} + +// getAttributesV5Url returns the v5 API path for listing an issuer's attributes. +func getAttributesV5Url(did string) string { + return ISSUERS_V5_PATH + "/" + did + "/attributes" +} + +// IsTrustedParticipantV5 checks whether the given DID is registered as a +// trusted participant at the specified v5 TIR endpoint. Returns true if the +// endpoint responds with HTTP 200 for the issuer lookup. +func (tc TirHttpClient) IsTrustedParticipantV5(tirEndpoint string, did string) (trusted bool) { + if tc.issuerExistsV5(tirEndpoint, did) { + logging.Log().Debugf("Issuer %s is a trusted participant via v5 endpoint %s.", did, tirEndpoint) + return true + } + return false +} + +// issuerExistsV5 checks the v5 endpoint for the existence of a given DID, +// using the cache to avoid redundant requests. +func (tc TirHttpClient) issuerExistsV5(tirEndpoint string, did string) (trusted bool) { + cacheKey := tirEndpoint + "/v5/" + did + exists, hit := tc.tirCache.Get(cacheKey) + + if !hit { + resp, err := tc.client.Get(tirEndpoint, getIssuerV5Url(did)) + if err != nil { + logging.Log().Warnf("V5: Was not able to check issuer %s at %s. Err: %v", did, tirEndpoint, err) + return false + } + if resp == nil { + logging.Log().Warnf("V5: No response for issuer %s from %s.", did, tirEndpoint) + return false + } + logging.Log().Debugf("V5: Issuer %s response from %s is %v", did, tirEndpoint, resp.StatusCode) + exists = resp.StatusCode == 200 + tc.tirCache.Set(cacheKey, exists, cache.DefaultExpiration) + } + + return exists.(bool) +} + +// GetTrustedIssuerV5 fetches a trusted issuer from v5 TIR endpoints. For each +// endpoint it performs a multi-step fetch: (1) get issuer, (2) if the issuer has +// attributes, paginate through the attributes list, (3) fetch each individual +// attribute. The assembled TrustedIssuer is cached for subsequent lookups. +func (tc TirHttpClient) GetTrustedIssuerV5(tirEndpoints []string, did string) (exists bool, trustedIssuer TrustedIssuer, err error) { + for _, tirEndpoint := range tirEndpoints { + cacheKey := tirEndpoint + "/v5/" + did + "/full" + cached, hit := tc.tilCache.Get(cacheKey) + if hit { + return true, cached.(TrustedIssuer), nil + } + + issuer, fetchErr := tc.fetchTrustedIssuerV5(tirEndpoint, did) + if fetchErr != nil { + logging.Log().Warnf("V5: Was not able to get the issuer %s from %s. Err: %v", did, tirEndpoint, fetchErr) + err = fetchErr + continue + } + + logging.Log().Debugf("V5: Got issuer %s.", logging.PrettyPrintObject(issuer)) + tc.tilCache.Set(cacheKey, issuer, cache.DefaultExpiration) + return true, issuer, nil + } + return false, trustedIssuer, err +} + +// fetchTrustedIssuerV5 performs the full multi-step v5 fetch for a single +// endpoint: get issuer metadata, paginate attribute list, and fetch each +// attribute's details. +func (tc TirHttpClient) fetchTrustedIssuerV5(tirEndpoint string, did string) (TrustedIssuer, error) { + // Step 1: GET /v5/issuers/{did} + resp, err := tc.client.Get(tirEndpoint, getIssuerV5Url(did)) + if err != nil { + return TrustedIssuer{}, err + } + if resp == nil { + return TrustedIssuer{}, ErrorTirNoResponse + } + if resp.StatusCode != 200 { + logging.Log().Debugf("V5: Issuer %s not found at %s (status %d).", did, tirEndpoint, resp.StatusCode) + return TrustedIssuer{}, fmt.Errorf("issuer %s not found at %s: status %d", did, tirEndpoint, resp.StatusCode) + } + + var issuerResp TrustedIssuerV5Response + if resp.Body == nil { + return TrustedIssuer{}, ErrorTirEmptyResponse + } + if err := json.NewDecoder(resp.Body).Decode(&issuerResp); err != nil { + return TrustedIssuer{}, fmt.Errorf("failed to decode v5 issuer response: %w", err) + } + + result := TrustedIssuer{Did: issuerResp.Did} + + // Step 2: If issuer has no attributes, return early + if !issuerResp.HasAttributes { + logging.Log().Debugf("V5: Issuer %s has no attributes.", did) + return result, nil + } + + // Step 3: Paginate through attributes list + attributeItems, err := tc.fetchAllAttributeItemsV5(tirEndpoint, did) + if err != nil { + return TrustedIssuer{}, fmt.Errorf("failed to fetch attribute list for %s: %w", did, err) + } + + // Step 4: Fetch each individual attribute + var attributes []IssuerAttribute + for _, item := range attributeItems { + attr, err := tc.fetchSingleAttributeV5(tirEndpoint, item.Href) + if err != nil { + logging.Log().Warnf("V5: Failed to fetch attribute %s for issuer %s: %v", item.ID, did, err) + return TrustedIssuer{}, fmt.Errorf("failed to fetch attribute %s for %s: %w", item.ID, did, err) + } + attributes = append(attributes, attr) + } + + result.Attributes = attributes + return result, nil +} + +// fetchAllAttributeItemsV5 paginates through the v5 attributes list endpoint, +// collecting all AttributeListItem entries across pages. It follows the +// Links.Next URL until exhausted or until the safety limit is reached. +func (tc TirHttpClient) fetchAllAttributeItemsV5(tirEndpoint string, did string) ([]AttributeListItem, error) { + var allItems []AttributeListItem + currentPath := getAttributesV5Url(did) + + for page := 0; page < maxPaginationPages; page++ { + resp, err := tc.client.Get(tirEndpoint, currentPath) + if err != nil { + return nil, fmt.Errorf("failed to fetch attributes page: %w", err) + } + if resp == nil { + return nil, ErrorTirNoResponse + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status %d when fetching attributes", resp.StatusCode) + } + if resp.Body == nil { + return nil, ErrorTirEmptyResponse + } + + var listResp AttributesListV5Response + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + return nil, fmt.Errorf("failed to decode attributes list: %w", err) + } + + allItems = append(allItems, listResp.Items...) + + // Stop if there is no next page + if listResp.Links.Next == "" || listResp.Links.Next == listResp.Self { + break + } + currentPath = listResp.Links.Next + } + + return allItems, nil +} + +// fetchSingleAttributeV5 fetches a single attribute by its href path from the +// v5 API and returns the parsed IssuerAttribute. +func (tc TirHttpClient) fetchSingleAttributeV5(tirEndpoint string, href string) (IssuerAttribute, error) { + resp, err := tc.client.Get(tirEndpoint, href) + if err != nil { + return IssuerAttribute{}, fmt.Errorf("failed to fetch attribute: %w", err) + } + if resp == nil { + return IssuerAttribute{}, ErrorTirNoResponse + } + if resp.StatusCode != 200 { + return IssuerAttribute{}, fmt.Errorf("unexpected status %d when fetching attribute", resp.StatusCode) + } + if resp.Body == nil { + return IssuerAttribute{}, ErrorTirEmptyResponse + } + + var attrResp AttributeV5Response + if err := json.NewDecoder(resp.Body).Decode(&attrResp); err != nil { + return IssuerAttribute{}, fmt.Errorf("failed to decode attribute response: %w", err) + } + return attrResp.Attribute, nil } diff --git a/tir/tirClient_test.go b/tir/tirClient_test.go index e191107..6883110 100644 --- a/tir/tirClient_test.go +++ b/tir/tirClient_test.go @@ -125,6 +125,393 @@ func TestGetTrustedIssuer(t *testing.T) { } +func TestIsTrustedParticipantV5(t *testing.T) { + type test struct { + testName string + testIssuer string + testEndpoint string + mockResponses map[string]*http.Response + mockErrors map[string]error + expectedResult bool + } + tests := []test{ + { + testName: "V5: The issuer should be found.", + testIssuer: "did:web:test.org", + testEndpoint: "https://my-tir.org", + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + }, + expectedResult: true, + }, + { + testName: "V5: The issuer should not be found when 404.", + testIssuer: "did:web:test.org", + testEndpoint: "https://my-tir.org", + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getNotFoundResponse(), + }, + expectedResult: false, + }, + { + testName: "V5: The issuer should not be found on network error.", + testIssuer: "did:web:test.org", + testEndpoint: "https://my-tir.org", + mockErrors: map[string]error{ + "https://my-tir.org/v5/issuers/did:web:test.org": errors.New("network_error"), + }, + expectedResult: false, + }, + { + testName: "V5: The issuer should not be found when response is nil.", + testIssuer: "did:web:test.org", + testEndpoint: "https://my-tir.org", + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": nil, + }, + expectedResult: false, + }, + } + + for _, tc := range tests { + common.ResetGlobalCache() + t.Run(tc.testName, func(t *testing.T) { + tirClient := TirHttpClient{ + client: mockClient{responses: tc.mockResponses, errors: tc.mockErrors}, + tilCache: mockCache{}, + tirCache: mockCache{}, + } + isTrusted := tirClient.IsTrustedParticipantV5(tc.testEndpoint, tc.testIssuer) + assert.Equal(t, tc.expectedResult, isTrusted) + }) + } +} + +func TestGetTrustedIssuerV5(t *testing.T) { + type test struct { + testName string + testIssuer string + testEndpoints []string + mockResponses map[string]*http.Response + mockErrors map[string]error + expectedIssuer string + expectedAttrCount int + expectExists bool + expectError bool + } + tests := []test{ + { + testName: "V5: Single attribute fetched successfully.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr1", Href: "v5/issuers/did:web:test.org/attributes/attr1"}}, + "", // no next page + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr1": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash1", Body: "body1", IssuerType: "type1", Tao: "tao1", RootTao: "root1"}, + "did:web:test.org", + ), + }, + expectedIssuer: "did:web:test.org", + expectedAttrCount: 1, + expectExists: true, + }, + { + testName: "V5: Multiple attributes fetched successfully.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes": getV5AttributesListResponse( + []AttributeListItem{ + {ID: "attr1", Href: "v5/issuers/did:web:test.org/attributes/attr1"}, + {ID: "attr2", Href: "v5/issuers/did:web:test.org/attributes/attr2"}, + }, + "", // no next page + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr1": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash1", Body: "body1", IssuerType: "type1"}, + "did:web:test.org", + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr2": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash2", Body: "body2", IssuerType: "type2"}, + "did:web:test.org", + ), + }, + expectedIssuer: "did:web:test.org", + expectedAttrCount: 2, + expectExists: true, + }, + { + testName: "V5: Pagination across two pages.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr1", Href: "v5/issuers/did:web:test.org/attributes/attr1"}}, + "v5/issuers/did:web:test.org/attributes?page=2", // next page + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes?page=2": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr2", Href: "v5/issuers/did:web:test.org/attributes/attr2"}}, + "", // no more pages + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr1": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash1", Body: "body1"}, + "did:web:test.org", + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr2": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash2", Body: "body2"}, + "did:web:test.org", + ), + }, + expectedIssuer: "did:web:test.org", + expectedAttrCount: 2, + expectExists: true, + }, + { + testName: "V5: Issuer with hasAttributes false returns empty attributes.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", false), + }, + expectedIssuer: "did:web:test.org", + expectedAttrCount: 0, + expectExists: true, + }, + { + testName: "V5: Issuer not found (404).", + testIssuer: "did:web:unknown.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:unknown.org": getNotFoundResponse(), + }, + expectExists: false, + expectError: true, + }, + { + testName: "V5: Attribute fetch error causes failure.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr1", Href: "v5/issuers/did:web:test.org/attributes/attr1"}}, + "", + ), + }, + mockErrors: map[string]error{ + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr1": errors.New("fetch_failed"), + }, + expectExists: false, + expectError: true, + }, + { + testName: "V5: Second endpoint succeeds after first fails.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://bad-tir.org", "https://good-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://bad-tir.org/v5/issuers/did:web:test.org": getNotFoundResponse(), + "https://good-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + "https://good-tir.org/v5/issuers/did:web:test.org/attributes": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr1", Href: "v5/issuers/did:web:test.org/attributes/attr1"}}, + "", + ), + "https://good-tir.org/v5/issuers/did:web:test.org/attributes/attr1": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash1", Body: "body1"}, + "did:web:test.org", + ), + }, + expectedIssuer: "did:web:test.org", + expectedAttrCount: 1, + expectExists: true, + }, + { + testName: "V5: Network error at issuer endpoint.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockErrors: map[string]error{ + "https://my-tir.org/v5/issuers/did:web:test.org": errors.New("connection_refused"), + }, + expectExists: false, + expectError: true, + }, + } + + for _, tc := range tests { + common.ResetGlobalCache() + t.Run(tc.testName, func(t *testing.T) { + tirClient := TirHttpClient{ + client: mockClient{responses: tc.mockResponses, errors: tc.mockErrors}, + tilCache: mockCache{}, + tirCache: mockCache{}, + } + exists, issuer, err := tirClient.GetTrustedIssuerV5(tc.testEndpoints, tc.testIssuer) + + assert.Equal(t, tc.expectExists, exists, "exists mismatch") + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + if tc.expectExists { + assert.Equal(t, tc.expectedIssuer, issuer.Did, "issuer DID mismatch") + assert.Equal(t, tc.expectedAttrCount, len(issuer.Attributes), "attribute count mismatch") + } + }) + } +} + +func TestGetTrustedIssuerV5_Caching(t *testing.T) { + common.ResetGlobalCache() + + // Use a real go-cache so we can verify caching behavior + realTilCache := newTestCache() + + did := "did:web:cached.org" + endpoint := "https://my-tir.org" + + responses := map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:cached.org": getV5IssuerResponse(did, true), + "https://my-tir.org/v5/issuers/did:web:cached.org/attributes": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr1", Href: "v5/issuers/did:web:cached.org/attributes/attr1"}}, + "", + ), + "https://my-tir.org/v5/issuers/did:web:cached.org/attributes/attr1": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash1", Body: "body1"}, + did, + ), + } + + countingClient := &requestCountingClient{ + inner: mockClient{responses: responses}, + } + + tirClient := TirHttpClient{ + client: countingClient, + tilCache: realTilCache, + tirCache: mockCache{}, + } + + // First call should make HTTP requests + exists1, issuer1, err1 := tirClient.GetTrustedIssuerV5([]string{endpoint}, did) + assert.True(t, exists1) + assert.NoError(t, err1) + assert.Equal(t, did, issuer1.Did) + assert.Equal(t, 1, len(issuer1.Attributes)) + firstCallCount := countingClient.count + + // Second call should hit cache (no additional HTTP requests) + exists2, issuer2, err2 := tirClient.GetTrustedIssuerV5([]string{endpoint}, did) + assert.True(t, exists2) + assert.NoError(t, err2) + assert.Equal(t, did, issuer2.Did) + assert.Equal(t, 1, len(issuer2.Attributes)) + + // Verify no additional HTTP requests were made + assert.Equal(t, firstCallCount, countingClient.count, "second call should use cache, not make HTTP requests") +} + +// requestCountingClient wraps an HttpGetClient and counts requests made. +type requestCountingClient struct { + inner HttpGetClient + count int +} + +func (rcc *requestCountingClient) Get(tirAddress string, tirPath string) (resp *http.Response, err error) { + rcc.count++ + return rcc.inner.Get(tirAddress, tirPath) +} + +// testCache is a simple cache implementation backed by a map for testing +// caching behavior (unlike mockCache which always returns cache misses). +type testCache struct { + data map[string]interface{} +} + +func newTestCache() *testCache { + return &testCache{data: make(map[string]interface{})} +} + +func (tc *testCache) Add(k string, x interface{}, d time.Duration) error { + tc.data[k] = x + return nil +} + +func (tc *testCache) Set(k string, x interface{}, d time.Duration) { + tc.data[k] = x +} + +func (tc *testCache) Get(k string) (interface{}, bool) { + v, ok := tc.data[k] + return v, ok +} + +func (tc *testCache) Delete(k string) { + delete(tc.data, k) +} + +func (tc *testCache) GetWithExpiration(k string) (interface{}, time.Time, bool) { + v, ok := tc.data[k] + return v, time.Now(), ok +} + +// --- V5 test helpers --- + +func getV5IssuerResponse(did string, hasAttributes bool) *http.Response { + body := fmt.Sprintf( + `{"did": "%s", "attributes": "v5/issuers/%s/attributes", "hasAttributes": %t}`, + did, did, hasAttributes, + ) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func getV5AttributesListResponse(items []AttributeListItem, nextLink string) *http.Response { + itemsJSON := "[" + for i, item := range items { + if i > 0 { + itemsJSON += "," + } + itemsJSON += fmt.Sprintf(`{"id": "%s", "href": "%s"}`, item.ID, item.Href) + } + itemsJSON += "]" + + linksJSON := `{"first": "", "last": "", "prev": ""` + if nextLink != "" { + linksJSON += fmt.Sprintf(`, "next": "%s"`, nextLink) + } else { + linksJSON += `, "next": ""` + } + linksJSON += "}" + + body := fmt.Sprintf( + `{"items": %s, "links": %s, "total": %d, "pageSize": %d, "self": ""}`, + itemsJSON, linksJSON, len(items), len(items), + ) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func getV5SingleAttributeResponse(attr IssuerAttribute, did string) *http.Response { + body := fmt.Sprintf( + `{"attribute": {"hash": "%s", "body": "%s", "issuerType": "%s", "tao": "%s", "rootTao": "%s"}, "did": "%s"}`, + attr.Hash, attr.Body, attr.IssuerType, attr.Tao, attr.RootTao, did, + ) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } +} + func getNotFoundResponse() *http.Response { return &http.Response{StatusCode: 404} } diff --git a/verifier/trustedparticipant_test.go b/verifier/trustedparticipant_test.go index 1b61891..1a88d03 100644 --- a/verifier/trustedparticipant_test.go +++ b/verifier/trustedparticipant_test.go @@ -32,6 +32,14 @@ func (mtc mockTirClient) GetTrustedIssuer(tirEndpoints []string, did string) (ex return slices.Contains(mtc.participantsList, did), mtc.expectedIssuer, mtc.expectedError } +func (mtc mockTirClient) IsTrustedParticipantV5(tirEndpoint string, did string) (trusted bool) { + return slices.Contains(mtc.participantsList, did) +} + +func (mtc mockTirClient) GetTrustedIssuerV5(tirEndpoints []string, did string) (exists bool, trustedIssuer tir.TrustedIssuer, err error) { + return slices.Contains(mtc.participantsList, did), mtc.expectedIssuer, mtc.expectedError +} + func TestVerifyVC_Participant(t *testing.T) { type test struct { From f6a1ae095c2ead42c48c4d46e54fe997c30a2ab2 Mon Sep 17 00:00:00 2001 From: general-agent-3 Date: Fri, 5 Jun 2026 05:57:01 +0000 Subject: [PATCH 6/9] Wire v5 dispatch in verifier layer for trustedissuer and trustedparticipant Route "ebsi-v5" typed entries to new v5 TIR client methods. The existing "ebsi" type continues to use v3/v4 auto-detection. In trustedparticipant.go, added typeEbsiV5 constant and dispatch branch calling IsTrustedParticipantV5. In trustedissuer.go, refactored ValidateVC to split URLs by type and try ebsi (v3/v4) first then ebsi-v5, renamed extractTilURLs to extractTilURLsByType with type filtering and empty-type backward compat. Updated mock TirClient with separate v5 lists and added comprehensive table-driven tests for v5-only, mixed ebsi+v5, and fallback scenarios. Co-Authored-By: Claude Opus 4.6 --- verifier/trustedissuer.go | 48 +++++++++++---- verifier/trustedissuer_test.go | 86 +++++++++++++++++++++++++- verifier/trustedparticipant.go | 9 ++- verifier/trustedparticipant_test.go | 95 +++++++++++++++++++++++------ 4 files changed, 202 insertions(+), 36 deletions(-) diff --git a/verifier/trustedissuer.go b/verifier/trustedissuer.go index de5e883..5175e4a 100644 --- a/verifier/trustedissuer.go +++ b/verifier/trustedissuer.go @@ -70,16 +70,32 @@ func (tpvs *TrustedIssuerValidationService) ValidateVC(verifiableCredential *com return false, ErrorNoTilForType } - // Extract URLs from entries with ebsi type (or empty/default) for v3/v4 lookup. - // V5 dispatch will be added in a later step. - tilURLs := extractTilURLs(tilEntries) - - exist, trustedIssuer, err := tpvs.tirClient.GetTrustedIssuer(tilURLs, verifiableCredential.Contents().Issuer.ID) + // Dispatch by type: collect URLs per registry type and try each. + // "ebsi" (or empty/default) uses v3/v4 auto-detection; "ebsi-v5" uses the v5 API. + ebsiURLs := extractTilURLsByType(tilEntries, typeEbsi) + ebsiV5URLs := extractTilURLsByType(tilEntries, typeEbsiV5) + + var exist bool + var trustedIssuer tir.TrustedIssuer + + // Try ebsi (v3/v4) endpoints first. + if len(ebsiURLs) > 0 { + exist, trustedIssuer, err = tpvs.tirClient.GetTrustedIssuer(ebsiURLs, verifiableCredential.Contents().Issuer.ID) + if err != nil { + logging.Log().Warnf("Was not able to validate trusted issuer via ebsi. Err: %v", err) + return false, err + } + } - if err != nil { - logging.Log().Warnf("Was not able to validate trusted issuer. Err: %v", err) - return false, err + // If not found via ebsi, try ebsi-v5 endpoints. + if !exist && len(ebsiV5URLs) > 0 { + exist, trustedIssuer, err = tpvs.tirClient.GetTrustedIssuerV5(ebsiV5URLs, verifiableCredential.Contents().Issuer.ID) + if err != nil { + logging.Log().Warnf("Was not able to validate trusted issuer via ebsi-v5. Err: %v", err) + return false, err + } } + if !exist { logging.Log().Warnf("Trusted issuer for %s does not exist in context %s.", logging.PrettyPrintObject(verifiableCredential), logging.PrettyPrintObject(validationContext)) return false, ErrorNoTilDefined @@ -115,13 +131,19 @@ func isWildcardTil(tilList []configModel.TrustedIssuersList) (isWildcard bool, e return false, err } -// extractTilURLs collects the URL strings from TrustedIssuersList entries. -// In this step all entries are passed through; v5-specific dispatch will be -// added in a later step. -func extractTilURLs(entries []configModel.TrustedIssuersList) []string { +// extractTilURLsByType collects URL strings from TrustedIssuersList entries +// matching the given registry type. Entries with an empty type are treated +// as "ebsi" (the default) for backward compatibility. +func extractTilURLsByType(entries []configModel.TrustedIssuersList, listType string) []string { urls := make([]string, 0, len(entries)) for _, entry := range entries { - urls = append(urls, entry.Url) + entryType := entry.Type + if entryType == "" { + entryType = typeEbsi + } + if entryType == listType { + urls = append(urls, entry.Url) + } } return urls } diff --git a/verifier/trustedissuer_test.go b/verifier/trustedissuer_test.go index d35d694..998911f 100644 --- a/verifier/trustedissuer_test.go +++ b/verifier/trustedissuer_test.go @@ -74,8 +74,11 @@ func TestVerifyVC_Issuers(t *testing.T) { credentialToVerifiy common.Credential verificationContext ValidationContext participantsList []string + participantsV5List []string tirResponse tir.TrustedIssuer + tirResponseV5 tir.TrustedIssuer tirError error + tirErrorV5 error expectedResult bool } @@ -136,6 +139,51 @@ func TestVerifyVC_Issuers(t *testing.T) { {testName: "If a wildcard til and another til is configured for the type, the vc should be rejected.", credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), verificationContext: getInvalidMixedVerificationContext(), participantsList: []string{"did:test:issuer"}, tirError: nil, expectedResult: false}, + // ebsi-v5 test cases + {testName: "If the issuer is found via ebsi-v5, the vc should be accepted.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getV5VerificationContext(), + participantsV5List: []string{"did:test:issuer"}, + tirResponseV5: getTrustedIssuer([]tir.IssuerAttribute{getAttribute(tir.TimeRange{}, "VerifiableCredential", map[string][]interface{}{})}), + expectedResult: true}, + {testName: "If the issuer is not found via ebsi-v5, the vc should be rejected.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getV5VerificationContext(), + participantsV5List: []string{}, + tirResponseV5: tir.TrustedIssuer{}, + expectedResult: false}, + {testName: "If the ebsi-v5 registry returns an error, the vc should be rejected.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getV5VerificationContext(), + participantsV5List: []string{"did:test:issuer"}, + tirResponseV5: tir.TrustedIssuer{}, + tirErrorV5: errors.New("v5-error"), + expectedResult: false}, + // Mixed ebsi + ebsi-v5 test cases + {testName: "Mixed types: issuer found via ebsi (v3/v4) takes precedence.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getMixedEbsiVerificationContext(), + participantsList: []string{"did:test:issuer"}, + tirResponse: getTrustedIssuer([]tir.IssuerAttribute{getAttribute(tir.TimeRange{}, "VerifiableCredential", map[string][]interface{}{})}), + participantsV5List: []string{}, + tirResponseV5: tir.TrustedIssuer{}, + expectedResult: true}, + {testName: "Mixed types: issuer not in ebsi but found via ebsi-v5.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getMixedEbsiVerificationContext(), + participantsList: []string{}, + tirResponse: tir.TrustedIssuer{}, + participantsV5List: []string{"did:test:issuer"}, + tirResponseV5: getTrustedIssuer([]tir.IssuerAttribute{getAttribute(tir.TimeRange{}, "VerifiableCredential", map[string][]interface{}{})}), + expectedResult: true}, + {testName: "Mixed types: issuer not found in either registry should be rejected.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getMixedEbsiVerificationContext(), + participantsList: []string{}, + tirResponse: tir.TrustedIssuer{}, + participantsV5List: []string{}, + tirResponseV5: tir.TrustedIssuer{}, + expectedResult: false}, } for _, tc := range tests { @@ -143,7 +191,15 @@ func TestVerifyVC_Issuers(t *testing.T) { logging.Log().Info("TestVerifyVC +++++++++++++++++ Running test: ", tc.testName) - trustedIssuerVerificationService := TrustedIssuerValidationService{mockTirClient{tc.participantsList, tc.tirResponse, tc.tirError}} + tirClient := mockTirClient{ + participantsList: tc.participantsList, + participantsV5List: tc.participantsV5List, + expectedIssuer: tc.tirResponse, + expectedIssuerV5: tc.tirResponseV5, + expectedError: tc.tirError, + expectedErrorV5: tc.tirErrorV5, + } + trustedIssuerVerificationService := TrustedIssuerValidationService{tirClient} result, _ := trustedIssuerVerificationService.ValidateVC(&tc.credentialToVerifiy, tc.verificationContext) if result != tc.expectedResult { t.Errorf("%s - Expected result %v but was %v.", tc.testName, tc.expectedResult, result) @@ -294,6 +350,34 @@ func getWildcardAndNormalVerificationContext() ValidationContext { } } +// getV5VerificationContext returns a context with only ebsi-v5 typed TIL entries. +func getV5VerificationContext() ValidationContext { + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": {{Type: "ebsi-v5", Url: "http://my-v5-til.org"}}, + }, + } +} + +// getMixedEbsiVerificationContext returns a context with both ebsi and ebsi-v5 +// typed TIL entries for the same credential type. +func getMixedEbsiVerificationContext() ValidationContext { + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": { + {Type: "ebsi", Url: "http://my-til.org"}, + {Type: "ebsi-v5", Url: "http://my-v5-til.org"}, + }, + }, + } +} + func getMultiTypeCredential(types []string, claimName string, value interface{}) common.Credential { vc, _ := common.CreateCredential(common.CredentialContents{ Issuer: &common.Issuer{ID: "did:test:issuer"}, diff --git a/verifier/trustedparticipant.go b/verifier/trustedparticipant.go index 5497299..7c49451 100644 --- a/verifier/trustedparticipant.go +++ b/verifier/trustedparticipant.go @@ -13,8 +13,9 @@ var ErrorCannotConverContext = errors.New("cannot_convert_context") var ErrorInvalidCredential = errors.New("invalid_trusted_participant_type") const ( - typeGaiaX = "gaia-x" - typeEbsi = "ebsi" + typeGaiaX = "gaia-x" + typeEbsi = "ebsi" + typeEbsiV5 = "ebsi-v5" ) /** @@ -55,6 +56,10 @@ func (tpvs *TrustedParticipantValidationService) ValidateVC(verifiableCredential logging.Log().Debug("Check at ebsi.") result = tpvs.tirClient.IsTrustedParticipant(participantList.Url, verifiableCredential.Contents().Issuer.ID) } + if participantList.Type == typeEbsiV5 { + logging.Log().Debug("Check at ebsi-v5.") + result = tpvs.tirClient.IsTrustedParticipantV5(participantList.Url, verifiableCredential.Contents().Issuer.ID) + } if participantList.Type == typeGaiaX { logging.Log().Debug("Check at gaia-x.") result = tpvs.gaiaXClient.IsTrustedParticipant(participantList.Url, verifiableCredential.Contents().Issuer.ID) diff --git a/verifier/trustedparticipant_test.go b/verifier/trustedparticipant_test.go index 1a88d03..fb6a88e 100644 --- a/verifier/trustedparticipant_test.go +++ b/verifier/trustedparticipant_test.go @@ -19,47 +19,96 @@ func (mgc mockGaiaXClient) IsTrustedParticipant(registryEndpoint string, did str } type mockTirClient struct { - participantsList []string - expectedIssuer tir.TrustedIssuer - expectedError error + participantsList []string + participantsV5List []string + expectedIssuer tir.TrustedIssuer + expectedIssuerV5 tir.TrustedIssuer + expectedError error + expectedErrorV5 error } +// IsTrustedParticipant checks the v3/v4 participants list. func (mtc mockTirClient) IsTrustedParticipant(tirEndpoint string, did string) (trusted bool) { return slices.Contains(mtc.participantsList, did) } +// GetTrustedIssuer returns a trusted issuer from the v3/v4 list. func (mtc mockTirClient) GetTrustedIssuer(tirEndpoints []string, did string) (exists bool, trustedIssuer tir.TrustedIssuer, err error) { return slices.Contains(mtc.participantsList, did), mtc.expectedIssuer, mtc.expectedError } +// IsTrustedParticipantV5 checks the v5 participants list. func (mtc mockTirClient) IsTrustedParticipantV5(tirEndpoint string, did string) (trusted bool) { - return slices.Contains(mtc.participantsList, did) + return slices.Contains(mtc.participantsV5List, did) } +// GetTrustedIssuerV5 returns a trusted issuer from the v5 list. func (mtc mockTirClient) GetTrustedIssuerV5(tirEndpoints []string, did string) (exists bool, trustedIssuer tir.TrustedIssuer, err error) { - return slices.Contains(mtc.participantsList, did), mtc.expectedIssuer, mtc.expectedError + return slices.Contains(mtc.participantsV5List, did), mtc.expectedIssuerV5, mtc.expectedErrorV5 } func TestVerifyVC_Participant(t *testing.T) { type test struct { - testName string - credentialToVerifiy common.Credential - verificationContext ValidationContext - ebsiParticipantsList []string - gaiaXParticipantsList []string - expectedResult bool + testName string + credentialToVerifiy common.Credential + verificationContext ValidationContext + ebsiParticipantsList []string + ebsiV5ParticipantsList []string + gaiaXParticipantsList []string + expectedResult bool } tests := []test{ - {testName: "A credential issued by an ebsi registerd issuer should be successfully validated.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": []config.TrustedParticipantsList{{Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, - {testName: "A credential issued by a gaia-x registerd issuer should be successfully validated.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": []config.TrustedParticipantsList{{Type: "gaia-x", Url: "http://gaia-x-registry.org"}}}}, gaiaXParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, - {testName: "A credential issued by a registerd issuer should be successfully validated.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": []config.TrustedParticipantsList{{Type: "gaia-x", Url: "http://gaia-x-registry.org"}, {Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, gaiaXParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, - {testName: "A credential issued by a registerd issuer should be successfully validated.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": []config.TrustedParticipantsList{{Type: "gaia-x", Url: "http://gaia-x-registry.org"}, {Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, - {testName: "A credential issued by a not-registerd issuer should be rejected.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": []config.TrustedParticipantsList{{Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, ebsiParticipantsList: []string{}, expectedResult: false}, - {testName: "If no registry is configured, the credential should be accepted.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{}}, expectedResult: true}, - {testName: "If no registry is configured, the credential should be accepted.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": []config.TrustedParticipantsList{}}}, expectedResult: true}, - {testName: "If an invalid context is received, the credential should be rejected.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: "No-Context", ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: false}, + {testName: "A credential issued by an ebsi registered issuer should be successfully validated.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, + ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "A credential issued by a gaia-x registered issuer should be successfully validated.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "gaia-x", Url: "http://gaia-x-registry.org"}}}}, + gaiaXParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "A credential issued by a registered issuer in mixed gaia-x/ebsi list should be validated via gaia-x.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "gaia-x", Url: "http://gaia-x-registry.org"}, {Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, + gaiaXParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "A credential issued by a registered issuer in mixed gaia-x/ebsi list should be validated via ebsi.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "gaia-x", Url: "http://gaia-x-registry.org"}, {Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, + ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "A credential issued by a not-registered issuer should be rejected.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, + ebsiParticipantsList: []string{}, expectedResult: false}, + {testName: "If no registry is configured, the credential should be accepted.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{}}, expectedResult: true}, + {testName: "If an empty registry list is configured, the credential should be accepted.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": {}}}, expectedResult: true}, + {testName: "If an invalid context is received, the credential should be rejected.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: "No-Context", + ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: false}, + // ebsi-v5 test cases + {testName: "A credential issued by an ebsi-v5 registered participant should be successfully validated.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi-v5", Url: "http://my-v5-registry.org"}}}}, + ebsiV5ParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "A credential issued by a participant not in ebsi-v5 registry should be rejected.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi-v5", Url: "http://my-v5-registry.org"}}}}, + ebsiV5ParticipantsList: []string{}, expectedResult: false}, + {testName: "Mixed ebsi and ebsi-v5 list: found via ebsi-v5 when not in ebsi.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}, {Type: "ebsi-v5", Url: "http://my-v5-registry.org"}}}}, + ebsiParticipantsList: []string{}, + ebsiV5ParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "Mixed ebsi and ebsi-v5 list: found via ebsi, v5 not checked.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}, {Type: "ebsi-v5", Url: "http://my-v5-registry.org"}}}}, + ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, + ebsiV5ParticipantsList: []string{}, expectedResult: true}, } for _, tc := range tests { @@ -67,7 +116,13 @@ func TestVerifyVC_Participant(t *testing.T) { logging.Log().Info("TestVerifyVC +++++++++++++++++ Running test: ", tc.testName) - trustedParticipantVerificationService := TrustedParticipantValidationService{mockTirClient{tc.ebsiParticipantsList, tir.TrustedIssuer{}, nil}, mockGaiaXClient{tc.gaiaXParticipantsList}} + tirClient := mockTirClient{ + participantsList: tc.ebsiParticipantsList, + participantsV5List: tc.ebsiV5ParticipantsList, + expectedIssuer: tir.TrustedIssuer{}, + expectedIssuerV5: tir.TrustedIssuer{}, + } + trustedParticipantVerificationService := TrustedParticipantValidationService{tirClient, mockGaiaXClient{tc.gaiaXParticipantsList}} result, _ := trustedParticipantVerificationService.ValidateVC(&tc.credentialToVerifiy, tc.verificationContext) if result != tc.expectedResult { t.Errorf("%s - Expected result %v but was %v.", tc.testName, tc.expectedResult, result) From 8684af2f47cb086532fee967dc4f1b4b0df95cff Mon Sep 17 00:00:00 2001 From: general-agent-3 Date: Fri, 5 Jun 2026 06:13:29 +0000 Subject: [PATCH 7/9] Add end-to-end tests and fixtures for TIR v5 support Test coverage for the complete TIR v5 flow: - Config parsing tests for structured trustedIssuersLists (v5 and mixed formats) - CCS client test for JSON responses with ebsi-v5 typed entries - Database round-trip tests for CredentialDB with ebsi-v5 typed lists - Verifier integration tests verifying v5 type propagation through getTrustRegistriesValidationContext and AuthenticationResponse flows - YAML test fixtures for v5-only and mixed v5/legacy configurations Co-Authored-By: Claude Opus 4.6 --- config/configClient_test.go | 37 +++ config/data/ccs_v5.json | 36 +++ config/data/config_test_mixed.yaml | 42 ++++ config/data/config_test_v5.yaml | 40 ++++ config/provider_test.go | 56 +++++ database/models_test.go | 152 ++++++++++++ verifier/verifier_test.go | 363 +++++++++++++++++++++++++++++ 7 files changed, 726 insertions(+) create mode 100644 config/data/ccs_v5.json create mode 100644 config/data/config_test_mixed.yaml create mode 100644 config/data/config_test_v5.yaml diff --git a/config/configClient_test.go b/config/configClient_test.go index 1c14f52..c01874e 100644 --- a/config/configClient_test.go +++ b/config/configClient_test.go @@ -263,6 +263,43 @@ func Test_getServices(t *testing.T) { assert.Equal(t, expectedScopesVO, scopesVO) } +// Test_getServicesV5 verifies that the CCS HTTP client correctly parses a JSON +// response containing "ebsi-v5" typed TrustedIssuersLists and +// TrustedParticipantsLists entries. +func Test_getServicesV5(t *testing.T) { + mockedHttpClient := MockHttpClient{readFile("ccs_v5.json", t)} + ccsClient := HttpConfigClient{mockedHttpClient, "test.com"} + services, err := ccsClient.GetServices() + if err != nil { + t.Error("should not return error", err) + } + assert.NotEmpty(t, services) + assert.Len(t, services, 1) + + svc := services[0] + assert.Equal(t, "service_v5", svc.Id) + assert.Equal(t, "v5_scope", svc.DefaultOidcScope) + + scopesVO := svc.ServiceScopes + creds := scopesVO["v5_scope"].Credentials + assert.Len(t, creds, 1) + + cred := creds[0] + assert.Equal(t, "VerifiableCredential", cred.Type) + + // Verify TrustedParticipantsLists with ebsi-v5 type + assert.Len(t, cred.TrustedParticipantsLists, 1) + assert.Equal(t, "ebsi-v5", cred.TrustedParticipantsLists[0].Type) + assert.Equal(t, "https://tir-v5.ebsi.fiware.dev", cred.TrustedParticipantsLists[0].Url) + + // Verify mixed TrustedIssuersLists: ebsi-v5 + ebsi + assert.Len(t, cred.TrustedIssuersLists, 2) + assert.Equal(t, "ebsi-v5", cred.TrustedIssuersLists[0].Type) + assert.Equal(t, "https://til-v5.ebsi.fiware.dev", cred.TrustedIssuersLists[0].Url) + assert.Equal(t, "ebsi", cred.TrustedIssuersLists[1].Type) + assert.Equal(t, "https://til-pdc.ebsi.fiware.dev", cred.TrustedIssuersLists[1].Url) +} + func TestTrustedIssuersLists_UnmarshalJSON(t *testing.T) { type testCase struct { name string diff --git a/config/data/ccs_v5.json b/config/data/ccs_v5.json new file mode 100644 index 0000000..2f78f4f --- /dev/null +++ b/config/data/ccs_v5.json @@ -0,0 +1,36 @@ +{ + "total": 1, + "pageNumber": 0, + "pageSize": 100, + "services": [ + { + "id": "service_v5", + "defaultOidcScope": "v5_scope", + "oidcScopes": { + "v5_scope": { + "credentials": [ + { + "type": "VerifiableCredential", + "trustedParticipantsLists": [ + { + "type": "ebsi-v5", + "url": "https://tir-v5.ebsi.fiware.dev" + } + ], + "trustedIssuersLists": [ + { + "type": "ebsi-v5", + "url": "https://til-v5.ebsi.fiware.dev" + }, + { + "type": "ebsi", + "url": "https://til-pdc.ebsi.fiware.dev" + } + ] + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/config/data/config_test_mixed.yaml b/config/data/config_test_mixed.yaml new file mode 100644 index 0000000..9c44c8a --- /dev/null +++ b/config/data/config_test_mixed.yaml @@ -0,0 +1,42 @@ +server: + port: 3000 + staticDir: "views/static" + templateDir: "views/" + +logging: + level: "DEBUG" + jsonLogging: true + logRequests: true + +verifier: + did: "did:key:somekey" + tirAddress: "https://test.dev/trusted_issuer/v3/issuers/" + +m2m: + authEnabled: false + +configRepo: + services: + - id: testServiceMixed + defaultOidcScope: mixedScope + oidcScopes: + mixedScope: + credentials: + - type: VerifiableCredential + trustedParticipantsLists: + - type: ebsi + url: https://tir-pdc.ebsi.fiware.dev + - type: ebsi-v5 + url: https://tir-v5.ebsi.fiware.dev + # Legacy string array format for backward compatibility. + trustedIssuersLists: + - https://til-pdc.ebsi.fiware.dev + presentationDefinition: + id: mixed-pd + input_descriptors: + - id: mixed-descriptor + constraints: + fields: + - id: mixed-field + path: + - $.vc.my.claim diff --git a/config/data/config_test_v5.yaml b/config/data/config_test_v5.yaml new file mode 100644 index 0000000..67579cf --- /dev/null +++ b/config/data/config_test_v5.yaml @@ -0,0 +1,40 @@ +server: + port: 3000 + staticDir: "views/static" + templateDir: "views/" + +logging: + level: "DEBUG" + jsonLogging: true + logRequests: true + +verifier: + did: "did:key:somekey" + tirAddress: "https://test.dev/trusted_issuer/v3/issuers/" + +m2m: + authEnabled: false + +configRepo: + services: + - id: testServiceV5 + defaultOidcScope: v5Scope + oidcScopes: + v5Scope: + credentials: + - type: VerifiableCredential + trustedParticipantsLists: + - type: ebsi-v5 + url: https://tir-v5.ebsi.fiware.dev + trustedIssuersLists: + - type: ebsi-v5 + url: https://til-v5.ebsi.fiware.dev + presentationDefinition: + id: v5-pd + input_descriptors: + - id: v5-descriptor + constraints: + fields: + - id: v5-field + path: + - $.vc.my.claim diff --git a/config/provider_test.go b/config/provider_test.go index 074b998..aab1688 100644 --- a/config/provider_test.go +++ b/config/provider_test.go @@ -247,6 +247,62 @@ func Test_ReadConfig(t *testing.T) { } } +// TestReadConfigV5 verifies that a YAML config with "ebsi-v5" structured +// TrustedIssuersLists and TrustedParticipantsLists is correctly parsed. +func TestReadConfigV5(t *testing.T) { + config.Reset() + gotConfig, err := ReadConfig("data/config_test_v5.yaml") + assert.NoError(t, err, "ReadConfig should not return an error for v5 config") + + services := gotConfig.ConfigRepo.Services + assert.Len(t, services, 1) + assert.Equal(t, "testServiceV5", services[0].Id) + + credentials := services[0].ServiceScopes["v5Scope"].Credentials + assert.Len(t, credentials, 1) + cred := credentials[0] + assert.Equal(t, "VerifiableCredential", cred.Type) + + // Verify structured TrustedIssuersLists with type "ebsi-v5" + assert.Len(t, cred.TrustedIssuersLists, 1) + assert.Equal(t, "ebsi-v5", cred.TrustedIssuersLists[0].Type) + assert.Equal(t, "https://til-v5.ebsi.fiware.dev", cred.TrustedIssuersLists[0].Url) + + // Verify TrustedParticipantsLists with type "ebsi-v5" + assert.Len(t, cred.TrustedParticipantsLists, 1) + assert.Equal(t, "ebsi-v5", cred.TrustedParticipantsLists[0].Type) + assert.Equal(t, "https://tir-v5.ebsi.fiware.dev", cred.TrustedParticipantsLists[0].Url) +} + +// TestReadConfigMixed verifies backward-compatible parsing of a YAML config +// that uses legacy string-array TrustedIssuersLists alongside structured +// TrustedParticipantsLists with mixed ebsi and ebsi-v5 types. +func TestReadConfigMixed(t *testing.T) { + config.Reset() + gotConfig, err := ReadConfig("data/config_test_mixed.yaml") + assert.NoError(t, err, "ReadConfig should not return an error for mixed config") + + services := gotConfig.ConfigRepo.Services + assert.Len(t, services, 1) + assert.Equal(t, "testServiceMixed", services[0].Id) + + credentials := services[0].ServiceScopes["mixedScope"].Credentials + assert.Len(t, credentials, 1) + cred := credentials[0] + + // Legacy string array format should default to type "ebsi" + assert.Len(t, cred.TrustedIssuersLists, 1) + assert.Equal(t, DEFAULT_LIST_TYPE, cred.TrustedIssuersLists[0].Type) + assert.Equal(t, "https://til-pdc.ebsi.fiware.dev", cred.TrustedIssuersLists[0].Url) + + // Mixed participants: ebsi and ebsi-v5 + assert.Len(t, cred.TrustedParticipantsLists, 2) + assert.Equal(t, "ebsi", cred.TrustedParticipantsLists[0].Type) + assert.Equal(t, "https://tir-pdc.ebsi.fiware.dev", cred.TrustedParticipantsLists[0].Url) + assert.Equal(t, "ebsi-v5", cred.TrustedParticipantsLists[1].Type) + assert.Equal(t, "https://tir-v5.ebsi.fiware.dev", cred.TrustedParticipantsLists[1].Url) +} + // TestRefreshTokenConfigDefaults verifies that the refresh token configuration // fields receive correct default values when absent from the YAML input and // are correctly parsed when explicitly set. diff --git a/database/models_test.go b/database/models_test.go index 7fdbb14..5c6c7ed 100644 --- a/database/models_test.go +++ b/database/models_test.go @@ -192,6 +192,158 @@ func TestCredentialQueryDB_FromVO_ClaimsConverted(t *testing.T) { assert.Equal(t, [][]string{{"c1"}}, db.ClaimSets) } +// --------------------------------------------------------------------------- +// CredentialDB — TrustedIssuersLists round-trip (ebsi-v5 support) +// --------------------------------------------------------------------------- + +// TestCredentialDB_VO_TrustedListsV5 verifies that CredentialDB.VO() correctly +// maps EndpointEntry entries (including "ebsi-v5" typed ones) back to their +// respective TrustedIssuersLists and TrustedParticipantsLists with preserved +// Type and Url fields. +func TestCredentialDB_VO_TrustedListsV5(t *testing.T) { + tests := []struct { + name string + endpoints []config.EndpointEntry + expectedIssuers config.TrustedIssuersLists + expectedParticipants config.TrustedParticipantsLists + }{ + { + name: "single ebsi-v5 issuer", + endpoints: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://til-v5.example.com"}, + }, + expectedIssuers: config.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + expectedParticipants: config.TrustedParticipantsLists{}, + }, + { + name: "mixed ebsi and ebsi-v5 issuers", + endpoints: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "ebsi", Endpoint: "https://til-ebsi.example.com"}, + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://til-v5.example.com"}, + }, + expectedIssuers: config.TrustedIssuersLists{ + {Type: "ebsi", Url: "https://til-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + expectedParticipants: config.TrustedParticipantsLists{}, + }, + { + name: "ebsi-v5 participant and issuer", + endpoints: []config.EndpointEntry{ + {Type: config.TrustedParticipants, ListType: "ebsi-v5", Endpoint: "https://tir-v5.example.com"}, + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://til-v5.example.com"}, + }, + expectedIssuers: config.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + expectedParticipants: config.TrustedParticipantsLists{ + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + { + name: "empty list type defaults to ebsi", + endpoints: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "", Endpoint: "https://til.example.com"}, + {Type: config.TrustedParticipants, ListType: "", Endpoint: "https://tir.example.com"}, + }, + expectedIssuers: config.TrustedIssuersLists{ + {Type: config.DEFAULT_LIST_TYPE, Url: "https://til.example.com"}, + }, + expectedParticipants: config.TrustedParticipantsLists{ + {Type: config.DEFAULT_LIST_TYPE, Url: "https://tir.example.com"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := CredentialDB{ + Type: "VerifiableCredential", + TrustedIssuersLists: tc.endpoints, + } + vo := db.VO() + assert.Equal(t, tc.expectedIssuers, vo.TrustedIssuersLists) + assert.Equal(t, tc.expectedParticipants, vo.TrustedParticipantsLists) + }) + } +} + +// TestCredentialDB_FromVO_TrustedListsV5 verifies that CredentialDB.FromVO() +// correctly merges TrustedIssuersLists and TrustedParticipantsLists (including +// "ebsi-v5" typed entries) into a unified EndpointEntry slice. +func TestCredentialDB_FromVO_TrustedListsV5(t *testing.T) { + tests := []struct { + name string + issuers config.TrustedIssuersLists + participants config.TrustedParticipantsLists + expectedEndpoints []config.EndpointEntry + }{ + { + name: "single ebsi-v5 issuer from VO", + issuers: config.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + participants: nil, + expectedEndpoints: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://til-v5.example.com"}, + }, + }, + { + name: "mixed ebsi and ebsi-v5 from VO", + issuers: config.TrustedIssuersLists{ + {Type: "ebsi", Url: "https://til-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + participants: config.TrustedParticipantsLists{ + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + expectedEndpoints: []config.EndpointEntry{ + {Type: config.TrustedParticipants, ListType: "ebsi-v5", Endpoint: "https://tir-v5.example.com"}, + {Type: config.TrustedIssuers, ListType: "ebsi", Endpoint: "https://til-ebsi.example.com"}, + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://til-v5.example.com"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + vo := config.Credential{ + Type: "VerifiableCredential", + TrustedIssuersLists: tc.issuers, + TrustedParticipantsLists: tc.participants, + } + db := CredentialDB{}.FromVO(vo) + assert.Equal(t, tc.expectedEndpoints, db.TrustedIssuersLists) + }) + } +} + +// TestCredentialDB_RoundTrip_V5 verifies that a CredentialDB with "ebsi-v5" +// typed entries survives a full FromVO() → VO() round-trip with type and URL +// preserved. +func TestCredentialDB_RoundTrip_V5(t *testing.T) { + original := config.Credential{ + Type: "VerifiableCredential", + TrustedIssuersLists: config.TrustedIssuersLists{ + {Type: "ebsi", Url: "https://til-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + TrustedParticipantsLists: config.TrustedParticipantsLists{ + {Type: "ebsi", Url: "https://tir-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + } + + db := CredentialDB{}.FromVO(original) + roundTripped := db.VO() + + assert.Equal(t, original.Type, roundTripped.Type) + assert.Equal(t, original.TrustedIssuersLists, roundTripped.TrustedIssuersLists) + assert.Equal(t, original.TrustedParticipantsLists, roundTripped.TrustedParticipantsLists) +} + // --------------------------------------------------------------------------- // DCQLDB — full VO / FromVO conversion // --------------------------------------------------------------------------- diff --git a/verifier/verifier_test.go b/verifier/verifier_test.go index 036ec53..b481f72 100644 --- a/verifier/verifier_test.go +++ b/verifier/verifier_test.go @@ -1752,3 +1752,366 @@ func TestInitVerifier_CredentialStatusWiring(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// TIR v5 — getTrustRegistriesValidationContext type propagation +// --------------------------------------------------------------------------- + +// TestGetTrustRegistriesValidationContext_V5TypePropagation verifies that +// getTrustRegistriesValidationContext correctly propagates "ebsi-v5" type +// information from config through to the TrustRegistriesValidationContext. +func TestGetTrustRegistriesValidationContext_V5TypePropagation(t *testing.T) { + logging.Configure(LOGGING_CONFIG) + + type test struct { + testName string + credentialScopes map[string]map[string]configModel.ScopeEntry + clientId string + scope string + credentialTypes []string + expectedIssuersMap map[string][]configModel.TrustedIssuersList + expectedParticipants map[string][]configModel.TrustedParticipantsList + configError error + expectedError error + } + + tests := []test{ + { + testName: "ebsi-v5 issuers and participants are propagated through validation context", + credentialScopes: map[string]map[string]configModel.ScopeEntry{ + "client-v5": { + "v5-scope": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + }, + }, + }, + clientId: "client-v5", + scope: "v5-scope", + credentialTypes: []string{"VerifiableCredential"}, + expectedIssuersMap: map[string][]configModel.TrustedIssuersList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + }, + expectedParticipants: map[string][]configModel.TrustedParticipantsList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + { + testName: "mixed ebsi and ebsi-v5 types are propagated correctly", + credentialScopes: map[string]map[string]configModel.ScopeEntry{ + "client-mixed": { + "mixed-scope": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi", Url: "https://til-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi", Url: "https://tir-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + }, + }, + }, + clientId: "client-mixed", + scope: "mixed-scope", + credentialTypes: []string{"VerifiableCredential"}, + expectedIssuersMap: map[string][]configModel.TrustedIssuersList{ + "VerifiableCredential": { + {Type: "ebsi", Url: "https://til-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + }, + expectedParticipants: map[string][]configModel.TrustedParticipantsList{ + "VerifiableCredential": { + {Type: "ebsi", Url: "https://tir-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + { + testName: "multiple credential types each with their own v5 config", + credentialScopes: map[string]map[string]configModel.ScopeEntry{ + "client-multi": { + "multi-scope": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-vc-v5.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi-v5", Url: "https://tir-vc-v5.example.com"}, + }, + }, + { + Type: "CustomerCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi", Url: "https://til-cc-ebsi.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi", Url: "https://tir-cc-ebsi.example.com"}, + }, + }, + }, + }, + }, + }, + clientId: "client-multi", + scope: "multi-scope", + credentialTypes: []string{"VerifiableCredential", "CustomerCredential"}, + expectedIssuersMap: map[string][]configModel.TrustedIssuersList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://til-vc-v5.example.com"}, + }, + "CustomerCredential": { + {Type: "ebsi", Url: "https://til-cc-ebsi.example.com"}, + }, + }, + expectedParticipants: map[string][]configModel.TrustedParticipantsList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://tir-vc-v5.example.com"}, + }, + "CustomerCredential": { + {Type: "ebsi", Url: "https://tir-cc-ebsi.example.com"}, + }, + }, + }, + { + testName: "config error is propagated", + configError: errors.New("config_failure"), + clientId: "client-err", + scope: "scope-err", + credentialScopes: map[string]map[string]configModel.ScopeEntry{}, + credentialTypes: []string{"VerifiableCredential"}, + expectedError: errors.New("config_failure"), + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + mockConfig := mockCredentialConfig{mockScopes: tc.credentialScopes, mockError: tc.configError} + verifier := CredentialVerifier{credentialsConfig: &mockConfig} + + ctx, err := verifier.getTrustRegistriesValidationContext(tc.clientId, tc.credentialTypes, tc.scope) + + if tc.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tc.expectedError.Error(), err.Error()) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.expectedIssuersMap, ctx.GetTrustedIssuersLists()) + assert.Equal(t, tc.expectedParticipants, ctx.GetTrustedParticipantLists()) + }) + } +} + +// TestGetTrustRegistriesValidationContextFromScope_V5 verifies that +// getTrustRegistriesValidationContextFromScope correctly propagates "ebsi-v5" +// type information and validates required credential types. +func TestGetTrustRegistriesValidationContextFromScope_V5(t *testing.T) { + logging.Configure(LOGGING_CONFIG) + + type test struct { + testName string + credentialScopes map[string]map[string]configModel.ScopeEntry + clientId string + scope string + presentedTypes []string + expectedIssuersMap map[string][]configModel.TrustedIssuersList + expectedParticipants map[string][]configModel.TrustedParticipantsList + expectedError error + } + + tests := []test{ + { + testName: "v5 issuers propagated from scope when all required types are presented", + credentialScopes: map[string]map[string]configModel.ScopeEntry{ + "client-v5": { + "v5-scope": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + }, + }, + }, + clientId: "client-v5", + scope: "v5-scope", + presentedTypes: []string{"VerifiableCredential"}, + expectedIssuersMap: map[string][]configModel.TrustedIssuersList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + }, + expectedParticipants: map[string][]configModel.TrustedParticipantsList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + { + testName: "missing required credential type returns error", + credentialScopes: map[string]map[string]configModel.ScopeEntry{ + "client-v5": { + "v5-scope": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + }, + }, + }, + }, + }, + clientId: "client-v5", + scope: "v5-scope", + presentedTypes: []string{"OtherCredential"}, + expectedError: ErrorRequiredCredentialNotProvided, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + mockConfig := mockCredentialConfig{mockScopes: tc.credentialScopes} + v := CredentialVerifier{credentialsConfig: &mockConfig} + + ctx, err := v.getTrustRegistriesValidationContextFromScope(tc.clientId, tc.scope, tc.presentedTypes) + + if tc.expectedError != nil { + assert.ErrorIs(t, err, tc.expectedError) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.expectedIssuersMap, ctx.GetTrustedIssuersLists()) + assert.Equal(t, tc.expectedParticipants, ctx.GetTrustedParticipantLists()) + }) + } +} + +// TestAuthenticationResponse_V5ValidationServices exercises the full +// AuthenticationResponse flow with "ebsi-v5" configured trust registries +// and a mocked validation service, verifying that the v5-typed trust config +// flows end-to-end through verification to JWT caching. +func TestAuthenticationResponse_V5ValidationServices(t *testing.T) { + logging.Configure(LOGGING_CONFIG) + + trueOption := true + + // Configure mock credentials with ebsi-v5 trust registries + v5CredentialScopes := map[string]map[string]configModel.ScopeEntry{ + "clientId": { + "": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + JwtInclusion: configModel.JwtInclusion{Enabled: &trueOption}, + }, + }, + }, + }, + } + + tests := []authTest{ + { + testName: "Same-device flow with ebsi-v5 trust registries succeeds when credential is valid.", + sameDevice: true, testState: "login-state", + testVP: getVP([]string{"vc"}), + testHolder: "holder", + testSession: loginSession{version: SAME_DEVICE, callback: "https://myhost.org/callback", sessionId: "my-session", clientId: "clientId", requestObject: "requestObjectJwt"}, + requestedState: "login-state", + verificationResult: []bool{true}, + expectedResponse: Response{FlowVersion: SAME_DEVICE, RedirectTarget: "https://myhost.org/callback", Code: "authCode", SessionId: "my-session"}, + }, + { + testName: "Same-device flow with ebsi-v5 trust registries fails when credential is invalid.", + sameDevice: true, testState: "login-state", + testVP: getVP([]string{"vc"}), + testHolder: "holder", + testSession: loginSession{version: SAME_DEVICE, callback: "https://myhost.org/callback", sessionId: "my-session", clientId: "clientId", requestObject: "requestObjectJwt"}, + requestedState: "login-state", + verificationResult: []bool{false}, + expectedError: ErrorInvalidVC, + expectedResponse: Response{}, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + sessionCache := mockSessionCache{sessions: map[string]loginSession{}} + if tc.testSession != (loginSession{}) { + sessionCache.sessions[tc.testState] = tc.testSession + } + tokenCache := mockTokenCache{tokens: map[string]tokenStore{}, errorToThrow: tc.tokenCacheError} + httpClient = mockHttpClient{tc.callbackError, nil} + ecdsaKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + testKey, _ := jwk.Import(ecdsaKey) + _ = jwk.AssignKeyID(testKey) + nonceGenerator := mockNonceGenerator{staticValues: []string{"authCode"}} + credentialsConfig := mockCredentialConfig{mockScopes: v5CredentialScopes} + + validationServices := []ValidationService{ + &mockExternalSsiKit{ + verificationResults: tc.verificationResult, + verificationError: tc.verificationError, + }, + } + v := CredentialVerifier{ + did: "did:key:verifier", + signingKey: testKey, + tokenCache: &tokenCache, + sessionCache: &sessionCache, + nonceGenerator: &nonceGenerator, + validationServices: validationServices, + clock: mockClock{}, + credentialsConfig: credentialsConfig, + clientIdentification: configModel.ClientIdentification{Id: "did:key:verifier"}, + } + + sameDeviceResponse, err := v.AuthenticationResponse(tc.requestedState, &tc.testVP) + if tc.expectedError != nil { + assert.Equal(t, tc.expectedError, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expectedResponse, sameDeviceResponse) + _, found := tokenCache.tokens[sameDeviceResponse.Code] + assert.True(t, found, "a token should be cached after successful authentication") + }) + } +} From 03327701fd97488af8d7100717ef74a1d2e257f1 Mon Sep 17 00:00:00 2001 From: general-agent-3 Date: Fri, 5 Jun 2026 06:23:52 +0000 Subject: [PATCH 8/9] Address review feedback on PR #5 - Add httptest-based integration tests for v5 attribute retrieval (TestV5AttributeRetrieval_Integration) covering single attribute, multi-page pagination, no-attributes, and 404 scenarios - Add httptest-based integration test for v5 participant checking (TestV5ParticipantCheck_Integration) covering registered and unregistered participants - Add jsonHandler helper for concise test server setup Co-Authored-By: Claude Opus 4.6 --- tir/tirClient_test.go | 223 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/tir/tirClient_test.go b/tir/tirClient_test.go index 6883110..e69df99 100644 --- a/tir/tirClient_test.go +++ b/tir/tirClient_test.go @@ -560,3 +560,226 @@ func getTestServer(path string, errorCode int) *httptest.Server { } })) } + +// TestV5AttributeRetrieval_Integration verifies correct retrieval of attributes +// from a v5 TIR API using a real HTTP test server. The test exercises the +// complete multi-step v5 flow: (1) GET issuer, (2) paginate attribute list, +// (3) fetch each individual attribute — and validates that the returned +// TrustedIssuer contains the expected attribute data. +func TestV5AttributeRetrieval_Integration(t *testing.T) { + type test struct { + testName string + did string + handlers map[string]http.HandlerFunc + expectedExists bool + expectedError bool + expectedDid string + expectedAttrCount int + // expectedAttrs maps attribute index to the expected IssuerAttribute fields. + expectedAttrs map[int]IssuerAttribute + } + + tests := []test{ + { + testName: "Single attribute retrieved from v5 API via httptest server.", + did: "did:web:example.org", + handlers: map[string]http.HandlerFunc{ + "/v5/issuers/did:web:example.org": jsonHandler(200, `{ + "did": "did:web:example.org", + "attributes": "v5/issuers/did:web:example.org/attributes", + "hasAttributes": true + }`), + "/v5/issuers/did:web:example.org/attributes": jsonHandler(200, `{ + "items": [{"id": "a1", "href": "v5/issuers/did:web:example.org/attributes/a1"}], + "links": {"first": "", "last": "", "next": "", "prev": ""}, + "total": 1, + "pageSize": 1, + "self": "" + }`), + "/v5/issuers/did:web:example.org/attributes/a1": jsonHandler(200, `{ + "attribute": {"hash": "sha256-abc", "body": "dGVzdC1ib2R5", "issuerType": "credential-issuer", "tao": "did:web:tao.org", "rootTao": "did:web:root.org"}, + "did": "did:web:example.org" + }`), + }, + expectedExists: true, + expectedDid: "did:web:example.org", + expectedAttrCount: 1, + expectedAttrs: map[int]IssuerAttribute{ + 0: {Hash: "sha256-abc", Body: "dGVzdC1ib2R5", IssuerType: "credential-issuer", Tao: "did:web:tao.org", RootTao: "did:web:root.org"}, + }, + }, + { + testName: "Multiple attributes across two pages retrieved via httptest server.", + did: "did:web:multi.org", + handlers: map[string]http.HandlerFunc{ + "/v5/issuers/did:web:multi.org": jsonHandler(200, `{ + "did": "did:web:multi.org", + "attributes": "v5/issuers/did:web:multi.org/attributes", + "hasAttributes": true + }`), + "/v5/issuers/did:web:multi.org/attributes": jsonHandler(200, `{ + "items": [{"id": "attr-1", "href": "v5/issuers/did:web:multi.org/attributes/attr-1"}], + "links": {"first": "", "last": "", "next": "v5/issuers/did:web:multi.org/attributes?page=2", "prev": ""}, + "total": 2, + "pageSize": 1, + "self": "" + }`), + "/v5/issuers/did:web:multi.org/attributes?page=2": jsonHandler(200, `{ + "items": [{"id": "attr-2", "href": "v5/issuers/did:web:multi.org/attributes/attr-2"}], + "links": {"first": "", "last": "", "next": "", "prev": ""}, + "total": 2, + "pageSize": 1, + "self": "" + }`), + "/v5/issuers/did:web:multi.org/attributes/attr-1": jsonHandler(200, `{ + "attribute": {"hash": "h1", "body": "Ym9keS0x", "issuerType": "type-A", "tao": "did:web:tao-a.org", "rootTao": "did:web:root-a.org"}, + "did": "did:web:multi.org" + }`), + "/v5/issuers/did:web:multi.org/attributes/attr-2": jsonHandler(200, `{ + "attribute": {"hash": "h2", "body": "Ym9keS0y", "issuerType": "type-B", "tao": "did:web:tao-b.org", "rootTao": "did:web:root-b.org"}, + "did": "did:web:multi.org" + }`), + }, + expectedExists: true, + expectedDid: "did:web:multi.org", + expectedAttrCount: 2, + expectedAttrs: map[int]IssuerAttribute{ + 0: {Hash: "h1", Body: "Ym9keS0x", IssuerType: "type-A", Tao: "did:web:tao-a.org", RootTao: "did:web:root-a.org"}, + 1: {Hash: "h2", Body: "Ym9keS0y", IssuerType: "type-B", Tao: "did:web:tao-b.org", RootTao: "did:web:root-b.org"}, + }, + }, + { + testName: "Issuer with no attributes returns empty attribute list.", + did: "did:web:noattr.org", + handlers: map[string]http.HandlerFunc{ + "/v5/issuers/did:web:noattr.org": jsonHandler(200, `{ + "did": "did:web:noattr.org", + "attributes": "", + "hasAttributes": false + }`), + }, + expectedExists: true, + expectedDid: "did:web:noattr.org", + expectedAttrCount: 0, + expectedAttrs: map[int]IssuerAttribute{}, + }, + { + testName: "Issuer not found returns 404.", + did: "did:web:unknown.org", + handlers: map[string]http.HandlerFunc{ + "/v5/issuers/did:web:unknown.org": jsonHandler(404, `{"error": "not found"}`), + }, + expectedExists: false, + expectedError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + common.ResetGlobalCache() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler, ok := tc.handlers[r.URL.RequestURI()] + if !ok { + w.WriteHeader(404) + return + } + handler(w, r) + })) + defer server.Close() + + tirClient := TirHttpClient{ + client: getClient{client: server.Client()}, + tilCache: mockCache{}, + tirCache: mockCache{}, + } + + exists, issuer, err := tirClient.GetTrustedIssuerV5([]string{server.URL}, tc.did) + + assert.Equal(t, tc.expectedExists, exists, "exists mismatch") + if tc.expectedError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expectedDid, issuer.Did, "issuer DID mismatch") + assert.Equal(t, tc.expectedAttrCount, len(issuer.Attributes), "attribute count mismatch") + + for idx, expectedAttr := range tc.expectedAttrs { + actual := issuer.Attributes[idx] + assert.Equal(t, expectedAttr.Hash, actual.Hash, "attribute[%d] Hash mismatch", idx) + assert.Equal(t, expectedAttr.Body, actual.Body, "attribute[%d] Body mismatch", idx) + assert.Equal(t, expectedAttr.IssuerType, actual.IssuerType, "attribute[%d] IssuerType mismatch", idx) + assert.Equal(t, expectedAttr.Tao, actual.Tao, "attribute[%d] Tao mismatch", idx) + assert.Equal(t, expectedAttr.RootTao, actual.RootTao, "attribute[%d] RootTao mismatch", idx) + } + }) + } +} + +// TestV5ParticipantCheck_Integration verifies that IsTrustedParticipantV5 +// correctly identifies registered and unregistered participants when querying +// a real HTTP test server serving v5 API responses. +func TestV5ParticipantCheck_Integration(t *testing.T) { + type test struct { + testName string + did string + serverStatus int + expectedResult bool + } + + tests := []test{ + { + testName: "Registered participant returns true.", + did: "did:web:trusted.org", + serverStatus: 200, + expectedResult: true, + }, + { + testName: "Unregistered participant (404) returns false.", + did: "did:web:unknown.org", + serverStatus: 404, + expectedResult: false, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + common.ResetGlobalCache() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/v5/issuers/" + tc.did + if r.URL.Path == expectedPath { + w.WriteHeader(tc.serverStatus) + if tc.serverStatus == 200 { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"did": "%s", "hasAttributes": false}`, tc.did) + } + } else { + w.WriteHeader(404) + } + })) + defer server.Close() + + tirClient := TirHttpClient{ + client: getClient{client: server.Client()}, + tilCache: mockCache{}, + tirCache: mockCache{}, + } + + result := tirClient.IsTrustedParticipantV5(server.URL, tc.did) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +// jsonHandler returns an http.HandlerFunc that writes the given status code and +// JSON body to the response. This simplifies test server setup for v5 API +// endpoint mocking. +func jsonHandler(statusCode int, body string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _, _ = w.Write([]byte(body)) + } +} From b75ae3da432a10a01790f359c0c55fbe94c6cdbc Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Fri, 5 Jun 2026 13:00:48 +0200 Subject: [PATCH 9/9] update open api --- api/credentials-config.yaml | 642 ++++++++++++++++++++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 api/credentials-config.yaml diff --git a/api/credentials-config.yaml b/api/credentials-config.yaml new file mode 100644 index 0000000..e3f09ec --- /dev/null +++ b/api/credentials-config.yaml @@ -0,0 +1,642 @@ +openapi: 3.0.3 +info: + title: Credentials configuration service + description: Provides and manages the scopes to be requested and the trust-anchors(trusted-issuers-list and trusted-participants-list) per service/credential. + version: 0.0.1 +tags: + - name: service +paths: + /service: + post: + tags: + - service + operationId: createService + summary: Create a service with its credentials configuration + description: Create a service with the given configuration. If no id is provided, the service will generate one. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + responses: + '201': + description: Successfully created the service. + headers: + Location: + schema: + type: string + format: uri + example: /service/packet-delivery-service + description: Location of the created service + '400': + description: Invalid service provided + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Service with the given id already exists. + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + get: + tags: + - service + parameters: + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/PageNumber' + operationId: getServices + summary: Return all services + description: Return all services configured, with pagination. + responses: + '200': + description: The service config. + content: + application/json: + schema: + $ref: '#/components/schemas/Services' + '400': + description: Invalid query parameters provided + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + /service/{id}: + get: + tags: + - service + parameters: + - $ref: '#/components/parameters/Id' + operationId: getService + summary: Return the full service config by ID + description: The service configuration, including all credentials and their trust anchors will be returned. + responses: + '200': + description: The service config + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + '404': + description: No such service exists. + delete: + tags: + - service + parameters: + - $ref: '#/components/parameters/Id' + operationId: deleteServiceById + description: Delete a single service(and all its configurations) with the given id. + summary: Delete the service + responses: + '204': + description: Successfully deleted + '404': + description: No such service exists. + put: + tags: + - service + parameters: + - $ref: '#/components/parameters/Id' + summary: Update a single service + description: Updates a single service by fully overriding it. + operationId: updateService + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + responses: + '200': + description: Successfully updated the service. + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + '404': + description: No such service exists. + '400': + description: Invalid service provided + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + /service/{id}/scope: + get: + tags: + - service + parameters: + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/OidcScope' + operationId: getScopeForService + summary: Get the scope for the service + description: Returns the scope(e.g. credential types to be requested) for the requested service + responses: + '200': + description: The scopes to be requested for the service + content: + application/json: + schema: + $ref: '#/components/schemas/Scope' + '404': + description: No such service exists. + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' +components: + parameters: + Id: + name: id + in: path + required: true + schema: + type: string + example: packet-delivery-service + OidcScope: + name: oidcScope + in: query + required: false + schema: + type: string + example: did_read + PageSize: + name: pageSize + in: query + schema: + type: integer + default: 100 + minimum: 1 + PageNumber: + name: page + in: query + schema: + type: integer + default: 0 + minimum: 0 + schemas: + Scope: + type: array + description: A list of credential-types to be requested + items: + type: string + example: + - VerifiableCredential + - CustomerCredential + Credential: + type: object + description: A credential-type with its trust configuration + properties: + type: + type: string + description: Type of the credential + example: VerifiableCredential + trustedParticipantsLists: + type: array + description: | + A list of trusted participants registry endpoints. Each entry is + either a structured object with type and url, or a plain URL string. + For backward-compatibility, plain URL strings are interpreted as + EBSI endpoints. + items: + $ref: '#/components/schemas/TrustedParticipantsListEndpoint' + example: + - type: ebsi + url: https://my-ebsi.list + - type: gaia-x + url: https://my-gaia-x.registry + trustedIssuersLists: + type: array + description: | + A list of trusted issuers registry endpoints. Each entry is either + a structured object with type and url, or a plain URL string. For + backward-compatibility, plain URL strings are interpreted as EBSI + (v3/v4) endpoints. Use type "ebsi-v5" for the TIR v5 API. + items: + $ref: '#/components/schemas/TrustedIssuersListEndpoint' + example: + - type: ebsi + url: https://til-pdc.gaia-x.fiware.dev + - type: ebsi-v5 + url: https://til-v5.example.com + holderVerification: + $ref: '#/components/schemas/HolderVerification' + requireCompliance: + type: boolean + default: false + description: Does the given credential require a compliancy credential + jwtInclusion: + $ref: '#/components/schemas/JwtInclusion' + required: + - type + JwtInclusion: + type: object + description: Configuration for the credential to decide its inclusion into the JWT. + properties: + enabled: + type: boolean + default: true + description: Should the given credential be included into the generated JWT + fullInclusion: + type: boolean + default: false + description: Should the complete credential be embedded + claimsToInclude: + type: array + description: Claims to be included + items: + $ref: '#/components/schemas/Claim' + Claim: + type: object + description: Claim of the a credential to be included in the JWT. + properties: + originalKey: + type: string + description: Key of the claim to be included. All objects under this key will be included unchanged. + newKey: + type: string + description: Key of the claim to be used in the jwt. If not provided, the original one will be used. + required: + - originalKey + TrustedParticipantsListEndpoint: + type: object + description: Endpoint of the trusted participants list and its type + properties: + type: + type: string + enum: + - gaia-x + - ebsi + - ebsi-v5 + url: + type: string + format: url + example: https://tir-pdc.gaia-x.fiware.dev + TrustedIssuersListEndpoint: + type: object + description: Endpoint of the trusted issuers list and its type. Use "ebsi" for v3/v4 auto-detection, "ebsi-v5" for the v5 API. + properties: + type: + type: string + enum: + - ebsi + - ebsi-v5 + url: + type: string + format: url + example: https://til-pdc.gaia-x.fiware.dev + HolderVerification: + type: object + description: Configuration of holder verification for the given credential type + properties: + enabled: + type: boolean + description: Should the holder verification be enabled + default: false + claim: + type: string + description: Name of the claim containing the holder id + default: subject + required: + - enabled + - claim + ServiceScopesEntry: + type: object + properties: + credentials: + type: array + description: Trust configuration for the credentials + minItems: 1 + items: + $ref: '#/components/schemas/Credential' + presentationDefinition: + $ref: '#/components/schemas/PresentationDefinition' + nullable: true + dcql: + $ref: '#/components/schemas/DCQL' + nullable: true + flatClaims: + type: boolean + default: false + description: When set, the claim are flatten to plain JWT-claims before beeing included, instead of keeping the credential/presentation structure, where the claims are under the key vc or vp + required: + - credentials + Service: + type: object + description: Configuration of a service and its credentials + properties: + id: + type: string + description: Id of the service to be configured. If no id is provided, the service will generate one. + example: packet-delivery-service + defaultOidcScope: + type: string + description: Default OIDC scope to be used if none is specified + example: default + oidcScopes: + type: object + description: A specific OIDC scope for that service, specifying the necessary VC types (credentials) + additionalProperties: + $ref: '#/components/schemas/ServiceScopesEntry' + authorizationType: + type: string + description: The authorization redirect to be created. + default: FRONTEND_V2 + enum: + - FRONTEND_V2 + - DEEPLINK + required: + - oidcScopes + - defaultOidcScope + Services: + type: object + description: The paginated list of services + properties: + total: + type: integer + description: Total number of services available + example: 25 + pageNumber: + type: integer + description: Number of the page to be retrieved. + example: 0 + pageSize: + type: integer + description: Size of the returend page, can be less than the requested depending on the available entries + example: 10 + services: + type: array + description: The list of services + items: + $ref: '#/components/schemas/Service' + DCQL: + type: object + description: JSON encoded query to request the credentials to be included in the presentation + properties: + credentials: + type: array + description: A non-empty array of Credential Queries that specify the requested Credentials. + items: + $ref: '#/components/schemas/CredentialQuery' + credential_sets: + type: array + description: A non-empty array of Credential Set Queries that specifies additional constraints on which of the requested Credentials to return. + items: + $ref: '#/components/schemas/CredentialSetQuery' + required: + - credentials + CredentialQuery: + type: object + description: A Credential Query is an object representing a request for a presentation of one or more matching Credentials + properties: + id: + type: string + description: A string identifying the Credential in the response and, if provided, the constraints in credential_sets. The value MUST be a non-empty string consisting of alphanumeric, underscore (_), or hyphen (-) characters. Within the Authorization Request, the same id MUST NOT be present more than once. + example: my-credential-query-id + format: + type: string + description: A string that specifies the format of the requested Credential. + enum: + - mso_mdoc + - vc+sd-jwt + - dc+sd-jwt + - ldp_vc + - jwt_vc_json + example: jwt_vc_json + multiple: + type: boolean + default: false + description: A boolean which indicates whether multiple Credentials can be returned for this Credential Query. If omitted, the default value is false. + example: false + claims: + type: array + description: A non-empty array of objects that specifies claims in the requested Credential. Verifiers MUST NOT point to the same claim more than once in a single query. Wallets SHOULD ignore such duplicate claim queries. + items: + $ref: '#/components/schemas/ClaimsQuery' + meta: + $ref: '#/components/schemas/MetaDataQuery' + require_cryptographic_holder_binding: + type: boolean + default: true + description: A boolean which indicates whether the Verifier requires a Cryptographic Holder Binding proof. The default value is true, i.e., a Verifiable Presentation with Cryptographic Holder Binding is required. If set to false, the Verifier accepts a Credential without Cryptographic Holder Binding proof. + example: true + claim_sets: + type: array + description: A non-empty array containing arrays of identifiers for elements in claims that specifies which combinations of claims for the Credential are requested. + items: + $ref: '#/components/schemas/ClaimSet' + trusted_authorities: + type: array + description: A non-empty array of objects that specifies expected authorities or trust frameworks that certify Issuers, that the Verifier will accept. Every Credential returned by the Wallet SHOULD match at least one of the conditions present in the corresponding trusted_authorities array if present. + items: + $ref: '#/components/schemas/TrustedAuthorityQuery' + ClaimsQuery: + type: object + description: A query to specifies claims in the requested Credential. + properties: + id: + type: string + description: REQUIRED if claim_sets is present in the Credential Query; OPTIONAL otherwise. A string identifying the particular claim. The value MUST be a non-empty string consisting of alphanumeric, underscore (_), or hyphen (-) characters. Within the particular claims array, the same id MUST NOT be present more than once. + example: my-claim-query-id + path: + type: array + description: The value MUST be a non-empty array representing a claims path pointer that specifies the path to a claim within the Credential. See https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-claims-path-pointer + items: + type: object + example: ["path", "to", "claim"] + values: + type: array + description: A non-empty array of strings, integers or boolean values that specifies the expected values of the claim. If the values property is present, the Wallet SHOULD return the claim only if the type and value of the claim both match exactly for at least one of the elements in the array. + items: + type: object + example: ["supported-value-1", "supported-value-2"] + intent_to_retain: + type: boolean + description: MDoc specific parameter, ignored for all other types. The flag can be set to inform that the reader wishes to keep(store) the data. In case of false, its data is only used to be dispalyed and verified. + example: false + namespace: + type: string + description: MDoc specific parameter, ignored for all other types. Refers to a namespace inside an mdoc. + example: "org.iso.7367.1" + claim_name: + type: string + description: MDoc specific parameter, ignored for all other types. Identifier for the data-element in the namespace. + example: "first_name" + MetaDataQuery: + type: object + description: Defines additional properties requested by the Verifier that apply to the metadata and validity data of the Credential. The properties of this object are defined per Credential Format. If empty, no specific constraints are placed on the metadata or validity of the requested Credential. + properties: + vct_values: + type: array + description: SD-JWT and JWT specific parameter. A non-empty array of strings that specifies allowed values for the type of the requested Verifiable Credential.The Wallet MAY return Credentials that inherit from any of the specified types, following the inheritance logic defined in https://datatracker.ietf.org/doc/html/draft-ietf-oauth-sd-jwt-vc-10 + items: + type: string + doctype_value: + type: string + description: Required for MDoc. String that specifies an allowed value for the doctype of the requested Verifiable Credential. It MUST be a valid doctype identifier as defined in https://www.iso.org/standard/69084.html + example: "org.iso.7367.1.mVRC" + type_values: + type: array + description: Required for ldp_vc. A non-empty array of string arrays. The Type value of the credential needs to be a subset of at least one of the string-arrays. + items: + type: array + items: + type: string + ClaimSet: + type: array + description: An array contain identifiers of elements in the claims, that specifies wich combination of claims is requested + items: + type: string + example: ["claim-id-a","claim-id-b"] + TrustedAuthorityQuery: + type: object + description: An object representing information that helps to identify an authority or the trust framework that certifies Issuers. A Credential is identified as a match to a Trusted Authorities Query if it matches with one of the provided values in one of the provided types. + properties: + type: + type: string + description: A string uniquely identifying the type of information about the issuer trust framework. + - aki + - etsi_tl + - openid_federation + example: "aki" + values: + type: array + description: A non-empty array of strings, where each string (value) contains information specific to the used Trusted Authorities Query type that allows the identification of an issuer, a trust framework, or a federation that an issuer belongs to. + items: + type: string + example: ["s9tIpPmhxdiuNkHMEWNpYim8S8Y"] + required: + - type + - values + CredentialSetQuery: + type: object + description: A Credential Set Query is an object representing a request for one or more Credentials to satisfy a particular use case with the Verifier. + properties: + options: + type: array + description: A non-empty array, where each value in the array is a list of Credential Query identifiers representing one set of Credentials that satisfies the use case. The value of each element in the options array is a non-empty array of identifiers which reference elements in credentials. + items: + type: array + items: + type: string + required: + type: boolean + description: A boolean which indicates whether this set of Credentials is required to satisfy the particular use case at the Verifier. + default: true + example: true + purpose: + type: object + description: A string, number or object specifying the purpose of the query. This specification does not define a specific structure or specific values for this property. The purpose is intended to be used by the Verifier to communicate the reason for the query to the Wallet. The Wallet MAY use this information to show the user the reason for the request. + example: "Identification" + PresentationDefinition: + type: object + description: Proofs required by the service - see https://identity.foundation/presentation-exchange/#presentation-definition + properties: + id: + type: string + description: Id of the definition + example: "32f54163-7166-48f1-93d8-ff217bdb0653" + name: + type: string + description: A human readable name for the definition + example: My default service credentials + purpose: + type: string + description: A string that describes the purpose for wich the definition should be used + example: The service requires age and name of the requesting user. + input_descriptors: + type: array + description: List of requested inputs for the presentation + items: + $ref: '#/components/schemas/InputDescriptor' + format: + $ref: '#/components/schemas/Format' + required: + - id + - input_descriptors + InputDescriptor: + type: object + properties: + id: + type: string + description: Id of the descriptor + example: "32f54163-7166-48f1-93d8-ff217bdb0653" + name: + type: string + description: A human readable name for the definition + example: User Age request + purpose: + type: string + description: A string that describes the purpose for which the claim is requested + example: Only users above a certain age should get service access + constraints: + $ref: '#/components/schemas/Constraints' + format: + $ref: '#/components/schemas/Format' + required: + - id + - constraints + Constraints: + type: object + properties: + fields: + type: array + description: List of the requested claims + items: + $ref: '#/components/schemas/Field' + Field: + type: object + properties: + id: + type: string + description: Id of the field + example: "32f54163-7166-48f1-93d8-ff217bdb0653" + name: + type: string + description: A human readable name for the definition + example: User Age request + purpose: + type: string + description: A string that describes the purpose for which the claim is requested + example: Only users above a certain age should get service access + optional: + type: boolean + description: Defines if the described field is considered optional or not + path: + type: array + description: An array of JsonPaths that selects the value from the input + items: + type: string + example: "$.credentialSubject.dateOfBirth" + filter: + type: object + description: Filter to be evaluated against the values returned from path evaluation + Format: + type: object + additionalProperties: true + ProblemDetails: + type: object + properties: + type: + description: An absolute URI that identifies the problem type. When dereferenced, it SHOULD provide human-readable documentation for the problem type. + type: string + format: uri + ##default: about:blank + title: + description: A short summary of the problem type. + type: string + example: Internal Server Error + status: + description: The HTTP status code generated by the origin server for this occurrence of the problem. + type: integer + example: 500 + detail: + description: A human readable explanation specific to this occurrence of the problem. + type: string + example: Connection timeout + instance: + description: An absolute URI that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. + type: string + format: uri