Skip to content

Commit 15d9cc5

Browse files
authored
Merge pull request #129 from ScriptKittyOS/slice/136-page-authentication
feat(s136): page authentication, and a regulated boot that refuses to serve without it
2 parents 2f49d6e + 1442bfd commit 15d9cc5

63 files changed

Lines changed: 3785 additions & 44 deletions

Some content is hidden

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

‎.github/workflows/headless-image.yml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,13 @@ jobs:
9292
run: |
9393
docker run --rm --read-only --tmpfs /tmp "$IMAGE" /opt/trinity/bin/headless eval \
9494
'IO.puts("otp=#{System.otp_release()} fips_provider=#{inspect(:crypto.info()[:fips_provider_available])} node=#{inspect(Node.self())}")'
95+
# Slice 136: the image binds 0.0.0.0, which is not the loopback, so the node refuses to
96+
# boot until TRINITY_WEB_AUTH says how the web pages authenticate. A shared token is the
97+
# one mode that needs no identity provider (and is refused under the regulated profile).
9598
cid=$(docker run -d --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
96-
-p 127.0.0.1:4000:4000 -e TRINITY_MCP_SERVER_TOKEN=ci-token "$IMAGE")
99+
-p 127.0.0.1:4000:4000 -e TRINITY_MCP_SERVER_TOKEN=ci-token \
100+
-e TRINITY_WEB_AUTH=local_token -e TRINITY_WEB_AUTH_TOKEN=ci-web-token-not-a-secret-0001 \
101+
"$IMAGE")
97102
for _ in $(seq 1 60); do
98103
curl -s -o /dev/null http://127.0.0.1:4000/ && break
99104
sleep 1

‎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/135`: secrets out of the data directory, and filesystem roots that fail closed
2853

2954
A defect, fixed. The data directory was always a filesystem root and the receipt signing key lived

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ enforces it.
128128
protected server's `401` with "authorize" on the `/mcp` page and keeps the token it obtains.
129129
Trinity issues no production authority: the personal issuer refuses to start under an
130130
external authority adapter, by construction (`docs/07-security-model.md`).
131+
- **Asks who is at the page.** Every page answers only someone signed in with the role it needs
132+
(`view`, `approve`, `administer`) through an OpenID Connect login at your identity provider,
133+
or, on the desktop, a browser on the same machine: a request naming any other host is refused
134+
before it is routed, and a websocket from another site's page is refused. A server bound
135+
beyond the loopback will not start until it is told how the pages authenticate. Every approval
136+
records who gave it, as a signed receipt with their subject, issuer and role
137+
(`docs/07-security-model.md`, "Web pages").
131138
- **Reachable from elsewhere.** A gateway is a channel Trinity answers from: the adapter carries
132139
text, and everything else (the session, the gate, the receipts) is the same machinery the
133140
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
@@ -98,6 +98,8 @@ never pin a version hex marks as retired or vulnerable.
9898
| `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. |
9999
| `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. |
100100
| `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. |
101+
| `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. |
102+
| `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. |
101103
| `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. |
102104
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
103105
| `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/ironbank/Dockerfile‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
# exception, and it reaches only the UBI repositories, which Iron Bank proxies.
1414
#
1515
# scripts/headless_image.sh build trinity-headless:hardened
16-
# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... trinity-headless:hardened
16+
# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... -e TRINITY_WEB_AUTH=oidc \
17+
# -e TRINITY_WEB_AUTH_ISSUER=... -e TRINITY_WEB_AUTH_CLIENT_ID=... trinity-headless:hardened
1718

1819
ARG BASE_REGISTRY=registry1.dso.mil/ironbank
1920
ARG BASE_IMAGE=redhat/ubi/ubi9-micro
@@ -140,6 +141,8 @@ COPY --chown=0:0 scripts/entrypoint.sh /usr/local/bin/trinity-entrypoint
140141
# directory is /data/secrets (TRINITY_SECRETS_DIR, slice 135), beside the data directory on the
141142
# same volume and never inside it: the receipt keys, the envelope key, the generated token. Point
142143
# it at a systemd credential or a Kubernetes Secret mount instead (docs/packaging.md).
144+
# Slice 136: 0.0.0.0 is not the loopback, so the node refuses to boot until TRINITY_WEB_AUTH says
145+
# how the web pages authenticate (docs/mcp-server.md).
143146
ENV LANG=C.UTF-8 \
144147
TRINITY_MODE=headless \
145148
TRINITY_BIND=0.0.0.0 \

‎config/config.exs‎

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

