@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 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
+ });