Files
frontend-helper/src/lib/session-refresh.ts
T

87 lines
2.9 KiB
TypeScript

import {
deleteSession,
getSession,
updateSession,
type SessionData,
} from "./session-store";
import { exchangeFeedToken, refreshProfileTokens } 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. 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;
export 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;
}