Probe /auth/status from frontend to avoid 401 on /user (closes #1188) (#1763)

* Probe /auth/status from frontend to avoid 401 console noise on /user

GET /api/v1/user requires auth and returns 401 for anonymous visitors,
which the browser logs to console even when JS catches it. Probe
/auth/status first (always 200), then fetch /user only when logged in.
Stale auth cookies are cleared when status reports "not logged in" to
preserve the cleanup-on-probe behaviour previously triggered by /user 401.

Closes #1188.

* Don't clear auth cookies when /auth/status probe itself fails

A transient network/5xx on the /auth/status probe used to fall through
into the cookie-clear branch and silently log the user out on the next
page load. Distinguish "probe failed" (null) from explicit "not logged
in"; only the latter clears JWT/XSRF cookies. Lock the distinction with
a negative assertion in the probe-failure test.

Also align packages/api prettier config with apps/remark42 (trailingComma: 'es5')
so future edits don't sweep unrelated trailing commas into the diff.
This commit is contained in:
Dmitry Verkhoturov
2026-04-30 19:32:49 -05:00
committed by GitHub
parent 8d9290ea1f
commit a4c5e17bbb
5 changed files with 79 additions and 13 deletions
+39 -2
View File
@@ -1,5 +1,42 @@
import { getUserComments } from './api';
import { apiFetcher } from './fetcher';
import { getUser, getUserComments } from './api';
import { apiFetcher, authFetcher, JWT_COOKIE_NAME, XSRF_COOKIE } from './fetcher';
import * as cookies from './cookies';
describe('getUser', () => {
beforeEach(() => {
jest.restoreAllMocks();
});
it('should fetch /user when /auth/status reports logged in', async () => {
const user = { id: '1', name: 'user' };
jest.spyOn(authFetcher, 'get').mockResolvedValue({ status: 'logged in', user: 'user' });
const apiSpy = jest.spyOn(apiFetcher, 'get').mockResolvedValue(user);
await expect(getUser()).resolves.toEqual(user);
expect(apiSpy).toHaveBeenCalledWith('/user');
});
it('should return null and clear auth cookies when /auth/status reports not logged in', async () => {
jest.spyOn(authFetcher, 'get').mockResolvedValue({ status: 'not logged in' });
const apiSpy = jest.spyOn(apiFetcher, 'get');
const clearSpy = jest.spyOn(cookies, 'clearAuthCookie').mockImplementation(() => {});
await expect(getUser()).resolves.toBeNull();
expect(apiSpy).not.toHaveBeenCalled();
expect(clearSpy).toHaveBeenCalledWith(JWT_COOKIE_NAME);
expect(clearSpy).toHaveBeenCalledWith(XSRF_COOKIE);
});
it('should return null without clearing cookies when /auth/status request fails', async () => {
jest.spyOn(authFetcher, 'get').mockRejectedValue(new Error('boom'));
const apiSpy = jest.spyOn(apiFetcher, 'get');
const clearSpy = jest.spyOn(cookies, 'clearAuthCookie').mockImplementation(() => {});
await expect(getUser()).resolves.toBeNull();
expect(apiSpy).not.toHaveBeenCalled();
expect(clearSpy).not.toHaveBeenCalled();
});
});
describe('getUserComments', () => {
it('should call apiFetcher.get with /comments endpoint and default skip and limit query params', () => {
+15 -2
View File
@@ -11,7 +11,8 @@ import {
Image,
EmailSubVerificationStatus,
} from './types';
import { apiFetcher, adminFetcher } from './fetcher';
import { apiFetcher, adminFetcher, authFetcher, JWT_COOKIE_NAME, XSRF_COOKIE } from './fetcher';
import { clearAuthCookie } from './cookies';
/* API methods */
@@ -60,7 +61,19 @@ export const removeMyComment = (id: Comment['id']): Promise<void> =>
export const getPreview = (text: string): Promise<string> => apiFetcher.post('/preview', {}, { text });
export function getUser(): Promise<User | null> {
export async function getUser(): Promise<User | null> {
// probe auth state via /auth/status (always 200, no console 401)
const status = await authFetcher.get<{ status: string }>('/status').catch(() => null);
if (status === null) {
// probe failed (network blip, transient 5xx) - leave cookies alone, treat as unknown
return null;
}
if (status.status !== 'logged in') {
// explicit "not logged in" - clear stale cookies, preserving cleanup-on-probe behaviour previously triggered by /user 401
clearAuthCookie(JWT_COOKIE_NAME);
clearAuthCookie(XSRF_COOKIE);
return null;
}
return apiFetcher.get<User | null>('/user').catch(() => null);
}
+2 -1
View File
@@ -2,5 +2,6 @@
"semi": false,
"printWidth": 100,
"quoteProps": "consistent",
"singleQuote": true
"singleQuote": true,
"trailingComma": "es5"
}
+8 -2
View File
@@ -83,6 +83,7 @@ export type Vote = -1 | 1
export function createPublicClient({ siteId: site, baseUrl }: ClientParams) {
const fetcher = createFetcher(site, `${baseUrl}${API_BASE}`)
const authFetcher = createFetcher(site, `${baseUrl}/auth`)
/**
* Get server config
@@ -92,9 +93,14 @@ export function createPublicClient({ siteId: site, baseUrl }: ClientParams) {
}
/**
* Get current authorized user
* Get current authorized user.
* Probes /auth/status first (always 200, no console 401), then fetches /user only if logged in.
*/
async function getUser(): Promise<User | null> {
const status = await authFetcher.get<{ status: string }>('/status').catch(() => null)
if (status?.status !== 'logged in') {
return null
}
return fetcher.get<User | null>('/user').catch(() => null)
}
@@ -104,7 +110,7 @@ export function createPublicClient({ siteId: site, baseUrl }: ClientParams) {
async function getComments(url: string): Promise<CommentsTree>
async function getComments(params: GetUserCommentsParams): Promise<Comment[]>
async function getComments(
params: string | GetUserCommentsParams,
params: string | GetUserCommentsParams
): Promise<Comment[] | CommentsTree> {
if (typeof params === 'string') {
return fetcher.get('/comments', { url: params })
@@ -97,11 +97,20 @@ describe<Context>('Public Client', (publicClient) => {
})
})
const userCases = [null, { id: '1', username: 'user' }]
userCases.forEach((user) => {
publicClient('should return user', async ({ client }) => {
mockEndpoint('/remark42/api/v1/user', { body: user })
await expect(client.getUser()).resolves.toEqual(user)
})
publicClient('getUser: should return user when logged in', async ({ client }) => {
const user = { id: '1', username: 'user' }
mockEndpoint('/remark42/auth/status', { body: { status: 'logged in', user: 'user' } })
mockEndpoint('/remark42/api/v1/user', { body: user })
await expect(client.getUser()).resolves.toEqual(user)
})
publicClient('getUser: should return null when not logged in', async ({ client }) => {
mockEndpoint('/remark42/auth/status', { body: { status: 'not logged in' } })
await expect(client.getUser()).resolves.toBeNull()
})
publicClient('getUser: should return null when status probe fails', async ({ client }) => {
mockEndpoint('/remark42/auth/status', { status: 500, body: 'boom' })
await expect(client.getUser()).resolves.toBeNull()
})
})