204204
# Configure the endpoint
205+
#
206+
# Slice 136: `check_origin` is an explicit list in every environment, never `false` and never
207+
# `:conn` (which compares the Origin with the request's own Host, a value a DNS rebinding page
208+
# controls). The loopback names here; config/runtime.exs adds the configured host in :prod. A
209+
# list entry with no scheme and no port matches any scheme and port, which the desktop's
210+
# ephemeral port needs.
205211
config :trinity, TrinityWeb.Endpoint,
206212
url: [host: "localhost"],
213+
check_origin: ["//localhost", "//127.0.0.1", "//[::1]"],
207214
adapter: Bandit.PhoenixAdapter,
208215
render_errors: [
209216
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
@@ -121,6 +121,42 @@ if config_env() != :test and auth_profile not in [nil, ""] do
121121
client_metadata_url: present.("TRINITY_MCP_AUTH_CLIENT_METADATA_URL")
122122
end
123123

124+
# Slice 136: who the web pages answer (`Trinity.WebAuth`). `TRINITY_WEB_AUTH` is `none`, `oidc` or
125+
# `local_token`; unset, it is `none` on a loopback bind and a refusal to boot on any other.
126+
# `oidc` logs in at `TRINITY_WEB_AUTH_ISSUER` (default: the MCP authorization issuer, the one
127+
# `:regulated` already requires) as `TRINITY_WEB_AUTH_CLIENT_ID` with `TRINITY_WEB_AUTH_CLIENT_SECRET`
128+
# when the client is confidential; the callback is `TRINITY_WEB_AUTH_REDIRECT_URI`, else
129+
# `https://<PHX_HOST>/auth/callback`. `TRINITY_WEB_AUTH_ROLE_CLAIM` names the claim roles are read
130+
# from when it is not one of the four known shapes. `local_token` takes `TRINITY_WEB_AUTH_TOKEN`
131+
# (at least 22 characters). The suite sets `config :trinity, :web_auth` itself.
132+
if config_env() != :test do
133+
present = fn name ->
134+
case System.get_env(name) do
135+
nil -> nil
136+
"" -> nil
137+
value -> value
138+
end
139+
end
140+
141+
int = fn name, default ->
142+
case present.(name) do
143+
nil -> default
144+
value -> String.to_integer(value)
145+
end
146+
end
147+
148+
config :trinity, :web_auth,
149+
mode: present.("TRINITY_WEB_AUTH"),
150+
issuer: present.("TRINITY_WEB_AUTH_ISSUER") || present.("TRINITY_MCP_AUTH_ISSUER"),
151+
client_id: present.("TRINITY_WEB_AUTH_CLIENT_ID"),
152+
client_secret: present.("TRINITY_WEB_AUTH_CLIENT_SECRET"),
153+
redirect_uri: present.("TRINITY_WEB_AUTH_REDIRECT_URI"),
154+
role_claim: present.("TRINITY_WEB_AUTH_ROLE_CLAIM"),
155+
token: present.("TRINITY_WEB_AUTH_TOKEN"),
156+
idle_timeout_ms: int.("TRINITY_WEB_AUTH_IDLE_TIMEOUT_MS", 1_800_000),
157+
absolute_timeout_ms: int.("TRINITY_WEB_AUTH_ABSOLUTE_TIMEOUT_MS", 43_200_000)
158+
end
159+
124160
# Slice 013. `TRINITY_FAKE_PROVIDER=1 mix phx.server` runs the chat on the scripted provider:
125161
# the registry becomes the fake's two entries and a fresh stream answers with its markdown
126162
# demo, so the UI can be exercised and screenshotted with no key and no egress. Development
@@ -226,8 +262,8 @@ if config_env() == :prod do
226262
# default: a server on a LAN is the operator's decision, made by setting it) on `PORT`,
227263
# 4000 by default, since nobody reads an ephemeral port off a headless machine. The bearer
228264
# on /mcp (`TRINITY_MCP_SERVER_TOKEN`, or the generated token file) is required whatever
229-
# the bind; the web pages carry no authentication yet, which is why the default stays on
230-
# the loopback (docs/mcp-server.md).
265+
# the bind. Slice 136: a bind other than the loopback needs `TRINITY_WEB_AUTH` set, or the
266+
# node refuses to boot (`Trinity.WebAuth`).
231267
{bind_ip, bind_port} =
232268
if System.get_env("TRINITY_MODE") == "headless" do
233269
ip =
@@ -246,9 +282,12 @@ if config_env() == :prod do
246282
{{127, 0, 0, 1}, desktop_port}
247283
end
248284

285+
# Slice 136: the websocket origins are the loopback names and the configured host, an explicit
286+
# list, in every profile (config/config.exs says why never `false` and never `:conn`).
249287
config :trinity, TrinityWeb.Endpoint,
250288
url: [host: host, port: 443, scheme: "https"],
251289
http: [ip: bind_ip, port: bind_port],
290+
check_origin: Enum.uniq(["//localhost", "//127.0.0.1", "//[::1]", "//" <> host]),
252291
secret_key_base: secret_key_base
253292

254293
# ## 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)