fix(api): give the remaining 401 throws a code, and say what the code covers

Three refusals still answered 401 with prose alone: the Bearer re-read on
`/api/auth/me/sessions`, and the wrong-second-factor arm on both MFA step-up
routes. The shared 401 description told clients the code is the field to
branch on, so a client that took that advice landed in its default arm on all
three — and on the MFA pair that is the difference between re-prompting for a
digit and signing the user out over a typo.

The sessions re-read is the same missing-credential fact the gate already
names, so it reuses `auth.missing`. The MFA pair gets a code of its own,
`auth.mfa.code_invalid`: none of the existing ones fit, because every other
401 code asks the client to discard something, and this one asks it to keep
the session and try another code.

The description narrows to match what is actually true. The generic gates
name a reason; a route that checks a credential of its own — sign-in, the MFA
challenge exchange, a password confirmation — still answers 401 with prose,
and the text now says so instead of promising a field those responses do not
carry.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:59:40 +02:00
1 parent dd8a724039
commit 2c63b0c4a7
7 files changed
+33 -10

No files matched your search

@@ -147,6 +147,10 @@ describe("POST /api/auth/me/mfa/disable", () => {
} as never);
const res = await POST(req());
expect(res.status).toBe(401);
// The code is what separates "re-prompt for a digit" from "this session is
// gone"; a client that branches on prose here signs the user out on a typo.
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("auth.mfa.code_invalid");
expect(destroyOtherSessions).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -13,6 +13,7 @@
*/
import {
apiHandler,
AUTH_ERROR_CODES,
requireMfaManagementAuth,
HttpError,
} from "@/lib/api-handler";
@@ -81,7 +82,7 @@ export const POST = apiHandler(async (req: Request) => {
},
});
annotate({ action: { name: "auth.mfa.disable.invalid_factor" } });
throw new HttpError(401, "Invalid code");
throw new HttpError(401, "Invalid code", AUTH_ERROR_CODES.mfaCode);
}
// Every cheap check has passed and the teardown is next, so spend the
@@ -18,6 +18,7 @@
*/
import {
apiHandler,
AUTH_ERROR_CODES,
requireMfaManagementAuth,
HttpError,
} from "@/lib/api-handler";
@@ -99,7 +100,7 @@ export const POST = apiHandler(async (req: Request) => {
action: { name: "auth.mfa.totp.confirm.invalid" },
meta: { replay: result.replay },
});
throw new HttpError(401, "Invalid code");
throw new HttpError(401, "Invalid code", AUTH_ERROR_CODES.mfaCode);
}
const recoveryCodes = generateRecoveryCodes();
+7 -2
View File
@@ -13,7 +13,12 @@
*/
import { NextRequest } from "next/server";
import { headers } from "next/headers";
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
AUTH_ERROR_CODES,
HttpError,
requireAuth,
} from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -139,7 +144,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
async function presentedBearerToken(): Promise<string> {
const authHeader = (await headers()).get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
throw new HttpError(401, "Not authenticated");
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
}
return authHeader.slice(7);
}