mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
A dash between two parts of a sentence ("{what} — tomorrow", "Could not
record {name} — try again", "End {name} — …") stands in for a comma, a
colon or a full stop the sentence should say. Every such string in the
seven bundles now says it in plain words. A range keeps its unspaced en
dash ("70–100 mmHg", "{start}–{end}"); that is how a range is written,
not a pause, and the spaced range forms became unspaced ones.
Labels the components put together themselves follow the same rule:
aria-labels read "Move up: Ramipril", a medication and its dose read
"Ramipril 5 mg", page titles use "|", Coach prefills are two sentences,
and the API's user-facing error messages lost their dashes too.
The bundle guard now refuses " — " and " – " beside " · ", with an empty
allowlist, and a sibling guard walks src/components and src/app for a
string or template literal that joins its parts with a spaced dash.
132 lines
4.5 KiB
TypeScript
132 lines
4.5 KiB
TypeScript
/**
|
|
* POST /api/auth/refresh — exchange a one-time-use refresh token for a new
|
|
* access + refresh pair. Audited on every call (success + failure).
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { apiHandler } from "@/lib/api-handler";
|
|
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
|
import {
|
|
resolveTokenPolicy,
|
|
NATIVE_REFRESH_TOKEN_DAYS,
|
|
} from "@/lib/auth/native-client";
|
|
import {
|
|
rotateRefreshToken,
|
|
revokeRefreshToken,
|
|
} from "@/lib/auth/refresh-token";
|
|
|
|
export const POST = apiHandler(async (request: NextRequest) => {
|
|
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
|
|
// Refresh is a high-value rotation endpoint; align it with the
|
|
// passkey-verify tier (10/15min) rather than the looser 60/15min. A
|
|
// legitimate device rotates well under this — the 24h access token only
|
|
// forces a refresh roughly daily — while reuse-detection still caps
|
|
// genuine abuse.
|
|
const rl = await checkAuthSurfaceRateLimit(
|
|
request,
|
|
"auth:refresh",
|
|
10,
|
|
15 * 60 * 1000,
|
|
);
|
|
const ip = rl.ip ?? "unknown";
|
|
if (!rl.allowed) {
|
|
return apiError("Too many refresh attempts. Please retry later.", 429);
|
|
}
|
|
|
|
const { data: body, error: jsonError } = await safeJson<{
|
|
refreshToken?: string;
|
|
revoke?: boolean;
|
|
}>(request, { maxBytes: 64 * 1024 });
|
|
if (jsonError) return jsonError;
|
|
if (!body.refreshToken || typeof body.refreshToken !== "string") {
|
|
return apiError("refreshToken required", 422);
|
|
}
|
|
|
|
// logout-on-device path: client wants to invalidate the refresh token.
|
|
if (body.revoke) {
|
|
const ok = await revokeRefreshToken(body.refreshToken);
|
|
annotate({
|
|
action: { name: "auth.token.refresh.revoke" },
|
|
meta: { refresh_revoked: ok },
|
|
});
|
|
await auditLog("auth.token.refresh.revoke", {
|
|
ipAddress: ip,
|
|
details: { ok },
|
|
});
|
|
return apiSuccess({ revoked: ok });
|
|
}
|
|
|
|
const policy = resolveTokenPolicy(request.headers);
|
|
// Refresh always uses the native policy: a refresh token only exists for
|
|
// native callers, and we never want to upgrade it back to a 90d token.
|
|
if (policy.refreshTokenDays === null) {
|
|
policy.refreshTokenDays = NATIVE_REFRESH_TOKEN_DAYS;
|
|
policy.accessTokenDays = 1;
|
|
}
|
|
|
|
const result = await rotateRefreshToken({
|
|
refreshToken: body.refreshToken,
|
|
policy,
|
|
deviceId: request.headers.get("x-device-id"),
|
|
userAgent: request.headers.get("user-agent"),
|
|
ipAddress: ip,
|
|
});
|
|
|
|
if (!result.ok) {
|
|
await auditLog("auth.token.refresh.failed", {
|
|
ipAddress: ip,
|
|
details: { reason: result.reason },
|
|
});
|
|
annotate({
|
|
action: { name: "auth.token.refresh.failed" },
|
|
meta: { refresh_failure: result.reason },
|
|
});
|
|
// v1.7.0 — stable machine `errorCode` so native clients can branch
|
|
// re-auth-required from a transient blip without string-matching the
|
|
// human-prose `error`:
|
|
// - `auth.refresh.reuse` — a consumed token was presented again;
|
|
// the device's refresh family is now revoked (stolen-token
|
|
// signal). The client must re-pair, not retry.
|
|
// - `auth.refresh.revoked` — the family was revoked out-of-band
|
|
// (explicit logout, or reuse detected on another device). Also
|
|
// terminal: re-auth required.
|
|
// - `auth.refresh.invalid` — not found / expired. The client drops
|
|
// the token and re-authenticates.
|
|
if (result.reason === "already_used") {
|
|
return apiError(
|
|
"Refresh token reuse detected. Please log in again.",
|
|
401,
|
|
{ errorCode: "auth.refresh.reuse" },
|
|
);
|
|
}
|
|
if (result.reason === "revoked") {
|
|
return apiError(
|
|
"Refresh token has been revoked. Please log in again.",
|
|
401,
|
|
{ errorCode: "auth.refresh.revoked" },
|
|
);
|
|
}
|
|
return apiError("Invalid refresh token", 401, {
|
|
errorCode: "auth.refresh.invalid",
|
|
});
|
|
}
|
|
|
|
await auditLog("auth.token.refresh", {
|
|
ipAddress: ip,
|
|
details: { policy: policy.policy },
|
|
});
|
|
annotate({
|
|
action: { name: "auth.token.refresh" },
|
|
meta: { token_policy: policy.policy },
|
|
});
|
|
|
|
return apiSuccess({
|
|
token: result.bundle.accessToken,
|
|
tokenExpiresAt: result.bundle.accessTokenExpiresAt.toISOString(),
|
|
refreshToken: result.bundle.refreshToken,
|
|
refreshTokenExpiresAt: result.bundle.refreshTokenExpiresAt.toISOString(),
|
|
});
|
|
});
|