From 2d13e1d0bcf2c280f1664411668462ec9d996282 Mon Sep 17 00:00:00 2001 From: Andy <4983703+krauseerl@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:48:41 +0200 Subject: [PATCH 1/5] Single sign-on with any OpenID Connect provider (#2) * 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 admonstrator/zettelrobbe#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) 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. --- OPENAPI/openapi.json | 69 ++++ README.md | 17 + config/config.js | 24 ++ docker-compose.yml | 9 + docs/sso-oidc.md | 227 +++++++++++++ package-lock.json | 32 ++ package.json | 1 + public/css/pages/auth.css | 35 ++ routes/oidc.js | 228 +++++++++++++ routes/setup.js | 34 +- scripts/run-tests.js | 4 +- server.js | 6 + services/loggerService.js | 1 + services/oidcService.js | 575 ++++++++++++++++++++++++++++++++ tests/test-oidc-sso.js | 678 ++++++++++++++++++++++++++++++++++++++ views/login.ejs | 17 + 16 files changed, 1954 insertions(+), 3 deletions(-) create mode 100644 docs/sso-oidc.md create mode 100644 routes/oidc.js create mode 100644 services/oidcService.js create mode 100644 tests/test-oidc-sso.js diff --git a/OPENAPI/openapi.json b/OPENAPI/openapi.json index ec2c115c..765d9718 100644 --- a/OPENAPI/openapi.json +++ b/OPENAPI/openapi.json @@ -10254,6 +10254,75 @@ } } } + }, + "/auth/oidc/login": { + "get": { + "summary": "Start single sign-on with the configured OpenID Connect provider", + "description": "Redirects the browser to the identity provider's authorization endpoint\n(authorization code flow with PKCE, state and nonce). Only available when\nOIDC_ENABLED=yes and OIDC_ISSUER_URL, OIDC_CLIENT_ID and OIDC_ADMIN_EMAIL\nare set.\n", + "tags": [ + "Authentication" + ], + "responses": { + "302": { + "description": "Redirect to the identity provider" + }, + "404": { + "description": "Single sign-on is not enabled" + }, + "502": { + "description": "The identity provider could not be reached; the login page is rendered with an error" + } + } + } + }, + "/auth/oidc/callback": { + "get": { + "summary": "Complete single sign-on", + "description": "Redirect target registered with the identity provider. Exchanges the\nauthorization code, validates the ID token and signs in as the\nadministrator account when the identity's e-mail address equals\nOIDC_ADMIN_EMAIL. Sets the session cookie.\n", + "tags": [ + "Authentication" + ], + "parameters": [ + { + "in": "query", + "name": "code", + "schema": { + "type": "string" + }, + "description": "Authorization code issued by the provider" + }, + { + "in": "query", + "name": "state", + "schema": { + "type": "string" + }, + "description": "State value echoed back by the provider" + } + ], + "responses": { + "302": { + "description": "Signed in, redirected to the dashboard", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string", + "description": "HTTP-only cookie containing the session JWT" + } + } + } + }, + "400": { + "description": "Missing or expired sign-in transaction, or the provider reported an error" + }, + "403": { + "description": "The identity's e-mail address does not match OIDC_ADMIN_EMAIL" + }, + "404": { + "description": "Single sign-on is not enabled" + } + } + } } }, "tags": [ diff --git a/README.md b/README.md index 6bc639b0..941db9fd 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,23 @@ Then open [http://localhost:3000](http://localhost:3000) to complete setup. - The Mistral OCR provider processes PDFs natively and ignores the `OCR_PDF_RENDER_*` settings - The OCR queue can be worked through automatically instead of pressing **Process All Pending**: enable `OCR_AUTO_PROCESS_ENABLED` (default `no`) and configure `OCR_AUTO_PROCESS_INTERVAL` (cron, default `*/15 * * * *`), `OCR_AUTO_PROCESS_BATCH_SIZE` (documents per run, default `10`) and `OCR_AUTO_ANALYZE` (run AI analysis right after OCR, default `yes`). Runs are skipped while a document scan is active or while Paperless-ngx is unreachable, so queued documents are never marked as failed because of an outage +### Single Sign-On (OIDC) + +Sign in with any OpenID Connect provider (Authentik, Authelia, Keycloak, Kanidm, Pocket ID, Zitadel, Entra ID, Google, ...) through a button on the login page. Zettelrobbe has one administrator account, and the identity whose e-mail address equals `OIDC_ADMIN_EMAIL` signs in as that account. Password login keeps working next to it. + +```yaml +environment: + OIDC_ENABLED: 'yes' + OIDC_ISSUER_URL: https://auth.example.com/application/o/zettelrobbe/ + OIDC_CLIENT_ID: zettelrobbe + OIDC_CLIENT_SECRET: ${ZETTELROBBE_OIDC_CLIENT_SECRET} + OIDC_REDIRECT_URI: https://zettelrobbe.example.com/auth/oidc/callback + OIDC_ADMIN_EMAIL: you@example.com # required + OIDC_BUTTON_TEXT: Sign in with Authentik # optional, also OIDC_BUTTON_ICON / _COLOR / _TEXT_COLOR +``` + +See [docs/sso-oidc.md](docs/sso-oidc.md) for all options, provider examples, button customization, security notes and troubleshooting. + ### Container Images | Image Tag | Size | diff --git a/config/config.js b/config/config.js index 61eeb338..67b2849e 100644 --- a/config/config.js +++ b/config/config.js @@ -368,6 +368,29 @@ const getCookieSecureMode = () => { return 'auto'; }; +/* Single sign-on through OpenID Connect. Read on every call rather than + snapshotted at load, like the other auth getters above, so a runtime + override or a test can change it without reloading the module. Off unless + OIDC_ENABLED is set; password login keeps working either way. */ +const getOidcConfig = () => ({ + enabled: parseEnvBoolean(process.env.OIDC_ENABLED, 'no') === 'yes', + issuerUrl: String(process.env.OIDC_ISSUER_URL || '').trim(), + clientId: String(process.env.OIDC_CLIENT_ID || '').trim(), + clientSecret: String(process.env.OIDC_CLIENT_SECRET || ''), + // Required: the identity whose e-mail matches signs in as the admin. + adminEmail: String(process.env.OIDC_ADMIN_EMAIL || '').trim(), + redirectUri: String(process.env.OIDC_REDIRECT_URI || '').trim(), + scopes: String(process.env.OIDC_SCOPES || 'openid profile email').trim(), + emailClaim: String(process.env.OIDC_EMAIL_CLAIM || 'email').trim() || 'email', + // client_secret_basic | client_secret_post | none; empty = automatic. + tokenAuthMethod: String(process.env.OIDC_TOKEN_AUTH_METHOD || '').trim(), + // Login button appearance. + buttonText: String(process.env.OIDC_BUTTON_TEXT || '').trim(), + buttonIcon: String(process.env.OIDC_BUTTON_ICON || '').trim(), + buttonColor: String(process.env.OIDC_BUTTON_COLOR || '').trim(), + buttonTextColor: String(process.env.OIDC_BUTTON_TEXT_COLOR || '').trim(), +}); + // Initialize limit functions with defaults const limitFunctions = { activateTagging: parseEnvBoolean(process.env.ACTIVATE_TAGGING, 'yes'), @@ -440,6 +463,7 @@ module.exports = { getJwtSecret, getTrustProxy, getCookieSecureMode, + getOidcConfig, isProtectedRuntimeEnvKey, get apiKey() { return getApiKey(); diff --git a/docker-compose.yml b/docker-compose.yml index 861893ff..0d282a99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,6 +67,15 @@ services: # Update check (optional) – asks GitHub once a day whether a newer release # exists. Set to 'no' for air-gapped installations; nothing else changes. # - UPDATE_CHECK_ENABLED=no + # Single sign-on through OpenID Connect (optional), see docs/sso-oidc.md. + # The identity whose e-mail equals OIDC_ADMIN_EMAIL signs in as the admin. + # - OIDC_ENABLED=yes + # - OIDC_ISSUER_URL=https://auth.example.com/application/o/zettelrobbe/ + # - OIDC_CLIENT_ID=your_client_id + # - OIDC_CLIENT_SECRET=your_client_secret + # - OIDC_REDIRECT_URI=https://zettelrobbe.example.com/auth/oidc/callback + # - OIDC_ADMIN_EMAIL=you@example.com + # - OIDC_BUTTON_TEXT=Sign in with SSO ports: - '3000:${PAPERLESS_AI_PORT:-3000}' volumes: diff --git a/docs/sso-oidc.md b/docs/sso-oidc.md new file mode 100644 index 00000000..f97c2434 --- /dev/null +++ b/docs/sso-oidc.md @@ -0,0 +1,227 @@ +# Single Sign-On with OpenID Connect + +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. + +- [How it works](#how-it-works) +- [Configuration](#configuration) +- [Setting it up](#setting-it-up) +- [Provider examples](#provider-examples) +- [Customizing the login button](#customizing-the-login-button) +- [Security notes](#security-notes) +- [Troubleshooting](#troubleshooting) + +## How it works + +Zettelrobbe has exactly one account: the administrator created by the setup wizard. Single sign-on +does not add users, it is a second way to sign in to that one account. + +1. You click the SSO button. Zettelrobbe sends you to your provider using the standard + authorization code flow with PKCE (S256), `state` and `nonce`. +2. You sign in at the provider, which sends you back to `/auth/oidc/callback`. +3. Zettelrobbe exchanges the code for tokens and validates the ID token (signature, issuer, + audience, expiry, nonce). +4. It reads the e-mail address from the `email` claim (ID token, completed from the userinfo + endpoint) and compares it with `OIDC_ADMIN_EMAIL`, ignoring upper and lower case. +5. If they match, you are signed in as the administrator, whatever your username is at the + provider. Any other identity is refused. + +Nothing is provider-specific. Endpoints, signing keys and the supported client authentication +methods come from the provider's discovery document (`/.well-known/openid-configuration`). + +The password login keeps working next to SSO, so you can still get in when the provider is down. + +## Configuration + +Like every Zettelrobbe setting, SSO is configured through environment variables, usually in the +`environment:` section of your `docker-compose.yml`. Single sign-on only switches on when +`OIDC_ENABLED=yes` **and** the three required values are set. If one is missing or invalid, the +button stays hidden and the log says what is missing at startup: + +```text +[WARN] OIDC_ENABLED=yes but single sign-on stays off: OIDC_ADMIN_EMAIL is missing or not an e-mail address. +``` + +### Required + +| Variable | Example | Purpose | +| ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `OIDC_ENABLED` | `yes` | Turns single sign-on on. Default `no` | +| `OIDC_ISSUER_URL` | `https://auth.example.com/application/o/zettelrobbe/` | The provider's issuer URL (see [Provider examples](#provider-examples)). The full discovery URL also works | +| `OIDC_CLIENT_ID` | `zettelrobbe` | Client ID of the application registered at the provider | +| `OIDC_ADMIN_EMAIL` | `you@example.com` | Your e-mail address at the provider. Only the identity with this address can sign in | + +### Usually needed + +| Variable | Default | Purpose | +| -------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `OIDC_CLIENT_SECRET` | – | Client secret of a confidential client. Leave empty for a public client (PKCE is always used) | +| `OIDC_REDIRECT_URI` | derived from the request | The callback URL registered at the provider: `https:///auth/oidc/callback`. Set it explicitly behind a reverse proxy | + +### Optional + +| Variable | Default | Purpose | +| ------------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OIDC_SCOPES` | `openid profile email` | Requested scopes. `openid` is always added. Keep `email`, it carries the address | +| `OIDC_EMAIL_CLAIM` | `email` | Claim that holds the e-mail address, for providers that put it elsewhere | +| `OIDC_TOKEN_AUTH_METHOD` | automatic | How the client authenticates at the token endpoint: `client_secret_basic`, `client_secret_post` or `none`. Automatic picks `none` without a secret, otherwise `client_secret_basic` unless the provider only lists `client_secret_post` | +| `OIDC_BUTTON_TEXT` | `Sign in with SSO` | Label of the login button (up to 60 characters) | +| `OIDC_BUTTON_ICON` | key icon | Icon on the button: an `https://` image URL, a path on this server such as `/favicon.png`, or `none` | +| `OIDC_BUTTON_COLOR` | theme default | Background colour of the button: `#rrggbb`, `#rgb`, a colour name, `rgb(...)` or `hsl(...)` | +| `OIDC_BUTTON_TEXT_COLOR` | theme default | Text colour of the button, same formats | + +`OIDC_CLIENT_SECRET` is treated like the other secrets: it is masked in the settings page and in +logs. + +## Setting it up + +These steps work for every provider. The [examples](#provider-examples) below fill in the +provider-specific parts. + +1. **Register an application at your provider.** + - Flow: authorization code. Client type: confidential (with a secret) or public. + - Redirect URI: `https:///auth/oidc/callback`, exactly as the browser + reaches Zettelrobbe. + - Scopes: `openid profile email`. +2. **Restrict access at the provider.** Allow only your own user (or a group with only you in it) + to use the application. See [Security notes](#security-notes). +3. **Add the variables to your compose file.** + + ```yaml + services: + zettelrobbe: + environment: + OIDC_ENABLED: 'yes' + OIDC_ISSUER_URL: https://auth.example.com/application/o/zettelrobbe/ + OIDC_CLIENT_ID: zettelrobbe + OIDC_CLIENT_SECRET: ${ZETTELROBBE_OIDC_CLIENT_SECRET} + OIDC_REDIRECT_URI: https://zettelrobbe.example.com/auth/oidc/callback + OIDC_ADMIN_EMAIL: you@example.com + # Optional: make the button look like your provider + OIDC_BUTTON_TEXT: Sign in with Authentik + OIDC_BUTTON_COLOR: '#fd4b2d' + OIDC_BUTTON_TEXT_COLOR: white + # Behind a reverse proxy + TRUST_PROXY: '1' + ``` + + Keep the secret out of the compose file by putting it in the `.env` file next to it, as shown + with `${ZETTELROBBE_OIDC_CLIENT_SECRET}`. + +4. **Recreate the container** (`docker compose up -d`) and check the log for + `[INFO] Single sign-on enabled with issuer ...`. +5. **Open the login page.** The SSO button appears below the password form. + +The setup wizard has to be finished first: SSO signs in to the administrator account, so that +account must exist. + +## Provider examples + +### Authentik + +1. **Applications → Providers → Create → OAuth2/OpenID Provider.** + - Authorization flow: the default explicit or implicit consent flow. + - Client type: _Confidential_. Note the client ID and secret. + - Redirect URIs: `Strict`, `https://zettelrobbe.example.com/auth/oidc/callback`. + - Signing key: select a certificate (for example _authentik Self-signed Certificate_). +2. **Applications → Applications → Create.** Name it, choose a slug (for example `zettelrobbe`) + and select the provider from step 1. +3. **Restrict access:** on the application, open _Policy / Group / User Bindings_ and bind your + user or a group. Without a binding, every Authentik user may use the application. +4. Issuer URL: `https:///application/o//`. The provider's overview page + shows it as _OpenID Configuration Issuer_. + +### Authelia + +Add a client to the Authelia configuration (Authelia 4.38 or later syntax): + +```yaml +identity_providers: + oidc: + clients: + - client_id: 'zettelrobbe' + client_name: 'Zettelrobbe' + client_secret: '$pbkdf2-sha512$310000$...' # hashed secret + public: false + authorization_policy: 'two_factor' + redirect_uris: + - 'https://zettelrobbe.example.com/auth/oidc/callback' + scopes: ['openid', 'profile', 'email'] + require_pkce: true + pkce_challenge_method: 'S256' + token_endpoint_auth_method: 'client_secret_basic' +``` + +Issuer URL: `https://`. Restrict the client with an access control rule or a +dedicated authorization policy. + +### Keycloak + +1. In your realm: **Clients → Create client**, type _OpenID Connect_, client ID `zettelrobbe`. +2. Turn _Client authentication_ on and keep _Standard flow_ enabled. +3. _Valid redirect URIs_: `https://zettelrobbe.example.com/auth/oidc/callback`. +4. Copy the secret from the _Credentials_ tab. + +Issuer URL: `https:///realms/`. + +### Other providers + +| Provider | `OIDC_ISSUER_URL` | Notes | +| ------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Kanidm | `https:///oauth2/openid/` | The issuer is per client | +| Pocket ID | `https://` | | +| Zitadel | `https://` | | +| Microsoft Entra ID | `https://login.microsoftonline.com//v2.0` | `email` is an optional claim in Entra ID. Add it to the token configuration, or set `OIDC_EMAIL_CLAIM=preferred_username` when the UPN is your e-mail address | +| Google | `https://accounts.google.com` | | + +Any other provider works the same way as long as it publishes a discovery document and sends the +e-mail address in the ID token or from its userinfo endpoint. + +## Customizing the login button + +With no customization the button is a neutral secondary button reading _Sign in with SSO_ with a +key icon. Four variables change it: + +```yaml +OIDC_BUTTON_TEXT: Sign in with Authentik +OIDC_BUTTON_ICON: https://auth.example.com/media/zettelrobbe-icon.svg +OIDC_BUTTON_COLOR: '#fd4b2d' +OIDC_BUTTON_TEXT_COLOR: white +``` + +- `OIDC_BUTTON_ICON` accepts an `http(s)` URL or a path on the Zettelrobbe server. `none` removes + the icon. Anything else falls back to the key icon. +- The colours accept `#rgb`, `#rrggbb` (with optional alpha), colour names, `rgb(...)` and + `hsl(...)`. Other values are ignored and the theme colour is used. Quote hex colours in YAML, + because `#` starts a comment. + +## Security notes + +- **Whoever passes the check is the administrator.** Zettelrobbe only checks that the e-mail + address equals `OIDC_ADMIN_EMAIL`. Many providers let users change their own e-mail address, so + always restrict the application at the provider to your own account or a group with only you in + it. +- **MFA belongs to the provider.** An SSO login does not ask for Zettelrobbe's own TOTP code. + Enforce MFA at the provider instead. +- **Use HTTPS.** Plain-HTTP issuers work but log a warning. Use them only on a trusted network. +- **Logging out ends the Zettelrobbe session only.** Your session at the provider stays open, so + the next click on the SSO button signs you in again without a password prompt. Log out at the + provider as well on a shared computer. +- The sign-in transaction (state, nonce, PKCE verifier) lives in a signed, HTTP-only cookie that + expires after 10 minutes and can never be used as a session. +- The SSO endpoints share the login rate limit (`LOGIN_RATE_LIMIT_MAX`). + +## Troubleshooting + +| Symptom | Cause and fix | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No SSO button on the login page | Check the startup log for `OIDC_ENABLED=yes but single sign-on stays off: ...` and set what it names | +| _Single sign-on is unavailable: the identity provider could not be reached_ | Zettelrobbe could not load the discovery document. Check `OIDC_ISSUER_URL` and that the container can reach the provider (DNS, firewall, certificates). The log has the exact error | +| The provider reports an invalid or mismatched redirect URI | The redirect URI must match the registered one character for character, including `https://`. Set `OIDC_REDIRECT_URI` to the full URL, and behind a reverse proxy also `TRUST_PROXY` | +| _Single sign-on failed_ after returning from the provider | The token exchange failed. With `invalid_client` in the log, set `OIDC_TOKEN_AUTH_METHOD` to the method registered at the provider, and check the client secret | +| _The identity provider did not send the "email" claim_ | Add `email` to `OIDC_SCOPES` and allow that scope for the client at the provider, or point `OIDC_EMAIL_CLAIM` at the claim that holds the address | +| _This account may not sign in to Zettelrobbe_ | The e-mail address at the provider differs from `OIDC_ADMIN_EMAIL`. The log line `[FAILED LOGIN] OIDC sign-in refused for ...` shows the address the provider sent | +| _Your single sign-on session expired_ | More than 10 minutes passed at the provider, or the browser dropped the cookie. Start again from the login page. If it keeps happening behind HTTPS, check `COOKIE_SECURE_MODE` and `TRUST_PROXY` | +| _Zettelrobbe has no administrator account yet_ | Finish the setup wizard first | diff --git a/package-lock.json b/package-lock.json index 5d854c6d..1b20cbbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "jsonwebtoken": "^9.0.3", "node-cron": "^4.6.0", "openai": "^7.23.0", + "openid-client": "^6.8.8", "qrcode": "^1.5.4", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", @@ -2949,6 +2950,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3441,6 +3451,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3534,6 +3553,19 @@ "license": "MIT", "peer": true }, + "node_modules/openid-client": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.8.tgz", + "integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.12", + "oauth4webapi": "^3.8.8" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index e92ab614..c16a1760 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "jsonwebtoken": "^9.0.3", "node-cron": "^4.6.0", "openai": "^7.23.0", + "openid-client": "^6.8.8", "qrcode": "^1.5.4", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", diff --git a/public/css/pages/auth.css b/public/css/pages/auth.css index ecd63c6b..97e8a6d1 100644 --- a/public/css/pages/auth.css +++ b/public/css/pages/auth.css @@ -108,4 +108,39 @@ .login-back-button { align-self: center; } + + /* login: "or" rule between the password form and the single sign-on + button. */ + .login-sso__divider { + display: flex; + align-items: center; + gap: var(--zr-3); + color: var(--zr-text-faint); + } + .login-sso__divider::before, + .login-sso__divider::after { + content: ''; + flex: 1 1 auto; + height: 1px; + background: var(--zr-line); + } + + /* login: the single sign-on button. OIDC_BUTTON_COLOR and + OIDC_BUTTON_TEXT_COLOR arrive as these two custom properties on the + element; without them it is a regular secondary button. */ + .login-sso__button { + background: var(--login-sso-bg, var(--zr-surface)); + border-color: var(--login-sso-bg, var(--zr-line-strong)); + color: var(--login-sso-fg, var(--zr-text)); + } + .login-sso__button:hover { + background: var(--login-sso-bg, var(--zr-surface-2)); + border-color: var(--login-sso-bg, var(--zr-text-faint)); + filter: brightness(0.95); + } + .login-sso__icon { + width: 18px; + height: 18px; + object-fit: contain; + } } diff --git a/routes/oidc.js b/routes/oidc.js new file mode 100644 index 00000000..e8d375bc --- /dev/null +++ b/routes/oidc.js @@ -0,0 +1,228 @@ +const express = require('express'); +const router = express.Router(); +const rateLimit = require('express-rate-limit'); +const jwt = require('jsonwebtoken'); +const config = require('../config/config'); +const documentModel = require('../models/document'); +const oidcService = require('../services/oidcService'); +const { SESSION_TOKEN_TYPE } = require('./auth'); + +// Holds state, nonce and PKCE verifier between the redirect to the provider +// and the callback. Signed like the MFA challenge, with its own type claim so +// it can never pass for a session and a session can never pass for it. +const OIDC_TRANSACTION_COOKIE = 'zr_oidc_tx'; +const OIDC_TRANSACTION_TYPE = 'oidc-login'; +const OIDC_TRANSACTION_TTL_SECONDS = 10 * 60; +const SESSION_TTL_MS = 24 * 60 * 60 * 1000; + +function shouldUseSecureCookies(req) { + const mode = config.getCookieSecureMode(); + if (mode === 'always') return true; + if (mode === 'never') return false; + + const forwardedProto = String(req.headers['x-forwarded-proto'] || '') + .split(',')[0] + .trim() + .toLowerCase(); + return Boolean(req.secure || forwardedProto === 'https'); +} + +function renderLoginError(res, status, error) { + return res.status(status).render('login', { + error, + mfaRequired: false, + username: '', + sso: oidcService.getLoginButton(), + }); +} + +// Same budget as the password form: both start a sign-in. +const oidcLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: parseInt(process.env.LOGIN_RATE_LIMIT_MAX || '10', 10), + standardHeaders: true, + legacyHeaders: false, + handler: (_req, res) => + renderLoginError( + res, + 429, + 'Too many login attempts. Please wait a few minutes and try again.' + ), +}); + +/** + * @swagger + * /auth/oidc/login: + * get: + * summary: Start single sign-on with the configured OpenID Connect provider + * description: | + * Redirects the browser to the identity provider's authorization endpoint + * (authorization code flow with PKCE, state and nonce). Only available when + * OIDC_ENABLED=yes and OIDC_ISSUER_URL, OIDC_CLIENT_ID and OIDC_ADMIN_EMAIL + * are set. + * tags: + * - Authentication + * responses: + * 302: + * description: Redirect to the identity provider + * 404: + * description: Single sign-on is not enabled + * 502: + * description: The identity provider could not be reached; the login page is rendered with an error + */ +router.get('/auth/oidc/login', oidcLimiter, async (req, res) => { + if (!oidcService.isEnabled()) { + return res.status(404).send('Single sign-on is not enabled'); + } + + const jwtSecret = config.getJwtSecret(); + if (!jwtSecret) { + return res.status(500).send('Server misconfiguration: JWT secret missing'); + } + + try { + const { url, transaction } = await oidcService.startLogin(req); + const transactionToken = jwt.sign( + { challengeType: OIDC_TRANSACTION_TYPE, ...transaction }, + jwtSecret, + { expiresIn: OIDC_TRANSACTION_TTL_SECONDS } + ); + // SameSite=Lax: the provider sends the browser back with a top-level GET, + // which carries Lax cookies. + res.cookie(OIDC_TRANSACTION_COOKIE, transactionToken, { + httpOnly: true, + secure: shouldUseSecureCookies(req), + sameSite: 'lax', + path: '/', + maxAge: OIDC_TRANSACTION_TTL_SECONDS * 1000, + }); + return res.redirect(url); + } catch (error) { + console.error('[ERROR] OIDC login could not start:', error.message); + return renderLoginError( + res, + 502, + 'Single sign-on is unavailable: the identity provider could not be reached. Check OIDC_ISSUER_URL.' + ); + } +}); + +/** + * @swagger + * /auth/oidc/callback: + * get: + * summary: Complete single sign-on + * description: | + * Redirect target registered with the identity provider. Exchanges the + * authorization code, validates the ID token and signs in as the + * administrator account when the identity's e-mail address equals + * OIDC_ADMIN_EMAIL. Sets the session cookie. + * tags: + * - Authentication + * parameters: + * - in: query + * name: code + * schema: + * type: string + * description: Authorization code issued by the provider + * - in: query + * name: state + * schema: + * type: string + * description: State value echoed back by the provider + * responses: + * 302: + * description: Signed in, redirected to the dashboard + * headers: + * Set-Cookie: + * schema: + * type: string + * description: HTTP-only cookie containing the session JWT + * 400: + * description: Missing or expired sign-in transaction, or the provider reported an error + * 403: + * description: The identity's e-mail address does not match OIDC_ADMIN_EMAIL + * 404: + * description: Single sign-on is not enabled + */ +router.get('/auth/oidc/callback', oidcLimiter, async (req, res) => { + if (!oidcService.isEnabled()) { + return res.status(404).send('Single sign-on is not enabled'); + } + + const jwtSecret = config.getJwtSecret(); + if (!jwtSecret) { + return res.status(500).send('Server misconfiguration: JWT secret missing'); + } + + const transactionToken = req.cookies?.[OIDC_TRANSACTION_COOKIE]; + res.clearCookie(OIDC_TRANSACTION_COOKIE, { path: '/' }); + + let transaction; + try { + transaction = jwt.verify(transactionToken || '', jwtSecret); + if (transaction.challengeType !== OIDC_TRANSACTION_TYPE) { + throw new Error('Invalid transaction type'); + } + } catch { + return renderLoginError( + res, + 400, + 'Your single sign-on session expired. Please try again.' + ); + } + + const query = new URLSearchParams(req.originalUrl.split('?')[1] || ''); + + let claims; + try { + claims = await oidcService.completeLogin(query, transaction); + } catch (error) { + console.error('[ERROR] OIDC callback failed:', error.message); + return renderLoginError( + res, + 400, + 'Single sign-on failed. Please try again or sign in with your password.' + ); + } + + try { + const users = await documentModel.getUsers(); + const { user, error, email } = oidcService.authorize(claims, users); + + if (!user) { + console.warn( + '[FAILED LOGIN] OIDC sign-in refused for %s: %s', + email || '(no e-mail claim)', + error + ); + return renderLoginError(res, 403, error); + } + + const token = jwt.sign( + { + id: user.id, + username: user.username, + typ: SESSION_TOKEN_TYPE, + }, + jwtSecret, + { expiresIn: '24h' } + ); + res.cookie('jwt', token, { + httpOnly: true, + secure: shouldUseSecureCookies(req), + sameSite: 'lax', + path: '/', + maxAge: SESSION_TTL_MS, + }); + + console.log('[LOGIN] OIDC sign-in for user:', user.username); + return res.redirect('/dashboard'); + } catch (error) { + console.error('[ERROR] OIDC sign-in error:', error); + return renderLoginError(res, 500, 'An error occurred during login'); + } +}); + +module.exports = router; +module.exports.OIDC_TRANSACTION_COOKIE = OIDC_TRANSACTION_COOKIE; diff --git a/routes/setup.js b/routes/setup.js index d41598cb..c6dd0ed1 100644 --- a/routes/setup.js +++ b/routes/setup.js @@ -44,6 +44,7 @@ const entityNameMatcher = require('../services/entityNameMatcher'); const entityMatchAiService = require('../services/entityMatchAiService'); const duplicateReviewJobService = require('../services/duplicateReviewJobService'); const tagSimplifyService = require('../services/tagSimplifyService'); +const oidcService = require('../services/oidcService'); const { THUMBNAIL_CACHE_DIR, getThumbnailCachePath, @@ -89,6 +90,7 @@ const SETTINGS_SECRET_FIELDS = [ 'OCR_API_KEY', 'MISTRAL_API_KEY', 'API_KEY', + 'OIDC_CLIENT_SECRET', ]; function formatBytes(bytes) { @@ -395,8 +397,17 @@ const loginLimiter = rateLimit({ * example: "#FF5733" */ -// Routes that don't require authentication -let PUBLIC_ROUTES = ['/health', '/login', '/logout', '/setup', '/api/setup']; +// Routes that don't require authentication. /auth/oidc is served by +// routes/oidc.js, mounted ahead of this router; it is listed so the sign-in +// callback stays reachable whatever the mount order. +let PUBLIC_ROUTES = [ + '/health', + '/login', + '/logout', + '/setup', + '/api/setup', + '/auth/oidc', +]; /** * Returns true if the incoming request originates from localhost. @@ -746,6 +757,7 @@ function renderLoginView(res, options = {}) { error: options.error || null, mfaRequired: Boolean(options.mfaRequired), username: options.username || '', + sso: oidcService.getLoginButton(), }); } @@ -3595,6 +3607,24 @@ const ENV_EXPORT_GROUPS = [ 'CONFIG_SOURCE_MODE', ], }, + { + title: 'Single sign-on (OIDC)', + keys: [ + 'OIDC_ENABLED', + 'OIDC_ISSUER_URL', + 'OIDC_CLIENT_ID', + 'OIDC_CLIENT_SECRET', + 'OIDC_ADMIN_EMAIL', + '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', + ], + }, { title: 'Maintenance', keys: [ diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 65a4db53..a1107dcc 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -130,6 +130,7 @@ const TESTS = { 'token-limit-config': 'test-token-limit-config.js', 'truncate-returns-string': 'test-truncate-returns-string.js', 'session-token-type': 'test-session-token-type.js', + 'oidc-sso': 'test-oidc-sso.js', 'setup-complete-guard': 'test-setup-complete-guard.js', 'rescan-fetch-before-delete': 'test-rescan-fetch-before-delete.js', 'ocr-empty-result-guard': 'test-ocr-empty-result-guard.js', @@ -160,7 +161,7 @@ const TESTS = { }; const AREAS = { - auth: ['login-mfa-flow', 'rate-limiting', 'thumbnail-auth-guard'], + auth: ['login-mfa-flow', 'rate-limiting', 'thumbnail-auth-guard', 'oidc-sso'], ocr: [ 'ocr-fallback-ai-errors', 'ocr-startup-recovery', @@ -265,6 +266,7 @@ const AREAS = { 'thumbnail-cache-path-sanitization', 'url-base-validation', 'session-token-type', + 'oidc-sso', 'setup-complete-guard', ], duplicates: [ diff --git a/server.js b/server.js index a7e1bc22..3aca8a28 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,8 @@ const documentModel = require('./models/document'); const setupService = require('./services/setupService'); const { runStartupMigrations } = require('./services/startupMigrations'); const setupRoutes = require('./routes/setup'); +const oidcRoutes = require('./routes/oidc'); +const oidcService = require('./services/oidcService'); const { isAuthenticated } = require('./routes/auth'); const mistralOcrService = require('./services/mistralOcrService'); const ocrAutoProcessService = require('./services/ocrAutoProcessService'); @@ -1075,6 +1077,9 @@ async function scanDocuments(source = 'scheduler') { } // Routes +// Single sign-on comes first: its callback must be reachable without a +// session, before the setup router's authentication guard runs. +app.use('/', oidcRoutes); app.use('/', setupRoutes); // Development-only reference page for the zr UI framework. It ships no product @@ -1492,6 +1497,7 @@ async function startServer() { app.listen(port, () => { console.log(`Server running on port ${port}`); warnIfRemoteSetupExposed(); + oidcService.logStatus(); startScanning(); // Armed separately from the scan scheduler so the dashboard also warns // when automatic processing is switched off. diff --git a/services/loggerService.js b/services/loggerService.js index 7d31b73c..99851be4 100644 --- a/services/loggerService.js +++ b/services/loggerService.js @@ -43,6 +43,7 @@ const SECRET_ENV_KEYS = [ 'API_KEY', 'PAPERLESS_AI_API_KEY', 'EXTERNAL_API_KEY', + 'OIDC_CLIENT_SECRET', ]; /** diff --git a/services/oidcService.js b/services/oidcService.js new file mode 100644 index 00000000..8c0f6356 --- /dev/null +++ b/services/oidcService.js @@ -0,0 +1,575 @@ +/** + * OidcService + * + * Single sign-on through any OpenID Connect provider (Authentik, Authelia, + * Keycloak, Kanidm, Pocket ID, Zitadel, Entra ID, ...). It implements the + * standard authorization code flow with PKCE, state and nonce, and nothing + * provider-specific: endpoints and capabilities come from the provider's + * discovery document. + * + * Zettelrobbe has exactly one account, the administrator created by the setup + * wizard. SSO therefore does not provision or manage users: an identity whose + * e-mail address equals OIDC_ADMIN_EMAIL signs in as that account, every + * other identity is refused. Who may reach the application at all is decided + * at the identity provider. + * + * The protocol work is delegated to openid-client, which validates the ID + * token (signature, issuer, audience, expiry, nonce) and the PKCE exchange. + */ + +const config = require('../config/config'); + +// 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. +let openidClient = null; +function getOpenidClient() { + if (!openidClient) { + openidClient = require('openid-client'); + } + return openidClient; +} + +const CALLBACK_PATH = '/auth/oidc/callback'; +const DISCOVERY_SUFFIX = '/.well-known/openid-configuration'; +// Discovery and token requests should not leave a login hanging. +const REQUEST_TIMEOUT_SECONDS = 10; +const DEFAULT_BUTTON_TEXT = 'Sign in with SSO'; +const MAX_BUTTON_TEXT_LENGTH = 60; + +const TOKEN_AUTH_METHODS = [ + 'client_secret_basic', + 'client_secret_post', + 'none', +]; + +function stripTrailingSlash(value) { + return String(value).replace(/\/+$/, ''); +} + +/** + * Light syntax check for an e-mail address: one "@", no whitespace, and a dot + * in the domain. The address only has to be comparable with what the + * provider sends. + * + * @param {*} value + * @returns {boolean} + */ +function isValidEmail(value) { + const text = String(value ?? '').trim(); + return text.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text); +} + +/** + * True for an absolute http(s) URL. A redirect URI without its scheme + * ("app.example.com/auth/oidc/callback") is a common slip that every provider + * rejects with a redirect URI error. + * + * @param {string} value + * @returns {boolean} + */ +function isAbsoluteHttpUrl(value) { + try { + const url = new URL(String(value || '').trim()); + return url.protocol === 'https:' || url.protocol === 'http:'; + } catch { + return false; + } +} + +/** + * The issuer URL to discover. Operators often paste the full discovery URL + * instead of the issuer; its well-known suffix is removed. + * + * @param {string} value + * @returns {string} + */ +function normalizeIssuerUrl(value) { + const text = String(value || '').trim(); + return text.endsWith(DISCOVERY_SUFFIX) + ? text.slice(0, -DISCOVERY_SUFFIX.length) + : text; +} + +/** + * A colour for the login button, accepted only in plain CSS colour syntax: + * #rgb / #rrggbb (with optional alpha), a named colour, or an rgb()/hsl() + * function. Anything else is rejected because the value lands in a style + * attribute. + * + * @param {string} value + * @returns {boolean} + */ +function isSafeCssColor(value) { + const text = String(value || '').trim(); + return ( + /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(text) || + /^[a-z]{3,30}$/i.test(text) || + /^(?:rgb|rgba|hsl|hsla)\([0-9a-z.,%/\s]{1,60}\)$/i.test(text) + ); +} + +/** + * The icon shown on the login button. Empty means the built-in key icon, + * "none" hides it, and an http(s) URL or a same-origin path ("/logo.png") + * shows that image. Anything else falls back to the built-in icon. + * + * @param {string} value + * @returns {{ type: 'default'|'none'|'image', src?: string }} + */ +function resolveButtonIcon(value) { + const text = String(value || '').trim(); + if (!text) return { type: 'default' }; + if (text.toLowerCase() === 'none') return { type: 'none' }; + if (/^\/(?!\/)\S*$/.test(text)) return { type: 'image', src: text }; + try { + const url = new URL(text); + if (url.protocol === 'https:' || url.protocol === 'http:') { + return { type: 'image', src: url.href }; + } + } catch { + // Not a URL; fall through to the default icon. + } + return { type: 'default' }; +} + +/** + * Picks how the client authenticates at the token endpoint. + * + * OIDC_TOKEN_AUTH_METHOD wins when set. Without a client secret the client is + * public ("none"). Otherwise client_secret_basic, the default of the OAuth 2.0 + * and OpenID Connect specifications, unless the provider's discovery document + * lists only client_secret_post. + * + * @param {{ tokenAuthMethod: string, clientSecret: string }} settings + * @param {{ token_endpoint_auth_methods_supported?: string[] }} serverMetadata + * @returns {string} + */ +function selectTokenAuthMethod(settings, serverMetadata) { + const configured = String(settings.tokenAuthMethod || '') + .trim() + .toLowerCase(); + if (configured) { + if (!TOKEN_AUTH_METHODS.includes(configured)) { + throw new Error( + `Unsupported OIDC_TOKEN_AUTH_METHOD "${configured}". Use one of: ${TOKEN_AUTH_METHODS.join(', ')}.` + ); + } + return configured; + } + + if (!settings.clientSecret) { + return 'none'; + } + + const supported = serverMetadata?.token_endpoint_auth_methods_supported; + if ( + Array.isArray(supported) && + !supported.includes('client_secret_basic') && + supported.includes('client_secret_post') + ) { + return 'client_secret_post'; + } + return 'client_secret_basic'; +} + +/** + * The scopes to request, always including "openid". + * + * @param {{ scopes: string }} settings + * @returns {string} + */ +function resolveScopes(settings) { + const scopes = String(settings.scopes || '') + .split(/\s+/) + .filter(Boolean); + if (!scopes.includes('openid')) { + scopes.unshift('openid'); + } + return scopes.join(' '); +} + +function buildClientAuthentication(client, method, clientSecret) { + if (method === 'none') return client.None(); + if (!clientSecret) { + throw new Error( + `OIDC_TOKEN_AUTH_METHOD=${method} needs OIDC_CLIENT_SECRET to be set.` + ); + } + return method === 'client_secret_post' + ? client.ClientSecretPost(clientSecret) + : client.ClientSecretBasic(clientSecret); +} + +class OidcService { + constructor() { + this._clientConfigPromise = null; + this._clientConfigKey = null; + } + + /** @returns {ReturnType} */ + getSettings() { + return config.getOidcConfig(); + } + + /** + * What is missing or invalid in the configuration. Empty when SSO is off or + * fully configured. + * + * @returns {string[]} + */ + getConfigErrors() { + const settings = this.getSettings(); + if (!settings.enabled) return []; + + const errors = []; + try { + const issuer = new URL(normalizeIssuerUrl(settings.issuerUrl)); + if (issuer.protocol !== 'https:' && issuer.protocol !== 'http:') { + errors.push('OIDC_ISSUER_URL must be an http(s) URL'); + } + } catch { + errors.push('OIDC_ISSUER_URL is missing or not a URL'); + } + if (!settings.clientId) { + errors.push('OIDC_CLIENT_ID is missing'); + } + if (settings.redirectUri && !isAbsoluteHttpUrl(settings.redirectUri)) { + errors.push( + 'OIDC_REDIRECT_URI must be an absolute URL starting with https:// or http://' + ); + } + if (!isValidEmail(settings.adminEmail)) { + errors.push('OIDC_ADMIN_EMAIL is missing or not an e-mail address'); + } + return errors; + } + + /** + * True when SSO is switched on and completely configured. A half-configured + * provider is treated as off rather than shown as a button that can only + * fail; logStatus() tells the operator why. + * + * @returns {boolean} + */ + isEnabled() { + return this.getSettings().enabled && this.getConfigErrors().length === 0; + } + + /** + * Logs once at startup whether SSO is active and, when it is switched on + * but incomplete, what is missing. + */ + logStatus() { + const settings = this.getSettings(); + if (!settings.enabled) return; + const errors = this.getConfigErrors(); + if (errors.length > 0) { + console.warn( + `[WARN] OIDC_ENABLED=yes but single sign-on stays off: ${errors.join('; ')}.` + ); + return; + } + console.log( + `[INFO] Single sign-on enabled with issuer ${normalizeIssuerUrl(settings.issuerUrl)}` + ); + } + + /** + * What the login page needs to render the SSO button. + * + * @returns {{ enabled: boolean, text: string, icon: { type: string, src?: string }, style: string }} + */ + getLoginButton() { + const settings = this.getSettings(); + const text = + settings.buttonText.slice(0, MAX_BUTTON_TEXT_LENGTH) || + DEFAULT_BUTTON_TEXT; + const style = []; + if (isSafeCssColor(settings.buttonColor)) { + style.push(`--login-sso-bg: ${settings.buttonColor}`); + } + if (isSafeCssColor(settings.buttonTextColor)) { + style.push(`--login-sso-fg: ${settings.buttonTextColor}`); + } + return { + enabled: this.isEnabled(), + text, + icon: resolveButtonIcon(settings.buttonIcon), + style: style.join('; '), + }; + } + + /** + * Discovers the provider once and reuses the result. The cache is keyed on + * the settings it was built from, so changed settings take effect, and a + * failed discovery is not cached, so a provider that was down at the first + * attempt is retried on the next login. + * + * @returns {Promise} + */ + async getClientConfig() { + const settings = this.getSettings(); + const key = JSON.stringify([ + settings.issuerUrl, + settings.clientId, + settings.clientSecret, + settings.tokenAuthMethod, + ]); + + if (this._clientConfigPromise && this._clientConfigKey === key) { + return this._clientConfigPromise; + } + + this._clientConfigKey = key; + this._clientConfigPromise = this._buildClientConfig(settings).catch( + (error) => { + if (this._clientConfigKey === key) { + this._clientConfigPromise = null; + this._clientConfigKey = null; + } + throw error; + } + ); + + return this._clientConfigPromise; + } + + /** + * Reads the provider's discovery document and builds the client from it. + * + * @param {ReturnType} settings + * @returns {Promise} + */ + async _buildClientConfig(settings) { + const client = getOpenidClient(); + const issuer = new URL(normalizeIssuerUrl(settings.issuerUrl)); + const insecure = issuer.protocol === 'http:'; + const options = { timeout: REQUEST_TIMEOUT_SECONDS }; + if (insecure) { + // Plain HTTP is only acceptable on a trusted network, but homelab + // providers sometimes run that way behind the same reverse proxy. + console.warn( + '[WARN] OIDC_ISSUER_URL uses plain HTTP. Use HTTPS unless the provider is only reachable on a trusted network.' + ); + options.execute = [client.allowInsecureRequests]; + } + + const serverMetadata = await this._discover( + client, + issuer, + settings, + options + ); + const authMethod = selectTokenAuthMethod(settings, serverMetadata); + + const clientConfig = new client.Configuration( + serverMetadata, + settings.clientId, + undefined, + buildClientAuthentication(client, authMethod, settings.clientSecret) + ); + clientConfig.timeout = REQUEST_TIMEOUT_SECONDS; + if (insecure) { + client.allowInsecureRequests(clientConfig); + } + return clientConfig; + } + + /** + * Runs discovery and returns the provider metadata. OpenID Connect demands + * that the configured issuer equals the advertised one character for + * character, and providers disagree on the trailing slash (Authentik has + * one, Keycloak and Authelia do not). A mismatch that is only that slash is + * retried with the provider's spelling instead of failing the login. + * + * @returns {Promise} + */ + async _discover(client, issuer, settings, options) { + try { + const discovered = await client.discovery( + issuer, + settings.clientId, + undefined, + undefined, + options + ); + return discovered.serverMetadata(); + } catch (error) { + const advertised = error?.cause?.body?.issuer; + const differsOnlyBySlash = + error?.code === 'OAUTH_JSON_ATTRIBUTE_COMPARISON_FAILED' && + typeof advertised === 'string' && + stripTrailingSlash(advertised) === stripTrailingSlash(issuer.href); + if (!differsOnlyBySlash) { + throw error; + } + const discovered = await client.discovery( + new URL(advertised), + settings.clientId, + undefined, + undefined, + options + ); + return discovered.serverMetadata(); + } + } + + /** + * The redirect URI registered with the provider. OIDC_REDIRECT_URI wins; + * otherwise it is derived from the request, which honours TRUST_PROXY for + * the protocol and host when the app runs behind a reverse proxy. + * + * @param {import('express').Request} req + * @returns {string} + */ + resolveRedirectUri(req) { + const settings = this.getSettings(); + if (settings.redirectUri) { + return settings.redirectUri; + } + return `${req.protocol}://${req.get('host')}${CALLBACK_PATH}`; + } + + /** + * Builds the provider's authorization URL and the values the callback must + * check it against. + * + * @param {import('express').Request} req + * @returns {Promise<{ url: string, transaction: { state: string, nonce: string, codeVerifier: string, redirectUri: string } }>} + */ + async startLogin(req) { + const client = getOpenidClient(); + const settings = this.getSettings(); + const clientConfig = await this.getClientConfig(); + const redirectUri = this.resolveRedirectUri(req); + + const codeVerifier = client.randomPKCECodeVerifier(); + const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + const state = client.randomState(); + const nonce = client.randomNonce(); + + const url = client.buildAuthorizationUrl(clientConfig, { + redirect_uri: redirectUri, + scope: resolveScopes(settings), + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + nonce, + }); + + return { + url: url.href, + transaction: { state, nonce, codeVerifier, redirectUri }, + }; + } + + /** + * Exchanges the authorization code and returns the user's claims: the + * validated ID token claims, completed from the userinfo endpoint where the + * provider offers one (some providers keep the e-mail out of the ID token). + * + * @param {URLSearchParams|string} callbackQuery - the query string the provider redirected back with + * @param {{ state: string, nonce: string, codeVerifier: string, redirectUri: string }} transaction + * @returns {Promise} + */ + async completeLogin(callbackQuery, transaction) { + const client = getOpenidClient(); + const clientConfig = await this.getClientConfig(); + + // Rebuild the callback URL from the redirect URI that was sent to the + // provider rather than from the incoming request: openid-client sends + // this URL (minus the query) as redirect_uri in the token request, and it + // must match the authorization request exactly, whatever a reverse proxy + // did to the Host header in between. + const currentUrl = new URL(transaction.redirectUri); + currentUrl.search = new URLSearchParams(callbackQuery).toString(); + + const tokens = await client.authorizationCodeGrant( + clientConfig, + currentUrl, + { + pkceCodeVerifier: transaction.codeVerifier, + expectedState: transaction.state, + expectedNonce: transaction.nonce, + idTokenExpected: true, + } + ); + + const idClaims = tokens.claims() || {}; + let userInfo = {}; + if (clientConfig.serverMetadata().userinfo_endpoint) { + try { + userInfo = await client.fetchUserInfo( + clientConfig, + tokens.access_token, + idClaims.sub + ); + } catch (error) { + // The ID token alone may be enough; userinfo only adds claims. + console.warn( + '[WARN] OIDC userinfo request failed, continuing with ID token claims:', + error.message + ); + } + } + + // The ID token is authoritative for anything it carries. + return { ...userInfo, ...idClaims }; + } + + /** + * Decides whether an identity may sign in. Pure: it only looks at the + * claims and the given list of local users. + * + * The e-mail claim (OIDC_EMAIL_CLAIM, default "email") must equal + * OIDC_ADMIN_EMAIL, ignoring case. The identity then signs in as the one + * local account, whatever its username. + * + * @param {object} claims - the user's claims from completeLogin() + * @param {Array<{ id: number, username: string }>} users - local accounts + * @returns {{ user: object|null, email: string|null, error: string|null }} + */ + authorize(claims, users) { + const settings = this.getSettings(); + const value = claims ? claims[settings.emailClaim] : undefined; + const email = typeof value === 'string' && value.trim() ? value.trim() : ''; + + if (!email) { + return { + user: null, + email: null, + error: `The identity provider did not send the "${settings.emailClaim}" claim. Request the "email" scope (OIDC_SCOPES) or set OIDC_EMAIL_CLAIM.`, + }; + } + + if (email.toLowerCase() !== settings.adminEmail.toLowerCase()) { + return { + user: null, + email, + error: + 'This account may not sign in to Zettelrobbe. Its e-mail address does not match OIDC_ADMIN_EMAIL.', + }; + } + + const user = Array.isArray(users) && users.length > 0 ? users[0] : null; + if (!user) { + return { + user: null, + email, + error: + 'Zettelrobbe has no administrator account yet. Finish the setup first.', + }; + } + + return { user, email, error: null }; + } +} + +module.exports = new OidcService(); +module.exports.CALLBACK_PATH = CALLBACK_PATH; +module.exports.isValidEmail = isValidEmail; +module.exports.isSafeCssColor = isSafeCssColor; +module.exports.normalizeIssuerUrl = normalizeIssuerUrl; +module.exports.resolveButtonIcon = resolveButtonIcon; +module.exports.resolveScopes = resolveScopes; +module.exports.selectTokenAuthMethod = selectTokenAuthMethod; diff --git a/tests/test-oidc-sso.js b/tests/test-oidc-sso.js new file mode 100644 index 00000000..078ff7b3 --- /dev/null +++ b/tests/test-oidc-sso.js @@ -0,0 +1,678 @@ +'use strict'; + +/** + * Single sign-on through OpenID Connect (routes/oidc.js, services/oidcService.js). + * + * A fake provider runs on localhost and behaves like Authentik: an issuer with + * a path and a trailing slash, a discovery document, RS256 ID tokens and a + * userinfo endpoint. The token endpoint checks the PKCE verifier, the client + * secret and the redirect URI, so a regression in any of them fails the happy + * path. + * + * Covered: + * 1. The login page offers the SSO button only when OIDC is fully + * configured, and carries its customization. + * 2. /auth/oidc/login redirects with PKCE (S256), state and nonce. + * 3. The callback signs the administrator in when the e-mail matches + * OIDC_ADMIN_EMAIL, whatever the username at the provider. + * 4. Wrong state, a missing transaction cookie, a provider error and a + * different e-mail are all refused. + * 5. The transaction cookie is not a session and a session is not a + * transaction. + * 6. Provider differences: token endpoint auth methods, issuer spelling. + * 7. The pure helpers on their own. + */ + +const assert = require('assert'); +const crypto = require('crypto'); +const http = require('http'); +const path = require('path'); +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const { mountRouter, REPO_ROOT } = require('./helpers/mount-router'); + +const CLIENT_ID = 'zettelrobbe'; +const CLIENT_SECRET = 'test-client-secret-value'; + +let passed = 0; +let failed = 0; + +async function test(name, fn) { + try { + await fn(); + console.log(`✅ ${name}`); + passed++; + } catch (err) { + console.error(`❌ ${name}`); + console.error(` ${err.message}`); + failed++; + } +} + +// ── Fake OpenID Connect provider ──────────────────────────────────────────── + +function startFakeProvider() { + const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + const jwk = { + ...publicKey.export({ format: 'jwk' }), + kid: 'k1', + use: 'sig', + alg: 'RS256', + }; + + const provider = { + issuer: null, + // What the next login at the provider should return. + user: { + sub: 'u-1', + preferred_username: 'andy', + email: 'andy@example.com', + email_verified: false, + }, + // Claims the ID token leaves out and only userinfo carries. + userinfoOnly: [], + // code -> authorization request parameters + codes: new Map(), + lastTokenRequest: null, + // Advertised in discovery and enforced at the token endpoint, the way + // Authelia only accepts the method registered for the client. + authMethods: ['client_secret_basic', 'client_secret_post'], + lastAuthMethod: null, + }; + + const server = http.createServer((req, res) => { + const url = new URL(req.url, provider.base); + const send = (status, body) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + + if (url.pathname === '/application/o/zr/.well-known/openid-configuration') { + return send(200, { + issuer: provider.issuer, + authorization_endpoint: `${provider.base}/application/o/authorize/`, + token_endpoint: `${provider.base}/application/o/token/`, + userinfo_endpoint: `${provider.base}/application/o/userinfo/`, + end_session_endpoint: `${provider.base}/application/o/zr/end-session/`, + jwks_uri: `${provider.base}/application/o/zr/jwks/`, + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: provider.authMethods, + }); + } + + if (url.pathname === '/application/o/zr/jwks/') { + return send(200, { keys: [jwk] }); + } + + if (url.pathname === '/application/o/token/' && req.method === 'POST') { + let raw = ''; + req.on('data', (chunk) => (raw += chunk)); + req.on('end', () => { + const params = new URLSearchParams(raw); + provider.lastTokenRequest = params; + + // RFC 6749 2.3.1: Basic credentials are form-urlencoded, then base64. + let clientId = params.get('client_id'); + let clientSecret = params.get('client_secret'); + let method = clientSecret ? 'client_secret_post' : 'none'; + const authorization = req.headers.authorization || ''; + if (authorization.startsWith('Basic ')) { + const decoded = Buffer.from( + authorization.slice(6), + 'base64' + ).toString(); + const separator = decoded.indexOf(':'); + clientId = decodeURIComponent(decoded.slice(0, separator)); + clientSecret = decodeURIComponent(decoded.slice(separator + 1)); + method = 'client_secret_basic'; + } + provider.lastAuthMethod = method; + if (!provider.authMethods.includes(method)) { + return send(401, { error: 'invalid_client' }); + } + const grant = provider.codes.get(params.get('code')); + provider.codes.delete(params.get('code')); + if (!grant) return send(400, { error: 'invalid_grant' }); + + const challenge = crypto + .createHash('sha256') + .update(params.get('code_verifier') || '') + .digest('base64url'); + if ( + clientId !== CLIENT_ID || + clientSecret !== CLIENT_SECRET || + params.get('redirect_uri') !== grant.redirectUri || + challenge !== grant.codeChallenge + ) { + return send(400, { error: 'invalid_grant' }); + } + + // Like Authentik with "include claims in id_token" off: some claims + // only arrive through userinfo. + const idClaims = { ...grant.user }; + provider.userinfoOnly.forEach((claim) => delete idClaims[claim]); + const idToken = jwt.sign( + { ...idClaims, nonce: grant.nonce }, + privateKey.export({ format: 'pem', type: 'pkcs1' }), + { + algorithm: 'RS256', + keyid: 'k1', + issuer: provider.issuer, + audience: CLIENT_ID, + expiresIn: '5m', + } + ); + return send(200, { + access_token: `at-${grant.user.sub}`, + token_type: 'Bearer', + expires_in: 300, + id_token: idToken, + }); + }); + return undefined; + } + + if (url.pathname === '/application/o/userinfo/') { + return send(200, provider.lastUser); + } + + res.writeHead(404); + return res.end(); + }); + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + provider.base = `http://127.0.0.1:${server.address().port}`; + provider.issuer = `${provider.base}/application/o/zr/`; + provider.close = () => new Promise((r) => server.close(r)); + resolve(provider); + }); + }); +} + +/** Plays the provider's authorization endpoint: approves and issues a code. */ +function approveAtProvider(provider, authorizeUrl) { + const url = new URL(authorizeUrl); + const code = crypto.randomBytes(8).toString('hex'); + provider.lastUser = { ...provider.user }; + provider.codes.set(code, { + user: provider.lastUser, + nonce: url.searchParams.get('nonce'), + redirectUri: url.searchParams.get('redirect_uri'), + codeChallenge: url.searchParams.get('code_challenge'), + }); + return { code, state: url.searchParams.get('state') }; +} + +function setCookies(response) { + if (typeof response.headers.getSetCookie === 'function') { + return response.headers.getSetCookie(); + } + const raw = response.headers.get('set-cookie'); + return raw ? [raw] : []; +} + +function cookieValue(response, name) { + for (const cookie of setCookies(response)) { + const match = new RegExp(`^${name}=([^;]*)`).exec(cookie); + if (match && match[1]) return decodeURIComponent(match[1]); + } + return null; +} + +(async () => { + const provider = await startFakeProvider(); + + const oidcEnv = { + OIDC_ENABLED: 'yes', + OIDC_ISSUER_URL: provider.issuer, + OIDC_CLIENT_ID: CLIENT_ID, + OIDC_CLIENT_SECRET: CLIENT_SECRET, + OIDC_ADMIN_EMAIL: 'Andy@Example.com', + OIDC_BUTTON_TEXT: 'Sign in with Authentik', + LOGIN_RATE_LIMIT_MAX: '1000', + }; + const harness = await mountRouter({ env: oidcEnv }); + + // Mounted after the setup router, the reverse of server.js, so this also + // proves the setup router lets /auth/oidc through without a session. + const oidcRouter = require(path.join(REPO_ROOT, 'routes', 'oidc.js')); + harness.app.use(oidcRouter); + + // Views render as their name plus the locals under test. + harness.app.engine('ejs', (filePath, options, callback) => + callback( + null, + JSON.stringify({ + view: path.basename(filePath, '.ejs'), + error: options.error || null, + sso: options.sso || null, + }) + ) + ); + + const secret = process.env.JWT_SECRET; + await harness.documentModel.addUser('admin', await bcrypt.hash('pw', 4)); + + const get = (routePath, cookie) => + fetch(harness.base + routePath, { + headers: cookie ? { Cookie: cookie } : {}, + redirect: 'manual', + }); + + /** Runs /auth/oidc/login and the provider step; returns what the callback needs. */ + async function beginLogin() { + const res = await get('/auth/oidc/login'); + assert.strictEqual(res.status, 302, `login start returned ${res.status}`); + const tx = cookieValue(res, 'zr_oidc_tx'); + assert.ok(tx, 'transaction cookie is set'); + const location = res.headers.get('location'); + return { location, tx, ...approveAtProvider(provider, location) }; + } + + // ── 1. Login page ───────────────────────────────────────────────────────── + await test('login page offers the SSO button with its configured text', async () => { + const body = JSON.parse(await (await get('/login')).text()); + assert.strictEqual(body.view, 'login'); + assert.strictEqual(body.sso.enabled, true); + assert.strictEqual(body.sso.text, 'Sign in with Authentik'); + assert.deepStrictEqual(body.sso.icon, { type: 'default' }); + assert.strictEqual(body.sso.style, ''); + }); + + await test('button colours and icon come from the environment', async () => { + process.env.OIDC_BUTTON_COLOR = '#fd4b2d'; + process.env.OIDC_BUTTON_TEXT_COLOR = 'white'; + process.env.OIDC_BUTTON_ICON = 'https://auth.example.com/static/logo.svg'; + try { + const body = JSON.parse(await (await get('/login')).text()); + assert.strictEqual( + body.sso.style, + '--login-sso-bg: #fd4b2d; --login-sso-fg: white' + ); + assert.deepStrictEqual(body.sso.icon, { + type: 'image', + src: 'https://auth.example.com/static/logo.svg', + }); + } finally { + delete process.env.OIDC_BUTTON_COLOR; + delete process.env.OIDC_BUTTON_TEXT_COLOR; + delete process.env.OIDC_BUTTON_ICON; + } + }); + + await test('without OIDC_ADMIN_EMAIL the button is hidden and the routes are gone', async () => { + delete process.env.OIDC_ADMIN_EMAIL; + try { + const body = JSON.parse(await (await get('/login')).text()); + assert.strictEqual(body.sso.enabled, false); + assert.strictEqual((await get('/auth/oidc/login')).status, 404); + } finally { + process.env.OIDC_ADMIN_EMAIL = 'Andy@Example.com'; + } + }); + + // ── 2. Authorization request ────────────────────────────────────────────── + await test('/auth/oidc/login redirects with PKCE S256, state, nonce and scopes', async () => { + const { location } = await beginLogin(); + const url = new URL(location); + assert.strictEqual( + url.origin + url.pathname, + `${provider.base}/application/o/authorize/` + ); + assert.strictEqual(url.searchParams.get('client_id'), CLIENT_ID); + assert.strictEqual(url.searchParams.get('response_type'), 'code'); + assert.strictEqual(url.searchParams.get('code_challenge_method'), 'S256'); + assert.ok(url.searchParams.get('code_challenge')); + assert.ok(url.searchParams.get('state')); + assert.ok(url.searchParams.get('nonce')); + assert.strictEqual(url.searchParams.get('scope'), 'openid profile email'); + assert.strictEqual( + url.searchParams.get('redirect_uri'), + `${harness.base}/auth/oidc/callback` + ); + }); + + // ── 3. Happy path ───────────────────────────────────────────────────────── + let oidcSession = null; + await test('callback signs the administrator in when the e-mail matches', async () => { + const { tx, code, state } = await beginLogin(); + const res = await get( + `/auth/oidc/callback?code=${code}&state=${state}`, + `zr_oidc_tx=${tx}` + ); + assert.strictEqual(res.status, 302, await res.text()); + assert.strictEqual(res.headers.get('location'), '/dashboard'); + oidcSession = cookieValue(res, 'jwt'); + const payload = jwt.verify(oidcSession, secret); + assert.strictEqual(payload.typ, 'session'); + // The provider's username is "andy"; the local account is "admin". + assert.strictEqual(payload.username, 'admin'); + assert.strictEqual( + provider.lastTokenRequest.get('grant_type'), + 'authorization_code' + ); + }); + + await test('the SSO session opens a protected page', async () => { + const res = await get('/dashboard', `jwt=${oidcSession}`); + assert.notStrictEqual(res.status, 302, 'must not bounce to /login'); + }); + + // ── 4. Refusals ─────────────────────────────────────────────────────────── + await test('a mismatched state is refused', async () => { + const { tx, code } = await beginLogin(); + const res = await get( + `/auth/oidc/callback?code=${code}&state=forged`, + `zr_oidc_tx=${tx}` + ); + assert.strictEqual(res.status, 400); + assert.strictEqual(cookieValue(res, 'jwt'), null); + }); + + await test('a callback without the transaction cookie is refused', async () => { + const { code, state } = await beginLogin(); + const res = await get(`/auth/oidc/callback?code=${code}&state=${state}`); + assert.strictEqual(res.status, 400); + assert.match(JSON.parse(await res.text()).error, /expired/); + }); + + await test('a provider error response is refused', async () => { + const { tx, state } = await beginLogin(); + const res = await get( + `/auth/oidc/callback?error=access_denied&state=${state}`, + `zr_oidc_tx=${tx}` + ); + assert.strictEqual(res.status, 400); + assert.strictEqual(cookieValue(res, 'jwt'), null); + }); + + await test('an identity with another e-mail is refused', async () => { + provider.user = { + sub: 'u-2', + preferred_username: 'admin', + email: 'mallory@example.com', + }; + try { + const { tx, code, state } = await beginLogin(); + const res = await get( + `/auth/oidc/callback?code=${code}&state=${state}`, + `zr_oidc_tx=${tx}` + ); + assert.strictEqual(res.status, 403); + assert.match(JSON.parse(await res.text()).error, /OIDC_ADMIN_EMAIL/); + assert.strictEqual(cookieValue(res, 'jwt'), null); + } finally { + provider.user = { + sub: 'u-1', + preferred_username: 'andy', + email: 'andy@example.com', + }; + } + }); + + await test('an e-mail that only userinfo carries is used', async () => { + provider.userinfoOnly = ['email']; + try { + const { tx, code, state } = await beginLogin(); + const res = await get( + `/auth/oidc/callback?code=${code}&state=${state}`, + `zr_oidc_tx=${tx}` + ); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.headers.get('location'), '/dashboard'); + } finally { + provider.userinfoOnly = []; + } + }); + + // ── 5. Token separation ─────────────────────────────────────────────────── + await test('the transaction cookie is not accepted as a session', async () => { + const { tx } = await beginLogin(); + const res = await get('/dashboard', `jwt=${tx}`); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.headers.get('location'), '/login'); + }); + + await test('a session token is not accepted as a transaction', async () => { + const { code, state } = await beginLogin(); + const res = await get( + `/auth/oidc/callback?code=${code}&state=${state}`, + `zr_oidc_tx=${oidcSession}` + ); + assert.strictEqual(res.status, 400); + }); + + // ── 6. Options ──────────────────────────────────────────────────────────── + await test('with OIDC disabled the routes are gone and the button is hidden', async () => { + process.env.OIDC_ENABLED = 'no'; + try { + assert.strictEqual((await get('/auth/oidc/login')).status, 404); + assert.strictEqual( + (await get('/auth/oidc/callback?code=x&state=y')).status, + 404 + ); + const body = JSON.parse(await (await get('/login')).text()); + assert.strictEqual(body.sso.enabled, false); + } finally { + process.env.OIDC_ENABLED = 'yes'; + } + }); + + // ── 6b. Provider differences ────────────────────────────────────────────── + const oidcServiceForReset = require( + path.join(REPO_ROOT, 'services', 'oidcService.js') + ); + const resetDiscovery = () => { + oidcServiceForReset._clientConfigPromise = null; + oidcServiceForReset._clientConfigKey = null; + }; + async function fullLogin() { + const { tx, code, state } = await beginLogin(); + return get( + `/auth/oidc/callback?code=${code}&state=${state}`, + `zr_oidc_tx=${tx}` + ); + } + await test('client_secret_basic is the default when the provider offers it', async () => { + resetDiscovery(); + provider.authMethods = ['client_secret_basic']; + try { + const res = await fullLogin(); + assert.strictEqual(res.status, 302); + assert.strictEqual(provider.lastAuthMethod, 'client_secret_basic'); + } finally { + provider.authMethods = ['client_secret_basic', 'client_secret_post']; + resetDiscovery(); + } + }); + + await test('client_secret_post is used when it is all the provider lists', async () => { + resetDiscovery(); + provider.authMethods = ['client_secret_post']; + try { + const res = await fullLogin(); + assert.strictEqual(res.status, 302); + assert.strictEqual(provider.lastAuthMethod, 'client_secret_post'); + } finally { + provider.authMethods = ['client_secret_basic', 'client_secret_post']; + resetDiscovery(); + } + }); + + await test('OIDC_TOKEN_AUTH_METHOD overrides the automatic choice', async () => { + process.env.OIDC_TOKEN_AUTH_METHOD = 'client_secret_post'; + try { + const res = await fullLogin(); + assert.strictEqual(res.status, 302); + assert.strictEqual(provider.lastAuthMethod, 'client_secret_post'); + } finally { + delete process.env.OIDC_TOKEN_AUTH_METHOD; + } + }); + + await test('an unknown OIDC_TOKEN_AUTH_METHOD fails the login start', async () => { + process.env.OIDC_TOKEN_AUTH_METHOD = 'private_key_jwt'; + try { + const res = await get('/auth/oidc/login'); + assert.strictEqual(res.status, 502); + } finally { + delete process.env.OIDC_TOKEN_AUTH_METHOD; + } + }); + + await test('an issuer without the trailing slash the provider uses still works', async () => { + process.env.OIDC_ISSUER_URL = provider.issuer.replace(/\/$/, ''); + try { + const res = await fullLogin(); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.headers.get('location'), '/dashboard'); + } finally { + process.env.OIDC_ISSUER_URL = provider.issuer; + } + }); + + await test('the full discovery URL is accepted as OIDC_ISSUER_URL', async () => { + process.env.OIDC_ISSUER_URL = `${provider.issuer}.well-known/openid-configuration`; + try { + const res = await fullLogin(); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.headers.get('location'), '/dashboard'); + } finally { + process.env.OIDC_ISSUER_URL = provider.issuer; + } + }); + + await test('an issuer that differs by more than a slash is refused', async () => { + process.env.OIDC_ISSUER_URL = `${provider.base}/application/o/zr/other/`; + try { + const res = await get('/auth/oidc/login'); + assert.strictEqual(res.status, 502); + } finally { + process.env.OIDC_ISSUER_URL = provider.issuer; + } + }); + + // ── 7. Helpers ──────────────────────────────────────────────────────────── + const oidcService = require( + path.join(REPO_ROOT, 'services', 'oidcService.js') + ); + const users = [{ id: 1, username: 'admin' }]; + + await test('authorize() compares the e-mail case-insensitively', async () => { + const result = oidcService.authorize( + { email: ' ANDY@example.com ' }, + users + ); + assert.strictEqual(result.user && result.user.id, 1); + }); + + await test('authorize() refuses when the e-mail claim is missing', async () => { + const result = oidcService.authorize({ sub: 'x' }, users); + assert.strictEqual(result.user, null); + assert.match(result.error, /"email" claim/); + }); + + await test('authorize() reads OIDC_EMAIL_CLAIM', async () => { + process.env.OIDC_EMAIL_CLAIM = 'upn'; + try { + const result = oidcService.authorize({ upn: 'andy@example.com' }, users); + assert.strictEqual(result.user && result.user.id, 1); + } finally { + delete process.env.OIDC_EMAIL_CLAIM; + } + }); + + await test('authorize() refuses before the setup created the account', async () => { + const result = oidcService.authorize({ email: 'andy@example.com' }, []); + assert.strictEqual(result.user, null); + assert.match(result.error, /setup/); + }); + + await test('getConfigErrors() names every missing setting', async () => { + const saved = { ...process.env }; + process.env.OIDC_ISSUER_URL = ''; + process.env.OIDC_CLIENT_ID = ''; + process.env.OIDC_ADMIN_EMAIL = 'not-an-address'; + try { + const errors = oidcService.getConfigErrors().join(' | '); + assert.match(errors, /OIDC_ISSUER_URL/); + assert.match(errors, /OIDC_CLIENT_ID/); + assert.match(errors, /OIDC_ADMIN_EMAIL/); + } finally { + process.env.OIDC_ISSUER_URL = saved.OIDC_ISSUER_URL; + process.env.OIDC_CLIENT_ID = saved.OIDC_CLIENT_ID; + process.env.OIDC_ADMIN_EMAIL = saved.OIDC_ADMIN_EMAIL; + } + }); + + await test('a redirect URI without its scheme keeps SSO off', async () => { + process.env.OIDC_REDIRECT_URI = 'app.example.com/auth/oidc/callback'; + try { + assert.match( + oidcService.getConfigErrors().join(' | '), + /OIDC_REDIRECT_URI/ + ); + assert.strictEqual(oidcService.isEnabled(), false); + process.env.OIDC_REDIRECT_URI = + 'https://app.example.com/auth/oidc/callback'; + assert.deepStrictEqual(oidcService.getConfigErrors(), []); + } finally { + delete process.env.OIDC_REDIRECT_URI; + } + }); + + await test('resolveScopes() always includes openid', async () => { + assert.strictEqual( + oidcService.resolveScopes({ scopes: 'openid profile email' }), + 'openid profile email' + ); + assert.strictEqual( + oidcService.resolveScopes({ scopes: 'profile email' }), + 'openid profile email' + ); + }); + + await test('isSafeCssColor() accepts colours and rejects anything else', async () => { + [ + '#fff', + '#fd4b2dcc', + 'white', + 'rgb(253, 75, 45)', + 'hsl(9 98% 58%)', + ].forEach((value) => assert.ok(oidcService.isSafeCssColor(value), value)); + [ + 'red; background: url(x)', + 'url(https://x)', + 'expression(alert(1))', + '#ff"', + '', + ].forEach((value) => assert.ok(!oidcService.isSafeCssColor(value), value)); + }); + + await test('resolveButtonIcon() accepts URLs and paths, "none" hides it', async () => { + const icon = oidcService.resolveButtonIcon; + assert.deepStrictEqual(icon(''), { type: 'default' }); + assert.deepStrictEqual(icon('none'), { type: 'none' }); + assert.deepStrictEqual(icon('/favicon.png'), { + type: 'image', + src: '/favicon.png', + }); + assert.deepStrictEqual(icon('javascript:alert(1)'), { type: 'default' }); + assert.deepStrictEqual(icon('//evil.example/x.png'), { type: 'default' }); + }); + + await harness.close(); + await provider.close(); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/views/login.ejs b/views/login.ejs index 8969f3b8..f999ee80 100644 --- a/views/login.ejs +++ b/views/login.ejs @@ -116,6 +116,23 @@ + + <% const ssoButton = typeof sso === 'object' && sso ? sso : { enabled: false }; %> + <% if (ssoButton.enabled && !isMfaStep) { %> + + <% } %> From 35eee204760b2e3d04e3f07f9beb01a83e7cd05c Mon Sep 17 00:00:00 2001 From: Andy <4983703+krauseerl@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:48:11 +0200 Subject: [PATCH 2/5] fix(health): keep scanner and Paperless-ngx details behind authentication (#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 --- OPENAPI/openapi.json | 18 ++++- routes/setup.js | 40 +++++++++- schemas.js | 10 ++- scripts/run-tests.js | 2 + tests/test-health-disclosure.js | 96 ++++++++++++++++++++++++ tests/test-scanner-startup-resilience.js | 2 +- 6 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 tests/test-health-disclosure.js diff --git a/OPENAPI/openapi.json b/OPENAPI/openapi.json index 765d9718..ed374b2a 100644 --- a/OPENAPI/openapi.json +++ b/OPENAPI/openapi.json @@ -471,14 +471,24 @@ }, "message": { "type": "string", - "description": "Additional status information (for non-healthy states)", + "description": "Additional status information (for non-healthy states). Authenticated callers only.", "example": "Document scan failed 3 time(s) in a row: connect ECONNREFUSED 172.18.0.2:8000" }, "scanner": { - "$ref": "#/components/schemas/ScannerHealth" + "description": "Authenticated callers only.", + "allOf": [ + { + "$ref": "#/components/schemas/ScannerHealth" + } + ] }, "paperless": { - "$ref": "#/components/schemas/PaperlessHealth" + "description": "Authenticated callers only.", + "allOf": [ + { + "$ref": "#/components/schemas/PaperlessHealth" + } + ] } } }, @@ -6567,7 +6577,7 @@ "/health": { "get": { "summary": "System health check endpoint", - "description": "Reports database connectivity **and** whether the document scanner is\nactually able to work. Used by the Docker healthcheck and monitoring.\n\n`status` is `healthy` when the database is reachable and the scanner is\noperational, `degraded` when automatic processing is enabled but the\nscan loop is not armed or has failed `failureThreshold` runs in a row\n(for example because Paperless-ngx is unreachable), and `database_error`\nwhen the local database cannot be queried.\n\nA `degraded` state answers with HTTP 503 unless `HEALTHCHECK_STRICT=no`\nis configured, in which case the details are reported with HTTP 200.\nWhen automatic processing is switched off via\n`DISABLE_AUTOMATIC_PROCESSING=yes`, a missing scan loop is expected and\nnever reported as degraded.\n", + "description": "Reports database connectivity **and** whether the document scanner is\nactually able to work. Used by the Docker healthcheck and monitoring.\n\n`status` is `healthy` when the database is reachable and the scanner is\noperational, `degraded` when automatic processing is enabled but the\nscan loop is not armed or has failed `failureThreshold` runs in a row\n(for example because Paperless-ngx is unreachable), and `database_error`\nwhen the local database cannot be queried.\n\nA `degraded` state answers with HTTP 503 unless `HEALTHCHECK_STRICT=no`\nis configured, in which case the details are reported with HTTP 200.\nWhen automatic processing is switched off via\n`DISABLE_AUTOMATIC_PROCESSING=yes`, a missing scan loop is expected and\nnever reported as degraded.\n\nWithout authentication the response carries only `status` and\n`database`, with the same HTTP status codes. `message`, `scanner` and\n`paperless` hold raw error messages with internal host names and\nports, so they are returned only with a session (cookie or bearer\ntoken) or the `x-api-key` header.\n", "tags": [ "System" ], diff --git a/routes/setup.js b/routes/setup.js index c6dd0ed1..42f017d2 100644 --- a/routes/setup.js +++ b/routes/setup.js @@ -590,6 +590,20 @@ function hasValidSession(req) { return Boolean(jwtSecret && token && verifySessionToken(token, jwtSecret)); } +/** + * True when a public route is called with a usable session or the API key. + * Like hasValidSession(), for routes the authentication guard lets through. + */ +function isAuthenticatedCaller(req) { + const currentApiKey = config.getApiKey(); + const apiKey = req.headers['x-api-key']; + if (currentApiKey && apiKey && apiKey === currentApiKey) { + return true; + } + + return hasValidSession(req); +} + /** * @swagger * /login: @@ -7782,6 +7796,12 @@ function buildScannerHealthSnapshot() { * When automatic processing is switched off via * `DISABLE_AUTOMATIC_PROCESSING=yes`, a missing scan loop is expected and * never reported as degraded. + * + * Without authentication the response carries only `status` and + * `database`, with the same HTTP status codes. `message`, `scanner` and + * `paperless` hold raw error messages with internal host names and + * ports, so they are returned only with a session (cookie or bearer + * token) or the `x-api-key` header. * tags: * - System * responses: @@ -7828,8 +7848,22 @@ router.get('/health', async (req, res) => { const snapshot = buildScannerHealthSnapshot(); const degraded = snapshot.scanner.degraded; + const status = degraded ? 'degraded' : 'healthy'; + + // /health is public for container healthchecks and uptime monitors, which + // only need the status and the HTTP code. The scanner and Paperless-ngx + // details carry raw error messages ("connect ECONNREFUSED 172.18.0.2:8000") + // and the schedule, so only an authenticated caller gets them. + if (!isAuthenticatedCaller(req)) { + const summary = { status, database: 'ok' }; + if (degraded && scanHealthService.strictHealthEnabled) { + return res.status(503).json(summary); + } + return res.json(summary); + } + const payload = { - status: degraded ? 'degraded' : 'healthy', + status, database: 'ok', ...snapshot, }; @@ -7849,7 +7883,9 @@ router.get('/health', async (req, res) => { console.error('Health check failed:', error); res.status(500).json({ status: 'error', - message: error.message, + message: isAuthenticatedCaller(req) + ? error.message + : 'Health check failed', }); } }); diff --git a/schemas.js b/schemas.js index a55f1ed5..5ea569bf 100644 --- a/schemas.js +++ b/schemas.js @@ -352,12 +352,16 @@ * example: ok * message: * type: string - * description: Additional status information (for non-healthy states) + * description: Additional status information (for non-healthy states). Authenticated callers only. * example: "Document scan failed 3 time(s) in a row: connect ECONNREFUSED 172.18.0.2:8000" * scanner: - * $ref: '#/components/schemas/ScannerHealth' + * description: Authenticated callers only. + * allOf: + * - $ref: '#/components/schemas/ScannerHealth' * paperless: - * $ref: '#/components/schemas/PaperlessHealth' + * description: Authenticated callers only. + * allOf: + * - $ref: '#/components/schemas/PaperlessHealth' * * ScannerHealth: * type: object diff --git a/scripts/run-tests.js b/scripts/run-tests.js index a1107dcc..21c00199 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -130,6 +130,7 @@ const TESTS = { 'token-limit-config': 'test-token-limit-config.js', 'truncate-returns-string': 'test-truncate-returns-string.js', 'session-token-type': 'test-session-token-type.js', + 'health-disclosure': 'test-health-disclosure.js', 'oidc-sso': 'test-oidc-sso.js', 'setup-complete-guard': 'test-setup-complete-guard.js', 'rescan-fetch-before-delete': 'test-rescan-fetch-before-delete.js', @@ -266,6 +267,7 @@ const AREAS = { 'thumbnail-cache-path-sanitization', 'url-base-validation', 'session-token-type', + 'health-disclosure', 'oidc-sso', 'setup-complete-guard', ], diff --git a/tests/test-health-disclosure.js b/tests/test-health-disclosure.js new file mode 100644 index 00000000..b4e33798 --- /dev/null +++ b/tests/test-health-disclosure.js @@ -0,0 +1,96 @@ +'use strict'; + +/** + * Regression test for information disclosure on GET /health (CWE-200). + * + * /health is public so container healthchecks and uptime monitors can reach + * it. It used to return the whole scanner and Paperless-ngx snapshot to + * anyone, including raw error messages such as + * "connect ECONNREFUSED 172.18.0.2:8000" that name internal hosts and ports. + * An unauthenticated caller now gets only `status` and `database`; a session + * or the API key still gets the full payload. + */ + +const assert = require('assert'); +const jwt = require('jsonwebtoken'); +const { mountRouter } = require('./helpers/mount-router'); + +let passed = 0; +let failed = 0; + +async function test(name, fn) { + try { + await fn(); + console.log(`✅ ${name}`); + passed++; + } catch (err) { + console.error(`❌ ${name}`); + console.error(` ${err.message}`); + failed++; + } +} + +(async () => { + const harness = await mountRouter({}); + const sessionToken = jwt.sign( + { id: 1, username: 'admin', typ: 'session' }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + const challengeToken = jwt.sign( + { id: 1, username: 'admin', challengeType: 'mfa-login' }, + process.env.JWT_SECRET, + { expiresIn: '5m' } + ); + + const health = (headers = {}) => + fetch(harness.base + '/health', { headers }).then(async (res) => ({ + status: res.status, + body: await res.json(), + })); + + try { + await test('an anonymous caller gets only status and database', async () => { + const { status, body } = await health(); + assert.ok(status === 200 || status === 503, `got HTTP ${status}`); + assert.deepStrictEqual(Object.keys(body).sort(), ['database', 'status']); + }); + + await test('a wrong API key is treated as anonymous', async () => { + const { body } = await health({ 'x-api-key': 'wrong-key' }); + assert.deepStrictEqual(Object.keys(body).sort(), ['database', 'status']); + }); + + await test('an MFA challenge token is treated as anonymous', async () => { + const { body } = await health({ Cookie: `jwt=${challengeToken}` }); + assert.deepStrictEqual(Object.keys(body).sort(), ['database', 'status']); + }); + + await test('a session gets the scanner and Paperless-ngx details', async () => { + const { body } = await health({ Cookie: `jwt=${sessionToken}` }); + assert.ok(body.scanner, 'scanner details expected'); + assert.ok(body.paperless, 'paperless details expected'); + }); + + await test('the API key gets the scanner and Paperless-ngx details', async () => { + const { body } = await health({ 'x-api-key': process.env.API_KEY }); + assert.ok(body.scanner, 'scanner details expected'); + assert.ok(body.paperless, 'paperless details expected'); + }); + + await test('anonymous and authenticated callers see the same status', async () => { + const anonymous = await health(); + const authenticated = await health({ 'x-api-key': process.env.API_KEY }); + assert.strictEqual(anonymous.status, authenticated.status); + assert.strictEqual(anonymous.body.status, authenticated.body.status); + }); + } finally { + await harness.close(); + } + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tests/test-scanner-startup-resilience.js b/tests/test-scanner-startup-resilience.js index f00c042f..9534502c 100644 --- a/tests/test-scanner-startup-resilience.js +++ b/tests/test-scanner-startup-resilience.js @@ -383,7 +383,7 @@ test('/health reports the scanner state', () => { 'Expected /health to include the scanner snapshot' ); assert.ok( - setupRouteSource.includes("status: degraded ? 'degraded' : 'healthy'"), + setupRouteSource.includes("degraded ? 'degraded' : 'healthy'"), 'Expected /health to expose a degraded status' ); }); From 962044f2a4af1482810c3e6629bb30605ba34a33 Mon Sep 17 00:00:00 2001 From: Andy <4983703+krauseerl@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:50:06 +0200 Subject: [PATCH 3/5] fix(auth): refuse a TOTP code that was already accepted (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- routes/setup.js | 64 +++++++++++++--- scripts/run-tests.js | 2 + tests/test-totp-replay.js | 149 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 tests/test-totp-replay.js diff --git a/routes/setup.js b/routes/setup.js index 42f017d2..974983da 100644 --- a/routes/setup.js +++ b/routes/setup.js @@ -737,18 +737,24 @@ function getAuthenticatedSettingsUsername(req) { return null; } -function verifyTotpToken(secret, inputToken) { +/** + * Returns the TOTP time step the code belongs to, or null when the code does + * not match any step inside the accepted window. + * + * @param {string} secret - base32 TOTP secret + * @param {string} inputToken - the code the user typed + * @returns {number|null} + */ +function findTotpStep(secret, inputToken) { const normalizedInput = String(inputToken || '').replace(/\s+/g, ''); if (!/^\d{6,8}$/.test(normalizedInput)) { - return false; + return null; } const now = Math.floor(Date.now() / 1000); for (let offset = -TOTP_WINDOW; offset <= TOTP_WINDOW; offset += 1) { - const expected = generateTotpToken( - secret, - now + offset * TOTP_STEP_SECONDS - ); + const unixTimeSeconds = now + offset * TOTP_STEP_SECONDS; + const expected = generateTotpToken(secret, unixTimeSeconds); if (!expected || expected.length !== normalizedInput.length) { continue; } @@ -759,11 +765,47 @@ function verifyTotpToken(secret, inputToken) { Buffer.from(normalizedInput) ) ) { - return true; + return Math.floor(unixTimeSeconds / TOTP_STEP_SECONDS); } } - return false; + return null; +} + +function verifyTotpToken(secret, inputToken) { + return findTotpStep(secret, inputToken) !== null; +} + +// Last accepted TOTP time step per username. RFC 6238 section 5.2: once a +// code has been accepted, the verifier must not accept it again. Kept in +// memory: a restart forgets it, and the window it protects is 90 seconds. +const lastAcceptedTotpSteps = new Map(); + +/** + * Verifies a TOTP code and consumes it, so the same code (or an older one) + * is refused for that user until a newer time step comes up. For actions a + * captured code must not be able to repeat: signing in, enabling and + * disabling MFA. + * + * @param {string} username - the account the code is for + * @param {string} secret - base32 TOTP secret + * @param {string} inputToken - the code the user typed + * @returns {boolean} + */ +function consumeTotpToken(username, secret, inputToken) { + const step = findTotpStep(secret, inputToken); + if (step === null) { + return false; + } + + const key = String(username || '').toLowerCase(); + const lastStep = lastAcceptedTotpSteps.get(key); + if (lastStep !== undefined && step <= lastStep) { + return false; + } + + lastAcceptedTotpSteps.set(key, step); + return true; } function renderLoginView(res, options = {}) { @@ -901,7 +943,7 @@ router.post('/login', loginLimiter, async (req, res) => { }); } - if (!verifyTotpToken(mfaSecret, mfaToken)) { + if (!consumeTotpToken(user.username, mfaSecret, mfaToken)) { return renderLoginView(res, { error: 'Invalid authentication code. Please try again.', mfaRequired: true, @@ -7194,7 +7236,7 @@ router.post( }); } - if (!verifyTotpToken(payload.secret, token)) { + if (!consumeTotpToken(username, payload.secret, token)) { return res .status(400) .json({ success: false, error: 'Invalid authentication code.' }); @@ -7371,7 +7413,7 @@ router.post( .json({ success: false, error: 'MFA is not enabled for this user.' }); } - if (!verifyTotpToken(user.mfa_secret, token)) { + if (!consumeTotpToken(username, user.mfa_secret, token)) { return res .status(400) .json({ success: false, error: 'Invalid authentication code.' }); diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 21c00199..03da5389 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -131,6 +131,7 @@ const TESTS = { 'truncate-returns-string': 'test-truncate-returns-string.js', 'session-token-type': 'test-session-token-type.js', 'health-disclosure': 'test-health-disclosure.js', + 'totp-replay': 'test-totp-replay.js', 'oidc-sso': 'test-oidc-sso.js', 'setup-complete-guard': 'test-setup-complete-guard.js', 'rescan-fetch-before-delete': 'test-rescan-fetch-before-delete.js', @@ -268,6 +269,7 @@ const AREAS = { 'url-base-validation', 'session-token-type', 'health-disclosure', + 'totp-replay', 'oidc-sso', 'setup-complete-guard', ], diff --git a/tests/test-totp-replay.js b/tests/test-totp-replay.js new file mode 100644 index 00000000..01fa2ffc --- /dev/null +++ b/tests/test-totp-replay.js @@ -0,0 +1,149 @@ +'use strict'; + +/** + * Regression test for TOTP code replay (RFC 6238 section 5.2, CWE-294). + * + * The MFA step of POST /login accepted the same six-digit code any number of + * times while it stayed inside the ±1 step window, so a code read off a + * shoulder, a screen share or a phishing page could open a second session for + * up to 90 seconds after the user's own sign-in. The verifier now remembers + * the last accepted time step per user and refuses that step and older ones. + * + * Covered here: + * 1. A code that signed in once is refused on a second sign-in. + * 2. The code of the next time step is still accepted afterwards. + * 3. A wrong code is refused without consuming anything. + */ + +const assert = require('assert'); +const crypto = require('crypto'); +const path = require('path'); +const bcrypt = require('bcryptjs'); +const { mountRouter } = require('./helpers/mount-router'); + +const PASSWORD = 'correct-horse-battery'; +const SECRET = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'; + +let passed = 0; +let failed = 0; + +async function test(name, fn) { + try { + await fn(); + console.log(`✅ ${name}`); + passed++; + } catch (err) { + console.error(`❌ ${name}`); + console.error(` ${err.message}`); + failed++; + } +} + +/** RFC 6238 code for SECRET at the given time step. */ +function totpForStep(step) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = ''; + for (const char of SECRET) { + bits += alphabet.indexOf(char).toString(2).padStart(5, '0'); + } + const key = Buffer.from(bits.match(/.{8}/g).map((b) => parseInt(b, 2))); + const counter = Buffer.alloc(8); + counter.writeBigUInt64BE(BigInt(step)); + const hmac = crypto.createHmac('sha1', key).update(counter).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const code = + ((hmac[offset] & 0x7f) << 24) | + (hmac[offset + 1] << 16) | + (hmac[offset + 2] << 8) | + hmac[offset + 3]; + return String(code % 1000000).padStart(6, '0'); +} + +function setCookies(response) { + if (typeof response.headers.getSetCookie === 'function') { + return response.headers.getSetCookie(); + } + const raw = response.headers.get('set-cookie'); + return raw ? [raw] : []; +} + +function cookieValue(response, name) { + for (const cookie of setCookies(response)) { + const match = new RegExp(`^${name}=([^;]*)`).exec(cookie); + if (match && match[1]) { + return match[1]; + } + } + return null; +} + +(async () => { + const harness = await mountRouter({}); + harness.app.engine('ejs', (filePath, _options, callback) => + callback(null, `view:${path.basename(filePath, '.ejs')}`) + ); + await harness.documentModel.addUser('admin', await bcrypt.hash(PASSWORD, 4)); + await harness.documentModel.setUserMfaSettings('admin', true, SECRET); + + const post = (body, cookie) => + fetch(harness.base + '/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(cookie ? { Cookie: cookie } : {}), + }, + body: new URLSearchParams(body).toString(), + redirect: 'manual', + }); + + // Password step, then the MFA step with the given code. Returns the + // response of the MFA step. + async function signIn(code) { + const passwordStep = await post({ username: 'admin', password: PASSWORD }); + const challenge = cookieValue(passwordStep, 'mfa_challenge'); + assert.ok(challenge, 'the password step must hand out an MFA challenge'); + return post({ mfaStep: '1', mfaToken: code }, `mfa_challenge=${challenge}`); + } + + // The current step and the next one: both stay inside the accepted ±1 + // window even if the clock crosses a step boundary while the test runs. + const currentStep = Math.floor(Date.now() / 1000 / 30); + const olderCode = totpForStep(currentStep); + const newerCode = totpForStep(currentStep + 1); + + try { + await test('a wrong code is refused', async () => { + const wrong = olderCode === '000000' ? '000001' : '000000'; + const res = await signIn(wrong); + assert.strictEqual(res.status, 200); + assert.strictEqual(cookieValue(res, 'jwt'), null); + }); + + await test('a valid code signs in once', async () => { + const res = await signIn(olderCode); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.headers.get('location'), '/dashboard'); + assert.ok(cookieValue(res, 'jwt'), 'a session cookie must be set'); + }); + + await test('the same code is refused on a second sign-in', async () => { + const res = await signIn(olderCode); + assert.strictEqual(res.status, 200); + assert.strictEqual(cookieValue(res, 'jwt'), null); + }); + + await test('the code of a newer time step is still accepted', async () => { + const res = await signIn(newerCode); + assert.strictEqual(res.status, 302); + assert.ok(cookieValue(res, 'jwt'), 'a session cookie must be set'); + }); + } finally { + await harness.close(); + } + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})().catch((error) => { + console.error(error); + process.exit(1); +}); From c8b2ce27bdf72682ba0c654a3723c3e574225e70 Mon Sep 17 00:00:00 2001 From: Andy <4983703+krauseerl@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:51:09 +0200 Subject: [PATCH 4/5] fix(deps): update proxy-addr, ip-address and brace-expansion (#3) 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 --- package-lock.json | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1b20cbbf..09d577b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1131,9 +1131,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2831,9 +2831,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "license": "MIT", "engines": { "node": ">= 12" @@ -3832,9 +3832,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", - "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3906,9 +3906,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -3916,6 +3916,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -4678,9 +4682,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4764,9 +4768,9 @@ } }, "node_modules/stylelint": { - "version": "17.15.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.15.0.tgz", - "integrity": "sha512-mWIkesYQvQjf4Kvdeu9ns0IL8/K/wtjaGxPqsPd6DlLZvaQxGysJsocxUDrBcyHQ5VleAk4uSMat4yMrVED7PA==", + "version": "17.16.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.16.0.tgz", + "integrity": "sha512-ejlWnbJ13x1GU32+zZXa6sYQJsKKkwSdv2B4rAdO3OWdEmvhSJKcXi5pOphQYSzyaj1kXpuGJr7pcMBNfDIuPw==", "dev": true, "funding": [ { From e1e16a136dc6c6fd189aa2ff9481ab299959b4f5 Mon Sep 17 00:00:00 2001 From: Andy <4983703+krauseerl@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:02:34 +0200 Subject: [PATCH 5/5] fix(auth): stop POST /login from revealing whether a username exists (#4) 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 --- routes/setup.js | 43 +++++++++++ scripts/run-tests.js | 2 + tests/test-login-username-timing.js | 113 ++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 tests/test-login-username-timing.js diff --git a/routes/setup.js b/routes/setup.js index 974983da..35e1542b 100644 --- a/routes/setup.js +++ b/routes/setup.js @@ -821,6 +821,48 @@ function isMfaEnabledForUser(user) { return Boolean(user && (user.mfa_enabled || user.mfaEnabled)); } +/* Cost the setup wizard hashes the administrator password with. */ +const ADMIN_PASSWORD_BCRYPT_ROUNDS = 15; +const dummyPasswordHashes = new Map(); + +/** + * Spends the same bcrypt work on a failed username lookup as on a real + * password check, so the response time no longer tells an attacker whether + * the username exists (CWE-208). The dummy hash uses the cost of the stored + * account when there is one, because the comparison time grows with it. + * + * @param {*} password - the submitted password + * @returns {Promise} + */ +async function spendPasswordCheckTime(password) { + let rounds = ADMIN_PASSWORD_BCRYPT_ROUNDS; + try { + const users = await documentModel.getUsers(); + const storedHash = users?.[0]?.password; + if (storedHash) { + rounds = bcrypt.getRounds(storedHash); + } + } catch { + // Keep the default cost. + } + + if (!dummyPasswordHashes.has(rounds)) { + dummyPasswordHashes.set( + rounds, + bcrypt.hash(crypto.randomBytes(32).toString('hex'), rounds) + ); + } + + try { + await bcrypt.compare( + String(password ?? ''), + await dummyPasswordHashes.get(rounds) + ); + } catch { + // Only the elapsed time matters here. + } +} + router.get('/login', (req, res) => { //check if a user exists beforehand documentModel.getUsers().then((users) => { @@ -975,6 +1017,7 @@ router.post('/login', loginLimiter, async (req, res) => { const user = await documentModel.getUser(username); if (!user || !user.password) { + await spendPasswordCheckTime(password); console.log('[FAILED LOGIN] User not found or invalid data:', username); return renderLoginView(res, { error: 'Invalid credentials', username }); } diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 03da5389..ad179e2f 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -132,6 +132,7 @@ const TESTS = { 'session-token-type': 'test-session-token-type.js', 'health-disclosure': 'test-health-disclosure.js', 'totp-replay': 'test-totp-replay.js', + 'login-username-timing': 'test-login-username-timing.js', 'oidc-sso': 'test-oidc-sso.js', 'setup-complete-guard': 'test-setup-complete-guard.js', 'rescan-fetch-before-delete': 'test-rescan-fetch-before-delete.js', @@ -270,6 +271,7 @@ const AREAS = { 'session-token-type', 'health-disclosure', 'totp-replay', + 'login-username-timing', 'oidc-sso', 'setup-complete-guard', ], diff --git a/tests/test-login-username-timing.js b/tests/test-login-username-timing.js new file mode 100644 index 00000000..2b49eb38 --- /dev/null +++ b/tests/test-login-username-timing.js @@ -0,0 +1,113 @@ +'use strict'; + +/** + * Regression test for username enumeration through POST /login response time + * (CWE-208). + * + * A known username used to cost one bcrypt comparison, an unknown one none at + * all, so the unknown name answered in a few milliseconds and the real one + * after the full bcrypt work (cost 15 for an account the setup wizard + * created). A single timed request told an attacker whether a username exists. + * + * The unknown-username branch now runs the same bcrypt comparison against a + * dummy hash with the stored account's cost. Measuring wall-clock time in CI + * would be flaky, so the test counts bcrypt comparisons and checks their cost + * instead. + */ + +const assert = require('assert'); +const path = require('path'); +const bcrypt = require('bcryptjs'); +const { mountRouter } = require('./helpers/mount-router'); + +const PASSWORD = 'correct-horse-battery'; +const ROUNDS = 5; + +let passed = 0; +let failed = 0; + +async function test(name, fn) { + try { + await fn(); + console.log(`✅ ${name}`); + passed++; + } catch (err) { + console.error(`❌ ${name}`); + console.error(` ${err.message}`); + failed++; + } +} + +(async () => { + const harness = await mountRouter({}); + // Only the comparisons matter, not the markup. + harness.app.engine('ejs', (filePath, _options, callback) => + callback(null, `view:${path.basename(filePath, '.ejs')}`) + ); + await harness.documentModel.addUser( + 'admin', + await bcrypt.hash(PASSWORD, ROUNDS) + ); + + const compared = []; + const originalCompare = bcrypt.compare; + bcrypt.compare = async (plain, hash) => { + compared.push(hash); + return originalCompare(plain, hash); + }; + + const login = (username, password) => + fetch(harness.base + '/login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ username, password }).toString(), + redirect: 'manual', + }); + + try { + await test('an unknown username still runs one bcrypt comparison', async () => { + compared.length = 0; + const res = await login('nosuchuser', 'wrong-password'); + assert.strictEqual(res.status, 200); + assert.strictEqual(compared.length, 1); + }); + + await test("the dummy hash uses the stored account's cost", async () => { + assert.strictEqual(bcrypt.getRounds(compared[0]), ROUNDS); + }); + + await test('a missing password does not skip the comparison', async () => { + compared.length = 0; + const res = await fetch(harness.base + '/login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=nosuchuser', + redirect: 'manual', + }); + assert.strictEqual(res.status, 200); + assert.strictEqual(compared.length, 1); + }); + + await test('a known username with a wrong password runs one comparison', async () => { + compared.length = 0; + const res = await login('admin', 'wrong-password'); + assert.strictEqual(res.status, 200); + assert.strictEqual(compared.length, 1); + }); + + await test('the correct password still signs in', async () => { + const res = await login('admin', PASSWORD); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.headers.get('location'), '/dashboard'); + }); + } finally { + bcrypt.compare = originalCompare; + await harness.close(); + } + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})().catch((error) => { + console.error(error); + process.exit(1); +});