@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/src/index.ts
ADDED
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SyncAuthErrorContext,
|
|
3
|
+
SyncAuthLifecycle,
|
|
4
|
+
SyncAuthRetryContext,
|
|
5
|
+
SyncIdentityBase,
|
|
6
|
+
} from '@syncular/core';
|
|
7
|
+
|
|
8
|
+
export type MaybePromise<T> = T | Promise<T>;
|
|
9
|
+
|
|
10
|
+
export interface OfflineAuthStorage {
|
|
11
|
+
getItem(key: string): MaybePromise<string | null>;
|
|
12
|
+
setItem(key: string, value: string): MaybePromise<void>;
|
|
13
|
+
removeItem(key: string): MaybePromise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OfflineSubjectIdentity extends SyncIdentityBase {
|
|
17
|
+
teamId?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OfflineAuthCachedValue<TValue> {
|
|
21
|
+
value: TValue;
|
|
22
|
+
savedAtMs: number;
|
|
23
|
+
expiresAtMs: number | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OfflineAuthState<
|
|
27
|
+
TSession,
|
|
28
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
29
|
+
> {
|
|
30
|
+
version: typeof OFFLINE_AUTH_STATE_VERSION;
|
|
31
|
+
session: OfflineAuthCachedValue<TSession> | null;
|
|
32
|
+
identity: OfflineAuthCachedValue<TIdentity> | null;
|
|
33
|
+
lastActorId: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OfflineAuthStateCodec<
|
|
37
|
+
TSession,
|
|
38
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
39
|
+
> {
|
|
40
|
+
parseSession(value: unknown): TSession | null;
|
|
41
|
+
parseIdentity(value: unknown): TIdentity | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LoadOfflineAuthStateOptions<
|
|
45
|
+
TSession,
|
|
46
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
47
|
+
> {
|
|
48
|
+
storage: OfflineAuthStorage;
|
|
49
|
+
codec: OfflineAuthStateCodec<TSession, TIdentity>;
|
|
50
|
+
storageKey?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SaveOfflineAuthStateOptions {
|
|
54
|
+
storage: OfflineAuthStorage;
|
|
55
|
+
storageKey?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CreateSessionCacheEntryOptions<TSession> {
|
|
59
|
+
nowMs?: number;
|
|
60
|
+
skewMs?: number;
|
|
61
|
+
allowMissingExpiry?: boolean;
|
|
62
|
+
getExpiresAtMs?: (session: TSession) => number | null | undefined;
|
|
63
|
+
getJwt?: (session: TSession) => string | null | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CreateIdentityCacheEntryOptions<TIdentity> {
|
|
67
|
+
nowMs?: number;
|
|
68
|
+
getExpiresAtMs?: (identity: TIdentity) => number | null | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface PersistOnlineSessionOptions<
|
|
72
|
+
TSession,
|
|
73
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
74
|
+
> extends CreateSessionCacheEntryOptions<TSession> {
|
|
75
|
+
state: OfflineAuthState<TSession, TIdentity>;
|
|
76
|
+
session: TSession | null;
|
|
77
|
+
getSessionActorId: (session: TSession) => string | null | undefined;
|
|
78
|
+
deriveIdentity?: (session: TSession) => TIdentity | null | undefined;
|
|
79
|
+
getIdentityExpiresAtMs?: (identity: TIdentity) => number | null | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PersistOfflineIdentityOptions<
|
|
83
|
+
TSession,
|
|
84
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
85
|
+
> extends CreateIdentityCacheEntryOptions<TIdentity> {
|
|
86
|
+
state: OfflineAuthState<TSession, TIdentity>;
|
|
87
|
+
identity: TIdentity | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ResolveOfflineAuthSubjectOptions<
|
|
91
|
+
TSession,
|
|
92
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
93
|
+
> {
|
|
94
|
+
state: OfflineAuthState<TSession, TIdentity>;
|
|
95
|
+
nowMs?: number;
|
|
96
|
+
skewMs?: number;
|
|
97
|
+
getSessionActorId: (session: TSession) => string | null | undefined;
|
|
98
|
+
getSessionTeamId?: (session: TSession) => string | null | undefined;
|
|
99
|
+
getIdentityActorId?: (identity: TIdentity) => string | null | undefined;
|
|
100
|
+
getIdentityTeamId?: (identity: TIdentity) => string | null | undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type OfflineAuthSubjectSource =
|
|
104
|
+
| 'online-session'
|
|
105
|
+
| 'offline-identity'
|
|
106
|
+
| 'last-actor'
|
|
107
|
+
| 'none';
|
|
108
|
+
|
|
109
|
+
export interface OfflineResolvedSubject<
|
|
110
|
+
TSession,
|
|
111
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
112
|
+
> {
|
|
113
|
+
source: OfflineAuthSubjectSource;
|
|
114
|
+
actorId: string | null;
|
|
115
|
+
teamId: string | null;
|
|
116
|
+
isOffline: boolean;
|
|
117
|
+
session: OfflineAuthCachedValue<TSession> | null;
|
|
118
|
+
identity: OfflineAuthCachedValue<TIdentity> | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface TokenRetryContext extends SyncAuthRetryContext {
|
|
122
|
+
previousToken: string | null;
|
|
123
|
+
nextToken: string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface CreateTokenLifecycleBridgeOptions {
|
|
127
|
+
resolveToken: () => MaybePromise<string | null | undefined>;
|
|
128
|
+
refreshToken?: (
|
|
129
|
+
context: SyncAuthErrorContext
|
|
130
|
+
) => MaybePromise<string | null | undefined>;
|
|
131
|
+
onAuthExpired?: (context: SyncAuthErrorContext) => MaybePromise<void>;
|
|
132
|
+
retryWithFreshToken?: (context: TokenRetryContext) => MaybePromise<boolean>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface TokenLifecycleBridge {
|
|
136
|
+
resolveToken: () => Promise<string | null>;
|
|
137
|
+
getAuthorizationHeaders: () => Promise<Record<string, string>>;
|
|
138
|
+
getRealtimeParams: (paramName?: string) => Promise<Record<string, string>>;
|
|
139
|
+
authLifecycle: SyncAuthLifecycle;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface OfflineLockPolicyOptions {
|
|
143
|
+
now?: () => number;
|
|
144
|
+
initiallyLocked?: boolean;
|
|
145
|
+
maxFailedAttempts?: number;
|
|
146
|
+
cooldownMs?: number;
|
|
147
|
+
idleTimeoutMs?: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface OfflineLockState {
|
|
151
|
+
isLocked: boolean;
|
|
152
|
+
failedAttempts: number;
|
|
153
|
+
blockedUntilMs: number | null;
|
|
154
|
+
lastActivityAtMs: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type OfflineLockEventBase = {
|
|
158
|
+
nowMs?: number;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export type OfflineLockEvent =
|
|
162
|
+
| ({ type: 'lock' } & OfflineLockEventBase)
|
|
163
|
+
| ({ type: 'unlock' } & OfflineLockEventBase)
|
|
164
|
+
| ({ type: 'activity' } & OfflineLockEventBase)
|
|
165
|
+
| ({ type: 'failed-unlock' } & OfflineLockEventBase)
|
|
166
|
+
| ({ type: 'reset-failures' } & OfflineLockEventBase)
|
|
167
|
+
| ({ type: 'tick' } & OfflineLockEventBase);
|
|
168
|
+
|
|
169
|
+
export type OfflineUnlockFailureReason = 'blocked' | 'rejected';
|
|
170
|
+
|
|
171
|
+
export interface OfflineUnlockResult {
|
|
172
|
+
ok: boolean;
|
|
173
|
+
reason: OfflineUnlockFailureReason | null;
|
|
174
|
+
state: OfflineLockState;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface AttemptOfflineUnlockArgs {
|
|
178
|
+
state: OfflineLockState;
|
|
179
|
+
verify: () => boolean;
|
|
180
|
+
options?: OfflineLockPolicyOptions;
|
|
181
|
+
nowMs?: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface AttemptOfflineUnlockAsyncArgs {
|
|
185
|
+
state: OfflineLockState;
|
|
186
|
+
verify: () => MaybePromise<boolean>;
|
|
187
|
+
options?: OfflineLockPolicyOptions;
|
|
188
|
+
nowMs?: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface OfflineLockController {
|
|
192
|
+
getState(): OfflineLockState;
|
|
193
|
+
replaceState(nextState: OfflineLockState): OfflineLockState;
|
|
194
|
+
dispatch(event: OfflineLockEvent): OfflineLockState;
|
|
195
|
+
lock(): OfflineLockState;
|
|
196
|
+
forceUnlock(): OfflineUnlockResult;
|
|
197
|
+
recordActivity(): OfflineLockState;
|
|
198
|
+
recordFailedUnlock(): OfflineLockState;
|
|
199
|
+
resetFailures(): OfflineLockState;
|
|
200
|
+
evaluateIdleTimeout(): OfflineLockState;
|
|
201
|
+
attemptUnlock(verify: () => boolean): OfflineUnlockResult;
|
|
202
|
+
attemptUnlockAsync(
|
|
203
|
+
verify: () => MaybePromise<boolean>
|
|
204
|
+
): Promise<OfflineUnlockResult>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const OFFLINE_AUTH_STATE_VERSION = 1;
|
|
208
|
+
export const DEFAULT_OFFLINE_AUTH_STORAGE_KEY = 'syncular-offline-auth-v1';
|
|
209
|
+
export const DEFAULT_EXPIRY_SKEW_MS = 30_000;
|
|
210
|
+
export const DEFAULT_MAX_FAILED_ATTEMPTS = 5;
|
|
211
|
+
export const DEFAULT_LOCK_COOLDOWN_MS = 30_000;
|
|
212
|
+
|
|
213
|
+
interface NormalizedLockPolicy {
|
|
214
|
+
now: () => number;
|
|
215
|
+
maxFailedAttempts: number;
|
|
216
|
+
cooldownMs: number;
|
|
217
|
+
idleTimeoutMs: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
221
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readFiniteNumber(value: unknown): number | null {
|
|
225
|
+
if (typeof value !== 'number') return null;
|
|
226
|
+
if (!Number.isFinite(value)) return null;
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readNullableFiniteNumber(value: unknown): number | null | undefined {
|
|
231
|
+
if (value === null) return null;
|
|
232
|
+
const parsed = readFiniteNumber(value);
|
|
233
|
+
if (parsed === null) return undefined;
|
|
234
|
+
return parsed;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readTrimmedString(value: unknown): string | null {
|
|
238
|
+
if (typeof value !== 'string') return null;
|
|
239
|
+
const trimmed = value.trim();
|
|
240
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readNullableTrimmedString(value: unknown): string | null | undefined {
|
|
244
|
+
if (value === null) return null;
|
|
245
|
+
const parsed = readTrimmedString(value);
|
|
246
|
+
if (parsed === null) return undefined;
|
|
247
|
+
return parsed;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseCachedValue<TValue>(
|
|
251
|
+
value: unknown,
|
|
252
|
+
parseValue: (value: unknown) => TValue | null
|
|
253
|
+
): OfflineAuthCachedValue<TValue> | null {
|
|
254
|
+
if (value === null || value === undefined) return null;
|
|
255
|
+
if (!isRecord(value)) return null;
|
|
256
|
+
|
|
257
|
+
const parsedValue = parseValue(value.value);
|
|
258
|
+
if (parsedValue === null) return null;
|
|
259
|
+
|
|
260
|
+
const savedAtMs = readFiniteNumber(value.savedAtMs);
|
|
261
|
+
if (savedAtMs === null) return null;
|
|
262
|
+
|
|
263
|
+
const expiresAtMs = readNullableFiniteNumber(value.expiresAtMs);
|
|
264
|
+
if (expiresAtMs === undefined) return null;
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
value: parsedValue,
|
|
268
|
+
savedAtMs,
|
|
269
|
+
expiresAtMs,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function toNowMs(
|
|
274
|
+
nowMs: number | undefined,
|
|
275
|
+
now: (() => number) | undefined
|
|
276
|
+
): number {
|
|
277
|
+
if (typeof nowMs === 'number' && Number.isFinite(nowMs)) {
|
|
278
|
+
return nowMs;
|
|
279
|
+
}
|
|
280
|
+
if (now) {
|
|
281
|
+
return now();
|
|
282
|
+
}
|
|
283
|
+
return Date.now();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function normalizeLockPolicy(
|
|
287
|
+
options: OfflineLockPolicyOptions | undefined
|
|
288
|
+
): NormalizedLockPolicy {
|
|
289
|
+
const maxFailedAttempts = Math.max(
|
|
290
|
+
1,
|
|
291
|
+
Math.floor(options?.maxFailedAttempts ?? DEFAULT_MAX_FAILED_ATTEMPTS)
|
|
292
|
+
);
|
|
293
|
+
const cooldownMs = Math.max(
|
|
294
|
+
0,
|
|
295
|
+
Math.floor(options?.cooldownMs ?? DEFAULT_LOCK_COOLDOWN_MS)
|
|
296
|
+
);
|
|
297
|
+
const idleTimeoutMs = Math.max(0, Math.floor(options?.idleTimeoutMs ?? 0));
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
now: options?.now ?? (() => Date.now()),
|
|
301
|
+
maxFailedAttempts,
|
|
302
|
+
cooldownMs,
|
|
303
|
+
idleTimeoutMs,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function normalizeLockStateForTime(
|
|
308
|
+
state: OfflineLockState,
|
|
309
|
+
nowMs: number
|
|
310
|
+
): OfflineLockState {
|
|
311
|
+
if (state.blockedUntilMs === null) return state;
|
|
312
|
+
if (state.blockedUntilMs > nowMs) return state;
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
...state,
|
|
316
|
+
blockedUntilMs: null,
|
|
317
|
+
failedAttempts: 0,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function decodeBase64UrlSegment(segment: string): string | null {
|
|
322
|
+
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/');
|
|
323
|
+
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
324
|
+
const base64 = `${normalized}${padding}`;
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
if (typeof Buffer !== 'undefined') {
|
|
328
|
+
return Buffer.from(base64, 'base64').toString('utf8');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (typeof atob === 'function') {
|
|
332
|
+
return atob(base64);
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function parseJwtPayload(token: string): Record<string, unknown> | null {
|
|
342
|
+
const segments = token.split('.');
|
|
343
|
+
if (segments.length < 2) return null;
|
|
344
|
+
|
|
345
|
+
const payloadSegment = segments[1];
|
|
346
|
+
if (!payloadSegment) return null;
|
|
347
|
+
|
|
348
|
+
const decoded = decodeBase64UrlSegment(payloadSegment);
|
|
349
|
+
if (!decoded) return null;
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const parsed: unknown = JSON.parse(decoded);
|
|
353
|
+
if (!isRecord(parsed)) return null;
|
|
354
|
+
return parsed;
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function createWebStorageAdapter(storage: {
|
|
361
|
+
getItem(key: string): string | null;
|
|
362
|
+
setItem(key: string, value: string): void;
|
|
363
|
+
removeItem(key: string): void;
|
|
364
|
+
}): OfflineAuthStorage {
|
|
365
|
+
return {
|
|
366
|
+
getItem(key) {
|
|
367
|
+
return storage.getItem(key);
|
|
368
|
+
},
|
|
369
|
+
setItem(key, value) {
|
|
370
|
+
storage.setItem(key, value);
|
|
371
|
+
},
|
|
372
|
+
removeItem(key) {
|
|
373
|
+
storage.removeItem(key);
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function createMemoryStorageAdapter(
|
|
379
|
+
initialEntries?: Record<string, string>
|
|
380
|
+
): OfflineAuthStorage {
|
|
381
|
+
const state = new Map<string, string>(Object.entries(initialEntries ?? {}));
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
getItem(key) {
|
|
385
|
+
return state.get(key) ?? null;
|
|
386
|
+
},
|
|
387
|
+
setItem(key, value) {
|
|
388
|
+
state.set(key, value);
|
|
389
|
+
},
|
|
390
|
+
removeItem(key) {
|
|
391
|
+
state.delete(key);
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function isOfflineSubjectIdentity(
|
|
397
|
+
value: unknown
|
|
398
|
+
): value is OfflineSubjectIdentity {
|
|
399
|
+
if (!isRecord(value)) return false;
|
|
400
|
+
const actorId = readTrimmedString(value.actorId);
|
|
401
|
+
if (!actorId) return false;
|
|
402
|
+
|
|
403
|
+
const teamId = value.teamId;
|
|
404
|
+
if (teamId === undefined || teamId === null) return true;
|
|
405
|
+
return readTrimmedString(teamId) !== null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function createEmptyOfflineAuthState<
|
|
409
|
+
TSession,
|
|
410
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
411
|
+
>(): OfflineAuthState<TSession, TIdentity> {
|
|
412
|
+
return {
|
|
413
|
+
version: OFFLINE_AUTH_STATE_VERSION,
|
|
414
|
+
session: null,
|
|
415
|
+
identity: null,
|
|
416
|
+
lastActorId: null,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function loadOfflineAuthState<
|
|
421
|
+
TSession,
|
|
422
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
423
|
+
>(
|
|
424
|
+
options: LoadOfflineAuthStateOptions<TSession, TIdentity>
|
|
425
|
+
): Promise<OfflineAuthState<TSession, TIdentity>> {
|
|
426
|
+
const storageKey = options.storageKey ?? DEFAULT_OFFLINE_AUTH_STORAGE_KEY;
|
|
427
|
+
const fallback = createEmptyOfflineAuthState<TSession, TIdentity>();
|
|
428
|
+
|
|
429
|
+
let rawValue: string | null = null;
|
|
430
|
+
try {
|
|
431
|
+
rawValue = await options.storage.getItem(storageKey);
|
|
432
|
+
} catch {
|
|
433
|
+
return fallback;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!rawValue) {
|
|
437
|
+
return fallback;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let parsed: unknown;
|
|
441
|
+
try {
|
|
442
|
+
parsed = JSON.parse(rawValue);
|
|
443
|
+
} catch {
|
|
444
|
+
return fallback;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!isRecord(parsed)) {
|
|
448
|
+
return fallback;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const version = readFiniteNumber(parsed.version);
|
|
452
|
+
if (version !== OFFLINE_AUTH_STATE_VERSION) {
|
|
453
|
+
return fallback;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const session = parseCachedValue(parsed.session, options.codec.parseSession);
|
|
457
|
+
const identity = parseCachedValue(
|
|
458
|
+
parsed.identity,
|
|
459
|
+
options.codec.parseIdentity
|
|
460
|
+
);
|
|
461
|
+
const lastActorId = readNullableTrimmedString(parsed.lastActorId);
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
version: OFFLINE_AUTH_STATE_VERSION,
|
|
465
|
+
session,
|
|
466
|
+
identity,
|
|
467
|
+
lastActorId: lastActorId ?? null,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export async function saveOfflineAuthState<
|
|
472
|
+
TSession,
|
|
473
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
474
|
+
>(
|
|
475
|
+
state: OfflineAuthState<TSession, TIdentity>,
|
|
476
|
+
options: SaveOfflineAuthStateOptions
|
|
477
|
+
): Promise<void> {
|
|
478
|
+
const storageKey = options.storageKey ?? DEFAULT_OFFLINE_AUTH_STORAGE_KEY;
|
|
479
|
+
const payload = JSON.stringify(state);
|
|
480
|
+
await options.storage.setItem(storageKey, payload);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export async function removeOfflineAuthState(
|
|
484
|
+
options: SaveOfflineAuthStateOptions
|
|
485
|
+
): Promise<void> {
|
|
486
|
+
const storageKey = options.storageKey ?? DEFAULT_OFFLINE_AUTH_STORAGE_KEY;
|
|
487
|
+
await options.storage.removeItem(storageKey);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function getJwtExpiryMs(token: string): number | null {
|
|
491
|
+
const payload = parseJwtPayload(token);
|
|
492
|
+
if (!payload) return null;
|
|
493
|
+
|
|
494
|
+
const exp = readFiniteNumber(payload.exp);
|
|
495
|
+
if (exp === null) return null;
|
|
496
|
+
|
|
497
|
+
return exp * 1000;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function isExpiryElapsed(args: {
|
|
501
|
+
expiresAtMs: number | null | undefined;
|
|
502
|
+
nowMs?: number;
|
|
503
|
+
skewMs?: number;
|
|
504
|
+
}): boolean {
|
|
505
|
+
if (args.expiresAtMs === null) return false;
|
|
506
|
+
if (args.expiresAtMs === undefined) return true;
|
|
507
|
+
if (!Number.isFinite(args.expiresAtMs)) return true;
|
|
508
|
+
|
|
509
|
+
const nowMs = toNowMs(args.nowMs, undefined);
|
|
510
|
+
const skewMs = Math.max(0, args.skewMs ?? DEFAULT_EXPIRY_SKEW_MS);
|
|
511
|
+
|
|
512
|
+
return args.expiresAtMs <= nowMs + skewMs;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function isCachedValueExpired<TValue>(
|
|
516
|
+
cachedValue: OfflineAuthCachedValue<TValue>,
|
|
517
|
+
options?: {
|
|
518
|
+
nowMs?: number;
|
|
519
|
+
skewMs?: number;
|
|
520
|
+
}
|
|
521
|
+
): boolean {
|
|
522
|
+
return isExpiryElapsed({
|
|
523
|
+
expiresAtMs: cachedValue.expiresAtMs,
|
|
524
|
+
nowMs: options?.nowMs,
|
|
525
|
+
skewMs: options?.skewMs,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function createSessionCacheEntry<TSession>(
|
|
530
|
+
session: TSession,
|
|
531
|
+
options?: CreateSessionCacheEntryOptions<TSession>
|
|
532
|
+
): OfflineAuthCachedValue<TSession> | null {
|
|
533
|
+
const nowMs = toNowMs(options?.nowMs, undefined);
|
|
534
|
+
const explicitExpiry = options?.getExpiresAtMs?.(session);
|
|
535
|
+
const jwtExpiry = options?.getJwt
|
|
536
|
+
? getJwtExpiryMs(options.getJwt(session) ?? '')
|
|
537
|
+
: null;
|
|
538
|
+
|
|
539
|
+
const expiresAtMs = explicitExpiry ?? jwtExpiry;
|
|
540
|
+
|
|
541
|
+
if (expiresAtMs === null || expiresAtMs === undefined) {
|
|
542
|
+
if (!options?.allowMissingExpiry) {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
value: session,
|
|
548
|
+
savedAtMs: nowMs,
|
|
549
|
+
expiresAtMs: null,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const skewMs = Math.max(0, options?.skewMs ?? DEFAULT_EXPIRY_SKEW_MS);
|
|
554
|
+
if (expiresAtMs <= nowMs + skewMs) {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
value: session,
|
|
560
|
+
savedAtMs: nowMs,
|
|
561
|
+
expiresAtMs,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function createIdentityCacheEntry<TIdentity>(
|
|
566
|
+
identity: TIdentity,
|
|
567
|
+
options?: CreateIdentityCacheEntryOptions<TIdentity>
|
|
568
|
+
): OfflineAuthCachedValue<TIdentity> {
|
|
569
|
+
const nowMs = toNowMs(options?.nowMs, undefined);
|
|
570
|
+
const expiresAtMs = options?.getExpiresAtMs?.(identity) ?? null;
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
value: identity,
|
|
574
|
+
savedAtMs: nowMs,
|
|
575
|
+
expiresAtMs,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function persistOnlineSession<
|
|
580
|
+
TSession,
|
|
581
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
582
|
+
>(
|
|
583
|
+
options: PersistOnlineSessionOptions<TSession, TIdentity>
|
|
584
|
+
): OfflineAuthState<TSession, TIdentity> {
|
|
585
|
+
if (!options.session) {
|
|
586
|
+
return clearOfflineAuthState(options.state);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const sessionEntry = createSessionCacheEntry(options.session, {
|
|
590
|
+
nowMs: options.nowMs,
|
|
591
|
+
skewMs: options.skewMs,
|
|
592
|
+
allowMissingExpiry: options.allowMissingExpiry,
|
|
593
|
+
getExpiresAtMs: options.getExpiresAtMs,
|
|
594
|
+
getJwt: options.getJwt,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const actorId = readTrimmedString(options.getSessionActorId(options.session));
|
|
598
|
+
|
|
599
|
+
let nextIdentity = options.state.identity;
|
|
600
|
+
if (options.deriveIdentity) {
|
|
601
|
+
const derivedIdentity = options.deriveIdentity(options.session);
|
|
602
|
+
if (derivedIdentity) {
|
|
603
|
+
nextIdentity = createIdentityCacheEntry(derivedIdentity, {
|
|
604
|
+
nowMs: options.nowMs,
|
|
605
|
+
getExpiresAtMs: options.getIdentityExpiresAtMs,
|
|
606
|
+
});
|
|
607
|
+
} else {
|
|
608
|
+
nextIdentity = null;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
...options.state,
|
|
614
|
+
session: sessionEntry,
|
|
615
|
+
identity: nextIdentity,
|
|
616
|
+
lastActorId: actorId ?? options.state.lastActorId,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function persistOfflineIdentity<
|
|
621
|
+
TSession,
|
|
622
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
623
|
+
>(
|
|
624
|
+
options: PersistOfflineIdentityOptions<TSession, TIdentity>
|
|
625
|
+
): OfflineAuthState<TSession, TIdentity> {
|
|
626
|
+
if (!options.identity) {
|
|
627
|
+
return {
|
|
628
|
+
...options.state,
|
|
629
|
+
identity: null,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const identityEntry = createIdentityCacheEntry(options.identity, {
|
|
634
|
+
nowMs: options.nowMs,
|
|
635
|
+
getExpiresAtMs: options.getExpiresAtMs,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const actorId = readTrimmedString(options.identity.actorId);
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
...options.state,
|
|
642
|
+
identity: identityEntry,
|
|
643
|
+
lastActorId: actorId ?? options.state.lastActorId,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function setOfflineAuthLastActorId<
|
|
648
|
+
TSession,
|
|
649
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
650
|
+
>(
|
|
651
|
+
state: OfflineAuthState<TSession, TIdentity>,
|
|
652
|
+
actorId: string | null
|
|
653
|
+
): OfflineAuthState<TSession, TIdentity> {
|
|
654
|
+
return {
|
|
655
|
+
...state,
|
|
656
|
+
lastActorId: readTrimmedString(actorId) ?? null,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export function clearOfflineAuthState<
|
|
661
|
+
TSession,
|
|
662
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
663
|
+
>(
|
|
664
|
+
state: OfflineAuthState<TSession, TIdentity>,
|
|
665
|
+
options?: {
|
|
666
|
+
preserveLastActorId?: boolean;
|
|
667
|
+
}
|
|
668
|
+
): OfflineAuthState<TSession, TIdentity> {
|
|
669
|
+
return {
|
|
670
|
+
version: OFFLINE_AUTH_STATE_VERSION,
|
|
671
|
+
session: null,
|
|
672
|
+
identity: null,
|
|
673
|
+
lastActorId: options?.preserveLastActorId ? state.lastActorId : null,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function resolveOfflineAuthSubject<
|
|
678
|
+
TSession,
|
|
679
|
+
TIdentity extends OfflineSubjectIdentity,
|
|
680
|
+
>(
|
|
681
|
+
options: ResolveOfflineAuthSubjectOptions<TSession, TIdentity>
|
|
682
|
+
): OfflineResolvedSubject<TSession, TIdentity> {
|
|
683
|
+
const nowMs = toNowMs(options.nowMs, undefined);
|
|
684
|
+
const skewMs = Math.max(0, options.skewMs ?? DEFAULT_EXPIRY_SKEW_MS);
|
|
685
|
+
|
|
686
|
+
const session =
|
|
687
|
+
options.state.session &&
|
|
688
|
+
!isCachedValueExpired(options.state.session, { nowMs, skewMs })
|
|
689
|
+
? options.state.session
|
|
690
|
+
: null;
|
|
691
|
+
|
|
692
|
+
if (session) {
|
|
693
|
+
const actorId = readTrimmedString(options.getSessionActorId(session.value));
|
|
694
|
+
if (actorId) {
|
|
695
|
+
const teamId = options.getSessionTeamId
|
|
696
|
+
? readTrimmedString(options.getSessionTeamId(session.value))
|
|
697
|
+
: null;
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
source: 'online-session',
|
|
701
|
+
actorId,
|
|
702
|
+
teamId,
|
|
703
|
+
isOffline: false,
|
|
704
|
+
session,
|
|
705
|
+
identity: null,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const identity =
|
|
711
|
+
options.state.identity &&
|
|
712
|
+
!isCachedValueExpired(options.state.identity, { nowMs, skewMs })
|
|
713
|
+
? options.state.identity
|
|
714
|
+
: null;
|
|
715
|
+
|
|
716
|
+
if (identity) {
|
|
717
|
+
const actorId = options.getIdentityActorId
|
|
718
|
+
? readTrimmedString(options.getIdentityActorId(identity.value))
|
|
719
|
+
: readTrimmedString(identity.value.actorId);
|
|
720
|
+
|
|
721
|
+
if (actorId) {
|
|
722
|
+
const rawTeamId = options.getIdentityTeamId
|
|
723
|
+
? options.getIdentityTeamId(identity.value)
|
|
724
|
+
: identity.value.teamId;
|
|
725
|
+
|
|
726
|
+
const teamId =
|
|
727
|
+
rawTeamId === null || rawTeamId === undefined
|
|
728
|
+
? null
|
|
729
|
+
: readTrimmedString(rawTeamId);
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
source: 'offline-identity',
|
|
733
|
+
actorId,
|
|
734
|
+
teamId,
|
|
735
|
+
isOffline: true,
|
|
736
|
+
session: null,
|
|
737
|
+
identity,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const lastActorId = readTrimmedString(options.state.lastActorId);
|
|
743
|
+
if (lastActorId) {
|
|
744
|
+
return {
|
|
745
|
+
source: 'last-actor',
|
|
746
|
+
actorId: lastActorId,
|
|
747
|
+
teamId: null,
|
|
748
|
+
isOffline: true,
|
|
749
|
+
session: null,
|
|
750
|
+
identity: null,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return {
|
|
755
|
+
source: 'none',
|
|
756
|
+
actorId: null,
|
|
757
|
+
teamId: null,
|
|
758
|
+
isOffline: true,
|
|
759
|
+
session: null,
|
|
760
|
+
identity: null,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export function createBearerAuthHeaders(
|
|
765
|
+
token: string | null | undefined
|
|
766
|
+
): Record<string, string> {
|
|
767
|
+
const resolved = readTrimmedString(token);
|
|
768
|
+
if (!resolved) return {};
|
|
769
|
+
return {
|
|
770
|
+
Authorization: `Bearer ${resolved}`,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export function createTokenLifecycleBridge(
|
|
775
|
+
options: CreateTokenLifecycleBridgeOptions
|
|
776
|
+
): TokenLifecycleBridge {
|
|
777
|
+
let latestToken: string | null = null;
|
|
778
|
+
let latestRefreshTokens: {
|
|
779
|
+
previousToken: string | null;
|
|
780
|
+
nextToken: string | null;
|
|
781
|
+
} | null = null;
|
|
782
|
+
let refreshInFlight: Promise<boolean> | null = null;
|
|
783
|
+
|
|
784
|
+
const resolveToken = async (): Promise<string | null> => {
|
|
785
|
+
const token = readTrimmedString(await options.resolveToken());
|
|
786
|
+
latestToken = token;
|
|
787
|
+
return token;
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const runRefresh = async (
|
|
791
|
+
context: SyncAuthErrorContext
|
|
792
|
+
): Promise<boolean> => {
|
|
793
|
+
const previousToken = latestToken;
|
|
794
|
+
const refreshTokenResolver = options.refreshToken ?? options.resolveToken;
|
|
795
|
+
const nextToken = readTrimmedString(await refreshTokenResolver(context));
|
|
796
|
+
|
|
797
|
+
latestToken = nextToken;
|
|
798
|
+
latestRefreshTokens = {
|
|
799
|
+
previousToken,
|
|
800
|
+
nextToken,
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
return Boolean(nextToken) && nextToken !== previousToken;
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const authLifecycle: SyncAuthLifecycle = {
|
|
807
|
+
onAuthExpired: options.onAuthExpired,
|
|
808
|
+
refreshToken: async (context) => {
|
|
809
|
+
if (!refreshInFlight) {
|
|
810
|
+
refreshInFlight = runRefresh(context).finally(() => {
|
|
811
|
+
refreshInFlight = null;
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return refreshInFlight;
|
|
816
|
+
},
|
|
817
|
+
retryWithFreshToken: async (context) => {
|
|
818
|
+
const previousToken = latestRefreshTokens?.previousToken ?? latestToken;
|
|
819
|
+
const nextToken = latestRefreshTokens?.nextToken ?? latestToken;
|
|
820
|
+
|
|
821
|
+
if (options.retryWithFreshToken) {
|
|
822
|
+
return options.retryWithFreshToken({
|
|
823
|
+
...context,
|
|
824
|
+
previousToken,
|
|
825
|
+
nextToken,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return context.refreshResult;
|
|
830
|
+
},
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
resolveToken,
|
|
835
|
+
getAuthorizationHeaders: async () => {
|
|
836
|
+
const token = await resolveToken();
|
|
837
|
+
return createBearerAuthHeaders(token);
|
|
838
|
+
},
|
|
839
|
+
getRealtimeParams: async (paramName = 'token') => {
|
|
840
|
+
const token = await resolveToken();
|
|
841
|
+
if (!token) return {};
|
|
842
|
+
return {
|
|
843
|
+
[paramName]: token,
|
|
844
|
+
};
|
|
845
|
+
},
|
|
846
|
+
authLifecycle,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export function createOfflineLockState(
|
|
851
|
+
options?: OfflineLockPolicyOptions
|
|
852
|
+
): OfflineLockState {
|
|
853
|
+
const policy = normalizeLockPolicy(options);
|
|
854
|
+
const nowMs = policy.now();
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
isLocked: options?.initiallyLocked ?? false,
|
|
858
|
+
failedAttempts: 0,
|
|
859
|
+
blockedUntilMs: null,
|
|
860
|
+
lastActivityAtMs: nowMs,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
export function isOfflineLockBlocked(
|
|
865
|
+
state: OfflineLockState,
|
|
866
|
+
nowMs = Date.now()
|
|
867
|
+
): boolean {
|
|
868
|
+
return state.blockedUntilMs !== null && state.blockedUntilMs > nowMs;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export function applyOfflineLockEvent(
|
|
872
|
+
state: OfflineLockState,
|
|
873
|
+
event: OfflineLockEvent,
|
|
874
|
+
options?: OfflineLockPolicyOptions
|
|
875
|
+
): OfflineLockState {
|
|
876
|
+
const policy = normalizeLockPolicy(options);
|
|
877
|
+
const nowMs = toNowMs(event.nowMs, policy.now);
|
|
878
|
+
const normalized = normalizeLockStateForTime(state, nowMs);
|
|
879
|
+
|
|
880
|
+
if (event.type === 'lock') {
|
|
881
|
+
return {
|
|
882
|
+
...normalized,
|
|
883
|
+
isLocked: true,
|
|
884
|
+
lastActivityAtMs: nowMs,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (event.type === 'unlock') {
|
|
889
|
+
if (isOfflineLockBlocked(normalized, nowMs)) {
|
|
890
|
+
return {
|
|
891
|
+
...normalized,
|
|
892
|
+
isLocked: true,
|
|
893
|
+
lastActivityAtMs: nowMs,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
...normalized,
|
|
899
|
+
isLocked: false,
|
|
900
|
+
failedAttempts: 0,
|
|
901
|
+
blockedUntilMs: null,
|
|
902
|
+
lastActivityAtMs: nowMs,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (event.type === 'activity') {
|
|
907
|
+
return {
|
|
908
|
+
...normalized,
|
|
909
|
+
lastActivityAtMs: nowMs,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (event.type === 'reset-failures') {
|
|
914
|
+
return {
|
|
915
|
+
...normalized,
|
|
916
|
+
failedAttempts: 0,
|
|
917
|
+
blockedUntilMs: null,
|
|
918
|
+
lastActivityAtMs: nowMs,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (event.type === 'failed-unlock') {
|
|
923
|
+
if (isOfflineLockBlocked(normalized, nowMs)) {
|
|
924
|
+
return {
|
|
925
|
+
...normalized,
|
|
926
|
+
isLocked: true,
|
|
927
|
+
lastActivityAtMs: nowMs,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const failedAttempts = normalized.failedAttempts + 1;
|
|
932
|
+
|
|
933
|
+
if (failedAttempts >= policy.maxFailedAttempts) {
|
|
934
|
+
const blockedUntilMs = nowMs + policy.cooldownMs;
|
|
935
|
+
return {
|
|
936
|
+
...normalized,
|
|
937
|
+
isLocked: true,
|
|
938
|
+
failedAttempts,
|
|
939
|
+
blockedUntilMs,
|
|
940
|
+
lastActivityAtMs: nowMs,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return {
|
|
945
|
+
...normalized,
|
|
946
|
+
isLocked: true,
|
|
947
|
+
failedAttempts,
|
|
948
|
+
blockedUntilMs: null,
|
|
949
|
+
lastActivityAtMs: nowMs,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (
|
|
954
|
+
policy.idleTimeoutMs > 0 &&
|
|
955
|
+
!normalized.isLocked &&
|
|
956
|
+
nowMs - normalized.lastActivityAtMs >= policy.idleTimeoutMs
|
|
957
|
+
) {
|
|
958
|
+
return {
|
|
959
|
+
...normalized,
|
|
960
|
+
isLocked: true,
|
|
961
|
+
lastActivityAtMs: nowMs,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return normalized;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
export function attemptOfflineUnlock(
|
|
969
|
+
args: AttemptOfflineUnlockArgs
|
|
970
|
+
): OfflineUnlockResult {
|
|
971
|
+
const policy = normalizeLockPolicy(args.options);
|
|
972
|
+
const nowMs = toNowMs(args.nowMs, policy.now);
|
|
973
|
+
const normalized = normalizeLockStateForTime(args.state, nowMs);
|
|
974
|
+
|
|
975
|
+
if (isOfflineLockBlocked(normalized, nowMs)) {
|
|
976
|
+
return {
|
|
977
|
+
ok: false,
|
|
978
|
+
reason: 'blocked',
|
|
979
|
+
state: {
|
|
980
|
+
...normalized,
|
|
981
|
+
isLocked: true,
|
|
982
|
+
},
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (args.verify()) {
|
|
987
|
+
const state = applyOfflineLockEvent(
|
|
988
|
+
normalized,
|
|
989
|
+
{ type: 'unlock', nowMs },
|
|
990
|
+
policy
|
|
991
|
+
);
|
|
992
|
+
return {
|
|
993
|
+
ok: true,
|
|
994
|
+
reason: null,
|
|
995
|
+
state,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const state = applyOfflineLockEvent(
|
|
1000
|
+
normalized,
|
|
1001
|
+
{ type: 'failed-unlock', nowMs },
|
|
1002
|
+
policy
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
return {
|
|
1006
|
+
ok: false,
|
|
1007
|
+
reason: isOfflineLockBlocked(state, nowMs) ? 'blocked' : 'rejected',
|
|
1008
|
+
state,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export async function attemptOfflineUnlockAsync(
|
|
1013
|
+
args: AttemptOfflineUnlockAsyncArgs
|
|
1014
|
+
): Promise<OfflineUnlockResult> {
|
|
1015
|
+
const policy = normalizeLockPolicy(args.options);
|
|
1016
|
+
const nowMs = toNowMs(args.nowMs, policy.now);
|
|
1017
|
+
const normalized = normalizeLockStateForTime(args.state, nowMs);
|
|
1018
|
+
|
|
1019
|
+
if (isOfflineLockBlocked(normalized, nowMs)) {
|
|
1020
|
+
return {
|
|
1021
|
+
ok: false,
|
|
1022
|
+
reason: 'blocked',
|
|
1023
|
+
state: {
|
|
1024
|
+
...normalized,
|
|
1025
|
+
isLocked: true,
|
|
1026
|
+
},
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (await args.verify()) {
|
|
1031
|
+
const state = applyOfflineLockEvent(
|
|
1032
|
+
normalized,
|
|
1033
|
+
{ type: 'unlock', nowMs },
|
|
1034
|
+
policy
|
|
1035
|
+
);
|
|
1036
|
+
return {
|
|
1037
|
+
ok: true,
|
|
1038
|
+
reason: null,
|
|
1039
|
+
state,
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const state = applyOfflineLockEvent(
|
|
1044
|
+
normalized,
|
|
1045
|
+
{ type: 'failed-unlock', nowMs },
|
|
1046
|
+
policy
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
return {
|
|
1050
|
+
ok: false,
|
|
1051
|
+
reason: isOfflineLockBlocked(state, nowMs) ? 'blocked' : 'rejected',
|
|
1052
|
+
state,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
export function createOfflineLockController(
|
|
1057
|
+
options?: OfflineLockPolicyOptions
|
|
1058
|
+
): OfflineLockController {
|
|
1059
|
+
const policy = normalizeLockPolicy(options);
|
|
1060
|
+
let state = createOfflineLockState(policy);
|
|
1061
|
+
|
|
1062
|
+
const dispatch = (event: OfflineLockEvent): OfflineLockState => {
|
|
1063
|
+
state = applyOfflineLockEvent(state, event, policy);
|
|
1064
|
+
return state;
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
return {
|
|
1068
|
+
getState() {
|
|
1069
|
+
return state;
|
|
1070
|
+
},
|
|
1071
|
+
replaceState(nextState) {
|
|
1072
|
+
state = nextState;
|
|
1073
|
+
return state;
|
|
1074
|
+
},
|
|
1075
|
+
dispatch,
|
|
1076
|
+
lock() {
|
|
1077
|
+
return dispatch({ type: 'lock' });
|
|
1078
|
+
},
|
|
1079
|
+
forceUnlock() {
|
|
1080
|
+
const nextState = dispatch({ type: 'unlock' });
|
|
1081
|
+
if (nextState.isLocked) {
|
|
1082
|
+
return {
|
|
1083
|
+
ok: false,
|
|
1084
|
+
reason: 'blocked',
|
|
1085
|
+
state: nextState,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return {
|
|
1090
|
+
ok: true,
|
|
1091
|
+
reason: null,
|
|
1092
|
+
state: nextState,
|
|
1093
|
+
};
|
|
1094
|
+
},
|
|
1095
|
+
recordActivity() {
|
|
1096
|
+
return dispatch({ type: 'activity' });
|
|
1097
|
+
},
|
|
1098
|
+
recordFailedUnlock() {
|
|
1099
|
+
return dispatch({ type: 'failed-unlock' });
|
|
1100
|
+
},
|
|
1101
|
+
resetFailures() {
|
|
1102
|
+
return dispatch({ type: 'reset-failures' });
|
|
1103
|
+
},
|
|
1104
|
+
evaluateIdleTimeout() {
|
|
1105
|
+
return dispatch({ type: 'tick' });
|
|
1106
|
+
},
|
|
1107
|
+
attemptUnlock(verify) {
|
|
1108
|
+
const result = attemptOfflineUnlock({
|
|
1109
|
+
state,
|
|
1110
|
+
verify,
|
|
1111
|
+
options: policy,
|
|
1112
|
+
});
|
|
1113
|
+
state = result.state;
|
|
1114
|
+
return result;
|
|
1115
|
+
},
|
|
1116
|
+
async attemptUnlockAsync(verify) {
|
|
1117
|
+
const result = await attemptOfflineUnlockAsync({
|
|
1118
|
+
state,
|
|
1119
|
+
verify,
|
|
1120
|
+
options: policy,
|
|
1121
|
+
});
|
|
1122
|
+
state = result.state;
|
|
1123
|
+
return result;
|
|
1124
|
+
},
|
|
1125
|
+
};
|
|
1126
|
+
}
|