From 789a2e50eff183ff9e892c0180d7b0cc6324a997 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Mon, 9 Mar 2026 10:33:52 -0400 Subject: [PATCH] Add sourceHeaders and conditional OIDC groups Add support for passing sourceHeaders when fetching NZB/torrent files: extend AddDownloadOptions and SABnzbd AddNZBOptions, forward headers in sabnzbd and nzbget clients, and populate sourceHeaders in download-torrent.processor (injecting Prowlarr API key as X-Api-Key for proxy URLs). Make OIDC request scope conditional: only include the 'groups' scope when group-based access control or admin-claim is enabled (update provider logic, add tests, and update setup UI text). Also remove explicit take:100 in Plex processors and add CLAUDE guidance about requesting approval before implementing code changes. --- CLAUDE.md | 8 +++ src/app/setup/steps/OIDCConfigStep.tsx | 2 +- src/lib/integrations/nzbget.service.ts | 1 + src/lib/integrations/sabnzbd.service.ts | 4 ++ .../interfaces/download-client.interface.ts | 2 + .../processors/download-torrent.processor.ts | 9 +++ .../plex-recently-added.processor.ts | 2 +- src/lib/processors/scan-plex.processor.ts | 2 +- src/lib/services/auth/OIDCAuthProvider.ts | 8 ++- .../services/auth/oidc-auth-provider.test.ts | 58 +++++++++++++++++++ 10 files changed, 92 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef61d3f..803edd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,14 @@ **ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested. +**NEVER implement without approval.** When asked to assess, investigate, or fix a problem: +1. **Research & analyze** — Read code, trace the issue, identify root cause. +2. **Present a solution plan** — Explain the root cause, list the specific files and changes needed, and describe the approach clearly. +3. **Wait for explicit approval** — Do NOT write any code until the user confirms the plan. +4. Only after approval: implement, build, and report results. + +This applies to bug fixes, feature requests, and any code changes. Investigation and analysis are always fine — writing code is not until approved. + --- ## 1. Token-Efficient Documentation System diff --git a/src/app/setup/steps/OIDCConfigStep.tsx b/src/app/setup/steps/OIDCConfigStep.tsx index d11b597..4e30ab1 100644 --- a/src/app/setup/steps/OIDCConfigStep.tsx +++ b/src/app/setup/steps/OIDCConfigStep.tsx @@ -272,7 +272,7 @@ export function OIDCConfigStep({ diff --git a/src/lib/integrations/nzbget.service.ts b/src/lib/integrations/nzbget.service.ts index 8a2161e..2df12b0 100644 --- a/src/lib/integrations/nzbget.service.ts +++ b/src/lib/integrations/nzbget.service.ts @@ -226,6 +226,7 @@ export class NZBGetService implements IDownloadClient { responseType: 'arraybuffer', timeout: 30000, maxRedirects: 5, + headers: options?.sourceHeaders, httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined, }); diff --git a/src/lib/integrations/sabnzbd.service.ts b/src/lib/integrations/sabnzbd.service.ts index 784069c..a7de95d 100644 --- a/src/lib/integrations/sabnzbd.service.ts +++ b/src/lib/integrations/sabnzbd.service.ts @@ -24,6 +24,8 @@ export interface AddNZBOptions { category?: string; priority?: 'low' | 'normal' | 'high' | 'force'; paused?: boolean; + /** Headers to include when fetching the NZB from the source URL */ + sourceHeaders?: Record; } export interface NZBInfo { @@ -492,6 +494,7 @@ export class SABnzbdService implements IDownloadClient { responseType: 'arraybuffer', timeout: 30000, maxRedirects: 5, + headers: options?.sourceHeaders, // Use the same SSL settings as the SABnzbd client if the NZB URL // happens to be served over HTTPS with a self-signed cert httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined, @@ -787,6 +790,7 @@ export class SABnzbdService implements IDownloadClient { category: options?.category, priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined, paused: options?.paused, + sourceHeaders: options?.sourceHeaders, }); } diff --git a/src/lib/interfaces/download-client.interface.ts b/src/lib/interfaces/download-client.interface.ts index 60d1900..3fd347e 100644 --- a/src/lib/interfaces/download-client.interface.ts +++ b/src/lib/interfaces/download-client.interface.ts @@ -102,6 +102,8 @@ export interface AddDownloadOptions { priority?: string; /** Whether to add in paused state */ paused?: boolean; + /** Headers to include when fetching the source file (e.g. Prowlarr API key for proxy URLs) */ + sourceHeaders?: Record; } /** Result of a connection test */ diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts index e537d2e..83e9b09 100644 --- a/src/lib/processors/download-torrent.processor.ts +++ b/src/lib/processors/download-torrent.processor.ts @@ -58,10 +58,19 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P logger.info(`Routing to ${client.clientType} (${client.protocol})`); + // Include Prowlarr API key as source header so NZB/torrent downloads from + // Prowlarr proxy URLs are authenticated (fixes 403 for indexers like NZBFinder) + const prowlarrApiKey = (await config.getMany(['prowlarr_api_key'])).prowlarr_api_key || process.env.PROWLARR_API_KEY; + const sourceHeaders: Record = {}; + if (prowlarrApiKey) { + sourceHeaders['X-Api-Key'] = prowlarrApiKey; + } + // Add download via unified interface const downloadClientId = await client.addDownload(torrent.downloadUrl, { category, priority: 'normal', + sourceHeaders, }); logger.info(`Download added with ID: ${downloadClientId}`); diff --git a/src/lib/processors/plex-recently-added.processor.ts b/src/lib/processors/plex-recently-added.processor.ts index 9d6e4ad..4bf45ce 100644 --- a/src/lib/processors/plex-recently-added.processor.ts +++ b/src/lib/processors/plex-recently-added.processor.ts @@ -265,7 +265,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa }, }, }, - take: 100, + }); if (matchableRequests.length > 0) { diff --git a/src/lib/processors/scan-plex.processor.ts b/src/lib/processors/scan-plex.processor.ts index a442665..4dfaf85 100644 --- a/src/lib/processors/scan-plex.processor.ts +++ b/src/lib/processors/scan-plex.processor.ts @@ -450,7 +450,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise { }, }, }, - take: 100, // Increased from 50 to handle more eligible requests + }); logger.info(`Found ${matchableRequests.length} matchable requests (all non-terminal statuses)`); diff --git a/src/lib/services/auth/OIDCAuthProvider.ts b/src/lib/services/auth/OIDCAuthProvider.ts index aec98ef..7be3888 100644 --- a/src/lib/services/auth/OIDCAuthProvider.ts +++ b/src/lib/services/auth/OIDCAuthProvider.ts @@ -98,9 +98,15 @@ export class OIDCAuthProvider implements IAuthProvider { timestamp: Date.now(), }); + // Only request 'groups' scope when group-based features are configured + const accessMethod = await this.configService.get('oidc.access_control_method'); + const adminClaimEnabled = await this.configService.get('oidc.admin_claim_enabled'); + const needsGroups = accessMethod === 'group_claim' || adminClaimEnabled === 'true'; + const scope = needsGroups ? 'openid profile email groups' : 'openid profile email'; + // Generate authorization URL const redirectUrl = client.authorizationUrl({ - scope: 'openid profile email groups', + scope, state, nonce, code_challenge: codeChallenge, diff --git a/tests/services/auth/oidc-auth-provider.test.ts b/tests/services/auth/oidc-auth-provider.test.ts index f64d675..86c7c43 100644 --- a/tests/services/auth/oidc-auth-provider.test.ts +++ b/tests/services/auth/oidc-auth-provider.test.ts @@ -128,6 +128,64 @@ describe('OIDCAuthProvider', () => { expect(result.state).toBe('state-1'); }); + it('omits groups scope when access control does not need it', async () => { + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + 'oidc.access_control_method': 'open', + }); + + clientMock.authorizationUrl.mockReturnValue('https://issuer/auth'); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + await provider.initiateLogin(); + + expect(clientMock.authorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'openid profile email' }) + ); + }); + + it('includes groups scope when access control uses group_claim', async () => { + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + 'oidc.access_control_method': 'group_claim', + }); + + clientMock.authorizationUrl.mockReturnValue('https://issuer/auth'); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + await provider.initiateLogin(); + + expect(clientMock.authorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'openid profile email groups' }) + ); + }); + + it('includes groups scope when admin claim is enabled', async () => { + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + 'oidc.access_control_method': 'allowed_list', + 'oidc.admin_claim_enabled': 'true', + }); + + clientMock.authorizationUrl.mockReturnValue('https://issuer/auth'); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + await provider.initiateLogin(); + + expect(clientMock.authorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'openid profile email groups' }) + ); + }); + it('throws when OIDC is not fully configured', async () => { setConfig({ 'oidc.issuer_url': null,