Skip to content

Commit fe828ac

Browse files
committed
feat(s136): complete slice 136 (page authentication, and a regulated boot that refuses to serve without it)
The web pages carried no authentication: anyone who could reach the port could answer approvals, start an MCP stdio command from /mcp, download the keys from the settings export and edit the gateway allow-list, and the hardened image binds every interface. - TRINITY_WEB_AUTH in {none, oidc, local_token}. Unset is none on a loopback bind and a boot refusal on any other bind, in every profile. :regulated refuses none and local_token on a non-loopback bind, and refuses x-forwarded-proto trust without TRINITY_TRUSTED_PROXY. - oidc: an OpenID Connect login (code + PKCE S256, state, nonce, iss) with oidcc 3.9 and oidcc_plug 0.5.1 pinned past their 2026 CVEs; roles view, approve and administer mapped from Keycloak, Entra (with overage refused), Okta and generic claims. - Every route and live_session is guarded (a plug before the router and on_mount), held by a route census; privileged routes need administer. - A Host allow-list refuses a foreign Host before anything routes (DNS rebinding); check_origin is an explicit list in every profile. - Approval and privileged-route receipts carry the principal's sub, iss and role, on a new access chain scope. - local_token for a LAN outside :regulated: exchanged once for a SameSite=Strict cookie, constant-time, rate-limited, with a shared-token banner. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 2a7b8b7 commit fe828ac

59 files changed

Lines changed: 3735 additions & 40 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,31 @@ evidence for each increment is retained by the maintainers and summarised here.
2424

2525
## 2026-10-08
2626

27+
### `slice/136`: the web pages ask who is there
28+
29+
Every page and route now answers only someone who has signed in with the role it needs, or, on the
30+
desktop, a browser on the same machine. Three roles: `view` reads and converses, `approve` decides
31+
approvals, `administer` changes what Trinity is (MCP servers, gateways, settings and keys, the
32+
export, the dashboards). Signing in is an OpenID Connect login at your identity provider, with roles
33+
read from Keycloak, Microsoft Entra ID, Okta or a claim you name; a sign-in whose roles cannot be
34+
read is refused with a page that says why. On a home network a single shared token can stand in,
35+
and every page then says that it identifies a token and not a person.
36+
37+
A request whose Host is not this machine's own is refused before anything else sees it, and a
38+
websocket from another site's page is refused; this is what keeps the desktop, which has no login,
39+
from being read by a web page through DNS rebinding. A server bound to anything but the loopback
40+
refuses to start until it is told how the pages authenticate, and the regulated profile refuses a
41+
shared token or no login there, and refuses to believe a forwarded protocol header unless a trusted
42+
proxy is declared. Sessions end on the server when revoked, after 30 idle minutes, after 12 hours,
43+
or on sign-out. Every approval now records who gave it, and every approval and privileged action is
44+
a signed receipt carrying the person's subject, issuer and the role used.
45+
46+
Verified by the test suite: a census of every route and every page event, an unauthenticated sweep
47+
of every route, the login against an identity provider running inside the test, the role and
48+
receipt checks through the pages, and the refusals at boot in separate operating system processes.
49+
An Entra tenant with a user in more than 200 groups, and the desktop build refusing a foreign Host,
50+
are listed for manual verification.
51+
2752
### `slice/100`: Trinity as a desktop application
2853

2954
The Tauri window now does what a desktop application is expected to do. A tray icon shows whether

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ enforces it.
125125
protected server's `401` with "authorize" on the `/mcp` page and keeps the token it obtains.
126126
Trinity issues no production authority: the personal issuer refuses to start under an
127127
external authority adapter, by construction (`docs/07-security-model.md`).
128+
- **Asks who is at the page.** Every page answers only someone signed in with the role it needs
129+
(`view`, `approve`, `administer`) through an OpenID Connect login at your identity provider,
130+
or, on the desktop, a browser on the same machine: a request naming any other host is refused
131+
before it is routed, and a websocket from another site's page is refused. A server bound
132+
beyond the loopback will not start until it is told how the pages authenticate. Every approval
133+
records who gave it, as a signed receipt with their subject, issuer and role
134+
(`docs/07-security-model.md`, "Web pages").
128135
- **Reachable from elsewhere.** A gateway is a channel Trinity answers from: the adapter carries
129136
text, and everything else (the session, the gate, the receipts) is the same machinery the
130137
desktop uses. A sender Trinity does not know gets a pairing code shown on the `/gateways` page

‎THIRD_PARTY_LICENSES.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
hand: the gate regenerates it and fails when this file and the bill disagree. -->
55
# Third-party licences
66

