fhirstarterjs 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +24 -0
- package/README.md +128 -0
- package/dist/fhirstarter.js +278 -0
- package/package.json +38 -0
- package/ts/fhirstarter.ts +306 -0
- package/types/api.d.ts +54 -0
- package/types/internal.d.ts +22 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
This is free and unencumbered software released into the public domain.
|
|
2
|
+
|
|
3
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
4
|
+
distribute this software, either in source code form or as a compiled
|
|
5
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
|
6
|
+
means.
|
|
7
|
+
|
|
8
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
|
9
|
+
of this software dedicate any and all copyright interest in the
|
|
10
|
+
software to the public domain. We make this dedication for the benefit
|
|
11
|
+
of the public at large and to the detriment of our heirs and
|
|
12
|
+
successors. We intend this dedication to be an overt act of
|
|
13
|
+
relinquishment in perpetuity of all present and future rights to this
|
|
14
|
+
software under copyright law.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
20
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
21
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
|
|
24
|
+
For more information, please refer to <https://unlicense.org>
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# ๐ฅ fhirstarterjs
|
|
2
|
+
|
|
3
|
+
SMART on FHIR Backend Services auth lifecycle for any FHIR client.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install fhirstarterjs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
This example uses the official `fhirclient` package as the FHIR client; `fhirstarterjs` only manages auth.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import FHIR from "fhirclient"
|
|
17
|
+
import FHIRStarter from "fhirstarterjs"
|
|
18
|
+
|
|
19
|
+
const auth = new FHIRStarter({
|
|
20
|
+
clientId: "your-client-id",
|
|
21
|
+
privateKey: "./privatekey.pem",
|
|
22
|
+
tokenEndpointUrl: "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token",
|
|
23
|
+
scopes: ["system/Patient.rs", "system/Observation.rs"],
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
await auth.start()
|
|
27
|
+
|
|
28
|
+
const client = FHIR.client({
|
|
29
|
+
serverUrl: "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4",
|
|
30
|
+
tokenResponse: auth.tokenResponse(),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const bundle = await client.request("Patient?family=Smith")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`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.
|
|
37
|
+
|
|
38
|
+
`fhirstarterjs` does not fetch FHIR resources and does not bundle a FHIR client. It manages the auth lifecycle; the FHIR client does the rest.
|
|
39
|
+
|
|
40
|
+
`privateKey` can be PEM text, a `Buffer` from `readFileSync`, or a path to a PKCS#8 PEM file.
|
|
41
|
+
|
|
42
|
+
## TypeScript types
|
|
43
|
+
|
|
44
|
+
If needed, you can import the public types directly from the shipped type folder:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import type { AuthConfig, JwkSet, LiveTokenResponse, Provider } from "fhirstarterjs/types/api"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Other FHIR clients
|
|
51
|
+
|
|
52
|
+
For clients with a bearer token setter (e.g. `fhir-kit-client`):
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const unsubscribe = auth.onRefresh((token) => {
|
|
56
|
+
client.bearerToken = token
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For raw `fetch` or any other HTTP client:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
const token = await auth.getAccessToken()
|
|
64
|
+
const res = await fetch(url, {
|
|
65
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
`new FHIRStarter(config)`
|
|
72
|
+
|
|
73
|
+
| Member | Returns | Description |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `start()` | `Promise<void>` | Fetch first token and begin proactive refresh loop |
|
|
76
|
+
| `stop()` | `void` | Clear the refresh timer |
|
|
77
|
+
| `token` | `string \| null` | Current valid token, or null if expired |
|
|
78
|
+
| `expiresIn` | `number \| null` | Seconds until actual expiry, or null |
|
|
79
|
+
| `authorizationHeader` | `string \| null` | `Bearer <token>` or null |
|
|
80
|
+
| `getAccessToken()` | `Promise<string>` | Async valid token with lazy refresh |
|
|
81
|
+
| `tokenResponse()` | `LiveTokenResponse` | Getter-backed token response for `fhirclient` |
|
|
82
|
+
| `onRefresh(callback)` | `() => void` | Subscribe to token updates โ returns unsubscribe |
|
|
83
|
+
| `getJwks()` | `Promise<JwkSet>` | Public JWKS derived from the private key |
|
|
84
|
+
|
|
85
|
+
`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.
|
|
86
|
+
|
|
87
|
+
## JWKS
|
|
88
|
+
|
|
89
|
+
Some SMART Backend Services registrations require a public JWKS URL when using `jku`. Generate it from the same private key you use for auth:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { writeFileSync } from "node:fs"
|
|
93
|
+
import FHIRStarter from "fhirstarterjs"
|
|
94
|
+
|
|
95
|
+
const auth = new FHIRStarter({
|
|
96
|
+
clientId: "your-client-id",
|
|
97
|
+
privateKey: "./privatekey.pem",
|
|
98
|
+
tokenEndpointUrl: "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token",
|
|
99
|
+
scopes: ["system/Patient.rs"],
|
|
100
|
+
keyId: "my-key-id",
|
|
101
|
+
jwksUrl: "https://example.com/.well-known/jwks.json",
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// No need to call auth.start() โ getJwks() only needs the private key.
|
|
105
|
+
const jwks = await auth.getJwks()
|
|
106
|
+
writeFileSync("./jwks.json", JSON.stringify(jwks, null, 3))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
## Compatibility
|
|
112
|
+
|
|
113
|
+
`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()`.
|
|
114
|
+
|
|
115
|
+
## Scripts
|
|
116
|
+
|
|
117
|
+
| Command | What |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `npm run check` | `tsc --noEmit` |
|
|
120
|
+
| `npm run build` | Compile to `dist/` (JS only โ no `.d.ts`) |
|
|
121
|
+
|
|
122
|
+
## Notes
|
|
123
|
+
|
|
124
|
+
- Call `auth.start()` to fetch the first token and begin the proactive refresh loop โ call `auth.stop()` during shutdown in long-running processes
|
|
125
|
+
- 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
|
|
126
|
+
- Concurrent callers share a single in-flight token refresh
|
|
127
|
+
- JWT assertions are signed RS384, expire after 5 minutes
|
|
128
|
+
- Requires Node 20+, a PKCS#8 RSA key, and SMART Backend Services scopes
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { importPKCS8, exportJWK, SignJWT } from "jose";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
/**
|
|
5
|
+
* SMART Backend Services auth provider.
|
|
6
|
+
*
|
|
7
|
+
* Manages client-credentials token acquisition and proactive refresh
|
|
8
|
+
* using a private RSA key and the JWT Bearer client-assertion flow (RFC 7523).
|
|
9
|
+
*/
|
|
10
|
+
export default class FHIRStarter {
|
|
11
|
+
config;
|
|
12
|
+
privateKeyText;
|
|
13
|
+
refreshCallbacks = new Set();
|
|
14
|
+
cache = null;
|
|
15
|
+
refreshPromise = null;
|
|
16
|
+
refreshTimer = null;
|
|
17
|
+
privateKeyObj = null;
|
|
18
|
+
started = false;
|
|
19
|
+
refreshFailed = false;
|
|
20
|
+
refreshRetryMs = 5_000;
|
|
21
|
+
/**
|
|
22
|
+
* @param config - Auth configuration including client ID, private key, token endpoint, and scopes.
|
|
23
|
+
* @throws If any required field is missing, blank, or invalid.
|
|
24
|
+
*/
|
|
25
|
+
constructor(config) {
|
|
26
|
+
if (!config.clientId)
|
|
27
|
+
throw new Error("AuthConfig: clientId is required");
|
|
28
|
+
if (!config.privateKey || (Buffer.isBuffer(config.privateKey) && config.privateKey.length === 0))
|
|
29
|
+
throw new Error("AuthConfig: privateKey is required");
|
|
30
|
+
if (!config.tokenEndpointUrl)
|
|
31
|
+
throw new Error("AuthConfig: tokenEndpointUrl is required");
|
|
32
|
+
const scopes = FHIRStarter.normalizeScopes(config.scopes);
|
|
33
|
+
if (scopes.length === 0)
|
|
34
|
+
throw new Error("AuthConfig: at least one scope is required");
|
|
35
|
+
try {
|
|
36
|
+
new URL(config.tokenEndpointUrl);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`AuthConfig: tokenEndpointUrl is not a valid URL: ${config.tokenEndpointUrl}`);
|
|
40
|
+
}
|
|
41
|
+
this.config = config;
|
|
42
|
+
this.privateKeyText = FHIRStarter.resolvePrivateKey(config.privateKey);
|
|
43
|
+
}
|
|
44
|
+
// --- Sync getters ---
|
|
45
|
+
/** The current access token, or `null` if no valid token is cached. */
|
|
46
|
+
get token() {
|
|
47
|
+
return this.cache && Date.now() < this.cache.expiresAt
|
|
48
|
+
? this.cache.accessToken
|
|
49
|
+
: null;
|
|
50
|
+
}
|
|
51
|
+
/** Seconds until the cached token expires, or `null` if no valid token is cached. */
|
|
52
|
+
get expiresIn() {
|
|
53
|
+
return this.cache && Date.now() < this.cache.expiresAt
|
|
54
|
+
? Math.ceil((this.cache.expiresAt - Date.now()) / 1000)
|
|
55
|
+
: null;
|
|
56
|
+
}
|
|
57
|
+
/** Ready-to-use `Authorization` header value (e.g. `"Bearer <token>"`), or `null` if no valid token is cached. */
|
|
58
|
+
get authorizationHeader() {
|
|
59
|
+
const token = this.token;
|
|
60
|
+
return token ? `Bearer ${token}` : null;
|
|
61
|
+
}
|
|
62
|
+
// --- Lifecycle ---
|
|
63
|
+
/**
|
|
64
|
+
* Acquires an initial token and starts the proactive background refresh timer.
|
|
65
|
+
* Safe to call multiple times โ subsequent calls are no-ops.
|
|
66
|
+
* @throws If the initial token acquisition fails.
|
|
67
|
+
*/
|
|
68
|
+
start = async () => {
|
|
69
|
+
if (this.started)
|
|
70
|
+
return;
|
|
71
|
+
this.started = true;
|
|
72
|
+
this.refreshRetryMs = 5_000;
|
|
73
|
+
this.refreshFailed = false;
|
|
74
|
+
try {
|
|
75
|
+
await this.getAccessToken();
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
this.started = false;
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
this.scheduleRefresh();
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Stops the background refresh timer.
|
|
85
|
+
* Any in-flight refresh will still complete, but no new timers will be scheduled.
|
|
86
|
+
*/
|
|
87
|
+
stop = () => {
|
|
88
|
+
this.started = false;
|
|
89
|
+
if (this.refreshTimer) {
|
|
90
|
+
clearTimeout(this.refreshTimer);
|
|
91
|
+
this.refreshTimer = null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// --- Token adapters ---
|
|
95
|
+
/**
|
|
96
|
+
* Returns a live `LiveTokenResponse` object whose `access_token` and `expires_in`
|
|
97
|
+
* properties are always read from the current cache.
|
|
98
|
+
* Useful for libraries that hold a reference to a token response object.
|
|
99
|
+
*/
|
|
100
|
+
tokenResponse = () => {
|
|
101
|
+
const auth = this;
|
|
102
|
+
return {
|
|
103
|
+
token_type: "bearer",
|
|
104
|
+
get access_token() { return auth.token ?? undefined; },
|
|
105
|
+
get expires_in() { return auth.expiresIn ?? undefined; },
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Registers a callback that is invoked whenever a new access token is obtained.
|
|
110
|
+
* The callback is called immediately with the current token if one is already cached.
|
|
111
|
+
* @param callback - Receives the new access token string.
|
|
112
|
+
* @returns An unsubscribe function; call it to stop receiving updates.
|
|
113
|
+
*/
|
|
114
|
+
onRefresh = (callback) => {
|
|
115
|
+
this.refreshCallbacks.add(callback);
|
|
116
|
+
const current = this.token;
|
|
117
|
+
if (current)
|
|
118
|
+
try {
|
|
119
|
+
callback(current);
|
|
120
|
+
}
|
|
121
|
+
catch { /* ignore */ }
|
|
122
|
+
return () => this.refreshCallbacks.delete(callback);
|
|
123
|
+
};
|
|
124
|
+
// --- Public async ---
|
|
125
|
+
/**
|
|
126
|
+
* Returns the public JWKS derived from the configured private key.
|
|
127
|
+
* Private key material (`d`, `p`, `q`, etc.) is stripped before returning.
|
|
128
|
+
*/
|
|
129
|
+
getJwks = async () => {
|
|
130
|
+
const { keyId } = this.config, privateKey = await this.getPrivateKey(), jwk = await exportJWK(privateKey);
|
|
131
|
+
// Public key only โ strip private components
|
|
132
|
+
delete jwk.d, delete jwk.p, delete jwk.q, delete jwk.dp, delete jwk.dq, delete jwk.qi;
|
|
133
|
+
jwk.alg = "RS384", jwk.use = "sig";
|
|
134
|
+
if (keyId)
|
|
135
|
+
jwk.kid = keyId;
|
|
136
|
+
return { keys: [jwk] };
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Returns a valid access token, refreshing if the cached token is at or past its refresh threshold.
|
|
140
|
+
* Falls back to a not-yet-expired stale token if the refresh request fails.
|
|
141
|
+
* @throws If no cached token is available and the refresh request fails.
|
|
142
|
+
*/
|
|
143
|
+
getAccessToken = async () => {
|
|
144
|
+
if (this.cache && Date.now() < this.cache.refreshAt)
|
|
145
|
+
return this.cache.accessToken;
|
|
146
|
+
const staleCache = this.cache;
|
|
147
|
+
try {
|
|
148
|
+
return await this.doRefresh();
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// Graceful degradation: if the token is not actually expired yet, return it
|
|
152
|
+
if (staleCache && Date.now() < staleCache.expiresAt)
|
|
153
|
+
return staleCache.accessToken;
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
// --- Private ---
|
|
158
|
+
// Single-flight refresh โ shared by getAccessToken and the proactive timer
|
|
159
|
+
doRefresh = () => {
|
|
160
|
+
if (this.refreshPromise)
|
|
161
|
+
return this.refreshPromise;
|
|
162
|
+
this.refreshPromise = this.refreshAccessToken()
|
|
163
|
+
.then((cache) => {
|
|
164
|
+
this.refreshPromise = null;
|
|
165
|
+
this.refreshFailed = false;
|
|
166
|
+
this.refreshRetryMs = 5_000;
|
|
167
|
+
if (this.started)
|
|
168
|
+
this.scheduleRefresh();
|
|
169
|
+
return cache.accessToken;
|
|
170
|
+
})
|
|
171
|
+
.catch((err) => {
|
|
172
|
+
this.refreshPromise = null;
|
|
173
|
+
throw err;
|
|
174
|
+
});
|
|
175
|
+
return this.refreshPromise;
|
|
176
|
+
};
|
|
177
|
+
scheduleRefresh = () => {
|
|
178
|
+
if (!this.started)
|
|
179
|
+
return;
|
|
180
|
+
if (this.refreshTimer)
|
|
181
|
+
clearTimeout(this.refreshTimer);
|
|
182
|
+
const delay = !this.cache || this.refreshFailed
|
|
183
|
+
? this.refreshRetryMs
|
|
184
|
+
: Math.max(1_000, this.cache.refreshAt - Date.now());
|
|
185
|
+
this.refreshTimer = setTimeout(async () => {
|
|
186
|
+
try {
|
|
187
|
+
await this.doRefresh();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
this.refreshFailed = true;
|
|
191
|
+
this.refreshRetryMs = Math.min(this.refreshRetryMs * 2, 60_000);
|
|
192
|
+
this.scheduleRefresh();
|
|
193
|
+
}
|
|
194
|
+
}, delay);
|
|
195
|
+
this.refreshTimer.unref?.();
|
|
196
|
+
};
|
|
197
|
+
notifyRefresh = (token) => {
|
|
198
|
+
for (const callback of this.refreshCallbacks)
|
|
199
|
+
try {
|
|
200
|
+
callback(token);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Ignore callback failures; auth lifecycle must continue.
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
refreshAccessToken = async () => {
|
|
207
|
+
const jwt = await this.buildJwt(), scopes = FHIRStarter.normalizeScopes(this.config.scopes), body = new URLSearchParams({
|
|
208
|
+
grant_type: "client_credentials",
|
|
209
|
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
210
|
+
client_assertion: jwt,
|
|
211
|
+
scope: scopes.join(" "),
|
|
212
|
+
}), res = await fetch(this.config.tokenEndpointUrl, {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
215
|
+
body,
|
|
216
|
+
signal: AbortSignal.timeout(30_000),
|
|
217
|
+
});
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
const text = await res.text().catch(() => "(no body)");
|
|
220
|
+
throw new Error(`Token request failed (${res.status}): ${text}`);
|
|
221
|
+
}
|
|
222
|
+
let data;
|
|
223
|
+
try {
|
|
224
|
+
data = await res.json();
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
throw new Error("Token response is not valid JSON");
|
|
228
|
+
}
|
|
229
|
+
if (!data.access_token || typeof data.access_token !== "string")
|
|
230
|
+
throw new Error("Token response missing access_token");
|
|
231
|
+
if (typeof data.expires_in !== "number" || data.expires_in <= 0)
|
|
232
|
+
throw new Error("Token response has invalid expires_in");
|
|
233
|
+
const ttlMs = data.expires_in * 1000, bufferMs = Math.min(60_000, ttlMs / 2), now = Date.now(), expiresAt = now + ttlMs, refreshAt = expiresAt - bufferMs, cache = { accessToken: data.access_token, refreshAt, expiresAt };
|
|
234
|
+
this.cache = cache;
|
|
235
|
+
this.notifyRefresh(cache.accessToken);
|
|
236
|
+
return cache;
|
|
237
|
+
};
|
|
238
|
+
getPrivateKey = async () => {
|
|
239
|
+
if (!this.privateKeyObj)
|
|
240
|
+
this.privateKeyObj = await importPKCS8(this.privateKeyText, "RS384");
|
|
241
|
+
return this.privateKeyObj;
|
|
242
|
+
};
|
|
243
|
+
buildJwt = async () => {
|
|
244
|
+
const { clientId, tokenEndpointUrl, keyId, jwksUrl } = this.config, privateKey = await this.getPrivateKey(), header = { alg: "RS384", typ: "JWT" };
|
|
245
|
+
if (keyId)
|
|
246
|
+
header.kid = keyId;
|
|
247
|
+
if (jwksUrl)
|
|
248
|
+
header.jku = jwksUrl;
|
|
249
|
+
return new SignJWT({
|
|
250
|
+
iss: clientId,
|
|
251
|
+
sub: clientId,
|
|
252
|
+
aud: tokenEndpointUrl,
|
|
253
|
+
jti: randomUUID(), // unique per RFC 7523 ยง3
|
|
254
|
+
})
|
|
255
|
+
.setProtectedHeader(header)
|
|
256
|
+
.setIssuedAt()
|
|
257
|
+
.setNotBefore("now")
|
|
258
|
+
.setExpirationTime("5m")
|
|
259
|
+
.sign(privateKey);
|
|
260
|
+
};
|
|
261
|
+
// --- Static helpers ---
|
|
262
|
+
static normalizeScopes(scopes) {
|
|
263
|
+
return Array.isArray(scopes) ? scopes.filter(Boolean) : scopes.split(/\s+/).filter(Boolean);
|
|
264
|
+
}
|
|
265
|
+
static resolvePrivateKey(privateKey) {
|
|
266
|
+
if (Buffer.isBuffer(privateKey))
|
|
267
|
+
return privateKey.toString("utf-8").trim();
|
|
268
|
+
const trimmed = privateKey.trim();
|
|
269
|
+
if (trimmed.includes("-----BEGIN"))
|
|
270
|
+
return trimmed;
|
|
271
|
+
try {
|
|
272
|
+
return readFileSync(trimmed, "utf-8").trim();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
throw new Error(`AuthConfig: privateKey must be PEM text, a Buffer, or a readable file path: ${trimmed}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fhirstarterjs",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SMART Backend Services auth lifecycle for any FHIR client",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/fhirstarter.js",
|
|
7
|
+
"types": "./ts/fhirstarter.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./ts/fhirstarter.ts",
|
|
11
|
+
"import": "./dist/fhirstarter.js"
|
|
12
|
+
},
|
|
13
|
+
"./types/api": {
|
|
14
|
+
"types": "./types/api.d.ts"
|
|
15
|
+
},
|
|
16
|
+
"./types/internal": {
|
|
17
|
+
"types": "./types/internal.d.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": ["dist", "ts", "types", "README.md", "LICENSE.md"],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"author": "Joshua Faulkenberry <j@joshuafaulkenberry.com> (https://joshuafaulkenberry.com/)",
|
|
25
|
+
"license": "Kopimi",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"check": "tsc --noEmit",
|
|
28
|
+
"build": "tsc -p tsconfig.build.json",
|
|
29
|
+
"prepack": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"jose": "^6.2.3"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^25.9.2",
|
|
36
|
+
"typescript": "^6.0.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { importPKCS8, exportJWK, SignJWT, type JWTHeaderParameters } from "jose"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { readFileSync } from "node:fs"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SMART Backend Services auth provider.
|
|
7
|
+
*
|
|
8
|
+
* Manages client-credentials token acquisition and proactive refresh
|
|
9
|
+
* using a private RSA key and the JWT Bearer client-assertion flow (RFC 7523).
|
|
10
|
+
*/
|
|
11
|
+
export default class FHIRStarter implements Provider {
|
|
12
|
+
private readonly config: AuthConfig
|
|
13
|
+
private readonly privateKeyText: string
|
|
14
|
+
private readonly refreshCallbacks: Set<RefreshCallback> = new Set()
|
|
15
|
+
private cache: TokenCache | null = null
|
|
16
|
+
private refreshPromise: Promise<string> | null = null
|
|
17
|
+
private refreshTimer: ReturnType<typeof setTimeout> | null = null
|
|
18
|
+
private privateKeyObj: Awaited<ReturnType<typeof importPKCS8>> | null = null
|
|
19
|
+
private started = false
|
|
20
|
+
private refreshFailed = false
|
|
21
|
+
private refreshRetryMs = 5_000
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param config - Auth configuration including client ID, private key, token endpoint, and scopes.
|
|
25
|
+
* @throws If any required field is missing, blank, or invalid.
|
|
26
|
+
*/
|
|
27
|
+
constructor(config: AuthConfig) {
|
|
28
|
+
if (!config.clientId) throw new Error("AuthConfig: clientId is required")
|
|
29
|
+
if (!config.privateKey || (Buffer.isBuffer(config.privateKey) && config.privateKey.length === 0))
|
|
30
|
+
throw new Error("AuthConfig: privateKey is required")
|
|
31
|
+
if (!config.tokenEndpointUrl) throw new Error("AuthConfig: tokenEndpointUrl is required")
|
|
32
|
+
|
|
33
|
+
const scopes = FHIRStarter.normalizeScopes(config.scopes)
|
|
34
|
+
if (scopes.length === 0) throw new Error("AuthConfig: at least one scope is required")
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
new URL(config.tokenEndpointUrl)
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error(`AuthConfig: tokenEndpointUrl is not a valid URL: ${config.tokenEndpointUrl}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.config = config
|
|
43
|
+
this.privateKeyText = FHIRStarter.resolvePrivateKey(config.privateKey)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Sync getters ---
|
|
47
|
+
|
|
48
|
+
/** The current access token, or `null` if no valid token is cached. */
|
|
49
|
+
get token(): string | null {
|
|
50
|
+
return this.cache && Date.now() < this.cache.expiresAt
|
|
51
|
+
? this.cache.accessToken
|
|
52
|
+
: null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Seconds until the cached token expires, or `null` if no valid token is cached. */
|
|
56
|
+
get expiresIn(): number | null {
|
|
57
|
+
return this.cache && Date.now() < this.cache.expiresAt
|
|
58
|
+
? Math.ceil((this.cache.expiresAt - Date.now()) / 1000)
|
|
59
|
+
: null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Ready-to-use `Authorization` header value (e.g. `"Bearer <token>"`), or `null` if no valid token is cached. */
|
|
63
|
+
get authorizationHeader(): string | null {
|
|
64
|
+
const token = this.token
|
|
65
|
+
return token ? `Bearer ${token}` : null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- Lifecycle ---
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Acquires an initial token and starts the proactive background refresh timer.
|
|
72
|
+
* Safe to call multiple times โ subsequent calls are no-ops.
|
|
73
|
+
* @throws If the initial token acquisition fails.
|
|
74
|
+
*/
|
|
75
|
+
start = async (): Promise<void> => {
|
|
76
|
+
if (this.started) return
|
|
77
|
+
this.started = true
|
|
78
|
+
this.refreshRetryMs = 5_000
|
|
79
|
+
this.refreshFailed = false
|
|
80
|
+
try {
|
|
81
|
+
await this.getAccessToken()
|
|
82
|
+
} catch (err) {
|
|
83
|
+
this.started = false
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
this.scheduleRefresh()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Stops the background refresh timer.
|
|
91
|
+
* Any in-flight refresh will still complete, but no new timers will be scheduled.
|
|
92
|
+
*/
|
|
93
|
+
stop = (): void => {
|
|
94
|
+
this.started = false
|
|
95
|
+
if (this.refreshTimer) {
|
|
96
|
+
clearTimeout(this.refreshTimer)
|
|
97
|
+
this.refreshTimer = null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Token adapters ---
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Returns a live `LiveTokenResponse` object whose `access_token` and `expires_in`
|
|
105
|
+
* properties are always read from the current cache.
|
|
106
|
+
* Useful for libraries that hold a reference to a token response object.
|
|
107
|
+
*/
|
|
108
|
+
tokenResponse = (): LiveTokenResponse => {
|
|
109
|
+
const auth = this
|
|
110
|
+
return {
|
|
111
|
+
token_type: "bearer",
|
|
112
|
+
get access_token() { return auth.token ?? undefined },
|
|
113
|
+
get expires_in() { return auth.expiresIn ?? undefined },
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Registers a callback that is invoked whenever a new access token is obtained.
|
|
119
|
+
* The callback is called immediately with the current token if one is already cached.
|
|
120
|
+
* @param callback - Receives the new access token string.
|
|
121
|
+
* @returns An unsubscribe function; call it to stop receiving updates.
|
|
122
|
+
*/
|
|
123
|
+
onRefresh = (callback: RefreshCallback): (() => void) => {
|
|
124
|
+
this.refreshCallbacks.add(callback)
|
|
125
|
+
const current = this.token
|
|
126
|
+
if (current) try { callback(current) } catch { /* ignore */ }
|
|
127
|
+
return () => this.refreshCallbacks.delete(callback)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Public async ---
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Returns the public JWKS derived from the configured private key.
|
|
134
|
+
* Private key material (`d`, `p`, `q`, etc.) is stripped before returning.
|
|
135
|
+
*/
|
|
136
|
+
getJwks = async (): Promise<JwkSet> => {
|
|
137
|
+
const
|
|
138
|
+
{ keyId } = this.config,
|
|
139
|
+
privateKey = await this.getPrivateKey(),
|
|
140
|
+
jwk = await exportJWK(privateKey)
|
|
141
|
+
|
|
142
|
+
// Public key only โ strip private components
|
|
143
|
+
delete jwk.d, delete jwk.p, delete jwk.q, delete jwk.dp, delete jwk.dq, delete jwk.qi
|
|
144
|
+
jwk.alg = "RS384", jwk.use = "sig"
|
|
145
|
+
if (keyId) jwk.kid = keyId
|
|
146
|
+
|
|
147
|
+
return { keys: [jwk] }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns a valid access token, refreshing if the cached token is at or past its refresh threshold.
|
|
152
|
+
* Falls back to a not-yet-expired stale token if the refresh request fails.
|
|
153
|
+
* @throws If no cached token is available and the refresh request fails.
|
|
154
|
+
*/
|
|
155
|
+
getAccessToken = async (): Promise<string> => {
|
|
156
|
+
if (this.cache && Date.now() < this.cache.refreshAt) return this.cache.accessToken
|
|
157
|
+
const staleCache = this.cache
|
|
158
|
+
try {
|
|
159
|
+
return await this.doRefresh()
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// Graceful degradation: if the token is not actually expired yet, return it
|
|
162
|
+
if (staleCache && Date.now() < staleCache.expiresAt) return staleCache.accessToken
|
|
163
|
+
throw err
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Private ---
|
|
168
|
+
|
|
169
|
+
// Single-flight refresh โ shared by getAccessToken and the proactive timer
|
|
170
|
+
private doRefresh = (): Promise<string> => {
|
|
171
|
+
if (this.refreshPromise) return this.refreshPromise
|
|
172
|
+
this.refreshPromise = this.refreshAccessToken()
|
|
173
|
+
.then((cache) => {
|
|
174
|
+
this.refreshPromise = null
|
|
175
|
+
this.refreshFailed = false
|
|
176
|
+
this.refreshRetryMs = 5_000
|
|
177
|
+
if (this.started) this.scheduleRefresh()
|
|
178
|
+
return cache.accessToken
|
|
179
|
+
})
|
|
180
|
+
.catch((err) => {
|
|
181
|
+
this.refreshPromise = null
|
|
182
|
+
throw err
|
|
183
|
+
})
|
|
184
|
+
return this.refreshPromise
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private scheduleRefresh = (): void => {
|
|
188
|
+
if (!this.started) return
|
|
189
|
+
if (this.refreshTimer) clearTimeout(this.refreshTimer)
|
|
190
|
+
|
|
191
|
+
const delay = !this.cache || this.refreshFailed
|
|
192
|
+
? this.refreshRetryMs
|
|
193
|
+
: Math.max(1_000, this.cache.refreshAt - Date.now())
|
|
194
|
+
|
|
195
|
+
this.refreshTimer = setTimeout(async () => {
|
|
196
|
+
try {
|
|
197
|
+
await this.doRefresh()
|
|
198
|
+
} catch {
|
|
199
|
+
this.refreshFailed = true
|
|
200
|
+
this.refreshRetryMs = Math.min(this.refreshRetryMs * 2, 60_000)
|
|
201
|
+
this.scheduleRefresh()
|
|
202
|
+
}
|
|
203
|
+
}, delay)
|
|
204
|
+
|
|
205
|
+
this.refreshTimer.unref?.()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private notifyRefresh = (token: string): void => {
|
|
209
|
+
for (const callback of this.refreshCallbacks)
|
|
210
|
+
try {
|
|
211
|
+
callback(token)
|
|
212
|
+
} catch {
|
|
213
|
+
// Ignore callback failures; auth lifecycle must continue.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private refreshAccessToken = async (): Promise<TokenCache> => {
|
|
218
|
+
const
|
|
219
|
+
jwt = await this.buildJwt(),
|
|
220
|
+
scopes = FHIRStarter.normalizeScopes(this.config.scopes),
|
|
221
|
+
body = new URLSearchParams({
|
|
222
|
+
grant_type: "client_credentials",
|
|
223
|
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
224
|
+
client_assertion: jwt,
|
|
225
|
+
scope: scopes.join(" "),
|
|
226
|
+
}),
|
|
227
|
+
res = await fetch(this.config.tokenEndpointUrl, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
230
|
+
body,
|
|
231
|
+
signal: AbortSignal.timeout(30_000),
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
const text = await res.text().catch(() => "(no body)")
|
|
236
|
+
throw new Error(`Token request failed (${res.status}): ${text}`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let data: TokenResponse
|
|
240
|
+
try { data = await res.json() }
|
|
241
|
+
catch { throw new Error("Token response is not valid JSON") }
|
|
242
|
+
|
|
243
|
+
if (!data.access_token || typeof data.access_token !== "string")
|
|
244
|
+
throw new Error("Token response missing access_token")
|
|
245
|
+
if (typeof data.expires_in !== "number" || data.expires_in <= 0)
|
|
246
|
+
throw new Error("Token response has invalid expires_in")
|
|
247
|
+
|
|
248
|
+
const
|
|
249
|
+
ttlMs = data.expires_in * 1000,
|
|
250
|
+
bufferMs = Math.min(60_000, ttlMs / 2),
|
|
251
|
+
now = Date.now(),
|
|
252
|
+
expiresAt = now + ttlMs,
|
|
253
|
+
refreshAt = expiresAt - bufferMs,
|
|
254
|
+
cache: TokenCache = { accessToken: data.access_token, refreshAt, expiresAt }
|
|
255
|
+
|
|
256
|
+
this.cache = cache
|
|
257
|
+
this.notifyRefresh(cache.accessToken)
|
|
258
|
+
return cache
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private getPrivateKey = async (): Promise<Awaited<ReturnType<typeof importPKCS8>>> => {
|
|
262
|
+
if (!this.privateKeyObj)
|
|
263
|
+
this.privateKeyObj = await importPKCS8(this.privateKeyText, "RS384")
|
|
264
|
+
return this.privateKeyObj
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private buildJwt = async (): Promise<string> => {
|
|
268
|
+
const
|
|
269
|
+
{ clientId, tokenEndpointUrl, keyId, jwksUrl } = this.config,
|
|
270
|
+
privateKey = await this.getPrivateKey(),
|
|
271
|
+
header: JWTHeaderParameters = { alg: "RS384", typ: "JWT" }
|
|
272
|
+
if (keyId) header.kid = keyId
|
|
273
|
+
if (jwksUrl) header.jku = jwksUrl
|
|
274
|
+
|
|
275
|
+
return new SignJWT({
|
|
276
|
+
iss: clientId,
|
|
277
|
+
sub: clientId,
|
|
278
|
+
aud: tokenEndpointUrl,
|
|
279
|
+
jti: randomUUID(), // unique per RFC 7523 ยง3
|
|
280
|
+
})
|
|
281
|
+
.setProtectedHeader(header)
|
|
282
|
+
.setIssuedAt()
|
|
283
|
+
.setNotBefore("now")
|
|
284
|
+
.setExpirationTime("5m")
|
|
285
|
+
.sign(privateKey)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- Static helpers ---
|
|
289
|
+
|
|
290
|
+
private static normalizeScopes(scopes: string | string[]): string[] {
|
|
291
|
+
return Array.isArray(scopes) ? scopes.filter(Boolean) : scopes.split(/\s+/).filter(Boolean)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private static resolvePrivateKey(privateKey: string | Buffer): string {
|
|
295
|
+
if (Buffer.isBuffer(privateKey)) return privateKey.toString("utf-8").trim()
|
|
296
|
+
|
|
297
|
+
const trimmed = privateKey.trim()
|
|
298
|
+
if (trimmed.includes("-----BEGIN")) return trimmed
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
return readFileSync(trimmed, "utf-8").trim()
|
|
302
|
+
} catch {
|
|
303
|
+
throw new Error(`AuthConfig: privateKey must be PEM text, a Buffer, or a readable file path: ${trimmed}`)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
package/types/api.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
/** Configuration required to authenticate using SMART Backend Services. */
|
|
3
|
+
interface AuthConfig {
|
|
4
|
+
/** App client_id registered with your FHIR authorization server */
|
|
5
|
+
clientId: string
|
|
6
|
+
/** RSA private key as PEM text, a Buffer, or a path to a PKCS#8 PEM file */
|
|
7
|
+
privateKey: string | Buffer
|
|
8
|
+
/** OAuth 2.0 token endpoint URL */
|
|
9
|
+
tokenEndpointUrl: string
|
|
10
|
+
/** SMART scopes to request โ space-delimited string or array */
|
|
11
|
+
scopes: string | string[]
|
|
12
|
+
/** Key ID โ set when using a registered JWKS URL */
|
|
13
|
+
keyId?: string
|
|
14
|
+
/** JWK Set URL registered with your authorization server โ included as `jku` in JWT header */
|
|
15
|
+
jwksUrl?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Standard JWK Set structure โ suitable for hosting at a public URL. */
|
|
19
|
+
interface JwkSet {
|
|
20
|
+
/** Array of JSON Web Keys. */
|
|
21
|
+
keys: import("jose").JWK[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Auth lifecycle provider implemented by {@link FHIRStarter}. */
|
|
25
|
+
interface Provider {
|
|
26
|
+
/** Current valid token, or null if no unexpired token is cached. */
|
|
27
|
+
readonly token: string | null
|
|
28
|
+
/** Seconds until actual token expiry, or null if no valid token is cached. */
|
|
29
|
+
readonly expiresIn: number | null
|
|
30
|
+
/** `Bearer <token>` string, or null if no valid token is cached. */
|
|
31
|
+
readonly authorizationHeader: string | null
|
|
32
|
+
/** Fetch the first token and begin the proactive refresh loop. Safe to call once. */
|
|
33
|
+
start(): Promise<void>
|
|
34
|
+
/** Clear the proactive refresh timer. */
|
|
35
|
+
stop(): void
|
|
36
|
+
/** Returns a valid access token. Refreshes if past the refresh window; degrades gracefully on failure. */
|
|
37
|
+
getAccessToken(): Promise<string>
|
|
38
|
+
/** Returns a getter-backed token response object for use with official `fhirclient`. */
|
|
39
|
+
tokenResponse(): LiveTokenResponse
|
|
40
|
+
/** Subscribe to token refreshes. Returns an unsubscribe function. */
|
|
41
|
+
onRefresh(callback: (token: string) => void): () => void
|
|
42
|
+
/** Returns the public JWKS derived from the private key. */
|
|
43
|
+
getJwks(): Promise<JwkSet>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Getter-backed token response shape compatible with `fhirclient`'s request path. */
|
|
47
|
+
interface LiveTokenResponse {
|
|
48
|
+
token_type: "bearer"
|
|
49
|
+
readonly access_token: string | undefined
|
|
50
|
+
readonly expires_in: number | undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { AuthConfig, JwkSet, Provider, LiveTokenResponse }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
/** Shape of the OAuth 2.0 token endpoint response. */
|
|
3
|
+
interface TokenResponse {
|
|
4
|
+
access_token: string
|
|
5
|
+
token_type: string
|
|
6
|
+
expires_in: number
|
|
7
|
+
scope?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** In-memory cache entry for a fetched access token. */
|
|
11
|
+
interface TokenCache {
|
|
12
|
+
accessToken: string
|
|
13
|
+
/** Epoch ms โ when to attempt proactive refresh (before actual expiry). */
|
|
14
|
+
refreshAt: number
|
|
15
|
+
/** Epoch ms โ actual token expiry. Never treat as expired before this. */
|
|
16
|
+
expiresAt: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type RefreshCallback = (token: string) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { TokenResponse, TokenCache, RefreshCallback }
|