diff --git a/component/pdp/capabilities.go b/component/pdp/capabilities.go index 695b55d5..a3d0df09 100644 --- a/component/pdp/capabilities.go +++ b/component/pdp/capabilities.go @@ -41,7 +41,8 @@ func capabilityForScope(ctx context.Context, scope string) (fhir.CapabilityState } func evalCapabilityPolicy(ctx context.Context, input PolicyInput) (PolicyInput, PolicyResult) { - if input.Action.Properties.ContentType != "application/fhir+json" { + // Skip capability checking for requests that don't target a specific resource type (e.g., /metadata, /) + if input.Resource.Type == nil { return input, Allow() } @@ -100,7 +101,7 @@ func evalInteraction( var resourceDescriptions []fhir.CapabilityStatementRestResource for _, rest := range statement.Rest { for _, res := range rest.Resource { - if res.Type == input.Resource.Type { + if res.Type == *input.Resource.Type { resourceDescriptions = append(resourceDescriptions, res) } } diff --git a/component/pdp/capabilities_test.go b/component/pdp/capabilities_test.go index 3e416ee7..5b17ce91 100644 --- a/component/pdp/capabilities_test.go +++ b/component/pdp/capabilities_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/nuts-foundation/nuts-knooppunt/lib/to" "github.com/stretchr/testify/assert" "github.com/zorgbijjou/golang-fhir-models/fhir-models/fhir" ) @@ -17,7 +18,7 @@ func TestComponent_reject_interaction(t *testing.T) { }, }, Resource: PolicyResource{ - Type: fhir.ResourceTypeOrganization, + Type: to.Ptr(fhir.ResourceTypeOrganization), Properties: PolicyResourceProperties{ ResourceId: "118876", }, @@ -47,7 +48,7 @@ func TestComponent_allow_interaction(t *testing.T) { }, }, Resource: PolicyResource{ - Type: fhir.ResourceTypeOrganization, + Type: to.Ptr(fhir.ResourceTypeOrganization), Properties: PolicyResourceProperties{ ResourceId: "118876", }, @@ -77,7 +78,7 @@ func TestComponent_allow_search_param(t *testing.T) { }, }, Resource: PolicyResource{ - Type: fhir.ResourceTypeOrganization, + Type: to.Ptr(fhir.ResourceTypeOrganization), Properties: PolicyResourceProperties{ ResourceId: "118876", }, @@ -108,7 +109,7 @@ func TestComponent_reject_search_param(t *testing.T) { }, }, Resource: PolicyResource{ - Type: fhir.ResourceTypeOrganization, + Type: to.Ptr(fhir.ResourceTypeOrganization), Properties: PolicyResourceProperties{ ResourceId: "118876", }, @@ -138,6 +139,9 @@ func TestComponent_reject_interaction_type(t *testing.T) { SubjectOrganizationId: "00000666", }, }, + Resource: PolicyResource{ + Type: to.Ptr(fhir.ResourceTypeOrganization), + }, Action: PolicyAction{ Properties: PolicyActionProperties{ ContentType: "application/fhir+json", @@ -163,7 +167,7 @@ func TestComponent_allow_include(t *testing.T) { }, }, Resource: PolicyResource{ - Type: fhir.ResourceTypeLocation, + Type: to.Ptr(fhir.ResourceTypeLocation), Properties: PolicyResourceProperties{ ResourceId: "88716123", }, @@ -194,7 +198,7 @@ func TestComponent_reject_include(t *testing.T) { }, }, Resource: PolicyResource{ - Type: fhir.ResourceTypeEndpoint, + Type: to.Ptr(fhir.ResourceTypeEndpoint), Properties: PolicyResourceProperties{ ResourceId: "88716123", }, @@ -225,7 +229,7 @@ func TestComponent_reject_revinclude(t *testing.T) { }, }, Resource: PolicyResource{ - Type: fhir.ResourceTypePractitioner, + Type: to.Ptr(fhir.ResourceTypePractitioner), Properties: PolicyResourceProperties{ ResourceId: "88716123", }, @@ -256,7 +260,7 @@ func TestComponent_allow_revinclude(t *testing.T) { }, }, Resource: PolicyResource{ - Type: fhir.ResourceTypeOrganization, + Type: to.Ptr(fhir.ResourceTypeOrganization), Properties: PolicyResourceProperties{ ResourceId: "88716123", }, diff --git a/component/pdp/fhirreq.go b/component/pdp/fhirreq.go index e273ada0..883145f1 100644 --- a/component/pdp/fhirreq.go +++ b/component/pdp/fhirreq.go @@ -384,7 +384,7 @@ func NewPolicyInput(request PDPRequest) (PolicyInput, PolicyResult) { } if tokens.ResourceType != nil { - policyInput.Resource.Type = *tokens.ResourceType + policyInput.Resource.Type = tokens.ResourceType if tokens.ResourceId != "" { policyInput.Resource.Properties.ResourceId = tokens.ResourceId } diff --git a/component/pdp/shared.go b/component/pdp/shared.go index 21c4bc85..8f9e72ca 100644 --- a/component/pdp/shared.go +++ b/component/pdp/shared.go @@ -55,7 +55,7 @@ type PolicyInput struct { } type PolicyResource struct { - Type fhir.ResourceType `json:"type"` + Type *fhir.ResourceType `json:"type"` Properties PolicyResourceProperties `json:"properties"` } diff --git a/docker-compose.yml b/docker-compose.yml index 836f4e18..398c2945 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -183,17 +183,22 @@ services: volumes: - ./pep/logs:/var/log/nginx environment: + # Backend connections - FHIR_BACKEND_HOST=hapi-fhir - FHIR_BACKEND_PORT=7050 + - FHIR_BASE_PATH=/fhir + - FHIR_UPSTREAM_PATH=/fhir/DEFAULT - KNOOPPUNT_PDP_HOST=knooppunt - KNOOPPUNT_PDP_PORT=8081 + # Nuts node connection (Authorization Server) + - NUTS_NODE_HOST=knooppunt + - NUTS_NODE_INTERNAL_PORT=8081 + # Data holder (this organization) - DATA_HOLDER_ORGANIZATION_URA=00000666 - DATA_HOLDER_FACILITY_TYPE=Z3 - - REQUESTING_FACILITY_TYPE=Z3 - - PURPOSE_OF_USE=treatment depends_on: - hapi-fhir - - knooppunt # Knooppunt provides the PDP endpoint + - knooppunt # Knooppunt provides the PDP and proxies to Nuts node healthcheck: test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/health"] diff --git a/helm/pep/templates/deployment.yaml b/helm/pep/templates/deployment.yaml index e70b0d3e..724d359f 100644 --- a/helm/pep/templates/deployment.yaml +++ b/helm/pep/templates/deployment.yaml @@ -45,18 +45,22 @@ spec: value: {{ .Values.env.fhirBackendHost | quote }} - name: FHIR_BACKEND_PORT value: {{ .Values.env.fhirBackendPort | quote }} + - name: FHIR_BASE_PATH + value: {{ .Values.env.fhirBasePath | quote }} - name: KNOOPPUNT_PDP_HOST value: {{ .Values.env.knooppuntPdpHost | quote }} - name: KNOOPPUNT_PDP_PORT value: {{ .Values.env.knooppuntPdpPort | quote }} + - name: NUTS_NODE_HOST + value: {{ .Values.env.nutsNodeHost | quote }} + - name: NUTS_NODE_INTERNAL_PORT + value: {{ .Values.env.nutsNodeInternalPort | quote }} - name: DATA_HOLDER_ORGANIZATION_URA value: {{ .Values.env.dataHolderOrganizationUra | quote }} - name: DATA_HOLDER_FACILITY_TYPE value: {{ .Values.env.dataHolderFacilityType | quote }} - - name: REQUESTING_FACILITY_TYPE - value: {{ .Values.env.requestingFacilityType | quote }} - - name: PURPOSE_OF_USE - value: {{ .Values.env.purposeOfUse | quote }} + - name: NGINX_PORT + value: {{ .Values.service.port | quote }} ports: - name: http containerPort: {{ .Values.service.port }} diff --git a/helm/pep/values.yaml b/helm/pep/values.yaml index c7db7a4e..bfc7e5b2 100644 --- a/helm/pep/values.yaml +++ b/helm/pep/values.yaml @@ -15,14 +15,19 @@ image: # PEP configuration environment variables env: + # FHIR backend (upstream server) fhirBackendHost: "hapi-fhir" fhirBackendPort: "7050" + fhirBasePath: "/fhir" + # Knooppunt PDP for authorization decisions knooppuntPdpHost: "knooppunt" knooppuntPdpPort: "8081" + # Nuts node for token introspection (RFC 7662) + nutsNodeHost: "nuts-node" + nutsNodeInternalPort: "8081" + # Data holder organization identity dataHolderOrganizationUra: "00000001" dataHolderFacilityType: "Z3" - requestingFacilityType: "Z3" - purposeOfUse: "treatment" # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ imagePullSecrets: [] diff --git a/pep/README.md b/pep/README.md index 17737316..a957fd74 100644 --- a/pep/README.md +++ b/pep/README.md @@ -4,39 +4,9 @@ NGINX-based reference implementation that enforces authorization decisions from ## Architecture -``` -┌─────────────────┐ -│ External Client │ -└────────┬────────┘ - │ GET /fhir/Patient/123 - │ Authorization: Bearer - ▼ -┌────────────────────────────────────────┐ -│ PEP (NGINX) │ -│ ┌──────────────────────────────────┐ │ -│ │ 1. Extract token │ │ -│ │ 2. Introspect (mock for testing) │ │ -│ │ 3. Build OPA request │ │ -│ │ 4. Call PDP for decision │ │ -│ │ 5. Enforce (allow/deny) │ │ -│ └──────────────────────────────────┘ │ -└────────┬─────────────────┬─────────────┘ - │ │ - │ POST /v1/data/ │ - │ knooppunt/authz│ - ▼ │ -┌────────────────────┐ │ -│ Knooppunt PDP │ │ -│ (port 8081) │ │ -│ - Validates input │ │ -│ - Returns allow/deny │ -└────────────────────┘ │ - │ (if allowed) - ▼ - ┌────────────────┐ - │ FHIR Server │ - └────────────────┘ -``` +![dataexchange-authorization-sd.svg](../docs/images/dataexchange-authorization-sd.svg) + +The PEP uses nginx subrequests to proxy calls to Nuts node and PDP via internal locations. ## Quick Start @@ -44,72 +14,141 @@ NGINX-based reference implementation that enforces authorization decisions from # Start PEP with rest of stack docker compose --profile pep up -d -# Test with mock token (format: bearer----) -curl -H "Authorization: Bearer bearer-00000020-01.015-123456789-900186021" \ - http://localhost:9080/fhir/Patient/patient-123 +# Test requires a valid OAuth token from Nuts node +# The PEP will introspect the token and validate DPoP if present ``` **Endpoints:** - PEP: `http://localhost:9080` -- PDP: `http://localhost:8081/pdp/v1/data/knooppunt/authz` (internal API) +- PDP: `http://localhost:8081/pdp` (internal API) ## How It Works -1. Extract bearer token from `Authorization` header -2. Introspect token via `/_introspect` endpoint (mock NJS function for testing) -3. Extract FHIR context (resource type, ID) from request URI -4. Build OPA request and call Knooppunt PDP -5. Enforce decision: allow (200) or deny (403) +1. Extract bearer/DPoP token from `Authorization` header +2. Introspect token via Nuts node `/internal/auth/v2/accesstoken/introspect` (RFC 7662) +3. Validate DPoP binding if token has `cnf.jkt` claim via `/internal/auth/v2/dpop/validate` (RFC 9449) +4. Pass introspection claims directly to PDPInput (no mapping - PD defines claim names) +5. Call Knooppunt PDP for authorization decision +6. Enforce decision: allow (200) or deny (403) ## Configuration Environment variables in `docker-compose.yml`: ```yaml +# FHIR API path exposed by PEP (default: /fhir) +FHIR_BASE_PATH=/fhir +# FHIR path on backend server (default: same as FHIR_BASE_PATH) +# Use for HAPI multi-tenancy: /fhir/DEFAULT +FHIR_UPSTREAM_PATH=/fhir + # Backend connections -FHIR_BACKEND_HOST=hapi-fhir # FHIR server -FHIR_BACKEND_PORT=7050 # HAPI FHIR default port, as 8080 is used by the knooppunt -KNOOPPUNT_PDP_HOST=knooppunt # PDP endpoint -KNOOPPUNT_PDP_PORT=8081 # Internal API only +FHIR_BACKEND_HOST=hapi-fhir +FHIR_BACKEND_PORT=7050 +KNOOPPUNT_PDP_HOST=knooppunt +KNOOPPUNT_PDP_PORT=8081 + +# Nuts node connection (Authorization Server) +NUTS_NODE_HOST=knooppunt +NUTS_NODE_INTERNAL_PORT=8081 -# Data holder configuration (organization where data is stored) +# Data holder (this organization) DATA_HOLDER_ORGANIZATION_URA=00000666 DATA_HOLDER_FACILITY_TYPE=Z3 -# Request configuration -REQUESTING_FACILITY_TYPE=Z3 -PURPOSE_OF_USE=treatment +# Security: Configure expected hostname for DPoP URL validation +# Prevents Host header spoofing attacks. Falls back to Host header if not set. +PEP_HOSTNAME=pep.example.com +``` + +## Presentation Definition + +The Presentation Definition (PD) is configured on the authorization server (Nuts node), not in the PEP. +For BgZ use cases, the PD typically requires: + +1. **X509Credential** - Organization identity with URA number +2. **HealthcareProviderRoleTypeCredential** - Vektis facility type +3. **NutsEmployeeCredential** - Employee identity for Mitz consent verification + +See `test/e2e/pep/testdata/accesspolicy.json` for an example PD used in e2e tests. + +## Claim Flow (No Mapping Required) + +Claims flow directly from the Presentation Definition to the PDP without any mapping in the PEP: + ``` +Verifiable Credentials Presentation Definition Introspection Response PDPInput + │ │ │ │ + │ ┌────────────────────┘ │ │ + ▼ ▼ │ │ + VC claims extracted using PD field constraints ──────────►│ │ + │ │ │ + │ The `id` field in each PD constraint │ │ + │ becomes the claim name in introspection │ │ + │ ▼ │ + │ { "active": true, │ + │ "subject_id": "...", │ + │ "subject_role": "..." │ + │ } │ + │ │ │ + │ │ passed through │ + │ └───────────────────►│ +``` + +The PEP passes claims directly from introspection to the PDP. **The Presentation Definition must use +`id` fields that match what the PDP expects.** -## OPA Request Format +### PD Constraint IDs (for PDP/Mitz) -The PEP sends requests with clear field names matching XACML/Mitz terminology: +The PEP passes all non-standard claims through to the PDP. The PDP expects these `id` values in the PD constraints: + +| PD Constraint `id` | VC Path Example | Description | +|--------------------|-----------------|-------------| +| `subject_id` | `$.credentialSubject.identifier` | Employee/practitioner identifier | +| `subject_role` | `$.credentialSubject.roleName` | Role code (e.g., "Medisch Specialist") | +| `subject_organization_id` | `$.credentialSubject.san.otherName` | URA number | +| `subject_organization` | `$.credentialSubject.subject.O` | Organization name | +| `subject_facility_type` | `$.credentialSubject.roleCodeNL` | Vektis facility type code | + +Additionally, the PDP receives from OAuth: +- `client_id` - OAuth client identifier (DID) +- `scope` - OAuth scopes (converted to `client_qualifications` array) + +## PDPInput Format + +The PEP sends requests matching the Go `PDPInput` struct: ```json -POST /pdp/v1/data/knooppunt/authz +POST /pdp { "input": { - "method": "GET", - "path": ["fhir", "Patient", "patient-123"], - - // REQUESTING PARTY (who is asking for data) - "requesting_organization_ura": "00000020", - "requesting_uzi_role_code": "01.015", - "requesting_practitioner_identifier": "123456789", - "requesting_facility_type": "Z3", - - // DATA HOLDER PARTY (who has the data) - "data_holder_organization_ura": "00000666", - "data_holder_facility_type": "Z3", - - // PATIENT/RESOURCE CONTEXT - "patient_bsn": "900186021", - "resource_type": "Patient", - "resource_id": "patient-123", - - // PURPOSE OF USE - "purpose_of_use": "treatment" + "subject": { + "type": "organization", + "id": "did:web:example.com", + "properties": { + "client_id": "did:web:example.com", + "client_qualifications": ["bgz"], + "subject_id": "000095254", + "subject_role": "medical-specialist", + "subject_organization_id": "87654321", + "subject_organization": "Test Hospital B.V.", + "subject_facility_type": "Z3" + } + }, + "request": { + "method": "GET", + "protocol": "HTTP/1.1", + "path": "/Condition", + "query_params": {"patient": ["Patient/patient-123"]}, + "header": {}, + "body": "" + }, + "context": { + "data_holder_organization_id": "12345678", + "data_holder_facility_type": "Z3", + "patient_bsn": "" + } } } ``` @@ -118,33 +157,44 @@ POST /pdp/v1/data/knooppunt/authz ```json { "result": { - "allow": true + "allow": true, + "reasons": [] } } ``` -## Production Deployment +## DPoP Token Binding -Replace the mock token introspection with real OAuth in `nginx/conf.d/knooppunt.conf`. Something like this: +When the introspection response includes a `cnf.jkt` claim, the PEP validates DPoP: -```nginx -# Change from: -location = /_introspect { - internal; - js_content authorize.mockIntrospect; -} +1. Extracts `DPoP` header from request +2. Calls Nuts node `/internal/auth/v2/dpop/validate` via internal subrequest +3. Returns 401 if validation fails -# To: -location = /_introspect { - internal; - proxy_pass http://nuts_node/internal/auth/v2/accesstoken/introspect; - proxy_method POST; - proxy_set_header Content-Type "application/x-www-form-urlencoded"; - proxy_pass_request_body on; -} +This ensures the access token is bound to the client's proof-of-possession key. + +## Testing + +### Unit Tests + +```bash +cd pep/nginx/js +npm install +npm test +``` + +### E2E Tests + +```bash +# Run from repository root +go test ./test/e2e/pep/... -v ``` -No changes needed to `authorize.js`. +The e2e test (in `test/e2e/pep/authorization_test.go`): +1. Starts Nuts node and HAPI FHIR containers, Knooppunt PDP in-process, and PEP container +2. Issues X509Credential, NutsEmployeeCredential, and HealthcareProviderRoleTypeCredential +3. Requests an access token with scope `bgz` +4. Tests the full PEP authorization flow with real credential validation ## Implementation Details @@ -153,12 +203,18 @@ No changes needed to `authorize.js`. - **nginx/js/authorize.js** - Authorization logic (NJS) - **nginx/js/authorize.test.js** - Unit tests for authorization logic -See inline code comments for details. +## Production Notes -## Testing +The PEP requires: +1. **Nuts node** with: + - Introspection endpoint at `/internal/auth/v2/accesstoken/introspect` + - DPoP validation endpoint at `/internal/auth/v2/dpop/validate` +2. **Presentation Definition** configured on the authorization server (Nuts node) with claim IDs matching what the PDP expects +3. **PDP** with policies matching the scopes/qualifications in the access tokens -```bash -cd pep/nginx/js -npm install -npm test -``` +**Limitations:** +- **Single-tenant only**: This PEP supports a single FHIR backend. Multi-tenant setups (e.g., HAPI multi-tenancy) are not supported because the PDP has no tenant context for patient lookups and consent checks. Deploy separate PEP instances per tenant if needed. + +**Considerations for production:** +- **Claim forwarding**: This PoC forwards all non-standard introspection claims to the PDP. In production, consider explicitly allowlisting which claims to forward based on the Presentation Definition, rather than forwarding everything. +- **Log security**: PEP logs contain OAuth metadata (client_id, scope) and error details for debugging. Ensure appropriate log access controls and retention policies. diff --git a/pep/nginx/Dockerfile b/pep/nginx/Dockerfile index d729aae7..daa9c7bf 100644 --- a/pep/nginx/Dockerfile +++ b/pep/nginx/Dockerfile @@ -23,12 +23,13 @@ ENV NGINX_PORT=8080 \ FHIR_BACKEND_HOST=fhir \ FHIR_BACKEND_PORT=7050 \ FHIR_BASE_PATH=/fhir \ + FHIR_UPSTREAM_PATH=/fhir \ KNOOPPUNT_PDP_HOST=knooppunt \ KNOOPPUNT_PDP_PORT=8081 \ + NUTS_NODE_HOST=knooppunt \ + NUTS_NODE_INTERNAL_PORT=8081 \ DATA_HOLDER_ORGANIZATION_URA=00000001 \ - DATA_HOLDER_FACILITY_TYPE=Z3 \ - REQUESTING_FACILITY_TYPE=Z3 \ - PURPOSE_OF_USE=treatment + DATA_HOLDER_FACILITY_TYPE=Z3 # Expose HTTP port EXPOSE 8080 diff --git a/pep/nginx/conf.d/knooppunt.conf b/pep/nginx/conf.d/knooppunt.conf index 47180ca7..f15681ee 100644 --- a/pep/nginx/conf.d/knooppunt.conf +++ b/pep/nginx/conf.d/knooppunt.conf @@ -9,6 +9,12 @@ upstream fhir_backend { keepalive 32; } +# Upstream for Nuts node (token introspection, DPoP validation) +upstream nuts_node { + server ${NUTS_NODE_HOST}:${NUTS_NODE_INTERNAL_PORT}; + keepalive 32; +} + # Upstream for Knooppunt PDP upstream knooppunt_pdp { server ${KNOOPPUNT_PDP_HOST}:${KNOOPPUNT_PDP_PORT}; @@ -23,8 +29,14 @@ server { limit_req zone=pep_limit burst=20 nodelay; limit_req_status 429; + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header Cache-Control "no-store" always; + # Custom log format with authorization details access_log /var/log/nginx/pep-access.log authz; + # Log errors to stderr for container visibility, plus file for production + error_log /dev/stderr warn; error_log /var/log/nginx/pep-error.log warn; # Health check endpoint @@ -35,7 +47,9 @@ server { } # FHIR API routes - protected by authorization - location /fhir/ { + # FHIR_BASE_PATH: incoming path clients use (default: /fhir) + # FHIR_UPSTREAM_PATH: path on backend FHIR server (default: same as FHIR_BASE_PATH) + location ${FHIR_BASE_PATH}/ { # Call authorization subrequest before proxying auth_request /_authorize; @@ -45,8 +59,7 @@ server { error_page 500 502 503 504 = @error5xx; # Forward to FHIR backend if authorized - # Rewrite /fhir/Patient/123 to ${FHIR_BASE_PATH}/Patient/123 - rewrite ^/fhir/(.*)$ ${FHIR_BASE_PATH}/$1 break; + rewrite ^${FHIR_BASE_PATH}/(.*)$ ${FHIR_UPSTREAM_PATH}/$1 break; proxy_pass http://fhir_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -70,19 +83,39 @@ server { js_content authorize.checkAuthorization; } - # Internal token introspection endpoint - # For testing: calls mockIntrospect() JavaScript function - # For production: replace with proxy_pass to real OAuth server + # Internal: Token introspection via Nuts node (RFC 7662) + # Nuts APIs are mounted at /nuts when accessed through Knooppunt location = /_introspect { internal; - js_content authorize.mockIntrospect; + proxy_pass http://nuts_node/nuts/internal/auth/v2/accesstoken/introspect; + proxy_method POST; + proxy_set_header Content-Type "application/x-www-form-urlencoded"; + proxy_pass_request_body on; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_send_timeout 10s; + proxy_read_timeout 10s; } - # Internal endpoint to call Knooppunt PDP (OPA-compliant endpoint) - # OPA standard: POST /v1/data/{package}/{rule} + # Internal: DPoP validation via Nuts node (RFC 9449) + location = /_dpop_validate { + internal; + proxy_pass http://nuts_node/nuts/internal/auth/v2/dpop/validate; + proxy_method POST; + proxy_set_header Content-Type "application/json"; + proxy_pass_request_body on; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_send_timeout 10s; + proxy_read_timeout 10s; + } + + # Internal: PDP authorization check location = /_pdp { internal; - proxy_pass http://knooppunt_pdp/pdp/v1/data/knooppunt/authz; + proxy_pass http://knooppunt_pdp/pdp; proxy_method POST; proxy_set_header Content-Type "application/json"; proxy_pass_request_body on; @@ -94,8 +127,11 @@ server { } # Error pages + # RFC 6750 Section 3 requires WWW-Authenticate header on 401 responses + # RFC 9449 Section 7.2: Advertise both DPoP and Bearer support location @error401 { - add_header Content-Type application/json; + add_header WWW-Authenticate 'DPoP realm="knooppunt", error="invalid_token", Bearer realm="knooppunt", error="invalid_token"' always; + add_header Content-Type application/json always; return 401 '{"error": "Unauthorized", "message": "Missing or invalid authentication"}'; } diff --git a/pep/nginx/js/authorize.js b/pep/nginx/js/authorize.js index 0c735c1f..d9b4ffa2 100644 --- a/pep/nginx/js/authorize.js +++ b/pep/nginx/js/authorize.js @@ -2,19 +2,20 @@ * Knooppunt PEP Authorization Module * * This module handles authorization requests by: - * 1. Extracting OAuth bearer token from Authorization header - * 2. Introspecting the token to get claims - * 3. Building flat JSON OPA request with FHIR context - * 4. Forwarding to Knooppunt PDP which makes "gesloten vraag" to Mitz + * 1. Extracting OAuth bearer/DPoP token from Authorization header + * 2. Introspecting the token via Nuts node (RFC 7662) + * 3. Validating DPoP token binding if present (RFC 9449) + * 4. Building PDPInput request for Knooppunt PDP * 5. Enforcing the PDP's authorization decision * - * The PDP (Issue #216) will translate this to XACML format for Mitz. + * The PDP will translate this to XACML format for Mitz "gesloten vraag". */ /** - * Extract bearer token from Authorization header + * Extract bearer or DPoP token from Authorization header + * Supports both "Bearer " and "DPoP " formats * @param {Object} request - NGINX request object - * @returns {string|null} - Bearer token or null + * @returns {string|null} - Token or null */ function extractBearerToken(request) { const authHeader = request.headersIn['Authorization']; @@ -22,151 +23,249 @@ function extractBearerToken(request) { return null; } - const match = authHeader.match(/^Bearer\s+(.+)$/i); - return match ? match[1] : null; + const match = authHeader.match(/^(Bearer|DPoP)\s+(.+)$/i); + return match ? match[2] : null; } /** - * Mock token introspection for testing (format: bearer----) - * Called by /_introspect endpoint + * Get the token type from Authorization header * @param {Object} request - NGINX request object + * @returns {string|null} - "Bearer" or "DPoP" or null */ -function mockIntrospect(request) { - try { - // Parse request body to get token - const body = request.requestText || ''; - const tokenMatch = body.match(/token=([^&]+)/); - - if (!tokenMatch) { - request.return(400, JSON.stringify({ - error: 'invalid_request', - error_description: 'Missing token parameter' - })); - return; - } - - const token = decodeURIComponent(tokenMatch[1]); +function getTokenType(request) { + const authHeader = request.headersIn['Authorization']; + if (!authHeader) { + return null; + } - // Mock token format: bearer---- - // Example: bearer-00000020-01.015-123456789-900186021 - const parts = token.split('-'); + const match = authHeader.match(/^(Bearer|DPoP)\s+/i); + return match ? match[1].toLowerCase() : null; +} - if (parts.length < 5 || parts[0] !== 'bearer') { - request.return(200, JSON.stringify({ - active: false - })); - return; - } +/** + * Parse OAuth scopes from space-separated string + * @param {string} scopeString - Space-separated scopes + * @returns {Array} - Array of scopes + */ +function parseScopes(scopeString) { + if (!scopeString) return []; + return scopeString.split(' ').filter(s => s.length > 0); +} - // Return RFC 7662 compliant introspection response - request.return(200, JSON.stringify({ - active: true, - sub: 'mock-user', - requesting_organization_ura: parts[1], - requesting_uzi_role_code: parts[2], - requesting_practitioner_identifier: parts[3], - patient_bsn: parts[4], - scope: 'bgz' - })); - } catch (e) { - request.error(`Mock introspection error: ${e}`); - request.return(500, JSON.stringify({ - error: 'server_error', - error_description: 'Introspection failed' - })); - } +/** + * Parse query parameters from query string + * @param {string} queryString - Query string without leading ? + * @returns {Object} - Map of param name to array of values + * @throws {URIError} - If URL decoding fails (malformed percent-encoding) + */ +function parseQueryParams(queryString) { + if (!queryString) return {}; + const params = {}; + queryString.split('&').forEach(pair => { + const idx = pair.indexOf('='); + if (idx > 0) { + const key = decodeURIComponent(pair.substring(0, idx)); + const value = decodeURIComponent(pair.substring(idx + 1)); + if (!params[key]) params[key] = []; + params[key].push(value); + } + }); + return params; } +// Standard OAuth/JWT/OIDC claims that should not be forwarded to PDP +// These are either handled specially (client_id, scope) or are token metadata +// Using object instead of Set for njs compatibility +// See: RFC 7662 (Introspection), RFC 9068 (JWT Access Token), OpenID Connect Core +const STANDARD_CLAIMS = { + // RFC 7662 Introspection Response + 'active': true, 'client_id': true, 'scope': true, 'token_type': true, + // RFC 7519 JWT / RFC 9068 JWT Access Token + 'iss': true, 'sub': true, 'aud': true, 'exp': true, 'nbf': true, 'iat': true, 'jti': true, + // RFC 9449 DPoP + 'cnf': true, + // OpenID Connect Core + 'azp': true, 'nonce': true, 'auth_time': true, 'sid': true, 'at_hash': true, 'c_hash': true +}; + /** - * Extract FHIR resource type and ID from URI - * Supports: /fhir/Patient/123, /fhir/Observation/456 - * @param {string} uri - Request URI - * @returns {Object} - {resourceType, resourceId} + * Normalize a claim value for PDP + * - Arrays are preserved as arrays (PDP may need to iterate) + * - Plain objects are converted to JSON strings (structure unknown) + * - Primitives (string, number, boolean) are preserved as-is + * - null/undefined become empty strings + * @param {*} value - Claim value from introspection + * @returns {string|number|boolean|Array} - Normalized value */ -function extractFhirContext(uri) { - const context = { - interactionType: null, - resourceType: null, - resourceId: null - }; - - // Only support resource reads for now - context.interactionType = "read" - - // Remove /fhir/ prefix - const fhirPath = uri.replace(/^\/fhir\//, ''); - - // Extract resource type and ID from path: Patient/123 - const pathMatch = fhirPath.match(/^([A-Za-z]+)(?:\/([^?]+))?/); - if (pathMatch) { - context.resourceType = pathMatch[1]; - context.resourceId = pathMatch[2] || null; +function normalizeClaimValue(value) { + if (value === null || value === undefined) { + return ''; } - - return context; + // Preserve arrays - PDP may need to check membership + if (Array.isArray(value)) { + return value; + } + // Convert plain objects to JSON string + if (typeof value === 'object') { + return JSON.stringify(value); + } + // Preserve primitive types (string, number, boolean) + return value; } /** - * Parse URI path into array - * @param {string} uri - Request URI - * @returns {Array} - Path segments + * Extract non-standard claims from introspection response + * Filters out standard OAuth/JWT/OIDC claims and returns all custom claims + * (typically populated by the Presentation Definition on the authorization server) + * @param {Object} introspection - Introspection response from Nuts node + * @returns {Object} - Custom claims to forward to PDP */ -function parsePathArray(uri) { - // Remove leading slash and query string - const path = uri.replace(/^\//, '').split('?')[0]; - return path.split('/').filter(segment => segment.length > 0); +function extractPDClaims(introspection) { + if (!introspection || typeof introspection !== 'object') { + return {}; + } + const claims = {}; + const keys = Object.keys(introspection); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (!STANDARD_CLAIMS[key]) { + claims[key] = normalizeClaimValue(introspection[key]); + } + } + return claims; } /** - * Build OPA request for PDP with clear field names matching XACML/Mitz terminology - * @param {Object} tokenClaims - Claims from introspected token - * @param {Object} fhirContext - FHIR resource context + * Build PDPInput request matching the Go PDPInput struct + * + * All non-standard claims from the introspection response are forwarded to the PDP. + * The Presentation Definition on the authorization server defines which claims are + * extracted from VCs - these are passed through automatically. + * + * @param {Object} introspection - Introspection response * @param {Object} request - NGINX request object - * @returns {Object} - OPA-compliant request for PDP + * @returns {Object} - PDPInput request */ -function buildOpaRequest(tokenClaims, fhirContext, request) { - const uri = request.variables.request_uri || ''; +function buildPDPRequest(introspection, request) { + const uri = request.variables.request_uri || request.uri || ''; + const uriParts = uri.split('?'); + let requestPath = uriParts[0]; + const queryString = uriParts[1]; + + // Strip FHIR base path to get the FHIR resource path for PDP + // e.g., /fhir/Condition -> /Condition (default: /fhir) + // The PDP parser expects FHIR resource paths, not the full URL path + let fhirBasePath = process.env.FHIR_BASE_PATH || '/fhir'; + if (fhirBasePath.endsWith('/')) { + fhirBasePath = fhirBasePath.slice(0, -1); + } + if (requestPath.startsWith(fhirBasePath + '/')) { + requestPath = requestPath.substring(fhirBasePath.length); + } else if (requestPath === fhirBasePath) { + requestPath = '/'; + } + + // Extract all PD-defined claims (non-standard OAuth/JWT claims) + const pdClaims = extractPDClaims(introspection); + + // Build properties object - use Object.assign since njs doesn't support spread operator + const properties = { + client_id: introspection.client_id || '', + client_qualifications: parseScopes(introspection.scope) + }; + // Merge all PD-defined claims into properties + Object.assign(properties, pdClaims); return { input: { - scope: tokenClaims.scope, - // HTTP Request context - method: request.variables.request_method || request.method, - path: parsePathArray(uri), - - // REQUESTING PARTY (who is asking for data) - requesting_organization_ura: tokenClaims.requesting_organization_ura || null, - requesting_uzi_role_code: tokenClaims.requesting_uzi_role_code || null, - requesting_practitioner_identifier: tokenClaims.requesting_practitioner_identifier || null, - // TODO: Facility type is a property of the organization (URA), not directly provided by clients. - // Once authn/authz IGs are finalized, determine how to properly resolve this value. - // Hardcoded for single-org reference implementation until architecture is defined. - requesting_facility_type: process.env.REQUESTING_FACILITY_TYPE || 'Z3', - - // DATA HOLDER PARTY (who has the data being requested) - data_holder_organization_ura: process.env.DATA_HOLDER_ORGANIZATION_URA || null, - data_holder_facility_type: process.env.DATA_HOLDER_FACILITY_TYPE || 'Z3', - - // PATIENT/RESOURCE CONTEXT - patient_bsn: tokenClaims.patient_bsn || null, - interaction_type: fhirContext.interactionType, - resource_type: fhirContext.resourceType, - resource_id: fhirContext.resourceId, - - // PURPOSE OF USE - purpose_of_use: process.env.PURPOSE_OF_USE || 'treatment' + subject: { + type: 'organization', + id: introspection.client_id || '', + properties: properties + }, + request: { + method: request.variables.request_method || request.method || 'GET', + protocol: 'HTTP/1.1', + path: requestPath || '/', + query_params: parseQueryParams(queryString), + header: {}, + body: request.requestText || '' + }, + context: { + data_holder_organization_id: process.env.DATA_HOLDER_ORGANIZATION_URA || '', + data_holder_facility_type: process.env.DATA_HOLDER_FACILITY_TYPE || '', + patient_bsn: '' + } } }; } +/** + * Validate DPoP token binding (RFC 9449) + * @param {Object} request - NGINX request object + * @param {Object} introspection - Introspection response + * @returns {Promise} - { valid: boolean, reason?: string } + */ +async function validateDPoP(request, introspection) { + // If token doesn't have DPoP binding (no cnf.jkt), validation passes + if (!introspection.cnf || !introspection.cnf.jkt) { + return { valid: true }; + } + + const dpopHeader = request.headersIn['DPoP']; + if (!dpopHeader) { + return { valid: false, reason: 'DPoP header required but missing' }; + } + + const token = extractBearerToken(request); + + // Use configured hostname for DPoP URL construction to prevent Host header spoofing. + // Falls back to Host header for backwards compatibility, but production should configure PEP_HOSTNAME. + const host = process.env.PEP_HOSTNAME || + request.headersIn['Host'] || + request.headersIn['host'] || + ''; + + // Derive scheme from request, falling back to https to preserve production behavior + const scheme = + (request.variables && request.variables.scheme) || + request.headersIn['X-Forwarded-Proto'] || + request.headersIn['x-forwarded-proto'] || + 'https'; + + const payload = { + dpop_proof: dpopHeader, + method: request.variables.request_method || request.method || 'GET', + thumbprint: introspection.cnf.jkt, + token: token, + url: `${scheme}://${host}${request.variables.request_uri || request.uri || ''}` + }; + + try { + const response = await request.subrequest('/_dpop_validate', { + method: 'POST', + body: JSON.stringify(payload) + }); + + if (response.status !== 200) { + return { valid: false, reason: `DPoP validation returned ${response.status}: ${response.responseText}` }; + } + + const result = JSON.parse(response.responseText); + return { valid: result.valid === true, reason: result.reason || '' }; + } catch (e) { + return { valid: false, reason: `DPoP validation error: ${e}` }; + } +} + /** * Main authorization function called by NGINX auth_request * @param {Object} request - NGINX request object */ async function checkAuthorization(request) { try { - // Step 1: Extract bearer token from Authorization header + // Step 1: Extract token from Authorization header const token = extractBearerToken(request); if (!token) { request.error('Missing or invalid Authorization header'); @@ -174,94 +273,116 @@ async function checkAuthorization(request) { return; } - request.log('Bearer token found, introspecting...'); + // RFC 9449: If using DPoP authorization scheme, DPoP header is required + const tokenType = getTokenType(request); + if (tokenType === 'dpop' && !request.headersIn['DPoP']) { + request.error('DPoP authorization scheme requires DPoP header'); + request.return(401); + return; + } - // Step 2: Introspect token via OAuth endpoint - // For testing: /_introspect calls mockIntrospect() function - // For production: Change /_introspect to proxy to real OAuth server - const introspectionResponse = await request.subrequest('/_introspect', { - method: 'POST', - body: `token=${encodeURIComponent(token)}` - }); + // Step 2: Introspect token via Nuts node (RFC 7662) + let introspectionResponse; + try { + introspectionResponse = await request.subrequest('/_introspect', { + method: 'POST', + body: `token=${encodeURIComponent(token)}` + }); + } catch (e) { + request.error(`Token introspection failed: ${e}`); + request.return(502); + return; + } if (introspectionResponse.status !== 200) { - request.error(`Token introspection failed: ${introspectionResponse.status}`); - request.return(401); + request.error(`Token introspection returned ${introspectionResponse.status}`); + request.return(introspectionResponse.status === 401 ? 401 : 502); return; } - let tokenClaims; + let introspection; try { - tokenClaims = JSON.parse(introspectionResponse.responseText); + introspection = JSON.parse(introspectionResponse.responseText); } catch (e) { - request.error(`Failed to parse introspection response: ${e}`); - request.return(401); + request.error(`Invalid introspection response: ${e}`); + request.return(502); return; } - if (!tokenClaims.active) { + if (!introspection.active) { request.error('Token is not active'); request.return(401); return; } - request.log(`Token parsed: requesting_org=${tokenClaims.requesting_organization_ura}, ` + - `patient_bsn=${tokenClaims.patient_bsn}`); + request.log(`Token active: client_id=${introspection.client_id}, scope=${introspection.scope}`); - // Step 3: Extract FHIR context from request - const fhirContext = extractFhirContext(request.variables.request_uri || ''); + // Step 3: Validate DPoP if token has cnf claim + const dpopResult = await validateDPoP(request, introspection); + if (!dpopResult.valid) { + request.error(`DPoP validation failed: ${dpopResult.reason}`); + request.return(401); + return; + } - request.log(`FHIR context: resourceType=${fhirContext.resourceType}, ` + - `resourceId=${fhirContext.resourceId}`); + // Step 4: Build PDPInput request + let pdpRequest; + try { + pdpRequest = buildPDPRequest(introspection, request); + } catch (e) { + if (e instanceof URIError) { + request.error(`Malformed URL encoding in request: ${e.message}`); + request.return(400); + return; + } + throw e; + } - // Step 4: Build OPA request for PDP - const opaRequest = buildOpaRequest(tokenClaims, fhirContext, request); + request.log(`Calling PDP: client_id=${pdpRequest.input.subject.id}, ` + + `path=${pdpRequest.input.request.path}, method=${pdpRequest.input.request.method}`); // Step 5: Call Knooppunt PDP - const pdpRequestOpts = { - method: 'POST', - body: JSON.stringify(opaRequest) - }; - - const opaRequestInput = opaRequest.input; - request.log(`Calling PDP: requesting_org=${opaRequestInput.requesting_organization_ura}, ` + - `data_holder=${opaRequestInput.data_holder_organization_ura}, ` + - `patient_bsn=${opaRequestInput.patient_bsn}, resource=${opaRequestInput.resource_type}`); - - const pdpResponse = await request.subrequest('/_pdp', pdpRequestOpts); + let pdpResponse; + try { + pdpResponse = await request.subrequest('/_pdp', { + method: 'POST', + body: JSON.stringify(pdpRequest) + }); + } catch (e) { + request.error(`PDP unreachable: ${e}`); + request.return(502); + return; + } - // Step 6: Process PDP response if (pdpResponse.status !== 200) { - request.error(`PDP returned error status: ${pdpResponse.status}`); - request.return(500); + request.error(`PDP returned ${pdpResponse.status}`); + request.return(502); return; } - let opaResponse; + let pdpResult; try { - opaResponse = JSON.parse(pdpResponse.responseText); + pdpResult = JSON.parse(pdpResponse.responseText); } catch (e) { - request.error(`Failed to parse PDP response: ${e}`); - request.return(500); + request.error(`Invalid PDP response: ${e}`); + request.return(502); return; } - // Step 7: Extract decision from OPA result - if (!opaResponse.result) { - request.error('PDP response missing result field'); - request.return(500); + // Validate PDP response schema + if (!pdpResult.result || typeof pdpResult.result.allow !== 'boolean') { + request.error(`Malformed PDP response: missing result.allow boolean`); + request.return(502); return; } - const decision = opaResponse.result; - - // Step 8: Enforce decision - if (decision.allow === true) { + // Step 6: Enforce decision + if (pdpResult.result.allow) { request.log('Access ALLOWED by PDP'); request.return(200); } else { - const reason = decision.reason || 'policy-denied'; - request.warn(`Access DENIED by PDP: reason=${reason}`); + const reasons = (pdpResult.result && pdpResult.result.reasons) ? pdpResult.result.reasons : []; + request.warn(`Access DENIED by PDP: ${JSON.stringify(reasons)}`); request.return(403); } @@ -271,12 +392,15 @@ async function checkAuthorization(request) { } } -// Export all functions for both NJS (needs default export) and tests (can destructure) export default { checkAuthorization, - mockIntrospect, extractBearerToken, - parsePathArray, - extractFhirContext, - buildOpaRequest + getTokenType, + parseScopes, + parseQueryParams, + normalizeClaimValue, + extractPDClaims, + buildPDPRequest, + validateDPoP, + STANDARD_CLAIMS }; diff --git a/pep/nginx/js/authorize.test.js b/pep/nginx/js/authorize.test.js index c634e56a..8f306475 100644 --- a/pep/nginx/js/authorize.test.js +++ b/pep/nginx/js/authorize.test.js @@ -3,28 +3,47 @@ * Run with: npm test (from pep/nginx/js directory) */ -// Import functions from authorize.js import authorize from './authorize.js'; import { jest } from '@jest/globals'; -const { extractBearerToken, parsePathArray, extractFhirContext, buildOpaRequest } = authorize; +const { + extractBearerToken, + getTokenType, + parseScopes, + parseQueryParams, + normalizeClaimValue, + extractPDClaims, + buildPDPRequest, + validateDPoP +} = authorize; -// Mock NGINX request object function createMockRequest(overrides = {}) { + const { variables, headersIn, method, ...rest } = overrides; + const effectiveMethod = method || 'GET'; return { - headersIn: {}, + headersIn: { ...headersIn }, variables: { request_uri: '', - request_method: 'GET' + request_method: effectiveMethod, + ...variables }, - method: 'GET', + uri: '', + method: effectiveMethod, requestText: '', log: jest.fn(), error: jest.fn(), warn: jest.fn(), return: jest.fn(), subrequest: jest.fn(), - ...overrides + ...rest + }; +} + +// Helper to create mock subrequest response (njs style) +function createMockSubrequestResponse(status, body) { + return { + status, + responseText: typeof body === 'string' ? body : JSON.stringify(body) }; } @@ -36,6 +55,13 @@ describe('extractBearerToken', () => { expect(extractBearerToken(request)).toBe('abc123'); }); + test('extracts DPoP token', () => { + const request = createMockRequest({ + headersIn: { 'Authorization': 'DPoP xyz789' } + }); + expect(extractBearerToken(request)).toBe('xyz789'); + }); + test('returns null when Authorization header is missing', () => { const request = createMockRequest(); expect(extractBearerToken(request)).toBeNull(); @@ -54,67 +80,281 @@ describe('extractBearerToken', () => { }); expect(extractBearerToken(request)).toBe('token123'); }); + + test('handles case-insensitive DPoP keyword', () => { + const request = createMockRequest({ + headersIn: { 'Authorization': 'DPOP token456' } + }); + expect(extractBearerToken(request)).toBe('token456'); + }); +}); + +describe('getTokenType', () => { + test('returns bearer for Bearer token', () => { + const request = createMockRequest({ + headersIn: { 'Authorization': 'Bearer abc123' } + }); + expect(getTokenType(request)).toBe('bearer'); + }); + + test('returns dpop for DPoP token', () => { + const request = createMockRequest({ + headersIn: { 'Authorization': 'DPoP xyz789' } + }); + expect(getTokenType(request)).toBe('dpop'); + }); + + test('returns null when Authorization header is missing', () => { + const request = createMockRequest(); + expect(getTokenType(request)).toBeNull(); + }); + + test('returns null for invalid format', () => { + const request = createMockRequest({ + headersIn: { 'Authorization': 'Basic abc123' } + }); + expect(getTokenType(request)).toBeNull(); + }); +}); + +describe('normalizeClaimValue', () => { + test('converts string values unchanged', () => { + expect(normalizeClaimValue('hello')).toBe('hello'); + }); + + test('preserves numbers as-is', () => { + expect(normalizeClaimValue(12345)).toBe(12345); + expect(normalizeClaimValue(0)).toBe(0); + expect(normalizeClaimValue(-42)).toBe(-42); + }); + + test('preserves booleans as-is', () => { + expect(normalizeClaimValue(true)).toBe(true); + expect(normalizeClaimValue(false)).toBe(false); + }); + + test('converts null to empty string', () => { + expect(normalizeClaimValue(null)).toBe(''); + }); + + test('converts undefined to empty string', () => { + expect(normalizeClaimValue(undefined)).toBe(''); + }); + + test('converts plain objects to JSON string', () => { + const obj = { key: 'value', nested: { a: 1 } }; + expect(normalizeClaimValue(obj)).toBe('{"key":"value","nested":{"a":1}}'); + }); + + test('preserves arrays as arrays', () => { + const arr = ['a', 'b', 'c']; + expect(normalizeClaimValue(arr)).toEqual(['a', 'b', 'c']); + expect(Array.isArray(normalizeClaimValue(arr))).toBe(true); + }); +}); + +describe('extractPDClaims', () => { + test('extracts non-standard claims from introspection', () => { + const introspection = { + active: true, + client_id: 'did:nuts:123', + scope: 'bgz', + iss: 'https://auth.example.com', + subject_id: 'practitioner-456', + subject_role: 'doctor', + custom_claim: 'custom_value' + }; + + const claims = extractPDClaims(introspection); + + // Should include PD-defined claims + expect(claims.subject_id).toBe('practitioner-456'); + expect(claims.subject_role).toBe('doctor'); + expect(claims.custom_claim).toBe('custom_value'); + + // Should exclude standard OAuth/JWT claims + expect(claims.active).toBeUndefined(); + expect(claims.client_id).toBeUndefined(); + expect(claims.scope).toBeUndefined(); + expect(claims.iss).toBeUndefined(); + }); + + test('preserves primitive types from introspection', () => { + const introspection = { + active: true, + numeric_claim: 12345, + boolean_claim: false, + string_claim: 'hello', + array_claim: ['a', 'b', 'c'] + }; + + const claims = extractPDClaims(introspection); + + expect(claims.numeric_claim).toBe(12345); + expect(claims.boolean_claim).toBe(false); + expect(claims.string_claim).toBe('hello'); + expect(claims.array_claim).toEqual(['a', 'b', 'c']); + expect(Array.isArray(claims.array_claim)).toBe(true); + }); + + test('handles null and undefined values', () => { + const introspection = { + active: true, + null_claim: null, + undefined_claim: undefined + }; + + const claims = extractPDClaims(introspection); + + expect(claims.null_claim).toBe(''); + expect(claims.undefined_claim).toBe(''); + }); + + test('returns empty object when only standard claims present', () => { + const introspection = { + active: true, + client_id: 'test', + scope: 'bgz', + iss: 'issuer', + iat: 1234567890, + exp: 1234567890 + }; + + const claims = extractPDClaims(introspection); + + expect(Object.keys(claims)).toHaveLength(0); + }); + + test('handles null input gracefully', () => { + expect(extractPDClaims(null)).toEqual({}); + }); + + test('handles undefined input gracefully', () => { + expect(extractPDClaims(undefined)).toEqual({}); + }); + + test('handles non-object input gracefully', () => { + expect(extractPDClaims('string')).toEqual({}); + expect(extractPDClaims(123)).toEqual({}); + }); + + test('converts object claims to JSON strings', () => { + const introspection = { + active: true, + complex_claim: { nested: 'value', arr: [1, 2] } + }; + + const claims = extractPDClaims(introspection); + + expect(claims.complex_claim).toBe('{"nested":"value","arr":[1,2]}'); + }); + + test('preserves array claims as arrays', () => { + const introspection = { + active: true, + roles: ['doctor', 'nurse'], + permissions: ['read', 'write', 'delete'] + }; + + const claims = extractPDClaims(introspection); + + expect(claims.roles).toEqual(['doctor', 'nurse']); + expect(Array.isArray(claims.roles)).toBe(true); + expect(claims.permissions).toEqual(['read', 'write', 'delete']); + }); + + test('filters additional OIDC standard claims', () => { + const introspection = { + active: true, + azp: 'authorized-party', + nonce: 'random-nonce', + auth_time: 1234567890, + sid: 'session-id', + at_hash: 'hash', + custom_claim: 'should-be-included' + }; + + const claims = extractPDClaims(introspection); + + // OIDC standard claims should be filtered + expect(claims.azp).toBeUndefined(); + expect(claims.nonce).toBeUndefined(); + expect(claims.auth_time).toBeUndefined(); + expect(claims.sid).toBeUndefined(); + expect(claims.at_hash).toBeUndefined(); + + // Custom claims should be included + expect(claims.custom_claim).toBe('should-be-included'); + }); }); -describe('parsePathArray', () => { - test('parses FHIR resource path', () => { - expect(parsePathArray('/fhir/Patient/patient-123')).toEqual(['fhir', 'Patient', 'patient-123']); +describe('parseScopes', () => { + test('parses space-separated scopes', () => { + expect(parseScopes('bgz eoverdracht')).toEqual(['bgz', 'eoverdracht']); }); - test('parses path without leading slash', () => { - expect(parsePathArray('fhir/Patient/123')).toEqual(['fhir', 'Patient', '123']); + test('handles single scope', () => { + expect(parseScopes('bgz')).toEqual(['bgz']); }); - test('removes query string', () => { - expect(parsePathArray('/fhir/Patient?_id=123')).toEqual(['fhir', 'Patient']); + test('handles empty string', () => { + expect(parseScopes('')).toEqual([]); }); - test('handles empty path', () => { - expect(parsePathArray('')).toEqual([]); + test('handles null/undefined', () => { + expect(parseScopes(null)).toEqual([]); + expect(parseScopes(undefined)).toEqual([]); }); - test('handles root path', () => { - expect(parsePathArray('/')).toEqual([]); + test('filters out empty segments', () => { + expect(parseScopes('bgz eoverdracht')).toEqual(['bgz', 'eoverdracht']); }); }); -describe('extractFhirContext', () => { - test('extracts resource type and ID', () => { - const result = extractFhirContext('/fhir/Patient/patient-123'); - expect(result.resourceType).toBe('Patient'); - expect(result.resourceId).toBe('patient-123'); +describe('parseQueryParams', () => { + test('parses simple query params', () => { + expect(parseQueryParams('_id=123&status=active')).toEqual({ + '_id': ['123'], + 'status': ['active'] + }); + }); + + test('handles multiple values for same key', () => { + expect(parseQueryParams('status=active&status=pending')).toEqual({ + 'status': ['active', 'pending'] + }); + }); + + test('handles empty query string', () => { + expect(parseQueryParams('')).toEqual({}); }); - test('extracts resource type without ID', () => { - const result = extractFhirContext('/fhir/Observation'); - expect(result.resourceType).toBe('Observation'); - expect(result.resourceId).toBeNull(); + test('handles null/undefined', () => { + expect(parseQueryParams(null)).toEqual({}); + expect(parseQueryParams(undefined)).toEqual({}); }); - test('handles search operations', () => { - const result = extractFhirContext('/fhir/Patient/_search'); - expect(result.resourceType).toBe('Patient'); - expect(result.resourceId).toBe('_search'); + test('decodes URL-encoded values', () => { + expect(parseQueryParams('name=John%20Doe&filter=type%3DPatient')).toEqual({ + 'name': ['John Doe'], + 'filter': ['type=Patient'] + }); }); - test('returns null for non-FHIR paths', () => { - const result = extractFhirContext('/health'); - expect(result.resourceType).toBeNull(); - expect(result.resourceId).toBeNull(); + test('throws on malformed percent-encoding', () => { + // Invalid percent-encoding should throw URIError for explicit 400 response + expect(() => parseQueryParams('patient=%ZZ&valid=ok')).toThrow(URIError); }); }); -describe('buildOpaRequest', () => { - // Save and restore process.env +describe('buildPDPRequest', () => { const originalEnv = process.env; beforeEach(() => { process.env = { ...originalEnv, DATA_HOLDER_ORGANIZATION_URA: '00000666', - DATA_HOLDER_FACILITY_TYPE: 'Z3', - REQUESTING_FACILITY_TYPE: 'Z3', - PURPOSE_OF_USE: 'treatment' + DATA_HOLDER_FACILITY_TYPE: 'Z3' }; }); @@ -122,52 +362,64 @@ describe('buildOpaRequest', () => { process.env = originalEnv; }); - test('builds complete OPA request', () => { - const tokenClaims = { - sub: 'user123', - requesting_organization_ura: '00000020', - requesting_uzi_role_code: '01.015', - requesting_practitioner_identifier: '123456789', - patient_bsn: '900186021' - }; - const fhirContext = { - resourceType: 'Patient', - resourceId: 'patient-123' + test('builds complete PDPInput structure', () => { + // Introspection claims use the exact names the PDP expects (defined by PD constraint ids) + const introspection = { + active: true, + client_id: 'did:nuts:client123', + scope: 'bgz eoverdracht', + subject_id: 'practitioner-456', + subject_role: '01.015', + subject_organization_id: '00000020', + subject_organization: 'Requesting Hospital', + subject_facility_type: 'Z3' }; const request = createMockRequest({ variables: { - request_uri: '/fhir/Patient/patient-123', + request_uri: '/fhir/Patient/patient-123?_include=Patient:organization', request_method: 'GET' } }); - const result = buildOpaRequest(tokenClaims, fhirContext, request); + const result = buildPDPRequest(introspection, request); expect(result).toEqual({ input: { - method: 'GET', - path: ['fhir', 'Patient', 'patient-123'], - requesting_organization_ura: '00000020', - requesting_uzi_role_code: '01.015', - requesting_practitioner_identifier: '123456789', - requesting_facility_type: 'Z3', - data_holder_organization_ura: '00000666', - data_holder_facility_type: 'Z3', - patient_bsn: '900186021', - resource_type: 'Patient', - resource_id: 'patient-123', - purpose_of_use: 'treatment' + subject: { + type: 'organization', + id: 'did:nuts:client123', + properties: { + client_id: 'did:nuts:client123', + client_qualifications: ['bgz', 'eoverdracht'], + subject_id: 'practitioner-456', + subject_role: '01.015', + subject_organization_id: '00000020', + subject_organization: 'Requesting Hospital', + subject_facility_type: 'Z3' + } + }, + request: { + method: 'GET', + protocol: 'HTTP/1.1', + path: '/Patient/patient-123', // /fhir/ prefix is stripped + query_params: { + '_include': ['Patient:organization'] + }, + header: {}, + body: '' + }, + context: { + data_holder_organization_id: '00000666', + data_holder_facility_type: 'Z3', + patient_bsn: '' + } } }); }); - test('handles missing optional fields', () => { - const tokenClaims = { - sub: 'user123' - }; - const fhirContext = { - resourceType: 'Observation', - resourceId: null + test('handles missing optional introspection fields', () => { + const introspection = { + active: true }; const request = createMockRequest({ variables: { @@ -176,27 +428,433 @@ describe('buildOpaRequest', () => { } }); - const result = buildOpaRequest(tokenClaims, fhirContext, request); + const result = buildPDPRequest(introspection, request); + + expect(result.input.subject.id).toBe(''); + expect(result.input.subject.properties.client_id).toBe(''); + expect(result.input.subject.properties.client_qualifications).toEqual([]); + // PD claims not in introspection are simply not included (no empty strings) + expect(result.input.subject.properties.subject_id).toBeUndefined(); + }); + + test('uses request.uri as fallback', () => { + const introspection = { active: true, client_id: 'test' }; + const request = createMockRequest({ + uri: '/fhir/Patient/123', + method: 'GET', + variables: {} + }); + + const result = buildPDPRequest(introspection, request); - expect(result.input.requesting_practitioner_identifier).toBeNull(); - expect(result.input.requesting_organization_ura).toBeNull(); - expect(result.input.patient_bsn).toBeNull(); - expect(result.input.resource_id).toBeNull(); + // /fhir/ prefix is stripped from path + expect(result.input.request.path).toBe('/Patient/123'); }); - test('prefers request.variables.request_method (NGINX canonical source) over request.method (NJS convenience property)', () => { - const tokenClaims = { sub: 'user', role: 'practitioner' }; - const fhirContext = { resourceType: 'Patient', resourceId: null }; + test('prefers request.variables over request object', () => { + const introspection = { active: true, client_id: 'test' }; const request = createMockRequest({ method: 'GET', + uri: '/fallback', + variables: { + request_uri: '/fhir/Patient', + request_method: 'POST' + } + }); + + const result = buildPDPRequest(introspection, request); + + expect(result.input.request.method).toBe('POST'); + // /fhir/ prefix is stripped from path + expect(result.input.request.path).toBe('/Patient'); + }); + + test('passes request body for POST search', () => { + const introspection = { active: true, client_id: 'test' }; + const searchBody = 'patient=Patient/123&_include=Observation:subject'; + const request = createMockRequest({ + uri: '/fhir/Observation/_search', + method: 'POST', + requestText: searchBody + }); + + const result = buildPDPRequest(introspection, request); + + expect(result.input.request.method).toBe('POST'); + expect(result.input.request.path).toBe('/Observation/_search'); + expect(result.input.request.body).toBe(searchBody); + }); +}); + +describe('validateDPoP', () => { + test('returns valid when no cnf claim present', async () => { + const request = createMockRequest(); + const introspection = { active: true }; + + const result = await validateDPoP(request, introspection); + + expect(result.valid).toBe(true); + }); + + test('returns valid when cnf has no jkt', async () => { + const request = createMockRequest(); + const introspection = { active: true, cnf: {} }; + + const result = await validateDPoP(request, introspection); + + expect(result.valid).toBe(true); + }); + + test('returns invalid when cnf.jkt present but DPoP header missing', async () => { + const request = createMockRequest({ + headersIn: { 'Authorization': 'DPoP token123' } + }); + const introspection = { + active: true, + cnf: { jkt: 'thumbprint123' } + }; + + const result = await validateDPoP(request, introspection); + + expect(result.valid).toBe(false); + expect(result.reason).toBe('DPoP header required but missing'); + }); + + test('calls Nuts node validation endpoint with correct payload', async () => { + const mockSubrequest = jest.fn().mockResolvedValue( + createMockSubrequestResponse(200, { valid: true }) + ); + const request = createMockRequest({ + headersIn: { + 'Authorization': 'DPoP token123', + 'DPoP': 'dpop-proof-jwt', + 'Host': 'example.com' + }, + variables: { + request_uri: '/fhir/Patient/123', + request_method: 'GET' + }, + subrequest: mockSubrequest + }); + const introspection = { + active: true, + cnf: { jkt: 'thumbprint123' } + }; + + const result = await validateDPoP(request, introspection); + + expect(result.valid).toBe(true); + expect(mockSubrequest).toHaveBeenCalledWith('/_dpop_validate', { + method: 'POST', + body: JSON.stringify({ + dpop_proof: 'dpop-proof-jwt', + method: 'GET', + thumbprint: 'thumbprint123', + token: 'token123', + url: 'https://example.com/fhir/Patient/123' + }) + }); + }); + + test('returns invalid when validation endpoint returns non-200', async () => { + const mockSubrequest = jest.fn().mockResolvedValue( + createMockSubrequestResponse(400, 'invalid_dpop') + ); + const request = createMockRequest({ + headersIn: { + 'Authorization': 'DPoP token123', + 'DPoP': 'dpop-proof-jwt', + 'Host': 'example.com' + }, + variables: { + request_uri: '/fhir/Patient', + request_method: 'GET' + }, + subrequest: mockSubrequest + }); + const introspection = { + active: true, + cnf: { jkt: 'thumbprint123' } + }; + + const result = await validateDPoP(request, introspection); + + expect(result.valid).toBe(false); + expect(result.reason).toContain('DPoP validation returned 400'); + }); + + test('returns invalid when validation response has valid: false', async () => { + const mockSubrequest = jest.fn().mockResolvedValue( + createMockSubrequestResponse(200, { valid: false, reason: 'expired' }) + ); + const request = createMockRequest({ + headersIn: { + 'Authorization': 'DPoP token123', + 'DPoP': 'dpop-proof-jwt', + 'Host': 'example.com' + }, + variables: { + request_uri: '/fhir/Patient', + request_method: 'GET' + }, + subrequest: mockSubrequest + }); + const introspection = { + active: true, + cnf: { jkt: 'thumbprint123' } + }; + + const result = await validateDPoP(request, introspection); + + expect(result.valid).toBe(false); + expect(result.reason).toBe('expired'); + }); + + test('handles subrequest errors gracefully', async () => { + const mockSubrequest = jest.fn().mockRejectedValue(new Error('Network error')); + const request = createMockRequest({ + headersIn: { + 'Authorization': 'DPoP token123', + 'DPoP': 'dpop-proof-jwt', + 'Host': 'example.com' + }, variables: { request_uri: '/fhir/Patient', - request_method: 'POST' // Should override request.method + request_method: 'GET' + }, + subrequest: mockSubrequest + }); + const introspection = { + active: true, + cnf: { jkt: 'thumbprint123' } + }; + + const result = await validateDPoP(request, introspection); + + expect(result.valid).toBe(false); + expect(result.reason).toContain('DPoP validation error'); + }); +}); + +describe('checkAuthorization integration', () => { + const { checkAuthorization } = authorize; + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + DATA_HOLDER_ORGANIZATION_URA: '00000666', + DATA_HOLDER_FACILITY_TYPE: 'Z3', + NUTS_NODE_HOST: 'nuts-node', + NUTS_NODE_INTERNAL_PORT: '8081', + KNOOPPUNT_PDP_HOST: 'knooppunt', + KNOOPPUNT_PDP_PORT: '8081' + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test('returns 401 when no Authorization header', async () => { + const request = createMockRequest(); + + await checkAuthorization(request); + + expect(request.return).toHaveBeenCalledWith(401); + expect(request.error).toHaveBeenCalledWith('Missing or invalid Authorization header'); + }); + + test('returns 401 when token introspection returns inactive', async () => { + const mockSubrequest = jest.fn().mockResolvedValue( + createMockSubrequestResponse(200, { active: false }) + ); + const request = createMockRequest({ + headersIn: { 'Authorization': 'Bearer test-token' }, + subrequest: mockSubrequest + }); + + await checkAuthorization(request); + + expect(request.return).toHaveBeenCalledWith(401); + expect(request.error).toHaveBeenCalledWith('Token is not active'); + }); + + test('returns 502 when introspection fails', async () => { + const mockSubrequest = jest.fn().mockResolvedValue( + createMockSubrequestResponse(500, {}) + ); + const request = createMockRequest({ + headersIn: { 'Authorization': 'Bearer test-token' }, + subrequest: mockSubrequest + }); + + await checkAuthorization(request); + + expect(request.return).toHaveBeenCalledWith(502); + }); + + test('returns 200 when PDP allows', async () => { + const mockSubrequest = jest.fn() + // First call: introspection + .mockResolvedValueOnce(createMockSubrequestResponse(200, { + active: true, + client_id: 'did:nuts:test', + scope: 'bgz' + })) + // Second call: PDP + .mockResolvedValueOnce(createMockSubrequestResponse(200, { + result: { allow: true } + })); + const request = createMockRequest({ + headersIn: { 'Authorization': 'Bearer test-token' }, + variables: { + request_uri: '/fhir/Patient/123', + request_method: 'GET' + }, + subrequest: mockSubrequest + }); + + await checkAuthorization(request); + + expect(request.return).toHaveBeenCalledWith(200); + expect(request.log).toHaveBeenCalledWith('Access ALLOWED by PDP'); + }); + + test('returns 403 when PDP denies', async () => { + const mockSubrequest = jest.fn() + // First call: introspection + .mockResolvedValueOnce(createMockSubrequestResponse(200, { + active: true, + client_id: 'did:nuts:test', + scope: 'bgz' + })) + // Second call: PDP + .mockResolvedValueOnce(createMockSubrequestResponse(200, { + result: { + allow: false, + reasons: [{ code: 'not_allowed', description: 'No consent' }] + } + })); + const request = createMockRequest({ + headersIn: { 'Authorization': 'Bearer test-token' }, + variables: { + request_uri: '/fhir/Patient/123', + request_method: 'GET' + }, + subrequest: mockSubrequest + }); + + await checkAuthorization(request); + + expect(request.return).toHaveBeenCalledWith(403); + expect(request.warn).toHaveBeenCalled(); + }); + + test('returns 502 when PDP response is malformed', async () => { + const mockSubrequest = jest.fn() + .mockResolvedValueOnce(createMockSubrequestResponse(200, { + active: true, + client_id: 'did:nuts:test', + scope: 'bgz' + })) + // PDP returns malformed response (allow is string instead of boolean) + .mockResolvedValueOnce(createMockSubrequestResponse(200, { + result: { allow: 'true' } + })); + const request = createMockRequest({ + headersIn: { 'Authorization': 'Bearer test-token' }, + variables: { + request_uri: '/fhir/Patient/123', + request_method: 'GET' + }, + subrequest: mockSubrequest + }); + + await checkAuthorization(request); + + expect(request.return).toHaveBeenCalledWith(502); + expect(request.error).toHaveBeenCalledWith('Malformed PDP response: missing result.allow boolean'); + }); + + test('returns 401 when DPoP validation fails', async () => { + const mockSubrequest = jest.fn() + // First call: introspection (token has cnf.jkt) + .mockResolvedValueOnce(createMockSubrequestResponse(200, { + active: true, + client_id: 'did:nuts:test', + cnf: { jkt: 'thumbprint123' } + })) + // Second call: DPoP validation fails + .mockResolvedValueOnce(createMockSubrequestResponse(200, { + valid: false, + reason: 'invalid signature' + })); + const request = createMockRequest({ + headersIn: { + 'Authorization': 'DPoP test-token', + 'DPoP': 'dpop-proof', + 'Host': 'example.com' + }, + variables: { + request_uri: '/fhir/Patient/123', + request_method: 'GET' + }, + subrequest: mockSubrequest + }); + + await checkAuthorization(request); + + expect(request.return).toHaveBeenCalledWith(401); + expect(request.error).toHaveBeenCalledWith('DPoP validation failed: invalid signature'); + }); + + test('returns 401 when DPoP scheme used without DPoP header (RFC 9449)', async () => { + // RFC 9449: If using DPoP authorization scheme, DPoP header is required + const request = createMockRequest({ + headersIn: { + 'Authorization': 'DPoP test-token' + // Note: No 'DPoP' header } }); - const result = buildOpaRequest(tokenClaims, fhirContext, request); + await checkAuthorization(request); + + expect(request.return).toHaveBeenCalledWith(401); + expect(request.error).toHaveBeenCalledWith('DPoP authorization scheme requires DPoP header'); + }); + + test('blocks DPoP-bound token presented as Bearer (attack scenario)', async () => { + // Security model test: An attacker who steals a DPoP-bound token + // cannot use it by simply changing Authorization scheme to Bearer. + // The token's cnf.jkt binding is intrinsic and revealed by introspection. + + const mockSubrequest = jest.fn() + // Introspection returns cnf.jkt even when token presented as Bearer + .mockResolvedValueOnce(createMockSubrequestResponse(200, { + active: true, + client_id: 'did:nuts:victim', + scope: 'bgz', + cnf: { jkt: 'victim-key-thumbprint' } // Token is DPoP-bound + })); + + // Attacker presents stolen token as Bearer (no DPoP header) + const request = createMockRequest({ + headersIn: { + 'Authorization': 'Bearer stolen-dpop-bound-token' + // Note: No 'DPoP' header - attacker doesn't have victim's private key + }, + variables: { + request_uri: '/fhir/Patient/123', + request_method: 'GET' + }, + subrequest: mockSubrequest + }); + + await checkAuthorization(request); - expect(result.input.method).toBe('POST'); + // Request MUST be blocked - DPoP proof required for tokens with cnf.jkt + expect(request.return).toHaveBeenCalledWith(401); + expect(request.error).toHaveBeenCalledWith('DPoP validation failed: DPoP header required but missing'); }); }); diff --git a/pep/nginx/nginx.conf b/pep/nginx/nginx.conf index e1b5b056..b01af1ca 100644 --- a/pep/nginx/nginx.conf +++ b/pep/nginx/nginx.conf @@ -15,10 +15,19 @@ load_module modules/ngx_http_js_module.so; # Environment variables used in NJS JavaScript code (via process.env): # These MUST be declared with 'env' directive to be accessible in authorize.js + +# Nuts node connection (for introspection) +env NUTS_NODE_HOST; +env NUTS_NODE_INTERNAL_PORT; + +# Knooppunt PDP connection +env KNOOPPUNT_PDP_HOST; +env KNOOPPUNT_PDP_PORT; + +# Data holder (this organization) env DATA_HOLDER_ORGANIZATION_URA; env DATA_HOLDER_FACILITY_TYPE; -env REQUESTING_FACILITY_TYPE; -env PURPOSE_OF_USE; + events { worker_connections 1024; diff --git a/test/e2e/harness/entrypoint.go b/test/e2e/harness/entrypoint.go index 53879a78..5169824c 100644 --- a/test/e2e/harness/entrypoint.go +++ b/test/e2e/harness/entrypoint.go @@ -3,12 +3,14 @@ package harness import ( "net/url" "os" + "path/filepath" "testing" "github.com/nuts-foundation/nuts-knooppunt/cmd" "github.com/nuts-foundation/nuts-knooppunt/component/http" "github.com/nuts-foundation/nuts-knooppunt/component/mcsd" "github.com/nuts-foundation/nuts-knooppunt/component/mitz" + "github.com/nuts-foundation/nuts-knooppunt/component/nutsnode" "github.com/nuts-foundation/nuts-knooppunt/component/nvi" "github.com/nuts-foundation/nuts-knooppunt/component/pdp" "github.com/nuts-foundation/nuts-knooppunt/test/mitzmock" @@ -35,13 +37,20 @@ type MITZDetails struct { MockMITZ *mitzmock.SubscriptionService } +type PEPTestConfig struct { + CertsDir string // Path to directory containing CA cert + TestDataDir string // Path to directory containing accesspolicy.json and discovery.json +} + type PEPDetails struct { - KnooppuntPDPBaseURL *url.URL - HAPIBaseURL *url.URL - PEPBaseURL *url.URL - MockMitzXACML *mitzmock.ClosedQuestionService + KnooppuntURL *url.URL // Internal interface URL (for PDP, etc.) + NutsPublicURL *url.URL // Public interface URL for Nuts APIs (for OAuth authServer) + HAPIBaseURL *url.URL // HAPI FHIR base URL + NutsAPI func(path string) string // Helper to build internal Nuts API URLs + MockMitz *mitzmock.ClosedQuestionService } + // Start starts the full test harness with all components (MCSD, NVI, MITZ). func Start(t *testing.T) Details { t.Helper() @@ -124,21 +133,30 @@ func StartMITZ(t *testing.T) MITZDetails { } } -// StartPEP starts a minimal harness for PEP e2e tests with HAPI, Knooppunt PDP, mock XACML Mitz, and PEP nginx. -func StartPEP(t *testing.T, pepConfig PEPConfig) PEPDetails { +// StartPEP starts a harness for PEP e2e tests with embedded Nuts node, PDP, and mock MITZ. +func StartPEP(t *testing.T, config PEPTestConfig) PEPDetails { t.Helper() + // Set up Nuts node environment variables + setupNutsEnvironment(t, config.TestDataDir, filepath.Join(config.CertsDir, "ca.pem")) + // Create mock XACML Mitz server mockMitz := mitzmock.NewClosedQuestionService(t) - // Start HAPI FHIR server - hapiBaseURL := startHAPI(t, "") + // Start HAPI FHIR server (shared helper from hapi.go) + dockerNetwork, err := createDockerNetwork(t) + require.NoError(t, err) + hapiBaseURL := startHAPI(t, dockerNetwork.Name) - // Start Knooppunt with PDP and MITZ enabled - knooppuntPDPURL := startKnooppunt(t, cmd.Config{ + // Start Knooppunt with embedded Nuts node and PDP + knooppuntURL := startKnooppunt(t, cmd.Config{ HTTP: http.TestConfig(), + Nuts: nutsnode.Config{Enabled: true}, PDP: pdp.Config{ Enabled: true, + PIP: pdp.PIPConfig{ + URL: hapiBaseURL.String() + "/DEFAULT", + }, }, MITZ: mitz.Config{ MitzBase: mockMitz.GetURL(), @@ -147,19 +165,50 @@ func StartPEP(t *testing.T, pepConfig PEPConfig) PEPDetails { }, }) - // Configure PEP to point to HAPI and Knooppunt - pepConfig.FHIRBackendHost = "host.docker.internal" - pepConfig.FHIRBackendPort = hapiBaseURL.Port() - pepConfig.KnooppuntPDPHost = "host.docker.internal" - pepConfig.KnooppuntPDPPort = knooppuntPDPURL.Port() - - // Start PEP container - pepBaseURL := startPEP(t, pepConfig) + // The public Nuts URL is on port 8080 (from http.TestConfig().PublicInterface) + nutsPublicURL, _ := url.Parse("http://localhost:8080/nuts") return PEPDetails{ - KnooppuntPDPBaseURL: knooppuntPDPURL, - HAPIBaseURL: hapiBaseURL, - PEPBaseURL: pepBaseURL, - MockMitzXACML: mockMitz, + KnooppuntURL: knooppuntURL, + NutsPublicURL: nutsPublicURL, + HAPIBaseURL: hapiBaseURL, + NutsAPI: func(path string) string { + return knooppuntURL.JoinPath("/nuts", path).String() + }, + MockMitz: mockMitz, } } + +// setupNutsEnvironment configures environment variables for the embedded Nuts node. +func setupNutsEnvironment(t *testing.T, testdataDir, caPath string) { + t.Helper() + + // Create temp directories for Nuts node configuration + tempDir := t.TempDir() + policyDir := filepath.Join(tempDir, "policies") + discoveryDir := filepath.Join(tempDir, "discovery") + require.NoError(t, os.MkdirAll(policyDir, 0755)) + require.NoError(t, os.MkdirAll(discoveryDir, 0755)) + + // Copy policy file + policyData, err := os.ReadFile(filepath.Join(testdataDir, "accesspolicy.json")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(policyDir, "accesspolicy.json"), policyData, 0644)) + + // Copy discovery definition (must be named .json) + discoveryData, err := os.ReadFile(filepath.Join(testdataDir, "discovery.json")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(discoveryDir, "bgz-test.json"), discoveryData, 0644)) + + // Set Nuts node environment variables + os.Setenv("NUTS_URL", "http://localhost:8080/nuts") + os.Setenv("NUTS_AUTH_CONTRACTVALIDATORS", "dummy") + os.Setenv("NUTS_POLICY_DIRECTORY", policyDir) + os.Setenv("NUTS_DISCOVERY_DEFINITIONS_DIRECTORY", discoveryDir) + os.Setenv("NUTS_DISCOVERY_SERVER_IDS", "bgz-test") + os.Setenv("NUTS_VDR_DIDMETHODS", "web") + os.Setenv("NUTS_INTERNALRATELIMITER", "false") + os.Setenv("NUTS_NETWORK_ENABLEDISCOVERY", "false") + os.Setenv("SSL_CERT_FILE", caPath) +} + diff --git a/test/e2e/harness/knooppunt.go b/test/e2e/harness/knooppunt.go index 36ca640b..69de7889 100644 --- a/test/e2e/harness/knooppunt.go +++ b/test/e2e/harness/knooppunt.go @@ -3,15 +3,25 @@ package harness import ( "net/http" "net/url" + "os" "testing" "github.com/nuts-foundation/nuts-knooppunt/cmd" "github.com/nuts-foundation/nuts-knooppunt/test" ) +// startKnooppunt starts Knooppunt with the given config and waits for it to be ready. +// If Nuts is enabled in the config, it also waits for the embedded Nuts node to be ready. func startKnooppunt(t *testing.T, config cmd.Config) *url.URL { t.Helper() + // Clean the hardcoded Nuts data directory if Nuts is enabled + if config.Nuts.Enabled { + if err := os.RemoveAll("data/nuts"); err != nil && !os.IsNotExist(err) { + t.Logf("Warning: failed to clean up data/nuts: %v", err) + } + } + var errChan = make(chan error, 1) go func() { if err := cmd.Start(t.Context(), config); err != nil { @@ -29,5 +39,17 @@ func startKnooppunt(t *testing.T, config cmd.Config) *url.URL { case err := <-timeoutChan: t.Fatalf("timeout waiting for knooppunt to start: %v", err) } + + // If Nuts is enabled, also wait for the embedded Nuts node to be ready + if config.Nuts.Enabled { + nutsDoneChan, nutsTimeoutChan := test.WaitForHTTPStatus(baseURL.JoinPath("/nuts/status").String(), http.StatusOK) + select { + case <-nutsDoneChan: + t.Log("Embedded Nuts node ready") + case err := <-nutsTimeoutChan: + t.Fatalf("timeout waiting for embedded Nuts node: %v", err) + } + } + return baseURL } diff --git a/test/e2e/harness/pep.go b/test/e2e/harness/pep.go index f647f65d..ca9a33d1 100644 --- a/test/e2e/harness/pep.go +++ b/test/e2e/harness/pep.go @@ -13,41 +13,54 @@ import ( type PEPConfig struct { FHIRBackendHost string FHIRBackendPort string - FHIRBasePath string // e.g. "/fhir" or "/fhir/DEFAULT" + FHIRBasePath string // incoming path clients use, e.g. "/fhir" + FHIRUpstreamPath string // path on backend FHIR server, e.g. "/fhir/DEFAULT" (defaults to FHIRBasePath) KnooppuntPDPHost string KnooppuntPDPPort string + NutsNodeHost string + NutsNodePort string DataHolderOrganizationURA string DataHolderFacilityType string - RequestingFacilityType string - PurposeOfUse string + PEPHostname string // expected hostname for DPoP validation (prevents Host header spoofing) } -func startPEP(t *testing.T, config PEPConfig) *url.URL { +// PEPContainerResult contains the PEP URL and container for additional operations +type PEPContainerResult struct { + URL *url.URL + Container testcontainers.Container +} + +// StartPEPContainer starts the PEP container and returns the URL and container. +func StartPEPContainer(t *testing.T, config PEPConfig) PEPContainerResult { t.Helper() ctx := t.Context() + env := map[string]string{ + "FHIR_BACKEND_HOST": config.FHIRBackendHost, + "FHIR_BACKEND_PORT": config.FHIRBackendPort, + "FHIR_BASE_PATH": config.FHIRBasePath, + "FHIR_UPSTREAM_PATH": config.FHIRUpstreamPath, + "KNOOPPUNT_PDP_HOST": config.KnooppuntPDPHost, + "KNOOPPUNT_PDP_PORT": config.KnooppuntPDPPort, + "NUTS_NODE_HOST": config.NutsNodeHost, + "NUTS_NODE_INTERNAL_PORT": config.NutsNodePort, + "DATA_HOLDER_ORGANIZATION_URA": config.DataHolderOrganizationURA, + "DATA_HOLDER_FACILITY_TYPE": config.DataHolderFacilityType, + "PEP_HOSTNAME": config.PEPHostname, + } + pepReq := testcontainers.ContainerRequest{ FromDockerfile: testcontainers.FromDockerfile{ Context: "../../../pep/nginx", Dockerfile: "Dockerfile", }, ExposedPorts: []string{"8080/tcp"}, - Env: map[string]string{ - "FHIR_BACKEND_HOST": config.FHIRBackendHost, - "FHIR_BACKEND_PORT": config.FHIRBackendPort, - "FHIR_BASE_PATH": config.FHIRBasePath, - "KNOOPPUNT_PDP_HOST": config.KnooppuntPDPHost, - "KNOOPPUNT_PDP_PORT": config.KnooppuntPDPPort, - "DATA_HOLDER_ORGANIZATION_URA": config.DataHolderOrganizationURA, - "DATA_HOLDER_FACILITY_TYPE": config.DataHolderFacilityType, - "REQUESTING_FACILITY_TYPE": config.RequestingFacilityType, - "PURPOSE_OF_USE": config.PurposeOfUse, - }, - WaitingFor: wait.ForHTTP("/health").WithPort("8080"), + Env: env, + WaitingFor: wait.ForHTTP("/health").WithPort("8080"), HostConfigModifier: func(hostConfig *container.HostConfig) { - // Map host.docker.internal to host gateway for Linux (GitHub Actions) - // On macOS/Windows (Docker Desktop), host.docker.internal already exists - // On Linux, this maps it to the bridge gateway IP automatically + // Map host.docker.internal to host gateway + // On macOS/Windows (Docker Desktop), this is already available + // On Linux, this adds it to /etc/hosts which Docker's DNS can resolve hostConfig.ExtraHosts = []string{"host.docker.internal:host-gateway"} }, } @@ -70,5 +83,5 @@ func startPEP(t *testing.T, config PEPConfig) *url.URL { Scheme: "http", Host: host + ":" + mappedPort.Port(), } - return u + return PEPContainerResult{URL: u, Container: pepContainer} } diff --git a/test/e2e/pdp/bgz_test.go b/test/e2e/pdp/bgz_test.go index da50faed..dbd5374a 100644 --- a/test/e2e/pdp/bgz_test.go +++ b/test/e2e/pdp/bgz_test.go @@ -37,16 +37,14 @@ func Test_BGZAuthorization(t *testing.T) { "protocol": "HTTP/1.0", "path": "/Patient", "query_params": { - "_include": ["Patient:general-practitioner"] - }, - "header": { - "Content-Type": ["application/fhir+json"] - } + "_include": ["Patient:general-practitioner"], + "_id": ["3E439979-017F-40AA-594D-EBCF880FFD97"] + } }, "context": { "data_holder_organization_id": "00000659", "data_holder_facility_type": "Z3", - "patient_bsn": "1234567890" + "patient_bsn": "" } } }` diff --git a/test/e2e/pep/authorization_test.go b/test/e2e/pep/authorization_test.go index 29a23b2c..0f0e91f7 100644 --- a/test/e2e/pep/authorization_test.go +++ b/test/e2e/pep/authorization_test.go @@ -1,8 +1,14 @@ package pep import ( + "bytes" + "encoding/json" "io" "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" "strings" "testing" @@ -11,112 +17,419 @@ import ( "github.com/stretchr/testify/require" ) +// Test_PEPAuthorization tests the PEP authorization flow with real Nuts node +// credential validation. +// +// This test validates the FULL credential flow: +// - X509Credential issued via go-didx509-toolkit from test certificates +// - NutsEmployeeCredential and HealthcareProviderRoleTypeCredential (self-attested) +// - Real Presentation Definition validation +// - Real token introspection with extracted claims +// - PEP authorization through Knooppunt PDP +// - Mitz consent checking (mocked) func Test_PEPAuthorization(t *testing.T) { + if testing.Short() { + t.Skip("Skipping e2e test in short mode") + } - // Start the PEP harness (HAPI, Knooppunt PDP, mock Mitz XACML, and PEP nginx) - harnessDetail := harness.StartPEP(t, harness.PEPConfig{ - FHIRBasePath: "/fhir/DEFAULT", // Use partitioned HAPI from harness - DataHolderOrganizationURA: "00000666", + // Check if Docker is available + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("Docker not available, skipping e2e test") + } + + // Get absolute paths to test resources + certsDir, err := filepath.Abs("certs") + require.NoError(t, err) + testdataDir, err := filepath.Abs("testdata") + require.NoError(t, err) + + // Verify certificate files exist + chainPath := filepath.Join(certsDir, "requester-chain.pem") + keyPath := filepath.Join(certsDir, "requester.key") + if _, err := os.Stat(chainPath); os.IsNotExist(err) { + t.Fatalf("Certificate chain not found. Run: cd certs && ./generate-root-ca.sh && ./issue-cert.sh requester 'Test Hospital B.V.' Amsterdam 0 87654321 0") + } + + // Start the PEP test harness (Knooppunt with Nuts + PDP + mock MITZ + HAPI) + pep := harness.StartPEP(t, harness.PEPTestConfig{ + CertsDir: certsDir, + TestDataDir: testdataDir, + }) + t.Logf("Knooppunt started at: %s (with embedded Nuts node at /nuts)", pep.KnooppuntURL) + + // Create a test patient in HAPI + createTestPatient(t, pep.HAPIBaseURL) + + // Create subject (DID) in embedded Nuts node + subjectName := "requester" + subjectDID := createSubject(t, pep.NutsAPI, subjectName) + t.Logf("Created subject DID: %s", subjectDID) + + // Issue X509Credential using go-didx509-toolkit + x509Credential := issueX509Credential(t, chainPath, keyPath, subjectDID) + t.Logf("X509Credential issued (first 100 chars): %s...", x509Credential[:min(100, len(x509Credential))]) + + // Store X509Credential in wallet + storeCredential(t, pep.NutsAPI, subjectName, x509Credential) + t.Log("X509Credential stored in wallet") + + // Register on Discovery Service (before requesting token) + registerOnDiscovery(t, pep.NutsAPI, subjectName) + t.Log("Registered on Discovery Service") + + // Start PEP container pointing to Knooppunt (which provides both PDP and Nuts APIs) + pepConfig := harness.PEPConfig{ + FHIRBackendHost: "host.docker.internal", + FHIRBackendPort: pep.HAPIBaseURL.Port(), + FHIRBasePath: "/fhir", // incoming path clients use + FHIRUpstreamPath: "/fhir/DEFAULT", // HAPI multi-tenant partition + KnooppuntPDPHost: "host.docker.internal", + KnooppuntPDPPort: pep.KnooppuntURL.Port(), + NutsNodeHost: "host.docker.internal", + NutsNodePort: pep.KnooppuntURL.Port(), // Nuts APIs are at /nuts on Knooppunt + DataHolderOrganizationURA: "12345678", DataHolderFacilityType: "Z3", - RequestingFacilityType: "Z3", - PurposeOfUse: "treatment", + } + pepResult := harness.StartPEPContainer(t, pepConfig) + pepBaseURL := pepResult.URL + // Add cleanup to print logs on failure + t.Cleanup(func() { + logs, _ := pepResult.Container.Logs(t.Context()) + if logs != nil { + logBytes, _ := io.ReadAll(logs) + t.Logf("PEP container logs:\n%s", string(logBytes)) + logs.Close() + } }) + t.Logf("PEP started at: %s", pepBaseURL) - pepBaseURL := harnessDetail.PEPBaseURL - hapiBaseURL := harnessDetail.HAPIBaseURL - mockMitz := harnessDetail.MockMitzXACML + // Request both Bearer and DPoP tokens to test both paths + // The authorization server URL uses the public Nuts URL (the node talks to itself) + authServer := pep.NutsPublicURL.JoinPath("oauth2", subjectName).String() - // Create a test patient in HAPI directly (bypassing PEP) for testing - patientJSON := `{ - "resourceType": "Patient", - "id": "patient-123", - "identifier": [{ - "system": "http://fhir.nl/fhir/NamingSystem/bsn", - "value": "900186021" - }], - "name": [{ - "family": "Test", - "given": ["Patient"] - }] - }` - createReq, err := http.NewRequest("PUT", hapiBaseURL.JoinPath("DEFAULT", "Patient", "patient-123").String(), strings.NewReader(patientJSON)) - require.NoError(t, err) - createReq.Header.Set("Content-Type", "application/fhir+json") - createResp, err := http.DefaultClient.Do(createReq) - require.NoError(t, err) - createResp.Body.Close() - require.Contains(t, []int{http.StatusOK, http.StatusCreated}, createResp.StatusCode, - "Failed to create test patient in HAPI: %d", createResp.StatusCode) + // Get Bearer token for some tests + bearerToken := requestAccessToken(t, pep.NutsAPI, subjectName, authServer, "bgz", "Bearer") + t.Logf("Bearer token obtained: %s...", bearerToken.AccessToken[:min(50, len(bearerToken.AccessToken))]) + assert.Equal(t, "Bearer", bearerToken.TokenType) + + // Get DPoP token for DPoP tests + dpopToken := requestAccessToken(t, pep.NutsAPI, subjectName, authServer, "bgz", "DPoP") + t.Logf("DPoP token obtained: %s...", dpopToken.AccessToken[:min(50, len(dpopToken.AccessToken))]) + t.Logf("DPoP key ID: %s", dpopToken.DPoPKID) + assert.Equal(t, "DPoP", dpopToken.TokenType) + assert.NotEmpty(t, dpopToken.DPoPKID, "DPoP token should have dpop_kid") + + // Introspect DPoP token to verify claims are extracted + introspection := introspectToken(t, pep.NutsAPI, dpopToken.AccessToken) + t.Logf("Introspection response: %+v", introspection) - t.Run("authorized request with valid token and consent", func(t *testing.T) { - t.Skip("Skipping this test because they fail in main branch as well; fix in another branch") - // Mock token format: bearer---- - token := "bearer-00000020-01.015-123456789-900186021" + // Verify the introspection contains expected claims (using exact PDP field names from PD) + assert.True(t, introspection["active"].(bool), "Token should be active") + // From X509Credential + assert.NotEmpty(t, introspection["subject_organization_id"], "subject_organization_id claim should be present") + assert.NotEmpty(t, introspection["subject_organization"], "subject_organization claim should be present") + // From NutsEmployeeCredential + assert.NotEmpty(t, introspection["subject_id"], "subject_id claim should be present") + assert.NotEmpty(t, introspection["subject_role"], "subject_role claim should be present") + // From HealthcareProviderRoleTypeCredential + assert.NotEmpty(t, introspection["subject_facility_type"], "subject_facility_type claim should be present") - // Note: mock defaults to "Permit", no need to set explicitly + // Verify DPoP token has cnf claim (proof-of-possession binding) + assert.NotNil(t, introspection["cnf"], "DPoP token should have cnf claim") - // Make request to PEP + t.Run("unauthorized request without token", func(t *testing.T) { req, err := http.NewRequest("GET", pepBaseURL.JoinPath("fhir", "Patient", "patient-123").String(), nil) require.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() - // Log response for debugging - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body)) - - // Should be allowed - expect 200 since we created the patient - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected successful authorization and FHIR request") - - // Verify XACML request contains expected fields - lastReq := mockMitz.GetLastRequestXML() - assert.Contains(t, lastReq, "900186021", "Request should contain patient BSN") - assert.Contains(t, lastReq, "00000020", "Request should contain requesting organization URA") - assert.Contains(t, lastReq, "00000666", "Request should contain data holder organization URA") + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) - t.Run("denied request when consent is denied", func(t *testing.T) { - t.Skip("Skipping this test because they fail in main branch as well; fix in another branch") - token := "bearer-00000020-01.015-123456789-900186021" - - // Mock Mitz will respond with Deny - mockMitz.SetResponse("Deny", "No consent found") + t.Run("authorized request with Bearer token", func(t *testing.T) { + pep.MockMitz.SetResponse("Permit", "Consent granted") - req, err := http.NewRequest("GET", pepBaseURL.JoinPath("fhir", "Patient", "patient-456").String(), nil) + targetURL := pepBaseURL.JoinPath("fhir", "Condition").String() + "?patient=Patient/patient-123" + req, err := http.NewRequest("GET", targetURL, nil) require.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+token) + + req.Header.Set("Authorization", "Bearer "+bearerToken.AccessToken) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() - assert.Equal(t, http.StatusForbidden, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected successful authorization with Bearer token") + assert.Contains(t, string(body), "Bundle") }) - t.Run("unauthorized request without token", func(t *testing.T) { - req, err := http.NewRequest("GET", pepBaseURL.JoinPath("fhir", "Patient", "patient-789").String(), nil) + t.Run("authorized request with DPoP token", func(t *testing.T) { + pep.MockMitz.SetResponse("Permit", "Consent granted") + + // The PEP constructs the DPoP validation URL with https:// scheme (assuming TLS in production) + // So the proof must use https:// even though we're testing over http:// + targetPath := "/fhir/Condition?patient=Patient/patient-123" + dpopURL := "https://" + pepBaseURL.Host + targetPath + t.Logf("DPoP proof URL: %s", dpopURL) + + // Create DPoP proof using embedded Nuts node (with the same key that bound the token) + dpopProof := createDPoPProof(t, pep.NutsAPI, dpopToken.DPoPKID, "GET", dpopURL, dpopToken.AccessToken) + t.Logf("DPoP proof created: %s...", dpopProof[:min(50, len(dpopProof))]) + + targetURL := pepBaseURL.JoinPath("fhir", "Condition").String() + "?patient=Patient/patient-123" + req, err := http.NewRequest("GET", targetURL, nil) require.NoError(t, err) + // Use DPoP authorization scheme with proof header + req.Header.Set("Authorization", "DPoP "+dpopToken.AccessToken) + req.Header.Set("DPoP", dpopProof) + resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected successful authorization with DPoP token: %s", string(body)) + assert.Contains(t, string(body), "Bundle") }) - t.Run("unauthorized request with invalid token format", func(t *testing.T) { - req, err := http.NewRequest("GET", pepBaseURL.JoinPath("fhir", "Patient", "patient-999").String(), nil) + t.Run("denied request when consent is denied", func(t *testing.T) { + pep.MockMitz.SetResponse("Deny", "No consent found") + + targetURL := pepBaseURL.JoinPath("fhir", "Condition").String() + "?patient=Patient/patient-123" + req, err := http.NewRequest("GET", targetURL, nil) require.NoError(t, err) - req.Header.Set("Authorization", "Bearer invalid-token") + + req.Header.Set("Authorization", "Bearer "+bearerToken.AccessToken) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) }) } + +func createSubject(t *testing.T, nutsAPI func(string) string, subject string) string { + t.Helper() + reqBody := map[string]string{"subject": subject} + body, _ := json.Marshal(reqBody) + + resp, err := http.Post( + nutsAPI("/internal/vdr/v2/subject"), + "application/json", + bytes.NewReader(body), + ) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode, "Failed to create subject: %s", string(respBody)) + + var result map[string]any + require.NoError(t, json.Unmarshal(respBody, &result)) + + documents := result["documents"].([]any) + doc := documents[0].(map[string]any) + return doc["id"].(string) +} + +func issueX509Credential(t *testing.T, chainPath, keyPath, subjectDID string) string { + t.Helper() + + cmd := exec.Command("docker", "run", "--rm", + "-v", chainPath+":/cert-chain.pem:ro", + "-v", keyPath+":/cert-key.key:ro", + "nutsfoundation/go-didx509-toolkit:main", + "vc", "/cert-chain.pem", "/cert-key.key", "CN=Fake UZI Root CA", subjectDID, + ) + + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + t.Fatalf("go-didx509-toolkit failed: %s\nstderr: %s", err, string(exitErr.Stderr)) + } + t.Fatalf("go-didx509-toolkit failed: %s", err) + } + + return strings.TrimSpace(string(output)) +} + +func storeCredential(t *testing.T, nutsAPI func(string) string, holder, credential string) { + t.Helper() + + body := []byte(`"` + credential + `"`) + + resp, err := http.Post( + nutsAPI("/internal/vcr/v2/holder/"+holder+"/vc"), + "application/json", + bytes.NewReader(body), + ) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusNoContent, resp.StatusCode, "Failed to store credential: %s", string(respBody)) +} + +func registerOnDiscovery(t *testing.T, nutsAPI func(string) string, subject string) { + t.Helper() + + reqBody := map[string]any{ + "registrationParameters": map[string]string{ + "fhirBaseURL": "http://example.com/fhir", + }, + } + body, _ := json.Marshal(reqBody) + + resp, err := http.Post( + nutsAPI("/internal/discovery/v1/bgz-test/"+subject), + "application/json", + bytes.NewReader(body), + ) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode, "Failed to register on discovery: %s", string(respBody)) +} + +type tokenResult struct { + AccessToken string + DPoPKID string + TokenType string +} + +func requestAccessToken(t *testing.T, nutsAPI func(string) string, subject, authServer, scope, tokenType string) tokenResult { + t.Helper() + + employeeCredential := map[string]any{ + "@context": []string{ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1", + }, + "type": []string{"VerifiableCredential", "NutsEmployeeCredential"}, + "credentialSubject": map[string]any{ + "identifier": "urn:oid:2.16.528.1.1007.3.1.12345", + "name": "Dr. Jan de Vries", + "roleName": "Medisch Specialist", + }, + } + + providerTypeCredential := map[string]any{ + "@context": []string{ + "https://www.w3.org/2018/credentials/v1", + }, + "type": []string{"VerifiableCredential", "HealthcareProviderRoleTypeCredential"}, + "credentialSubject": map[string]any{ + "roleCodeNL": "Z3", + }, + } + + tokenEndpoint := nutsAPI("/internal/auth/v2/" + subject + "/request-service-access-token") + reqBody := map[string]any{ + "authorization_server": authServer, + "scope": scope, + "credentials": []any{employeeCredential, providerTypeCredential}, + "token_type": tokenType, + } + body, _ := json.Marshal(reqBody) + + req, err := http.NewRequest("POST", tokenEndpoint, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode, "Failed to get access token: %s", string(respBody)) + + var result map[string]any + require.NoError(t, json.Unmarshal(respBody, &result)) + + tr := tokenResult{ + AccessToken: result["access_token"].(string), + TokenType: result["token_type"].(string), + } + if dpopKID, ok := result["dpop_kid"].(string); ok { + tr.DPoPKID = dpopKID + } + return tr +} + +func createDPoPProof(t *testing.T, nutsAPI func(string) string, kid, method, targetURL, accessToken string) string { + t.Helper() + + encodedKID := url.QueryEscape(kid) + dpopEndpoint := nutsAPI("/internal/auth/v2/dpop/" + encodedKID) + + reqBody := map[string]any{ + "htm": method, + "htu": targetURL, + "token": accessToken, + } + body, _ := json.Marshal(reqBody) + + resp, err := http.Post(dpopEndpoint, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode, "Failed to create DPoP proof: %s", string(respBody)) + + var result map[string]any + require.NoError(t, json.Unmarshal(respBody, &result)) + return result["dpop"].(string) +} + +func introspectToken(t *testing.T, nutsAPI func(string) string, token string) map[string]any { + t.Helper() + + resp, err := http.PostForm( + nutsAPI("/internal/auth/v2/accesstoken/introspect"), + url.Values{"token": {token}}, + ) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + return result +} + +func createTestPatient(t *testing.T, hapiURL *url.URL) { + t.Helper() + + patientJSON := `{ + "resourceType": "Patient", + "id": "patient-123", + "identifier": [{ + "system": "http://fhir.nl/fhir/NamingSystem/bsn", + "value": "900186021" + }], + "name": [{ + "family": "Test", + "given": ["Patient"] + }] + }` + + req, err := http.NewRequest("PUT", hapiURL.JoinPath("DEFAULT", "Patient", "patient-123").String(), strings.NewReader(patientJSON)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/fhir+json") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Contains(t, []int{http.StatusOK, http.StatusCreated}, resp.StatusCode) +} diff --git a/test/e2e/pep/certs/ca.key b/test/e2e/pep/certs/ca.key new file mode 100644 index 00000000..41e2fc8c --- /dev/null +++ b/test/e2e/pep/certs/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDeAI8W0XsgeVyi +qvkDI9Ny+Jnaa9t4eeZKdTPyCP6wkFfupK+J/T1OgJs+pD+RRAxF02znbFsUCuQL +rPIQbNDrd+Y9KZ4n08Fa/3PE3ud1CaSSpBqZftR6U2HgsLJNnTMBJUqF+N+Mdmg8 +xFQ900N/6OMD6a9pKiv6MtZnOueKpYPl1GqnRFWnDNkhGwWqy3qNLM/EQsttFYTx +hlJ/5gbunTqL0zW/I8tu+VUJ/EAUPIUgLgniAQQkGQx+cc5sal23E4UpGJ4CDSAm +/TwF2Iljs9+lki82zxnZVwubHx28q3TMKKlrsluOlOMxI5VUa6BpBCfINv2v7t3r +1n1yv9NhAgMBAAECggEAD7SxatdaIyOC4r0+zbKQqI8e7113EvBo0va0vJhXUG19 +0xPSmWwRlMazdPyQxLmwNpZtG3hGf0X/+TV8kJ3qL+Z1IvmZR08LXGE7Mv/kFxse +CgPTH+3hoV/Zylgl3SjUbW3SdmTzh8/usvHe1drm6Rs0SFgVgVaq8tH66iW4MM5A +KRqcx92qsaxb5eJBZp/afIUDwD6bZYlmMPsfbbJgud4wQ7mSWMjFSSL5tAHhmgF2 +UWfrbrSaBq6yySYnrW01H3hq8bjeeggOywMxIXFY6sdOg5eTHvBQDJ9pmVMSDRar +RzPOJvTyZHiaRpN215JRJk71ij2qiSQUmqHfG1EXuwKBgQDxXJEHFSup9c9bsFtI +LOUQIL0uBqTJhmm+Nv5HnkaUxna0pSQp4ywp+Xb/OV7HfeqOEIo89dEFLCicfl4M +Tg29TtlSVJhV/XnO4olaAje1j0LsH22Ro6koBqPDmtl7OkRUw8CFixbLYl/2S0cX +Sgu6lJRQjpk2LYRVgnnUHIf68wKBgQDrd2n9bQXlA2xj8htoTZnwKLdI4w9oyNbN +lPDfFDWo+Ih3Kep69TikMn4QofRfOPovOJh2gXTyD3KwcYucJ4ihJIhb2K4ZF2bM +CC/2L/5zXSqeuIVdzUPCyMOOomYUdmGxo7EWARFtCJD7MD++gD9ygiJiPjcfAbu9 +PVuSVGmlWwKBgACuazZi5+ml6PzwRYGxpr/h58bOe/6Zo4jG7PbUyow29zTRVoXL +v18q9hwIVG0pvNTD1TAQ3ZMvKbovXSKZwc4r/88MsBVmDsb3ur2HThL0IZM7D3se +xCZ5xlKSCFUht/mpR8zYtKrET1MJqVy2d8wCCV0k2efePwZixOdFYVjjAoGBANel +7M8pqw1bvkgmso0rDQHS+FFrinBIB9oOPy+/PYm73JduLw5fSXmvuJ8ZBEq1TwQy +TAe0dls+ZKZNxzPDTTFv2OZtIr1eHkpccTiCKgKT3/WvPo1y8U0SO2+FMgIpjT14 +kjV50vDNuKIkRSz+HZ2Mq1rYafkeyEb/S79PSdYjAoGAKtFfPhEu6/Y71rGboMY7 +nyz862Icezg9g4AnPZNVfTwTrXyyEidXG9Xt0F9N7jBG3moXkzO7mga+/qEUf7Xw +kqVyxxUQQT37xXHyfiF4U7UVoIbRMQPdSifNL0WsWmOEGxtmW9LsqtIFedKHWkeo +7/OfNn0T3locffD7M78cphE= +-----END PRIVATE KEY----- diff --git a/test/e2e/pep/certs/ca.pem b/test/e2e/pep/certs/ca.pem new file mode 100644 index 00000000..7b62d550 --- /dev/null +++ b/test/e2e/pep/certs/ca.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9jCCAd6gAwIBAgIUaQdjAIi+qRfZjz09J/48kgOLAvQwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNjAxMjgxNjA0MTJa +Fw0zNjAxMjYxNjA0MTJaMBsxGTAXBgNVBAMMEEZha2UgVVpJIFJvb3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDeAI8W0XsgeVyiqvkDI9Ny+Jna +a9t4eeZKdTPyCP6wkFfupK+J/T1OgJs+pD+RRAxF02znbFsUCuQLrPIQbNDrd+Y9 +KZ4n08Fa/3PE3ud1CaSSpBqZftR6U2HgsLJNnTMBJUqF+N+Mdmg8xFQ900N/6OMD +6a9pKiv6MtZnOueKpYPl1GqnRFWnDNkhGwWqy3qNLM/EQsttFYTxhlJ/5gbunTqL +0zW/I8tu+VUJ/EAUPIUgLgniAQQkGQx+cc5sal23E4UpGJ4CDSAm/TwF2Iljs9+l +ki82zxnZVwubHx28q3TMKKlrsluOlOMxI5VUa6BpBCfINv2v7t3r1n1yv9NhAgMB +AAGjMjAwMA8GA1UdEwQIMAYBAf8CAQAwHQYDVR0OBBYEFK6HzJXe6Z0Vx503ihLj +US8fg3MFMA0GCSqGSIb3DQEBCwUAA4IBAQAm0aKaaec72MQW7vpM4S0j/J9m3NO7 +A1XuT1psmddmxsPwCfg6CoF6tRVN7XLMogjgcWT2tXjPChr4Ez3tf1QM9PZPN0DZ +8oHylryB9DxTPZdgk6kLBTHDUuP7+ST7RRgQw02ClYXod7mOslZdMEd6AtF2ec2c +Ae0Ro+6fmesWtyQEPmRW4mvr+kGTugif7Q4gp/+VfKs/wfFJ7W45RbiVrQ/IKxHk +XZM3q+6zIkInb1Rhw+S0Lu4nO0cVSGXm2/BZ/BRFBn4PeHwprGebGEoLXBc0LLeV +lxEUC+863Zb7FGJYVSr/BBwl0tRQQ/EYSESaGt9wJH23DTy43rbVANcu +-----END CERTIFICATE----- diff --git a/test/e2e/pep/certs/ca.srl b/test/e2e/pep/certs/ca.srl new file mode 100644 index 00000000..9aad7f0c --- /dev/null +++ b/test/e2e/pep/certs/ca.srl @@ -0,0 +1 @@ +2918362B162FBC6CFE0243C9F8D539C8E3C91902 diff --git a/test/e2e/pep/certs/generate-root-ca.sh b/test/e2e/pep/certs/generate-root-ca.sh new file mode 100755 index 00000000..6c0eeef6 --- /dev/null +++ b/test/e2e/pep/certs/generate-root-ca.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Generates a fake UZI root CA for testing +# Based on: https://github.com/nuts-foundation/uzi-did-x509-issuer/tree/main/test_ca + +set -e + +if [[ $OSTYPE == msys ]]; then + echo "Script does not work on GitBash/Cygwin!" + exit 1 +fi + +CONFIG=" +[req] +distinguished_name=dn +[ dn ] +[ ext ] +basicConstraints=CA:TRUE,pathlen:0 +" + +echo "Generating Fake UZI Root CA..." +openssl genrsa -out ca.key 2048 +openssl req -config <(echo "$CONFIG") -extensions ext -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.pem -subj "/CN=Fake UZI Root CA" +echo "Root CA generated: ca.pem, ca.key" diff --git a/test/e2e/pep/certs/issue-cert.sh b/test/e2e/pep/certs/issue-cert.sh new file mode 100755 index 00000000..d5548332 --- /dev/null +++ b/test/e2e/pep/certs/issue-cert.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Issues a test UZI certificate with SAN containing URA +# Based on: https://github.com/nuts-foundation/uzi-did-x509-issuer/tree/main/test_ca +# +# Usage: ./issue-cert.sh +# Example: ./issue-cert.sh nodeA "Test Hospital" "Amsterdam" 0 87654321 0 +# +# The URA is encoded in the SAN otherName field as: +# 2.16.528.1.1007.99.2110-1--S--00.000- + +set -e + +if [[ $OSTYPE == msys ]]; then + echo "Detected GitBash/Cygwin on Windows" + DN_PREFIX="//" +else + DN_PREFIX="/" +fi + +HOST=$1 +X509_O=$2 +X509_L=$3 +UZI=$4 +URA=$5 +AGB=$6 + +if [[ -z $HOST || -z $X509_O || -z $X509_L || -z $UZI || -z $URA || -z $AGB ]]; then + echo "Usage: $0 HOST ORGANIZATION LOCALITY UZI URA AGB" + echo "Example: $0 nodeA 'Test Hospital' Amsterdam 0 87654321 0" + exit 1 +fi + +# Check if CA exists +if [[ ! -f ca.pem || ! -f ca.key ]]; then + echo "Error: CA files not found. Run generate-root-ca.sh first." + exit 1 +fi + +echo "Generating key and certificate for $HOST (URA: $URA)..." + +# Generate private key +openssl genrsa -out $HOST.key 2048 + +# Create CSR +openssl req -new -key $HOST.key -out $HOST.csr \ + -subj "${DN_PREFIX}CN=${HOST}/O=${X509_O}/L=${X509_L}/serialNumber=${UZI}" + +# Create extension file with URA in SAN +# The format matches the bgz.json pattern: ^[0-9.]+-\d+-\d+-S-(\d+)-00\.000-\d+$ +local_openssl_config=" +extendedKeyUsage = serverAuth, clientAuth +subjectAltName = otherName:2.5.5.5;UTF8:2.16.528.1.1007.99.2110-1-${UZI}-S-${URA}-00.000-${AGB} +" +echo "$local_openssl_config" > $HOST.ext + +# Sign the certificate +openssl x509 -req -in $HOST.csr -CA ca.pem -CAkey ca.key -CAcreateserial \ + -out $HOST.pem -days 365 -sha256 -extfile $HOST.ext + +# Create certificate chain (end-entity cert first, then CA per RFC 5246) +cat $HOST.pem > $HOST-chain.pem +cat ca.pem >> $HOST-chain.pem + +# Cleanup +rm -f $HOST.csr $HOST.ext + +echo "Certificate generated:" +echo " - $HOST.key (private key)" +echo " - $HOST.pem (certificate)" +echo " - $HOST-chain.pem (certificate chain)" diff --git a/test/e2e/pep/certs/requester-chain.pem b/test/e2e/pep/certs/requester-chain.pem new file mode 100644 index 00000000..746d2321 --- /dev/null +++ b/test/e2e/pep/certs/requester-chain.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIDojCCAoqgAwIBAgIUKRg2KxYvvGz+AkPJ+NU5yOPJGQIwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNjAxMjgxNzEwMTda +Fw0yNzAxMjgxNzEwMTdaMFExEjAQBgNVBAMMCXJlcXVlc3RlcjEbMBkGA1UECgwS +VGVzdCBIb3NwaXRhbCBCLlYuMRIwEAYDVQQHDAlBbXN0ZXJkYW0xCjAIBgNVBAUT +ATAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCwvV3JgsUJLGly18V7 +tNG7RQ71x2xQQHSkNoA0LTM1c8ab4J8ISO+LCiHrnG3/n2Foq6+r/BuIGqZgPydt +o6w0vupX3/O81FWV6bB+koQoTVlIigzfJpJqaq27jR7882NPXOWEtYYezm9h8usQ +kUkkuwMxe8q3cUMM9jAxXMDVZqqIuVM7qx2gSYJcStmEA2dibIji3lrvQwbaECe3 +1I4dDB11Uw7RMD06ZMwgZw+sX5WWGpYIbS9okQdOJc/r8kEhkDWJtIYTM8/vzxLJ +Ul0aLj8gf/5ccH20luSNcXfSYuOu7Z4LSNDO9EQ9BG0/h8GyVpvly2hf0uVRxlZa +DsqxAgMBAAGjgacwgaQwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMEMG +A1UdEQQ8MDqgOAYDVQUFoDEMLzIuMTYuNTI4LjEuMTAwNy45OS4yMTEwLTEtMC1T +LTg3NjU0MzIxLTAwLjAwMC0wMB0GA1UdDgQWBBRhMonqdrbU+3uM1+2loPvVLWiS +bjAfBgNVHSMEGDAWgBSuh8yV3umdFcedN4oS41EvH4NzBTANBgkqhkiG9w0BAQsF +AAOCAQEAJsFVcykMEZgsr2auRhKNooAgYcaNkqj6XtxBfXFr7Zgxl+J9oB8zKHaR +EYsMaU70cNzEI+Fk5CBufYIoSBO9qKhiMJLz5YU7CMizPe0FgHeH8gstLvzlYd52 +CX+JcCbOkVPIfRSL8QVJOqVTqO1ckJQdd3TcqEStn7HY2ySdlH1WVAoJIv6je2S/ +FIShu+c2UhJmKsmQnb3GTNb8yqRpcwna8ReKdQf0KerHPc7CRcFteRc+CO7uwGYF +j3knPx1m0ly1eOj7hxaAOEjwhWxkSQEEPDJ6nA5mVNSpxR/LX4zUFhP4Bca5Ttp/ +Qotm63NCxckNwkkRblv15w1+JwURqg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIC9jCCAd6gAwIBAgIUaQdjAIi+qRfZjz09J/48kgOLAvQwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNjAxMjgxNjA0MTJa +Fw0zNjAxMjYxNjA0MTJaMBsxGTAXBgNVBAMMEEZha2UgVVpJIFJvb3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDeAI8W0XsgeVyiqvkDI9Ny+Jna +a9t4eeZKdTPyCP6wkFfupK+J/T1OgJs+pD+RRAxF02znbFsUCuQLrPIQbNDrd+Y9 +KZ4n08Fa/3PE3ud1CaSSpBqZftR6U2HgsLJNnTMBJUqF+N+Mdmg8xFQ900N/6OMD +6a9pKiv6MtZnOueKpYPl1GqnRFWnDNkhGwWqy3qNLM/EQsttFYTxhlJ/5gbunTqL +0zW/I8tu+VUJ/EAUPIUgLgniAQQkGQx+cc5sal23E4UpGJ4CDSAm/TwF2Iljs9+l +ki82zxnZVwubHx28q3TMKKlrsluOlOMxI5VUa6BpBCfINv2v7t3r1n1yv9NhAgMB +AAGjMjAwMA8GA1UdEwQIMAYBAf8CAQAwHQYDVR0OBBYEFK6HzJXe6Z0Vx503ihLj +US8fg3MFMA0GCSqGSIb3DQEBCwUAA4IBAQAm0aKaaec72MQW7vpM4S0j/J9m3NO7 +A1XuT1psmddmxsPwCfg6CoF6tRVN7XLMogjgcWT2tXjPChr4Ez3tf1QM9PZPN0DZ +8oHylryB9DxTPZdgk6kLBTHDUuP7+ST7RRgQw02ClYXod7mOslZdMEd6AtF2ec2c +Ae0Ro+6fmesWtyQEPmRW4mvr+kGTugif7Q4gp/+VfKs/wfFJ7W45RbiVrQ/IKxHk +XZM3q+6zIkInb1Rhw+S0Lu4nO0cVSGXm2/BZ/BRFBn4PeHwprGebGEoLXBc0LLeV +lxEUC+863Zb7FGJYVSr/BBwl0tRQQ/EYSESaGt9wJH23DTy43rbVANcu +-----END CERTIFICATE----- diff --git a/test/e2e/pep/certs/requester.key b/test/e2e/pep/certs/requester.key new file mode 100644 index 00000000..ba542ff5 --- /dev/null +++ b/test/e2e/pep/certs/requester.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCwvV3JgsUJLGly +18V7tNG7RQ71x2xQQHSkNoA0LTM1c8ab4J8ISO+LCiHrnG3/n2Foq6+r/BuIGqZg +Pydto6w0vupX3/O81FWV6bB+koQoTVlIigzfJpJqaq27jR7882NPXOWEtYYezm9h +8usQkUkkuwMxe8q3cUMM9jAxXMDVZqqIuVM7qx2gSYJcStmEA2dibIji3lrvQwba +ECe31I4dDB11Uw7RMD06ZMwgZw+sX5WWGpYIbS9okQdOJc/r8kEhkDWJtIYTM8/v +zxLJUl0aLj8gf/5ccH20luSNcXfSYuOu7Z4LSNDO9EQ9BG0/h8GyVpvly2hf0uVR +xlZaDsqxAgMBAAECggEAHwgEp0ynUULDufcCFMQM/gM0SvmKfjWu9SUfkemsv9IV +2UkRSyhZLLGo/oAO/S6L8Q+R7tG7OMSrGSOFhfXSlsk9hpPK7QjgBQwModCSVSwt +hLO+alDQrNARtGgk/Hc0ZNsT7l7bz11iB5HZ7WUA8WrHdwD7+QTxJ52zHPui3OTY +MLhpnz5rQZ9ENfxlPK668i8zReudIJ6ObuDR0pwacwaZxOLyKZZPgw6HMoV/4hyt +d+BLVThTxjBh/rbM2zkSeB9PaLVNq1WVI1TiPzRooDLKtewuEi17e2BYZS1RZE4I +a28wFJ7BhJ1U5gTLiIFoLuHuxlh/LmCxuf22nfruFQKBgQDySZQynk5pCtpkyIxb +kr50lv3H1EkJ8zpyRXydcNLnt/L5CzSzpReyC/5mk2HXQbvysVJRnR+HPaLqWstr +ghZ4pAueXohC8sp8cNPxCscq1eYmed1NKQbpb4Fngm9xPoTylPAkzbh2zBohWGRD +YM64iMlcQVl+z/bN2pbzPWO9vQKBgQC6vha4So4LpOELc18FioeYLPgm6Qrn9WIF +X2dsgBAGvHbZWcdBP0vckn9gFRVoZDgnxH1D5s2N2/rzmGctd2ojFxaebqEMnGBO +JyP8+ObfSoePaRhOLdJLy5H7LS0BxmBxRZrFVnws4mPsGZwsqqLtqy/qYILDtFwr +hJGJWgbOBQKBgQCYWvJo/ik6XovEkmPIdbdz4zrEfNZM/nkDQHTDIB5Pfdm5B3Xl +fWwwFuCrqgP9cyV30E9+aLpZtcWLbvq5qPzuceGofbNbvgbcR6rOyUNCyWzHRxyF +F8Zz5h1OdLQVVwYM8OEtk3tqoJ/R1h5+TBLR8ZoFfEaFusps2gbQAAuHVQKBgQCa +ufefOP7avQFN+IjJ0Y8p0lDGBJ9ptBJEe1j5OF6PBka/Ljj/Yc+ccbGiTbXQKgGx +SYe05B56pMMYZLVQobnKW039dZJxHXxaJOoXp6+7YUhS1fQyiprM/F33LOY2q0Sr +dc2YJmF62xWJwWp6Q+P9YrKv0slmGCGqWQwxLuumdQKBgCMuUZ14TIojTaFYRci/ +oaNRVlAu12dYhtGg53IECkK/sfQgoz3w0VWSPlgjNo+dtjrgf/8QkVMHt4Kh1Vcl +GRKEA7B3Dx/Kribj2Hg2PvG70nR8dbK94xGfpW9qguARHC7SeCc7UgP3mdA/j03Z +flWQv459JsqOl+tsy5LbPnGx +-----END PRIVATE KEY----- diff --git a/test/e2e/pep/certs/requester.pem b/test/e2e/pep/certs/requester.pem new file mode 100644 index 00000000..2745d9e7 --- /dev/null +++ b/test/e2e/pep/certs/requester.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDojCCAoqgAwIBAgIUKRg2KxYvvGz+AkPJ+NU5yOPJGQIwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNjAxMjgxNzEwMTda +Fw0yNzAxMjgxNzEwMTdaMFExEjAQBgNVBAMMCXJlcXVlc3RlcjEbMBkGA1UECgwS +VGVzdCBIb3NwaXRhbCBCLlYuMRIwEAYDVQQHDAlBbXN0ZXJkYW0xCjAIBgNVBAUT +ATAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCwvV3JgsUJLGly18V7 +tNG7RQ71x2xQQHSkNoA0LTM1c8ab4J8ISO+LCiHrnG3/n2Foq6+r/BuIGqZgPydt +o6w0vupX3/O81FWV6bB+koQoTVlIigzfJpJqaq27jR7882NPXOWEtYYezm9h8usQ +kUkkuwMxe8q3cUMM9jAxXMDVZqqIuVM7qx2gSYJcStmEA2dibIji3lrvQwbaECe3 +1I4dDB11Uw7RMD06ZMwgZw+sX5WWGpYIbS9okQdOJc/r8kEhkDWJtIYTM8/vzxLJ +Ul0aLj8gf/5ccH20luSNcXfSYuOu7Z4LSNDO9EQ9BG0/h8GyVpvly2hf0uVRxlZa +DsqxAgMBAAGjgacwgaQwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMEMG +A1UdEQQ8MDqgOAYDVQUFoDEMLzIuMTYuNTI4LjEuMTAwNy45OS4yMTEwLTEtMC1T +LTg3NjU0MzIxLTAwLjAwMC0wMB0GA1UdDgQWBBRhMonqdrbU+3uM1+2loPvVLWiS +bjAfBgNVHSMEGDAWgBSuh8yV3umdFcedN4oS41EvH4NzBTANBgkqhkiG9w0BAQsF +AAOCAQEAJsFVcykMEZgsr2auRhKNooAgYcaNkqj6XtxBfXFr7Zgxl+J9oB8zKHaR +EYsMaU70cNzEI+Fk5CBufYIoSBO9qKhiMJLz5YU7CMizPe0FgHeH8gstLvzlYd52 +CX+JcCbOkVPIfRSL8QVJOqVTqO1ckJQdd3TcqEStn7HY2ySdlH1WVAoJIv6je2S/ +FIShu+c2UhJmKsmQnb3GTNb8yqRpcwna8ReKdQf0KerHPc7CRcFteRc+CO7uwGYF +j3knPx1m0ly1eOj7hxaAOEjwhWxkSQEEPDJ6nA5mVNSpxR/LX4zUFhP4Bca5Ttp/ +Qotm63NCxckNwkkRblv15w1+JwURqg== +-----END CERTIFICATE----- diff --git a/test/e2e/pep/testdata/accesspolicy.json b/test/e2e/pep/testdata/accesspolicy.json new file mode 100644 index 00000000..467dfa2f --- /dev/null +++ b/test/e2e/pep/testdata/accesspolicy.json @@ -0,0 +1,120 @@ +{ + "bgz": { + "organization": { + "format": { + "jwt_vc": { + "alg": ["PS256", "ES256", "RS256"] + }, + "jwt_vp": { + "alg": ["ES256", "PS256", "RS256"] + }, + "ldp_vc": { + "proof_type": ["JsonWebSignature2020"] + } + }, + "id": "pd_bgz_care_organization", + "name": "BgZ Care organization", + "purpose": "Authorization for BgZ (Basisgegevensset Zorg) access", + "input_descriptors": [ + { + "id": "id_x509credential", + "name": "Care organization identity from UZI certificate", + "purpose": "Finding a care organization for authorizing access to medical metadata.", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "array", + "contains": { + "const": "X509Credential" + } + } + }, + { + "path": ["$.issuer"], + "purpose": "Only accept credentials from our test CA", + "filter": { + "type": "string", + "pattern": "^did:x509:0:sha256:OPYJNiIh8WSwSDzJL0R2d2yPlolN9eAzZ8zvfMK3lcM::.*$" + } + }, + { + "id": "subject_organization", + "path": ["$.credentialSubject[0].subject.O", "$.credentialSubject.subject.O"], + "filter": { + "type": "string" + } + }, + { + "id": "subject_organization_id", + "path": ["$.credentialSubject[0].san.otherName", "$.credentialSubject.san.otherName"], + "filter": { + "type": "string", + "pattern": "^[0-9.]+-\\d+-\\d+-S-(\\d+)-00\\.000-\\d+$" + } + } + ] + } + }, + { + "id": "id_nuts_employee_credential", + "name": "Employee identity", + "purpose": "Identify the employee making the request for accountability and consent verification.", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "array", + "contains": { + "const": "NutsEmployeeCredential" + } + } + }, + { + "id": "subject_id", + "path": ["$.credentialSubject.identifier"], + "filter": { + "type": "string" + } + }, + { + "id": "subject_role", + "path": ["$.credentialSubject.roleName"], + "filter": { + "type": "string" + } + } + ] + } + }, + { + "id": "id_healthcare_provider_role_type_credential", + "name": "Healthcare provider role type", + "purpose": "Identify the facility type of the requesting organization for Mitz consent verification.", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "array", + "contains": { + "const": "HealthcareProviderRoleTypeCredential" + } + } + }, + { + "id": "subject_facility_type", + "path": ["$.credentialSubject.roleCodeNL"], + "filter": { + "type": "string" + } + } + ] + } + } + ] + } + } +} diff --git a/test/e2e/pep/testdata/discovery.json b/test/e2e/pep/testdata/discovery.json new file mode 100644 index 00000000..3c6edf34 --- /dev/null +++ b/test/e2e/pep/testdata/discovery.json @@ -0,0 +1,57 @@ +{ + "id": "bgz-test", + "endpoint": "http://localhost:8080/nuts/discovery/bgz-test", + "presentation_max_validity": 36000, + "presentation_definition": { + "id": "pd_bgz_discovery", + "format": { + "jwt_vc": { + "alg": ["PS256", "ES256", "RS256"] + }, + "jwt_vp": { + "alg": ["ES256", "PS256", "RS256"] + } + }, + "input_descriptors": [ + { + "id": "id_x509credential", + "name": "Organization identity from UZI certificate", + "purpose": "Finding a care organization for authorizing access to medical metadata.", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "X509Credential" + } + }, + { + "path": ["$.issuer"], + "purpose": "Only accept credentials from our test CA", + "filter": { + "type": "string", + "pattern": "^did:x509:0:sha256:OPYJNiIh8WSwSDzJL0R2d2yPlolN9eAzZ8zvfMK3lcM::.*$" + } + }, + { + "id": "organization_name", + "path": ["$.credentialSubject[0].subject.O", "$.credentialSubject.subject.O"], + "filter": { + "type": "string" + } + }, + { + "id": "organization_ura", + "path": ["$.credentialSubject[0].san.otherName", "$.credentialSubject.san.otherName"], + "filter": { + "type": "string", + "pattern": "^[0-9.]+-\\d+-\\d+-S-(\\d+)-00\\.000-\\d+$" + } + } + ] + } + } + ] + } +}