mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(auth): end connected assistants, tokens and share links on sign-out everywhere
"Sign out everywhere" ended web sessions and device logins but left every
credential that works without signing in: a connected AI assistant kept its
OAuth connection and minted the next access token from it, a connector or
ingest token kept working, and a clinician share link kept opening the
record. A password change revoked the tokens but not the assistant's
connection or the share links. Anything a stolen session had minted
outlived the remedy.
- Sign out everywhere now ends every other sign-in, every AI-assistant
connection, every programmatic token except the caller's own, and every
clinician share link. Share links are revoked by default; the web offers
a checkbox to keep them (keepShareLinks=1), and a client that sends
nothing, such as the shipped app, gets the safe default.
- A password change and an admin password reset also end AI-assistant
connections, share links and step-up elevations.
- Turning on the first second factor (TOTP or a security key) ends every
other sign-in, as a password change does. Adding a further factor does
not.
destroyOtherSessions takes an explicit reach ("sign-ins" or "everything")
so each caller says how far it goes.
This commit is contained in:
1 parent
0fc83cb0a1
commit
92c5b0a60d
23 files changed
+658
-66
No files matched your search
+34
-2
@@ -2590,8 +2590,21 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Sign out everywhere else
|
||||
description: v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current
|
||||
session. API tokens are not touched (manage those under /settings/api-tokens).
|
||||
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."
|
||||
parameters:
|
||||
- in: query
|
||||
name: keepShareLinks
|
||||
schema:
|
||||
description: "`1` keeps the clinician share links. Anything else, or nothing, revokes them."
|
||||
type: string
|
||||
enum:
|
||||
- "1"
|
||||
description: "`1` keeps the clinician share links. Anything else, or nothing, revokes them."
|
||||
responses:
|
||||
"200":
|
||||
description: Other sessions revoked.
|
||||
@@ -38525,8 +38538,27 @@ components:
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Number of OTHER sessions removed (the current one is kept).
|
||||
accessTokensRevoked:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: "Tokens revoked: the access tokens of other device logins plus every programmatic token (connector,
|
||||
measurement, document), never the caller's own."
|
||||
connectorsRevoked:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Connected AI-assistant (MCP OAuth) connections revoked.
|
||||
shareLinksRevoked:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Clinician share links revoked; 0 when `keepShareLinks=1` was sent.
|
||||
required:
|
||||
- sessionsRevoked
|
||||
- accessTokensRevoked
|
||||
- connectorsRevoked
|
||||
- shareLinksRevoked
|
||||
additionalProperties: false
|
||||
SessionRevokeEnvelope:
|
||||
type: object
|
||||
|
||||
+2
-1
@@ -6555,7 +6555,8 @@
|
||||
"revokeSessionConfirmTitle": "Dieses Gerät abmelden?",
|
||||
"revokeSessionConfirmBody": "{device} muss sich neu anmelden. An den dort erfassten Daten ändert sich nichts.",
|
||||
"signOutEverywhereConfirmTitle": "Alle anderen Geräte abmelden?",
|
||||
"signOutEverywhereConfirmBody": "Jedes Gerät außer diesem muss sich neu anmelden. Dieses Gerät bleibt angemeldet. An deinen erfassten Daten ändert sich nichts."
|
||||
"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"
|
||||
},
|
||||
"privacy": {
|
||||
"encryption": {
|
||||
|
||||
+2
-1
@@ -6555,7 +6555,8 @@
|
||||
"revokeSessionConfirmTitle": "Sign this device out?",
|
||||
"revokeSessionConfirmBody": "{device} will need to sign in again. Nothing recorded on it is affected.",
|
||||
"signOutEverywhereConfirmTitle": "Sign out every other device?",
|
||||
"signOutEverywhereConfirmBody": "Every device except this one will need to sign in again. This device stays signed in. Nothing you have recorded is affected."
|
||||
"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"
|
||||
},
|
||||
"privacy": {
|
||||
"encryption": {
|
||||
|
||||
+2
-1
@@ -6555,7 +6555,8 @@
|
||||
"revokeSessionConfirmTitle": "¿Cerrar la sesión de este dispositivo?",
|
||||
"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 dispositivos salvo este tendrán que iniciar sesión de nuevo. Este seguirá con la sesión abierta. Nada de lo que has registrado se ve afectado."
|
||||
"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"
|
||||
},
|
||||
"privacy": {
|
||||
"encryption": {
|
||||
|
||||
+2
-1
@@ -6555,7 +6555,8 @@
|
||||
"revokeSessionConfirmTitle": "Déconnecter cet appareil ?",
|
||||
"revokeSessionConfirmBody": "{device} devra se reconnecter. Ce qui y est enregistré n'est pas affecté.",
|
||||
"signOutEverywhereConfirmTitle": "Déconnecter tous les autres appareils ?",
|
||||
"signOutEverywhereConfirmBody": "Chaque appareil sauf celui-ci devra se reconnecter. Celui-ci reste connecté. Rien de ce que tu as enregistré n'est affecté."
|
||||
"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"
|
||||
},
|
||||
"privacy": {
|
||||
"encryption": {
|
||||
|
||||
+2
-1
@@ -6555,7 +6555,8 @@
|
||||
"revokeSessionConfirmTitle": "Disconnettere questo dispositivo?",
|
||||
"revokeSessionConfirmBody": "{device} dovrà accedere di nuovo. Quanto registrato su di esso non viene toccato.",
|
||||
"signOutEverywhereConfirmTitle": "Disconnettere tutti gli altri dispositivi?",
|
||||
"signOutEverywhereConfirmBody": "Ogni dispositivo tranne questo dovrà accedere di nuovo. Questo resta connesso. Nulla di ciò che hai registrato viene toccato."
|
||||
"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"
|
||||
},
|
||||
"privacy": {
|
||||
"encryption": {
|
||||
|
||||
+2
-1
@@ -6555,7 +6555,8 @@
|
||||
"revokeSessionConfirmTitle": "이 기기를 로그아웃할까요?",
|
||||
"revokeSessionConfirmBody": "{device} — 다시 로그인해야 해요. 기기에 기록된 내용은 영향받지 않아요.",
|
||||
"signOutEverywhereConfirmTitle": "다른 기기를 모두 로그아웃할까요?",
|
||||
"signOutEverywhereConfirmBody": "이 기기를 뺀 모든 기기가 다시 로그인해야 해요. 이 기기는 로그인 상태로 남아요. 기록한 내용은 영향받지 않아요."
|
||||
"signOutEverywhereConfirmBody": "다른 모든 기기, 연결된 모든 AI 어시스턴트와 모든 API 토큰은 다시 로그인하거나 다시 설정해야 합니다. 이 기기는 로그인 상태로 유지됩니다. 기록한 내용에는 영향이 없습니다.",
|
||||
"signOutEverywhereShareLinks": "의료진 공유 링크도 종료"
|
||||
},
|
||||
"privacy": {
|
||||
"encryption": {
|
||||
|
||||
+2
-1
@@ -6555,7 +6555,8 @@
|
||||
"revokeSessionConfirmTitle": "Wylogować to urządzenie?",
|
||||
"revokeSessionConfirmBody": "{device} będzie musiało zalogować się ponownie. Zapisane na nim dane pozostają bez zmian.",
|
||||
"signOutEverywhereConfirmTitle": "Wylogować wszystkie pozostałe urządzenia?",
|
||||
"signOutEverywhereConfirmBody": "Każde urządzenie poza tym będzie musiało zalogować się ponownie. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian."
|
||||
"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"
|
||||
},
|
||||
"privacy": {
|
||||
"encryption": {
|
||||
|
||||
@@ -76,10 +76,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
|
||||
it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => {
|
||||
const res = await POST(req());
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
|
||||
kind: "session",
|
||||
sessionId: "sess-current",
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
{
|
||||
kind: "session",
|
||||
sessionId: "sess-current",
|
||||
},
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
});
|
||||
|
||||
it("spares the CALLER's own device login on the Bearer path", async () => {
|
||||
@@ -99,10 +103,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
|
||||
const res = await POST(req());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
|
||||
kind: "accessToken",
|
||||
accessTokenHash: "hash-of-caller-access-token",
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
{
|
||||
kind: "accessToken",
|
||||
accessTokenHash: "hash-of-caller-access-token",
|
||||
},
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
});
|
||||
|
||||
it("spends the elevation before tearing the factor down", async () => {
|
||||
|
||||
@@ -118,6 +118,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
auth.transport === "cookie"
|
||||
? { kind: "session", sessionId: auth.session.id }
|
||||
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
|
||||
await auditLog("auth.mfa.disabled", {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { verifyTotp } from "@/lib/auth/mfa/totp";
|
||||
@@ -110,6 +111,12 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
// A confirmed TOTP secret was refused above, so the only second factor the
|
||||
// account can already hold is a security key.
|
||||
const hadSecondFactor =
|
||||
(await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
})) > 0;
|
||||
|
||||
// The code verified and the activation is next — spend the elevation here so
|
||||
// a wrong code above costs the caller nothing.
|
||||
@@ -141,6 +148,19 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
// admin-enforced forced-enrollment redirect must clear immediately.
|
||||
await setMfaEnrollCookie(false);
|
||||
|
||||
// Turning on the first second factor ends every other sign-in, as a password
|
||||
// change does: a session opened before the factor existed, perhaps by
|
||||
// whoever made the owner turn it on, must not keep going without it.
|
||||
if (!hadSecondFactor) {
|
||||
await destroyOtherSessions(
|
||||
user.id,
|
||||
auth.transport === "cookie"
|
||||
? { kind: "session", sessionId: auth.session.id }
|
||||
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("auth.mfa.enabled", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
|
||||
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
@@ -57,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// failed ceremony above must not have cost the caller their elevation.
|
||||
await auth.commitElevation();
|
||||
|
||||
const hadSecondFactor =
|
||||
user.totpConfirmedAt !== null ||
|
||||
(await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
})) > 0;
|
||||
|
||||
const { registrationInfo } = verification;
|
||||
const created = await prisma.webauthnMfaCredential.create({
|
||||
data: {
|
||||
@@ -76,6 +83,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// so clear any forced-enrollment redirect immediately.
|
||||
await setMfaEnrollCookie(false);
|
||||
|
||||
// Turning on the first second factor ends every other sign-in, as a password
|
||||
// change does: a session opened before the factor existed, perhaps by
|
||||
// whoever made the owner turn it on, must not keep going without it.
|
||||
if (!hadSecondFactor) {
|
||||
await destroyOtherSessions(
|
||||
user.id,
|
||||
auth.transport === "cookie"
|
||||
? { kind: "session", sessionId: auth.session.id }
|
||||
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("auth.mfa.webauthn.register", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -163,16 +163,21 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({
|
||||
sessionsRevoked: 3,
|
||||
accessTokensRevoked: 0,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.sessionsRevoked).toBe(3);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
|
||||
kind: "session",
|
||||
sessionId: "sess-current",
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ kind: "session", sessionId: "sess-current" },
|
||||
// Everything, share links included, when the client says nothing: the
|
||||
// shipped app sends no parameter and gets the safe default.
|
||||
{ reach: "everything", keepShareLinks: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("names a Bearer caller by its access token so its own device login is spared", async () => {
|
||||
@@ -198,15 +203,18 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({
|
||||
sessionsRevoked: 1,
|
||||
accessTokensRevoked: 1,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
|
||||
kind: "accessToken",
|
||||
accessTokenHash: hashToken(raw),
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ kind: "accessToken", accessTokenHash: hashToken(raw) },
|
||||
{ reach: "everything", keepShareLinks: false },
|
||||
);
|
||||
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* GET /api/auth/me/sessions — list the user's active web sessions.
|
||||
* DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER
|
||||
* session (and native refresh tokens), keeping
|
||||
* the caller's current session. Closes #64.
|
||||
* session, device login, AI-assistant
|
||||
* connection and token, and (unless
|
||||
* `?keepShareLinks=1`) every clinician share
|
||||
* link, keeping the caller's current
|
||||
* credential. Closes #64.
|
||||
*
|
||||
* v1.23 — the user-facing session/device-management surface. Distinct from
|
||||
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
|
||||
@@ -114,15 +117,35 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
: { kind: "session", sessionId: auth.session.id };
|
||||
|
||||
const { sessionsRevoked, accessTokensRevoked } = await destroyOtherSessions(
|
||||
user.id,
|
||||
current,
|
||||
);
|
||||
// "Everywhere" reaches every credential that works without signing in: AI
|
||||
// assistant connections, programmatic tokens and, unless the caller asks to
|
||||
// keep them, clinician share links. The default is to revoke, because the
|
||||
// person pressing this is often doing it after losing a device or a session,
|
||||
// and a share link made by whoever held it would otherwise keep working. A
|
||||
// client that sends nothing (the shipped app) gets the safe default.
|
||||
const keepShareLinks =
|
||||
new URL(request.url).searchParams.get("keepShareLinks") === "1";
|
||||
|
||||
const {
|
||||
sessionsRevoked,
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
} = await destroyOtherSessions(user.id, current, {
|
||||
reach: "everything",
|
||||
keepShareLinks,
|
||||
});
|
||||
|
||||
await auditLog("auth.session.revoke_others", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { sessionsRevoked, accessTokensRevoked },
|
||||
details: {
|
||||
sessionsRevoked,
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
keepShareLinks,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -130,10 +153,17 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
sessions_revoked: sessionsRevoked,
|
||||
access_tokens_revoked: accessTokensRevoked,
|
||||
connectors_revoked: connectorsRevoked,
|
||||
share_links_revoked: shareLinksRevoked,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ sessionsRevoked });
|
||||
return apiSuccess({
|
||||
sessionsRevoked,
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
@@ -43,6 +44,11 @@ export function SecuritySessionsCard({
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
// "Everywhere" also ends clinician share links unless the person keeps
|
||||
// them. Revoking is the default: this is often pressed after losing a
|
||||
// device or a session, and a link made by whoever held it opens the record
|
||||
// without signing in.
|
||||
const [endShareLinks, setEndShareLinks] = useState(true);
|
||||
// Collapsed by default — the list opens only when the user asks for it, so
|
||||
// the security surface stays calm and skimmable on first paint. UI-only
|
||||
// state; nothing is persisted across reloads.
|
||||
@@ -69,7 +75,11 @@ export function SecuritySessionsCard({
|
||||
|
||||
const revokeOthers = useMutation({
|
||||
mutationFn: () =>
|
||||
apiDelete<{ sessionsRevoked: number }>("/api/auth/me/sessions"),
|
||||
apiDelete<{ sessionsRevoked: number }>(
|
||||
endShareLinks
|
||||
? "/api/auth/me/sessions"
|
||||
: "/api/auth/me/sessions?keepShareLinks=1",
|
||||
),
|
||||
onSuccess: (res) => {
|
||||
setStatus(
|
||||
t("settings.security.signOutEverywhereDone", {
|
||||
@@ -85,7 +95,6 @@ export function SecuritySessionsCard({
|
||||
});
|
||||
|
||||
const sessions = data?.sessions ?? [];
|
||||
const hasOthers = sessions.some((s) => !s.isCurrent);
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-security-sessions-card">
|
||||
@@ -185,7 +194,7 @@ export function SecuritySessionsCard({
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hasOthers && (
|
||||
{data && (
|
||||
<SettingsCardActions>
|
||||
<ConfirmButton
|
||||
slot="sign-out-everywhere"
|
||||
@@ -196,6 +205,19 @@ export function SecuritySessionsCard({
|
||||
confirmLabel={t("settings.security.signOutEverywhere")}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
|
||||
@@ -52,6 +52,8 @@ interface ConfirmCopy {
|
||||
pending?: boolean;
|
||||
/** `data-slot` for tests and the design guards. */
|
||||
slot?: string;
|
||||
/** A control that belongs with the question, such as an opt-out checkbox. */
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
function ConfirmBody({
|
||||
@@ -61,6 +63,7 @@ function ConfirmBody({
|
||||
onConfirm,
|
||||
pending = false,
|
||||
slot,
|
||||
extra,
|
||||
}: ConfirmCopy) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -70,6 +73,7 @@ function ConfirmBody({
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{body}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{extra}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
@@ -102,6 +106,7 @@ export function ConfirmButton({
|
||||
icon,
|
||||
ariaLabel,
|
||||
slot,
|
||||
extra,
|
||||
}: ConfirmCopy & {
|
||||
/** The control's own label, before the dialog opens. */
|
||||
label: string;
|
||||
@@ -143,6 +148,7 @@ export function ConfirmButton({
|
||||
onConfirm={onConfirm}
|
||||
pending={pending}
|
||||
slot={slot}
|
||||
extra={extra}
|
||||
/>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,9 @@ vi.mock("@/lib/db", () => ({
|
||||
apiToken: { updateMany: vi.fn() },
|
||||
refreshToken: { updateMany: vi.fn() },
|
||||
trustedDevice: { deleteMany: vi.fn() },
|
||||
mcpOAuthConnection: { updateMany: vi.fn() },
|
||||
clinicianShareLink: { updateMany: vi.fn() },
|
||||
stepUpElevation: { deleteMany: vi.fn() },
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
},
|
||||
}));
|
||||
@@ -333,6 +336,14 @@ describe("destroyAllSessions", () => {
|
||||
where: { userId: "user-rotated", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-rotated", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
expect(prisma.clinicianShareLink.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-rotated", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+85
-7
@@ -509,6 +509,20 @@ export async function destroyAllSessions(userId: string): Promise<void> {
|
||||
// trusted-device token: a stolen device cookie must not survive a
|
||||
// password change or account-credential remediation.
|
||||
prisma.trustedDevice.deleteMany({ where: { userId } }),
|
||||
// A connected AI assistant holds a refresh chain of its own: revoking its
|
||||
// access tokens above left the connection able to mint the next one.
|
||||
prisma.mcpOAuthConnection.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
}),
|
||||
// A clinician share link opens the record without signing in. Whoever
|
||||
// held the old password could have made one, and it would outlive the
|
||||
// change; the owner makes a fresh link for the people who should keep it.
|
||||
prisma.clinicianShareLink.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
}),
|
||||
prisma.stepUpElevation.deleteMany({ where: { userId } }),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -526,9 +540,10 @@ export type CurrentCredential =
|
||||
* v1.23 — "sign out everywhere" for the user-facing active-session surface
|
||||
* (issue #64). Distinct from `destroyAllSessions`: this keeps the caller's
|
||||
* CURRENT credential alive so pressing the button does not sign out the device
|
||||
* it was pressed on, and it leaves the long-lived programmatic `ApiToken`s
|
||||
* alone — those are credentials the user manages separately under
|
||||
* /settings/api-tokens, not "sessions" in the device-list sense.
|
||||
* it was pressed on. Until v1.39.3 it also left the programmatic tokens, the
|
||||
* AI-assistant connections and the share links alone, as credentials managed
|
||||
* on their own cards; with `reach: "everything"` it now ends those too (see
|
||||
* below), because a stolen session could have minted any of them.
|
||||
*
|
||||
* It DOES revoke every other native-client `RefreshToken` (each is a device
|
||||
* login), and, since v1.38.11, the access token paired with each of them. A
|
||||
@@ -542,13 +557,42 @@ export type CurrentCredential =
|
||||
* rotation's own consume step refuses a row revoked underneath it and retires
|
||||
* what it minted.
|
||||
*
|
||||
* Returns the number of OTHER web sessions removed and the number of access
|
||||
* tokens revoked, so the surface and the audit row can say what happened.
|
||||
* `reach` says how far "everywhere" goes:
|
||||
*
|
||||
* `sign-ins` — web sessions, device logins, trusted devices and step-up
|
||||
* elevations. What turning a second factor on or off ends, because those
|
||||
* change what a sign-in needs, and a sign-in made under the old rule must
|
||||
* not outlive it.
|
||||
*
|
||||
* `everything` — the above, plus every credential the account handed out
|
||||
* that works without signing in: connected AI assistants (their OAuth
|
||||
* connection, not only the current access token, since the connection mints
|
||||
* the next one), every programmatic token (the connector, measurement and
|
||||
* document tokens), and, unless `keepShareLinks`, every clinician share
|
||||
* link. What the user-facing "sign out everywhere" does: someone who presses
|
||||
* it after losing a device or a session expects nothing to remain that
|
||||
* reaches the record. Share links default to revoked for the same reason,
|
||||
* 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.
|
||||
*
|
||||
* Returns what was removed, so the surface and the audit row can say what
|
||||
* happened.
|
||||
*/
|
||||
export type SignOutReach =
|
||||
{ reach: "sign-ins" } | { reach: "everything"; keepShareLinks?: boolean };
|
||||
|
||||
export interface SignOutResult {
|
||||
sessionsRevoked: number;
|
||||
accessTokensRevoked: number;
|
||||
connectorsRevoked: number;
|
||||
shareLinksRevoked: number;
|
||||
}
|
||||
|
||||
export async function destroyOtherSessions(
|
||||
userId: string,
|
||||
current: CurrentCredential,
|
||||
): Promise<{ sessionsRevoked: number; accessTokensRevoked: number }> {
|
||||
options: SignOutReach,
|
||||
): Promise<SignOutResult> {
|
||||
// Which rows describe the CALLER depends on how the caller authenticated, and
|
||||
// the two shapes are not interchangeable. A cookie caller is a `Session` row.
|
||||
// A Bearer caller is an `ApiToken` whose device login is the `RefreshToken`
|
||||
@@ -604,9 +648,43 @@ export async function destroyOtherSessions(
|
||||
// expects nothing to remain that could act on the account; a live elevation
|
||||
// is exactly that.
|
||||
await tx.stepUpElevation.deleteMany({ where: { userId } });
|
||||
|
||||
if (options.reach === "sign-ins") {
|
||||
return {
|
||||
sessionsRevoked: deleted.count,
|
||||
accessTokensRevoked: access.count,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Everything else that reaches the record without a sign-in. Every
|
||||
// remaining token goes, except the Bearer caller's own access token.
|
||||
const tokens = await tx.apiToken.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
revoked: false,
|
||||
...(current.kind === "accessToken"
|
||||
? { tokenHash: { not: current.accessTokenHash } }
|
||||
: {}),
|
||||
},
|
||||
data: { revoked: true },
|
||||
});
|
||||
const connectors = await tx.mcpOAuthConnection.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt },
|
||||
});
|
||||
const links = options.keepShareLinks
|
||||
? { count: 0 }
|
||||
: await tx.clinicianShareLink.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt },
|
||||
});
|
||||
return {
|
||||
sessionsRevoked: deleted.count,
|
||||
accessTokensRevoked: access.count,
|
||||
accessTokensRevoked: access.count + tokens.count,
|
||||
connectorsRevoked: connectors.count,
|
||||
shareLinksRevoked: links.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,6 +222,22 @@ const signOutEverywhereResponse = z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Number of OTHER sessions removed (the current one is kept)."),
|
||||
accessTokensRevoked: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Tokens revoked: the access tokens of other device logins plus every programmatic token (connector, measurement, document), never the caller's own.",
|
||||
),
|
||||
connectorsRevoked: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Connected AI-assistant (MCP OAuth) connections revoked."),
|
||||
shareLinksRevoked: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Clinician share links revoked; 0 when `keepShareLinks=1` was sent.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "SignOutEverywhereResponse" });
|
||||
|
||||
@@ -1312,7 +1328,15 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Sign out everywhere else",
|
||||
description:
|
||||
"v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current session. API tokens are not touched (manage those under /settings/api-tokens).",
|
||||
"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.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
keepShareLinks: z.enum(["1"]).optional().meta({
|
||||
description:
|
||||
"`1` keeps the clinician share links. Anything else, or nothing, revokes them.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Other sessions revoked.",
|
||||
|
||||
@@ -81,10 +81,11 @@ describe("refresh rotation — revocation landing between read and consume", ()
|
||||
.mockImplementation((async (args: unknown) => {
|
||||
if (!revokedInsideTheWindow) {
|
||||
revokedInsideTheWindow = true;
|
||||
await destroyOtherSessions(user.id, {
|
||||
kind: "session",
|
||||
sessionId: browser.id,
|
||||
});
|
||||
await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "session", sessionId: browser.id },
|
||||
{ reach: "everything" },
|
||||
);
|
||||
}
|
||||
return original(args as never);
|
||||
}) as never);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Turning on the first second factor ends every other sign-in, as a password
|
||||
* change does. A session opened before the factor existed, perhaps by whoever
|
||||
* the owner is locking out, must not keep going without it. Adding a further
|
||||
* factor to an account that already has one leaves other sign-ins alone.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-second-factor-32-bytes-minimum-1234567890";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const USER_ID = "user-second-factor";
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
async function confirm(code: string) {
|
||||
const { POST } = await import("@/app/api/auth/me/mfa/totp/confirm/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
new NextRequest("http://localhost/api/auth/me/mfa/totp/confirm", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function session(): Promise<string> {
|
||||
const row = await getPrismaClient().session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + HOUR) },
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function pendingSecret(): Promise<string> {
|
||||
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
const secret = generateTotpSecret();
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: USER_ID },
|
||||
data: { totpSecretEncrypted: encrypt(secret), totpConfirmedAt: null },
|
||||
});
|
||||
return secret;
|
||||
}
|
||||
|
||||
function codeFor(secret: string): string {
|
||||
return new OTPAuth.TOTP({
|
||||
issuer: "HealthLog",
|
||||
label: "HealthLog",
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
}).generate({ timestamp: Date.now() });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "second-factor-user",
|
||||
email: "second-factor@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("TOTP confirmation", () => {
|
||||
it("ends every other sign-in when it is the account's first second factor", async () => {
|
||||
const secret = await pendingSecret();
|
||||
const other = await session();
|
||||
const current = await session();
|
||||
cookieJar.set("healthlog_session", current);
|
||||
|
||||
const res = await confirm(codeFor(secret));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const left = await getPrismaClient().session.findMany({
|
||||
where: { userId: USER_ID },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(left.map((s) => s.id)).toEqual([current]);
|
||||
expect(left.map((s) => s.id)).not.toContain(other);
|
||||
});
|
||||
|
||||
it("leaves other sign-ins alone when a security key was already there", async () => {
|
||||
const secret = await pendingSecret();
|
||||
await getPrismaClient().webauthnMfaCredential.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: "Key",
|
||||
credentialId: "cred-1",
|
||||
credentialPublicKey: Buffer.from("pk"),
|
||||
counter: BigInt(0),
|
||||
transports: [],
|
||||
},
|
||||
});
|
||||
await session();
|
||||
const current = await session();
|
||||
cookieJar.set("healthlog_session", current);
|
||||
|
||||
const res = await confirm(codeFor(secret));
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
await getPrismaClient().session.count({ where: { userId: USER_ID } }),
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@
|
||||
*
|
||||
* Covers the DB-layer contracts the unit mocks can't pin:
|
||||
* - destroyOtherSessions keeps the current session, deletes the rest, revokes
|
||||
* native refresh tokens, and leaves API tokens untouched.
|
||||
* native refresh tokens; with `reach: "sign-ins"` it leaves API tokens,
|
||||
* connections and share links untouched, with `reach: "everything"` it
|
||||
* ends them too (share links unless kept).
|
||||
* - destroySessionById is scoped to the owning user (no cross-user delete)
|
||||
* and takes the public handle, never the row id.
|
||||
* - the (userId, deviceHash) unique index enforces the login-alert dedupe.
|
||||
@@ -29,7 +31,7 @@ async function makeUser(username: string) {
|
||||
}
|
||||
|
||||
describe("destroyOtherSessions", () => {
|
||||
it("keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => {
|
||||
it("sign-ins: keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser("sess-owner");
|
||||
|
||||
@@ -58,10 +60,11 @@ describe("destroyOtherSessions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await destroyOtherSessions(user.id, {
|
||||
kind: "session",
|
||||
sessionId: current.id,
|
||||
});
|
||||
const result = await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "session", sessionId: current.id },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
expect(result.sessionsRevoked).toBe(2);
|
||||
|
||||
const remaining = await prisma.session.findMany({
|
||||
@@ -204,10 +207,11 @@ describe("destroyOtherSessions — native device logins", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await destroyOtherSessions(user.id, {
|
||||
kind: "session",
|
||||
sessionId: current.id,
|
||||
});
|
||||
const result = await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "session", sessionId: current.id },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
expect(result.accessTokensRevoked).toBe(2);
|
||||
|
||||
const revoked = await prisma.apiToken.findMany({
|
||||
@@ -251,10 +255,11 @@ describe("destroyOtherSessions — native device logins", () => {
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
|
||||
});
|
||||
|
||||
const result = await destroyOtherSessions(user.id, {
|
||||
kind: "accessToken",
|
||||
accessTokenHash: "access-caller",
|
||||
});
|
||||
const result = await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "accessToken", accessTokenHash: "access-caller" },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
// A Bearer caller has no session row, so every browser session goes.
|
||||
expect(result.sessionsRevoked).toBe(1);
|
||||
expect(result.accessTokensRevoked).toBe(1);
|
||||
@@ -273,3 +278,183 @@ describe("destroyOtherSessions — native device logins", () => {
|
||||
expect(otherAccess?.revoked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroyOtherSessions — everything", () => {
|
||||
async function seedCredentials(userId: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const token = await prisma.apiToken.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "ingest",
|
||||
tokenHash: "narrow-hash",
|
||||
permissions: ["measurements:write"],
|
||||
},
|
||||
});
|
||||
const own = await prisma.apiToken.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "caller",
|
||||
tokenHash: "access-caller",
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
const connection = await prisma.mcpOAuthConnection.create({
|
||||
data: {
|
||||
userId,
|
||||
clientId: "client",
|
||||
clientName: "Assistant",
|
||||
scope: "health:read",
|
||||
resource: "https://health.example/mcp",
|
||||
currentJti: "jti-1",
|
||||
},
|
||||
});
|
||||
const link = await prisma.clinicianShareLink.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: "share-hash",
|
||||
label: "Clinic",
|
||||
rangeStart: new Date("2026-01-01"),
|
||||
sectionsJson: { v: 2, leaves: [] },
|
||||
expiresAt: new Date(Date.now() + 1e9),
|
||||
},
|
||||
});
|
||||
return { token, own, connection, link };
|
||||
}
|
||||
|
||||
it("revokes every token but the caller's, every connection and every share link", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser("sess-everything");
|
||||
const seeded = await seedCredentials(user.id);
|
||||
|
||||
const result = await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "accessToken", accessTokenHash: "access-caller" },
|
||||
{ reach: "everything" },
|
||||
);
|
||||
|
||||
expect(result.connectorsRevoked).toBe(1);
|
||||
expect(result.shareLinksRevoked).toBe(1);
|
||||
expect(
|
||||
(
|
||||
await prisma.apiToken.findUniqueOrThrow({
|
||||
where: { id: seeded.token.id },
|
||||
})
|
||||
).revoked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await prisma.apiToken.findUniqueOrThrow({
|
||||
where: { id: seeded.own.id },
|
||||
})
|
||||
).revoked,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
await prisma.mcpOAuthConnection.findUniqueOrThrow({
|
||||
where: { id: seeded.connection.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
(
|
||||
await prisma.clinicianShareLink.findUniqueOrThrow({
|
||||
where: { id: seeded.link.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the share links when asked to, and still ends the rest", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser("sess-keep-links");
|
||||
const seeded = await seedCredentials(user.id);
|
||||
|
||||
const result = await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "accessToken", accessTokenHash: "access-caller" },
|
||||
{ reach: "everything", keepShareLinks: true },
|
||||
);
|
||||
|
||||
expect(result.shareLinksRevoked).toBe(0);
|
||||
expect(result.connectorsRevoked).toBe(1);
|
||||
expect(
|
||||
(
|
||||
await prisma.clinicianShareLink.findUniqueOrThrow({
|
||||
where: { id: seeded.link.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("sign-ins leaves connections, tokens and share links alone", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser("sess-signins-only");
|
||||
const seeded = await seedCredentials(user.id);
|
||||
|
||||
await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "accessToken", accessTokenHash: "access-caller" },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
await prisma.apiToken.findUniqueOrThrow({
|
||||
where: { id: seeded.token.id },
|
||||
})
|
||||
).revoked,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
await prisma.mcpOAuthConnection.findUniqueOrThrow({
|
||||
where: { id: seeded.connection.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroyAllSessions", () => {
|
||||
it("also ends AI-assistant connections and share links", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { destroyAllSessions } = await import("@/lib/auth/session");
|
||||
const user = await makeUser("sess-all");
|
||||
const connection = await prisma.mcpOAuthConnection.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
clientId: "client",
|
||||
clientName: "Assistant",
|
||||
scope: "health:read",
|
||||
resource: "https://health.example/mcp",
|
||||
currentJti: "jti-1",
|
||||
},
|
||||
});
|
||||
const link = await prisma.clinicianShareLink.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash: "share-hash-all",
|
||||
label: "Clinic",
|
||||
rangeStart: new Date("2026-01-01"),
|
||||
sectionsJson: { v: 2, leaves: [] },
|
||||
expiresAt: new Date(Date.now() + 1e9),
|
||||
},
|
||||
});
|
||||
|
||||
await destroyAllSessions(user.id);
|
||||
|
||||
expect(
|
||||
(
|
||||
await prisma.mcpOAuthConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
(
|
||||
await prisma.clinicianShareLink.findUniqueOrThrow({
|
||||
where: { id: link.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -658,10 +658,11 @@ describe("E8 — an elevation does not outlive its anchor", () => {
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
const { destroyOtherSessions } = await import("@/lib/auth/session");
|
||||
await destroyOtherSessions(USER_ID, {
|
||||
kind: "session",
|
||||
sessionId: session.id,
|
||||
});
|
||||
await destroyOtherSessions(
|
||||
USER_ID,
|
||||
{ kind: "session", sessionId: session.id },
|
||||
{ reach: "everything" },
|
||||
);
|
||||
|
||||
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user