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
32 changes: 32 additions & 0 deletions app/docs/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
listDocs: vi.fn(),
listSharedDocs: vi.fn(),
}));

vi.mock("@/lib/auth/session", () => ({ getSession: mocks.getSession }));
vi.mock("@/lib/docs/store", () => ({
listDocs: mocks.listDocs,
listSharedDocs: mocks.listSharedDocs,
}));
vi.mock("@/lib/db", () => ({ query: vi.fn() }));

import { GET } from "@/app/docs/route";

describe("GET /docs", () => {
beforeEach(() => {
mocks.getSession.mockResolvedValue({ id: 1, email: "user@example.com", user_id: 1 });
mocks.listDocs.mockResolvedValue([]);
mocks.listSharedDocs.mockResolvedValue([]);
});

it("shows a logout button for the current session", async () => {
const res = await GET(new Request("https://justhtml.sh/docs"));
const html = await res.text();

expect(html).toContain('<form method="POST" action="/logout">');
expect(html).toContain('<button type="submit">log out</button>');
});
});
4 changes: 3 additions & 1 deletion app/docs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const VARIANT_C_STYLE = `
.row a.title { font-weight: 700; }
.row .tail { color: #888; }
.row .tail a { color: #888; }
.session { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.75rem; }
.session form { margin: 0; }
</style>`;

// The paste-to-agent prompt and copy button, reused verbatim from the homepage
Expand Down Expand Up @@ -203,7 +205,7 @@ export async function GET(req: Request): Promise<Response> {
const sections: string[] = [];
sections.push(VARIANT_C_STYLE);
sections.push(
`<div class="body"><pre>Signed in as <code>${esc(email)}</code>. See <a href="/bookmarks">bookmarks</a>.</pre></div>`
`<div class="body session"><pre>Signed in as <code>${esc(email)}</code>. See <a href="/bookmarks">bookmarks</a>.</pre><form method="POST" action="/logout"><button type="submit">log out</button></form></div>`
);
if (hasAccount) {
sections.push(ownedSection(owned));
Expand Down
50 changes: 50 additions & 0 deletions app/logout/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sha256Hex } from "@/lib/auth/tokens";

const mocks = vi.hoisted(() => ({ query: vi.fn() }));
vi.mock("@/lib/db", () => ({ query: mocks.query }));

import { POST } from "@/app/logout/route";

function request(cookie?: string, origin = "https://justhtml.sh"): Request {
const headers = new Headers({ origin });
if (cookie) headers.set("cookie", cookie);
return new Request("https://justhtml.sh/logout", { method: "POST", headers });
}

describe("POST /logout", () => {
beforeEach(() => {
mocks.query.mockReset();
mocks.query.mockResolvedValue({ rows: [] });
});

it("revokes the session, clears the cookie, and redirects to login", async () => {
const res = await POST(request("other=value; jh_sess=sess_test"));

expect(res.status).toBe(303);
expect(res.headers.get("location")).toBe("/login");
expect(res.headers.get("set-cookie")).toBe(
"jh_sess=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0"
);
expect(mocks.query).toHaveBeenCalledWith(
expect.stringContaining("UPDATE sessions SET revoked_at = now()"),
[sha256Hex("sess_test")]
);
});

it("still clears a stale or missing session cookie", async () => {
const res = await POST(request());

expect(res.status).toBe(303);
expect(res.headers.get("set-cookie")).toContain("Max-Age=0");
expect(mocks.query).not.toHaveBeenCalled();
});

it("rejects cross-site requests", async () => {
const res = await POST(request("jh_sess=sess_test", "https://example.com"));

expect(res.status).toBe(403);
expect(res.headers.has("set-cookie")).toBe(false);
expect(mocks.query).not.toHaveBeenCalled();
});
});
22 changes: 22 additions & 0 deletions app/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { clearSessionCookieHeader, readSessionCookie } from "@/lib/auth/session";
import { originOk } from "@/lib/auth/request";
import { sha256Hex } from "@/lib/auth/tokens";
import { query } from "@/lib/db";
import { redirect } from "@/lib/page";

export const dynamic = "force-dynamic";

export async function POST(req: Request): Promise<Response> {
if (!originOk(req)) return new Response("Forbidden.", { status: 403 });

const token = readSessionCookie(req);
if (token?.startsWith("sess_")) {
await query(
`UPDATE sessions SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL`,
[sha256Hex(token)]
);
}

return redirect("/login", { "Set-Cookie": clearSessionCookieHeader() });
}
5 changes: 5 additions & 0 deletions lib/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ export function sessionCookieHeader(token: string): string {
return `${SESSION_COOKIE}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_S}`;
}

/** Expire the session cookie in the current browser. */
export function clearSessionCookieHeader(): string {
return `${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
}

/**
* Set-Cookie for the login-intent marker — set on the browser that just
* requested a magic link. Scoped to /login so it rides the emailed
Expand Down
Loading