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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user