fhirstarterjs 1.0.6 → 1.0.8

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/README.md CHANGED
@@ -81,9 +81,23 @@ const res = await fetch(url, {
81
81
  | `tokenResponse()` | `LiveTokenResponse` | Getter-backed token response for `fhirclient` |
82
82
  | `onRefresh(callback)` | `() => void` | Subscribe to token updates — returns unsubscribe |
83
83
  | `getJwks()` | `Promise<JwkSet>` | Public JWKS derived from the private key |
84
+ | `fhirStarter.thumbprint(privateKey)` | `string` | RFC 7638 JWK Thumbprint (base64url SHA-256) |
84
85
 
85
86
  `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
 
88
+ ## Thumbprint
89
+
90
+ Derive a deterministic `kid` from a private key without instantiating the class:
91
+
92
+ ```ts
93
+ import fhirStarter from "fhirstarterjs"
94
+
95
+ const kid = fhirStarter.thumbprint("./privatekey.pem")
96
+ console.log(kid) // base64url SHA-256 of the canonical RSA public JWK
97
+ ```
98
+
99
+ 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.
100
+
87
101
  ## JWKS
88
102
 
89
103
  Some SMART Backend Services registrations require a public JWKS URL when using `jku`. Generate it from the same private key you use for auth:
@@ -1,5 +1,5 @@
1
1
  import { importPKCS8, exportJWK, SignJWT, } from "jose";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, createPrivateKey, createPublicKey, randomUUID } from "node:crypto";
3
3
  import { readFileSync } from "node:fs";
4
4
  /**
5
5
  * SMART Backend Services auth provider.
@@ -117,7 +117,7 @@ export default class fhirStarter {
117
117
  * Registers a callback that is invoked whenever a new access token is obtained.
118
118
  * The callback is called immediately with the current token if one is already cached.
119
119
  * @param callback - Receives the new access token string.
120
- * @returns An unsubscribe function; call it to stop receiving updates.
120
+ * @returns An unsubscribe function call it to stop receiving updates.
121
121
  */
122
122
  onRefresh = (callback) => {
123
123
  this.refreshCallbacks.add(callback);
@@ -138,14 +138,14 @@ export default class fhirStarter {
138
138
  */
139
139
  getJwks = async () => {
140
140
  const { keyId } = this.config, privateKey = await this.getPrivateKey(), jwk = await exportJWK(privateKey);
141
- // Public key only — strip private components
142
- (delete jwk.d,
143
- delete jwk.p,
144
- delete jwk.q,
145
- delete jwk.dp,
146
- delete jwk.dq,
147
- delete jwk.qi);
148
- ((jwk.alg = "RS384"), (jwk.use = "sig"));
141
+ delete jwk.d;
142
+ delete jwk.p;
143
+ delete jwk.q;
144
+ delete jwk.dp;
145
+ delete jwk.dq;
146
+ delete jwk.qi;
147
+ jwk.alg = "RS384";
148
+ jwk.use = "sig";
149
149
  if (keyId)
150
150
  jwk.kid = keyId;
151
151
  return { keys: [jwk] };
@@ -215,7 +215,7 @@ export default class fhirStarter {
215
215
  callback(token);
216
216
  }
217
217
  catch {
218
- // Ignore callback failures; auth lifecycle must continue.
218
+ // Ignore callback failures auth lifecycle must continue.
219
219
  }
220
220
  };
221
221
  refreshAccessToken = async () => {
@@ -298,4 +298,19 @@ export default class fhirStarter {
298
298
  throw new Error(`AuthConfig: privateKey must be PEM text, a Buffer, or a readable file path: ${trimmed}`);
299
299
  }
300
300
  }
301
+ /**
302
+ * Derives a deterministic key identifier from a private key using an
303
+ * RFC 7638 JWK Thumbprint (SHA-256 of canonical RSA public JWK members, base64url).
304
+ *
305
+ * @param privateKey - RSA PKCS#8 PEM text, a Buffer, or a readable file path.
306
+ * @returns A base64url-encoded SHA-256 thumbprint string.
307
+ * @throws If the key is not RSA or cannot be parsed.
308
+ */
309
+ static thumbprint(privateKey) {
310
+ const pem = fhirStarter.resolvePrivateKey(privateKey), key = createPrivateKey(pem);
311
+ if (key.asymmetricKeyType !== "rsa")
312
+ throw new Error(`thumbprint: expected RSA key, got ${key.asymmetricKeyType}`);
313
+ const pub = createPublicKey(key).export({ format: "jwk" }), canonical = JSON.stringify({ e: pub.e, kty: "RSA", n: pub.n });
314
+ return createHash("sha256").update(canonical).digest("base64url");
315
+ }
301
316
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirstarterjs",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "SMART Backend Services auth lifecycle for any FHIR client",
5
5
  "author": "Joshua Faulkenberry <j@joshuafaulkenberry.com> (https://joshuafaulkenberry.com/)",
6
6
  "license": "Kopimi",
@@ -56,7 +56,7 @@
56
56
  "jose": "^6.2.3"
57
57
  },
58
58
  "devDependencies": {
59
- "@types/node": "^25.9.3",
59
+ "@types/node": "^26.0.1",
60
60
  "typescript": "^6.0.3"
61
61
  }
62
62
  }
package/ts/fhirstarter.ts CHANGED
@@ -3,9 +3,10 @@ import {
3
3
  exportJWK,
4
4
  SignJWT,
5
5
  type JWTHeaderParameters,
6
- } from "jose";
7
- import { randomUUID } from "node:crypto";
8
- import { readFileSync } from "node:fs";
6
+ } from "jose"
7
+ import { createHash, createPrivateKey, createPublicKey, randomUUID } from "node:crypto"
8
+ import { readFileSync } from "node:fs"
9
+ import type { PublicKeyInput } from "node:crypto"
9
10
 
10
11
  /**
11
12
  * SMART Backend Services auth provider.
@@ -14,45 +15,45 @@ import { readFileSync } from "node:fs";
14
15
  * using a private RSA key and the JWT Bearer client-assertion flow (RFC 7523).
15
16
  */
16
17
  export default class fhirStarter implements Provider {
17
- private readonly config: AuthConfig;
18
- private readonly privateKeyText: string;
19
- private readonly refreshCallbacks: Set<RefreshCallback> = new Set();
20
- private cache: TokenCache | null = null;
21
- private refreshPromise: Promise<string> | null = null;
22
- private refreshTimer: ReturnType<typeof setTimeout> | null = null;
23
- private privateKeyObj: Awaited<ReturnType<typeof importPKCS8>> | null = null;
24
- private started = false;
25
- private refreshFailed = false;
26
- private refreshRetryMs = 5_000;
18
+ private readonly config: AuthConfig
19
+ private readonly privateKeyText: string
20
+ private readonly refreshCallbacks: Set<RefreshCallback> = new Set()
21
+ private cache: TokenCache | null = null
22
+ private refreshPromise: Promise<string> | null = null
23
+ private refreshTimer: ReturnType<typeof setTimeout> | null = null
24
+ private privateKeyObj: Awaited<ReturnType<typeof importPKCS8>> | null = null
25
+ private started = false
26
+ private refreshFailed = false
27
+ private refreshRetryMs = 5_000
27
28
 
28
29
  /**
29
30
  * @param config - Auth configuration including client ID, private key, token endpoint, and scopes.
30
31
  * @throws If any required field is missing, blank, or invalid.
31
32
  */
32
33
  constructor(config: AuthConfig) {
33
- if (!config.clientId) throw new Error("AuthConfig: clientId is required");
34
+ if (!config.clientId) throw new Error("AuthConfig: clientId is required")
34
35
  if (
35
36
  !config.privateKey ||
36
37
  (Buffer.isBuffer(config.privateKey) && config.privateKey.length === 0)
37
38
  )
38
- throw new Error("AuthConfig: privateKey is required");
39
+ throw new Error("AuthConfig: privateKey is required")
39
40
  if (!config.tokenEndpointUrl)
40
- throw new Error("AuthConfig: tokenEndpointUrl is required");
41
+ throw new Error("AuthConfig: tokenEndpointUrl is required")
41
42
 
42
- const scopes = fhirStarter.normalizeScopes(config.scopes);
43
+ const scopes = fhirStarter.normalizeScopes(config.scopes)
43
44
  if (scopes.length === 0)
44
- throw new Error("AuthConfig: at least one scope is required");
45
+ throw new Error("AuthConfig: at least one scope is required")
45
46
 
46
47
  try {
47
- new URL(config.tokenEndpointUrl);
48
+ new URL(config.tokenEndpointUrl)
48
49
  } catch {
49
50
  throw new Error(
50
51
  `AuthConfig: tokenEndpointUrl is not a valid URL: ${config.tokenEndpointUrl}`,
51
- );
52
+ )
52
53
  }
53
54
 
54
- this.config = config;
55
- this.privateKeyText = fhirStarter.resolvePrivateKey(config.privateKey);
55
+ this.config = config
56
+ this.privateKeyText = fhirStarter.resolvePrivateKey(config.privateKey)
56
57
  }
57
58
 
58
59
  // --- Sync getters ---
@@ -61,20 +62,20 @@ export default class fhirStarter implements Provider {
61
62
  get token(): string | null {
62
63
  return this.cache && Date.now() < this.cache.expiresAt ?
63
64
  this.cache.accessToken
64
- : null;
65
+ : null
65
66
  }
66
67
 
67
68
  /** Seconds until the cached token expires, or `null` if no valid token is cached. */
68
69
  get expiresIn(): number | null {
69
70
  return this.cache && Date.now() < this.cache.expiresAt ?
70
71
  Math.ceil((this.cache.expiresAt - Date.now()) / 1000)
71
- : null;
72
+ : null
72
73
  }
73
74
 
74
75
  /** Ready-to-use `Authorization` header value (e.g. `"Bearer <token>"`), or `null` if no valid token is cached. */
75
76
  get authorizationHeader(): string | null {
76
- const token = this.token;
77
- return token ? `Bearer ${token}` : null;
77
+ const token = this.token
78
+ return token ? `Bearer ${token}` : null
78
79
  }
79
80
 
80
81
  // --- Lifecycle ---
@@ -85,30 +86,30 @@ export default class fhirStarter implements Provider {
85
86
  * @throws If the initial token acquisition fails.
86
87
  */
87
88
  start = async (): Promise<void> => {
88
- if (this.started) return;
89
- this.started = true;
90
- this.refreshRetryMs = 5_000;
91
- this.refreshFailed = false;
89
+ if (this.started) return
90
+ this.started = true
91
+ this.refreshRetryMs = 5_000
92
+ this.refreshFailed = false
92
93
  try {
93
- await this.getAccessToken();
94
+ await this.getAccessToken()
94
95
  } catch (err) {
95
- this.started = false;
96
- throw err;
96
+ this.started = false
97
+ throw err
97
98
  }
98
- this.scheduleRefresh();
99
- };
99
+ this.scheduleRefresh()
100
+ }
100
101
 
101
102
  /**
102
103
  * Stops the background refresh timer.
103
104
  * Any in-flight refresh will still complete, but no new timers will be scheduled.
104
105
  */
105
106
  stop = (): void => {
106
- this.started = false;
107
+ this.started = false
107
108
  if (this.refreshTimer) {
108
- clearTimeout(this.refreshTimer);
109
- this.refreshTimer = null;
109
+ clearTimeout(this.refreshTimer)
110
+ this.refreshTimer = null
110
111
  }
111
- };
112
+ }
112
113
 
113
114
  // --- Token adapters ---
114
115
 
@@ -118,38 +119,38 @@ export default class fhirStarter implements Provider {
118
119
  * Useful for libraries that hold a reference to a token response object.
119
120
  */
120
121
  tokenResponse = (): LiveTokenResponse => {
121
- const auth = this;
122
+ const auth = this
122
123
  return {
123
124
  token_type: "bearer",
124
125
  get access_token() {
125
- return auth.token ?? undefined;
126
+ return auth.token ?? undefined
126
127
  },
127
128
  get expires_in() {
128
- return auth.expiresIn ?? undefined;
129
+ return auth.expiresIn ?? undefined
129
130
  },
130
131
  get scope() {
131
- return auth.cache?.scope;
132
+ return auth.cache?.scope
132
133
  },
133
- };
134
- };
134
+ }
135
+ }
135
136
 
136
137
  /**
137
138
  * Registers a callback that is invoked whenever a new access token is obtained.
138
139
  * The callback is called immediately with the current token if one is already cached.
139
140
  * @param callback - Receives the new access token string.
140
- * @returns An unsubscribe function; call it to stop receiving updates.
141
+ * @returns An unsubscribe function call it to stop receiving updates.
141
142
  */
142
143
  onRefresh = (callback: RefreshCallback): (() => void) => {
143
- this.refreshCallbacks.add(callback);
144
- const current = this.token;
144
+ this.refreshCallbacks.add(callback)
145
+ const current = this.token
145
146
  if (current)
146
147
  try {
147
- callback(current);
148
+ callback(current)
148
149
  } catch {
149
150
  /* ignore */
150
151
  }
151
- return () => this.refreshCallbacks.delete(callback);
152
- };
152
+ return () => this.refreshCallbacks.delete(callback)
153
+ }
153
154
 
154
155
  // --- Public async ---
155
156
 
@@ -160,20 +161,20 @@ export default class fhirStarter implements Provider {
160
161
  getJwks = async (): Promise<JwkSet> => {
161
162
  const { keyId } = this.config,
162
163
  privateKey = await this.getPrivateKey(),
163
- jwk = await exportJWK(privateKey);
164
-
165
- // Public key only — strip private components
166
- (delete jwk.d,
167
- delete jwk.p,
168
- delete jwk.q,
169
- delete jwk.dp,
170
- delete jwk.dq,
171
- delete jwk.qi);
172
- ((jwk.alg = "RS384"), (jwk.use = "sig"));
173
- if (keyId) jwk.kid = keyId;
174
-
175
- return { keys: [jwk] };
176
- };
164
+ jwk = await exportJWK(privateKey)
165
+
166
+ delete jwk.d
167
+ delete jwk.p
168
+ delete jwk.q
169
+ delete jwk.dp
170
+ delete jwk.dq
171
+ delete jwk.qi
172
+ jwk.alg = "RS384"
173
+ jwk.use = "sig"
174
+ if (keyId) jwk.kid = keyId
175
+
176
+ return { keys: [jwk] }
177
+ }
177
178
 
178
179
  /**
179
180
  * Returns a valid access token, refreshing if the cached token is at or past its refresh threshold.
@@ -182,68 +183,68 @@ export default class fhirStarter implements Provider {
182
183
  */
183
184
  getAccessToken = async (): Promise<string> => {
184
185
  if (this.cache && Date.now() < this.cache.refreshAt)
185
- return this.cache.accessToken;
186
- const staleCache = this.cache;
186
+ return this.cache.accessToken
187
+ const staleCache = this.cache
187
188
  try {
188
- return await this.doRefresh();
189
+ return await this.doRefresh()
189
190
  } catch (err) {
190
191
  // Graceful degradation: if the token is not actually expired yet, return it
191
192
  if (staleCache && Date.now() < staleCache.expiresAt)
192
- return staleCache.accessToken;
193
- throw err;
193
+ return staleCache.accessToken
194
+ throw err
194
195
  }
195
- };
196
+ }
196
197
 
197
198
  // --- Private ---
198
199
 
199
200
  // Single-flight refresh — shared by getAccessToken and the proactive timer
200
201
  private doRefresh = (): Promise<string> => {
201
- if (this.refreshPromise) return this.refreshPromise;
202
+ if (this.refreshPromise) return this.refreshPromise
202
203
  this.refreshPromise = this.refreshAccessToken()
203
204
  .then((cache) => {
204
- this.refreshPromise = null;
205
- this.refreshFailed = false;
206
- this.refreshRetryMs = 5_000;
207
- if (this.started) this.scheduleRefresh();
208
- return cache.accessToken;
205
+ this.refreshPromise = null
206
+ this.refreshFailed = false
207
+ this.refreshRetryMs = 5_000
208
+ if (this.started) this.scheduleRefresh()
209
+ return cache.accessToken
209
210
  })
210
211
  .catch((err) => {
211
- this.refreshPromise = null;
212
- throw err;
213
- });
214
- return this.refreshPromise;
215
- };
212
+ this.refreshPromise = null
213
+ throw err
214
+ })
215
+ return this.refreshPromise
216
+ }
216
217
 
217
218
  private scheduleRefresh = (): void => {
218
- if (!this.started) return;
219
- if (this.refreshTimer) clearTimeout(this.refreshTimer);
219
+ if (!this.started) return
220
+ if (this.refreshTimer) clearTimeout(this.refreshTimer)
220
221
 
221
222
  const delay =
222
223
  !this.cache || this.refreshFailed ?
223
224
  this.refreshRetryMs
224
- : Math.max(1_000, this.cache.refreshAt - Date.now());
225
+ : Math.max(1_000, this.cache.refreshAt - Date.now())
225
226
 
226
227
  this.refreshTimer = setTimeout(async () => {
227
228
  try {
228
- await this.doRefresh();
229
+ await this.doRefresh()
229
230
  } catch {
230
- this.refreshFailed = true;
231
- this.refreshRetryMs = Math.min(this.refreshRetryMs * 2, 60_000);
232
- this.scheduleRefresh();
231
+ this.refreshFailed = true
232
+ this.refreshRetryMs = Math.min(this.refreshRetryMs * 2, 60_000)
233
+ this.scheduleRefresh()
233
234
  }
234
- }, delay);
235
+ }, delay)
235
236
 
236
- this.refreshTimer.unref?.();
237
- };
237
+ this.refreshTimer.unref?.()
238
+ }
238
239
 
239
240
  private notifyRefresh = (token: string): void => {
240
241
  for (const callback of this.refreshCallbacks)
241
242
  try {
242
- callback(token);
243
+ callback(token)
243
244
  } catch {
244
- // Ignore callback failures; auth lifecycle must continue.
245
+ // Ignore callback failures auth lifecycle must continue.
245
246
  }
246
- };
247
+ }
247
248
 
248
249
  private refreshAccessToken = async (): Promise<TokenCache> => {
249
250
  const jwt = await this.buildJwt(),
@@ -260,24 +261,24 @@ export default class fhirStarter implements Provider {
260
261
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
261
262
  body,
262
263
  signal: AbortSignal.timeout(30_000),
263
- });
264
+ })
264
265
 
265
266
  if (!res.ok) {
266
- const text = await res.text().catch(() => "(no body)");
267
- throw new Error(`Token request failed (${res.status}): ${text}`);
267
+ const text = await res.text().catch(() => "(no body)")
268
+ throw new Error(`Token request failed (${res.status}): ${text}`)
268
269
  }
269
270
 
270
- let data: TokenResponse;
271
+ let data: TokenResponse
271
272
  try {
272
- data = await res.json();
273
+ data = await res.json()
273
274
  } catch {
274
- throw new Error("Token response is not valid JSON");
275
+ throw new Error("Token response is not valid JSON")
275
276
  }
276
277
 
277
278
  if (!data.access_token || typeof data.access_token !== "string")
278
- throw new Error("Token response missing access_token");
279
+ throw new Error("Token response missing access_token")
279
280
  if (typeof data.expires_in !== "number" || data.expires_in <= 0)
280
- throw new Error("Token response has invalid expires_in");
281
+ throw new Error("Token response has invalid expires_in")
281
282
 
282
283
  const ttlMs = data.expires_in * 1000,
283
284
  bufferMs = Math.min(60_000, ttlMs / 2),
@@ -289,12 +290,12 @@ export default class fhirStarter implements Provider {
289
290
  refreshAt,
290
291
  expiresAt,
291
292
  ...(typeof data.scope === "string" && { scope: data.scope }),
292
- };
293
+ }
293
294
 
294
- this.cache = cache;
295
- this.notifyRefresh(cache.accessToken);
296
- return cache;
297
- };
295
+ this.cache = cache
296
+ this.notifyRefresh(cache.accessToken)
297
+ return cache
298
+ }
298
299
 
299
300
  private getPrivateKey = async (): Promise<
300
301
  Awaited<ReturnType<typeof importPKCS8>>
@@ -302,16 +303,16 @@ export default class fhirStarter implements Provider {
302
303
  if (!this.privateKeyObj)
303
304
  this.privateKeyObj = await importPKCS8(this.privateKeyText, "RS384", {
304
305
  extractable: true,
305
- });
306
- return this.privateKeyObj;
307
- };
306
+ })
307
+ return this.privateKeyObj
308
+ }
308
309
 
309
310
  private buildJwt = async (): Promise<string> => {
310
311
  const { clientId, tokenEndpointUrl, keyId, jwksUrl } = this.config,
311
312
  privateKey = await this.getPrivateKey(),
312
- header: JWTHeaderParameters = { alg: "RS384", typ: "JWT" };
313
- if (keyId) header.kid = keyId;
314
- if (jwksUrl) header.jku = jwksUrl;
313
+ header: JWTHeaderParameters = { alg: "RS384", typ: "JWT" }
314
+ if (keyId) header.kid = keyId
315
+ if (jwksUrl) header.jku = jwksUrl
315
316
 
316
317
  return new SignJWT({
317
318
  iss: clientId,
@@ -322,30 +323,50 @@ export default class fhirStarter implements Provider {
322
323
  .setProtectedHeader(header)
323
324
  .setIssuedAt()
324
325
  .setExpirationTime("5m")
325
- .sign(privateKey);
326
- };
326
+ .sign(privateKey)
327
+ }
327
328
 
328
329
  // --- Static helpers ---
329
330
 
330
331
  private static normalizeScopes(scopes: string | string[]): string[] {
331
332
  return Array.isArray(scopes) ?
332
333
  scopes.filter(Boolean)
333
- : scopes.split(/\s+/).filter(Boolean);
334
+ : scopes.split(/\s+/).filter(Boolean)
334
335
  }
335
336
 
336
337
  private static resolvePrivateKey(privateKey: string | Buffer): string {
337
338
  if (Buffer.isBuffer(privateKey))
338
- return privateKey.toString("utf-8").trim();
339
+ return privateKey.toString("utf-8").trim()
339
340
 
340
- const trimmed = privateKey.trim();
341
- if (trimmed.includes("-----BEGIN")) return trimmed;
341
+ const trimmed = privateKey.trim()
342
+ if (trimmed.includes("-----BEGIN")) return trimmed
342
343
 
343
344
  try {
344
- return readFileSync(trimmed, "utf-8").trim();
345
+ return readFileSync(trimmed, "utf-8").trim()
345
346
  } catch {
346
347
  throw new Error(
347
348
  `AuthConfig: privateKey must be PEM text, a Buffer, or a readable file path: ${trimmed}`,
348
- );
349
+ )
349
350
  }
350
351
  }
352
+
353
+ /**
354
+ * Derives a deterministic key identifier from a private key using an
355
+ * RFC 7638 JWK Thumbprint (SHA-256 of canonical RSA public JWK members, base64url).
356
+ *
357
+ * @param privateKey - RSA PKCS#8 PEM text, a Buffer, or a readable file path.
358
+ * @returns A base64url-encoded SHA-256 thumbprint string.
359
+ * @throws If the key is not RSA or cannot be parsed.
360
+ */
361
+ static thumbprint(privateKey: string | Buffer): string {
362
+ const
363
+ pem = fhirStarter.resolvePrivateKey(privateKey),
364
+ key = createPrivateKey(pem)
365
+ if (key.asymmetricKeyType !== "rsa")
366
+ throw new Error(`thumbprint: expected RSA key, got ${key.asymmetricKeyType}`)
367
+ const
368
+ pub = createPublicKey(key as unknown as PublicKeyInput).export({ format: "jwk" }) as { e?: string, n?: string, kty?: string },
369
+ canonical = JSON.stringify({ e: pub.e, kty: "RSA", n: pub.n })
370
+ return createHash("sha256").update(canonical).digest("base64url")
371
+ }
351
372
  }