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:
Daniel 2026-04-14 04:11:55 +02:00
parent 4a26abed10
commit fa16cb13cb
2 changed files with 72 additions and 57 deletions

View file

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

View file

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