feat(auth): explain the plain-HTTP secure-cookie gotcha on login

When the server issues a Secure session cookie but the request arrived over
plain HTTP (the common LAN install over http://ip:3000), the browser drops
the cookie and the next request dead-ends on a bare "Access token required" —
the top source of avoidable install issues. The login response now flags this
exact case and the login page shows a localized box explaining the fix (use
HTTPS, or set COOKIE_SECURE=false) with a link to the Troubleshooting guide.
It only triggers in the real failure case, never for correct HTTPS setups.
This commit is contained in:
Maurice
2026-06-29 18:24:49 +02:00
committed by Maurice
parent e91f592f22
commit 3701ab6cad
25 changed files with 111 additions and 3 deletions
+12 -1
View File
@@ -11,7 +11,7 @@ export default function LoginPage(): React.ReactElement {
navigate,
mode, setMode,
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken,
isLoading, error, setError, insecureCookie, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
@@ -447,6 +447,17 @@ export default function LoginPage(): React.ReactElement {
</div>
)}
{insecureCookie && (
<div style={{ padding: '12px 14px', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#92400e' }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>{t('login.insecureCookie.title')}</div>
<div style={{ lineHeight: 1.55 }}>{t('login.insecureCookie.body')}</div>
<a href="https://github.com/mauriceboe/TREK/wiki/Troubleshooting" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-block', marginTop: 6, fontWeight: 600, color: '#b45309', textDecoration: 'underline' }}>
{t('login.insecureCookie.link')}
</a>
</div>
)}
{passwordChangeStep && (
<>
<div style={{ padding: '10px 14px', background: '#fefce8', border: '1px solid #fde68a', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#92400e' }}>
+12 -1
View File
@@ -41,6 +41,9 @@ export function useLogin() {
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
// Set when the server signals it just issued a Secure cookie over plain HTTP —
// the browser drops it, so we explain the fix instead of a bare 401 later.
const [insecureCookie, setInsecureCookie] = useState(false)
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
@@ -225,6 +228,7 @@ export function useLogin() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
setInsecureCookie(false)
setIsLoading(true)
try {
if (passwordChangeStep) {
@@ -260,6 +264,13 @@ export function useLogin() {
await register(username, email, password, inviteToken || undefined)
} else {
const result = await login(email, password, rememberMe)
if ((result as { insecureCookie?: boolean }).insecureCookie) {
// Credentials were correct, but the secure cookie won't survive plain
// HTTP — proceeding would just dead-end on "Access token required".
setInsecureCookie(true)
setIsLoading(false)
return
}
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
setMfaToken(result.mfa_token)
setMfaStep(true)
@@ -291,7 +302,7 @@ export function useLogin() {
navigate,
mode, setMode,
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken,
isLoading, error, setError, insecureCookie, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
@@ -4,6 +4,7 @@ import { AuthService } from './auth.service';
import { RateLimitService } from './rate-limit.service';
import { OptionalJwtGuard } from './optional-jwt.guard';
import { writeAudit, getClientIp } from '../../services/auditLog';
import { willDropSecureCookie } from '../../services/cookie';
import type { User } from '../../types';
const WINDOW = 15 * 60 * 1000;
@@ -88,7 +89,13 @@ export class AuthPublicController {
return { mfa_required: true, mfa_token: result.mfa_token };
}
this.auth.setAuthCookie(res, result.token!, req, result.remember);
return { token: result.token, user: result.user };
return {
token: result.token,
user: result.user,
// Surfaced so the client can explain the plain-HTTP cookie gotcha instead
// of the user hitting a bare "Access token required" on the next request.
...(willDropSecureCookie(req) ? { insecureCookie: true } : {}),
};
}
@Post('forgot-password')
+16
View File
@@ -54,6 +54,22 @@ function buildOptions(clear: boolean, secure: boolean, remember?: RememberOption
};
}
/**
* True when we are about to set a `Secure` session cookie but the request did
* NOT arrive over HTTPS — the browser silently drops the cookie, so the next
* request has no session and the server answers "Access token required". This is
* the classic plain-HTTP install gotcha; callers surface it to the user with a
* concrete fix (use HTTPS or set COOKIE_SECURE=false) instead of a bare 401.
*/
export function willDropSecureCookie(req?: Request): boolean {
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') return false;
if (req?.secure === true) return false;
return (
process.env.NODE_ENV?.toLowerCase() === 'production' ||
process.env.FORCE_HTTPS?.toLowerCase() === 'true'
);
}
export function setAuthCookie(res: Response, token: string, req?: Request, remember?: RememberOption): void {
res.cookie(COOKIE_NAME, token, cookieOptions(false, req, remember));
}
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.emailPlaceholder': 'your@email.com', // en-fallback
'login.passkey.signIn': 'تسجيل الدخول باستخدام مفتاح المرور',
'login.passkey.failed': 'فشل تسجيل الدخول بمفتاح المرور. يرجى المحاولة مرة أخرى.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -84,5 +84,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Falha na redefinição. O link pode ter expirado.',
'login.passkey.signIn': 'Entrar com uma passkey',
'login.passkey.failed': 'Falha ao entrar com passkey. Tente novamente.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -84,5 +84,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Obnovení se nezdařilo. Odkaz mohl vypršet.',
'login.passkey.signIn': 'Přihlásit se pomocí přístupového klíče',
'login.passkey.failed': 'Přihlášení přístupovým klíčem se nezdařilo. Zkuste to prosím znovu.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -86,5 +86,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',
'login.passkey.signIn': 'Mit Passkey anmelden',
'login.passkey.failed': 'Anmeldung mit Passkey fehlgeschlagen. Bitte erneut versuchen.',
'login.insecureCookie.title': "Login hält über HTTP nicht",
'login.insecureCookie.body': "Du verbindest dich über reines HTTP, daher verwirft dein Browser TREKs sicheren Session-Cookie — die nächste Anfrage scheitert mit „Access token required\". Lösung: HTTPS nutzen, oder für ein Heim-Setup COOKIE_SECURE=false setzen.",
'login.insecureCookie.link': "Zur Troubleshooting-Anleitung",
};
export default login;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Reset failed. The link may have expired.',
'login.passkey.signIn': 'Sign in with a passkey',
'login.passkey.failed': 'Passkey sign-in failed. Please try again.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -86,5 +86,8 @@ const login: TranslationStrings = {
'login.oidcLoggedOut': 'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.',
'login.passkey.signIn': 'Iniciar sesión con una passkey',
'login.passkey.failed': 'Error al iniciar sesión con la passkey. Inténtalo de nuevo.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -88,5 +88,8 @@ const login: TranslationStrings = {
'login.demoHint': 'Essayez la démo — aucune inscription nécessaire',
'login.passkey.signIn': 'Se connecter avec une passkey',
'login.passkey.failed': 'Échec de la connexion par passkey. Veuillez réessayer.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -88,5 +88,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Η επαναφορά απέτυχε. Ο σύνδεσμος μπορεί να έχει λήξει.',
'login.passkey.signIn': 'Σύνδεση με passkey',
'login.passkey.failed': 'Η σύνδεση με passkey απέτυχε. Παρακαλώ δοκιμάστε ξανά.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -86,5 +86,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'A visszaállítás nem sikerült. A link lehet, hogy lejárt.',
'login.passkey.signIn': 'Bejelentkezés passkey-jel',
'login.passkey.failed': 'A passkey-bejelentkezés sikertelen. Kérjük, próbáld újra.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -83,5 +83,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Reset gagal. Tautan mungkin sudah kedaluwarsa.',
'login.passkey.signIn': 'Masuk dengan passkey',
'login.passkey.failed': 'Masuk dengan passkey gagal. Silakan coba lagi.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Reset non riuscito. Il link potrebbe essere scaduto.',
'login.passkey.signIn': 'Accedi con una passkey',
'login.passkey.failed': 'Accesso con passkey non riuscito. Riprova.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'リセットに失敗しました。リンクの有効期限が切れている可能性があります。',
'login.passkey.signIn': 'パスキーでサインイン',
'login.passkey.failed': 'パスキーでのサインインに失敗しました。もう一度お試しください。',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -83,5 +83,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': '재설정 실패. 링크가 만료되었을 수 있습니다.',
'login.passkey.signIn': '패스키로 로그인',
'login.passkey.failed': '패스키 로그인에 실패했습니다. 다시 시도하세요.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.demoHint': 'Probeer de demo — geen registratie nodig',
'login.passkey.signIn': 'Inloggen met een passkey',
'login.passkey.failed': 'Inloggen met passkey mislukt. Probeer het opnieuw.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.setNewPasswordHint': 'Musisz zmienić hasło.',
'login.passkey.signIn': 'Zaloguj się kluczem dostępu',
'login.passkey.failed': 'Logowanie kluczem dostępu nie powiodło się. Spróbuj ponownie.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -86,5 +86,8 @@ const login: TranslationStrings = {
'login.demoHint': 'Попробуйте демо — регистрация не требуется',
'login.passkey.signIn': 'Войти с помощью passkey',
'login.passkey.failed': 'Не удалось войти с помощью passkey. Попробуйте ещё раз.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Återställningen misslyckades. Länken kan ha gått ut.',
'login.passkey.signIn': 'Logga in med en inloggningsnyckel',
'login.passkey.failed': 'Inloggningsnyckel misslyckades. Försök igen.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -86,5 +86,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Sıfırlama başarısız oldu. Bağlantının süresi dolmuş olabilir.',
'login.passkey.signIn': 'Passkey ile oturum açın',
'login.passkey.failed': 'Passkey ile oturum açma başarısız oldu. Lütfen tekrar deneyin.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.demoHint': 'Спробуйте демо — реєстрація не потрібна',
'login.passkey.signIn': 'Увійти за допомогою passkey',
'login.passkey.failed': 'Не вдалося увійти за допомогою passkey. Спробуйте ще раз.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -82,5 +82,8 @@ const login: TranslationStrings = {
'login.demoHint': '試用演示——無需註冊',
'login.passkey.signIn': '使用 Passkey 登入',
'login.passkey.failed': 'Passkey 登入失敗,請重試。',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+3
View File
@@ -82,5 +82,8 @@ const login: TranslationStrings = {
'login.demoHint': '试用演示——无需注册',
'login.passkey.signIn': '使用通行密钥登录',
'login.passkey.failed': '通行密钥登录失败,请重试。',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;