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