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(