Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion local/app/api/auth/local-auth-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { clearLocalRateLimitsForTests } from "@local/lib/rate-limit";

const mocks = vi.hoisted(() => ({
bcryptCompare: vi.fn(),
cleanupExpiredSessionRevocations: vi.fn(async () => 0),
clearSessionCookieOptions: vi.fn(() => ({ maxAge: 0, path: "/" })),
getCurrentAdmin: vi.fn(),
isSetupRequired: vi.fn(),
Expand All @@ -20,6 +21,8 @@ const mocks = vi.hoisted(() => ({
},
},
sessionCookieOptions: vi.fn(() => ({ httpOnly: true, path: "/" })),
revokeCurrentSession: vi.fn(async () => true),
SessionRevocationStoreUnavailableError: class SessionRevocationStoreUnavailableError extends Error {},
signSession: vi.fn(async () => "signed-session"),
}));

Expand All @@ -37,7 +40,10 @@ vi.mock("@local/lib/prisma", () => ({
}));

vi.mock("@local/lib/session", () => ({
cleanupExpiredSessionRevocations: mocks.cleanupExpiredSessionRevocations,
clearSessionCookieOptions: mocks.clearSessionCookieOptions,
revokeCurrentSession: mocks.revokeCurrentSession,
SessionRevocationStoreUnavailableError: mocks.SessionRevocationStoreUnavailableError,
SESSION_COOKIE: "subboost-local-session",
sessionCookieOptions: mocks.sessionCookieOptions,
signSession: mocks.signSession,
Expand Down Expand Up @@ -114,15 +120,55 @@ describe("local auth and health routes", () => {
});
});

it("logs out by clearing the session cookie", async () => {
it("persists logout revocation before clearing the session cookie", async () => {
const { POST } = await import("./logout/route");

const response = await POST();

expect(await readJson(response)).toEqual({ status: 200, body: { success: true } });
expect(mocks.revokeCurrentSession).toHaveBeenCalledTimes(1);
expect(response.headers.get("set-cookie")).toContain("subboost-local-session=");
});

it("does not clear the cookie or report success when revocation storage fails", async () => {
const { POST } = await import("./logout/route");
mocks.revokeCurrentSession.mockRejectedValueOnce(
new mocks.SessionRevocationStoreUnavailableError("db down")
);

const response = await POST();

expect(await readJson(response)).toEqual({
status: 503,
body: { error: "Session service unavailable.", code: "SESSION_STORE_UNAVAILABLE" },
});
expect(response.headers.get("set-cookie")).toBeNull();
});

it("preserves successful logout when expired-session cleanup fails", async () => {
const { POST } = await import("./logout/route");
const cause = new Error("cleanup unavailable");
mocks.cleanupExpiredSessionRevocations.mockRejectedValueOnce(cause);
const log = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const response = await POST();
expect(await readJson(response)).toEqual({ status: 200, body: { success: true } });
expect(response.headers.get("set-cookie")).toContain("subboost-local-session=");
expect(log).toHaveBeenCalledWith("Local session revocation cleanup failed:", cause);
} finally {
log.mockRestore();
}
});

it("propagates unexpected revocation errors without clearing the cookie", async () => {
const { POST } = await import("./logout/route");
const cause = new Error("unexpected failure");
mocks.revokeCurrentSession.mockRejectedValueOnce(cause);
await expect(POST()).rejects.toBe(cause);
expect(mocks.clearSessionCookieOptions).not.toHaveBeenCalled();
expect(mocks.cleanupExpiredSessionRevocations).not.toHaveBeenCalled();
});

it("returns the current admin snapshot and anonymous setup state", async () => {
const { GET } = await import("./me/route");
mocks.isSetupRequired.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
Expand Down
27 changes: 26 additions & 1 deletion local/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
import { NextResponse } from "next/server";
import { clearSessionCookieOptions, SESSION_COOKIE } from "@local/lib/session";
import {
cleanupExpiredSessionRevocations,
clearSessionCookieOptions,
revokeCurrentSession,
SessionRevocationStoreUnavailableError,
SESSION_COOKIE,
} from "@local/lib/session";

export async function POST() {
try {
await revokeCurrentSession();
} catch (error) {
if (error instanceof SessionRevocationStoreUnavailableError) {
return NextResponse.json(
{ error: "Session service unavailable.", code: "SESSION_STORE_UNAVAILABLE" },
{ status: 503 }
);
}
throw error;
}

const response = NextResponse.json({ success: true });
response.cookies.set(SESSION_COOKIE, "", clearSessionCookieOptions());

try {
await cleanupExpiredSessionRevocations();
} catch (error) {
console.error("Local session revocation cleanup failed:", error);
}

return response;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE "RevokedSession" (
"revocationKey" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "RevokedSession_pkey" PRIMARY KEY ("revocationKey")
);

CREATE INDEX "RevokedSession_expiresAt_idx" ON "RevokedSession"("expiresAt");
8 changes: 8 additions & 0 deletions local/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,11 @@ model JobLeaseLock {

@@index([expiresAt])
}

model RevokedSession {
revocationKey String @id
expiresAt DateTime
createdAt DateTime @default(now())

@@index([expiresAt])
}
1 change: 1 addition & 0 deletions local/src/lib/auto-update-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ describe("local subscription auto update service", () => {
});
mocks.prepareRefreshCacheResult.mockReturnValue({
ok: true,
refreshedConfig: { rules: [], sources: [{ url: "https://airport.example/sub" }] },
cacheEntry: { nodes: [{ name: "A" }], subscriptionInfo: { upload: 1 } },
nodeCount: 1,
});
Expand Down
4 changes: 1 addition & 3 deletions local/src/lib/auto-update-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,13 @@ async function completeSuccess(params: {
maxNodesPerSubscription: MAX_NODES_PER_SUBSCRIPTION,
});
if (decision.kind !== "success") throw new Error(`Unexpected refresh completion decision: ${decision.kind}`);
const config = { ...params.prepared.config, sources: params.prepared.snapshot.savedSources };

const persisted = await writeAutoUpdateState(
params.subscription.id,
params.subscription.updatedAt,
decision.nextAutoUpdateState.state,
{
encryptedNodes: encryptJson(refreshResult.cacheEntry.nodes),
encryptedConfig: encryptJson(config),
encryptedConfig: encryptJson(refreshResult.refreshedConfig),
encryptedSubscriptionInfo: encryptJson(refreshResult.cacheEntry.subscriptionInfo),
lastUpdatedAt: cachedAt,
cacheExpiresAt: buildSubscriptionCacheExpiry(cachedAt),
Expand Down
130 changes: 125 additions & 5 deletions local/src/lib/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
cookieValue: undefined as string | undefined,
jwtVerify: vi.fn(),
prisma: {
revokedSession: {
deleteMany: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
signPayload: null as unknown,
}));

Expand All @@ -25,6 +33,14 @@ vi.mock("jose", () => ({
mocks.signPayload = { ...(this.payload as Record<string, unknown>), sub: subject };
return this;
}
setIssuer(issuer: string) {
mocks.signPayload = { ...(mocks.signPayload as Record<string, unknown>), iss: issuer };
return this;
}
setJti(jti: string) {
mocks.signPayload = { ...(mocks.signPayload as Record<string, unknown>), jti };
return this;
}
setIssuedAt() {
return this;
}
Expand All @@ -38,10 +54,15 @@ vi.mock("jose", () => ({
jwtVerify: mocks.jwtVerify,
}));

vi.mock("./prisma", () => ({ prisma: mocks.prisma }));

import {
clearSessionCookieOptions,
cleanupExpiredSessionRevocations,
readSession,
revokeCurrentSession,
sessionCookieOptions,
SessionRevocationStoreUnavailableError,
signSession,
} from "./session";

Expand All @@ -52,30 +73,129 @@ describe("local session helpers", () => {
mocks.signPayload = null;
process.env.JWT_SECRET = "test-secret";
process.env.APP_URL = "https://local.example";
mocks.jwtVerify.mockResolvedValue({ payload: { sub: "admin-1", username: "ry" } });
mocks.prisma.revokedSession.findUnique.mockResolvedValue(null);
mocks.prisma.revokedSession.findMany.mockResolvedValue([]);
mocks.prisma.revokedSession.deleteMany.mockResolvedValue({ count: 0 });
mocks.prisma.revokedSession.upsert.mockResolvedValue({});
mocks.jwtVerify.mockResolvedValue({
payload: { exp: 4102444800, iss: "subboost-local", jti: "session-1", sub: "admin-1", username: "ry" },
});
});

it("signs sessions with the admin id as JWT subject", async () => {
await expect(signSession({ adminId: "admin-1", username: "ry" })).resolves.toBe("signed-session-token");
expect(mocks.signPayload).toEqual({ sub: "admin-1", username: "ry" });
expect(mocks.signPayload).toEqual({
iss: "subboost-local",
jti: expect.any(String),
sub: "admin-1",
username: "ry",
});
});

it("reads valid sessions and rejects missing, malformed, or invalid tokens", async () => {
await expect(readSession()).resolves.toBeNull();

mocks.cookieValue = "session-token";
mocks.cookieValue = "header.payload.signature";
await expect(readSession()).resolves.toEqual({ adminId: "admin-1", username: "ry" });

mocks.jwtVerify.mockResolvedValueOnce({ payload: { sub: 123, username: "ry" } });
mocks.prisma.revokedSession.findUnique.mockResolvedValueOnce({ revocationKey: "revoked" });
await expect(readSession()).resolves.toBeNull();

mocks.jwtVerify.mockResolvedValueOnce({ payload: { sub: "admin-1", username: "" } });
mocks.jwtVerify.mockResolvedValueOnce({ payload: { exp: 4102444800, sub: 123, username: "ry" } });
await expect(readSession()).resolves.toBeNull();

mocks.jwtVerify.mockResolvedValueOnce({ payload: { exp: 4102444800, sub: "admin-1", username: "" } });
await expect(readSession()).resolves.toBeNull();

mocks.jwtVerify.mockRejectedValueOnce(new Error("bad token"));
await expect(readSession()).resolves.toBeNull();
});

it("fails closed when revocation state cannot be read", async () => {
mocks.cookieValue = "header.payload.signature";
mocks.prisma.revokedSession.findUnique.mockRejectedValueOnce(new Error("db down"));

await expect(readSession()).rejects.toBeInstanceOf(SessionRevocationStoreUnavailableError);
});

it.each([
{ exp: 4102444800, iss: "another-app", sub: "admin-1", username: "admin" },
{ exp: 4102444800, sub: "admin-1", username: 123 },
])("rejects invalid session claims without querying revocations", async (payload) => {
mocks.cookieValue = "header.payload.signature";
mocks.jwtVerify.mockResolvedValueOnce({ payload });

await expect(readSession()).resolves.toBeNull();
expect(mocks.prisma.revokedSession.findUnique).not.toHaveBeenCalled();
});

it("does not persist revocations for missing or invalid sessions", async () => {
await expect(revokeCurrentSession()).resolves.toBe(false);
mocks.cookieValue = "invalid-token";
mocks.jwtVerify.mockRejectedValueOnce(new Error("invalid signature"));
await expect(revokeCurrentSession()).resolves.toBe(false);
expect(mocks.prisma.revokedSession.upsert).not.toHaveBeenCalled();
});

it("reports revocation write failures with their original cause", async () => {
mocks.cookieValue = "header.payload.signature";
const cause = new Error("database unavailable");
mocks.prisma.revokedSession.upsert.mockRejectedValueOnce(cause);

await expect(revokeCurrentSession()).rejects.toMatchObject({
name: "SessionRevocationStoreUnavailableError",
cause,
});
});

it.each([undefined, {}])("uses bounded cleanup defaults without deleting an empty batch", async (options) => {
const before = Date.now();
await expect(cleanupExpiredSessionRevocations(options)).resolves.toBe(0);
const query = mocks.prisma.revokedSession.findMany.mock.calls[0][0];
expect(query.take).toBe(100);
expect(query.where.expiresAt.lte.getTime()).toBeGreaterThanOrEqual(before);
expect(query.where.expiresAt.lte.getTime()).toBeLessThanOrEqual(Date.now());
expect(mocks.prisma.revokedSession.deleteMany).not.toHaveBeenCalled();
});

it.each([[0, 1], [3.9, 3], [2000, 1000]])("bounds cleanup limit %s to %s", async (limit, expected) => {
const now = new Date("2026-09-20T00:00:00.000Z");
await expect(cleanupExpiredSessionRevocations({ limit, now })).resolves.toBe(0);
expect(mocks.prisma.revokedSession.findMany).toHaveBeenCalledWith({
where: { expiresAt: { lte: now } },
select: { revocationKey: true },
orderBy: { expiresAt: "asc" },
take: expected,
});
});

it("revokes the current session idempotently and cleans expired rows in a bounded batch", async () => {
mocks.cookieValue = "header.payload.signature";
await expect(revokeCurrentSession()).resolves.toBe(true);
await expect(revokeCurrentSession()).resolves.toBe(true);
expect(mocks.prisma.revokedSession.upsert).toHaveBeenCalledTimes(2);
expect(mocks.prisma.revokedSession.upsert).toHaveBeenCalledWith({
where: { revocationKey: expect.any(String) },
create: { revocationKey: expect.any(String), expiresAt: expect.any(Date) },
update: { expiresAt: expect.any(Date) },
});

mocks.prisma.revokedSession.findMany.mockResolvedValueOnce([
{ revocationKey: "expired-1" },
{ revocationKey: "expired-2" },
]);
mocks.prisma.revokedSession.deleteMany.mockResolvedValueOnce({ count: 2 });
const now = new Date("2026-09-20T00:00:00.000Z");

await expect(cleanupExpiredSessionRevocations({ limit: 2, now })).resolves.toBe(2);
expect(mocks.prisma.revokedSession.findMany).toHaveBeenCalledWith({
where: { expiresAt: { lte: now } },
select: { revocationKey: true },
orderBy: { expiresAt: "asc" },
take: 2,
});
});

it("builds secure session cookie options and clear options", () => {
expect(sessionCookieOptions()).toEqual({
httpOnly: true,
Expand Down
Loading
Loading