mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'fix/step-up-and-session-revocation' into release/v1.39.3
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/ko.json # messages/pl.json
This commit is contained in:
commit
03ccb21227
96 files changed
+3238
-495
No files matched your search
@@ -18,7 +18,12 @@ import { Readable } from "node:stream";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
HttpError,
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -42,7 +47,10 @@ export const GET = apiHandler(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
// data, so a stolen admin session alone must not be enough.
|
||||
await assertRecentCookieProof(admin, session.id);
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "admin.backups.download" }, meta: { id } });
|
||||
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
HttpError,
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -45,7 +50,10 @@ const handler = apiHandler(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
// data, so a stolen admin session alone must not be enough.
|
||||
await assertRecentCookieProof(admin, session.id);
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "admin.backups.restore" }, meta: { id } });
|
||||
|
||||
|
||||
@@ -35,7 +35,12 @@ import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
|
||||
import { NextRequest } from "next/server";
|
||||
import { ZodError } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
HttpError,
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
|
||||
@@ -143,7 +148,10 @@ async function* decodedBytes(
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
// data, so a stolen admin session alone must not be enough.
|
||||
await assertRecentCookieProof(admin, session.id);
|
||||
annotate({ action: { name: "admin.backups.upload" } });
|
||||
const ipAddress = getClientIp(request);
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -47,7 +51,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await requireAdmin();
|
||||
const { user, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
// data, so a stolen admin session alone must not be enough.
|
||||
await assertRecentCookieProof(user, session.id);
|
||||
annotate({ action: { name: "admin.data.delete" } });
|
||||
|
||||
// v1.18.1 — the documented convention buckets authenticated admin
|
||||
|
||||
@@ -3,6 +3,7 @@ import { buildDateKey } from "@/lib/ai/coach/budget";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -43,8 +44,11 @@ interface ProviderHealthSummary {
|
||||
lastFailureStatus: number | null;
|
||||
}
|
||||
|
||||
/** The two operator-managed tags sort first — they affect every user on the chain. */
|
||||
const CENTRAL_TYPES = ["admin-openai", "admin-codex"];
|
||||
/**
|
||||
* The operator-managed tags sort first — they affect every user on the chain.
|
||||
* Read from the one definition, in its order (the OpenAI key, then Codex).
|
||||
*/
|
||||
const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -31,7 +35,10 @@ export const POST = apiHandler(
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await requireAdmin();
|
||||
const { user, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
// data, so a stolen admin session alone must not be enough.
|
||||
await assertRecentCookieProof(user, session.id);
|
||||
|
||||
const { id } = await params;
|
||||
annotate({
|
||||
|
||||
@@ -44,6 +44,8 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
ip: "1.2.3.4",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true, remaining: 9 })),
|
||||
refundRateLimit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
ip: "203.0.113.1",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
refundRateLimit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, ip: "1.2.3.4" }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
refundRateLimit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
refundRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
@@ -21,6 +26,15 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
|
||||
|
||||
/**
|
||||
* Failed password sign-ins one account takes per window, from any number of
|
||||
* addresses. Twice the per-IP ceiling, so the owner mistyping from one network
|
||||
* meets that one first, and small enough that guessing is hopeless.
|
||||
*/
|
||||
const LOGIN_ACCOUNT_BUCKET = "auth:login:account";
|
||||
const LOGIN_ACCOUNT_LIMIT = 10;
|
||||
const LOGIN_ACCOUNT_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY means password login must be a dead end, not just a hidden
|
||||
// button — otherwise a pre-existing password (or a leaked/reused one)
|
||||
@@ -91,6 +105,42 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// A per-account ceiling beside the per-IP one. The IP bucket alone is only
|
||||
// as good as the address it is keyed on, and a guesser with many addresses
|
||||
// (or a proxy that forwards whatever address the caller claims) could try
|
||||
// one account's password without end. This bucket follows the account
|
||||
// wherever the guesses come from.
|
||||
//
|
||||
// Keyed on the account when the identifier resolves and on the identifier's
|
||||
// hash when it does not, with the same ceiling, so a locked answer says
|
||||
// nothing about whether the account exists. Charged before the Argon2id
|
||||
// verification (a locked account costs no hashing) and refunded on a
|
||||
// successful sign-in, so it counts failures: the owner signing in does not
|
||||
// use it up. While it is spent, a passkey or single sign-on still works.
|
||||
const accountBucket = user
|
||||
? `${LOGIN_ACCOUNT_BUCKET}:u:${user.id}`
|
||||
: `${LOGIN_ACCOUNT_BUCKET}:i:${hashToken(identifier.toLowerCase())}`;
|
||||
const accountRl = await checkRateLimit(
|
||||
accountBucket,
|
||||
LOGIN_ACCOUNT_LIMIT,
|
||||
LOGIN_ACCOUNT_WINDOW_MS,
|
||||
);
|
||||
if (!accountRl.allowed) {
|
||||
await auditLog("auth.login.account_throttled", {
|
||||
userId: user?.id,
|
||||
ipAddress: ip,
|
||||
details: { identifierHash },
|
||||
});
|
||||
annotate({ action: { name: "auth.login.account_throttled" } });
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Too many login attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(accountRl) },
|
||||
);
|
||||
}
|
||||
|
||||
// One verification for every outcome. An unknown identifier and an
|
||||
// account carrying no password hash (a passkey-only account) used to
|
||||
// return here before any hashing, so the three answers cost one indexed
|
||||
@@ -132,6 +182,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// The password was right: this attempt was not a failed guess.
|
||||
await refundRateLimit(accountBucket);
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
|
||||
@@ -76,10 +76,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
|
||||
it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => {
|
||||
const res = await POST(req());
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
|
||||
kind: "session",
|
||||
sessionId: "sess-current",
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
{
|
||||
kind: "session",
|
||||
sessionId: "sess-current",
|
||||
},
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
});
|
||||
|
||||
it("spares the CALLER's own device login on the Bearer path", async () => {
|
||||
@@ -99,10 +103,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
|
||||
const res = await POST(req());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
|
||||
kind: "accessToken",
|
||||
accessTokenHash: "hash-of-caller-access-token",
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
{
|
||||
kind: "accessToken",
|
||||
accessTokenHash: "hash-of-caller-access-token",
|
||||
},
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
});
|
||||
|
||||
it("spends the elevation before tearing the factor down", async () => {
|
||||
|
||||
@@ -118,6 +118,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
auth.transport === "cookie"
|
||||
? { kind: "session", sessionId: auth.session.id }
|
||||
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
|
||||
await auditLog("auth.mfa.disabled", {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { verifyTotp } from "@/lib/auth/mfa/totp";
|
||||
@@ -110,6 +111,12 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
// A confirmed TOTP secret was refused above, so the only second factor the
|
||||
// account can already hold is a security key.
|
||||
const hadSecondFactor =
|
||||
(await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
})) > 0;
|
||||
|
||||
// The code verified and the activation is next — spend the elevation here so
|
||||
// a wrong code above costs the caller nothing.
|
||||
@@ -141,6 +148,19 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
// admin-enforced forced-enrollment redirect must clear immediately.
|
||||
await setMfaEnrollCookie(false);
|
||||
|
||||
// Turning on the first second factor ends every other sign-in, as a password
|
||||
// change does: a session opened before the factor existed, perhaps by
|
||||
// whoever made the owner turn it on, must not keep going without it.
|
||||
if (!hadSecondFactor) {
|
||||
await destroyOtherSessions(
|
||||
user.id,
|
||||
auth.transport === "cookie"
|
||||
? { kind: "session", sessionId: auth.session.id }
|
||||
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("auth.mfa.enabled", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
|
||||
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
@@ -57,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// failed ceremony above must not have cost the caller their elevation.
|
||||
await auth.commitElevation();
|
||||
|
||||
const hadSecondFactor =
|
||||
user.totpConfirmedAt !== null ||
|
||||
(await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
})) > 0;
|
||||
|
||||
const { registrationInfo } = verification;
|
||||
const created = await prisma.webauthnMfaCredential.create({
|
||||
data: {
|
||||
@@ -76,6 +83,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// so clear any forced-enrollment redirect immediately.
|
||||
await setMfaEnrollCookie(false);
|
||||
|
||||
// Turning on the first second factor ends every other sign-in, as a password
|
||||
// change does: a session opened before the factor existed, perhaps by
|
||||
// whoever made the owner turn it on, must not keep going without it.
|
||||
if (!hadSecondFactor) {
|
||||
await destroyOtherSessions(
|
||||
user.id,
|
||||
auth.transport === "cookie"
|
||||
? { kind: "session", sessionId: auth.session.id }
|
||||
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("auth.mfa.webauthn.register", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -163,16 +163,21 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({
|
||||
sessionsRevoked: 3,
|
||||
accessTokensRevoked: 0,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.sessionsRevoked).toBe(3);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
|
||||
kind: "session",
|
||||
sessionId: "sess-current",
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ kind: "session", sessionId: "sess-current" },
|
||||
// Everything, share links included, when the client says nothing: the
|
||||
// shipped app sends no parameter and gets the safe default.
|
||||
{ reach: "everything", keepShareLinks: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("names a Bearer caller by its access token so its own device login is spared", async () => {
|
||||
@@ -198,15 +203,18 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({
|
||||
sessionsRevoked: 1,
|
||||
accessTokensRevoked: 1,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
|
||||
kind: "accessToken",
|
||||
accessTokenHash: hashToken(raw),
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ kind: "accessToken", accessTokenHash: hashToken(raw) },
|
||||
{ reach: "everything", keepShareLinks: false },
|
||||
);
|
||||
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* GET /api/auth/me/sessions — list the user's active web sessions.
|
||||
* DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER
|
||||
* session (and native refresh tokens), keeping
|
||||
* the caller's current session. Closes #64.
|
||||
* session, device login, AI-assistant
|
||||
* connection and token, and (unless
|
||||
* `?keepShareLinks=1`) every clinician share
|
||||
* link, keeping the caller's current
|
||||
* credential. Closes #64.
|
||||
*
|
||||
* v1.23 — the user-facing session/device-management surface. Distinct from
|
||||
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
|
||||
@@ -114,15 +117,35 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
: { kind: "session", sessionId: auth.session.id };
|
||||
|
||||
const { sessionsRevoked, accessTokensRevoked } = await destroyOtherSessions(
|
||||
user.id,
|
||||
current,
|
||||
);
|
||||
// "Everywhere" reaches every credential that works without signing in: AI
|
||||
// assistant connections, programmatic tokens and, unless the caller asks to
|
||||
// keep them, clinician share links. The default is to revoke, because the
|
||||
// person pressing this is often doing it after losing a device or a session,
|
||||
// and a share link made by whoever held it would otherwise keep working. A
|
||||
// client that sends nothing (the shipped app) gets the safe default.
|
||||
const keepShareLinks =
|
||||
new URL(request.url).searchParams.get("keepShareLinks") === "1";
|
||||
|
||||
const {
|
||||
sessionsRevoked,
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
} = await destroyOtherSessions(user.id, current, {
|
||||
reach: "everything",
|
||||
keepShareLinks,
|
||||
});
|
||||
|
||||
await auditLog("auth.session.revoke_others", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { sessionsRevoked, accessTokensRevoked },
|
||||
details: {
|
||||
sessionsRevoked,
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
keepShareLinks,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -130,10 +153,17 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
sessions_revoked: sessionsRevoked,
|
||||
access_tokens_revoked: accessTokensRevoked,
|
||||
connectors_revoked: connectorsRevoked,
|
||||
share_links_revoked: shareLinksRevoked,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ sessionsRevoked });
|
||||
return apiSuccess({
|
||||
sessionsRevoked,
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* POST /api/auth/reproof
|
||||
*
|
||||
* A browser session re-proves a credential the account holds, so that for the
|
||||
* next five minutes it may export the whole record, make a share link, mint a
|
||||
* token, or run an admin backup, restore, wipe or reset. Those routes answer
|
||||
* 401 `auth.reproof.required` with `meta.methods` when the session carries no
|
||||
* recent proof; the web opens the re-proof dialog, posts the proof here, and
|
||||
* retries.
|
||||
*
|
||||
* What a proof stamps on the session row:
|
||||
* - `totp`, `webauthn` or `passkey`: `mfaVerifiedAt` and `reproofAt`. The
|
||||
* same stamp a completed second factor or passkey sign-in writes, from the
|
||||
* same factor, so it also clears the fresh-factor routes, exactly as
|
||||
* signing in again would.
|
||||
* - `password`: `reproofAt` only. Accepted only on an account without a
|
||||
* second factor; an account with one is told which proofs it can give
|
||||
* instead (422 `auth.reproof.too_weak`), because a password has never
|
||||
* stood in for the second factor.
|
||||
*
|
||||
* Cookie-only. The Bearer transport has its own proof flow (`POST
|
||||
* /api/auth/step-up`, token-bound single-use elevations), and a stamp on a
|
||||
* session row means nothing to a token.
|
||||
*
|
||||
* GET asks the same question the gated routes ask, without acting: 200 when
|
||||
* the session's proof is recent, otherwise the same 401 `auth.reproof.required`
|
||||
* with `meta.methods`. The web calls it before a large upload, so the file is
|
||||
* not sent twice around a re-proof.
|
||||
*
|
||||
* Proofs draw on the account's shared re-proof budget (five per fifteen
|
||||
* minutes, the same bucket as the step-up mint and the enrollment routes) and
|
||||
* every refusal is audited. Failures are indistinguishable on the wire.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
recentProofMethods,
|
||||
requireCookieAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
REPROOF_FAILED_CODE,
|
||||
recordReproofFailure,
|
||||
throttleReproof,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import { stepUpMintSchema } from "@/lib/validations/step-up";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user, session } = await requireCookieAuth();
|
||||
const ip = getClientIp(request);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const proof = parsed.data;
|
||||
|
||||
// Refuse a proof that could not satisfy the gate BEFORE spending a guess on
|
||||
// it: a password on an account with a second factor would verify and still
|
||||
// leave every gated action refused.
|
||||
const methods = await recentProofMethods(user);
|
||||
if (!methods.includes(proof.method)) {
|
||||
annotate({
|
||||
action: { name: "auth.reproof.method_refused" },
|
||||
meta: { method: proof.method },
|
||||
});
|
||||
return apiError("Use one of the listed ways to confirm it is you", 422, {
|
||||
errorCode: "auth.reproof.too_weak",
|
||||
methods,
|
||||
});
|
||||
}
|
||||
|
||||
const limited = await throttleReproof(user.id, ip, "reproof");
|
||||
if (limited) return limited;
|
||||
|
||||
const outcome = await verifyExistingFactorProof(user, proof);
|
||||
if (!outcome.ok) {
|
||||
await recordReproofFailure(
|
||||
user.id,
|
||||
ip,
|
||||
"reproof",
|
||||
proof.method,
|
||||
outcome.reason,
|
||||
);
|
||||
return apiError("Verification failed", 401, {
|
||||
errorCode: REPROOF_FAILED_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data:
|
||||
proof.method === "password"
|
||||
? { reproofAt: now }
|
||||
: { reproofAt: now, mfaVerifiedAt: now },
|
||||
});
|
||||
|
||||
await auditLog("auth.reproof.succeeded", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method: proof.method },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.reproof.succeeded" },
|
||||
meta: { method: proof.method },
|
||||
});
|
||||
|
||||
return apiSuccess({ method: proof.method, verifiedAt: now.toISOString() });
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user, session } = await requireCookieAuth();
|
||||
await assertRecentCookieProof(user, session.id);
|
||||
return apiSuccess({ recent: true });
|
||||
});
|
||||
@@ -12,6 +12,15 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
session: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
})),
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
measurement: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
|
||||
@@ -31,9 +31,17 @@ vi.mock("@/lib/db", () => ({
|
||||
consentReceipt: { findMany: vi.fn() },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
user: { findUnique: vi.fn() },
|
||||
passkey: { findMany: vi.fn() },
|
||||
passkey: { findMany: vi.fn(), count: vi.fn(async () => 0) },
|
||||
apiToken: { count: vi.fn() },
|
||||
session: { count: vi.fn() },
|
||||
session: {
|
||||
count: vi.fn(),
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
})),
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
userAchievement: {
|
||||
findMany: vi.fn(),
|
||||
|
||||
@@ -75,6 +75,7 @@ vi.mock("@/lib/db", () => ({
|
||||
inboundDocument: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
session: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
passkey: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -114,6 +115,14 @@ beforeEach(() => {
|
||||
// Default: no registered security key. Tests that exercise the
|
||||
// WebAuthn-only cohort override this.
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
|
||||
// Default: a session that signed in a moment ago. The cases about the
|
||||
// recent-proof gate set their own stamps.
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/export/encrypted", () => {
|
||||
@@ -147,6 +156,38 @@ describe("POST /api/export/encrypted", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a single-factor account whose session is not recent (401 reproof)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.meta.errorCode).toBe("auth.reproof.required");
|
||||
});
|
||||
|
||||
it("allows a single-factor account after a password re-proof", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
* `application/octet-stream`.
|
||||
*
|
||||
* SECURITY:
|
||||
* - Step-up: when the account has any second factor enrolled (TOTP OR a
|
||||
* security key), this is gated by `requireFreshMfaIfEnrolled` — exporting the
|
||||
* whole record is a sensitive action. Single-factor accounts fall back to a
|
||||
* normal authenticated session.
|
||||
* - Fresh proof: `requireRecentProof`, the gate every whole-record export
|
||||
* shares. A second factor within five minutes on an account that has one;
|
||||
* otherwise a recent sign-in or a password re-proof.
|
||||
* - The passphrase NEVER hits a log or wide-event: it is read off the parsed
|
||||
* body, passed straight into the KDF, and never `annotate()`d. The egress
|
||||
* redaction denylist already scrubs `/passphrase/i` (key-name) and the
|
||||
@@ -20,19 +19,16 @@
|
||||
* - There is NO server-side recovery — the passphrase is not stored. A
|
||||
* forgotten passphrase means the archive is unrecoverable; the UI says so.
|
||||
*
|
||||
* Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh
|
||||
* second factor for MFA accounts (Bearer can never satisfy step-up).
|
||||
* Auth: cookie session with a recent proof; Bearer on the token alone for an
|
||||
* account without a second factor, with a second-factor `X-Step-Up`
|
||||
* elevation for one with.
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) — same bucket as the
|
||||
* plaintext export so the encrypted variant cannot be used to bypass the cap.
|
||||
* Audit: `user.export.encrypted` with the row counts (never the passphrase).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfaIfEnrolled,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecentProof } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -92,13 +88,13 @@ const encryptedExportSchema = z
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Resolve the session (cookie or Bearer), then escalate to a fresh second
|
||||
// factor when the account has one enrolled. `requireFreshMfaIfEnrolled`
|
||||
// covers BOTH cohorts — a confirmed TOTP secret AND a registered WebAuthn
|
||||
// security key — so a security-key-only account is gated too. A single-factor
|
||||
// account cannot produce a fresh-MFA proof, so it passes straight through
|
||||
// rather than being locked out of its own encrypted export.
|
||||
const auth = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
// A fresh proof, like every whole-record export (`requireRecentProof`). On
|
||||
// the cookie path: a second factor within five minutes on an account that
|
||||
// has one, otherwise a recent sign-in or password re-proof. On Bearer the
|
||||
// shipped app calls this route without an elevation, so an account without
|
||||
// a second factor still passes on its token; one with a second factor
|
||||
// presents a second-factor elevation (before, it could not export at all).
|
||||
const auth = await requireRecentProof({ bearer: "elevation-if-enrolled" });
|
||||
const user = auth.user;
|
||||
annotate({ action: { name: "user.export.encrypted" } });
|
||||
|
||||
@@ -119,6 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error);
|
||||
}
|
||||
const { passphrase } = parsed.data;
|
||||
await auth.commitElevation();
|
||||
|
||||
// Sealed as it is produced and spooled to a temporary file, then sent: the
|
||||
// format puts the tag in front of the ciphertext, so the archive cannot go
|
||||
|
||||
@@ -25,12 +25,16 @@
|
||||
* included; the payload's `manifest` field discloses both, and the export UI
|
||||
* states this too.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Auth: a fresh proof (`requireRecentProof`). The file is the whole record in
|
||||
* plain text, so a live session alone is not enough: a cookie session needs a
|
||||
* recent sign-in or re-proof (a second factor on an account that has one), a
|
||||
* Bearer caller an `X-Step-Up` elevation. No shipped client calls this route
|
||||
* on Bearer.
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
||||
* Audit: `user.export.full-backup` with the row counts.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecentProof } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
@@ -41,13 +45,15 @@ import { streamToResponseBody } from "@/lib/export/response-stream";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const auth = await requireRecentProof({ bearer: "elevation" });
|
||||
const { user } = auth;
|
||||
annotate({ action: { name: "user.export.full-backup" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
await auth.commitElevation();
|
||||
|
||||
// Written into the response as it is produced (`streamFullBackupJson`), not
|
||||
// built and stringified first: for an account of 1.25 million measurements
|
||||
|
||||
@@ -10,6 +10,15 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: { findMany: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
session: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
})),
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -29,6 +38,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
|
||||
@@ -57,6 +67,20 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/mcp/tokens — fresh proof", () => {
|
||||
it("refuses a browser session with no recent sign-in or re-proof", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
const res = await POST(postReq({ name: "laptop" }));
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mcp/tokens — scope minting", () => {
|
||||
it("defaults to read-only [health:read]", async () => {
|
||||
const res = await POST(postReq({ name: "laptop" }));
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -71,7 +71,11 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// A connector token outlives the session that mints it, so the cookie path
|
||||
// needs a fresh proof. The shipped app mints these on its token without an
|
||||
// elevation and reads a 401 here as a dead session, so the Bearer arm stays
|
||||
// on the token until it sends one (see the iOS note).
|
||||
const { user } = await requireRecentProof({ bearer: "token" });
|
||||
annotate({ action: { name: "mcp.tokens.create" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
|
||||
@@ -19,6 +19,15 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
session: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
})),
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -101,6 +110,22 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/share-links — fresh proof", () => {
|
||||
it("refuses a browser session with no recent sign-in or re-proof", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
const res = await POST(postReq(validBody()));
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.meta.errorCode).toBe("auth.reproof.required");
|
||||
expect(body.meta.methods).toEqual([]);
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/share-links — create", () => {
|
||||
it("returns the raw token once and persists ONLY its hash", async () => {
|
||||
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
@@ -136,7 +136,12 @@ function toSummary(row: {
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// A share link is a standing credential to the record that works without
|
||||
// signing in, so making one needs a fresh proof on the cookie path, not only
|
||||
// a live session. The shipped app creates links on its token without an
|
||||
// elevation, and reads any 401 here as a dead session, so the Bearer arm
|
||||
// stays on the token until the app sends one (see the iOS note).
|
||||
const { user } = await requireRecentProof({ bearer: "token" });
|
||||
annotate({ action: { name: "share-link.create" } });
|
||||
|
||||
const rl = await checkRateLimit(`share-link:${user.id}`, 20, 60 * 60 * 1000);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireCookieAuth: vi.fn(),
|
||||
assertRecentCookieProof: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/issue-token", () => ({
|
||||
@@ -42,7 +43,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { requireCookieAuth } from "@/lib/api-handler";
|
||||
import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -63,7 +64,11 @@ function req(body: unknown): Request {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(requireCookieAuth).mockResolvedValue({
|
||||
user: USER,
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(assertRecentCookieProof).mockResolvedValue(undefined);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
@@ -77,6 +82,26 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("the fresh proof in front of the mint", () => {
|
||||
it("mints nothing when the session carries no recent proof", async () => {
|
||||
vi.mocked(assertRecentCookieProof).mockRejectedValue(
|
||||
new Error("auth.reproof.required"),
|
||||
);
|
||||
await expect(POST(req({ name: "Scale" }) as never)).rejects.toThrow(
|
||||
"auth.reproof.required",
|
||||
);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks about the caller's own session", async () => {
|
||||
await POST(req({ name: "Scale" }) as never);
|
||||
expect(assertRecentCookieProof).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.any(String) }),
|
||||
"sess-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it mints", () => {
|
||||
it("names exactly the upload scope, and no wildcard", async () => {
|
||||
const res = await POST(req({ name: "Paperless workflow" }) as never);
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
requireCookieAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -55,7 +59,11 @@ const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
const MAX_LIVE_TOKENS = 10;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user, session } = await requireCookieAuth();
|
||||
// And a fresh proof on top of the session: the token lives far longer than
|
||||
// a stolen session would, so minting one needs the person, not just the
|
||||
// browser.
|
||||
await assertRecentCookieProof(user, session.id);
|
||||
annotate({ action: { name: "tokens.documents.create" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireCookieAuth: vi.fn(),
|
||||
assertRecentCookieProof: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/issue-token", () => ({
|
||||
@@ -42,7 +43,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { requireCookieAuth } from "@/lib/api-handler";
|
||||
import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -63,7 +64,11 @@ function req(body: unknown): Request {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(requireCookieAuth).mockResolvedValue({
|
||||
user: USER,
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(assertRecentCookieProof).mockResolvedValue(undefined);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
@@ -77,6 +82,26 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("the fresh proof in front of the mint", () => {
|
||||
it("mints nothing when the session carries no recent proof", async () => {
|
||||
vi.mocked(assertRecentCookieProof).mockRejectedValue(
|
||||
new Error("auth.reproof.required"),
|
||||
);
|
||||
await expect(POST(req({ name: "Scale" }) as never)).rejects.toThrow(
|
||||
"auth.reproof.required",
|
||||
);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks about the caller's own session", async () => {
|
||||
await POST(req({ name: "Scale" }) as never);
|
||||
expect(assertRecentCookieProof).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.any(String) }),
|
||||
"sess-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it mints", () => {
|
||||
it("names exactly the ingest scope, and no wildcard", async () => {
|
||||
const res = await POST(req({ name: "Home Assistant" }) as never);
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
requireCookieAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -94,7 +98,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// front of, and it is the surface this endpoint is reached from anyway. The
|
||||
// same argument the passkey-registration and trusted-device routes make, and
|
||||
// `requireCookieAuth` is the helper they share.
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user, session } = await requireCookieAuth();
|
||||
// And a fresh proof on top of the session: the token lives far longer than
|
||||
// a stolen session would, so minting one needs the person, not just the
|
||||
// browser.
|
||||
await assertRecentCookieProof(user, session.id);
|
||||
annotate({ action: { name: "tokens.measurements.create" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
|
||||
Reference in new issue
Block a user