portal-mojo 0.2.14 → 0.2.15
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/docs/auth.md +10 -2
- package/docs/realtime.md +21 -1
- package/package.json +1 -1
- package/src/client/auth.ts +17 -4
- package/src/client/realtime.ts +112 -11
package/docs/auth.md
CHANGED
|
@@ -25,8 +25,8 @@ onAuth('rotated', () => queryClient.invalidateQueries()); // same user, re-issu
|
|
|
25
25
|
|
|
26
26
|
## Session mechanics
|
|
27
27
|
|
|
28
|
-
- JWT pair (`access_token`/`refresh_token`); single-flight refresh (
|
|
29
|
-
|
|
28
|
+
- JWT pair (`access_token`/`refresh_token`); single-flight refresh (concurrent
|
|
29
|
+
gated HTTP requests and realtime reconnects → ONE refresh POST); pre-request gate throws a synthetic
|
|
30
30
|
`AuthRequiredError` instead of firing doomed requests.
|
|
31
31
|
- A temporary refresh failure (network, timeout or server unavailable) blocks
|
|
32
32
|
the protected request and rejects with the original transport error. It
|
|
@@ -34,7 +34,15 @@ onAuth('rotated', () => queryClient.invalidateQueries()); // same user, re-issu
|
|
|
34
34
|
open. Only expired/unusable credentials or confirmed refresh 401/403 emit
|
|
35
35
|
`unauthorized` and become `AuthRequiredError`. `refreshTokens()` still
|
|
36
36
|
resolves a boolean, and concurrent gated calls share one refresh POST.
|
|
37
|
+
- Refresh POSTs have a 15-second deadline. A timed-out attempt releases the
|
|
38
|
+
shared flight so a later request or reconnect can try again. RealtimeProvider
|
|
39
|
+
uses this same gate before connecting and before sending authentication;
|
|
40
|
+
applications do not need a second refresh timer for WebSockets.
|
|
37
41
|
- `useAuthSnapshot()` → `{authenticated, uid, email}` — live, cross-tab.
|
|
42
|
+
- An internal session revision distinguishes same-tab renewal from explicit
|
|
43
|
+
replacement/clearing and cross-tab storage changes. Session-bound realtime
|
|
44
|
+
retains this fence across unmount/remount, so a late renewal cannot undo a
|
|
45
|
+
terminal server denial. It is an in-memory lifecycle fence, not authorization.
|
|
38
46
|
- `logout()` is client-side (django-mojo has no logout endpoint) and emits
|
|
39
47
|
`'logout'`.
|
|
40
48
|
- Every flow that answers with a login grant adopts it only when it carries
|
package/docs/realtime.md
CHANGED
|
@@ -9,6 +9,23 @@ consumer permissions own topic subscriptions. Disabling the provider or losing
|
|
|
9
9
|
the auth token closes the socket. A permission loss in one consumer must not
|
|
10
10
|
disable this shared provider.
|
|
11
11
|
|
|
12
|
+
The provider binds both owned and injected clients to the shared auth session.
|
|
13
|
+
Before opening a socket and again when challenged, it waits for `ensureValidToken()`;
|
|
14
|
+
HTTP and realtime share one refresh POST. A slow or temporarily failed renewal
|
|
15
|
+
keeps the connection in `connecting`/`authenticating`/`backoff`, not `auth-failed`.
|
|
16
|
+
Consumers should preserve their current workspace during these transient states.
|
|
17
|
+
Confirmed refresh denial still fails closed. Token replacement invalidates old
|
|
18
|
+
handshakes synchronously, including during a pending refresh.
|
|
19
|
+
An in-flight renewal completing after an authentication failure or forced
|
|
20
|
+
disconnect cannot clear that suspension. An intentional replacement session
|
|
21
|
+
can authenticate again; ordinary token refresh cannot.
|
|
22
|
+
|
|
23
|
+
For a non-React session consumer, call `const unbind = client.bindAuthSession()`
|
|
24
|
+
before `client.setEnabled(true)`, and call `unbind()` on teardown. One binding
|
|
25
|
+
owns the client at a time; unbinding closes the connection without aborting a
|
|
26
|
+
renewal shared by HTTP. Standalone `connect(token)` clients keep explicit-token
|
|
27
|
+
behavior and are responsible for their token lifecycle.
|
|
28
|
+
|
|
12
29
|
## Wire contract
|
|
13
30
|
|
|
14
31
|
- URL is derived from the configured API origin as exactly `/ws/realtime/`
|
|
@@ -55,7 +72,10 @@ A top-level `{type:'error'}` frame is attributed in order:
|
|
|
55
72
|
|
|
56
73
|
1. **During authentication** — auth failure: the client closes and suspends
|
|
57
74
|
until the token value changes. Surfaces through the lifecycle status only;
|
|
58
|
-
no `error` event is dispatched.
|
|
75
|
+
no `error` event is dispatched. Exception: the exact direct
|
|
76
|
+
`message === 'Authentication timeout'` before credentials were sent on a
|
|
77
|
+
session-bound socket is a transport timeout and enters bounded retry.
|
|
78
|
+
The same error after credentials were sent remains an authentication failure.
|
|
59
79
|
2. **While a subscribe/unsubscribe is pending** — that operation's denial
|
|
60
80
|
(acknowledgements are uncorrelated, so denial is a generic `error`); the
|
|
61
81
|
topic reports `denied`. Surfaces through the topic status only; no `error`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
4
4
|
"description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/src/client/auth.ts
CHANGED
|
@@ -61,13 +61,17 @@ export function sessionIsPersistent(): boolean {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function setTokens(access: string, refresh?: string | null, persistent = true): void {
|
|
64
|
+
storeTokens(access, refresh, persistent, 'session');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function storeTokens(access: string, refresh: string | null | undefined, persistent: boolean, source: 'session' | 'refresh'): void {
|
|
64
68
|
// Clear both storages first — a leftover token in the other storage must
|
|
65
69
|
// never shadow this session (web-mojo wrote without clearing).
|
|
66
70
|
clearTokens({ silent: true });
|
|
67
71
|
const storage = persistent ? localStorage : sessionStorage;
|
|
68
72
|
storage.setItem(ACCESS_KEY, access);
|
|
69
73
|
if (refresh) storage.setItem(REFRESH_KEY, refresh);
|
|
70
|
-
notifyAuthChanged();
|
|
74
|
+
notifyAuthChanged(source);
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
export function clearTokens(opts: { silent?: boolean } = {}): void {
|
|
@@ -89,8 +93,14 @@ export interface AuthSnapshot {
|
|
|
89
93
|
|
|
90
94
|
let snapshotCache: AuthSnapshot = { authenticated: false, uid: null, email: null };
|
|
91
95
|
let snapshotKey = '∅';
|
|
96
|
+
let sessionRevision = 0;
|
|
92
97
|
const authListeners = new Set<() => void>();
|
|
93
98
|
|
|
99
|
+
/** @internal Same-tab replacement fence; ordinary renewal does not advance it. */
|
|
100
|
+
export function getAuthSessionRevision(): number {
|
|
101
|
+
return sessionRevision;
|
|
102
|
+
}
|
|
103
|
+
|
|
94
104
|
function computeSnapshot(): void {
|
|
95
105
|
const access = getAccessToken();
|
|
96
106
|
const refresh = getRefreshToken();
|
|
@@ -106,7 +116,8 @@ function computeSnapshot(): void {
|
|
|
106
116
|
};
|
|
107
117
|
}
|
|
108
118
|
|
|
109
|
-
function notifyAuthChanged(): void {
|
|
119
|
+
function notifyAuthChanged(source: 'session' | 'refresh' = 'session'): void {
|
|
120
|
+
if (source === 'session') sessionRevision += 1;
|
|
110
121
|
computeSnapshot();
|
|
111
122
|
for (const cb of authListeners) cb();
|
|
112
123
|
}
|
|
@@ -212,10 +223,12 @@ async function doRefresh(): Promise<RefreshOutcome> {
|
|
|
212
223
|
const superseded = (): boolean => getRefreshToken() !== refresh;
|
|
213
224
|
const sessionUsable = (): boolean => checkTokenStatus().action !== 'logout';
|
|
214
225
|
try {
|
|
215
|
-
const body = await mojoCall(REFRESH_PATH, {
|
|
226
|
+
const body = await mojoCall(REFRESH_PATH, {
|
|
227
|
+
method: 'POST', body: { refresh_token: refresh }, signal: AbortSignal.timeout(15_000),
|
|
228
|
+
});
|
|
216
229
|
if (superseded()) return { ok: sessionUsable() };
|
|
217
230
|
const pkg = body.data as { access_token: string; refresh_token?: string };
|
|
218
|
-
|
|
231
|
+
storeTokens(pkg.access_token, pkg.refresh_token, persistent, 'refresh');
|
|
219
232
|
emitAuth('refreshed');
|
|
220
233
|
return { ok: true };
|
|
221
234
|
} catch (error) {
|
package/src/client/realtime.ts
CHANGED
|
@@ -3,7 +3,8 @@ import {
|
|
|
3
3
|
useSyncExternalStore, type PropsWithChildren,
|
|
4
4
|
} from 'react';
|
|
5
5
|
import { apiOrigin } from './client';
|
|
6
|
-
import { getAccessToken, subscribeAuth } from './auth';
|
|
6
|
+
import { ensureValidToken, getAccessToken, getAuthSessionRevision, getRefreshToken, subscribeAuth } from './auth';
|
|
7
|
+
import { AuthRequiredError } from './errors';
|
|
7
8
|
|
|
8
9
|
export type RealtimeLifecycleStatus =
|
|
9
10
|
| 'disabled' | 'idle' | 'connecting' | 'authenticating' | 'ready'
|
|
@@ -106,6 +107,10 @@ export class RealtimeClient {
|
|
|
106
107
|
private readonly topicTimeoutMs: number;
|
|
107
108
|
private socket: RealtimeSocket | null = null;
|
|
108
109
|
private token: string | null = null;
|
|
110
|
+
private authBinding: { unsubscribe: () => void } | null = null;
|
|
111
|
+
private authSessionRevision: number | null = null;
|
|
112
|
+
private preparing: object | null = null;
|
|
113
|
+
private authSent: RealtimeSocket | null = null;
|
|
109
114
|
private enabled = false;
|
|
110
115
|
private generation = 0;
|
|
111
116
|
private reconnectAttempt = 0;
|
|
@@ -142,11 +147,44 @@ export class RealtimeClient {
|
|
|
142
147
|
return () => this.statusListeners.delete(listener);
|
|
143
148
|
};
|
|
144
149
|
|
|
150
|
+
/** Bind one session owner; renewal shares HTTP's single-flight auth gate. */
|
|
151
|
+
bindAuthSession(): () => void {
|
|
152
|
+
if (this.authBinding) throw new Error('Realtime client already has an auth session owner');
|
|
153
|
+
const binding = { unsubscribe: () => {} };
|
|
154
|
+
this.authBinding = binding;
|
|
155
|
+
this.stop(this.suspended ?? (this.enabled ? 'idle' : 'disabled'));
|
|
156
|
+
// Synchronous invalidation is essential: a React effect can run after
|
|
157
|
+
// an old refresh/handshake has completed for the previous account.
|
|
158
|
+
const syncSession = () => {
|
|
159
|
+
const revision = getAuthSessionRevision();
|
|
160
|
+
const replaced = this.authSessionRevision !== revision;
|
|
161
|
+
this.authSessionRevision = revision;
|
|
162
|
+
// A renewal already in flight when the server denied/revoked this
|
|
163
|
+
// connection is not an intentional replacement session. The fence
|
|
164
|
+
// also survives a renewal completed while no binding was mounted.
|
|
165
|
+
if (!replaced && this.suspended) {
|
|
166
|
+
this.token = getAccessToken();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.setToken(getAccessToken());
|
|
170
|
+
};
|
|
171
|
+
binding.unsubscribe = subscribeAuth(syncSession);
|
|
172
|
+
syncSession();
|
|
173
|
+
if (this.enabled) this.open();
|
|
174
|
+
return () => {
|
|
175
|
+
if (this.authBinding !== binding) return;
|
|
176
|
+
this.disconnect();
|
|
177
|
+
binding.unsubscribe();
|
|
178
|
+
this.authBinding = null;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
145
182
|
setEnabled(enabled: boolean): void {
|
|
146
183
|
if (this.enabled === enabled) return;
|
|
147
184
|
this.enabled = enabled;
|
|
148
185
|
if (!enabled) this.stop('disabled');
|
|
149
|
-
else if (this.
|
|
186
|
+
else if (this.suspended) this.setLifecycle(this.suspended);
|
|
187
|
+
else if (this.hasCredentials()) this.open();
|
|
150
188
|
else this.setLifecycle('idle');
|
|
151
189
|
}
|
|
152
190
|
|
|
@@ -156,7 +194,7 @@ export class RealtimeClient {
|
|
|
156
194
|
this.token = token;
|
|
157
195
|
this.suspended = null;
|
|
158
196
|
this.stop(this.enabled && token ? 'idle' : this.enabled ? 'idle' : 'disabled');
|
|
159
|
-
if (this.enabled &&
|
|
197
|
+
if (this.enabled && this.hasCredentials()) this.open();
|
|
160
198
|
}
|
|
161
199
|
|
|
162
200
|
connect(token: string): void {
|
|
@@ -171,6 +209,8 @@ export class RealtimeClient {
|
|
|
171
209
|
|
|
172
210
|
destroy(): void {
|
|
173
211
|
this.disconnect();
|
|
212
|
+
this.authBinding?.unsubscribe();
|
|
213
|
+
this.authBinding = null;
|
|
174
214
|
this.statusListeners.clear();
|
|
175
215
|
this.topicListeners.clear();
|
|
176
216
|
this.eventListeners.clear();
|
|
@@ -232,14 +272,18 @@ export class RealtimeClient {
|
|
|
232
272
|
}
|
|
233
273
|
|
|
234
274
|
nudge(): void {
|
|
235
|
-
if (!this.enabled || !this.
|
|
275
|
+
if (!this.enabled || !this.hasCredentials() || this.suspended || this.socket || this.retryTimer == null) return;
|
|
236
276
|
if (this.clock.now() < this.extendedBackoffUntil) return;
|
|
237
277
|
this.clearRetry();
|
|
238
278
|
this.open();
|
|
239
279
|
}
|
|
240
280
|
|
|
281
|
+
private hasCredentials(): boolean {
|
|
282
|
+
return Boolean(this.token || (this.authBinding && getRefreshToken()));
|
|
283
|
+
}
|
|
284
|
+
|
|
241
285
|
private open(): void {
|
|
242
|
-
if (!this.enabled || !this.
|
|
286
|
+
if (!this.enabled || !this.hasCredentials() || this.suspended || this.socket || this.preparing) return;
|
|
243
287
|
const run = this.generation;
|
|
244
288
|
// Token refresh and provider remount use this same entry point; neither
|
|
245
289
|
// may bypass a server admission deadline.
|
|
@@ -248,6 +292,47 @@ export class RealtimeClient {
|
|
|
248
292
|
return;
|
|
249
293
|
}
|
|
250
294
|
this.setLifecycle('connecting');
|
|
295
|
+
this.prepareSession(run, null, () => this.openSocket(run));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private prepareSession(run: number, socket: RealtimeSocket | null, ready: () => void): void {
|
|
299
|
+
const binding = this.authBinding;
|
|
300
|
+
if (!binding) { ready(); return; }
|
|
301
|
+
if (this.preparing) return;
|
|
302
|
+
const attempt = {};
|
|
303
|
+
this.preparing = attempt;
|
|
304
|
+
const current = () => this.preparing === attempt && this.authBinding === binding
|
|
305
|
+
&& this.enabled && !this.suspended && run === this.generation && this.socket === socket;
|
|
306
|
+
void Promise.resolve().then(() => {
|
|
307
|
+
if (current()) return ensureValidToken();
|
|
308
|
+
}).then(() => {
|
|
309
|
+
if (!current()) return;
|
|
310
|
+
this.preparing = null;
|
|
311
|
+
ready();
|
|
312
|
+
}).catch((error: unknown) => {
|
|
313
|
+
if (!current()) return;
|
|
314
|
+
this.preparing = null;
|
|
315
|
+
if (error instanceof AuthRequiredError) this.suspend('auth-failed', 4003, 'Authentication failed');
|
|
316
|
+
else this.retryAuthentication(run, socket);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private retryAuthentication(run: number, socket: RealtimeSocket | null): void {
|
|
321
|
+
this.preparing = null;
|
|
322
|
+
this.authSent = null;
|
|
323
|
+
this.socket = null;
|
|
324
|
+
this.clearHeartbeat();
|
|
325
|
+
this.resetTopicConnectionState();
|
|
326
|
+
this.scheduleReconnect(run, 0);
|
|
327
|
+
socket?.close(1000, 'Session renewal pending');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private openSocket(run: number): void {
|
|
331
|
+
// A renewal may have completed while an admission cooldown was set.
|
|
332
|
+
if (this.clock.now() < this.extendedBackoffUntil) {
|
|
333
|
+
this.scheduleReconnect(run, 0);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
251
336
|
let socket: RealtimeSocket;
|
|
252
337
|
try {
|
|
253
338
|
socket = this.socketFactory(this.url);
|
|
@@ -267,6 +352,8 @@ export class RealtimeClient {
|
|
|
267
352
|
socket.onerror = () => { /* close is the single retry path */ };
|
|
268
353
|
socket.onclose = (event) => {
|
|
269
354
|
if (!this.isCurrent(run, socket)) return;
|
|
355
|
+
this.preparing = null;
|
|
356
|
+
this.authSent = null;
|
|
270
357
|
this.socket = null;
|
|
271
358
|
this.clearHeartbeat();
|
|
272
359
|
this.resetTopicConnectionState();
|
|
@@ -283,12 +370,16 @@ export class RealtimeClient {
|
|
|
283
370
|
if (!frame || typeof frame.type !== 'string') return;
|
|
284
371
|
|
|
285
372
|
if (frame.type === 'auth_required') {
|
|
286
|
-
if (this.lifecycle !== 'authenticating' || !this.token) return;
|
|
287
|
-
|
|
373
|
+
if (this.lifecycle !== 'authenticating' || !this.token || this.authSent === socket) return;
|
|
374
|
+
this.prepareSession(run, socket, () => {
|
|
375
|
+
// Latch before send: a transport may answer synchronously.
|
|
376
|
+
this.authSent = socket;
|
|
377
|
+
socket.send(JSON.stringify({ type: 'authenticate', token: this.token, prefix: 'bearer' }));
|
|
378
|
+
});
|
|
288
379
|
return;
|
|
289
380
|
}
|
|
290
381
|
if (frame.type === 'auth_success') {
|
|
291
|
-
if (this.lifecycle !== 'authenticating') return;
|
|
382
|
+
if (this.lifecycle !== 'authenticating' || this.authSent !== socket) return;
|
|
292
383
|
this.reconnectAttempt = 0;
|
|
293
384
|
this.extendedBackoffUntil = 0;
|
|
294
385
|
this.automaticUserTopic = (typeof frame.user_type === 'string' && (typeof frame.user_id === 'number' || typeof frame.user_id === 'string'))
|
|
@@ -325,6 +416,8 @@ export class RealtimeClient {
|
|
|
325
416
|
// Fence queued frames before closing: late auth_success must not
|
|
326
417
|
// clear the deadline or make this refused socket ready.
|
|
327
418
|
this.extendedBackoffUntil = this.clock.now() + 60_000;
|
|
419
|
+
this.preparing = null;
|
|
420
|
+
this.authSent = null;
|
|
328
421
|
this.socket = null;
|
|
329
422
|
this.clearHeartbeat();
|
|
330
423
|
this.resetTopicConnectionState();
|
|
@@ -335,7 +428,11 @@ export class RealtimeClient {
|
|
|
335
428
|
}
|
|
336
429
|
if (frame.type === 'error') {
|
|
337
430
|
if (this.lifecycle === 'authenticating') {
|
|
338
|
-
|
|
431
|
+
// The server's admission timer can expire while renewal is
|
|
432
|
+
// pending. No credential was rejected in this specific case.
|
|
433
|
+
if (frame.message === 'Authentication timeout' && this.authBinding && this.authSent !== socket) {
|
|
434
|
+
this.retryAuthentication(run, socket);
|
|
435
|
+
} else this.suspend('auth-failed', 4003, 'Authentication failed');
|
|
339
436
|
} else if (this.topicOperation) {
|
|
340
437
|
this.denyCurrentTopic();
|
|
341
438
|
} else {
|
|
@@ -492,6 +589,9 @@ export class RealtimeClient {
|
|
|
492
589
|
|
|
493
590
|
private suspend(reason: 'auth-failed' | 'forced-disconnect', code: number, message: string): void {
|
|
494
591
|
this.suspended = reason;
|
|
592
|
+
this.preparing = null;
|
|
593
|
+
this.authSent = null;
|
|
594
|
+
this.clearRetry();
|
|
495
595
|
this.setLifecycle(reason);
|
|
496
596
|
const socket = this.socket;
|
|
497
597
|
this.socket = null;
|
|
@@ -502,6 +602,8 @@ export class RealtimeClient {
|
|
|
502
602
|
|
|
503
603
|
private stop(status: RealtimeLifecycleStatus): void {
|
|
504
604
|
this.generation += 1;
|
|
605
|
+
this.preparing = null;
|
|
606
|
+
this.authSent = null;
|
|
505
607
|
this.clearRetry();
|
|
506
608
|
this.clearHeartbeat();
|
|
507
609
|
this.resetTopicConnectionState();
|
|
@@ -561,8 +663,7 @@ export type RealtimeProviderProps = PropsWithChildren<{
|
|
|
561
663
|
|
|
562
664
|
export function RealtimeProvider({ children, enabled = true, client, socketFactory, origin }: RealtimeProviderProps) {
|
|
563
665
|
const owned = useMemo(() => client ?? new RealtimeClient({ socketFactory, origin }), [client, socketFactory, origin]);
|
|
564
|
-
|
|
565
|
-
useEffect(() => { owned.setToken(token); }, [owned, token]);
|
|
666
|
+
useEffect(() => owned.bindAuthSession(), [owned]);
|
|
566
667
|
useEffect(() => {
|
|
567
668
|
owned.setEnabled(enabled);
|
|
568
669
|
return () => owned.setEnabled(false);
|