87 lines
2.9 KiB
TypeScript
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;
|
|
}
|