Skip to content

Commit 67d3f24

Browse files
fix(auth): store and revoke refresh tokens so silent refresh works
/api/login and /api/register return a refresh_token, but the login and register forms only kept the access token, so apiFetch's silent-refresh path never had one to use and every session hard-expired at 72h. Pass it through an AuthSession and store it at login; logout now revokes it via POST /api/auth/logout before clearing both tokens. Logout uses plain fetch rather than apiFetch on purpose: with an expired access token, apiFetch would rotate the refresh token first and logout would then revoke the old, already-dead one. Storing the token exposed a backend bug the dead path had hidden: /api/auth/refresh compared the TIMESTAMPTZ expires_at with a naive utcnow(), raising TypeError, so every refresh returned 500. Use timezone-aware UTC both when writing and checking expiry (the naive write was also hours off on a database not running in UTC). Also: - treat a refresh token alone as a session on startup - only log out on a real 401 at startup; a 5xx or network error now keeps the tokens and shows a toast instead - check r.ok before parsing /api/projects (new loadProjects helper), so an error body no longer throws inside normalizeProjects - update the walkthrough's "72 hours, no way to refresh" note
1 parent 28c2dae commit 67d3f24

9 files changed

Lines changed: 122 additions & 39 deletions

File tree

‎landing-page/scripts/auth_api.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from slowapi.util import get_remote_address
77
from config import MODELS_DIR, NEON_MANIFESTS_DIR
88
import psycopg2, psycopg2.errors, os, secrets, hashlib, base64
9-
from datetime import datetime, timedelta
9+
from datetime import datetime, timedelta, timezone
1010
from jose import jwt, JWTError
1111
from contextlib import contextmanager
1212
import bcrypt
@@ -624,7 +624,10 @@ def _hash_token(raw: str) -> str:
624624

625625
def create_refresh_token(user_id: int) -> str:
626626
raw = secrets.token_urlsafe(32)
627-
expires_at = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
627+
# Timezone-aware, unlike the JWT `exp`s nearby: expires_at is TIMESTAMPTZ,
628+
# and Postgres reads a naive value in the *session's* timezone, so a
629+
# utcnow() here lands hours off on any server not running in UTC.
630+
expires_at = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
628631
with db_cursor() as (con, cur):
629632
cur.execute(
630633
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (%s, %s, %s)",
@@ -733,7 +736,9 @@ def refresh_token(x_refresh_token: str = Header(None, alias="X-Refresh-Token")):
733736
(token_hash,),
734737
)
735738
row = cur.fetchone()
736-
if not row or row[3] is not None or row[2] < datetime.utcnow():
739+
# expires_at comes back timezone-aware (TIMESTAMPTZ); comparing it with
740+
# a naive utcnow() raised TypeError, so every refresh used to 500.
741+
if not row or row[3] is not None or row[2] < datetime.now(timezone.utc):
737742
raise HTTPException(status_code=401, detail="invalid or expired refresh token")
738743
cur.execute("UPDATE refresh_tokens SET revoked_at=NOW() WHERE id=%s", (row[0],))
739744
con.commit()

‎landing-page/src/App.tsx‎

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@ import AlphaBanner from "./components/layout/AlphaBanner";
55
import AppRouter from "./components/AppRouter";
66
import ToastContainer from "./components/shared/ToastContainer";
77
import type { View, Project, ProjectInitialTab } from "./types";
8-
import type { CurrentUser } from "./hooks/useAuth";
9-
import { getToken, setToken, clearToken } from "./hooks/useAuth";
10-
import { normalizeProjects, refreshProject } from "./utils/projects";
8+
import type { AuthSession, CurrentUser } from "./hooks/useAuth";
9+
import {
10+
authHeaders,
11+
clearRefreshToken,
12+
clearToken,
13+
getRefreshToken,
14+
getToken,
15+
setRefreshToken,
16+
setToken,
17+
} from "./hooks/useAuth";
18+
import { loadProjects, refreshProject } from "./utils/projects";
1119
import { useProjectMutations } from "./hooks/useProjectMutations";
1220
import { useEncodingFlow } from "./hooks/useEncodingFlow";
1321
import { useScrollFade } from "./hooks/useScrollFade";
@@ -280,48 +288,88 @@ export default function App() {
280288
});
281289
});
282290

