@remit/web-client 0.0.32 → 0.0.33
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.33",
|
|
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,12 +29,78 @@ export const authClient = createAuthClient();
|
|
|
29
29
|
|
|
30
30
|
const EXPIRY_SKEW_SECONDS = 60;
|
|
31
31
|
|
|
32
|
+
// How long to stop reaching for the token endpoint after a mint is throttled.
|
|
33
|
+
// A near-expiry token is still usable for `EXPIRY_SKEW_SECONDS`, so backing off
|
|
34
|
+
// keeps a throttled window from turning every request into another mint attempt
|
|
35
|
+
// against an endpoint that already said no.
|
|
36
|
+
const REFRESH_BACKOFF_SECONDS = 30;
|
|
37
|
+
|
|
38
|
+
// Survives page reloads and in-tab navigations so a fresh page load reuses the
|
|
39
|
+
// session's token instead of minting a new one. Cleared on sign-out. Scoped to
|
|
40
|
+
// the tab, not the origin: the token is short-lived and the httpOnly session
|
|
41
|
+
// cookie remains the real credential.
|
|
42
|
+
const STORAGE_KEY = "remit.better-auth.token";
|
|
43
|
+
|
|
32
44
|
interface CachedToken {
|
|
33
45
|
value: string;
|
|
34
46
|
expiresAt: number;
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
const tokenStore = (): Storage | null => {
|
|
50
|
+
try {
|
|
51
|
+
return globalThis.sessionStorage ?? null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const readStore = (): CachedToken | null => {
|
|
58
|
+
const raw = tokenStore()?.getItem(STORAGE_KEY);
|
|
59
|
+
if (!raw) return null;
|
|
60
|
+
try {
|
|
61
|
+
const parsed: unknown = JSON.parse(raw);
|
|
62
|
+
if (
|
|
63
|
+
parsed &&
|
|
64
|
+
typeof parsed === "object" &&
|
|
65
|
+
typeof (parsed as CachedToken).value === "string" &&
|
|
66
|
+
typeof (parsed as CachedToken).expiresAt === "number"
|
|
67
|
+
) {
|
|
68
|
+
return {
|
|
69
|
+
value: (parsed as CachedToken).value,
|
|
70
|
+
expiresAt: (parsed as CachedToken).expiresAt,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// A corrupt entry is treated as no token.
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const writeStore = (token: CachedToken | null): void => {
|
|
80
|
+
const store = tokenStore();
|
|
81
|
+
if (!store) return;
|
|
82
|
+
try {
|
|
83
|
+
if (!token) {
|
|
84
|
+
store.removeItem(STORAGE_KEY);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
store.setItem(STORAGE_KEY, JSON.stringify(token));
|
|
88
|
+
} catch {
|
|
89
|
+
// Storage being unavailable (private mode, quota) is not fatal: the
|
|
90
|
+
// in-memory cache still serves the current page.
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
37
94
|
let cached: CachedToken | null = null;
|
|
95
|
+
let backoffUntil = 0;
|
|
96
|
+
|
|
97
|
+
// Hydrate the in-memory cache from storage on first read of a page load, so a
|
|
98
|
+
// token minted before a reload is reused rather than re-minted.
|
|
99
|
+
const currentCache = (): CachedToken | null => {
|
|
100
|
+
if (cached) return cached;
|
|
101
|
+
cached = readStore();
|
|
102
|
+
return cached;
|
|
103
|
+
};
|
|
38
104
|
|
|
39
105
|
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
|
|
40
106
|
|
|
@@ -103,6 +169,8 @@ const mint = (): Promise<string> => {
|
|
|
103
169
|
inFlight = requestToken()
|
|
104
170
|
.then((token) => {
|
|
105
171
|
cached = { value: token, expiresAt: decodeExp(token) };
|
|
172
|
+
writeStore(cached);
|
|
173
|
+
backoffUntil = 0;
|
|
106
174
|
return token;
|
|
107
175
|
})
|
|
108
176
|
.finally(() => {
|
|
@@ -111,23 +179,49 @@ const mint = (): Promise<string> => {
|
|
|
111
179
|
return inFlight;
|
|
112
180
|
};
|
|
113
181
|
|
|
182
|
+
const isFresh = (token: CachedToken): boolean =>
|
|
183
|
+
token.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds();
|
|
184
|
+
|
|
185
|
+
const isUsable = (token: CachedToken): boolean =>
|
|
186
|
+
token.expiresAt > nowSeconds();
|
|
187
|
+
|
|
114
188
|
/**
|
|
115
189
|
* Return a valid RS256 JWT for the current session, minting a fresh one only
|
|
116
190
|
* when the cached token is missing or within the skew window of expiry. The API
|
|
117
191
|
* interceptor attaches it as a Bearer token; the backend verifies it against the
|
|
118
192
|
* JWKS.
|
|
119
193
|
*
|
|
120
|
-
*
|
|
121
|
-
* request
|
|
122
|
-
*
|
|
194
|
+
* A throttled or failed refresh never discards a token that is still usable: the
|
|
195
|
+
* request keeps the session it already holds and the endpoint is left alone
|
|
196
|
+
* until the backoff clears. Only a mint with nothing usable to fall back to
|
|
197
|
+
* throws — returning null would put the request on the wire without an
|
|
198
|
+
* Authorization header, and the backend answers that with a 401 that names a
|
|
199
|
+
* missing token rather than the mint that failed.
|
|
123
200
|
*/
|
|
124
201
|
export const fetchBetterAuthToken = async (): Promise<string> => {
|
|
125
|
-
|
|
126
|
-
|
|
202
|
+
const current = currentCache();
|
|
203
|
+
|
|
204
|
+
if (current && isFresh(current)) {
|
|
205
|
+
return current.value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (current && isUsable(current) && nowSeconds() < backoffUntil) {
|
|
209
|
+
return current.value;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
return await mint();
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (current && isUsable(current)) {
|
|
216
|
+
backoffUntil = nowSeconds() + REFRESH_BACKOFF_SECONDS;
|
|
217
|
+
return current.value;
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
127
220
|
}
|
|
128
|
-
return mint();
|
|
129
221
|
};
|
|
130
222
|
|
|
131
223
|
export const resetBetterAuthTokenCache = (): void => {
|
|
224
|
+
backoffUntil = 0;
|
|
225
|
+
writeStore(null);
|
|
132
226
|
cached = null;
|
|
133
227
|
};
|
|
@@ -13,12 +13,21 @@ const base64url = (value: string): string =>
|
|
|
13
13
|
.replace(/\//g, "_")
|
|
14
14
|
.replace(/=+$/, "");
|
|
15
15
|
|
|
16
|
-
/** A token shaped like the one better-auth mints,
|
|
17
|
-
const
|
|
16
|
+
/** A token shaped like the one better-auth mints, expiring after `ttl` seconds. */
|
|
17
|
+
const jwtExpiringIn = (label: string, ttl: number): string =>
|
|
18
18
|
`header.${base64url(
|
|
19
|
-
JSON.stringify({ exp: Math.floor(Date.now() / 1000) +
|
|
19
|
+
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + ttl, label }),
|
|
20
20
|
)}.signature`;
|
|
21
21
|
|
|
22
|
+
/** A token shaped like the one better-auth mints, valid for an hour. */
|
|
23
|
+
const jwt = (label: string): string => jwtExpiringIn(label, 3600);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A token past its comfortable-refresh window but not yet expired: `getToken`
|
|
27
|
+
* treats it as needing a refresh while still holding it as a usable fallback.
|
|
28
|
+
*/
|
|
29
|
+
const nearExpiryJwt = (label: string): string => jwtExpiringIn(label, 30);
|
|
30
|
+
|
|
22
31
|
const realFetch = globalThis.fetch;
|
|
23
32
|
|
|
24
33
|
interface Stub {
|
|
@@ -149,4 +158,64 @@ describe("fetchBetterAuthToken", () => {
|
|
|
149
158
|
/could not be completed|Failed to fetch/i,
|
|
150
159
|
);
|
|
151
160
|
});
|
|
161
|
+
|
|
162
|
+
test("a throttled refresh keeps the still-valid token instead of discarding it", async () => {
|
|
163
|
+
const seed = stubTokenEndpoint(() =>
|
|
164
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
165
|
+
);
|
|
166
|
+
seed.release();
|
|
167
|
+
const held = await fetchBetterAuthToken();
|
|
168
|
+
|
|
169
|
+
const throttled = stubTokenEndpoint(
|
|
170
|
+
() => new Response("", { status: 429, statusText: "Too Many Requests" }),
|
|
171
|
+
);
|
|
172
|
+
throttled.release();
|
|
173
|
+
const afterThrottle = await fetchBetterAuthToken();
|
|
174
|
+
|
|
175
|
+
assert.equal(throttled.calls, 1);
|
|
176
|
+
assert.equal(afterThrottle, held);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("after a throttled refresh it backs off rather than hammering the endpoint", async () => {
|
|
180
|
+
const seed = stubTokenEndpoint(() =>
|
|
181
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
182
|
+
);
|
|
183
|
+
seed.release();
|
|
184
|
+
const held = await fetchBetterAuthToken();
|
|
185
|
+
|
|
186
|
+
const throttled = stubTokenEndpoint(
|
|
187
|
+
() => new Response("", { status: 429, statusText: "Too Many Requests" }),
|
|
188
|
+
);
|
|
189
|
+
throttled.release();
|
|
190
|
+
await fetchBetterAuthToken();
|
|
191
|
+
|
|
192
|
+
const first = await fetchBetterAuthToken();
|
|
193
|
+
const second = await fetchBetterAuthToken();
|
|
194
|
+
|
|
195
|
+
assert.equal(throttled.calls, 1);
|
|
196
|
+
assert.equal(first, held);
|
|
197
|
+
assert.equal(second, held);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a throttled refresh with no usable token to fall back on still throws", async () => {
|
|
201
|
+
const seed = stubTokenEndpoint(() =>
|
|
202
|
+
tokenResponse(jwtExpiringIn("already-expired", -10)),
|
|
203
|
+
);
|
|
204
|
+
seed.release();
|
|
205
|
+
await fetchBetterAuthToken();
|
|
206
|
+
|
|
207
|
+
const throttled = stubTokenEndpoint(
|
|
208
|
+
() => new Response("", { status: 429, statusText: "Too Many Requests" }),
|
|
209
|
+
);
|
|
210
|
+
throttled.release();
|
|
211
|
+
|
|
212
|
+
await assert.rejects(
|
|
213
|
+
() => fetchBetterAuthToken(),
|
|
214
|
+
(error: unknown) => {
|
|
215
|
+
assert.ok(error instanceof AuthTokenError);
|
|
216
|
+
assert.equal(error.status, 429);
|
|
217
|
+
return true;
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
});
|
|
152
221
|
});
|