From 06645f27f8c70dc1fc0f0216af069d1340e0d9f2 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 10 Sep 2026 14:33:20 +0200 Subject: [PATCH] feat: data-driven attestation scopes, with EU Business Wallet data for the company users users.yaml gains an `attestations:` map per user, keyed by OIDC scope; a requested scope's map is released verbatim as top-level claims - the same contract the ehic scope implements by hand, since vc's apigw requests the credential type as the scope and stores the claims as the credential's document data unchanged. A new credential type is now a users.yaml block plus a scopes_supported entry, not a Go change. Shipped data: EBW-OID (WE BUILD ds001, uri:eu.ebw.oid.1), EUCC (ds004, urn:eudi:eucc:1) and EU PoA (ds007, uri:eu.eudi.eu-poa.1) for erik-010, maria-011, jan-012 and sophie-013, with every claim the rulebooks' SD-JWT schemas mark required; a test pins that. Natural persons carry none. Co-Authored-By: Claude Fable 5.1 --- README.md | 39 +++++- cmd/op/main.go | 19 ++- cmd/op/main_test.go | 117 ++++++++++++++++ configs/config.production.yaml | 3 + configs/config.yaml | 3 + internal/users/users.go | 17 +++ users.yaml | 245 +++++++++++++++++++++++++++++++++ 7 files changed, 439 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 644ab74..1292394 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ binaries; select which to run via the command. - Pushed Authorization Requests (`/par`, RFC 9126) - Token endpoint with `client_secret_basic`, `client_secret_post`, and `none` auth - Scope-based claim filtering (`profile` → name/birthdate, `email` → email) -- Configurable scopes including OID4VCI credential scopes (`pid`, `pid_1_5`, `pid_1_8`) +- Configurable scopes including OID4VCI credential scopes (`pid`, `pid_1_5`, `pid_1_8`, `ehic`) +- Data-driven attestation scopes: any `attestations:` block in `users.yaml` is released verbatim under the scope named by its key (shipped: `eucc`, `eu_poa`, `ebw_oid` for the EU Business Wallet) - Dynamic client registration (`/register`, RFC 7591) - JWKS endpoint (ES256 keys, generated at startup) - UserInfo endpoint (Bearer token validated) @@ -60,7 +61,7 @@ server: op_port: 9005 rp_port: 9006 issuer: "${ISSUER}" - scopes_supported: [openid, profile, email, pid, pid_1_5, pid_1_8] + scopes_supported: [openid, profile, email, organisation, pid, pid_1_5, pid_1_8, ehic, eucc, eu_poa, ebw_oid] clients: - client_id: "mini-oidc-rp" @@ -111,6 +112,40 @@ users: issuing_country: "SE" ``` +### Attestations (credential payloads per scope) + +vc's apigw requests one credential type at a time, using the type's scope +name, and stores the OIDC claims it gets back verbatim as the credential's +document data. `attestations:` is that contract as data: each key is a scope, +each value the claims released when it is requested - so the keys must be the +claim names the credential type's VCTM declares, nested objects included, and +a new credential type is a `users.yaml` block plus a `scopes_supported` entry. + +```yaml + - sub: "erik-010" + given_name: "Erik" + # ... + attestations: + ebw_oid: # scope ebw_oid -> uri:eu.ebw.oid.1 + id: "SEBOLAG.5591234567" + name: "Nordic Tech Solutions AB" + attestation_legal_category: "PUB-EAA" + issuing_authority: "Bolagsverket" + issuing_country: "SE" + eucc: # scope eucc -> urn:eudi:eucc:1 + legal_person_name: "Nordic Tech Solutions AB" + registered_address: {full_address: "Sveavägen 42, 111 34 Stockholm, Sweden", post_name: "Stockholm"} + legal_representative: ["Erik Lindström"] + # ... +``` + +The shipped `users.yaml` gives the company users (`erik-010`, `maria-011`, +`jan-012`, `sophie-013`) EU Business Wallet attestations - Owner +Identification Data (EBW-OID, WE BUILD ds001), EU Company Certificate (EUCC, +ds004) and EU Power of Attorney (EU PoA, ds007) - with the claims those +rulebooks' SD-JWT schemas require. The natural persons (`alice-001` ...) have +none, so asking them for one of these types fails for lack of data. + ## OIDC Endpoints | Endpoint | Method | Description | diff --git a/cmd/op/main.go b/cmd/op/main.go index fd60b94..4f60010 100644 --- a/cmd/op/main.go +++ b/cmd/op/main.go @@ -558,13 +558,28 @@ func claimsForScopes(user *users.User, scopeStr string) map[string]any { addPIDClaims(claims, user, true) } + // Attestation scopes (users.Attestations) - released verbatim, see the + // field's doc. A user without a block for a requested scope gets no + // claims from it; apigw then fails issuance for lack of data rather than + // minting an empty credential, which is the right outcome for a test IdP. + attested := false + for _, sc := range scopes { + if att, ok := user.Attestations[sc]; ok { + attested = true + for k, v := range att { + claims[k] = v + } + } + } + // If no recognized scopes matched, return all claims (backwards compat). // Every scope handled above must be listed here: otherwise a request for // exactly that scope - which is what apigw sends, one credential type at a // time - matches nothing, falls through, and overwrites the claims that - // handler just set with the generic profile ones. + // handler just set with the generic profile ones. An attestation scope + // that matched counts the same way. if !has("profile") && !has("email") && !has("organisation") && !has("ehic") && - !has("pid") && !has("pid_1_5") && !has("pid_1_8") { + !has("pid") && !has("pid_1_5") && !has("pid_1_8") && !attested { claims["given_name"] = user.GivenName claims["family_name"] = user.FamilyName claims["name"] = user.Name diff --git a/cmd/op/main_test.go b/cmd/op/main_test.go index 5668d14..6396887 100644 --- a/cmd/op/main_test.go +++ b/cmd/op/main_test.go @@ -292,3 +292,120 @@ func TestAgeInYearsRejectsUnparseableOrFuture(t *testing.T) { } } } + +// --- attestations: data-driven credential payloads released per scope --- + +func testCompanyUser() *users.User { + u := testUser() + u.Attestations = map[string]map[string]any{ + "eucc": { + "legal_person_name": "Analytical Engines Ltd", + "legal_person_id": "UKCRO.00012345", + "registered_address": map[string]any{ + "full_address": "1 Dorset Street, London", + "post_name": "London", + }, + "legal_representative": []any{"Ada Lovelace"}, + }, + "ebw_oid": { + "id": "UKCRO.00012345", + "name": "Analytical Engines Ltd", + }, + } + return u +} + +func TestClaimsForScopesAttestationReleasedVerbatim(t *testing.T) { + claims := claimsForScopes(testCompanyUser(), "eucc") + + if claims["legal_person_name"] != "Analytical Engines Ltd" || claims["legal_person_id"] != "UKCRO.00012345" { + t.Fatalf("expected the eucc attestation's claims at top level, got %v", claims) + } + addr, ok := claims["registered_address"].(map[string]any) + if !ok || addr["post_name"] != "London" { + t.Fatalf("nested objects must be released as-is, got %v", claims["registered_address"]) + } + // An attestation-only request is a recognized scope: the backwards-compat + // fallback must not paint profile claims over it. + if _, ok := claims["given_name"]; ok { + t.Fatalf("attestation scope must not fall through to the profile fallback, got %v", claims) + } + // And other attestations stay unreleased. + if _, ok := claims["id"]; ok { + t.Fatalf("ebw_oid claims released without the ebw_oid scope: %v", claims) + } +} + +func TestClaimsForScopesAttestationOnlyForRequestedScope(t *testing.T) { + claims := claimsForScopes(testCompanyUser(), "openid profile ebw_oid") + + if claims["name"] != "Analytical Engines Ltd" { + t.Fatalf("ebw_oid must override profile's name with the legal person's, got %v", claims["name"]) + } + if _, ok := claims["legal_person_name"]; ok { + t.Fatalf("eucc claims released without the eucc scope: %v", claims) + } +} + +func TestClaimsForScopesAttestationAbsentForUserWithoutIt(t *testing.T) { + claims := claimsForScopes(testUser(), "eucc") + + if _, ok := claims["legal_person_name"]; ok { + t.Fatalf("user without an eucc block must not get eucc claims: %v", claims) + } + // Unknown-to-this-user scope: the backwards-compat fallback applies, as before. + if claims["given_name"] != "Ada" { + t.Fatalf("expected the profile fallback for an unmatched scope, got %v", claims) + } +} + +// The shipped users.yaml must give the company users complete EU Business +// Wallet attestations - the claims the WE BUILD SD-JWT schemas mark required +// (minus vct/iss/cnf/timestamps, which the issuer adds). +func TestShippedUsersHaveCompleteEUBusinessWalletAttestations(t *testing.T) { + store, err := users.Load("../../users.yaml") + if err != nil { + t.Fatalf("load users.yaml: %v", err) + } + required := map[string][]string{ + "ebw_oid": {"id", "name", "attestation_legal_category", "issuing_authority", "issuing_country"}, + "eucc": {"legal_person_name", "legal_person_id", "legal_form_type", "registration_member_state", + "registered_address", "registration_date", "legal_person_status", "legal_person_activity", + "issuing_authority", "issuing_authority_id", "issuing_country", "issuance_date", + "authentic_source_id", "authentic_source_name", "legal_representative"}, + "eu_poa": {"attestation_legal_category", "issuing_authority", "issuing_country", "date_of_execution", + "euid_reference", "principal_full_name", "principal_date_of_birth", "company_statutory_full_name", + "company_business_register_name", "company_jurisdiction", "attorney_full_name", + "scope_of_representation_powers", "validity_period_valid_from", "validity_period_valid_until", + "applicable_law_jurisdiction", "signing_place", "signing_date"}, + } + expect := map[string][]string{ + "erik-010": {"ebw_oid", "eucc"}, + "maria-011": {"ebw_oid", "eucc"}, + "jan-012": {"eucc", "eu_poa"}, + "sophie-013": {"ebw_oid", "eucc", "eu_poa"}, + } + for sub, scopes := range expect { + u := store.FindBySub(sub) + if u == nil { + t.Fatalf("user %s missing from users.yaml", sub) + } + for _, scope := range scopes { + att := u.Attestations[scope] + if att == nil { + t.Errorf("%s: no %s attestation", sub, scope) + continue + } + for _, key := range required[scope] { + if _, ok := att[key]; !ok { + t.Errorf("%s/%s: required claim %q missing", sub, scope, key) + } + } + } + } + for _, sub := range []string{"alice-001", "bob-002", "carol-003"} { + if u := store.FindBySub(sub); u != nil && len(u.Attestations) != 0 { + t.Errorf("%s is a natural person and should carry no company attestations", sub) + } + } +} diff --git a/configs/config.production.yaml b/configs/config.production.yaml index 087ec6f..ee5a64a 100644 --- a/configs/config.production.yaml +++ b/configs/config.production.yaml @@ -13,6 +13,9 @@ server: - pid_1_5 - pid_1_8 - ehic + - eucc + - eu_poa + - ebw_oid clients: - client_id: "${CLIENT_ID:-mini-oidc-rp}" diff --git a/configs/config.yaml b/configs/config.yaml index 9b4c12e..7e08f45 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -13,6 +13,9 @@ server: - pid_1_5 - pid_1_8 - ehic + - eucc + - eu_poa + - ebw_oid # Static OIDC clients. Supports ${var} templating from environment variables. clients: diff --git a/internal/users/users.go b/internal/users/users.go index 9d217bb..987a244 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -59,6 +59,23 @@ type User struct { Role string `yaml:"role" json:"role,omitempty"` RepresentationType string `yaml:"representation_type" json:"representation_type,omitempty"` EmployeeID string `yaml:"employee_id" json:"employee_id,omitempty"` + + // Attestations are further credential payloads, keyed by the OIDC scope + // that releases them, e.g. + // + // attestations: + // eucc: {legal_person_name: ..., registered_address: {...}, ...} + // eu_poa: {attorney_full_name: ..., ...} + // ebw_oid: {id: ..., name: ..., ...} + // + // Every key/value of the matching map is released verbatim as a top-level + // claim when that scope is requested - the same contract EHIC uses: vc's + // apigw requests the credential type as the scope and stores the claims + // as the credential's document data unchanged. So the keys must be the + // claim names the credential type's VCTM declares (nested objects stay + // nested). Data-driven on purpose: a new credential type is a users.yaml + // block plus a scopes_supported entry, not a Go change. + Attestations map[string]map[string]any `yaml:"attestations" json:"attestations,omitempty"` } // EHIC holds the data released under the "ehic" scope. Field names mirror the diff --git a/users.yaml b/users.yaml index b160e1e..dc40e05 100644 --- a/users.yaml +++ b/users.yaml @@ -109,6 +109,52 @@ users: post_name: "Stockholm" admin_unit_l1: "SE" admin_unit_l2: "Stockholm" + # EU Business Wallet attestations (WE BUILD ds001/ds004), released + # verbatim under the scope named by the key - see users.go Attestations. + attestations: + ebw_oid: + id: "SEBOLAG.5591234567" + name: "Nordic Tech Solutions AB" + attestation_legal_category: "PUB-EAA" + issuing_authority: "Bolagsverket" + issuing_country: "SE" + date_of_expiry: "2027-12-31" + trust_anchor: "https://trust.example.org/anchors/se/bolagsverket" + eucc: + legal_person_name: "Nordic Tech Solutions AB" + legal_person_id: "SEBOLAG.5591234567" + legal_form_type: "Aktiebolag (AB)" + registration_member_state: "SE" + registered_address: + full_address: "Sveavägen 42, 111 34 Stockholm, Sweden" + thorough_fare: "Sveavägen" + locator_designator: "42" + post_code: "111 34" + post_name: "Stockholm" + admin_unit_level_1: "SE" + admin_unit_level_2: "Stockholm" + registration_date: "2015-06-01" + legal_person_status: "active" + legal_person_activity: + code: "62.01" + description: "Computer programming activities" + legal_representative: + - "Erik Lindström" + share_capital: + amount: "500000" + currency: "SEK" + digital_contact_point: + email: "info@nordictech.example.com" + website: "https://nordictech.example.com" + issuing_authority: "Bolagsverket" + issuing_authority_id: "SE:BOLAGSVERKET" + issuing_country: "SE" + issuing_jurisdiction: "SE" + issuance_date: "2026-01-15T09:00:00Z" + authentic_source_id: "SE:BOLAGSVERKET" + authentic_source_name: "Bolagsverket" + attestation_legal_category: "PUB-EAA" + trust_anchor: "https://trust.example.org/anchors/se/bolagsverket" # --- Joint representative (one of two signatories) --- @@ -145,6 +191,52 @@ users: post_name: "Berlin" admin_unit_l1: "DE" admin_unit_l2: "Berlin" + # EU Business Wallet attestations - see erik-010 for the mechanism. + attestations: + ebw_oid: + id: "DEHRB.B123456" + name: "Grünberg Consulting GmbH" + attestation_legal_category: "PUB-EAA" + issuing_authority: "Amtsgericht Charlottenburg (Handelsregister)" + issuing_country: "DE" + date_of_expiry: "2027-06-30" + trust_anchor: "https://trust.example.org/anchors/de/handelsregister" + eucc: + legal_person_name: "Grünberg Consulting GmbH" + legal_person_id: "DEHRB.B123456" + legal_form_type: "Gesellschaft mit beschränkter Haftung (GmbH)" + registration_member_state: "DE" + registered_address: + full_address: "Friedrichstraße 100, 10117 Berlin, Germany" + thorough_fare: "Friedrichstraße" + locator_designator: "100" + post_code: "10117" + post_name: "Berlin" + admin_unit_level_1: "DE" + admin_unit_level_2: "Berlin" + registration_date: "2010-02-15" + legal_person_status: "active" + legal_person_activity: + code: "70.22" + description: "Business and other management consultancy" + legal_representative: + - "Maria Schneider" + - "Thomas Grünberg" + share_capital: + amount: "25000" + currency: "EUR" + digital_contact_point: + email: "kontakt@gruenberg-gmbh.example.com" + website: "https://gruenberg-gmbh.example.com" + issuing_authority: "Amtsgericht Charlottenburg (Handelsregister)" + issuing_authority_id: "DE:AG-CHARLOTTENBURG" + issuing_country: "DE" + issuing_jurisdiction: "DE-BE" + issuance_date: "2026-01-20T10:30:00Z" + authentic_source_id: "DE:HANDELSREGISTER" + authentic_source_name: "Handelsregister" + attestation_legal_category: "PUB-EAA" + trust_anchor: "https://trust.example.org/anchors/de/handelsregister" # --- Employee / contact person (no signing authority) --- @@ -177,6 +269,79 @@ users: post_name: "Berlin" admin_unit_l1: "DE" admin_unit_l2: "Berlin" + # Jan has no signing authority of his own: he holds the company's EUCC + # and a Power of Attorney granted by Maria (the CFO) for procurement. + attestations: + eucc: + legal_person_name: "Grünberg Consulting GmbH" + legal_person_id: "DEHRB.B123456" + legal_form_type: "Gesellschaft mit beschränkter Haftung (GmbH)" + registration_member_state: "DE" + registered_address: + full_address: "Friedrichstraße 100, 10117 Berlin, Germany" + thorough_fare: "Friedrichstraße" + locator_designator: "100" + post_code: "10117" + post_name: "Berlin" + admin_unit_level_1: "DE" + admin_unit_level_2: "Berlin" + registration_date: "2010-02-15" + legal_person_status: "active" + legal_person_activity: + code: "70.22" + description: "Business and other management consultancy" + legal_representative: + - "Maria Schneider" + - "Thomas Grünberg" + share_capital: + amount: "25000" + currency: "EUR" + digital_contact_point: + email: "kontakt@gruenberg-gmbh.example.com" + website: "https://gruenberg-gmbh.example.com" + issuing_authority: "Amtsgericht Charlottenburg (Handelsregister)" + issuing_authority_id: "DE:AG-CHARLOTTENBURG" + issuing_country: "DE" + issuing_jurisdiction: "DE-BE" + issuance_date: "2026-01-20T10:30:00Z" + authentic_source_id: "DE:HANDELSREGISTER" + authentic_source_name: "Handelsregister" + attestation_legal_category: "PUB-EAA" + trust_anchor: "https://trust.example.org/anchors/de/handelsregister" + eu_poa: + attestation_legal_category: "QEAA" + issuing_authority: "Notariat Berlin-Mitte" + issuing_country: "DE" + date_of_execution: "2026-02-01" + euid_reference: "DEHRB.B123456" + principal_full_name: "Maria Schneider" + principal_date_of_birth: "1982-09-14" + principal_place_of_birth: "Berlin" + principal_identification_document_kind: "national_id_card" + principal_identification_document_number: "L01X00T47" + principal_full_address: "Kastanienallee 12, 10435 Berlin, Germany" + company_statutory_full_name: "Grünberg Consulting GmbH" + company_business_register_name: "Handelsregister Amtsgericht Charlottenburg" + company_business_register_location: "Berlin" + company_kind_of_legal_entity: "GmbH" + company_jurisdiction: "DE" + company_registered_office: "Friedrichstraße 100, 10117 Berlin, Germany" + attorney_full_name: "Jan Kowalski" + attorney_date_of_birth: "1993-07-08" + attorney_place_of_birth: "Warsaw" + attorney_identification_document_kind: "passport" + attorney_identification_document_number: "ZS0123456" + attorney_full_address: "Prenzlauer Allee 200, 10405 Berlin, Germany" + scope_of_representation_powers: + - "Conclusion of purchase and service contracts up to EUR 50,000" + - "Signing of tender documents and supplier registrations" + scope_of_representation_power_of_substitution: "not_allowed" + validity_period_valid_from: "2026-02-01" + validity_period_valid_until: "2027-01-31" + applicable_law_jurisdiction: "DE" + signing_place: "Berlin" + signing_date: "2026-02-01" + trust_anchor: "https://trust.example.org/anchors/de/notariat" # --- Representative of a French company (sole trader / power of attorney) --- @@ -211,3 +376,83 @@ users: post_name: "Lyon" admin_unit_l1: "FR" admin_unit_l2: "Auvergne-Rhône-Alpes" + # Sophie is the sole manager of Transport Dubois SARL and also holds a + # Power of Attorney from her co-founder for the company's banking. + attestations: + ebw_oid: + id: "FRRCS.Lyon.B987654321" + name: "Transport Dubois SARL" + attestation_legal_category: "PUB-EAA" + issuing_authority: "Greffe du Tribunal de Commerce de Lyon" + issuing_country: "FR" + date_of_expiry: "2027-11-20" + trust_anchor: "https://trust.example.org/anchors/fr/infogreffe" + eucc: + legal_person_name: "Transport Dubois SARL" + legal_person_id: "FRRCS.Lyon.B987654321" + legal_form_type: "Société à responsabilité limitée (SARL)" + registration_member_state: "FR" + registered_address: + full_address: "15 Rue de la République, 69002 Lyon, France" + thorough_fare: "Rue de la République" + locator_designator: "15" + post_code: "69002" + post_name: "Lyon" + admin_unit_level_1: "FR" + admin_unit_level_2: "Auvergne-Rhône-Alpes" + registration_date: "2018-11-20" + legal_person_status: "active" + legal_person_activity: + code: "49.41" + description: "Freight transport by road" + legal_representative: + - "Sophie Dubois" + share_capital: + amount: "10000" + currency: "EUR" + digital_contact_point: + email: "contact@transport-dubois.example.com" + website: "https://transport-dubois.example.com" + issuing_authority: "Greffe du Tribunal de Commerce de Lyon" + issuing_authority_id: "FR:GREFFE-LYON" + issuing_country: "FR" + issuing_jurisdiction: "FR" + issuance_date: "2026-01-10T08:15:00Z" + authentic_source_id: "FR:INFOGREFFE" + authentic_source_name: "Infogreffe" + attestation_legal_category: "PUB-EAA" + trust_anchor: "https://trust.example.org/anchors/fr/infogreffe" + eu_poa: + attestation_legal_category: "QEAA" + issuing_authority: "Chambre des Notaires du Rhône" + issuing_country: "FR" + date_of_execution: "2026-01-12" + euid_reference: "FRRCS.Lyon.B987654321" + principal_full_name: "Pierre Dubois" + principal_date_of_birth: "1960-04-02" + principal_place_of_birth: "Villeurbanne" + principal_identification_document_kind: "national_id_card" + principal_identification_document_number: "690012345678" + principal_full_address: "8 Quai Saint-Antoine, 69002 Lyon, France" + company_statutory_full_name: "Transport Dubois SARL" + company_business_register_name: "Registre du Commerce et des Sociétés de Lyon" + company_business_register_location: "Lyon" + company_kind_of_legal_entity: "SARL" + company_jurisdiction: "FR" + company_registered_office: "15 Rue de la République, 69002 Lyon, France" + attorney_full_name: "Sophie Dubois" + attorney_date_of_birth: "1988-12-01" + attorney_place_of_birth: "Lyon" + attorney_identification_document_kind: "passport" + attorney_identification_document_number: "21AB45678" + attorney_full_address: "22 Rue Garibaldi, 69006 Lyon, France" + scope_of_representation_powers: + - "Opening and operating the company's bank accounts" + - "Signing loan and leasing agreements for vehicles" + scope_of_representation_power_of_substitution: "limited" + validity_period_valid_from: "2026-01-12" + validity_period_valid_until: "2028-01-11" + applicable_law_jurisdiction: "FR" + signing_place: "Lyon" + signing_date: "2026-01-12" + trust_anchor: "https://trust.example.org/anchors/fr/notaires"