Merge Feed browser: Sign-in into develop

Server session (POST/GET /api/session), sign-in screen, token refresh and sign-out (DELETE /api/session). Tasks kdlb9zs9n, tlv84yb7n, yarm6mesq.
This commit is contained in:
2026-10-19 13:40:12 -06:00
24 changed files with 8356 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Minnect host base URLs. Copy this file to .env.local (gitignored) to override.
# Sane defaults are built in, so .env.local is optional.
# Profiles host: sign-in, auth/me, refresh, revoke, feed-token exchange.
PROFILES_BASE_URL=https://profiles.minnect.com
# Feed host: home feed, posts, likes, comments (used by later tasks).
FEED_BASE_URL=https://feed.sharedservices.vt.com
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+4
View File
@@ -6,3 +6,7 @@ out/
.worktrees/
*.log
.DS_Store
# typescript / next
*.tsbuildinfo
next-env.d.ts
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;
+6999
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "frontend-helper",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"next": "15.5.28",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/node": "22.10.7",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
"eslint": "8.57.1",
"eslint-config-next": "15.5.28",
"typescript": "5.7.3",
"vitest": "2.1.9"
}
}
+110
View File
@@ -0,0 +1,110 @@
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).
//
// 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";
function badRequest(message: string) {
return NextResponse.json({ error: message }, { status: 400 });
}
/**
* POST /api/session
* Body: { email, password }
* On success: sets the opaque session cookie, returns { name, email, pictureUrl }.
* On failure: no session, no cookie, one generic message.
*/
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
body = null;
}
const record = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const email = typeof record.email === "string" ? record.email : "";
const password = typeof record.password === "string" ? record.password : "";
if (!email || !password) {
return badRequest("Email and password are required.");
}
try {
const { person, tokens } = await loginToMinnect(email, password);
const sessionId = createSession({
profileAccessToken: tokens.profileAccessToken,
profileAccessTokenExpiresAt: tokens.profileAccessTokenExpiresAt,
profileRefreshToken: tokens.profileRefreshToken,
feedAccessToken: tokens.feedAccessToken,
feedAccessTokenExpiresAt: tokens.feedAccessTokenExpiresAt,
person,
createdAt: Date.now(),
});
const res = NextResponse.json({
name: person.name,
email: person.email,
pictureUrl: person.pictureUrl,
});
res.cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions());
return res;
} catch (err) {
if (err instanceof LoginError) {
return NextResponse.json({ error: err.publicMessage }, { status: err.status });
}
// Never echo an unexpected error to the browser.
return NextResponse.json({ error: "Could not sign you in." }, { status: 500 });
}
}
/**
* GET /api/session
* Returns { name, email, pictureUrl } for the signed-in person, or 401
* { error: "sign_in" } when the session is missing or dead.
*/
export async function GET(req: NextRequest) {
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
if (!sessionId) {
return NextResponse.json({ error: "sign_in" }, { status: 401 });
}
const session = getSession(sessionId);
if (!session) {
return NextResponse.json({ error: "sign_in" }, { status: 401 });
}
return NextResponse.json({
name: session.person.name,
email: session.person.email,
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;
}
+15
View File
@@ -0,0 +1,15 @@
:root { --red: #E8202A; --ink: #111; --grey: #6b6b6b; --line: #d9d9d9; --bg: #f5f5f5; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Roboto, system-ui, sans-serif; color: var(--ink); background: var(--bg); }
.center { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
.card { width: 100%; max-width: 400px; background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 32px 24px; display: flex; flex-direction: column; gap: 8px; }
.card h1 { font-size: 22px; margin: 12px 0 8px; }
label { font-size: 14px; font-weight: 500; margin-top: 8px; }
input { font: inherit; padding: 12px; border: 1px solid #999; border-radius: 8px; background: #fff; color: var(--ink); }
input:focus-visible, button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; border: 0; border-radius: 8px; background: var(--red); color: #fff; cursor: pointer; }
button:disabled { opacity: .5; cursor: not-allowed; }
.error { margin: 0 0 4px; padding: 10px 12px; border: 1px solid var(--red); border-radius: 8px; color: #a8141b; background: #fff3f3; font-size: 14px; }
.shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: #fff; border-bottom: 1px solid var(--line); }
.shell header div { display: flex; flex-direction: column; font-size: 14px; }
.shell header span { color: var(--grey); }
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { Roboto } from "next/font/google";
import "./globals.css";
const roboto = Roboto({ subsets: ["latin"], weight: ["400", "500", "700"] });
export const metadata: Metadata = {
title: "Minnect feed browser",
description: "Sign in with your Minnect account to read and write the home feed.",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={roboto.className}>
<body>{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { App } from "@/components/App";
export default function HomePage() {
return <App />;
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { loadSession, signOut, type Person } from "@/lib/auth-client";
import { SignInForm } from "./SignInForm";
import { Logo } from "./Logo";
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
export function App() {
const [state, setState] = useState<State>({ kind: "loading" });
const [signingOut, setSigningOut] = useState(false);
useEffect(() => {
let live = true;
loadSession().then((person) => {
if (live) setState(person ? { kind: "in", person } : { kind: "out" });
});
return () => {
live = false;
};
}, []);
async function handleSignOut() {
if (signingOut) return;
setSigningOut(true);
await signOut();
setSigningOut(false);
setState({ kind: "out" });
}
if (state.kind === "loading") {
return (
<main className="center">
<p aria-live="polite">Loading...</p>
</main>
);
}
if (state.kind === "out") {
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
}
// Placeholder shell. The home feed is a later task; sign-out is wired here.
return (
<main className="shell">
<header>
<Logo size={32} />
<div>
<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>
);
}
+8
View File
@@ -0,0 +1,8 @@
export function Logo({ size = 44 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 48 48" role="img" aria-label="Minnect">
<polygon points="24,2 43,13 43,35 24,46 5,35 5,13" fill="#E8202A" />
<path d="M14 33V15l10 11 10-11v18" fill="none" stroke="#fff" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useState, type FormEvent } from "react";
import { signIn, type Person } from "@/lib/auth-client";
import { Logo } from "./Logo";
export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
const result = await signIn(email.trim(), password);
setBusy(false);
if (result.ok) {
setPassword("");
onSignedIn(result.person);
} else {
setPassword("");
setError(result.message);
}
}
return (
<main className="center">
<form className="card" onSubmit={submit} noValidate>
<Logo />
<h1>Sign in to Minnect</h1>
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit" disabled={busy || !email || !password}>
{busy ? "Signing in..." : "Sign in"}
</button>
</form>
</main>
);
}
+64
View File
@@ -0,0 +1,64 @@
// Browser-side calls to this app's own /api/session. No token, password or
// session id is stored or logged here; the session lives in an httpOnly cookie.
export type Person = { name: string; email: string; pictureUrl: string | null };
export const WRONG_CREDENTIALS = "Email or password is incorrect.";
export const GENERIC_FAILURE = "Could not sign you in. Try again.";
type Fetch = typeof fetch;
function toPerson(body: unknown): Person | null {
if (!body || typeof body !== "object") return null;
const r = body as Record<string, unknown>;
if (typeof r.email !== "string") return null;
return {
name: typeof r.name === "string" ? r.name : "",
email: r.email,
pictureUrl: typeof r.pictureUrl === "string" ? r.pictureUrl : null,
};
}
/** GET /api/session: the person, or null when signed out or unreachable. */
export async function loadSession(f: Fetch = fetch): Promise<Person | null> {
try {
const res = await f("/api/session", { credentials: "same-origin", cache: "no-store" });
if (!res.ok) return null;
return toPerson(await res.json());
} catch {
return 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. */
export async function signIn(email: string, password: string, f: Fetch = fetch): Promise<SignInResult> {
try {
const res = await f("/api/session", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (res.status === 401) return { ok: false, message: WRONG_CREDENTIALS };
if (!res.ok) return { ok: false, message: GENERIC_FAILURE };
const person = toPerson(await res.json());
return person ? { ok: true, person } : { ok: false, message: GENERIC_FAILURE };
} catch {
return { ok: false, message: GENERIC_FAILURE };
}
}
+22
View File
@@ -0,0 +1,22 @@
// The single browser-facing cookie. It holds only an opaque session id,
// never a Minnect token (ADR-001). httpOnly keeps it out of JavaScript.
export const SESSION_COOKIE = "mfb_session";
export interface SessionCookieOptions {
httpOnly: true;
secure: boolean;
sameSite: "lax";
path: "/";
}
export function sessionCookieOptions(): SessionCookieOptions {
return {
httpOnly: true,
// Secure is required in production (ADR-001). It is relaxed in local
// development so the cookie works over plain http on localhost.
secure: process.env.NODE_ENV !== "development",
sameSite: "lax",
path: "/",
};
}
+17
View File
@@ -0,0 +1,17 @@
// Minnect host base URLs. Defaults match the build contract (docs spec, "Hosts").
// Override per environment in .env.local (gitignored). Never put tokens here.
function baseUrl(value: string | undefined, fallback: string): string {
const v = (value ?? "").trim();
return (v || fallback).replace(/\/+$/, "");
}
export const PROFILES_BASE_URL = baseUrl(
process.env.PROFILES_BASE_URL,
"https://profiles.minnect.com",
);
export const FEED_BASE_URL = baseUrl(
process.env.FEED_BASE_URL,
"https://feed.sharedservices.vt.com",
);
+291
View File
@@ -0,0 +1,291 @@
import { PROFILES_BASE_URL } from "./env";
import type { SessionPerson } from "./session-store";
// Talks to the Minnect profiles host to turn an email + password into the
// tokens a session needs. Field-order extraction follows the build contract
// (docs spec, "Login"). Never logs or echoes the Minnect error body.
/**
* A login failure with a message that is safe to show the browser.
* The raw Minnect error is never attached, so an unknown email and a wrong
* password are indistinguishable to the client (spec requirement).
*/
export class LoginError extends Error {
readonly status: number;
readonly publicMessage: string;
constructor(status: number, publicMessage: string) {
super(publicMessage);
this.name = "LoginError";
this.status = status;
this.publicMessage = publicMessage;
}
}
const GENERIC_SIGN_IN_ERROR = "Email or password is incorrect.";
const FEED_ERROR = "Could not open the feed.";
export interface LoginTokens {
profileAccessToken: string;
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 {
person: SessionPerson;
tokens: LoginTokens;
}
export interface LoginOptions {
/** Injectable fetch, for testing. Defaults to the global fetch. */
fetchImpl?: typeof fetch;
}
type JsonBody = Record<string, unknown> | null;
function jsonHeaders(token?: string): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
async function parseJson(res: Response): Promise<JsonBody> {
try {
return (await res.json()) as JsonBody;
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
}
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
// Profile access/refresh tokens, in the spec's documented field order:
// 1. data.accessToken / data.refreshToken
// 2. accessToken / refreshToken
// expiresIn (seconds) from data.expiresIn, then expiresIn.
function readProfileTokens(body: JsonBody): {
accessToken?: string;
refreshToken?: string;
expiresIn?: number;
} {
const root = asRecord(body);
const data = asRecord(root.data);
const accessToken = asString(data.accessToken) ?? asString(root.accessToken);
const refreshToken =
asString(data.refreshToken) ?? asString(root.refreshToken);
const rawExpires = data.expiresIn ?? root.expiresIn;
const expiresIn = typeof rawExpires === "number" ? rawExpires : undefined;
return { accessToken, refreshToken, expiresIn };
}
// Feed access token, in the spec's documented field order:
// 1. accessToken 2. data.accessToken 3. token 4. data.token
function readFeedToken(body: JsonBody): string | undefined {
const root = asRecord(body);
const data = asRecord(root.data);
return (
asString(root.accessToken) ??
asString(data.accessToken) ??
asString(root.token) ??
asString(data.token)
);
}
// 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.
* Throws LoginError on any failure; it never creates a session itself.
*/
export async function loginToMinnect(
email: string,
password: string,
options: LoginOptions = {},
): Promise<LoginResult> {
const doFetch = options.fetchImpl ?? fetch;
// 1. Sign in.
const loginRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/login`, {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({ email, password }),
});
if (loginRes.status !== 200) {
// Wrong password or unknown email both land here with one generic message.
throw new LoginError(401, GENERIC_SIGN_IN_ERROR);
}
const loginBody = await parseJson(loginRes);
const {
accessToken: profileAccessToken,
refreshToken: profileRefreshToken,
expiresIn,
} = readProfileTokens(loginBody);
if (!profileAccessToken || !profileRefreshToken) {
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
}
const profileAccessTokenExpiresAt = expiresAtFrom(expiresIn, Date.now());
// 2. Who the person is.
const meRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/me`, {
method: "GET",
headers: jsonHeaders(profileAccessToken),
});
if (meRes.status !== 200) {
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
}
const meBody = await parseJson(meRes);
const user = asRecord(asRecord(meBody).data);
const userId = asString(user.userId);
const personEmail = asString(user.email);
if (!userId || !personEmail) {
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
}
const name =
[user.firstName, user.lastName]
.map((p) => (typeof p === "string" ? p.trim() : ""))
.filter((p) => p.length > 0)
.join(" ") || personEmail;
const pictureUrl = asString(user.profilePictureUrl) ?? null;
// 3. Feed token exchange.
const { feedAccessToken, feedAccessTokenExpiresAt } = await exchangeFeedToken(
profileAccessToken,
options,
);
return {
person: { userId, name, email: personEmail, pictureUrl },
tokens: {
profileAccessToken,
profileAccessTokenExpiresAt,
profileRefreshToken,
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);
}
+59
View File
@@ -0,0 +1,59 @@
import { randomBytes } from "node:crypto";
// Server-side session store. The browser never sees any of this; it only holds
// the opaque id (see cookie.ts). Keyed by a random 256-bit session id.
//
// NOTE: this is in-memory and per-process. It is fine for now but does not
// survive a restart and is not shared across instances. A durable store
// (e.g. Redis) is a later concern, tracked outside this task.
export interface SessionPerson {
userId: string;
name: string;
email: string;
pictureUrl: string | null;
}
export interface SessionData {
profileAccessToken: string;
/** Epoch milliseconds when the profile access token expires, or null if unknown. */
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;
}
// Survive Next.js dev HMR by stashing the map on globalThis.
const globalRef = globalThis as typeof globalThis & {
__mfbSessionStore?: Map<string, SessionData>;
};
const store: Map<string, SessionData> =
globalRef.__mfbSessionStore ?? new Map<string, SessionData>();
globalRef.__mfbSessionStore = store;
export function createSession(data: SessionData): string {
const id = randomBytes(32).toString("hex");
store.set(id, data);
return id;
}
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);
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from "vitest";
import { loadSession, signIn, WRONG_CREDENTIALS } from "@/lib/auth-client";
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
describe("signIn", () => {
it("wrong password gives the one message", async () => {
const f = vi.fn(async () => json({ error: "Email or password is incorrect." }, 401));
expect(await signIn("a@b.co", "bad", f as unknown as typeof fetch)).toEqual({ ok: false, message: WRONG_CREDENTIALS });
});
it("success returns the person and posts JSON to /api/session", async () => {
const f = vi.fn(async () => json({ name: "Ann", email: "a@b.co", pictureUrl: null }));
const r = await signIn("a@b.co", "pw", f as unknown as typeof fetch);
expect(r).toEqual({ ok: true, person: { name: "Ann", email: "a@b.co", pictureUrl: null } });
expect(f.mock.calls[0]).toMatchObject(["/api/session", { method: "POST" }]);
});
it("network failure is not the wrong-password message", async () => {
const f = vi.fn(async () => { throw new Error("down"); });
const r = await signIn("a@b.co", "pw", f as unknown as typeof fetch);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.message).not.toBe(WRONG_CREDENTIALS);
});
});
describe("loadSession", () => {
it("401 means signed out", async () => {
const f = vi.fn(async () => json({ error: "sign_in" }, 401));
expect(await loadSession(f as unknown as typeof fetch)).toBeNull();
});
it("200 returns the person", async () => {
const f = vi.fn(async () => json({ name: "Ann", email: "a@b.co", pictureUrl: "u" }));
expect((await loadSession(f as unknown as typeof fetch))?.email).toBe("a@b.co");
});
});
+139
View File
@@ -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();
});
});
+262
View File
@@ -0,0 +1,262 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { DELETE, GET, POST } from "@/app/api/session/route";
import { SESSION_COOKIE } from "@/lib/cookie";
import { getSession } from "@/lib/session-store";
// A fetch stub that walks the Minnect calls. `overrides` lets a test force a
// non-200 or a weird body at any step. `revoke` covers the sign-out call.
function makeFetchStub(
overrides: Partial<Record<"login" | "me" | "feed" | "revoke", Response>> = {},
) {
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
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")) {
return (
overrides.login ??
new Response(
JSON.stringify({
data: { accessToken: "profile-access", refreshToken: "profile-refresh", expiresIn: 3600 },
}),
{ status: 200, headers: { "content-type": "application/json" } },
)
);
}
if (url.endsWith("/api/v4/auth/me")) {
return (
overrides.me ??
new Response(
JSON.stringify({
data: {
userId: "user-123",
email: "person@example.com",
firstName: "Pat",
lastName: "Rivera",
profilePictureUrl: "https://cdn.example.com/pat.jpg",
role: "user",
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
)
);
}
if (url.endsWith("/api/v4/auth/external-services/feed")) {
return (
overrides.feed ??
new Response(JSON.stringify({ accessToken: "feed-access" }), {
status: 200,
headers: { "content-type": "application/json" },
})
);
}
throw new Error(`unexpected fetch to ${url}`);
});
}
function postRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("POST /api/session", () => {
it("wrong password: 401, generic message, no cookie, no session", async () => {
const serverError = {
data: null,
errors: [{ message: "User not found in our records", code: "UnauthorizedException" }],
};
vi.stubGlobal(
"fetch",
makeFetchStub({
login: new Response(JSON.stringify(serverError), {
status: 401,
headers: { "content-type": "application/json" },
}),
}),
);
const res = await POST(postRequest({ email: "person@example.com", password: "wrong" }));
const json = await res.json();
expect(res.status).toBe(401);
expect(json).toEqual({ error: "Email or password is incorrect." });
// The Minnect message must not leak through.
expect(JSON.stringify(json)).not.toContain("records");
// No cookie is set.
expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined();
});
it("missing fields: 400, no cookie", async () => {
vi.stubGlobal("fetch", makeFetchStub());
const res = await POST(postRequest({ email: "person@example.com" }));
expect(res.status).toBe(400);
expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined();
});
it("correct login: 200, person body, opaque session cookie, server session created", async () => {
vi.stubGlobal("fetch", makeFetchStub());
const res = await POST(postRequest({ email: "person@example.com", password: "right" }));
const json = await res.json();
expect(res.status).toBe(200);
expect(json).toEqual({
name: "Pat Rivera",
email: "person@example.com",
pictureUrl: "https://cdn.example.com/pat.jpg",
});
const cookie = res.cookies.get(SESSION_COOKIE);
expect(cookie).toBeDefined();
expect(cookie?.httpOnly).toBe(true);
expect(cookie?.sameSite).toBe("lax");
// The cookie is an opaque id, not a Minnect token.
const id = cookie!.value;
expect(id).not.toContain("profile-access");
expect(id).not.toContain("feed-access");
// The tokens live server-side only.
const session = getSession(id);
expect(session?.profileAccessToken).toBe("profile-access");
expect(session?.profileRefreshToken).toBe("profile-refresh");
expect(session?.feedAccessToken).toBe("feed-access");
});
it("feed-token exchange fails: 502, feed message, no cookie", async () => {
vi.stubGlobal(
"fetch",
makeFetchStub({
feed: new Response(JSON.stringify({ data: null }), {
status: 500,
headers: { "content-type": "application/json" },
}),
}),
);
const res = await POST(postRequest({ email: "person@example.com", password: "right" }));
const json = await res.json();
expect(res.status).toBe(502);
expect(json).toEqual({ error: "Could not open the feed." });
expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined();
});
});
describe("GET /api/session", () => {
it("no cookie: 401 sign_in", async () => {
const res = await GET(new NextRequest("http://localhost/api/session"));
const json = await res.json();
expect(res.status).toBe(401);
expect(json).toEqual({ error: "sign_in" });
});
it("unknown session id: 401 sign_in", async () => {
const res = await GET(
new NextRequest("http://localhost/api/session", {
headers: { cookie: `${SESSION_COOKIE}=does-not-exist` },
}),
);
const json = await res.json();
expect(res.status).toBe(401);
expect(json).toEqual({ error: "sign_in" });
});
it("valid session: returns the person's name and email", async () => {
vi.stubGlobal("fetch", makeFetchStub());
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
const res = await GET(
new NextRequest("http://localhost/api/session", {
headers: { cookie: `${SESSION_COOKIE}=${id}` },
}),
);
const json = await res.json();
expect(res.status).toBe(200);
expect(json.name).toBe("Pat Rivera");
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("");
});
});
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
},
});