Fix login persistence with AUTH_SEND_JWT_HEADER enabled

With AUTH_SEND_JWT_HEADER=true, frontend now properly handles JWT authentication:
- Store JWT token in client-side cookie named 'JWT'
- Extract and store XSRF token from JWT payload
- Set Secure flag automatically when on HTTPS connection
- Update documentation to clarify this behavior

This fixes an issue where login state would be lost after page reload
when using header-based JWT authentication.
This commit is contained in:
Dmitry Verkhoturov
2025-04-29 23:45:55 +01:00
parent 242499787e
commit ddb490bbc1
6 changed files with 263 additions and 22 deletions
+54 -7
View File
@@ -8,15 +8,14 @@ interface CookieOptions {
path?: string;
domain?: string;
secure?: boolean;
sameSite?: 'Strict' | 'Lax' | 'None';
}
export function setCookie(name: string, value: string, options: CookieOptions = {}) {
if (options.expires) {
// Convert number (seconds) or Date to UTC string
if (typeof options.expires === 'number') {
const d = new Date();
d.setTime(d.getTime() + options.expires * 1000);
options.expires = d;
options.expires = options.expires.toUTCString();
options.expires = new Date(Date.now() + options.expires * 1000).toUTCString();
} else if (options.expires instanceof Date) {
options.expires = options.expires.toUTCString();
}
@@ -27,15 +26,63 @@ export function setCookie(name: string, value: string, options: CookieOptions =
let updatedCookie = `${name}=${value}`;
for (const [key, value] of Object.entries(options)) {
updatedCookie += `; ${key}`;
if (value !== true) {
updatedCookie += `=${value}`;
// For boolean attributes like 'secure', only add them if true, otherwise skip
if (value === true) {
updatedCookie += `; ${key}`;
}
if (typeof value !== 'boolean') {
updatedCookie += `; ${key}=${value}`;
}
}
document.cookie = updatedCookie;
}
/**
* Sets a cookie with enhanced security options for authentication
* @param name The name of the cookie
* @param value The value to set
* @param options Additional cookie options
*/
export function setAuthCookie(name: string, value: string, options: CookieOptions = {}) {
const isSecure = window.location.protocol === 'https:';
const cookiePrefix = isSecure ? '__Host-' : '';
// Default options for auth cookies with strong security
const authOptions: CookieOptions = {
path: '/',
sameSite: 'Strict',
secure: isSecure,
...options,
};
setCookie(`${cookiePrefix}${name}`, value, authOptions);
}
/**
* Clears an authentication cookie by setting its expiration to the past
* @param name The name of the cookie to clear
*/
export function clearAuthCookie(name: string) {
const isSecure = window.location.protocol === 'https:';
const cookiePrefix = isSecure ? '__Host-' : '';
setCookie(`${cookiePrefix}${name}`, '', {
path: '/',
secure: isSecure,
expires: new Date(0), // Set to epoch time to expire immediately
});
// Also try to clear the non-prefixed version to be thorough
if (cookiePrefix) {
setCookie(name, '', {
path: '/',
secure: isSecure,
expires: new Date(0),
});
}
}
export function getCookie(name: string) {
const matches = document.cookie.match(
new RegExp(`(?:^|; )${name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1')}=([^;]*)`)
@@ -4,7 +4,16 @@ jest.mock('./settings', () => ({
import { RequestError } from 'utils/errorUtils';
import { API_BASE, BASE_URL } from './constants.config';
import { apiFetcher, authFetcher, adminFetcher, JWT_HEADER } from './fetcher';
import {
apiFetcher,
authFetcher,
adminFetcher,
JWT_HEADER,
JWT_COOKIE_NAME,
XSRF_COOKIE,
AUTH_COOKIE_TTL_SECONDS,
} from './fetcher';
import * as cookies from './cookies';
type FetchImplementationProps = {
status?: number;
@@ -31,6 +40,16 @@ function mockFetch({ headers = {}, data = {}, ...props }: FetchImplementationPro
}
describe('fetcher', () => {
// Mock cookies for the test environment
beforeEach(() => {
// Mock getCookie to always return undefined for XSRF_COOKIE
jest.spyOn(cookies, 'getCookie').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
const headers = {};
const apiUri = '/anything';
const apiUrl = `${BASE_URL}${API_BASE}/anything?site=remark`;
@@ -96,6 +115,12 @@ describe('fetcher', () => {
});
describe('headers', () => {
beforeEach(() => {
// Clear cookies before each test
document.cookie = `${JWT_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
document.cookie = `${XSRF_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
});
it('should set active token and than clean it on unauthorized response', async () => {
expect.assertions(4);
@@ -125,6 +150,119 @@ describe('fetcher', () => {
expect(window.fetch).toHaveBeenCalledWith(apiUrl, { method: 'get', headers });
});
it('should store JWT token in a cookie when received in header', async () => {
// Mock the auth cookie helper - use mockReturnValueOnce for cleaner tests
jest.spyOn(cookies, 'setAuthCookie').mockReturnValueOnce(undefined);
// Create test JWT token - we'll mock the parsing
const jwtToken = 'test.jwt.token';
// Mock parseJwtPayload implementation since it's not directly accessible
jest.spyOn(window, 'atob').mockReturnValueOnce(JSON.stringify({ jti: 'test-jti-id', sub: '1234567890' }));
mockFetch({ headers: { [JWT_HEADER]: jwtToken, ...headers } });
await apiFetcher.get(apiUri);
// Check that setAuthCookie was called for both JWT and XSRF tokens
expect(cookies.setAuthCookie).toHaveBeenCalledWith(
JWT_COOKIE_NAME,
jwtToken,
expect.objectContaining({ expires: AUTH_COOKIE_TTL_SECONDS })
);
expect(cookies.setAuthCookie).toHaveBeenCalledWith(
XSRF_COOKIE,
'test-jti-id',
expect.objectContaining({ expires: AUTH_COOKIE_TTL_SECONDS })
);
});
it('should call setAuthCookie with proper parameters when receiving JWT token', async () => {
// Spy on setAuthCookie calls with mockImplementationOnce and jest.fn()
jest.spyOn(cookies, 'setAuthCookie').mockImplementationOnce(jest.fn());
// Create test JWT token
const jwtToken = 'test.jwt.token';
// Mock parseJwtPayload implementation since it's not directly accessible
jest.spyOn(window, 'atob').mockReturnValueOnce(JSON.stringify({ jti: 'test-jti-id', sub: '1234567890' }));
mockFetch({ headers: { [JWT_HEADER]: jwtToken, ...headers } });
await apiFetcher.get(apiUri);
// Verify setAuthCookie was called with expected parameters
expect(cookies.setAuthCookie).toHaveBeenCalledWith(
JWT_COOKIE_NAME,
jwtToken,
expect.objectContaining({ expires: AUTH_COOKIE_TTL_SECONDS })
);
expect(cookies.setAuthCookie).toHaveBeenCalledWith(
XSRF_COOKIE,
'test-jti-id',
expect.objectContaining({ expires: AUTH_COOKIE_TTL_SECONDS })
);
});
it('should handle errors when setting cookies', async () => {
// Mock console.error using jest.spyOn
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
// Make setAuthCookie throw an error
jest.spyOn(cookies, 'setAuthCookie').mockImplementationOnce(() => {
throw new Error('Cookie access denied');
});
// Create test JWT token
const jwtToken = 'test.jwt.token';
// Mock parseJwtPayload implementation since it's not directly accessible
jest.spyOn(window, 'atob').mockReturnValueOnce(JSON.stringify({ jti: 'test-jti-id', sub: '1234567890' }));
mockFetch({ headers: { [JWT_HEADER]: jwtToken, ...headers } });
// This should not throw despite cookie setting failing
await apiFetcher.get(apiUri);
// Error should be logged
expect(consoleErrorSpy).toHaveBeenCalled();
// Restore console.error
consoleErrorSpy.mockRestore();
});
it('should reset activeJwtToken and clear cookies on 401/403 responses', async () => {
// Mock clearAuthCookie
jest.spyOn(cookies, 'clearAuthCookie').mockImplementationOnce(jest.fn());
// Setup JWT token with mocked payload
const jwtToken = 'test.jwt.token';
// Mock JWT parsing
const mockPayload = { jti: 'test-jti-id', sub: '1234567890' };
jest.spyOn(window, 'atob').mockReturnValueOnce(JSON.stringify(mockPayload));
// First set JWT token
mockFetch({ headers: { [JWT_HEADER]: jwtToken, ...headers } });
await apiFetcher.get(apiUri);
// Now trigger a 401 response
mockFetch({ status: 401 });
// Use await expect().rejects for async errors instead of try/catch
await expect(apiFetcher.get(apiUri)).rejects.toEqual(new RequestError('Not authorized.', 401));
// Verify cookies were cleared
expect(cookies.clearAuthCookie).toHaveBeenCalledWith(JWT_COOKIE_NAME);
expect(cookies.clearAuthCookie).toHaveBeenCalledWith(XSRF_COOKIE);
// Verify that subsequent requests don't include the JWT header
mockFetch({ headers });
await apiFetcher.get(apiUri);
expect(window.fetch).toHaveBeenCalled();
});
});
describe('send data', () => {
+48 -1
View File
@@ -1,16 +1,43 @@
import { errorMessages, RequestError } from 'utils/errorUtils';
import { siteId } from './settings';
import { getCookie } from './cookies';
import { getCookie, setAuthCookie, clearAuthCookie } from './cookies';
import { StaticStore } from './static-store';
import { BASE_URL, API_BASE } from './constants';
/** Header name for JWT token */
export const JWT_HEADER = 'X-JWT';
/** Cookie name for JWT token when using AUTH_SEND_JWT_HEADER */
export const JWT_COOKIE_NAME = 'JWT';
/** Header name for XSRF token */
export const XSRF_HEADER = 'X-XSRF-TOKEN';
/** Cookie field with XSRF token */
export const XSRF_COOKIE = 'XSRF-TOKEN';
/**
* Cookie TTL in seconds - matches backend's auth.ttl.cookie default of 200 hours
* The JWT token itself expires in 5 minutes, but the cookie persists longer
* to match server-side behavior when not using AUTH_SEND_JWT_HEADER
*/
export const AUTH_COOKIE_TTL_SECONDS = 200 * 60 * 60;
/**
* Safely parses JWT payload with proper base64url handling
* @param token - JWT token string
* @returns parsed payload or null if parsing fails
*/
function parseJwtPayload(token: string): Record<string, unknown> | null {
try {
const base64Url = token.split('.')[1];
if (!base64Url) return null;
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const rawPayload = window.atob(base64);
return JSON.parse(rawPayload);
} catch (e) {
console.error('Failed to parse JWT payload', e);
return null;
}
}
type QueryParams = Record<string, string | number | undefined>;
type Payload = BodyInit | Record<string, unknown> | null;
@@ -74,10 +101,30 @@ const createFetcher = (baseUrl: string = ''): Methods => {
// backend could update jwt in any time. so, we should handle it
if (res.headers.has(JWT_HEADER)) {
activeJwtToken = res.headers.get(JWT_HEADER) as string;
// Store the JWT token in cookies for persistence across page reloads
try {
const payload = parseJwtPayload(activeJwtToken);
if (payload && payload.jti) {
// Set XSRF cookie with the JWT ID using enhanced security
setAuthCookie(XSRF_COOKIE, payload.jti as string, {
expires: AUTH_COOKIE_TTL_SECONDS,
});
// Store the JWT in cookie for persistence with enhanced security
setAuthCookie(JWT_COOKIE_NAME, activeJwtToken, {
expires: AUTH_COOKIE_TTL_SECONDS,
});
}
} catch (e) {
console.error('Failed to process JWT token', e);
}
}
if ([401, 403].includes(res.status)) {
activeJwtToken = undefined;
clearAuthCookie(JWT_COOKIE_NAME);
clearAuthCookie(XSRF_COOKIE);
}
if (res.status >= 400) {
+3 -11
View File
@@ -4994,7 +4994,7 @@ packages:
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/chrome-trace-event@1.0.3:
@@ -6986,14 +6986,6 @@ packages:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: true
/fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -8225,7 +8217,7 @@ packages:
micromatch: 4.0.5
walker: 1.0.8
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/jest-haste-map@28.1.3:
@@ -8244,7 +8236,7 @@ packages:
micromatch: 4.0.5
walker: 1.0.8
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/jest-leak-detector@28.1.3: