mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -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(),
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user