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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user