@ovok/core 0.4.30 → 0.4.31

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.
@@ -32,6 +32,24 @@ export type SaveObservationsResult = {
32
32
  queueId: string;
33
33
  };
34
34
  export type OvokFhirVersion = "R4" | "R5";
35
+ export type OvokUnauthenticatedReason = {
36
+ type: "refresh-rejected";
37
+ status: 400 | 401 | 403;
38
+ } | {
39
+ type: "refresh-unavailable";
40
+ status?: number;
41
+ } | {
42
+ type: "authentication-rejected";
43
+ status: number;
44
+ } | {
45
+ type: "request-unauthorized";
46
+ status: 401;
47
+ };
48
+ export type OvokClientOptions = Omit<MedplumClientOptions, "onUnauthenticated"> & {
49
+ onUnauthenticated?: (reason: OvokUnauthenticatedReason) => void;
50
+ socialLoginClientId?: string;
51
+ offlineQueue?: OfflineMeasurementQueueOptions;
52
+ };
35
53
  export declare class OvokClient extends MedplumClient {
36
54
  protected socialLoginClientId: string;
37
55
  clientStorage: IClientStorage | undefined;
@@ -42,10 +60,7 @@ export declare class OvokClient extends MedplumClient {
42
60
  * @param config - The configuration for the client.
43
61
  * @param config.socialLoginClientId - The social login client id to use for the client.
44
62
  */
45
- constructor(config: MedplumClientOptions & {
46
- socialLoginClientId?: string;
47
- offlineQueue?: OfflineMeasurementQueueOptions;
48
- });
63
+ constructor(config: OvokClientOptions);
49
64
  /** Lists saved accounts without exposing their access or refresh tokens. */
50
65
  getSavedAccounts(): OvokSavedAccount[];
51
66
  /** Activates a previously saved account without asking for its password again. */
@@ -64,6 +64,53 @@ const slimMethods = __importStar(require("./slim/methods"));
64
64
  exports.MAX_CONDITIONAL_OBSERVATION_ENTRIES = 8;
65
65
  var observation_bundle_utils_2 = require("./observation/observation-bundle-utils");
66
66
  Object.defineProperty(exports, "MAX_OBSERVATION_BUNDLE_BYTES", { enumerable: true, get: function () { return observation_bundle_utils_2.MAX_OBSERVATION_BUNDLE_BYTES; } });
67
+ const retryAfterMs = (response) => {
68
+ var _a, _b;
69
+ const value = (_b = (_a = response.headers) === null || _a === void 0 ? void 0 : _a.get) === null || _b === void 0 ? void 0 : _b.call(_a, "Retry-After");
70
+ if (!value) {
71
+ return undefined;
72
+ }
73
+ const seconds = Number(value);
74
+ if (Number.isFinite(seconds)) {
75
+ return Math.max(0, seconds * 1000);
76
+ }
77
+ const date = Date.parse(value);
78
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
79
+ };
80
+ const waitForRetry = (milliseconds, signal) => new Promise((resolve, reject) => {
81
+ var _a;
82
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
83
+ const error = new Error("Token refresh aborted");
84
+ error.name = "AbortError";
85
+ reject((_a = signal.reason) !== null && _a !== void 0 ? _a : error);
86
+ return;
87
+ }
88
+ const timer = setTimeout(() => {
89
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener("abort", abort);
90
+ resolve();
91
+ }, milliseconds);
92
+ const abort = () => {
93
+ var _a;
94
+ clearTimeout(timer);
95
+ const error = new Error("Token refresh aborted");
96
+ error.name = "AbortError";
97
+ reject((_a = signal === null || signal === void 0 ? void 0 : signal.reason) !== null && _a !== void 0 ? _a : error);
98
+ };
99
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener("abort", abort, { once: true });
100
+ });
101
+ const isTemporaryRefreshFailure = (status) => status === 429 || (status >= 500 && status <= 599);
102
+ const refreshUnavailableError = (response) => {
103
+ const error = new Error("Token refresh is temporarily unavailable");
104
+ error.name = "NetworkError";
105
+ Object.assign(error, { cause: response });
106
+ return error;
107
+ };
108
+ const refreshResponseError = (response) => {
109
+ const error = new Error(`Token refresh returned HTTP ${response.status}`);
110
+ error.name = "TokenRefreshError";
111
+ Object.assign(error, { status: response.status, cause: response });
112
+ return error;
113
+ };
67
114
  const isNetworkError = (error) => {
68
115
  if (!(error instanceof Error)) {
69
116
  return false;
@@ -87,25 +134,174 @@ class OvokClient extends core_1.MedplumClient {
87
134
  const baseUrl = (_c = config.baseUrl) !== null && _c !== void 0 ? _c : "https://api.medplum.com/";
88
135
  const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
89
136
  const tokenUrl = new URL((_d = config.tokenUrl) !== null && _d !== void 0 ? _d : "oauth2/token", normalizedBaseUrl).href;
90
- super({
91
- ...config,
92
- storage,
93
- fetch: async (url, options) => {
94
- var _a, _b;
95
- const response = await fetchImplementation(url, options);
96
- const requestUrl = new URL(url, normalizedBaseUrl);
97
- const refreshRefused = requestUrl.href === tokenUrl && [400, 401, 403].includes(response.status);
98
- const sessionEnded = requestUrl.pathname.endsWith("/auth/session/current") && response.status === 401;
99
- if (refreshRefused || sessionEnded) {
137
+ let unauthenticatedReason;
138
+ let clearActiveLogin = () => undefined;
139
+ let terminalRefreshToken;
140
+ let terminalRefreshError;
141
+ const isTokenRefresh = (url, options) => new URL(url, normalizedBaseUrl).href === tokenUrl &&
142
+ (options === null || options === void 0 ? void 0 : options.method) === "POST" &&
143
+ typeof options.body === "string" &&
144
+ new URLSearchParams(options.body).get("grant_type") === "refresh_token";
145
+ const markActiveAccountExpired = () => {
146
+ var _a, _b;
147
+ const accountId = (_b = (_a = storage.getObject("activeLogin")) === null || _a === void 0 ? void 0 : _a.profile) === null || _b === void 0 ? void 0 : _b.reference;
148
+ if (accountId) {
149
+ (0, account_storage_1.setStoredAccountExpired)(storage, accountId, true);
150
+ }
151
+ };
152
+ const fetchOnce = async (url, options) => {
153
+ var _a, _b;
154
+ const response = await fetchImplementation(url, options);
155
+ const requestUrl = new URL(url, normalizedBaseUrl);
156
+ if (isTokenRefresh(url, options)) {
157
+ if (response.status === 400) {
158
+ let invalidGrant = false;
159
+ try {
160
+ invalidGrant = (await response.clone().json()).error === "invalid_grant";
161
+ }
162
+ catch (_c) {
163
+ // Do not sign out unless the response confirms that the refresh token was revoked.
164
+ }
165
+ if (!invalidGrant) {
166
+ throw refreshResponseError(response);
167
+ }
168
+ }
169
+ if ([400, 401, 403].includes(response.status)) {
170
+ unauthenticatedReason = {
171
+ type: "refresh-rejected",
172
+ status: response.status,
173
+ };
174
+ markActiveAccountExpired();
175
+ }
176
+ else if (response.ok) {
100
177
  const accountId = (_b = (_a = storage.getObject("activeLogin")) === null || _a === void 0 ? void 0 : _a.profile) === null || _b === void 0 ? void 0 : _b.reference;
101
178
  if (accountId) {
102
- (0, account_storage_1.setStoredAccountExpired)(storage, accountId, true);
179
+ (0, account_storage_1.setStoredAccountExpired)(storage, accountId, false);
103
180
  }
181
+ unauthenticatedReason = undefined;
182
+ }
183
+ }
184
+ else if (requestUrl.href === tokenUrl && !response.ok) {
185
+ unauthenticatedReason = {
186
+ type: "authentication-rejected",
187
+ status: response.status,
188
+ };
189
+ }
190
+ else if (response.status === 401) {
191
+ unauthenticatedReason = { type: "request-unauthorized", status: 401 };
192
+ }
193
+ return response;
194
+ };
195
+ const fetchWithRefreshRetry = async (url, options) => {
196
+ var _a, _b, _c, _d;
197
+ const requestUrl = new URL(url, normalizedBaseUrl);
198
+ if (!isTokenRefresh(url, options)) {
199
+ const response = await fetchOnce(url, options);
200
+ const sessionEnded = requestUrl.pathname.endsWith("/auth/session/current") && response.status === 401;
201
+ if (sessionEnded) {
202
+ markActiveAccountExpired();
104
203
  }
105
204
  return response;
205
+ }
206
+ const login = storage.getObject("activeLogin");
207
+ if (terminalRefreshError) {
208
+ if ((login === null || login === void 0 ? void 0 : login.accessToken) && login.accessToken !== terminalRefreshToken) {
209
+ terminalRefreshToken = undefined;
210
+ terminalRefreshError = undefined;
211
+ }
212
+ else {
213
+ throw terminalRefreshError;
214
+ }
215
+ }
216
+ const expiresAt = (login === null || login === void 0 ? void 0 : login.accessToken) ? (0, core_1.tryGetJwtExpiration)(login.accessToken) : undefined;
217
+ const signal = options === null || options === void 0 ? void 0 : options.signal;
218
+ if (expiresAt !== undefined && options) {
219
+ const tokenRequestOptions = options;
220
+ tokenRequestOptions.maxRetries = 0;
221
+ tokenRequestOptions.maxRetryTime = 0;
222
+ }
223
+ if (expiresAt === undefined) {
224
+ const response = await fetchOnce(url, options);
225
+ if (isTemporaryRefreshFailure(response.status)) {
226
+ void ((_a = response.body) === null || _a === void 0 ? void 0 : _a.cancel().catch(() => undefined));
227
+ throw refreshUnavailableError(response);
228
+ }
229
+ return response;
230
+ }
231
+ let attempt = 0;
232
+ let firstAttempt = true;
233
+ let lastResponse;
234
+ let lastNetworkError;
235
+ while (firstAttempt || Date.now() < expiresAt) {
236
+ firstAttempt = false;
237
+ try {
238
+ lastResponse = await fetchOnce(url, options);
239
+ }
240
+ catch (error) {
241
+ if ((signal === null || signal === void 0 ? void 0 : signal.aborted) || !isNetworkError(error)) {
242
+ throw error;
243
+ }
244
+ if (Date.now() >= expiresAt) {
245
+ lastNetworkError = error;
246
+ break;
247
+ }
248
+ lastNetworkError = error;
249
+ const remainingMs = expiresAt - Date.now();
250
+ const backoffMs = Math.min(500 * (2 ** Math.min(attempt, 6)), 30000);
251
+ await waitForRetry(Math.min(backoffMs, remainingMs), signal);
252
+ attempt += 1;
253
+ continue;
254
+ }
255
+ if (!isTemporaryRefreshFailure(lastResponse.status)) {
256
+ return lastResponse;
257
+ }
258
+ const remainingMs = expiresAt - Date.now();
259
+ if (remainingMs <= 0) {
260
+ break;
261
+ }
262
+ const backoffMs = Math.min(500 * (2 ** Math.min(attempt, 6)), 30000);
263
+ const waitMs = Math.min(Math.max((_b = retryAfterMs(lastResponse)) !== null && _b !== void 0 ? _b : 0, backoffMs), remainingMs);
264
+ if (waitMs >= remainingMs) {
265
+ await waitForRetry(remainingMs, signal);
266
+ break;
267
+ }
268
+ void ((_c = lastResponse.body) === null || _c === void 0 ? void 0 : _c.cancel().catch(() => undefined));
269
+ await waitForRetry(waitMs, signal);
270
+ attempt += 1;
271
+ }
272
+ const status = lastResponse === null || lastResponse === void 0 ? void 0 : lastResponse.status;
273
+ const reason = {
274
+ type: "refresh-unavailable",
275
+ ...(status === undefined ? {} : { status }),
276
+ };
277
+ unauthenticatedReason = reason;
278
+ markActiveAccountExpired();
279
+ if (lastResponse) {
280
+ return lastResponse;
281
+ }
282
+ terminalRefreshToken = login === null || login === void 0 ? void 0 : login.accessToken;
283
+ terminalRefreshError = lastNetworkError !== null && lastNetworkError !== void 0 ? lastNetworkError : refreshUnavailableError(undefined);
284
+ clearActiveLogin();
285
+ (_d = config.onUnauthenticated) === null || _d === void 0 ? void 0 : _d.call(config, reason);
286
+ unauthenticatedReason = undefined;
287
+ throw terminalRefreshError;
288
+ };
289
+ super({
290
+ ...config,
291
+ storage,
292
+ onUnauthenticated: () => {
293
+ var _a;
294
+ const reason = unauthenticatedReason !== null && unauthenticatedReason !== void 0 ? unauthenticatedReason : {
295
+ type: "request-unauthorized",
296
+ status: 401,
297
+ };
298
+ unauthenticatedReason = undefined;
299
+ (_a = config.onUnauthenticated) === null || _a === void 0 ? void 0 : _a.call(config, reason);
106
300
  },
301
+ fetch: fetchWithRefreshRetry,
107
302
  });
108
303
  this.socialLoginClientId = "";
304
+ clearActiveLogin = this.clearActiveLogin.bind(this);
109
305
  this.hasExplicitStorageAdapter = config.storage !== undefined;
110
306
  this.socialLoginClientId = (_e = config.socialLoginClientId) !== null && _e !== void 0 ? _e : "";
111
307
  this.addEventListener("profileRefreshed", () => this.syncSavedAccountProfile());
@@ -48,7 +48,7 @@ exports.OVOK_CORE_REQUIREMENTS = {
48
48
  description: "The FHIR surface a server must provide for the @ovok/core client library's own methods to function.",
49
49
  software: {
50
50
  name: "@ovok/core",
51
- version: "0.4.30",
51
+ version: "0.4.31",
52
52
  },
53
53
  fhirVersion: "4.0.1",
54
54
  format: ["json"],
package/dist/index.d.ts CHANGED
@@ -923,6 +923,7 @@ declare const Medplum: {
923
923
  };
924
924
  export { Medplum };
925
925
  export { OvokClient } from "./client/ovok-client";
926
+ export type { OvokClientOptions, OvokUnauthenticatedReason } from "./client/ovok-client";
926
927
  export * from "./client/errors/rate-limit-error";
927
928
  export * from "./client/errors/sign-in-required-error";
928
929
  export * from "./client/errors/ovok-error";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ovok/core",
3
- "version": "0.4.30",
3
+ "version": "0.4.31",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",