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
- Host: GitHub
- URL: https://github.com/faulkj/fhirstarter
- Owner: faulkj
- License: other
- Created: 2026-06-09T20:01:53.000Z (29 days ago)
- Default Branch: master
- Last Pushed: 2026-06-16T22:12:15.000Z (22 days ago)
- Last Synced: 2026-06-17T00:13:43.699Z (22 days ago)
- Topics: authentication, back-end, emr, fhir, healthcare, smart-on-fhir, typescript
- Language: TypeScript
- Homepage:
- Size: 28.3 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE.md
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