From a41d86e8bc35aed54256af14036d12095a44d759 Mon Sep 17 00:00:00 2001 From: Michael Barz Date: Mon, 1 Jun 2026 14:10:43 +0200 Subject: [PATCH] feat: add embedded authelia --- .../adr/0005-embed-authelia-as-default-idp.md | 407 +++++++++++++ go.mod | 25 +- go.sum | 22 + opencloud/pkg/command/server.go | 24 + opencloud/pkg/command/services.go | 6 + opencloud/pkg/init/init.go | 9 + opencloud/pkg/init/structs.go | 1 + opencloud/pkg/runtime/service/service.go | 24 +- pkg/config/config.go | 2 + pkg/config/defaultconfig.go | 2 + services/auth-authelia/pkg/command/health.go | 30 + services/auth-authelia/pkg/command/root.go | 35 ++ services/auth-authelia/pkg/command/server.go | 98 ++++ services/auth-authelia/pkg/command/version.go | 26 + services/auth-authelia/pkg/config/config.go | 64 +++ .../pkg/config/defaults/defaultconfig.go | 38 ++ .../auth-authelia/pkg/config/parser/parse.go | 42 ++ services/auth-authelia/pkg/render/render.go | 535 ++++++++++++++++++ .../pkg/config/defaults/defaultconfig.go | 32 +- 19 files changed, 1392 insertions(+), 30 deletions(-) create mode 100644 docs/adr/0005-embed-authelia-as-default-idp.md create mode 100644 services/auth-authelia/pkg/command/health.go create mode 100644 services/auth-authelia/pkg/command/root.go create mode 100644 services/auth-authelia/pkg/command/server.go create mode 100644 services/auth-authelia/pkg/command/version.go create mode 100644 services/auth-authelia/pkg/config/config.go create mode 100644 services/auth-authelia/pkg/config/defaults/defaultconfig.go create mode 100644 services/auth-authelia/pkg/config/parser/parse.go create mode 100644 services/auth-authelia/pkg/render/render.go diff --git a/docs/adr/0005-embed-authelia-as-default-idp.md b/docs/adr/0005-embed-authelia-as-default-idp.md new file mode 100644 index 0000000000..273332d1cb --- /dev/null +++ b/docs/adr/0005-embed-authelia-as-default-idp.md @@ -0,0 +1,407 @@ +--- +title: "Embed Authelia as the default Identity Provider" +--- + +* Status: proposed +* Deciders: [@micbar @rhafer @butonic @fschade] +* Date: 2026-05-31 + +Reference: https://github.com/authelia/authelia/issues/5803, https://github.com/authelia/authelia/pull/8841 + +## Context and Problem Statement + +OpenCloud ships a fully self-contained, out-of-the-box authentication stack by +embedding upstream Go libraries directly into its service binary: + +- The **idm** service embeds `github.com/libregraph/idm` and runs an in-process + LDAP server (`server.NewServer` in `services/idm/pkg/command/server.go`). +- The **idp** service embeds `github.com/libregraph/lico` ("LibreConnect") as the + OIDC provider. It boots lico via `bootstrap.Boot()`, extracts an + `http.Handler` from the returned managers, and mounts it into OpenCloud's own + chi router under a go-micro HTTP service + (`services/idp/pkg/service/v0/service.go:118-127`). + +Both dependencies are used **unforked, without `replace` directives**. This gives +operators a working IdP with zero external dependencies — a key part of the +OpenCloud value proposition. + +lico is functional but minimal. It provides OIDC and a basic login form, but no +second-factor authentication (TOTP / WebAuthn), no access-control policy engine, +and no self-service SSO portal. The OpenCloud team has long been interested in +**Authelia** as a richer embedded default IdP — it provides OIDC 1.0, 2FA +(TOTP/WebAuthn/Duo), per-resource access control, a regulation/brute-force layer, +and a complete login portal. + +The blocker, raised by OpenCloud in [authelia/authelia#5803][issue] (2023), was +that all of Authelia's logic lived in `internal/` and was therefore not +importable from another Go module. [authelia/authelia#8841][pr] (merged +2025-03-08) resolved this by adding a public, curated +`github.com/authelia/authelia/v4/experimental/embed` package. As of `v4.39.20` +(2026-05) this package is still present and actively maintained. + +This ADR evaluates whether and how to embed Authelia, and what role it should +play relative to the existing idp (lico) service. + +[issue]: https://github.com/authelia/authelia/issues/5803 +[pr]: https://github.com/authelia/authelia/pull/8841 + +## Decision Drivers + +* **Out-of-the-box experience** — keep the "single binary, no external auth + dependency" property that lico provides today. +* **Feature parity and beyond** — gain 2FA, access policies and an SSO portal + without operators bolting on a separate product. +* **Integration cost** — reuse of OpenCloud's runtime supervisor, config system, + routing and middleware; minimal custom glue. +* **Upstream stability** — the embed API is explicitly experimental; we must be + able to absorb breakage on Authelia version bumps. +* **Dependency weight** — added impact on the monolith binary's module graph, + build time and binary size. + +## Considered Options + +### Option A — Supervised standalone service (run the whole Authelia daemon) + +This is the integration model the `experimental/embed` package is designed for. +The public surface is intentionally small and config-file driven: + +```go +ctx, val, err := embed.New(paths []string, filterNames []string) // load + validate config +err = embed.ProvidersStartupCheck(ctx, log) +err = embed.ServiceRunAll(ctx) // run all Authelia services +``` + +`embed.ServiceRunAll` → `service.RunAll` provisions the full set of Authelia +services: the main `fasthttp` server **with its own listener**, the metrics +server, the users file watcher, and the logging signal handler +(`internal/service/provider.go` `GetProvisioners()`). + +We would add an OpenCloud service (e.g. `services/auth-authelia` or a new mode of +the existing idp service) whose `Server()` command: + +1. Renders an Authelia YAML config from OpenCloud's config system to a file **on + every start** (with secrets persisted separately; see "Config generation" + below) — mirroring how the idp (lico) service generates its clients + registration config in `NewService` (`createTemporaryClientsConfig`). It does + not depend on `opencloud init`. +2. Calls `embed.New(paths, filters)` and surfaces validation errors/warnings. +3. Wraps `embed.ServiceRunAll(ctx)` in a `runner.Runner` registered with the + suture supervisor in `opencloud/pkg/runtime/service/service.go`, exactly like + other services. + +Authelia owns its own port, router, lifecycle and config hot-reload. + +#### Pros: + +* Matches the embed package's intended usage → least friction with upstream, most + likely to keep working across version bumps. +* Reuses Authelia's own server, TLS, metrics and hot-reload — little glue code. +* Full feature set (portal, 2FA, policies) available immediately. + +#### Cons: + +* Authelia binds its **own** listener; it is not mounted into OpenCloud's chi + router. OpenCloud cannot inject its logging/tracing/static-asset middleware the + way it does for lico. Tracing and access-log correlation need separate wiring. +* Config is file/env driven, not struct driven (see "Config" note below) — we + must generate and template Authelia config. +* Two HTTP servers in the binary (Authelia's fasthttp + go-micro), each with its + own port/proxy entry. + +### Option B — Handler-mounted, lico-style + +Mirror the current idp integration: obtain an `http.Handler` from Authelia and +mount it into OpenCloud's existing chi router / go-micro HTTP service, reusing +OpenCloud middleware. + +The `experimental/embed/provider` subpackage exposes individual constructors +(`provider.New(config, caCertPool)` for the full `middlewares.Providers`, +`provider.NewOpenIDConnect`, `NewSession`, `NewAuthorizer`, …). In principle one +could assemble providers and build the server handler manually. + +#### Pros: + +* Identical integration shape to lico → uniform routing, middleware, tracing, + single HTTP server. + +#### Cons: + +* The embed package does **not** export the assembled `*fasthttp.RequestHandler` + / router. `server.New(...)` lives in `internal/` and returns a `*fasthttp.Server`, + not a `net/http.Handler` — Authelia is fasthttp-based, lico is `net/http`-based, + so there is no drop-in handler to mount into chi. +* We would be re-implementing Authelia's `internal/server` wiring against an API + the maintainers explicitly reserve the right to break ("methods may panic if + not properly utilized", `doc.go`). High maintenance risk, fights upstream + intent. +* fasthttp ↔ net/http adaptation adds overhead and edge cases (hijacking, + streaming, header semantics). + +### Option C — Keep lico, do nothing + +Stay on the current lico-based idp. + +#### Pros: + +* Zero work and zero new dependencies. + +#### Cons: + +* No 2FA, no policy engine, no portal — the gap that motivated this ADR remains. + +## Decision Outcome + +**Option A (supervised standalone service). Authelia is now the default IdP in +supervised (`opencloud server`) mode; the lico `idp` service is kept as an opt-in +alternative.** + +Rationale: + +* Option A is the only path that aligns with how `experimental/embed` is + designed and tested upstream, which is the single biggest factor in keeping a + declared-experimental dependency maintainable. +* Option B fights the fasthttp/`internal` boundary and would couple us to + unexported wiring the maintainers refuse to stabilize. + +The integration first shipped opt-in (lico default, Authelia behind a flag) to +de-risk it. It is now promoted to the **default**, with lico demoted to an opt-in +fallback so an exit path is preserved. See "Making Authelia the default" below for +the concrete wiring. + +Because Authelia maps to the **idp / OIDC-provider** role — not the LDAP **idm** +role — it supplements/replaces lico. The embedded libregraph **idm** LDAP server +stays as Authelia's `authentication_backend` (`provider.NewAuthenticationLDAP`), +so the existing user directory is reused unchanged. + +### Making Authelia the default + +Three changes flip the default from lico to Authelia (all in supervised +`opencloud server` mode): + +* **Service run-set** (`opencloud/pkg/runtime/service/service.go`): `auth-authelia` + moves to the core `reg(3, ...)` group (runs by default); `idp` moves to the + optional `areg(...)` group (registered but off). Falling back to lico is then + `OC_ADD_RUN_SERVICES=idp` + `OC_EXCLUDE_RUN_SERVICES=auth-authelia` (plus the + issuer/route notes below). + +* **OIDC issuer = `/authelia`.** Nine services derive the issuer from + `OC_URL;OC_OIDC_ISSUER;…` (last value wins, so `OC_OIDC_ISSUER` overrides + `OC_URL`). Authelia serves OIDC under the `/authelia` base path, so the issuer + is the subpath. The `server` command sets `OC_OIDC_ISSUER=/authelia` + before config parsing when it is unset, which propagates to all nine without + per-service wiring. An explicit `OC_OIDC_ISSUER` (external IdP, or lico fallback + which needs the root ``) is left untouched. Standalone single-service + deployments set `OC_OIDC_ISSUER` themselves. + + *Why not serve Authelia at the root so `issuer = OC_URL` needs no change?* + Authelia serves its login portal and its OIDC endpoints under one shared base + path and derives the issuer from it (`IssuerURL()` → `BasePath()`); the portal + index is `GET /`. At the root the portal would collide with the OpenCloud web UI + (also `/`), breaking the redirect-based login. The subpath is required, hence + the issuer subpath. + +* **Proxy routes** (`services/proxy/.../defaultconfig.go`): the `/authelia` route + (→ Authelia's listener) stays; the lico-specific `/konnect/` and `/signin/` + routes and the root `/.well-known/openid-configuration` route are removed — + discovery is now at `/authelia/.well-known/openid-configuration`, served + by the `/authelia` route. The default `proxy.oidc.issuer` literal becomes + `https://localhost:9200/authelia` for the no-env localhost case. + +* **Access-token verification = `none`** (`proxy.oidc.access_token_verify_method`): + Authelia issues **opaque** access tokens, whereas lico issued JWTs. The proxy's + `jwt` method verifies the token locally against the JWKS and cannot validate an + opaque token, so the default is `none`: local JWT verification is skipped and the + token is validated against Authelia's `/userinfo` endpoint instead (`SkipUserInfo` + stays `false`; the result is cached). This is validation by the IdP, not a bypass. + The proxy already anticipates this pairing (see the Authelia branch in + `services/proxy/pkg/middleware/oidc_auth.go`). The cost is a cached `/userinfo` + round-trip per new token; to avoid it, configure Authelia clients with + `access_token_signed_response_alg: 'RS256'` (JWT access tokens) and switch the + method back to `jwt`. + +### Service startup ordering + +`auth-authelia` runs in priority group 4 (with `frontend`), not group 3. Its +provider startup check dials the idm LDAP server (`ldaps://…:9235`), which the idm +service brings up in group 3; the runtime's post-group-3 wait ensures idm is +listening before group 4 starts. Registering it in group 3 races idm and fails the +startup check with "connection refused". (As an `areg`/optional service it was +implicitly scheduled after all groups, which masked the ordering requirement.) + +### Risks and mitigations + +* **Experimental API instability.** `doc.go` warns of breaking changes at any + minor bump and methods that may panic. *Mitigation:* pin exact Authelia + versions, gate upgrades behind an integration test that boots + `embed.New` + `ServiceRunAll` and exercises an OIDC flow, budget for glue + churn per bump. +* **Dependency graph / binary bloat.** Authelia pulls in fasthttp, its own SQL + storage (postgres/mysql/sqlite), webauthn, totp, session and regulation. + *Mitigation:* run the spike below first and measure `go mod graph` deltas, + build time and binary size before committing. +* **Config model mismatch.** `embed.Configuration` is a type **alias** to the + `internal` `schema.Configuration`; external code can hold the value but cannot + import the internal package to construct nested fields. *Mitigation:* drive + configuration through a generated YAML file + `embed.New(paths, filters)` (the + approach prototyped by @rhafer in 2023), not by building structs in Go. The + service renders that file itself on every start (see "Config generation" below), + so there is no struct-construction path to maintain. +* **Two HTTP servers / proxy wiring.** *Mitigation:* register Authelia's port in + the OpenCloud proxy/route table; document the topology for operators. + +### Implementation Steps + +1. **Dependency spike** (timeboxed): in a scratch module, `go get + github.com/authelia/authelia/v4@v4.39.20`, call `embed.New` with a minimal + generated config and `embed.ServiceRunAll`, boot it standalone against the + libregraph LDAP backend. Record module-graph, build-time and binary-size + deltas. Go / no-go gate. +2. **Re-engage upstream** ([#5803][issue] thread): confirm with @james-d-elliott + that the supervised-daemon embed model covers our use case and clarify the + support expectations for `experimental/embed`. +3. **Config generation layer**: template an Authelia YAML config from OpenCloud's + config system (issuer, OIDC clients, LDAP backend, session/storage, TOTP), + rendered by the service itself on every start (secrets persisted separately) + rather than by `opencloud init` (see "Config generation" below). This is the + bulk of the work. +4. **Service skeleton**: new opt-in service wrapping `embed.ServiceRunAll` in a + `runner.Runner`, registered with the suture supervisor in + `opencloud/pkg/runtime/service/service.go`; selectable vs. lico via config. +5. **Routing / proxy**: register Authelia's listener in the proxy/route table; + wire tracing and access-log correlation. +6. **Tests & docs**: boot + OIDC-flow integration test pinned to the chosen + Authelia version; admin docs in the docs repo (`/docs/admin/configuration/`) + covering the lico ↔ Authelia choice and the new settings. +7. **Review default**: once validated in production, revisit whether Authelia + becomes the default IdP (a follow-up ADR referencing this one). + +### Config generation + +Authelia is configured entirely through a YAML file (`embed.New(paths)`), so the +integration needs that file to exist with valid secrets, the OIDC clients, and +the LDAP backend pointing at the embedded libregraph-idm directory. Two patterns +were possible: + +* **Render it from `opencloud init`** (the first prototype): init writes + `authelia.yaml` next to `opencloud.yaml`. Rejected — it makes init aware of a + second, foreign config schema, and no other service works this way. +* **Render it from the service** (chosen): the `auth-authelia` service renders the + Authelia config in its `Server` command, then calls `embed.New`. This mirrors + the idp (lico) service, which generates its clients registration config in + `NewService` (`createTemporaryClientsConfig`), and keeps init free of Authelia + specifics. (See the Decision below for *when* it renders — on every start, with + secrets persisted separately.) + +Decision: **the service renders its own config on every start.** Concretely +(`services/auth-authelia/pkg/render`). + +A first prototype rendered `authelia.yaml` **once** (only if missing) and reused +it thereafter, so the embedded secrets would persist. That turned out to be a +trap: the file also froze every value *derived* from OpenCloud config (OIDC +issuer / `OC_URL`, SMTP settings, the LDAP bind password). After an admin changed +those in OpenCloud, the stale `authelia.yaml` silently kept the old values, with +no signal that it no longer reflected the configuration. + +The fix is to **separate the regenerated config from the persisted secrets**, the +same split OpenCloud already uses elsewhere (e.g. lico's `createTemporaryClientsConfig` +is rewritten on every start, while its signing keys are generate-once files). +Authelia deep-merges multiple config files, so the service passes a small set: + +* **`authelia.yaml` — regenerated on every start** from the current OpenCloud + configuration, so it can never go stale. It holds only non-secret, derived + values (issuer/domain, OIDC clients, LDAP backend incl. bind password, storage + path, notifier, log level) and carries a `GENERATED FILE - DO NOT EDIT` header + that points admins at OpenCloud config / env vars. +* **`authelia.secrets.yaml` — generated once**, then left untouched. It holds the + random secrets (session, storage-encryption, OIDC HMAC and RSA signing key, + password-reset JWT). Persisting these keeps active sessions and issued OIDC + tokens valid across restarts and config regenerations; deleting the file forces + fresh secrets. +* **`authelia.override.yaml` — optional, admin-managed**, never written by + OpenCloud, merged **last** so it takes precedence. This restores the ability to + customise Authelia (settings OpenCloud does not manage) without reintroducing + the staleness problem, since the generated file is no longer the edit surface. + +The public URL (OIDC issuer, session cookie domain, client redirect URIs) comes +from the shared `OC_URL` (`cfg.Commons.OpenCloudURL`). + +**Secrets vs. init's role.** The Authelia-internal secrets are generated and +persisted by the service (in `authelia.secrets.yaml`), not by init. The only value +shared with the rest of OpenCloud is the **LDAP bind password**: rather than +seeding a dedicated `authelia` LDAP account, Authelia **reuses the existing `idp` +service user** (`uid=idp,ou=sysusers,o=libregraph-idm`) — the same account the +lico idp binds as. As with every LDAP service user, `opencloud init` generates +that password and persists it in `opencloud.yaml`; for Authelia it is written to +`auth_authelia.ldap.bind_password` (mirroring `idp.ldap.bind_password`, and equal +to it) and the service reads it via its config. So init still owns *passwords* +(consistent with all services) and seeds no new account, while the service owns +the *Authelia config file*. + +This keeps the "single binary, no external dependency" property: a fresh +deployment that has run `opencloud init` and starts the opt-in `auth-authelia` +service gets a working IdP with no extra config steps, and one whose generated +config always reflects the live OpenCloud configuration. + +### Serving the Authelia frontend + +Authelia serves its **own** login portal — OpenCloud does not host the assets. +The flow, verified end-to-end against a running `auth-authelia` service: + +* The compiled React portal is **embedded into the binary** at compile time via + `//go:embed public_html` in Authelia's `internal/server/asset.go`. There is no + separate static-asset directory to deploy. +* Authelia serves the portal, the OIDC endpoints and the assets from **its own + HTTP listener** (`127.0.0.1:9091`, base path `/authelia`, set via + `server.address` in the rendered `authelia.yaml`). +* The OpenCloud proxy forwards `/authelia/*` to that listener (the + `/authelia → http://127.0.0.1:9091` route in + `services/proxy/pkg/config/defaults/defaultconfig.go`). +* **Base path is automatic.** Authelia strips its configured router path + (`middlewares.StripPath`) and templates `` into + `index.html`, so the SPA's relative `./static/...` asset URLs resolve under + `/authelia` without any rewriting on the OpenCloud side. +* **OIDC issuer needs forwarded headers.** Authelia derives the effective issuer + from `X-Forwarded-Proto` / `X-Forwarded-Host`. The OpenCloud proxy sets these + (`req.SetXForwarded()` in `services/proxy/pkg/router/router.go`), so behind the + TLS-terminating proxy the issuer resolves to `https:///authelia`. + Hitting the `:9091` listener directly over plain HTTP returns `400` on the OIDC + endpoints — this is expected, not a bug. + +#### Building the embedded frontend + +`go:embed` only captures what is on disk **when the OpenCloud binary is +compiled**. While we build against a local `replace` checkout of Authelia (rather +than a published module), the frontend must be built into that checkout first. +Two sharp edges, both hit during the spike: + +1. **`vite build` wipes the committed `api/` placeholders.** Authelia commits + `internal/server/public_html/api/{index.html,openapi.yml}` as **0-byte + placeholders** (the real Swagger docs are generated by a separate build step). + Authelia's `templates.LoadTemplatedAssets` **requires both files to exist** at + startup. `vite build` empties the output dir and deletes them, so the service + then fails to boot with + `loading template 'assets/public_html/api/index.html': file does not exist`. + The empty placeholders are enough (they only blank the `/api` docs page; the + portal and OIDC are unaffected). + +2. **`go:embed` is compile-time.** Changing the assets on disk does nothing to an + already-built binary; OpenCloud must be rebuilt afterwards. + +The reproducible sequence is therefore: + +```bash +# 1. build the portal into the Authelia checkout (the replace target) +cd /web && pnpm install && pnpm build # vite → internal/server/public_html + +# 2. restore the committed api/ placeholders that vite deleted +git -C checkout -- internal/server/public_html/api + +# 3. rebuild the OpenCloud binary so go:embed captures the real assets +cd && make build +``` + +This friction is a direct consequence of the local `replace` directive and +**disappears once we depend on a published `authelia/v4` release**: the module zip +on the proxy ships `public_html` already built, including the generated `api/` +docs. Until then, steps 1–3 (or an equivalent build target) must run before any +build that needs a working portal. \ No newline at end of file diff --git a/go.mod b/go.mod index 9cc3f4ef9a..2a78611000 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/opencloud-eu/opencloud -go 1.25.0 +go 1.26.0 require ( dario.cat/mergo v1.0.2 @@ -102,7 +102,7 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/crypto v0.51.0 + golang.org/x/crypto v0.52.0 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/image v0.40.0 golang.org/x/net v0.55.0 @@ -138,6 +138,7 @@ require ( github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/authelia/authelia/v4 v4.39.20 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bitly/go-simplejson v0.5.0 // indirect github.com/bits-and-blooms/bitset v1.22.0 // indirect @@ -188,7 +189,7 @@ require ( github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -199,10 +200,10 @@ require ( github.com/evanphx/json-patch/v5 v5.5.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/gdexlab/go-render v1.0.1 // indirect github.com/go-acme/lego/v4 v4.4.0 // indirect - github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20260416181348-e7dc79048676 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-git/go-git/v5 v5.19.1 // indirect @@ -223,7 +224,7 @@ require ( github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-test/deep v1.1.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect @@ -257,7 +258,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/juliangruber/go-intersect v1.1.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect github.com/kovidgoyal/go-parallel v1.1.1 // indirect @@ -279,7 +280,7 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/mattn/go-sqlite3 v1.14.42 // indirect + github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/maxymania/go-system v0.0.0-20170110133659-647cc364bf0b // indirect github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103 // indirect github.com/miekg/dns v1.1.68 // indirect @@ -328,7 +329,7 @@ require ( github.com/prometheus/alertmanager v0.31.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.17.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/statsd_exporter v0.22.8 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/rs/xid v1.6.0 // indirect @@ -360,7 +361,7 @@ require ( github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - github.com/tinylib/msgp v1.6.1 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect @@ -387,7 +388,7 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sys v0.45.0 // indirect @@ -415,3 +416,5 @@ replace github.com/go-micro/plugins/v4/store/nats-js-kv => github.com/opencloud- // to get the logger injection (https://github.com/pablodz/inotifywaitgo/pull/11) replace github.com/pablodz/inotifywaitgo v0.0.9 => github.com/opencloud-eu/inotifywaitgo v0.0.0-20251111171128-a390bae3c5e9 + +replace github.com/authelia/authelia/v4 => /Users/m.barz/Code/opencloud/authelia diff --git a/go.sum b/go.sum index 83cae6c315..6a09a011de 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/authelia/authelia/v4 v4.39.20 h1:nM/fmvpLtlJ1Nx9SttGsVFQF6NRun85en8y/MiVJeAk= +github.com/authelia/authelia/v4 v4.39.20/go.mod h1:i8DrZ4QBQH0vX/OYS2cvW7IMKCcf8+hLYpwzcH9PRT8= github.com/aws/aws-sdk-go v1.37.27/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/bbalet/stopwords v1.0.0 h1:0TnGycCtY0zZi4ltKoOGRFIlZHv0WqpoIGUsObjztfo= github.com/bbalet/stopwords v1.0.0/go.mod h1:sAWrQoDMfqARGIn4s6dp7OW7ISrshUD8IP2q3KoqPjc= @@ -303,6 +305,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= github.com/dnsimple/dnsimple-go v0.63.0/go.mod h1:O5TJ0/U6r7AfT8niYNlmohpLbCSG+c71tQlGr9SeGrg= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -351,6 +355,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gdexlab/go-render v1.0.1 h1:rxqB3vo5s4n1kF0ySmoNeSPRYkEsyHgln4jFIQY7v0U= @@ -373,6 +379,8 @@ github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkPro github.com/go-asn1-ber/asn1-ber v1.4.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20260416181348-e7dc79048676 h1:/8x2r8Tu++vpNYlO8ACB3cdwoFYDGGxkOSOLLHN9E2o= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20260416181348-e7dc79048676/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= @@ -468,6 +476,8 @@ github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= @@ -721,6 +731,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -822,6 +834,8 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-tty v0.0.0-20180219170247-931426f7535a/go.mod h1:XPvLUNfbS4fJH25nqRHfWLMa1ONC8Amw+mIA639KxkE= github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -1055,6 +1069,8 @@ github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1 github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/statsd_exporter v0.22.7/go.mod h1:N/TevpjkIh9ccs6nuzY3jQn9dFqnUakOjnEuMPJJJnI= github.com/prometheus/statsd_exporter v0.22.8 h1:Qo2D9ZzaQG+id9i5NYNGmbf1aa/KxKbB9aKfMS+Yib0= github.com/prometheus/statsd_exporter v0.22.8/go.mod h1:/DzwbTEaFTE0Ojz5PqcSk6+PFHOPWGxdXVr6yC8eFOM= @@ -1213,6 +1229,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= @@ -1341,6 +1359,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180621125126-a49355c7e3f8/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -1364,6 +1384,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= diff --git a/opencloud/pkg/command/server.go b/opencloud/pkg/command/server.go index 776f24d271..126c6f6ab1 100644 --- a/opencloud/pkg/command/server.go +++ b/opencloud/pkg/command/server.go @@ -1,6 +1,9 @@ package command import ( + "os" + "strings" + "github.com/opencloud-eu/opencloud/opencloud/pkg/register" "github.com/opencloud-eu/opencloud/opencloud/pkg/runtime" "github.com/opencloud-eu/opencloud/pkg/config" @@ -16,6 +19,7 @@ func Server(cfg *config.Config) *cobra.Command { Use: "server", Short: "start a fullstack server (runtime and all services in supervised mode)", PreRunE: func(cmd *cobra.Command, args []string) error { + defaultOIDCIssuerToAuthelia() return configlog.ReturnError(parser.ParseConfig(cfg, false)) }, GroupID: CommandGroupServer, @@ -27,6 +31,26 @@ func Server(cfg *config.Config) *cobra.Command { } } +// defaultOIDCIssuerToAuthelia points the shared OIDC issuer at the embedded Authelia provider, which +// is the default IdP in supervised mode. Authelia serves OIDC under the '/authelia' base path, so the +// issuer is '/authelia'. Every issuer-consuming service honors OC_OIDC_ISSUER, so setting it +// here (before config parsing) propagates the value to all of them without per-service wiring. +// +// It only sets a default: an explicit OC_OIDC_ISSUER is left untouched (e.g. for an external IdP, or +// when falling back to the lico 'idp' service, which serves OIDC at the root and needs OC_URL). +func defaultOIDCIssuerToAuthelia() { + if _, ok := os.LookupEnv("OC_OIDC_ISSUER"); ok { + return + } + + ocURL := os.Getenv("OC_URL") + if ocURL == "" { + ocURL = "https://localhost:9200" + } + + _ = os.Setenv("OC_OIDC_ISSUER", strings.TrimRight(ocURL, "/")+"/authelia") +} + func init() { register.AddCommand(Server) } diff --git a/opencloud/pkg/command/services.go b/opencloud/pkg/command/services.go index 3c0d3852d0..54cd9a03e5 100644 --- a/opencloud/pkg/command/services.go +++ b/opencloud/pkg/command/services.go @@ -13,6 +13,7 @@ import ( appregistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/command" audit "github.com/opencloud-eu/opencloud/services/audit/pkg/command" authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/command" + authauthelia "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/command" authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/command" authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/command" authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/command" @@ -83,6 +84,11 @@ var serviceCommands = []register.Command{ cfg.AuthApp.Commons = cfg.Commons }) }, + func(cfg *config.Config) *cobra.Command { + return ServiceCommand(cfg, cfg.AuthAuthelia.Service.Name, authauthelia.GetCommands(cfg.AuthAuthelia), func(c *config.Config) { + cfg.AuthAuthelia.Commons = cfg.Commons + }) + }, func(cfg *config.Config) *cobra.Command { return ServiceCommand(cfg, cfg.AuthBasic.Service.Name, authbasic.GetCommands(cfg.AuthBasic), func(c *config.Config) { cfg.AuthBasic.Commons = cfg.Commons diff --git a/opencloud/pkg/init/init.go b/opencloud/pkg/init/init.go index 83ae39e453..8a8237c53d 100644 --- a/opencloud/pkg/init/init.go +++ b/opencloud/pkg/init/init.go @@ -200,6 +200,15 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword }, }, }, + // The auth-authelia service renders its own authelia.yaml and binds to the embedded LDAP + // directory as the idm admin user (uid=libregraph) so it can modify user passwords (password + // change). That account's password is the 'idm' service-user password, reused here rather than + // seeding a dedicated Authelia account. + AuthAuthelia: LdapBasedService{ + Ldap: LdapSettings{ + BindPassword: idmServicePassword, + }, + }, Collaboration: Collaboration{ WopiApp: WopiApp{ Secret: collaborationWOPISecret, diff --git a/opencloud/pkg/init/structs.go b/opencloud/pkg/init/structs.go index 3e6d0ae4f9..6a758a03b1 100644 --- a/opencloud/pkg/init/structs.go +++ b/opencloud/pkg/init/structs.go @@ -29,6 +29,7 @@ type OpenCloudConfig struct { Proxy ProxyService `yaml:"proxy"` Frontend FrontendService `yaml:"frontend"` AuthBasic AuthbasicService `yaml:"auth_basic"` + AuthAuthelia LdapBasedService `yaml:"auth_authelia"` AuthBearer AuthbearerService `yaml:"auth_bearer"` Users UsersAndGroupsService `yaml:"users"` Groups UsersAndGroupsService `yaml:"groups"` diff --git a/opencloud/pkg/runtime/service/service.go b/opencloud/pkg/runtime/service/service.go index 13b0af8076..10b6b6ba8a 100644 --- a/opencloud/pkg/runtime/service/service.go +++ b/opencloud/pkg/runtime/service/service.go @@ -25,6 +25,7 @@ import ( appRegistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/command" audit "github.com/opencloud-eu/opencloud/services/audit/pkg/command" authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/command" + authauthelia "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/command" authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/command" authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/command" authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/command" @@ -273,11 +274,6 @@ func NewService(ctx context.Context, options ...Option) (*Service, error) { cfg.Webfinger.Commons = cfg.Commons return webfinger.Execute(cfg.Webfinger) }) - reg(3, opts.Config.IDP.Service.Name, func(ctx context.Context, cfg *occfg.Config) error { - cfg.IDP.Context = ctx - cfg.IDP.Commons = cfg.Commons - return idp.Execute(cfg.IDP) - }) reg(3, opts.Config.Proxy.Service.Name, func(ctx context.Context, cfg *occfg.Config) error { cfg.Proxy.Context = ctx cfg.Proxy.Commons = cfg.Commons @@ -308,10 +304,28 @@ func NewService(ctx context.Context, options ...Option) (*Service, error) { return frontend.Execute(cfg.Frontend) }) + // auth-authelia is the default identity provider (OIDC). It runs in priority group 4 (after the + // post-group-3 wait) because its startup check dials the idm LDAP server (ldaps://...:9235), which + // is brought up by the idm service in group 3 - starting it in group 3 would race idm and fail with + // "connection refused". The lico-based 'idp' service is kept as an opt-in alternative (registered + // as an optional service below). + reg(4, opts.Config.AuthAuthelia.Service.Name, func(ctx context.Context, cfg *occfg.Config) error { + cfg.AuthAuthelia.Context = ctx + cfg.AuthAuthelia.Commons = cfg.Commons + return authauthelia.Execute(cfg.AuthAuthelia) + }) + // populate optional services areg := func(name string, exec func(context.Context, *occfg.Config) error) { s.Additional[name] = NewSutureServiceBuilder(name, exec) } + // The lico-based 'idp' service is the opt-in alternative to the default auth-authelia provider. + // Enable it via OC_ADD_RUN_SERVICES (and disable auth-authelia) to fall back to lico. + areg(opts.Config.IDP.Service.Name, func(ctx context.Context, cfg *occfg.Config) error { + cfg.IDP.Context = ctx + cfg.IDP.Commons = cfg.Commons + return idp.Execute(cfg.IDP) + }) areg(opts.Config.Antivirus.Service.Name, func(ctx context.Context, cfg *occfg.Config) error { cfg.Antivirus.Context = ctx cfg.Antivirus.Commons = cfg.Commons diff --git a/pkg/config/config.go b/pkg/config/config.go index 36536c8064..c4a55984da 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,6 +8,7 @@ import ( appRegistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/config" audit "github.com/opencloud-eu/opencloud/services/audit/pkg/config" authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/config" + authAuthelia "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config" authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/config" authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/config" authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/config" @@ -88,6 +89,7 @@ type Config struct { AppRegistry *appRegistry.Config `yaml:"app_registry"` Audit *audit.Config `yaml:"audit"` AuthApp *authapp.Config `yaml:"auth_app"` + AuthAuthelia *authAuthelia.Config `yaml:"auth_authelia"` AuthBasic *authbasic.Config `yaml:"auth_basic"` AuthBearer *authbearer.Config `yaml:"auth_bearer"` AuthMachine *authmachine.Config `yaml:"auth_machine"` diff --git a/pkg/config/defaultconfig.go b/pkg/config/defaultconfig.go index 59f5de02b2..4012b6403b 100644 --- a/pkg/config/defaultconfig.go +++ b/pkg/config/defaultconfig.go @@ -8,6 +8,7 @@ import ( appRegistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/config/defaults" audit "github.com/opencloud-eu/opencloud/services/audit/pkg/config/defaults" authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/config/defaults" + authAuthelia "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config/defaults" authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/config/defaults" authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/config/defaults" authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/config/defaults" @@ -63,6 +64,7 @@ func DefaultConfig() *Config { AppRegistry: appRegistry.DefaultConfig(), Audit: audit.DefaultConfig(), AuthApp: authapp.DefaultConfig(), + AuthAuthelia: authAuthelia.DefaultConfig(), AuthBasic: authbasic.DefaultConfig(), AuthBearer: authbearer.DefaultConfig(), AuthMachine: authmachine.DefaultConfig(), diff --git a/services/auth-authelia/pkg/command/health.go b/services/auth-authelia/pkg/command/health.go new file mode 100644 index 0000000000..c1243910c6 --- /dev/null +++ b/services/auth-authelia/pkg/command/health.go @@ -0,0 +1,30 @@ +package command + +import ( + "github.com/opencloud-eu/opencloud/pkg/config/configlog" + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config/parser" + + "github.com/spf13/cobra" +) + +// Health is the entrypoint for the health command. +// +// Authelia exposes its own health endpoint on the listener it manages itself (see the rendered +// authelia.yaml). The OpenCloud service therefore only validates that its configuration can be +// parsed and that the Authelia configuration file is referenced. +func Health(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "health", + Short: "check health status", + PreRunE: func(cmd *cobra.Command, args []string) error { + return configlog.ReturnError(parser.ParseConfig(cfg)) + }, + RunE: func(cmd *cobra.Command, args []string) error { + logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel) + logger.Debug().Str("config", cfg.ConfigPath).Msg("Health configuration is valid") + return nil + }, + } +} diff --git a/services/auth-authelia/pkg/command/root.go b/services/auth-authelia/pkg/command/root.go new file mode 100644 index 0000000000..6368bb702f --- /dev/null +++ b/services/auth-authelia/pkg/command/root.go @@ -0,0 +1,35 @@ +package command + +import ( + "os" + + "github.com/opencloud-eu/opencloud/pkg/clihelper" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config" + + "github.com/spf13/cobra" +) + +// GetCommands provides all commands for this service +func GetCommands(cfg *config.Config) []*cobra.Command { + return []*cobra.Command{ + // start this service + Server(cfg), + + // infos about this service + Health(cfg), + Version(cfg), + } +} + +// Execute is the entry point for the opencloud auth-authelia command. +func Execute(cfg *config.Config) error { + app := clihelper.DefaultApp(&cobra.Command{ + Use: "auth-authelia", + Short: "Serve the embedded Authelia authentication provider for OpenCloud", + }) + + app.AddCommand(GetCommands(cfg)...) + app.SetArgs(os.Args[1:]) + + return app.ExecuteContext(cfg.Context) +} diff --git a/services/auth-authelia/pkg/command/server.go b/services/auth-authelia/pkg/command/server.go new file mode 100644 index 0000000000..1e20827be7 --- /dev/null +++ b/services/auth-authelia/pkg/command/server.go @@ -0,0 +1,98 @@ +package command + +import ( + "context" + "fmt" + "os/signal" + + "github.com/opencloud-eu/opencloud/pkg/config/configlog" + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/runner" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config/parser" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/render" + + embed "github.com/authelia/authelia/v4/experimental/embed" + "github.com/spf13/cobra" +) + +// Server is the entrypoint for the server command. +func Server(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "server", + Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name), + PreRunE: func(cmd *cobra.Command, args []string) error { + return configlog.ReturnFatal(parser.ParseConfig(cfg)) + }, + RunE: func(cmd *cobra.Command, args []string) error { + logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel) + + var cancel context.CancelFunc + if cfg.Context == nil { + cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...) + defer cancel() + } + ctx := cfg.Context + + // Render the Authelia configuration. The main config file is regenerated from the current + // OpenCloud configuration on every start (so it never goes stale), while the secrets file is + // generated once and reused. This mirrors how the idp service generates its config in + // NewService, and is why the service does not depend on 'opencloud init' to produce the file. + configPaths, err := render.Config(logger, cfg) + if err != nil { + logger.Error().Err(err).Str("config", cfg.ConfigPath).Msg("failed to render authelia configuration") + return err + } + + // The embedded Authelia services watch this context for cancellation. When the OpenCloud + // supervisor (or a process signal) cancels it, Authelia performs a graceful shutdown. + runCtx, runCancel := context.WithCancel(ctx) + defer runCancel() + + // Build the embedded Authelia context from the rendered configuration files (Authelia + // deep-merges them). This validates the configuration and instantiates all Authelia + // providers (LDAP backend, storage, OIDC, ...). + actx, validator, err := embed.NewWithContext(runCtx, configPaths, cfg.FilterNames) + if err != nil { + if validator != nil { + for _, verr := range validator.Errors() { + logger.Error().Err(verr).Strs("config", configPaths).Msg("invalid authelia configuration") + } + } + logger.Error().Err(err).Strs("config", configPaths).Msg("failed to initialize embedded authelia") + return err + } + + // Run Authelia's provider startup checks before serving. This is what migrates the storage + // schema to the latest version (creating the authentication_logs, banned_ip, ... tables); + // without it the SQLite database stays empty and every auth attempt fails with "no such + // table". The notifier (SMTP) and NTP startup checks are disabled in the rendered config so + // an unreachable mail server or missing outbound NTP does not block startup. + if err := embed.ProvidersStartupCheck(actx, true); err != nil { + logger.Error().Err(err).Str("config", cfg.ConfigPath).Msg("authelia provider startup checks failed") + return err + } + + logger.Info().Str("config", cfg.ConfigPath).Msg("starting embedded authelia") + + gr := runner.NewGroup() + gr.Add(runner.New(cfg.Service.Name, func() error { + // ServiceRunAll blocks until runCtx is cancelled (or Authelia receives a process signal), + // running Authelia's own HTTP server, metrics server and watchers. + return embed.ServiceRunAll(actx) + }, func() { + runCancel() + })) + + grResults := gr.Run(ctx) + + // return the first non-nil error found in the results + for _, grResult := range grResults { + if grResult.RunnerError != nil { + return grResult.RunnerError + } + } + return nil + }, + } +} diff --git a/services/auth-authelia/pkg/command/version.go b/services/auth-authelia/pkg/command/version.go new file mode 100644 index 0000000000..7f491f4529 --- /dev/null +++ b/services/auth-authelia/pkg/command/version.go @@ -0,0 +1,26 @@ +package command + +import ( + "fmt" + + "github.com/opencloud-eu/opencloud/pkg/version" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config" + + "github.com/spf13/cobra" +) + +// Version prints the version of this binary. +// +// The embedded Authelia service does not register with the go-micro service registry (it serves its +// own HTTP listener), so this command only prints the OpenCloud build version. +func Version(_ *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "print the version of this binary", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("Version: " + version.GetString()) + fmt.Printf("Compiled: %s\n", version.Compiled()) + return nil + }, + } +} diff --git a/services/auth-authelia/pkg/config/config.go b/services/auth-authelia/pkg/config/config.go new file mode 100644 index 0000000000..e08cd313bc --- /dev/null +++ b/services/auth-authelia/pkg/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "context" + + "github.com/opencloud-eu/opencloud/pkg/shared" +) + +// Config combines all available configuration parts for the auth-authelia service. +// +// The auth-authelia service embeds Authelia (https://www.authelia.com) as an out-of-the-box +// authentication provider and OpenID Connect provider. Unlike most other OpenCloud services it +// does not expose a go-micro HTTP server. Instead it boots Authelia via the upstream +// experimental/embed package, which serves its own HTTP listener. The OpenCloud proxy forwards +// the relevant request paths to that listener. +// +// The actual Authelia configuration (LDAP backend, OIDC clients, storage, notifier, secrets) lives +// in a dedicated YAML file. Mirroring how the idp (lico) service generates its clients registration +// config, the auth-authelia service renders that file itself on first start (see pkg/render); it is +// not produced by `opencloud init`. This service only needs to know where the file lives and the +// shared 'authelia' LDAP bind password. +type Config struct { + Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service + + Service Service `yaml:"-"` + + LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;AUTH_AUTHELIA_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"7.0.0"` + + ConfigPath string `yaml:"config_path" env:"AUTH_AUTHELIA_CONFIG_PATH" desc:"Path to the Authelia configuration file. The auth-authelia service renders it on first start if it does not exist." introductionVersion:"7.0.0"` + FilterNames []string `yaml:"-"` // Authelia config file filters (e.g. 'template'); empty by default. + + LDAP LDAP `yaml:"ldap"` + SMTP SMTP `yaml:"smtp"` + + Context context.Context `yaml:"-"` +} + +// SMTP configures the Authelia mail notifier (password reset, 2FA registration). It is optional: +// when Host is empty the service renders a filesystem notifier instead, so a fresh deployment works +// without any mail server. The env vars default to the OpenCloud notifications service's SMTP +// settings (so SMTP is configured once for the whole deployment), with AUTH_AUTHELIA_SMTP_* taking +// precedence when an Authelia-specific mail server is wanted. +type SMTP struct { + Host string `yaml:"smtp_host" env:"NOTIFICATIONS_SMTP_HOST;AUTH_AUTHELIA_SMTP_HOST" desc:"SMTP host to connect to. When set, Authelia sends notification mails via SMTP; when empty it writes them to a file." introductionVersion:"7.0.0"` + Port int `yaml:"smtp_port" env:"NOTIFICATIONS_SMTP_PORT;AUTH_AUTHELIA_SMTP_PORT" desc:"Port of the SMTP host to connect to." introductionVersion:"7.0.0"` + Sender string `yaml:"smtp_sender" env:"NOTIFICATIONS_SMTP_SENDER;AUTH_AUTHELIA_SMTP_SENDER" desc:"Sender address of emails sent by Authelia (e.g. 'OpenCloud ')." introductionVersion:"7.0.0"` + Username string `yaml:"smtp_username" env:"NOTIFICATIONS_SMTP_USERNAME;AUTH_AUTHELIA_SMTP_USERNAME" desc:"Username for SMTP authentication." introductionVersion:"7.0.0"` + Password string `yaml:"smtp_password" env:"NOTIFICATIONS_SMTP_PASSWORD;AUTH_AUTHELIA_SMTP_PASSWORD" desc:"Password for SMTP authentication." introductionVersion:"7.0.0"` + Encryption string `yaml:"smtp_encryption" env:"NOTIFICATIONS_SMTP_ENCRYPTION;AUTH_AUTHELIA_SMTP_ENCRYPTION" desc:"Encryption method for SMTP. Possible values are 'starttls', 'ssltls' and 'none'." introductionVersion:"7.0.0"` +} + +// LDAP holds the settings the service needs to render the Authelia LDAP authentication backend. +// Authelia binds to the embedded libregraph-idm directory as the idm admin user (uid=libregraph) - +// the only account the directory lets modify entries, which Authelia needs for password changes. +// Only the bind password is configurable here; the bind DN and address are fixed to the embedded +// directory. +type LDAP struct { + BindPassword string `yaml:"bind_password" env:"OC_LDAP_BIND_PASSWORD;AUTH_AUTHELIA_LDAP_BIND_PASSWORD" desc:"Password for the 'idm' admin LDAP user (uid=libregraph) Authelia binds as. Generated by 'opencloud init' (shared with the idm service)." introductionVersion:"7.0.0"` +} + +// Service defines the available service configuration. +type Service struct { + Name string `yaml:"-"` +} diff --git a/services/auth-authelia/pkg/config/defaults/defaultconfig.go b/services/auth-authelia/pkg/config/defaults/defaultconfig.go new file mode 100644 index 0000000000..6d76cd6cf7 --- /dev/null +++ b/services/auth-authelia/pkg/config/defaults/defaultconfig.go @@ -0,0 +1,38 @@ +package defaults + +import ( + "path/filepath" + + "github.com/opencloud-eu/opencloud/pkg/config/defaults" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config" +) + +// FullDefaultConfig returns a fully initialized default configuration. +func FullDefaultConfig() *config.Config { + cfg := DefaultConfig() + EnsureDefaults(cfg) + Sanitize(cfg) + return cfg +} + +// DefaultConfig returns a basic default configuration. +func DefaultConfig() *config.Config { + return &config.Config{ + Service: config.Service{ + Name: "auth-authelia", + }, + // The config file is rendered by `opencloud init` into the OpenCloud config directory. + ConfigPath: filepath.Join(defaults.BaseConfigPath(), "authelia.yaml"), + FilterNames: nil, + } +} + +// EnsureDefaults adds default values to the configuration if they are not set yet. +func EnsureDefaults(cfg *config.Config) { + if cfg.LogLevel == "" { + cfg.LogLevel = "error" + } +} + +// Sanitize sanitizes the configuration. +func Sanitize(_ *config.Config) {} diff --git a/services/auth-authelia/pkg/config/parser/parse.go b/services/auth-authelia/pkg/config/parser/parse.go new file mode 100644 index 0000000000..29253fce14 --- /dev/null +++ b/services/auth-authelia/pkg/config/parser/parse.go @@ -0,0 +1,42 @@ +package parser + +import ( + "errors" + "fmt" + + occfg "github.com/opencloud-eu/opencloud/pkg/config" + "github.com/opencloud-eu/opencloud/pkg/config/envdecode" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config/defaults" +) + +// ParseConfig loads configuration from known paths. +func ParseConfig(cfg *config.Config) error { + err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg) + if err != nil { + return err + } + + defaults.EnsureDefaults(cfg) + + // load all env variables relevant to the config in the current context. + if err := envdecode.Decode(cfg); err != nil { + // no environment variable set for this config is an expected "error" + if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) { + return err + } + } + + defaults.Sanitize(cfg) + + return Validate(cfg) +} + +// Validate validates the configuration. +func Validate(cfg *config.Config) error { + if cfg.ConfigPath == "" { + return fmt.Errorf("the authelia config path is not configured for the %s service", cfg.Service.Name) + } + + return nil +} diff --git a/services/auth-authelia/pkg/render/render.go b/services/auth-authelia/pkg/render/render.go new file mode 100644 index 0000000000..446e9db59f --- /dev/null +++ b/services/auth-authelia/pkg/render/render.go @@ -0,0 +1,535 @@ +// Package render generates the Authelia configuration consumed by the embedded Authelia provider. +// +// It writes two files (mirroring how the idp (lico) service generates its config in +// services/idp/pkg/service/v0/service.go, and how OpenCloud keeps secrets separate from config): +// +// - authelia.yaml: regenerated from the current OpenCloud configuration on every start, so it +// always reflects the live values (OC_URL, SMTP settings, the idp bind password, ...). It carries +// a "do not edit" header because manual changes are overwritten. +// - authelia.secrets.yaml: the random secrets (session, storage encryption, OIDC HMAC and signing +// key, password-reset JWT). Generated once and then left untouched so existing sessions and OIDC +// tokens survive restarts and config changes. +// +// Authelia deep-merges multiple config files, so the two are passed together. An optional +// authelia.override.yaml (admin-managed, never generated) is merged last and takes precedence. +// +// There is intentionally no dependency on 'opencloud init': the only value the service needs from +// init is the LDAP bind password of the libregraph-idm admin user it binds as, read from its config. +package render + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" + + "github.com/opencloud-eu/opencloud/pkg/config/defaults" + "github.com/opencloud-eu/opencloud/pkg/generators" + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/auth-authelia/pkg/config" +) + +const ( + // secretLength is the length of the random secrets generated for Authelia (session, storage + // encryption, OIDC HMAC and the password-reset JWT). Authelia recommends long secrets. + secretLength = 64 + // oidcKeyBits is the size of the RSA key used by Authelia to sign OIDC tokens. + oidcKeyBits = 2048 + // port is the loopback port the embedded Authelia HTTP server binds to. It must match the + // backend referenced by the proxy route (services/proxy .../defaultconfig.go). + port = 9091 + // basePath is the URL base path Authelia is served under. Serving Authelia on a subpath lets a + // single proxy route forward to it without colliding with the web frontend or the lico idp. + basePath = "authelia" + // ldapBindDN is the DN of the LDAP service user Authelia binds as (service-user binding). It must + // be the libregraph-idm admin DN, because that directory only permits its configured admin DN to + // modify entries (e.g. a user's userPassword during a password change); a read-only account such + // as 'uid=idp' is rejected with "Insufficient Access Rights". This is the same admin account the + // graph service uses for user management. The matching password (the 'idm' service-user password) + // is provided via cfg.LDAP.BindPassword. + ldapBindDN = "uid=libregraph,ou=sysusers,o=libregraph-idm" + // ldapAddress is the loopback LDAPS address of the embedded libregraph-idm server. + ldapAddress = "ldaps://127.0.0.1:9235" + + // secretsFilename is the generate-once secrets file, kept next to the main config file. + secretsFilename = "authelia.secrets.yaml" + // overrideFilename is an optional admin-managed file merged last (highest precedence). It is + // never generated or modified by OpenCloud. + overrideFilename = "authelia.override.yaml" +) + +// secretsData holds the values rendered into authelia.secrets.yaml. +type secretsData struct { + SessionSecret string + StorageEncryptionKey string + ResetPasswordJWTSecret string + OIDCHMACSecret string + OIDCKeyPEMIndented string +} + +// configData holds the (non-secret) values rendered into authelia.yaml on every start. +type configData struct { + LogLevel string + Address string + Domain string + AutheliaURL string + LDAPAddress string + LDAPBindDN string + LDAPPassword string + StoragePath string + OIDCClientsYAML string + NotifierYAML string +} + +// Config writes the Authelia configuration files and returns the ordered list of paths to pass to +// the embedded provider. The secrets file is generated once and reused; the main config file is +// regenerated from the current OpenCloud configuration on every call so it never goes stale. An +// optional authelia.override.yaml is appended last so admin overrides take precedence. +// +// The LDAP bind password is read from cfg.LDAP.BindPassword; Authelia binds as the libregraph-idm +// admin user (uid=libregraph) so it can both look users up and modify passwords. No dedicated +// Authelia LDAP account is needed. +func Config(logger log.Logger, cfg *config.Config) ([]string, error) { + targetPath := cfg.ConfigPath + if targetPath == "" { + return nil, fmt.Errorf("authelia config path is empty") + } + if cfg.LDAP.BindPassword == "" { + return nil, fmt.Errorf("the 'authelia' LDAP bind password is not configured; run 'opencloud init' " + + "so the password gets generated and seeded, or set AUTH_AUTHELIA_LDAP_BIND_PASSWORD") + } + + dir := filepath.Dir(targetPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("could not create authelia config directory: %w", err) + } + + storagePath := filepath.Join(defaults.BaseDataPath(), "authelia", "authelia.sqlite3") + if err := os.MkdirAll(filepath.Dir(storagePath), 0700); err != nil { + return nil, fmt.Errorf("could not create authelia storage directory: %w", err) + } + + // 1. Secrets: generate once, then leave untouched. + secretsPath := filepath.Join(dir, secretsFilename) + if err := ensureSecrets(logger, secretsPath); err != nil { + return nil, err + } + + // 2. Main config: always regenerated from the current OpenCloud configuration. + ocURL := openCloudURL(cfg) + domain := "localhost" + if u, perr := url.Parse(ocURL); perr == nil && u.Hostname() != "" { + domain = u.Hostname() + } + + data := configData{ + LogLevel: cfg.LogLevel, + Address: fmt.Sprintf("tcp://127.0.0.1:%d/%s", port, basePath), + Domain: domain, + AutheliaURL: ocURL + "/" + basePath, + LDAPAddress: ldapAddress, + LDAPBindDN: ldapBindDN, + LDAPPassword: cfg.LDAP.BindPassword, + StoragePath: storagePath, + OIDCClientsYAML: renderOIDCClients(ocURL), + NotifierYAML: renderNotifier(cfg, domain), + } + if err := renderToFile(configTemplate, data, targetPath); err != nil { + return nil, fmt.Errorf("could not write authelia config: %w", err) + } + logger.Info().Str("config", targetPath).Str("oidc_issuer", data.AutheliaURL). + Msg("regenerated authelia config from the current OpenCloud configuration") + + // Authelia requires the session cookie domain to contain a dot or be an IP address; it rejects + // a bare hostname such as 'localhost' and refuses to start. The default OC_URL is + // https://localhost:9200, so warn explicitly that Authelia needs a real domain. + if !isValidCookieDomain(domain) { + logger.Warn().Str("domain", domain).Str("config", targetPath). + Msg("session cookie domain is not valid for Authelia (needs a dot or must be an IP); " + + "Authelia will not start until 'session.cookies' uses a fully-qualified domain. Set " + + "OC_URL to your public URL.") + } + + paths := []string{secretsPath, targetPath} + + // 3. Optional admin override, merged last so it takes precedence. + overridePath := filepath.Join(dir, overrideFilename) + if _, err := os.Stat(overridePath); err == nil { + logger.Info().Str("override", overridePath).Msg("merging authelia override config") + paths = append(paths, overridePath) + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("could not stat authelia override config %q: %w", overridePath, err) + } + + return paths, nil +} + +// ensureSecrets generates the secrets file if it does not exist yet. An existing file is left +// untouched so the secrets persist across restarts and config regenerations. +func ensureSecrets(logger log.Logger, path string) error { + if _, err := os.Stat(path); err == nil { + logger.Info().Str("secrets", path).Msg("using existing authelia secrets") + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("could not stat authelia secrets %q: %w", path, err) + } + + sessionSecret, err := generators.GenerateRandomPassword(secretLength) + if err != nil { + return fmt.Errorf("could not generate authelia session secret: %w", err) + } + storageKey, err := generators.GenerateRandomPassword(secretLength) + if err != nil { + return fmt.Errorf("could not generate authelia storage encryption key: %w", err) + } + resetJWTSecret, err := generators.GenerateRandomPassword(secretLength) + if err != nil { + return fmt.Errorf("could not generate authelia password reset jwt secret: %w", err) + } + oidcHMAC, err := generators.GenerateRandomPassword(secretLength) + if err != nil { + return fmt.Errorf("could not generate authelia oidc hmac secret: %w", err) + } + oidcKeyPEM, err := generateRSAPrivateKeyPEM(oidcKeyBits) + if err != nil { + return fmt.Errorf("could not generate authelia oidc signing key: %w", err) + } + + data := secretsData{ + SessionSecret: sessionSecret, + StorageEncryptionKey: storageKey, + ResetPasswordJWTSecret: resetJWTSecret, + OIDCHMACSecret: oidcHMAC, + OIDCKeyPEMIndented: indentLines(oidcKeyPEM, 10), + } + if err := renderToFile(secretsTemplate, data, path); err != nil { + return fmt.Errorf("could not write authelia secrets: %w", err) + } + logger.Info().Str("secrets", path).Msg("generated authelia secrets") + return nil +} + +// renderToFile parses and executes the given template into path with mode 0600. +func renderToFile(tmplText string, data any, path string) error { + tmpl, err := template.New("authelia").Parse(tmplText) + if err != nil { + return fmt.Errorf("could not parse template: %w", err) + } + var buf strings.Builder + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("could not render template: %w", err) + } + return os.WriteFile(path, []byte(buf.String()), 0600) +} + +// openCloudURL resolves the public OpenCloud URL used to derive the OIDC issuer, session domain and +// the OIDC client redirect URIs. It prefers the shared commons value (env OC_URL), falling back to +// OC_URL directly and finally the localhost default. +func openCloudURL(cfg *config.Config) string { + ocURL := "" + if cfg.Commons != nil { + ocURL = cfg.Commons.OpenCloudURL + } + if ocURL == "" { + ocURL = os.Getenv("OC_URL") + } + if ocURL == "" { + ocURL = "https://localhost:9200" + } + return strings.TrimRight(ocURL, "/") +} + +// isValidCookieDomain reports whether domain is acceptable as an Authelia session cookie domain. +// Authelia requires either an IP address or a hostname containing at least one dot; a bare label +// such as 'localhost' is rejected. +func isValidCookieDomain(domain string) bool { + if net.ParseIP(domain) != nil { + return true + } + return strings.Contains(domain, ".") +} + +// generateRSAPrivateKeyPEM generates a new RSA private key and returns it PEM (PKCS#1) encoded. +func generateRSAPrivateKeyPEM(bits int) (string, error) { + key, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return "", err + } + block := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + } + return string(pem.EncodeToMemory(block)), nil +} + +// indentLines prefixes every non-empty line with n spaces. Used to embed multi-line PEM data into a +// YAML block scalar. +func indentLines(s string, n int) string { + prefix := strings.Repeat(" ", n) + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + for i, line := range lines { + if line == "" { + continue + } + lines[i] = prefix + line + } + return strings.Join(lines, "\n") +} + +// oidcClient mirrors an OpenCloud OIDC client (see services/idp default clients). All clients are +// public/PKCE clients (no secret), matching the lico configuration. +// +// Note: Authelia (v4.39) has no per-client post-logout redirect URI list; RP-initiated logout +// validates the redirect against the client's registered redirect_uris. The native clients below +// therefore only need their redirect_uris registered. +type oidcClient struct { + ID string + Name string + ConsentMode string // "implicit" for trusted clients, "auto" otherwise + RedirectURIs []string +} + +// defaultOIDCClients returns the default OpenCloud OIDC clients, mirroring the lico/idp defaults. +// {{OC_URL}} placeholders are substituted with the configured OpenCloud URL. +func defaultOIDCClients() []oidcClient { + return []oidcClient{ + { + ID: "web", + Name: "OpenCloud Web App", + ConsentMode: "implicit", + RedirectURIs: []string{ + "{{OC_URL}}/", + "{{OC_URL}}/oidc-callback.html", + "{{OC_URL}}/oidc-silent-redirect.html", + }, + }, + { + ID: "OpenCloudDesktop", + Name: "OpenCloud Desktop Client", + ConsentMode: "auto", + RedirectURIs: []string{ + "http://127.0.0.1", + "http://localhost", + }, + }, + { + ID: "OpenCloudAndroid", + Name: "OpenCloud Android App", + ConsentMode: "auto", + RedirectURIs: []string{ + "oc://android.opencloud.eu", + }, + }, + { + ID: "OpenCloudIOS", + Name: "OpenCloud iOS App", + ConsentMode: "auto", + RedirectURIs: []string{ + "oc://ios.opencloud.eu", + }, + }, + } +} + +// renderOIDCClients renders the OIDC clients list as YAML, indented to sit under +// 'identity_providers.oidc.clients:'. List item dashes are at 6 spaces, fields at 8 spaces. +func renderOIDCClients(ocURL string) string { + var b strings.Builder + for _, c := range defaultOIDCClients() { + b.WriteString(" - client_id: '" + c.ID + "'\n") + b.WriteString(" client_name: '" + c.Name + "'\n") + b.WriteString(" public: true\n") + b.WriteString(" authorization_policy: 'one_factor'\n") + b.WriteString(" consent_mode: '" + c.ConsentMode + "'\n") + b.WriteString(" require_pkce: true\n") + b.WriteString(" pkce_challenge_method: 'S256'\n") + b.WriteString(" token_endpoint_auth_method: 'none'\n") + b.WriteString(" redirect_uris:\n") + for _, uri := range c.RedirectURIs { + b.WriteString(" - '" + strings.ReplaceAll(uri, "{{OC_URL}}", ocURL) + "'\n") + } + // 'offline_access' is required for the 'refresh_token' grant below; without it Authelia + // rejects the grant (refresh tokens are how the web/desktop/mobile clients renew sessions). + b.WriteString(" scopes:\n") + b.WriteString(" - 'openid'\n") + b.WriteString(" - 'profile'\n") + b.WriteString(" - 'email'\n") + b.WriteString(" - 'groups'\n") + b.WriteString(" - 'offline_access'\n") + b.WriteString(" grant_types:\n") + b.WriteString(" - 'authorization_code'\n") + b.WriteString(" - 'refresh_token'\n") + b.WriteString(" response_types:\n") + b.WriteString(" - 'code'\n") + } + return strings.TrimRight(b.String(), "\n") +} + +// renderNotifier renders the 'notifier' YAML block. When an SMTP host is configured it renders an +// SMTP notifier; otherwise it falls back to a filesystem notifier that writes mails to a file under +// the data dir - so a fresh deployment works without any mail server. The notifier startup check is +// disabled either way so an unreachable or flaky mail server never blocks startup. +func renderNotifier(cfg *config.Config, domain string) string { + var b strings.Builder + b.WriteString("notifier:\n") + b.WriteString(" disable_startup_check: true\n") + + if cfg.SMTP.Host == "" { + filename := filepath.Join(defaults.BaseDataPath(), "authelia", "notification.txt") + b.WriteString(" # No SMTP host configured: notifications (password reset, 2FA registration) are\n") + b.WriteString(" # written to this file. Configure SMTP (NOTIFICATIONS_SMTP_* / AUTH_AUTHELIA_SMTP_*)\n") + b.WriteString(" # to send them by email instead.\n") + b.WriteString(" filesystem:\n") + b.WriteString(" filename: " + yamlSingleQuote(filename) + "\n") + return strings.TrimRight(b.String(), "\n") + } + + sender := cfg.SMTP.Sender + if sender == "" { + sender = fmt.Sprintf("OpenCloud ", domain) + } + b.WriteString(" smtp:\n") + b.WriteString(" address: " + yamlSingleQuote(smtpAddress(cfg.SMTP)) + "\n") + b.WriteString(" sender: " + yamlSingleQuote(sender) + "\n") + if cfg.SMTP.Username != "" { + b.WriteString(" username: " + yamlSingleQuote(cfg.SMTP.Username) + "\n") + } + if cfg.SMTP.Password != "" { + b.WriteString(" password: " + yamlSingleQuote(cfg.SMTP.Password) + "\n") + } + if strings.EqualFold(cfg.SMTP.Encryption, "none") { + // No transport encryption: Authelia refuses to send credentials in the clear unless told to. + b.WriteString(" disable_require_tls: true\n") + } + return strings.TrimRight(b.String(), "\n") +} + +// smtpAddress builds an Authelia SMTP address (scheme://host[:port]) from the OpenCloud SMTP +// settings. The scheme encodes the transport encryption: 'submissions' for implicit TLS (ssltls), +// 'submission' for STARTTLS (starttls), and 'smtp' otherwise. +func smtpAddress(s config.SMTP) string { + scheme := "smtp" + switch strings.ToLower(s.Encryption) { + case "ssltls": + scheme = "submissions" + case "starttls": + scheme = "submission" + } + addr := scheme + "://" + s.Host + if s.Port > 0 { + addr += ":" + strconv.Itoa(s.Port) + } + return addr +} + +// yamlSingleQuote wraps a value in a single-quoted YAML scalar, escaping embedded single quotes by +// doubling them. Used for operator-supplied values (SMTP credentials, paths) that may contain +// characters which are unsafe unquoted. +func yamlSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// secretsTemplate renders authelia.secrets.yaml. It contains only the persisted secrets; everything +// else lives in the regenerated main config. Authelia deep-merges the two at load time. +const secretsTemplate = `# GENERATED FILE - DO NOT EDIT. +# Authelia secrets, generated once by the auth-authelia service and then left untouched so that +# existing sessions and issued OIDC tokens remain valid across restarts and config changes. +# Deleting this file makes the service generate new secrets on the next start, which invalidates all +# sessions and OIDC tokens. Keep this file private (mode 0600). + +identity_validation: + reset_password: + jwt_secret: '{{ .ResetPasswordJWTSecret }}' + +session: + secret: '{{ .SessionSecret }}' + +storage: + encryption_key: '{{ .StorageEncryptionKey }}' + +identity_providers: + oidc: + hmac_secret: '{{ .OIDCHMACSecret }}' + jwks: + - key_id: 'default' + algorithm: 'RS256' + use: 'sig' + key: | +{{ .OIDCKeyPEMIndented }} +` + +// configTemplate renders authelia.yaml. It targets the Authelia v4.39 configuration schema and holds +// only values derived from the OpenCloud configuration (no secrets) so it can be safely regenerated +// on every start. The embedded Authelia provider validates the merged configuration on startup. +const configTemplate = `# GENERATED FILE - DO NOT EDIT. +# The auth-authelia service regenerates this file from the current OpenCloud configuration every time +# it starts, so manual changes here are lost. Change these values through the OpenCloud configuration +# instead (OC_URL, AUTH_AUTHELIA_* / NOTIFICATIONS_SMTP_* environment variables, ...). +# +# Secrets are kept separately in 'authelia.secrets.yaml' (generated once). +# To set or override options that OpenCloud does not manage, create 'authelia.override.yaml' in this +# directory: it is merged last (takes precedence) and is never modified by OpenCloud. + +server: + address: '{{ .Address }}' + +log: + level: '{{ .LogLevel }}' + +theme: 'auto' + +totp: + issuer: '{{ .Domain }}' + +authentication_backend: + ldap: + implementation: 'custom' + address: '{{ .LDAPAddress }}' + tls: + # The embedded libregraph-idm LDAPS server uses a self-signed certificate on localhost. + skip_verify: true + base_dn: 'o=libregraph-idm' + additional_users_dn: 'ou=users' + additional_groups_dn: 'ou=groups' + users_filter: '(&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=inetOrgPerson))' + groups_filter: '(&(member={dn})(objectClass=groupOfNames))' + user: '{{ .LDAPBindDN }}' + password: '{{ .LDAPPassword }}' + attributes: + username: 'uid' + display_name: 'displayName' + mail: 'mail' + group_name: 'cn' + +access_control: + default_policy: 'one_factor' + +session: + cookies: + - domain: '{{ .Domain }}' + authelia_url: '{{ .AutheliaURL }}' + +storage: + local: + path: '{{ .StoragePath }}' + +# NTP is only used to detect clock skew for TOTP. The startup connectivity check is disabled so the +# embedded service does not require outbound NTP to boot. +ntp: + disable_startup_check: true + +{{ .NotifierYAML }} + +identity_providers: + oidc: + clients: +{{ .OIDCClientsYAML }} +` diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index 187e52be6f..ea35720fa8 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -40,9 +40,18 @@ func DefaultConfig() *config.Config { Name: "proxy", }, OIDC: config.OIDC{ - Issuer: "https://localhost:9200", + // The default IdP is the embedded Authelia provider, served under the '/authelia' base path, + // so the issuer is '/authelia'. In supervised mode the runtime sets OC_OIDC_ISSUER + // accordingly; this literal covers the localhost default when no issuer env is set. + Issuer: "https://localhost:9200/authelia", - AccessTokenVerifyMethod: config.AccessTokenVerificationJWT, + // Authelia (the default IdP) issues opaque access tokens, not JWTs, so the proxy cannot + // verify them locally against the JWKS ('jwt' method). 'none' skips local JWT verification; + // tokens are instead validated against Authelia's /userinfo endpoint (SkipUserInfo is false + // below, and the result is cached). This is validation by the IdP, not a bypass. Switch to + // 'jwt' only if the IdP issues JWT access tokens (e.g. lico, or Authelia clients configured + // with access_token_signed_response_alg). + AccessTokenVerifyMethod: config.AccessTokenVerificationNone, SkipUserInfo: false, UserinfoCache: &config.Cache{ Store: "memory", @@ -128,23 +137,18 @@ func DefaultPolicies() []config.Policy { Service: "eu.opencloud.web.webfinger", Unprotected: true, }, - { - Endpoint: "/.well-known/openid-configuration", - Service: "eu.opencloud.web.idp", - Unprotected: true, - }, { Endpoint: "/branding/logo", Service: "eu.opencloud.web.web", }, { - Endpoint: "/konnect/", - Service: "eu.opencloud.web.idp", - Unprotected: true, - }, - { - Endpoint: "/signin/", - Service: "eu.opencloud.web.idp", + // The embedded Authelia provider (auth-authelia service) is the default IdP. It serves + // its login portal and all OIDC endpoints (incl. /authelia/.well-known/openid-configuration) + // under the '/authelia' base path on its own HTTP listener; this route forwards them to it. + // The OIDC issuer is '/authelia'. When falling back to the lico 'idp' service, + // add the '/konnect/' and '/signin/' routes (and set the issuer to '') instead. + Endpoint: "/authelia", + Backend: "http://127.0.0.1:9091", Unprotected: true, }, {