@djangocfg/api 2.1.455 → 2.1.456
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/auth.cjs +2654 -2920
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +18 -82
- package/dist/auth.d.ts +18 -82
- package/dist/auth.mjs +385 -2861
- package/dist/auth.mjs.map +1 -1
- package/dist/chunk-2G67QRNU.mjs +2096 -0
- package/dist/chunk-2G67QRNU.mjs.map +1 -0
- package/dist/chunk-32SRQGAC.mjs +2109 -0
- package/dist/chunk-32SRQGAC.mjs.map +1 -0
- package/dist/chunk-4BPRCONN.mjs +2098 -0
- package/dist/chunk-4BPRCONN.mjs.map +1 -0
- package/dist/chunk-5UZ2Z323.mjs +2021 -0
- package/dist/chunk-5UZ2Z323.mjs.map +1 -0
- package/dist/chunk-7KFTJXNM.mjs +2097 -0
- package/dist/chunk-7KFTJXNM.mjs.map +1 -0
- package/dist/chunk-BK4K5CVT.mjs +2019 -0
- package/dist/chunk-BK4K5CVT.mjs.map +1 -0
- package/dist/chunk-JLPJZ6WB.mjs +2020 -0
- package/dist/chunk-JLPJZ6WB.mjs.map +1 -0
- package/dist/chunk-TVU6PYJH.mjs +2032 -0
- package/dist/chunk-TVU6PYJH.mjs.map +1 -0
- package/dist/clients.cjs +2210 -1847
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +23 -0
- package/dist/clients.d.ts +23 -0
- package/dist/clients.mjs +14 -1845
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +1776 -962
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +4 -1375
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +2245 -1877
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +81 -4
- package/dist/index.d.ts +81 -4
- package/dist/index.mjs +20 -1860
- package/dist/index.mjs.map +1 -1
- package/dist/sdk.gen-2SQOPTWJ.mjs +26 -0
- package/dist/sdk.gen-2SQOPTWJ.mjs.map +1 -0
- package/dist/sdk.gen-HSGK4C5S.mjs +26 -0
- package/dist/sdk.gen-HSGK4C5S.mjs.map +1 -0
- package/dist/sdk.gen-NUK2VGHO.mjs +25 -0
- package/dist/sdk.gen-NUK2VGHO.mjs.map +1 -0
- package/dist/sdk.gen-O4KAQUZB.mjs +26 -0
- package/dist/sdk.gen-O4KAQUZB.mjs.map +1 -0
- package/dist/sdk.gen-PPAVSBNT.mjs +26 -0
- package/dist/sdk.gen-PPAVSBNT.mjs.map +1 -0
- package/dist/sdk.gen-XLHLOCJ2.mjs +25 -0
- package/dist/sdk.gen-XLHLOCJ2.mjs.map +1 -0
- package/dist/sdk.gen-ZOE6NQAL.mjs +26 -0
- package/dist/sdk.gen-ZOE6NQAL.mjs.map +1 -0
- package/dist/sdk.gen-ZT7LGJVO.mjs +26 -0
- package/dist/sdk.gen-ZT7LGJVO.mjs.map +1 -0
- package/package.json +2 -2
- package/src/_api/generated/_cfg_accounts/hooks/index.ts +1 -0
- package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsTokenBlacklistCreate.ts +28 -0
- package/src/_api/generated/_cfg_accounts/openapi.json +99 -0
- package/src/_api/generated/_cfg_accounts/schemas/TokenBlacklistRequest.ts +11 -0
- package/src/_api/generated/_cfg_accounts/schemas/index.ts +1 -0
- package/src/_api/generated/_cfg_centrifugo/openapi.json +9 -0
- package/src/_api/generated/_cfg_totp/openapi.json +33 -0
- package/src/_api/generated/helpers/auth.ts +276 -31
- package/src/_api/generated/openapi.json +129 -0
- package/src/_api/generated/sdk.gen.ts +94 -16
- package/src/_api/generated/types.gen.ts +18 -0
- package/src/auth/__tests__/guard.test.ts +0 -31
- package/src/auth/constants.ts +6 -0
- package/src/auth/context/AccountsContext.tsx +14 -24
- package/src/auth/context/AuthContext.tsx +226 -609
- package/src/auth/hooks/index.ts +3 -4
- package/src/auth/hooks/useGithubAuth.ts +3 -3
- package/src/auth/hooks/useSession.ts +22 -0
- package/src/auth/hooks/useTwoFactor.ts +4 -4
- package/src/auth/middlewares/index.ts +5 -6
- package/src/auth/utils/guard.ts +0 -22
- package/src/auth/utils/index.ts +0 -2
- package/src/_api/generated/_cfg_accounts/events.ts +0 -198
- package/src/_api/generated/_cfg_centrifugo/events.ts +0 -198
- package/src/_api/generated/_cfg_totp/events.ts +0 -198
- package/src/auth/__tests__/jwt.test.ts +0 -119
- package/src/auth/__tests__/sessionBootstrap.test.ts +0 -111
- package/src/auth/__tests__/useTokenRefresh.dom.test.tsx +0 -167
- package/src/auth/hooks/useAuthGuard.ts +0 -35
- package/src/auth/hooks/useTokenRefresh.ts +0 -131
- package/src/auth/refreshHandler.ts +0 -79
- package/src/auth/utils/jwt.ts +0 -66
|
@@ -149,6 +149,123 @@ let _refreshInflight: Promise<string | null> | null = null;
|
|
|
149
149
|
/** Marker header — set on retried requests so we never loop on 401. */
|
|
150
150
|
const RETRY_MARKER = 'X-Auth-Retry';
|
|
151
151
|
|
|
152
|
+
// ── Reactive session snapshot ───────────────────────────────────────────────
|
|
153
|
+
//
|
|
154
|
+
// The store is the single source of truth for "am I logged in". React (or any
|
|
155
|
+
// listener) subscribes via `auth.subscribe` + `auth.getSnapshot` — designed to
|
|
156
|
+
// plug straight into `useSyncExternalStore`. The snapshot flips automatically
|
|
157
|
+
// on: token writes, refresh success/failure, cross-tab storage events, and an
|
|
158
|
+
// internal timer armed to the next JWT `exp` boundary.
|
|
159
|
+
|
|
160
|
+
export type SessionStatus = 'authenticated' | 'anonymous';
|
|
161
|
+
|
|
162
|
+
export type SessionSnapshot = {
|
|
163
|
+
/** 'authenticated' = a live access token OR a live refresh token exists. */
|
|
164
|
+
status: SessionStatus;
|
|
165
|
+
/** ms-epoch expiry of the current access token (null: no token / no exp). */
|
|
166
|
+
accessExpiresAt: number | null;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** Decode a JWT `exp` claim to ms epoch. Null on any malformed input. */
|
|
170
|
+
function jwtExpMs(token: string): number | null {
|
|
171
|
+
try {
|
|
172
|
+
const payload = token.split('.')[1];
|
|
173
|
+
if (!payload) return null;
|
|
174
|
+
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
175
|
+
return typeof json.exp === 'number' ? json.exp * 1000 : null;
|
|
176
|
+
} catch { return null; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function computeSnapshot(): SessionSnapshot {
|
|
180
|
+
const access = _storage.get(ACCESS_KEY);
|
|
181
|
+
const refresh = _storage.get(REFRESH_KEY);
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const accessExp = access ? jwtExpMs(access) : null;
|
|
184
|
+
const accessAlive = access !== null && (accessExp === null || accessExp > now);
|
|
185
|
+
const refreshExp = refresh ? jwtExpMs(refresh) : null;
|
|
186
|
+
const refreshAlive = refresh !== null && (refreshExp === null || refreshExp > now);
|
|
187
|
+
return {
|
|
188
|
+
status: accessAlive || refreshAlive ? 'authenticated' : 'anonymous',
|
|
189
|
+
accessExpiresAt: accessExp,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Stable object for SSR — `useSyncExternalStore` needs referential equality. */
|
|
194
|
+
const SERVER_SNAPSHOT: SessionSnapshot = { status: 'anonymous', accessExpiresAt: null };
|
|
195
|
+
|
|
196
|
+
/** Same-tab, cross-store sync channel. An app can host several copies of this
|
|
197
|
+
* generated store (separate JS modules) sharing the same storage keys; the
|
|
198
|
+
* `storage` event only fires in OTHER tabs, so a window event bridges them
|
|
199
|
+
* synchronously within the tab (login in one store flips all of them). */
|
|
200
|
+
const SESSION_SYNC_EVENT = `cfg-auth:changed:${ACCESS_KEY}`;
|
|
201
|
+
|
|
202
|
+
let _snapshot: SessionSnapshot = computeSnapshot();
|
|
203
|
+
const _sessionListeners = new Set<() => void>();
|
|
204
|
+
let _expiryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
205
|
+
let _storageListenerInstalled = false;
|
|
206
|
+
|
|
207
|
+
/** Re-arm the timer that flips the snapshot exactly when a token expires. */
|
|
208
|
+
function scheduleExpiryFlip(): void {
|
|
209
|
+
if (!isBrowser) return;
|
|
210
|
+
if (_expiryTimer !== null) { clearTimeout(_expiryTimer); _expiryTimer = null; }
|
|
211
|
+
if (_sessionListeners.size === 0) return;
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
const exps: number[] = [];
|
|
214
|
+
const access = _storage.get(ACCESS_KEY);
|
|
215
|
+
const refresh = _storage.get(REFRESH_KEY);
|
|
216
|
+
const accessExp = access ? jwtExpMs(access) : null;
|
|
217
|
+
const refreshExp = refresh ? jwtExpMs(refresh) : null;
|
|
218
|
+
if (accessExp !== null && accessExp > now) exps.push(accessExp);
|
|
219
|
+
if (refreshExp !== null && refreshExp > now) exps.push(refreshExp);
|
|
220
|
+
if (!exps.length) return;
|
|
221
|
+
// +250ms grace so the token is REALLY expired when we flip; clamp to the
|
|
222
|
+
// max signed-32-bit setTimeout delay (~24.8 days).
|
|
223
|
+
const delay = Math.min(Math.min(...exps) - now + 250, 0x7fffffff);
|
|
224
|
+
_expiryTimer = setTimeout(notifySessionChanged, delay);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Recompute the snapshot; notify subscribers only when it actually changed. */
|
|
228
|
+
function notifySessionChanged(): void {
|
|
229
|
+
const next = computeSnapshot();
|
|
230
|
+
const changed =
|
|
231
|
+
next.status !== _snapshot.status ||
|
|
232
|
+
next.accessExpiresAt !== _snapshot.accessExpiresAt;
|
|
233
|
+
if (changed) _snapshot = next;
|
|
234
|
+
scheduleExpiryFlip();
|
|
235
|
+
if (!changed) return;
|
|
236
|
+
for (const listener of Array.from(_sessionListeners)) {
|
|
237
|
+
try { listener(); } catch {}
|
|
238
|
+
}
|
|
239
|
+
// Wake sibling stores in this tab. Re-entrant but convergent: a store that
|
|
240
|
+
// recomputes to the SAME snapshot doesn't re-dispatch, so the cascade stops
|
|
241
|
+
// after every store has caught up.
|
|
242
|
+
if (isBrowser) {
|
|
243
|
+
try { window.dispatchEvent(new Event(SESSION_SYNC_EVENT)); } catch {}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Cross-tab + cross-store sync — `storage` events only fire in OTHER tabs;
|
|
248
|
+
* the window event covers sibling stores in THIS tab. Either way a login or
|
|
249
|
+
* logout anywhere flips every snapshot reading the same keys. */
|
|
250
|
+
function ensureStorageSync(): void {
|
|
251
|
+
if (!isBrowser || _storageListenerInstalled) return;
|
|
252
|
+
_storageListenerInstalled = true;
|
|
253
|
+
window.addEventListener('storage', (e) => {
|
|
254
|
+
if (e.key === null || e.key === ACCESS_KEY || e.key === REFRESH_KEY) {
|
|
255
|
+
notifySessionChanged();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
window.addEventListener(SESSION_SYNC_EVENT, () => notifySessionChanged());
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Terminal-401 subscribers. Unlike the single `onUnauthorized` slot these
|
|
263
|
+
* compose: each registration returns its own unsubscribe, so StrictMode
|
|
264
|
+
* double-mounts and multiple packages can't clobber each other.
|
|
265
|
+
*/
|
|
266
|
+
type SessionExpiredHandler = (response: Response) => void;
|
|
267
|
+
const _sessionExpiredHandlers = new Set<SessionExpiredHandler>();
|
|
268
|
+
|
|
152
269
|
/**
|
|
153
270
|
* Captured reference to the shared Hey API client. Set exactly once by
|
|
154
271
|
* `installAuthOnClient(client)` (called from client.gen.ts). All `auth.set*`
|
|
@@ -194,15 +311,79 @@ export const auth = {
|
|
|
194
311
|
setStorageMode(mode: StorageMode): void {
|
|
195
312
|
_storageMode = mode;
|
|
196
313
|
_storage = mode === 'cookie' ? cookieBackend : localStorageBackend;
|
|
314
|
+
notifySessionChanged();
|
|
197
315
|
},
|
|
198
316
|
|
|
199
317
|
// ── Bearer token ──────────────────────────────────────────────────
|
|
200
318
|
getToken(): string | null { return _storage.get(ACCESS_KEY); },
|
|
201
|
-
setToken(token: string | null): void {
|
|
319
|
+
setToken(token: string | null): void {
|
|
320
|
+
_storage.set(ACCESS_KEY, token);
|
|
321
|
+
notifySessionChanged();
|
|
322
|
+
},
|
|
202
323
|
getRefreshToken(): string | null { return _storage.get(REFRESH_KEY); },
|
|
203
|
-
setRefreshToken(token: string | null): void {
|
|
204
|
-
|
|
205
|
-
|
|
324
|
+
setRefreshToken(token: string | null): void {
|
|
325
|
+
_storage.set(REFRESH_KEY, token);
|
|
326
|
+
notifySessionChanged();
|
|
327
|
+
},
|
|
328
|
+
clearTokens(): void {
|
|
329
|
+
_storage.set(ACCESS_KEY, null);
|
|
330
|
+
_storage.set(REFRESH_KEY, null);
|
|
331
|
+
notifySessionChanged();
|
|
332
|
+
},
|
|
333
|
+
/** Session-aware: token PRESENT and not past its `exp` (or a live refresh
|
|
334
|
+
* token exists that can mint one). Prefer `getSnapshot().status` in React. */
|
|
335
|
+
isAuthenticated(): boolean { return computeSnapshot().status === 'authenticated'; },
|
|
336
|
+
|
|
337
|
+
// ── Session (the ONE write path for login/logout flows) ──────────
|
|
338
|
+
/**
|
|
339
|
+
* Persist a token pair atomically. Every login flow (OTP verify, 2FA,
|
|
340
|
+
* OAuth callback) and the refresh handler should end here — do NOT
|
|
341
|
+
* scatter `setToken`/`setRefreshToken` pairs through app code.
|
|
342
|
+
* `refresh: undefined` keeps the current refresh token (access-only
|
|
343
|
+
* rotation); `refresh: null` explicitly drops it.
|
|
344
|
+
*/
|
|
345
|
+
setSession(tokens: { access: string; refresh?: string | null }): void {
|
|
346
|
+
_storage.set(ACCESS_KEY, tokens.access);
|
|
347
|
+
if (tokens.refresh !== undefined) _storage.set(REFRESH_KEY, tokens.refresh);
|
|
348
|
+
notifySessionChanged();
|
|
349
|
+
},
|
|
350
|
+
clearSession(): void { auth.clearTokens(); },
|
|
351
|
+
|
|
352
|
+
// ── Reactive snapshot (for useSyncExternalStore) ──────────────────
|
|
353
|
+
/**
|
|
354
|
+
* @example React:
|
|
355
|
+
* const session = useSyncExternalStore(
|
|
356
|
+
* auth.subscribe, auth.getSnapshot, auth.getServerSnapshot,
|
|
357
|
+
* );
|
|
358
|
+
* const isAuthenticated = session.status === 'authenticated';
|
|
359
|
+
*/
|
|
360
|
+
getSnapshot(): SessionSnapshot { return _snapshot; },
|
|
361
|
+
getServerSnapshot(): SessionSnapshot { return SERVER_SNAPSHOT; },
|
|
362
|
+
subscribe(listener: () => void): () => void {
|
|
363
|
+
ensureStorageSync();
|
|
364
|
+
_sessionListeners.add(listener);
|
|
365
|
+
// Sync state & arm the expiry timer now that someone is listening.
|
|
366
|
+
notifySessionChanged();
|
|
367
|
+
return () => {
|
|
368
|
+
_sessionListeners.delete(listener);
|
|
369
|
+
if (_sessionListeners.size === 0 && _expiryTimer !== null) {
|
|
370
|
+
clearTimeout(_expiryTimer);
|
|
371
|
+
_expiryTimer = null;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Fired on TERMINAL 401 — after the refresh+retry path is exhausted.
|
|
378
|
+
* The store has already cleared the session before calling back, so
|
|
379
|
+
* handlers only need to route to login (no clear-then-redirect
|
|
380
|
+
* ordering to get wrong). Returns an unsubscribe function; multiple
|
|
381
|
+
* handlers compose (unlike the legacy single-slot `onUnauthorized`).
|
|
382
|
+
*/
|
|
383
|
+
onSessionExpired(cb: SessionExpiredHandler): () => void {
|
|
384
|
+
_sessionExpiredHandlers.add(cb);
|
|
385
|
+
return () => { _sessionExpiredHandlers.delete(cb); };
|
|
386
|
+
},
|
|
206
387
|
|
|
207
388
|
// ── API key ───────────────────────────────────────────────────────
|
|
208
389
|
getApiKey(): string | null {
|
|
@@ -278,23 +459,53 @@ export const auth = {
|
|
|
278
459
|
};
|
|
279
460
|
|
|
280
461
|
/**
|
|
281
|
-
* Run the user-supplied refresh handler
|
|
282
|
-
*
|
|
283
|
-
*
|
|
462
|
+
* Run the user-supplied refresh handler, persist the rotated tokens, and
|
|
463
|
+
* return the fresh access token (or null on any failure path).
|
|
464
|
+
*
|
|
465
|
+
* TWO layers of coordination, because a single app can host MULTIPLE copies of
|
|
466
|
+
* this generated store (e.g. `@djangocfg/api` + a locally-generated client +
|
|
467
|
+
* `@djangocfg/monitor`), all sharing the SAME refresh token in storage:
|
|
468
|
+
*
|
|
469
|
+
* 1. Per-store single-flight (`_refreshInflight`) — concurrent 401s WITHIN
|
|
470
|
+
* this module coalesce onto one promise. Fast path, no lock.
|
|
471
|
+
* 2. Cross-store mutex (`navigator.locks`) keyed on the refresh-token storage
|
|
472
|
+
* key — serializes refreshes across EVERY store in the app. The refresh
|
|
473
|
+
* token is re-read INSIDE the lock, so if another store already rotated it
|
|
474
|
+
* (SimpleJWT `ROTATE_REFRESH_TOKENS` blacklists the old one), the loser
|
|
475
|
+
* picks up the fresh access token instead of re-POSTing a blacklisted
|
|
476
|
+
* refresh token and getting a spurious 401 → logout.
|
|
477
|
+
*
|
|
478
|
+
* Web Locks is browser-only; on SSR / older runtimes we degrade gracefully to
|
|
479
|
+
* layer 1 alone (correct whenever at most one store refreshes).
|
|
284
480
|
*/
|
|
285
481
|
async function tryRefresh(): Promise<string | null> {
|
|
286
482
|
if (_refreshInflight) return _refreshInflight;
|
|
287
483
|
if (!_refreshHandler) return null;
|
|
288
|
-
|
|
289
|
-
|
|
484
|
+
|
|
485
|
+
// The critical section — re-reads the refresh token every time it runs, so a
|
|
486
|
+
// rotation performed by another store (or by us on a prior attempt) is always
|
|
487
|
+
// observed. Returns the fresh access token, or null on any failure.
|
|
488
|
+
const runRefresh = async (): Promise<string | null> => {
|
|
489
|
+
const refresh = auth.getRefreshToken();
|
|
490
|
+
if (!refresh) return null;
|
|
491
|
+
const result = await _refreshHandler!(refresh);
|
|
492
|
+
if (!result?.access) return null;
|
|
493
|
+
auth.setToken(result.access);
|
|
494
|
+
if (result.refresh) auth.setRefreshToken(result.refresh);
|
|
495
|
+
return result.access;
|
|
496
|
+
};
|
|
290
497
|
|
|
291
498
|
_refreshInflight = (async () => {
|
|
292
499
|
try {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
500
|
+
// Serialize across all stores in this app when Web Locks is available.
|
|
501
|
+
const locks =
|
|
502
|
+
typeof navigator !== 'undefined'
|
|
503
|
+
? (navigator as Navigator & { locks?: LockManager }).locks
|
|
504
|
+
: undefined;
|
|
505
|
+
if (locks?.request) {
|
|
506
|
+
return await locks.request(`${REFRESH_KEY}:refresh`, runRefresh);
|
|
507
|
+
}
|
|
508
|
+
return await runRefresh();
|
|
298
509
|
} catch {
|
|
299
510
|
return null;
|
|
300
511
|
} finally {
|
|
@@ -305,6 +516,23 @@ async function tryRefresh(): Promise<string | null> {
|
|
|
305
516
|
return _refreshInflight;
|
|
306
517
|
}
|
|
307
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Terminal 401 — no refresh path can recover this session. The store clears
|
|
521
|
+
* its own tokens FIRST (subscribers flip to 'anonymous' via the snapshot),
|
|
522
|
+
* then notifies `onSessionExpired` subscribers and the legacy
|
|
523
|
+
* `onUnauthorized` slot. Apps only route to login; they never own the
|
|
524
|
+
* clear-before-redirect ordering.
|
|
525
|
+
*/
|
|
526
|
+
function expireSession(response: Response): void {
|
|
527
|
+
auth.clearTokens();
|
|
528
|
+
for (const cb of Array.from(_sessionExpiredHandlers)) {
|
|
529
|
+
try { cb(response); } catch {}
|
|
530
|
+
}
|
|
531
|
+
if (_onUnauthorized) {
|
|
532
|
+
try { _onUnauthorized(response); } catch {}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
308
536
|
/**
|
|
309
537
|
* Wire the shared client to the global auth store. Called exactly
|
|
310
538
|
* once from `client.gen.ts` (post-processed) right after
|
|
@@ -476,7 +704,7 @@ export function installAuthOnClient(client: HeyClient): void {
|
|
|
476
704
|
if (locale) request.headers.set('Accept-Language', locale);
|
|
477
705
|
|
|
478
706
|
// Only set X-API-Key from global store if NOT already present on the
|
|
479
|
-
// request — per-request headers
|
|
707
|
+
// request — per-request headers take precedence.
|
|
480
708
|
const apiKey = auth.getApiKey();
|
|
481
709
|
if (apiKey && !request.headers.has('X-API-Key')) request.headers.set('X-API-Key', apiKey);
|
|
482
710
|
|
|
@@ -496,9 +724,6 @@ export function installAuthOnClient(client: HeyClient): void {
|
|
|
496
724
|
return request;
|
|
497
725
|
});
|
|
498
726
|
|
|
499
|
-
// Wrap raw JSON error objects (thrown by hey-api on non-2xx responses) into
|
|
500
|
-
// APIError so callers can do `error instanceof APIError` and read
|
|
501
|
-
// `error.statusCode` / `error.response` consistently.
|
|
502
727
|
client.interceptors.error.use((err, res, req) => {
|
|
503
728
|
if (err instanceof APIError) return err;
|
|
504
729
|
const url = (req as Request | undefined)?.url ?? '';
|
|
@@ -507,22 +732,19 @@ export function installAuthOnClient(client: HeyClient): void {
|
|
|
507
732
|
return new APIError(status, statusText, err, url);
|
|
508
733
|
});
|
|
509
734
|
|
|
735
|
+
|
|
510
736
|
client.interceptors.response.use(async (response, request) => {
|
|
511
737
|
if (response.status !== 401) return response;
|
|
512
738
|
|
|
513
739
|
// Already retried once — give up to avoid loops.
|
|
514
740
|
if (request.headers.get(RETRY_MARKER)) {
|
|
515
|
-
|
|
516
|
-
try { _onUnauthorized(response); } catch {}
|
|
517
|
-
}
|
|
741
|
+
expireSession(response);
|
|
518
742
|
return response;
|
|
519
743
|
}
|
|
520
744
|
|
|
521
745
|
const newToken = await tryRefresh();
|
|
522
746
|
if (!newToken) {
|
|
523
|
-
|
|
524
|
-
try { _onUnauthorized(response); } catch {}
|
|
525
|
-
}
|
|
747
|
+
expireSession(response);
|
|
526
748
|
return response;
|
|
527
749
|
}
|
|
528
750
|
|
|
@@ -540,18 +762,41 @@ export function installAuthOnClient(client: HeyClient): void {
|
|
|
540
762
|
}
|
|
541
763
|
try {
|
|
542
764
|
const retried = await fetch(retry);
|
|
543
|
-
if (retried.status === 401
|
|
544
|
-
try { _onUnauthorized(retried); } catch {}
|
|
545
|
-
}
|
|
765
|
+
if (retried.status === 401) expireSession(retried);
|
|
546
766
|
return retried;
|
|
547
767
|
} catch {
|
|
548
|
-
// Network error on retry
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
768
|
+
// Network error on the retry. The refresh SUCCEEDED (we hold a fresh
|
|
769
|
+
// token), so the session is alive — a transient network blip must NOT
|
|
770
|
+
// log the user out. Surface the original 401 to the caller only.
|
|
552
771
|
return response;
|
|
553
772
|
}
|
|
554
773
|
});
|
|
555
774
|
}
|
|
556
775
|
|
|
776
|
+
// ── Default refresh handler (auto-registered) ──────────────────────────────
|
|
777
|
+
//
|
|
778
|
+
// Wires the SimpleJWT refresh endpoint so a 401 transparently refreshes and
|
|
779
|
+
// retries. Registered at import time; an app can override it afterwards with
|
|
780
|
+
// `auth.setRefreshHandler(...)` (last write wins). Returns the ROTATED refresh
|
|
781
|
+
// token so the store persists it — `ROTATE_REFRESH_TOKENS` blacklists the old
|
|
782
|
+
// one, so reusing it would 401 on the next refresh.
|
|
783
|
+
//
|
|
784
|
+
// The SDK is imported LAZILY inside the handler: a static import would close
|
|
785
|
+
// the cycle auth.ts → sdk.gen → client.gen → auth.ts and TDZ-crash whichever
|
|
786
|
+
// module evaluates first. The dynamic import resolves once, then caches.
|
|
787
|
+
auth.setRefreshHandler(async (refresh) => {
|
|
788
|
+
try {
|
|
789
|
+
const { CfgAccountsAuth } = await import('../sdk.gen');
|
|
790
|
+
const { data } = await CfgAccountsAuth.cfgAccountsTokenRefreshCreate({
|
|
791
|
+
body: { refresh },
|
|
792
|
+
throwOnError: true,
|
|
793
|
+
});
|
|
794
|
+
return data?.access
|
|
795
|
+
? { access: data.access, refresh: data.refresh ?? refresh }
|
|
796
|
+
: null;
|
|
797
|
+
} catch {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
|
|
557
802
|
export type Auth = typeof auth;
|
|
@@ -241,6 +241,9 @@
|
|
|
241
241
|
},
|
|
242
242
|
{
|
|
243
243
|
"jwtAuthWithLastLogin": []
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"tokenAuth": []
|
|
244
247
|
}
|
|
245
248
|
],
|
|
246
249
|
"responses": {
|
|
@@ -282,6 +285,9 @@
|
|
|
282
285
|
},
|
|
283
286
|
{
|
|
284
287
|
"jwtAuthWithLastLogin": []
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
"tokenAuth": []
|
|
285
291
|
}
|
|
286
292
|
],
|
|
287
293
|
"responses": {
|
|
@@ -460,6 +466,9 @@
|
|
|
460
466
|
{
|
|
461
467
|
"jwtAuthWithLastLogin": []
|
|
462
468
|
},
|
|
469
|
+
{
|
|
470
|
+
"tokenAuth": []
|
|
471
|
+
},
|
|
463
472
|
{}
|
|
464
473
|
],
|
|
465
474
|
"responses": {
|
|
@@ -531,6 +540,9 @@
|
|
|
531
540
|
{
|
|
532
541
|
"jwtAuthWithLastLogin": []
|
|
533
542
|
},
|
|
543
|
+
{
|
|
544
|
+
"tokenAuth": []
|
|
545
|
+
},
|
|
534
546
|
{}
|
|
535
547
|
],
|
|
536
548
|
"responses": {
|
|
@@ -642,6 +654,9 @@
|
|
|
642
654
|
},
|
|
643
655
|
{
|
|
644
656
|
"jwtAuthWithLastLogin": []
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
"tokenAuth": []
|
|
645
660
|
}
|
|
646
661
|
],
|
|
647
662
|
"responses": {
|
|
@@ -756,6 +771,16 @@
|
|
|
756
771
|
"summary": "Profile Update with Avatar"
|
|
757
772
|
}
|
|
758
773
|
}
|
|
774
|
+
},
|
|
775
|
+
"multipart/form-data": {
|
|
776
|
+
"schema": {
|
|
777
|
+
"$ref": "#/components/schemas/CfgUserUpdateRequest"
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
"application/x-www-form-urlencoded": {
|
|
781
|
+
"schema": {
|
|
782
|
+
"$ref": "#/components/schemas/CfgUserUpdateRequest"
|
|
783
|
+
}
|
|
759
784
|
}
|
|
760
785
|
}
|
|
761
786
|
},
|
|
@@ -826,6 +851,16 @@
|
|
|
826
851
|
"summary": "Profile Update with Avatar"
|
|
827
852
|
}
|
|
828
853
|
}
|
|
854
|
+
},
|
|
855
|
+
"multipart/form-data": {
|
|
856
|
+
"schema": {
|
|
857
|
+
"$ref": "#/components/schemas/PatchedCfgUserUpdateRequest"
|
|
858
|
+
}
|
|
859
|
+
},
|
|
860
|
+
"application/x-www-form-urlencoded": {
|
|
861
|
+
"schema": {
|
|
862
|
+
"$ref": "#/components/schemas/PatchedCfgUserUpdateRequest"
|
|
863
|
+
}
|
|
829
864
|
}
|
|
830
865
|
}
|
|
831
866
|
},
|
|
@@ -898,6 +933,16 @@
|
|
|
898
933
|
"summary": "Valid Profile Update"
|
|
899
934
|
}
|
|
900
935
|
}
|
|
936
|
+
},
|
|
937
|
+
"multipart/form-data": {
|
|
938
|
+
"schema": {
|
|
939
|
+
"$ref": "#/components/schemas/CfgUserUpdateRequest"
|
|
940
|
+
}
|
|
941
|
+
},
|
|
942
|
+
"application/x-www-form-urlencoded": {
|
|
943
|
+
"schema": {
|
|
944
|
+
"$ref": "#/components/schemas/CfgUserUpdateRequest"
|
|
945
|
+
}
|
|
901
946
|
}
|
|
902
947
|
}
|
|
903
948
|
},
|
|
@@ -968,6 +1013,16 @@
|
|
|
968
1013
|
"summary": "Valid Profile Update"
|
|
969
1014
|
}
|
|
970
1015
|
}
|
|
1016
|
+
},
|
|
1017
|
+
"multipart/form-data": {
|
|
1018
|
+
"schema": {
|
|
1019
|
+
"$ref": "#/components/schemas/PatchedCfgUserUpdateRequest"
|
|
1020
|
+
}
|
|
1021
|
+
},
|
|
1022
|
+
"application/x-www-form-urlencoded": {
|
|
1023
|
+
"schema": {
|
|
1024
|
+
"$ref": "#/components/schemas/PatchedCfgUserUpdateRequest"
|
|
1025
|
+
}
|
|
971
1026
|
}
|
|
972
1027
|
}
|
|
973
1028
|
},
|
|
@@ -1014,6 +1069,31 @@
|
|
|
1014
1069
|
"x-async-capable": false
|
|
1015
1070
|
}
|
|
1016
1071
|
},
|
|
1072
|
+
"/cfg/accounts/token/blacklist/": {
|
|
1073
|
+
"post": {
|
|
1074
|
+
"operationId": "cfg_accounts_token_blacklist_create",
|
|
1075
|
+
"description": "Revoke a refresh token (logout).\n\nBlacklists the posted refresh token so it can never mint another access\ntoken. Called best-effort by the client's logout — without it, \"logout\"\nis purely client-side and a stolen refresh token survives until expiry.",
|
|
1076
|
+
"tags": [
|
|
1077
|
+
"cfg_accounts_auth"
|
|
1078
|
+
],
|
|
1079
|
+
"requestBody": {
|
|
1080
|
+
"content": {
|
|
1081
|
+
"application/json": {
|
|
1082
|
+
"schema": {
|
|
1083
|
+
"$ref": "#/components/schemas/TokenBlacklistRequest"
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
},
|
|
1087
|
+
"required": true
|
|
1088
|
+
},
|
|
1089
|
+
"responses": {
|
|
1090
|
+
"200": {
|
|
1091
|
+
"description": "No response body"
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
"x-async-capable": false
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1017
1097
|
"/cfg/accounts/token/refresh/": {
|
|
1018
1098
|
"post": {
|
|
1019
1099
|
"operationId": "cfg_accounts_token_refresh_create",
|
|
@@ -1060,6 +1140,9 @@
|
|
|
1060
1140
|
},
|
|
1061
1141
|
{
|
|
1062
1142
|
"jwtAuthWithLastLogin": []
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
"tokenAuth": []
|
|
1063
1146
|
}
|
|
1064
1147
|
],
|
|
1065
1148
|
"responses": {
|
|
@@ -1110,6 +1193,9 @@
|
|
|
1110
1193
|
},
|
|
1111
1194
|
{
|
|
1112
1195
|
"jwtAuthWithLastLogin": []
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
"tokenAuth": []
|
|
1113
1199
|
}
|
|
1114
1200
|
],
|
|
1115
1201
|
"responses": {
|
|
@@ -1150,6 +1236,9 @@
|
|
|
1150
1236
|
},
|
|
1151
1237
|
{
|
|
1152
1238
|
"jwtAuthWithLastLogin": []
|
|
1239
|
+
},
|
|
1240
|
+
{
|
|
1241
|
+
"tokenAuth": []
|
|
1153
1242
|
}
|
|
1154
1243
|
],
|
|
1155
1244
|
"responses": {
|
|
@@ -1190,6 +1279,9 @@
|
|
|
1190
1279
|
},
|
|
1191
1280
|
{
|
|
1192
1281
|
"jwtAuthWithLastLogin": []
|
|
1282
|
+
},
|
|
1283
|
+
{
|
|
1284
|
+
"tokenAuth": []
|
|
1193
1285
|
}
|
|
1194
1286
|
],
|
|
1195
1287
|
"responses": {
|
|
@@ -1231,6 +1323,9 @@
|
|
|
1231
1323
|
},
|
|
1232
1324
|
{
|
|
1233
1325
|
"jwtAuthWithLastLogin": []
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
"tokenAuth": []
|
|
1234
1329
|
}
|
|
1235
1330
|
],
|
|
1236
1331
|
"responses": {
|
|
@@ -1264,6 +1359,9 @@
|
|
|
1264
1359
|
},
|
|
1265
1360
|
{
|
|
1266
1361
|
"jwtAuthWithLastLogin": []
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
"tokenAuth": []
|
|
1267
1365
|
}
|
|
1268
1366
|
],
|
|
1269
1367
|
"responses": {
|
|
@@ -1313,6 +1411,9 @@
|
|
|
1313
1411
|
},
|
|
1314
1412
|
{
|
|
1315
1413
|
"jwtAuthWithLastLogin": []
|
|
1414
|
+
},
|
|
1415
|
+
{
|
|
1416
|
+
"tokenAuth": []
|
|
1316
1417
|
}
|
|
1317
1418
|
],
|
|
1318
1419
|
"responses": {
|
|
@@ -1363,6 +1464,9 @@
|
|
|
1363
1464
|
},
|
|
1364
1465
|
{
|
|
1365
1466
|
"jwtAuthWithLastLogin": []
|
|
1467
|
+
},
|
|
1468
|
+
{
|
|
1469
|
+
"tokenAuth": []
|
|
1366
1470
|
}
|
|
1367
1471
|
],
|
|
1368
1472
|
"responses": {
|
|
@@ -1414,6 +1518,9 @@
|
|
|
1414
1518
|
{
|
|
1415
1519
|
"jwtAuthWithLastLogin": []
|
|
1416
1520
|
},
|
|
1521
|
+
{
|
|
1522
|
+
"tokenAuth": []
|
|
1523
|
+
},
|
|
1417
1524
|
{}
|
|
1418
1525
|
],
|
|
1419
1526
|
"responses": {
|
|
@@ -1475,6 +1582,9 @@
|
|
|
1475
1582
|
{
|
|
1476
1583
|
"jwtAuthWithLastLogin": []
|
|
1477
1584
|
},
|
|
1585
|
+
{
|
|
1586
|
+
"tokenAuth": []
|
|
1587
|
+
},
|
|
1478
1588
|
{}
|
|
1479
1589
|
],
|
|
1480
1590
|
"responses": {
|
|
@@ -2439,6 +2549,19 @@
|
|
|
2439
2549
|
"secret"
|
|
2440
2550
|
]
|
|
2441
2551
|
},
|
|
2552
|
+
"TokenBlacklistRequest": {
|
|
2553
|
+
"type": "object",
|
|
2554
|
+
"properties": {
|
|
2555
|
+
"refresh": {
|
|
2556
|
+
"type": "string",
|
|
2557
|
+
"writeOnly": true,
|
|
2558
|
+
"minLength": 1
|
|
2559
|
+
}
|
|
2560
|
+
},
|
|
2561
|
+
"required": [
|
|
2562
|
+
"refresh"
|
|
2563
|
+
]
|
|
2564
|
+
},
|
|
2442
2565
|
"TokenRefresh": {
|
|
2443
2566
|
"type": "object",
|
|
2444
2567
|
"properties": {
|
|
@@ -2943,6 +3066,12 @@
|
|
|
2943
3066
|
"type": "http",
|
|
2944
3067
|
"scheme": "bearer",
|
|
2945
3068
|
"bearerFormat": "JWT"
|
|
3069
|
+
},
|
|
3070
|
+
"tokenAuth": {
|
|
3071
|
+
"type": "apiKey",
|
|
3072
|
+
"in": "header",
|
|
3073
|
+
"name": "Authorization",
|
|
3074
|
+
"description": "Token-based authentication with required prefix \"Token\""
|
|
2946
3075
|
}
|
|
2947
3076
|
}
|
|
2948
3077
|
},
|