fix(auth): déconnexion forcée quand une requête 401 en cours de session (#108) #116
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
||||
|
|
@ -124,6 +141,19 @@ export class ApiClient {
|
|||
|
||||
if (!response.ok) {
|
||||
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
|
||||
// hardcoded string, always the real enum member.
|
||||
throw new ApiError(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { apiClient } from "../../api/client";
|
||||
import {
|
||||
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}. */
|
||||
interface AuthContextValue {
|
||||
|
|
@ -37,6 +46,12 @@ const AuthContext = createContext<AuthContextValue | null>(null);
|
|||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<SafeUserProfile | null>(null);
|
||||
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(() => {
|
||||
apiClient
|
||||
|
|
@ -48,6 +63,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
.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) => {
|
||||
try {
|
||||
setUser(await apiClient.signup(input));
|
||||
|
|
|
|||
Loading…
Reference in a new issue