yarm6mesq: Refresh expired tokens and revoke on sign-out
This commit is contained in:
@@ -30,6 +30,19 @@ 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 };
|
||||
|
||||
/** POST /api/session. A 401 is always the one wrong-credentials message. */
|
||||
|
||||
+120
-17
@@ -30,6 +30,20 @@ export interface LoginTokens {
|
||||
profileAccessTokenExpiresAt: number | null;
|
||||
profileRefreshToken: string;
|
||||
feedAccessToken: string;
|
||||
feedAccessTokenExpiresAt: number | null;
|
||||
}
|
||||
|
||||
/** Profile tokens from a fresh login or a refresh, with the access-token expiry resolved to an epoch. */
|
||||
export interface ProfileTokens {
|
||||
profileAccessToken: string;
|
||||
profileAccessTokenExpiresAt: number | null;
|
||||
profileRefreshToken: string;
|
||||
}
|
||||
|
||||
/** The feed access token and its expiry (null when the body does not carry one). */
|
||||
export interface FeedToken {
|
||||
feedAccessToken: string;
|
||||
feedAccessTokenExpiresAt: number | null;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
@@ -103,6 +117,106 @@ function readFeedToken(body: JsonBody): string | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
// expiresIn (seconds) -> absolute epoch ms, or null when it is not a number.
|
||||
function expiresAtFrom(expiresIn: unknown, now: number): number | null {
|
||||
return typeof expiresIn === "number" ? now + expiresIn * 1000 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a profile access token for a feed token.
|
||||
* `POST /api/v4/auth/external-services/feed` with body `{}`.
|
||||
* Used by sign-in and by refresh. Throws LoginError(FEED_ERROR) on failure.
|
||||
*/
|
||||
export async function exchangeFeedToken(
|
||||
profileAccessToken: string,
|
||||
options: LoginOptions = {},
|
||||
): Promise<FeedToken> {
|
||||
const doFetch = options.fetchImpl ?? fetch;
|
||||
const res = await doFetch(
|
||||
`${PROFILES_BASE_URL}/api/v4/auth/external-services/feed`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: jsonHeaders(profileAccessToken),
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
throw new LoginError(502, FEED_ERROR);
|
||||
}
|
||||
const body = await parseJson(res);
|
||||
const feedAccessToken = readFeedToken(body);
|
||||
if (!feedAccessToken) {
|
||||
throw new LoginError(502, FEED_ERROR);
|
||||
}
|
||||
// Feed-token expiry is read opportunistically: the spec does not require the
|
||||
// body to carry it. When it is absent, the feed-call-returns-401 path (the
|
||||
// feed route, a later task) is the backstop for a stale feed token.
|
||||
const root = asRecord(body);
|
||||
const rawExpires = asRecord(root.data).expiresIn ?? root.expiresIn;
|
||||
return {
|
||||
feedAccessToken,
|
||||
feedAccessTokenExpiresAt: expiresAtFrom(rawExpires, Date.now()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a profile refresh token for a new profile access + refresh token.
|
||||
* `POST /api/v4/auth/refresh-token` with `{ refreshToken }`. The new tokens are
|
||||
* read with the same field order as sign-in. No password is involved. Throws a
|
||||
* plain Error on any failure; the caller treats that as "refresh failed".
|
||||
*/
|
||||
export async function refreshProfileTokens(
|
||||
refreshToken: string,
|
||||
options: LoginOptions = {},
|
||||
): Promise<ProfileTokens> {
|
||||
const doFetch = options.fetchImpl ?? fetch;
|
||||
const res = await doFetch(
|
||||
`${PROFILES_BASE_URL}/api/v4/auth/refresh-token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
},
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
throw new Error("Profile token refresh failed.");
|
||||
}
|
||||
const {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiresIn,
|
||||
} = readProfileTokens(await parseJson(res));
|
||||
if (!accessToken || !newRefreshToken) {
|
||||
throw new Error("Profile token refresh returned no tokens.");
|
||||
}
|
||||
return {
|
||||
profileAccessToken: accessToken,
|
||||
profileAccessTokenExpiresAt: expiresAtFrom(expiresIn, Date.now()),
|
||||
profileRefreshToken: newRefreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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").
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the three-call sign-in: login -> auth/me -> feed-token exchange.
|
||||
* Returns the person to show and the tokens to store server-side.
|
||||
@@ -134,8 +248,7 @@ export async function loginToMinnect(
|
||||
if (!profileAccessToken || !profileRefreshToken) {
|
||||
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
|
||||
}
|
||||
const profileAccessTokenExpiresAt =
|
||||
typeof expiresIn === "number" ? Date.now() + expiresIn * 1000 : null;
|
||||
const profileAccessTokenExpiresAt = expiresAtFrom(expiresIn, Date.now());
|
||||
|
||||
// 2. Who the person is.
|
||||
const meRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/me`, {
|
||||
@@ -160,21 +273,10 @@ export async function loginToMinnect(
|
||||
const pictureUrl = asString(user.profilePictureUrl) ?? null;
|
||||
|
||||
// 3. Feed token exchange.
|
||||
const feedRes = await doFetch(
|
||||
`${PROFILES_BASE_URL}/api/v4/auth/external-services/feed`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: jsonHeaders(profileAccessToken),
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
const { feedAccessToken, feedAccessTokenExpiresAt } = await exchangeFeedToken(
|
||||
profileAccessToken,
|
||||
options,
|
||||
);
|
||||
if (feedRes.status !== 200) {
|
||||
throw new LoginError(502, FEED_ERROR);
|
||||
}
|
||||
const feedToken = readFeedToken(await parseJson(feedRes));
|
||||
if (!feedToken) {
|
||||
throw new LoginError(502, FEED_ERROR);
|
||||
}
|
||||
|
||||
return {
|
||||
person: { userId, name, email: personEmail, pictureUrl },
|
||||
@@ -182,7 +284,8 @@ export async function loginToMinnect(
|
||||
profileAccessToken,
|
||||
profileAccessTokenExpiresAt,
|
||||
profileRefreshToken,
|
||||
feedAccessToken: feedToken,
|
||||
feedAccessToken,
|
||||
feedAccessTokenExpiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export interface SessionData {
|
||||
profileAccessTokenExpiresAt: number | null;
|
||||
profileRefreshToken: string;
|
||||
feedAccessToken: string;
|
||||
/** Epoch milliseconds when the feed access token expires, or null if unknown. */
|
||||
feedAccessTokenExpiresAt: number | null;
|
||||
person: SessionPerson;
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -43,6 +45,15 @@ export function getSession(id: string): SessionData | null {
|
||||
return store.get(id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the data for an existing session. Used after a token refresh to
|
||||
* persist the new tokens. A no-op when the session is already gone, so a
|
||||
* refresh cannot resurrect a signed-out session.
|
||||
*/
|
||||
export function updateSession(id: string, data: SessionData): void {
|
||||
if (store.has(id)) store.set(id, data);
|
||||
}
|
||||
|
||||
export function deleteSession(id: string): void {
|
||||
store.delete(id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user