Account minting, knowledge sync, the archetype refresh, the matrix appservice flow and the SSO secret delivery move to pages under 4-implementation/swarm. Each handbook page keeps what an operator does or sees, with a pointer.
186 lines
10 KiB
Markdown
186 lines
10 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`](../../2-handbook/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.
|