Log out users automatically when their JWT expires.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m11s
CI / E2E browser tests (pull_request) Successful in 3m57s

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%).
This commit is contained in:
Joakim Mörling 2026-06-17 12:07:46 +02:00
parent 5335ba4f12
commit 81e3968e31
24 changed files with 515 additions and 41 deletions

View file

@ -170,11 +170,16 @@ export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PR
./gradlew check ./gradlew check
``` ```
This runs frontend lint, frontend unit tests (242), backend tests (163), coverage This runs frontend lint, frontend unit tests, backend tests, coverage
thresholds, Flyway checks, and **all 90 E2E tests in Docker**. **Do not commit or 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` push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh`
(runs the same `check` on every `git commit`). (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) ### Frontend (Vue.js 3)
- `<script setup>` with Composition API only. Never Options API. - `<script setup>` with Composition API only. Never Options API.
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables. - File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables.

View file

@ -1,8 +1,12 @@
package se.bilhalsning.config; package se.bilhalsning.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@ -10,6 +14,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import se.bilhalsning.dto.ErrorResponse;
import se.bilhalsning.security.JwtAuthenticationFilter; import se.bilhalsning.security.JwtAuthenticationFilter;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
@ -17,6 +22,13 @@ import se.bilhalsning.security.JwtService;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
static final String UNAUTHENTICATED_MESSAGE =
"Din session har löpt ut eller är ogiltig. Logga in igen.";
static final String FORBIDDEN_MESSAGE =
"Du har inte behörighet att utföra denna åtgärd.";
private final ObjectMapper objectMapper = new ObjectMapper();
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
@ -46,8 +58,21 @@ public class SecurityConfig {
.requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) .anyRequest().authenticated())
.exceptionHandling(eh -> eh
.authenticationEntryPoint((request, response, ex) ->
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
.accessDeniedHandler((request, response, ex) ->
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
private void writeError(HttpServletResponse response, HttpStatus status, String message)
throws java.io.IOException {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(message)));
}
} }

View file

@ -18,7 +18,7 @@ public class JwtService {
this(secret, DEFAULT_EXPIRATION_MS); this(secret, DEFAULT_EXPIRATION_MS);
} }
JwtService(String secret, long expirationMs) { public JwtService(String secret, long expirationMs) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
this.expirationMs = expirationMs; this.expirationMs = expirationMs;
} }

View file

