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.