mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
911e642c24
commit
b100c73a62
22 files changed
+1325
-463
No files matched your search
+523
-441
File diff suppressed because it is too large.
Load diff
@@ -207,6 +207,13 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds
|
||||
the container unhealthy if Postgres is still
|
||||
starting. Tail `docker compose logs db` to confirm Postgres came up
|
||||
cleanly.
|
||||
- **The login page says "Signing in cannot complete on this page".** The
|
||||
server sets its session cookie with the `Secure` flag (the production
|
||||
default) and the page was opened over plain `http://`, where the browser
|
||||
would drop it. Set `SESSION_COOKIE_SECURE=false` for a server reached only
|
||||
over HTTP on a private network, or open the `https://` address (behind a TLS
|
||||
proxy, make sure it forwards `X-Forwarded-Proto: https`). The sign-in is
|
||||
refused before any password, code or ticket is used.
|
||||
- **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and
|
||||
`NEXT_PUBLIC_APP_URL` both point at the public hostname, not at
|
||||
`localhost`, and restart `app` after the change.
|
||||
|
||||
@@ -114,3 +114,30 @@ describe("GET /api/auth/registration-status", () => {
|
||||
expect(body.data.registrationEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/registration-status — sessionCookieSecure (#1097)", () => {
|
||||
it("publishes whether the server issues Secure session cookies, on every branch", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
} as never);
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
let body = await (await callGet()).json();
|
||||
expect(body.data.sessionCookieSecure).toBe(true);
|
||||
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
|
||||
body = await (await callGet()).json();
|
||||
expect(body.data.sessionCookieSecure).toBe(false);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockRejectedValue(
|
||||
new Error("db down"),
|
||||
);
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
body = await (await callGet()).json();
|
||||
expect(body.data).toEqual({
|
||||
registrationEnabled: false,
|
||||
sessionCookieSecure: true,
|
||||
});
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY means password login must be a dead end, not just a hidden
|
||||
@@ -65,6 +66,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// A browser page on plain http:// with Secure-only cookies cannot keep the
|
||||
// session. Refuse before the password is verified (the account throttle
|
||||
// never counts it) and before an MFA ticket is minted.
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
|
||||
const parsed = loginPasswordSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { mfaVerifySchema } from "@/lib/validations/mfa";
|
||||
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -65,6 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Before the ticket is loaded, an attempt reserved, or a one-time code
|
||||
// spent: a sign-in whose cookie cannot stick must not consume either.
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
|
||||
const parsed = mfaVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
|
||||
@@ -39,6 +39,7 @@ import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa";
|
||||
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -63,6 +64,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Before the ticket is loaded or an attempt reserved (see mfa/verify).
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
|
||||
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { transportVerdict } from "@/lib/auth/client-transport";
|
||||
import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
|
||||
import {
|
||||
@@ -54,6 +55,24 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
return loginError("oidc_disabled");
|
||||
}
|
||||
|
||||
// The SSO button states the page's transport. A browser on plain http://
|
||||
// with Secure-only cookies would come back from the provider and drop the
|
||||
// session cookie, so it is stopped here instead of after the round trip.
|
||||
if (!isNative) {
|
||||
const clientProto = req.nextUrl.searchParams.get("client_proto");
|
||||
const clientHost = req.nextUrl.searchParams.get("client_host") ?? "";
|
||||
if (
|
||||
(clientProto === "http" || clientProto === "https") &&
|
||||
transportVerdict(shouldEmitSecureCookie(), {
|
||||
protocol: clientProto,
|
||||
host: clientHost,
|
||||
}) === "blocked"
|
||||
) {
|
||||
annotate({ meta: { reason: "insecure_transport" } });
|
||||
return loginError("insecure_transport");
|
||||
}
|
||||
}
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:login",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
// OIDC_ONLY must block passkey login too, not just password login.
|
||||
@@ -31,6 +32,15 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
// The body is optional (the web client sends its page transport; nothing
|
||||
// else needs to send anything). A missing or unreadable body is no body, as
|
||||
// before; only a stated plain-http page refuses, before a challenge exists.
|
||||
if (request.headers.get("content-type")?.includes("application/json")) {
|
||||
const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 });
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
}
|
||||
|
||||
const { options, challengeId } = await createAuthenticationOptions();
|
||||
|
||||
annotate({ action: { name: "auth.passkey.login-options" } });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -15,10 +16,16 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
return apiSuccess({
|
||||
registrationEnabled: settings?.registrationEnabled ?? true,
|
||||
// Whether this server issues `Secure` session cookies. The login page
|
||||
// compares it with its own scheme before any credential is sent.
|
||||
sessionCookieSecure: shouldEmitSecureCookie(),
|
||||
});
|
||||
} catch {
|
||||
// Fail closed on backend errors.
|
||||
annotate({ action: { name: "auth.registration-status" } });
|
||||
return apiSuccess({ registrationEnabled: false });
|
||||
return apiSuccess({
|
||||
registrationEnabled: false,
|
||||
sessionCookieSecure: shouldEmitSecureCookie(),
|
||||
});
|
||||
}
|
||||
});
|
||||
+68
-15
@@ -25,6 +25,14 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { sanitizeSameOriginPath } from "@/lib/url-safety";
|
||||
import {
|
||||
InsecureTransportBanner,
|
||||
useClientTransport,
|
||||
} from "@/components/auth/insecure-transport-banner";
|
||||
import { transportVerdict } 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 { loadDocument } from "@/lib/navigation/load-document";
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -44,7 +52,8 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(() => {
|
||||
const oidcError = searchParams.get("error");
|
||||
if (!oidcError) return null;
|
||||
// The SSO start refused a page on plain http://; the banner says why.
|
||||
if (!oidcError || oidcError === "insecure_transport") return null;
|
||||
const key: Record<string, string> = {
|
||||
oidc_denied: "auth.oidc.errorDenied",
|
||||
oidc_no_email: "auth.oidc.errorNoEmail",
|
||||
@@ -71,22 +80,55 @@ export default function LoginPage() {
|
||||
// standalone alert detached from the form.
|
||||
const errorId = useId();
|
||||
const errorDescriptor = error ? errorId : undefined;
|
||||
const { data: registrationEnabled } = useQuery({
|
||||
const { data: registrationStatus } = useQuery({
|
||||
queryKey: queryKeys.authRegistrationStatus(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const data = await apiGet<{ registrationEnabled?: boolean }>(
|
||||
"/api/auth/registration-status",
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
return Boolean(data?.registrationEnabled ?? true);
|
||||
const data = await apiGet<{
|
||||
registrationEnabled?: boolean;
|
||||
sessionCookieSecure?: boolean;
|
||||
}>("/api/auth/registration-status", { cache: "no-store" });
|
||||
return {
|
||||
registrationEnabled: Boolean(data?.registrationEnabled ?? true),
|
||||
sessionCookieSecure: data?.sessionCookieSecure === true,
|
||||
};
|
||||
} catch {
|
||||
// Fail open, exactly as the raw `if (!res.ok) return true` did.
|
||||
return true;
|
||||
// Fail open, exactly as the raw `if (!res.ok) return true` did; and
|
||||
// no transport gate without the server's word on its cookies.
|
||||
return { registrationEnabled: true, sessionCookieSecure: false };
|
||||
}
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const registrationEnabled = registrationStatus?.registrationEnabled;
|
||||
|
||||
// #1097 — a server that sets its sign-in cookie only over https:// cannot
|
||||
// sign anyone in on a plain http:// page: the browser drops the cookie. Say
|
||||
// so before a credential leaves the browser, and stop the sign-in controls.
|
||||
// The server refuses the same requests (409) if they are sent anyway, and
|
||||
// the SSO start sends the browser back here with `insecure_transport`.
|
||||
const clientTransport = useClientTransport();
|
||||
const [transportRefused, setTransportRefused] = useState(
|
||||
() => searchParams.get("error") === "insecure_transport",
|
||||
);
|
||||
const verdict = transportRefused
|
||||
? "blocked"
|
||||
: transportVerdict(
|
||||
registrationStatus?.sessionCookieSecure === true,
|
||||
clientTransport,
|
||||
);
|
||||
const signInBlocked = verdict === "blocked";
|
||||
|
||||
function noteRefusal(err: unknown): boolean {
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
|
||||
) {
|
||||
setTransportRefused(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const { data: oidcStatus } = useQuery({
|
||||
queryKey: queryKeys.authOidcStatus(),
|
||||
@@ -106,6 +148,10 @@ export default function LoginPage() {
|
||||
|
||||
function handleOidcLogin() {
|
||||
const params = new URLSearchParams({ next: getRedirectTarget() });
|
||||
if (clientTransport) {
|
||||
params.set("client_proto", clientTransport.protocol);
|
||||
params.set("client_host", clientTransport.host);
|
||||
}
|
||||
loadDocument(`/api/auth/oidc/login?${params}`);
|
||||
}
|
||||
|
||||
@@ -176,7 +222,7 @@ export default function LoginPage() {
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/login-options");
|
||||
}>("/api/auth/passkey/login-options", { clientTransport });
|
||||
|
||||
const credential = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
@@ -189,6 +235,7 @@ export default function LoginPage() {
|
||||
// navigates; the shell refetches `["auth","me"]` fresh on mount.
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
if (noteRefusal(err)) return;
|
||||
// Route rejections carry the envelope error verbatim; everything
|
||||
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
|
||||
if (err instanceof ApiError) {
|
||||
@@ -220,7 +267,7 @@ export default function LoginPage() {
|
||||
>("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, clientTransport }),
|
||||
});
|
||||
|
||||
if (meta?.mfaRequired && meta.mfaTicket) {
|
||||
@@ -235,6 +282,7 @@ export default function LoginPage() {
|
||||
// navigates; the shell refetches `["auth","me"]` fresh on mount.
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
if (noteRefusal(err)) return;
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
@@ -289,6 +337,9 @@ export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
if (oidcStatus === undefined) return;
|
||||
if (oidcStatus.only === true) return;
|
||||
// Wait for the server's word on its cookies, and never arm the browser's
|
||||
// credential prompt for a sign-in that cannot keep its cookie.
|
||||
if (registrationStatus === undefined || signInBlocked) return;
|
||||
if (autofillStarted.current) return;
|
||||
autofillStarted.current = true;
|
||||
let cancelled = false;
|
||||
@@ -302,7 +353,7 @@ export default function LoginPage() {
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/login-options");
|
||||
}>("/api/auth/passkey/login-options", { clientTransport });
|
||||
|
||||
const credential = await startAuthentication({
|
||||
optionsJSON: options,
|
||||
@@ -327,7 +378,7 @@ export default function LoginPage() {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [oidcStatus]);
|
||||
}, [oidcStatus, registrationStatus, signInBlocked]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-4">
|
||||
@@ -380,12 +431,14 @@ export default function LoginPage() {
|
||||
data-slot="login-actions"
|
||||
className="mt-8 space-y-4"
|
||||
>
|
||||
<InsecureTransportBanner verdict={verdict} />
|
||||
{oidcStatus?.enabled && (
|
||||
<Button
|
||||
onClick={handleOidcLogin}
|
||||
variant={oidcStatus.only ? "default" : "outline"}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={signInBlocked}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
{oidcStatus.buttonLabel || t("auth.oidc.signInDefault")}
|
||||
@@ -414,7 +467,7 @@ export default function LoginPage() {
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
disabled={loading || signInBlocked}
|
||||
>
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
@@ -491,7 +544,7 @@ export default function LoginPage() {
|
||||
data-testid="login-password-submit"
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
disabled={loading || signInBlocked}
|
||||
>
|
||||
{loading && mode === "password" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { isLocalhostHost, transportVerdict } from "@/lib/auth/client-transport";
|
||||
import {
|
||||
cookieCannotStick,
|
||||
INSECURE_TRANSPORT_CODE,
|
||||
refuseWhenCookieCannotStick,
|
||||
} from "@/lib/auth/transport-refusal";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("isLocalhostHost", () => {
|
||||
it.each([
|
||||
"localhost",
|
||||
"localhost:3000",
|
||||
"app.localhost:8080",
|
||||
"127.0.0.1",
|
||||
"127.0.0.1:3000",
|
||||
"127.1.2.3",
|
||||
"[::1]",
|
||||
"[::1]:3000",
|
||||
"LOCALHOST",
|
||||
])("treats %s as loopback", (host) => {
|
||||
expect(isLocalhostHost(host)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"192.168.1.20:3000",
|
||||
"truenas.local:30180",
|
||||
"healthlog.example.com",
|
||||
"10.0.0.5",
|
||||
"127.0.0.1.nip.io",
|
||||
"localhost.example.com",
|
||||
"[fe80::1]:3000",
|
||||
])("treats %s as a network address", (host) => {
|
||||
expect(isLocalhostHost(host)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transportVerdict", () => {
|
||||
const http = (host: string) => ({ protocol: "http" as const, host });
|
||||
it("blocks plain http on a network address when cookies are Secure", () => {
|
||||
expect(transportVerdict(true, http("192.168.1.20:3000"))).toBe("blocked");
|
||||
});
|
||||
it("only notes plain http on localhost", () => {
|
||||
expect(transportVerdict(true, http("localhost:3000"))).toBe("soft");
|
||||
});
|
||||
it("passes https, non-Secure cookies, and an unknown transport", () => {
|
||||
expect(
|
||||
transportVerdict(true, { protocol: "https", host: "nas.local" }),
|
||||
).toBe("ok");
|
||||
expect(transportVerdict(false, http("192.168.1.20:3000"))).toBe("ok");
|
||||
expect(transportVerdict(true, undefined)).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refuseWhenCookieCannotStick", () => {
|
||||
const body = (protocol: string, host = "192.168.1.20:3000") => ({
|
||||
email: "a",
|
||||
password: "b",
|
||||
clientTransport: { protocol, host },
|
||||
});
|
||||
|
||||
it("refuses http + Secure with 409 and the code", async () => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
const response = refuseWhenCookieCannotStick(body("http"));
|
||||
expect(response?.status).toBe(409);
|
||||
const json = await response!.json();
|
||||
expect(json.meta).toEqual({
|
||||
errorCode: INSECURE_TRANSPORT_CODE,
|
||||
sessionCookieSecure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes https, localhost, a body without the field, and SESSION_COOKIE_SECURE=false", () => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
expect(refuseWhenCookieCannotStick(body("https"))).toBeNull();
|
||||
expect(
|
||||
refuseWhenCookieCannotStick(body("http", "localhost:3000")),
|
||||
).toBeNull();
|
||||
expect(
|
||||
refuseWhenCookieCannotStick({ email: "a", password: "b" }),
|
||||
).toBeNull();
|
||||
expect(refuseWhenCookieCannotStick(null)).toBeNull();
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
|
||||
expect(cookieCannotStick(body("http"))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a malformed field rather than refusing on it", () => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
expect(
|
||||
cookieCannotStick({ clientTransport: { protocol: "gopher", host: "x" } }),
|
||||
).toBe(false);
|
||||
expect(cookieCannotStick({ clientTransport: "http" })).toBe(false);
|
||||
});
|
||||
|
||||
it("follows NODE_ENV when SESSION_COOKIE_SECURE is unset", () => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
expect(cookieCannotStick(body("http"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* What the browser knows and the server does not: whether this page was
|
||||
* opened over https://. A `Secure` cookie only sticks on an https:// page
|
||||
* (and, in most browsers, on http://localhost). The server knows whether it
|
||||
* will issue `Secure` cookies (`shouldEmitSecureCookie`, published as
|
||||
* `sessionCookieSecure` on `GET /api/auth/registration-status`); combining the
|
||||
* two facts says, before any credential leaves the browser, whether a sign-in
|
||||
* here can finish.
|
||||
*
|
||||
* Pure and dependency-free: imported by the login page and by the server
|
||||
* refusal (`./transport-refusal.ts`) so both sides apply the same rule.
|
||||
*/
|
||||
|
||||
export interface ClientTransport {
|
||||
protocol: "http" | "https";
|
||||
/** `location.host`: hostname plus an optional port. */
|
||||
host: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loopback names a browser treats as a secure context. A `Secure` cookie set
|
||||
* on one of them usually sticks even over http://, so a sign-in there gets a
|
||||
* soft note, never a refusal.
|
||||
*/
|
||||
export function isLocalhostHost(host: string): boolean {
|
||||
let name = host.trim().toLowerCase();
|
||||
if (name.startsWith("[")) {
|
||||
// [::1] or [::1]:3000
|
||||
name = name.slice(
|
||||
1,
|
||||
name.indexOf("]") === -1 ? undefined : name.indexOf("]"),
|
||||
);
|
||||
} else if (name.split(":").length === 2) {
|
||||
name = name.split(":")[0];
|
||||
}
|
||||
if (name === "localhost" || name.endsWith(".localhost")) return true;
|
||||
if (name === "::1") return true;
|
||||
return /^127(?:\.\d{1,3}){3}$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* - `blocked`: the server sets `Secure` cookies and this page is plain http on
|
||||
* a non-loopback host; a sign-in cannot keep its cookie, so it must not start.
|
||||
* - `soft`: the same on a loopback host; most browsers accept it, say so.
|
||||
* - `ok`: nothing to say.
|
||||
*/
|
||||
export type TransportVerdict = "ok" | "soft" | "blocked";
|
||||
|
||||
export function transportVerdict(
|
||||
sessionCookieSecure: boolean,
|
||||
transport: ClientTransport | undefined,
|
||||
): TransportVerdict {
|
||||
if (!sessionCookieSecure || !transport || transport.protocol !== "http") {
|
||||
return "ok";
|
||||
}
|
||||
return isLocalhostHost(transport.host) ? "soft" : "blocked";
|
||||
}
|
||||
|
||||
/** The current page's transport, for the request bodies the server checks. */
|
||||
export function currentClientTransport(): ClientTransport | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||
return { protocol, host: window.location.host };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Refuse a browser sign-in whose session cookie cannot stick, BEFORE it spends
|
||||
* anything: before the password is verified (so the account throttle never
|
||||
* counts it), before an MFA ticket is minted, before a ticket or a one-time
|
||||
* code is consumed, before a passkey challenge is issued.
|
||||
*
|
||||
* The fact comes from the client (`clientTransport`), not from proxy headers:
|
||||
* a TLS proxy that omits `X-Forwarded-Proto` would make a header heuristic
|
||||
* refuse a correctly served install. A body without the field is never
|
||||
* refused. The header heuristic in `secure-cookie.ts` stays as the operator
|
||||
* log line for clients that do not send it.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { transportVerdict } from "@/lib/auth/client-transport";
|
||||
import { clientTransportSchema } from "@/lib/validations/client-transport";
|
||||
|
||||
export const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
|
||||
|
||||
/** True when the body states a transport the session cookie cannot survive. */
|
||||
export function cookieCannotStick(body: unknown): boolean {
|
||||
if (!body || typeof body !== "object") return false;
|
||||
const parsed = clientTransportSchema.safeParse(
|
||||
(body as { clientTransport?: unknown }).clientTransport,
|
||||
);
|
||||
if (!parsed.success) return false;
|
||||
return transportVerdict(shouldEmitSecureCookie(), parsed.data) === "blocked";
|
||||
}
|
||||
|
||||
/** The 409 to return, or null when the sign-in may proceed. */
|
||||
export function refuseWhenCookieCannotStick(body: unknown): Response | null {
|
||||
if (!cookieCannotStick(body)) return null;
|
||||
annotate({ action: { name: "auth.session.insecure_transport.refused" } });
|
||||
return apiError(
|
||||
"This server only sets its sign-in cookie over https://, but this page was opened over http://, so signing in cannot complete here. Open the https:// address, or set SESSION_COOKIE_SECURE=false for a server that is only reached over plain HTTP on a private network.",
|
||||
409,
|
||||
{ errorCode: INSECURE_TRANSPORT_CODE, sessionCookieSecure: true },
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"auth.reproof.required",
|
||||
"auth.reproof.too_weak",
|
||||
"auth.scope.insufficient",
|
||||
"auth.session.insecure_transport",
|
||||
"auth.stepup.required",
|
||||
"auth.token.expired",
|
||||
"auth.token.invalid",
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
errorEnvelope,
|
||||
loginPasswordSchema,
|
||||
} from "./shared";
|
||||
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
|
||||
|
||||
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
|
||||
|
||||
@@ -438,9 +439,24 @@ const registrationStatusResponse = z
|
||||
.describe(
|
||||
"Whether open self-registration is on. An operator who turned it off can still admit a signup through an invite token.",
|
||||
),
|
||||
sessionCookieSecure: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether this server issues its session cookies with the `Secure` flag (`SESSION_COOKIE_SECURE`, else on in production). A browser on a plain `http://` page cannot keep such a cookie; the web login page compares this with its own scheme before any credential is sent.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "RegistrationStatusResponse" });
|
||||
|
||||
/**
|
||||
* The browser sign-in refusal for a page whose session cookie cannot stick.
|
||||
* Only ever returned to a caller that sent `clientTransport`.
|
||||
*/
|
||||
const insecureTransportResponse = {
|
||||
description:
|
||||
'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was verified, no MFA ticket was minted or consumed, no code was spent. A request without `clientTransport` (the iOS app, scripts) is never refused this way.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
const apiTokenInfo = z
|
||||
.object({
|
||||
id: z.string().describe("Row id; the handle for DELETE."),
|
||||
@@ -701,6 +717,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: loginPasswordSchema } },
|
||||
},
|
||||
responses: {
|
||||
"409": insecureTransportResponse,
|
||||
"200": {
|
||||
description:
|
||||
"Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`).",
|
||||
@@ -733,6 +750,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: mfaVerifySchema } },
|
||||
},
|
||||
responses: {
|
||||
"409": insecureTransportResponse,
|
||||
"200": {
|
||||
description:
|
||||
"Second factor verified — session + optional bearer issued.",
|
||||
@@ -1247,6 +1265,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"409": insecureTransportResponse,
|
||||
"200": {
|
||||
description:
|
||||
"Second factor verified — session + optional bearer issued.",
|
||||
@@ -1613,8 +1632,15 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Begin a passkey sign-in (discoverable credentials)",
|
||||
description:
|
||||
"Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST /api/auth/passkey/login-verify. Anonymous, takes no body, and scopes the assertion to no account — the options carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain is misconfigured rather than falling open.",
|
||||
"Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST /api/auth/passkey/login-verify. Anonymous, takes no body (the web client may send an optional `clientTransport`), and scopes the assertion to no account — the options carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain is misconfigured rather than falling open.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": { schema: passkeyLoginOptionsSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"409": insecureTransportResponse,
|
||||
"200": {
|
||||
description: "Assertion options issued.",
|
||||
content: {
|
||||
@@ -2117,12 +2143,25 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
.describe(
|
||||
"Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg.",
|
||||
),
|
||||
client_proto: z
|
||||
.enum(["http", "https"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Browser leg only: the scheme of the page the SSO button was pressed on. With `http` on a non-loopback `client_host` while the server issues `Secure` session cookies, the start redirects to `/auth/login?error=insecure_transport` instead of the provider, because the session cookie would be dropped on return.",
|
||||
),
|
||||
client_host: z
|
||||
.string()
|
||||
.max(255)
|
||||
.optional()
|
||||
.describe(
|
||||
"Browser leg only: `location.host` of that page, read together with `client_proto`.",
|
||||
),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"302": {
|
||||
description:
|
||||
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
|
||||
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isValidKvnr, normaliseKvnr } from "@/lib/validations/kvnr";
|
||||
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
|
||||
import { clientTransportSchema } from "@/lib/validations/client-transport";
|
||||
|
||||
/** Minimum password length — must match checkPasswordStrength() in @/lib/auth/password.ts */
|
||||
const PASSWORD_MIN_LENGTH = 12;
|
||||
@@ -40,8 +41,18 @@ export const registerSchema = z.object({
|
||||
export const loginPasswordSchema = z.object({
|
||||
email: z.string().trim().min(1, "Email or username required"),
|
||||
password: z.string().min(1),
|
||||
clientTransport: clientTransportSchema.optional(),
|
||||
});
|
||||
|
||||
/** `POST /api/auth/passkey/login-options`: an optional body, web client only. */
|
||||
export const passkeyLoginOptionsSchema = z
|
||||
.object({ clientTransport: clientTransportSchema.optional() })
|
||||
.meta({
|
||||
id: "PasskeyLoginOptionsRequest",
|
||||
description:
|
||||
"Optional. The web client sends its page transport so a sign-in that could not keep its cookie is refused before a challenge is issued. Any other caller may send no body at all.",
|
||||
});
|
||||
|
||||
export const profileSchema = z.object({
|
||||
email: z.email("Invalid email address").nullable().optional(),
|
||||
heightCm: z.number().min(50).max(300).nullable().optional(),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* The optional `clientTransport` field the web client sends on the browser
|
||||
* sign-in requests. Optional everywhere: a client that does not send it (the
|
||||
* iOS app, scripts) is never refused because of it. It can only ever cause a
|
||||
* refusal of its own request, so a forged value harms nobody but its sender.
|
||||
*/
|
||||
export const clientTransportSchema = z
|
||||
.object({
|
||||
protocol: z
|
||||
.enum(["http", "https"])
|
||||
.describe("The scheme of the page the request comes from."),
|
||||
host: z
|
||||
.string()
|
||||
.max(255)
|
||||
.describe("`location.host` of that page: hostname and optional port."),
|
||||
})
|
||||
.meta({
|
||||
id: "ClientTransport",
|
||||
description:
|
||||
"Sent by the web client only. When the server issues `Secure` session cookies and this says the page is on plain `http://` (and not a loopback host), the sign-in is refused with 409 `auth.session.insecure_transport` before any credential, ticket or code is checked or used, because the browser would drop the session cookie.",
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
* auth.ts`) describe the identical wire contract.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { clientTransportSchema } from "@/lib/validations/client-transport";
|
||||
|
||||
/** A 6-digit TOTP code. */
|
||||
export const totpCodeSchema = z
|
||||
@@ -42,6 +43,7 @@ export const mfaVerifySchema = z
|
||||
.describe(
|
||||
"Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).",
|
||||
),
|
||||
clientTransport: clientTransportSchema.optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "MfaVerifyRequest",
|
||||
@@ -111,6 +113,7 @@ export const mfaWebauthnLoginVerifySchema = z
|
||||
.describe(
|
||||
"Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).",
|
||||
),
|
||||
clientTransport: clientTransportSchema.optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "MfaWebauthnLoginVerifyRequest",
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* #1097 against real Postgres: a browser sign-in whose session cookie cannot
|
||||
* stick (Secure-only cookies, page on plain http:// at a network address) is
|
||||
* refused BEFORE it spends anything.
|
||||
*
|
||||
* - the password step mints no MFA ticket, creates no session and charges
|
||||
* no account-throttle failure;
|
||||
* - the MFA step leaves the ticket unclaimed and the TOTP code unspent, so
|
||||
* the same ticket and code still complete over https;
|
||||
* - the passkey start issues no challenge;
|
||||
* - a request without `clientTransport` (the iOS app, scripts) is never
|
||||
* refused; neither is localhost.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-test-hmac-key-test-hmac-key-0123456789";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { hashPassword } = await import("@/lib/auth/password");
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
|
||||
|
||||
const PASSWORD = "Correct horse battery staple!42";
|
||||
const LAN_HTTP = { protocol: "http", host: "192.168.1.20:3000" };
|
||||
const HTTPS = { protocol: "https", host: "healthlog.example.test" };
|
||||
|
||||
let secret: string;
|
||||
|
||||
function totpNow(): string {
|
||||
return new OTPAuth.TOTP({
|
||||
issuer: "HealthLog",
|
||||
label: "HealthLog",
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
}).generate({ timestamp: Date.now() });
|
||||
}
|
||||
|
||||
function post(path: string, body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function login(clientTransport?: unknown, password = PASSWORD) {
|
||||
const { POST } = await import("@/app/api/auth/login/route");
|
||||
return POST(
|
||||
post("/api/auth/login", {
|
||||
email: "mfa@example.test",
|
||||
password,
|
||||
...(clientTransport ? { clientTransport } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function mfaVerify(
|
||||
mfaTicket: string,
|
||||
code: string,
|
||||
clientTransport?: unknown,
|
||||
) {
|
||||
const { POST } = await import("@/app/api/auth/mfa/verify/route");
|
||||
return POST(
|
||||
post("/api/auth/mfa/verify", {
|
||||
mfaTicket,
|
||||
method: "totp",
|
||||
code,
|
||||
...(clientTransport ? { clientTransport } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.mfaChallenge.deleteMany();
|
||||
await prisma.authChallenge.deleteMany();
|
||||
await prisma.rateLimit.deleteMany();
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
secret = generateTotpSecret();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: "mfa-user",
|
||||
username: "mfa-user",
|
||||
email: "mfa@example.test",
|
||||
passwordHash: await hashPassword(PASSWORD),
|
||||
onboardingCompletedAt: new Date(),
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
totpConfirmedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("sign-in over plain http with Secure-only cookies (real Postgres)", () => {
|
||||
it("refuses the password step before a ticket, a session or a throttle charge", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
// A wrong password too: the refusal must come before the check, so a
|
||||
// wrong password cannot be told from a right one here either.
|
||||
for (const password of [PASSWORD, "wrong password entirely"]) {
|
||||
const res = await login(LAN_HTTP, password);
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "auth.session.insecure_transport",
|
||||
sessionCookieSecure: true,
|
||||
});
|
||||
}
|
||||
expect(await prisma.mfaChallenge.count()).toBe(0);
|
||||
expect(await prisma.session.count()).toBe(0);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { action: { startsWith: "auth.login" } },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.rateLimit.count({
|
||||
where: { NOT: { key: { startsWith: "auth:login" } } },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves the ticket and the code unspent at the MFA step", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const first = await login(HTTPS);
|
||||
expect(first.status).toBe(200);
|
||||
const ticket = (await first.json()).meta.mfaTicket as string;
|
||||
expect(ticket).toBeTruthy();
|
||||
|
||||
const code = totpNow();
|
||||
const refused = await mfaVerify(ticket, code, LAN_HTTP);
|
||||
expect(refused.status).toBe(409);
|
||||
expect((await refused.json()).meta.errorCode).toBe(
|
||||
"auth.session.insecure_transport",
|
||||
);
|
||||
|
||||
const challenge = await prisma.mfaChallenge.findFirstOrThrow();
|
||||
expect(challenge.attempts).toBe(0);
|
||||
expect(challenge.consumedAt).toBeNull();
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: "mfa-user" },
|
||||
select: { totpLastStep: true },
|
||||
});
|
||||
expect(user.totpLastStep).toBeNull();
|
||||
|
||||
// The same ticket and the same code still finish over https.
|
||||
const done = await mfaVerify(ticket, code, HTTPS);
|
||||
expect(done.status).toBe(200);
|
||||
expect(await prisma.session.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("issues no passkey challenge for a page on plain http", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { POST } = await import("@/app/api/auth/passkey/login-options/route");
|
||||
const refused = await POST(
|
||||
post("/api/auth/passkey/login-options", { clientTransport: LAN_HTTP }),
|
||||
);
|
||||
expect(refused.status).toBe(409);
|
||||
expect(await prisma.authChallenge.count()).toBe(0);
|
||||
|
||||
const ok = await POST(
|
||||
new NextRequest("http://localhost/api/auth/passkey/login-options", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await prisma.authChallenge.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("never refuses a caller that sends no transport, or localhost", async () => {
|
||||
const withoutField = await login();
|
||||
expect(withoutField.status).toBe(200);
|
||||
expect((await withoutField.json()).meta.mfaRequired).toBe(true);
|
||||
|
||||
const local = await login({ protocol: "http", host: "localhost:3000" });
|
||||
expect(local.status).toBe(200);
|
||||
});
|
||||
|
||||
it("does not refuse when the server does not set Secure cookies", async () => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
|
||||
const res = await login(LAN_HTTP);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user