sdyl405w4: Remove the sign-out feature and the Minnect revoke call

This commit is contained in:
2026-10-19 17:03:29 -06:00
parent 5c53bd9afb
commit ab78a61851
7 changed files with 9 additions and 191 deletions
+3 -20
View File
@@ -1,13 +1,14 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { loginToMinnect, LoginError } from "@/lib/minnect"; import { loginToMinnect, LoginError } from "@/lib/minnect";
import { createSession, getSession } from "@/lib/session-store"; import { createSession, getSession } from "@/lib/session-store";
import { endSession } from "@/lib/session-refresh";
import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie"; import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie";
// This route holds the session on the server. The browser talks only to this // This route holds the session on the server. The browser talks only to this
// app; Minnect tokens never leave the server (ADR-001). // app; Minnect tokens never leave the server (ADR-001).
// //
// POST signs in, GET says who the person is, DELETE signs out (revoke + clear). // POST signs in, GET says who the person is. There is no sign-out: the session ends
// when the server restarts or the cookie is cleared, and Minnect's revoke endpoint
// is never called (the refresh token is shared across the person's devices).
// The reusable token-refresh logic lives in session-refresh.ts for the feed // The reusable token-refresh logic lives in session-refresh.ts for the feed
// route (a later task) to call before it uses a token. // route (a later task) to call before it uses a token.
@@ -91,21 +92,3 @@ export async function GET(req: NextRequest) {
feedUserId: session.person.feedUserId ?? null, feedUserId: session.person.feedUserId ?? null,
}); });
} }
/**
* DELETE /api/session
* Sign out: revoke the refresh token at Minnect (best-effort), delete the
* server session, and clear the cookie. Always returns 200: sign-out succeeds
* even when revoke errors. After this, the session is gone, so every
* token-requiring route (and GET /api/session) returns 401 { error: "sign_in" }.
*/
export async function DELETE(req: NextRequest) {
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
await endSession(sessionId);
const res = NextResponse.json({ ok: true });
// Expire the cookie with the same attributes it was set with.
res.cookies.set(SESSION_COOKIE, "", { ...sessionCookieOptions(), maxAge: 0 });
return res;
}
+1 -13
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { loadSession, signOut, type Person } from "@/lib/auth-client"; import { loadSession, type Person } from "@/lib/auth-client";
import { SignInForm } from "./SignInForm"; import { SignInForm } from "./SignInForm";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import { Feed } from "./Feed"; import { Feed } from "./Feed";
@@ -10,7 +10,6 @@ type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Perso
export function App() { export function App() {
const [state, setState] = useState<State>({ kind: "loading" }); const [state, setState] = useState<State>({ kind: "loading" });
const [signingOut, setSigningOut] = useState(false);
useEffect(() => { useEffect(() => {
let live = true; let live = true;
@@ -22,14 +21,6 @@ export function App() {
}; };
}, []); }, []);
async function handleSignOut() {
if (signingOut) return;
setSigningOut(true);
await signOut();
setSigningOut(false);
setState({ kind: "out" });
}
if (state.kind === "loading") { if (state.kind === "loading") {
return ( return (
<main className="center"> <main className="center">
@@ -54,9 +45,6 @@ export function App() {
<strong>{state.person.name}</strong> <strong>{state.person.name}</strong>
<span>{state.person.email}</span> <span>{state.person.email}</span>
</div> </div>
<button type="button" onClick={handleSignOut} disabled={signingOut}>
{signingOut ? "Signing out..." : "Sign out"}
</button>
</header> </header>
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} /> <Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
</main> </main>
-13
View File
@@ -31,19 +31,6 @@ export async function loadSession(f: Fetch = fetch): Promise<Person | null> {
} }
} }
/**
* DELETE /api/session: sign out. Resolves once the server has cleared the
* session and cookie. A failed request still returns the UI to signed-out,
* because the local session view is no longer trustworthy.
*/
export async function signOut(f: Fetch = fetch): Promise<void> {
try {
await f("/api/session", { method: "DELETE", credentials: "same-origin", cache: "no-store" });
} catch {
// Ignore: the caller returns to the sign-in screen regardless.
}
}
export type SignInResult = { ok: true; person: Person } | { ok: false; message: string }; export type SignInResult = { ok: true; person: Person } | { ok: false; message: string };
/** POST /api/session. A 401 is always the one wrong-credentials message. */ /** POST /api/session. A 401 is always the one wrong-credentials message. */
-21
View File
@@ -196,27 +196,6 @@ export async function refreshProfileTokens(
}; };
} }
/**
* Revoke a profile refresh token on sign-out.
* `POST /api/v4/auth/revoke-token` with `{ refreshToken }`. Best-effort: any
* error (network or non-200) is swallowed so that sign-out still succeeds.
*/
export async function revokeToken(
refreshToken: string,
options: LoginOptions = {},
): Promise<void> {
const doFetch = options.fetchImpl ?? fetch;
try {
await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/revoke-token`, {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({ refreshToken }),
});
} catch {
// Sign-out still succeeds if revoke fails (spec, "Sign out").
}
}
/** `GET /api/v1/auth/me` on the feed host. Returns data.id, or null on any failure. */ /** `GET /api/v1/auth/me` on the feed host. Returns data.id, or null on any failure. */
async function fetchFeedUserId(feedToken: string, doFetch: typeof fetch): Promise<string | null> { async function fetchFeedUserId(feedToken: string, doFetch: typeof fetch): Promise<string | null> {
try { try {
+2 -20
View File
@@ -4,12 +4,11 @@ import {
updateSession, updateSession,
type SessionData, type SessionData,
} from "./session-store"; } from "./session-store";
import { exchangeFeedToken, refreshProfileTokens, revokeToken } from "./minnect"; import { exchangeFeedToken, refreshProfileTokens } from "./minnect";
// Reusable server-side session logic the route handlers share. The feed route // Reusable server-side session logic the route handlers share. The feed route
// (a later task) calls `ensureFreshSession` before using a token, and // (a later task) calls `ensureFreshSession` before using a token, and
// `refreshSession` once after a feed call returns 401. Sign-out uses // `refreshSession` once after a feed call returns 401. Minnect tokens never leave the server; nothing here is logged.
// `endSession`. Minnect tokens never leave the server; nothing here is logged.
/** Refresh when a token expires within this window (spec: two minutes). */ /** Refresh when a token expires within this window (spec: two minutes). */
export const REFRESH_WINDOW_MS = 2 * 60 * 1000; export const REFRESH_WINDOW_MS = 2 * 60 * 1000;
@@ -85,20 +84,3 @@ export async function ensureFreshSession(
} }
return session; return session;
} }
/**
* Sign-out. Revoke the refresh token (best-effort) and delete the server
* session. Always resolves: a revoke error does not stop sign-out. The caller
* clears the cookie.
*/
export async function endSession(
sessionId: string | undefined,
options: Pick<RefreshOptions, "fetchImpl"> = {},
): Promise<void> {
if (!sessionId) return;
const session = getSession(sessionId);
if (session) {
await revokeToken(session.profileRefreshToken, options);
}
deleteSession(sessionId);
}
-25
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
endSession,
ensureFreshSession, ensureFreshSession,
refreshSession, refreshSession,
} from "@/lib/session-refresh"; } from "@/lib/session-refresh";
@@ -113,27 +112,3 @@ describe("refreshSession failure", () => {
expect(getSession(id)).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();
});
});
+3 -79
View File
@@ -1,26 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { DELETE, GET, POST } from "@/app/api/session/route"; import { GET, POST } from "@/app/api/session/route";
import { SESSION_COOKIE } from "@/lib/cookie"; import { SESSION_COOKIE } from "@/lib/cookie";
import { getSession } from "@/lib/session-store"; import { getSession } from "@/lib/session-store";
// A fetch stub that walks the Minnect calls. `overrides` lets a test force a // 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. // non-200 or a weird body at any step.
function makeFetchStub( function makeFetchStub(
overrides: Partial<Record<"login" | "me" | "feed" | "revoke", Response>> = {}, overrides: Partial<Record<"login" | "me" | "feed", Response>> = {},
) { ) {
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => { return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString(); 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")) { if (url.endsWith("/api/v4/auth/login")) {
return ( return (
overrides.login ?? overrides.login ??
@@ -193,70 +184,3 @@ describe("GET /api/session", () => {
}); });
}); });
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("");
});
});