7-
134 components, derived from `sbom.cdx.json`, the CycloneDX bill this project's
7+
137 components, derived from `sbom.cdx.json`, the CycloneDX bill this project's
88
quality gate generates on every commit. This file and that bill are the same data rendered
99
twice, so they cannot disagree.
1010

@@ -97,6 +97,8 @@ it, and a reader should not infer from an entry here that the component is in a
9797
| `oban` | `2.24.1` | Apache-2.0 | bom |
9898
| `oban_met` | `1.3.1` | Apache-2.0 | bom |
9999
| `oban_web` | `2.13.0` | Apache-2.0 | bom |
100+
| `oidcc` | `3.9.0` | Apache-2.0 | bom |
101+
| `oidcc_plug` | `0.5.1` | Apache-2.0 | bom |
100102
| `optimus` | `0.6.1` | MIT | bom |
101103
| `owl` | `0.13.1` | Apache-2.0 | bom |
102104
| `pgvector` | `0.4.1` | MIT | bom |
@@ -137,6 +139,7 @@ it, and a reader should not infer from an entry here that the component is in a
137139
| `telemetry` | `1.4.2` | Apache-2.0 | bom |
138140
| `telemetry_metrics` | `1.2.0` | Apache-2.0 | bom |
139141
| `telemetry_poller` | `1.3.0` | Apache-2.0 | bom |
142+
| `telemetry_registry` | `0.3.2` | Apache-2.0 | bom |
140143
| `text_diff` | `0.1.0` | MIT | bom |
141144
| `texture` | `1.2.1` | Apache-2.0 | bom |
142145
| `thousand_island` | `1.5.0` | MIT | bom |

‎VERSIONS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ never pin a version hex marks as retired or vulnerable.
9393
| `luerl` | ~> 1.5 | ✅ in `mix.lock` | Lua 5.3 implemented in Erlang, for the in-VM sandbox: a skill's script runs inside this node with no OS process, no file handle and no socket. Apache-2.0, **no dependencies of its own**; 1.5.1 measured on hex.pm 2026-09-24, released 2025-12-03. `:luerl_sandbox` is a module of this package and not a package of its own, which slice 110's SLICE.md assumed wrongly. Its `max_reductions` is polled with a 100 ms sleep between checks, so it cannot bound a tight loop below tens of millions of reductions; Trinity runs the interpreter under its own process with `max_heap_size` and a timeout instead, and reports reductions as a statistic (slice 110 NOTES F1). Added at Slice 110. |
9494
| `stream_data` | ~> 1.4 | ✅ in `mix.lock` | Property-based testing: generated input for `test/property`. Apache-2.0; 1.4.0 measured on hex.pm 2026-09-24, released 2026-07-14. Test only, so it ships in no artifact. Elixir has no focused fuzzing library and property-based testing is what the OpenSSF gold criterion `dynamic_analysis` accepts in its place, which is why this is a dependency rather than a habit. Added at Slice 004. |
9595
| `jose` | ~> 1.11 | ✅ in `mix.lock` | JOSE (JWK, JWS, JWKS) over OTP's crypto, for the MCP resource server's token validation and the personal profile's embedded authorization server. MIT; 1.11.12 measured on hex.pm 2026-09-22. Not joken, a layer over it this tree does not need. Added at Slice 062. |
96+
| `oidcc` | ~> 3.9 | ✅ in `mix.lock` | OpenID Connect relying party for the web pages' login (`TrinityWeb.Auth.OIDC`): authorization code with PKCE S256, `state`, `nonce`, ID token validation. EEF Security WG, OpenID Certified; Apache-2.0. 3.9.0 measured on hex.pm 2026-10-08, released 2026-08-30; it fixes CVE-2026-75759 (GHSA-533g-4vf3-xwrj, high: an encrypted but unsigned ID token was accepted, affected >= 3.2.0-beta.1 and < 3.9.0), so the floor is 3.9 and a test asserts it. Brings `telemetry_registry` 0.3.2 (Apache-2.0). Added at Slice 136. |
97+
| `oidcc_plug` | ~> 0.5.1 | ✅ in `mix.lock` | Plugs over oidcc for the login's redirect and callback. Apache-2.0. 0.5.1 measured on hex.pm 2026-10-08, released 2026-08-04; 0.5.0 fixed CVE-2026-66884 (GHSA-fg66-w5gp-22cr, state check broken) and CVE-2026-66883 (GHSA-w5r8-m75h-98fc, user agent binding inert) and is itself retired on hex.pm (invalid igniter requirement), so the floor is 0.5.1 and a test asserts it. Ships no LiveView `on_mount`; `TrinityWeb.Auth` is that. Added at Slice 136. |
9698
| `oban_web` | ~> 2.13 | ✅ in `mix.lock` | The Oban dashboard, a LiveView mounted at /oban (dev, and behind `config :trinity, :oban_web, true` elsewhere). Apache-2.0 on hex.pm since the 2.12 line (2.13.0 measured 2026-09-22); the plan of 2026-09-05 wrote it in when it was commercial. Added at Slice 050. |
9799
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
98100
| `req_llm` | ~> 1.22 | ✅ in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. |

