feat: add embedded authelia

This commit is contained in:
Michael Barz
2026-06-01 14:10:43 +02:00
parent 31340e30fb
commit a41d86e8bc
19 changed files with 1392 additions and 30 deletions

View File

@@ -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 = `<OC_URL>/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=<OC_URL>/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 `<OC_URL>`) 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 `<OC_URL>/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 `<base href=".../authelia/">` 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://<OC_URL>/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 <authelia-checkout>/web && pnpm install && pnpm build # vite → internal/server/public_html
# 2. restore the committed api/ placeholders that vite deleted
git -C <authelia-checkout> checkout -- internal/server/public_html/api
# 3. rebuild the OpenCloud binary so go:embed captures the real assets
cd <opencloud> && 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 13 (or an equivalent build target) must run before any
build that needs a working portal.

25
go.mod
View File

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

22
go.sum
View File

@@ -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=

View File

@@ -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 '<OC_URL>/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)
}

View File

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

View File

@@ -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,

View File

@@ -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"`

View File

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

View File

@@ -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"`

View File

@@ -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(),

View File

@@ -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
},
}
}

View File

@@ -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)
}

View File

@@ -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
},
}
}

View File

@@ -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
},
}
}

View File

@@ -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 <noreply@example.com>')." 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:"-"`
}

View File

@@ -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) {}

View File

@@ -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
}

View File

@@ -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 <no-reply@%s>", 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 }}
`

View File

@@ -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 '<OC_URL>/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 '<OC_URL>/authelia'. When falling back to the lico 'idp' service,
// add the '/konnect/' and '/signin/' routes (and set the issuer to '<OC_URL>') instead.
Endpoint: "/authelia",
Backend: "http://127.0.0.1:9091",
Unprotected: true,
},
{