Log out users automatically when their JWT expires. #11

Merged
jocke merged 1 commit from feature/expired-token-logout into master 2026-06-17 10:44:18 +00:00
Owner

What

Log users out automatically when their JWT expires, instead of leaving them stuck on pages that silently fail to load.

Why

Previously an expired token left the frontend in a broken 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 "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, making 403 mean both "no/invalid token" and "forbidden".

Changes

Backend

  • SecurityConfig: add an AuthenticationEntryPoint (401 + Swedish {message} body) for unauthenticated/expired-token requests, and an AccessDeniedHandler (403 + body) for genuine authorization failures. Now 401 = not authenticated/expired, 403 = authenticated but forbidden.
  • JwtService: make the (secret, expirationMs) constructor public so integration tests can mint expired tokens.

Frontend

  • utils/jwt.ts: isTokenExpired() using the previously-unused exp claim.
  • api/client.ts: global 401 interceptor — on a 401 from any non-/auth/ endpoint, call auth.logout() and redirect to /logga-in?redirect=<path>. Adds isSessionExpired/isForbidden helpers.
  • router/index.ts: guard rejects expired tokens (logout + redirect); expired-token users can still open /logga-in and /registrera.
  • 6 data-fetching pages/composables: catch blocks skip the generic message on 401 (handled globally); wrong-password 401 handling on change-pw/email pages preserved.

Tests

  • Backend: 6 no-auth tests flipped 403→401, shouldReturn403ForNonAdminUser kept as 403, +2 new (expired-token→401, entry-point body).
  • Frontend: new client.spec.ts (7), authStore +5, Router +5, OrdersPage +1.
  • E2E (Docker): new expired-token.spec.ts (2 scenarios); mocked the API in 2 pre-existing fake-JWT E2E tests that broke because the backend now correctly 401s unsigned test-sig tokens.

Verification

./gradlew check passes (frontend lint + 267 unit tests, backend tests + coverage, Flyway, 92 E2E tests in Docker); ./gradlew coverage thresholds maintained (jwt.ts at 100%).

## What Log users out automatically when their JWT expires, instead of leaving them stuck on pages that silently fail to load. ## Why Previously an expired token left the frontend in a broken 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 "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, making 403 mean both "no/invalid token" and "forbidden". ## Changes **Backend** - `SecurityConfig`: add an `AuthenticationEntryPoint` (401 + Swedish `{message}` body) for unauthenticated/expired-token requests, and an `AccessDeniedHandler` (403 + body) for genuine authorization failures. Now 401 = not authenticated/expired, 403 = authenticated but forbidden. - `JwtService`: make the `(secret, expirationMs)` constructor public so integration tests can mint expired tokens. **Frontend** - `utils/jwt.ts`: `isTokenExpired()` using the previously-unused `exp` claim. - `api/client.ts`: global 401 interceptor — on a 401 from any non-`/auth/` endpoint, call `auth.logout()` and redirect to `/logga-in?redirect=<path>`. Adds `isSessionExpired`/`isForbidden` helpers. - `router/index.ts`: guard rejects expired tokens (logout + redirect); expired-token users can still open `/logga-in` and `/registrera`. - 6 data-fetching pages/composables: catch blocks skip the generic message on 401 (handled globally); wrong-password 401 handling on change-pw/email pages preserved. ## Tests - Backend: 6 no-auth tests flipped 403→401, `shouldReturn403ForNonAdminUser` kept as 403, +2 new (expired-token→401, entry-point body). - Frontend: new `client.spec.ts` (7), `authStore` +5, `Router` +5, `OrdersPage` +1. - E2E (Docker): new `expired-token.spec.ts` (2 scenarios); mocked the API in 2 pre-existing fake-JWT E2E tests that broke because the backend now correctly 401s unsigned test-sig tokens. ## Verification `./gradlew check` passes (frontend lint + 267 unit tests, backend tests + coverage, Flyway, 92 E2E tests in Docker); `./gradlew coverage` thresholds maintained (jwt.ts at 100%).
jocke added 1 commit 2026-06-17 10:12:24 +00:00
Log out users automatically when their JWT expires.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m10s
CI / E2E browser tests (pull_request) Successful in 4m7s
489fb0335a
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=<currentPath>. 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%).
Collaborator

