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/screen-orientation": "^6.0.0",
|
||||||
"@capacitor/share": "^6.0.0",
|
"@capacitor/share": "^6.0.0",
|
||||||
"@capacitor/splash-screen": "^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/transcriptionSettings.js"></script>
|
||||||
<script defer src="/js/voicePreferences.js"></script>
|
<script defer src="/js/voicePreferences.js"></script>
|
||||||
<script defer src="/js/app.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/auth.js"></script>
|
||||||
<script defer src="/js/liveEncounter.js"></script>
|
<script defer src="/js/liveEncounter.js"></script>
|
||||||
<script defer src="/js/voiceDictation.js"></script>
|
<script defer src="/js/voiceDictation.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (ssoOk === 'ok') {
|
if (ssoOk === 'ok') {
|
||||||
var ssoSid = urlParams.get('sid');
|
var ssoSid = urlParams.get('sid');
|
||||||
history.replaceState(null, '', window.location.pathname);
|
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)
|
// Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically)
|
||||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||||
.then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); })
|
.then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); })
|
||||||
|
|
@ -72,36 +75,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
})
|
})
|
||||||
.catch(function() {});
|
.catch(function() {});
|
||||||
|
|
||||||
var savedToken = localStorage.getItem(TOKEN_KEY);
|
var hydrate = window.SecureStorage
|
||||||
if (ssoOk) {
|
? window.SecureStorage.hydrate([TOKEN_KEY, USER_KEY, SESSION_KEY])
|
||||||
// SSO handled above — do nothing here
|
: Promise.resolve();
|
||||||
} else if (ssoError) {
|
|
||||||
// SSO error handled above — auth screen already shown
|
hydrate.then(function() {
|
||||||
} else if (savedToken) {
|
var savedToken = window.SecureStorage
|
||||||
// Existing token — verify silently; auth screen stays hidden during check
|
? window.SecureStorage.getSync(TOKEN_KEY)
|
||||||
fetch('/api/auth/me', {
|
: localStorage.getItem(TOKEN_KEY);
|
||||||
headers: { 'Authorization': 'Bearer ' + savedToken }
|
|
||||||
})
|
if (ssoOk) {
|
||||||
.then(function(r) {
|
// SSO handled above — do nothing here
|
||||||
if (r.ok) return r.json();
|
} else if (ssoError) {
|
||||||
throw new Error('expired');
|
// SSO error handled above — auth screen already shown
|
||||||
})
|
} else if (savedToken) {
|
||||||
.then(function(data) {
|
fetch('/api/auth/me', {
|
||||||
if (data && data.user) {
|
headers: { 'Authorization': 'Bearer ' + savedToken }
|
||||||
enterApp(data.user, savedToken);
|
})
|
||||||
} else {
|
.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();
|
showAuthScreen();
|
||||||
clearSession();
|
clearSession();
|
||||||
}
|
});
|
||||||
})
|
} else {
|
||||||
.catch(function() {
|
|
||||||
showAuthScreen();
|
showAuthScreen();
|
||||||
clearSession();
|
}
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// No token — show login immediately
|
|
||||||
showAuthScreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- HELPER FUNCTIONS ----
|
// ---- HELPER FUNCTIONS ----
|
||||||
|
|
||||||
|
|
@ -125,8 +135,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
function enterApp(user, token) {
|
function enterApp(user, token) {
|
||||||
window.AUTH_TOKEN = token;
|
window.AUTH_TOKEN = token;
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
if (window.SecureStorage) {
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
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 (authScreen) authScreen.style.display = 'none';
|
||||||
if (mainApp) {
|
if (mainApp) {
|
||||||
|
|
@ -191,16 +206,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
if (window.SecureStorage) {
|
||||||
localStorage.removeItem(USER_KEY);
|
window.SecureStorage.remove(TOKEN_KEY);
|
||||||
localStorage.removeItem(SESSION_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.AUTH_TOKEN = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.getAuthHeaders = function() {
|
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 {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'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.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);
|
enterApp(data.user, data.token);
|
||||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -473,7 +500,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
|
|
||||||
if (data.success && data.token && data.user) {
|
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);
|
enterApp(data.user, data.token);
|
||||||
showToast(data.message || 'Account created!', 'success');
|
showToast(data.message || 'Account created!', 'success');
|
||||||
} else if (data.success && data.needsVerification) {
|
} 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>';
|
list.innerHTML = '<p style="color:var(--g400);font-size:13px;">No active sessions found.</p>';
|
||||||
return;
|
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) {
|
list.innerHTML = data.sessions.map(function(s) {
|
||||||
var isCurrent = s.id === currentSid;
|
var isCurrent = s.id === currentSid;
|
||||||
var created = new Date(s.created_at).toLocaleDateString();
|
var created = new Date(s.created_at).toLocaleDateString();
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
showLoading('Uploading document...');
|
showLoading('Uploading document...');
|
||||||
fetch('/api/documents/upload', {
|
fetch('/api/documents/upload', {
|
||||||
method: 'POST',
|
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
|
body: formData
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.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