@ -42,16 +42,18 @@ class AdminControllerTest {
private AdminOrderWorkflowService adminOrderWorkflowService; private AdminOrderWorkflowService adminOrderWorkflowService;
@Test @Test
void shouldReturn403WhenNotAuthenticated() throws Exception { void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@WithMockUser(username = "test@bilhej.se", roles = "USER") @WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403ForNonAdminUser() throws Exception { void shouldReturn403ForNonAdminUser() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden())
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test

View file

@ -225,7 +225,8 @@ class AuthControllerTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content( .content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}")) "{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -263,6 +264,7 @@ class AuthControllerTest {
mockMvc.perform(post("/api/auth/change-email") mockMvc.perform(post("/api/auth/change-email")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}")) .content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
} }

View file

@ -18,16 +18,22 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.OrderResponse; import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
class OrderControllerTest { class OrderControllerTest {
private static final String TEST_SECRET =
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@ -38,9 +44,10 @@ class OrderControllerTest {
private UserService userService; private UserService userService;
@Test @Test
void shouldReturn403WhenNotAuthenticated() throws Exception { void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/orders")) mockMvc.perform(get("/api/orders"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -100,11 +107,31 @@ class OrderControllerTest {
} }
@Test @Test
void shouldReturn403WhenPostingWithoutAuth() throws Exception { void shouldReturn401WhenPostingWithoutAuth() throws Exception {
mockMvc.perform(post("/api/orders") mockMvc.perform(post("/api/orders")
.contentType("application/json") .contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}")) .content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
void shouldReturn401WithSwedishMessageWhenTokenExpired() throws Exception {
JwtService expiredJwtService = new JwtService(TEST_SECRET, -1000L);
String expiredToken = expiredJwtService.generateToken("test@bilhej.se");
mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
void shouldReturn401WithMessageWhenNoAuthHeader() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message")
.value(org.hamcrest.Matchers.containsString("session")));
} }
@Test @Test

View file

@ -39,10 +39,11 @@ class PaymentControllerTest {
private UserService userService; private UserService userService;
@Test @Test
void shouldReturn403WhenNotAuthenticated() throws Exception { void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(post("/api/payment/{orderId}/pay", mockMvc.perform(post("/api/payment/{orderId}/pay",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")) "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test

View file

@ -70,6 +70,13 @@ test.describe('Auth guards', () => {
}) })
test('allows admin user to access /admin', async ({ page }) => { test('allows admin user to access /admin', async ({ page }) => {
await page.route('**/api/admin/orders', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
}),
)
const jwt = makeJwt({ role: 'admin' }) const jwt = makeJwt({ role: 'admin' })
await page.goto('/') await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)

View file

@ -0,0 +1,55 @@
import { test, expect } from '@playwright/test'
test.describe('Expired token logout', () => {
test('router guard redirects expired token to login and logs out', async ({
page,
}) => {
const past = Math.floor(Date.now() / 1000) - 3600
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: past })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/orders')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
const header = page.locator('header')
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).not.toBeVisible()
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
expect(stored).toBeNull()
})
test('API 401 logs out and redirects when guard accepts token but backend rejects it', async ({
page,
}) => {
const future = Math.floor(Date.now() / 1000) + 3600
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: future })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/orders')
await page.waitForURL(/\/logga-in\?redirect=\/orders/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
const header = page.locator('header')
await expect(header.getByRole('button', { name: 'Logga ut' })).not.toBeVisible()
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
expect(stored).toBeNull()
})
})
function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}

View file

@ -100,6 +100,13 @@ test.describe('Header auth state', () => {
}) })
test('logout redirects to home page', async ({ page }) => { test('logout redirects to home page', async ({ page }) => {
await page.route('**/api/orders', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
}),
)
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' }) const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/orders') await page.goto('/orders')
await page.evaluate( await page.evaluate(

View file

@ -4,6 +4,26 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import OrdersPage from '@/pages/OrdersPage.vue' import OrdersPage from '@/pages/OrdersPage.vue'
const sessionMocks = vi.hoisted(() => {
const mockLogout = vi.fn()
const mockPush = vi.fn()
return {
mockLogout,
mockPush,
mockAuth: { isAuthenticated: true, logout: mockLogout },
}
})
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => sessionMocks.mockAuth,
}))
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { fullPath: '/orders' } },
push: sessionMocks.mockPush,
},
}))
function mockFetchResponse(status: number, body: unknown) { function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({ return Promise.resolve({
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
@ -376,3 +396,34 @@ describe('OrdersPage', () => {
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false) expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false)
}) })
}) })
describe('OrdersPage — expired session (401)', () => {
beforeEach(() => {
localStorage.clear()
globalThis.fetch = vi.fn()
sessionMocks.mockLogout.mockClear()
sessionMocks.mockPush.mockClear()
sessionMocks.mockAuth.isAuthenticated = true
})
it('does not show generic error and triggers global logout/redirect on 401', async () => {
vi.mocked(globalThis.fetch).mockImplementation((url) => {
const urlStr = String(url)
if (urlStr.includes('/payment/swish-info')) {
return mockFetchResponse(200, { number: '123', amount: 49 })
}
return mockFetchResponse(401, { message: 'Din session har löpt ut.' })
})
localStorage.setItem('auth_token', 'expired-token')
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).not.toContain('Kunde inte hämta beställningar')
expect(sessionMocks.mockLogout).toHaveBeenCalledTimes(1)
expect(sessionMocks.mockPush).toHaveBeenCalledWith({
name: 'login',
query: { redirect: '/orders' },
})
})
})

View file

