yarm6mesq: Refresh expired tokens and revoke on sign-out

This commit is contained in:
2026-10-19 13:28:49 -06:00
parent 22aa454b81
commit 8f3b037da4
8 changed files with 507 additions and 25 deletions
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import {
endSession,
ensureFreshSession,
refreshSession,
} from "@/lib/session-refresh";
import { createSession, getSession, type SessionData } from "@/lib/session-store";
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
// Seeds a session whose profile access token expires at `profileExpiresAt`
// (and feed at `feedExpiresAt`), with known "old" tokens.
function seedSession(
profileExpiresAt: number | null,
feedExpiresAt: number | null = null,
): string {
const data: SessionData = {
profileAccessToken: "old-access",
profileAccessTokenExpiresAt: profileExpiresAt,
profileRefreshToken: "old-refresh",
feedAccessToken: "old-feed",
feedAccessTokenExpiresAt: feedExpiresAt,
person: { userId: "u1", name: "Pat Rivera", email: "person@example.com", pictureUrl: null },
createdAt: Date.now(),
};
return createSession(data);
}
// Stub for the refresh path: refresh-token -> new profile tokens, feed -> new feed token.
function refreshFetchStub(
overrides: Partial<Record<"refresh" | "feed", Response>> = {},
) {
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input);
if (url.endsWith("/api/v4/auth/refresh-token")) {
return (
overrides.refresh ??
json({ data: { accessToken: "new-access", refreshToken: "new-refresh", expiresIn: 3600 } })
);
}
if (url.endsWith("/api/v4/auth/external-services/feed")) {
return overrides.feed ?? json({ accessToken: "new-feed" });
}
throw new Error(`unexpected fetch to ${url}`);
});
}
describe("ensureFreshSession", () => {
it("an access token expiring within 2 min triggers refresh; tokens change, no password", async () => {
const id = seedSession(Date.now() + 60_000); // 1 min out: inside the 2-min window
const fetchImpl = refreshFetchStub();
const updated = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
expect(updated).not.toBeNull();
expect(updated!.profileAccessToken).toBe("new-access");
expect(updated!.profileRefreshToken).toBe("new-refresh");
expect(updated!.feedAccessToken).toBe("new-feed");
// Persisted under the same id.
expect(getSession(id)!.profileAccessToken).toBe("new-access");
// Refresh used the stored refresh token, never the login/password route.
expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/login"))).toBe(false);
});
it("a feed token expiring within 2 min also triggers refresh", async () => {
const id = seedSession(Date.now() + 60 * 60_000, Date.now() + 30_000);
const fetchImpl = refreshFetchStub();
const updated = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
expect(updated!.feedAccessToken).toBe("new-feed");
expect(updated!.profileAccessToken).toBe("new-access");
});
it("tokens comfortably in the future are not refreshed", async () => {
const id = seedSession(Date.now() + 60 * 60_000, Date.now() + 60 * 60_000);
const fetchImpl = refreshFetchStub();
const same = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
expect(same!.profileAccessToken).toBe("old-access");
expect(fetchImpl).not.toHaveBeenCalled();
});
it("no cookie / unknown session is null", async () => {
expect(await ensureFreshSession(undefined)).toBeNull();
expect(await ensureFreshSession("does-not-exist")).toBeNull();
});
});
describe("refreshSession failure", () => {
it("a failed refresh deletes the session", async () => {
const id = seedSession(Date.now() + 60_000);
const fetchImpl = refreshFetchStub({ refresh: json({ data: null }, 401) });
const result = await refreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
expect(result).toBeNull();
expect(getSession(id)).toBeNull();
});
it("a failed feed exchange during refresh also deletes the session", async () => {
const id = seedSession(Date.now() + 60_000);
const fetchImpl = refreshFetchStub({ feed: json({ data: null }, 500) });
const result = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
expect(result).toBeNull();
expect(getSession(id)).toBeNull();
});
});
describe("endSession", () => {
it("revokes the refresh token and deletes the session", async () => {
const id = seedSession(Date.now() + 60 * 60_000);
const fetchImpl = vi.fn(async (_input: RequestInfo | URL) => json({ data: { success: true } }));
await endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token"))).toBe(true);
expect(getSession(id)).toBeNull();
});
it("still deletes the session when revoke throws", async () => {
const id = seedSession(Date.now() + 60 * 60_000);
const fetchImpl = vi.fn(async (_input: RequestInfo | URL): Promise<Response> => {
throw new Error("revoke down");
});
await expect(
endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }),
).resolves.toBeUndefined();
expect(getSession(id)).toBeNull();
});
});
+83 -4
View File
@@ -1,15 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { GET, POST } from "@/app/api/session/route";
import { DELETE, GET, POST } from "@/app/api/session/route";
import { SESSION_COOKIE } from "@/lib/cookie";
import { getSession } from "@/lib/session-store";
// A fetch stub that walks the three Minnect calls. `overrides` lets a test
// force a non-200 or a weird body at any step.
function makeFetchStub(overrides: Partial<Record<"login" | "me" | "feed", Response>> = {}) {
// A fetch stub that walks the Minnect calls. `overrides` lets a test force a
// non-200 or a weird body at any step. `revoke` covers the sign-out call.
function makeFetchStub(
overrides: Partial<Record<"login" | "me" | "feed" | "revoke", Response>> = {},
) {
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/api/v4/auth/revoke-token")) {
return (
overrides.revoke ??
new Response(JSON.stringify({ data: { success: true } }), {
status: 200,
headers: { "content-type": "application/json" },
})
);
}
if (url.endsWith("/api/v4/auth/login")) {
return (
overrides.login ??
@@ -181,3 +192,71 @@ describe("GET /api/session", () => {
expect(json.email).toBe("person@example.com");
});
});
function requestWithCookie(method: string, id: string): NextRequest {
return new NextRequest("http://localhost/api/session", {
method,
headers: { cookie: `${SESSION_COOKIE}=${id}` },
});
}
describe("DELETE /api/session", () => {
it("revokes, deletes the session, clears the cookie; next GET is 401", async () => {
const fetchStub = makeFetchStub();
vi.stubGlobal("fetch", fetchStub);
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
expect(getSession(id)).not.toBeNull();
const delRes = await DELETE(requestWithCookie("DELETE", id));
expect(delRes.status).toBe(200);
// The revoke-token call was made.
expect(
fetchStub.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token")),
).toBe(true);
// Cookie is cleared (empty value, immediate expiry).
const cookie = delRes.cookies.get(SESSION_COOKIE);
expect(cookie?.value).toBe("");
expect(cookie?.maxAge).toBe(0);
// Session is gone.
expect(getSession(id)).toBeNull();
// The next token-requiring call (GET) returns 401 sign_in.
const getRes = await GET(requestWithCookie("GET", id));
expect(getRes.status).toBe(401);
expect(await getRes.json()).toEqual({ error: "sign_in" });
});
it("still succeeds when revoke returns an error", async () => {
vi.stubGlobal("fetch", makeFetchStub());
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
// Now make revoke fail.
vi.stubGlobal(
"fetch",
makeFetchStub({
revoke: new Response(JSON.stringify({ data: null }), {
status: 500,
headers: { "content-type": "application/json" },
}),
}),
);
const delRes = await DELETE(requestWithCookie("DELETE", id));
expect(delRes.status).toBe(200);
expect(delRes.cookies.get(SESSION_COOKIE)?.value).toBe("");
expect(getSession(id)).toBeNull();
});
it("no cookie: still 200 and clears the cookie", async () => {
vi.stubGlobal("fetch", makeFetchStub());
const delRes = await DELETE(new NextRequest("http://localhost/api/session", { method: "DELETE" }));
expect(delRes.status).toBe(200);
expect(delRes.cookies.get(SESSION_COOKIE)?.value).toBe("");
});
});