From 81e3968e316894b0c538ffb686b871455975685e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Wed, 17 Jun 2026 12:07:46 +0200 Subject: [PATCH] Log out users automatically when their JWT expires. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously an expired token left the frontend in a stuck state: the router guard only checked token presence (never the exp claim), so the user could still navigate to protected pages, and every API call then failed with a generic Swedish "Kunde inte hämta…" message while the header kept showing the logged-in UI. There was no global response interceptor, and the backend returned an ambiguous 403 (no body) for unauthenticated requests because no AuthenticationEntryPoint was configured, making 403 mean both "no/invalid token" and "forbidden". Backend: - Add an AuthenticationEntryPoint in SecurityConfig that returns 401 with a Swedish {"message": ...} ErrorResponse body for unauthenticated/expired-token requests, and an AccessDeniedHandler returning 403 with the same body shape for genuine authorization failures. This makes 401 = not authenticated/expired and 403 = authenticated but forbidden, the standard REST convention. - Make JwtService(String, long) constructor public so integration tests can mint expired tokens (was package-private). - Update the 6 no-auth controller tests from 403 to 401 (OrderControllerTest, AdminControllerTest, PaymentControllerTest, AuthControllerTest change-password/change-email) and assert the message body exists; keep shouldReturn403ForNonAdminUser as 403. - Add OrderControllerTest.shouldReturn401WithSwedishMessageWhenTokenExpired (expired JWT via TTL -1000ms) and shouldReturn401WithMessageWhenNoAuthHeader. Frontend: - Add isTokenExpired() to utils/jwt.ts using the previously-unused exp claim, and expose it on the auth store. - Add a global 401 interceptor in api/client.ts: on a 401 from any non-/auth/ endpoint, call auth.logout() and redirect to /logga-in?redirect=. Skip /auth/ so wrong-password 401s on login/change-password stay handled locally. Add isSessionExpired and isForbidden helpers for per-page catch blocks. - Harden the router guard to reject tokens whose exp is in the past (logout + redirect to login with ?redirect=), and let expired-token users open /logga-in and /registrera instead of bouncing to home. - Refactor the generic-error catch blocks on OrdersPage, EditOrderPage, ComposePage, PaymentRedirect, useAdminOrders, and useAdminOrderActions to skip the generic Swedish message on 401 (handled globally) while preserving wrong-password 401 handling on change-pw/email pages. Tests: - New frontend/src/__tests__/client.spec.ts covering 401 -> logout + redirect, 401 from /auth/ -> no logout, 403 -> no logout, no-token 401 -> no redirect, and isSessionExpired/isForbidden helpers. - Add authStore.spec.ts cases for isTokenExpired (no token, past exp, future exp, missing exp, after logout). - Add Router.spec.ts cases for expired-token redirects, token clearing, future-exp access, and guest pages not bouncing expired users. - Add OrdersPage.spec.ts case asserting 401 triggers no generic error and the global logout/redirect. - New E2E expired-token.spec.ts (Docker) covering both the router-guard expired-token redirect and the API-401 redirect, with logged-out header and cleared localStorage assertions. - Mock the API in two pre-existing fake-JWT E2E tests (auth-guards admin access, header-auth logout redirect) that broke because the backend now correctly 401s their unsigned test-sig tokens. Verified with ./gradlew check (frontend lint + 267 unit tests, backend tests + coverage, Flyway, 92 E2E tests in Docker) and ./gradlew coverage; all coverage thresholds maintained (jwt.ts at 100%). --- AGENTS.md | 9 +- .../se/bilhalsning/config/SecurityConfig.java | 25 ++++ .../se/bilhalsning/security/JwtService.java | 2 +- .../controller/AdminControllerTest.java | 8 +- .../controller/AuthControllerTest.java | 6 +- .../controller/OrderControllerTest.java | 35 ++++- .../controller/PaymentControllerTest.java | 5 +- frontend/e2e/auth-guards.spec.ts | 7 + frontend/e2e/expired-token.spec.ts | 55 ++++++++ frontend/e2e/header-auth.spec.ts | 7 + frontend/src/__tests__/OrdersPage.spec.ts | 51 +++++++ frontend/src/__tests__/Router.spec.ts | 58 ++++++++ frontend/src/__tests__/authStore.spec.ts | 46 +++++++ frontend/src/__tests__/client.spec.ts | 125 ++++++++++++++++++ frontend/src/api/client.ts | 23 ++++ .../src/composables/useAdminOrderActions.ts | 26 ++-- frontend/src/composables/useAdminOrders.ts | 7 +- frontend/src/pages/ComposePage.vue | 7 +- frontend/src/pages/EditOrderPage.vue | 13 +- frontend/src/pages/OrdersPage.vue | 14 +- frontend/src/pages/PaymentRedirect.vue | 7 +- frontend/src/router/index.ts | 6 +- frontend/src/stores/authStore.ts | 7 +- frontend/src/utils/jwt.ts | 7 + 24 files changed, 515 insertions(+), 41 deletions(-) create mode 100644 frontend/e2e/expired-token.spec.ts create mode 100644 frontend/src/__tests__/client.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 0521b8d..b9b2dc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,11 +170,16 @@ export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PR ./gradlew check ``` -This runs frontend lint, frontend unit tests (242), backend tests (163), coverage -thresholds, Flyway checks, and **all 90 E2E tests in Docker**. **Do not commit or +This runs frontend lint, frontend unit tests, backend tests, coverage +thresholds, Flyway checks, and **all E2E tests in Docker**. **Do not commit or push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh` (runs the same `check` on every `git commit`). +**Note for agents:** The pre-commit hook runs the full `./gradlew check` which +takes ~3.5 minutes. If your tool enforces a default timeout (e.g. 120 s on +agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run +`./gradlew check` manually before committing. + ### Frontend (Vue.js 3) - `