‎ci/headless/Containerfile‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
# the libc it was built against. No desktop shell, no Burrito: `mix release headless`.
88
#
99
# docker build -f ci/headless/Containerfile -t trinity-headless:local .
10-
# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... trinity-headless:local
10+
# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... -e TRINITY_WEB_AUTH=oidc \
11+
# -e TRINITY_WEB_AUTH_ISSUER=... -e TRINITY_WEB_AUTH_CLIENT_ID=... trinity-headless:local
1112
#
1213
# The container binds 0.0.0.0 inside its own network namespace (TRINITY_BIND) so the published
13-
# port reaches it; the bearer is required on /mcp whatever the bind. The data directory is
14+
# port reaches it; the bearer is required on /mcp whatever the bind. Slice 136: 0.0.0.0 is not the
15+
# loopback, so the node refuses to boot until TRINITY_WEB_AUTH says how the web pages
16+
# authenticate (docs/mcp-server.md). The data directory is
1417
# /data/trinity (XDG_DATA_HOME=/data, a volume): the databases, the receipt keys, the
1518
# envelope key, the generated token.
1619
FROM hexpm/elixir:1.20.4-erlang-28.5.0.5-debian-bookworm-20260824-slim AS build

‎config/config.exs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,15 @@ if System.get_env("TRINITY_DB", "sqlite") == "sqlite" do
194194
end
195195

