mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(auth): sign out the other devices, lose a refresh to a revocation, key the replay cache by authority
"Sign out everywhere" revoked every other refresh token and nothing else. A native login is two rows, and the Bearer check consults only the paired access token, so the other phones kept working until that token expired, up to a day on the default policy. The route also handed destroyOtherSessions a session-kind credential for a Bearer caller, whose session.id is the ApiToken row id; the exception matched nothing and the calling phone revoked its own refresh token. destroyOtherSessions now finds the paired access tokens through the refresh rows and revokes them inside the same transaction, reporting the count, and the route names a Bearer caller by its access-token hash. Programmatic tokens are untouched. rotateRefreshToken marked the consumed row under usedAt: null alone, so a revocation landing between the read and the write was overtaken and a pair minted after the family ended stayed live. The guard now includes revokedAt: null; on a lost race the minted pair is retired and the reason distinguishes a revoked family from a double consume. withIdempotency consulted the grant only at replay time, and only for its existence; a narrow-scoped token was checked only on the delegated arm. Both move into the cell key: a delegated cell carries the grant's id, level and scope fingerprint, a narrow token's cells carry the token, and a request with no live grant neither reads nor claims a cell. Cookie and wildcard own-record cells are keyed byte-for-byte as before. Tests: integration for the paired access tokens (cookie and Bearer caller), a deterministic revoke-mid-rotation race against Postgres, unit coverage for the new cell keys, and the structural guard moved to the new invariant.
This commit is contained in:
1 parent
a262cf7bb1
commit
076c2f9c5d
9 files changed
+659
-105
No files matched your search
@@ -5,7 +5,14 @@ import { NextRequest } from "next/server";
|
||||
process.env.API_TOKEN_HMAC_KEY ??= "a".repeat(64);
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { session: { findMany: vi.fn() } },
|
||||
prisma: {
|
||||
session: { findMany: vi.fn() },
|
||||
apiToken: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(() => ({ catch: () => undefined })),
|
||||
},
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
// `sessionHandle` stays REAL: the point of the handle test is that the route
|
||||
@@ -43,6 +50,8 @@ vi.mock("next/headers", () => ({
|
||||
}));
|
||||
|
||||
import { GET, DELETE } from "../route";
|
||||
import { headers } from "next/headers";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { DELETE as DELETE_ONE } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -151,7 +160,10 @@ describe("GET /api/auth/me/sessions", () => {
|
||||
describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
it("revokes other sessions and reports the count", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({ sessionsRevoked: 3 });
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({
|
||||
sessionsRevoked: 3,
|
||||
accessTokensRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
expect(res.status).toBe(200);
|
||||
@@ -162,6 +174,41 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
sessionId: "sess-current",
|
||||
});
|
||||
});
|
||||
|
||||
it("names a Bearer caller by its access token so its own device login is spared", async () => {
|
||||
// No cookie session: the caller is a phone presenting its login token.
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const raw = `hlk_${"d".repeat(64)}`;
|
||||
vi.mocked(headers).mockResolvedValue({
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "authorization" ? `Bearer ${raw}` : null,
|
||||
} as never);
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "tok-phone",
|
||||
userId: "user-1",
|
||||
permissions: ["*"],
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "u",
|
||||
role: "USER",
|
||||
} as never);
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({
|
||||
sessionsRevoked: 1,
|
||||
accessTokensRevoked: 1,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
|
||||
kind: "accessToken",
|
||||
accessTokenHash: hashToken(raw),
|
||||
});
|
||||
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/sessions/[id]", () => {
|
||||
|
||||
@@ -12,13 +12,19 @@
|
||||
* caller's own row is flagged `isCurrent` so the UI can mark "this device".
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { headers } from "next/headers";
|
||||
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions, sessionHandle } from "@/lib/auth/session";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import {
|
||||
destroyOtherSessions,
|
||||
sessionHandle,
|
||||
type CurrentCredential,
|
||||
} from "@/lib/auth/session";
|
||||
import { lookupIpLocation } from "@/lib/geo";
|
||||
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
|
||||
|
||||
@@ -86,23 +92,54 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user, session } = await requireAuth();
|
||||
const auth = await requireAuth();
|
||||
const { user } = auth;
|
||||
|
||||
const { sessionsRevoked } = await destroyOtherSessions(user.id, {
|
||||
kind: "session",
|
||||
sessionId: session.id,
|
||||
});
|
||||
// Name the caller by the transport it actually used. On the Bearer path
|
||||
// `session.id` is the `ApiToken` row id, not a session id; handing that to
|
||||
// the session-kind spared nothing, so the phone that pressed "sign out
|
||||
// everywhere" was the one signed out at its next rotation, while the other
|
||||
// devices kept their access tokens. The access-token kind spares the
|
||||
// caller's own device login by its `accessTokenHash`.
|
||||
const current: CurrentCredential =
|
||||
auth.authMethod === "bearer"
|
||||
? {
|
||||
kind: "accessToken",
|
||||
accessTokenHash: hashToken(await presentedBearerToken()),
|
||||
}
|
||||
: { kind: "session", sessionId: auth.session.id };
|
||||
|
||||
const { sessionsRevoked, accessTokensRevoked } = await destroyOtherSessions(
|
||||
user.id,
|
||||
current,
|
||||
);
|
||||
|
||||
await auditLog("auth.session.revoke_others", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { sessionsRevoked },
|
||||
details: { sessionsRevoked, accessTokensRevoked },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.session.revoke_others" },
|
||||
meta: { sessions_revoked: sessionsRevoked },
|
||||
meta: {
|
||||
sessions_revoked: sessionsRevoked,
|
||||
access_tokens_revoked: accessTokensRevoked,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ sessionsRevoked });
|
||||
});
|
||||
|
||||
/**
|
||||
* The raw Bearer token this request presented. `requireAuth` has already
|
||||
* accepted it, so a missing header here is a programming error, not a client
|
||||
* one; it is refused the same way rather than reasoned about.
|
||||
*/
|
||||
async function presentedBearerToken(): Promise<string> {
|
||||
const authHeader = (await headers()).get("authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
throw new HttpError(401, "Not authenticated");
|
||||
}
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
Reference in new issue
Block a user