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 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 }