@remit/web-client 0.0.36 → 0.0.38
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.38",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
|
|
30
30
|
"preview": "vite preview",
|
|
31
31
|
"test:typecheck": "npm run generate:routes && tsgo --noEmit",
|
|
32
|
-
"test:run": "node --import tsx --import ./test-support/register.mjs --test 'src/**/*.test.ts'",
|
|
32
|
+
"test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=50 --test 'src/**/*.test.ts'",
|
|
33
33
|
"test": "npm run test:typecheck && npm run test:run"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createAuthClient } from "better-auth/react";
|
|
2
|
-
import { taggedFetch } from "../lib/network-error";
|
|
2
|
+
import { NetworkError, taggedFetch } from "../lib/network-error";
|
|
3
3
|
import { getRuntimeConfig } from "../runtime-config";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -185,18 +185,47 @@ const isFresh = (token: CachedToken): boolean =>
|
|
|
185
185
|
const isUsable = (token: CachedToken): boolean =>
|
|
186
186
|
token.expiresAt > nowSeconds();
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A mint failure the held token is allowed to ride out: a request timeout (408),
|
|
190
|
+
* throttling (429), a server-side fault (5xx), or a transport failure that never
|
|
191
|
+
* reached the server. These say nothing about the session's validity, so keeping
|
|
192
|
+
* the token the tab already holds is correct. A 4xx that is not 408 or 429 does
|
|
193
|
+
* not qualify — least of all a 401/403, which is the server revoking the session.
|
|
194
|
+
*/
|
|
195
|
+
const isTransientMintFailure = (error: unknown): boolean => {
|
|
196
|
+
if (error instanceof NetworkError) return true;
|
|
197
|
+
if (error instanceof AuthTokenError) {
|
|
198
|
+
return (
|
|
199
|
+
error.status === 408 || error.status === 429 || (error.status ?? 0) >= 500
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The mint was refused because the session is no longer valid. The held token is
|
|
207
|
+
* signed for that same revoked session, so it must be discarded rather than
|
|
208
|
+
* served until it expires.
|
|
209
|
+
*/
|
|
210
|
+
const isRevocation = (error: unknown): boolean =>
|
|
211
|
+
error instanceof AuthTokenError &&
|
|
212
|
+
(error.status === 401 || error.status === 403);
|
|
213
|
+
|
|
188
214
|
/**
|
|
189
215
|
* Return a valid RS256 JWT for the current session, minting a fresh one only
|
|
190
216
|
* when the cached token is missing or within the skew window of expiry. The API
|
|
191
217
|
* interceptor attaches it as a Bearer token; the backend verifies it against the
|
|
192
218
|
* JWKS.
|
|
193
219
|
*
|
|
194
|
-
* A throttled or
|
|
195
|
-
* request keeps the session it already holds and the endpoint
|
|
196
|
-
* until the backoff clears.
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
220
|
+
* A throttled, faulted, or unreachable refresh never discards a token that is
|
|
221
|
+
* still usable: the request keeps the session it already holds and the endpoint
|
|
222
|
+
* is left alone until the backoff clears. A revocation (401/403) is the opposite
|
|
223
|
+
* — the session behind the held token is gone, so it is cleared and the failure
|
|
224
|
+
* propagates to re-authentication rather than serving a dead token until expiry.
|
|
225
|
+
* Only a mint with nothing usable to fall back to throws otherwise — returning
|
|
226
|
+
* null would put the request on the wire without an Authorization header, and the
|
|
227
|
+
* backend answers that with a 401 that names a missing token rather than the mint
|
|
228
|
+
* that failed.
|
|
200
229
|
*/
|
|
201
230
|
export const fetchBetterAuthToken = async (): Promise<string> => {
|
|
202
231
|
const current = currentCache();
|
|
@@ -212,7 +241,11 @@ export const fetchBetterAuthToken = async (): Promise<string> => {
|
|
|
212
241
|
try {
|
|
213
242
|
return await mint();
|
|
214
243
|
} catch (error) {
|
|
215
|
-
if (
|
|
244
|
+
if (isRevocation(error)) {
|
|
245
|
+
resetBetterAuthTokenCache();
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
if (current && isUsable(current) && isTransientMintFailure(error)) {
|
|
216
249
|
backoffUntil = nowSeconds() + REFRESH_BACKOFF_SECONDS;
|
|
217
250
|
return current.value;
|
|
218
251
|
}
|
|
@@ -197,6 +197,134 @@ describe("fetchBetterAuthToken", () => {
|
|
|
197
197
|
assert.equal(second, held);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
test("a network failure keeps the still-valid token instead of discarding it", async () => {
|
|
201
|
+
const seed = stubTokenEndpoint(() =>
|
|
202
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
203
|
+
);
|
|
204
|
+
seed.release();
|
|
205
|
+
const held = await fetchBetterAuthToken();
|
|
206
|
+
|
|
207
|
+
globalThis.fetch = (async () => {
|
|
208
|
+
throw new TypeError("Failed to fetch");
|
|
209
|
+
}) as typeof fetch;
|
|
210
|
+
const afterNetworkFailure = await fetchBetterAuthToken();
|
|
211
|
+
|
|
212
|
+
assert.equal(afterNetworkFailure, held);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a 5xx refresh keeps the still-valid token instead of discarding it", async () => {
|
|
216
|
+
const seed = stubTokenEndpoint(() =>
|
|
217
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
218
|
+
);
|
|
219
|
+
seed.release();
|
|
220
|
+
const held = await fetchBetterAuthToken();
|
|
221
|
+
|
|
222
|
+
const faulted = stubTokenEndpoint(
|
|
223
|
+
() =>
|
|
224
|
+
new Response("", { status: 503, statusText: "Service Unavailable" }),
|
|
225
|
+
);
|
|
226
|
+
faulted.release();
|
|
227
|
+
const afterFault = await fetchBetterAuthToken();
|
|
228
|
+
|
|
229
|
+
assert.equal(faulted.calls, 1);
|
|
230
|
+
assert.equal(afterFault, held);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a 408 refresh keeps the still-valid token instead of discarding it", async () => {
|
|
234
|
+
const seed = stubTokenEndpoint(() =>
|
|
235
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
236
|
+
);
|
|
237
|
+
seed.release();
|
|
238
|
+
const held = await fetchBetterAuthToken();
|
|
239
|
+
|
|
240
|
+
const timedOut = stubTokenEndpoint(
|
|
241
|
+
() => new Response("", { status: 408, statusText: "Request Timeout" }),
|
|
242
|
+
);
|
|
243
|
+
timedOut.release();
|
|
244
|
+
const afterTimeout = await fetchBetterAuthToken();
|
|
245
|
+
|
|
246
|
+
assert.equal(timedOut.calls, 1);
|
|
247
|
+
assert.equal(afterTimeout, held);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("an unclassified 4xx refresh throws without serving the held token", async () => {
|
|
251
|
+
const seed = stubTokenEndpoint(() =>
|
|
252
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
253
|
+
);
|
|
254
|
+
seed.release();
|
|
255
|
+
await fetchBetterAuthToken();
|
|
256
|
+
|
|
257
|
+
const rejected = stubTokenEndpoint(
|
|
258
|
+
() => new Response("", { status: 400, statusText: "Bad Request" }),
|
|
259
|
+
);
|
|
260
|
+
rejected.release();
|
|
261
|
+
|
|
262
|
+
await assert.rejects(
|
|
263
|
+
() => fetchBetterAuthToken(),
|
|
264
|
+
(error: unknown) => {
|
|
265
|
+
assert.ok(error instanceof AuthTokenError);
|
|
266
|
+
assert.equal(error.status, 400);
|
|
267
|
+
return true;
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
assert.equal(rejected.calls, 1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a revoked mint (401) clears the held token and re-authenticates", async () => {
|
|
274
|
+
const seed = stubTokenEndpoint(() =>
|
|
275
|
+
tokenResponse(nearExpiryJwt("revoked-soon")),
|
|
276
|
+
);
|
|
277
|
+
seed.release();
|
|
278
|
+
const held = await fetchBetterAuthToken();
|
|
279
|
+
|
|
280
|
+
const revoked = stubTokenEndpoint(
|
|
281
|
+
() => new Response("", { status: 401, statusText: "Unauthorized" }),
|
|
282
|
+
);
|
|
283
|
+
revoked.release();
|
|
284
|
+
await assert.rejects(
|
|
285
|
+
() => fetchBetterAuthToken(),
|
|
286
|
+
(error: unknown) => {
|
|
287
|
+
assert.ok(error instanceof AuthTokenError);
|
|
288
|
+
assert.equal(error.status, 401);
|
|
289
|
+
return true;
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
assert.equal(revoked.calls, 1);
|
|
293
|
+
|
|
294
|
+
const reissued = stubTokenEndpoint(() => tokenResponse(jwt("reissued")));
|
|
295
|
+
reissued.release();
|
|
296
|
+
const afterReauth = await fetchBetterAuthToken();
|
|
297
|
+
|
|
298
|
+
assert.equal(reissued.calls, 1);
|
|
299
|
+
assert.notEqual(afterReauth, held);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a revoked mint (403) clears the held token and re-authenticates", async () => {
|
|
303
|
+
const seed = stubTokenEndpoint(() =>
|
|
304
|
+
tokenResponse(nearExpiryJwt("forbidden-soon")),
|
|
305
|
+
);
|
|
306
|
+
seed.release();
|
|
307
|
+
await fetchBetterAuthToken();
|
|
308
|
+
|
|
309
|
+
const forbidden = stubTokenEndpoint(
|
|
310
|
+
() => new Response("", { status: 403, statusText: "Forbidden" }),
|
|
311
|
+
);
|
|
312
|
+
forbidden.release();
|
|
313
|
+
await assert.rejects(
|
|
314
|
+
() => fetchBetterAuthToken(),
|
|
315
|
+
(error: unknown) => {
|
|
316
|
+
assert.ok(error instanceof AuthTokenError);
|
|
317
|
+
assert.equal(error.status, 403);
|
|
318
|
+
return true;
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const reissued = stubTokenEndpoint(() => tokenResponse(jwt("reissued")));
|
|
323
|
+
reissued.release();
|
|
324
|
+
assert.ok(await fetchBetterAuthToken());
|
|
325
|
+
assert.equal(reissued.calls, 1);
|
|
326
|
+
});
|
|
327
|
+
|
|
200
328
|
test("a throttled refresh with no usable token to fall back on still throws", async () => {
|
|
201
329
|
const seed = stubTokenEndpoint(() =>
|
|
202
330
|
tokenResponse(jwtExpiringIn("already-expired", -10)),
|