Add JWT token refresh: /auth/refresh endpoint + 401 interceptor in fetchApi

Anton Kaminsky29d agoa9b2860fdfa5parent d873b75
3 files changed+164
hub-api/src/routes/auth.ts
@@ -271,6 +271,36 @@
271271 },
272272 });
273273
274 // ── Refresh session token ────────────────────────────────────────
275
276 app.post("/refresh", {
277 preHandler: [(app as any).authenticate],
278 handler: async (request, reply) => {
279 const payload = request.user as any;
280
281 if (payload.type !== "session") {
282 return reply.code(403).send({ error: "Only session tokens can be refreshed" });
283 }
284
285 const user = db
286 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
287 .get(payload.id) as any;
288
289 if (!user) {
290 return reply.code(401).send({ error: "User not found" });
291 }
292
293 const token = app.jwt.sign({
294 id: user.id,
295 username: user.username,
296 display_name: user.display_name,
297 type: "session",
298 });
299
300 return { token, user: { id: user.id, username: user.username, display_name: user.display_name } };
301 },
302 });
303
274304 // ── Personal Access Tokens (PATs) ─────────────────────────────────
275305
276306 const createTokenSchema = z.object({
277307
web/lib/api.ts
@@ -15,6 +15,79 @@
1515 : null;
1616}
1717
18// ── Token refresh infrastructure ──────────────────────────────────
19
20let refreshInFlight: Promise<string | null> | null = null;
21
22/** Write token + user to localStorage and cross-subdomain cookies. */
23function persistToken(token: string, user: { id: number; username: string; display_name: string }) {
24 localStorage.setItem("grove_hub_token", token);
25 localStorage.setItem("grove_hub_user", JSON.stringify(user));
26
27 const hostname = window.location.hostname;
28 const secure = window.location.protocol === "https:" ? "; Secure" : "";
29 const maxAge = 60 * 60 * 24 * 30;
30
31 const domains: string[] = [];
32 if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
33 // no domain cookies for IPs
34 } else if (hostname === "localhost" || hostname.endsWith(".localhost")) {
35 domains.push("localhost", ".localhost");
36 } else {
37 const parts = hostname.split(".");
38 if (parts.length <= 2) domains.push(`.${hostname}`);
39 else domains.push(`.${parts.slice(-2).join(".")}`);
40 }
41
42 const base = (name: string, val: string) =>
43 `${name}=${encodeURIComponent(val)}; path=/; SameSite=Lax; max-age=${maxAge}${secure}`;
44
45 document.cookie = base("grove_hub_token", token);
46 document.cookie = base("grove_hub_user", JSON.stringify(user));
47 for (const domain of domains) {
48 document.cookie = `${base("grove_hub_token", token)}; domain=${domain}`;
49 document.cookie = `${base("grove_hub_user", JSON.stringify(user))}; domain=${domain}`;
50 }
51
52 window.dispatchEvent(new CustomEvent("grove:token-refreshed", { detail: { token, user } }));
53}
54
55/**
56 * Refresh the current session token.
57 * Deduplicates concurrent calls so only one /auth/refresh request is in-flight.
58 */
59export function refreshToken(): Promise<string | null> {
60 if (refreshInFlight) return refreshInFlight;
61
62 refreshInFlight = (async () => {
63 const currentToken = getToken();
64 if (!currentToken) return null;
65
66 try {
67 const res = await fetch(`${API_BASE}/auth/refresh`, {
68 method: "POST",
69 headers: { Authorization: `Bearer ${currentToken}` },
70 });
71
72 if (!res.ok) return null;
73
74 const data = await res.json();
75 if (!data.token) return null;
76
77 persistToken(data.token, data.user);
78 return data.token as string;
79 } catch {
80 return null;
81 } finally {
82 refreshInFlight = null;
83 }
84 })();
85
86 return refreshInFlight;
87}
88
89// ── Core fetch ────────────────────────────────────────────────────
90
1891async function fetchApi<T>(
1992 base: string,
2093 path: string,
@@ -38,6 +111,35 @@
38111 });
39112
40113 if (!res.ok) {
114 // On 401, attempt a single token refresh and retry
115 if (res.status === 401 && token && !path.startsWith("/auth/")) {
116 const newToken = await refreshToken();
117 if (newToken) {
118 const retryHeaders: Record<string, string> = {
119 Authorization: `Bearer ${newToken}`,
120 };
121 if (options.body) {
122 retryHeaders["Content-Type"] = "application/json";
123 }
124
125 const retryRes = await fetch(`${base}${path}`, {
126 ...options,
127 headers: {
128 ...retryHeaders,
129 ...options.headers,
130 },
131 });
132
133 if (!retryRes.ok) {
134 const body = await retryRes.json().catch(() => ({}));
135 throw new ApiError(retryRes.status, body.error ?? "Request failed");
136 }
137
138 if (retryRes.status === 204) return null as T;
139 return retryRes.json();
140 }
141 }
142
41143 const body = await res.json().catch(() => ({}));
42144 throw new ApiError(res.status, body.error ?? "Request failed");
43145 }
44146
web/lib/auth.tsx
@@ -3,6 +3,7 @@
33import { createContext, useContext, useEffect, useState, useCallback } from "react";
44import type { User } from "./api";
55
6
67interface AuthContextValue {
78 user: User | null;
89 token: string | null;
@@ -109,6 +110,37 @@
109110 setLoading(false);
110111 }, []);
111112
113 // Sync React state when api.ts reactive refresh updates the token
114 useEffect(() => {
115 function handleRefresh(e: Event) {
116 const { token: newToken, user: newUser } = (e as CustomEvent).detail;
117 setToken(newToken);
118 setUser(newUser);
119 }
120 window.addEventListener("grove:token-refreshed", handleRefresh);
121 return () => window.removeEventListener("grove:token-refreshed", handleRefresh);
122 }, []);
123
124 // Cross-tab sync via storage events
125 useEffect(() => {
126 function handleStorage(e: StorageEvent) {
127 if (e.key === "grove_hub_token") {
128 if (e.newValue) {
129 setToken(e.newValue);
130 try {
131 const u = localStorage.getItem("grove_hub_user");
132 if (u) setUser(JSON.parse(u));
133 } catch {}
134 } else {
135 setToken(null);
136 setUser(null);
137 }
138 }
139 }
140 window.addEventListener("storage", handleStorage);
141 return () => window.removeEventListener("storage", handleStorage);
142 }, []);
143
112144 const login = useCallback((newToken: string, newUser: User) => {
113145 localStorage.setItem("grove_hub_token", newToken);
114146 localStorage.setItem("grove_hub_user", JSON.stringify(newUser));
115147