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:
2026-03-19 22:24:26 +01:00
parent 8bdd5e2277
commit d2412da057
13 changed files with 307 additions and 35 deletions

View File

@@ -8,12 +8,13 @@ import { useConfig } from "@/hooks/use-channels";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const { mutate: login, isPending, error } = useLogin();
const { data: config } = useConfig();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
login({ email, password });
login({ email, password, rememberMe });
};
return (
@@ -54,6 +55,23 @@ export default function LoginPage() {
/>
</div>
<div className="space-y-1">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="h-3.5 w-3.5 rounded border-zinc-600 bg-zinc-900 accent-white"
/>
<span className="text-xs text-zinc-400">Remember me</span>
</label>
{rememberMe && (
<p className="pl-5 text-xs text-amber-500/80">
A refresh token will be stored locally don&apos;t share it.
</p>
)}
</div>
{error && <p className="text-xs text-red-400">{error.message}</p>}
<button

View File

@@ -15,7 +15,7 @@ import { Toaster } from "@/components/ui/sonner";
import { ApiRequestError } from "@/lib/api";
function QueryProvider({ children }: { children: React.ReactNode }) {
const { token, setToken } = useAuthContext();
const { token, setTokens } = useAuthContext();
const router = useRouter();
const tokenRef = useRef(token);
useEffect(() => { tokenRef.current = token; }, [token]);
@@ -29,7 +29,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
// Guests hitting 401 on restricted content should not be redirected.
if (error instanceof ApiRequestError && error.status === 401 && tokenRef.current) {
toast.warning("Session expired, please log in again.");
setToken(null);
setTokens(null, null, false);
router.push("/login");
}
},
@@ -39,7 +39,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
// Mutations always require auth — redirect on 401 regardless.
if (error instanceof ApiRequestError && error.status === 401) {
toast.warning("Session expired, please log in again.");
setToken(null);
setTokens(null, null, false);
router.push("/login");
}
},

View File

@@ -4,42 +4,94 @@ import {
createContext,
useContext,
useState,
useEffect,
type ReactNode,
} from "react";
import { useRouter } from "next/navigation";
import { api, setRefreshCallback } from "@/lib/api";
const TOKEN_KEY = "k-tv-token";
const ACCESS_KEY_LOCAL = "k-tv-token";
const ACCESS_KEY_SESSION = "k-tv-token-session";
const REFRESH_KEY = "k-tv-refresh-token";
interface AuthContextValue {
token: string | null;
/** True once the initial localStorage read has completed */
refreshToken: string | null;
/** Always true (lazy init reads storage synchronously) */
isLoaded: boolean;
setToken: (token: string | null) => void;
setTokens: (access: string | null, refresh: string | null, remember: boolean) => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const [token, setTokenState] = useState<string | null>(() => {
try {
return localStorage.getItem(TOKEN_KEY);
return sessionStorage.getItem(ACCESS_KEY_SESSION) ?? localStorage.getItem(ACCESS_KEY_LOCAL);
} catch {
return null;
}
});
// isLoaded is always true: lazy init above reads localStorage synchronously
const [isLoaded] = useState(true);
const setToken = (t: string | null) => {
setTokenState(t);
if (t) {
localStorage.setItem(TOKEN_KEY, t);
} else {
localStorage.removeItem(TOKEN_KEY);
const [refreshToken, setRefreshTokenState] = useState<string | null>(() => {
try {
return localStorage.getItem(REFRESH_KEY);
} catch {
return null;
}
});
const setTokens = (access: string | null, refresh: string | null, remember: boolean) => {
try {
if (access === null) {
sessionStorage.removeItem(ACCESS_KEY_SESSION);
localStorage.removeItem(ACCESS_KEY_LOCAL);
localStorage.removeItem(REFRESH_KEY);
} else if (remember) {
localStorage.setItem(ACCESS_KEY_LOCAL, access);
sessionStorage.removeItem(ACCESS_KEY_SESSION);
if (refresh) {
localStorage.setItem(REFRESH_KEY, refresh);
} else {
localStorage.removeItem(REFRESH_KEY);
}
} else {
sessionStorage.setItem(ACCESS_KEY_SESSION, access);
localStorage.removeItem(ACCESS_KEY_LOCAL);
localStorage.removeItem(REFRESH_KEY);
}
} catch {
// storage unavailable — state-only fallback
}
setTokenState(access);
setRefreshTokenState(refresh);
};
// Wire up the transparent refresh callback in api.ts
useEffect(() => {
if (refreshToken) {
setRefreshCallback(async () => {
try {
const data = await api.auth.refresh(refreshToken);
const newRefresh = data.refresh_token ?? null;
setTokens(data.access_token, newRefresh, true);
return data.access_token;
} catch {
setTokens(null, null, false);
router.push("/login");
return null;
}
});
} else {
setRefreshCallback(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshToken]);
return (
<AuthContext.Provider value={{ token, isLoaded, setToken }}>
<AuthContext.Provider value={{ token, refreshToken, isLoaded: true, setTokens }}>
{children}
</AuthContext.Provider>
);

View File

@@ -16,14 +16,21 @@ export function useCurrentUser() {
}
export function useLogin() {
const { setToken } = useAuthContext();
const { setTokens } = useAuthContext();
const router = useRouter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
api.auth.login(email, password),
onSuccess: (data) => {
setToken(data.access_token);
mutationFn: ({
email,
password,
rememberMe,
}: {
email: string;
password: string;
rememberMe: boolean;
}) => api.auth.login(email, password, rememberMe),
onSuccess: (data, { rememberMe }) => {
setTokens(data.access_token, data.refresh_token ?? null, rememberMe);
queryClient.invalidateQueries({ queryKey: ["me"] });
router.push("/dashboard");
},
@@ -31,14 +38,14 @@ export function useLogin() {
}
export function useRegister() {
const { setToken } = useAuthContext();
const { setTokens } = useAuthContext();
const router = useRouter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
api.auth.register(email, password),
onSuccess: (data) => {
setToken(data.access_token);
setTokens(data.access_token, null, false);
queryClient.invalidateQueries({ queryKey: ["me"] });
router.push("/dashboard");
},
@@ -46,13 +53,13 @@ export function useRegister() {
}
export function useLogout() {
const { token, setToken } = useAuthContext();
const { token, setTokens } = useAuthContext();
const router = useRouter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => (token ? api.auth.logout(token) : Promise.resolve()),
onSettled: () => {
setToken(null);
setTokens(null, null, false);
queryClient.clear();
router.push("/login");
},

View File

@@ -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) =>

View File

@@ -178,6 +178,7 @@ export interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
}
export interface UserResponse {