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(); }