feat: B2 — 401 interceptor + auto-refresh token (fix SuperOAuth path + response shape)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 27s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 27s
This commit is contained in:
@@ -152,7 +152,7 @@ router.post("/refresh", async (req: Request, res: Response): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/refresh`, {
|
const response = await fetch(`${superOAuthUrl}/api/v1/auth/refresh`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ refreshToken }),
|
body: JSON.stringify({ refreshToken }),
|
||||||
@@ -160,22 +160,22 @@ router.post("/refresh", async (req: Request, res: Response): Promise<void> => {
|
|||||||
|
|
||||||
const data = await response.json() as {
|
const data = await response.json() as {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: { tokens: { accessToken: string; refreshToken?: string }; user?: { id: string; email: string | null; nickname: string } };
|
data?: { accessToken: string; refreshToken?: string };
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!response.ok || !data.data?.tokens?.accessToken) {
|
if (!response.ok || !data.data?.accessToken) {
|
||||||
res.clearCookie(COOKIE_NAME);
|
res.clearCookie(COOKIE_NAME);
|
||||||
res.clearCookie(REFRESH_COOKIE_NAME);
|
res.clearCookie(REFRESH_COOKIE_NAME);
|
||||||
res.status(401).json({ success: false, error: "REFRESH_FAILED" });
|
res.status(401).json({ success: false, error: "REFRESH_FAILED" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.cookie(COOKIE_NAME, data.data.tokens.accessToken, COOKIE_OPTIONS);
|
res.cookie(COOKIE_NAME, data.data.accessToken, COOKIE_OPTIONS);
|
||||||
if (data.data.tokens.refreshToken) {
|
if (data.data.refreshToken) {
|
||||||
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
|
res.cookie(REFRESH_COOKIE_NAME, data.data.refreshToken, REFRESH_COOKIE_OPTIONS);
|
||||||
}
|
}
|
||||||
res.json({ success: true, data: { user: data.data.user ?? null } });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("POST /auth/refresh — auth service unavailable", { err });
|
logger.error("POST /auth/refresh — auth service unavailable", { err });
|
||||||
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
|
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Token expiré et refresh impossible → déconnexion silencieuse
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setUser(null);
|
||||||
|
window.addEventListener('auth:expired', handler);
|
||||||
|
return () => window.removeEventListener('auth:expired', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, setUser }}>
|
<AuthContext.Provider value={{ user, loading, setUser }}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -8,19 +8,39 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
function buildInit(init?: RequestInit): RequestInit {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
return {
|
||||||
credentials: 'include', // transmet le cookie httpOnly automatiquement
|
credentials: 'include',
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||||
'Content-Type': 'application/json',
|
};
|
||||||
...init?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new ApiError(res.status, path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Déduplique les appels refresh simultanés
|
||||||
|
let refreshingPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
async function tryRefresh(): Promise<boolean> {
|
||||||
|
if (refreshingPromise) return refreshingPromise;
|
||||||
|
refreshingPromise = fetch(`${BASE}/auth/refresh`, { method: 'POST', credentials: 'include' })
|
||||||
|
.then((r) => r.ok)
|
||||||
|
.finally(() => { refreshingPromise = null; });
|
||||||
|
return refreshingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, buildInit(init));
|
||||||
|
|
||||||
|
if (res.status === 401 && path !== '/auth/refresh') {
|
||||||
|
const refreshed = await tryRefresh();
|
||||||
|
if (refreshed) {
|
||||||
|
const retry = await fetch(`${BASE}${path}`, buildInit(init));
|
||||||
|
if (!retry.ok) throw new ApiError(retry.status, path);
|
||||||
|
return retry.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new Event('auth:expired'));
|
||||||
|
throw new ApiError(401, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new ApiError(res.status, path);
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user