From 2c63b0c4a7e2f4f2c3558789ca53797db98ada7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 04:59:40 +0200 Subject: [PATCH] fix(api): give the remaining 401 throws a code, and say what the code covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 15 ++++++++++----- .../auth/me/mfa/disable/__tests__/route.test.ts | 4 ++++ src/app/api/auth/me/mfa/disable/route.ts | 3 ++- src/app/api/auth/me/mfa/totp/confirm/route.ts | 3 ++- src/app/api/auth/me/sessions/route.ts | 9 +++++++-- src/lib/api-errors.ts | 7 +++++++ src/lib/openapi/routes/shared.ts | 2 +- 7 files changed, 33 insertions(+), 10 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 06a43b581..ad23edaa4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -70,13 +70,18 @@ paths: schema: $ref: "#/components/schemas/CycleCustomSymptomListEnvelope" "401": &a1 - description: "Authentication required or invalid credentials. `meta.errorCode` says which, and it is the field to branch - on — the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session + description: "Authentication required or invalid credentials. The generic auth gates — the ones every route passes + through before its own body runs — name the reason in `meta.errorCode`, and that is the field to branch on + there; the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session cookie and no Bearer header; send the user to sign in. `auth.token.expired`: the Bearer token's lifetime has passed; refresh and retry the request. `auth.token.invalid`: unknown, revoked, or the owning account is - gone; discard the credential and sign in again — retrying will not help. The related 403 refusals carry - `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any - Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's)." + gone; discard the credential and sign in again — retrying will not help. `auth.mfa.code_invalid`: a second + factor presented on a step-up flow did not verify; re-prompt for a code and keep the session. The related + 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this + route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that + is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a + password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the + route refused the credential it was handed, not that the response is malformed." content: application/json: schema: diff --git a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts index 7884d3f52..6639267e6 100644 --- a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts @@ -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(); }); }); diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index 50d22d12a..8e9718220 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -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 diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 8e701bf04..1251829d0 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -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(); diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts index 3d447e3d9..ee4ade61c 100644 --- a/src/app/api/auth/me/sessions/route.ts +++ b/src/app/api/auth/me/sessions/route.ts @@ -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 { 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); } diff --git a/src/lib/api-errors.ts b/src/lib/api-errors.ts index 05b49dec6..29aaf9a22 100644 --- a/src/lib/api-errors.ts +++ b/src/lib/api-errors.ts @@ -55,6 +55,13 @@ export const AUTH_ERROR_CODES = { scope: "auth.scope.insufficient", /** Authenticated, but not an admin. Cookie-only surface; never a token. */ admin: "auth.admin.required", + /** + * The second factor presented on a step-up flow did not verify — a wrong + * TOTP digit, a replayed one, or a recovery code that is spent. The session + * itself is untouched: the client re-prompts for a code rather than signing + * the user out, which is the opposite of what the other 401 codes ask for. + */ + mfaCode: "auth.mfa.code_invalid", } as const; /** diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 66f0baf49..4018f4ab3 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -230,7 +230,7 @@ export const invalidBaseTokenResponse = { export const stdResponses = { "401": { description: - "Authentication required or invalid credentials. `meta.errorCode` says which, and it is the field to branch on — the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session cookie and no Bearer header; send the user to sign in. `auth.token.expired`: the Bearer token's lifetime has passed; refresh and retry the request. `auth.token.invalid`: unknown, revoked, or the owning account is gone; discard the credential and sign in again — retrying will not help. The related 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's).", + "Authentication required or invalid credentials. The generic auth gates — the ones every route passes through before its own body runs — name the reason in `meta.errorCode`, and that is the field to branch on there; the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session cookie and no Bearer header; send the user to sign in. `auth.token.expired`: the Bearer token's lifetime has passed; refresh and retry the request. `auth.token.invalid`: unknown, revoked, or the owning account is gone; discard the credential and sign in again — retrying will not help. `auth.mfa.code_invalid`: a second factor presented on a step-up flow did not verify; re-prompt for a code and keep the session. The related 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the route refused the credential it was handed, not that the response is malformed.", content: { "application/json": { schema: errorEnvelope } }, }, "422": {