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": {