@oxy.so/core 1.8.1 → 1.9.1
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/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +56 -0
- package/dist/cjs/i18n/locales/en-US.json +3 -1
- package/dist/cjs/i18n/locales/es-ES.json +3 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +3 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +3 -1
- package/dist/cjs/session/accountDialogController.js +73 -4
- package/dist/cjs/session/refresh.js +39 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +56 -0
- package/dist/esm/i18n/locales/en-US.json +3 -1
- package/dist/esm/i18n/locales/es-ES.json +3 -1
- package/dist/esm/i18n/locales/locales/en-US.json +3 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +3 -1
- package/dist/esm/session/accountDialogController.js +72 -4
- package/dist/esm/session/refresh.js +38 -4
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +28 -0
- package/dist/types/models/interfaces.d.ts +6 -1
- package/dist/types/session/accountDialogController.d.ts +50 -3
- package/dist/types/session/refresh.d.ts +14 -0
- package/package.json +1 -1
- package/src/HttpService.ts +57 -0
- package/src/__tests__/nearExpiryMintStorm.test.ts +165 -0
- package/src/i18n/locales/en-US.json +3 -1
- package/src/i18n/locales/es-ES.json +3 -1
- package/src/models/interfaces.ts +6 -1
- package/src/session/__tests__/accountDialogController.test.ts +105 -0
- package/src/session/__tests__/accountDialogShape.test.ts +1 -0
- package/src/session/accountDialogController.ts +87 -5
- package/src/session/refresh.ts +39 -4
|
@@ -141,6 +141,27 @@ export declare class HttpService {
|
|
|
141
141
|
* it, and there is exactly one timestamp to reason about.
|
|
142
142
|
*/
|
|
143
143
|
private lastRefreshWasRateLimited;
|
|
144
|
+
/**
|
|
145
|
+
* The access token the last SUCCESSFUL refresh handed back while it was still
|
|
146
|
+
* inside the refresh lead window, or `null`.
|
|
147
|
+
*
|
|
148
|
+
* The device mint (`POST /session/device/token`) answers with the session's
|
|
149
|
+
* STORED access token for as long as that token has not expired — it only
|
|
150
|
+
* mints a new one once the old one is past `exp`. A client that re-mints
|
|
151
|
+
* {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
|
|
152
|
+
* token back, and because that counts as a success (no cooldown), every
|
|
153
|
+
* following request re-ran the preflight and minted again: one mint per
|
|
154
|
+
* request for the last minute of every token, which exhausted the server's
|
|
155
|
+
* 30/min mint budget. The 429 that followed imposed a 60s cooldown that
|
|
156
|
+
* straddled the token's real expiry, so the next request went out with no
|
|
157
|
+
* bearer, drew a 401, found the refresh cooling down, and signed the user out.
|
|
158
|
+
*
|
|
159
|
+
* Remembering the answer ends that: while the current token IS this value and
|
|
160
|
+
* has not yet expired, the server has already said it has nothing fresher, so
|
|
161
|
+
* the preflight sends the still-valid token instead of asking again. Once the
|
|
162
|
+
* token crosses `exp` the preflight asks once more and the server rotates.
|
|
163
|
+
*/
|
|
164
|
+
private noFresherTokenThan;
|
|
144
165
|
private authRefreshHandler;
|
|
145
166
|
private accessTokenProvider;
|
|
146
167
|
private deviceSecretMintInFlight;
|
|
@@ -309,6 +330,13 @@ export declare class HttpService {
|
|
|
309
330
|
* Public because the handler lives in `session/refresh.ts`, not on this class.
|
|
310
331
|
*/
|
|
311
332
|
noteRefreshRateLimited(): void;
|
|
333
|
+
/**
|
|
334
|
+
* Whether the server has already answered a refresh of the CURRENT access
|
|
335
|
+
* token with that same, still-valid token — i.e. it has nothing fresher to
|
|
336
|
+
* give until the token's `exp`. The proactive scheduler reads this so it waits
|
|
337
|
+
* for expiry instead of re-asking at its 1s floor.
|
|
338
|
+
*/
|
|
339
|
+
isAwaitingCurrentTokenExpiry(): boolean;
|
|
312
340
|
/**
|
|
313
341
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
314
342
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
@@ -693,7 +693,12 @@ export interface AssetUploadProgress {
|
|
|
693
693
|
export interface DeviceLinkedSession {
|
|
694
694
|
sessionId: string;
|
|
695
695
|
deviceId: string;
|
|
696
|
-
|
|
696
|
+
/**
|
|
697
|
+
* Not sent by `GET /session/device/sessions/:sessionId` today — every row
|
|
698
|
+
* shares one device, and the account a row belongs to is `user`. Optional so
|
|
699
|
+
* a caller cannot render it unchecked ("undefined (This device)").
|
|
700
|
+
*/
|
|
701
|
+
deviceName?: string;
|
|
697
702
|
isActive: boolean;
|
|
698
703
|
lastActive: string;
|
|
699
704
|
expiresAt: string;
|
|
@@ -220,8 +220,19 @@ export interface SignInFlowState {
|
|
|
220
220
|
}
|
|
221
221
|
/** Immutable snapshot consumed by `useSyncExternalStore`. */
|
|
222
222
|
export interface AccountDialogSnapshot {
|
|
223
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* The current view. Never `'accounts'` without a session: the account menu
|
|
225
|
+
* describes a signed-in account, so with no bearer the controller shows the
|
|
226
|
+
* sign-in entry (`'signin'`) in its place.
|
|
227
|
+
*/
|
|
224
228
|
view: AccountDialogView;
|
|
229
|
+
/**
|
|
230
|
+
* Where {@link AccountDialogController.back} leads from {@link view}, or
|
|
231
|
+
* `null` when `view` is the dialog's first view (a back there closes the
|
|
232
|
+
* dialog instead). Hosts show their back affordance from this, not from a
|
|
233
|
+
* table of their own.
|
|
234
|
+
*/
|
|
235
|
+
backView: AccountDialogView | null;
|
|
225
236
|
/**
|
|
226
237
|
* The server-authoritative device directory — principals and the contexts
|
|
227
238
|
* each may act as (ADR 0002) — or `null` before the first read.
|
|
@@ -449,8 +460,32 @@ export declare class AccountDialogController {
|
|
|
449
460
|
* (no retry storm).
|
|
450
461
|
*/
|
|
451
462
|
private reconcileAuth;
|
|
452
|
-
/**
|
|
453
|
-
|
|
463
|
+
/**
|
|
464
|
+
* Set the dialog view directly.
|
|
465
|
+
*
|
|
466
|
+
* `'accounts'` is only honoured with a session. Signed out it lands on the
|
|
467
|
+
* sign-in entry instead — the menu it would open holds "Switch account",
|
|
468
|
+
* storage and "Sign out" for an account that does not exist.
|
|
469
|
+
*/
|
|
470
|
+
setView(requested: AccountDialogView): void;
|
|
471
|
+
/**
|
|
472
|
+
* Go back one step: `signup` and `qr` return to the sign-in entry, and the
|
|
473
|
+
* entry returns to the account menu when somebody is signed in.
|
|
474
|
+
*
|
|
475
|
+
* Returns `false` — changing nothing — when the current view is the dialog's
|
|
476
|
+
* first one, so the host closes the dialog instead. Leaving an active
|
|
477
|
+
* request withdraws it; otherwise its authorize code stays approvable until
|
|
478
|
+
* it expires.
|
|
479
|
+
*/
|
|
480
|
+
back(): boolean;
|
|
481
|
+
/** `'accounts'` needs a session; without one the sign-in entry stands in for it. */
|
|
482
|
+
private resolveView;
|
|
483
|
+
/**
|
|
484
|
+
* Whether an account is signed in here — the precondition of the account
|
|
485
|
+
* menu. The bearer, not the directory: the directory is bearer-read, and the
|
|
486
|
+
* menu's content (hero, storage, sign-out) is all about the bearer's account.
|
|
487
|
+
*/
|
|
488
|
+
private hasSession;
|
|
454
489
|
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
455
490
|
add(): void;
|
|
456
491
|
/** Switch to the "create account" view (passkey / Commons signup entry). */
|
|
@@ -795,6 +830,18 @@ export declare class AccountDialogController {
|
|
|
795
830
|
/** Recompute the snapshot and notify subscribers. */
|
|
796
831
|
private emit;
|
|
797
832
|
}
|
|
833
|
+
/**
|
|
834
|
+
* The view one step back from `view`, or `null` when `view` is the dialog's
|
|
835
|
+
* first view.
|
|
836
|
+
*
|
|
837
|
+
* The sign-in entry is `'add'` ("add another account") when somebody is signed
|
|
838
|
+
* in and `'signin'` when nobody is; `signup` and `qr` are reached FROM that
|
|
839
|
+
* entry, so they return to it. The entry itself returns to the account menu —
|
|
840
|
+
* which exists only with a session. This used to be a host-side
|
|
841
|
+
* "back = accounts" that assumed a signed-in origin, so a signed-out Back from
|
|
842
|
+
* "Create your account" opened a menu holding "Sign out" for nobody.
|
|
843
|
+
*/
|
|
844
|
+
export declare function backViewOf(view: AccountDialogView, hasSession: boolean): AccountDialogView | null;
|
|
798
845
|
/** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
|
|
799
846
|
export declare function createAccountDialogController(options: AccountDialogControllerOptions): AccountDialogController;
|
|
800
847
|
export {};
|
|
@@ -33,6 +33,20 @@ import { type IdentityBinding } from './identitySession';
|
|
|
33
33
|
* window — the scheduler just fires it during idle/background.
|
|
34
34
|
*/
|
|
35
35
|
export declare const TOKEN_REFRESH_LEAD_MS = 60000;
|
|
36
|
+
/**
|
|
37
|
+
* How long past `exp` to re-mint when a successful refresh handed back a token
|
|
38
|
+
* that is STILL inside the lead window.
|
|
39
|
+
*
|
|
40
|
+
* The device mint returns the session's stored access token until that token
|
|
41
|
+
* has actually expired, so asking again before `exp` only returns the same
|
|
42
|
+
* token. Re-arming from expiry in that state computed a negative delay, hit the
|
|
43
|
+
* {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
|
|
44
|
+
* minute of every token — 30+ mints, the server's whole per-minute budget, and
|
|
45
|
+
* the 429 that followed straddled the real expiry. Waiting until just past
|
|
46
|
+
* `exp` asks exactly once more, when the server will rotate. The margin covers
|
|
47
|
+
* the server's strict `exp < now` comparison and a little clock skew.
|
|
48
|
+
*/
|
|
49
|
+
export declare const REMINT_AFTER_EXPIRY_MS = 2000;
|
|
36
50
|
export interface RefreshDeps {
|
|
37
51
|
oxy: OxyServices;
|
|
38
52
|
store: AuthStateStore;
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -240,6 +240,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60_000;
|
|
|
240
240
|
*/
|
|
241
241
|
const TOKEN_REFRESH_LEAD_SECONDS = 60;
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Whether `token` is a JWT that is still valid but already inside the refresh
|
|
245
|
+
* lead window. `false` for an expired, opaque or no-`exp` token.
|
|
246
|
+
*/
|
|
247
|
+
function isInsideRefreshLeadWindow(token: string): boolean {
|
|
248
|
+
try {
|
|
249
|
+
const decoded = jwtDecode<JwtPayload>(token);
|
|
250
|
+
if (typeof decoded.exp !== 'number') {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const now = Math.floor(Date.now() / 1000);
|
|
254
|
+
// `<=`: the proactive scheduler fires at exactly `exp - lead`.
|
|
255
|
+
return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
243
261
|
/**
|
|
244
262
|
* Soft ceiling on the number of live entries in the identity-scoped GET
|
|
245
263
|
* response cache. Crossing it does NOT evict anything (the {@link TTLCache}
|
|
@@ -390,6 +408,27 @@ export class HttpService {
|
|
|
390
408
|
* it, and there is exactly one timestamp to reason about.
|
|
391
409
|
*/
|
|
392
410
|
private lastRefreshWasRateLimited = false;
|
|
411
|
+
/**
|
|
412
|
+
* The access token the last SUCCESSFUL refresh handed back while it was still
|
|
413
|
+
* inside the refresh lead window, or `null`.
|
|
414
|
+
*
|
|
415
|
+
* The device mint (`POST /session/device/token`) answers with the session's
|
|
416
|
+
* STORED access token for as long as that token has not expired — it only
|
|
417
|
+
* mints a new one once the old one is past `exp`. A client that re-mints
|
|
418
|
+
* {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
|
|
419
|
+
* token back, and because that counts as a success (no cooldown), every
|
|
420
|
+
* following request re-ran the preflight and minted again: one mint per
|
|
421
|
+
* request for the last minute of every token, which exhausted the server's
|
|
422
|
+
* 30/min mint budget. The 429 that followed imposed a 60s cooldown that
|
|
423
|
+
* straddled the token's real expiry, so the next request went out with no
|
|
424
|
+
* bearer, drew a 401, found the refresh cooling down, and signed the user out.
|
|
425
|
+
*
|
|
426
|
+
* Remembering the answer ends that: while the current token IS this value and
|
|
427
|
+
* has not yet expired, the server has already said it has nothing fresher, so
|
|
428
|
+
* the preflight sends the still-valid token instead of asking again. Once the
|
|
429
|
+
* token crosses `exp` the preflight asks once more and the server rotates.
|
|
430
|
+
*/
|
|
431
|
+
private noFresherTokenThan: string | null = null;
|
|
393
432
|
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
394
433
|
private accessTokenProvider: AccessTokenProvider | null = null;
|
|
395
434
|
private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
@@ -1324,6 +1363,12 @@ export class HttpService {
|
|
|
1324
1363
|
|
|
1325
1364
|
// If the token expires within the refresh lead window, refresh it.
|
|
1326
1365
|
if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
|
|
1366
|
+
// The server already answered this token's refresh with this very token:
|
|
1367
|
+
// it has nothing fresher until `exp`. Use it rather than asking again
|
|
1368
|
+
// (see `noFresherTokenThan`).
|
|
1369
|
+
if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
|
|
1370
|
+
return `Bearer ${accessToken}`;
|
|
1371
|
+
}
|
|
1327
1372
|
const refreshed = await this.refreshAccessToken('preflight');
|
|
1328
1373
|
if (refreshed) return `Bearer ${refreshed}`;
|
|
1329
1374
|
if (decoded.exp > currentTime) {
|
|
@@ -1378,6 +1423,7 @@ export class HttpService {
|
|
|
1378
1423
|
this.tokenStore.setTokens(newToken);
|
|
1379
1424
|
this.notifyTokenChange();
|
|
1380
1425
|
}
|
|
1426
|
+
this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
|
|
1381
1427
|
// A success clears the failure timestamp so the next refresh is never
|
|
1382
1428
|
// throttled by a stale cooldown.
|
|
1383
1429
|
this.lastRefreshFailureAt = 0;
|
|
@@ -1414,6 +1460,17 @@ export class HttpService {
|
|
|
1414
1460
|
this.lastRefreshWasRateLimited = true;
|
|
1415
1461
|
}
|
|
1416
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* Whether the server has already answered a refresh of the CURRENT access
|
|
1465
|
+
* token with that same, still-valid token — i.e. it has nothing fresher to
|
|
1466
|
+
* give until the token's `exp`. The proactive scheduler reads this so it waits
|
|
1467
|
+
* for expiry instead of re-asking at its 1s floor.
|
|
1468
|
+
*/
|
|
1469
|
+
isAwaitingCurrentTokenExpiry(): boolean {
|
|
1470
|
+
const token = this.tokenStore.getAccessToken();
|
|
1471
|
+
return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1417
1474
|
/**
|
|
1418
1475
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
1419
1476
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The near-expiry mint storm that signed a busy app out (OxyHQ/Mention#1140).
|
|
3
|
+
*
|
|
4
|
+
* What the server does: `POST /session/device/token` answers with the session's
|
|
5
|
+
* STORED access token for as long as that token has not expired, and mints a
|
|
6
|
+
* new one only once it is past `exp`. The mint is budgeted at 30/min per device.
|
|
7
|
+
*
|
|
8
|
+
* What the client did with that: the scheduler and the request-time preflight
|
|
9
|
+
* both re-mint 60s before `exp`. The server handed the same token back, which
|
|
10
|
+
* counted as a success (no cooldown), so the scheduler re-armed at its 1s floor
|
|
11
|
+
* and every request re-ran the preflight: ~30 mints in the token's last minute,
|
|
12
|
+
* then a 429, whose 60s cooldown straddled the real expiry. The next request
|
|
13
|
+
* carried no bearer, drew a 401, found the refresh cooling down, and the
|
|
14
|
+
* HttpService cleared the token — which the provider reads as a sign-out.
|
|
15
|
+
*
|
|
16
|
+
* Production logs for the device showed exactly this: a burst of ~30 mints at
|
|
17
|
+
* ~1.4s intervals every ~16 minutes, a single mint ~78s later (the scheduler's
|
|
18
|
+
* 5+10+20+40s backoff through the 60s rate-limit cooldown), and a sign-out on
|
|
19
|
+
* the one cycle where the app was in active use across the expiry.
|
|
20
|
+
*
|
|
21
|
+
* This suite simulates that server against the real HttpService, refresh
|
|
22
|
+
* scheduler and 401 lane.
|
|
23
|
+
*/
|
|
24
|
+
import { OxyServices } from '../OxyServices';
|
|
25
|
+
import { startTokenRefreshScheduler } from '../session/refresh';
|
|
26
|
+
|
|
27
|
+
const TOKEN_TTL_SECONDS = 15 * 60;
|
|
28
|
+
const MINT_BUDGET_PER_MINUTE = 30;
|
|
29
|
+
|
|
30
|
+
function createJwt(payload: Record<string, unknown>): string {
|
|
31
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
32
|
+
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expOf(token: string): number {
|
|
36
|
+
const [, payload] = token.split('.');
|
|
37
|
+
return (JSON.parse(Buffer.from(payload, 'base64url').toString()) as { exp: number }).exp;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The device mint as the server implements it, including its rate limiter.
|
|
42
|
+
*
|
|
43
|
+
* `rotateWithinSeconds` is how close to `exp` the stored token may be before the
|
|
44
|
+
* server rotates instead of handing it back: `0` is the server as it was (it
|
|
45
|
+
* rotated only a token already past `exp`), a positive value is the fixed one.
|
|
46
|
+
*/
|
|
47
|
+
function createMintServer(rotateWithinSeconds: number): {
|
|
48
|
+
mint: () => { status: 200; token: string } | { status: 429 };
|
|
49
|
+
storedToken: () => string;
|
|
50
|
+
mints: number[];
|
|
51
|
+
rateLimited: number;
|
|
52
|
+
} {
|
|
53
|
+
let serial = 0;
|
|
54
|
+
const issue = (): string => {
|
|
55
|
+
serial += 1;
|
|
56
|
+
return createJwt({ userId: 'u', jti: `t${serial}`, exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS });
|
|
57
|
+
};
|
|
58
|
+
let stored = issue();
|
|
59
|
+
const mints: number[] = [];
|
|
60
|
+
const state = { rateLimited: 0 };
|
|
61
|
+
return {
|
|
62
|
+
mint: () => {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
if (mints.filter((at) => now - at < 60_000).length >= MINT_BUDGET_PER_MINUTE) {
|
|
65
|
+
state.rateLimited += 1;
|
|
66
|
+
return { status: 429 };
|
|
67
|
+
}
|
|
68
|
+
mints.push(now);
|
|
69
|
+
// `getAccessToken`: hand back the stored token unless it has expired.
|
|
70
|
+
if (expOf(stored) - Math.floor(now / 1000) < (rotateWithinSeconds > 0 ? rotateWithinSeconds : 0)) {
|
|
71
|
+
stored = issue();
|
|
72
|
+
}
|
|
73
|
+
return { status: 200, token: stored };
|
|
74
|
+
},
|
|
75
|
+
storedToken: () => stored,
|
|
76
|
+
mints,
|
|
77
|
+
get rateLimited() {
|
|
78
|
+
return state.rateLimited;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('a busy app across an access token expiry', () => {
|
|
84
|
+
const originalFetch = globalThis.fetch;
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
globalThis.fetch = originalFetch;
|
|
88
|
+
jest.useRealTimers();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
async function runBusyApp(rotateWithinSeconds: number): Promise<{
|
|
92
|
+
tokenEvents: Array<string | null>;
|
|
93
|
+
finalToken: string | null;
|
|
94
|
+
server: ReturnType<typeof createMintServer>;
|
|
95
|
+
}> {
|
|
96
|
+
jest.useFakeTimers({ now: new Date('2026-09-25T13:48:00Z') });
|
|
97
|
+
const server = createMintServer(rotateWithinSeconds);
|
|
98
|
+
|
|
99
|
+
// The app's own API: 401 without a live bearer, as any resource server does.
|
|
100
|
+
globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
|
|
101
|
+
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
102
|
+
const bearer = headers.Authorization ?? headers.authorization;
|
|
103
|
+
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : null;
|
|
104
|
+
if (!token || expOf(token) <= Math.floor(Date.now() / 1000)) {
|
|
105
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
106
|
+
status: 401,
|
|
107
|
+
headers: { 'content-type': 'application/json' },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return new Response(JSON.stringify({ data: { ok: true } }), {
|
|
111
|
+
status: 200,
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
});
|
|
114
|
+
}) as typeof fetch;
|
|
115
|
+
|
|
116
|
+
const oxy = new OxyServices({ baseURL: 'https://api.mention.earth', enableCache: false, enableRetry: false });
|
|
117
|
+
oxy.setTokens(server.storedToken());
|
|
118
|
+
oxy.httpService.setAuthRefreshHandler(async (reason) => {
|
|
119
|
+
const answer = server.mint();
|
|
120
|
+
if (answer.status === 429) {
|
|
121
|
+
// What `refreshDeviceSecretArm` does with a 429.
|
|
122
|
+
oxy.httpService.noteRefreshRateLimited();
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
oxy.setTokens(answer.token);
|
|
126
|
+
return answer.token;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const tokenEvents: Array<string | null> = [];
|
|
130
|
+
oxy.onTokensChanged((token) => tokenEvents.push(token));
|
|
131
|
+
const scheduler = startTokenRefreshScheduler(oxy);
|
|
132
|
+
|
|
133
|
+
// Twenty minutes of an app in use: a request every 700ms, straight across
|
|
134
|
+
// the first token's expiry at 14:03:00.
|
|
135
|
+
for (let elapsed = 0; elapsed < 20 * 60_000; elapsed += 700) {
|
|
136
|
+
await oxy.httpService.get('/feed').catch(() => undefined);
|
|
137
|
+
await jest.advanceTimersByTimeAsync(700);
|
|
138
|
+
}
|
|
139
|
+
scheduler.dispose();
|
|
140
|
+
return { tokenEvents, finalToken: oxy.getAccessToken(), server };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it('against the server that hands back an unexpired token: never cleared, never rate limited', async () => {
|
|
144
|
+
const { tokenEvents, finalToken, server } = await runBusyApp(0);
|
|
145
|
+
|
|
146
|
+
// Before the fix: ~30 mints of the same token, a 429, then the token was
|
|
147
|
+
// cleared (a run of `null` events) and the app was signed out.
|
|
148
|
+
expect(tokenEvents).not.toContain(null);
|
|
149
|
+
expect(finalToken).not.toBeNull();
|
|
150
|
+
expect(server.rateLimited).toBe(0);
|
|
151
|
+
// One rotation. The server still hands the expired token back for the
|
|
152
|
+
// second it expires in (it compares `exp < now`), which costs a few mints in
|
|
153
|
+
// that one second — nowhere near the budget.
|
|
154
|
+
expect(server.mints.length).toBeLessThan(MINT_BUDGET_PER_MINUTE / 3);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('against the server that rotates a token near expiry: one mint per rotation', async () => {
|
|
158
|
+
const { tokenEvents, finalToken, server } = await runBusyApp(120);
|
|
159
|
+
|
|
160
|
+
expect(tokenEvents).not.toContain(null);
|
|
161
|
+
expect(finalToken).not.toBeNull();
|
|
162
|
+
expect(server.rateLimited).toBe(0);
|
|
163
|
+
expect(server.mints).toHaveLength(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -1850,6 +1850,7 @@
|
|
|
1850
1850
|
"switch": {
|
|
1851
1851
|
"title": "Switch account",
|
|
1852
1852
|
"count": "{{count}} accounts",
|
|
1853
|
+
"countOne": "{{count}} account",
|
|
1853
1854
|
"empty": "Accounts you own or share"
|
|
1854
1855
|
}
|
|
1855
1856
|
},
|
|
@@ -1970,7 +1971,8 @@
|
|
|
1970
1971
|
"loading": "Loading sessions…",
|
|
1971
1972
|
"empty": "No active sessions",
|
|
1972
1973
|
"signOutAllOnThisDevice": "Sign out of all other devices",
|
|
1973
|
-
"signOutAllSubtitle": "End {{count}} other device session(s)"
|
|
1974
|
+
"signOutAllSubtitle": "End {{count}} other device session(s)",
|
|
1975
|
+
"otherSession": "Another session"
|
|
1974
1976
|
},
|
|
1975
1977
|
"confirms": {
|
|
1976
1978
|
"removeDeviceTitle": "Remove device",
|
|
@@ -1850,6 +1850,7 @@
|
|
|
1850
1850
|
"switch": {
|
|
1851
1851
|
"title": "Cambiar de cuenta",
|
|
1852
1852
|
"count": "{{count}} cuentas",
|
|
1853
|
+
"countOne": "{{count}} cuenta",
|
|
1853
1854
|
"empty": "Cuentas que posees o compartes"
|
|
1854
1855
|
}
|
|
1855
1856
|
},
|
|
@@ -1970,7 +1971,8 @@
|
|
|
1970
1971
|
"loading": "Cargando sesiones…",
|
|
1971
1972
|
"empty": "No hay sesiones activas",
|
|
1972
1973
|
"signOutAllOnThisDevice": "Cerrar sesión en los demás dispositivos",
|
|
1973
|
-
"signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos"
|
|
1974
|
+
"signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos",
|
|
1975
|
+
"otherSession": "Otra sesión"
|
|
1974
1976
|
},
|
|
1975
1977
|
"confirms": {
|
|
1976
1978
|
"removeDeviceTitle": "Eliminar dispositivo",
|
package/src/models/interfaces.ts
CHANGED
|
@@ -801,7 +801,12 @@ export interface AssetUploadProgress {
|
|
|
801
801
|
export interface DeviceLinkedSession {
|
|
802
802
|
sessionId: string;
|
|
803
803
|
deviceId: string;
|
|
804
|
-
|
|
804
|
+
/**
|
|
805
|
+
* Not sent by `GET /session/device/sessions/:sessionId` today — every row
|
|
806
|
+
* shares one device, and the account a row belongs to is `user`. Optional so
|
|
807
|
+
* a caller cannot render it unchecked ("undefined (This device)").
|
|
808
|
+
*/
|
|
809
|
+
deviceName?: string;
|
|
805
810
|
isActive: boolean;
|
|
806
811
|
lastActive: string;
|
|
807
812
|
expiresAt: string;
|
|
@@ -241,6 +241,111 @@ function makeHarness(
|
|
|
241
241
|
const directoryReads = (urls: string[]): number =>
|
|
242
242
|
urls.filter((url) => url === '/session/device/directory').length;
|
|
243
243
|
|
|
244
|
+
describe('AccountDialogController — back, and the menu needs a session', () => {
|
|
245
|
+
/** A harness with no bearer — nobody is signed in on this device. */
|
|
246
|
+
function signedOut(): Harness {
|
|
247
|
+
const harness = makeHarness();
|
|
248
|
+
harness.oxy.emitTokenChange(null);
|
|
249
|
+
return harness;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
it('signed out, Back from "Create your account" returns to the sign-in entry, not the account menu', () => {
|
|
253
|
+
// The Android QA repro (OxyHQ/oxy#1375 item 1): Sign In → "New to Oxy?
|
|
254
|
+
// Create one" → ‹. The host's back used to be a hard-coded
|
|
255
|
+
// `setView('accounts')`, which opened "Switch account … Sign out" for nobody.
|
|
256
|
+
const { controller } = signedOut();
|
|
257
|
+
controller.setView('signin');
|
|
258
|
+
controller.startSignup();
|
|
259
|
+
expect(controller.getSnapshot().backView).toBe('signin');
|
|
260
|
+
|
|
261
|
+
expect(controller.back()).toBe(true);
|
|
262
|
+
|
|
263
|
+
expect(controller.getSnapshot().view).toBe('signin');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('signed out, the sign-in entry is the first view: back reports nothing to go back to', () => {
|
|
267
|
+
const { controller } = signedOut();
|
|
268
|
+
controller.setView('signin');
|
|
269
|
+
const before = controller.getSnapshot();
|
|
270
|
+
expect(before.backView).toBeNull();
|
|
271
|
+
|
|
272
|
+
expect(controller.back()).toBe(false);
|
|
273
|
+
|
|
274
|
+
expect(controller.getSnapshot()).toBe(before);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('signed out, the account menu is never the view — it resolves to the sign-in entry', async () => {
|
|
278
|
+
const { controller } = signedOut();
|
|
279
|
+
// A controller is constructed on `accounts`; started with no bearer, it
|
|
280
|
+
// shows the entry.
|
|
281
|
+
controller.start();
|
|
282
|
+
await flush();
|
|
283
|
+
expect(controller.getSnapshot().view).toBe('signin');
|
|
284
|
+
|
|
285
|
+
controller.setView('qr');
|
|
286
|
+
controller.setView('accounts');
|
|
287
|
+
expect(controller.getSnapshot().view).toBe('signin');
|
|
288
|
+
|
|
289
|
+
controller.add();
|
|
290
|
+
expect(controller.getSnapshot().backView).toBeNull();
|
|
291
|
+
expect(controller.back()).toBe(false);
|
|
292
|
+
expect(controller.getSnapshot().view).toBe('add');
|
|
293
|
+
controller.destroy();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('signed out, Back from the QR request withdraws it and returns to the entry', async () => {
|
|
297
|
+
const { controller, oxy } = signedOut();
|
|
298
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
299
|
+
sessionToken: 'secret-tok',
|
|
300
|
+
authorizeCode: 'AUTH-CODE',
|
|
301
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
302
|
+
expiresAt: Date.now() + 300_000,
|
|
303
|
+
status: 'pending',
|
|
304
|
+
});
|
|
305
|
+
controller.setView('signin');
|
|
306
|
+
await controller.showQr();
|
|
307
|
+
expect(controller.getSnapshot().view).toBe('qr');
|
|
308
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
309
|
+
|
|
310
|
+
expect(controller.back()).toBe(true);
|
|
311
|
+
|
|
312
|
+
const snap = controller.getSnapshot();
|
|
313
|
+
expect(snap.view).toBe('signin');
|
|
314
|
+
expect(snap.signIn.phase).toBe('idle');
|
|
315
|
+
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('signed in, back walks signup → add → accounts, and the menu is the first view', () => {
|
|
319
|
+
const { controller } = makeHarness();
|
|
320
|
+
controller.add();
|
|
321
|
+
controller.startSignup();
|
|
322
|
+
expect(controller.getSnapshot().backView).toBe('add');
|
|
323
|
+
|
|
324
|
+
expect(controller.back()).toBe(true);
|
|
325
|
+
expect(controller.getSnapshot().view).toBe('add');
|
|
326
|
+
expect(controller.getSnapshot().backView).toBe('accounts');
|
|
327
|
+
|
|
328
|
+
expect(controller.back()).toBe(true);
|
|
329
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
330
|
+
expect(controller.getSnapshot().backView).toBeNull();
|
|
331
|
+
|
|
332
|
+
expect(controller.back()).toBe(false);
|
|
333
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('signing out while the account menu is open leaves the menu for the sign-in entry', async () => {
|
|
337
|
+
const { controller, oxy } = makeHarness();
|
|
338
|
+
controller.start();
|
|
339
|
+
await flush();
|
|
340
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
341
|
+
|
|
342
|
+
oxy.emitTokenChange(null);
|
|
343
|
+
|
|
344
|
+
expect(controller.getSnapshot().view).toBe('signin');
|
|
345
|
+
controller.destroy();
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
244
349
|
describe('AccountDialogController — initial + views', () => {
|
|
245
350
|
it('starts on the accounts view with an empty list and idle sign-in', () => {
|
|
246
351
|
const { controller } = makeHarness();
|