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 +14 -0
- package/dist/fhirstarter.js +26 -11
- package/package.json +2 -2
- package/ts/fhirstarter.ts +143 -122
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:
|
package/dist/fhirstarter.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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.
|
|
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": "^
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
}
|