283-
const handleLoginSuccess = (user: CurrentUser, token: string) => {
291+
const fetchProjects = () =>
292+
loadProjects(setProjects)
293+
.catch(() => "failed" as const)
294+
.then((outcome) => {
295+
if (outcome === "failed") {
296+
toast.error("couldn't load your projects — try reloading the page");
297+
}
298+
});
299+
300+
const handleLoginSuccess = ({ user, token, refreshToken }: AuthSession) => {
284301
setToken(token);
302+
// Clear rather than keep a previous session's refresh token, which would
303+
// otherwise be what apiFetch rotates once this access token expires.
304+
if (refreshToken) setRefreshToken(refreshToken);
305+
else clearRefreshToken();
285306
setCurrentUser(user);
286-
apiFetch("/api/projects")
287-
.then((r) => r.json())
288-
.then((data) => setProjects(normalizeProjects(data)));
307+
fetchProjects();
289308
setView("projects");
290309
};
291310

292-
const doLogout = () => {
311+
const clearSession = () => {
293312
clearToken();
313+
clearRefreshToken();
294314
setCurrentUser(null);
295315
setProjects([]);
296316
setSelectedProjectId(null);
297317
setView("landing");
298318
};
319+
320+
// Revokes this browser's refresh token server-side before dropping it
321+
// locally, so a copied-out token stops working at logout rather than 30
322+
// days later. Deliberately a plain fetch, not apiFetch: with an expired
323+
// access token apiFetch would first rotate the refresh token, and this
324+
// call would then revoke the old, already-dead one while the fresh one it
325+
// just minted stayed live. Fire-and-forget (keepalive), so logging out
326+
// never waits on, or fails because of, the network.
327+
const doLogout = () => {
328+
const rt = getRefreshToken();
329+
if (rt) {
330+
fetch("/api/auth/logout", {
331+
method: "POST",
332+
keepalive: true,
333+
headers: { ...authHeaders(), "X-Refresh-Token": rt },
334+
}).catch(() => {});
335+
}
336+
clearSession();
337+
};
299338
// The user-initiated "logout" button goes through the same unsaved-work
300339
// confirmation as any other exit from the Neon editor. The server-driven
301340
// forced logout below (a dead/expired session even a refresh couldn't
302341
// fix, see apiFetch's registerUnauthenticatedHandler) deliberately calls
303-
// doLogout directly instead -- the session is already gone server-side by
342+
// clearSession directly instead -- the session is already gone server-side by
304343
// that point, so there's nothing a "stay and keep editing" cancel could
305344
// actually preserve, only a client UI stuck believing it's still logged in.
306345
const handleLogout = () => guardNeonExit(doLogout);
307346

347+
// A forced logout has nothing to revoke: apiFetch only calls this once the
348+
// refresh token itself was refused, and it has already cleared both tokens.
308349
useEffect(() => {
309-
registerUnauthenticatedHandler(doLogout);
350+
registerUnauthenticatedHandler(clearSession);
310351
}, []);
311352

312353
useEffect(() => {
313-
const token = getToken();
314-
if (!token) return;
354+
// A refresh token alone is still a session: apiFetch's 401 handling
355+
// trades it for a new access token on the /api/me call below.
356+
if (!getToken() && !getRefreshToken()) return;
315357
apiFetch("/api/me")
316-
.then((r) => (r.ok ? r.json() : Promise.reject()))
317-
.then((user) => {
318-
setCurrentUser(user);
358+
.then(async (r) => {
359+
// Usually apiFetch has already logged out and toasted by now, but not
360+
// when its refresh succeeded and the retried call still got a 401.
361+
if (r.status === 401) return clearSession();
362+
if (!r.ok) throw new Error(`/api/me failed (${r.status})`);
363+
setCurrentUser(await r.json());
319364
setView((v) => (v === "landing" ? "projects" : v));
320-
return apiFetch("/api/projects");
365+
return fetchProjects();
321366
})
322-
.then((r) => r.json())
323-
.then((data) => setProjects(normalizeProjects(data)))
324-
.catch(() => clearToken());
367+
// Anything else -- backend down, a 5xx -- says nothing about whether
368+
// the session is valid, so keep the tokens for the next load instead of
369+
// logging the user out over a server hiccup.
370+
.catch(() =>
371+
toast.error("couldn't reach the server — try reloading the page"),
372+
);
325373
}, []);
326374

327375
return (

‎landing-page/src/components/AppRouter.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
ProjectImage,
1010
ProjectInitialTab,
1111
} from "../types";
12-
import type { CurrentUser } from "../hooks/useAuth";
12+
import type { AuthSession, CurrentUser } from "../hooks/useAuth";
1313
import { apiFetch, apiFetchOrThrow, apiFetchJobStream } from "../lib/apiFetch";
1414
import { minNextStep, pendingIcImages } from "../utils/imageStep";
1515
import { refreshProject } from "../utils/projects";
@@ -92,7 +92,7 @@ interface AppRouterProps {
9292
meiContent: { bytes: string; stem: string } | null;
9393
handleDownloadManifest: () => void;
9494
handleDownloadMei: () => void;
95-
handleLoginSuccess: (user: CurrentUser, token: string) => void;
95+
handleLoginSuccess: (session: AuthSession) => void;
9696
handleLogout: () => void;
9797
mutations: ReturnType<typeof useProjectMutations>;
9898
handleEncodeResult: (ev: {

‎landing-page/src/components/auth/AuthPage.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import LoginForm from "./LoginForm";
22
import RegisterForm from "./RegisterForm";
3-
import type { CurrentUser } from "../../hooks/useAuth";
3+
import type { AuthSession } from "../../hooks/useAuth";
44

55
interface AuthPageProps {
66
mode: "login" | "register";
77
onSwitchMode: (mode: "login" | "register") => void;
8-
onSuccess: (user: CurrentUser, token: string) => void;
8+
onSuccess: (session: AuthSession) => void;
99
}
1010

1111
export default function AuthPage({

‎landing-page/src/components/auth/LoginForm.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { useState } from "react";
2-
import type { CurrentUser } from "../../hooks/useAuth";
2+
import type { AuthSession } from "../../hooks/useAuth";
33

44
interface LoginFormProps {
55
onSwitchToRegister: () => void;
6-
onSuccess: (user: CurrentUser, token: string) => void;
6+
onSuccess: (session: AuthSession) => void;
77
}
88

99
export default function LoginForm({
@@ -25,8 +25,8 @@ export default function LoginForm({
2525
setError("invalid username or password");
2626
return;
2727
}
28-
const { token, user } = await r.json();
29-
onSuccess(user, token);
28+
const d = await r.json();
29+
onSuccess({ user: d.user, token: d.token, refreshToken: d.refresh_token });
3030
};
3131
return (
3232
<div className="bg-[#C8E6E3] rounded-3xl p-6 sm:p-10 flex flex-col gap-4">

‎landing-page/src/components/auth/RegisterForm.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { useState } from "react";
2-
import type { CurrentUser } from "../../hooks/useAuth";
2+
import type { AuthSession } from "../../hooks/useAuth";
33

44
interface RegisterFormProps {
55
onSwitchToLogin: () => void;
6-
onSuccess: (user: CurrentUser, token: string) => void;
6+
onSuccess: (session: AuthSession) => void;
77
}
88

99
export default function RegisterForm({
@@ -40,8 +40,8 @@ export default function RegisterForm({
4040
setError((data as { detail?: string }).detail || "registration failed");
4141
return;
4242
}
43-
const { token, user } = await r.json();
44-
onSuccess(user, token);
43+
const d = await r.json();
44+
onSuccess({ user: d.user, token: d.token, refreshToken: d.refresh_token });
4545
};
4646

4747
return (

‎landing-page/src/components/documentation/DocsWalkthrough.tsx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -272,11 +272,13 @@ export default function DocsWalkthrough({
272272
your profile, change your password, or delete your account entirely.
273273
</p>
274274
<p className="leading-relaxed">
275-
One thing worth knowing: logging in keeps you signed in for 72 hours,
276-
and there's currently no way to refresh that without logging in again.
277-
If you get signed out mid-workflow, don't worry — your project's
278-
progress is saved step by step, so logging back in and reopening the
279-
project picks up right where you left off.
275+
One thing worth knowing: logging in keeps you signed in on that
276+
browser, and mothra quietly renews the session while you keep using it
277+
— you'll only be asked to log in again after about a month away.
278+
Logging out ends the session on that browser straight away. If you do
279+
get signed out mid-workflow, don't worry — your project's progress is
280+
saved step by step, so logging back in and reopening the project picks
281+
up right where you left off.
280282
</p>
281283
</section>
282284
</div>

‎landing-page/src/hooks/useAuth.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ export interface CurrentUser {
66
createdAt: string;
77
}
88

9+
// What /api/login and /api/register hand back, camel-cased. refreshToken is
10+
// optional only so a backend predating refresh tokens still logs in -- the
11+
// access token alone then works until it expires, as it always did.
12+
export interface AuthSession {
13+
user: CurrentUser;
14+
token: string;
15+
refreshToken?: string;
16+
}
17+
918
const TOKEN_KEY = "mothra_token";
1019
const REFRESH_TOKEN_KEY = "mothra_refresh_token";
1120

‎landing-page/src/utils/projects.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ export function normalizeProjects(raw: Project[]): Project[] {
1212
}));
1313
}
1414

15+
/**
16+
* GETs the user's whole project list into local state.
17+
*
18+
* Leaves state untouched on any non-OK response: an error body isn't a
19+
* project array, and feeding it to normalizeProjects throws. "unauthenticated"
20+
* means a 401 apiFetch couldn't fix by refreshing, so it has already logged
21+
* the user out and toasted -- callers only need to report "failed". Network
22+
* failures reject, as with any fetch.
23+
*/
24+
export async function loadProjects(
25+
setProjects: Dispatch<SetStateAction<Project[]>>,
26+
): Promise<"ok" | "unauthenticated" | "failed"> {
27+
const r = await apiFetch("/api/projects");
28+
if (r.status === 401) return "unauthenticated";
29+
if (!r.ok) return "failed";
30+
setProjects(normalizeProjects(await r.json()));
31+
return "ok";
32+
}
33+
1534
/**
1635
* Re-GETs one project and merges the server's copy into local state.
1736
*

0 commit comments

Comments
 (0)