Metadata-Version: 2.4
Name: cms-hte-ial2-reader
Version: 0.0.4
Summary: Reads IAL2 token, verifies digital signature and returns FHIR Patient resource
Author-email: Imran Qureshi <imran.qureshi@bwell.com>
Project-URL: Repository, https://github.com/icanbwell/cms-hte-ial2-reader
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: pyjwt>=2.8.0
Requires-Dist: cryptography>=43.0.0
Requires-Dist: fhirschemapy>=0.0.11
Dynamic: license-file

# cms-hte-ial2-reader

Reads IAL2 token, verifies digital signature and returns FHIR Patient resource

Accepts a signed JWT (ID Token) from a Credential Service Provider (CSP),
verifies the signature via JWKS, extracts demographics, and returns a FHIR
R4 Patient resource. Transparently accepts three token shapes, with no
caller-facing difference in usage:

- **CMS-Aligned Networks CSP Payload Specification V7.1** -- flat claims.
- **TEFCA IAS SOP v3.0** -- flat claims with a different field-name
  vocabulary (e.g. `regionality` instead of `region`); a token missing the
  `identity_assurance_level` claim is treated as implicit IAL2 if it looks
  TEFCA-shaped, since the SOP guarantees IAL2 proofing as a precondition
  of the CSP issuing the token at all.
- **Unified Identity Token Payload (OIDC4IDA-style)** -- the CMS-Aligned
  Networks / TEFCA working group's in-progress target format, nesting
  verified attributes in a `verified_claims.{verification,claims}`
  envelope. This is a discussion draft, not a finalized spec.

## Usage

```python
from cmshteial2reader import IAL2Extractor, TokenVerifier

verifier = TokenVerifier(
    jwks_uri="https://idp.example.com/.well-known/jwks.json",
    audience="my-client-id",
)
extractor = IAL2Extractor(verifier=verifier)
patient = await extractor.extract(token_string)
# patient is a FHIR R4 Patient resource dict
```

To accept tokens from any issuer on an explicit allow list (rather than a
single pinned issuer), use `MultiIssuerTokenVerifier` in place of
`TokenVerifier`:

```python
from cmshteial2reader import IAL2Extractor, MultiIssuerTokenVerifier

verifier = MultiIssuerTokenVerifier.from_env(audience="my-client-id")
extractor = IAL2Extractor(verifier=verifier)
patient = await extractor.extract(token_string)
```

`MultiIssuerTokenVerifier.from_env` reads its whitelist of trusted JWKS URLs
from the `IAL2_ALLOWED_JWKS_URLS` environment variable (comma-separated).

### Assurance-level enforcement

`IAL2Extractor` requires the token's `identity_assurance_level` claim to be
at least IAL2 (accepting values like `"ial2"`, `"IAL2"`, or `"2"`) -- a
token missing the claim, or asserting a lower level (e.g. `"ial1"`), raises
`InsufficientAssuranceLevelError` (a subclass of `TokenVerificationError`,
so existing callers that only catch the latter still catch this). This
applies to both `extract()` and `extract_claims()`. To require a higher
level, pass `minimum_assurance_level` to the constructor:

```python
extractor = IAL2Extractor(verifier=verifier, minimum_assurance_level=3)
```

A TEFCA-dialect token (one with no `identity_assurance_level` claim but
recognizable TEFCA-only fields such as `regionality`/`historical_address`/
`ssn_last_four_digits`) is only treated as implicit IAL2 -- never a higher
level, since that's all the SOP guarantees -- and only after the token has
already passed signature verification (and, for `MultiIssuerTokenVerifier`,
issuer whitelisting). Raising `minimum_assurance_level` above 2 will reject
TEFCA-dialect tokens, since they can't assert anything higher.

### CMS Blue Button `cms_smart` extension

CMS Aligned Networks convey a patient's IAL2-verified identity by nesting a
CSP-issued ID token inside the outer auth token's claims, under
`extensions.cms_smart.id_token` (see
https://bluebutton.cms.gov/cms-aligned-networks-documentation/). Callers
that receive an already-verified outer JWT (e.g. from their own auth
middleware) can pull out the nested token with `extract_cms_smart_id_token`
and pass it to `IAL2Extractor.extract`:

```python
from cmshteial2reader import IAL2Extractor, MultiIssuerTokenVerifier, extract_cms_smart_id_token

id_token = extract_cms_smart_id_token(outer_jwt_claims)
if id_token is not None:
    verifier = MultiIssuerTokenVerifier.from_env(audience="my-client-id")
    extractor = IAL2Extractor(verifier=verifier)
    patient = await extractor.extract(id_token)
```

`extract_cms_smart_id_token` only reads the `id_token` claim -- it does not
verify anything, and it does not inspect or enforce `purpose_of_use` or
`version`. The outer JWT must already be signature-verified by the caller,
the nested id_token still needs its own verification via
`IAL2Extractor`/`TokenVerifier`, and a caller that needs to gate on
purpose-of-use must check `claims["extensions"]["cms_smart"]` itself.

## Quickstart
- `make init` to set up the local dev environment.
- `make up` to start the Docker dev stack.
- `make tests` to run tests in the dev container.

## Common Tasks
- `make shell` to open a shell in the dev container.
- `make run-pre-commit` to run linting and formatting hooks.
- `make build` to build the package artifacts.

## Publishing
- Set a token: `export TWINE_PASSWORD=pypi-xxx`
- `make package` to publish to PyPI or `make testpackage` for TestPyPI.

## Project Metadata
- Repo: https://github.com/icanbwell/cms-hte-ial2-reader
- Author: Imran Qureshi <imran.qureshi@bwell.com>