@ -214,6 +214,64 @@ describe('Router guards', () => {
}) })
}) })
describe('Router guards — expired tokens', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('redirects expired-token user from /orders to /logga-in with redirect query', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/orders')
})
it('clears the expired token from localStorage on redirect', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/orders')
await router.isReady()
expect(localStorage.getItem('auth_token')).toBeNull()
})
it('allows access with a token whose exp is in the future', async () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('orders')
})
it('lets expired-token user open /logga-in instead of bouncing to home', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/logga-in')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
})
it('lets expired-token user open /registrera instead of bouncing to home', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/registrera')
await router.isReady()
expect(router.currentRoute.value.name).toBe('register')
})
})
function makeJwt(payload: Record<string, unknown>): string { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -212,6 +212,52 @@ describe('authStore', () => {
}) })
}) })
describe('authStore.isTokenExpired', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('returns true when there is no token', () => {
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(true)
})
it('returns true for a token with an expired exp claim', () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(true)
})
it('returns false for a token with a future exp claim', () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
})
it('returns false for a token without an exp claim', () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
})
it('returns true after logout clears the token', async () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
store.logout()
expect(store.isTokenExpired()).toBe(true)
})
})
function makeJwt(payload: Record<string, unknown>): string { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mocks = vi.hoisted(() => {
const mockLogout = vi.fn()
const mockPush = vi.fn()
return {
mockLogout,
mockPush,
mockAuth: { isAuthenticated: true, logout: mockLogout },
}
})
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => mocks.mockAuth,
}))
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { fullPath: '/orders' } },
push: mocks.mockPush,
},
}))
import { request, ApiError, isSessionExpired, isForbidden } from '@/api/client'
function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
})
}
describe('api client', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
globalThis.fetch = vi.fn()
mocks.mockLogout.mockClear()
mocks.mockPush.mockClear()
mocks.mockAuth.isAuthenticated = true
})
it('logs out and redirects to login on 401 from a protected endpoint', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
localStorage.setItem('auth_token', 'expired-token')
await expect(request('/orders')).rejects.toThrow('Din session har löpt ut.')
expect(mocks.mockLogout).toHaveBeenCalledTimes(1)
expect(mocks.mockPush).toHaveBeenCalledWith({
name: 'login',
query: { redirect: '/orders' },
})
})
it('still throws ApiError with 401 status after handling expired session', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
try {
await request('/orders')
throw new Error('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(ApiError)
expect((err as ApiError).status).toBe(401)
}
})
it('does not log out on 401 from an auth endpoint (wrong credentials)', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
)
await expect(request('/auth/login', { method: 'POST' })).rejects.toThrow(
'Felaktig e-post eller lösenord',
)
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('does not log out on 403 (forbidden is not session expiry)', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(403, { message: 'Du har inte behörighet' }),
)
localStorage.setItem('auth_token', 'valid-token')
await expect(request('/admin/orders')).rejects.toThrow(
'Du har inte behörighet',
)
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('does not redirect when there is no token on 401', async () => {
mocks.mockAuth.isAuthenticated = false
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
await expect(request('/orders')).rejects.toThrow()
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('isSessionExpired returns true only for a 401 ApiError', () => {
expect(isSessionExpired(new ApiError(401, 'x'))).toBe(true)
expect(isSessionExpired(new ApiError(403, 'x'))).toBe(false)
expect(isSessionExpired(new ApiError(500, 'x'))).toBe(false)
expect(isSessionExpired(new Error('x'))).toBe(false)
expect(isSessionExpired(null)).toBe(false)
})
it('isForbidden returns true only for a 403 ApiError', () => {
expect(isForbidden(new ApiError(403, 'x'))).toBe(true)
expect(isForbidden(new ApiError(401, 'x'))).toBe(false)
expect(isForbidden(new Error('x'))).toBe(false)
})
})

View file

@ -1,3 +1,6 @@
import { useAuthStore } from '@/stores/authStore'
import router from '@/router'
const API_BASE = import.meta.env.VITE_API_URL || '/api' const API_BASE = import.meta.env.VITE_API_URL || '/api'
export class ApiError extends Error { export class ApiError extends Error {
@ -10,10 +13,27 @@ export class ApiError extends Error {
} }
} }
export function isSessionExpired(err: unknown): boolean {
return err instanceof ApiError && err.status === 401
}
export function isForbidden(err: unknown): boolean {
return err instanceof ApiError && err.status === 403
}
function getToken(): string | null { function getToken(): string | null {
return localStorage.getItem('auth_token') return localStorage.getItem('auth_token')
} }
function handleExpiredSession(): void {
const auth = useAuthStore()
if (auth.isAuthenticated) {
auth.logout()
const redirect = router.currentRoute.value.fullPath
router.push({ name: 'login', query: { redirect } })
}
}
export async function request<T>( export async function request<T>(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
@ -34,6 +54,9 @@ export async function request<T>(
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && !url.startsWith('/auth/')) {
handleExpiredSession()
}
const body = await response.json().catch(() => ({})) const body = await response.json().catch(() => ({}))
throw new ApiError(response.status, body.message || 'Något gick fel') throw new ApiError(response.status, body.message || 'Något gick fel')
} }

