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
+120 -17
View File
@@ -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,
},
};
}