mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge branch 'fix/v1393-review-security' into release/v1.39.3
This commit is contained in:
commit
3cbfe7f7f9
76 files changed
+2751
-409
No files matched your search
@@ -27,11 +27,19 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
const requireAuth = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: never[]) => Promise<Response>>(h: T): T =>
|
||||
h,
|
||||
requireAuth: vi.fn(),
|
||||
requireAuth,
|
||||
// The invitation takes the fresh-proof gate; here it resolves the caller
|
||||
// the stubbed `requireAuth` names, so these cases stay about the row.
|
||||
// The gate itself has its own case in `invite-recent-proof.test.ts`.
|
||||
requireRecentProof: vi.fn(async () => ({
|
||||
...(await requireAuth()),
|
||||
commitElevation: async () => {},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Offering access to the record asks for a fresh proof.
|
||||
*
|
||||
* An invitation outlives the session that sent it, so a live session alone
|
||||
* was enough for a stolen one to leave a standing way into the record in the
|
||||
* thief's own account. The real gate runs here; only the session, the
|
||||
* database and the headers are stubbed.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
session: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
user: { findFirst: vi.fn() },
|
||||
accountGrant: { create: vi.fn(), findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
recordDelegatedAccess: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/sharing/grants")>()),
|
||||
inviteGrant: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { inviteGrant } from "@/lib/sharing/grants";
|
||||
|
||||
const OWNER = {
|
||||
id: "owner-1",
|
||||
role: "USER",
|
||||
username: "owner",
|
||||
totpConfirmedAt: null,
|
||||
passwordHash: "argon",
|
||||
};
|
||||
|
||||
function invite(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/account/grants", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ identifier: "housemate", access: "READ" }),
|
||||
});
|
||||
}
|
||||
|
||||
function signedIn(createdAt: Date) {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
|
||||
user: OWNER,
|
||||
} as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
createdAt,
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/account/grants — fresh proof", () => {
|
||||
it("a session that signed in an hour ago is asked to confirm, and nothing is offered", async () => {
|
||||
signedIn(new Date(Date.now() - 60 * 60 * 1000));
|
||||
const res = await POST(invite());
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.meta.errorCode).toBe("auth.reproof.required");
|
||||
expect(body.meta.methods).toEqual(["password"]);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
expect(inviteGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a session that signed in a minute ago offers the invitation", async () => {
|
||||
signedIn(new Date(Date.now() - 60 * 1000));
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
id: "invitee-1",
|
||||
username: "housemate",
|
||||
displayName: null,
|
||||
} as never);
|
||||
vi.mocked(inviteGrant).mockResolvedValue({
|
||||
id: "grant-1",
|
||||
access: "READ",
|
||||
scopeJson: null,
|
||||
invitedAt: new Date(),
|
||||
acceptedAt: null,
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
} as never);
|
||||
const res = await POST(invite());
|
||||
expect(res.status).toBe(201);
|
||||
expect(inviteGrant).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -31,11 +31,19 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
const requireAuth = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: never[]) => Promise<Response>>(h: T): T =>
|
||||
h,
|
||||
requireAuth: vi.fn(),
|
||||
requireAuth,
|
||||
// The invitation takes the fresh-proof gate; here it resolves the caller
|
||||
// the stubbed `requireAuth` names, so these cases stay about the row.
|
||||
// The gate itself has its own case in `invite-recent-proof.test.ts`.
|
||||
requireRecentProof: vi.fn(async () => ({
|
||||
...(await requireAuth()),
|
||||
commitElevation: async () => {},
|
||||
})),
|
||||
requireFreshMfaIfEnrolled: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -21,9 +21,15 @@
|
||||
* a code rather than leaving a token caller with an authentication error on a
|
||||
* request that authenticated fine.
|
||||
*
|
||||
* **Neither is delegable, and that is structural.** Both resolve through bare
|
||||
* `requireAuth()`, which refuses outright while the caller is acting on
|
||||
* another account. So a delegate cannot invite anybody into the record they
|
||||
* v1.39.3 — offering any level also asks for a fresh proof
|
||||
* (`requireRecentProof`), and "sign out everywhere" withdraws the invitations
|
||||
* nobody has accepted yet. An invitation outlives the session that sent it, so
|
||||
* one offered from a stolen session would otherwise stay live after the owner
|
||||
* signed everything else out.
|
||||
*
|
||||
* **Neither is delegable, and that is structural.** Both resolve through
|
||||
* `requireAuth()` (the invitation through `requireRecentProof`, which calls
|
||||
* it), which refuses outright while the caller is acting on another account. So a delegate cannot invite anybody into the record they
|
||||
* were given access to, cannot widen their own grant, and cannot pass it on:
|
||||
* the route does not have a re-delegation check that somebody could soften, it
|
||||
* has no code path that runs under a switch at all. The grantor is then taken
|
||||
@@ -45,6 +51,7 @@ import {
|
||||
apiHandler,
|
||||
requireAuth,
|
||||
requireFreshMfaIfEnrolled,
|
||||
requireRecentProof,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
@@ -122,7 +129,12 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const auth = await requireAuth();
|
||||
// Offering access leaves a standing way into the record in somebody else's
|
||||
// hands, one that outlives the session that offered it. So a live session is
|
||||
// not enough: a sign-in or re-proof within five minutes on the browser, with
|
||||
// the second factor on an account that has one, or a step-up elevation on a
|
||||
// token. No shipped client offers access with a token.
|
||||
const auth = await requireRecentProof({ bearer: "elevation" });
|
||||
const user = auth.user;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
@@ -194,6 +206,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("No account with that name or e-mail", 404);
|
||||
}
|
||||
|
||||
await auth.commitElevation();
|
||||
|
||||
try {
|
||||
// `inviteGrant` decides everything about the row except the level: the
|
||||
// self-grant refusal, and the one-live-grant-per-pair rule the partial
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// The per-account throttle has its own suite (login-throttle.test.ts); here
|
||||
// it lets every attempt through and counts nothing.
|
||||
vi.mock("@/lib/auth/login-throttle", () => ({
|
||||
beginAccountLoginAttempt: vi.fn(async () => ({
|
||||
waiting: null,
|
||||
source: null,
|
||||
failed: async () => {},
|
||||
succeeded: async () => {},
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
|
||||
@@ -3,6 +3,16 @@ import { NextRequest } from "next/server";
|
||||
|
||||
// --- Mocks must come before importing the route. ---
|
||||
|
||||
// The per-account throttle has its own suite (login-throttle.test.ts); here
|
||||
// it lets every attempt through and counts nothing.
|
||||
vi.mock("@/lib/auth/login-throttle", () => ({
|
||||
beginAccountLoginAttempt: vi.fn(async () => ({
|
||||
waiting: null,
|
||||
source: null,
|
||||
failed: async () => {},
|
||||
succeeded: async () => {},
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// The per-account throttle has its own suite (login-throttle.test.ts); here
|
||||
// it lets every attempt through and counts nothing.
|
||||
vi.mock("@/lib/auth/login-throttle", () => ({
|
||||
beginAccountLoginAttempt: vi.fn(async () => ({
|
||||
waiting: null,
|
||||
source: null,
|
||||
failed: async () => {},
|
||||
succeeded: async () => {},
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// The per-account throttle has its own suite (login-throttle.test.ts); here
|
||||
// it lets every attempt through and counts nothing.
|
||||
vi.mock("@/lib/auth/login-throttle", () => ({
|
||||
beginAccountLoginAttempt: vi.fn(async () => ({
|
||||
waiting: null,
|
||||
source: null,
|
||||
failed: async () => {},
|
||||
succeeded: async () => {},
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
|
||||
@@ -9,12 +9,8 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
refundRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { beginAccountLoginAttempt } from "@/lib/auth/login-throttle";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
@@ -26,15 +22,6 @@ 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)
|
||||
@@ -105,27 +92,31 @@ 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) {
|
||||
// Under an SSO-only policy the one password sign-in allowed is the one that
|
||||
// confirms the account a pending single sign-on link names. Any other
|
||||
// account is refused BEFORE its password is checked: refusing after the
|
||||
// check answered "wrong password" and "right password, wrong account"
|
||||
// differently, which made this exception an oracle for every other
|
||||
// account's password.
|
||||
if (pendingLink && pendingLink.userId !== user?.id) {
|
||||
return apiError("Password login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// The per-account half of the throttle (`@/lib/auth/login-throttle`): a
|
||||
// place this account has signed in from is neither counted nor held back,
|
||||
// anywhere else waits longer after each failure past the first few, up to
|
||||
// fifteen minutes. Decided before the Argon2id verification, so a waiting
|
||||
// attempt costs no hashing.
|
||||
const attempt = await beginAccountLoginAttempt({
|
||||
user,
|
||||
identifier,
|
||||
request,
|
||||
ip: rl.ip,
|
||||
});
|
||||
annotate({ meta: { login_known_source: attempt.source ?? "none" } });
|
||||
if (attempt.waiting) {
|
||||
await auditLog("auth.login.account_throttled", {
|
||||
userId: user?.id,
|
||||
ipAddress: ip,
|
||||
@@ -137,7 +128,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data: null,
|
||||
error: "Too many login attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(accountRl) },
|
||||
{ status: 429, headers: rateLimitHeaders(attempt.waiting) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,6 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const valid = await verifyPasswordOrDummy(user?.passwordHash, password);
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
await attempt.failed();
|
||||
// v1.4.43 W3-SECURITY (H-1): never write the typed identifier into
|
||||
// the audit row — `reason` already tells the operator what
|
||||
// happened, and PII must not land in operator artefacts. The
|
||||
@@ -168,6 +160,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
await attempt.failed();
|
||||
await auditLog("auth.login.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
@@ -176,14 +169,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Invalid credentials", 401);
|
||||
}
|
||||
|
||||
if (pendingLink && pendingLink.userId !== user.id) {
|
||||
return apiError("Password login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// The password was right: this attempt was not a failed guess.
|
||||
await refundRateLimit(accountBucket);
|
||||
await attempt.succeeded();
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
* factor that later satisfies every step-up gate, so a stolen session must not
|
||||
* be able to mint one: the cookie arm also passes `checkCookieEnrollmentProof`
|
||||
* — a sign-in or second factor inside the last five minutes, or a password /
|
||||
* factor proof in the body (the step-up mint's shapes). The Bearer arm already
|
||||
* carries that proof in its elevation. The recovery-code batch is
|
||||
* factor proof in the body (the step-up mint's shapes); on an account that
|
||||
* already has a second factor, only that factor or a passkey. The Bearer arm
|
||||
* carries the proof in its elevation, under the same rule: on an account with
|
||||
* a second factor the elevation must come from one. The recovery-code batch is
|
||||
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
|
||||
* setup never persists codes.
|
||||
*/
|
||||
@@ -41,7 +43,7 @@ const SETUP_RATE_LIMIT = 5;
|
||||
const SETUP_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const auth = await requireMfaManagementAuth();
|
||||
const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
|
||||
const { user } = auth;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
*
|
||||
* The cookie arm additionally passes `checkCookieEnrollmentProof`: a session
|
||||
* alone cannot add a security key, for the same reason it cannot enroll TOTP —
|
||||
* the new key would satisfy every step-up gate from then on.
|
||||
* the new key would satisfy every step-up gate from then on. On an account that
|
||||
* already has a second factor, both arms want that factor (or a passkey): a
|
||||
* password, or a session that skipped the factor on a remembered browser, does
|
||||
* not add a key.
|
||||
*/
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
@@ -20,7 +23,7 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const auth = await requireMfaManagementAuth();
|
||||
const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
|
||||
const { user } = auth;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({
|
||||
update: vi.fn(() => ({ catch: () => undefined })),
|
||||
},
|
||||
user: { findUnique: vi.fn() },
|
||||
accountGrant: { findMany: vi.fn(async () => []) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -165,6 +166,7 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
accessTokensRevoked: 0,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
pendingInvitesRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
@@ -205,6 +207,7 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
accessTokensRevoked: 1,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
pendingInvitesRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
@@ -219,6 +222,53 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sign out everywhere and record sharing", () => {
|
||||
it("reports withdrawn invitations and lists the accepted grants it kept", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({
|
||||
sessionsRevoked: 1,
|
||||
accessTokensRevoked: 0,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
pendingInvitesRevoked: 2,
|
||||
});
|
||||
const party = { id: "user-2", username: "carer", displayName: null };
|
||||
const live = {
|
||||
id: "grant-live",
|
||||
access: "READ",
|
||||
acceptedAt: new Date("2026-01-01"),
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
grantee: party,
|
||||
};
|
||||
const lapsed = {
|
||||
...live,
|
||||
id: "grant-lapsed",
|
||||
expiresAt: new Date("2026-02-01"),
|
||||
};
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
live,
|
||||
lapsed,
|
||||
] as never);
|
||||
|
||||
const res = await DELETE(del());
|
||||
const body = await res.json();
|
||||
expect(body.data.pendingInvitesRevoked).toBe(2);
|
||||
// The lapsed one confers nothing any more and is not listed.
|
||||
expect(body.data.grantsKept).toEqual([
|
||||
{ id: "grant-live", account: party, access: "READ" },
|
||||
]);
|
||||
expect(prisma.accountGrant.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
grantorId: "user-1",
|
||||
revokedAt: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/sessions/[id]", () => {
|
||||
function ctx(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* connection and token, and (unless
|
||||
* `?keepShareLinks=1`) every clinician share
|
||||
* link, keeping the caller's current
|
||||
* credential. Closes #64.
|
||||
* credential. Closes #64. Also withdraws
|
||||
* record-sharing invitations nobody has
|
||||
* accepted yet, and lists the accepted
|
||||
* grants it left standing (`grantsKept`).
|
||||
*
|
||||
* v1.23 — the user-facing session/device-management surface. Distinct from
|
||||
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
|
||||
@@ -34,6 +37,8 @@ import {
|
||||
type CurrentCredential,
|
||||
} from "@/lib/auth/session";
|
||||
import { lookupIpLocation } from "@/lib/geo";
|
||||
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
|
||||
import { isGrantActive } from "@/lib/sharing/grants";
|
||||
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -131,11 +136,32 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
pendingInvitesRevoked,
|
||||
} = await destroyOtherSessions(user.id, current, {
|
||||
reach: "everything",
|
||||
keepShareLinks,
|
||||
});
|
||||
|
||||
// The accepted grants this account gave, still live. Not ended here: each is
|
||||
// a person the owner chose, often a carer who still needs the record, and a
|
||||
// button labelled "sign out" must not quietly cut them off. They are listed
|
||||
// instead, so the person who just signed everything out can see who can
|
||||
// still read the record and end any of them with one click.
|
||||
const now = new Date();
|
||||
const grantsKept = (
|
||||
await prisma.accountGrant.findMany({
|
||||
where: {
|
||||
grantorId: user.id,
|
||||
acceptedAt: { not: null },
|
||||
revokedAt: null,
|
||||
},
|
||||
orderBy: { acceptedAt: "desc" },
|
||||
include: { grantee: { select: GRANT_PARTY_SELECT } },
|
||||
})
|
||||
)
|
||||
.filter((g) => isGrantActive(g, now))
|
||||
.map((g) => ({ id: g.id, account: g.grantee, access: g.access }));
|
||||
|
||||
await auditLog("auth.session.revoke_others", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -144,6 +170,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
pendingInvitesRevoked,
|
||||
grantsKept: grantsKept.length,
|
||||
keepShareLinks,
|
||||
},
|
||||
});
|
||||
@@ -155,6 +183,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
access_tokens_revoked: accessTokensRevoked,
|
||||
connectors_revoked: connectorsRevoked,
|
||||
share_links_revoked: shareLinksRevoked,
|
||||
pending_invites_revoked: pendingInvitesRevoked,
|
||||
grants_kept: grantsKept.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -163,6 +193,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
pendingInvitesRevoked,
|
||||
grantsKept,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,16 @@ import {
|
||||
} from "@/lib/auth/passkey";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
import {
|
||||
isSecondFactorProof,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
throttleReproof,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
apiError,
|
||||
@@ -55,6 +61,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) return proofRequired();
|
||||
|
||||
// On an account with a second factor a password does not add a sign-in
|
||||
// credential, exactly as it does not satisfy the second factor at sign-in:
|
||||
// a passkey registered on the strength of the password alone would from then
|
||||
// on sign in without the second factor at all. Refused before a guess is
|
||||
// spent, with the proofs that would work.
|
||||
if (
|
||||
!isSecondFactorProof(parsed.data.method) &&
|
||||
(await hasSecondFactorEnrolled(user))
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "auth.reproof.method_refused" },
|
||||
meta: { stage: "passkey_enroll", method: parsed.data.method },
|
||||
});
|
||||
return apiError("Confirm with your second factor or a passkey", 422, {
|
||||
errorCode: "auth.reproof.too_weak",
|
||||
methods: await recentProofMethods(user, true),
|
||||
});
|
||||
}
|
||||
|
||||
// Every proof counts against the account's shared re-proof bucket and every
|
||||
// refusal is audited. Without both, a stolen session could guess the account
|
||||
// password or a TOTP code here as fast as it could send requests.
|
||||
@@ -73,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
return proofRequired();
|
||||
}
|
||||
await refundReproof(user.id);
|
||||
|
||||
// A strong existing factor refreshes the session's MFA stamp. Password proof
|
||||
// deliberately clears it: registration is authorized by this single-use,
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock("@/lib/auth/password", () => ({
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
@@ -77,7 +78,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import {
|
||||
requireFreshMfaIfEnrolled,
|
||||
@@ -146,6 +147,9 @@ describe("POST /api/auth/password — MFA step-up (v1.25)", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
|
||||
expect(prisma.user.update).toHaveBeenCalled();
|
||||
// The right current password was not a guess: the shared re-proof
|
||||
// bucket gets its slot back.
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
throttleReproof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
@@ -72,6 +73,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
return apiError("Current password is incorrect", 401);
|
||||
}
|
||||
// The current password was right, so this attempt was not a guess. Give the
|
||||
// shared re-proof budget its slot back; otherwise a password change followed
|
||||
// by a few confirmations elsewhere runs the owner out of attempts.
|
||||
await refundReproof(user.id);
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
return apiError("New password must differ from current password", 422);
|
||||
|
||||
@@ -52,6 +52,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
REPROOF_FAILED_CODE,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
throttleReproof,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
@@ -110,6 +111,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
await refundReproof(user.id);
|
||||
|
||||
const now = new Date();
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
|
||||
@@ -318,3 +318,42 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export — the whole record asks for a fresh proof", () => {
|
||||
const stale = {
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
};
|
||||
|
||||
it("type=all on a session that signed in an hour ago is asked to confirm", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
|
||||
const { GET } = await import("../route");
|
||||
const res = await GET(mkReq("http://localhost/api/export?type=all"));
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no type means the whole record, and is gated the same way", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
|
||||
const { GET } = await import("../route");
|
||||
const res = await GET(mkReq("http://localhost/api/export"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("type=all right after signing in goes through", async () => {
|
||||
const { GET } = await import("../route");
|
||||
const res = await GET(mkReq("http://localhost/api/export?type=all"));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("a single-type export stays on the session alone", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue(stale as never);
|
||||
const { GET } = await import("../route");
|
||||
const res = await GET(
|
||||
mkReq("http://localhost/api/export?type=measurements"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
healthProfileFactRevision: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
session: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
})),
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
},
|
||||
toJson: (value: unknown) => value,
|
||||
}));
|
||||
@@ -908,3 +919,18 @@ describe("POST /api/export/health-record — the insurance leaf", () => {
|
||||
expect(text).toContain("A123456789");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/export/health-record — a fresh proof on the browser", () => {
|
||||
it("a session that signed in an hour ago is asked to confirm first", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(mkReq({ format: "pdf", selection: allLeaves() }));
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,11 @@
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
requireRecordAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -88,7 +92,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// v1.37.0 — MANAGE: the doctor report is the one export an invited manager
|
||||
// reaches, because it renders a prepared artefact from a declared selection
|
||||
// rather than handing over the record whole.
|
||||
const { user, actor } = await requireRecordAuth("manage", "record");
|
||||
const auth = await requireRecordAuth("manage", "record");
|
||||
const { user, actor } = auth;
|
||||
// A whole-record artefact: a browser session must have signed in or
|
||||
// re-proved within five minutes, the gate every whole-record export takes.
|
||||
// The proof is the person at the keyboard, so it is the actor's session
|
||||
// that is asked, whether they export their own record or one they manage.
|
||||
// A token is its own proof here, as before: the shipped app calls this
|
||||
// route with its token and no elevation.
|
||||
if (auth.authMethod === "cookie") {
|
||||
await assertRecentCookieProof(actor, auth.session.id);
|
||||
}
|
||||
annotate({ action: { name: "export.health-record.build" } });
|
||||
|
||||
// The whole doctor-report surface is the `doctorReport` module. Refuse with
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
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 { apiError, getClientIp } from "@/lib/api-response";
|
||||
@@ -24,12 +24,23 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
* Export user data.
|
||||
* Query params:
|
||||
* format: "csv" | "json" (default: json)
|
||||
* type: "measurements" | "medications" | "intake" | "all" (default: all)
|
||||
* type: "measurements" | "medications" | "intake" | "mood" | "all"
|
||||
* (default: all)
|
||||
*
|
||||
* Re-authentication is required via session (user must be logged in).
|
||||
* `type=all` (the default) is the whole record in one file, so it takes the
|
||||
* same fresh-proof gate as every other whole-record export
|
||||
* (`requireRecentProof`): a browser session must have signed in or re-proved
|
||||
* within five minutes, a token must present a step-up elevation. No shipped
|
||||
* client calls this route with a token. The single-type exports stay on the
|
||||
* session alone, like their dedicated routes under `/api/export/*`.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const wholeRecord =
|
||||
(new URL(request.url).searchParams.get("type") ?? "all") === "all";
|
||||
const auth = wholeRecord
|
||||
? await requireRecentProof({ bearer: "elevation" })
|
||||
: { ...(await requireAuth()), commitElevation: async () => {} };
|
||||
const { user } = auth;
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
@@ -54,6 +65,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Spend a Bearer elevation only once the request is about to act.
|
||||
await auth.commitElevation();
|
||||
|
||||
const userId = user.id;
|
||||
await auditLog("export.download", {
|
||||
userId,
|
||||
|
||||
@@ -13,12 +13,17 @@
|
||||
* resolves inside the response and the operation can never quietly carry less
|
||||
* than the document does.
|
||||
*
|
||||
* Read-only; `userId` narrowed from `requireAuth`. Offset paging applies across
|
||||
* Read-only; `userId` narrowed from `requireAuth`. A browser session needs a
|
||||
* recent sign-in or re-proof (`assertRecentCookieProof`). Offset paging applies across
|
||||
* the flattened resource list via `_count` (clamped ≤200) / `_offset`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
requireAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -32,7 +37,16 @@ import {
|
||||
} from "@/lib/fhir/rest";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
const auth = await requireAuth(FHIR_READ_SCOPE);
|
||||
const { user } = auth;
|
||||
// The whole record in one response: a browser session must have signed in
|
||||
// or re-proved within five minutes, like every other whole-record export.
|
||||
// A token is its own proof here, as before: the shipped app and the FHIR
|
||||
// read tokens call this route without an elevation, and a refusal would
|
||||
// read to the app as a dead session.
|
||||
if (auth.authMethod === "cookie") {
|
||||
await assertRecentCookieProof(user, auth.session.id);
|
||||
}
|
||||
annotate({ action: { name: "fhir.everything.read" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
|
||||
@@ -15,6 +15,21 @@ import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
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/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -39,6 +54,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { GET as metadataGet } from "../metadata/route";
|
||||
import { GET as everythingGet } from "../Patient/$everything/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { GET as observationRead } from "../Observation/[id]/route";
|
||||
import { GET as patientRead } from "../Patient/[id]/route";
|
||||
import { GET as coverageRead } from "../Coverage/[id]/route";
|
||||
@@ -291,6 +307,17 @@ describe("GET /api/fhir/{type}/{id} — read", () => {
|
||||
});
|
||||
|
||||
describe("GET /api/fhir/Patient/$everything", () => {
|
||||
it("asks a browser session that signed in an hour ago to confirm first", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
const res = await everythingGet(req("/api/fhir/Patient/$everything"));
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
|
||||
});
|
||||
|
||||
it("returns every resource family the document bundle carries", async () => {
|
||||
const res = await everythingGet(
|
||||
req("/api/fhir/Patient/$everything?_count=200"),
|
||||
|
||||
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
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/modules/operator-availability", async (importOriginal) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
|
||||
process.env.APP_URL = "https://health.example";
|
||||
@@ -21,6 +21,22 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
})),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
// The consent reads the session's own proof stamps. By default the session
|
||||
// signed in a moment ago, which is a recent proof on an account without a
|
||||
// second factor; the cases below age it.
|
||||
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) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
@@ -30,6 +46,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
|
||||
const VERIFIER = "a".repeat(64);
|
||||
@@ -327,6 +344,82 @@ describe("POST /authorize — decision", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorize — allowing a connection needs a recent proof", () => {
|
||||
// A queued stale stamp a case did not read must not leak into the next one.
|
||||
afterEach(() => {
|
||||
vi.mocked(prisma.session.findUnique).mockReset();
|
||||
});
|
||||
|
||||
function staleSession() {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
}
|
||||
const allow = {
|
||||
response_type: "code",
|
||||
client_id: CLIENT.clientId,
|
||||
redirect_uri: REDIRECT,
|
||||
code_challenge: CHALLENGE,
|
||||
code_challenge_method: "S256",
|
||||
scope: "health:read",
|
||||
state: "xyz",
|
||||
resource: RESOURCE,
|
||||
decision: "allow",
|
||||
};
|
||||
|
||||
it("GET shows a confirm link instead of the form when the session is an hour old", async () => {
|
||||
signedIn();
|
||||
staleSession();
|
||||
const res = await GET(getReq(authorizeUrl()) as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toMatch(/Confirm it is you/);
|
||||
expect(body).not.toMatch(/name="decision"/);
|
||||
const href = body.match(/href="([^"]+)"/)?.[1] ?? "";
|
||||
const next =
|
||||
new URL(href.replace(/&/g, "&"), "https://x").searchParams.get(
|
||||
"next",
|
||||
) ?? "";
|
||||
expect(href.startsWith("/confirm-access?next=")).toBe(true);
|
||||
// Back to this exact consent request.
|
||||
expect(next).toBe(
|
||||
new URL(authorizeUrl()).pathname + new URL(authorizeUrl()).search,
|
||||
);
|
||||
});
|
||||
|
||||
it("POST allow on an hour-old session mints nothing", async () => {
|
||||
signedIn();
|
||||
staleSession();
|
||||
const res = await POST(postReq(allow) as never);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
expect(await res.text()).toMatch(/confirm-access\?next=/);
|
||||
});
|
||||
|
||||
it("an account with a second factor needs the factor, not a young session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: {
|
||||
id: "s1",
|
||||
expiresAt: new Date(Date.now() + 1e6),
|
||||
actingAsUserId: null,
|
||||
recordEpoch: 0,
|
||||
},
|
||||
user: { id: "user-1", totpConfirmedAt: new Date("2026-01-01") } as never,
|
||||
});
|
||||
const res = await POST(postReq(allow) as never);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("deny needs no proof", async () => {
|
||||
signedIn();
|
||||
staleSession();
|
||||
const res = await POST(postReq({ ...allow, decision: "deny" }) as never);
|
||||
expect(res.status).toBe(303);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorize CSP — the consent form may lead only to the validated redirect", () => {
|
||||
function csp(res: Response): string {
|
||||
return res.headers.get("content-security-policy") ?? "";
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* a GET (confused-deputy mitigation: a third party cannot silently
|
||||
* obtain a grant — an authenticated human must click "Allow").
|
||||
* POST — the consent decision. Requires a session, re-validates every
|
||||
* parameter, and on "Allow" mints a single-use, short-lived,
|
||||
* parameter, and on "Allow" asks for a recent sign-in or re-proof
|
||||
* (the GET shows a confirm link instead of the form without one),
|
||||
* then mints a single-use, short-lived,
|
||||
* self-describing authorization code bound to this user + client +
|
||||
* redirect URI + PKCE challenge + scope + audience, then 303s back.
|
||||
*
|
||||
@@ -30,6 +32,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { assertRecentCookieProof } from "@/lib/api-handler";
|
||||
import { StepUpRequiredError } from "@/lib/api-errors";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import {
|
||||
@@ -88,6 +92,42 @@ function html(body: string, status = 200): Response {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this session signed in or re-proved recently enough to grant access?
|
||||
*
|
||||
* Allowing a connection hands a standing credential to the record to a third
|
||||
* party, one that outlives the session: the same class of act as minting a
|
||||
* token or making a share link, and it takes the same gate
|
||||
* (`assertRecentCookieProof`: a sign-in or re-proof within five minutes, with
|
||||
* the second factor on an account that has one). Without it, whoever held a
|
||||
* stolen session could connect their own assistant and keep reading the
|
||||
* record after the owner signed everything out.
|
||||
*/
|
||||
async function hasRecentProof(
|
||||
session: NonNullable<Awaited<ReturnType<typeof getSession>>>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await assertRecentCookieProof(session.user, session.session.id);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof StepUpRequiredError) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page shown instead of the consent form while the session carries no
|
||||
* recent proof. It links to the confirm page, which runs the same re-proof
|
||||
* dialog as the rest of the app and then comes back to this exact request.
|
||||
*/
|
||||
function reproofPage(clientName: string, returnTo: string, status = 200) {
|
||||
const confirmHref = `/confirm-access?next=${encodeURIComponent(returnTo)}`;
|
||||
return html(
|
||||
`<main><h1>Confirm it is you</h1><p>Before <strong>${htmlEscape(clientName)}</strong> can connect to your HealthLog data, confirm it is you. This is asked when you have not signed in or confirmed in the last five minutes.</p><p><a href="${htmlEscape(confirmHref)}">Confirm and continue</a></p></main>`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scopes this surface grants for a request. `health:read` is always
|
||||
* granted (the floor). `health:write` is granted ONLY when explicitly
|
||||
@@ -359,6 +399,11 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await hasRecentProof(session))) {
|
||||
annotate({ action: { name: "mcp.oauth.authorize.reproof_required" } });
|
||||
return reproofPage(v.clientName, url.pathname + url.search);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "mcp.oauth.authorize.consent_shown" } });
|
||||
// Consent screen — a plain POST form (no inline script/style, CSP-safe).
|
||||
const hidden = (name: string, value: string) =>
|
||||
@@ -499,6 +544,23 @@ async function authorizePost(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// The same gate the consent page asked for, checked again on the decision
|
||||
// itself: the page may have been left open past the window, or the POST
|
||||
// built by hand. Denying needs no proof, so only "allow" is gated.
|
||||
if (!(await hasRecentProof(session))) {
|
||||
annotate({ action: { name: "mcp.oauth.authorize.reproof_required" } });
|
||||
const back = new URL("/api/mcp/oauth/authorize", request.url);
|
||||
back.searchParams.set("response_type", "code");
|
||||
back.searchParams.set("client_id", v.clientId);
|
||||
back.searchParams.set("redirect_uri", v.redirectUri);
|
||||
back.searchParams.set("code_challenge", v.codeChallenge);
|
||||
back.searchParams.set("code_challenge_method", "S256");
|
||||
back.searchParams.set("scope", v.scope);
|
||||
back.searchParams.set("resource", v.resource);
|
||||
if (v.state !== undefined) back.searchParams.set("state", v.state);
|
||||
return reproofPage(v.clientName, back.pathname + back.search, 401);
|
||||
}
|
||||
|
||||
const code = signArtifact(
|
||||
"authCode",
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user