diff --git a/client/src/__test__/api-service/api.test.ts b/client/src/__test__/api-service/api.test.ts
new file mode 100644
index 00000000..8c3072e3
--- /dev/null
+++ b/client/src/__test__/api-service/api.test.ts
@@ -0,0 +1,59 @@
+// api.test.ts
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import api, { setUnauthorizedHandler } from '../../api-service/api';
+
+function jsonErrorResponse(status: number): Response {
+ return {
+ ok: false,
+ status,
+ headers: new Headers({ 'content-type': 'application/json' }),
+ json: async () => ({ message: 'Unauthorized' })
+ } as unknown as Response;
+}
+
+describe('api unauthorized handling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setUnauthorizedHandler(undefined);
+ });
+
+ it('invokes the unauthorized handler on 401 for authenticated requests', async () => {
+ const handler = vi.fn();
+ setUnauthorizedHandler(handler);
+ vi.mocked(global.fetch).mockResolvedValue(jsonErrorResponse(401));
+
+ await expect(api.getJSON('/api/rest/home')).rejects.toThrow('Unauthorized');
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it('invokes the unauthorized handler on 403 for authenticated requests', async () => {
+ const handler = vi.fn();
+ setUnauthorizedHandler(handler);
+ vi.mocked(global.fetch).mockResolvedValue(jsonErrorResponse(403));
+
+ await expect(api.getJSON('/api/rest/home')).rejects.toThrow('Unauthorized');
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not invoke the handler for unauthenticated requests', async () => {
+ const handler = vi.fn();
+ setUnauthorizedHandler(handler);
+ vi.mocked(global.fetch).mockResolvedValue(jsonErrorResponse(401));
+
+ await expect(api.getJSONNoAuth('/api/public/notes/abc')).rejects.toThrow('Unauthorized');
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it('does not invoke the handler for sign-in failures', async () => {
+ const handler = vi.fn();
+ setUnauthorizedHandler(handler);
+ vi.mocked(global.fetch).mockResolvedValue(jsonErrorResponse(401));
+
+ await expect(api.postJSON('/api/auth/sign-in', {})).rejects.toThrow('Unauthorized');
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/__test__/context/AuthProvider.test.tsx b/client/src/__test__/context/AuthProvider.test.tsx
index 8c082d93..a6db2dc3 100644
--- a/client/src/__test__/context/AuthProvider.test.tsx
+++ b/client/src/__test__/context/AuthProvider.test.tsx
@@ -5,8 +5,8 @@ import { render, waitFor, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AuthProvider from '../../context/AuthProvider';
import AuthContext, { AuthContextData } from '../../context/AuthContext';
-import api from '../../api-service/api';
-import { API_TOKEN, USER_DATA } from '../../app-constants/app-constants';
+import api, { setUnauthorizedHandler } from '../../api-service/api';
+import { API_TOKEN, REDIRECT_PATH, USER_DATA } from '../../app-constants/app-constants';
import ApiConfig from '../../api-service/apiConfig';
// Mock the API service methods.
@@ -351,4 +351,91 @@ describe('AuthProvider', () => {
expect(localStorage.getItem(USER_DATA)).toContain('Refreshed User');
});
});
+
+ const setupSignedInSession = async () => {
+ const fakeTokenResponse = { token: 'refresh-token' };
+ const fakeCurrentUser = {
+ userId: '789',
+ name: 'Refreshed User',
+ email: 'refreshed@example.com',
+ admin: false,
+ createdAt: new Date().toISOString(),
+ gravatarImageUrl: 'http://dummyimage.com',
+ lang: 'en',
+ lastLogin: new Date().toISOString()
+ };
+
+ vi.spyOn(api, 'getJSON').mockImplementation(async (url: string) => {
+ if (url === ApiConfig.refreshTokenUrl) {
+ return fakeTokenResponse;
+ }
+ if (url === ApiConfig.currentUserUrl) {
+ return fakeCurrentUser;
+ }
+ return undefined;
+ });
+ localStorage.setItem(API_TOKEN, 'dummy');
+
+ const renderResult = render(
+
+
+
+ );
+
+ await waitFor(() =>
+ expect(renderResult.getByTestId('signed').textContent).toBe('true')
+ );
+ return renderResult;
+ };
+
+ const refreshTokenCalls = () =>
+ vi.mocked(api.getJSON).mock.calls.filter(call => call[0] === ApiConfig.refreshTokenUrl);
+
+ it('should sign out and preserve the redirect path when a request is rejected as unauthorized', async () => {
+ const { getByTestId } = await setupSignedInSession();
+
+ const registered = vi.mocked(setUnauthorizedHandler).mock.calls.at(-1)?.[0];
+ expect(registered).toBeTypeOf('function');
+
+ act(() => {
+ registered!();
+ });
+
+ await waitFor(() =>
+ expect(getByTestId('signed').textContent).toBe('false')
+ );
+ expect(getByTestId('user').textContent).toBe('none');
+ expect(localStorage.getItem(API_TOKEN)).toBeNull();
+ expect(localStorage.getItem(REDIRECT_PATH)).toBe(window.location.pathname);
+ });
+
+ it('should refresh the session when the window regains focus', async () => {
+ await setupSignedInSession();
+ const callsBefore = refreshTokenCalls().length;
+
+ act(() => {
+ window.dispatchEvent(new Event('focus'));
+ });
+
+ await waitFor(() =>
+ expect(refreshTokenCalls().length).toBeGreaterThan(callsBefore)
+ );
+ expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token');
+ });
+
+ it('should sign out when the session refresh fails after wake', async () => {
+ const { getByTestId } = await setupSignedInSession();
+
+ vi.mocked(api.getJSON).mockRejectedValue(new Error('Unauthorized'));
+
+ act(() => {
+ window.dispatchEvent(new Event('focus'));
+ });
+
+ await waitFor(() =>
+ expect(getByTestId('signed').textContent).toBe('false')
+ );
+ expect(localStorage.getItem(API_TOKEN)).toBeNull();
+ expect(localStorage.getItem(REDIRECT_PATH)).toBe(window.location.pathname);
+ });
});
diff --git a/client/src/api-service/api.ts b/client/src/api-service/api.ts
index f40f196f..15bfa7f2 100644
--- a/client/src/api-service/api.ts
+++ b/client/src/api-service/api.ts
@@ -1,5 +1,19 @@
import { API_TOKEN } from '../app-constants/app-constants';
+type UnauthorizedHandler = () => void;
+
+let unauthorizedHandler: UnauthorizedHandler | undefined;
+
+/**
+ * Registers a callback invoked when an authenticated request is rejected with 401 or 403.
+ * Pass `undefined` to unregister.
+ *
+ * @param {UnauthorizedHandler | undefined} handler The callback to invoke on auth failures.
+ */
+export function setUnauthorizedHandler(handler: UnauthorizedHandler | undefined): void {
+ unauthorizedHandler = handler;
+}
+
/**
* Retrieves the API token from local storage.
*
@@ -50,7 +64,7 @@ function handleError(httpStatusCode: number) {
throw new Error('Unknown error');
}
-async function handleResponse(response: Response) {
+async function handleResponse(response: Response, authenticated: boolean = true) {
// Successful responses
if (response?.ok) {
const codesToIgnore: number[] = [204];
@@ -62,6 +76,9 @@ async function handleResponse(response: Response) {
// Error responses
if (response) {
+ if (authenticated && (response.status === 401 || response.status === 403)) {
+ unauthorizedHandler?.();
+ }
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
@@ -89,12 +106,12 @@ const api = {
getJSONNoAuth: async (url: string) => {
const response = await fetch(url, getRequestInit('GET', {}, false));
- return handleResponse(response);
+ return handleResponse(response, false);
},
postJSON: async (url: string, payload: object) => {
const response = await fetch(url, getRequestInit('POST', payload, isAddAuth(url)));
- return handleResponse(response);
+ return handleResponse(response, isAddAuth(url));
},
patchJSON: async (url: string, payload: object) => {
@@ -104,7 +121,7 @@ const api = {
putJSON: async (url: string, payload: object) => {
const response = await fetch(url, getRequestInit('PUT', payload, isAddAuth(url)));
- return handleResponse(response);
+ return handleResponse(response, isAddAuth(url));
},
deleteNoContent: async (url: string) => {
diff --git a/client/src/context/AuthProvider.tsx b/client/src/context/AuthProvider.tsx
index c0e1d9b1..f8de8e32 100644
--- a/client/src/context/AuthProvider.tsx
+++ b/client/src/context/AuthProvider.tsx
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import AuthContext, { AuthContextData } from './AuthContext';
import { API_TOKEN, REDIRECT_PATH, USER_DATA } from '../app-constants/app-constants';
import { SignInResponse } from '../types/SigninResponse';
-import api from '../api-service/api';
+import api, { setUnauthorizedHandler } from '../api-service/api';
import ApiConfig from '../api-service/apiConfig';
import { UserResponse } from '../types/UserResponse';
import { UserRegistration } from '../types/UserRegistration';
@@ -36,11 +36,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
else if (e) {
console.warn(e);
}
- // Clear stored client id and name
- localStorage.clear();
- localStorage.setItem(REDIRECT_PATH, pathname);
- setUser(undefined);
- setSigned(false);
+ handleSessionExpired(pathname);
}
return undefined;
};
@@ -115,6 +111,18 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
clearHomeCache();
};
+ const handleSessionExpired = (pathname: string): void => {
+ signOut();
+ if (pathname) {
+ localStorage.setItem(REDIRECT_PATH, pathname);
+ }
+ };
+
+ useEffect(() => {
+ setUnauthorizedHandler(() => handleSessionExpired(window.location.pathname));
+ return () => setUnauthorizedHandler(undefined);
+ }, []);
+
useEffect(() => {
checkCurrentAuthUser(window.location.pathname)
.catch(e => console.error(e))
@@ -123,16 +131,43 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
useEffect(() => {
if (!signed) return;
+
const TWENTY_FIVE_MINUTES = 25 * 60 * 1000;
- const intervalId = setInterval(() => {
+ const WAKE_REFRESH_THROTTLE = 60 * 1000;
+ let lastWakeRefreshAt = 0;
+
+ const refreshSession = () => {
checkCurrentAuthUser(window.location.pathname).catch(() => {
- setSigned(false);
- setUser(undefined);
- localStorage.clear();
- clearHomeCache();
+ handleSessionExpired(window.location.pathname);
});
- }, TWENTY_FIVE_MINUTES);
- return () => clearInterval(intervalId);
+ };
+
+ // Timers do not fire while the machine sleeps or the tab is frozen, so
+ // revalidate the session as soon as the user comes back to the tab.
+ const refreshOnWake = () => {
+ const now = Date.now();
+ if (now - lastWakeRefreshAt < WAKE_REFRESH_THROTTLE) return;
+ lastWakeRefreshAt = now;
+ refreshSession();
+ };
+
+ const onVisibilityChange = () => {
+ if (document.visibilityState === 'visible') {
+ refreshOnWake();
+ }
+ };
+
+ const intervalId = setInterval(refreshSession, TWENTY_FIVE_MINUTES);
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ window.addEventListener('focus', refreshOnWake);
+ window.addEventListener('online', refreshOnWake);
+
+ return () => {
+ clearInterval(intervalId);
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ window.removeEventListener('focus', refreshOnWake);
+ window.removeEventListener('online', refreshOnWake);
+ };
}, [signed]);
const updateUser = (userUpdated: UserResponse): void => {
diff --git a/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java b/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java
index 38931535..a1cf68fb 100644
--- a/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java
+++ b/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java
@@ -21,6 +21,8 @@
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
+ private static final String REFRESH_PATH = "/rest/user-sessions/refresh";
+
private final UserService userService;
private final JwtService jwtService;
@@ -67,7 +69,15 @@ protected void doFilterInternal(
UserDetails user = userService.userDetailsService().loadUserByUsername(email);
- if (!jwtService.validateTokenAndUser(jwtToken, user)) {
+ boolean valid = jwtService.validateTokenAndUser(jwtToken, user);
+
+ // Allow a recently expired token to be exchanged only on the refresh endpoint, so a
+ // waking or previously frozen tab can resume its session without a full re-login.
+ if (!valid && REFRESH_PATH.equals(requestPath)) {
+ valid = jwtService.validateTokenForRefresh(jwtToken, user);
+ }
+
+ if (!valid) {
throw new ServletException("Invalid token for user");
}
diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/JwtService.java b/server/src/main/java/br/com/tasknoteapp/server/service/JwtService.java
index 0a5dc506..c54124c6 100644
--- a/server/src/main/java/br/com/tasknoteapp/server/service/JwtService.java
+++ b/server/src/main/java/br/com/tasknoteapp/server/service/JwtService.java
@@ -19,4 +19,6 @@ public interface JwtService {
boolean isTokenExpired(String token);
boolean validateTokenAndUser(String token, UserDetails user);
+
+ boolean validateTokenForRefresh(String token, UserDetails user);
}
diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/impl/JwtServiceImpl.java b/server/src/main/java/br/com/tasknoteapp/server/service/impl/JwtServiceImpl.java
index 0237c9a3..935d9b5b 100644
--- a/server/src/main/java/br/com/tasknoteapp/server/service/impl/JwtServiceImpl.java
+++ b/server/src/main/java/br/com/tasknoteapp/server/service/impl/JwtServiceImpl.java
@@ -3,10 +3,12 @@
import br.com.tasknoteapp.server.entity.User;
import br.com.tasknoteapp.server.service.JwtService;
import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Date;
@@ -28,6 +30,10 @@ class JwtServiceImpl implements JwtService {
private static final long SECOND = 1000;
private static final long MINUTE = SECOND * 60;
private static final long EXPIRATION_TIME = MINUTE * 30;
+
+ // How long after expiration a token may still be exchanged for a new one on the refresh
+ // endpoint, so sleep/wake or frozen tabs do not force a full re-login.
+ private static final Duration REFRESH_GRACE_PERIOD = Duration.ofHours(12);
private final SecretKey key;
public JwtServiceImpl(@Value("${br.com.tasknote.server.jwt-secret}") String secretKey) {
@@ -98,8 +104,26 @@ public boolean isTokenExpired(String token) {
public boolean validateTokenAndUser(String token, UserDetails user) {
final String email = user.getUsername();
boolean basicValid = !isTokenExpired(token) && email.equals(getEmailFromToken(token));
+ return basicValid && isIssuedAfterLastPasswordChange(token, user);
+ }
+
+ @Override
+ public boolean validateTokenForRefresh(String token, UserDetails user) {
+ final String email = user.getUsername();
+ if (!email.equals(getEmailFromToken(token))) {
+ return false;
+ }
- if (basicValid && user instanceof User userEntity) {
+ LocalDateTime expiration = extractExpiration(token);
+ boolean withinGracePeriod =
+ expiration != null
+ && expiration.isAfter(LocalDateTime.now().minus(REFRESH_GRACE_PERIOD));
+
+ return withinGracePeriod && isIssuedAfterLastPasswordChange(token, user);
+ }
+
+ private boolean isIssuedAfterLastPasswordChange(String token, UserDetails user) {
+ if (user instanceof User userEntity) {
LocalDateTime iat = extractIssuedAt(token);
if (iat != null && userEntity.getLastPasswordChange() != null) {
// Token must be issued after or at the same time as last password change
@@ -107,8 +131,7 @@ public boolean validateTokenAndUser(String token, UserDetails user) {
return !iat.isBefore(userEntity.getLastPasswordChange());
}
}
-
- return basicValid;
+ return true;
}
private T extractClaim(String token, Function claimsResolver) {
@@ -120,6 +143,9 @@ private Optional extractAllClaims(String token) {
try {
return Optional.of(
Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload());
+ } catch (ExpiredJwtException e) {
+ // Signature is still valid: expose the claims so callers can decide about grace periods.
+ return Optional.of(e.getClaims());
} catch (JwtException e) {
return Optional.empty();
}
diff --git a/server/src/test/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilterTest.java b/server/src/test/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilterTest.java
new file mode 100644
index 00000000..8e02a9e9
--- /dev/null
+++ b/server/src/test/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilterTest.java
@@ -0,0 +1,112 @@
+package br.com.tasknoteapp.server.filter;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import br.com.tasknoteapp.server.service.JwtService;
+import br.com.tasknoteapp.server.service.UserService;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+
+@ExtendWith(MockitoExtension.class)
+class JwtAuthenticationFilterTest {
+
+ private static final String REFRESH_PATH = "/rest/user-sessions/refresh";
+ private static final String TOKEN = "token";
+ private static final String EMAIL = "user@example.com";
+
+ private JwtAuthenticationFilter filter;
+
+ @Mock private UserService userService;
+ @Mock private JwtService jwtService;
+ @Mock private HttpServletRequest request;
+ @Mock private HttpServletResponse response;
+ @Mock private FilterChain filterChain;
+ @Mock private UserDetailsService userDetailsService;
+ @Mock private UserDetails userDetails;
+
+ @BeforeEach
+ void setUp() {
+ filter = new JwtAuthenticationFilter(userService, jwtService);
+ SecurityContextHolder.clearContext();
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ private void stubTokenRequest(String path) {
+ when(request.getHeader("Authorization")).thenReturn("Bearer " + TOKEN);
+ when(request.getServletPath()).thenReturn(path);
+ when(jwtService.getEmailFromToken(TOKEN)).thenReturn(EMAIL);
+ when(userService.userDetailsService()).thenReturn(userDetailsService);
+ when(userDetailsService.loadUserByUsername(EMAIL)).thenReturn(userDetails);
+ lenient().when(userDetails.getUsername()).thenReturn(EMAIL);
+ lenient().when(userDetails.getPassword()).thenReturn("password");
+ lenient().when(userDetails.getAuthorities()).thenReturn(List.of());
+ }
+
+ @Test
+ void doFilterInternal_validToken_authenticates() throws ServletException, IOException {
+ stubTokenRequest("/rest/home");
+ when(jwtService.validateTokenAndUser(TOKEN, userDetails)).thenReturn(true);
+
+ filter.doFilterInternal(request, response, filterChain);
+
+ verify(filterChain).doFilter(request, response);
+ verify(jwtService, never()).validateTokenForRefresh(any(), any());
+ assertNotNull(SecurityContextHolder.getContext().getAuthentication());
+ }
+
+ @Test
+ void doFilterInternal_expiredTokenOnRefreshPathWithinGrace_authenticates()
+ throws ServletException, IOException {
+ stubTokenRequest(REFRESH_PATH);
+ when(jwtService.validateTokenAndUser(TOKEN, userDetails)).thenReturn(false);
+ when(jwtService.validateTokenForRefresh(TOKEN, userDetails)).thenReturn(true);
+
+ filter.doFilterInternal(request, response, filterChain);
+
+ verify(filterChain).doFilter(request, response);
+ assertNotNull(SecurityContextHolder.getContext().getAuthentication());
+ }
+
+ @Test
+ void doFilterInternal_expiredTokenOnOtherPath_rejected() {
+ stubTokenRequest("/rest/home");
+ when(jwtService.validateTokenAndUser(TOKEN, userDetails)).thenReturn(false);
+
+ assertThrows(
+ ServletException.class, () -> filter.doFilterInternal(request, response, filterChain));
+ verify(jwtService, never()).validateTokenForRefresh(any(), any());
+ }
+
+ @Test
+ void doFilterInternal_expiredTokenBeyondGraceOnRefreshPath_rejected() {
+ stubTokenRequest(REFRESH_PATH);
+ when(jwtService.validateTokenAndUser(TOKEN, userDetails)).thenReturn(false);
+ when(jwtService.validateTokenForRefresh(TOKEN, userDetails)).thenReturn(false);
+
+ assertThrows(
+ ServletException.class, () -> filter.doFilterInternal(request, response, filterChain));
+ }
+}
diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/impl/JwtServiceImplTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/impl/JwtServiceImplTest.java
index 70427fd3..4c318366 100644
--- a/server/src/test/java/br/com/tasknoteapp/server/service/impl/JwtServiceImplTest.java
+++ b/server/src/test/java/br/com/tasknoteapp/server/service/impl/JwtServiceImplTest.java
@@ -188,6 +188,99 @@ void validateTokenAndUser_shouldReturnFalseIfTokenIssuedBeforeLastPasswordChange
assertFalse(valid, "Token issued before password change should be invalid");
}
+ @Test
+ void validateTokenForRefresh_shouldReturnTrueForValidToken() {
+ User user = new User();
+ user.setId(testUserId);
+ user.setEmail(testEmail);
+ user.setAdmin(false);
+ user.setName(testName);
+ String token = jwtService.generateToken(user);
+
+ when(userDetails.getUsername()).thenReturn(testEmail);
+ boolean valid = jwtService.validateTokenForRefresh(token, userDetails);
+
+ assertTrue(valid);
+ }
+
+ @Test
+ void validateTokenForRefresh_shouldReturnTrueForRecentlyExpiredToken() {
+ long now = System.currentTimeMillis();
+ String expiredToken = buildToken(testEmail, now - 3_600_000, now - 1_800_000);
+
+ when(userDetails.getUsername()).thenReturn(testEmail);
+ boolean valid = jwtService.validateTokenForRefresh(expiredToken, userDetails);
+
+ assertTrue(valid, "Token expired 30 minutes ago should be refreshable within grace period");
+ }
+
+ @Test
+ void validateTokenForRefresh_shouldReturnFalseForTokenExpiredBeyondGracePeriod() {
+ long now = System.currentTimeMillis();
+ long thirteenHours = 13L * 60 * 60 * 1000;
+ String expiredToken =
+ buildToken(testEmail, now - thirteenHours - 1_800_000, now - thirteenHours);
+
+ when(userDetails.getUsername()).thenReturn(testEmail);
+ boolean valid = jwtService.validateTokenForRefresh(expiredToken, userDetails);
+
+ assertFalse(valid, "Token expired beyond the grace period should not be refreshable");
+ }
+
+ @Test
+ void validateTokenForRefresh_shouldReturnFalseForDifferentUser() {
+ long now = System.currentTimeMillis();
+ String expiredToken = buildToken(testEmail, now - 3_600_000, now - 1_800_000);
+
+ UserDetails differentUser = mock(UserDetails.class);
+ when(differentUser.getUsername()).thenReturn("different@example.com");
+
+ boolean valid = jwtService.validateTokenForRefresh(expiredToken, differentUser);
+
+ assertFalse(valid);
+ }
+
+ @Test
+ void validateTokenForRefresh_shouldReturnFalseIfTokenIssuedBeforeLastPasswordChange() {
+ long now = System.currentTimeMillis();
+ String expiredToken = buildToken(testEmail, now - 3_600_000, now - 1_800_000);
+
+ User user = new User();
+ user.setId(testUserId);
+ user.setEmail(testEmail);
+ user.setAdmin(false);
+ user.setName(testName);
+ user.setLastPasswordChange(LocalDateTime.now().minusMinutes(10));
+
+ boolean valid = jwtService.validateTokenForRefresh(expiredToken, user);
+
+ assertFalse(valid, "Token issued before the last password change should not be refreshable");
+ }
+
+ @Test
+ void validateTokenForRefresh_shouldReturnFalseForTamperedToken() {
+ String tamperedToken =
+ buildToken(testEmail, System.currentTimeMillis() - 3_600_000,
+ System.currentTimeMillis() - 1_800_000)
+ + "tampered";
+
+ when(userDetails.getUsername()).thenReturn(testEmail);
+ boolean valid = jwtService.validateTokenForRefresh(tamperedToken, userDetails);
+
+ assertFalse(valid);
+ }
+
+ private String buildToken(String subject, long issuedAtMillis, long expirationMillis) {
+ return Jwts.builder()
+ .issuer("Java-API")
+ .subject(subject)
+ .issuedAt(new Date(issuedAtMillis))
+ .expiration(new Date(expirationMillis))
+ .claims(Map.of())
+ .signWith(getKey())
+ .compact();
+ }
+
private Claims extractClaims(String token) {
return Jwts.parser().verifyWith(getKey()).build().parseSignedClaims(token).getPayload();
}