View file

@ -1,5 +1,5 @@
import { ref, reactive, type Ref } from 'vue' import { ref, reactive, type Ref } from 'vue'
import { ApiError } from '@/api/client' import { ApiError, isSessionExpired } from '@/api/client'
import { import {
updateOrderStatus, updateOrderStatus,
registerShipment, registerShipment,
@ -69,10 +69,12 @@ export function useAdminOrderActions(
replaceOrder(updated) replaceOrder(updated)
} catch (err) { } catch (err) {
order.status = previousStatus order.status = previousStatus
statusError.value = if (!isSessionExpired(err)) {
err instanceof ApiError && err.message statusError.value =
? err.message err instanceof ApiError && err.message
: 'Kunde inte uppdatera status. Försök igen.' ? err.message
: 'Kunde inte uppdatera status. Försök igen.'
}
} }
} }
@ -100,11 +102,13 @@ export function useAdminOrderActions(
) )
replaceOrder(updated) replaceOrder(updated)
trackingInputValues[orderId] = updated.trackingId ?? trackingInput trackingInputValues[orderId] = updated.trackingId ?? trackingInput
} catch { } catch (err) {
order.status = previousStatus order.status = previousStatus
order.trackingId = previousTrackingId order.trackingId = previousTrackingId
trackingError.value = if (!isSessionExpired(err)) {
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.' trackingError.value =
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
}
} finally { } finally {
registeringId.value = null registeringId.value = null
} }
@ -123,10 +127,12 @@ export function useAdminOrderActions(
const updated = await updateAdminNotes(orderId, notes) const updated = await updateAdminNotes(orderId, notes)
replaceOrder(updated) replaceOrder(updated)
adminNotesValues[orderId] = updated.adminNotes ?? '' adminNotesValues[orderId] = updated.adminNotes ?? ''
} catch { } catch (err) {
order.adminNotes = previousNotes order.adminNotes = previousNotes
adminNotesValues[orderId] = previousNotes ?? '' adminNotesValues[orderId] = previousNotes ?? ''
notesError.value = 'Kunde inte spara anteckningar. Försök igen.' if (!isSessionExpired(err)) {
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
}
} finally { } finally {
savingNotesId.value = null savingNotesId.value = null
} }

View file

@ -1,5 +1,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { fetchAllOrders, type AdminOrder } from '@/api/admin' import { fetchAllOrders, type AdminOrder } from '@/api/admin'
import { isSessionExpired } from '@/api/client'
import { PAID_GROUP_STATUSES } from '@/constants/orderStatus' import { PAID_GROUP_STATUSES } from '@/constants/orderStatus'
export type AdminOrderFilter = export type AdminOrderFilter =
@ -61,8 +62,10 @@ export function useAdminOrders() {
error.value = '' error.value = ''
try { try {
orders.value = await fetchAllOrders() orders.value = await fetchAllOrders()
} catch { } catch (err) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.' if (!isSessionExpired(err)) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }

View file

@ -2,6 +2,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders' import { createOrder } from '@/api/orders'
import { isSessionExpired } from '@/api/client'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
@ -41,8 +42,10 @@ async function handleSubmit() {
params: { orderId: order.id }, params: { orderId: order.id },
query: { plate: plate.value }, query: { plate: plate.value },
}) })
} catch { } catch (err) {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.' if (!isSessionExpired(err)) {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
}
} finally { } finally {
submitting.value = false submitting.value = false
} }

View file

