Hybrid auth: cookie-only on web, Keychain Bearer on mobile
Runtime split driven by window.Capacitor.isNativePlatform():
Web browser
- No token in localStorage / sessionStorage — XSS can't read it
- Server-set httpOnly cookie carries the session
- fetch() default credentials='same-origin' sends the cookie
- getAuthHeaders() returns Content-Type only, no Authorization
- Middleware already falls back to cookie when Bearer is absent
Capacitor native (iOS / Android)
- Unchanged — Bearer token lives in Keychain / Keystore via the
capacitor-secure-storage-plugin SecureStorage wrapper
- Bearer header still sent on every request
enterApp() / clearSession() / getAuthHeaders() all now branch on
isNativeApp(). Legacy localStorage entries from the dual-mode era
are wiped on clearSession() for users migrating in.
Rollback: git reset --hard pre-httponly-only-2026-04-14
This commit is contained in:
parent
4a26abed10
commit
fa16cb13cb
2 changed files with 72 additions and 57 deletions
|
|
@ -16,6 +16,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
var USER_KEY = 'ped_scribe_user';
|
||||
var SESSION_KEY = 'ped_session_id';
|
||||
|
||||
// Runtime-split auth model:
|
||||
// - Web browser → httpOnly cookie only (no localStorage token, XSS-safe).
|
||||
// fetch() defaults to credentials:'same-origin' so cookies are sent.
|
||||
// - Capacitor native app → Bearer token in iOS Keychain / Android Keystore
|
||||
// via SecureStorage wrapper. WebView cookies can be evicted; Keychain
|
||||
// survives cold starts reliably.
|
||||
function isNativeApp() {
|
||||
try {
|
||||
return !!(window.Capacitor && typeof window.Capacitor.isNativePlatform === 'function' && window.Capacitor.isNativePlatform());
|
||||
} catch(e) { return false; }
|
||||
}
|
||||
window.IS_NATIVE_APP = isNativeApp();
|
||||
|
||||
// Auth module initialized
|
||||
|
||||
// Make sure forms are visible/hidden correctly on load
|
||||
|
|
@ -33,9 +46,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (ssoOk === 'ok') {
|
||||
var ssoSid = urlParams.get('sid');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
if (ssoSid) {
|
||||
if (window.SecureStorage) window.SecureStorage.set(SESSION_KEY, ssoSid);
|
||||
else localStorage.setItem(SESSION_KEY, ssoSid);
|
||||
if (ssoSid && isNativeApp()) {
|
||||
window.SecureStorage.set(SESSION_KEY, ssoSid);
|
||||
}
|
||||
// Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically)
|
||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
|
|
@ -75,43 +87,35 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
})
|
||||
.catch(function() {});
|
||||
|
||||
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();
|
||||
});
|
||||
if (isNativeApp()) {
|
||||
// Native app path: token lives in Keychain/Keystore via SecureStorage
|
||||
window.SecureStorage.hydrate([TOKEN_KEY, USER_KEY, SESSION_KEY]).then(function() {
|
||||
var savedToken = window.SecureStorage.getSync(TOKEN_KEY);
|
||||
if (ssoOk || ssoError) return; // SSO branch handled above
|
||||
if (!savedToken) { showAuthScreen(); return; }
|
||||
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(); });
|
||||
});
|
||||
} else {
|
||||
// Web path: rely on httpOnly cookie. fetch sends same-origin cookies
|
||||
// automatically in modern browsers — no Authorization header needed.
|
||||
if (ssoOk || ssoError) {
|
||||
// SSO branch handled above
|
||||
} else {
|
||||
showAuthScreen();
|
||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
.then(function(r) { if (r.ok) return r.json(); throw new Error('not-logged-in'); })
|
||||
.then(function(data) {
|
||||
if (data && data.user) enterApp(data.user, '');
|
||||
else showAuthScreen();
|
||||
})
|
||||
.catch(function() { showAuthScreen(); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- HELPER FUNCTIONS ----
|
||||
|
||||
|
|
@ -135,13 +139,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
function enterApp(user, token) {
|
||||
window.AUTH_TOKEN = token;
|
||||
if (window.SecureStorage) {
|
||||
if (isNativeApp()) {
|
||||
// Mobile: persist token to Keychain/Keystore
|
||||
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));
|
||||
}
|
||||
// Web: do not persist the token anywhere — session lives in the
|
||||
// httpOnly cookie already set by the server. User info is
|
||||
// re-fetched via /api/auth/me on each boot.
|
||||
|
||||
if (authScreen) authScreen.style.display = 'none';
|
||||
if (mainApp) {
|
||||
|
|
@ -206,14 +211,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
|
||||
function clearSession() {
|
||||
if (window.SecureStorage) {
|
||||
if (isNativeApp()) {
|
||||
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);
|
||||
// Web: cookie cleared by /api/auth/logout. Also wipe any legacy
|
||||
// localStorage entries from the dual-mode era so they don't linger.
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
} catch(e) {}
|
||||
}
|
||||
window.AUTH_TOKEN = null;
|
||||
// Clear service worker caches so a logged-out user on a shared device
|
||||
|
|
@ -228,12 +237,17 @@ document.addEventListener('DOMContentLoaded', 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) {} }
|
||||
// Web: no Authorization header. The httpOnly cookie is sent automatically
|
||||
// by fetch (default credentials = same-origin). Server middleware falls
|
||||
// back to cookie when Bearer is absent.
|
||||
if (!isNativeApp()) {
|
||||
return { 'Content-Type': 'application/json' };
|
||||
}
|
||||
// Native: Bearer from Keychain/Keystore via SecureStorage.
|
||||
var token = window.AUTH_TOKEN || window.SecureStorage.getSync(TOKEN_KEY) || '';
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + (token || '')
|
||||
'Authorization': 'Bearer ' + token
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -416,9 +430,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
|
||||
if (data.success && data.token && data.user) {
|
||||
if (data.sessionId) {
|
||||
if (window.SecureStorage) window.SecureStorage.set(SESSION_KEY, data.sessionId);
|
||||
else localStorage.setItem(SESSION_KEY, data.sessionId);
|
||||
if (data.sessionId && isNativeApp()) {
|
||||
window.SecureStorage.set(SESSION_KEY, data.sessionId);
|
||||
}
|
||||
enterApp(data.user, data.token);
|
||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||
|
|
@ -509,9 +522,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
hideLoading();
|
||||
|
||||
if (data.success && data.token && data.user) {
|
||||
if (data.sessionId) {
|
||||
if (window.SecureStorage) window.SecureStorage.set(SESSION_KEY, data.sessionId);
|
||||
else localStorage.setItem(SESSION_KEY, data.sessionId);
|
||||
if (data.sessionId && isNativeApp()) {
|
||||
window.SecureStorage.set(SESSION_KEY, data.sessionId);
|
||||
}
|
||||
enterApp(data.user, data.token);
|
||||
showToast(data.message || 'Account created!', 'success');
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@
|
|||
showLoading('Uploading document...');
|
||||
fetch('/api/documents/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || (window.SecureStorage && window.SecureStorage.getSync('ped_scribe_token')) || localStorage.getItem('ped_scribe_token') || '') },
|
||||
headers: window.IS_NATIVE_APP
|
||||
? { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || (window.SecureStorage && window.SecureStorage.getSync('ped_scribe_token')) || '') }
|
||||
: {}, // web: httpOnly cookie sent automatically via credentials:'same-origin' default
|
||||
credentials: 'same-origin',
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
|
|
|
|||
Loading…
Reference in a new issue