Skip to content

Commit 3989195

Browse files
Epic sandbox fix (#171)
* Update epic sandbox jwks auth in docs * Add jwks serving script * Fix excluding null in fastapi responses * model_dump fix in cookbook
1 parent 3ddbec9 commit 3989195

File tree

13 files changed

+245
-20
lines changed

13 files changed

+245
-20
lines changed

cookbook/multi_ehr_data_aggregation.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
Requirements:
99
- pip install healthchain python-dotenv
1010
11+
FHIR Sources:
12+
- Epic Sandbox: Set EPIC_* environment variables
13+
- Cerner Open Sandbox: No auth needed
14+
1115
Run:
1216
- python data_aggregation.py
1317
"""
@@ -96,9 +100,7 @@ def get_unified_patient(patient_id: str, sources: List[str]) -> Bundle:
96100
doc = Document(data=merged_bundle)
97101
doc = pipeline(doc)
98102

99-
# print([outcome.model_dump() for outcome in doc.fhir.operation_outcomes])
100-
101-
return doc.fhir.bundle.model_dump()
103+
return doc.fhir.bundle
102104

103105
app = HealthChainAPI()
104106
app.register_gateway(gateway)

docs/cookbook/ml_model_deployment.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ No FHIR parsing code needed—define the mapping once, use it everywhere.
230230
231231
!!! tip "Explore Interactively"
232232
233-
Step through the full flow in [notebooks/fhir_ml_workflow.ipynb](../../notebooks/fhir_ml_workflow.ipynb): FHIR bundle → Dataset → DataFrame → inference → RiskAssessment.
233+
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.
234234
235235
Now let's see how this pipeline plugs into each deployment pattern.
236236

docs/cookbook/setup_fhir_sandboxes.md

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,41 @@ openssl req -new -x509 -key privatekey.pem -out publickey509.pem -subj '/CN=myap
4646

4747
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.
4848

49-
#### Upload Public Key
49+
#### Register Public Key via JWKS URL
5050

51-
1. In your Epic app configuration, upload the `publickey509.pem` file
52-
2. Click **Save**
53-
3. Note down your **Non-Production Client ID**
51+
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.
52+
53+
1. **Set up a JWKS server**:
54+
```bash
55+
# Ensure your .env has the private key path
56+
# EPIC_CLIENT_SECRET_PATH=path/to/privatekey.pem
57+
# EPIC_KEY_ID=healthchain-demo-key
58+
59+
python scripts/serve_jwks.py
60+
```
61+
62+
2. **Get a free static domain from ngrok**:
63+
- Sign up at [ngrok.com](https://ngrok.com)
64+
- Claim your free static domain from the dashboard
65+
- Example: `your-app.ngrok-free.app`
66+
67+
3. **Expose your JWKS server**:
68+
```bash
69+
ngrok http 9999 --domain=your-app.ngrok-free.app
70+
```
71+
72+
4. **Register in Epic App Orchard**:
73+
74+
- In your Epic app configuration, locate the **Non-Production JWK Set URL** field
75+
- Enter: `https://your-app.ngrok-free.app/.well-known/jwks.json`
76+
- Click **Save**
77+
- Note down your **Non-Production Client ID**
78+
79+
The JWKS must be:
80+
81+
- Publicly accessible without authentication
82+
- Served over HTTPS
83+
- Stable (URL should not change)
5484

5585
![Epic Sandbox Client ID](../assets/images/epicsandbox3.png)
5686

@@ -73,8 +103,11 @@ EPIC_CLIENT_ID=your_non_production_client_id
73103
EPIC_CLIENT_SECRET_PATH=path/to/privatekey.pem
74104
EPIC_TOKEN_URL=https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token
75105
EPIC_USE_JWT_ASSERTION=true
106+
EPIC_KEY_ID=healthchain-demo-key # Must match the kid in your JWKS
76107
```
77108

109+
**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.
110+
78111
### Using Epic Sandbox in Code
79112

80113
```python
@@ -91,6 +124,20 @@ gateway = FHIRGateway()
91124
gateway.add_source("epic", EPIC_URL)
92125
```
93126

127+
### Testing Your Connection
128+
129+
After configuration:
130+
131+
```bash
132+
python scripts/check_epic_connection.py
133+
```
134+
135+
This script will:
136+
1. Load your Epic configuration
137+
2. Create a JWT assertion with the `kid` header
138+
3. Request an access token from Epic
139+
4. Test a FHIR endpoint query
140+
94141
### Available Test Patients
95142

96143
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
99146
- **Linda Ross** - Patient ID: `eIXesllypH3M9tAA5WdJftQ3`
100147
- Many others with various clinical scenarios
101148

149+
???+ note "Troubleshooting (click to expand)"
150+
**Token request fails after JWKS registration:**
151+
- Wait 15-30 minutes for Epic to propagate changes
152+
- Verify your JWKS URL is publicly accessible (test in browser)
153+
- Check that `EPIC_KEY_ID` matches the `kid` in your JWKS
154+
- Ensure the ngrok tunnel is still running
155+
156+
**JWKS format errors:**
157+
- Verify the JWKS structure at your URL matches Epic's requirements
158+
- Check that `n` and `e` are properly base64url encoded (no padding)
159+
- Algorithm should be RS384, RS256, or RS512
160+
102161
---
103162
## Cerner Sandbox
104163

docs/reference/gateway/soap_cda.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,4 @@ The response includes additional structured sections extracted from the clinical
161161
| Gateway Receives | `CdaRequest` | Processed by your service |
162162
| Gateway Returns | Your processed result | `CdaResponse` |
163163

164-
You can use the [CdaAdapter](../pipeline/adapters/cdaadapter.md) to handle conversion between CDA documents and HealthChain pipeline data containers.
164+
You can use the [CdaAdapter](../io/adapters/cdaadapter.md) to handle conversion between CDA documents and HealthChain pipeline data containers.

docs/reference/io/adapters/adapters.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Unlike the legacy connector pattern, adapters are used explicitly and provide cl
88

99
Adapters parse data from specific healthcare formats into FHIR resources and store them in a `Document` container for processing.
1010

11-
([Document API Reference](../../api/containers.md#healthchain.io.containers.document.Document))
11+
([Document API Reference](../../../api/containers.md#healthchain.io.containers.document))
1212

1313
| Adapter | Input Format | Output Format | FHIR Resources | Document Access |
1414
|---------|--------------|---------------|----------------|-----------------|
@@ -67,7 +67,7 @@ print(f"Allergies: {doc.fhir.allergy_list}")
6767
response = adapter.format(doc) # Document → CdaResponse
6868
```
6969

70-
For more details on the Document container, see [Document](../containers/document.md).
70+
For more details on the Document container, see [Document](../../io/containers/document.md).
7171

7272
## Adapter Configuration
7373

docs/reference/io/containers/dataset.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,4 @@ dataset.remove_column('temp_feature') # Drop a feature
108108

109109
## API Reference
110110

111-
See the [Dataset API Reference](../../api/containers.md#healthchain.io.containers.dataset) for detailed class documentation.
111+
See the [Dataset API Reference](../../../api/containers.md#healthchain.io.containers.dataset) for detailed class documentation.

docs/reference/io/containers/document.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,4 @@ print(doc.models.get_output("my_model", "task"))
232232

233233
## API Reference
234234

235-
See [Document API Reference](../../api/containers.md#healthchain.io.containers.document) for full details.
235+
See [Document API Reference](../../../api/containers.md#healthchain.io.containers.document) for full details.

docs/reference/pipeline/components/fhirproblemextractor.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,4 @@ pipeline = MedicalCodingPipeline(
104104

105105
- [FHIR Condition Resources](https://www.hl7.org/fhir/condition.html)
106106
- [Medical Coding Pipeline](../prebuilt_pipelines/medicalcoding.md)
107-
- [Document Container](../data_container.md)
107+
- [Document Container](../../io/containers/document.md)

healthchain/gateway/cds/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ def _register_base_routes(self):
110110
# Discovery endpoint
111111
discovery_path = self.config.discovery_path.lstrip("/")
112112

113-
@self.get(f"/{discovery_path}", response_model_exclude_none=True)
113+
@self.get(
114+
f"/{discovery_path}",
115+
response_model=CDSServiceInformation,
116+
response_model_exclude_none=True,
117+
)
114118
async def discovery_handler(cds: "CDSHooksService" = Depends(get_self_service)):
115119
"""CDS Hooks discovery endpoint."""
116120
return cds.handle_discovery()
@@ -132,6 +136,7 @@ async def service_handler(
132136
path=endpoint,
133137
endpoint=service_handler,
134138
methods=["POST"],
139+
response_model=CDSResponse,
135140
response_model_exclude_none=True,
136141
summary=f"CDS Hook: {hook_id}",
137142
description=f"Execute CDS Hook service: {hook_id}",

healthchain/gateway/clients/auth.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class OAuth2Config(BaseModel):
3232
scope: Optional[str] = None
3333
audience: Optional[str] = None # For Epic and other systems that require audience
3434
use_jwt_assertion: bool = False # Use JWT client assertion instead of client secret
35+
key_id: Optional[str] = None # Key ID (kid) for JWT header - required for JWKS
3536

3637
def model_post_init(self, __context) -> None:
3738
"""Validate that exactly one of client_secret or client_secret_path is provided."""
@@ -219,8 +220,9 @@ def _create_jwt_assertion(self) -> str:
219220
), # Expires in 5 minutes
220221
}
221222

222-
# Create and sign JWT
223-
signed_jwt = JWT().encode(claims, key, alg="RS384")
223+
# Create and sign JWT with optional kid header
224+
headers = {"kid": self.config.key_id} if self.config.key_id else None
225+
signed_jwt = JWT().encode(claims, key, alg="RS384", optional_headers=headers)
224226

225227
return signed_jwt
226228

@@ -356,7 +358,8 @@ def _create_jwt_assertion(self) -> str:
356358
), # Expires in 5 minutes
357359
}
358360

359-
# Create and sign JWT
360-
signed_jwt = JWT().encode(claims, key, alg="RS384")
361+
# Create and sign JWT with optional kid header
362+
headers = {"kid": self.config.key_id} if self.config.key_id else None
363+
signed_jwt = JWT().encode(claims, key, alg="RS384", optional_headers=headers)
361364

362365
return signed_jwt

0 commit comments

Comments
 (0)