Files
HealthLog/src/app/api/auth/step-up/route.ts
T
Marc-André Bombeck 0c77eaa085 fix(errors): name the refusals behind the AI test and a passkey proof
Three refusals answered without an error code, so the web printed the
status's generic sentence. A connection test with nothing configured
read "Some of the entries were not accepted", and a mistyped password
while adding a passkey read "Your session has ended. Sign in again",
which sent people to sign out for nothing.

An empty chain now resolves to no provider, which the test route
answers with `ai.provider.none`. The passkey registration and the
step-up mint answer a failed or stale proof with `auth.reproof.failed`.
`ai.provider.none`, `ai.budget.exceeded` and `auth.reproof.too_weak`
get sentences in every locale.
2026-11-11 22:49:20 +01:00

173 lines
6.3 KiB
TypeScript

/**
* POST /api/auth/step-up
*
* Re-prove a factor and receive a single-use, token-bound step-up elevation for
* one second-factor-management call.
*
* WHICH factor is re-proved decides WHAT the elevation reaches. `password`
* reaches exactly what a plain cookie session reaches. `totp`, `webauthn`, and
* `passkey` additionally satisfy the fresh-factor routes — disable, recovery-code
* rotation, security-key removal — and that set is precisely the set of
* ceremonies for which the web stamps a session second-factor-verified. If a
* password-proved elevation opened those routes, a stolen token plus the account
* password would rotate the recovery codes (which take no factor in the body)
* and then spend one of them to disable the second factor outright.
*
* This is the whole point of the mechanism, so it is worth being blunt about
* what it is not: it is NOT a way to turn a token into a stronger credential.
* Presenting the token gets you as far as this endpoint and no further — the
* body must carry a fresh proof of the account password or of a primary passkey
* the device holds. A stolen token can call this all day and mint nothing.
*
* Bearer-only (`requireBearerAuth`). A browser cannot reach it even with a valid
* session, which keeps an ambient cookie credential out of the mint path
* entirely.
*
* Failures are deliberately indistinguishable. A wrong password, an account with
* no password set, a passkey belonging to someone else, a stale challenge — all
* return the same 401 with the same prose. The audit row carries the real reason;
* the wire carries none of it.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
checkAuthSurfaceRateLimit,
checkRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import {
REPROOF_FAILED_CODE,
REPROOF_LIMIT,
REPROOF_WINDOW_MS,
reproofBucket,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
import {
mintStepUpElevation,
STEP_UP_ELEVATION_TTL_SECONDS,
} from "@/lib/auth/step-up";
import {
isFreshFactorMethod,
type StepUpMethod,
} from "@/lib/auth/step-up-factors";
import { stepUpMintSchema } from "@/lib/validations/step-up";
export const dynamic = "force-dynamic";
/**
* Per-account ceiling. Tight because the password arm runs an Argon2id verify
* against a user-supplied string — the same 5-per-15-minutes the password-change
* route uses, for the same reason.
*/
const MINT_LIMIT = REPROOF_LIMIT;
const MINT_WINDOW_MS = REPROOF_WINDOW_MS;
export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireBearerAuth();
const { user } = auth;
// `requireAuth`'s contract puts the resolved `ApiToken` row id here. It is the
// binding the minted elevation is tied to.
const apiTokenId = auth.session.id;
const ip = getClientIp(request);
// Two buckets. The per-IP surface bucket collapses to the tight shared anon
// bucket when the proxy trust chain is broken, so a misconfigured deployment
// caps a spray rather than opening one.
const ipRl = await checkAuthSurfaceRateLimit(
request,
"auth:step-up",
20,
MINT_WINDOW_MS,
);
if (!ipRl.allowed) {
return apiError("Too many attempts. Please try again later.", 429, {
headers: rateLimitHeaders(ipRl),
});
}
// The account's shared re-proof bucket: a guess spent here is a guess the
// web enrollment surfaces cannot spend again, and the other way round.
const userRl = await checkRateLimit(
reproofBucket(user.id),
MINT_LIMIT,
MINT_WINDOW_MS,
);
if (!userRl.allowed) {
await auditLog("auth.stepup.mint.rate_limited", {
userId: user.id,
ipAddress: ip,
});
annotate({ action: { name: "auth.stepup.mint.rate_limited" } });
return apiError("Too many attempts. Please wait 15 minutes.", 429, {
headers: rateLimitHeaders(userRl),
});
}
const parsed = await parseJsonBody(request, stepUpMintSchema, {
maxBytes: 64 * 1024,
message: "Invalid request",
});
if (!parsed.ok) return parsed.error;
const method: StepUpMethod = parsed.data.method;
// The shared verifier: the same challenge ownership and ceremony checks, and
// the same confirmed-secret rule for TOTP, as every other re-proof surface.
// The replay guard and the accepted-step burn behave exactly as they do at
// login and at MFA-disable, so a code spent here cannot be replayed there.
const outcome = await verifyExistingFactorProof(user, parsed.data);
const proved = outcome.ok;
// Audit-only detail. Never reaches the response — see the file header.
const failure = outcome.ok ? "" : outcome.reason;
if (!proved) {
await auditLog("auth.stepup.mint.failed", {
userId: user.id,
ipAddress: ip,
details: { method: parsed.data.method, reason: failure },
});
annotate({
action: { name: "auth.stepup.mint.failed" },
meta: { method: parsed.data.method, reason: failure },
});
return apiError("Verification failed", 401, {
errorCode: REPROOF_FAILED_CODE,
});
}
const { token, expiresAt } = await mintStepUpElevation({
userId: user.id,
apiTokenId,
method,
});
await auditLog("auth.stepup.mint.succeeded", {
userId: user.id,
ipAddress: ip,
details: { method },
});
annotate({
action: { name: "auth.stepup.mint.succeeded" },
meta: { method },
});
// `elevation` is the raw secret and is returned exactly once. It is never
// persisted in plaintext and never logged — no wide-event field carries a
// response body, and `redactSecrets` holds an `hle_` rule regardless.
return apiSuccess({
elevation: token,
expiresAt: expiresAt.toISOString(),
expiresInSeconds: STEP_UP_ELEVATION_TTL_SECONDS,
method,
// Whether this elevation reaches the fresh-factor routes (disable,
// recovery-code rotation, security-key removal). A password-proved
// elevation does not, exactly as a password login does not on the web.
// Surfaced so the client can pick the right ceremony up front instead of
// discovering the refusal after spending a proof.
satisfiesFreshFactor: isFreshFactorMethod(method),
});
});