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
+104
View File
@@ -0,0 +1,104 @@
import {
deleteSession,
getSession,
updateSession,
type SessionData,
} from "./session-store";
import { exchangeFeedToken, refreshProfileTokens, revokeToken } from "./minnect";
// Reusable server-side session logic the route handlers share. The feed route
// (a later task) calls `ensureFreshSession` before using a token, and
// `refreshSession` once after a feed call returns 401. Sign-out uses
// `endSession`. Minnect tokens never leave the server; nothing here is logged.
/** Refresh when a token expires within this window (spec: two minutes). */
export const REFRESH_WINDOW_MS = 2 * 60 * 1000;
interface RefreshOptions {
/** Injectable fetch, for testing. Defaults to the global fetch. */
fetchImpl?: typeof fetch;
/** Injectable clock for the expiry decision, for testing. Defaults to Date.now(). */
now?: number;
}
/** True when the expiry is known and falls within the refresh window. */
function expiresSoon(expiresAt: number | null, now: number): boolean {
return expiresAt !== null && expiresAt - now <= REFRESH_WINDOW_MS;
}
/**
* Refresh the profile and feed tokens of a session and persist them, keeping the
* same session id (and the same person). Uses the stored profile refresh token,
* never a password. On any failure the session is deleted and `null` is
* returned, so the browser is sent back to the sign-in screen.
*/
export async function refreshSession(
sessionId: string,
options: RefreshOptions = {},
): Promise<SessionData | null> {
const session = getSession(sessionId);
if (!session) return null;
try {
const profile = await refreshProfileTokens(
session.profileRefreshToken,
options,
);
const feed = await exchangeFeedToken(profile.profileAccessToken, options);
const updated: SessionData = {
...session,
profileAccessToken: profile.profileAccessToken,
profileAccessTokenExpiresAt: profile.profileAccessTokenExpiresAt,
profileRefreshToken: profile.profileRefreshToken,
feedAccessToken: feed.feedAccessToken,
feedAccessTokenExpiresAt: feed.feedAccessTokenExpiresAt,
};
updateSession(sessionId, updated);
return updated;
} catch {
// Refresh failed: drop the session. The caller returns 401 { error: "sign_in" }.
deleteSession(sessionId);
return null;
}
}
/**
* Load a session and, when its profile or feed access token is within the
* refresh window, refresh it first. Returns the usable session, or `null` when
* there is no session or the refresh failed. Call this before using a token.
*/
export async function ensureFreshSession(
sessionId: string | undefined,
options: RefreshOptions = {},
): Promise<SessionData | null> {
if (!sessionId) return null;
const session = getSession(sessionId);
if (!session) return null;
const now = options.now ?? Date.now();
if (
expiresSoon(session.profileAccessTokenExpiresAt, now) ||
expiresSoon(session.feedAccessTokenExpiresAt, now)
) {
return refreshSession(sessionId, options);
}
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);
}