diff --git a/config/changelog.js b/config/changelog.js index 707e605..0bf3de7 100644 --- a/config/changelog.js +++ b/config/changelog.js @@ -150,6 +150,11 @@ const RELEASES = [ 'New: Claude as OCR provider. It reads PDFs and images directly and uses the API key and model of the AI provider unless OCR has its own', 'Fix: Saving the settings no longer signs everybody out. The restart after a save created a new sign-in key, so every open session ended, and a page left open only showed vague errors such as "Model discovery failed". A session that ends now sends the page to the login screen', 'Fix: The manual tagging page uses the current design again. Its buttons, cards, document preview and tag chips were still built on styles that no longer exist', + 'New: Single sign-on with any OpenID Connect provider (Authentik, Keycloak, Entra ID and others). Contributed by @krauseerl in #372. E-mail addresses the provider marks as unverified are refused unless OIDC_ALLOW_UNVERIFIED_EMAIL=yes', + 'New: Single sign-on can be set up on the settings page, including a "Test connection" button. Values set by the container stay locked there', + 'Improvement: The settings page shows one topic at a time, grouped in a sidebar, with a search field and a save bar that counts unsaved changes across all topics', + 'Improvement: The Claude model is picked from a dropdown filled by "Fetch Models", like every other model', + 'Security: The login no longer reveals whether a username exists, a two-factor code is accepted only once, and the health endpoint keeps scanner and Paperless-ngx details behind authentication. Contributed by @krauseerl', ], }, ]; diff --git a/routes/setup.js b/routes/setup.js index a978bd9..7d41ea0 100644 --- a/routes/setup.js +++ b/routes/setup.js @@ -57,6 +57,7 @@ const { ANTHROPIC_SUGGESTED_MODELS, ANTHROPIC_EFFORT_LEVELS, ANTHROPIC_DEFAULT_MAX_TOKENS, + isConfiguredAnthropicHost, } = require('../services/anthropicSupport'); const config = require('../config/config.js'); require('dotenv').config({ path: '../data/.env' }); @@ -3917,9 +3918,17 @@ function resolveStoredAiToken(aiProvider) { return ''; } -function resolveSettingsAiToken(aiProvider, token) { +function resolveSettingsAiToken(aiProvider, token, apiUrl) { const normalizedToken = String(token || '').trim(); - return normalizedToken || resolveStoredAiToken(aiProvider); + if (normalizedToken) return normalizedToken; + // The saved Claude key only goes to a Claude endpoint already configured. + const provider = String(aiProvider || '') + .trim() + .toLowerCase(); + if (provider === 'anthropic' && !isConfiguredAnthropicHost(apiUrl)) { + return ''; + } + return resolveStoredAiToken(aiProvider); } /* Accepts what the row menu sends (one id) and what the bulk menu sends (a @@ -4149,8 +4158,10 @@ function resolveOcrApiKey(apiKey) { /* resolveOcrApiKey() for a request that names its OCR provider. Claude OCR must never receive a stored Mistral key: MISTRAL_API_KEY never belongs to it, and OCR_API_KEY only when Claude is the OCR provider it was saved for. - An empty answer is fine — setupService falls back to the Anthropic AI key. */ -function resolveOcrApiKeyForProvider(provider, apiKey) { + An empty answer is fine — setupService falls back to the Anthropic AI key. + `apiUrl` is where the request goes; the stored key is withheld from any + host that is not already configured for Claude. */ +function resolveOcrApiKeyForProvider(provider, apiKey, apiUrl = '') { const normalizedProvider = String(provider || '') .trim() .toLowerCase(); @@ -4166,7 +4177,9 @@ function resolveOcrApiKeyForProvider(provider, apiKey) { const storedProvider = String(process.env.OCR_PROVIDER || '') .trim() .toLowerCase(); - return storedProvider === 'anthropic' ? process.env.OCR_API_KEY || '' : ''; + return storedProvider === 'anthropic' && isConfiguredAnthropicHost(apiUrl) + ? process.env.OCR_API_KEY || '' + : ''; } async function validatePaperlessConnectionForSetup( @@ -5359,7 +5372,11 @@ router.post('/api/setup/ocr/models', express.json(), async (req, res) => { apiUrl: req.body?.apiUrl, // The wizard used to take the field at face value, so an operator whose // key already sat in the environment was told to supply one. - apiKey: resolveOcrApiKeyForProvider(req.body?.provider, req.body?.apiKey), + apiKey: resolveOcrApiKeyForProvider( + req.body?.provider, + req.body?.apiKey, + req.body?.apiUrl || req.body?.anthropicApiUrl + ), anthropicApiKey: req.body?.anthropicApiKey, anthropicApiUrl: req.body?.anthropicApiUrl, anthropicModel: req.body?.anthropicModel, @@ -5485,7 +5502,11 @@ router.post( const validation = await validateAiConnectionForSetup({ aiProvider: req.body?.aiProvider, apiUrl: req.body?.apiUrl, - token: resolveSettingsAiToken(req.body?.aiProvider, req.body?.token), + token: resolveSettingsAiToken( + req.body?.aiProvider, + req.body?.token, + req.body?.apiUrl + ), model: req.body?.model, azureApiVersion: req.body?.azureApiVersion, setupValidationTimeoutMs: req.body?.setupValidationTimeoutMs, @@ -5568,7 +5589,8 @@ router.post( apiUrl: req.body?.apiUrl, apiKey: resolveOcrApiKeyForProvider( req.body?.provider, - req.body?.apiKey + req.body?.apiKey, + req.body?.apiUrl || req.body?.anthropicApiUrl ), model: req.body?.model, setupOcrValidationTimeoutMs: @@ -5776,7 +5798,11 @@ router.post( const result = await discoverAiModelsForSetup({ aiProvider: req.body?.aiProvider, apiUrl: req.body?.apiUrl, - token: resolveSettingsAiToken(req.body?.aiProvider, req.body?.token), + token: resolveSettingsAiToken( + req.body?.aiProvider, + req.body?.token, + req.body?.apiUrl + ), setupValidationTimeoutMs: req.body?.setupValidationTimeoutMs, }); @@ -5897,7 +5923,8 @@ router.post( apiUrl: req.body?.apiUrl, apiKey: resolveOcrApiKeyForProvider( req.body?.provider, - req.body?.apiKey + req.body?.apiKey, + req.body?.apiUrl || req.body?.anthropicApiUrl ), anthropicApiKey: req.body?.anthropicApiKey, anthropicApiUrl: req.body?.anthropicApiUrl, diff --git a/services/anthropicSupport.js b/services/anthropicSupport.js index 5122d8d..d4f1ddc 100644 --- a/services/anthropicSupport.js +++ b/services/anthropicSupport.js @@ -19,7 +19,10 @@ */ const Anthropic = require('@anthropic-ai/sdk'); -const { assertCompletionNotTruncated } = require('./serviceUtils'); +const { + assertCompletionNotTruncated, + stripTrailingSlashes, +} = require('./serviceUtils'); /** The model a fresh installation starts with. */ const ANTHROPIC_DEFAULT_MODEL = 'claude-haiku-5-5'; @@ -323,9 +326,7 @@ function anthropicMaxTokens(requested, configured) { */ function createAnthropicClient({ apiKey, apiUrl = '', timeoutMs } = {}) { const clientOptions = { apiKey: String(apiKey || '') }; - const baseURL = String(apiUrl || '') - .trim() - .replace(/\/+$/, ''); + const baseURL = stripTrailingSlashes(String(apiUrl || '').trim()); if (baseURL) clientOptions.baseURL = baseURL; if (typeof timeoutMs === 'number' && timeoutMs > 0) { clientOptions.timeout = timeoutMs; @@ -333,6 +334,49 @@ function createAnthropicClient({ apiKey, apiUrl = '', timeoutMs } = {}) { return new Anthropic(clientOptions); } +/** Where a client goes when no proxy URL is set. */ +const ANTHROPIC_DEFAULT_ORIGIN = 'https://api.anthropic.com'; + +/** + * Whether a request targets a Claude endpoint this installation already uses: + * ANTHROPIC_API_URL (empty means api.anthropic.com), or OCR_API_URL while + * Claude is the OCR provider. A stored key is only substituted for an empty + * key field when this holds; otherwise any signed-in caller could name their + * own host and receive the saved key in the x-api-key header. Typed keys are + * not affected. An empty URL passes: the request then goes to the configured + * proxy or to api.anthropic.com, both of which already hold the key. + * + * @param {string} requestedUrl - the URL the request names, empty for none + * @param {NodeJS.ProcessEnv} [env] + * @returns {boolean} + */ +function isConfiguredAnthropicHost(requestedUrl, env = process.env) { + const origin = (value) => { + const trimmed = String(value || '').trim(); + if (!trimmed) return ANTHROPIC_DEFAULT_ORIGIN; + try { + const parsed = new URL(trimmed); + return `${parsed.protocol}//${parsed.host}`.toLowerCase(); + } catch { + return null; + } + }; + + if (!String(requestedUrl || '').trim()) return true; + const requested = origin(requestedUrl); + if (!requested) return false; + + const configured = [origin(env.ANTHROPIC_API_URL)]; + const ocrIsClaude = + String(env.OCR_PROVIDER || '') + .trim() + .toLowerCase() === 'anthropic'; + if (ocrIsClaude && String(env.OCR_API_URL || '').trim()) { + configured.push(origin(env.OCR_API_URL)); + } + return configured.includes(requested); +} + /** * The visible answer of a Messages response: every text block, in order. * Thinking blocks are no part of it, and a response can begin with them, so @@ -435,6 +479,7 @@ module.exports = { anthropicThinkingParams, anthropicMaxTokens, createAnthropicClient, + isConfiguredAnthropicHost, anthropicMessageText, mapAnthropicUsage, assertAnthropicCompleted, diff --git a/services/mistralOcrService.js b/services/mistralOcrService.js index 0a582c2..401c79e 100644 --- a/services/mistralOcrService.js +++ b/services/mistralOcrService.js @@ -17,7 +17,11 @@ const popplerService = require('./popplerService'); const documentModel = require('../models/document'); const AIServiceFactory = require('./aiServiceFactory'); const documentProcessingService = require('./documentProcessingService'); -const { isTimeoutError, buildTimeoutErrorMessage } = require('./serviceUtils'); +const { + isTimeoutError, + buildTimeoutErrorMessage, + stripTrailingSlashes, +} = require('./serviceUtils'); const { ANTHROPIC_DEFAULT_MODEL, anthropicThinkingParams, @@ -195,9 +199,11 @@ class MistralOcrService { // OCR_API_URL only when the operator set one (a proxy used for OCR // alone); otherwise the AI provider's ANTHROPIC_API_URL. Empty means // api.anthropic.com. - return String(config.mistralOcr?.apiUrl || config.anthropic?.apiUrl || '') - .trim() - .replace(/\/+$/, ''); + return stripTrailingSlashes( + String( + config.mistralOcr?.apiUrl || config.anthropic?.apiUrl || '' + ).trim() + ); } if (this.provider === 'ollama') { diff --git a/services/oidcService.js b/services/oidcService.js index 9f84aa2..3eba124 100644 --- a/services/oidcService.js +++ b/services/oidcService.js @@ -18,6 +18,7 @@ */ const config = require('../config/config'); +const { stripTrailingSlashes } = require('./serviceUtils'); // Loaded on first use, not at startup: an install that never turns SSO on // keeps running even on an image whose dependencies predate openid-client. @@ -43,7 +44,7 @@ const TOKEN_AUTH_METHODS = [ ]; function stripTrailingSlash(value) { - return String(value).replace(/\/+$/, ''); + return stripTrailingSlashes(String(value)); } /** diff --git a/services/setupService.js b/services/setupService.js index fde99d2..5327c21 100644 --- a/services/setupService.js +++ b/services/setupService.js @@ -9,6 +9,7 @@ const { ANTHROPIC_DEFAULT_MODEL, ANTHROPIC_SUGGESTED_MODELS, createAnthropicClient, + isConfiguredAnthropicHost, } = require('./anthropicSupport'); const CUSTOM_PROVIDER_FALLBACK_API_KEY = 'no-auth-required'; @@ -659,7 +660,9 @@ class SetupService { * * `anthropicApiKey` / `anthropicApiUrl` carry AI provider values that are * not saved yet (the setup wizard collects both before anything is - * written); the environment covers the ones that are. + * written); the environment covers the ones that are. The stored + * ANTHROPIC_API_KEY is only used when the URL is one already configured + * (see isConfiguredAnthropicHost). * * @param {object} options * @returns {{apiKey: string, apiUrl: string}} @@ -667,17 +670,17 @@ class SetupService { resolveAnthropicOcrConnection(options = {}) { const firstSet = (...values) => values.map((value) => String(value || '').trim()).find(Boolean) || ''; + const apiUrl = firstSet( + options.apiUrl, + options.anthropicApiUrl, + process.env.ANTHROPIC_API_URL + ); + const storedKey = isConfiguredAnthropicHost(apiUrl) + ? process.env.ANTHROPIC_API_KEY + : ''; return { - apiKey: firstSet( - options.apiKey, - options.anthropicApiKey, - process.env.ANTHROPIC_API_KEY - ), - apiUrl: firstSet( - options.apiUrl, - options.anthropicApiUrl, - process.env.ANTHROPIC_API_URL - ), + apiKey: firstSet(options.apiKey, options.anthropicApiKey, storedKey), + apiUrl, }; } diff --git a/tests/test-anthropic-settings.js b/tests/test-anthropic-settings.js index 270fbd8..3f95829 100644 --- a/tests/test-anthropic-settings.js +++ b/tests/test-anthropic-settings.js @@ -436,6 +436,7 @@ function startFakeAnthropic() { setScenario({ PAPERLESS_AI_SETUP_ALLOW_LOCALHOST: 'yes', ANTHROPIC_API_KEY: GOOD_KEY, + ANTHROPIC_API_URL: fake.url, }); let response = await request('POST', '/api/settings/ai/models', { aiProvider: 'anthropic', @@ -461,6 +462,49 @@ function startFakeAnthropic() { assert.match(body.message, /suggested Claude models/); }); + await test('the stored Claude key never goes to a host that is not configured', async () => { + // The fake answers on 127.0.0.1 and localhost alike; only the stored + // URL names one of them, so the other counts as somebody else's host. + const otherHost = fake.url.includes('127.0.0.1') + ? fake.url.replace('127.0.0.1', 'localhost') + : fake.url.replace('localhost', '127.0.0.1'); + setScenario({ + PAPERLESS_AI_SETUP_ALLOW_LOCALHOST: 'yes', + ANTHROPIC_API_KEY: GOOD_KEY, + ANTHROPIC_API_URL: fake.url, + OCR_PROVIDER: 'anthropic', + OCR_API_KEY: GOOD_KEY, + }); + + let response = await request('POST', '/api/settings/ai/models', { + aiProvider: 'anthropic', + apiUrl: otherHost, + token: '', + }); + let body = await response.json(); + assert.strictEqual(response.status, 200); + assert.strictEqual(body.live, false, 'AI: no stored key for a new host'); + + response = await request('POST', '/api/settings/ocr/models', { + provider: 'anthropic', + apiUrl: otherHost, + apiKey: '', + }); + body = await response.json(); + assert.strictEqual(response.status, 200); + assert.strictEqual(body.live, false, 'OCR: no stored key for a new host'); + + const connection = setupService.resolveAnthropicOcrConnection({ + anthropicApiUrl: otherHost, + }); + assert.strictEqual(connection.apiKey, ''); + assert.strictEqual( + setupService.resolveAnthropicOcrConnection({}).apiKey, + GOOD_KEY, + 'the configured URL still gets the stored key' + ); + }); + // ── validateOcrConfig for Claude OCR ──────────────────────────────────── await test('validateOcrConfig(anthropic) runs one OCR round trip with the typed key', async () => { setScenario({ ANTHROPIC_API_KEY: AI_KEY }); diff --git a/tests/test-ocr-model-fetch-key.js b/tests/test-ocr-model-fetch-key.js index b646b42..93179cd 100644 --- a/tests/test-ocr-model-fetch-key.js +++ b/tests/test-ocr-model-fetch-key.js @@ -117,7 +117,7 @@ check('every OCR route resolves the key before using it', () => { // which hands every other provider to resolveOcrApiKey() unchanged. const calls = ( source.match( - /resolveOcrApiKeyForProvider\(\s*req\.body\?\.provider,\s*req\.body\?\.apiKey\s*\)/g + /resolveOcrApiKeyForProvider\(\s*req\.body\?\.provider,\s*req\.body\?\.apiKey,\s*req\.body\?\.apiUrl \|\| req\.body\?\.anthropicApiUrl\s*\)/g ) || [] ).length; assert.strictEqual(