mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(sharing): invitations need a fresh proof and end with sign out everywhere
Offering access to the record took a live session alone, and an invitation outlives the session that sent it: whoever held a stolen session could invite their own account and keep a way into the record after the owner signed everything out. POST /api/account/grants now takes the recent-proof gate at every level (a sign-in or re-proof within five minutes on the browser, with the second factor on an account that has one; a step-up elevation on a token, which no shipped client uses here), and the invite form opens the re-proof dialog and sends again. Sign out everywhere now withdraws every invitation the account offered that nobody has accepted. Accepted grants are left standing on purpose: each is a person the owner chose, often a carer who still needs the record, and a sign-out button must not quietly cut them off. The response lists them as grantsKept and the sessions card shows each with a one-click End access.
This commit is contained in:
1 parent
a4d27c618e
commit
a294d7f684
22 files changed
+585
-65
No files matched your search
@@ -73,6 +73,8 @@ function callers(exportName: string): string[] {
|
||||
|
||||
/** Routes that resolve their own caller and take the recent-proof gate. */
|
||||
const RECENT_PROOF_ROUTES: Record<string, string> = {
|
||||
// Offering access to the record. No shipped client calls it on a token.
|
||||
"app/api/account/grants/route.ts": "elevation",
|
||||
// `type=all` only; the single-type exports stay on `requireAuth`.
|
||||
"app/api/export/route.ts": "elevation",
|
||||
"app/api/export/full-backup/route.ts": "elevation",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -70,15 +70,13 @@ describe("grant invitation lapse date", () => {
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const onError = source.slice(
|
||||
source.indexOf("onError: (err) => setError"),
|
||||
source.indexOf(
|
||||
" );\n };",
|
||||
source.indexOf("onError: (err) => setError"),
|
||||
),
|
||||
);
|
||||
// The failure arm of the submit: everything from its `catch` to the end
|
||||
// of the handler.
|
||||
const start = source.indexOf("} catch (err) {");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const onError = source.slice(start, source.indexOf("\n };", start));
|
||||
|
||||
expect(onError).toContain("setError(t(inviteErrorKey(err)))");
|
||||
expect(onError).toContain("t(inviteErrorKey(err))");
|
||||
expect(onError).not.toContain("setIdentifier");
|
||||
expect(onError).not.toContain("setExpiresOn");
|
||||
expect(source).toContain('role="alert"');
|
||||
|
||||
@@ -18,6 +18,10 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInviteGrant } from "@/lib/queries/use-account-grants";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
|
||||
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
@@ -79,6 +83,7 @@ import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
export function GrantInviteCard() {
|
||||
const { t } = useTranslations();
|
||||
const invite = useInviteGrant();
|
||||
const recentProof = useRecentProof();
|
||||
const ownerTimezone = useInvitationOwnerTimezone();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [expiresOn, setExpiresOn] = useState("");
|
||||
@@ -118,31 +123,40 @@ export function GrantInviteCard() {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = identifier.trim();
|
||||
if (value.length === 0 || choice.blocked) return;
|
||||
setError(null);
|
||||
setInvited(null);
|
||||
invite.mutate(
|
||||
{
|
||||
identifier: value,
|
||||
access,
|
||||
scope: choice.scope,
|
||||
expiresAt: endOfDayIso(expiresOn, ownerTimezone),
|
||||
},
|
||||
{
|
||||
onSuccess: (grant) => {
|
||||
setIdentifier("");
|
||||
setExpiresOn("");
|
||||
setAccess("READ");
|
||||
setNarrowed(null);
|
||||
setSections([]);
|
||||
setInvited(grant.account.username);
|
||||
},
|
||||
onError: (err) => setError(t(inviteErrorKey(err))),
|
||||
},
|
||||
);
|
||||
// Offering access hands somebody a standing way into the record, so the
|
||||
// server asks for a fresh proof unless the session signed in or re-proved
|
||||
// within five minutes; the dialog collects it and the invitation is sent
|
||||
// again.
|
||||
try {
|
||||
const grant = await recentProof.run(() =>
|
||||
invite.mutateAsync({
|
||||
identifier: value,
|
||||
access,
|
||||
scope: choice.scope,
|
||||
expiresAt: endOfDayIso(expiresOn, ownerTimezone),
|
||||
}),
|
||||
);
|
||||
setIdentifier("");
|
||||
setExpiresOn("");
|
||||
setAccess("READ");
|
||||
setNarrowed(null);
|
||||
setSections([]);
|
||||
setInvited(grant.account.username);
|
||||
} catch (err) {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === "auth.reproof.sign_in_again"
|
||||
? err.message
|
||||
: t(inviteErrorKey(err)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -159,7 +173,7 @@ export function GrantInviteCard() {
|
||||
"recordSharing.invite.needsAccount",
|
||||
)}`}
|
||||
/>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<form onSubmit={(event) => void submit(event)} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="grant-invite-identifier">
|
||||
{t("recordSharing.invite.identifierLabel")}
|
||||
@@ -373,6 +387,7 @@ export function GrantInviteCard() {
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
{recentProof.dialog}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
@@ -23,8 +24,22 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
|
||||
import { useRevokeGrant } from "@/lib/queries/use-account-grants";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** An accepted grant sign-out-everywhere left standing (`grantsKept`). */
|
||||
interface KeptGrant {
|
||||
id: string;
|
||||
account: { id: string; username: string; displayName: string | null };
|
||||
access: "READ" | "WRITE" | "MANAGE";
|
||||
}
|
||||
|
||||
const ACCESS_LABEL_KEY: Record<KeptGrant["access"], string> = {
|
||||
READ: "recordSharing.invite.accessReadLabel",
|
||||
WRITE: "recordSharing.invite.accessWriteLabel",
|
||||
MANAGE: "recordSharing.invite.accessManageLabel",
|
||||
};
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
device: string;
|
||||
@@ -54,6 +69,12 @@ export function SecuritySessionsCard({
|
||||
// state; nothing is persisted across reloads.
|
||||
const [open, setOpen] = useState(false);
|
||||
const regionId = useId();
|
||||
// The people who can still read this record after "sign out everywhere".
|
||||
// Their access is not ended by it (a carer who still needs the record would
|
||||
// be cut off by a button labelled "sign out"), so they are shown here with
|
||||
// a one-click end instead. Held only until the card unmounts.
|
||||
const [keptGrants, setKeptGrants] = useState<KeptGrant[]>([]);
|
||||
const revokeGrant = useRevokeGrant();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.sessions(),
|
||||
@@ -75,17 +96,32 @@ export function SecuritySessionsCard({
|
||||
|
||||
const revokeOthers = useMutation({
|
||||
mutationFn: () =>
|
||||
apiDelete<{ sessionsRevoked: number }>(
|
||||
apiDelete<{
|
||||
sessionsRevoked: number;
|
||||
pendingInvitesRevoked?: number;
|
||||
grantsKept?: KeptGrant[];
|
||||
}>(
|
||||
endShareLinks
|
||||
? "/api/auth/me/sessions"
|
||||
: "/api/auth/me/sessions?keepShareLinks=1",
|
||||
),
|
||||
onSuccess: (res) => {
|
||||
const invites = res?.pendingInvitesRevoked ?? 0;
|
||||
setStatus(
|
||||
t("settings.security.signOutEverywhereDone", {
|
||||
count: res?.sessionsRevoked ?? 0,
|
||||
}),
|
||||
[
|
||||
t("settings.security.signOutEverywhereDone", {
|
||||
count: res?.sessionsRevoked ?? 0,
|
||||
}),
|
||||
invites > 0
|
||||
? t("settings.security.signOutEverywhereInvitesDone", {
|
||||
count: invites,
|
||||
})
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
setKeptGrants(res?.grantsKept ?? []);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.securityActivity(),
|
||||
@@ -206,17 +242,24 @@ export function SecuritySessionsCard({
|
||||
onConfirm={() => revokeOthers.mutate()}
|
||||
pending={revokeOthers.isPending}
|
||||
extra={
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
data-testid="sign-out-everywhere-share-links"
|
||||
checked={endShareLinks}
|
||||
onCheckedChange={(next) => setEndShareLinks(next === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.security.signOutEverywhereShareLinks")}
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
data-testid="sign-out-everywhere-share-links"
|
||||
checked={endShareLinks}
|
||||
onCheckedChange={(next) =>
|
||||
setEndShareLinks(next === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.security.signOutEverywhereShareLinks")}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
{t("settings.security.signOutEverywhereInvites")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SettingsCardActions>
|
||||
@@ -227,6 +270,57 @@ export function SecuritySessionsCard({
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{keptGrants.length > 0 && (
|
||||
<div
|
||||
data-slot="sign-out-everywhere-grants-kept"
|
||||
className="space-y-2 border-t pt-4"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.security.grantsKeptTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.security.grantsKeptBody")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{keptGrants.map((grant) => (
|
||||
<li
|
||||
key={grant.id}
|
||||
className="flex items-center justify-between gap-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">
|
||||
{grant.account.displayName ?? grant.account.username}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(ACCESS_LABEL_KEY[grant.access])}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
disabled={revokeGrant.isPending}
|
||||
onClick={() =>
|
||||
revokeGrant.mutate(grant.id, {
|
||||
onSuccess: () =>
|
||||
setKeptGrants((current) =>
|
||||
current.filter((g) => g.id !== grant.id),
|
||||
),
|
||||
onError: () =>
|
||||
setStatus(
|
||||
t("recordSharing.actionError.revokeFailed"),
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("recordSharing.given.revoke")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -575,6 +575,15 @@ export type CurrentCredential =
|
||||
* and the web offers to keep them because a link already handed to a
|
||||
* doctor is often wanted. The caller's own credential is spared either way.
|
||||
*
|
||||
* `everything` also withdraws every record-sharing invitation this account
|
||||
* offered that nobody has accepted yet. An invitation outlives the session
|
||||
* that sent it, so one offered from a stolen session would be waiting for
|
||||
* the thief's own account to accept it. Accepted grants are NOT ended here:
|
||||
* they are a person the owner chose (a partner, a carer), usually still
|
||||
* wanted, and ending them silently would cut a carer off without anyone
|
||||
* deciding to. The route lists them in its answer instead, and the web
|
||||
* offers to end each one with a click.
|
||||
*
|
||||
* Returns what was removed, so the surface and the audit row can say what
|
||||
* happened.
|
||||
*/
|
||||
@@ -586,6 +595,7 @@ export interface SignOutResult {
|
||||
accessTokensRevoked: number;
|
||||
connectorsRevoked: number;
|
||||
shareLinksRevoked: number;
|
||||
pendingInvitesRevoked: number;
|
||||
}
|
||||
|
||||
export async function destroyOtherSessions(
|
||||
@@ -655,6 +665,7 @@ export async function destroyOtherSessions(
|
||||
accessTokensRevoked: access.count,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
pendingInvitesRevoked: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -680,11 +691,19 @@ export async function destroyOtherSessions(
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt },
|
||||
});
|
||||
// Offered by this account and not accepted yet: `acceptedAt` null, not
|
||||
// already ended. Stamped rather than deleted, so the consent record still
|
||||
// shows the invitation and who withdrew it.
|
||||
const invites = await tx.accountGrant.updateMany({
|
||||
where: { grantorId: userId, acceptedAt: null, revokedAt: null },
|
||||
data: { revokedAt, revokedBy: "GRANTOR" },
|
||||
});
|
||||
return {
|
||||
sessionsRevoked: deleted.count,
|
||||
accessTokensRevoked: access.count + tokens.count,
|
||||
connectorsRevoked: connectors.count,
|
||||
shareLinksRevoked: links.count,
|
||||
pendingInvitesRevoked: invites.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Invite an account to this record",
|
||||
description:
|
||||
"Offers access to somebody already registered on this instance, at READ, WRITE or MANAGE and over the whole record or named sections; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate. Offering MANAGE is step-up gated (`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401 `auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not authenticated' to a caller it had just authenticated.",
|
||||
"Offers access to somebody already registered on this instance, at READ, WRITE or MANAGE and over the whole record or named sections; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate. Offering MANAGE is step-up gated (`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401 `auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not authenticated' to a caller it had just authenticated. Every invitation, at any level, also needs a fresh proof: a browser session must have signed in or used `POST /api/auth/reproof` within the last five minutes (with the second factor, on an account that has one), or it gets 401 `auth.reproof.required` with `meta.methods`; a Bearer caller presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and gets 401 `auth.stepup.required` without it. `DELETE /api/auth/me/sessions` withdraws the invitations nobody has accepted yet.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: accountGrantInvite } },
|
||||
@@ -587,7 +587,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"401": {
|
||||
description:
|
||||
"Offering MANAGE from an MFA-enrolled account without a recent second-factor proof (`meta.errorCode: auth.stepup.required`). Re-prove the factor and retry; READ and WRITE invitations are never gated.",
|
||||
"No recent proof. A browser session that has not signed in or re-proved within five minutes gets `meta.errorCode: auth.reproof.required` with `meta.methods`; re-prove at POST /api/auth/reproof and retry. A Bearer caller without a valid `X-Step-Up` elevation gets `auth.stepup.required`, as does offering MANAGE from an MFA-enrolled browser session without a recent second-factor proof.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": {
|
||||
|
||||
@@ -238,6 +238,29 @@ const signOutEverywhereResponse = z
|
||||
.describe(
|
||||
"Clinician share links revoked; 0 when `keepShareLinks=1` was sent.",
|
||||
),
|
||||
pendingInvitesRevoked: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Record-sharing invitations this account offered that nobody had accepted yet, now withdrawn (`revokedBy: GRANTOR`).",
|
||||
),
|
||||
grantsKept: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe("The grant id, for DELETE /api/account/grants/{id}."),
|
||||
account: z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
}),
|
||||
access: z.enum(["READ", "WRITE", "MANAGE"]),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Accepted grants this account gave that are still live. Signing out does not end them, because each is a person the owner chose and ending one silently could cut off a carer; they are listed so a client can show who can still read the record and offer to end each with DELETE /api/account/grants/{id}.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "SignOutEverywhereResponse" });
|
||||
|
||||
@@ -1328,7 +1351,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Sign out everywhere else",
|
||||
description:
|
||||
"Ends every credential except the caller's own: every OTHER web session, every other device login (refresh token and its paired access token), every trusted device and step-up elevation, every connected AI assistant (the OAuth connection, so it cannot mint a new access token), every programmatic token (connector, measurement and document tokens), and every clinician share link. Share links are revoked by default because a link made by whoever held a lost session or device would otherwise keep opening the record; send `keepShareLinks=1` to keep them. A Bearer caller keeps its own device login.",
|
||||
"Ends every credential except the caller's own: every OTHER web session, every other device login (refresh token and its paired access token), every trusted device and step-up elevation, every connected AI assistant (the OAuth connection, so it cannot mint a new access token), every programmatic token (connector, measurement and document tokens), and every clinician share link. Share links are revoked by default because a link made by whoever held a lost session or device would otherwise keep opening the record; send `keepShareLinks=1` to keep them. Record-sharing invitations this account offered that nobody has accepted yet are withdrawn too, because an invitation sent from a stolen session would otherwise wait for the thief's own account. Accepted grants are not ended: the response lists them as `grantsKept` so the client can show who can still read the record and offer to end each one. A Bearer caller keeps its own device login.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
keepShareLinks: z.enum(["1"]).optional().meta({
|
||||
|
||||
Reference in new issue
Block a user