diff --git a/OPENAPI/openapi.json b/OPENAPI/openapi.json index ec2c115c..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" ], @@ -10254,6 +10264,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..09d577b7 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", @@ -1130,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" @@ -2830,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" @@ -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", @@ -3800,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": { @@ -3874,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", @@ -3884,6 +3916,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -4646,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": { @@ -4732,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": [ { 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..35e1542b 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. @@ -579,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: @@ -712,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; } @@ -734,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 = {}) { @@ -746,6 +813,7 @@ function renderLoginView(res, options = {}) { error: options.error || null, mfaRequired: Boolean(options.mfaRequired), username: options.username || '', + sso: oidcService.getLoginButton(), }); } @@ -753,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) => { @@ -875,7 +985,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, @@ -907,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 }); } @@ -3595,6 +3706,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: [ @@ -7150,7 +7279,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.' }); @@ -7327,7 +7456,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.' }); @@ -7752,6 +7881,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: @@ -7798,8 +7933,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, }; @@ -7819,7 +7968,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 65a4db53..ad179e2f 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -130,6 +130,10 @@ 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', + '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', 'ocr-empty-result-guard': 'test-ocr-empty-result-guard.js', @@ -160,7 +164,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 +269,10 @@ const AREAS = { 'thumbnail-cache-path-sanitization', 'url-base-validation', 'session-token-type', + 'health-disclosure', + 'totp-replay', + 'login-username-timing', + '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-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-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); +}); 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/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' ); }); 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); +}); 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) { %> + + <% } %>