Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions client/src/__test__/api-service/api.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
91 changes: 89 additions & 2 deletions client/src/__test__/context/AuthProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
<AuthProvider>
<ConsumerComponent />
</AuthProvider>
);

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);
});
});
25 changes: 21 additions & 4 deletions client/src/api-service/api.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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];
Expand All @@ -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();
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) => {
Expand Down
61 changes: 48 additions & 13 deletions client/src/context/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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))
Expand All @@ -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 => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ public interface JwtService {
boolean isTokenExpired(String token);

boolean validateTokenAndUser(String token, UserDetails user);

boolean validateTokenForRefresh(String token, UserDetails user);
}
Loading
Loading