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
+23 -2
View File
@@ -1,13 +1,15 @@
import { NextResponse, type NextRequest } from "next/server";
import { loginToMinnect, LoginError } from "@/lib/minnect";
import { createSession, getSession } from "@/lib/session-store";
import { endSession } from "@/lib/session-refresh";
import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie";
// This route holds the session on the server. The browser talks only to this
// app; Minnect tokens never leave the server (ADR-001).
//
// This task implements POST (sign in) and GET (who am I). DELETE/revoke and
// token refresh are separate, later tasks and are not implemented here.
// POST signs in, GET says who the person is, DELETE signs out (revoke + clear).
// The reusable token-refresh logic lives in session-refresh.ts for the feed
// route (a later task) to call before it uses a token.
export const runtime = "nodejs";
@@ -45,6 +47,7 @@ export async function POST(req: NextRequest) {
profileAccessTokenExpiresAt: tokens.profileAccessTokenExpiresAt,
profileRefreshToken: tokens.profileRefreshToken,
feedAccessToken: tokens.feedAccessToken,
feedAccessTokenExpiresAt: tokens.feedAccessTokenExpiresAt,
person,
createdAt: Date.now(),
});
@@ -87,3 +90,21 @@ export async function GET(req: NextRequest) {
pictureUrl: session.person.pictureUrl,
});
}
/**
* DELETE /api/session
* Sign out: revoke the refresh token at Minnect (best-effort), delete the
* server session, and clear the cookie. Always returns 200: sign-out succeeds
* even when revoke errors. After this, the session is gone, so every
* token-requiring route (and GET /api/session) returns 401 { error: "sign_in" }.
*/
export async function DELETE(req: NextRequest) {
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
await endSession(sessionId);
const res = NextResponse.json({ ok: true });
// Expire the cookie with the same attributes it was set with.
res.cookies.set(SESSION_COOKIE, "", { ...sessionCookieOptions(), maxAge: 0 });
return res;
}
+14 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { loadSession, type Person } from "@/lib/auth-client";
import { loadSession, signOut, type Person } from "@/lib/auth-client";
import { SignInForm } from "./SignInForm";
import { Logo } from "./Logo";
@@ -9,6 +9,7 @@ type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Perso
export function App() {
const [state, setState] = useState<State>({ kind: "loading" });
const [signingOut, setSigningOut] = useState(false);
useEffect(() => {
let live = true;
@@ -20,6 +21,14 @@ export function App() {
};
}, []);
async function handleSignOut() {
if (signingOut) return;
setSigningOut(true);
await signOut();
setSigningOut(false);
setState({ kind: "out" });
}
if (state.kind === "loading") {
return (
<main className="center">
@@ -30,7 +39,7 @@ export function App() {
if (state.kind === "out") {
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
}
// Placeholder shell. The home feed and sign-out are later tasks.
// Placeholder shell. The home feed is a later task; sign-out is wired here.
return (
<main className="shell">
<header>
@@ -39,6 +48,9 @@ export function App() {
<strong>{state.person.name}</strong>
<span>{state.person.email}</span>
</div>
<button type="button" onClick={handleSignOut} disabled={signingOut}>
{signingOut ? "Signing out..." : "Sign out"}
</button>
</header>
</main>
);
+13
View File
@@ -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
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,
},
};
}
+104
View File
@@ -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);
}
+11
View File
@@ -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);
}