diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0b8a74389..fbe1426cd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2621,7 +2621,10 @@ paths: (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." + 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." parameters: - in: query name: keepShareLinks @@ -19394,7 +19397,12 @@ paths: (`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." + 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: @@ -19409,8 +19417,10 @@ paths: schema: $ref: "#/components/schemas/AccountGrantEnvelope" "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." + description: "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: @@ -39023,11 +39033,57 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Clinician share links revoked; 0 when `keepShareLinks=1` was sent. + pendingInvitesRevoked: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: "Record-sharing invitations this account offered that nobody had accepted yet, now withdrawn (`revokedBy: + GRANTOR`)." + grantsKept: + type: array + items: + type: object + properties: + id: + type: string + description: The grant id, for DELETE /api/account/grants/{id}. + account: + type: object + properties: + id: + type: string + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + required: + - id + - username + - displayName + additionalProperties: false + access: + type: string + enum: + - READ + - WRITE + - MANAGE + required: + - id + - account + - access + additionalProperties: false + description: 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}. required: - sessionsRevoked - accessTokensRevoked - connectorsRevoked - shareLinksRevoked + - pendingInvitesRevoked + - grantsKept additionalProperties: false SessionRevokeEnvelope: type: object diff --git a/messages/de.json b/messages/de.json index 8b6523d6b..8e7c9e885 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6556,7 +6556,11 @@ "revokeSessionConfirmBody": "{device} muss sich neu anmelden. An den dort erfassten Daten ändert sich nichts.", "signOutEverywhereConfirmTitle": "Alle anderen Geräte abmelden?", "signOutEverywhereConfirmBody": "Alle anderen Geräte, jeder verbundene KI-Assistent und jedes API-Token müssen sich neu anmelden oder neu eingerichtet werden. Dieses Gerät bleibt angemeldet. Deine Einträge bleiben unberührt.", - "signOutEverywhereShareLinks": "Auch die Freigabelinks für Ärztinnen und Ärzte beenden" + "signOutEverywhereShareLinks": "Auch die Freigabelinks für Ärztinnen und Ärzte beenden", + "signOutEverywhereInvites": "Einladungen zu deiner Akte, die noch niemand angenommen hat, werden zurückgezogen. Wer schon angenommen hat, behält den Zugriff; du kannst ihn gleich danach beenden.", + "signOutEverywhereInvitesDone": "{count} nicht angenommene Einladung(en) zurückgezogen.", + "grantsKeptTitle": "Diese Personen sehen deine Akte weiterhin", + "grantsKeptBody": "Das Abmelden beendet keinen Zugriff, den du jemandem gegeben hast. Beende ihn hier, wenn du ihn nicht gegeben hast oder nicht mehr willst." }, "privacy": { "encryption": { diff --git a/messages/en.json b/messages/en.json index 8eeaf9ad7..5fb9c4cf2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6556,7 +6556,11 @@ "revokeSessionConfirmBody": "{device} will need to sign in again. Nothing recorded on it is affected.", "signOutEverywhereConfirmTitle": "Sign out every other device?", "signOutEverywhereConfirmBody": "Every other device, every connected AI assistant and every API token will need to sign in or be set up again. This device stays signed in. Nothing you have recorded is affected.", - "signOutEverywhereShareLinks": "Also end the doctor share links" + "signOutEverywhereShareLinks": "Also end the doctor share links", + "signOutEverywhereInvites": "Invitations to your record that nobody has accepted yet are withdrawn. People who already accepted keep their access; you can end it next.", + "signOutEverywhereInvitesDone": "Withdrew {count} invitation(s) nobody had accepted.", + "grantsKeptTitle": "These people can still see your record", + "grantsKeptBody": "Signing out does not end access you gave someone. End it here if you did not give it, or no longer want them to have it." }, "privacy": { "encryption": { diff --git a/messages/es.json b/messages/es.json index 193dd1fea..3caf7e3d1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6556,7 +6556,11 @@ "revokeSessionConfirmBody": "{device} tendrá que iniciar sesión de nuevo. Lo registrado en él no se ve afectado.", "signOutEverywhereConfirmTitle": "¿Cerrar la sesión en los demás dispositivos?", "signOutEverywhereConfirmBody": "Todos los demás dispositivos, cada asistente de IA conectado y cada token de API tendrán que volver a iniciar sesión o configurarse de nuevo. Este dispositivo sigue con la sesión iniciada. Nada de lo que has registrado se ve afectado.", - "signOutEverywhereShareLinks": "Terminar también los enlaces compartidos con profesionales médicos" + "signOutEverywhereShareLinks": "Terminar también los enlaces compartidos con profesionales médicos", + "signOutEverywhereInvites": "Se retiran las invitaciones a tu historial que nadie ha aceptado todavía. Quien ya aceptó conserva el acceso; puedes quitárselo a continuación.", + "signOutEverywhereInvitesDone": "Se retiraron {count} invitación(es) que nadie había aceptado.", + "grantsKeptTitle": "Estas personas todavía pueden ver tu historial", + "grantsKeptBody": "Cerrar sesión no quita el acceso que diste a alguien. Quítalo aquí si no lo diste o si ya no quieres que lo tenga." }, "privacy": { "encryption": { diff --git a/messages/fr.json b/messages/fr.json index 2fe9be017..f362b2bae 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6556,7 +6556,11 @@ "revokeSessionConfirmBody": "{device} devra se reconnecter. Ce qui y est enregistré n'est pas affecté.", "signOutEverywhereConfirmTitle": "Déconnecter tous les autres appareils ?", "signOutEverywhereConfirmBody": "Tous les autres appareils, chaque assistant IA connecté et chaque jeton d'API devront se reconnecter ou être reconfigurés. Cet appareil reste connecté. Rien de ce que vous avez enregistré n'est touché.", - "signOutEverywhereShareLinks": "Mettre fin aussi aux liens de partage avec les médecins" + "signOutEverywhereShareLinks": "Mettre fin aussi aux liens de partage avec les médecins", + "signOutEverywhereInvites": "Les invitations à votre dossier que personne n'a encore acceptées sont retirées. Les personnes qui ont déjà accepté gardent leur accès ; vous pourrez y mettre fin ensuite.", + "signOutEverywhereInvitesDone": "{count} invitation(s) non acceptée(s) retirée(s).", + "grantsKeptTitle": "Ces personnes peuvent toujours voir votre dossier", + "grantsKeptBody": "Se déconnecter ne met pas fin à l'accès que vous avez donné à quelqu'un. Mettez-y fin ici si vous ne l'avez pas donné ou si vous ne le souhaitez plus." }, "privacy": { "encryption": { diff --git a/messages/it.json b/messages/it.json index 4f527a583..404551bb8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6556,7 +6556,11 @@ "revokeSessionConfirmBody": "{device} dovrà accedere di nuovo. Quanto registrato su di esso non viene toccato.", "signOutEverywhereConfirmTitle": "Disconnettere tutti gli altri dispositivi?", "signOutEverywhereConfirmBody": "Tutti gli altri dispositivi, ogni assistente IA collegato e ogni token API dovranno accedere di nuovo o essere configurati di nuovo. Questo dispositivo resta connesso. Nulla di ciò che hai registrato viene toccato.", - "signOutEverywhereShareLinks": "Termina anche i link di condivisione con i medici" + "signOutEverywhereShareLinks": "Termina anche i link di condivisione con i medici", + "signOutEverywhereInvites": "Gli inviti alla tua cartella che nessuno ha ancora accettato vengono ritirati. Chi ha già accettato mantiene l'accesso; puoi revocarlo subito dopo.", + "signOutEverywhereInvitesDone": "Ritirati {count} inviti non accettati.", + "grantsKeptTitle": "Queste persone possono ancora vedere la tua cartella", + "grantsKeptBody": "Disconnettersi non revoca l'accesso che hai dato a qualcuno. Revocalo qui se non l'hai dato tu o se non vuoi più che lo abbia." }, "privacy": { "encryption": { diff --git a/messages/ko.json b/messages/ko.json index 5e80dc3b9..85af2c610 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6556,7 +6556,11 @@ "revokeSessionConfirmBody": "{device} — 다시 로그인해야 해요. 기기에 기록된 내용은 영향받지 않아요.", "signOutEverywhereConfirmTitle": "다른 기기를 모두 로그아웃할까요?", "signOutEverywhereConfirmBody": "다른 모든 기기, 연결된 모든 AI 어시스턴트와 모든 API 토큰은 다시 로그인하거나 다시 설정해야 합니다. 이 기기는 로그인 상태로 유지됩니다. 기록한 내용에는 영향이 없습니다.", - "signOutEverywhereShareLinks": "의료진 공유 링크도 종료" + "signOutEverywhereShareLinks": "의료진 공유 링크도 종료", + "signOutEverywhereInvites": "아직 아무도 수락하지 않은 기록 공유 초대는 철회됩니다. 이미 수락한 사람은 접근 권한을 유지하며, 이어서 끝낼 수 있어요.", + "signOutEverywhereInvitesDone": "수락되지 않은 초대 {count}개를 철회했어요.", + "grantsKeptTitle": "이 사람들은 여전히 내 기록을 볼 수 있어요", + "grantsKeptBody": "로그아웃해도 다른 사람에게 준 접근 권한은 끝나지 않아요. 직접 준 적이 없거나 더 이상 원하지 않으면 여기에서 끝내세요." }, "privacy": { "encryption": { diff --git a/messages/pl.json b/messages/pl.json index f04de5400..541ad9267 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6556,7 +6556,11 @@ "revokeSessionConfirmBody": "{device} będzie musiało zalogować się ponownie. Zapisane na nim dane pozostają bez zmian.", "signOutEverywhereConfirmTitle": "Wylogować wszystkie pozostałe urządzenia?", "signOutEverywhereConfirmBody": "Wszystkie inne urządzenia, każdy połączony asystent AI i każdy token API będą musiały zalogować się ponownie lub zostać skonfigurowane od nowa. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian.", - "signOutEverywhereShareLinks": "Zakończ także linki udostępniania dla lekarzy" + "signOutEverywhereShareLinks": "Zakończ także linki udostępniania dla lekarzy", + "signOutEverywhereInvites": "Zaproszenia do Twojej karty, których nikt jeszcze nie przyjął, zostaną wycofane. Osoby, które już je przyjęły, zachowują dostęp; możesz go zakończyć zaraz potem.", + "signOutEverywhereInvitesDone": "Wycofano nieprzyjęte zaproszenia: {count}.", + "grantsKeptTitle": "Te osoby nadal widzą Twoją kartę", + "grantsKeptBody": "Wylogowanie nie kończy dostępu, który komuś dałeś. Zakończ go tutaj, jeśli go nie dawałeś albo już nie chcesz, żeby go miał." }, "privacy": { "encryption": { diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index 9a1e0f8ae..cb6bb3878 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -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 = { + // 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", diff --git a/src/app/api/account/grants/__tests__/invite-access-level.test.ts b/src/app/api/account/grants/__tests__/invite-access-level.test.ts index 3798cc52f..431ad44e0 100644 --- a/src/app/api/account/grants/__tests__/invite-access-level.test.ts +++ b/src/app/api/account/grants/__tests__/invite-access-level.test.ts @@ -27,11 +27,19 @@ vi.mock("@/lib/api-handler", async () => { await vi.importActual( "@/lib/api-handler", ); + const requireAuth = vi.fn(); return { ...actual, apiHandler: Promise>(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 () => {}, + })), }; }); diff --git a/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts b/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts new file mode 100644 index 000000000..e4c987074 --- /dev/null +++ b/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts @@ -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()), + 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(); + }); +}); diff --git a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts index 265283687..1d79bad00 100644 --- a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts +++ b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts @@ -31,11 +31,19 @@ vi.mock("@/lib/api-handler", async () => { await vi.importActual( "@/lib/api-handler", ); + const requireAuth = vi.fn(); return { ...actual, apiHandler: Promise>(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(), }; }); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 1a0184f55..0cae8e85d 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -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 diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts index d19634e43..8f90a9de1 100644 --- a/src/app/api/auth/me/sessions/__tests__/route.test.ts +++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts @@ -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 }) }; diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts index 1b9b02c34..9f256b4f4 100644 --- a/src/app/api/auth/me/sessions/route.ts +++ b/src/app/api/auth/me/sessions/route.ts @@ -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, }); }); diff --git a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx index 49bb21f75..faae601af 100644 --- a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx +++ b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx @@ -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"'); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index 3b86108ea..d9202302c 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -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", )}`} /> -
+ void submit(event)} className="space-y-3">
); diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 2884ae478..afe723a05 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -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, }; }); } diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 9b566b1cb..d4d59dc54 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -570,7 +570,7 @@ export const accountSharingPaths: NonNullable = { 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 = { }, "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": { diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index c26652e3b..961b25e76 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -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 = { 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({ diff --git a/tests/integration/sessions-and-known-devices.test.ts b/tests/integration/sessions-and-known-devices.test.ts index 719fbd3e8..3a5a9f5b4 100644 --- a/tests/integration/sessions-and-known-devices.test.ts +++ b/tests/integration/sessions-and-known-devices.test.ts @@ -386,6 +386,65 @@ describe("destroyOtherSessions — everything", () => { ).toBeNull(); }); + it("withdraws invitations nobody accepted and leaves accepted grants standing", async () => { + const prisma = getPrismaClient(); + const owner = await makeUser("sess-invites-owner"); + const carer = await makeUser("sess-invites-carer"); + const stranger = await makeUser("sess-invites-stranger"); + const other = await makeUser("sess-invites-other"); + const accepted = await prisma.accountGrant.create({ + data: { + grantorId: owner.id, + granteeId: carer.id, + access: "READ", + acceptedAt: new Date(), + }, + }); + const pending = await prisma.accountGrant.create({ + data: { grantorId: owner.id, granteeId: stranger.id, access: "WRITE" }, + }); + // Somebody else's invitation to the owner is not the owner's to withdraw. + const received = await prisma.accountGrant.create({ + data: { grantorId: other.id, granteeId: owner.id, access: "READ" }, + }); + + const result = await destroyOtherSessions( + owner.id, + { kind: "accessToken", accessTokenHash: "none" }, + { reach: "everything" }, + ); + + expect(result.pendingInvitesRevoked).toBe(1); + const after = async (id: string) => + prisma.accountGrant.findUniqueOrThrow({ where: { id } }); + expect((await after(pending.id)).revokedAt).not.toBeNull(); + expect((await after(pending.id)).revokedBy).toBe("GRANTOR"); + expect((await after(accepted.id)).revokedAt).toBeNull(); + expect((await after(received.id)).revokedAt).toBeNull(); + }); + + it("sign-ins leaves pending invitations alone", async () => { + const prisma = getPrismaClient(); + const owner = await makeUser("sess-invites-signins"); + const other = await makeUser("sess-invites-signins-2"); + const pending = await prisma.accountGrant.create({ + data: { grantorId: owner.id, granteeId: other.id, access: "READ" }, + }); + const result = await destroyOtherSessions( + owner.id, + { kind: "accessToken", accessTokenHash: "none" }, + { reach: "sign-ins" }, + ); + expect(result.pendingInvitesRevoked).toBe(0); + expect( + ( + await prisma.accountGrant.findUniqueOrThrow({ + where: { id: pending.id }, + }) + ).revokedAt, + ).toBeNull(); + }); + it("sign-ins leaves connections, tokens and share links alone", async () => { const prisma = getPrismaClient(); const user = await makeUser("sess-signins-only");