@oxy.so/services 4.0.2 → 4.0.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 +52 -20
- package/lib/commonjs/ui/context/OxyContext.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/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/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/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/session/__tests__/tokenLossRecovery.test.ts +195 -0
- package/src/ui/session/tokenLossRecovery.ts +146 -0
|
@@ -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
|
+
}
|