yarm6mesq: Refresh expired tokens and revoke on sign-out
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user