frontend(auth): persist refresh_token and auto-refresh access token for long-lived sessions
This commit is contained in:
parent
eb81f038bf
commit
8348d48b82
|
|
@ -7,12 +7,15 @@ import React, {
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
// Config Keycloak pour le login inline
|
/* ===== Keycloak config ===== */
|
||||||
const KC_BASE = "https://web.sociowire.com:8443";
|
const KC_BASE = "https://web.sociowire.com:8443";
|
||||||
const KC_REALM = "sociowire";
|
const KC_REALM = "sociowire";
|
||||||
const KC_CLIENT_ID = "sociowire-frontend";
|
const KC_CLIENT_ID = "sociowire-frontend";
|
||||||
|
|
||||||
|
/* ===== Storage keys ===== */
|
||||||
const TOKEN_KEY = "sociowire:access_token";
|
const TOKEN_KEY = "sociowire:access_token";
|
||||||
|
const REFRESH_KEY = "sociowire:refresh_token";
|
||||||
|
const EXPIRES_AT_KEY = "sociowire:expires_at";
|
||||||
const USERNAME_KEY = "sociowire:username";
|
const USERNAME_KEY = "sociowire:username";
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
|
|
@ -22,38 +25,126 @@ export function AuthProvider({ children }) {
|
||||||
const [token, setToken] = useState(null);
|
const [token, setToken] = useState(null);
|
||||||
const [lastError, setLastError] = useState("");
|
const [lastError, setLastError] = useState("");
|
||||||
|
|
||||||
// Charge token/username depuis localStorage au démarrage
|
/* ==============================
|
||||||
|
REFRESH TOKEN (helper)
|
||||||
|
============================== */
|
||||||
|
async function refreshWithToken(refreshToken) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("grant_type", "refresh_token");
|
||||||
|
params.set("client_id", KC_CLIENT_ID);
|
||||||
|
params.set("refresh_token", refreshToken);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${KC_BASE}/realms/${KC_REALM}/protocol/openid-connect/token`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: params.toString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("[AUTH] refresh failed", res.status);
|
||||||
|
logout();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const accessToken = data.access_token;
|
||||||
|
const newRefresh = data.refresh_token || refreshToken;
|
||||||
|
const expiresIn = Number(data.expires_in || 0);
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
logout();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setToken(accessToken);
|
||||||
|
setAuthenticated(true);
|
||||||
|
|
||||||
|
localStorage.setItem(TOKEN_KEY, accessToken);
|
||||||
|
localStorage.setItem(REFRESH_KEY, newRefresh);
|
||||||
|
localStorage.setItem(
|
||||||
|
EXPIRES_AT_KEY,
|
||||||
|
String(Date.now() + expiresIn * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[AUTH] refresh error", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
INIT AUTH (APP LOAD)
|
||||||
|
============================== */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const storedRefresh = localStorage.getItem(REFRESH_KEY);
|
||||||
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
|
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
|
||||||
|
const expiresAt = Number(localStorage.getItem(EXPIRES_AT_KEY) || "0");
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const needsRefresh = !storedToken || now > expiresAt - 60_000;
|
||||||
|
|
||||||
|
if (storedRefresh && needsRefresh) {
|
||||||
|
const ok = await refreshWithToken(storedRefresh);
|
||||||
|
if (ok) {
|
||||||
|
setUsername(storedUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (storedToken) {
|
if (storedToken) {
|
||||||
setToken(storedToken);
|
setToken(storedToken);
|
||||||
setUsername(storedUser);
|
setUsername(storedUser);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
} else {
|
|
||||||
setAuthenticated(false);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[AUTH] localStorage error", e);
|
console.warn("[AUTH] init error", e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Login via password grant sur Keycloak
|
/* ==============================
|
||||||
|
BACKGROUND REFRESH
|
||||||
|
============================== */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated) return;
|
||||||
|
|
||||||
|
const t = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const refresh = localStorage.getItem(REFRESH_KEY);
|
||||||
|
const expiresAt = Number(localStorage.getItem(EXPIRES_AT_KEY) || "0");
|
||||||
|
|
||||||
|
if (!refresh) return;
|
||||||
|
if (Date.now() > expiresAt - 60_000) {
|
||||||
|
await refreshWithToken(refresh);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [authenticated]);
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
LOGIN
|
||||||
|
============================== */
|
||||||
async function login(user, pass) {
|
async function login(user, pass) {
|
||||||
setLastError("");
|
setLastError("");
|
||||||
const u = (user || "").trim();
|
|
||||||
if (!u || !pass) {
|
|
||||||
setLastError("Username and password required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("grant_type", "password");
|
params.set("grant_type", "password");
|
||||||
params.set("client_id", KC_CLIENT_ID);
|
params.set("client_id", KC_CLIENT_ID);
|
||||||
params.set("username", u);
|
params.set("username", user);
|
||||||
params.set("password", pass);
|
params.set("password", pass);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -61,89 +152,56 @@ export function AuthProvider({ children }) {
|
||||||
`${KC_BASE}/realms/${KC_REALM}/protocol/openid-connect/token`,
|
`${KC_BASE}/realms/${KC_REALM}/protocol/openid-connect/token`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: params.toString(),
|
body: params.toString(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
setLastError("Invalid credentials");
|
||||||
console.error("[AUTH] login failed", res.status, text);
|
|
||||||
|
|
||||||
let msg = "Invalid credentials or auth error.";
|
|
||||||
try {
|
|
||||||
const j = JSON.parse(text);
|
|
||||||
if (j.error_description) {
|
|
||||||
msg = j.error_description;
|
|
||||||
} else if (j.error) {
|
|
||||||
msg = j.error;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore parse error
|
|
||||||
}
|
|
||||||
|
|
||||||
setLastError(`${res.status} ${msg}`);
|
|
||||||
setAuthenticated(false);
|
|
||||||
setToken(null);
|
|
||||||
setUsername("");
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USERNAME_KEY);
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const accessToken = data.access_token;
|
const accessToken = data.access_token;
|
||||||
if (!accessToken) {
|
const refreshToken = data.refresh_token;
|
||||||
setLastError("No access token in response.");
|
const expiresIn = Number(data.expires_in || 0);
|
||||||
setAuthenticated(false);
|
|
||||||
setToken(null);
|
if (!accessToken || !refreshToken) {
|
||||||
setUsername("");
|
setLastError("Invalid token response");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setToken(accessToken);
|
setToken(accessToken);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
setUsername(u);
|
setUsername(user);
|
||||||
setLastError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.setItem(TOKEN_KEY, accessToken);
|
localStorage.setItem(TOKEN_KEY, accessToken);
|
||||||
localStorage.setItem(USERNAME_KEY, u);
|
localStorage.setItem(REFRESH_KEY, refreshToken);
|
||||||
|
localStorage.setItem(
|
||||||
|
EXPIRES_AT_KEY,
|
||||||
|
String(Date.now() + expiresIn * 1000)
|
||||||
|
);
|
||||||
|
localStorage.setItem(USERNAME_KEY, user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[AUTH] failed to persist token", e);
|
console.error("[AUTH] login error", e);
|
||||||
}
|
setLastError("Network error");
|
||||||
} catch (e) {
|
|
||||||
console.error("[AUTH] network error", e);
|
|
||||||
setLastError("Network error during login.");
|
|
||||||
setAuthenticated(false);
|
|
||||||
setToken(null);
|
|
||||||
setUsername("");
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USERNAME_KEY);
|
|
||||||
} catch (e2) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
LOGOUT
|
||||||
|
============================== */
|
||||||
function logout() {
|
function logout() {
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUsername("");
|
setUsername("");
|
||||||
setLastError("");
|
setLastError("");
|
||||||
try {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
|
localStorage.removeItem(EXPIRES_AT_KEY);
|
||||||
localStorage.removeItem(USERNAME_KEY);
|
localStorage.removeItem(USERNAME_KEY);
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -165,8 +223,6 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const ctx = useContext(AuthContext);
|
const ctx = useContext(AuthContext);
|
||||||
if (!ctx) {
|
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||||
throw new Error("useAuth must be used inside <AuthProvider>");
|
|
||||||
}
|
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue