fix(opds): send Basic auth preemptively for optional-auth servers (#4206)

OPDS servers that allow anonymous access (e.g. Calibre-Web) return 200
without a WWW-Authenticate challenge. `fetchWithAuth` only attached
credentials on a 401/403 retry, so a user who configured valid login
details kept seeing guest-only content (own shelves missing).

Send a Basic Authorization header on the first request whenever
credentials are available. Digest auth still falls through to the
challenge-driven retry since it can't be sent preemptively, and the
retry is skipped when it would just repeat the preemptive Basic header.

Fixes #4202

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-18 00:07:15 +08:00
committed by GitHub
parent c8fabd331c
commit 83607d14ea
2 changed files with 100 additions and 2 deletions
@@ -17,6 +17,22 @@ vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn(),
}));
type FakeResponseInit = {
status?: number;
body?: string;
wwwAuthenticate?: string;
};
const makeResponse = ({ status = 200, body = '', wwwAuthenticate }: FakeResponseInit = {}) => ({
ok: status >= 200 && status < 300,
status,
headers: {
get: (name: string) =>
name.toLowerCase() === 'www-authenticate' ? (wwwAuthenticate ?? null) : null,
},
text: async () => body,
});
describe('opdsReq', () => {
let needsProxy: typeof import('@/app/opds/utils/opdsReq').needsProxy;
let getProxiedURL: typeof import('@/app/opds/utils/opdsReq').getProxiedURL;
@@ -109,4 +125,75 @@ describe('opdsReq', () => {
expect(proxied).toContain('/node-api/opds/proxy');
});
});
describe('fetchWithAuth', () => {
let fetchWithAuth: typeof import('@/app/opds/utils/opdsReq').fetchWithAuth;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
const opdsReq = await import('@/app/opds/utils/opdsReq');
fetchWithAuth = opdsReq.fetchWithAuth;
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('sends Basic auth on the first request when credentials are provided', async () => {
// Servers that allow anonymous access return 200 without a challenge.
// The credentials must be sent preemptively or the user keeps seeing
// guest content (issue #4202).
fetchMock.mockResolvedValue(makeResponse({ status: 200, body: '<feed/>' }));
await fetchWithAuth('https://opds.example.com/feed', 'alice', 's3cret', false);
expect(fetchMock).toHaveBeenCalledTimes(1);
const init = fetchMock.mock.calls[0]![1] as RequestInit;
const headers = init.headers as Record<string, string>;
expect(headers['Authorization']).toBe(`Basic ${btoa('alice:s3cret')}`);
});
it('does not send an Authorization header when no credentials are provided', async () => {
fetchMock.mockResolvedValue(makeResponse({ status: 200, body: '<feed/>' }));
await fetchWithAuth('https://opds.example.com/feed', undefined, undefined, false);
expect(fetchMock).toHaveBeenCalledTimes(1);
const init = fetchMock.mock.calls[0]![1] as RequestInit;
const headers = init.headers as Record<string, string>;
expect(headers['Authorization']).toBeUndefined();
});
it('passes preemptive auth through the proxy URL when useProxy is true', async () => {
fetchMock.mockResolvedValue(makeResponse({ status: 200, body: '<feed/>' }));
await fetchWithAuth('https://opds.example.com/feed', 'alice', 's3cret', true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const proxyUrl = fetchMock.mock.calls[0]![0] as string;
const auth = new URL(proxyUrl, 'https://web.readest.com').searchParams.get('auth');
expect(auth).toBe(`Basic ${btoa('alice:s3cret')}`);
});
it('retries with Digest auth when the server issues a Digest challenge', async () => {
fetchMock
.mockResolvedValueOnce(
makeResponse({
status: 401,
wwwAuthenticate: 'Digest realm="opds", nonce="abc123", qop="auth"',
}),
)
.mockResolvedValueOnce(makeResponse({ status: 200, body: '<feed/>' }));
const res = await fetchWithAuth('https://opds.example.com/feed', 'alice', 's3cret', false);
expect(fetchMock).toHaveBeenCalledTimes(2);
const init = fetchMock.mock.calls[1]![1] as RequestInit;
const headers = init.headers as Record<string, string>;
expect(headers['Authorization']).toMatch(/^Digest /);
expect(res.status).toBe(200);
});
});
});
+13 -2
View File
@@ -331,14 +331,23 @@ export const fetchWithAuth = async (
const finalPassword = password || urlPassword;
const normalizedCustomHeaders = normalizeOPDSCustomHeaders(customHeaders);
// Send Basic auth preemptively when credentials are available. Servers that
// allow anonymous access (e.g. Calibre-Web) return 200 without a challenge,
// so the user keeps seeing guest content unless the auth header is included
// on the first request. Digest auth can't be sent preemptively (it needs the
// server's nonce), so Digest servers still fall through to the retry below.
const preemptiveAuth =
finalUsername && finalPassword ? createBasicAuth(finalUsername, finalPassword) : null;
const fetchURL = useProxy
? getProxiedURL(cleanUrl, '', false, normalizedCustomHeaders)
? getProxiedURL(cleanUrl, preemptiveAuth || '', false, normalizedCustomHeaders)
: cleanUrl;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml, */*',
...(!useProxy ? normalizedCustomHeaders : {}),
...(options.headers as Record<string, string>),
...(preemptiveAuth && !useProxy ? { Authorization: preemptiveAuth } : {}),
};
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
@@ -368,7 +377,9 @@ export const fetchWithAuth = async (
authHeader = createBasicAuth(finalUsername, finalPassword);
}
if (authHeader) {
// Skip the retry when it would just repeat the preemptive Basic header
// we already sent (the credentials are simply wrong in that case).
if (authHeader && authHeader !== preemptiveAuth) {
const finalUrl = useProxy
? getProxiedURL(cleanUrl, authHeader, false, normalizedCustomHeaders)
: fetchURL;