diff --git a/cookbook/multi_ehr_data_aggregation.py b/cookbook/multi_ehr_data_aggregation.py index fc6bf272..16fb55f6 100644 --- a/cookbook/multi_ehr_data_aggregation.py +++ b/cookbook/multi_ehr_data_aggregation.py @@ -8,6 +8,10 @@ Requirements: - pip install healthchain python-dotenv +FHIR Sources: +- Epic Sandbox: Set EPIC_* environment variables +- Cerner Open Sandbox: No auth needed + Run: - python data_aggregation.py """ @@ -96,9 +100,7 @@ def get_unified_patient(patient_id: str, sources: List[str]) -> Bundle: doc = Document(data=merged_bundle) doc = pipeline(doc) - # print([outcome.model_dump() for outcome in doc.fhir.operation_outcomes]) - - return doc.fhir.bundle.model_dump() + return doc.fhir.bundle app = HealthChainAPI() app.register_gateway(gateway) diff --git a/docs/cookbook/ml_model_deployment.md b/docs/cookbook/ml_model_deployment.md index 9c878a15..8d3fde0d 100644 --- a/docs/cookbook/ml_model_deployment.md +++ b/docs/cookbook/ml_model_deployment.md @@ -230,7 +230,7 @@ No FHIR parsing code needed—define the mapping once, use it everywhere. !!! tip "Explore Interactively" - Step through the full flow in [notebooks/fhir_ml_workflow.ipynb](../../notebooks/fhir_ml_workflow.ipynb): FHIR bundle → Dataset → DataFrame → inference → RiskAssessment. + Step through the full flow in [notebooks/fhir_ml_workflow.ipynb](https://github.com/dotimplement/HealthChain/blob/main/notebooks/fhir_ml_workflow.ipynb): FHIR bundle → Dataset → DataFrame → inference → RiskAssessment. Now let's see how this pipeline plugs into each deployment pattern. diff --git a/docs/cookbook/setup_fhir_sandboxes.md b/docs/cookbook/setup_fhir_sandboxes.md index 8df24754..b4f1cf30 100644 --- a/docs/cookbook/setup_fhir_sandboxes.md +++ b/docs/cookbook/setup_fhir_sandboxes.md @@ -46,11 +46,41 @@ openssl req -new -x509 -key privatekey.pem -out publickey509.pem -subj '/CN=myap Where `/CN=myapp` is the subject name (e.g., your app name). The subject name doesn't have functional impact but is required for creating an X.509 certificate. -#### Upload Public Key +#### Register Public Key via JWKS URL -1. In your Epic app configuration, upload the `publickey509.pem` file -2. Click **Save** -3. Note down your **Non-Production Client ID** +Epic now requires registering your public key via a **JWKS (JSON Web Key Set) URL** instead of direct file upload. For quick and dirty development/testing purposes, you can use ngrok to expose your JWKS server publicly. + +1. **Set up a JWKS server**: + ```bash + # Ensure your .env has the private key path + # EPIC_CLIENT_SECRET_PATH=path/to/privatekey.pem + # EPIC_KEY_ID=healthchain-demo-key + + python scripts/serve_jwks.py + ``` + +2. **Get a free static domain from ngrok**: + - Sign up at [ngrok.com](https://ngrok.com) + - Claim your free static domain from the dashboard + - Example: `your-app.ngrok-free.app` + +3. **Expose your JWKS server**: + ```bash + ngrok http 9999 --domain=your-app.ngrok-free.app + ``` + +4. **Register in Epic App Orchard**: + + - In your Epic app configuration, locate the **Non-Production JWK Set URL** field + - Enter: `https://your-app.ngrok-free.app/.well-known/jwks.json` + - Click **Save** + - Note down your **Non-Production Client ID** + +The JWKS must be: + +- Publicly accessible without authentication +- Served over HTTPS +- Stable (URL should not change) ![Epic Sandbox Client ID](../assets/images/epicsandbox3.png) @@ -73,8 +103,11 @@ EPIC_CLIENT_ID=your_non_production_client_id EPIC_CLIENT_SECRET_PATH=path/to/privatekey.pem EPIC_TOKEN_URL=https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token EPIC_USE_JWT_ASSERTION=true +EPIC_KEY_ID=healthchain-demo-key # Must match the kid in your JWKS ``` +**Important**: The `EPIC_KEY_ID` must match the Key ID (`kid`) you used when creating your JWKS. This allows Epic to identify which key to use for JWT verification. + ### Using Epic Sandbox in Code ```python @@ -91,6 +124,20 @@ gateway = FHIRGateway() gateway.add_source("epic", EPIC_URL) ``` +### Testing Your Connection + +After configuration: + +```bash +python scripts/check_epic_connection.py +``` + +This script will: +1. Load your Epic configuration +2. Create a JWT assertion with the `kid` header +3. Request an access token from Epic +4. Test a FHIR endpoint query + ### Available Test Patients Epic provides [sample test patients](https://fhir.epic.com/Documentation?docId=testpatients) including: @@ -99,6 +146,18 @@ Epic provides [sample test patients](https://fhir.epic.com/Documentation?docId=t - **Linda Ross** - Patient ID: `eIXesllypH3M9tAA5WdJftQ3` - Many others with various clinical scenarios +???+ note "Troubleshooting (click to expand)" + **Token request fails after JWKS registration:** + - Wait 15-30 minutes for Epic to propagate changes + - Verify your JWKS URL is publicly accessible (test in browser) + - Check that `EPIC_KEY_ID` matches the `kid` in your JWKS + - Ensure the ngrok tunnel is still running + + **JWKS format errors:** + - Verify the JWKS structure at your URL matches Epic's requirements + - Check that `n` and `e` are properly base64url encoded (no padding) + - Algorithm should be RS384, RS256, or RS512 + --- ## Cerner Sandbox diff --git a/docs/reference/gateway/soap_cda.md b/docs/reference/gateway/soap_cda.md index c57f7053..31464233 100644 --- a/docs/reference/gateway/soap_cda.md +++ b/docs/reference/gateway/soap_cda.md @@ -161,4 +161,4 @@ The response includes additional structured sections extracted from the clinical | Gateway Receives | `CdaRequest` | Processed by your service | | Gateway Returns | Your processed result | `CdaResponse` | -You can use the [CdaAdapter](../pipeline/adapters/cdaadapter.md) to handle conversion between CDA documents and HealthChain pipeline data containers. +You can use the [CdaAdapter](../io/adapters/cdaadapter.md) to handle conversion between CDA documents and HealthChain pipeline data containers. diff --git a/docs/reference/io/adapters/adapters.md b/docs/reference/io/adapters/adapters.md index cc94e725..5d540142 100644 --- a/docs/reference/io/adapters/adapters.md +++ b/docs/reference/io/adapters/adapters.md @@ -8,7 +8,7 @@ Unlike the legacy connector pattern, adapters are used explicitly and provide cl Adapters parse data from specific healthcare formats into FHIR resources and store them in a `Document` container for processing. -([Document API Reference](../../api/containers.md#healthchain.io.containers.document.Document)) +([Document API Reference](../../../api/containers.md#healthchain.io.containers.document)) | Adapter | Input Format | Output Format | FHIR Resources | Document Access | |---------|--------------|---------------|----------------|-----------------| @@ -67,7 +67,7 @@ print(f"Allergies: {doc.fhir.allergy_list}") response = adapter.format(doc) # Document → CdaResponse ``` -For more details on the Document container, see [Document](../containers/document.md). +For more details on the Document container, see [Document](../../io/containers/document.md). ## Adapter Configuration diff --git a/docs/reference/io/containers/dataset.md b/docs/reference/io/containers/dataset.md index af2bd421..04749db9 100644 --- a/docs/reference/io/containers/dataset.md +++ b/docs/reference/io/containers/dataset.md @@ -108,4 +108,4 @@ dataset.remove_column('temp_feature') # Drop a feature ## API Reference -See the [Dataset API Reference](../../api/containers.md#healthchain.io.containers.dataset) for detailed class documentation. +See the [Dataset API Reference](../../../api/containers.md#healthchain.io.containers.dataset) for detailed class documentation. diff --git a/docs/reference/io/containers/document.md b/docs/reference/io/containers/document.md index fecfda3b..a43fc3e1 100644 --- a/docs/reference/io/containers/document.md +++ b/docs/reference/io/containers/document.md @@ -232,4 +232,4 @@ print(doc.models.get_output("my_model", "task")) ## API Reference -See [Document API Reference](../../api/containers.md#healthchain.io.containers.document) for full details. +See [Document API Reference](../../../api/containers.md#healthchain.io.containers.document) for full details. diff --git a/docs/reference/pipeline/components/fhirproblemextractor.md b/docs/reference/pipeline/components/fhirproblemextractor.md index add22c59..19e0c836 100644 --- a/docs/reference/pipeline/components/fhirproblemextractor.md +++ b/docs/reference/pipeline/components/fhirproblemextractor.md @@ -104,4 +104,4 @@ pipeline = MedicalCodingPipeline( - [FHIR Condition Resources](https://www.hl7.org/fhir/condition.html) - [Medical Coding Pipeline](../prebuilt_pipelines/medicalcoding.md) -- [Document Container](../data_container.md) +- [Document Container](../../io/containers/document.md) diff --git a/healthchain/gateway/cds/__init__.py b/healthchain/gateway/cds/__init__.py index 19004736..6bbd6357 100644 --- a/healthchain/gateway/cds/__init__.py +++ b/healthchain/gateway/cds/__init__.py @@ -110,7 +110,11 @@ def _register_base_routes(self): # Discovery endpoint discovery_path = self.config.discovery_path.lstrip("/") - @self.get(f"/{discovery_path}", response_model_exclude_none=True) + @self.get( + f"/{discovery_path}", + response_model=CDSServiceInformation, + response_model_exclude_none=True, + ) async def discovery_handler(cds: "CDSHooksService" = Depends(get_self_service)): """CDS Hooks discovery endpoint.""" return cds.handle_discovery() @@ -132,6 +136,7 @@ async def service_handler( path=endpoint, endpoint=service_handler, methods=["POST"], + response_model=CDSResponse, response_model_exclude_none=True, summary=f"CDS Hook: {hook_id}", description=f"Execute CDS Hook service: {hook_id}", diff --git a/healthchain/gateway/clients/auth.py b/healthchain/gateway/clients/auth.py index 6daf8189..e20cabf3 100644 --- a/healthchain/gateway/clients/auth.py +++ b/healthchain/gateway/clients/auth.py @@ -32,6 +32,7 @@ class OAuth2Config(BaseModel): scope: Optional[str] = None audience: Optional[str] = None # For Epic and other systems that require audience use_jwt_assertion: bool = False # Use JWT client assertion instead of client secret + key_id: Optional[str] = None # Key ID (kid) for JWT header - required for JWKS def model_post_init(self, __context) -> None: """Validate that exactly one of client_secret or client_secret_path is provided.""" @@ -219,8 +220,9 @@ def _create_jwt_assertion(self) -> str: ), # Expires in 5 minutes } - # Create and sign JWT - signed_jwt = JWT().encode(claims, key, alg="RS384") + # Create and sign JWT with optional kid header + headers = {"kid": self.config.key_id} if self.config.key_id else None + signed_jwt = JWT().encode(claims, key, alg="RS384", optional_headers=headers) return signed_jwt @@ -356,7 +358,8 @@ def _create_jwt_assertion(self) -> str: ), # Expires in 5 minutes } - # Create and sign JWT - signed_jwt = JWT().encode(claims, key, alg="RS384") + # Create and sign JWT with optional kid header + headers = {"kid": self.config.key_id} if self.config.key_id else None + signed_jwt = JWT().encode(claims, key, alg="RS384", optional_headers=headers) return signed_jwt diff --git a/healthchain/gateway/clients/fhir/base.py b/healthchain/gateway/clients/fhir/base.py index 99fadea5..b733476f 100644 --- a/healthchain/gateway/clients/fhir/base.py +++ b/healthchain/gateway/clients/fhir/base.py @@ -47,6 +47,7 @@ class FHIRAuthConfig(BaseModel): scope: Optional[str] = "system/*.read system/*.write" audience: Optional[str] = None use_jwt_assertion: bool = False # Use JWT client assertion (Epic/SMART style) + key_id: Optional[str] = None # Key ID (kid) for JWT header - required for JWKS @property def requires_auth(self) -> bool: @@ -99,6 +100,7 @@ def to_oauth2_config(self) -> OAuth2Config: scope=self.scope, audience=self.audience, use_jwt_assertion=self.use_jwt_assertion, + key_id=self.key_id, ) @classmethod @@ -119,6 +121,7 @@ def from_env(cls, env_prefix: str) -> "FHIRAuthConfig": {env_prefix}_TIMEOUT (optional, default: 30) {env_prefix}_VERIFY_SSL (optional, default: true) {env_prefix}_USE_JWT_ASSERTION (optional, default: false) + {env_prefix}_KEY_ID (optional, for JWKS identification) Returns: FHIRAuthConfig instance @@ -161,6 +164,7 @@ def from_env(cls, env_prefix: str) -> "FHIRAuthConfig": use_jwt_assertion = ( os.getenv(f"{env_prefix}_USE_JWT_ASSERTION", "false").lower() == "true" ) + key_id = os.getenv(f"{env_prefix}_KEY_ID") return cls( client_id=client_id, @@ -173,6 +177,7 @@ def from_env(cls, env_prefix: str) -> "FHIRAuthConfig": timeout=timeout, verify_ssl=verify_ssl, use_jwt_assertion=use_jwt_assertion, + key_id=key_id, ) def to_connection_string(self) -> str: @@ -215,6 +220,8 @@ def to_connection_string(self) -> str: params["verify_ssl"] = "false" if self.use_jwt_assertion: params["use_jwt_assertion"] = "true" + if self.key_id: + params["key_id"] = self.key_id # Build connection string query_string = urllib.parse.urlencode(params) @@ -411,4 +418,5 @@ def parse_fhir_auth_connection_string(connection_string: str) -> FHIRAuthConfig: timeout=int(params.get("timeout", 30)), verify_ssl=params.get("verify_ssl", "true").lower() == "true", use_jwt_assertion=params.get("use_jwt_assertion", "false").lower() == "true", + key_id=params.get("key_id"), ) diff --git a/healthchain/gateway/fhir/base.py b/healthchain/gateway/fhir/base.py index 845e3a59..07705c7a 100644 --- a/healthchain/gateway/fhir/base.py +++ b/healthchain/gateway/fhir/base.py @@ -385,7 +385,6 @@ def _register_operation_route( methods=["GET"], summary=summary, description=description, - response_model_exclude_none=True, response_class=FHIRResponse, tags=self.tags, include_in_schema=True, @@ -403,6 +402,10 @@ def _execute_handler(fhir: "BaseFHIRGateway", *args) -> Any: try: handler_func = fhir._resource_handlers[resource_type][operation] result = handler_func(*args) + + # Serialize FHIR resources excluding None values + if isinstance(result, Resource): + return result.model_dump(exclude_none=True) return result except Exception as e: logger.error(f"Error in {operation} handler: {str(e)}") diff --git a/scripts/serve_jwks.py b/scripts/serve_jwks.py new file mode 100644 index 00000000..e2664c67 --- /dev/null +++ b/scripts/serve_jwks.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +JWKS Server for Epic FHIR Authentication + +Serves your public key as a JWKS endpoint for Epic's OAuth2 JWT verification. +Use with ngrok to expose publicly for Epic App Orchard registration. + +Setup: + 1. Ensure EPIC_CLIENT_SECRET_PATH in .env points to your private key PEM + 2. Set EPIC_KEY_ID in .env (e.g., "healthchain-demo-key") + 3. Run: python scripts/serve_jwks.py + 4. In another terminal: ngrok http 9999 --domain=your-static-domain.ngrok-free.app + 5. Register: https://your-static-domain.ngrok-free.app/.well-known/jwks.json in Epic + +Run: + python scripts/serve_jwks.py +""" + +import os +import base64 +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend + +load_dotenv() + +app = FastAPI(title="JWKS Server for Epic FHIR") + + +def pem_to_jwk(pem_path: str, kid: str, alg: str = "RS384") -> dict: + """ + Convert a PEM private key to JWK (JSON Web Key) format for JWKS. + + Args: + pem_path: Path to PEM private key file + kid: Key ID to identify this key in JWKS + alg: Algorithm (default: RS384, Epic supports RS256/RS384/RS512) + + Returns: + JWK dictionary with public key components + """ + # Load private key + with open(pem_path, "rb") as f: + private_key = serialization.load_pem_private_key( + f.read(), password=None, backend=default_backend() + ) + + # Extract public key + public_key = private_key.public_key() + public_numbers = public_key.public_numbers() + + # Base64url encode (no padding) + def b64url_encode(num: int, length: int) -> str: + """Encode integer as base64url without padding.""" + bytes_val = num.to_bytes(length, byteorder="big") + return base64.urlsafe_b64encode(bytes_val).rstrip(b"=").decode("utf-8") + + # Calculate byte lengths for n and e + n_length = (public_numbers.n.bit_length() + 7) // 8 + e_length = (public_numbers.e.bit_length() + 7) // 8 + + return { + "kty": "RSA", + "use": "sig", # Signature use + "alg": alg, + "kid": kid, + "n": b64url_encode(public_numbers.n, n_length), + "e": b64url_encode(public_numbers.e, e_length), + } + + +# Load configuration at startup +PRIVATE_KEY_PATH = os.getenv("EPIC_CLIENT_SECRET_PATH") +KEY_ID = os.getenv("EPIC_KEY_ID", "healthchain-demo-key") + +if not PRIVATE_KEY_PATH: + print("❌ ERROR: EPIC_CLIENT_SECRET_PATH not set in .env") + print(" Please set it to the path of your private key PEM file") + exit(1) + +if not Path(PRIVATE_KEY_PATH).exists(): + print(f"❌ ERROR: Private key not found at {PRIVATE_KEY_PATH}") + exit(1) + +# Generate JWKS at startup +try: + jwk = pem_to_jwk(PRIVATE_KEY_PATH, KEY_ID) + JWKS = {"keys": [jwk]} + print("✓ JWKS generated successfully") + print(f"✓ Key ID: {KEY_ID}") + print("✓ Algorithm: RS384") +except Exception as e: + print(f"❌ ERROR generating JWKS: {e}") + exit(1) + + +@app.get("/") +def root(): + """Health check endpoint.""" + return { + "status": "ok", + "message": "JWKS server running", + "endpoints": {"jwks": "/.well-known/jwks.json", "health": "/health"}, + } + + +@app.get("/health") +def health(): + """Health check endpoint.""" + return {"status": "healthy", "kid": KEY_ID} + + +@app.get("/.well-known/jwks.json") +def jwks_endpoint(): + """ + JWKS endpoint for Epic OAuth2 JWT verification. + + Register this URL in Epic App Orchard: + https://your-domain/.well-known/jwks.json + """ + return JSONResponse(content=JWKS) + + +if __name__ == "__main__": + import uvicorn + + print("\n" + "=" * 60) + print("JWKS Server for Epic FHIR Authentication") + print("=" * 60) + print("\n✓ Serving JWKS at: http://localhost:9999/.well-known/jwks.json") + print(f"✓ Key ID (kid): {KEY_ID}") + print("\nNext steps:") + print(" 1. In another terminal, run:") + print(" ngrok http 9999 --domain=your-static-domain.ngrok-free.app") + print(" 2. Register the public URL in Epic App Orchard:") + print(" https://your-static-domain.ngrok-free.app/.well-known/jwks.json") + print(" 3. Wait 15-30 minutes for Epic to propagate the change") + print(" 4. Test with: python scripts/check_epic_connection.py") + print("\n" + "=" * 60 + "\n") + + uvicorn.run(app, host="0.0.0.0", port=9999, log_level="info")