fix(auth): refuse a sign-in over plain http before it spends a factor (#1097)

A server that issues Secure session cookies cannot sign anyone in on a
plain http:// page: the browser drops the cookie. The login request was
still verified, the MFA ticket minted and then claimed, and the next
attempt answered "Invalid or expired challenge".

The web client now sends an optional clientTransport on the browser
sign-in requests (password login, passkey start, both MFA completions).
When the server issues Secure cookies and the page is plain http on a
non-loopback host, the request is refused with 409
auth.session.insecure_transport before the password is checked, a
ticket is minted or claimed, a code is spent, or a passkey challenge is
issued. A request without the field is never refused. The SSO start
takes client_proto/client_host and sends such a page back to the login
page instead of to the provider.

GET /api/auth/registration-status publishes sessionCookieSecure, and the
login page shows the reason with both fixes and disables the sign-in
controls before anything is sent; on localhost it shows a note only.
SESSION_COOKIE_SECURE keeps its meaning; the header heuristic log line
stays.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:21:19 +02:00
1 parent 911e642c24
commit b100c73a62
22 files changed
+1325 -463

No files matched your search

@@ -0,0 +1,46 @@
/**
* The login page's transport notice (#1097): the blocked banner names both
* operator fixes, the localhost note is soft, and "ok" renders nothing.
* Mutation check: render the banner for `soft` and the second case goes red.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { InsecureTransportBanner } from "@/components/auth/insecure-transport-banner";
import type { TransportVerdict } from "@/lib/auth/client-transport";
function render(verdict: TransportVerdict, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<InsecureTransportBanner verdict={verdict} />
</I18nProvider>,
);
}
describe("InsecureTransportBanner", () => {
it("blocked: an alert naming SESSION_COOKIE_SECURE and https", () => {
const html = render("blocked");
expect(html).toContain('data-testid="insecure-transport-banner"');
expect(html).toContain('role="alert"');
expect(html).toContain("SESSION_COOKIE_SECURE=false");
expect(html).toContain("https://");
});
it("soft: a note on localhost, no alert", () => {
const html = render("soft");
expect(html).toContain('data-testid="insecure-transport-note"');
expect(html).not.toContain('role="alert"');
});
it("ok: nothing", () => {
expect(render("ok")).toBe("");
});
it("speaks German", () => {
expect(render("blocked", "de")).toContain("https://");
expect(render("blocked", "de")).not.toContain(
"This server only sets its sign-in cookie",
);
});
});
@@ -0,0 +1,75 @@
"use client";
/**
* Login-page notice for a page whose session cookie cannot stick (#1097).
*
* `blocked`: the server sets its sign-in cookie only over https:// and this
* page is plain http:// on a network address. The browser would drop the
* cookie, so the sign-in controls are disabled and the two operator fixes are
* named. `soft`: the same on localhost, where most browsers accept the cookie;
* the form stays usable.
*/
import { useSyncExternalStore } from "react";
import { TriangleAlert } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import type {
ClientTransport,
TransportVerdict,
} from "@/lib/auth/client-transport";
const noopSubscribe = () => () => {};
/**
* The page's transport, read after hydration only (the server render has no
* `location`), so the first client render matches the server HTML.
*/
export function useClientTransport(): ClientTransport | undefined {
const raw = useSyncExternalStore(
noopSubscribe,
() => `${window.location.protocol}//${window.location.host}`,
() => null,
);
if (!raw) return undefined;
const [scheme, host] = raw.split("//");
return { protocol: scheme === "https:" ? "https" : "http", host };
}
export function InsecureTransportBanner({
verdict,
}: {
verdict: TransportVerdict;
}) {
const { t } = useTranslations();
if (verdict === "ok") return null;
if (verdict === "soft") {
return (
<p
data-testid="insecure-transport-note"
className="text-muted-foreground rounded-lg border p-3 text-xs"
>
{t("auth.insecureTransport.localhostNote")}
</p>
);
}
return (
<div
role="alert"
data-testid="insecure-transport-banner"
className="border-warning/40 bg-warning/10 space-y-2 rounded-lg border p-3 text-sm"
>
<p className="flex items-start gap-2 font-semibold">
<TriangleAlert
className="text-warning mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
{t("auth.insecureTransport.title")}
</p>
<p>{t("auth.insecureTransport.body")}</p>
<ul className="list-disc space-y-1 ps-5">
<li>{t("auth.insecureTransport.fixLan")}</li>
<li>{t("auth.insecureTransport.fixHttps")}</li>
</ul>
</div>
);
}
+12 -3
View File
@@ -9,6 +9,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { currentClientTransport } from "@/lib/auth/client-transport";
/** The server's refusal for a page whose sign-in cookie cannot stick. */
const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
import { describePasskeyError } from "@/lib/passkey-errors";
export type MfaMethod = "totp" | "recovery" | "webauthn";
@@ -61,13 +65,17 @@ export function MfaLoginStep({
method: useRecovery ? "recovery" : "totp",
code: code.trim(),
rememberDevice: useRecovery ? false : rememberDevice,
clientTransport: currentClientTransport(),
});
onSuccess();
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("auth.mfa.verifyFailed"),
err instanceof ApiError &&
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
? t("auth.insecureTransport.title")
: err instanceof ApiError && err.message
? err.message
: t("auth.mfa.verifyFailed"),
);
} finally {
setLoading(false);
@@ -89,6 +97,7 @@ export function MfaLoginStep({
challengeId,
credential,
rememberDevice,
clientTransport: currentClientTransport(),
});
onSuccess();
} catch (err) {