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:
Daniel 2026-04-14 02:33:32 +02:00
parent 82b8fa0e0e
commit c736782c15
5 changed files with 144 additions and 39 deletions

View file

@ -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"
}
}

View file

@ -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>

View file

@ -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();

View file

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

View 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; });
}));
}
};
})();