PageSourceSearch

https://smrtbld.ai/js/offline/offline-auth.js

js smrtbld.ai collected 2026-10-05 15:08:31 UTC 10,080 bytes, 303 lines download raw bytes

1/**
2 * Offline Authentication
3 *
4 * Ported from smrtTime.Web — adapted for smrtBuild's auth flow.
5 *
6 * Two-tier offline auth:
7 *   Tier 1 (PIN): User has set a PIN → verify locally via PBKDF2 hash
8 *   Tier 2 (Grace): No PIN → allow 24h grace from last online auth
9 *
10 * On login: caches session data in IndexedDB via offlineStore.
11 * On JWT expiry while offline: prompts for PIN or checks grace window.
12 * On reconnect: normal auth resumes.
13 *
14 * Usage:
15 *   import { offlineAuth } from '../offline/offline-auth.js';
16 *   await offlineAuth.cacheSession(token, user, company);
17 *   const status = await offlineAuth.checkOfflineAuth();
18 */
19
20import { offlineStore } from './offline-store.js';
21
22// ── Constants ─────────────────────────────────────────────────────────
23
24const PIN_PBKDF2_ITERATIONS = 100_000;
25const PIN_HASH_ALGORITHM = 'SHA-256';
26const PIN_SALT_BYTES = 16;
27const PIN_SESSION_DURATION_MS = 4 * 60 * 60 * 1000;   // 4 hours per PIN unlock
28const JWT_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000;       // 24 hours without PIN
29const PIN_MIN_LENGTH = 4;
30const PIN_MAX_LENGTH = 6;
31const NAME_IDENTIFIER_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier';
32
33function _decodeJwtPayload(token) {
34    try {
35        const payload = String(token || '').split('.')[1];
36        if (!payload) return null;
37        let base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
38        while (base64.length % 4) base64 += '=';
39        return JSON.parse(atob(base64));
40    } catch {
41        return null;
42    }
43}
44
45/** Derive the only valid offline partition: exact JWT DatabaseName + current user. */
46function derivePartition(token, user) {
47    const claims = _decodeJwtPayload(token);
48    const databaseName = claims?.DatabaseName ?? claims?.databaseName;
49    const claimUserId = claims?.[NAME_IDENTIFIER_CLAIM] ?? claims?.nameid;
50    const currentUserId = user?.userId ?? user?.userID ?? user?.id;
51    if (claimUserId != null && currentUserId != null
52        && String(claimUserId).trim() !== String(currentUserId).trim()) {
53        throw new Error('Authenticated user does not match the JWT NameIdentifier claim.');
54    }
55    const userId = claimUserId ?? currentUserId;
56    if (typeof databaseName !== 'string' || !databaseName.trim() || userId == null || String(userId).trim() === '') {
57        throw new Error('Cannot derive offline partition from JWT DatabaseName and authenticated user id.');
58    }
59    const normalizedUserId = String(userId).trim();
60    return Object.freeze({
61        databaseName,
62        userId: normalizedUserId,
63        key: `${encodeURIComponent(databaseName)}::${encodeURIComponent(normalizedUserId)}`,
64    });
65}
66
67async function activatePartition(token, user) {
68    await offlineStore.ready;
69    return offlineStore.activatePartition(derivePartition(token, user));
70}
71
72function deriveCachedPartition(session) {
73    if (!session?.token || !session?.user) return null;
74    const partition = derivePartition(session.token, session.user);
75    if (session.partitionKey && session.partitionKey !== partition.key) {
76        throw new Error('Cached offline session partition does not match its JWT identity.');
77    }
78    return partition;
79}
80
81async function activateCachedPartition(session) {
82    const partition = deriveCachedPartition(session);
83    if (!partition) return null;
84    return offlineStore.activatePartition(partition);
85}
86
87async function deactivatePartition() {
88    await offlineStore.ready;
89    offlineStore.deactivatePartition();
90}
91
92function getActivePartition() {
93    return offlineStore.getActivePartition();
94}
95
96// ── Session Management ────────────────────────────────────────────────
97
98/**
99 * Cache the current auth session in IndexedDB for offline use.
100 * Called after successful login.
101 */
102async function cacheSession(token, user, company) {
103    await offlineStore.ready;
104    await activatePartition(token, user);
105    await offlineStore.cacheSession(token, user, company);
106}
107
108/** Get the cached offline session. */
109async function getSession() {
110    await offlineStore.ready;
111    const session = await offlineStore.getSession();
112    if (session) await activateCachedPartition(session);
113    return session;
114}
115
116/** Clear the offline session (called on explicit logout). */
117async function clearSession() {
118    await offlineStore.ready;
119    await offlineStore.clearSessionData();
120}
121
122/** Update the last online auth timestamp. */
123async function touchOnlineAuth() {
124    await offlineStore.ready;
125    await offlineStore.touchOnlineAuth();
126}
127
128/** Get cached user data for offline display. */
129async function getCachedAuth({ activate = true } = {}) {
130    await offlineStore.ready;
131    const session = await offlineStore.getSession();
132    if (!session) return null;
133    const partition = deriveCachedPartition(session);
134    if (activate) await offlineStore.activatePartition(partition);
135    return { user: session.user, company: session.company, token: session.token };
136}
137
138// ── PIN Management ────────────────────────────────────────────────────
139
140/**
141 * Store a PIN hash for offline verification.
142 * Uses PBKDF2-SHA256 with a random salt via Web Crypto API.
143 * @param {string} pin — 4-6 digit PIN
144 */
145async function setPin(pin) {
146    if (!pin || pin.length < PIN_MIN_LENGTH || pin.length > PIN_MAX_LENGTH) {
147        throw new Error(`PIN must be ${PIN_MIN_LENGTH}-${PIN_MAX_LENGTH} digits`);
148    }
149
150    await offlineStore.ready;
151    const salt = crypto.getRandomValues(new Uint8Array(PIN_SALT_BYTES));
152    const hash = await _hashPin(pin, salt);
153
154    await offlineStore.updateSessionPin(
155        _bufferToBase64(hash),
156        _bufferToBase64(salt)
157    );
158    console.log('[OfflineAuth] PIN hash stored');
159}
160
161/**
162 * Verify a PIN against the stored hash.
163 * @param {string} pin
164 * @returns {Promise<boolean>}
165 */
166async function verifyPin(pin) {
167    await offlineStore.ready;
168    const session = await offlineStore.getSession();
169    if (!session?.pinHash || !session?.pinSalt) return false;
170
171    const salt = _base64ToBuffer(session.pinSalt);
172    const hash = await _hashPin(pin, salt);
173    return _bufferToBase64(hash) === session.pinHash;
174}
175
176/** Check if the user has a PIN set. */
177async function hasPin() {
178    await offlineStore.ready;
179    const session = await offlineStore.getSession();
180    return !!(session?.pinHash && session?.pinSalt);
181}
182
183/** Clear the stored PIN. */
184async function clearPin() {
185    await offlineStore.ready;
186    await offlineStore.updateSessionPin(null, null);
187}
188
189// ── Offline Authentication Check ──────────────────────────────────────
190
191/**
192 * Check if the user is authenticated for offline use.
193 * Called when JWT is expired and we're offline.
194 *
195 * @returns {Promise<'valid'|'pin-required'|'expired'|'none'>}
196 */
197async function checkOfflineAuth({ activate = true } = {}) {
198    await offlineStore.ready;
199    const session = await offlineStore.getSession();
200    if (!session) return 'none';
201    try {
202        const partition = deriveCachedPartition(session);
203        if (activate) await offlineStore.activatePartition(partition);
204    }
205    catch { return 'none'; }
206
207    // Check if there's an active offline session (from PIN unlock)
208    if (session.offlineSessionExpiry && Date.now() < session.offlineSessionExpiry) {
209        return 'valid';
210    }
211
212    // Check if PIN is available
213    if (session.pinHash && session.pinSalt) {
214        return 'pin-required';
215    }
216
217    // No PIN — check grace period (24 hours from last online auth)
218    if (session.lastOnlineAuth) {
219        const elapsed = Date.now() - session.lastOnlineAuth;
220        if (elapsed < JWT_GRACE_PERIOD_MS) {
221            return 'valid';
222        }
223    }
224
225    return 'expired';
226}
227
228/**
229 * Grant an offline session after PIN verification.
230 * Sets the offline session expiry (4 hours from now).
231 */
232async function grantOfflineSession() {
233    await offlineStore.ready;
234    await offlineStore.setOfflineSessionExpiry(Date.now() + PIN_SESSION_DURATION_MS);
235    console.log('[OfflineAuth] Offline session granted (4 hours)');
236}
237
238// ── PBKDF2 Crypto Helpers ─────────────────────────────────────────────
239
240async function _hashPin(pin, salt) {
241    const encoder = new TextEncoder();
242    const keyMaterial = await crypto.subtle.importKey(
243        'raw',
244        encoder.encode(pin),
245        'PBKDF2',
246        false,
247        ['deriveBits']
248    );
249
250    return crypto.subtle.deriveBits(
251        {
252            name: 'PBKDF2',
253            salt,
254            iterations: PIN_PBKDF2_ITERATIONS,
255            hash: PIN_HASH_ALGORITHM,
256        },
257        keyMaterial,
258        256 // 32 bytes
259    );
260}
261
262function _bufferToBase64(buffer) {
263    const bytes = new Uint8Array(buffer);
264    let binary = '';
265    for (const b of bytes) binary += String.fromCharCode(b);
266    return btoa(binary);
267}
268
269function _base64ToBuffer(base64) {
270    const binary = atob(base64);
271    const bytes = new Uint8Array(binary.length);
272    for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
273    return bytes;
274}
275
276// ── Export ─────────────────────────────────────────────────────────────
277
278export const offlineAuth = {
279    // Session
280    cacheSession,
281    getSession,
282    clearSession,
283    touchOnlineAuth,
284    getCachedAuth,
285    derivePartition,
286    activatePartition,
287    deactivatePartition,
288    getActivePartition,
289
290    // PIN
291    setPin,
292    verifyPin,
293    hasPin,
294    clearPin,
295
296    // Auth check
297    checkOfflineAuth,
298    grantOfflineSession,
299
300    // Constants (exposed for UI validation)
301    PIN_MIN_LENGTH,
302    PIN_MAX_LENGTH,
303};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.