Add hardware-backed secure storage for mobile auth token
Web still uses localStorage; Capacitor native app now routes token/user/session-id through capacitor-secure-storage-plugin (iOS Keychain, Android EncryptedSharedPreferences / Keystore). A thin SecureStorage wrapper detects Capacitor at runtime and falls back to localStorage elsewhere, keeping a single auth.js codebase for both targets. To activate on mobile: cd mobile && npm install && npx cap sync android
This commit is contained in:
parent
82b8fa0e0e
commit
c736782c15
5 changed files with 144 additions and 39 deletions
|
|
@ -23,6 +23,7 @@
|
|||
"@capacitor/screen-orientation": "^6.0.0",
|
||||
"@capacitor/share": "^6.0.0",
|
||||
"@capacitor/splash-screen": "^6.0.0",
|
||||
"@capacitor/status-bar": "^6.0.0"
|
||||
"@capacitor/status-bar": "^6.0.0",
|
||||
"capacitor-secure-storage-plugin": "^0.10.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -422,6 +422,7 @@
|
|||
<script defer src="/js/transcriptionSettings.js"></script>
|
||||
<script defer src="/js/voicePreferences.js"></script>
|
||||
<script defer src="/js/app.js"></script>
|
||||
<script defer src="/js/secureStorage.js"></script>
|
||||
<script defer src="/js/auth.js"></script>
|
||||
<script defer src="/js/liveEncounter.js"></script>
|
||||
<script defer src="/js/voiceDictation.js"></script>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (ssoOk === 'ok') {
|
||||
var ssoSid = urlParams.get('sid');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
if (ssoSid) localStorage.setItem(SESSION_KEY, ssoSid);
|
||||
if (ssoSid) {
|
||||
if (window.SecureStorage) window.SecureStorage.set(SESSION_KEY, ssoSid);
|
||||
else localStorage.setItem(SESSION_KEY, ssoSid);
|
||||
}
|
||||
// Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically)
|
||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
.then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); })
|
||||
|
|
@ -72,36 +75,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
})
|
||||
.catch(function() {});
|
||||
|
||||
var savedToken = localStorage.getItem(TOKEN_KEY);
|
||||
if (ssoOk) {
|
||||
// SSO handled above — do nothing here
|
||||
} else if (ssoError) {
|
||||
// SSO error handled above — auth screen already shown
|
||||
} else if (savedToken) {
|
||||
// Existing token — verify silently; auth screen stays hidden during check
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': 'Bearer ' + savedToken }
|
||||
})
|
||||
.then(function(r) {
|
||||
if (r.ok) return r.json();
|
||||
throw new Error('expired');
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data && data.user) {
|
||||
enterApp(data.user, savedToken);
|
||||
} else {
|
||||
var hydrate = window.SecureStorage
|
||||
? window.SecureStorage.hydrate([TOKEN_KEY, USER_KEY, SESSION_KEY])
|
||||
: Promise.resolve();
|
||||
|
||||
hydrate.then(function() {
|
||||
var savedToken = window.SecureStorage
|
||||
? window.SecureStorage.getSync(TOKEN_KEY)
|
||||
: localStorage.getItem(TOKEN_KEY);
|
||||
|
||||
if (ssoOk) {
|
||||
// SSO handled above — do nothing here
|
||||
} else if (ssoError) {
|
||||
// SSO error handled above — auth screen already shown
|
||||
} else if (savedToken) {
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': 'Bearer ' + savedToken }
|
||||
})
|
||||
.then(function(r) {
|
||||
if (r.ok) return r.json();
|
||||
throw new Error('expired');
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data && data.user) {
|
||||
enterApp(data.user, savedToken);
|
||||
} else {
|
||||
showAuthScreen();
|
||||
clearSession();
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
showAuthScreen();
|
||||
clearSession();
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
});
|
||||
} else {
|
||||
showAuthScreen();
|
||||
clearSession();
|
||||
});
|
||||
} else {
|
||||
// No token — show login immediately
|
||||
showAuthScreen();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- HELPER FUNCTIONS ----
|
||||
|
||||
|
|
@ -125,8 +135,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
function enterApp(user, token) {
|
||||
window.AUTH_TOKEN = token;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
if (window.SecureStorage) {
|
||||
window.SecureStorage.set(TOKEN_KEY, token);
|
||||
window.SecureStorage.set(USER_KEY, JSON.stringify(user));
|
||||
} else {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
if (authScreen) authScreen.style.display = 'none';
|
||||
if (mainApp) {
|
||||
|
|
@ -191,16 +206,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
|
||||
function clearSession() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
if (window.SecureStorage) {
|
||||
window.SecureStorage.remove(TOKEN_KEY);
|
||||
window.SecureStorage.remove(USER_KEY);
|
||||
window.SecureStorage.remove(SESSION_KEY);
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
window.AUTH_TOKEN = null;
|
||||
}
|
||||
|
||||
window.getAuthHeaders = function() {
|
||||
var token = window.AUTH_TOKEN;
|
||||
if (!token && window.SecureStorage) token = window.SecureStorage.getSync(TOKEN_KEY);
|
||||
if (!token) { try { token = localStorage.getItem(TOKEN_KEY); } catch(e) {} }
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem(TOKEN_KEY) || '')
|
||||
'Authorization': 'Bearer ' + (token || '')
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -383,7 +407,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
|
||||
if (data.success && data.token && data.user) {
|
||||
if (data.sessionId) localStorage.setItem(SESSION_KEY, data.sessionId);
|
||||
if (data.sessionId) {
|
||||
if (window.SecureStorage) window.SecureStorage.set(SESSION_KEY, data.sessionId);
|
||||
else localStorage.setItem(SESSION_KEY, data.sessionId);
|
||||
}
|
||||
enterApp(data.user, data.token);
|
||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||
} else {
|
||||
|
|
@ -473,7 +500,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
hideLoading();
|
||||
|
||||
if (data.success && data.token && data.user) {
|
||||
if (data.sessionId) localStorage.setItem(SESSION_KEY, data.sessionId);
|
||||
if (data.sessionId) {
|
||||
if (window.SecureStorage) window.SecureStorage.set(SESSION_KEY, data.sessionId);
|
||||
else localStorage.setItem(SESSION_KEY, data.sessionId);
|
||||
}
|
||||
enterApp(data.user, data.token);
|
||||
showToast(data.message || 'Account created!', 'success');
|
||||
} else if (data.success && data.needsVerification) {
|
||||
|
|
@ -555,7 +585,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
list.innerHTML = '<p style="color:var(--g400);font-size:13px;">No active sessions found.</p>';
|
||||
return;
|
||||
}
|
||||
var currentSid = data.currentSessionId || localStorage.getItem(SESSION_KEY);
|
||||
var currentSid = data.currentSessionId || (window.SecureStorage ? window.SecureStorage.getSync(SESSION_KEY) : localStorage.getItem(SESSION_KEY));
|
||||
list.innerHTML = data.sessions.map(function(s) {
|
||||
var isCurrent = s.id === currentSid;
|
||||
var created = new Date(s.created_at).toLocaleDateString();
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
showLoading('Uploading document...');
|
||||
fetch('/api/documents/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
|
||||
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || (window.SecureStorage && window.SecureStorage.getSync('ped_scribe_token')) || localStorage.getItem('ped_scribe_token') || '') },
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
|
|
|
|||
73
public/js/secureStorage.js
Normal file
73
public/js/secureStorage.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// ============================================================
|
||||
// SECURE STORAGE WRAPPER
|
||||
// Browser: localStorage
|
||||
// Capacitor mobile: iOS Keychain / Android EncryptedSharedPreferences
|
||||
// via capacitor-secure-storage-plugin
|
||||
// ============================================================
|
||||
(function() {
|
||||
var memCache = {};
|
||||
|
||||
function isNative() {
|
||||
try {
|
||||
return !!(window.Capacitor && typeof window.Capacitor.isNativePlatform === 'function' && window.Capacitor.isNativePlatform());
|
||||
} catch(e) { return false; }
|
||||
}
|
||||
|
||||
function plugin() {
|
||||
try {
|
||||
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.SecureStoragePlugin;
|
||||
return p || null;
|
||||
} catch(e) { return null; }
|
||||
}
|
||||
|
||||
window.SecureStorage = {
|
||||
isNative: isNative,
|
||||
|
||||
get: function(key) {
|
||||
if (isNative() && plugin()) {
|
||||
return plugin().get({ key: key })
|
||||
.then(function(r) { memCache[key] = r.value; return r.value; })
|
||||
.catch(function() { return null; });
|
||||
}
|
||||
try { return Promise.resolve(localStorage.getItem(key)); } catch(e) { return Promise.resolve(null); }
|
||||
},
|
||||
|
||||
set: function(key, value) {
|
||||
memCache[key] = value;
|
||||
if (isNative() && plugin()) {
|
||||
return plugin().set({ key: key, value: value }).catch(function() {});
|
||||
}
|
||||
try { localStorage.setItem(key, value); } catch(e) {}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
remove: function(key) {
|
||||
delete memCache[key];
|
||||
if (isNative() && plugin()) {
|
||||
var p = plugin().remove({ key: key }).catch(function() {});
|
||||
// Also clear legacy localStorage copy if any
|
||||
try { localStorage.removeItem(key); } catch(e) {}
|
||||
return p;
|
||||
}
|
||||
try { localStorage.removeItem(key); } catch(e) {}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
// Synchronous read for hot paths (fetch headers). On native returns cached
|
||||
// value populated by prior get/set. On web reads localStorage directly.
|
||||
getSync: function(key) {
|
||||
if (isNative()) return memCache[key] || null;
|
||||
try { return localStorage.getItem(key); } catch(e) { return null; }
|
||||
},
|
||||
|
||||
// Hydrate the memory cache from native storage at boot. Resolves when ready.
|
||||
hydrate: function(keys) {
|
||||
if (!isNative() || !plugin()) return Promise.resolve();
|
||||
return Promise.all(keys.map(function(k) {
|
||||
return plugin().get({ key: k })
|
||||
.then(function(r) { memCache[k] = r.value; })
|
||||
.catch(function() { memCache[k] = null; });
|
||||
}));
|
||||
}
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in a new issue