fix: say up front when the mobile app cannot use an http mail server

The app refuses a JMAP server over plain http (loopback aside), yet the
desktop issued a code, reported "Signed in on your phone" once it was
redeemed, and the phone failed. Linking now stops before the step-up
with an explanation.
This commit is contained in:
Linus Rath
2026-09-27 04:53:49 +02:00
parent 473e5c8bff
commit 377c283d86
32 changed files with 90 additions and 27 deletions
+8
View File
@@ -4,6 +4,7 @@ import { logger } from '@/lib/logger';
import { createPairingCode, isGrantAvailable, stashGrant } from '@/lib/auth/pairing-store';
import { pairingOwner, readPairReauthFromStore, setPairReauthInStore } from '@/lib/auth/pair-reauth';
import { mintGrantWithPassword } from '@/lib/auth/pair-grant';
import { appAcceptsServerUrl } from '@/lib/auth/pair-bundle';
import { beginPairStepUp, pairAttemptKey, settlePairStepUp } from '@/lib/auth/pair-attempts';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
@@ -112,6 +113,13 @@ export async function POST(request: NextRequest) {
}
const owner = pairingOwner(slot, context.username);
// The app refuses a mail server over plain http. Say so here, before a
// password attempt is spent and before the desktop would report a phone
// that could never connect as signed in.
if (!appAcceptsServerUrl(context.serverUrl)) {
return NextResponse.json({ error: 'insecure_server' }, { status: 400 });
}
let grantId: string;
const password = typeof body?.password === 'string' ? body.password : '';
if (password) {
@@ -329,6 +329,7 @@ describe('LinkDeviceSection step-up', () => {
[500, 'session_secret_required', 'link_device.error_session_secret'],
[502, 'server_unreachable', 'link_device.error_server_unreachable'],
[502, 'pairing_unavailable', 'link_device.error_pairing_unavailable'],
[400, 'insecure_server', 'link_device.error_insecure_server'],
[400, 'invalid_request', 'link_device.error'],
[500, 'something_else', 'link_device.error'],
])('maps %i %s to %s', async (status, error, key) => {
@@ -1161,6 +1161,9 @@ export function LinkDeviceSection() {
case 'pairing_unavailable':
backToIdle(t('link_device.error_pairing_unavailable'));
return;
case 'insecure_server':
backToIdle(t('link_device.error_insecure_server'));
return;
case 'too_many_attempts':
setTotp('');
fail(t('link_device.error_too_many_attempts'));
+16
View File
@@ -834,3 +834,19 @@ describe('pair/create after the security review', () => {
expect((await create({ slot: 5, password: WRONG_PASSWORD })).status).toBe(429);
});
});
describe('pair/create for a server the app refuses', () => {
it('refuses a plain-http mail server before any step-up', async () => {
handler = mailServer({ base: 'http://mail.lan' });
signIn(0, { serverUrl: 'http://mail.lan' });
expect(await create({ password: PASSWORD })).toMatchObject({ status: 400, body: { error: 'insecure_server' } });
expect(calls).toEqual([]);
});
it('allows loopback http, as the app does for development', async () => {
handler = mailServer({ base: 'http://localhost:8080' });
signIn(0, { serverUrl: 'http://localhost:8080' });
config.jmapServerUrl = 'http://localhost:8080';
expect((await create({ password: PASSWORD })).status).toBe(200);
});
});
+8
View File
@@ -59,6 +59,14 @@ function isLoopbackHttp(url: string): boolean {
return url.toLowerCase().startsWith('http://') && (host === 'localhost' || host === '127.0.0.1' || host === '10.0.2.2');
}
/**
* Whether the app will connect to this JMAP server at all: https, or loopback
* http for development (the app's own rule for a paired `server_url`).
*/
export function appAcceptsServerUrl(serverUrl: string): boolean {
return serverUrl.toLowerCase().startsWith('https://') || isLoopbackHttp(serverUrl);
}
/**
* Mirrors the app's `isAcceptableTokenEndpoint` (src/lib/oauth.ts): https (or
* loopback http for development), on the host family of the JMAP server or
+2 -1
View File
@@ -1752,7 +1752,8 @@
"success_title": "تم تسجيل الدخول على هاتفك",
"success_body": "يمكنك إغلاق هذه الصفحة. لربط جهاز آخر، اعرض رمزًا جديدًا.",
"link_another": "ربط جهاز آخر",
"use_sso": "استخدم تسجيل الدخول الموحّد بدلاً من ذلك"
"use_sso": "استخدم تسجيل الدخول الموحّد بدلاً من ذلك",
"error_insecure_server": "يتصل تطبيق الهاتف بخوادم البريد عبر https فقط، وهذا الخادم يستخدم http."
},
"public_keys": {
"name_label": "اسم المفتاح العام",
+2 -1
View File
@@ -1752,7 +1752,8 @@
"success_title": "Sessió iniciada al telèfon",
"success_body": "Podeu tancar aquesta pàgina. Per enllaçar un altre dispositiu, mostreu un codi nou.",
"link_another": "Enllaça un altre dispositiu",
"use_sso": "Utilitza l'inici de sessió únic"
"use_sso": "Utilitza l'inici de sessió únic",
"error_insecure_server": "L'aplicació mòbil només es connecta a servidors de correu per https, i aquest fa servir http."
},
"public_keys": {
"name_label": "Nom de la clau pública",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Přihlášeno v telefonu",
"success_body": "Tuto stránku můžete zavřít. Chcete-li propojit další zařízení, zobrazte nový kód.",
"link_another": "Propojit další zařízení",
"use_sso": "Použít místo toho jednotné přihlášení"
"use_sso": "Použít místo toho jednotné přihlášení",
"error_insecure_server": "Mobilní aplikace se k poštovním serverům připojuje jen přes https a tento server používá http."
},
"public_keys": {
"name_label": "Název veřejného klíče",
+2 -1
View File
@@ -1748,7 +1748,8 @@
"success_title": "Logget ind på din telefon",
"success_body": "Du kan lukke denne side. Vis en ny kode for at tilknytte en anden enhed.",
"link_another": "Tilknyt en anden enhed",
"use_sso": "Brug single sign-on i stedet"
"use_sso": "Brug single sign-on i stedet",
"error_insecure_server": "Mobilappen forbinder kun til mailservere via https, og denne bruger http."
},
"public_keys": {
"name_label": "Navn på offentlig nøgle",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Auf Ihrem Smartphone angemeldet",
"success_body": "Sie können diese Seite schließen. Um ein weiteres Gerät zu verknüpfen, lassen Sie einen neuen Code anzeigen.",
"link_another": "Weiteres Gerät verknüpfen",
"use_sso": "Stattdessen Single Sign-On verwenden"
"use_sso": "Stattdessen Single Sign-On verwenden",
"error_insecure_server": "Die App verbindet sich nur über https mit Mailservern, und dieser verwendet http."
},
"public_keys": {
"name_label": "Name des öffentlichen Schlüssels",
+2 -1
View File
@@ -1759,7 +1759,8 @@
"success_title": "Signed in on your phone",
"success_body": "You can close this page. To link another device, show a new code.",
"link_another": "Link another device",
"use_sso": "Use single sign-on instead"
"use_sso": "Use single sign-on instead",
"error_insecure_server": "The mobile app only connects to mail servers over https, and this one uses http."
}
},
"identities": {
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Sesión iniciada en su teléfono",
"success_body": "Puede cerrar esta página. Para vincular otro dispositivo, muestre un código nuevo.",
"link_another": "Vincular otro dispositivo",
"use_sso": "Usar el inicio de sesión único"
"use_sso": "Usar el inicio de sesión único",
"error_insecure_server": "La aplicación móvil solo se conecta a servidores de correo por https, y este usa http."
},
"public_keys": {
"name_label": "Nombre de la clave pública",
+2 -1
View File
@@ -1752,7 +1752,8 @@
"success_title": "ورود در گوشی شما انجام شد",
"success_body": "می‌توانید این صفحه را ببندید. برای اتصال دستگاه دیگر، کد جدیدی نمایش دهید.",
"link_another": "اتصال دستگاه دیگر",
"use_sso": "به‌جای آن از ورود یکپارچه استفاده کنید"
"use_sso": "به‌جای آن از ورود یکپارچه استفاده کنید",
"error_insecure_server": "برنامه موبایل فقط از طریق https به سرورهای ایمیل وصل می‌شود و این سرور از http استفاده می‌کند."
},
"public_keys": {
"name_label": "نام کلید عمومی",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Connecté sur votre téléphone",
"success_body": "Vous pouvez fermer cette page. Pour associer un autre appareil, affichez un nouveau code.",
"link_another": "Associer un autre appareil",
"use_sso": "Utiliser plutôt l'authentification unique"
"use_sso": "Utiliser plutôt l'authentification unique",
"error_insecure_server": "L'application mobile ne se connecte aux serveurs de messagerie qu'en https, et celui-ci utilise http."
},
"public_keys": {
"name_label": "Nom de la clé publique",
+2 -1
View File
@@ -1710,7 +1710,8 @@
"success_title": "ההתחברות בטלפון הושלמה",
"success_body": "אפשר לסגור את הדף הזה. כדי לקשר מכשיר נוסף, הצג קוד חדש.",
"link_another": "קשר מכשיר נוסף",
"use_sso": "השתמש בכניסה יחידה במקום"
"use_sso": "השתמש בכניסה יחידה במקום",
"error_insecure_server": "האפליקציה מתחברת לשרתי דואר רק ב-https, והשרת הזה משתמש ב-http."
},
"public_keys": {
"name_label": "שם המפתח הציבורי",
+2 -1
View File
@@ -1748,7 +1748,8 @@
"success_title": "Bejelentkezve a telefonodon",
"success_body": "Bezárhatod ezt az oldalt. Másik eszköz összekapcsolásához jeleníts meg egy új kódot.",
"link_another": "Másik eszköz összekapcsolása",
"use_sso": "Egyszeri bejelentkezés használata helyette"
"use_sso": "Egyszeri bejelentkezés használata helyette",
"error_insecure_server": "A mobilalkalmazás csak https-en keresztül csatlakozik levelezőszerverekhez, ez pedig http-t használ."
},
"public_keys": {
"name_label": "Nyilvános kulcs neve",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Accesso effettuato sul telefono",
"success_body": "Puoi chiudere questa pagina. Per collegare un altro dispositivo, mostra un nuovo codice.",
"link_another": "Collega un altro dispositivo",
"use_sso": "Usa invece l'accesso unico (SSO)"
"use_sso": "Usa invece l'accesso unico (SSO)",
"error_insecure_server": "L'app mobile si collega ai server di posta solo tramite https, e questo usa http."
},
"public_keys": {
"name_label": "Nome della chiave pubblica",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "スマートフォンでサインインしました",
"success_body": "このページは閉じてかまいません。別のデバイスを連携するには、新しいコードを表示してください。",
"link_another": "別のデバイスを連携",
"use_sso": "代わりにシングルサインオンを使う"
"use_sso": "代わりにシングルサインオンを使う",
"error_insecure_server": "モバイルアプリは https のメールサーバーにしか接続できませんが、このサーバーは http を使っています。"
},
"public_keys": {
"name_label": "公開鍵の名前",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "휴대폰에서 로그인되었습니다",
"success_body": "이 페이지를 닫아도 됩니다. 다른 기기를 연결하려면 새 코드를 표시하세요.",
"link_another": "다른 기기 연결",
"use_sso": "대신 SSO(통합 로그인) 사용"
"use_sso": "대신 SSO(통합 로그인) 사용",
"error_insecure_server": "모바일 앱은 https로만 메일 서버에 연결하는데, 이 서버는 http를 사용합니다."
},
"public_keys": {
"name_label": "공개 키 이름",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Pierakstīšanās tālrunī izdevās",
"success_body": "Varat aizvērt šo lapu. Lai saistītu citu ierīci, parādiet jaunu kodu.",
"link_another": "Saistīt citu ierīci",
"use_sso": "Tā vietā izmantot vienoto pieteikšanos"
"use_sso": "Tā vietā izmantot vienoto pieteikšanos",
"error_insecure_server": "Mobilā lietotne ar pasta serveriem savienojas tikai caur https, bet šis izmanto http."
},
"public_keys": {
"name_label": "Publiskās atslēgas nosaukums",
+2 -1
View File
@@ -1759,7 +1759,8 @@
"success_title": "Утсан дээрээ нэвтэрлээ",
"success_body": "Та энэ хуудсыг хааж болно. Өөр төхөөрөмж холбохын тулд шинэ код харуулна уу.",
"link_another": "Өөр төхөөрөмж холбох",
"use_sso": "Оронд нь нэгдсэн нэвтрэлт ашиглах"
"use_sso": "Оронд нь нэгдсэн нэвтрэлт ашиглах",
"error_insecure_server": "Гар утасны апп зөвхөн https-ээр мэйл серверт холбогддог бөгөөд энэ сервер http ашиглаж байна."
}
},
"identities": {
+2 -1
View File
@@ -1759,7 +1759,8 @@
"success_title": "Pålogget på telefonen",
"success_body": "Du kan lukke denne siden. Vis en ny kode for å koble til en annen enhet.",
"link_another": "Koble til en annen enhet",
"use_sso": "Bruk enkel pålogging i stedet"
"use_sso": "Bruk enkel pålogging i stedet",
"error_insecure_server": "Mobilappen kobler bare til e-postservere over https, og denne bruker http."
}
},
"identities": {
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Ingelogd op uw telefoon",
"success_body": "U kunt deze pagina sluiten. Toon een nieuwe code om nog een apparaat te koppelen.",
"link_another": "Nog een apparaat koppelen",
"use_sso": "In plaats daarvan eenmalige aanmelding gebruiken"
"use_sso": "In plaats daarvan eenmalige aanmelding gebruiken",
"error_insecure_server": "De mobiele app maakt alleen via https verbinding met mailservers, en deze gebruikt http."
},
"public_keys": {
"name_label": "Naam van openbare sleutel",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Zalogowano na telefonie",
"success_body": "Możesz zamknąć tę stronę. Aby połączyć kolejne urządzenie, pokaż nowy kod.",
"link_another": "Połącz kolejne urządzenie",
"use_sso": "Zamiast tego użyj logowania jednokrotnego"
"use_sso": "Zamiast tego użyj logowania jednokrotnego",
"error_insecure_server": "Aplikacja mobilna łączy się z serwerami poczty tylko przez https, a ten używa http."
},
"public_keys": {
"name_label": "Nazwa klucza publicznego",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Conectado no seu celular",
"success_body": "Você pode fechar esta página. Para vincular outro dispositivo, mostre um novo código.",
"link_another": "Vincular outro dispositivo",
"use_sso": "Usar o início de sessão único"
"use_sso": "Usar o início de sessão único",
"error_insecure_server": "A aplicação móvel só se liga a servidores de correio por https, e este usa http."
},
"public_keys": {
"name_label": "Nome da chave pública",
+2 -1
View File
@@ -1752,7 +1752,8 @@
"success_title": "Conectat pe telefon",
"success_body": "Puteți închide această pagină. Pentru a asocia un alt dispozitiv, afișați un cod nou.",
"link_another": "Asociați alt dispozitiv",
"use_sso": "Folosește autentificarea unică în schimb"
"use_sso": "Folosește autentificarea unică în schimb",
"error_insecure_server": "Aplicația mobilă se conectează la serverele de e-mail doar prin https, iar acesta folosește http."
},
"public_keys": {
"name_label": "Numele cheii publice",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Вход на телефоне выполнен",
"success_body": "Эту страницу можно закрыть. Чтобы привязать ещё одно устройство, покажите новый код.",
"link_another": "Привязать ещё одно устройство",
"use_sso": "Использовать единый вход"
"use_sso": "Использовать единый вход",
"error_insecure_server": "Мобильное приложение подключается к почтовым серверам только по https, а этот сервер использует http."
},
"public_keys": {
"name_label": "Название открытого ключа",
+2 -1
View File
@@ -1752,7 +1752,8 @@
"success_title": "Prihlásené v telefóne",
"success_body": "Túto stránku môžete zavrieť. Ak chcete prepojiť ďalšie zariadenie, zobrazte nový kód.",
"link_another": "Prepojiť ďalšie zariadenie",
"use_sso": "Použiť namiesto toho jednotné prihlásenie"
"use_sso": "Použiť namiesto toho jednotné prihlásenie",
"error_insecure_server": "Mobilná aplikácia sa k poštovým serverom pripája len cez https a tento server používa http."
},
"public_keys": {
"name_label": "Názov verejného kľúča",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Telefonunuzda oturum açıldı",
"success_body": "Bu sayfayı kapatabilirsiniz. Başka bir cihaz bağlamak için yeni bir kod gösterin.",
"link_another": "Başka bir cihaz bağla",
"use_sso": "Bunun yerine çoklu oturum açmayı kullan"
"use_sso": "Bunun yerine çoklu oturum açmayı kullan",
"error_insecure_server": "Mobil uygulama posta sunucularına yalnızca https üzerinden bağlanır, bu sunucu ise http kullanıyor."
},
"public_keys": {
"name_label": "Ortak anahtar adı",
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "Вхід на телефоні виконано",
"success_body": "Цю сторінку можна закрити. Щоб прив'язати ще один пристрій, покажіть новий код.",
"link_another": "Прив'язати ще один пристрій",
"use_sso": "Натомість використати єдиний вхід"
"use_sso": "Натомість використати єдиний вхід",
"error_insecure_server": "Мобільний застосунок підключається до поштових серверів лише через https, а цей сервер використовує http."
},
"public_keys": {
"name_label": "Назва відкритого ключа",
+2 -1
View File
@@ -1759,7 +1759,8 @@
"success_title": "已在手機上登入",
"success_body": "你可以關閉此頁面。若要連結其他裝置,請顯示新的 QR Code。",
"link_another": "連結其他裝置",
"use_sso": "改用單一登入"
"use_sso": "改用單一登入",
"error_insecure_server": "行動應用程式只透過 https 連線到郵件伺服器,而此伺服器使用的是 http。"
}
},
"identities": {
+2 -1
View File
@@ -1745,7 +1745,8 @@
"success_title": "已在手机上登录",
"success_body": "你可以关闭此页面。如需关联其他设备,请显示新的二维码。",
"link_another": "关联其他设备",
"use_sso": "改用单点登录"
"use_sso": "改用单点登录",
"error_insecure_server": "移动应用只通过 https 连接邮件服务器,而此服务器使用的是 http。"
},
"public_keys": {
"name_label": "公钥名称",