fix(auth): déconnexion forcée quand une requête 401 en cours de session (#108)
All checks were successful
All checks were successful
La session expire déjà (JWT 7 j), mais le client web n'y réagissait pas :
seul le `GET /auth/me` du montage de `AuthProvider` attrapait un 401 ; un
401 de n'importe quel autre endpoint ne faisait que remonter une erreur
locale, l'UI connectée restant affichée jusqu'au prochain rechargement
complet. Sur l'app native (rarement redémarrée, token persisté) la session
paraissait ne jamais finir.
- `client.ts` : sur un `401 NOT_AUTHENTICATED` pour toute requête autre que
le probe `GET /auth/me`, `ApiClient._request` purge le token store et
appelle un handler `onSessionExpired` (enregistré via
`setSessionExpiredHandler`). L'`ApiError` continue d'être levée comme
avant.
- `AuthContext.tsx` : `AuthProvider` enregistre ce handler au montage —
`setUser(null)` + `navigate(/login, { replace: true })`, gardé par un
`userRef` pour être un no-op si déjà déconnecté. `ApiClient` restant
agnostique du framework, c'est bien lui qui délègue la navigation/état.
Pas de changement API : le back renvoie déjà 401 sur JWT expiré
(`require-auth.ts`), couvert par sa suite Mocha.
Tests : `apps/web/cypress/e2e/session-expiry.cy.ts` — un 401 en cours de
session redirige vers `/login` et vide l'état (un retour sur une route
gardée re-redirige) ; le 401 du `/auth/me` initial d'un visiteur non
connecté ne déclenche pas ce chemin (la landing reste affichée). Suite e2e
existante (auth / layout / account / recipes / onboarding / preferences)
revérifiée sans régression.
Closes #108
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c17b5b0060
commit
a54cb85027
3 changed files with 129 additions and 2 deletions
66
apps/web/cypress/e2e/session-expiry.cy.ts
Normal file
66
apps/web/cypress/e2e/session-expiry.cy.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
||||||
|
// .github/workflows/ci.yml). apps/api's own Mocha suite covers the real
|
||||||
|
// 401 behavior (an expired/invalid JWT -> `require-auth` -> 401
|
||||||
|
// NOT_AUTHENTICATED); this only covers the *web client's* reaction to it.
|
||||||
|
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
|
||||||
|
const authenticatedProfile = {
|
||||||
|
id: 1,
|
||||||
|
firstName: "Alice",
|
||||||
|
lastName: "Martin",
|
||||||
|
email: "alice@example.com",
|
||||||
|
tokenVersion: 0,
|
||||||
|
houseId: 1,
|
||||||
|
dietId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Issue #108 — a session that expired (or was invalidated) server-side
|
||||||
|
// mid-use only took effect on the next full reload: any other request just
|
||||||
|
// surfaced a local error while the authenticated UI stayed put. `ApiClient`
|
||||||
|
// now reacts to a `401 NOT_AUTHENTICATED` on any request other than the
|
||||||
|
// initial `/auth/me` probe by dropping the session and bouncing to
|
||||||
|
// `/login`.
|
||||||
|
describe("Session expiry", () => {
|
||||||
|
it("bounces to /login when a request 401s mid-session", () => {
|
||||||
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||||
|
// Logged in, planning loads fine.
|
||||||
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
||||||
|
|
||||||
|
cy.visit("/");
|
||||||
|
cy.wait("@getPlanning");
|
||||||
|
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
||||||
|
|
||||||
|
// The session dies server-side: every endpoint now 401s, including the
|
||||||
|
// `/auth/me` probe a fresh load would make.
|
||||||
|
const unauthenticated = {
|
||||||
|
statusCode: 401,
|
||||||
|
body: { code: ErrorCode.NOT_AUTHENTICATED, message: "not authenticated" },
|
||||||
|
};
|
||||||
|
cy.intercept("GET", "**/auth/me", unauthenticated);
|
||||||
|
cy.intercept("GET", /\/planning\?/, unauthenticated);
|
||||||
|
|
||||||
|
// The next planning fetch (a week-nav click) 401s -> bounced to /login.
|
||||||
|
cy.get(".week-nav__arrow").last().click();
|
||||||
|
cy.url().should("include", "/login");
|
||||||
|
|
||||||
|
// ...and it's a real logout: reloading a guarded route redirects again
|
||||||
|
// rather than showing stale authed content.
|
||||||
|
cy.visit("/recettes");
|
||||||
|
cy.url().should("include", "/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bounce a logged-out visitor — the initial /auth/me 401 is the normal state", () => {
|
||||||
|
cy.intercept("GET", "**/auth/me", {
|
||||||
|
statusCode: 401,
|
||||||
|
body: { code: ErrorCode.NOT_AUTHENTICATED, message: "not authenticated" },
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.visit("/");
|
||||||
|
// `/` for a logged-out visitor is the public landing page (issue #41),
|
||||||
|
// not a redirect to /login — the `/auth/me` probe 401 must not trip the
|
||||||
|
// session-expiry handler.
|
||||||
|
cy.contains("a", "Se connecter").should("be.visible");
|
||||||
|
cy.url().should("not.include", "/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -65,6 +65,23 @@ export function setTokenStore(store: TokenStore): void {
|
||||||
activeTokenStore = store;
|
activeTokenStore = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by {@link ApiClient._request} the moment any request *other than*
|
||||||
|
* the initial `GET /auth/me` probe comes back `401 NOT_AUTHENTICATED` — i.e.
|
||||||
|
* a session that *was* valid has expired (7-day JWT) or been invalidated
|
||||||
|
* server-side mid-use. The token store is already cleared by the time this
|
||||||
|
* fires; the app-level handler (registered by `AuthProvider`) resets the
|
||||||
|
* in-memory auth state and sends the user to `/login`. `null` until
|
||||||
|
* registered — on the server, in tests, and before `AuthProvider` mounts,
|
||||||
|
* nobody reacts and the `ApiError` just propagates as before (issue #108).
|
||||||
|
*/
|
||||||
|
let onSessionExpired: (() => void) | null = null;
|
||||||
|
|
||||||
|
/** Registers (or, with `null`, clears) the {@link onSessionExpired} handler — `AuthProvider` does both, on mount and unmount. */
|
||||||
|
export function setSessionExpiredHandler(handler: (() => void) | null): void {
|
||||||
|
onSessionExpired = handler;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
|
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
|
||||||
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
||||||
|
|
@ -124,6 +141,19 @@ export class ApiClient {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
||||||
|
// A session that was valid has just died server-side (expired JWT,
|
||||||
|
// or a future "log out everywhere"): drop the local token and let
|
||||||
|
// the app react (see `onSessionExpired`). Excludes the initial
|
||||||
|
// `GET /auth/me` probe — a 401 there is the normal "not logged in
|
||||||
|
// yet" state `AuthProvider` already handles, not a mid-use expiry.
|
||||||
|
if (
|
||||||
|
response.status === 401 &&
|
||||||
|
body?.code === ErrorCode.NOT_AUTHENTICATED &&
|
||||||
|
path !== "/auth/me"
|
||||||
|
) {
|
||||||
|
await activeTokenStore.clear();
|
||||||
|
onSessionExpired?.();
|
||||||
|
}
|
||||||
// Fallback for a response that couldn't even be parsed as JSON — no
|
// Fallback for a response that couldn't even be parsed as JSON — no
|
||||||
// hardcoded string, always the real enum member.
|
// hardcoded string, always the real enum member.
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
|
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
|
||||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
import {
|
||||||
import { apiClient } from "../../api/client";
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { apiClient, setSessionExpiredHandler } from "../../api/client";
|
||||||
|
|
||||||
/** Shape of the auth state/actions exposed via {@link useAuth}. */
|
/** Shape of the auth state/actions exposed via {@link useAuth}. */
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
|
|
@ -37,6 +46,12 @@ const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<SafeUserProfile | null>(null);
|
const [user, setUser] = useState<SafeUserProfile | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Read by the session-expiry handler below without making it a dependency
|
||||||
|
// (it registers once): a stray call while already logged out is a no-op.
|
||||||
|
const userRef = useRef(user);
|
||||||
|
userRef.current = user;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient
|
apiClient
|
||||||
|
|
@ -48,6 +63,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// `ApiClient` is framework-agnostic (see its own doc comment) so it
|
||||||
|
// can't navigate or touch React state — it just calls this when a live
|
||||||
|
// session 401s mid-use (the 7-day JWT expired, say). Without it an
|
||||||
|
// expired session only took effect on the next full reload; any other
|
||||||
|
// request just surfaced a local error while the authenticated UI stayed
|
||||||
|
// put (issue #108). The token store is already cleared client-side by
|
||||||
|
// the time this runs.
|
||||||
|
setSessionExpiredHandler(() => {
|
||||||
|
if (userRef.current === null) return;
|
||||||
|
setUser(null);
|
||||||
|
void navigate("/login", { replace: true });
|
||||||
|
});
|
||||||
|
return () => setSessionExpiredHandler(null);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
const signup = useCallback(async (input: SignupInput) => {
|
const signup = useCallback(async (input: SignupInput) => {
|
||||||
try {
|
try {
|
||||||
setUser(await apiClient.signup(input));
|
setUser(await apiClient.signup(input));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue