@oxy.so/services 4.0.2 → 4.0.4
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 +52 -20
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/runtime/createOxyRuntime.js +16 -0
- package/lib/commonjs/ui/runtime/createOxyRuntime.js.map +1 -1
- package/lib/commonjs/ui/session/tokenLossRecovery.js +122 -0
- package/lib/commonjs/ui/session/tokenLossRecovery.js.map +1 -0
- package/lib/module/ui/context/OxyContext.js +53 -21
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/runtime/createOxyRuntime.js +16 -0
- package/lib/module/ui/runtime/createOxyRuntime.js.map +1 -1
- package/lib/module/ui/session/tokenLossRecovery.js +116 -0
- package/lib/module/ui/session/tokenLossRecovery.js.map +1 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/runtime/createOxyRuntime.d.ts +7 -2
- package/lib/typescript/commonjs/ui/runtime/createOxyRuntime.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/tokenLossRecovery.d.ts +37 -0
- package/lib/typescript/commonjs/ui/session/tokenLossRecovery.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/runtime/createOxyRuntime.d.ts +7 -2
- package/lib/typescript/module/ui/runtime/createOxyRuntime.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/tokenLossRecovery.d.ts +37 -0
- package/lib/typescript/module/ui/session/tokenLossRecovery.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/ui/context/OxyContext.tsx +53 -23
- package/src/ui/runtime/createOxyRuntime.ts +23 -2
- package/src/ui/session/__tests__/tokenLossRecovery.test.ts +195 -0
- package/src/ui/session/tokenLossRecovery.ts +146 -0
|
@@ -83,7 +83,9 @@ export interface OxyRuntime {
|
|
|
83
83
|
/**
|
|
84
84
|
* Project the client's current device state onto the snapshot. Idempotent and
|
|
85
85
|
* revision-guarded: a projection whose profile fetch was overtaken by a
|
|
86
|
-
* fresher state discards itself rather than publishing a stale subject.
|
|
86
|
+
* fresher state discards itself rather than publishing a stale subject. It is
|
|
87
|
+
* also teardown-guarded: one started before {@link clearSession} publishes
|
|
88
|
+
* nothing, whatever `SessionClient` still holds.
|
|
87
89
|
*/
|
|
88
90
|
reconcileFromClient(): Promise<void>;
|
|
89
91
|
/** Read the device directory and publish it (opts this runtime into ADR 0002). */
|
|
@@ -137,7 +139,10 @@ export interface OxyRuntime {
|
|
|
137
139
|
preserveSessionIds?: string[];
|
|
138
140
|
}): void;
|
|
139
141
|
setActiveSessionId(sessionId: string | null): void;
|
|
140
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Local teardown: drop the session projection without touching the server.
|
|
144
|
+
* Every projection already in flight is abandoned.
|
|
145
|
+
*/
|
|
141
146
|
clearSession(): void;
|
|
142
147
|
}
|
|
143
148
|
export declare function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createOxyRuntime.d.ts","sourceRoot":"","sources":["../../../../../src/ui/runtime/createOxyRuntime.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC7E,OAAO,KAAK,EAAE,aAAa,EAAiB,IAAI,EAAE,MAAM,cAAc,CAAC;AAYvE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,+BAA4B,CAAC;AACzE,OAAO,KAAK,EAAE,kBAAkB,EAAoC,MAAM,YAAS,CAAC;AAEpF;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC5B,cAAc,IAAI,MAAM,GAAG,IAAI,CAAC;IAChC,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;CAC/C;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,IAAI,kBAAkB,GAAG,IAAI,CAAC;IACtC,YAAY,IAAI,eAAe,GAAG,IAAI,CAAC;IACvC,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAC5E,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAClC,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjD,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACtD;AAED,MAAM,WAAW,kBAAkB;IACjC,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;CACrD;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,aAAa,CAAC;IAC3B,aAAa,EAAE,oBAAoB,CAAC;IACpC,iBAAiB,EAAE,kBAAkB,CAAC;IACtC,+EAA+E;IAC/E,QAAQ,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAExC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAE1D;;;;OAIG;IACH,aAAa,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAEnC,2EAA2E;IAC3E,mBAAmB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IAErD;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,sBAAsB,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAEhF,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CACpD;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,WAAW,IAAI,kBAAkB,CAAC;IAClC,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IAE5C,0EAA0E;IAC1E,KAAK,IAAI,MAAM,IAAI,CAAC;IAEpB
|
|
1
|
+
{"version":3,"file":"createOxyRuntime.d.ts","sourceRoot":"","sources":["../../../../../src/ui/runtime/createOxyRuntime.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface TokenLossRecoveryDeps {
|
|
2
|
+
/** Re-mint once through the installed refresh handler; the token, or null. */
|
|
3
|
+
remint: () => Promise<string | null>;
|
|
4
|
+
/** Whether the durable auth store still holds `deviceId` + `deviceSecret`. */
|
|
5
|
+
hasDeviceCredential: () => Promise<boolean>;
|
|
6
|
+
/**
|
|
7
|
+
* Whether a key-based lane could still re-establish the session without a
|
|
8
|
+
* device secret. Consulted only once the credential is gone.
|
|
9
|
+
*/
|
|
10
|
+
hasKeyedRecovery: () => Promise<boolean>;
|
|
11
|
+
/** Whether the UI still shows a signed-in user. */
|
|
12
|
+
isSignedIn: () => boolean;
|
|
13
|
+
/** Whether a bearer is planted right now. */
|
|
14
|
+
hasToken: () => boolean;
|
|
15
|
+
/** Tear down the local session (the provider's `clearSessionState`). */
|
|
16
|
+
signOutLocally: () => Promise<void>;
|
|
17
|
+
/** Timer seam for tests. */
|
|
18
|
+
sleep?: (ms: number) => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface TokenLossRecovery {
|
|
21
|
+
/** Start recovering, unless a recovery is already running. */
|
|
22
|
+
start(): void;
|
|
23
|
+
/** Abandon any running recovery (provider unmount). */
|
|
24
|
+
dispose(): void;
|
|
25
|
+
/** Whether a recovery is running. */
|
|
26
|
+
isRecovering(): boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Delays between re-mint attempts while the device credential is intact. Starts
|
|
30
|
+
* near the post-failure refresh cooldowns and settles at one attempt a minute,
|
|
31
|
+
* which also respects the mint's own 60s rate-limit cooldown.
|
|
32
|
+
*/
|
|
33
|
+
export declare const TOKEN_RECOVERY_BACKOFF_MS: readonly number[];
|
|
34
|
+
/** Attempts through a key-based lane once the device credential is gone. */
|
|
35
|
+
export declare const MAX_KEYED_RECOVERY_ATTEMPTS = 3;
|
|
36
|
+
export declare function createTokenLossRecovery(deps: TokenLossRecoveryDeps): TokenLossRecovery;
|
|
37
|
+
//# sourceMappingURL=tokenLossRecovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenLossRecovery.d.ts","sourceRoot":"","sources":["../../../../../src/ui/session/tokenLossRecovery.ts"],"names":[],"mappings":"AA+BA,MAAM,WAAW,qBAAqB;IACpC,8EAA8E;IAC9E,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACrC,8EAA8E;IAC9E,mBAAmB,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C;;;OAGG;IACH,gBAAgB,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,mDAAmD;IACnD,UAAU,EAAE,MAAM,OAAO,CAAC;IAC1B,6CAA6C;IAC7C,QAAQ,EAAE,MAAM,OAAO,CAAC;IACxB,wEAAwE;IACxE,cAAc,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,4BAA4B;IAC5B,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,iBAAiB;IAChC,8DAA8D;IAC9D,KAAK,IAAI,IAAI,CAAC;IACd,uDAAuD;IACvD,OAAO,IAAI,IAAI,CAAC;IAChB,qCAAqC;IACrC,YAAY,IAAI,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,SAAS,MAAM,EAAmD,CAAC;AAE3G,4EAA4E;AAC5E,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAS7C,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,qBAAqB,GAAG,iBAAiB,CAoEtF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/services",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.4",
|
|
4
4
|
"description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"@commitlint/cli": "^21.2.1",
|
|
165
165
|
"@commitlint/config-conventional": "^21.2.0",
|
|
166
166
|
"@expo/vector-icons": "^15.0.3",
|
|
167
|
-
"@oxy.so/core": "1.
|
|
167
|
+
"@oxy.so/core": "1.10.0",
|
|
168
168
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
169
169
|
"@react-native-community/netinfo": "^12.0.1",
|
|
170
170
|
"@react-native/babel-preset": "^0.86.2",
|
|
@@ -216,7 +216,7 @@
|
|
|
216
216
|
"peerDependencies": {
|
|
217
217
|
"@expo/vector-icons": "^15.0.3",
|
|
218
218
|
"@oxy.so/bloom": "^4.21.1",
|
|
219
|
-
"@oxy.so/core": "^1.9.
|
|
219
|
+
"@oxy.so/core": "^1.9.1",
|
|
220
220
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
221
221
|
"@react-native-community/netinfo": ">=11.4.1",
|
|
222
222
|
"@tanstack/query-async-storage-persister": "^5.101",
|
|
@@ -45,7 +45,8 @@ import {
|
|
|
45
45
|
import { isWebBrowser } from '../utils/isWebBrowser';
|
|
46
46
|
import { resolveDeliveryPlatform } from '../utils/deliveryPlatform';
|
|
47
47
|
import { runProviderColdBoot } from '../boot/runProviderColdBoot';
|
|
48
|
-
import { loadPersistedDeviceCredential } from '../utils/deviceCredential';
|
|
48
|
+
import { hasPersistedDeviceCredential, loadPersistedDeviceCredential } from '../utils/deviceCredential';
|
|
49
|
+
import { createTokenLossRecovery } from '../session/tokenLossRecovery';
|
|
49
50
|
import { bindAuthStoreToRuntime } from '../stores/authStore';
|
|
50
51
|
import { useLanguageManagement } from '../hooks/useLanguageManagement';
|
|
51
52
|
import { useSessionManagement } from '../hooks/useSessionManagement';
|
|
@@ -543,14 +544,52 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
543
544
|
});
|
|
544
545
|
|
|
545
546
|
|
|
546
|
-
// Token-change side effects
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
547
|
+
// Token-change side effects. HttpService clears the bearer and emits `null`
|
|
548
|
+
// when a request draws a 401 its refresh could not answer — which happens on a
|
|
549
|
+
// transient failure (network, a cooling-down or rate-limited mint) as well as
|
|
550
|
+
// on a real revocation. A signed-in user is therefore NOT signed out on the
|
|
551
|
+
// spot: `tokenLossRecovery` keeps the session while the durable device
|
|
552
|
+
// credential survives and re-mints it, the way a relaunch would, and signs out
|
|
553
|
+
// only once the refresh handler has dropped that credential on a definitive
|
|
554
|
+
// server verdict (OxyHQ/Mention#1140). Until a token is back, `tokenReady` is
|
|
555
|
+
// false, so private queries wait instead of 401-ing.
|
|
552
556
|
const clearingInvalidTokenRef = useRef(false);
|
|
553
557
|
useEffect(() => {
|
|
558
|
+
const signOutLocally = async (): Promise<void> => {
|
|
559
|
+
if (clearingInvalidTokenRef.current) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
clearingInvalidTokenRef.current = true;
|
|
563
|
+
try {
|
|
564
|
+
await clearSessionStateRef.current();
|
|
565
|
+
} catch (clearError) {
|
|
566
|
+
logger('Failed to clear invalidated auth session', clearError);
|
|
567
|
+
} finally {
|
|
568
|
+
clearingInvalidTokenRef.current = false;
|
|
569
|
+
if (runtime.getSnapshot().authResolved) {
|
|
570
|
+
runtime.setTokenReady(true);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
const recovery = createTokenLossRecovery({
|
|
575
|
+
remint: () => oxyServices.httpService.refreshAccessToken('preflight'),
|
|
576
|
+
hasDeviceCredential: () => hasPersistedDeviceCredential(authStore),
|
|
577
|
+
hasKeyedRecovery: async () => {
|
|
578
|
+
if (Platform.OS === 'web') {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
// The same keys the refresh handler's second arm signs with: the
|
|
583
|
+
// identity-bound client's own key, or the cross-app shared identity.
|
|
584
|
+
return identity ? await KeyManager.hasIdentity() : await KeyManager.hasSharedIdentity();
|
|
585
|
+
} catch {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
isSignedIn: () => runtime.getSnapshot().account !== null,
|
|
590
|
+
hasToken: () => Boolean(oxyServices.getAccessToken()),
|
|
591
|
+
signOutLocally,
|
|
592
|
+
});
|
|
554
593
|
const handleTokenChange = (accessToken: string | null) => {
|
|
555
594
|
runtime.setHasAccessToken(Boolean(accessToken));
|
|
556
595
|
if (accessToken) {
|
|
@@ -562,20 +601,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
562
601
|
}
|
|
563
602
|
if (runtime.getSnapshot().account !== null) {
|
|
564
603
|
runtime.setTokenReady(false);
|
|
565
|
-
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
clearingInvalidTokenRef.current = true;
|
|
569
|
-
clearSessionStateRef.current()
|
|
570
|
-
.catch((clearError) => {
|
|
571
|
-
logger('Failed to clear invalidated auth session', clearError);
|
|
572
|
-
})
|
|
573
|
-
.finally(() => {
|
|
574
|
-
clearingInvalidTokenRef.current = false;
|
|
575
|
-
if (runtime.getSnapshot().authResolved) {
|
|
576
|
-
runtime.setTokenReady(true);
|
|
577
|
-
}
|
|
578
|
-
});
|
|
604
|
+
recovery.start();
|
|
579
605
|
return;
|
|
580
606
|
}
|
|
581
607
|
if (runtime.getSnapshot().authResolved) {
|
|
@@ -583,8 +609,12 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
583
609
|
}
|
|
584
610
|
};
|
|
585
611
|
handleTokenChange(oxyServices.getAccessToken());
|
|
586
|
-
|
|
587
|
-
|
|
612
|
+
const unsubscribe = oxyServices.onTokensChanged(handleTokenChange);
|
|
613
|
+
return () => {
|
|
614
|
+
unsubscribe();
|
|
615
|
+
recovery.dispose();
|
|
616
|
+
};
|
|
617
|
+
}, [logger, oxyServices, runtime, sessionClient, authStore, identity]);
|
|
588
618
|
|
|
589
619
|
// Unified in-session refresh (SDK-owned; every RP inherits it). Installs the
|
|
590
620
|
// ONE core refresh handler (re-mint from the persisted zero-cookie device
|
|
@@ -107,7 +107,9 @@ export interface OxyRuntime {
|
|
|
107
107
|
/**
|
|
108
108
|
* Project the client's current device state onto the snapshot. Idempotent and
|
|
109
109
|
* revision-guarded: a projection whose profile fetch was overtaken by a
|
|
110
|
-
* fresher state discards itself rather than publishing a stale subject.
|
|
110
|
+
* fresher state discards itself rather than publishing a stale subject. It is
|
|
111
|
+
* also teardown-guarded: one started before {@link clearSession} publishes
|
|
112
|
+
* nothing, whatever `SessionClient` still holds.
|
|
111
113
|
*/
|
|
112
114
|
reconcileFromClient(): Promise<void>;
|
|
113
115
|
|
|
@@ -166,7 +168,10 @@ export interface OxyRuntime {
|
|
|
166
168
|
mergeSessions(incoming: ClientSession[], options?: { merge?: boolean; preserveSessionIds?: string[] }): void;
|
|
167
169
|
setActiveSessionId(sessionId: string | null): void;
|
|
168
170
|
|
|
169
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Local teardown: drop the session projection without touching the server.
|
|
173
|
+
* Every projection already in flight is abandoned.
|
|
174
|
+
*/
|
|
170
175
|
clearSession(): void;
|
|
171
176
|
}
|
|
172
177
|
|
|
@@ -268,6 +273,14 @@ export function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime {
|
|
|
268
273
|
let dirty = false;
|
|
269
274
|
/** The subject as it stood when the OUTERMOST transition opened. */
|
|
270
275
|
let subjectAtEntry: string | null = null;
|
|
276
|
+
/**
|
|
277
|
+
* Bumped by every local teardown. A projection captures it before its first
|
|
278
|
+
* await and discards itself if it moved: the revision guard alone cannot see
|
|
279
|
+
* a sign-out, because a local teardown (the token-null lane, a `logoutAll`
|
|
280
|
+
* whose device call already failed) leaves `SessionClient` holding the SAME
|
|
281
|
+
* state, at the same revision, that the projection was started for.
|
|
282
|
+
*/
|
|
283
|
+
let sessionGeneration = 0;
|
|
271
284
|
|
|
272
285
|
function buildSnapshot(): OxyRuntimeSnapshot {
|
|
273
286
|
const activeContext = resolveActiveContext(facts.directory);
|
|
@@ -401,6 +414,7 @@ export function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime {
|
|
|
401
414
|
// profile fetch if a fresher state has landed — last-writer-wins, so two
|
|
402
415
|
// overlapping projections can never publish in the wrong order.
|
|
403
416
|
const capturedRevision = state.revision;
|
|
417
|
+
const capturedGeneration = sessionGeneration;
|
|
404
418
|
// Resolve the pin BEFORE the fetch (memoised: one storage + keychain read
|
|
405
419
|
// per boot) so the first pass over a freshly applied state is already bound.
|
|
406
420
|
// Deferring it would let a sibling app's switch render once as this
|
|
@@ -414,6 +428,12 @@ export function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime {
|
|
|
414
428
|
logger('Failed to resolve account profiles during the device projection', fetchError);
|
|
415
429
|
return;
|
|
416
430
|
}
|
|
431
|
+
if (capturedGeneration !== sessionGeneration) {
|
|
432
|
+
// Signed out while the profiles were in flight. Committing now would put
|
|
433
|
+
// the account back on a device with no bearer — the "Hi <old user>"
|
|
434
|
+
// greeting to a signed-out visitor.
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
417
437
|
const latest = sessionClient.getState();
|
|
418
438
|
if (!latest || latest.revision !== capturedRevision) {
|
|
419
439
|
return;
|
|
@@ -608,6 +628,7 @@ export function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime {
|
|
|
608
628
|
},
|
|
609
629
|
|
|
610
630
|
clearSession() {
|
|
631
|
+
sessionGeneration += 1;
|
|
611
632
|
transition(() => {
|
|
612
633
|
setSessions(EMPTY_SESSIONS);
|
|
613
634
|
setActiveSessionIdFact(null);
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lost access token is a question, not a sign-out (OxyHQ/Mention#1140).
|
|
3
|
+
*
|
|
4
|
+
* The provider used to sign the user out on ANY cleared bearer. HttpService
|
|
5
|
+
* clears it whenever a 401's refresh comes back empty — including when the mint
|
|
6
|
+
* is merely cooling down after a 429 or the network blipped — and with no token
|
|
7
|
+
* the refresh scheduler stops, so the session never came back until a relaunch
|
|
8
|
+
* re-minted from the device credential that had been intact the whole time.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
createTokenLossRecovery,
|
|
12
|
+
MAX_KEYED_RECOVERY_ATTEMPTS,
|
|
13
|
+
TOKEN_RECOVERY_BACKOFF_MS,
|
|
14
|
+
type TokenLossRecoveryDeps,
|
|
15
|
+
} from '../tokenLossRecovery';
|
|
16
|
+
|
|
17
|
+
interface Harness {
|
|
18
|
+
deps: TokenLossRecoveryDeps;
|
|
19
|
+
state: { signedIn: boolean; token: string | null; credential: boolean; keyed: boolean };
|
|
20
|
+
remint: jest.Mock<Promise<string | null>, []>;
|
|
21
|
+
signOutLocally: jest.Mock<Promise<void>, []>;
|
|
22
|
+
sleeps: number[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function harness(remintAnswers: Array<string | null | 'drop-credential'>): Harness {
|
|
26
|
+
const state = { signedIn: true, token: null as string | null, credential: true, keyed: false };
|
|
27
|
+
const sleeps: number[] = [];
|
|
28
|
+
const remint = jest.fn(async (): Promise<string | null> => {
|
|
29
|
+
const answer = remintAnswers.length > 0 ? remintAnswers.shift() : null;
|
|
30
|
+
if (answer === 'drop-credential') {
|
|
31
|
+
// What the refresh handler does on `invalid_device_secret` /
|
|
32
|
+
// `no_active_session`: the credential goes, and the mint returns nothing.
|
|
33
|
+
state.credential = false;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (answer) {
|
|
37
|
+
state.token = answer;
|
|
38
|
+
}
|
|
39
|
+
return answer ?? null;
|
|
40
|
+
});
|
|
41
|
+
const signOutLocally = jest.fn(async () => {
|
|
42
|
+
state.signedIn = false;
|
|
43
|
+
});
|
|
44
|
+
const deps: TokenLossRecoveryDeps = {
|
|
45
|
+
remint,
|
|
46
|
+
hasDeviceCredential: async () => state.credential,
|
|
47
|
+
hasKeyedRecovery: async () => state.keyed,
|
|
48
|
+
isSignedIn: () => state.signedIn,
|
|
49
|
+
hasToken: () => state.token !== null,
|
|
50
|
+
signOutLocally,
|
|
51
|
+
sleep: async (ms) => {
|
|
52
|
+
sleeps.push(ms);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return { deps, state, remint, signOutLocally, sleeps };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runToCompletion(recovery: ReturnType<typeof createTokenLossRecovery>): Promise<void> {
|
|
59
|
+
for (let i = 0; i < 200 && recovery.isRecovering(); i += 1) {
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
61
|
+
}
|
|
62
|
+
expect(recovery.isRecovering()).toBe(false);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('token loss recovery', () => {
|
|
66
|
+
it('keeps a user with an intact device credential signed in through transient failures, then restores the token', async () => {
|
|
67
|
+
// The incident: the mint is rate limited / cooling down for a while, then
|
|
68
|
+
// succeeds — exactly what a relaunch a few minutes later did.
|
|
69
|
+
const h = harness([null, null, null, 'fresh']);
|
|
70
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
71
|
+
|
|
72
|
+
recovery.start();
|
|
73
|
+
await runToCompletion(recovery);
|
|
74
|
+
|
|
75
|
+
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
76
|
+
expect(h.state.token).toBe('fresh');
|
|
77
|
+
expect(h.remint).toHaveBeenCalledTimes(4);
|
|
78
|
+
expect(h.sleeps).toEqual(TOKEN_RECOVERY_BACKOFF_MS.slice(0, 3));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('signs out once a re-mint gets a definitive verdict and no key can re-establish the session', async () => {
|
|
82
|
+
const h = harness([null, 'drop-credential']);
|
|
83
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
84
|
+
|
|
85
|
+
recovery.start();
|
|
86
|
+
await runToCompletion(recovery);
|
|
87
|
+
|
|
88
|
+
expect(h.signOutLocally).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(h.remint).toHaveBeenCalledTimes(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('signs out at once when the credential was already gone and there is no key-based lane (the old behaviour for a real revocation)', async () => {
|
|
93
|
+
const h = harness([]);
|
|
94
|
+
h.state.credential = false;
|
|
95
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
96
|
+
|
|
97
|
+
recovery.start();
|
|
98
|
+
await runToCompletion(recovery);
|
|
99
|
+
|
|
100
|
+
expect(h.remint).not.toHaveBeenCalled();
|
|
101
|
+
expect(h.signOutLocally).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('re-establishes through the shared identity when the credential is gone, like the cold boot does', async () => {
|
|
105
|
+
const h = harness([null, 'from-shared-key']);
|
|
106
|
+
h.state.credential = false;
|
|
107
|
+
h.state.keyed = true;
|
|
108
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
109
|
+
|
|
110
|
+
recovery.start();
|
|
111
|
+
await runToCompletion(recovery);
|
|
112
|
+
|
|
113
|
+
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
114
|
+
expect(h.state.token).toBe('from-shared-key');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('gives the key-based lane a bounded number of tries, then signs out', async () => {
|
|
118
|
+
const h = harness([]);
|
|
119
|
+
h.state.credential = false;
|
|
120
|
+
h.state.keyed = true;
|
|
121
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
122
|
+
|
|
123
|
+
recovery.start();
|
|
124
|
+
await runToCompletion(recovery);
|
|
125
|
+
|
|
126
|
+
expect(h.remint).toHaveBeenCalledTimes(MAX_KEYED_RECOVERY_ATTEMPTS);
|
|
127
|
+
expect(h.signOutLocally).toHaveBeenCalledTimes(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('stops without touching the session when the user signs out meanwhile', async () => {
|
|
131
|
+
const h = harness([]);
|
|
132
|
+
h.remint.mockImplementation(async () => {
|
|
133
|
+
h.state.signedIn = false;
|
|
134
|
+
return null;
|
|
135
|
+
});
|
|
136
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
137
|
+
|
|
138
|
+
recovery.start();
|
|
139
|
+
await runToCompletion(recovery);
|
|
140
|
+
|
|
141
|
+
expect(h.remint).toHaveBeenCalledTimes(1);
|
|
142
|
+
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('stops when another lane planted a token first', async () => {
|
|
146
|
+
const h = harness([]);
|
|
147
|
+
h.remint.mockImplementation(async () => {
|
|
148
|
+
h.state.token = 'planted-by-a-sign-in';
|
|
149
|
+
return null;
|
|
150
|
+
});
|
|
151
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
152
|
+
|
|
153
|
+
recovery.start();
|
|
154
|
+
await runToCompletion(recovery);
|
|
155
|
+
|
|
156
|
+
expect(h.remint).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('runs one recovery at a time', async () => {
|
|
161
|
+
const h = harness([null, 'fresh']);
|
|
162
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
163
|
+
|
|
164
|
+
recovery.start();
|
|
165
|
+
recovery.start();
|
|
166
|
+
await runToCompletion(recovery);
|
|
167
|
+
|
|
168
|
+
expect(h.remint).toHaveBeenCalledTimes(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('signs out rather than strand a signed-in UI with no bearer when recovery itself throws', async () => {
|
|
172
|
+
const h = harness([]);
|
|
173
|
+
h.deps.hasDeviceCredential = async () => {
|
|
174
|
+
throw new Error('store exploded');
|
|
175
|
+
};
|
|
176
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
177
|
+
|
|
178
|
+
recovery.start();
|
|
179
|
+
await runToCompletion(recovery);
|
|
180
|
+
|
|
181
|
+
expect(h.signOutLocally).toHaveBeenCalledTimes(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('does nothing after dispose', async () => {
|
|
185
|
+
const h = harness([]);
|
|
186
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
187
|
+
|
|
188
|
+
recovery.dispose();
|
|
189
|
+
recovery.start();
|
|
190
|
+
await runToCompletion(recovery);
|
|
191
|
+
|
|
192
|
+
expect(h.remint).not.toHaveBeenCalled();
|
|
193
|
+
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a signed-in app does when its access token disappears at runtime.
|
|
3
|
+
*
|
|
4
|
+
* `HttpService` clears the bearer when a request draws a 401 and the refresh it
|
|
5
|
+
* then tries returns nothing. That refresh can return nothing for reasons that
|
|
6
|
+
* say NOTHING about the session: the mint is cooling down after a failure or a
|
|
7
|
+
* 429, the network dropped, the server was restarting. The provider used to read
|
|
8
|
+
* every such clear as a sign-out and tore the session down on the spot — and
|
|
9
|
+
* because the proactive refresh scheduler only runs while a token exists, nothing
|
|
10
|
+
* was left to bring the session back. Only a relaunch did, because the cold boot
|
|
11
|
+
* mints from the durable device credential that had been there all along
|
|
12
|
+
* (OxyHQ/Mention#1140).
|
|
13
|
+
*
|
|
14
|
+
* So a lost token is a question, and this answers it the way a relaunch would:
|
|
15
|
+
*
|
|
16
|
+
* - The durable store still holds the device credential → the session is NOT
|
|
17
|
+
* known to be over. Keep the user signed in (private queries pause, because
|
|
18
|
+
* there is no bearer) and re-mint with backoff until one succeeds. A
|
|
19
|
+
* definitive server verdict (`invalid_device_secret`, `no_active_session`)
|
|
20
|
+
* makes the refresh handler drop or clear that credential, which is what ends
|
|
21
|
+
* this loop in a sign-out — so a real revocation still signs out.
|
|
22
|
+
* - No credential, but a key-based lane can still re-establish (the native
|
|
23
|
+
* shared identity, or an identity-bound client's own key) → try that a few
|
|
24
|
+
* times, as the cold boot's shared-key step would, then sign out.
|
|
25
|
+
* - Neither → the session is over. Sign out now, as before.
|
|
26
|
+
*
|
|
27
|
+
* Framework-free and clock-injectable so the policy is unit tested without a
|
|
28
|
+
* provider.
|
|
29
|
+
*/
|
|
30
|
+
import { logger } from '@oxy.so/core';
|
|
31
|
+
|
|
32
|
+
export interface TokenLossRecoveryDeps {
|
|
33
|
+
/** Re-mint once through the installed refresh handler; the token, or null. */
|
|
34
|
+
remint: () => Promise<string | null>;
|
|
35
|
+
/** Whether the durable auth store still holds `deviceId` + `deviceSecret`. */
|
|
36
|
+
hasDeviceCredential: () => Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a key-based lane could still re-establish the session without a
|
|
39
|
+
* device secret. Consulted only once the credential is gone.
|
|
40
|
+
*/
|
|
41
|
+
hasKeyedRecovery: () => Promise<boolean>;
|
|
42
|
+
/** Whether the UI still shows a signed-in user. */
|
|
43
|
+
isSignedIn: () => boolean;
|
|
44
|
+
/** Whether a bearer is planted right now. */
|
|
45
|
+
hasToken: () => boolean;
|
|
46
|
+
/** Tear down the local session (the provider's `clearSessionState`). */
|
|
47
|
+
signOutLocally: () => Promise<void>;
|
|
48
|
+
/** Timer seam for tests. */
|
|
49
|
+
sleep?: (ms: number) => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TokenLossRecovery {
|
|
53
|
+
/** Start recovering, unless a recovery is already running. */
|
|
54
|
+
start(): void;
|
|
55
|
+
/** Abandon any running recovery (provider unmount). */
|
|
56
|
+
dispose(): void;
|
|
57
|
+
/** Whether a recovery is running. */
|
|
58
|
+
isRecovering(): boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Delays between re-mint attempts while the device credential is intact. Starts
|
|
63
|
+
* near the post-failure refresh cooldowns and settles at one attempt a minute,
|
|
64
|
+
* which also respects the mint's own 60s rate-limit cooldown.
|
|
65
|
+
*/
|
|
66
|
+
export const TOKEN_RECOVERY_BACKOFF_MS: readonly number[] = [2_000, 5_000, 10_000, 20_000, 30_000, 60_000];
|
|
67
|
+
|
|
68
|
+
/** Attempts through a key-based lane once the device credential is gone. */
|
|
69
|
+
export const MAX_KEYED_RECOVERY_ATTEMPTS = 3;
|
|
70
|
+
|
|
71
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
72
|
+
new Promise((resolve) => {
|
|
73
|
+
setTimeout(resolve, ms);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const LOG_CONTEXT = { component: 'OxyContext', method: 'tokenLossRecovery' } as const;
|
|
77
|
+
|
|
78
|
+
export function createTokenLossRecovery(deps: TokenLossRecoveryDeps): TokenLossRecovery {
|
|
79
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
80
|
+
let running = false;
|
|
81
|
+
let disposed = false;
|
|
82
|
+
|
|
83
|
+
const settled = (): boolean => disposed || deps.hasToken() || !deps.isSignedIn();
|
|
84
|
+
|
|
85
|
+
const run = async (): Promise<void> => {
|
|
86
|
+
let attempt = 0;
|
|
87
|
+
let keyedAttempts = 0;
|
|
88
|
+
logger.warn('Access token lost while signed in — recovering the session instead of signing out', LOG_CONTEXT);
|
|
89
|
+
while (!settled()) {
|
|
90
|
+
if (!(await deps.hasDeviceCredential())) {
|
|
91
|
+
const keyed = keyedAttempts < MAX_KEYED_RECOVERY_ATTEMPTS && (await deps.hasKeyedRecovery());
|
|
92
|
+
if (!keyed) {
|
|
93
|
+
if (settled()) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
logger.warn('Session recovery ended: no device credential and no key-based lane left — signing out', {
|
|
97
|
+
...LOG_CONTEXT,
|
|
98
|
+
attempts: attempt,
|
|
99
|
+
keyedAttempts,
|
|
100
|
+
});
|
|
101
|
+
await deps.signOutLocally();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
keyedAttempts += 1;
|
|
105
|
+
}
|
|
106
|
+
if (settled()) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const token = await deps.remint().catch(() => null);
|
|
110
|
+
attempt += 1;
|
|
111
|
+
if (token || settled()) {
|
|
112
|
+
if (token) {
|
|
113
|
+
logger.warn('Session recovered after a lost access token', { ...LOG_CONTEXT, attempts: attempt });
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await sleep(TOKEN_RECOVERY_BACKOFF_MS[Math.min(attempt - 1, TOKEN_RECOVERY_BACKOFF_MS.length - 1)]);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
start(): void {
|
|
123
|
+
if (running || disposed) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
running = true;
|
|
127
|
+
void run()
|
|
128
|
+
.catch(async (error: unknown) => {
|
|
129
|
+
// Never leave a signed-in UI with no bearer and nothing recovering it.
|
|
130
|
+
logger.warn('Session recovery failed unexpectedly — signing out', LOG_CONTEXT, error);
|
|
131
|
+
if (!settled()) {
|
|
132
|
+
await deps.signOutLocally().catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.finally(() => {
|
|
136
|
+
running = false;
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
dispose(): void {
|
|
140
|
+
disposed = true;
|
|
141
|
+
},
|
|
142
|
+
isRecovering(): boolean {
|
|
143
|
+
return running;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|