yarm6mesq: Refresh expired tokens and revoke on sign-out
This commit is contained in:
@@ -1,13 +1,15 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { loginToMinnect, LoginError } from "@/lib/minnect";
|
import { loginToMinnect, LoginError } from "@/lib/minnect";
|
||||||
import { createSession, getSession } from "@/lib/session-store";
|
import { createSession, getSession } from "@/lib/session-store";
|
||||||
|
import { endSession } from "@/lib/session-refresh";
|
||||||
import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie";
|
import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie";
|
||||||
|
|
||||||
// This route holds the session on the server. The browser talks only to this
|
// This route holds the session on the server. The browser talks only to this
|
||||||
// app; Minnect tokens never leave the server (ADR-001).
|
// app; Minnect tokens never leave the server (ADR-001).
|
||||||
//
|
//
|
||||||
// This task implements POST (sign in) and GET (who am I). DELETE/revoke and
|
// POST signs in, GET says who the person is, DELETE signs out (revoke + clear).
|
||||||
// token refresh are separate, later tasks and are not implemented here.
|
// 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";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ export async function POST(req: NextRequest) {
|
|||||||
profileAccessTokenExpiresAt: tokens.profileAccessTokenExpiresAt,
|
profileAccessTokenExpiresAt: tokens.profileAccessTokenExpiresAt,
|
||||||
profileRefreshToken: tokens.profileRefreshToken,
|
profileRefreshToken: tokens.profileRefreshToken,
|
||||||
feedAccessToken: tokens.feedAccessToken,
|
feedAccessToken: tokens.feedAccessToken,
|
||||||
|
feedAccessTokenExpiresAt: tokens.feedAccessTokenExpiresAt,
|
||||||
person,
|
person,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -87,3 +90,21 @@ export async function GET(req: NextRequest) {
|
|||||||
pictureUrl: session.person.pictureUrl,
|
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
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { SignInForm } from "./SignInForm";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Perso
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [state, setState] = useState<State>({ kind: "loading" });
|
const [state, setState] = useState<State>({ kind: "loading" });
|
||||||
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
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") {
|
if (state.kind === "loading") {
|
||||||
return (
|
return (
|
||||||
<main className="center">
|
<main className="center">
|
||||||
@@ -30,7 +39,7 @@ export function App() {
|
|||||||
if (state.kind === "out") {
|
if (state.kind === "out") {
|
||||||
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
|
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 (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<header>
|
<header>
|
||||||
@@ -39,6 +48,9 @@ export function App() {
|
|||||||
<strong>{state.person.name}</strong>
|
<strong>{state.person.name}</strong>
|
||||||
<span>{state.person.email}</span>
|
<span>{state.person.email}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" onClick={handleSignOut} disabled={signingOut}>
|
||||||
|
{signingOut ? "Signing out..." : "Sign out"}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
export type SignInResult = { ok: true; person: Person } | { ok: false; message: string };
|
||||||
|
|
||||||
/** POST /api/session. A 401 is always the one wrong-credentials message. */
|
/** POST /api/session. A 401 is always the one wrong-credentials message. */
|
||||||
|
|||||||
+120
-17
@@ -30,6 +30,20 @@ export interface LoginTokens {
|
|||||||
profileAccessTokenExpiresAt: number | null;
|
profileAccessTokenExpiresAt: number | null;
|
||||||
profileRefreshToken: string;
|
profileRefreshToken: string;
|
||||||
feedAccessToken: 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 {
|
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.
|
* Runs the three-call sign-in: login -> auth/me -> feed-token exchange.
|
||||||
* Returns the person to show and the tokens to store server-side.
|
* Returns the person to show and the tokens to store server-side.
|
||||||
@@ -134,8 +248,7 @@ export async function loginToMinnect(
|
|||||||
if (!profileAccessToken || !profileRefreshToken) {
|
if (!profileAccessToken || !profileRefreshToken) {
|
||||||
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
|
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
|
||||||
}
|
}
|
||||||
const profileAccessTokenExpiresAt =
|
const profileAccessTokenExpiresAt = expiresAtFrom(expiresIn, Date.now());
|
||||||
typeof expiresIn === "number" ? Date.now() + expiresIn * 1000 : null;
|
|
||||||
|
|
||||||
// 2. Who the person is.
|
// 2. Who the person is.
|
||||||
const meRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/me`, {
|
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;
|
const pictureUrl = asString(user.profilePictureUrl) ?? null;
|
||||||
|
|
||||||
// 3. Feed token exchange.
|
// 3. Feed token exchange.
|
||||||
const feedRes = await doFetch(
|
const { feedAccessToken, feedAccessTokenExpiresAt } = await exchangeFeedToken(
|
||||||
`${PROFILES_BASE_URL}/api/v4/auth/external-services/feed`,
|
profileAccessToken,
|
||||||
{
|
options,
|
||||||
method: "POST",
|
|
||||||
headers: jsonHeaders(profileAccessToken),
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
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 {
|
return {
|
||||||
person: { userId, name, email: personEmail, pictureUrl },
|
person: { userId, name, email: personEmail, pictureUrl },
|
||||||
@@ -182,7 +284,8 @@ export async function loginToMinnect(
|
|||||||
profileAccessToken,
|
profileAccessToken,
|
||||||
profileAccessTokenExpiresAt,
|
profileAccessTokenExpiresAt,
|
||||||
profileRefreshToken,
|
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;
|
profileAccessTokenExpiresAt: number | null;
|
||||||
profileRefreshToken: string;
|
profileRefreshToken: string;
|
||||||
feedAccessToken: string;
|
feedAccessToken: string;
|
||||||
|
/** Epoch milliseconds when the feed access token expires, or null if unknown. */
|
||||||
|
feedAccessTokenExpiresAt: number | null;
|
||||||
person: SessionPerson;
|
person: SessionPerson;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,15 @@ export function getSession(id: string): SessionData | null {
|
|||||||
return store.get(id) ?? 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 {
|
export function deleteSession(id: string): void {
|
||||||
store.delete(id);
|
store.delete(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
endSession,
|
||||||
|
ensureFreshSession,
|
||||||
|
refreshSession,
|
||||||
|
} from "@/lib/session-refresh";
|
||||||
|
import { createSession, getSession, type SessionData } from "@/lib/session-store";
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seeds a session whose profile access token expires at `profileExpiresAt`
|
||||||
|
// (and feed at `feedExpiresAt`), with known "old" tokens.
|
||||||
|
function seedSession(
|
||||||
|
profileExpiresAt: number | null,
|
||||||
|
feedExpiresAt: number | null = null,
|
||||||
|
): string {
|
||||||
|
const data: SessionData = {
|
||||||
|
profileAccessToken: "old-access",
|
||||||
|
profileAccessTokenExpiresAt: profileExpiresAt,
|
||||||
|
profileRefreshToken: "old-refresh",
|
||||||
|
feedAccessToken: "old-feed",
|
||||||
|
feedAccessTokenExpiresAt: feedExpiresAt,
|
||||||
|
person: { userId: "u1", name: "Pat Rivera", email: "person@example.com", pictureUrl: null },
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
return createSession(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub for the refresh path: refresh-token -> new profile tokens, feed -> new feed token.
|
||||||
|
function refreshFetchStub(
|
||||||
|
overrides: Partial<Record<"refresh" | "feed", Response>> = {},
|
||||||
|
) {
|
||||||
|
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/v4/auth/refresh-token")) {
|
||||||
|
return (
|
||||||
|
overrides.refresh ??
|
||||||
|
json({ data: { accessToken: "new-access", refreshToken: "new-refresh", expiresIn: 3600 } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v4/auth/external-services/feed")) {
|
||||||
|
return overrides.feed ?? json({ accessToken: "new-feed" });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch to ${url}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ensureFreshSession", () => {
|
||||||
|
it("an access token expiring within 2 min triggers refresh; tokens change, no password", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60_000); // 1 min out: inside the 2-min window
|
||||||
|
const fetchImpl = refreshFetchStub();
|
||||||
|
|
||||||
|
const updated = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(updated).not.toBeNull();
|
||||||
|
expect(updated!.profileAccessToken).toBe("new-access");
|
||||||
|
expect(updated!.profileRefreshToken).toBe("new-refresh");
|
||||||
|
expect(updated!.feedAccessToken).toBe("new-feed");
|
||||||
|
// Persisted under the same id.
|
||||||
|
expect(getSession(id)!.profileAccessToken).toBe("new-access");
|
||||||
|
// Refresh used the stored refresh token, never the login/password route.
|
||||||
|
expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/login"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a feed token expiring within 2 min also triggers refresh", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60 * 60_000, Date.now() + 30_000);
|
||||||
|
const fetchImpl = refreshFetchStub();
|
||||||
|
|
||||||
|
const updated = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(updated!.feedAccessToken).toBe("new-feed");
|
||||||
|
expect(updated!.profileAccessToken).toBe("new-access");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tokens comfortably in the future are not refreshed", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60 * 60_000, Date.now() + 60 * 60_000);
|
||||||
|
const fetchImpl = refreshFetchStub();
|
||||||
|
|
||||||
|
const same = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(same!.profileAccessToken).toBe("old-access");
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no cookie / unknown session is null", async () => {
|
||||||
|
expect(await ensureFreshSession(undefined)).toBeNull();
|
||||||
|
expect(await ensureFreshSession("does-not-exist")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refreshSession failure", () => {
|
||||||
|
it("a failed refresh deletes the session", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60_000);
|
||||||
|
const fetchImpl = refreshFetchStub({ refresh: json({ data: null }, 401) });
|
||||||
|
|
||||||
|
const result = await refreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed feed exchange during refresh also deletes the session", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60_000);
|
||||||
|
const fetchImpl = refreshFetchStub({ feed: json({ data: null }, 500) });
|
||||||
|
|
||||||
|
const result = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("endSession", () => {
|
||||||
|
it("revokes the refresh token and deletes the session", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60 * 60_000);
|
||||||
|
const fetchImpl = vi.fn(async (_input: RequestInfo | URL) => json({ data: { success: true } }));
|
||||||
|
|
||||||
|
await endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token"))).toBe(true);
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still deletes the session when revoke throws", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60 * 60_000);
|
||||||
|
const fetchImpl = vi.fn(async (_input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
throw new Error("revoke down");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+83
-4
@@ -1,15 +1,26 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { GET, POST } from "@/app/api/session/route";
|
import { DELETE, GET, POST } from "@/app/api/session/route";
|
||||||
import { SESSION_COOKIE } from "@/lib/cookie";
|
import { SESSION_COOKIE } from "@/lib/cookie";
|
||||||
import { getSession } from "@/lib/session-store";
|
import { getSession } from "@/lib/session-store";
|
||||||
|
|
||||||
// A fetch stub that walks the three Minnect calls. `overrides` lets a test
|
// A fetch stub that walks the Minnect calls. `overrides` lets a test force a
|
||||||
// force a non-200 or a weird body at any step.
|
// non-200 or a weird body at any step. `revoke` covers the sign-out call.
|
||||||
function makeFetchStub(overrides: Partial<Record<"login" | "me" | "feed", Response>> = {}) {
|
function makeFetchStub(
|
||||||
|
overrides: Partial<Record<"login" | "me" | "feed" | "revoke", Response>> = {},
|
||||||
|
) {
|
||||||
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
const url = typeof input === "string" ? input : input.toString();
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
|
||||||
|
if (url.endsWith("/api/v4/auth/revoke-token")) {
|
||||||
|
return (
|
||||||
|
overrides.revoke ??
|
||||||
|
new Response(JSON.stringify({ data: { success: true } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
if (url.endsWith("/api/v4/auth/login")) {
|
if (url.endsWith("/api/v4/auth/login")) {
|
||||||
return (
|
return (
|
||||||
overrides.login ??
|
overrides.login ??
|
||||||
@@ -181,3 +192,71 @@ describe("GET /api/session", () => {
|
|||||||
expect(json.email).toBe("person@example.com");
|
expect(json.email).toBe("person@example.com");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function requestWithCookie(method: string, id: string): NextRequest {
|
||||||
|
return new NextRequest("http://localhost/api/session", {
|
||||||
|
method,
|
||||||
|
headers: { cookie: `${SESSION_COOKIE}=${id}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DELETE /api/session", () => {
|
||||||
|
it("revokes, deletes the session, clears the cookie; next GET is 401", async () => {
|
||||||
|
const fetchStub = makeFetchStub();
|
||||||
|
vi.stubGlobal("fetch", fetchStub);
|
||||||
|
|
||||||
|
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
||||||
|
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
|
||||||
|
expect(getSession(id)).not.toBeNull();
|
||||||
|
|
||||||
|
const delRes = await DELETE(requestWithCookie("DELETE", id));
|
||||||
|
expect(delRes.status).toBe(200);
|
||||||
|
|
||||||
|
// The revoke-token call was made.
|
||||||
|
expect(
|
||||||
|
fetchStub.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token")),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Cookie is cleared (empty value, immediate expiry).
|
||||||
|
const cookie = delRes.cookies.get(SESSION_COOKIE);
|
||||||
|
expect(cookie?.value).toBe("");
|
||||||
|
expect(cookie?.maxAge).toBe(0);
|
||||||
|
|
||||||
|
// Session is gone.
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
|
||||||
|
// The next token-requiring call (GET) returns 401 sign_in.
|
||||||
|
const getRes = await GET(requestWithCookie("GET", id));
|
||||||
|
expect(getRes.status).toBe(401);
|
||||||
|
expect(await getRes.json()).toEqual({ error: "sign_in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still succeeds when revoke returns an error", async () => {
|
||||||
|
vi.stubGlobal("fetch", makeFetchStub());
|
||||||
|
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
||||||
|
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
|
||||||
|
|
||||||
|
// Now make revoke fail.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
makeFetchStub({
|
||||||
|
revoke: new Response(JSON.stringify({ data: null }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const delRes = await DELETE(requestWithCookie("DELETE", id));
|
||||||
|
expect(delRes.status).toBe(200);
|
||||||
|
expect(delRes.cookies.get(SESSION_COOKIE)?.value).toBe("");
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no cookie: still 200 and clears the cookie", async () => {
|
||||||
|
vi.stubGlobal("fetch", makeFetchStub());
|
||||||
|
const delRes = await DELETE(new NextRequest("http://localhost/api/session", { method: "DELETE" }));
|
||||||
|
expect(delRes.status).toBe(200);
|
||||||
|
expect(delRes.cookies.get(SESSION_COOKIE)?.value).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user