@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { fetchOrder, updateOrder, type Order } from '@/api/orders' import { fetchOrder, updateOrder, type Order } from '@/api/orders'
import { isSessionExpired } from '@/api/client'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
@ -44,8 +45,10 @@ async function loadOrder() {
if (fetched.status === 'pending_payment') { if (fetched.status === 'pending_payment') {
letterText.value = fetched.letterText letterText.value = fetched.letterText
} }
} catch { } catch (err) {
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.' if (!isSessionExpired(err)) {
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -64,8 +67,10 @@ async function handleSubmit() {
params: { orderId: order.value.id }, params: { orderId: order.value.id },
query: { plate: order.value.plate }, query: { plate: order.value.plate },
}) })
} catch { } catch (err) {
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.' if (!isSessionExpired(err)) {
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
}
} finally { } finally {
submitting.value = false submitting.value = false
} }

View file

@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { fetchOrders, cancelOrder, type Order } from '@/api/orders' import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
import { fetchSwishInfo } from '@/api/payment' import { fetchSwishInfo } from '@/api/payment'
import { isSessionExpired } from '@/api/client'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { import {
ORDER_STATUS_BADGE, ORDER_STATUS_BADGE,
@ -65,8 +66,10 @@ async function loadOrders() {
]) ])
orders.value = fetchedOrders orders.value = fetchedOrders
orderAmount.value = swishInfo.amount orderAmount.value = swishInfo.amount
} catch { } catch (err) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.' if (!isSessionExpired(err)) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -87,8 +90,11 @@ async function handleCancel(order: Order) {
try { try {
const updated = await cancelOrder(order.id) const updated = await cancelOrder(order.id)
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o)) orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
} catch { } catch (err) {
actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.' if (!isSessionExpired(err)) {
actionError.value =
'Kunde inte avbryta beställningen. Försök igen senare.'
}
} finally { } finally {
cancellingId.value = null cancellingId.value = null
} }

View file

@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { payOrder, fetchSwishInfo } from '@/api/payment' import { payOrder, fetchSwishInfo } from '@/api/payment'
import { isSessionExpired } from '@/api/client'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -38,8 +39,10 @@ async function confirmPayment() {
try { try {
await payOrder(orderId) await payOrder(orderId)
await router.push({ name: 'orders' }) await router.push({ name: 'orders' })
} catch { } catch (err) {
error.value = 'Kunde inte bekräfta betalningen. Försök igen.' if (!isSessionExpired(err)) {
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
}
} finally { } finally {
paying.value = false paying.value = false
} }

View file

@ -148,9 +148,11 @@ router.beforeEach((to) => {
if (!getActivePinia()) return if (!getActivePinia()) return
const auth = useAuthStore() const auth = useAuthStore()
const authenticated = auth.isAuthenticated && !auth.isTokenExpired()
if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' } if (to.meta.guestOnly && authenticated) return { name: 'home' }
if (to.meta.requiresAuth && !auth.isAuthenticated) { if (to.meta.requiresAuth && !authenticated) {
if (auth.isAuthenticated) auth.logout()
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' } if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' }

View file

@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { register, login, changeEmail, confirmEmailChange } from '@/api/auth' import { register, login, changeEmail, confirmEmailChange } from '@/api/auth'
import { parseJwtPayload } from '@/utils/jwt' import { parseJwtPayload, isTokenExpired as isJwtExpired } from '@/utils/jwt'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token')) const token = ref<string | null>(localStorage.getItem('auth_token'))
@ -21,6 +21,10 @@ export const useAuthStore = defineStore('auth', () => {
return payload.role ?? null return payload.role ?? null
} }
function isTokenExpired(): boolean {
return isJwtExpired(token.value)
}
function setToken(newToken: string) { function setToken(newToken: string) {
token.value = newToken token.value = newToken
role.value = extractRole(newToken) role.value = extractRole(newToken)
@ -69,6 +73,7 @@ export const useAuthStore = defineStore('auth', () => {
email, email,
isAuthenticated, isAuthenticated,
isAdmin, isAdmin,
isTokenExpired,
registerUser, registerUser,
loginUser, loginUser,
changeUserEmail, changeUserEmail,

View file

@ -20,3 +20,10 @@ export function parseJwtPayload(token: string): JwtPayload {
return {} return {}
} }
} }
export function isTokenExpired(token: string | null): boolean {
if (!token) return true
const payload = parseJwtPayload(token)
if (payload.exp === undefined || payload.exp === null) return false
return payload.exp < Math.floor(Date.now() / 1000)
}