feat(auth): refresh tokens + remember me
Backend: add refresh JWT (30d, token_type claim), POST /auth/refresh endpoint (rotates token pair), remember_me on login, JWT_REFRESH_EXPIRY_DAYS env var. Extractors now reject refresh tokens on protected routes. Frontend: sessionStorage for non-remembered sessions, localStorage + refresh token for remembered sessions. Transparent 401 recovery in api.ts (retry once after refresh). Remember me checkbox on login page with security note when checked.
This commit is contained in:
@@ -34,6 +34,23 @@ export class ApiRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Called by AuthProvider when refreshToken changes — enables transparent 401 recovery
|
||||
let refreshCallback: (() => Promise<string | null>) | null = null;
|
||||
let refreshInFlight: Promise<string | null> | null = null;
|
||||
|
||||
export function setRefreshCallback(cb: (() => Promise<string | null>) | null) {
|
||||
refreshCallback = cb;
|
||||
}
|
||||
|
||||
async function attemptRefresh(): Promise<string | null> {
|
||||
if (!refreshCallback) return null;
|
||||
if (refreshInFlight) return refreshInFlight;
|
||||
refreshInFlight = refreshCallback().finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit & { token?: string } = {},
|
||||
@@ -50,6 +67,35 @@ async function request<T>(
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
|
||||
// Transparent refresh: on 401, try to get a new access token and retry once.
|
||||
// Skip for the refresh endpoint itself to avoid infinite loops.
|
||||
if (res.status === 401 && path !== "/auth/refresh") {
|
||||
const newToken = await attemptRefresh();
|
||||
if (newToken) {
|
||||
const retryHeaders = new Headers(init.headers);
|
||||
retryHeaders.set("Authorization", `Bearer ${newToken}`);
|
||||
if (init.body && !retryHeaders.has("Content-Type")) {
|
||||
retryHeaders.set("Content-Type", "application/json");
|
||||
}
|
||||
const retryRes = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: retryHeaders,
|
||||
});
|
||||
if (!retryRes.ok) {
|
||||
let message = retryRes.statusText;
|
||||
try {
|
||||
const body = await retryRes.json();
|
||||
message = body.message ?? body.error ?? message;
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
throw new ApiRequestError(retryRes.status, message);
|
||||
}
|
||||
if (retryRes.status === 204) return null as T;
|
||||
return retryRes.json() as Promise<T>;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
@@ -77,10 +123,16 @@ export const api = {
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
|
||||
login: (email: string, password: string) =>
|
||||
login: (email: string, password: string, rememberMe = false) =>
|
||||
request<TokenResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, remember_me: rememberMe }),
|
||||
}),
|
||||
|
||||
refresh: (refreshToken: string) =>
|
||||
request<TokenResponse>("/auth/refresh", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
}),
|
||||
|
||||
logout: (token: string) =>
|
||||
|
||||
@@ -178,6 +178,7 @@ export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
|
||||
Reference in New Issue
Block a user