fix(api): give the generic auth refusals a stable errorCode

`requireAuth`, `requireAdmin`, `requireCookieAuth`, `requireFreshMfa` and
`requireBearerAuth` all refused with a bare prose string and no `meta`.
So the single most important branch a native client makes — refresh the
token and retry, drop the session and show the login screen, or stop
retrying because this credential will never work — could only be made by
string-matching the English sentence "Token expired". Any rewording of
that string, or a localisation pass, silently turns a recoverable
refresh into a forced logout for every already-shipped build.

The mechanism was already here and these gates were the ones skipped:
the assistant, consent, step-up, module and sharing refusals all carry
`meta.errorCode`, and `/api/auth/refresh` emits `auth.refresh.*`. So
`HttpError` takes an optional code and the wrapper puts it in the same
field. Five values, dot.case like the majority: `auth.missing`,
`auth.token.expired`, `auth.token.invalid`, `auth.scope.insufficient`,
`auth.admin.required`. A revoked token reads invalid, not expired —
refreshing a revoked token loops forever.

Additive: an HttpError thrown without a code serialises byte-identically
to before, which is most of them. The published 401 description now
enumerates the codes and says what a client should do with each.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:24:34 +02:00
1 parent a7bdf14a4e
commit 21dd7c7e48
5 files changed
+322 -15

No files matched your search

+32 -13
View File
@@ -81,6 +81,7 @@ export function isMcpAudienceToken(permissions: readonly string[]): boolean {
// the api-handler ↔ record-session-fence import cycle) and are re-exported
// here so the hundreds of existing importers read exactly as before.
import {
AUTH_ERROR_CODES,
HttpError,
SharingAuthError,
SharingAccessDeniedError,
@@ -90,6 +91,7 @@ import {
} from "./api-errors";
export {
AUTH_ERROR_CODES,
HttpError,
SharingAuthError,
SharingAccessDeniedError,
@@ -295,9 +297,17 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
{ status: error.statusCode },
);
} else if (error instanceof HttpError) {
// `meta.errorCode` only when the throw site named one, so every
// HttpError without a code serialises byte-identically to before.
evt.setError(error);
response = NextResponse.json(
{ data: null, error: error.message },
{
data: null,
error: error.message,
...(error.errorCode
? { meta: { errorCode: error.errorCode } }
: {}),
},
{ status: error.statusCode },
);
} else if (error instanceof SyntaxError) {
@@ -540,7 +550,7 @@ async function authenticateCaller(
}
// 3. No credentials.
throw new HttpError(401, "Not authenticated");
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
}
/**
@@ -584,13 +594,15 @@ async function authenticateBearer(
: {}),
},
}).catch(() => {});
const message =
// Sentence and code are decided together, so a future rewording of the
// prose cannot silently change what a client does with the refusal.
const [message, errorCode] =
err.statusCode === 403
? "Insufficient permissions"
? (["Insufficient permissions", AUTH_ERROR_CODES.scope] as const)
: err.reason === "expired"
? "Token expired"
: "Invalid token";
throw new HttpError(err.statusCode, message);
? (["Token expired", AUTH_ERROR_CODES.expired] as const)
: (["Invalid token", AUTH_ERROR_CODES.invalid] as const);
throw new HttpError(err.statusCode, message, errorCode);
}
throw err;
}
@@ -626,7 +638,11 @@ async function authenticateBearer(
method: method || "unknown",
},
}).catch(() => {});
throw new HttpError(403, "Insufficient permissions");
throw new HttpError(
403,
"Insufficient permissions",
AUTH_ERROR_CODES.scope,
);
}
}
@@ -1114,7 +1130,8 @@ async function resolveSwitchedRecord(
*/
export async function requireAdmin(): Promise<AuthContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
if (!sessionData)
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
const evt = getEvent();
if (evt) {
@@ -1126,7 +1143,7 @@ export async function requireAdmin(): Promise<AuthContext> {
}
if (sessionData.user.role !== "ADMIN") {
throw new HttpError(403, "Admin access required");
throw new HttpError(403, "Admin access required", AUTH_ERROR_CODES.admin);
}
return { ...sessionData, authMethod: "cookie" };
}
@@ -1143,7 +1160,8 @@ export async function requireAdmin(): Promise<AuthContext> {
*/
export async function requireCookieAuth(): Promise<AuthContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
if (!sessionData)
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
const evt = getEvent();
if (evt) {
@@ -1217,7 +1235,8 @@ export async function requireFreshMfa(
proofSource: FreshFactorProofSource = "second-factor",
): Promise<FreshMfaContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
if (!sessionData)
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
const evt = getEvent();
if (evt) {
@@ -1448,7 +1467,7 @@ export async function requireBearerAuth(): Promise<BearerAuthContext> {
authHeader = null;
}
if (!authHeader?.startsWith("Bearer ")) {
throw new HttpError(401, "Not authenticated");
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
}
const raw = authHeader.slice(7);
const auth = await authenticateBearer(raw, undefined);