Review: feature/expired-token-logout

Verdict: Approve

Well-scoped fix that correctly separates 401 (unauthenticated/expired) from 403 (forbidden), enforces exp in the router guard, and adds a global 401 interceptor without breaking existing wrong-password handling.

Verified

  • E2E suite: 92 passed in ~47 s on the current branch.

Suggestions (non-blocking)

  1. Concurrent 401s: If multiple API requests fail with 401 at the same time, handleExpiredSession() will call auth.logout() and router.push() for each one. Consider adding a guard (e.g., checking router.currentRoute.value.name === 'login' or a short-lived flag) to avoid redundant navigations.
  2. Misleading error on expired session during password change: Because /auth/change-password and /auth/change-email 401s are handled locally as "wrong password", a user whose session expired while on those pages will see the wrong-password message instead of being logged out. This is consistent with the PR description, but a future improvement could distinguish the two cases (e.g., a specific error code from the backend).
  3. Minor: WWW-Authenticate header on 401 responses would be more RFC-compliant, but not required for this app.

Looks good

  • Backend entry point / access-denied handlers are clean and return consistent Swedish ErrorResponse bodies.
  • Test updates correctly flip 403→401 for unauthenticated cases while keeping shouldReturn403ForNonAdminUser as 403.
  • E2E tests that used fake JWTs are now mocked so the backend's correct 401 behavior doesn't break them.
  • Good coverage across backend unit tests, frontend unit tests (client.spec.ts, authStore.spec.ts, Router.spec.ts), and E2E (expired-token.spec.ts).

No blockers — LGTM.

## Review: feature/expired-token-logout **Verdict: Approve** ✅ Well-scoped fix that correctly separates 401 (unauthenticated/expired) from 403 (forbidden), enforces `exp` in the router guard, and adds a global 401 interceptor without breaking existing wrong-password handling. ### Verified - E2E suite: **92 passed** in ~47 s on the current branch. ### Suggestions (non-blocking) 1. **Concurrent 401s**: If multiple API requests fail with 401 at the same time, `handleExpiredSession()` will call `auth.logout()` and `router.push()` for each one. Consider adding a guard (e.g., checking `router.currentRoute.value.name === 'login'` or a short-lived flag) to avoid redundant navigations. 2. **Misleading error on expired session during password change**: Because `/auth/change-password` and `/auth/change-email` 401s are handled locally as "wrong password", a user whose session expired while on those pages will see the wrong-password message instead of being logged out. This is consistent with the PR description, but a future improvement could distinguish the two cases (e.g., a specific error code from the backend). 3. **Minor**: `WWW-Authenticate` header on 401 responses would be more RFC-compliant, but not required for this app. ### Looks good - Backend entry point / access-denied handlers are clean and return consistent Swedish `ErrorResponse` bodies. - Test updates correctly flip 403→401 for unauthenticated cases while keeping `shouldReturn403ForNonAdminUser` as 403. - E2E tests that used fake JWTs are now mocked so the backend's correct 401 behavior doesn't break them. - Good coverage across backend unit tests, frontend unit tests (`client.spec.ts`, `authStore.spec.ts`, `Router.spec.ts`), and E2E (`expired-token.spec.ts`). No blockers — LGTM.
jocke force-pushed feature/expired-token-logout from 489fb0335a to 81e3968e31 2026-06-17 10:43:37 +00:00 Compare
jocke merged commit c88fa142d3 into master 2026-06-17 10:44:18 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: jocke/bilhej#11
No description provided.