Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 |
Expand Down
19 changes: 17 additions & 2 deletions cmd/op/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions cmd/op/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,120 @@
}
}
}

// --- 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) {

Check failure on line 365 in cmd/op/main_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=sirosfoundation_mini-oidc&issues=AaCLT9VKJDmgtimSku2k&open=AaCLT9VKJDmgtimSku2k&pullRequest=27
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)
}
}
}
3 changes: 3 additions & 0 deletions configs/config.production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
3 changes: 3 additions & 0 deletions configs/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions internal/users/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading