diff --git a/aidbox-custom-operations/aidbox-health-card-issue/.env.example b/aidbox-custom-operations/aidbox-health-card-issue/.env.example index 900aa65b..824c8d36 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/.env.example +++ b/aidbox-custom-operations/aidbox-health-card-issue/.env.example @@ -1,11 +1,13 @@ # Aidbox Configuration -AIDBOX_BASE_URL=http://localhost:8080/fhir +AIDBOX_BASE_URL=http://localhost:8081/fhir AIDBOX_CLIENT_ID=health-cards-client AIDBOX_CLIENT_SECRET=secret # Health Cards Configuration -HEALTH_CARDS_ISSUER=https://example.org/health-cards +# iss must be the base that serves /.well-known/jwks.json (the namespaced +# Aidbox App op). Spec requires https + no trailing slash in production. +HEALTH_CARDS_ISSUER=http://localhost:8081/health-cards-app HEALTH_CARDS_KEY_PATH=./keys/private-key.pem # Server Configuration -PORT=3000 +PORT=3001 diff --git a/aidbox-custom-operations/aidbox-health-card-issue/Dockerfile b/aidbox-custom-operations/aidbox-health-card-issue/Dockerfile index 51b78029..e86c654c 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/Dockerfile +++ b/aidbox-custom-operations/aidbox-health-card-issue/Dockerfile @@ -30,7 +30,7 @@ RUN chown -R healthcards:nodejs /app USER healthcards # Expose port -EXPOSE 3000 +EXPOSE 3001 # Start the application CMD ["npm", "start"] \ No newline at end of file diff --git a/aidbox-custom-operations/aidbox-health-card-issue/README.md b/aidbox-custom-operations/aidbox-health-card-issue/README.md index 3b749575..dce59d7b 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/README.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/README.md @@ -4,113 +4,131 @@ languages: [TypeScript] --- # SMART Health Cards Issue Operation -A minimal TypeScript implementation of the FHIR `$health-cards-issue` operation that integrates with Aidbox to generate SMART Health Cards from patient health data. +A Node/Express implementation of the FHIR [`$health-cards-issue`](https://hl7.org/fhir/uv/smart-health-cards-and-links/STU1/OperationDefinition-patient-i-health-cards-issue.html) operation on [Aidbox](https://www.health-samurai.io/aidbox). It pulls patient data from Aidbox, minimizes it per the [SMART Health Cards spec](https://spec.smarthealth.cards/), and issues a signed verifiable credential (JWS/ES256). The card ships three ways (FHIR API, QR `shc:/`, and a `.smart-health-card` file), with an in-browser verifier included. -## Overview - -This project implements the [SMART Health Cards specification](https://hl7.org/fhir/uv/smart-health-cards-and-links/STU1/OperationDefinition-patient-i-health-cards-issue.html) as a FHIR operation. It retrieves patient health data from Aidbox, sanitizes it according to SMART Health Cards requirements, and generates verifiable health cards in [JWS](https://datatracker.ietf.org/doc/html/rfc7515) format. +A SMART Health Card is a FHIR `Bundle` wrapped as a W3C Verifiable Credential and signed as a JWS (ES256), payload minified and DEFLATE-compressed (`zip:"DEF"`). The issuer's signature proves the card is authentic; it does not keep the contents confidential. For encrypted sharing, see the sibling `smart-health-link` example. ## Architecture ```mermaid flowchart LR - Client[Client] - Aidbox[Aidbox
FHIR Server] - App[Health Cards App
Node.js] + Client["Client"] + Aidbox["Aidbox"] + App["Health Cards App
(Node)"] + + Client -->|"$health-cards-issue"| Aidbox + Aidbox -->|"proxies op"| App + App -->|"fetch data"| Aidbox + App -->|"JWS"| Aidbox + Aidbox -->|"credential"| Client + Client -.->|"GET JWKS"| Aidbox + Aidbox -.->|"JWKS"| App +``` + +## Flow 1: Issue (`$health-cards-issue`) + +```mermaid +sequenceDiagram + actor C as Client + participant A as Aidbox + participant S as Health Cards App + C->>A: POST /fhir/Patient/{id}/$health-cards-issue
Parameters { credentialType, _since? } + A->>S: proxies operation + S->>A: GET Patient/{id}, Immunization?/Observation? + A-->>S: FHIR resources + Note over S: minimize (strip id/meta/text/display,
fullUrl + refs → resource:N), DEFLATE, sign ES256 + S-->>A: Parameters { verifiableCredential: "" } + A-->>C: Parameters { verifiableCredential } +``` - Client -->|$health-cards-issue| Aidbox - Aidbox -->|$health-cards-issue| App - App -->|Fetch FHIR data| Aidbox - App -->|Signed Health Card| Aidbox - Aidbox -->|Signed Health Card| Client +## Flow 2: Verify (JWKS) +The issuer publishes its public key so anyone can verify: - style Aidbox fill:#e1f5fe - style App fill:#f3e5f5 - style Client fill:#e8f5e8 +- Aidbox already owns its own `/.well-known/jwks.json` (RSA, for OAuth). +- The SHC EC key therefore lives under a namespaced path: `/health-cards-app/.well-known/jwks.json`. +- `iss` points at that base, so `/.well-known/jwks.json` resolves to the key. + +```mermaid +sequenceDiagram + actor V as Verifier + participant A as Aidbox + participant S as Health Cards App + Note over V: read iss + kid from the JWS + V->>A: GET /.well-known/jwks.json
(iss = http://localhost:8081/health-cards-app) + A->>S: http-rpc (operation: well-known-jwks, public) + S-->>A: { keys: [ EC/P-256 public JWK, kid ] } + A-->>V: JWKS (CORS) + Note over V: pick key by kid → verify ES256 → inflate DEFLATE ``` -**Components:** -- **FHIR Server**: Aidbox with [custom operation routing](https://www.health-samurai.io/docs/aidbox/developer-experience/aidbox-sdk/apps) -- **Application**: Node.js TypeScript backend service - actual implementation of `$health-cards-issue` operation -- **Output**: Cryptographically signed SMART Health Cards (JWS format) +## Flow 3: Deliver and verify in the browser -## Quick Start +```mermaid +sequenceDiagram + actor U as User (browser) + participant S as Health Cards App + U->>S: POST /demo/issue { patientId, credentialType } + S-->>U: { verifiableCredential, qr: "shc:/…", downloadUrl } + U->>S: GET /.well-known/jwks.json (same-origin) + S-->>U: JWKS + Note over U: WebCrypto ES256 verify + deflate-raw inflate → render Bundle + U->>S: GET /download → application/smart-health-card +``` -### Prerequisites +## Endpoints -- Docker and Docker Compose -- Node.js 18+ (for development) +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| POST | `/fhir/Patient/{id}/$health-cards-issue` | required | Issue a card | +| GET | `/health-cards-app/.well-known/jwks.json` | public | Issuer JWKS (`= /.well-known/jwks.json`) | +| GET | `:3001/` | public | Viewer (Issue + Verify) | +| POST | `:3001/demo/issue` | demo | Issue → `{ jws, qr, downloadUrl }` | +| GET | `:3001/download` | demo | `.smart-health-card` file | +| GET | `:3001/.well-known/jwks.json` | public | Same JWKS, same-origin for the viewer | -### Running the Application +## Quick Start -1. **Create .env file** ```bash cp .env.example .env +npm install && npm run generate-keys +docker compose up --build ``` +Activate Aidbox at [localhost:8081](http://localhost:8081) (init bundle seeds `Patient/example-patient` + a COVID `Immunization` + `Observation`), then open the viewer at [localhost:3001](http://localhost:3001). -2. **Generate signing keys for JWS generation**: - ```bash - npm install - npm run generate-keys - ``` - - -3. **Run docker compose**: - ```bash - docker compose up --build - ``` - -4. **Initialize Aidbox instance** -Navigate to [Aidbox UI](http://localhost:8080) and [initialize](https://docs.aidbox.app/getting-started/run-aidbox-locally#id-4.-activate-your-aidbox-instance) the Aidbox instance. - -### Testing Health Cards generation - -1. **Run `$health-cards-issue` operation** - -Navigate to [Aidbox Rest Console](http://localhost:8080/ui/console#/rest) and execute the following request: - -```http -POST /fhir/Patient/example-patient/$health-cards-issue -Content-Type: application/fhir+json - -{ - "resourceType": "Parameters", - "parameter": [ - { - "name": "credentialType", - "valueString": "Immunization" - }, - { - "name": "credentialType", - "valueString": "Observation" - }, - { - "name": "_since", - "valueInstant": "2023-01-01T00:00:00Z" - }, - { - "name": "includeIdentityClaim", - "valueBoolean": false - } - ] -} -``` +## Testing -Example response: -```json -{ - "resourceType": "Parameters", - "parameter": [ - { - "name": "verifiableCredential", - "valueString": "eyJhbGciOiJFUzI1NiIsInppcCI6IkRFRiIsImtpZCI6IjcxMTFkNDhkMzNhYmJmZTIifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUub3JnL2hlYWx0aC1jYXJkcyIsIm5iZiI6MTc1NTY5OTM5MywidmMiOnsidHlwZSI6WyJodHRwczovL3NtYXJ0aGVhbHRoLmNhcmRzI2hlYWx0aC1jYXJkIiwiaHR0cHM6Ly9zbWFydGhlYWx0aC5jYXJkcyNpbW11bml6YXRpb24iXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZmhpclZlcnNpb24iOiI0LjAuMSIsImZoaXJCdW5kbGUiOnsicmVzb3VyY2VUeXBlIjoiQnVuZGxlIiwidHlwZSI6ImNvbGxlY3Rpb24iLCJlbnRyeSI6W3sicmVzb3VyY2UiOnsicmVzb3VyY2VUeXBlIjoiUGF0aWVudCIsIm5hbWUiOlt7ImZhbWlseSI6IkRvZSIsImdpdmVuIjpbIkpvaG4iXX1dLCJiaXJ0aERhdGUiOiIxOTgwLTAxLTAxIn19LHsicmVzb3VyY2UiOnsicGF0aWVudCI6eyJyZWZlcmVuY2UiOiJQYXRpZW50L2V4YW1wbGUtcGF0aWVudCJ9LCJwcm90b2NvbEFwcGxpZWQiOlt7InNlcmllcyI6IkNPVklELTE5IFByaW1hcnkgU2VyaWVzIiwiZG9zZU51bWJlclBvc2l0aXZlSW50IjoxLCJzZXJpZXNEb3Nlc1Bvc2l0aXZlSW50IjoyfV0sInNpdGUiOnsiY29kaW5nIjpbeyJjb2RlIjoiTEEiLCJzeXN0ZW0iOiJodHRwOi8vdGVybWlub2xvZ3kuaGw3Lm9yZy9Db2RlU3lzdGVtL3YzLUFjdFNpdGUifV19LCJ2YWNjaW5lQ29kZSI6eyJjb2RpbmciOlt7ImNvZGUiOiIyMDgiLCJzeXN0ZW0iOiJodHRwOi8vaGw3Lm9yZy9maGlyL3NpZC9jdngifV19LCJkb3NlUXVhbnRpdHkiOnsiY29kZSI6Im1MIiwidW5pdCI6Im1MIiwidmFsdWUiOjAuMywic3lzdGVtIjoiaHR0cDovL3VuaXRzb2ZtZWFzdXJlLm9yZyJ9LCJyb3V0ZSI6eyJjb2RpbmciOlt7ImNvZGUiOiJJTSIsInN5c3RlbSI6Imh0dHA6Ly90ZXJtaW5vbG9neS5obDcub3JnL0NvZGVTeXN0ZW0vdjMtUm91dGVPZkFkbWluaXN0cmF0aW9uIn1dfSwicmVzb3VyY2VUeXBlIjoiSW1tdW5pemF0aW9uIiwicmVjb3JkZWQiOiIyMDIzLTAzLTE1IiwicHJpbWFyeVNvdXJjZSI6dHJ1ZSwic3RhdHVzIjoiY29tcGxldGVkIiwibG90TnVtYmVyIjoiQUJDMTIzIiwib2NjdXJyZW5jZURhdGVUaW1lIjoiMjAyMy0wMy0xNSIsImV4cGlyYXRpb25EYXRlIjoiMjAyNC0xMi0zMSIsInBlcmZvcm1lciI6W3siYWN0b3IiOnsiZGlzcGxheSI6IkRyLiBKYW5lIFNtaXRoLCBNRCJ9LCJmdW5jdGlvbiI6eyJjb2RpbmciOlt7ImNvZGUiOiJBUCIsInN5c3RlbSI6Imh0dHA6Ly90ZXJtaW5vbG9neS5obDcub3JnL0NvZGVTeXN0ZW0vdjItMDQ0MyJ9XX19XX19LHsicmVzb3VyY2UiOnsiY2F0ZWdvcnkiOlt7ImNvZGluZyI6W3siY29kZSI6ImxhYm9yYXRvcnkiLCJzeXN0ZW0iOiJodHRwOi8vdGVybWlub2xvZ3kuaGw3Lm9yZy9Db2RlU3lzdGVtL29ic2VydmF0aW9uLWNhdGVnb3J5In1dfV0sInJlc291cmNlVHlwZSI6Ik9ic2VydmF0aW9uIiwiZWZmZWN0aXZlRGF0ZVRpbWUiOiIyMDIzLTAzLTEwIiwic3RhdHVzIjoiZmluYWwiLCJjb2RlIjp7ImNvZGluZyI6W3siY29kZSI6Ijk0NTAwLTYiLCJzeXN0ZW0iOiJodHRwOi8vbG9pbmMub3JnIn1dfSwidmFsdWVDb2RlYWJsZUNvbmNlcHQiOnsiY29kaW5nIjpbeyJjb2RlIjoiMjYwMzg1MDA5Iiwic3lzdGVtIjoiaHR0cDovL3Nub21lZC5pbmZvL3NjdCJ9XX0sInN1YmplY3QiOnsicmVmZXJlbmNlIjoiUGF0aWVudC9leGFtcGxlLXBhdGllbnQifSwicGVyZm9ybWVyIjpbeyJkaXNwbGF5IjoiRXhhbXBsZSBMYWIifV19fV19fX19.3WVpGCl8qD3E2apBLma1OZ36DyLsS_AwhOaZWdQ0iu4rMkt2zuLj5o4V_xWL-Tv5175H7-gWbx6bqrmM3Foi3w" - } - ] +- **Viewer** (`:3001`): **Issue** a card (`#covid19`, `Immunization`, or `Observation`) → get JWS, QR, and a `.smart-health-card` download, auto-verified. **Verify** any pasted JWS against the JWKS. +- **API**: + ```http + POST /fhir/Patient/example-patient/$health-cards-issue + Content-Type: application/fhir+json -``` + { "resourceType": "Parameters", + "parameter": [ { "name": "credentialType", "valueUri": "https://smarthealth.cards#covid19" } ] } + ``` + Also accepts `Immunization` / `Observation` (uri or string), `_since` (dateTime), `includeIdentityClaim` (string claim paths). +- **`credentialType` mapping**: `#covid19` selects COVID `Immunization`s only (filtered by CVX vaccine code); `Immunization` selects all immunizations; `Observation` selects lab results. All matches go into a single card. +- **`resourceLink` (OUT)**: the response also returns a `resourceLink` per bundled resource, mapping each minified `resource:N` entry back to its live FHIR URL (for example, `resource:0` maps to `/Patient/example-patient`). +- **`credentialValueSet` (IN)**: restricts resources by content — keeps only those whose code (`Immunization.vaccineCode` / `Observation.code`) is a member of the given ValueSet, via Aidbox `ValueSet/$validate-code`. The official SMART Health Cards terminology package (`cards.smarthealth.terminology`) is installed by the **init bundle** through the `$fhir-package-install` operation (from `https://terminology.smarthealth.cards/package.tgz`), which provides `https://terminology.smarthealth.cards/ValueSet/immunization-covid-cvx`. The seed has two immunizations (one CVX, one SNOMED): `Immunization` + `immunization-covid-cvx` keeps only the CVX one; `Observation` + it filters the LOINC lab out. (The package's SNOMED value sets aren't demoed — they enumerate SNOMED extension codes the configured external tx doesn't resolve.) Repeating IN params (`credentialType`, `credentialValueSet`, `includeIdentityClaim`) are supported — the viewer's Issue tab has `+` buttons (the last two under **Advanced**). Multiple `credentialValueSet` values are logical AND. +- **Spec validator**: paste the JWS at [demo-portals.smarthealth.cards](https://demo-portals.smarthealth.cards/). Header, `zip:DEF`, signature, `kid`, and Bundle should all read valid. Two warnings are expected in local dev (unknown issuer and http keys); paste `keys/public-key.jwk.json` to check the signature. + +## Conformance + +Built to pass strict verification: +- **JWS** `ES256`, `zip:DEF`, `kid` = base64url SHA-256 JWK thumbprint (RFC 7638). +- Payload minified + **raw-DEFLATE before signing** (jose `SignJWT` doesn't compress; we `deflateRaw` + `CompactSign`). +- Bundle `collection`; strip `id`/`meta`(≠security)/`text`/`Coding.display`; `fullUrl` + refs → `resource:N`. +- JWKS: EC/P-256/`use:sig`/`alg:ES256`/`kid`/`x`/`y`, **no `d`**, CORS; `iss` = the base serving it. + +## VCI / trust + +Real verifiers check `iss` against the [VCI trusted-issuer directory](https://github.com/the-commons-project/vci-directory) and fetch JWKS over https (TLS 1.2+). This demo self-hosts over `http://localhost` and is not VCI-listed, which is the expected local-dev deviation. Production needs an https `iss` (no trailing slash) plus VCI enrollment. + +**Card content and VCI profiles.** For `#covid19`, the bundle content follows the VCI / [US Public Health](https://build.fhir.org/ig/HL7/fhir-us-ph-library/) vaccine-credential profiles by resource type and codes: Patient (name + DOB), `Immunization` (CVX vaccine code), and COVID `Observation` (LOINC code + SNOMED value). The seed data is shaped accordingly. This demo stops short of validating against those `StructureDefinition`s or trimming to their exact minimal data set, which is out of scope here. (SHC strips `meta`, so conformance means the set of elements, not a `meta.profile` tag.) To enforce it, load the IG package into Aidbox and run `$validate` on the issued bundle. + +**Out of scope**: SMART-on-FHIR OAuth (Aidbox config), VCI enrollment, formal VCI/us-ph profile validation, revocation, key rotation. + +## Related -2. **Test the JWS** -Extract the `valueString` element and decode the JWT using `https://www.jwt.io/` -The decoded payload in the HealthCard should match the data you pre-loaded into Aidbox using [init-bundle](/init-bindle/bundle.json) -To validate the signature, retrieve the public key from [http://localhost:8080/health-cards-app/.well-known/jwks.json](http://localhost:8080/health-cards-app/.well-known/jwks.json) +- [`smart-health-link`](../../aidbox-integrations/smart-health-link): the encrypted-link counterpart (JWE), which shares data via a `shlink:` instead of a signed card. diff --git a/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md b/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md new file mode 100644 index 00000000..75e95040 --- /dev/null +++ b/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md @@ -0,0 +1,93 @@ +# ADR-005: Delivery surfaces & strict SMART Health Cards conformance + +## Status +Accepted + +## Context +The original example issued a `verifiableCredential` from `$health-cards-issue` and published a JWKS, +but its cards did **not** pass strict verification, and it implemented only one of the delivery +surfaces for SMART Health Cards. We want cards that validate (e.g. at +`demo-portals.smarthealth.cards`) and all three delivery surfaces: FHIR API, QR, and file. + +## Decision + +### A. Conformance fixes (cards must verify) +1. **Real DEFLATE before signing.** `jose.SignJWT` sets `zip:"DEF"` in the header but does **not** + compress the JWS payload, so the old card advertised compression it never applied. We now + `deflateRawSync` the minified payload and sign the bytes with `CompactSign` + (`{ alg:'ES256', zip:'DEF', kid }`). (`src/utils/crypto.ts`) +2. **`kid` = RFC 7638 JWK thumbprint.** Previously `kid` was `SHA-256(PEM)[:16]`. Now + `generate-keys.ts` sets `kid = calculateJwkThumbprint(jwk)`, writes it into the JWK, and + `config.ts` reads `kid` **from the JWK file** so the signing `kid` always equals the published one. +3. **`resource:N` references.** `bundle-builder.ts` now sets `entry.fullUrl = "resource:i"` and rewrites + every `Reference.reference` that targets a bundled resource to `resource:i`, in addition to stripping + `id`/`meta`/`text`/`Coding.display`. +4. **`iss` matches the JWKS location.** Was `https://example.org/health-cards` while the key was served + at `…/health-cards-app/.well-known/jwks.json`. Now `HEALTH_CARDS_ISSUER = + http://localhost:8080/health-cards-app`, so `/.well-known/jwks.json` resolves to the key. +5. **Public, CORS-enabled JWKS.** The `well-known-jwks` App op gained an inline `allow` policy so + verifiers can fetch it unauthenticated; the app's Express `cors()` covers the same-origin route the + browser verifier uses. +6. **`includeIdentityClaim` is a string.** The operation defines it as a repeating string of claim paths + (e.g. `"Patient.name"`); we read `valueString` (tolerating the old boolean) and include exactly the + named claims. + +### B. JWKS hosting decision +Aidbox already serves its **own** `/.well-known/jwks.json` (RSA keys for OAuth/OIDC token signing) and +owns that route; those keys are the wrong type for SHC (which needs EC/ES256). We therefore keep the +existing design of **namespacing** the SHC JWKS under the app path +(`["health-cards-app",".well-known","jwks.json"]`) and set `iss` to that base. This avoids the +collision, keeps a single public host (Aidbox), and makes `/.well-known/jwks.json` correct. + +### C. Delivery surfaces +- **QR** (`src/utils/shc-encode.ts#toQrNumeric`): the `shc:/` numeric encoding (`Ord(c)-45`, two + digits; single chunk — multi-chunk is deprecated). The viewer renders it with a self-contained + byte-mode QR encoder; cards too large for byte-mode fall back to the file/JWS, with a note that + production issuers emit numeric-mode QR. +- **File** (`toFileBody` + `GET /download`): `application/smart-health-card`, + body `{ "verifiableCredential": [ "" ] }`, `.smart-health-card` filename. +- **Verifier viewer** (`src/viewer.html`): WebCrypto ES256 verification against the JWKS + + `DecompressionStream('deflate-raw')` to inflate, rendering the Bundle with a verified badge. +- **Pipeline refactor**: the fetch → minimize → sign steps moved into + `HealthCardService.issueForPatient`, shared by the Aidbox handler, `/demo/issue`, and `/download`. + +### D. Both credential styles +Accept the `https://smarthealth.cards#covid19` VCI type (→ COVID Immunizations by CVX code) **and** the +generic HL7-IG resource-type form (`Immunization`, `Observation`). + +### E. VCI content profiles — align, don't enforce +The VCI / US Public Health vaccine-credential profiles constrain the bundle **content** (profiles on +Patient, Immunization, and COVID lab-result Observation). We align by **resource type + codes** +(CVX / LOINC+SNOMED), and the seed data is shaped accordingly, but we deliberately **do not** validate +against those `StructureDefinition`s or trim to their minimal data set — that is out of scope for this +example. (SHC strips `meta`, so conformance is about the element set, not a `meta.profile` tag.) +Enforcement path if needed: load the IG package into Aidbox and `$validate` the issued bundle. + +### F. resourceLink (OUT) — implemented +The operation returns a `resourceLink` per bundled resource, mapping each minified `resource:N` entry +to the live FHIR URL it came from (`bundledResource` → `hostedResource`, `vcIndex` = 0). The mapping is +already built during minification (`bundle-builder` refMap), so it is surfaced directly; `hostedResource` +is the issuer origin + `/fhir`. This supersedes ADR-003's out-of-scope note. + +### G. credentialValueSet (IN) — implemented +Resources are filtered by content: a resource is kept only if its code (`Immunization.vaccineCode` / +`Observation.code` — the two elements the operation names) is a member of the given ValueSet, checked +via Aidbox `ValueSet/$validate-code`. The ValueSet comes from the official SMART Health Cards +terminology package `cards.smarthealth.terminology`, installed by the init bundle via the +`$fhir-package-install` operation (`https://terminology.smarthealth.cards/package.tgz`) — it isn't on a +FHIR registry, and `$fhir-package-install` accepts a URL directly, so no tarball is committed/mounted. +The demo uses `immunization-covid-cvx` (enumerated CVX, validated locally). No hand-authored ValueSet. +The package's SNOMED value sets aren't demoed — they enumerate SNOMED extension codes the configured +external tx server doesn't resolve. This supersedes ADR-003's out-of-scope note. + +## Consequences +- **Positive**: cards pass strict validation; all three delivery surfaces are demonstrated; JWKS is + correct and reachable; issuance logic is shared and testable. +- **Trade-offs**: local `iss` is `http://localhost` (spec wants https + no trailing slash) and the + issuer is not VCI-listed — both are documented local-dev deviations. The inline QR is byte-mode only. + +## References +- [SMART Health Cards Framework](https://spec.smarthealth.cards/) +- [OperationDefinition: $health-cards-issue](https://hl7.org/fhir/uv/smart-health-cards-and-links/STU1/OperationDefinition-patient-i-health-cards-issue.html) +- [RFC 7638 — JWK Thumbprint](https://www.rfc-editor.org/rfc/rfc7638) +- [VCI Directory](https://github.com/the-commons-project/vci-directory) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml b/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml index f7387233..b67c815f 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml +++ b/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml @@ -21,7 +21,7 @@ services: depends_on: - postgres ports: - - 8080:8080 + - 8081:8080 volumes: - ./init-bundle:/app/init-bundle:ro environment: @@ -50,7 +50,7 @@ services: BOX_SECURITY_AUDIT_LOG_ENABLED: true BOX_SECURITY_DEV_MODE: true BOX_SETTINGS_MODE: read-write - BOX_WEB_BASE_URL: http://localhost:8080 + BOX_WEB_BASE_URL: http://localhost:8081 BOX_WEB_PORT: 8080 BOX_INIT_BUNDLE: file:///app/init-bundle/bundle.json healthcheck: @@ -63,13 +63,13 @@ services: health-cards-app: build: . ports: - - 3000:3000 + - 3001:3001 depends_on: aidbox: condition: service_healthy environment: # Server Configuration - PORT: 3000 + PORT: 3001 NODE_ENV: production # Aidbox Configuration @@ -78,7 +78,11 @@ services: AIDBOX_CLIENT_SECRET: secret # Health Cards Configuration - HEALTH_CARDS_ISSUER: https://example.org/health-cards + # iss MUST be the base whose /.well-known/jwks.json serves the key. + # Here that is the namespaced Aidbox App operation (see init-bundle App). + # NOTE: the spec requires https + no trailing slash; http/localhost is a + # local-dev deviation (prod: https + TLS 1.2 + VCI directory enrollment). + HEALTH_CARDS_ISSUER: http://localhost:8081/health-cards-app HEALTH_CARDS_KEY_PATH: ./keys/private-key.pem volumes: - ./keys:/app/keys:ro diff --git a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json index e6f5021f..b8a5a5cf 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json +++ b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json @@ -2,6 +2,18 @@ "resourceType": "Bundle", "type": "transaction", "entry": [ + { + "request": { "method": "POST", "url": "$fhir-package-install" }, + "resource": { + "resourceType": "Parameters", + "parameter": [ + { + "name": "package", + "valueString": "https://terminology.smarthealth.cards/package.tgz" + } + ] + } + }, { "resource": { "resourceType": "Client", @@ -38,7 +50,7 @@ "type": "app", "apiVersion": 1, "endpoint": { - "url": "http://health-cards-app:3000/health-cards-issue", + "url": "http://health-cards-app:3001/health-cards-issue", "secret": "health-cards-app-secret" }, "operations": { @@ -52,7 +64,10 @@ ".well-known", "jwks.json" ], - "method": "GET" + "method": "GET", + "policies": { + "jwks-public": { "engine": "allow" } + } } } }, @@ -253,6 +268,30 @@ "method": "PUT", "url": "Observation/example-covid-test" } + }, + { + "resource": { + "resourceType": "Immunization", + "id": "example-immunization-snomed", + "status": "completed", + "vaccineCode": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "1031000172108", + "display": "COVID-19 mRNA vaccine (BNT162b2) of Pfizer-BioNTech" + } + ] + }, + "patient": { + "reference": "Patient/example-patient" + }, + "occurrenceDateTime": "2023-10-01" + }, + "request": { + "method": "PUT", + "url": "Immunization/example-immunization-snomed" + } } ] } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/scripts/generate-keys.ts b/aidbox-custom-operations/aidbox-health-card-issue/scripts/generate-keys.ts index 75586d3d..b8252243 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/scripts/generate-keys.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/scripts/generate-keys.ts @@ -3,7 +3,6 @@ import * as jose from 'jose'; import * as fs from 'fs/promises'; import * as path from 'path'; -import { generateKeyId } from '../src/utils/key-utils'; const KEYS_DIR = path.join(__dirname, '..', 'keys'); const PRIVATE_KEY_PATH = path.join(KEYS_DIR, 'private-key.pem'); @@ -35,36 +34,35 @@ async function generateKeys(): Promise { // Export public key as JWK const jwk = await jose.exportJWK(publicKey); - // Generate consistent key ID using shared utility - const keyId = generateKeyId(publicKeyPem); + // SMART Health Cards REQUIRE kid to be the base64url-encoded SHA-256 JWK + // Thumbprint (RFC 7638) of the key. This is computed over the JWK members + // (kty, crv, x, y) — not the PEM — and is what the JWKS `kid` must equal. + const kid = await jose.calculateJwkThumbprint(jwk, 'sha256'); - // Add required fields for SMART Health Cards + // Add required fields for SMART Health Cards (RFC 7517 / spec §6) const healthCardJwk = { - ...jwk, kty: 'EC', use: 'sig', alg: 'ES256', - kid: keyId, + kid, crv: 'P-256', + x: jwk.x, + y: jwk.y, }; await fs.writeFile(JWK_PATH, JSON.stringify(healthCardJwk, null, 2), 'utf8'); - console.log(`Public key JWK saved to: ${JWK_PATH}`); - + console.log(`Public key JWK saved to: ${JWK_PATH} (kid=${kid})`); console.log('✅ Key generation completed successfully!'); - } catch (error) { console.error('Failed to generate keys:', error); process.exit(1); } } - if (require.main === module) { - generateKeys() - .catch((error) => { - console.error('Script failed:', error); - process.exit(1); - }); + generateKeys().catch(error => { + console.error('Script failed:', error); + process.exit(1); + }); } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/handlers/health-cards.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/handlers/health-cards.ts index 0e5afa4b..d197b7d4 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/handlers/health-cards.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/handlers/health-cards.ts @@ -1,144 +1,90 @@ import { Request, Response } from 'express'; -import { Config } from '../types/config'; -import { OperationRequest, HealthCardResponse } from '../types/operation'; -import { FHIRClient } from '../services/fhir-client'; -import { BundleBuilder } from '../services/bundle-builder'; -import { HealthCardService } from '../services/health-card'; +import { OperationRequest } from '../types/operation'; +import { HealthCardService, NoResourcesError } from '../services/health-card'; import { validateOperationRequest, extractParametersFromResource, } from '../utils/validation'; -export class HealthCardsHandler { - private fhirClient: FHIRClient; - private bundleBuilder: BundleBuilder; - private healthCardService: HealthCardService; +function operationOutcome( + code: string, + text: string | undefined, + severity: 'error' | 'information' = 'error' +): object { + return { + resourceType: 'OperationOutcome', + issue: [{ severity, code, details: { text } }], + }; +} - constructor(config: Config) { - this.fhirClient = new FHIRClient(config); - this.bundleBuilder = new BundleBuilder(); - this.healthCardService = new HealthCardService(config); - } +export class HealthCardsHandler { + constructor(private healthCardService: HealthCardService) {} async handleHealthCardsIssue(req: Request, res: Response): Promise { try { const operationRequest: OperationRequest = req.body; - // Validate the request const validation = validateOperationRequest(operationRequest); if (!validation.isValid) { - res.status(400).json({ - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'error', - code: 'invalid', - details: { text: validation.error }, - }, - ], - }); + res.status(400).json(operationOutcome('invalid', validation.error)); return; } const patientId = operationRequest.request['route-params'].id; - - // Extract operation parameters from FHIR Parameters resource const params = extractParametersFromResource( operationRequest.request.resource ); - const credentialTypes = params.credentialType; - const includeIdentityClaim = params.includeIdentityClaim; - const since = params._since; - const credentialValueSet = params.credentialValueSet; - // Validate credential types - if (!this.healthCardService.validateCredentialTypes(credentialTypes)) { - res.status(400).json({ - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'error', - code: 'not-supported', - details: { text: 'Unsupported credential type' }, - }, - ], - }); + if (!this.healthCardService.validateCredentialTypes(params.credentialType)) { + res + .status(400) + .json(operationOutcome('not-supported', 'Unsupported credential type')); return; } - // Fetch patient data - const patient = await this.fhirClient.getPatient(patientId); - - // Fetch clinical resources based on credential types - const resources = await this.fhirClient.getResourcesByType( + const { jws, resourceLinks } = await this.healthCardService.issueForPatient( patientId, - credentialTypes, - since - ); - - // Filter by value set if specified - const filteredResources = this.bundleBuilder.filterByValueSet( - resources, - credentialValueSet + params.credentialType, + { + since: params._since, + includeIdentityClaim: params.includeIdentityClaim, + credentialValueSet: params.credentialValueSet, + } ); - if (filteredResources.length === 0) { - res.status(404).json({ - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'information', - code: 'not-found', - details: { - text: 'No resources found for the specified criteria', - }, - }, + // OUT Parameters: the verifiableCredential plus a resourceLink per bundled + // resource, mapping each `resource:N` entry to its live FHIR URL. + const parameter: object[] = [{ name: 'verifiableCredential', valueString: jws }]; + for (const link of resourceLinks) { + parameter.push({ + name: 'resourceLink', + part: [ + { name: 'vcIndex', valueInteger: 0 }, + { name: 'bundledResource', valueUri: link.bundledResource }, + { name: 'hostedResource', valueUri: link.hostedResource }, ], }); - return; } - // Create FHIR Bundle - const bundle = this.bundleBuilder.createHealthCardBundle( - patient, - filteredResources, - includeIdentityClaim - ); - - // Generate health card - const healthCard = await this.healthCardService.generateHealthCard( - bundle, - credentialTypes - ); - - // Create response - const response: HealthCardResponse = { - resourceType: 'Parameters', - parameter: [ - { - name: 'verifiableCredential', - valueString: healthCard, - }, - ], - }; - - res.status(200).json(response); + res.status(200).json({ resourceType: 'Parameters', parameter }); } catch (error) { + if (error instanceof NoResourcesError) { + res + .status(404) + .json(operationOutcome('not-found', error.message, 'information')); + return; + } + // eslint-disable-next-line no-console console.error('Health cards operation error:', error); - - res.status(500).json({ - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'error', - code: 'exception', - details: { - text: 'Internal server error during health card generation', - }, - }, - ], - }); + res + .status(500) + .json( + operationOutcome( + 'exception', + 'Internal server error during health card generation' + ) + ); } } } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts index 57d26664..e88a27c3 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts @@ -1,10 +1,14 @@ import 'dotenv/config'; import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; +import * as fs from 'fs'; +import * as path from 'path'; import { loadConfig } from './types/config'; import { HealthCardsHandler } from './handlers/health-cards'; import { JWKSHandler } from './handlers/jwks'; import { JWKSService } from './services/jwks'; +import { HealthCardService, NoResourcesError } from './services/health-card'; +import { toQrNumeric, toFileBody } from './utils/shc-encode'; const app = express(); const config = loadConfig(); @@ -14,9 +18,9 @@ app.use(cors()); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); - -// Initialize services -const healthCardsHandler = new HealthCardsHandler(config); +// Services (shared across the Aidbox operation, the demo route, and download). +const healthCardService = new HealthCardService(config); +const healthCardsHandler = new HealthCardsHandler(healthCardService); const jwksService = new JWKSService({ keyId: config.jwks.keyId, publicKeyPath: config.jwks.publicKeyPath, @@ -24,17 +28,117 @@ const jwksService = new JWKSService({ }); const jwksHandler = new JWKSHandler(jwksService); +// The viewer is served directly by the app (like the SHL example). Read from +// the source tree so it works both under nodemon (cwd = project root) and in the +// built container (cwd = /app; tsc does not copy .html into dist/). +const VIEWER_PATH = path.join(process.cwd(), 'src', 'viewer.html'); + +const DEFAULT_PATIENT_ID = 'example-patient'; +const DEFAULT_CREDENTIAL_TYPE = 'https://smarthealth.cards#covid19'; + // Health check endpoint -app.get('/health', (req: Request, res: Response) => { +app.get('/health', (_req: Request, res: Response) => { res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString(), service: 'aidbox-health-card-issue', - version: '1.0.0' + version: '1.0.0', }); }); -// Main operation endpoint - matches Aidbox App configuration +// SHC issuer JWKS. Also published (canonically) through Aidbox at +// /.well-known/jwks.json via the namespaced App op; this same-origin route +// (CORS-enabled) is what the in-browser verifier fetches. +app.get('/.well-known/jwks.json', (req: Request, res: Response) => { + jwksHandler.handleWellKnownJWKS(req, res); +}); + +// Demo viewer (Issue + Verify). +app.get('/', (_req: Request, res: Response) => { + res.type('html').send(fs.readFileSync(VIEWER_PATH, 'utf8')); +}); + +// Patient list for the viewer's dropdown. +app.get('/demo/patients', async (_req: Request, res: Response) => { + try { + const patients = await healthCardService.listPatients(); + res.json(patients.length ? patients : [{ id: DEFAULT_PATIENT_ID, label: DEFAULT_PATIENT_ID }]); + } catch { + res.json([{ id: DEFAULT_PATIENT_ID, label: DEFAULT_PATIENT_ID }]); + } +}); + +// Unauthenticated demo trigger used by the viewer's Issue tab. In production the +// real entry point is the authenticated $health-cards-issue operation. +app.post('/demo/issue', async (req: Request, res: Response) => { + try { + const patientId = req.body.patientId || DEFAULT_PATIENT_ID; + const credentialType = req.body.credentialType || DEFAULT_CREDENTIAL_TYPE; + const since = req.body.since || undefined; + const credentialTypes = Array.isArray(credentialType) + ? credentialType + : [credentialType]; + const credentialValueSet = Array.isArray(req.body.credentialValueSet) + ? req.body.credentialValueSet.filter(Boolean) + : req.body.credentialValueSet + ? [req.body.credentialValueSet] + : undefined; + const identityClaims = Array.isArray(req.body.includeIdentityClaim) + ? req.body.includeIdentityClaim.filter(Boolean) + : undefined; + + const { jws, resourceLinks } = await healthCardService.issueForPatient(patientId, credentialTypes, { + includeIdentityClaim: identityClaims && identityClaims.length ? identityClaims : true, + since, + credentialValueSet, + }); + + res.json({ + verifiableCredential: jws, + qr: toQrNumeric(jws), + downloadUrl: `/download?patientId=${encodeURIComponent(patientId)}&credentialType=${encodeURIComponent(String(credentialType))}`, + issuer: config.healthCards.issuer, + resourceLinks, + }); + } catch (error) { + if (error instanceof NoResourcesError) { + res.status(404).json({ error: error.message }); + return; + } + // eslint-disable-next-line no-console + console.error('demo/issue error:', error); + res.status(500).json({ error: 'Failed to issue health card' }); + } +}); + +// SMART Health Card file download (application/smart-health-card). +app.get('/download', async (req: Request, res: Response) => { + try { + const patientId = String(req.query.patientId || DEFAULT_PATIENT_ID); + const credentialType = String(req.query.credentialType || DEFAULT_CREDENTIAL_TYPE); + + const { jws } = await healthCardService.issueForPatient(patientId, [credentialType], { + includeIdentityClaim: true, + }); + + res.setHeader('Content-Type', 'application/smart-health-card'); + res.setHeader( + 'Content-Disposition', + 'attachment; filename="covid19.smart-health-card"' + ); + res.status(200).send(JSON.stringify(toFileBody(jws))); + } catch (error) { + if (error instanceof NoResourcesError) { + res.status(404).json({ error: error.message }); + return; + } + // eslint-disable-next-line no-console + console.error('download error:', error); + res.status(500).json({ error: 'Failed to build health card file' }); + } +}); + +// Aidbox App operation dispatch (matches init-bundle App endpoint). app.post('/health-cards-issue', (req: Request, res: Response) => { const operationId = req.body.operation.id; switch (operationId) { @@ -47,22 +151,18 @@ app.post('/health-cards-issue', (req: Request, res: Response) => { default: res.status(400).json({ error: 'Bad request', - message: `Unsupported operation: ${operationId}` + message: `Unsupported operation: ${operationId}`, }); } }); // Error handling middleware -app.use((error: Error, req: Request, res: Response, next: NextFunction) => { +app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => { + // eslint-disable-next-line no-console console.error('Unhandled error:', error); - res.status(500).json({ resourceType: 'OperationOutcome', - issue: [{ - severity: 'error', - code: 'exception', - details: { text: 'Internal server error' } - }] + issue: [{ severity: 'error', code: 'exception', details: { text: 'Internal server error' } }], }); }); @@ -70,37 +170,41 @@ app.use((error: Error, req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response) => { res.status(404).json({ resourceType: 'OperationOutcome', - issue: [{ - severity: 'error', - code: 'not-found', - details: { text: `Endpoint not found: ${req.method} ${req.path}` } - }] + issue: [ + { + severity: 'error', + code: 'not-found', + details: { text: `Endpoint not found: ${req.method} ${req.path}` }, + }, + ], }); }); -// Initialize services and start server const PORT = config.server.port; -async function startServer() { +async function startServer(): Promise { try { - // Initialize JWKS service await jwksService.initialize(); + // eslint-disable-next-line no-console console.log('JWKS service initialized successfully'); - // Start the Express server app.listen(PORT, () => { + // eslint-disable-next-line no-console console.log(`Health Cards service started on port ${PORT}`); - console.log(`Health check: http://localhost:${PORT}/health`); - console.log(`Operation endpoint: http://localhost:${PORT}/health-cards-issue`); - console.log(`JWKS endpoint: http://localhost:${PORT}/.well-known/jwks.json`); + // eslint-disable-next-line no-console + console.log(`Viewer: http://localhost:${PORT}/`); + // eslint-disable-next-line no-console + console.log(`JWKS: http://localhost:${PORT}/.well-known/jwks.json`); + // eslint-disable-next-line no-console + console.log(`Issuer: ${config.healthCards.issuer}`); }); } catch (error) { + // eslint-disable-next-line no-console console.error('Failed to start server:', error); process.exit(1); } } -// Start the application startServer(); export default app; diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts index 4bf338c1..954ee789 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts @@ -1,47 +1,101 @@ import { FHIRBundle, FHIRResource } from '../types/health-card'; +/** + * `includeIdentityClaim` per the operation is a repeating string of claim paths + * (e.g. "Patient.name", "Patient.birthDate"). We also tolerate a boolean for + * backward compatibility: true → default claims, false → omit the Patient. + */ +export type IdentityClaimInput = boolean | string[] | undefined; + +/** Maps a minified card entry (`resource:N`) back to its source FHIR reference. */ +export interface ResourceLinkRef { + bundledResource: string; // e.g. "resource:0" + reference: string; // e.g. "Patient/example-patient" +} + +export interface BundleWithLinks { + bundle: FHIRBundle; + links: ResourceLinkRef[]; +} + +const DEFAULT_IDENTITY_CLAIMS = ['Patient.name', 'Patient.birthDate']; + export class BundleBuilder { createHealthCardBundle( patient: FHIRResource, resources: FHIRResource[], - includeIdentityClaim: boolean = true - ): FHIRBundle { - const bundleEntries: Array<{ resource: FHIRResource }> = []; - - // Add patient resource with identity claims - if (includeIdentityClaim) { - const patientCopy = this.createPatientWithIdentityClaims(patient); - bundleEntries.push({ resource: patientCopy }); + includeIdentityClaim: IdentityClaimInput = true + ): BundleWithLinks { + const claims = this.resolveIdentityClaims(includeIdentityClaim); + + // Ordered list of resources that will populate the bundle. The Patient (if + // included) is always resource:0 so clinical references resolve to it. + const ordered: FHIRResource[] = []; + if (claims) { + ordered.push(this.createPatientWithIdentityClaims(patient, claims)); } - - // Add clinical resources - resources.forEach(resource => { - const resourceCopy = this.sanitizeResource(resource); - bundleEntries.push({ resource: resourceCopy }); + ordered.push(...resources.map(r => this.sanitizeResource(r))); + + // Map original absolute references (ResourceType/id) → short resource:N URIs. + // Built from the ORIGINAL resources (ids are stripped during sanitization). + // The same mapping is surfaced as `links` for the resourceLink OUT param. + const refMap = new Map(); + const links: ResourceLinkRef[] = []; + const originals: FHIRResource[] = []; + if (claims) originals.push(patient); + originals.push(...resources); + originals.forEach((r, i) => { + if (r?.resourceType && r?.id) { + const bundledResource = `resource:${i}`; + refMap.set(`${r.resourceType}/${r.id}`, bundledResource); + links.push({ bundledResource, reference: `${r.resourceType}/${r.id}` }); + } }); + const entry = ordered.map((resource, i) => ({ + fullUrl: `resource:${i}`, + // Rewrite references, then drop empty elements (the validator rejects + // empty "", [], {} — and dropping a dangling reference can leave one). + resource: this.pruneEmpty(this.rewriteReferences(resource, refMap)), + })); + return { - resourceType: 'Bundle', - type: 'collection', - entry: bundleEntries, + bundle: { resourceType: 'Bundle', type: 'collection', entry }, + links, }; } - private createPatientWithIdentityClaims(patient: FHIRResource): FHIRResource { - const patientCopy: FHIRResource = { - resourceType: 'Patient', - }; - - // Include basic identity claims per SMART Health Cards spec - if (patient.name && patient.name.length > 0) { - patientCopy.name = patient.name.map((name: any) => ({ - family: name.family, - given: name.given, - })); + /** + * Normalizes the identity-claim input to the list of claim paths to include, + * or `null` when the Patient must be omitted entirely. + */ + private resolveIdentityClaims(input: IdentityClaimInput): string[] | null { + if (input === false) return null; + if (input === true || input === undefined) return DEFAULT_IDENTITY_CLAIMS; + if (Array.isArray(input)) { + return input.length > 0 ? input : DEFAULT_IDENTITY_CLAIMS; } + return DEFAULT_IDENTITY_CLAIMS; + } - if (patient.birthDate) { - patientCopy.birthDate = patient.birthDate; + private createPatientWithIdentityClaims( + patient: FHIRResource, + claims: string[] + ): FHIRResource { + const patientCopy: FHIRResource = { resourceType: 'Patient' }; + + for (const claim of claims) { + const field = claim.startsWith('Patient.') ? claim.slice('Patient.'.length) : claim; + + if (field === 'name' && Array.isArray(patient.name)) { + // Keep only family + given, drop use/text/period per data minimization. + patientCopy.name = patient.name.map((name: any) => ({ + family: name.family, + given: name.given, + })); + } else if (patient[field] !== undefined) { + patientCopy[field] = patient[field]; + } } return patientCopy; @@ -54,7 +108,6 @@ export class BundleBuilder { // - DomainResource.text elements // - CodeableConcept.text elements (recursively) // - Coding.display elements (recursively) - return this.deepSanitize(resource); } @@ -71,12 +124,13 @@ export class BundleBuilder { const sanitized: any = {}; for (const [key, value] of Object.entries(obj)) { - // Skip mandatory SMART Health Cards removals + // Mandatory removals: Resource.id and any *.text (DomainResource.text, + // CodeableConcept.text). if (key === 'id' || key === 'text') { continue; } - // Handle meta - only keep .meta.security if present + // meta: keep only .meta.security if present, drop everything else. if (key === 'meta') { if (value && typeof value === 'object' && (value as any).security) { sanitized[key] = { security: (value as any).security }; @@ -84,17 +138,11 @@ export class BundleBuilder { continue; } - // For CodeableConcept objects, remove .text - if (key === 'text' && obj.coding) { - continue; // Skip text field in CodeableConcept - } - - // For Coding objects, remove .display + // Coding.display — drop when this object looks like a Coding. if (key === 'display' && (obj.system || obj.code)) { - continue; // Skip display field in Coding + continue; } - // Recursively sanitize nested objects and arrays sanitized[key] = this.deepSanitize(value); } @@ -104,20 +152,57 @@ export class BundleBuilder { return obj; } - filterByValueSet( - resources: FHIRResource[], - valueSetUrl?: string - ): FHIRResource[] { - if (!valueSetUrl) { - return resources; + /** + * Rewrites every `Reference.reference` that targets a bundled resource to its + * short `resource:N` URI. References to resources not in the bundle are left + * untouched. + */ + private rewriteReferences(obj: any, refMap: Map): any { + if (obj === null || obj === undefined) { + return obj; } - // Basic value set filtering - in a real implementation, - // this would validate against actual FHIR ValueSet resources - // eslint-disable-next-line no-console - console.warn( - `Value set filtering not fully implemented for: ${valueSetUrl}` - ); - return resources; + if (Array.isArray(obj)) { + return obj.map(item => this.rewriteReferences(item, refMap)); + } + + if (typeof obj === 'object') { + const out: any = {}; + for (const [key, value] of Object.entries(obj)) { + if (key === 'reference' && typeof value === 'string') { + // Rewrite refs to bundled resources; drop dangling references (a card + // reference must be a resolvable resource:N URI). + if (refMap.has(value)) out[key] = refMap.get(value); + } else { + out[key] = this.rewriteReferences(value, refMap); + } + } + return out; + } + + return obj; + } + + private isEmpty(v: any): boolean { + if (v === null || v === undefined || v === '') return true; + if (Array.isArray(v)) return v.length === 0; + if (typeof v === 'object') return Object.keys(v).length === 0; + return false; // numbers/booleans (incl. 0/false) are not empty + } + + /** Recursively remove empty strings, arrays, and objects (invalid in a card). */ + private pruneEmpty(obj: any): any { + if (Array.isArray(obj)) { + return obj.map(x => this.pruneEmpty(x)).filter(x => !this.isEmpty(x)); + } + if (obj && typeof obj === 'object') { + const out: any = {}; + for (const [k, v] of Object.entries(obj)) { + const pv = this.pruneEmpty(v); + if (!this.isEmpty(pv)) out[k] = pv; + } + return out; + } + return obj; } } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts index d012c5da..2123cdd2 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts @@ -3,6 +3,13 @@ import { Config } from '../types/config'; import { FHIRResource, } from '../types/health-card'; +import { COVID19_CREDENTIAL_TYPE } from '../utils/credential-utils'; + +// CVX codes for COVID-19 vaccines (used to filter Immunizations for #covid19). +const COVID19_CVX_CODES = [ + '207', '208', '210', '211', '212', '213', '217', '218', '219', '221', + '225', '227', '228', '229', '230', '300', '301', '302', +]; export class FHIRClient { private client: AxiosInstance; @@ -23,6 +30,43 @@ export class FHIRClient { }); } + /** + * True if `system|code` is a member of the ValueSet, via Aidbox terminology + * (`ValueSet/$validate-code`). Used to filter resources by `credentialValueSet`. + */ + async validateCode( + valueSetUrl: string, + system: string, + code: string + ): Promise { + try { + const response = await this.client.get('/ValueSet/$validate-code', { + params: { url: valueSetUrl, system, code }, + }); + const result = (response.data?.parameter || []).find( + (p: any) => p.name === 'result' + ); + return result?.valueBoolean === true; + } catch { + return false; + } + } + + async listPatients(): Promise> { + try { + const response = await this.client.get('/Patient?_count=50&_elements=name'); + const bundle = response.data; + return (bundle.entry || []).map((entry: any) => { + const p = entry.resource; + const n = Array.isArray(p.name) ? p.name[0] : undefined; + const name = n ? `${(n.given || []).join(' ')} ${n.family || ''}`.trim() : ''; + return { id: p.id, label: name ? `${name} (${p.id})` : p.id }; + }); + } catch { + return []; + } + } + async getPatient(patientId: string): Promise { try { const response = await this.client.get(`/Patient/${patientId}`); @@ -88,6 +132,13 @@ export class FHIRClient { const resources: FHIRResource[] = []; for (const type of credentialTypes) { + // VCI covid19 credential → COVID-19 Immunizations only. + if (type === COVID19_CREDENTIAL_TYPE) { + const immunizations = await this.getImmunizations(patientId, since); + resources.push(...immunizations.filter(isCovid19Immunization)); + continue; + } + switch (type.toLowerCase()) { case 'immunization': const immunizations = await this.getImmunizations(patientId, since); @@ -106,3 +157,16 @@ export class FHIRClient { return resources; } } + +/** + * True when the Immunization carries a CVX code known to be a COVID-19 vaccine. + */ +function isCovid19Immunization(immunization: FHIRResource): boolean { + const codings = immunization?.vaccineCode?.coding; + if (!Array.isArray(codings)) return false; + return codings.some( + (c: any) => + c?.system === 'http://hl7.org/fhir/sid/cvx' && + COVID19_CVX_CODES.includes(String(c?.code)) + ); +} diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts index 29e6485c..2587ea60 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts @@ -1,10 +1,43 @@ import { Config } from '../types/config'; -import { FHIRBundle } from '../types/health-card'; +import { FHIRBundle, FHIRResource } from '../types/health-card'; import { CryptoUtils } from '../utils/crypto'; +import { FHIRClient } from './fhir-client'; +import { BundleBuilder, IdentityClaimInput } from './bundle-builder'; import { validateCredentialTypes } from '../utils/credential-utils'; +/** + * Thrown when a patient has no resources matching the requested criteria, so + * that callers can map it to a 404 (vs a 500 for unexpected failures). + */ +export class NoResourcesError extends Error { + constructor(message = 'No resources found for the specified criteria') { + super(message); + this.name = 'NoResourcesError'; + } +} + +export interface IssueOptions { + since?: string; + includeIdentityClaim?: IdentityClaimInput; + credentialValueSet?: string[]; +} + +/** Links a `resource:N` entry in the card to the live FHIR resource it came from. */ +export interface ResourceLink { + bundledResource: string; // "resource:N" inside the card's fhirBundle + hostedResource: string; // absolute URL of the live FHIR resource +} + +export interface IssueResult { + jws: string; + resourceLinks: ResourceLink[]; +} + export class HealthCardService { private crypto: CryptoUtils; + private fhir: FHIRClient; + private bundleBuilder: BundleBuilder; + private fhirBaseUrl: string; constructor(config: Config) { this.crypto = new CryptoUtils( @@ -12,25 +45,104 @@ export class HealthCardService { config.healthCards.issuer, config.jwks.keyId ); + this.fhir = new FHIRClient(config); + this.bundleBuilder = new BundleBuilder(); + this.fhirBaseUrl = config.healthCards.fhirBaseUrl; } - async generateHealthCard( - bundle: FHIRBundle, - credentialTypes: string[] - ): Promise { - try { - const healthCard = await this.crypto.generateHealthCard( - bundle, - credentialTypes - ); - return healthCard; - } catch (error) { - throw new Error(`Failed to generate health card: ${error}`); + validateCredentialTypes(credentialTypes: string[]): boolean { + return validateCredentialTypes(credentialTypes); + } + + listPatients(): Promise> { + return this.fhir.listPatients(); + } + + /** + * Full issuance pipeline: fetch the patient + clinical resources, assemble the + * minified FHIR Bundle, and sign it into a SMART Health Card (JWS). Shared by + * the Aidbox operation handler, the demo route, QR, and file download. + */ + async issueForPatient( + patientId: string, + credentialTypes: string[], + opts: IssueOptions = {} + ): Promise { + const patient = await this.fhir.getPatient(patientId); + const resources = await this.fhir.getResourcesByType( + patientId, + credentialTypes, + opts.since + ); + const filtered = await this.filterByValueSet( + resources, + opts.credentialValueSet + ); + + if (filtered.length === 0) { + throw new NoResourcesError(); } + + const { bundle, links } = this.bundleBuilder.createHealthCardBundle( + patient, + filtered, + opts.includeIdentityClaim + ); + + const jws = await this.crypto.generateHealthCard(bundle, credentialTypes); + const resourceLinks = links.map(l => ({ + bundledResource: l.bundledResource, + hostedResource: `${this.fhirBaseUrl}/${l.reference}`, + })); + + return { jws, resourceLinks }; } - validateCredentialTypes(credentialTypes: string[]): boolean { - return validateCredentialTypes(credentialTypes); + /** + * Keep only resources whose code (Immunization.vaccineCode / Observation.code) + * is a member of the given ValueSet, using Aidbox terminology. No-op when no + * credentialValueSet is provided. + */ + private async filterByValueSet( + resources: FHIRResource[], + valueSetUrls?: string[] + ): Promise { + if (!valueSetUrls || valueSetUrls.length === 0) return resources; + + const kept: FHIRResource[] = []; + for (const r of resources) { + const codings = this.extractCodings(r); + // Multiple credentialValueSet values are logical AND: the resource must + // have a code that is a member of every specified ValueSet. + let inAll = true; + for (const vs of valueSetUrls) { + let inThis = false; + for (const c of codings) { + if (c.system && c.code && (await this.fhir.validateCode(vs, c.system, c.code))) { + inThis = true; + break; + } + } + if (!inThis) { + inAll = false; + break; + } + } + if (inAll) kept.push(r); + } + return kept; } + private extractCodings(resource: FHIRResource): Array<{ system?: string; code?: string }> { + const cc = resource.vaccineCode || resource.code; // Immunization / Observation + return Array.isArray(cc?.coding) ? cc.coding : []; + } + + /** Sign a pre-built bundle (kept for direct/testing use). */ + async generateHealthCard( + bundle: FHIRBundle, + credentialTypes: string[] + ): Promise { + return this.crypto.generateHealthCard(bundle, credentialTypes); + } } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts index 6b186a4f..83138bc6 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; + export interface Config { aidbox: { baseUrl: string; @@ -7,6 +9,7 @@ export interface Config { healthCards: { issuer: string; keyPath: string; + fhirBaseUrl: string; }; jwks: { keyId: string; @@ -17,7 +20,26 @@ export interface Config { }; } -import { generateKeyIdFromFile } from '../utils/key-utils'; +/** + * Reads the `kid` from the generated JWK file so that the signing `kid` + * (used in the JWS header) is always identical to the `kid` published in the + * JWKS. The JWK is written by `scripts/generate-keys.ts` with + * `kid` = base64url SHA-256 JWK Thumbprint (RFC 7638), as the spec requires. + */ +function readKeyIdFromJwk(publicKeyPath: string): string { + const jwkPath = publicKeyPath.replace('.pem', '.jwk.json'); + try { + const jwk = JSON.parse(fs.readFileSync(jwkPath, 'utf8')); + if (!jwk.kid) { + throw new Error('JWK is missing "kid"'); + } + return jwk.kid; + } catch (error) { + throw new Error( + `Failed to read key id from ${jwkPath}. Run "npm run generate-keys" first. (${error})` + ); + } +} export function loadConfig(): Config { const requiredEnvVars = [ @@ -34,10 +56,10 @@ export function loadConfig(): Config { } } - // Generate default values for optional JWKS settings const publicKeyPath = process.env.JWKS_PUBLIC_KEY_PATH || './keys/public-key.pem'; - const keyId = process.env.JWKS_KEY_ID || generateKeyIdFromFile(publicKeyPath); + // kid is derived from the JWK thumbprint; JWKS_KEY_ID may override for testing. + const keyId = process.env.JWKS_KEY_ID || readKeyIdFromJwk(publicKeyPath); return { aidbox: { @@ -48,14 +70,15 @@ export function loadConfig(): Config { healthCards: { issuer: process.env.HEALTH_CARDS_ISSUER!, keyPath: process.env.HEALTH_CARDS_KEY_PATH!, + // Public FHIR base for resourceLink.hostedResource (issuer origin + /fhir). + fhirBaseUrl: new URL(process.env.HEALTH_CARDS_ISSUER!).origin + '/fhir', }, jwks: { keyId, publicKeyPath, }, server: { - port: parseInt(process.env.PORT || '3000', 10), + port: parseInt(process.env.PORT || '3001', 10), }, }; } - diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/types/health-card.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/types/health-card.ts index c6a9ae3c..75cdcb59 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/types/health-card.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/types/health-card.ts @@ -1,7 +1,8 @@ export interface HealthCardPayload { iss: string; // Issuer URL - nbf: number; // Not before timestamp + nbf: number; // Not before (seconds since epoch) + exp?: number; // Optional expiration (seconds since epoch) vc: { type: string[]; credentialSubject: { @@ -16,6 +17,7 @@ export interface FHIRBundle { resourceType: 'Bundle'; type: 'collection'; entry: Array<{ + fullUrl?: string; // short resource-scheme URI, e.g. "resource:0" resource: FHIRResource; }>; } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/types/operation.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/types/operation.ts index a6d87555..8c153a11 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/types/operation.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/types/operation.ts @@ -9,8 +9,10 @@ export interface OperationRequest { parameter: Array<{ name: string; valueString?: string; + valueUri?: string; valueBoolean?: boolean; valueInstant?: string; + valueDateTime?: string; }>; }; 'route-params': { diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/credential-utils.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/credential-utils.ts index 6c46b8d0..aed07005 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/credential-utils.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/credential-utils.ts @@ -1,28 +1,42 @@ /** - * Supported credential types for SMART Health Cards - * Centralized definition to ensure consistency across the application + * Supported credential types for the $health-cards-issue operation. + * + * Two styles are accepted: + * - generic HL7-IG form: FHIR Resource Types ("Immunization", "Observation") + * - VCI form: "https://smarthealth.cards#covid19" */ -export const SUPPORTED_CREDENTIAL_TYPES = ['Immunization', 'Observation'] as const; +export const COVID19_CREDENTIAL_TYPE = 'https://smarthealth.cards#covid19'; -export type SupportedCredentialType = typeof SUPPORTED_CREDENTIAL_TYPES[number]; +export const SUPPORTED_CREDENTIAL_TYPES = [ + 'Immunization', + 'Observation', + COVID19_CREDENTIAL_TYPE, +] as const; + +export type SupportedCredentialType = (typeof SUPPORTED_CREDENTIAL_TYPES)[number]; /** - * Validates if a single credential type is supported + * Validates if a single credential type is supported (case-insensitive for the + * FHIR resource-type form; exact for the smarthealth.cards URI form). */ -export function isValidCredentialType(type: string): type is SupportedCredentialType { - return SUPPORTED_CREDENTIAL_TYPES.includes(type as SupportedCredentialType); +export function isValidCredentialType(type: string): boolean { + if (type === COVID19_CREDENTIAL_TYPE) return true; + const lower = type.toLowerCase(); + return SUPPORTED_CREDENTIAL_TYPES.some( + t => t !== COVID19_CREDENTIAL_TYPE && t.toLowerCase() === lower + ); } /** - * Validates if all credential types in an array are supported + * Validates if all credential types in an array are supported. */ export function validateCredentialTypes(credentialTypes: string[]): boolean { return credentialTypes.every(type => isValidCredentialType(type)); } /** - * Gets the list of supported credential types + * Gets the list of supported credential types. */ export function getSupportedCredentialTypes(): readonly string[] { return SUPPORTED_CREDENTIAL_TYPES; -} \ No newline at end of file +} diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts index eec5e989..090b3376 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts @@ -1,7 +1,10 @@ import * as fs from 'fs'; -import { SignJWT, importPKCS8 } from 'jose'; +import { deflateRawSync } from 'zlib'; +import { CompactSign, importPKCS8 } from 'jose'; import { HealthCardPayload, FHIRBundle } from '../types/health-card'; +const COVID19_TYPE = 'https://smarthealth.cards#covid19'; + export class CryptoUtils { private privateKey: any; private issuer: string; @@ -33,15 +36,12 @@ export class CryptoUtils { throw new Error('Private key not loaded'); } - // Determine the credential type for VC - const vcType = this.getCredentialType(credentialTypes); - - // Create the verifiable credential payload + // Build the verifiable credential payload (SMART Health Cards §5) const payload: HealthCardPayload = { iss: this.issuer, - nbf: Math.floor(Date.now() / 1000), // Current timestamp + nbf: Math.floor(Date.now() / 1000), // seconds since epoch vc: { - type: ['https://smarthealth.cards#health-card', vcType], + type: this.buildVcTypes(credentialTypes), credentialSubject: { fhirVersion: '4.0.1', fhirBundle: bundle, @@ -49,26 +49,54 @@ export class CryptoUtils { }, }; - // Sign the payload using JWS - const jws = await new SignJWT(payload) + // The SMART Health Cards spec requires the JWS payload to be minified + // (JSON.stringify omits optional whitespace) and DEFLATE-compressed (raw, + // no zlib/gz header) BEFORE signing, with the header advertising zip:"DEF". + // jose's SignJWT does NOT compress for JWS, so we compress explicitly and + // sign the compressed bytes with CompactSign. + // Max compression (level 9) keeps the card small so it fits a QR <= v22. + const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload), 'utf8'), { + level: 9, + }); + + const jws = await new CompactSign(compressed) .setProtectedHeader({ alg: 'ES256', zip: 'DEF', - kid: this.keyId, // Use consistent key identifier + kid: this.keyId, // base64url SHA-256 JWK thumbprint (RFC 7638) }) .sign(this.privateKey); return jws; } - private getCredentialType(credentialTypes: string[]): string { - // Map FHIR resource types to SMART Health Cards credential types - if (credentialTypes.includes('Immunization')) { - return 'https://smarthealth.cards#immunization'; - } else if (credentialTypes.includes('Observation')) { - return 'https://smarthealth.cards#laboratory'; - } else { - return 'https://smarthealth.cards#health-card'; + /** + * Build the `vc.type` array. `https://smarthealth.cards#health-card` is always + * present; more specific types are added when they apply (spec: "other types + * SHOULD be included when they apply"). Supports both the generic FHIR + * resource-type credentials (Immunization/Observation) and the #covid19 VCI type. + */ + private buildVcTypes(credentialTypes: string[]): string[] { + const types = new Set(['https://smarthealth.cards#health-card']); + + const wantsCovid = credentialTypes.includes(COVID19_TYPE); + const wantsImmunization = credentialTypes.some( + t => t.toLowerCase() === 'immunization' + ); + const wantsObservation = credentialTypes.some( + t => t.toLowerCase() === 'observation' + ); + + if (wantsImmunization || wantsCovid) { + types.add('https://smarthealth.cards#immunization'); + } + if (wantsCovid) { + types.add(COVID19_TYPE); } + if (wantsObservation) { + types.add('https://smarthealth.cards#laboratory'); + } + + return [...types]; } } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/key-utils.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/key-utils.ts deleted file mode 100644 index a6a023f0..00000000 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/key-utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as crypto from 'crypto'; - -/** - * Generates a consistent key ID based on the public key content - * Uses SHA-256 hash of the key content and returns first 16 characters - */ -export function generateKeyId(publicKeyPem: string): string { - const hash = crypto.createHash('sha256').update(publicKeyPem).digest('hex'); - return hash.substring(0, 16); -} - -/** - * Generates a key ID from a file path with fallback to timestamp-based ID - * Used for configuration loading when file might not exist yet - */ -export function generateKeyIdFromFile(publicKeyPath: string): string { - try { - const fs = require('fs'); - const publicKeyPem = fs.readFileSync(publicKeyPath, 'utf8'); - return generateKeyId(publicKeyPem); - } catch (error) { - // Fallback to timestamp-based key ID if file doesn't exist yet - return `key-${Date.now().toString().slice(-8)}`; - } -} \ No newline at end of file diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/shc-encode.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/shc-encode.ts new file mode 100644 index 00000000..109a387e --- /dev/null +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/shc-encode.ts @@ -0,0 +1,25 @@ +/** + * SMART Health Cards delivery encodings (spec §6 QR and §5 file download). + */ + +/** + * Encode a JWS as the numeric `shc:/` QR payload: each character `c` becomes the + * two-digit number `Ord(c) - 45`. Single-chunk only (multi-chunk is deprecated + * as of Dec 2022). A compact COVID card fits a single QR. + */ +export function toQrNumeric(jws: string): string { + let digits = ''; + for (let i = 0; i < jws.length; i++) { + const v = jws.charCodeAt(i) - 45; + digits += v.toString().padStart(2, '0'); + } + return `shc:/${digits}`; +} + +/** + * The `.smart-health-card` file body: a JSON object with a `verifiableCredential` + * array of one or more JWS strings. Served as `application/smart-health-card`. + */ +export function toFileBody(jws: string | string[]): { verifiableCredential: string[] } { + return { verifiableCredential: Array.isArray(jws) ? jws : [jws] }; +} diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts index cfdf0498..04e123ff 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts @@ -47,22 +47,22 @@ export function validateOperationRequest( return { isValid: true }; } - function isValidDateTimeString(dateTime: string): boolean { try { const date = new Date(dateTime); - return !isNaN(date.getTime()) && dateTime.includes('T'); + // Accept FHIR date (YYYY-MM-DD) as well as full dateTime/instant. + return !isNaN(date.getTime()); } catch { return false; } } - - -interface ExtractedParameters { +export interface ExtractedParameters { credentialType: string[]; - credentialValueSet?: string; - includeIdentityClaim: boolean; + credentialValueSet?: string[]; + // Per the operation, includeIdentityClaim is a repeating string of claim + // paths (e.g. "Patient.name"). A boolean is tolerated for backward compat. + includeIdentityClaim: boolean | string[]; _since?: string; } @@ -78,40 +78,54 @@ export function extractParametersFromResource( return defaults; } - const extracted = resource.parameter.reduce( - (acc: ExtractedParameters, param: any) => { - const value = - param.valueString ?? param.valueBoolean ?? param.valueInstant; - - if (!value) return acc; - - switch (param.name) { - case 'credentialType': - acc.credentialType = acc.credentialType || []; - acc.credentialType.push(value); - break; - case 'credentialValueSet': - acc.credentialValueSet = value; - break; - case 'includeIdentityClaim': - acc.includeIdentityClaim = value; - break; - case '_since': - acc._since = value; - break; + const credentialType: string[] = []; + const identityClaims: string[] = []; + const credentialValueSet: string[] = []; + let identityBoolean: boolean | undefined; + let since: string | undefined; + + for (const param of resource.parameter) { + switch (param.name) { + case 'credentialType': { + // Spec type is uri; tolerate valueString for compatibility. + const v = param.valueUri ?? param.valueString; + if (v) credentialType.push(v); + break; + } + case 'credentialValueSet': { + const v = param.valueUri ?? param.valueString; + if (v) credentialValueSet.push(v); + break; + } + case 'includeIdentityClaim': { + // Spec type is string (repeating claim paths). Tolerate boolean. + if (typeof param.valueBoolean === 'boolean') { + identityBoolean = param.valueBoolean; + } else if (param.valueString) { + identityClaims.push(param.valueString); + } + break; } + case '_since': { + // Spec type is dateTime; tolerate instant/string. + const v = param.valueDateTime ?? param.valueInstant ?? param.valueString; + if (v) since = v; + break; + } + } + } - return acc; - }, - {} as Partial - ); + const includeIdentityClaim: boolean | string[] = + identityClaims.length > 0 + ? identityClaims + : identityBoolean !== undefined + ? identityBoolean + : defaults.includeIdentityClaim; return { - ...defaults, - ...extracted, - credentialType: extracted.credentialType?.length - ? extracted.credentialType - : defaults.credentialType, + credentialType: credentialType.length ? credentialType : defaults.credentialType, + credentialValueSet: credentialValueSet.length ? credentialValueSet : undefined, + includeIdentityClaim, + _since: since, }; } - diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html new file mode 100644 index 00000000..c992cfcc --- /dev/null +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -0,0 +1,635 @@ + + + + + +SMART Health Card · Issue & Verify + + + +
+
+

SMART Health Card · Issue & Verify

+

Issue a verifiable health card

+

Issue a signed SMART Health Card (JWS/ES256) from patient data in Aidbox, get it as a QR and a .smart-health-card file, then verify its signature against the issuer's JWKS — right here in the browser.

+
+ +
+
+ + +
+ + +
+
+ + +
+
+ +
+ +
+
+ + +

Only resources with _lastUpdated ≥ this date (server write time, not clinical date). A future date returns nothing.

+
+ + + + +
+ +
+
+ + + +
+ + + + + + + + + + + + + + +
+ + + +