Skip to content

Single sign-on with any OpenID Connect provider - #372

Closed
krauseerl wants to merge 5 commits into
admonstrator:mainfrom
krauseerl:main
Closed

krauseerl wants to merge 5 commits into
admonstrator:mainfrom
krauseerl:main

Conversation

@krauseerl

@krauseerl krauseerl commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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_EMAIL is 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.js and routes/oidc.js implement the standard authorization code flow with PKCE (S256), state and nonce on top of openid-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:

  • Required: OIDC_ENABLED, OIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_ADMIN_EMAIL. If one is missing, SSO stays off and startup logs what is missing.
  • Usually needed: OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI.
  • Optional: OIDC_SCOPES, OIDC_EMAIL_CLAIM, OIDC_TOKEN_AUTH_METHOD.
  • Button: 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.js runs 29 checks against a fake Authentik-like provider.
  • The full suite has 111 passed and 6 skipped. The skipped tests need a running server. One test fails: changelog-releases, which also fails on main.
  • I rendered the login page in Chromium and checked eslint, prettier and stylelint on the changed files.
  • I have tested against a live identity provider, used my local authentik deployment.
  • I have tested to login with an IDP profile with a different e-mail, was declined successfully

Refs #320

* 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.
@krauseerl krauseerl changed the title Single sign-on with any OpenID Connect provider (#2) Single sign-on with any OpenID Connect provider Oct 5, 2026
krauseerl and others added 4 commits October 7, 2026 17:48
…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>
@admonstrator

Copy link
Copy Markdown
Owner

Thanks! Me and my friend @claude will check this PR and add it to the next release 👍🏻

@admonstrator admonstrator self-assigned this Oct 12, 2026
@admonstrator admonstrator added the enhancement New feature or request label Oct 12, 2026
admonstrator pushed a commit that referenced this pull request Oct 12, 2026
… 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
admonstrator pushed a commit that referenced this pull request Oct 12, 2026
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
admonstrator added a commit that referenced this pull request Oct 13, 2026
Single sign-on with OpenID Connect (#372) on nightly
admonstrator pushed a commit that referenced this pull request Oct 13, 2026
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
@admonstrator admonstrator mentioned this pull request Oct 13, 2026

admonstrator commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

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 nightly via #382, with your name on them. We only added what the project needed around it: a place on the settings page and a check for unverified e-mail addresses (switchable via OIDC_ALLOW_UNVERIFIED_EMAIL). It ships with v2026.10.01 (#384), the changelog credits you, and once it reaches main GitHub will list you as a contributor.

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-jam

Claude, your friend at Zettelrobbe HQ
Tagging documents for a living, dancing for free


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: SSO support (OpenID Connect / OAuth2) for external identity providers

2 participants