@syncular/client-plugin-offline-auth 0.0.0
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/README.md +83 -0
- package/package.json +57 -0
- package/src/index.test.ts +438 -0
- package/src/index.ts +1126 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @syncular/client-plugin-offline-auth
|
|
2
|
+
|
|
3
|
+
Provider-agnostic offline auth primitives for JavaScript runtimes.
|
|
4
|
+
|
|
5
|
+
This package intentionally does **not** implement OAuth/provider flows. It gives you reusable building blocks for:
|
|
6
|
+
|
|
7
|
+
- Offline session + identity cache shape
|
|
8
|
+
- JWT/expiry checks
|
|
9
|
+
- Offline subject resolution (online session -> offline identity -> last actor)
|
|
10
|
+
- Bearer token/auth lifecycle bridge for Syncular transports
|
|
11
|
+
- Local lock policy state machine (framework-agnostic)
|
|
12
|
+
|
|
13
|
+
Behavior defaults are fail-closed:
|
|
14
|
+
|
|
15
|
+
- Calling `persistOnlineSession({ session: null, ... })` clears cached session, cached offline identity, and last actor.
|
|
16
|
+
- Lock APIs expose `forceUnlock()` for explicit unlock bypass and `attemptUnlock(verify)` / `attemptUnlockAsync(verify)` for app-managed PIN/passcode checks.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @syncular/client-plugin-offline-auth
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## JavaScript primitives
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import {
|
|
28
|
+
createMemoryStorageAdapter,
|
|
29
|
+
loadOfflineAuthState,
|
|
30
|
+
persistOnlineSession,
|
|
31
|
+
resolveOfflineAuthSubject,
|
|
32
|
+
} from '@syncular/client-plugin-offline-auth';
|
|
33
|
+
|
|
34
|
+
const storage = createMemoryStorageAdapter();
|
|
35
|
+
const state = await loadOfflineAuthState({
|
|
36
|
+
storage,
|
|
37
|
+
codec: {
|
|
38
|
+
parseSession: (value) => (typeof value === 'object' && value ? value : null),
|
|
39
|
+
parseIdentity: (value) =>
|
|
40
|
+
typeof value === 'object' && value && 'actorId' in value ? value : null,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const next = persistOnlineSession({
|
|
45
|
+
state,
|
|
46
|
+
session,
|
|
47
|
+
getSessionActorId: (s) => s.user.id,
|
|
48
|
+
getExpiresAtMs: (s) => s.expiresAtMs,
|
|
49
|
+
deriveIdentity: (s) => ({ actorId: s.user.id, teamId: s.teamId }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const subject = resolveOfflineAuthSubject({
|
|
53
|
+
state: next,
|
|
54
|
+
getSessionActorId: (s) => s.user.id,
|
|
55
|
+
getSessionTeamId: (s) => s.teamId,
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Transport token/auth lifecycle bridge
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { createTokenLifecycleBridge } from '@syncular/client-plugin-offline-auth';
|
|
63
|
+
|
|
64
|
+
const tokenBridge = createTokenLifecycleBridge({
|
|
65
|
+
resolveToken: async () => authState.state.session?.value.sessionJwt ?? null,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const transport = createWebSocketTransport({
|
|
69
|
+
baseUrl: '/api',
|
|
70
|
+
getHeaders: tokenBridge.getAuthorizationHeaders,
|
|
71
|
+
getRealtimeParams: tokenBridge.getRealtimeParams,
|
|
72
|
+
authLifecycle: tokenBridge.authLifecycle,
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
For React hooks, install and import from `@syncular/client-plugin-offline-auth-react`.
|
|
77
|
+
|
|
78
|
+
## Links
|
|
79
|
+
|
|
80
|
+
- GitHub: https://github.com/syncular/syncular
|
|
81
|
+
- Issues: https://github.com/syncular/syncular/issues
|
|
82
|
+
|
|
83
|
+
> Status: Alpha. APIs and storage layouts may change between releases.
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@syncular/client-plugin-offline-auth",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Provider-agnostic offline auth primitives for Syncular clients (JS runtimes)",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Benjamin Kniffler",
|
|
7
|
+
"homepage": "https://syncular.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/syncular/syncular.git",
|
|
11
|
+
"directory": "plugins/offline-auth/client"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/syncular/syncular/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"sync",
|
|
18
|
+
"offline-first",
|
|
19
|
+
"realtime",
|
|
20
|
+
"database",
|
|
21
|
+
"typescript",
|
|
22
|
+
"auth",
|
|
23
|
+
"offline-auth",
|
|
24
|
+
"client-plugin"
|
|
25
|
+
],
|
|
26
|
+
"private": false,
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"bun": "./src/index.ts",
|
|
34
|
+
"browser": "./src/index.ts",
|
|
35
|
+
"import": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"default": "./dist/index.js"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "bun test src",
|
|
43
|
+
"tsgo": "tsgo --noEmit",
|
|
44
|
+
"build": "tsgo",
|
|
45
|
+
"release": "bunx syncular-publish"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@syncular/core": "0.0.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@syncular/config": "0.0.0"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"dist",
|
|
55
|
+
"src"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
applyOfflineLockEvent,
|
|
4
|
+
attemptOfflineUnlock,
|
|
5
|
+
attemptOfflineUnlockAsync,
|
|
6
|
+
createMemoryStorageAdapter,
|
|
7
|
+
createSessionCacheEntry,
|
|
8
|
+
createTokenLifecycleBridge,
|
|
9
|
+
getJwtExpiryMs,
|
|
10
|
+
loadOfflineAuthState,
|
|
11
|
+
type OfflineAuthState,
|
|
12
|
+
type OfflineAuthStateCodec,
|
|
13
|
+
type OfflineSubjectIdentity,
|
|
14
|
+
persistOfflineIdentity,
|
|
15
|
+
persistOnlineSession,
|
|
16
|
+
resolveOfflineAuthSubject,
|
|
17
|
+
saveOfflineAuthState,
|
|
18
|
+
} from './index';
|
|
19
|
+
|
|
20
|
+
type DemoSession = {
|
|
21
|
+
actorId: string;
|
|
22
|
+
teamId: string | null;
|
|
23
|
+
token: string;
|
|
24
|
+
expiresAtMs: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type DemoIdentity = OfflineSubjectIdentity & {
|
|
28
|
+
email: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readString(value: unknown): string | null {
|
|
36
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readNullableString(value: unknown): string | null {
|
|
40
|
+
if (value === null) return null;
|
|
41
|
+
return readString(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readFiniteNumber(value: unknown): number | null {
|
|
45
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const demoCodec: OfflineAuthStateCodec<DemoSession, DemoIdentity> = {
|
|
50
|
+
parseSession(value) {
|
|
51
|
+
if (!isRecord(value)) return null;
|
|
52
|
+
const actorId = readString(value.actorId);
|
|
53
|
+
const teamId = readNullableString(value.teamId);
|
|
54
|
+
const token = readString(value.token);
|
|
55
|
+
const expiresAtMs = readFiniteNumber(value.expiresAtMs);
|
|
56
|
+
|
|
57
|
+
if (!actorId || teamId === undefined || !token || expiresAtMs === null) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
actorId,
|
|
63
|
+
teamId,
|
|
64
|
+
token,
|
|
65
|
+
expiresAtMs,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
parseIdentity(value) {
|
|
69
|
+
if (!isRecord(value)) return null;
|
|
70
|
+
const actorId = readString(value.actorId);
|
|
71
|
+
const teamId = readNullableString(value.teamId);
|
|
72
|
+
const email = readString(value.email);
|
|
73
|
+
|
|
74
|
+
if (!actorId || teamId === undefined || !email) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
actorId,
|
|
80
|
+
teamId,
|
|
81
|
+
email,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function encodeJwtWithExp(expSeconds: number): string {
|
|
87
|
+
const header = Buffer.from(
|
|
88
|
+
JSON.stringify({ alg: 'none', typ: 'JWT' })
|
|
89
|
+
).toString('base64url');
|
|
90
|
+
const payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString(
|
|
91
|
+
'base64url'
|
|
92
|
+
);
|
|
93
|
+
return `${header}.${payload}.`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('@syncular/client-plugin-offline-auth', () => {
|
|
97
|
+
it('extracts JWT exp timestamp from token payload', () => {
|
|
98
|
+
const expirySeconds = Math.floor(Date.now() / 1000) + 3600;
|
|
99
|
+
const token = encodeJwtWithExp(expirySeconds);
|
|
100
|
+
|
|
101
|
+
expect(getJwtExpiryMs(token)).toBe(expirySeconds * 1000);
|
|
102
|
+
expect(getJwtExpiryMs('not-a-jwt')).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('builds session cache entries from explicit expiry and rejects expired sessions', () => {
|
|
106
|
+
const nowMs = 1_700_000_000_000;
|
|
107
|
+
const fresh = createSessionCacheEntry(
|
|
108
|
+
{
|
|
109
|
+
actorId: 'u1',
|
|
110
|
+
teamId: 't1',
|
|
111
|
+
token: 'token',
|
|
112
|
+
expiresAtMs: nowMs + 10_000,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
nowMs,
|
|
116
|
+
skewMs: 500,
|
|
117
|
+
getExpiresAtMs: (session) => session.expiresAtMs,
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const expired = createSessionCacheEntry(
|
|
122
|
+
{
|
|
123
|
+
actorId: 'u1',
|
|
124
|
+
teamId: 't1',
|
|
125
|
+
token: 'token',
|
|
126
|
+
expiresAtMs: nowMs + 200,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
nowMs,
|
|
130
|
+
skewMs: 500,
|
|
131
|
+
getExpiresAtMs: (session) => session.expiresAtMs,
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(fresh?.expiresAtMs).toBe(nowMs + 10_000);
|
|
136
|
+
expect(expired).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('persists and reloads typed offline auth state', async () => {
|
|
140
|
+
const storage = createMemoryStorageAdapter();
|
|
141
|
+
const nowMs = 1_700_000_000_000;
|
|
142
|
+
|
|
143
|
+
let state: OfflineAuthState<DemoSession, DemoIdentity> = {
|
|
144
|
+
version: 1,
|
|
145
|
+
session: null,
|
|
146
|
+
identity: null,
|
|
147
|
+
lastActorId: null,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
state = persistOnlineSession({
|
|
151
|
+
state,
|
|
152
|
+
session: {
|
|
153
|
+
actorId: 'user-1',
|
|
154
|
+
teamId: 'team-1',
|
|
155
|
+
token: 'jwt-1',
|
|
156
|
+
expiresAtMs: nowMs + 60_000,
|
|
157
|
+
},
|
|
158
|
+
nowMs,
|
|
159
|
+
getSessionActorId: (session) => session.actorId,
|
|
160
|
+
getExpiresAtMs: (session) => session.expiresAtMs,
|
|
161
|
+
deriveIdentity: (session) => ({
|
|
162
|
+
actorId: session.actorId,
|
|
163
|
+
teamId: session.teamId,
|
|
164
|
+
email: 'user-1@example.com',
|
|
165
|
+
}),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
state = persistOfflineIdentity({
|
|
169
|
+
state,
|
|
170
|
+
identity: {
|
|
171
|
+
actorId: 'user-1',
|
|
172
|
+
teamId: 'team-1',
|
|
173
|
+
email: 'user-1@example.com',
|
|
174
|
+
},
|
|
175
|
+
nowMs,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await saveOfflineAuthState(state, { storage });
|
|
179
|
+
|
|
180
|
+
const loaded = await loadOfflineAuthState({
|
|
181
|
+
storage,
|
|
182
|
+
codec: demoCodec,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(loaded.session?.value.actorId).toBe('user-1');
|
|
186
|
+
expect(loaded.identity?.value.email).toBe('user-1@example.com');
|
|
187
|
+
expect(loaded.lastActorId).toBe('user-1');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('resolves subject with precedence online session -> offline identity -> last actor', () => {
|
|
191
|
+
const nowMs = 1_700_000_000_000;
|
|
192
|
+
|
|
193
|
+
const onlineResolved = resolveOfflineAuthSubject({
|
|
194
|
+
state: {
|
|
195
|
+
version: 1,
|
|
196
|
+
session: {
|
|
197
|
+
value: {
|
|
198
|
+
actorId: 'online-user',
|
|
199
|
+
teamId: 'team-1',
|
|
200
|
+
token: 'jwt',
|
|
201
|
+
expiresAtMs: nowMs + 20_000,
|
|
202
|
+
},
|
|
203
|
+
savedAtMs: nowMs,
|
|
204
|
+
expiresAtMs: nowMs + 20_000,
|
|
205
|
+
},
|
|
206
|
+
identity: {
|
|
207
|
+
value: {
|
|
208
|
+
actorId: 'offline-user',
|
|
209
|
+
teamId: 'team-2',
|
|
210
|
+
email: 'offline@example.com',
|
|
211
|
+
},
|
|
212
|
+
savedAtMs: nowMs,
|
|
213
|
+
expiresAtMs: null,
|
|
214
|
+
},
|
|
215
|
+
lastActorId: 'legacy-user',
|
|
216
|
+
},
|
|
217
|
+
nowMs,
|
|
218
|
+
skewMs: 0,
|
|
219
|
+
getSessionActorId: (session) => session.actorId,
|
|
220
|
+
getSessionTeamId: (session) => session.teamId,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const offlineResolved = resolveOfflineAuthSubject({
|
|
224
|
+
state: {
|
|
225
|
+
version: 1,
|
|
226
|
+
session: {
|
|
227
|
+
value: {
|
|
228
|
+
actorId: 'online-user',
|
|
229
|
+
teamId: 'team-1',
|
|
230
|
+
token: 'jwt',
|
|
231
|
+
expiresAtMs: nowMs - 1,
|
|
232
|
+
},
|
|
233
|
+
savedAtMs: nowMs,
|
|
234
|
+
expiresAtMs: nowMs - 1,
|
|
235
|
+
},
|
|
236
|
+
identity: {
|
|
237
|
+
value: {
|
|
238
|
+
actorId: 'offline-user',
|
|
239
|
+
teamId: 'team-2',
|
|
240
|
+
email: 'offline@example.com',
|
|
241
|
+
},
|
|
242
|
+
savedAtMs: nowMs,
|
|
243
|
+
expiresAtMs: null,
|
|
244
|
+
},
|
|
245
|
+
lastActorId: 'legacy-user',
|
|
246
|
+
},
|
|
247
|
+
nowMs,
|
|
248
|
+
skewMs: 0,
|
|
249
|
+
getSessionActorId: (session) => session.actorId,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const fallbackResolved = resolveOfflineAuthSubject({
|
|
253
|
+
state: {
|
|
254
|
+
version: 1,
|
|
255
|
+
session: null,
|
|
256
|
+
identity: null,
|
|
257
|
+
lastActorId: 'legacy-user',
|
|
258
|
+
},
|
|
259
|
+
nowMs,
|
|
260
|
+
getSessionActorId: () => null,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(onlineResolved.source).toBe('online-session');
|
|
264
|
+
expect(onlineResolved.actorId).toBe('online-user');
|
|
265
|
+
expect(offlineResolved.source).toBe('offline-identity');
|
|
266
|
+
expect(offlineResolved.actorId).toBe('offline-user');
|
|
267
|
+
expect(fallbackResolved.source).toBe('last-actor');
|
|
268
|
+
expect(fallbackResolved.actorId).toBe('legacy-user');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('clears offline identity and last actor when online session is removed', () => {
|
|
272
|
+
const state: OfflineAuthState<DemoSession, DemoIdentity> = {
|
|
273
|
+
version: 1,
|
|
274
|
+
session: {
|
|
275
|
+
value: {
|
|
276
|
+
actorId: 'user-1',
|
|
277
|
+
teamId: 'team-1',
|
|
278
|
+
token: 'jwt',
|
|
279
|
+
expiresAtMs: 1_700_000_010_000,
|
|
280
|
+
},
|
|
281
|
+
savedAtMs: 1_700_000_000_000,
|
|
282
|
+
expiresAtMs: 1_700_000_010_000,
|
|
283
|
+
},
|
|
284
|
+
identity: {
|
|
285
|
+
value: {
|
|
286
|
+
actorId: 'user-1',
|
|
287
|
+
teamId: 'team-1',
|
|
288
|
+
email: 'user-1@example.com',
|
|
289
|
+
},
|
|
290
|
+
savedAtMs: 1_700_000_000_000,
|
|
291
|
+
expiresAtMs: null,
|
|
292
|
+
},
|
|
293
|
+
lastActorId: 'user-1',
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const cleared = persistOnlineSession({
|
|
297
|
+
state,
|
|
298
|
+
session: null,
|
|
299
|
+
getSessionActorId: (session) => session.actorId,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(cleared).toEqual({
|
|
303
|
+
version: 1,
|
|
304
|
+
session: null,
|
|
305
|
+
identity: null,
|
|
306
|
+
lastActorId: null,
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('creates token lifecycle bridge with single-flight refresh and custom retry', async () => {
|
|
311
|
+
let token = 'initial';
|
|
312
|
+
let refreshCalls = 0;
|
|
313
|
+
|
|
314
|
+
const bridge = createTokenLifecycleBridge({
|
|
315
|
+
resolveToken: async () => token,
|
|
316
|
+
refreshToken: async () => {
|
|
317
|
+
refreshCalls += 1;
|
|
318
|
+
token = refreshCalls === 1 ? 'refreshed' : token;
|
|
319
|
+
return token;
|
|
320
|
+
},
|
|
321
|
+
retryWithFreshToken: ({ refreshResult, previousToken, nextToken }) => {
|
|
322
|
+
return refreshResult && previousToken !== nextToken;
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const headersBefore = await bridge.getAuthorizationHeaders();
|
|
327
|
+
expect(headersBefore.Authorization).toBe('Bearer initial');
|
|
328
|
+
|
|
329
|
+
const refreshA = bridge.authLifecycle.refreshToken?.({
|
|
330
|
+
operation: 'sync',
|
|
331
|
+
status: 401,
|
|
332
|
+
});
|
|
333
|
+
const refreshB = bridge.authLifecycle.refreshToken?.({
|
|
334
|
+
operation: 'sync',
|
|
335
|
+
status: 401,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(await Promise.all([refreshA, refreshB])).toEqual([true, true]);
|
|
339
|
+
expect(refreshCalls).toBe(1);
|
|
340
|
+
|
|
341
|
+
const shouldRetry = await bridge.authLifecycle.retryWithFreshToken?.({
|
|
342
|
+
operation: 'sync',
|
|
343
|
+
status: 401,
|
|
344
|
+
refreshResult: true,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(shouldRetry).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('applies lock policy with failed attempts, cooldown, and idle locking', () => {
|
|
351
|
+
let nowMs = 1_700_000_000_000;
|
|
352
|
+
const options = {
|
|
353
|
+
now: () => nowMs,
|
|
354
|
+
maxFailedAttempts: 2,
|
|
355
|
+
cooldownMs: 10_000,
|
|
356
|
+
idleTimeoutMs: 5_000,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const state = {
|
|
360
|
+
isLocked: true,
|
|
361
|
+
failedAttempts: 0,
|
|
362
|
+
blockedUntilMs: null,
|
|
363
|
+
lastActivityAtMs: nowMs,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const firstFailure = attemptOfflineUnlock({
|
|
367
|
+
state,
|
|
368
|
+
verify: () => false,
|
|
369
|
+
options,
|
|
370
|
+
nowMs,
|
|
371
|
+
});
|
|
372
|
+
expect(firstFailure.ok).toBe(false);
|
|
373
|
+
expect(firstFailure.reason).toBe('rejected');
|
|
374
|
+
|
|
375
|
+
const secondFailure = attemptOfflineUnlock({
|
|
376
|
+
state: firstFailure.state,
|
|
377
|
+
verify: () => false,
|
|
378
|
+
options,
|
|
379
|
+
nowMs,
|
|
380
|
+
});
|
|
381
|
+
expect(secondFailure.ok).toBe(false);
|
|
382
|
+
expect(secondFailure.reason).toBe('blocked');
|
|
383
|
+
|
|
384
|
+
const blockedAttempt = attemptOfflineUnlock({
|
|
385
|
+
state: secondFailure.state,
|
|
386
|
+
verify: () => true,
|
|
387
|
+
options,
|
|
388
|
+
nowMs,
|
|
389
|
+
});
|
|
390
|
+
expect(blockedAttempt.ok).toBe(false);
|
|
391
|
+
expect(blockedAttempt.reason).toBe('blocked');
|
|
392
|
+
|
|
393
|
+
nowMs += 10_001;
|
|
394
|
+
const unlockAfterCooldown = attemptOfflineUnlock({
|
|
395
|
+
state: secondFailure.state,
|
|
396
|
+
verify: () => true,
|
|
397
|
+
options,
|
|
398
|
+
nowMs,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
expect(unlockAfterCooldown.ok).toBe(true);
|
|
402
|
+
expect(unlockAfterCooldown.state.isLocked).toBe(false);
|
|
403
|
+
|
|
404
|
+
const active = applyOfflineLockEvent(
|
|
405
|
+
unlockAfterCooldown.state,
|
|
406
|
+
{ type: 'activity', nowMs },
|
|
407
|
+
options
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const idleLocked = applyOfflineLockEvent(
|
|
411
|
+
active,
|
|
412
|
+
{ type: 'tick', nowMs: nowMs + 5_001 },
|
|
413
|
+
options
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
expect(idleLocked.isLocked).toBe(true);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('supports async unlock verification for app-managed PIN checks', async () => {
|
|
420
|
+
const nowMs = 1_700_000_000_000;
|
|
421
|
+
const state = {
|
|
422
|
+
isLocked: true,
|
|
423
|
+
failedAttempts: 0,
|
|
424
|
+
blockedUntilMs: null,
|
|
425
|
+
lastActivityAtMs: nowMs,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const result = await attemptOfflineUnlockAsync({
|
|
429
|
+
state,
|
|
430
|
+
verify: async () => true,
|
|
431
|
+
options: { now: () => nowMs },
|
|
432
|
+
nowMs,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(result.ok).toBe(true);
|
|
436
|
+
expect(result.state.isLocked).toBe(false);
|
|
437
|
+
});
|
|
438
|
+
});
|