Each service section lost its container, credential and metrics detail to a Host setup section on its own implementation page, unchanged apart from links. The four agentAuth option rows that sat above the page title moved into the telemetry page's metrics option table.
243 lines
13 KiB
Markdown
243 lines
13 KiB
Markdown
# SSO: relying-party secrets and machine callers
|
|
|
|
How authelia's client secrets reach the forge and the homeserver, how the two
|
|
differ, and why machine callers must fail closed. The operator's view is
|
|
[the SSO page](../../2-handbook/swarm/sso.md).
|
|
|
|
## What secrets exist, and where each one lives
|
|
|
|
[`credentials-reference.md`](../../4-implementation/swarm/credentials-reference.md) tabulates every secret in the swarm, with its
|
|
generator and its path, in one place — including authelia's own keys
|
|
(session, JWT, storage-encryption, OIDC HMAC, OIDC issuer) and the two
|
|
halves of each client secret. That page's two rules — a secret is always
|
|
a path, never a value, and the generator and the reader typically live in
|
|
different containers — are why the client secret's plaintext half needs
|
|
the delivery step below and the rest of authelia's keys don't.
|
|
|
|
## Getting the plaintext to the relying party
|
|
|
|
Three cases, and they're genuinely different mechanisms rather than one
|
|
mechanism with flags.
|
|
|
|
### 1. All-local — one host runs both
|
|
|
|
Nothing to configure at all. authelia mints each client's secret at its
|
|
first boot, and the mint in its container creates it in the swarm
|
|
secret store at `swarm/services/<client id>/oidc/client`, keeping only the digest on disk. A host-side unit
|
|
then reads it from the store and writes it into the service's container:
|
|
`swarm-bao-forge-oidc` for the forge, under the forge publisher's store
|
|
leaf, into `/run/hive-forge-oidc/<client id>.secret` on the host, which
|
|
`forgejo-sso-source` loads as a credential; `swarm-bao-matrix-oidc` for matrix, under
|
|
matrix-ctl's. A unit that can't read the secret yet fails and retries
|
|
every 30 seconds for a day, so there is no start order to get right. The
|
|
service's own module contributes its client entry — callback URL
|
|
included — to authelia's client list.
|
|
|
|
Each relying service's own glue module builds its callback once, and
|
|
both authelia's client list and the service's own OIDC client config
|
|
read that same value, so the redirect URI authelia accepts and the one
|
|
the service actually sends can't drift apart. A mismatch there is a
|
|
rejected login with no error text worth reading.
|
|
|
|
<!-- vale write-good.Passive = NO -->
|
|
|
|
⚠️ nixos-container refuses to start a container whose bind source is
|
|
missing, and this secret doesn't exist until authelia's first boot has run
|
|
and the mint has stored it. The forge therefore binds the **directory**
|
|
`/run/hive-forge-oidc`, which a tmpfiles rule creates at boot, never the
|
|
file. The directory is tmpfs, so the secret is gone after a reboot until
|
|
the fetch runs again; forgejo keeps it in its login-source row, so logins
|
|
still work meanwhile. Matrix's secret is written into the container's state
|
|
directory instead.
|
|
|
|
<!-- vale write-good.Passive = YES -->
|
|
|
|
### 2. Swarm-managed services
|
|
|
|
The controller side owns provisioning: `swarmctl` writes both halves, the
|
|
same way it already owns authelia's user store (`users.yml`, read and
|
|
written in place).
|
|
|
|
### 3. A hive elsewhere
|
|
|
|
The forge's host needn't be the store's or authelia's host, and the
|
|
operator places no secret file: `swarm-bao-forge-oidc` runs on the
|
|
forge's host as in the all-local case and fetches the secret over the
|
|
store's address. What that host needs is the forge publisher's store
|
|
leaf, issued from the store's CA and copied over from the store's host:
|
|
|
|
```nix
|
|
services.hyperhive = {
|
|
swarm.authelia.url = "https://auth.example.com";
|
|
deploy.bao = {
|
|
forgePublisherClientCertFile = "/var/lib/swarm-bao-pki/forge-publisher.pem";
|
|
forgePublisherClientKeyFile = "/var/lib/swarm-bao-pki/forge-publisher-key.pem";
|
|
};
|
|
};
|
|
```
|
|
|
|
**A forge host without that leaf still evaluates.** `swarm-bao-forge-oidc`
|
|
renders and fails at start, naming both options, and the forge has no
|
|
login source until an operator places the leaf. Where the store runs on
|
|
the same host, `glue-bao-tls.nix` defaults both options to the leaf it
|
|
mints.
|
|
|
|
## Where each relying party differs
|
|
|
|
The registration half is identical; what each service does with the
|
|
result isn't.
|
|
|
|
| | forge | matrix |
|
|
| ------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
| how it learns the config | a oneshot calls `forgejo admin auth`, writing a login-source row into its database | tuwunel reads a `[[global.identity_provider]]` entry from its config file |
|
|
| how it reads the secret | a path inside its container | the same path, handed on by `LoadCredential` |
|
|
| callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, a shape tuwunel fixes rather than accepts |
|
|
| cost of a malformed entry | the login source is missing | the homeserver can refuse to start |
|
|
|
|
Four consequences worth stating plainly:
|
|
|
|
- **A person's first forge login creates their forge account**, named
|
|
after authelia's `preferred_username` (`[oauth2_client]` in the forge
|
|
module), provided the subject has an email. It starts as an ordinary user; `swarmctl forge make-admin
|
|
<name>` makes it a site admin. A name that already has a local forge
|
|
account doesn't get it handed over: forgejo asks for that account's
|
|
own password first (`ACCOUNT_LINKING = login`). Agents, `core` and
|
|
`swarm-controller` all have local accounts, and `swarmctl user add`
|
|
refuses none of those names.
|
|
|
|
- **A member of `admins` becomes a matrix homeserver admin** once their
|
|
first matrix login has created the account: `swarm-controller` promotes
|
|
them within five minutes, and never demotes. See
|
|
[`2-handbook/swarm/matrix.md`](../../2-handbook/swarm/matrix.md#operators-are-homeserver-admins).
|
|
|
|
- **tuwunel re-reads its secret file on every OAuth exchange**, not only
|
|
at startup, and its own sandboxing hides most paths from it. It gets the
|
|
file through `LoadCredential` for the same reason the appservice
|
|
registration does — that keeps `DynamicUser` and `PrivateUsers` intact,
|
|
with no host-side ownership arrangement to maintain.
|
|
- **Matrix SSO lives inside the homeserver.** The client-server API is
|
|
spoken by non-browser clients holding matrix access tokens — every
|
|
agent's own daemon — as well as by federation, so the homeserver
|
|
serves `/_matrix/` directly and authenticates it itself. The
|
|
forward-auth vhosts protect browser surfaces; this isn't one of them.
|
|
|
|
## Machine clients
|
|
|
|
Everything above is a person in a browser. A swarm also has callers that
|
|
hold no session and follow no redirect: the telemetry collector, the
|
|
queue's auth-callout responder, and each hive.
|
|
|
|
**One client per hive, not one per service.** A hive's identity belongs to
|
|
the directory rather than to whichever service happens to consume it, so a
|
|
hive holds a single OIDC client — `<hiveClientPrefix><hive>` — and mints a
|
|
different token per service from it. The alternative, letting each
|
|
consuming subsystem declare its own list, collides on the same client id
|
|
the moment a second consumer appears.
|
|
|
|
**The audience is that client id.** A swarm service that has to tell hives
|
|
apart needs one name both sides already agree on, and the client id is
|
|
already that name. A parallel per-hive naming scheme would be a second
|
|
thing to keep in step, and it drifts silently — a mismatch presents as a
|
|
valid token refused at the target, which reads like a broken credential
|
|
rather than a broken name.
|
|
|
|
**Authelia signs tokens (`RS256`), not opaque ones**, because a resource server
|
|
that can't call the provider back is a real case here: the telemetry
|
|
collector verifies offline against `/jwks.json`, and an opaque token gives
|
|
it nothing to verify. The queue's responder introspects instead — a
|
|
different question asked of the same token, and the reason both
|
|
`/api/oidc/introspection` and `/jwks.json` have to stay reachable.
|
|
|
|
## Machine callers must fail closed
|
|
|
|
⚠️ **An error page that answers `200` is a security bug, not a cosmetic
|
|
one.** The browser surface intercepts upstream errors and serves a
|
|
friendly "SSO is unavailable" page; that page is a file, so it returns
|
|
`200`. Any machine caller routed through it receives a success carrying
|
|
HTML instead of the failure that actually happened:
|
|
|
|
- `/api/authz/auth-request` — nginx `auth_request` treats **any 2xx as
|
|
success**, so a down provider means _access granted_
|
|
- `/api/oidc/introspection` — a token check that answers `200`
|
|
- `/api/oidc/token`, `/.well-known/openid-configuration` — a client
|
|
parsing an error page as its JSON document
|
|
|
|
The gateway routes authelia's `/api/` and `/.well-known/` prefixes, and
|
|
the bridge's `/bridge/`, **without** error interception. The split is by
|
|
_audience_, not by an enumerated path list: a human gets the page, every
|
|
machine caller gets the status.
|
|
Enumerating endpoints individually would leave the next one added
|
|
silently intercepted.
|
|
|
|
The same shape bites any machine route behind a browser-shaped gate: the
|
|
caller follows a `302` to a login page, the login page answers `200`,
|
|
and the caller reports success while nothing happened. Log ingest hit exactly this
|
|
and lost eleven hours of delivery in silence.
|
|
|
|
**Checking it, if you change this routing.** Point the vhost at a dead
|
|
upstream and compare three requests, not one:
|
|
|
|
1. through `/` — must still serve the friendly page
|
|
2. through `/api/` — must deny
|
|
3. a direct dial to authelia — must match what (2) did
|
|
|
|
All three matter. A change that silently deleted the browser page would
|
|
pass a deny-only check, and one that quietly stopped denying would pass a
|
|
page-only check. The person who introduced the split verified it this way.
|
|
|
|
## Host setup
|
|
|
|
One authelia per swarm, in a `swarm-authelia` container, at
|
|
`auth.<swarm-domain>`. Operator and agents are both subjects of the same
|
|
provider, differentiated by roles and claims rather than by mechanism —
|
|
there is one IdP and one auth path.
|
|
|
|
The container sits on the hive bridge in a network namespace of its own and
|
|
dials the store, the collector and its own public name through it. It binds
|
|
no TCP port: the login server, the metrics listener and the bridge each
|
|
listen on a unix socket in `/run/swarm-authelia`, a directory only the
|
|
gateway's nginx and the container's processes can open.
|
|
|
|
- **`deploy.authelia`** — run the container here.
|
|
- **`swarm.authelia.url`** — where clients go to authenticate. Present
|
|
on **every** hive and the same value on all of them:
|
|
`https://<swarm.authelia.domain>`, whether or not this host runs the
|
|
container. Set it explicitly when joining a swarm whose IdP is under
|
|
another name.
|
|
|
|
Agent subjects come from swarm-controller's agent-creation job, written
|
|
into the users database by `swarm-authelia-bridge`; human ones come from
|
|
`swarmctl user add` → [setup.md § 2](../../1-getting-started/setup.md#2--your-sso-account).
|
|
|
|
The bridge listens on a unix socket on the authelia host, and the gateway
|
|
publishes it as
|
|
`https://<swarm.authelia.domain>/bridge/`, which is where
|
|
`deploy.swarm-controller.authBridgeUrl` points by default on every host.
|
|
It answers only swarm-controller's OIDC client
|
|
(`swarm.controller.queueClientId`) and refuses every other token `403`.
|
|
|
|
On first boot this module seeds the users database with a placeholder subject marked `disabled`, so authelia has a non-empty store to start against before any real account exists (`swarm-authelia.nix:873`). Authelia's session, storage and other keys live in the swarm secret store; the container copies them into place at start, seeded from the files an earlier release generated there, and generates them itself only where it has no store identity. Nothing rotates them automatically; replacing one
|
|
invalidates data already written (sessions, the encrypted store), so
|
|
that's an operator action.
|
|
|
|
Storage is local sqlite and the notifier writes to a file. Both are
|
|
small-deployment choices, and the scope is the justification: redis
|
|
buys shared session state across replicas and there is one instance;
|
|
SMTP exists to mail humans, and provisioning here is programmatic.
|
|
|
|
The swarm collector scrapes authelia's own metrics at
|
|
`https://<swarm.authelia.domain>/metrics`, whichever host runs the
|
|
container. The gateway admits only the collector's OAuth2 client there;
|
|
any other session gets `deny`.
|
|
|
|
The container's `authelia-swarm-secrets` unit creates each client secret directly in
|
|
the swarm secret store, under the secret publisher's own store leaf handed in as
|
|
a system credential, and writes only the digest to disk; see
|
|
[`credentials.md`](../../2-handbook/swarm/credentials.md). Authelia starts from its digests, so the IdP
|
|
comes up while the store is down, and a unit that needs the store retries every
|
|
30 seconds. The container's journal forwarder reads its client secret back from
|
|
the store under the same leaf, and is off where the container has none.
|
|
|
|
See [`sso.md`](../../2-handbook/swarm/sso.md) for bootstrapping the first user and the OIDC
|
|
relying-party flow, and [`credentials.md`](../../2-handbook/swarm/credentials.md) for where authelia
|
|
generates and reads each of its keys.
|