An open API service indexing awesome lists of open source software.

https://github.com/faulkj/fhirstarter

SMART Backend Services auth lifecycle for any JavaScript FHIR client
https://github.com/faulkj/fhirstarter

authentication back-end emr fhir healthcare smart-on-fhir typescript

Last synced: 3 days ago
JSON representation

SMART Backend Services auth lifecycle for any JavaScript FHIR client

Awesome Lists containing this project

README

          

# 🔥 fhirStarter

SMART on FHIR Backend Services auth lifecycle for any FHIR client.

## Install

```sh
npm install fhirstarterjs
```

## Usage

This example uses the official `fhirclient` package as the FHIR client; `fhirStarter` only manages auth.

```ts
import FHIR from "fhirclient"
import fhirStarter from "fhirstarterjs"

const auth = new fhirStarter({
clientId: "your-client-id",
privateKey: "./privatekey.pem",
tokenEndpointUrl: "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token",
scopes: ["system/Patient.rs", "system/Observation.rs"],
})

await auth.start()

const client = FHIR.client({
serverUrl: "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4",
tokenResponse: auth.tokenResponse(),
})

const bundle = await client.request("Patient?family=Smith")
```

`auth.start()` fetches the first token and starts the proactive refresh loop. `auth.tokenResponse()` returns a live getter-backed object — `fhirclient` reads `access_token` dynamically per request, so it always picks up the latest token.

`fhirStarter` does not fetch FHIR resources and does not bundle a FHIR client. It manages the auth lifecycle; the FHIR client does the rest.

`privateKey` can be PEM text, a `Buffer` from `readFileSync`, or a path to a PKCS#8 PEM file.

## TypeScript types

If needed, you can import the public types directly:

```ts
import type { AuthConfig, JwkSet, LiveTokenResponse, Provider } from "fhirstarterjs"
```

## Other FHIR clients

For clients with a bearer token setter (e.g. `fhir-kit-client`):

```ts
const unsubscribe = auth.onRefresh((token) => {
client.bearerToken = token
})
```

For raw `fetch` or any other HTTP client:

```ts
const token = await auth.getAccessToken()
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
```

## API

`new fhirStarter(config)`

| Member | Returns | Description |
|---|---|---|
| `start()` | `Promise` | Fetch first token and begin proactive refresh loop |
| `stop()` | `void` | Clear the refresh timer |
| `token` | `string \| null` | Current valid token, or null if expired |
| `expiresIn` | `number \| null` | Seconds until actual expiry, or null |
| `authorizationHeader` | `string \| null` | `Bearer ` or null |
| `getAccessToken()` | `Promise` | Async valid token with lazy refresh |
| `tokenResponse()` | `LiveTokenResponse` | Getter-backed token response for `fhirclient` |
| `onRefresh(callback)` | `() => void` | Subscribe to token updates — returns unsubscribe |
| `getJwks()` | `Promise` | Public JWKS derived from the private key |
| `fhirStarter.thumbprint(privateKey)` | `string` | RFC 7638 JWK Thumbprint (base64url SHA-256) |

`getJwks()` strips private key material — host the output JSON at your registered JWKS URL and pass that URL as `jwksUrl` so the JWT `jku` header is set automatically.

## Thumbprint

Derive a deterministic `kid` from a private key without instantiating the class:

```ts
import fhirStarter from "fhirstarterjs"

const kid = fhirStarter.thumbprint("./privatekey.pem")
console.log(kid) // base64url SHA-256 of the canonical RSA public JWK
```

This implements RFC 7638 — the SHA-256 of the sorted canonical JWK members `{e, kty, n}`, base64url-encoded. Use it as the `keyId` when registering your JWKS.

## JWKS

Some SMART Backend Services registrations require a public JWKS URL when using `jku`. Generate it from the same private key you use for auth:

```ts
import { writeFileSync } from "node:fs"
import fhirStarter from "fhirstarterjs"

const auth = new fhirStarter({
clientId: "your-client-id",
privateKey: "./privatekey.pem",
tokenEndpointUrl: "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token",
scopes: ["system/Patient.rs"],
keyId: "my-key-id",
jwksUrl: "https://example.com/.well-known/jwks.json",
})

// No need to call auth.start() — getJwks() only needs the private key.
const jwks = await auth.getJwks()
writeFileSync("./jwks.json", JSON.stringify(jwks, null, 3))
```

Host `jwks.json` at the exact URL you register with your authorization server, then pass that URL as `jwksUrl`. If you set `keyId`, the generated key includes `kid`, and signed JWTs use the same `kid` header.

## Compatibility

`tokenResponse()` is designed for `fhirclient.request()`. If a client copies the token at construction time rather than reading it per-request, use `onRefresh()` to update or recreate that client instead. If `fhirclient` clears its internal state after a 401, recreate the client instance with `auth.tokenResponse()`.

## Scripts

| Command | What |
|---|---|
| `npm run check` | `tsc --noEmit` |
| `npm run build` | Compile to `dist/` |

## Notes

- Call `auth.start()` to fetch the first token and begin the proactive refresh loop — call `auth.stop()` during shutdown in long-running processes
- Tokens are cached with separate refresh and expiry timestamps — if a refresh fails but the token is not yet expired, the old token remains usable
- Concurrent callers share a single in-flight token refresh
- JWT assertions are signed RS384, expire after 5 minutes
- Requires Node 20+, a PKCS#8 RSA key, and SMART Backend Services scopes