Repository navigation
Conversation
* feat(auth): single sign-on with any OpenID Connect provider Background: Zettelrobbe has a single administrator account with a local password. Operators running an identity provider (Authentik, Authelia, Keycloak, ...) want to sign in through it instead of keeping yet another password. Requested in #320 and #259. Changes: - New services/oidcService.js and routes/oidc.js: standard authorization code flow with PKCE (S256), state and nonce via openid-client. Endpoints, keys and token endpoint auth methods come from discovery; nothing is provider-specific. Tolerates a trailing-slash issuer mismatch and a full discovery URL as OIDC_ISSUER_URL. - One account, no user logic: the identity whose e-mail claim equals OIDC_ADMIN_EMAIL (case-insensitive) signs in as the administrator. No provisioning, no group handling; access is restricted at the provider. - OIDC_ADMIN_EMAIL is required. With OIDC_ENABLED=yes but an incomplete configuration, SSO stays off and startup logs what is missing. - Login form: an "or" divider and an SSO button, customizable with OIDC_BUTTON_TEXT, OIDC_BUTTON_ICON, OIDC_BUTTON_COLOR and OIDC_BUTTON_TEXT_COLOR (colours and icon URLs are validated). - OIDC_CLIENT_SECRET is masked in settings and logs; OIDC_* keys are part of the env export. - docs/sso-oidc.md: configuration, provider examples, button customization, security notes and troubleshooting. README and docker-compose.yml point to it. OpenAPI regenerated for the two new routes. Testing: - tests/test-oidc-sso.js (29 checks) against a fake Authentik-like provider: happy path, refusals (state, missing transaction, provider error, other e-mail), token separation, auth methods, issuer spelling, button customization and helper validation. - node scripts/run-tests.js --all: 111 passed, 6 skipped (need a running server), 1 failed (changelog-releases, fails on main too). - Login page rendered in Chromium with default and customized button. - eslint, prettier and stylelint clean on changed files. Impact: Off by default. Password login is unchanged and keeps working with SSO enabled. New dependency: openid-client (loaded only when SSO is used). Upstream Status: Not submitted upstream yet. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYo842vZsjeyzKRLQuLqwX * fix(auth): reject an OIDC_REDIRECT_URI without scheme Background: A redirect URI configured without its scheme (paperless-ai.example.com/auth/oidc/callback) was sent to the provider verbatim. Authentik rejected it with a generic "Redirect URI Error", which gave no hint at the cause. Seen on a real Authentik setup. Changes: - getConfigErrors() requires OIDC_REDIRECT_URI, when set, to be an absolute http(s) URL. Otherwise SSO stays off and the startup log names the setting. - docs/sso-oidc.md troubleshooting mentions the scheme. Testing: - tests/test-oidc-sso.js: new check, 30 passed. - eslint and prettier clean on changed files. Impact: Only affects misconfigured installs, which now fail visibly at startup instead of at the provider.
…tion (#6) Background: GET /health is a public route (Docker healthcheck, uptime monitors). It returned the full scanner and Paperless-ngx snapshot to anyone, including raw error strings. Against a local instance with Paperless-ngx stopped, an unauthenticated request returned: "lastError": "connect ECONNREFUSED 127.0.0.1:8000" "paperless": { ..., "error": "connect ECONNREFUSED 127.0.0.1:8000" } plus the scan schedule and run timestamps. The HealthResponse schema's own example ("connect ECONNREFUSED 172.18.0.2:8000") shows the same leak of internal host names and ports (CWE-200). The 500 branch also returned the raw exception message. Changes: - routes/setup.js: isAuthenticatedCaller() accepts a session token or the API key on a public route. /health answers an anonymous caller with only { status, database } and the same HTTP status codes as before (503 when degraded in strict mode). Authenticated callers get the full payload, unchanged. The 500 branch returns a generic message to anonymous callers. - @Swagger description of /health and the HealthResponse schema state which fields are authenticated-only; OPENAPI/openapi.json regenerated. - tests/test-health-disclosure.js (security area). - tests/test-scanner-startup-resilience.js: its source check now matches the status expression without the object-literal key. Testing: - Local instance, Paperless-ngx stopped: anonymous and wrong-key requests return {"status":"degraded","database":"ok"} with HTTP 503; the API key returns the full payload with HTTP 503. - New test: 3 of 6 fail on main, 6/6 pass with the fix. - node scripts/run-tests.js --all: all pass except changelog-releases, which fails identically on main. - eslint and prettier clean on the changed files; regen-openapi produces no further diff. Impact: - Monitors that read scanner or paperless fields from /health without credentials must send the x-api-key header. The HTTP status code and the top-level status field, which the Docker healthcheck and the settings page restart check use, are unchanged. Upstream Status: - Not reported upstream. Claude-Session: https://claude.ai/code/session_011apVSoVYdcjtnV6Ly3bepu Co-authored-by: Claude <noreply@anthropic.com>
Background: The MFA step of POST /login accepted the same six-digit code any number of times while it stayed inside the ±1 step window (up to 90 seconds). Against a local instance, two independent sign-ins (separate cookie jars) with the same password and the same code both returned 302 /dashboard with a session cookie. A code captured over the shoulder, from a screen share or by a phishing proxy could therefore open a second session after the user's own sign-in. RFC 6238 section 5.2 requires the verifier not to accept a code again after a successful validation (CWE-294). Changes: - routes/setup.js: findTotpStep() returns the time step a code matches; verifyTotpToken() keeps its boolean contract on top of it. consumeTotpToken() remembers the last accepted step per username (in memory) and refuses that step and older ones. - The login MFA step, POST /api/settings/mfa/enable and POST /api/settings/mfa/disable now consume the code. The setup-wizard challenge and POST /api/settings/mfa/verify (a code check without an action) keep the non-consuming check. - tests/test-totp-replay.js (security area). Testing: - Same two-session reproduction after the fix: first sign-in 302 /dashboard, second sign-in 200 (MFA form again) without a session cookie. - New test: the replay case fails on main and passes with the fix. - node scripts/run-tests.js --all: 116 passed, 2 skipped, 1 failed (changelog-releases, fails identically on main). - eslint and prettier clean on the changed files. Impact: - A user who just enabled MFA, or just signed in, has to wait for the next code (at most 30 seconds) before the same authenticator code works again. - The memory of used steps is lost on restart, which only re-opens the 90-second window for codes used right before the restart. - No API or response shape change. Upstream Status: - Not reported upstream. Claude-Session: https://claude.ai/code/session_011apVSoVYdcjtnV6Ly3bepu Co-authored-by: Claude <noreply@anthropic.com>
Background: `npm audit --omit=dev` reported three vulnerable runtime dependencies: - proxy-addr 2.0.7 (critical, GHSA-jqcg-44mw-7w3h): IP spoofing via an IPv4-mapped IPv6 trust subnet. Express uses it to compute req.ip when TRUST_PROXY is set, and req.ip keys the login and API rate limiters. - brace-expansion 5.0.9 (high, GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p): CPU / stack exhaustion denial of service. - ip-address 10.4.0 (moderate, GHSA-rpw4-54j3-4h4q, GHSA-2vr4-cq9g-pvrc, GHSA-j6r3-76f7-8jcv, GHSA-h3mg-xc3c-68pw), pulled in by express-rate-limit. Changes: - `npm audit fix` (no --force): proxy-addr 2.0.8, brace-expansion 5.0.12, ip-address 10.7.3, plus the dev-only patch bumps npm resolved alongside (postcss-selector-parser 7.1.6, source-map-js 1.2.2). Lockfile only; package.json ranges are unchanged. Testing: - `npm audit --omit=dev`: 0 vulnerabilities (was 1 critical, 1 high, 1 moderate). - `node scripts/run-tests.js --all`: 115 passed, 2 skipped, 1 failed. The failure (changelog-releases: newest changelog block does not match PAPERLESS_AI_VERSION) fails identically on main without this change. Impact: - No API or behaviour change. The remaining dev-only advisories (braces via nodemon/stylelint) need a breaking major bump and are not part of this change. Upstream Status: - Not reported upstream. Claude-Session: https://claude.ai/code/session_011apVSoVYdcjtnV6Ly3bepu Co-authored-by: Claude <noreply@anthropic.com>
) Background: POST /login returned immediately when the username was unknown, but ran a bcrypt comparison when it existed. Against a local instance with a cost-10 hash, eight attempts each measured: existing username: mean 91.9 ms unknown username: mean 4.5 ms An account created by the setup wizard is hashed with cost 15, which widens the gap to seconds. One timed request is enough to confirm the administrator's username, so the login rate limit does not help (CWE-208 / CWE-204). Changes: - routes/setup.js: on an unknown username (or an account without a password hash), spendPasswordCheckTime() runs bcrypt.compare() against a dummy hash before answering. The dummy uses the cost of the stored account (bcrypt.getRounds), default 15, and is generated once per cost. - tests/test-login-username-timing.js (security area): counts bcrypt comparisons per login path and checks the dummy cost, so CI does not depend on wall-clock timing. Testing: - Same measurement after the fix: existing 91.4 ms, unknown 96.0 ms. - New test fails on main (3 of 5) and passes with the fix. - node scripts/run-tests.js --area security: 21/21 passed. - eslint and prettier clean on the changed files. Impact: - A failed login with an unknown username now takes as long as one with a wrong password. No API or response change. Upstream Status: - Not reported upstream. Claude-Session: https://claude.ai/code/session_011apVSoVYdcjtnV6Ly3bepu Co-authored-by: Claude <noreply@anthropic.com>
|
Thanks! Me and my friend @claude will check this PR and add it to the next release 👍🏻 |
… out of /setup Background: Review of #372 on nightly found two gaps where single sign-on meets existing configuration code: - setupService.saveConfig() rewrites runtime-overrides.json (and data/.env in legacy mode) from the settings form, which has no OIDC_* fields. OIDC lines kept in data/.env or the overrides were dropped by the first save and SSO turned off after the restart. Same cause as the JWT_SECRET loss fixed earlier on nightly. Compose-injected values were not affected. - OIDC_CLIENT_SECRET was missing from the bootstrap sanitizer, so the open /setup page could serialise it into window.__SETUP_BOOTSTRAP__ when it sat in data/.env or the overrides before an account existed. Changes: - withSessionSecret() also carries every running OIDC_* value the saved configuration does not set itself. - OIDC_CLIENT_SECRET added to BOOTSTRAP_SECRET_FIELDS. - test-settings-save-session-secret: OIDC_* in the runtime overrides survive a settings save (fails without the fix). Testing: - node scripts/run-tests.js --all: 121 passed, 0 failed - ESLint and Prettier clean on changed files Impact: SSO configured in data/.env or the overrides keeps working after a settings save; the client secret never reaches the setup page. Upstream Status: Not applicable (fork feature). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
Background: SSO signs in whoever presents OIDC_ADMIN_EMAIL. A provider that allows self-registration or freely edited profile addresses could let anyone claim the admin's address; email_verified was never consulted. #372's own test provider sent email_verified: false, which suggests some providers do so by default, so a hard refusal needs an escape hatch. Changes: - oidcService.authorize(): refuse when email_verified is false (boolean or the string "false"). A missing claim is not treated as unverified. - config.getOidcConfig(): OIDC_ALLOW_UNVERIFIED_EMAIL (default no) turns the refusal off for providers that never confirm addresses. - docs/sso-oidc.md: option table and security notes. - test-oidc-sso: fake provider now reports a verified address; new cases for an unverified address, a missing claim and the switch. Testing: - node tests/test-oidc-sso.js: 32 passed, 0 failed - node scripts/run-tests.js --all: 121 passed, 0 failed - ESLint and Prettier clean on changed files, no OpenAPI drift Impact: Providers that send email_verified: false for the admin need the address verified there or OIDC_ALLOW_UNVERIFIED_EMAIL=yes. Upstream Status: Not applicable (fork feature). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
Single sign-on with OpenID Connect (#372) on nightly
Background: Release v2026.10.01 now ships everything on nightly, including single sign-on (contributed in #372), the regrouped settings page and the auth fixes that came with #372. The changelog block only listed the Anthropic work. Changes: - Add entries for OpenID Connect single sign-on (credited to @krauseerl, #372), SSO on the settings page, the regrouped settings page, the Claude model dropdown and the login, TOTP and health hardening. Testing: - node scripts/run-tests.js --all: 122 passed, 6 skipped, 0 failed - eslint and prettier on config/changelog.js clean; OpenAPI unchanged Impact: Changelog text only. Upstream Status: Not applicable (fork-specific release notes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
|
Hey Andy, Claude here, the AI that works on Zettelrobbe alongside @admonstrator. He asked me to look after your PR, and I want to start with a thank-you from both of us: single sign-on has been on the wish list for a while, and you're the one who actually sat down and built it, plus the auth and health fixes you found along the way. Your commits are now on That's the only reason I'm closing this PR: your work already found its way in by another road. If you run it against your own provider and something looks off, we'd really like to hear it, you know that side of SSO better than anyone here.
Claude, your friend at Zettelrobbe HQ Generated by Claude Code |

Summary
Zettelrobbe can sign you in through any standards-compliant OpenID Connect (OIDC) provider, for
example Authentik, Authelia, Keycloak, Kanidm, Pocket ID, Zitadel, Microsoft Entra ID or Google.
When it is configured, the login page shows an extra button next to the password form. One click
takes you to your provider and back into Zettelrobbe.
Changes
The login page has a "Sign in with SSO" button. It works with any OpenID Connect provider (Authentik, Authelia, Keycloak, Kanidm, Pocket ID, Zitadel, Entra ID, Google, ...). The identity whose e-mail address equals
OIDC_ADMIN_EMAILis signed in as the single administrator. Password login keeps working next to it.Zettelrobbe has exactly one account, so there is no user or group logic on the app side. Who may use the application is restricted at the identity provider.
How:
services/oidcService.jsandroutes/oidc.jsimplement the standard authorization code flow with PKCE (S256), state and nonce on top ofopenid-client. Endpoints, signing keys and token endpoint auth methods come from the provider's discovery document. A trailing-slash difference in the issuer is tolerated, and so is a full discovery URL. Configuration is environment-only:OIDC_ENABLED,OIDC_ISSUER_URL,OIDC_CLIENT_ID,OIDC_ADMIN_EMAIL. If one is missing, SSO stays off and startup logs what is missing.OIDC_CLIENT_SECRET,OIDC_REDIRECT_URI.OIDC_SCOPES,OIDC_EMAIL_CLAIM,OIDC_TOKEN_AUTH_METHOD.OIDC_BUTTON_TEXT,OIDC_BUTTON_ICON,OIDC_BUTTON_COLOR,OIDC_BUTTON_TEXT_COLOR. Colours and icon URLs are validated.The full guide is in docs/sso-oidc.md. It covers setup, examples for Authentik, Authelia and Keycloak, button customization, security notes and troubleshooting.
Testing
tests/test-oidc-sso.jsruns 29 checks against a fake Authentik-like provider.changelog-releases, which also fails onmain.Refs #320