196196
# Configure the endpoint
197+
#
198+
# Slice 136: `check_origin` is an explicit list in every environment, never `false` and never
199+
# `:conn` (which compares the Origin with the request's own Host, a value a DNS rebinding page
200+
# controls). The loopback names here; config/runtime.exs adds the configured host in :prod. A
201+
# list entry with no scheme and no port matches any scheme and port, which the desktop's
202+
# ephemeral port needs.
197203
config :trinity, TrinityWeb.Endpoint,
198204
url: [host: "localhost"],
205+
check_origin: ["//localhost", "//127.0.0.1", "//[::1]"],
199206
adapter: Bandit.PhoenixAdapter,
200207
render_errors: [
201208
formats: [html: TrinityWeb.ErrorHTML, json: TrinityWeb.ErrorJSON],

‎config/dev.exs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ config :trinity, TrinityWeb.Endpoint,
2323
# Binding to loopback ipv4 address prevents access from other machines.
2424
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
2525
http: [ip: {127, 0, 0, 1}],
26-
check_origin: false,
2726
code_reloader: true,
2827
debug_errors: true,
2928
secret_key_base: "pEceiX84jrYwBFOP3ahGMX2Fwkza/3kU0AHhXBIi2JNnbu7muM20VzLmWLCddSwi",

‎config/runtime.exs‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,42 @@ if config_env() != :test and auth_profile not in [nil, ""] do
109109
client_metadata_url: present.("TRINITY_MCP_AUTH_CLIENT_METADATA_URL")
110110
end
111111

112+
# Slice 136: who the web pages answer (`Trinity.WebAuth`). `TRINITY_WEB_AUTH` is `none`, `oidc` or
113+
# `local_token`; unset, it is `none` on a loopback bind and a refusal to boot on any other.
114+
# `oidc` logs in at `TRINITY_WEB_AUTH_ISSUER` (default: the MCP authorization issuer, the one
115+
# `:regulated` already requires) as `TRINITY_WEB_AUTH_CLIENT_ID` with `TRINITY_WEB_AUTH_CLIENT_SECRET`
116+
# when the client is confidential; the callback is `TRINITY_WEB_AUTH_REDIRECT_URI`, else
117+
# `https://<PHX_HOST>/auth/callback`. `TRINITY_WEB_AUTH_ROLE_CLAIM` names the claim roles are read
118+
# from when it is not one of the four known shapes. `local_token` takes `TRINITY_WEB_AUTH_TOKEN`
119+
# (at least 22 characters). The suite sets `config :trinity, :web_auth` itself.
120+
if config_env() != :test do
121+
present = fn name ->
122+
case System.get_env(name) do
123+
nil -> nil
124+
"" -> nil
125+
value -> value
126+
end
127+
end
128+
129+
int = fn name, default ->
130+
case present.(name) do
131+
nil -> default
132+
value -> String.to_integer(value)
133+
end
134+
end
135+
136+
config :trinity, :web_auth,
137+
mode: present.("TRINITY_WEB_AUTH"),
138+
issuer: present.("TRINITY_WEB_AUTH_ISSUER") || present.("TRINITY_MCP_AUTH_ISSUER"),
139+
client_id: present.("TRINITY_WEB_AUTH_CLIENT_ID"),
140+
client_secret: present.("TRINITY_WEB_AUTH_CLIENT_SECRET"),
141+
redirect_uri: present.("TRINITY_WEB_AUTH_REDIRECT_URI"),
142+
role_claim: present.("TRINITY_WEB_AUTH_ROLE_CLAIM"),
143+
token: present.("TRINITY_WEB_AUTH_TOKEN"),
144+
idle_timeout_ms: int.("TRINITY_WEB_AUTH_IDLE_TIMEOUT_MS", 1_800_000),
145+
absolute_timeout_ms: int.("TRINITY_WEB_AUTH_ABSOLUTE_TIMEOUT_MS", 43_200_000)
146+
end
147+
112148
# Slice 013. `TRINITY_FAKE_PROVIDER=1 mix phx.server` runs the chat on the scripted provider:
113149
# the registry becomes the fake's two entries and a fresh stream answers with its markdown
114150
# demo, so the UI can be exercised and screenshotted with no key and no egress. Development
@@ -214,8 +250,8 @@ if config_env() == :prod do
214250
# default: a server on a LAN is the operator's decision, made by setting it) on `PORT`,
215251
# 4000 by default, since nobody reads an ephemeral port off a headless machine. The bearer
216252
# on /mcp (`TRINITY_MCP_SERVER_TOKEN`, or the generated token file) is required whatever
217-
# the bind; the web pages carry no authentication yet, which is why the default stays on
218-
# the loopback (docs/mcp-server.md).
253+
# the bind. Slice 136: a bind other than the loopback needs `TRINITY_WEB_AUTH` set, or the
254+
# node refuses to boot (`Trinity.WebAuth`).
219255
{bind_ip, bind_port} =
220256
if System.get_env("TRINITY_MODE") == "headless" do
221257
ip =
@@ -234,9 +270,12 @@ if config_env() == :prod do
234270
{{127, 0, 0, 1}, desktop_port}
235271
end
236272

273+
# Slice 136: the websocket origins are the loopback names and the configured host, an explicit
274+
# list, in every profile (config/config.exs says why never `false` and never `:conn`).
237275
config :trinity, TrinityWeb.Endpoint,
238276
url: [host: host, port: 443, scheme: "https"],
239277
http: [ip: bind_ip, port: bind_port],
278+
check_origin: Enum.uniq(["//localhost", "//127.0.0.1", "//[::1]", "//" <> host]),
240279
secret_key_base: secret_key_base
241280

242281
# ## SSL Support

‎config/test.exs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,18 @@ config :trinity, TrinityWeb.Endpoint,
229229
secret_key_base: "PAlioLgquSvnIrD4YjwqUt4LEP1x1E5d56Z7KY/QhgmyRUFR9ynpk3oz/hITAYwN",
230230
server: true
231231

232+
# Slice 136: the suite binds the loopback, so the web pages run in `:none` unless a test says
233+
# otherwise (those tests are synchronous and put their own `:web_auth`). `www.example.com` is the
234+
# Host `Phoenix.ConnTest.build_conn/0` sends; it is the suite's configured host for the Host
235+
# allow-list, as `PHX_HOST` is a deployment's. The OIDC tests run their issuer on a loopback
236+
# port over plain HTTP, which oidcc refuses unless told; nothing outside this file tells it.
237+
config :trinity, :web_auth,
238+
extra_hosts: ["www.example.com"],
239+
allow_unsafe_http: true,
240+
idle_timeout_ms: 1_800_000,
241+
absolute_timeout_ms: 43_200_000,
242+
token_rate_window_ms: 2_000
243+
232244
# Slice 090: the LiveDashboard routes are compiled in the test environment too. They are
233245
# `compile_env`-gated and were dev-only, which meant the custom sessions page had no test and no
234246
# way to be screenshotted without running a dev server against a dev database. A route that exists

0 commit comments

Comments
 (0)