diff --git a/.gitignore b/.gitignore index 1b65323..184d704 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,11 @@ uploads/ # Scripts / files generated by agents in sandboxes. backend/sandbox/workdirs/ + +# Config files copied from templates by: 'scripts/generate-nginx-cert.sh' +backend/environment/oidc/cacert.pem + +# Config files copied from templates by 'scripts/generate-secrets.sh' +authelia/configuration.yml +authelia/users_database.yml +backend/environment/oidc/config.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 5fb89de..ae20fce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,9 +11,36 @@ A Docker Compose template that assembles a running Soliplex stack (backend, Flut First-time setup (required before `up`): ```bash -./scripts/generate-secrets.sh # populates .secrets/*.gen (gitignored) +./scripts/generate-secrets.sh # populates .secrets/*.gen (gitignored) +./scripts/generate-nginx-cert.sh # generates nginx TLS cert + builds backend cacert.pem from cacert.pem.in ``` +Both scripts rebuild a gitignored output file from a version-controlled +`*.in` template on every run (idempotent — safe to re-run): + +- `generate-nginx-cert.sh` rebuilds `backend/environment/oidc/cacert.pem` + from `backend/environment/oidc/cacert.pem.in` (the Mozilla CA bundle), + appending the freshly generated nginx public cert between BEGIN/END + marker comments so the backend trusts the HTTPS path to Authelia. +- `generate-secrets.sh` rebuilds two configs from their templates, + injecting derived values in place of `REPLACE_ME` placeholders: + - `backend/environment/oidc/config.yaml` from `config.yaml.in` — the + OIDC JWKS **public key** PEM is written under + `auth_systems[authelia].token_validation_pem`, between the + `BEGIN/END PUBLIC KEY` markers. + - `authelia/configuration.yml` from `configuration.yml.in` — the + PBKDF2-SHA512 **digest** of the OIDC client secret is written under + `identity_providers.oidc.clients[soliplex].client_secret`, + replacing the `$pbkdf2-sha512$REPLACE_ME` placeholder. + +The plaintext OIDC client secret stays in +`.secrets/authelia_oidc_client_secret.gen` for the backend to mount. + +The three gitignored outputs (`cacert.pem`, `config.yaml`, +`configuration.yml`) should never be hand-edited — edits will be +overwritten the next time either script runs. Edit the `.in` templates +instead. + Run the stack: ```bash @@ -33,11 +60,12 @@ Ports exposed to the host: `9000` (nginx HTTP), `9443` (nginx HTTPS, self-signed ### Service graph (see `docker-compose.yml`) -- **nginx** — serves the Flutter web frontend (built from the `soliplex/frontend` release tarball inside `nginx/Dockerfile`) and reverse-proxies `/api/` and `/mcp/` to `backend:8000`. Terminates TLS on 9443 with a self-signed cert generated at build time. -- **backend** — runs `soliplex-cli serve /environment`. **Currently launched with `--no-auth-mode`** (see `docker-compose.yml`; marked temporary). The `--reload=config` flag means edits under `backend/environment/` take effect without rebuild. +- **nginx** — serves the Flutter web frontend (built from the `soliplex/frontend` release tarball inside `nginx/Dockerfile`) and reverse-proxies `/api/` and `/mcp/` to `backend:8000`. Terminates TLS on 9443 with a self-signed cert generated on the host by `scripts/generate-nginx-cert.sh` (into `.secrets/nginx-server.{crt,key}.gen`) and bind-mounted into the container — so cert rotation is `./scripts/generate-nginx-cert.sh && docker compose restart nginx backend`, no rebuild required. Also proxies `/authelia/` through to the Authelia container (portal UI + OIDC endpoints). **No longer enforces auth** at the edge — the backend is now the OIDC relying party and enforces auth itself. +- **backend** — runs `soliplex-cli serve /environment`. **Currently launched with `--no-auth-mode`** (see `docker-compose.yml`; marked temporary — drop the flag once the OIDC client registration is verified). The `--reload=config` flag means edits under `backend/environment/` take effect without rebuild. Acts as an OIDC relying party against Authelia — config in `backend/environment/oidc/config.yaml` (gitignored; rebuilt from `config.yaml.in` by `scripts/generate-secrets.sh`), `authelia_oidc_client_secret` mounted at `/run/secrets/authelia_oidc_client_secret`, and the nginx self-signed cert appended to `backend/environment/oidc/cacert.pem` (gitignored; rebuilt from `cacert.pem.in` by `scripts/generate-nginx-cert.sh`) so it trusts the HTTPS path to `/authelia/.well-known/openid-configuration`. +- **authelia** — acts as the OpenID Connect Provider for the backend. Portal + OIDC endpoints (`/.well-known/openid-configuration`, `/api/oidc/*`) served at `https://soliplex.localhost:9443/authelia/` (path prefix, same origin — no hosts-file edits required on systems with systemd-resolved / modern glibc, which auto-resolve `*.localhost` to 127.0.0.1). File-based user backend at `authelia/users_database.yml`; Postgres storage in DB `soliplex_authelia`; secrets injected via `AUTHELIA_*_FILE` (`authelia_jwt_secret`, `authelia_session_secret`, `authelia_storage_encryption_key`, `authelia_db_password`, `authelia_oidc_hmac_secret`). The JWKS signing key is NOT mounted as a Docker secret — Authelia doesn't recognise `_FILE` env vars for list entries (jwks[]) — so `scripts/generate-secrets.sh` inlines the PEM into the rebuilt `authelia/configuration.yml`. OIDC client is registered there under `identity_providers.oidc.clients`; the client-secret digest also lives inline. That file is gitignored and is rebuilt from `authelia/configuration.yml.in` by `scripts/generate-secrets.sh`, which in the same run also rebuilds `backend/environment/oidc/config.yaml` from its `.in` template with the matching JWKS public-key PEM. Default dev credentials `admin` / `authelia` — rotate the argon2 hash before any non-local use. Authelia requires HTTPS, so the OIDC flow only works on 9443. **Access the stack via `https://soliplex.localhost:9443/`** — Authelia's validator rejects bare `localhost` as a session cookie domain, and `127.0.0.1` doesn't work either because the backend (inside its container) can't reach the host on that IP; `soliplex.localhost` resolves identically on both sides (host-side via `.localhost` auto-resolution; container-side via `extra_hosts: soliplex.localhost → host-gateway` in `docker-compose.yml`), keeping the OIDC `iss` claim consistent. - **haiku-rag** — watches `rag/docs/` and writes a LanceDB to `rag/db/`. That same `rag/db/` directory is bind-mounted into the backend at `/db` so the backend's `rag` skill can query it. Delegates document conversion/chunking to docling-serve. - **docling-serve** — stateless document converter. CPU image by default; comment swap in `docker-compose.yml` for GPU. -- **postgres** — three databases created on first boot by `postgres/config/init.sh`: `soliplex_agui` (thread persistence), `soliplex_authz` (authorization policy), `soliplex_ingester`. Each gets a dedicated low-privilege role whose password is read from `/run/secrets/_db_password`. Init runs only on an empty data volume; to re-run, `docker compose down -v`. +- **postgres** — four databases created on first boot by `postgres/config/init.sh`: `soliplex_agui` (thread persistence), `soliplex_authz` (soliplex's own authorization policy — distinct from Authelia), `soliplex_ingester`, `soliplex_authelia` (Authelia session/config storage). Each gets a dedicated low-privilege role whose password is read from `/run/secrets/_db_password`. Init runs only on an empty data volume; to re-run, `docker compose down -v`. ### Secrets @@ -72,5 +100,8 @@ Drop documents into `rag/docs/` and haiku-rag will ingest them on its monitor cy - `constraints.txt` pins `soliplex >= 0.60.0.1, < 0.61`. Bumping this is a backend rebuild. - The frontend is pulled from **the latest** `soliplex/frontend` GitHub release inside `nginx/Dockerfile` — rebuilds are not reproducible across time unless you pin the tarball URL. Cache-bust hash is captured from the release tag and written to `/tmp/soliplex-frontend-release-hash` during build. -- Backend `--no-auth-mode` is explicitly labeled temporary in `docker-compose.yml`. Don't assume auth is enforced end-to-end in this template. -- `docker compose down -v` drops the `postgres_data` volume — all chat threads, authz grants, and ingester state go with it. +- Backend `--no-auth-mode` is explicitly labeled temporary in `docker-compose.yml`. Until it's removed, **nothing enforces auth** — nginx no longer runs the `auth_request` gate either. Drop the flag once the Authelia OIDC client registration is verified end-to-end. +- `docker compose down -v` drops the `postgres_data` volume — all chat threads, authz grants, ingester state, **and Authelia session/config state** go with it. +- The nginx self-signed cert rotates every time `scripts/generate-nginx-cert.sh` is re-run. That script both writes `.secrets/nginx-server.{crt,key}.gen` and rebuilds `backend/environment/oidc/cacert.pem` from `cacert.pem.in`, appending the public cert between marker comments. Forgetting to re-run it will make the backend's OIDC discovery call fail with an X.509 verify error. +- Don't hand-edit `backend/environment/oidc/cacert.pem`, `backend/environment/oidc/config.yaml`, or `authelia/configuration.yml` — they're gitignored build artifacts. Edit the matching `*.in` templates and re-run the generator scripts. +- Authelia requires HTTPS for its OIDC flow, which only works on 9443. The 9000 listener stays open for behind-an-upstream-proxy deployments that terminate TLS upstream. diff --git a/README.md b/README.md index 170c2d5..c82d2a5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,103 @@ # Soliplex Docker Compose Template -A starting point for running Soliplex and related servcies under -Docker Compose. +A starting point for running Soliplex and related services under Docker +Compose: the Soliplex backend, a Flutter web frontend served by nginx, +the haiku.rag document pipeline, docling-serve, Postgres, and Authelia +as the OpenID Connect provider. + +## Prerequisites + +- Docker + Docker Compose +- OpenSSL (for the secret and cert generation scripts) +- An Ollama server reachable from the stack (for the `gpt-oss:*` models + referenced in `backend/environment/installation.yaml`) + +## First-time setup + +Run the two generator scripts, in either order, before the first +`docker compose up`: + +```bash +./scripts/generate-secrets.sh # populates .secrets/*.gen (gitignored) +./scripts/generate-nginx-cert.sh # self-signed nginx cert + builds backend cacert.pem from cacert.pem.in +``` + +Both scripts are idempotent. Each one rebuilds a gitignored output file +from a version-controlled `*.in` template on every run: + +- `generate-nginx-cert.sh` rebuilds `backend/environment/oidc/cacert.pem` + from `cacert.pem.in` (the Mozilla CA bundle) and appends the freshly + generated nginx public cert so the backend trusts the HTTPS path to + Authelia. +- `generate-secrets.sh` also writes the `.secrets/*.gen` files and + rebuilds two configs from their templates, injecting derived values + in place of `REPLACE_ME` placeholders: + - the OIDC JWKS **public key** PEM into + `backend/environment/oidc/config.yaml` (from `config.yaml.in`) under + `auth_systems[authelia].token_validation_pem` + - the PBKDF2-SHA512 **digest** of the OIDC client secret into + `authelia/configuration.yml` (from `configuration.yml.in`) under + `identity_providers.oidc.clients[soliplex].client_secret` + +No manual paste step is required. The plaintext OIDC client secret stays +in `.secrets/authelia_oidc_client_secret.gen` for the backend to mount. +Don't hand-edit the three gitignored outputs — edit the `.in` templates +and re-run the scripts. + +Create a `.env` file defining `OLLAMA_BASE_URL`, pointing at the Ollama +server that will serve the models. + +## Running the stack + +```bash +docker compose up # foreground +docker compose up -d # detached +docker compose build # rebuild one service +docker compose logs -f backend +docker compose down # stop (keeps postgres_data volume) +docker compose down -v # stop AND wipe postgres volume +``` + +Access the UI at **`https://soliplex.localhost:9443/`** — not `localhost` +or `127.0.0.1`. The OIDC flow requires the same URL to be reachable from +both the browser (host side) and the backend container; `soliplex.localhost` +auto-resolves to 127.0.0.1 on the host (systemd-resolved / modern glibc +handle `*.localhost` per RFC 6761) and is routed to the host via +`extra_hosts` from inside the backend container. Authelia also accepts it +as a cookie domain (contains a period). You'll have to accept the +self-signed cert on first load. + +If your host OS does not auto-resolve `*.localhost`, add this to +`/etc/hosts`: + +``` +127.0.0.1 soliplex.localhost +``` + +Default Authelia dev credentials: **`admin` / `authelia`**. Rotate the +argon2 hash in `authelia/users_database.yml` before any non-local use. + +### Ports + +| Port | Service | +|------|---------| +| 9000 | nginx HTTP (no TLS — for upstream-terminated deployments) | +| 9443 | nginx HTTPS (self-signed; **use this one**) | +| 8000 | backend direct | +| 8001 | haiku-rag MCP | +| 5001 | docling-serve | +| 5432 | Postgres | + +### Re-running setup + +- Rotating the nginx cert: re-run `./scripts/generate-nginx-cert.sh` then + `docker compose restart nginx backend`. No rebuild needed. +- Rotating any secret: re-run `./scripts/generate-secrets.sh`. For secrets + tied to the Postgres users created on first boot, you must also + `docker compose down -v` to re-init the data volume, or the backend + will fail to authenticate to the DB. + +## Further reading + +Architectural details (service graph, secret modes, Soliplex config +layout, sandbox, RAG pipeline, gotchas) live in [`CLAUDE.md`](./CLAUDE.md). diff --git a/authelia/Dockerfile b/authelia/Dockerfile new file mode 100644 index 0000000..b6547bc --- /dev/null +++ b/authelia/Dockerfile @@ -0,0 +1 @@ +FROM docker.io/authelia/authelia:latest diff --git a/authelia/README.md b/authelia/README.md new file mode 100644 index 0000000..de97804 --- /dev/null +++ b/authelia/README.md @@ -0,0 +1,171 @@ +Configuration for 'authelia' service + +Authelia is an **OpenID Connect Provider** for the stack. The soliplex +backend is registered as an OIDC relying party (client_id: `soliplex`) +and enforces auth itself. +The browser-side flow is: + +1. User hits a protected backend route, is redirected to + `/api/login/authelia?return_to=...` +2. Backend (via authlib) redirects to Authelia's authorize endpoint at + `https://soliplex.localhost:9443/authelia/api/oidc/authorization` +3. User authenticates at Authelia's portal, grants consent +4. Authelia redirects back to `/api/auth/authelia?code=...` +5. Backend exchanges the code for an `access_token` + `refresh_token` + + `id_token`, validates the `access_token`'s RS256 signature using the + JWKS public key from `backend/environment/oidc/config.yaml` + +## Portal URL + +`https://soliplex.localhost:9443/authelia/` — served on the same origin +as the Flutter app via the `server.address` path prefix in +`configuration.yml`. Access the whole stack via +`https://soliplex.localhost:9443/` (not `localhost` or `127.0.0.1`): the +OIDC flow needs the same URL to resolve from both the browser and the +backend container, and `soliplex.localhost` is wired up on both sides +(host auto-resolves `*.localhost`; the backend uses `extra_hosts` in +`docker-compose.yml` to route it through host-gateway). Authelia's +cookie validator accepts it because the domain contains a period. + +## Default credentials (dev only) + +- Username: `admin` +- Password: `authelia` + +The argon2id hash is **regenerated by `scripts/generate-secrets.sh` on +every run** and injected into `users_database.yml` — so the hash never +goes stale across Authelia version bumps (past argon2 parameter +changes broke the old widely-cited docs hash). To use a different +admin password, edit the `--password` argument in the script's +`crypto hash generate argon2` invocation. For a non-dev deployment, +switch to the LDAP backend and remove the file-based user entirely. + +## OIDC provider configuration + +The `soliplex` client is registered in `configuration.yml` under +`identity_providers.oidc.clients`: + +| Field | Value | +|-------|-------| +| `client_id` | `soliplex` | +| `client_secret` | PBKDF2-SHA512 digest of `authelia_oidc_client_secret.gen` (injected by `generate-secrets.sh`) | +| `redirect_uris` | `https://soliplex.localhost:9443/api/auth/authelia?return_to=https%3A%2F%2Fsoliplex.localhost%3A9443%2F` | +| `grant_types` | `authorization_code`, `refresh_token` | +| `response_types` | `code` | +| `scopes` | `openid`, `profile`, `email`, `groups`, `offline_access` | +| `token_endpoint_auth_method` | `client_secret_basic` | +| `access_token_signed_response_alg` | `RS256` (Soliplex validates the access_token as a signed JWT — Authelia's default opaque tokens would fail decode) | +| `consent_mode` | `implicit` | + +The JWKS signing key (`identity_providers.oidc.jwks[0].key`) is an +RSA-4096 private key inlined at config-build time — **not** mounted as +a Docker secret (Authelia doesn't recognise `_FILE` env vars for list +entries). The matching public key is injected into the backend's +`config.yaml` as `token_validation_pem`. + +The discovery doc is served at +`https://soliplex.localhost:9443/authelia/.well-known/openid-configuration` +and reports `issuer: https://soliplex.localhost:9443/authelia`. The +backend verifies issuer alignment, which is why the URL must resolve +identically from the browser and from inside the backend container. + +## Secrets + +| File secret | Env var | Purpose | +|-------------|---------|---------| +| `authelia_jwt_secret` | `AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE` | password-reset JWT signer | +| `authelia_session_secret` | `AUTHELIA_SESSION_SECRET_FILE` | session cookie signer | +| `authelia_storage_encryption_key` | `AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE` | at-rest encryption for the storage backend | +| `authelia_db_password` | `AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE` | Postgres password for the `soliplex_authelia` role | +| `authelia_oidc_hmac_secret` | `AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE` | OIDC HMAC signing secret (≥ 64 chars) | + +All are populated by `scripts/generate-secrets.sh` as +`.secrets/*.gen` files and mounted into the container at +`/run/secrets/*`. Re-running the script preserves existing `.gen` +files (so DB passwords don't rotate and break the already-initialised +Postgres volume); only the derived configs (`configuration.yml`, +`users_database.yml`, backend `config.yaml`) are rebuilt each run. + +Not a Docker secret (see OIDC provider configuration above): + +- OIDC JWKS signing key — inlined into `configuration.yml` +- `soliplex` client secret PBKDF2 digest — inlined into `configuration.yml` +- `admin` user argon2id hash — inlined into `users_database.yml` + +## Storage + +Postgres database `soliplex_authelia`, owned by the +`soliplex_authelia` role. Created on first-boot by +`postgres/config/init.sh`; wiping the `postgres_data` volume +(`docker compose down -v`) resets all Authelia session / OIDC consent +state. + +## Adding users + +New users are added by appending entries to `users_database.yml.in` +(the version-controlled template). `scripts/generate-secrets.sh` +rebuilds `users_database.yml` from the template on every run, +injecting a fresh argon2id hash for any password placeholder it finds. +Edit the template — **not** the gitignored `users_database.yml`. + +Minimal entry: + +```yaml +users: + alice: + disabled: false + displayname: 'Alice Cooper' + password: 'REPLACE ME' # injected by generate-secrets.sh + email: 'alice@example.com' + groups: + - 'users' +``` + +For users whose password should differ from the default dev password, +generate a hash manually and paste the `$argon2id$...` value directly +(replacing `'REPLACE ME'`): + +```bash +docker compose run --rm --no-deps authelia \ + authelia crypto hash generate argon2 --password '' +``` + +After editing, re-run `scripts/generate-secrets.sh` to rebuild +`users_database.yml` from the template, then +`docker compose restart authelia`. + +### YAML attributes → OIDC claims + +When the backend requests scopes `openid profile email groups +offline_access` (the current default), Authelia populates the ID and +access tokens with these claims: + +| `users_database.yml` field | Scope required | OIDC claim | Notes | +|----------------------------|----------------|------------|-------| +| *YAML key* (e.g. `admin`, `alice`) | `profile` | `preferred_username` | The mapping key itself is the username. | +| `displayname` | `profile` | `name` | Human-readable display name. | +| `email` | `email` | `email` | Single primary address. | +| `email` (when verified) | `email` | `email_verified` | Authelia sets this `true` for the primary address. | +| *(Authelia-generated)* | `openid` | `sub` | Opaque, stable subject identifier — **not** the YAML key; derived from a per-user UUID stored alongside the user. Use this as the primary-key-in-your-app reference, not `preferred_username`. | +| `groups` | `groups` | `groups` | Array of group names; the Soliplex backend uses these for authz decisions. | +| `disabled` | — | *(none)* | Not a claim; `true` blocks login entirely. | +| `password` | — | *(none)* | argon2id hash; never exposed via OIDC. | + +Optional fields Authelia recognises but which the template does not +set by default: + +- `given_name`, `family_name`, `middle_name`, `nickname` — + mapped into the matching `profile`-scope claims. +- `alt_emails` — additional addresses, surfaced under `email` scope. + +## Files in this directory + +- `Dockerfile` — pins the upstream `authelia/authelia` image. +- `configuration.yml.in` — main config template. Rebuilt into the + gitignored `configuration.yml` by `scripts/generate-secrets.sh`, + which injects the JWKS private key PEM and the PBKDF2 client-secret + digest. +- `users_database.yml.in` — file-based auth backend template. + Rebuilt into the gitignored `users_database.yml` by the same script, + which injects a fresh argon2id hash for every `'REPLACE ME'` password + placeholder. diff --git a/authelia/configuration.yml.in b/authelia/configuration.yml.in new file mode 100644 index 0000000..baaf09d --- /dev/null +++ b/authelia/configuration.yml.in @@ -0,0 +1,194 @@ +--- +############################################################### +# Authelia Configuration +# +# Minimal template config suitable for local development behind +# the nginx front-end proxy on `https://soliplex.localhost:9443/`. +# +# Docs: https://www.authelia.com/configuration/ +# Secrets (jwt, session, storage_encryption_key, postgres password, +# OIDC hmac secret + JWKS signing key) are injected via +# AUTHELIA_*_FILE environment variables from Docker secrets — see +# `docker-compose.yml`. +############################################################### + +theme: 'light' + +server: + # Path prefix '/authelia' lets nginx serve the portal and the OIDC + # endpoints (/.well-known/openid-configuration, /api/oidc/*) at + # https://soliplex.localhost:9443/authelia/ on the same origin as the + # Flutter app, avoiding hosts-file edits. + address: 'tcp://0.0.0.0:9091/authelia' + +log: + level: 'info' + +totp: + issuer: 'soliplex.local' + +identity_validation: + reset_password: + # jwt_secret injected via AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE + jwt_secret: '' + +authentication_backend: + password_reset: + # The filesystem notifier (below) does not support resetting + # flows that expect a real user inbox; disable the UI until + # SMTP is configured. + disable: true + refresh_interval: '5m' + file: + path: '/config/users_database.yml' + password: + algorithm: 'argon2id' + +access_control: + # Evaluated by Authelia's OIDC authorization endpoint when the soliplex + # backend (as an OIDC relying party) redirects the user for consent. + # 'one_factor' requires username+password; bump to 'two_factor' once + # TOTP enrolment is wired up. + default_policy: 'one_factor' + +session: + name: 'authelia_session' + # secret injected via AUTHELIA_SESSION_SECRET_FILE + secret: '' + expiration: '1h' + inactivity: '5m' + remember_me: '1M' + cookies: + # Authelia's validator rejects bare 'localhost' (requires a period + # or an IP). 'soliplex.localhost' satisfies the period rule and is + # reachable from both the browser (host-side '.localhost' resolves + # to 127.0.0.1) and the backend container (extra_hosts maps + # 'soliplex.localhost' to host-gateway), keeping the OIDC issuer + # claim consistent. The session cookie primarily covers the OIDC + # authorization flow (Authelia's portal and /api/oidc/* endpoints). + - domain: 'soliplex.localhost' + authelia_url: 'https://soliplex.localhost:9443/authelia/' + default_redirection_url: 'https://soliplex.localhost:9443/' + +regulation: + max_retries: 3 + find_time: '2m' + ban_time: '5m' + +storage: + # encryption_key injected via AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE + encryption_key: '' + postgres: + address: 'tcp://postgres:5432' + database: 'soliplex_authelia' + username: 'soliplex_authelia' + # password injected via AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE + password: '' + +notifier: + # Dev: write password-reset / 2FA-enrolment notifications to a + # file inside the container. Swap for SMTP before production. + filesystem: + filename: '/tmp/authelia-notifications.txt' + +############################################################### +# OIDC Provider +# +# Authelia acts as the OpenID Connect Provider for the soliplex +# backend. Discovery document is served at: +# https://soliplex.localhost:9443/authelia/.well-known/openid-configuration +############################################################### +identity_providers: + oidc: + # hmac_secret injected via AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE + hmac_secret: '' + # By OIDC 1.0 default, Authelia exposes profile/email/groups claims only + # at the UserInfo endpoint — they are NOT placed in the access_token or + # id_token. The soliplex backend derives user claims by JWT-decoding the + # access_token (soliplex.views.authn.get_user_info → + # authn.validate_access_token), so without an explicit policy the + # /api/user_info endpoint returns "<unknown>" for every field. This + # policy copies the standard profile/email/groups claims into both + # tokens; it is bound to the 'soliplex' client below via 'claims_policy'. + claims_policies: + soliplex: + access_token: + - 'email' + - 'email_verified' + - 'groups' + - 'preferred_username' + - 'name' + - 'given_name' + - 'family_name' + id_token: + - 'email' + - 'email_verified' + - 'groups' + - 'preferred_username' + - 'name' + - 'given_name' + - 'family_name' + jwks: + # RS256 private key for signing ID tokens. Authelia does not + # support a '_FILE' env var for jwks list entries, so the PEM is + # inlined here: scripts/generate-secrets.sh generates the RSA + # keypair, writes the private key between the BEGIN/END PRIVATE + # KEY markers below (replacing the REPLACE_ME_JWKS_KEY + # placeholder), and injects the matching public key into + # backend/environment/oidc/config.yaml. This file (gitignored) is + # rebuilt from configuration.yml.in on every run — don't + # hand-edit the output. + - key: | + -----BEGIN PRIVATE KEY----- + REPLACE_ME_JWKS_KEY + -----END PRIVATE KEY----- + clients: + - client_id: 'soliplex' + client_name: 'Soliplex' + # PBKDF2-SHA512 digest of the client secret. Generate with: + # docker run --rm authelia/authelia:latest \ + # authelia crypto hash generate pbkdf2 --variant sha512 --password '<plaintext>' + # Paste the $pbkdf2-sha512$... digest here. Store the PLAINTEXT + # in .secrets/authelia_oidc_client_secret.gen for the backend. + client_secret: '$pbkdf2-sha512$REPLACE_ME' + public: false + authorization_policy: 'one_factor' + redirect_uris: + # Soliplex's OIDC callback is /api/auth/{system} and the + # backend appends a ?return_to=... query (see + # soliplex.views.authn:65-68). Authelia's matcher is strict + # on the full URI including the query, so the 'return_to' + # value must match exactly. In practice the Flutter frontend + # uses hash-based routing, so the server-visible 'return_to' + # is almost always the bare origin ('.../') — the in-app + # route lives in the #fragment, which is never sent to the + # server. If another server-side return_to becomes common, + # add it here (exact URL-encoded match required). + - 'https://soliplex.localhost:9443/api/auth/authelia?return_to=https%3A%2F%2Fsoliplex.localhost%3A9443%2F' + scopes: + - 'openid' + - 'profile' + - 'email' + - 'groups' + # Required for Authelia to issue a refresh_token alongside + # the access_token. Soliplex's callback unconditionally reads + # tokendict['refresh_token'] (soliplex.views.authn:124), so + # without this scope the callback KeyErrors. + - 'offline_access' + grant_types: + - 'authorization_code' + - 'refresh_token' + response_types: + - 'code' + token_endpoint_auth_method: 'client_secret_basic' + consent_mode: 'implicit' + # Bind the 'soliplex' claims policy defined above so the standard + # profile/email/groups claims land in the access_token (which the + # backend decodes for /api/user_info). + claims_policy: 'soliplex' + # Soliplex's backend validates the access_token as a signed + # RS256 JWT (see soliplex.authn.validate_access_token). + # Without this override Authelia would issue opaque access + # tokens ('authelia_at_...') and the JWT decode would fail + # with "invalid token". + access_token_signed_response_alg: 'RS256' diff --git a/authelia/users_database.yml.in b/authelia/users_database.yml.in new file mode 100644 index 0000000..7be65d2 --- /dev/null +++ b/authelia/users_database.yml.in @@ -0,0 +1,25 @@ +--- +############################################################### +# Authelia file-based users database. +# +# Default admin user — password: "authelia" — INTENDED FOR +# LOCAL DEVELOPMENT ONLY. Regenerate the hash before exposing +# this stack to anything real: +# +# docker compose run --rm --no-deps authelia \ +# authelia crypto hash generate argon2 --password <your-pw> +# +# Paste the resulting $argon2id$... string into `password:` below +# and restart the authelia container. +############################################################### +users: + admin: + disabled: false + displayname: 'Default Admin' + given_name: 'Default' + family_name: 'Admin' + password: 'REPLACE ME' + email: 'admin@example.com' + groups: + - 'admins' + - 'users' diff --git a/backend/constraints.txt b/backend/constraints.txt index 5f4d197..5ef837c 100644 --- a/backend/constraints.txt +++ b/backend/constraints.txt @@ -1 +1 @@ -soliplex >= 0.60.0.1, < 0.61 +soliplex >= 0.60.3, < 0.61 diff --git a/backend/environment/installation.yaml b/backend/environment/installation.yaml index 50d7a6f..8db1722 100644 --- a/backend/environment/installation.yaml +++ b/backend/environment/installation.yaml @@ -177,6 +177,18 @@ secrets: - kind: "file_path" file_path: /run/secrets/authz_db_password + # ---------------------------------------------------------------------- + # Authelia OIDC client secret + # + # Referenced from oidc/config.yaml as "secret:AUTHELIA_OIDC_CLIENT_SECRET". + # Plaintext is generated by scripts/generate-secrets.sh; the matching + # PBKDF2 digest is pasted into authelia/configuration.yml. + # ---------------------------------------------------------------------- + - secret_name: "AUTHELIA_OIDC_CLIENT_SECRET" + sources: + - kind: "file_path" + file_path: /run/secrets/authelia_oidc_client_secret + # ---------------------------------------------------------------------- # MCP room token generation / validation secret # @@ -368,13 +380,14 @@ room_paths: #========================================================================== # See: https://soliplex.github.io/soliplex/config/logging/ #========================================================================== -# logging_config_file: "./logging.yaml" +logging_config_file: "./logging.yaml" # logging_headers_map: # request_id: "X-Request-ID" -# logging_claims_map: -# user_id: "email" +logging_claims_map: + user_id: "preferred_username" + email: "email" #========================================================================== # Logfire configuration diff --git a/backend/environment/logging.yaml b/backend/environment/logging.yaml index f00cb49..3005545 100644 --- a/backend/environment/logging.yaml +++ b/backend/environment/logging.yaml @@ -12,6 +12,24 @@ formatters: defaults: some_key: null + soliplex-authn: + format: "{name}|{asctime}|{levelname}|{message}" + datefmt: "%Y-%m-%dT%H:%M:%S" + style: "{" + validate: true + defaults: + header_id: "<no-header-id>" + + soliplex: + #format: "{name}|{asctime}|{levelname}|{email}|{message}" + format: "{name}|{asctime}|{levelname}|{user_id}|{message}" + datefmt: "%Y-%m-%dT%H:%M:%S" + style: "{" + validate: true + defaults: + header_id: "<no-header-id>" + user_id: "<no-user-id>" + filters: # Add this filter to update log level on a logger from 'DEBUG' to 'INFO'. @@ -22,26 +40,49 @@ filters: handlers: - console: + console-default: class: "logging.StreamHandler" formatter: "default" #filters: [] stream: "ext://sys.stdout" + console-soliplex-authn: + class: "logging.StreamHandler" + formatter: "soliplex-authn" + #filters: [] + stream: "ext://sys.stdout" + + console-soliplex: + class: "logging.StreamHandler" + formatter: "soliplex" + #filters: [] + stream: "ext://sys.stdout" + loggers: soliplex: level: "INFO" + propagate: false + handlers: + - "console-soliplex" soliplex.authn: level: "INFO" + propagate: false + handlers: + - "console-soliplex-authn" # filters: # - "update_debug_to_info" soliplex.authz: level: "INFO" + propagate: false + handlers: + - "console-soliplex" + # filters: + # - "update_debug_to_info" root: level: "INFO" handlers: - - "console" + - "console-default" diff --git a/backend/environment/oidc/cacert.pem b/backend/environment/oidc/cacert.pem.in similarity index 99% rename from backend/environment/oidc/cacert.pem rename to backend/environment/oidc/cacert.pem.in index 64c05d7..e1db7ba 100644 --- a/backend/environment/oidc/cacert.pem +++ b/backend/environment/oidc/cacert.pem.in @@ -4776,3 +4776,6 @@ Ao9QAwKxuDdollDruF/UKIqlIgyKhPBZLtU30WHlQnNYKoH3dtvi4k0NX/a3vgW0 rk4N3hY9A4GzJl5LuEsAz/+MF7psYC0nhzck5npgL7XTgwSqT0N1osGDsieYK7EO gLrAhV5Cud+xYJHT6xh+cHiudoO+cVrQkOPKwRYlZ0rwtnu64ZzZ -----END CERTIFICATE----- +# >>> soliplex-template nginx self-signed cert >>> +REPLACE ME +# <<< soliplex-template nginx self-signed cert <<< diff --git a/backend/environment/oidc/config.yaml b/backend/environment/oidc/config.yaml deleted file mode 100644 index f4dcafa..0000000 --- a/backend/environment/oidc/config.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# -# OIDC Auth Systems -# -oidc_client_pem_path: "./cacert.pem" - -auth_systems: - - - id: "keycloak" - title: "Authenticate with Keycloak" - server_url: "https://sso.domain.net/realms/soliplex" - client_id: "soliplex-service" - client_secret: "" # "secret:{SOLIPLEX_CLIENT_SECRET}" - scope: "openid email profile" - token_validation_pem: | - -----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAutt7XlnN7OtFx7p6WMif - SlUYMQ62RO/7x5hwWsvYhdHejd9KDBP8EZxIWk3vsMD0JhVl2cMNwMEqyU0CqHrS - E13+79lpG9trpfM674JyE9LwuGYX0YCkwtWA5zwlXjbHObr7WgziDP4+Nfs/1mtv - qERlvChBX6pyz/bgFtA9a0eZlXZbXSP5XwiHtHhMTil4MWBihLiN7nU7r0goduyf - eB7WiOJQ/p6kl/Y+FKdnVt8KHAudA2iErJ8mlPKaWLob5MipNsCB7WVTn16qq/O/ - PYiyk134+YXSMfWM8GxRb8/WtfwYlkaXXCB1YAj5M0O3RJLhTEvXMo2KUilOhKXX - DQIDAQAB - -----END PUBLIC KEY----- diff --git a/backend/environment/oidc/config.yaml.in b/backend/environment/oidc/config.yaml.in new file mode 100644 index 0000000..f8a5ded --- /dev/null +++ b/backend/environment/oidc/config.yaml.in @@ -0,0 +1,40 @@ +# +# OIDC Auth Systems +# +# `cacert.pem` carries the Mozilla CA bundle plus the nginx self-signed +# cert (appended by `scripts/generate-nginx-cert.sh`), so the backend +# trusts the HTTPS path to Authelia's OIDC endpoints. +# +oidc_client_pem_path: "./cacert.pem" + +auth_systems: + + - id: "authelia" + title: "Authenticate with Authelia" + # Authelia's OIDC base — discovery doc at: + # https://soliplex.localhost:9443/authelia/.well-known/openid-configuration + # 'soliplex.localhost' is used instead of '127.0.0.1' so the URL + # resolves identically from the browser (host-side: '.localhost' + # auto-maps to 127.0.0.1) and from inside this container (via + # extra_hosts: soliplex.localhost → host-gateway in docker-compose.yml). + # The OIDC issuer claim depends on this alignment. + server_url: "https://soliplex.localhost:9443/authelia" + client_id: "soliplex" + # Plaintext client secret mounted at + # /run/secrets/authelia_oidc_client_secret + # and declared in installation.yaml as AUTHELIA_OIDC_CLIENT_SECRET. + # Authelia stores the PBKDF2 digest of the same plaintext in its + # own configuration.yml. + client_secret: "secret:AUTHELIA_OIDC_CLIENT_SECRET" + # 'offline_access' is required for Authelia to issue a refresh + # token; Soliplex's OIDC callback unconditionally reads + # tokendict['refresh_token'] and would KeyError without it. + scope: "openid email profile groups offline_access" + # RS256 public key corresponding to Authelia's OIDC JWKS signing + # key (`.secrets/authelia_oidc_jwks_key.gen`). Re-derive and paste + # from `.secrets/authelia_oidc_jwks_pubkey.gen` after running + # `scripts/generate-secrets.sh`. + token_validation_pem: | + -----BEGIN PUBLIC KEY----- + REPLACE_ME + -----END PUBLIC KEY----- diff --git a/docker-compose.yml b/docker-compose.yml index 4907804..089796c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,26 @@ services: depends_on: - backend - tui + - authelia + + # SSL cert + key are generated on the host by + # scripts/generate-nginx-cert.sh (into .secrets/) and bind-mounted + # here so cert rotation does not require a container rebuild. + # nginx.conf is also bind-mounted so config edits take effect via + # `docker compose restart nginx` (no rebuild required). + volumes: + - type: bind + source: "./nginx/nginx.conf" + target: "/etc/nginx/nginx.conf" + read_only: true + - type: bind + source: "./.secrets/nginx-server.crt.gen" + target: "/etc/nginx/ssl/server.crt" + read_only: true + - type: bind + source: "./.secrets/nginx-server.key.gen" + target: "/etc/nginx/ssl/server.key" + read_only: true ports: - "9000:9000" @@ -48,6 +68,46 @@ services: # the websocket) stays consistent with what the browser sees. command: "/app/.venv/bin/soliplex-tui-serve --backend-url http://backend:8000 --host 0.0.0.0 --port 8002 --public-url https://soliplex.localhost:9443/tui" + authelia: + + build: + context: authelia + dockerfile: Dockerfile + + image: docker.io/authelia/authelia:latest + + depends_on: + postgres: + condition: service_healthy + + user: "1000:1000" + + secrets: + - authelia_jwt_secret + - authelia_session_secret + - authelia_storage_encryption_key + - authelia_db_password + - authelia_oidc_hmac_secret + + # NOTE: the OIDC JWKS signing key is NOT mounted as a Docker secret. + # Authelia does not recognise a '_FILE' env var for list entries + # (jwks[]), so the key PEM is inlined into authelia/configuration.yml + # by scripts/generate-secrets.sh (which also derives and injects the + # matching public key into backend/environment/oidc/config.yaml). + environment: + AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE: /run/secrets/authelia_jwt_secret + AUTHELIA_SESSION_SECRET_FILE: /run/secrets/authelia_session_secret + AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE: /run/secrets/authelia_storage_encryption_key + AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE: /run/secrets/authelia_db_password + AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE: /run/secrets/authelia_oidc_hmac_secret + + volumes: + - type: bind + source: "authelia" + target: "/config" + + restart: unless-stopped + backend: build: @@ -67,10 +127,22 @@ services: timeout: 10s retries: 4 + # Make 'soliplex.localhost' (the canonical external URL of the + # stack) reachable from inside the container so the backend's + # server-side OIDC discovery / token calls hit nginx via the host + # gateway. Without this, 'soliplex.localhost' resolves to the + # container's own loopback and the OIDC flow fails with + # httpx.ConnectError. On the host, '.localhost' names auto-resolve + # to 127.0.0.1 (systemd-resolved / glibc), so the browser uses the + # same URL — keeping the issuer claim consistent across sides. + extra_hosts: + - "soliplex.localhost:host-gateway" + secrets: - agui_db_password - authz_db_password - url_safe_token_secret + - authelia_oidc_client_secret environment: OLLAMA_BASE_URL: ${OLLAMA_BASE_URL} @@ -95,8 +167,7 @@ services: ports: - "8000:8000" - # Temporary: run with '--no-auth-mode' - command: "/app/.venv/bin/soliplex-cli serve --no-auth-mode --reload=config --host=0.0.0.0 --proxy-headers --forwarded-allow-ips=* /environment" + command: "/app/.venv/bin/soliplex-cli serve --reload=config --host=0.0.0.0 --proxy-headers --forwarded-allow-ips=* /environment" haiku-rag: @@ -172,6 +243,7 @@ services: environment: AUTO_CREATE_DATABASE: ${AUTO_CREATE_DATABASE:-1} AGUI_DB_PASS_FILE: /run/secrets/agui_db_password + AUTHELIA_DB_PASS_FILE: /run/secrets/authelia_db_password AUTHZ_DB_PASS_FILE: /run/secrets/authz_db_password INGESTION_DB_PASS_FILE: /run/secrets/ingestion_db_password POSTGRES_INITDB_ARGS: "-A scram-sha-256" @@ -194,6 +266,7 @@ services: - /tmp secrets: - source: agui_db_password + - source: authelia_db_password - source: authz_db_password - source: ingestion_db_password - source: postgres_password @@ -214,6 +287,18 @@ secrets: #------------------------------------------------------------------------------ agui_db_password: file: ./.secrets/agui_db_password.gen + authelia_db_password: + file: ./.secrets/authelia_db_password.gen + authelia_jwt_secret: + file: ./.secrets/authelia_jwt_secret.gen + authelia_oidc_client_secret: + file: ./.secrets/authelia_oidc_client_secret.gen + authelia_oidc_hmac_secret: + file: ./.secrets/authelia_oidc_hmac_secret.gen + authelia_session_secret: + file: ./.secrets/authelia_session_secret.gen + authelia_storage_encryption_key: + file: ./.secrets/authelia_storage_encryption_key.gen authz_db_password: file: ./.secrets/authz_db_password.gen ingestion_db_password: @@ -222,19 +307,3 @@ secrets: file: ./.secrets/postgres_password.gen url_safe_token_secret: file: ./.secrets/url_safe_token_secret.gen -# -#------------------------------------------------------------------------------ -# Secrets in '.env' file / environment -#------------------------------------------------------------------------------ -# See above for using file-based secrets instead. -#------------------------------------------------------------------------------ -# agui_db_password: -# environment: "SOLIPLEX_AGUI_DB_PASSWORD" -# authz_db_password: -# environment: "SOLIPLEX_AUTHZ_DB_PASSWORD" -# ingestion_db_password: -# environment: "SOLIPLEX_INGESTION_DB_PASSWORD" -# postgres_password: -# environment: "SOLIPLEX_POSTGRES_PASSWORD" -# url_safe_token_secret: -# environment: "SOLIPLEX_URL_SAFE_TOKEN_SECRET" diff --git a/nginx/Dockerfile b/nginx/Dockerfile index 821657f..ecc0deb 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -30,25 +30,22 @@ FROM debian:trixie # Install: # - iputils-ping for network debugging -# - openssl for certificate generation, # - ca-certificates # - curl for healthcheck # - headers-more module to suppress the Server header RUN apt-get update && \ apt-get install --no-install-recommends -y \ - openssl ca-certificates curl \ + ca-certificates curl \ libnginx-mod-http-headers-more-filter \ libnginx-mod-http-ndk \ libnginx-mod-http-lua && \ rm -rf /var/lib/apt/lists/* -# Generate self-signed SSL certificate -RUN mkdir -p /etc/nginx/ssl && \ - openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout /etc/nginx/ssl/server.key \ - -out /etc/nginx/ssl/server.crt \ - -subj "/C=US/ST=State/L=City/O=Organization/CN=localhost" && \ - chown -R 1000:1000 /etc/nginx/ssl +# The SSL server cert+key are generated on the host by +# scripts/generate-nginx-cert.sh and bind-mounted by +# docker-compose.yml at /etc/nginx/ssl/{server.crt,server.key}. +# Only the mount point needs to exist in the image. +RUN mkdir -p /etc/nginx/ssl ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt ENV SSL_CERT_DIR=/etc/ssl/certs diff --git a/nginx/nginx.conf b/nginx/nginx.conf index da46c30..e93f2b1 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -76,13 +76,17 @@ http { } # nginx only sends the host without the port as a header if you use - # $host. We need to tack on the port if it's not an https or http - # request on the standard ports. + # $host. Omit the port for standard HTTP/HTTPS (80/443); include it + # for any non-default port. This matters for Authelia's OIDC + # discovery endpoint: it derives the issuer URL from the Host + # header, and the 'authelia_url' / session-cookie config in + # authelia/configuration.yml pins the port (':9443'), so the Host + # header must carry it too. map "$backend_proto:$backend_port" $host_port { - "http:80" ""; - "https:9443" ""; - default :$backend_port; + "http:80" ""; + "https:443" ""; + default :$backend_port; } # Map Upgrade header for websocket proxying. Sends 'Connection: upgrade' @@ -273,6 +277,27 @@ http { ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_session_tickets off; + # Authelia portal + OIDC endpoints (login UI, /.well-known/openid-configuration, + # /api/oidc/*). Path prefix matches `server.address` in authelia/configuration.yml. + # Authentication is now enforced by the backend as an OIDC relying party; + # nginx no longer gates /api/ or /mcp/ via auth_request. + location /authelia/ { + set $upstream_authelia_portal "authelia"; + proxy_pass http://$upstream_authelia_portal:9091; + # Authelia derives the OIDC issuer from the Host header + + # X-Forwarded-Proto; it pins the port from the Host header, + # not X-Forwarded-Port. So the Host we forward MUST include + # ':9443' to match the 'authelia_url' in configuration.yml. + proxy_set_header Host $host:$server_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Port $server_port; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + } + root /app/build/web; index index.html; client_max_body_size 10m; diff --git a/postgres/config/init.sh b/postgres/config/init.sh index e6eec3a..e7f93ed 100644 --- a/postgres/config/init.sh +++ b/postgres/config/init.sh @@ -34,6 +34,14 @@ elif [ -z "$AUTHZ_DB_PASS" ]; then exit 1 fi +# Read password from secret file if available, otherwise fallback to environment variable +if [ -f "$AUTHELIA_DB_PASS_FILE" ]; then + AUTHELIA_DB_PASS=$(cat "$AUTHELIA_DB_PASS_FILE") +elif [ -z "$AUTHELIA_DB_PASS" ]; then + echo "ERROR: Neither AUTHELIA_DB_PASS_FILE nor AUTHELIA_DB_PASS is set" + exit 1 +fi + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL -- Create ingetser application user with password @@ -45,6 +53,9 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E -- Create ragserver authz application user with password CREATE USER soliplex_authz WITH PASSWORD '$AUTHZ_DB_PASS'; + -- Create authelia application user with password + CREATE USER soliplex_authelia WITH PASSWORD '$AUTHELIA_DB_PASS'; + -- Create database owned by postgres (not application user) CREATE DATABASE soliplex_ingester; ALTER DATABASE soliplex_ingester OWNER TO postgres; @@ -57,6 +68,10 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E CREATE DATABASE soliplex_authz; ALTER DATABASE soliplex_authz OWNER TO postgres; + -- Create database owned by postgres (not application user) + CREATE DATABASE soliplex_authelia; + ALTER DATABASE soliplex_authelia OWNER TO postgres; + -- Connect to the soliplex_ingester database to set up schema permissions \c soliplex_ingester @@ -95,8 +110,22 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E GRANT ALL PRIVILEGES ON DATABASE soliplex_authz to soliplex_authz; GRANT ALL PRIVILEGES ON SCHEMA public TO soliplex_authz; + + -- Connect to the soliplex_authelia database to set up schema permissions + \c soliplex_authelia + + -- Grant minimal required PRIVILEGES (EVAL.md #14 recommendation) + -- Only CONNECT, not superuser or database ownership + GRANT CONNECT ON DATABASE soliplex_authelia TO soliplex_authelia; + + -- Schema-level permissions + GRANT USAGE ON SCHEMA public TO soliplex_authelia; + + GRANT ALL PRIVILEGES ON DATABASE soliplex_authelia to soliplex_authelia; + GRANT ALL PRIVILEGES ON SCHEMA public TO soliplex_authelia; EOSQL echo "Database 'soliplex_ingester' initialized with minimal privileges for user 'soliplex_ingester'" echo "Database 'soliplex_agui' initialized with minimal privileges for user 'soliplex_agui'" echo "Database 'soliplex_authz' initialized with minimal privileges for user 'soliplex_authz'" +echo "Database 'soliplex_authelia' initialized with minimal privileges for user 'soliplex_authelia'" diff --git a/scripts/generate-nginx-cert.sh b/scripts/generate-nginx-cert.sh new file mode 100755 index 0000000..e47cd0c --- /dev/null +++ b/scripts/generate-nginx-cert.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# generate-nginx-cert.sh +# +# Generate the nginx self-signed server cert on the host, store it in +# .secrets/nginx-server.{crt,key}.gen (bind-mounted into the nginx +# container), and write the backend's CA bundle +# (backend/environment/oidc/cacert.pem — gitignored) by copying the +# template bundle (cacert.pem.in) and appending the public cert so +# soliplex trusts nginx when it hits Authelia's OIDC endpoints. +# +# Idempotent: the output cacert.pem is rebuilt from cacert.pem.in on +# every run. Any block in the template delimited by the BEGIN/END +# markers below (e.g. the `REPLACE ME` placeholder) is stripped before +# the current cert is appended. +# +# Run: +# - once before the first `docker compose up` +# - whenever the cert expires (365 days) or needs rotation +# After re-running, `docker compose restart nginx backend`. + +set -eu + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOCKER_DIR="$(dirname "$SCRIPT_DIR")" +SECRETS_DIR="${DOCKER_DIR}/.secrets" +CERT="${SECRETS_DIR}/nginx-server.crt.gen" +KEY="${SECRETS_DIR}/nginx-server.key.gen" +CACERT_IN="${DOCKER_DIR}/backend/environment/oidc/cacert.pem.in" +CACERT="${DOCKER_DIR}/backend/environment/oidc/cacert.pem" +BEGIN_MARKER="# >>> soliplex-template nginx self-signed cert >>>" +END_MARKER="# <<< soliplex-template nginx self-signed cert <<<" + +if ! command -v openssl >/dev/null 2>&1; then + echo -e "${RED}ERROR: openssl not found on PATH${NC}" >&2 + exit 1 +fi + +if [ ! -f "$CACERT_IN" ]; then + echo -e "${RED}ERROR: ${CACERT_IN} not found${NC}" >&2 + exit 1 +fi + +mkdir -p "$SECRETS_DIR" + +# If docker compose ran before this script, it bind-mounted the missing +# source paths and Docker auto-created each as an empty root-owned +# directory. openssl would later fail with a confusing "Expecting: +# TRUSTED CERTIFICATE" error on nginx startup. Detect and bail early. +for stub in "$CERT" "$KEY"; do + if [ -d "$stub" ]; then + echo -e "${RED}ERROR: ${stub} is a directory (likely created by 'docker compose up' before this script ran).${NC}" >&2 + echo -e "${RED}Remove it first (may need sudo because Docker created it as root):${NC}" >&2 + echo -e "${RED} sudo rmdir '${stub}'${NC}" >&2 + exit 1 + fi +done + +echo -e "${CYAN}Generating nginx self-signed cert (RSA-2048, 365 days)...${NC}" +# Primary SAN is 'soliplex.localhost', the canonical external URL of +# the stack: it resolves to 127.0.0.1 on the host (systemd-resolved / +# glibc auto-map '*.localhost') and to host-gateway inside the backend +# container (via docker-compose extra_hosts), so the browser and the +# backend's server-side OIDC calls hit the same URL and Authelia's +# issuer claim stays consistent. 'localhost' and IP:127.0.0.1 are kept +# as fallback SANs for direct-access tooling / debugging. +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "$KEY" -out "$CERT" \ + -subj "/C=US/ST=State/L=City/O=Organization/CN=soliplex.localhost" \ + -addext "subjectAltName=DNS:soliplex.localhost,DNS:localhost,IP:127.0.0.1" \ + 2>/dev/null +chmod 600 "$KEY" +chmod 644 "$CERT" + +subject=$(openssl x509 -in "$CERT" -noout -subject) +notAfter=$(openssl x509 -in "$CERT" -noout -enddate) +echo -e "${GREEN} ${subject}${NC}" +echo -e "${GREEN} ${notAfter}${NC}" + +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +echo -e "${CYAN}Rebuilding ${CACERT} from ${CACERT_IN} (stripping any marker block)...${NC}" +awk -v b="$BEGIN_MARKER" -v e="$END_MARKER" ' + $0 == b {skip=1; next} + $0 == e {skip=0; next} + !skip {print} +' "$CACERT_IN" > "${TMPDIR}/cacert.pem" + +echo -e "${CYAN}Appending current cert with marker block...${NC}" +{ + cat "${TMPDIR}/cacert.pem" + echo "$BEGIN_MARKER" + echo "# Regenerate via: scripts/generate-nginx-cert.sh" + echo "# Valid until: ${notAfter#notAfter=}" + cat "$CERT" + echo "$END_MARKER" +} > "${TMPDIR}/cacert.new" + +mv "${TMPDIR}/cacert.new" "$CACERT" +echo -e "${GREEN}Wrote ${CACERT}${NC}" +echo "" +echo -e "${YELLOW}Next: docker compose restart nginx backend${NC}" diff --git a/scripts/generate-secrets.sh b/scripts/generate-secrets.sh index 2d3e5e9..94ec923 100755 --- a/scripts/generate-secrets.sh +++ b/scripts/generate-secrets.sh @@ -37,6 +37,31 @@ generate_password() { openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c "$length" || true } +# Generate the OIDC JWKS keypair used for signing ID tokens. Unlike +# other secrets, the private key is NOT mounted into Authelia as a +# Docker secret (Authelia doesn't support a '_FILE' env var for list +# entries like jwks[]). Instead it is injected directly into +# authelia/configuration.yml below. The public-key half is injected +# into backend/environment/oidc/config.yaml so the backend can verify +# tokens Authelia signs. +jwks_key_file="${SECRETS_DIR}/authelia_oidc_jwks_key.gen" +jwks_pubkey_file="${SECRETS_DIR}/authelia_oidc_jwks_pubkey.gen" +if [ -s "$jwks_key_file" ]; then + echo -e "${YELLOW}◦ Keeping existing OIDC JWKS keypair ($(basename "$jwks_key_file"))${NC}" + # Re-derive the public key from the existing private key in case + # the pubkey file is missing or stale. + openssl rsa -in "$jwks_key_file" -pubout -out "$jwks_pubkey_file" 2>/dev/null + chmod 644 "$jwks_pubkey_file" +else + echo -e "${CYAN}Generating OIDC JWKS RSA-4096 keypair...${NC}" + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 \ + -out "$jwks_key_file" 2>/dev/null + chmod 600 "$jwks_key_file" + openssl rsa -in "$jwks_key_file" -pubout -out "$jwks_pubkey_file" 2>/dev/null + chmod 644 "$jwks_pubkey_file" + echo -e "${GREEN}✓ Wrote: $(basename "$jwks_key_file"), $(basename "$jwks_pubkey_file")${NC}" +fi + # Parse docker-compose.yml and generate secrets echo -e "${CYAN}Scanning $COMPOSE_FILE for .gen secret files...${NC}" echo "" @@ -55,20 +80,40 @@ grep -E '^\s+file:\s+' "$COMPOSE_FILE" | \ while IFS= read -r secret_file_path; do # Convert relative path to absolute secret_file_path="${secret_file_path#./}" - + secret_file="${DOCKER_DIR}/${secret_file_path}" secret_name=$(basename "$secret_file") - - # Generate password - password=$(generate_password 32) - - # Write to file without newline - echo -n "$password" > "$secret_file" + + # Preserve existing secrets across re-runs: rotating DB passwords + # after Postgres has initialised breaks auth for every service + # using that DB (the old hash lives in postgres_data). Only + # generate when the file is missing or empty. + if [ -s "$secret_file" ]; then + password=$(cat "$secret_file") + echo "${secret_name}|${password}" >> "$TEMP_PASSWORDS" + echo -e "${YELLOW}◦ Kept: ${secret_name}${NC}" + secret_count=$((secret_count + 1)) + continue + fi + + # Branch on secret name: most are random passwords; the OIDC HMAC + # secret gets a longer password per Authelia guidance. + case "$secret_name" in + authelia_oidc_hmac_secret.gen) + # Authelia recommends >= 64 chars for the OIDC HMAC secret. + password=$(generate_password 64) + echo -n "$password" > "$secret_file" + ;; + *) + password=$(generate_password 32) + echo -n "$password" > "$secret_file" + ;; + esac chmod 600 "$secret_file" 2>/dev/null || true - + # Store for display later echo "${secret_name}|${password}" >> "$TEMP_PASSWORDS" - + echo -e "${GREEN}✓ Generated: ${secret_name}${NC}" secret_count=$((secret_count + 1)) done @@ -106,6 +151,186 @@ echo -e "${RED}IMPORTANT: Save these passwords securely!${NC}" echo -e "${YELLOW}They are stored in the secret files but will not be displayed again.${NC}" echo "" +# If the JWKS public key was derived, rebuild +# backend/environment/oidc/config.yaml (gitignored) from its template +# (config.yaml.in), injecting the PEM under +# auth_systems[authelia].token_validation_pem and replacing whatever +# PEM block (placeholder or previously injected key) sits between the +# BEGIN/END PUBLIC KEY markers. +pubkey_file="${SECRETS_DIR}/authelia_oidc_jwks_pubkey.gen" +oidc_config_file_in="${DOCKER_DIR}/backend/environment/oidc/config.yaml.in" +oidc_config_file="${DOCKER_DIR}/backend/environment/oidc/config.yaml" +if [ -f "$pubkey_file" ] && [ -f "$oidc_config_file_in" ]; then + tmp_config=$(mktemp) + awk -v pubkey_file="$pubkey_file" -v indent=" " ' + BEGIN { + while ((getline line < pubkey_file) > 0) { + pem = pem (pem ? "\n" : "") indent line + } + close(pubkey_file) + } + /^[[:space:]]*-----BEGIN PUBLIC KEY-----/ { + print pem + in_block = 1 + next + } + in_block && /^[[:space:]]*-----END PUBLIC KEY-----/ { + in_block = 0 + next + } + !in_block { print } + ' "$oidc_config_file_in" > "$tmp_config" + mv "$tmp_config" "$oidc_config_file" + echo -e "${GREEN}✓ Wrote OIDC config with injected JWKS public key:${NC}" + echo -e " ${oidc_config_file#${DOCKER_DIR}/}" + echo "" +elif [ -f "$pubkey_file" ]; then + echo -e "${YELLOW}WARNING: ${oidc_config_file_in} not found — config not built.${NC}" + echo -e "${YELLOW}Paste the contents of ${pubkey_file} into${NC}" + echo -e "${YELLOW}auth_systems[authelia].token_validation_pem manually.${NC}" + echo "" +fi + +# Rebuild authelia/configuration.yml (gitignored) from its template +# (configuration.yml.in), injecting: +# - the RSA-4096 JWKS private key between the BEGIN/END PRIVATE KEY +# markers under identity_providers.oidc.jwks +# - the PBKDF2-SHA512 digest of the OIDC client secret under +# identity_providers.oidc.clients[soliplex].client_secret +# The digest is computed via the Authelia CLI. The backend needs the +# plaintext of the client secret (already written to +# .secrets/authelia_oidc_client_secret.gen); Authelia's YAML needs the +# digest, which must live inline since it isn't mounted as a Docker +# secret. +client_secret_file="${SECRETS_DIR}/authelia_oidc_client_secret.gen" +authelia_config_file_in="${DOCKER_DIR}/authelia/configuration.yml.in" +authelia_config_file="${DOCKER_DIR}/authelia/configuration.yml" +if [ -f "$client_secret_file" ]; then + echo -e "${CYAN}=== OIDC Client Secret Digest ===${NC}" + echo "" + if command -v docker >/dev/null 2>&1; then + client_secret_plain=$(cat "$client_secret_file") + digest_output=$(docker run --rm docker.io/authelia/authelia:latest \ + authelia crypto hash generate pbkdf2 --variant sha512 \ + --password "$client_secret_plain" 2>&1 || true) + digest=$(echo "$digest_output" | awk -F': ' '/Digest/ {print $2}') + if [ -n "$digest" ] && [ -f "$authelia_config_file_in" ] \ + && [ -f "$jwks_key_file" ]; then + tmp_config=$(mktemp) + # Single awk pass: + # * replace the PEM block between BEGIN/END PRIVATE KEY + # markers with the freshly generated JWKS key + # * replace the client_secret line whose value begins + # with $pbkdf2-sha512$ (placeholder or previously + # injected digest), preserving leading whitespace. Char + # 39 is '. + awk -v digest="$digest" \ + -v key_file="$jwks_key_file" \ + -v pem_indent=" " ' + BEGIN { + while ((getline line < key_file) > 0) { + pem = pem (pem ? "\n" : "") pem_indent line + } + close(key_file) + } + !key_done && /^[[:space:]]*-----BEGIN PRIVATE KEY-----/ { + print pem + in_pem = 1 + key_done = 1 + next + } + in_pem && /^[[:space:]]*-----END PRIVATE KEY-----/ { + in_pem = 0 + next + } + in_pem { next } + !digest_done && /^[[:space:]]*client_secret:[[:space:]]*.\$pbkdf2-sha512\$/ { + match($0, /^[[:space:]]*/) + indent = substr($0, RSTART, RLENGTH) + printf "%sclient_secret: %c%s%c\n", indent, 39, digest, 39 + digest_done = 1 + next + } + { print } + ' "$authelia_config_file_in" > "$tmp_config" + mv "$tmp_config" "$authelia_config_file" + echo -e "${GREEN}✓ Wrote Authelia config with injected JWKS key + client secret digest:${NC}" + echo -e " ${authelia_config_file#${DOCKER_DIR}/}" + echo "" + elif [ -n "$digest" ]; then + echo -e "${YELLOW}WARNING: ${authelia_config_file_in} or ${jwks_key_file} missing — config not built.${NC}" + echo -e "${YELLOW}Paste this digest into identity_providers.oidc.clients[soliplex].client_secret manually:${NC}" + echo "" + echo " $digest" + echo "" + else + echo -e "${RED}Failed to compute PBKDF2 digest via Authelia CLI.${NC}" + echo -e "${YELLOW}Run manually:${NC}" + echo " docker run --rm docker.io/authelia/authelia:latest \\" + echo " authelia crypto hash generate pbkdf2 --variant sha512 \\" + echo " --password \"\$(cat ${client_secret_file})\"" + echo "" + fi + else + echo -e "${YELLOW}Docker not available — hash the client secret manually:${NC}" + echo " docker run --rm docker.io/authelia/authelia:latest \\" + echo " authelia crypto hash generate pbkdf2 --variant sha512 \\" + echo " --password \"\$(cat ${client_secret_file})\"" + echo "" + fi +fi + +# Rebuild authelia/users_database.yml (gitignored) from its template, +# injecting a fresh argon2id hash of the default dev password +# ('authelia') in place of the 'REPLACE ME' placeholder. Regenerating +# the hash on every run prevents it from going stale across Authelia +# version bumps (argon2 parameter changes have caused the widely +# cited docs hash to stop validating in practice). To use a different +# admin password, change the '--password' argument below — the +# plaintext lives in this script, not on disk. +users_db_in="${DOCKER_DIR}/authelia/users_database.yml.in" +users_db="${DOCKER_DIR}/authelia/users_database.yml" +if [ -f "$users_db_in" ]; then + echo -e "${CYAN}=== Authelia Admin Password Hash ===${NC}" + echo "" + if command -v docker >/dev/null 2>&1; then + argon2_output=$(docker run --rm docker.io/authelia/authelia:latest \ + authelia crypto hash generate argon2 --password authelia 2>&1 || true) + argon2_hash=$(echo "$argon2_output" | awk -F': ' '/Digest/ {print $2}') + if [ -n "$argon2_hash" ]; then + tmp_db=$(mktemp) + # Replace the first password line whose value is the + # 'REPLACE ME' placeholder, preserving leading whitespace. + # Char 39 is '. + awk -v hash="$argon2_hash" ' + !done && /^[[:space:]]*password:[[:space:]]*.REPLACE ME./ { + match($0, /^[[:space:]]*/) + indent = substr($0, RSTART, RLENGTH) + printf "%spassword: %c%s%c\n", indent, 39, hash, 39 + done = 1 + next + } + { print } + ' "$users_db_in" > "$tmp_db" + mv "$tmp_db" "$users_db" + echo -e "${GREEN}✓ Wrote Authelia users database with fresh argon2id hash:${NC}" + echo -e " ${users_db#${DOCKER_DIR}/}" + echo "" + else + echo -e "${RED}Failed to compute argon2 hash via Authelia CLI.${NC}" + echo -e "${YELLOW}Run manually and paste the digest into ${users_db#${DOCKER_DIR}/}:${NC}" + echo " docker run --rm docker.io/authelia/authelia:latest \\" + echo " authelia crypto hash generate argon2 --password authelia" + echo "" + fi + else + echo -e "${YELLOW}Docker not available — hash the admin password manually:${NC}" + echo " docker run --rm docker.io/authelia/authelia:latest \\" + echo " authelia crypto hash generate argon2 --password authelia" + echo "" + fi +fi + # Provide next steps echo -e "${CYAN}=== Next Steps ===${NC}" echo ""