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

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