@oxyhq/services 13.1.2 → 13.1.3
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/lib/commonjs/ui/context/OxyContext.js +45 -17
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/screens/NotificationsScreen.js +1 -1
- package/lib/commonjs/ui/screens/NotificationsScreen.js.map +1 -1
- package/lib/commonjs/ui/utils/activeAuthuser.js +35 -14
- package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +45 -17
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/screens/NotificationsScreen.js +1 -1
- package/lib/module/ui/screens/NotificationsScreen.js.map +1 -1
- package/lib/module/ui/utils/activeAuthuser.js +35 -14
- package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/ui/context/OxyContext.tsx +45 -17
- package/src/ui/screens/NotificationsScreen.tsx +1 -1
- package/src/ui/utils/__tests__/activeAuthuser.test.ts +75 -0
- package/src/ui/utils/activeAuthuser.ts +34 -14
|
@@ -1334,16 +1334,27 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1334
1334
|
// to the cross-domain fallbacks exactly as before.
|
|
1335
1335
|
const prioritizeMultiAccount = isWebBrowser() && readActiveAuthuser() !== null;
|
|
1336
1336
|
|
|
1337
|
-
// DELIBERATELY-SIGNED-OUT gate (web): when the user pressed "Sign out",
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1340
|
-
//
|
|
1341
|
-
//
|
|
1342
|
-
//
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
1337
|
+
// DELIBERATELY-SIGNED-OUT gate (web): when the user pressed "Sign out", any
|
|
1338
|
+
// credential that can silently re-mint a session may still be live on the
|
|
1339
|
+
// next reload — the central IdP session (FedCM credential association /
|
|
1340
|
+
// per-apex `fedcm_session` cookie) AND the device refresh cookies. Since
|
|
1341
|
+
// PR #455 the PRIMARY web session also joins the `oxy_rt_<authuser>` device
|
|
1342
|
+
// set, so the un-gated cookie-restore steps would `refresh-all` that still-
|
|
1343
|
+
// present cookie and sign the user back in without intent — exactly the
|
|
1344
|
+
// "sign-out is silently undone" regression. So this flag gates EVERY
|
|
1345
|
+
// AUTOMATIC silent-restore cold-boot step: `stored-session`,
|
|
1346
|
+
// `cookie-restore-active`, `fedcm-silent`, `silent-iframe`, and
|
|
1347
|
+
// `cookie-restore`. The gate — not a cookie wipe — is the authority: the
|
|
1348
|
+
// account may stay "known" for a fast deliberate re-sign-in, but no step
|
|
1349
|
+
// restores it silently while the flag is set. Read the durable flag ONCE here
|
|
1350
|
+
// (synchronously usable by every step `enabled` gate). Any deliberate sign-in
|
|
1351
|
+
// (password, FedCM, account switch, device claim) clears it, so there is no
|
|
1352
|
+
// "stuck signed out" state. The `sso-bounce` step needs no extra gate: it is
|
|
1353
|
+
// already self-suppressed after sign-out (its `hasPriorSession` hint is
|
|
1354
|
+
// cleared). `sso-return` is NOT gated — it commits the result of a deliberate
|
|
1355
|
+
// top-level `/sso` bounce the user just initiated. `shared-key-signin` is
|
|
1356
|
+
// native-only and the flag never sets on native (`markSignedOut` no-ops
|
|
1357
|
+
// off-web), so its gate would be moot; it is left untouched.
|
|
1347
1358
|
const silentRestoreBlocked = isSilentRestoreSuppressed();
|
|
1348
1359
|
|
|
1349
1360
|
try {
|
|
@@ -1363,12 +1374,17 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1363
1374
|
},
|
|
1364
1375
|
},
|
|
1365
1376
|
{
|
|
1366
|
-
// 2) Stored-session bearer restore.
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
// common WEB reload winner.
|
|
1377
|
+
// 2) Stored-session bearer restore. Runs on ALL platforms EXCEPT when
|
|
1378
|
+
// the deliberately-signed-out flag is set (web only — it never sets on
|
|
1379
|
+
// native, so native still always reaches exactly this step, its ONLY
|
|
1380
|
+
// restore path). This is also the common WEB reload winner.
|
|
1370
1381
|
//
|
|
1371
|
-
//
|
|
1382
|
+
// SIGNED-OUT GATE: after a deliberate full sign-out the stored session
|
|
1383
|
+
// state is cleared, so this would normally skip anyway — but the gate
|
|
1384
|
+
// makes that authoritative rather than incidental, so no residual
|
|
1385
|
+
// stored token can silently restore while the user is signed out.
|
|
1386
|
+
//
|
|
1387
|
+
// ORDERING (FIX A): this step runs BEFORE the slow web-only
|
|
1372
1388
|
// probes (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a
|
|
1373
1389
|
// normal reload the local bearer validates in one round-trip and
|
|
1374
1390
|
// wins; `runColdBoot` then short-circuits and never even evaluates
|
|
@@ -1379,6 +1395,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1379
1395
|
// local session this step skips and the cross-domain fallback chain
|
|
1380
1396
|
// (fedcm → iframe → cookie → sso-bounce) runs exactly as before.
|
|
1381
1397
|
id: 'stored-session',
|
|
1398
|
+
enabled: () => !silentRestoreBlocked,
|
|
1382
1399
|
run: async () => {
|
|
1383
1400
|
const restored = await restoreStoredSession();
|
|
1384
1401
|
if (restored) {
|
|
@@ -1455,8 +1472,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1455
1472
|
// When no slot is persisted this is disabled and the original order is
|
|
1456
1473
|
// unchanged; when the slot's cookies have lapsed it returns no accounts
|
|
1457
1474
|
// and the chain falls through to the cross-domain fallbacks below.
|
|
1475
|
+
//
|
|
1476
|
+
// SIGNED-OUT GATE: since PR #455 the primary's `oxy_rt` slot survives a
|
|
1477
|
+
// deliberate sign-out, so without this gate `refresh-all` would re-mint
|
|
1478
|
+
// and silently re-log-in the user on reload. The flag is cleared by any
|
|
1479
|
+
// deliberate sign-in (including an account SWITCH, which also writes the
|
|
1480
|
+
// new active slot), so the switch-survives-reload path is unaffected.
|
|
1458
1481
|
id: 'cookie-restore-active',
|
|
1459
|
-
enabled: () => prioritizeMultiAccount,
|
|
1482
|
+
enabled: () => prioritizeMultiAccount && !silentRestoreBlocked,
|
|
1460
1483
|
run: async () => {
|
|
1461
1484
|
const restored = await restoreViaRefreshCookie();
|
|
1462
1485
|
return restored ? { kind: 'session', session: true } : { kind: 'skip' };
|
|
@@ -1546,8 +1569,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1546
1569
|
// would be a redundant second `refreshAllSessions`. It still runs in the
|
|
1547
1570
|
// common no-persisted-slot case (e.g. first visit to a first-party app
|
|
1548
1571
|
// whose central refresh cookies already exist).
|
|
1572
|
+
//
|
|
1573
|
+
// SIGNED-OUT GATE (same rationale as `cookie-restore-active`): the
|
|
1574
|
+
// primary's `oxy_rt` slot survives a deliberate sign-out (PR #455), so
|
|
1575
|
+
// this must be skipped while the signed-out flag is set or it would
|
|
1576
|
+
// silently re-restore the primary on reload.
|
|
1549
1577
|
id: 'cookie-restore',
|
|
1550
|
-
enabled: () => isWebBrowser() && !prioritizeMultiAccount,
|
|
1578
|
+
enabled: () => isWebBrowser() && !prioritizeMultiAccount && !silentRestoreBlocked,
|
|
1551
1579
|
run: async () => {
|
|
1552
1580
|
const restored = await restoreViaRefreshCookie();
|
|
1553
1581
|
return restored ? { kind: 'session', session: true } : { kind: 'skip' };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-safe guarantees for the web `activeAuthuser` storage helpers.
|
|
3
|
+
*
|
|
4
|
+
* The PROPERTY ACCESS `window.localStorage` can throw a `SecurityError`
|
|
5
|
+
* synchronously in opaque-origin / sandboxed iframes or when storage is
|
|
6
|
+
* disabled — not just `getItem`/`setItem`. These helpers run during cold boot
|
|
7
|
+
* (and feed render-phase gate values in the providers), so they MUST never
|
|
8
|
+
* propagate that throw: reads fail safe to a benign default and writes no-op.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
readActiveAuthuser,
|
|
13
|
+
writeActiveAuthuser,
|
|
14
|
+
clearActiveAuthuser,
|
|
15
|
+
markSignedOut,
|
|
16
|
+
clearSignedOut,
|
|
17
|
+
isSilentRestoreSuppressed,
|
|
18
|
+
} from '../activeAuthuser';
|
|
19
|
+
|
|
20
|
+
describe('activeAuthuser helpers — localStorage fail-safe', () => {
|
|
21
|
+
const realDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
if (realDescriptor) {
|
|
25
|
+
Object.defineProperty(window, 'localStorage', realDescriptor);
|
|
26
|
+
}
|
|
27
|
+
window.localStorage?.clear?.();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Replace the `localStorage` accessor so reading the property throws. */
|
|
31
|
+
function makeLocalStorageGetterThrow(): void {
|
|
32
|
+
Object.defineProperty(window, 'localStorage', {
|
|
33
|
+
configurable: true,
|
|
34
|
+
get() {
|
|
35
|
+
throw new DOMException('denied', 'SecurityError');
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it('readActiveAuthuser returns null (never throws) when the getter throws', () => {
|
|
41
|
+
makeLocalStorageGetterThrow();
|
|
42
|
+
expect(() => readActiveAuthuser()).not.toThrow();
|
|
43
|
+
expect(readActiveAuthuser()).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('isSilentRestoreSuppressed returns false (never throws) when the getter throws', () => {
|
|
47
|
+
makeLocalStorageGetterThrow();
|
|
48
|
+
expect(() => isSilentRestoreSuppressed()).not.toThrow();
|
|
49
|
+
expect(isSilentRestoreSuppressed()).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('writeActiveAuthuser / clearActiveAuthuser / markSignedOut / clearSignedOut no-op when the getter throws', () => {
|
|
53
|
+
makeLocalStorageGetterThrow();
|
|
54
|
+
expect(() => {
|
|
55
|
+
writeActiveAuthuser(2);
|
|
56
|
+
clearActiveAuthuser();
|
|
57
|
+
markSignedOut();
|
|
58
|
+
clearSignedOut();
|
|
59
|
+
}).not.toThrow();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('round-trips normally when storage works (zero behavior change)', () => {
|
|
63
|
+
writeActiveAuthuser(3);
|
|
64
|
+
expect(readActiveAuthuser()).toBe(3);
|
|
65
|
+
|
|
66
|
+
expect(isSilentRestoreSuppressed()).toBe(false);
|
|
67
|
+
markSignedOut();
|
|
68
|
+
expect(isSilentRestoreSuppressed()).toBe(true);
|
|
69
|
+
clearSignedOut();
|
|
70
|
+
expect(isSilentRestoreSuppressed()).toBe(false);
|
|
71
|
+
|
|
72
|
+
clearActiveAuthuser();
|
|
73
|
+
expect(readActiveAuthuser()).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -30,8 +30,22 @@ import {
|
|
|
30
30
|
|
|
31
31
|
const ACTIVE_AUTHUSER_KEY = 'oxy_active_authuser';
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Safely resolve `window.localStorage`, returning `null` when it is
|
|
35
|
+
* unavailable. The PROPERTY ACCESS itself (`window.localStorage`) can throw a
|
|
36
|
+
* `SecurityError` synchronously in opaque-origin / sandboxed iframes or when
|
|
37
|
+
* storage is disabled — even `typeof window.localStorage` evaluates the getter
|
|
38
|
+
* and throws. Every read/write in this module routes through here so the getter
|
|
39
|
+
* throw is caught once, at the source, and callers stay clean. Returns `null`
|
|
40
|
+
* off-web and on any access failure (fail safe).
|
|
41
|
+
*/
|
|
42
|
+
function getLocalStorage(): Storage | null {
|
|
43
|
+
if (typeof window === 'undefined') return null;
|
|
44
|
+
try {
|
|
45
|
+
return window.localStorage ?? null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
function getSessionStorage(): Storage | null {
|
|
@@ -51,11 +65,12 @@ function getSessionStorage(): Storage | null {
|
|
|
51
65
|
* fall back to deterministic selection (lowest authuser).
|
|
52
66
|
*/
|
|
53
67
|
export function readActiveAuthuser(): number | null {
|
|
54
|
-
|
|
68
|
+
const storage = getLocalStorage();
|
|
69
|
+
if (!storage) {
|
|
55
70
|
return null;
|
|
56
71
|
}
|
|
57
72
|
try {
|
|
58
|
-
const raw =
|
|
73
|
+
const raw = storage.getItem(ACTIVE_AUTHUSER_KEY);
|
|
59
74
|
if (raw === null) return null;
|
|
60
75
|
const parsed = Number.parseInt(raw, 10);
|
|
61
76
|
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
|
@@ -71,10 +86,11 @@ export function readActiveAuthuser(): number | null {
|
|
|
71
86
|
* this succeeding — it is best-effort UX persistence, not authoritative.
|
|
72
87
|
*/
|
|
73
88
|
export function writeActiveAuthuser(authuser: number): void {
|
|
74
|
-
if (!hasLocalStorage()) return;
|
|
75
89
|
if (!Number.isFinite(authuser) || authuser < 0) return;
|
|
90
|
+
const storage = getLocalStorage();
|
|
91
|
+
if (!storage) return;
|
|
76
92
|
try {
|
|
77
|
-
|
|
93
|
+
storage.setItem(ACTIVE_AUTHUSER_KEY, String(authuser));
|
|
78
94
|
} catch {
|
|
79
95
|
// Best-effort persistence; swallow QuotaExceededError / SecurityError.
|
|
80
96
|
}
|
|
@@ -86,9 +102,10 @@ export function writeActiveAuthuser(authuser: number): void {
|
|
|
86
102
|
* cleared slot.
|
|
87
103
|
*/
|
|
88
104
|
export function clearActiveAuthuser(): void {
|
|
89
|
-
|
|
105
|
+
const storage = getLocalStorage();
|
|
106
|
+
if (!storage) return;
|
|
90
107
|
try {
|
|
91
|
-
|
|
108
|
+
storage.removeItem(ACTIVE_AUTHUSER_KEY);
|
|
92
109
|
} catch {
|
|
93
110
|
// Best-effort.
|
|
94
111
|
}
|
|
@@ -103,9 +120,10 @@ export function clearActiveAuthuser(): void {
|
|
|
103
120
|
* failure (best-effort).
|
|
104
121
|
*/
|
|
105
122
|
export function markSignedOut(): void {
|
|
106
|
-
|
|
123
|
+
const storage = getLocalStorage();
|
|
124
|
+
if (!storage) return;
|
|
107
125
|
try {
|
|
108
|
-
|
|
126
|
+
storage.setItem(ssoSignedOutKey(window.location.origin), '1');
|
|
109
127
|
} catch {
|
|
110
128
|
// Best-effort; swallow QuotaExceededError / SecurityError (private mode).
|
|
111
129
|
}
|
|
@@ -118,9 +136,10 @@ export function markSignedOut(): void {
|
|
|
118
136
|
* state. No-ops on native / storage failure.
|
|
119
137
|
*/
|
|
120
138
|
export function clearSignedOut(): void {
|
|
121
|
-
|
|
139
|
+
const storage = getLocalStorage();
|
|
140
|
+
if (!storage) return;
|
|
122
141
|
try {
|
|
123
|
-
|
|
142
|
+
storage.removeItem(ssoSignedOutKey(window.location.origin));
|
|
124
143
|
} catch {
|
|
125
144
|
// Best-effort.
|
|
126
145
|
}
|
|
@@ -134,8 +153,9 @@ export function clearSignedOut(): void {
|
|
|
134
153
|
* `fedcm-silent` and `silent-iframe` cold-boot steps.
|
|
135
154
|
*/
|
|
136
155
|
export function isSilentRestoreSuppressed(): boolean {
|
|
137
|
-
|
|
138
|
-
|
|
156
|
+
const storage = getLocalStorage();
|
|
157
|
+
if (!storage) return false;
|
|
158
|
+
return silentRestoreSuppressed(storage, window.location.origin);
|
|
139
159
|
}
|
|
140
160
|
|
|
141
161
|
/**
|