From 08c20147da6f1286a3936a3aadd8835ecffa816f Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 06:20:56 -0400 Subject: [PATCH 01/13] docs(s062): G1 plan under the owner's profile decision; EMA paused as the issuer question Signed-off-by: Ayla Croft --- ROADMAP.md | 2 +- slices/062-mcp-server-auth/NOTES.md | 146 ++++++++++++++++++++++++++++ slices/062-mcp-server-auth/SLICE.md | 7 +- 3 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 slices/062-mcp-server-auth/NOTES.md diff --git a/ROADMAP.md b/ROADMAP.md index 9d5dd6f..e8bbde0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,7 +55,7 @@ standards register names the rows that ask for them. | 059 | MCP capability gap against beam_mcp, and the server seam probe | 6 MCP | S/M | 020 | approved | | 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved | | 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | approved | -| 062 | MCP authorization: OAuth client role, RS, embedded AS, Enterprise Managed Authorization (ID-JAG) | 6 MCP | L | 061 | planned | +| 062 | MCP authorization: OAuth client role, RS, embedded AS, Enterprise Managed Authorization (ID-JAG) | 6 MCP | L | 061 | in_progress | | 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | planned | | 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned | | 072 | Gateway: Discord (Nostrum) | 7 Gateways | S | 070 | planned | diff --git a/slices/062-mcp-server-auth/NOTES.md b/slices/062-mcp-server-auth/NOTES.md new file mode 100644 index 0000000..8e86e4a --- /dev/null +++ b/slices/062-mcp-server-auth/NOTES.md @@ -0,0 +1,146 @@ +# Slice 062: NOTES + +## Read before code, 2026-09-22 + +Tree at `b4a7bb9` on `main` (050 approved; 061 the dependency). What this slice joins: 061's server (the +wrapper above the core, `Trinity.MCP.Server.Auth.Local` as the `:authorize` hook, the MCP session and its +receipts, the plug in the endpoint ahead of the parsers), 060's client (`Trinity.MCP.Client.Auth.bearer/1`, +the seam a static token fills), 024's authority selection (`Trinity.Authority.Selection.selected/0`: `Local`, +or the external adapter a regulated deployment names), the receipts (decision and query on the MCP session's +scope), and ADR-0008 decision 4: identity is not authority. + +**The owner's decision of 2026-09-22, which shapes every line below.** Quoted: "production profile is OAuth +client + resource server against an external enterprise AS. Embedded AS is a non-default personal profile +and must not be the path that can mint authority for healthcare or DoD effects." And the constraint: "no +tool dispatch without an audience-bound token and a host gate; no token material in the model context; +receipts must name issuer/subject/scope. Pause if the slice would make Trinity the issuer of production +authority." + +Measured today: `jose` 1.11.12 on hex.pm (MIT, 2025-11-20), the JOSE library the JWT work uses (JWK, JWS, +JWKS; ES256 and EdDSA through OTP's crypto); not `joken` (Apache-2.0, a layer over jose this slice does not +need). One new row in VERSIONS.md. + +### The profiles, as this slice builds them + +`config :trinity, :mcp_auth, profile:` names one of three; the first is the default, as at 061. + +- **`:local`** (the default; the desktop, a developer's machine): 061's static bearer on the loopback bind, + unchanged. No token is a JWT; nothing here is an authorization server. +- **`:production`** (the owner's production profile): Trinity is an OAuth 2.1 **resource server** against an + **external** authorization server the deployment names (`issuer:`, `resource:` the server's own identifier, + `audience:` defaulting to it). RFC 9728 Protected Resource Metadata at `/.well-known/oauth-protected-resource` + listing that issuer; a request without a valid bearer is `401` with `WWW-Authenticate: Bearer + resource_metadata="…"`; a bearer is a JWT validated against the issuer's JWKS (RFC 8414 / OIDC discovery of the + issuer at boot, the JWKS cached and refreshed on an unknown `kid`): `iss` equal to the configured issuer, + `aud` containing the resource identifier (audience-bound, or refused), `exp` and `nbf`, `alg` in the allow + list (ES256, ES384, EdDSA, RS256; never `none`, never HMAC). `introspection: true` selects RFC 7662 against + the issuer for opaque tokens instead. Trinity issues nothing in this profile. +- **`:personal`** (non-default, and refused where it must be): the same resource server plus a small **embedded + authorization server** for a person's own machine: RFC 8414 metadata, authorization-code with PKCE (S256 + only), the consent page answered by the owner in the browser (the local Trinity user; no OIDC upstream in + this slice), Client ID Metadata Documents first (the client's `client_id` is the https URL of its metadata + document, fetched and checked) and RFC 7591 DCR only with `dcr: true`, RFC 8707 `resource` required, RFC 9207 + `iss` on the authorization response, ES256 access tokens with `aud` = the resource identifier, `scope` from + the consent, ten minutes of life, JWKS at `/.well-known/jwks.json`, key rotation by `kid`. **This profile + refuses to start** when `Trinity.Authority.selected/0` is not `Trinity.Authority.Local` (the regulated + deployment, where an external authority adapter is in force, has no embedded issuer, by construction and + not by configuration), and every token it mints carries `"profile" => "personal"`, which the resource + server refuses in the production profile whatever key signed it. That is the owner's "must not be the + path that can mint authority for healthcare or DoD effects", made a property the tests hold. + +### Paused: Enterprise Managed Authorization (AC3) + +The SLICE's EMA component redeems an enterprise IdP's ID-JAG *at Trinity's own AS* and issues Trinity's +access token: Trinity as the issuer for enterprise identities, which is the case the owner named as a pause. +In the production profile the external AS is where an ID-JAG is redeemed and Trinity's resource server +validates the token it issues, which the profile above already does; in the personal profile there is no +enterprise IdP. **AC3 is retagged `[paused]` in this commit**, and the question is on the owner's desk (asked +in the same turn as this plan): (a) EMA leaves Trinity: the external AS's job, Trinity's RS accepts what it +issues (the recommendation; the standards row for EMA moves to "satisfied by the external AS"); or (b) EMA +is built inside the personal profile only, which would be an enterprise flow on a machine with no enterprise +IdP. Nothing of EMA is built until the answer; the rest of the slice does not depend on it. + +### The three constraints, as properties + +- **No tool dispatch without an audience-bound token and a host gate.** In the production and personal + profiles, `tools/call` reaches the wrapper only through the resource server's check (the Plug refuses + before the body is read), and the wrapper still runs every call through the permission gate and the + membrane (061). A token without the resource identifier in `aud` is refused; a scope that does not cover + the tool's effect is refused before the gate (AC4: `trinity:tools:read` for a `:none` tool, `trinity:tools:artifact` + for an `:artifact` one; `trinity:recall` an alias of the first for the SLICE's wording). +- **No token material in the model context.** The resource server hands the wrapper a *principal* + (`iss`, `sub`, `scope`, `client_id`, the token's `jti` when present) and never the token; the principal + rides in `Trinity.Tools.Context.principal`, a new field, and nowhere in a message, a prompt or a receipt's + payload does a bearer appear. A census test holds that the only place the `authorization` header is read + is the auth boundary, and that no module of `Trinity.MCP.Server` names it. +- **Receipts name issuer, subject and scope.** The decision and query receipts an MCP call leaves carry + `subject.principal = %{iss, sub, scope, client_id}`; a refusal by the resource server (no bearer, wrong + audience, expired, bad scope) is a decision receipt of outcome `deny` on the MCP session's scope naming what + it could read of the principal and the reason. + +### The client role (AC8) + +`Trinity.MCP.Auth.Client`, above 060's transport: on a `401` with `WWW-Authenticate`, the driver reads +`resource_metadata`, the client fetches the PRM, the AS metadata (RFC 8414, OIDC fallback), and runs +authorization-code with PKCE and `resource` (RFC 8707) through a loopback redirect (`/oauth/callback` in +TrinityWeb, the owner opening the authorization URL from the `/mcp` page), checks `iss` (RFC 9207) on the +callback, exchanges the code, and stores the token per issuer under `/secrets/oauth/` (mode 0600; +`Trinity.Secrets` is slice 100's keychain, and this file store is what stands until then, named as such). +The client identifies itself by a configured `client_id` (pre-registered at the enterprise AS: the production +path), by CIMD when `client_metadata_url:` is set, and by DCR only when the AS offers it and `dcr: true`. +060's `Client.Auth.bearer/1` reads this store before the environment variable; the driver performs no flow. + +### The boundary (AC7) + +`Trinity.MCP.Auth` is a boundary of its own under `Trinity.MCP` with `deps: []` on the tree (jose, Plug and +Req are externals): it takes its configuration as arguments (the key directory, the issuer, the resource +identifier), returns decisions (`{:ok, principal} | {:error, reason}`) and writes no receipt and reads no +session; the Plug and the server, in `Trinity.MCP`, receipt what it decides. The compiler holds the rule; +a census test names the modules and their references. That is what lets slice 123 extract it. + +## G1 plan, 2026-09-22 + +Branch `slice/062-mcp-auth`; ROADMAP row 062 to `in_progress`; `jose ~> 1.11` in mix.exs and VERSIONS.md. +Each line names its test. + +1. `Trinity.MCP.Auth` (the behaviour: `authorize(conn, config) :: {:ok, principal} | {:error, reason}`, + `metadata(config)` for the PRM), `Auth.Config` (the profile and its fields, validated at boot), + `Auth.Principal` (the struct: `iss`, `sub`, `scope`, `client_id`, `jti`, `profile`), `Auth.Token` + (JWT validation over jose: the claim checks named in code and tests), `Auth.JWKS` (discovery, cache, + refresh by `kid`), `Auth.Local` (061's bearer moved behind the behaviour). Tests: each claim check red + on its own (wrong `iss`, missing `aud`, wrong `aud`, expired, `nbf` in the future, `alg` none, a key the + JWKS does not hold), the personal-profile marker refused in production. +2. The fake enterprise AS in test/support (`Trinity.MCP.Auth.FakeAS`, a Plug behind Bandit: RFC 8414 + metadata, `/authorize` auto-consenting, `/token` with PKCE and `resource`, JWKS, key rotation on demand, + RFC 7662 introspection). Tests AC1 (401 with `resource_metadata`; the PRM lists the AS; wrong `aud` and + expired refused; each receipted), AC5 (rotate the fake AS's key: old tokens verify by `kid` until expiry, + new tokens use the new key). +3. `Trinity.MCP.Server.Plug` runs the profile's `authorize/2` before the transport (the transport's own + `:authorize` hook stays for the `:local` profile) and hands the principal to the wrapper through the + request process (the transport dispatches in the conn's process; NOTES will say so as a deviation); + `Trinity.MCP.Server` puts it in the context and refuses a scope that does not cover the tool + (`Auth.Scopes`); `Trinity.Effects.Runner` writes `subject.principal`. Tests AC4 and the receipts. +4. `Auth.Embedded` (the personal profile's AS): metadata, authorize with the consent page (`/oauth/authorize`, + `/oauth/consent` in TrinityWeb), token endpoint, CIMD fetch and check, DCR behind `dcr: true`, ES256 keys + under `/mcp-as-.key`, JWKS, rotation (`mix trinity.mcp.rotate_key`), the marker claim, the + refusal to start under an external authority adapter. Tests AC2 (a CIMD-registered test client completes + the code flow with PKCE and `resource` and the RS accepts the token; DCR refused unless enabled; the marker + refused in production; the profile refused under a non-Local authority). +5. `Auth.Client` (the client role) with the `/oauth/callback` route and the file store; `Client.Auth.bearer/1` + reads the store. Tests AC8 against the fake AS (the token obtained is audience-bound and the RS accepts it; + a callback with the wrong `iss` is rejected; the 060 driver presents it and performs no flow, by census). +6. The `/mcp` page: the auth profile shown; for a client row, "authorize" when a server answered 401 with a + resource metadata URL. The settings page: the production profile's issuer and resource identifier (also + configuration: `TRINITY_MCP_AUTH_*` in `config/runtime.exs`). +7. AC7's census; the no-token-material census; docs/08 (the rows: RS satisfied by this slice under the + production profile; EMA per the owner's answer), docs/09 (the identity rows' status), docs/07 (the + profiles and the three constraints), docs/mcp-server.md (connecting through an enterprise AS; the + personal profile), README; PROOF. + +Manual verification queue: **AC6** (a real MCP client that supports EMA connecting through the fake IdP): with +AC3 paused, AC6 is paused with it; if the owner's answer is (a), both leave the slice and the manual queue is +empty. Recorded now so the queue is known at G1. + +Not built here: SAML, MCP Apps, refresh tokens for EMA, the OIDC upstream login for the personal profile's +consent (the local owner is the user), the keychain (slice 100), non-person X.509 identity (the SLICE's own +"later amendment"). diff --git a/slices/062-mcp-server-auth/SLICE.md b/slices/062-mcp-server-auth/SLICE.md index c9b6154..bba584b 100644 --- a/slices/062-mcp-server-auth/SLICE.md +++ b/slices/062-mcp-server-auth/SLICE.md @@ -48,12 +48,13 @@ clients (CIMD URLs), scopes; conformance tests modelled on the spec's flows; doc 1. [auto] RS: unauthenticated → 401 with `resource_metadata`; PRM lists our AS; wrong `aud`/expired → 401; each receipted (tests). 2. [auto] AS: a CIMD-registered test client completes authorization-code + PKCE with resource indicator and receives an audience-bound token that the RS accepts; DCR path works only when explicitly enabled (tests). -3. [auto] EMA: a fake enterprise IdP (in-repo, publishes OIDC discovery + JWKS) issues an ID-JAG for user `u@example.com`; +3. [paused] EMA: a fake enterprise IdP (in-repo, publishes OIDC discovery + JWKS) issues an ID-JAG for user `u@example.com`; our AS validates it, maps domain → org and group → scopes, issues an access token; the RS accepts it; a token for an - unverified domain is refused; a replayed/expired ID-JAG is refused (tests, with the exact claim checks named). + unverified domain is refused; a replayed/expired ID-JAG is refused (tests, with the exact claim checks named). Paused at G1, 2026-09-22, under the owner's decision that Trinity is not the issuer of production authority (NOTES.md, "Paused"); the owner's answer decides whether it leaves the slice. + 4. [auto] Scope mapping: `trinity:recall` cannot call an `:artifact` tool; `trinity:tools:artifact` can, subject to the gate (tests). 5. [auto] Key rotation: rotate the AS signing key; old tokens verify until expiry via JWKS `kid`; new tokens use the new key (test). -6. [manual] Manual: one real MCP client that supports EMA (per the MCP client matrix at the time) connects through the fake IdP +6. [paused] Manual: one real MCP client that supports EMA (per the MCP client matrix at the time) connects through the fake IdP flow; screenshots. If none is available on the developer machine, recorded as not measured. 7. [auto] The library boundary: `Trinity.MCP.Auth.*` has no dependency on `Trinity.Sessions`/`Trinity.Tools` (boundary check), so it can be extracted as its own package (slice 123). From 34e56d262e55bb65b16f9e6c46148739a5266302 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 06:33:40 -0400 Subject: [PATCH 02/13] feat(s062): the authorization boundary (profiles local, production, personal), the host, the plug's 401 with a challenge, the principal in the context and the receipts, scope before the gate, the OAuth endpoints and the client role's callback Signed-off-by: Ayla Croft --- VERSIONS.md | 1 + lib/trinity/effects/runner.ex | 11 +- lib/trinity/mcp.ex | 2 +- lib/trinity/mcp/auth.ex | 60 +++ lib/trinity/mcp/auth/client.ex | 243 +++++++++ lib/trinity/mcp/auth/client/store.ex | 87 ++++ lib/trinity/mcp/auth/config.ex | 135 +++++ lib/trinity/mcp/auth/discovery.ex | 77 +++ lib/trinity/mcp/auth/embedded.ex | 487 ++++++++++++++++++ lib/trinity/mcp/auth/embedded/keys.ex | 101 ++++ lib/trinity/mcp/auth/external.ex | 65 +++ lib/trinity/mcp/auth/jwks.ex | 103 ++++ lib/trinity/mcp/auth/local.ex | 50 ++ lib/trinity/mcp/auth/principal.ex | 54 ++ lib/trinity/mcp/auth/scopes.ex | 32 ++ lib/trinity/mcp/auth/token.ex | 110 ++++ lib/trinity/mcp/auth_host.ex | 232 +++++++++ lib/trinity/mcp/boot.ex | 10 +- lib/trinity/mcp/client.ex | 25 +- lib/trinity/mcp/client/auth.ex | 29 +- lib/trinity/mcp/server.ex | 45 +- lib/trinity/mcp/server/auth/local.ex | 72 --- lib/trinity/mcp/server/plug.ex | 42 +- lib/trinity/tools/context.ex | 6 +- lib/trinity/versions.ex | 7 + .../controllers/oauth_controller.ex | 130 +++++ lib/trinity_web/controllers/oauth_html.ex | 8 + .../controllers/oauth_html/consent.html.heex | 37 ++ lib/trinity_web/router.ex | 23 + mix.exs | 2 + mix.lock | 1 + 31 files changed, 2189 insertions(+), 98 deletions(-) create mode 100644 lib/trinity/mcp/auth.ex create mode 100644 lib/trinity/mcp/auth/client.ex create mode 100644 lib/trinity/mcp/auth/client/store.ex create mode 100644 lib/trinity/mcp/auth/config.ex create mode 100644 lib/trinity/mcp/auth/discovery.ex create mode 100644 lib/trinity/mcp/auth/embedded.ex create mode 100644 lib/trinity/mcp/auth/embedded/keys.ex create mode 100644 lib/trinity/mcp/auth/external.ex create mode 100644 lib/trinity/mcp/auth/jwks.ex create mode 100644 lib/trinity/mcp/auth/local.ex create mode 100644 lib/trinity/mcp/auth/principal.ex create mode 100644 lib/trinity/mcp/auth/scopes.ex create mode 100644 lib/trinity/mcp/auth/token.ex create mode 100644 lib/trinity/mcp/auth_host.ex delete mode 100644 lib/trinity/mcp/server/auth/local.ex create mode 100644 lib/trinity_web/controllers/oauth_controller.ex create mode 100644 lib/trinity_web/controllers/oauth_html.ex create mode 100644 lib/trinity_web/controllers/oauth_html/consent.html.heex diff --git a/VERSIONS.md b/VERSIONS.md index 7af0aed..b87a3c3 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -89,6 +89,7 @@ never pin a version hex marks as retired or vulnerable. | `postgrex` | >= 0.0.0 (optional) | ✅ in `mix.lock` | Secondary DB driver, `optional: true` so the desktop build carries none of it; compiled in only under `TRINITY_DB=postgres`, which the CI job proves. Added at Slice 010. Was one row with pgvector; pgvector keeps its own row below. | | `pgvector` | ~> 0.4.1 | ✅ in `mix.lock` | Vectors on the Postgres path: `memories.embedding_vector vector(384)` under an HNSW cosine index, searched by `Trinity.Memory.VectorStores.Pgvector` (Slice 032). The postgres job runs on the `pgvector/pgvector:pg17` image. Split from the postgrex row at Slice 010. | | `oban` | ~> 2.24 | ✅ in `mix.lock` | Uses `Oban.Engines.Lite` on SQLite and `Oban.Engines.Basic` on Postgres (the adapter is compile time, ADR-0002, so the engine is too). ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050 (2.24.1 measured on hex.pm 2026-09-22, Apache-2.0). | +| `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. | | `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. | | `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. | | `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. | diff --git a/lib/trinity/effects/runner.ex b/lib/trinity/effects/runner.ex index 4efdc80..50cc6c9 100644 --- a/lib/trinity/effects/runner.ex +++ b/lib/trinity/effects/runner.ex @@ -128,7 +128,16 @@ defmodule Trinity.Effects.Runner do # and the caller's trace context rides in the meta for 090; a desktop call carries neither, # so every receipt written before this slice reads the same. defp origin(%Context{origin: nil}, subject), do: subject - defp origin(%Context{origin: origin}, subject), do: Map.put(subject, "origin", origin) + + defp origin(%Context{origin: origin} = ctx, subject), + do: subject |> Map.put("origin", origin) |> principal(ctx) + + # Slice 062: an MCP caller's issuer, subject and scope on every receipt its call leaves + # (the context carries the receipt form, string keys, never a token). + defp principal(subject, %Context{principal: %{} = p}) when map_size(p) > 0, + do: Map.put(subject, "principal", p) + + defp principal(subject, _ctx), do: subject defp trace(%Context{trace: %{} = trace}, meta) when map_size(trace) > 0, do: Map.put(meta, "trace", trace) diff --git a/lib/trinity/mcp.ex b/lib/trinity/mcp.ex index 0fd287f..3e1b4b5 100644 --- a/lib/trinity/mcp.ex +++ b/lib/trinity/mcp.ex @@ -46,7 +46,7 @@ defmodule Trinity.MCP do Server.Plug, Server.Stdio, Server.Exports, - Server.Auth.Local, + AuthHost, Server.Replay ] diff --git a/lib/trinity/mcp/auth.ex b/lib/trinity/mcp/auth.ex new file mode 100644 index 0000000..ad55e25 --- /dev/null +++ b/lib/trinity/mcp/auth.ex @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth do + @moduledoc """ + Who is calling Trinity's MCP server, and with what scopes (slice 062; ADR-0008 decision 4: + identity is not authority). A profile implements this behaviour: `Local` (061's static bearer, + the default), `External` (the production profile: a resource server against an external + authorization server; Trinity issues nothing), `Embedded` (the personal profile: the same + resource server plus a small authorization server on the owner's machine, refused where an + external authority adapter is in force). `authorize/2` answers with a `Principal` and never + with the token, so nothing past this boundary holds token material; the caller (the plug, the + server) receipts the decision, since this boundary writes no receipt and reads no session: + `deps: []` on the tree, which is what lets slice 123 extract it. + """ + use Boundary, + deps: [], + exports: [ + Config, + Principal, + Token, + Discovery, + JWKS, + Scopes, + Local, + External, + Embedded, + Embedded.Keys, + Client, + Client.Store + ] + + alias Trinity.MCP.Auth.{Config, Principal} + + @doc "Authorizes a request under the profile: the principal, or why not (never the token)." + @callback authorize(Plug.Conn.t(), Config.t()) :: {:ok, Principal.t()} | {:error, term()} + + @doc "The RFC 9728 Protected Resource Metadata this profile publishes, or nil when it publishes none." + @callback resource_metadata(Config.t()) :: map() | nil + + @doc "The profile module for a configuration." + @spec impl(Config.t()) :: module() + def impl(%Config{profile: :local}), do: Trinity.MCP.Auth.Local + def impl(%Config{profile: :production}), do: Trinity.MCP.Auth.External + def impl(%Config{profile: :personal}), do: Trinity.MCP.Auth.Embedded + + @doc "Authorizes through the configuration's profile." + @spec authorize(Plug.Conn.t(), Config.t()) :: {:ok, Principal.t()} | {:error, term()} + def authorize(conn, %Config{} = config), do: impl(config).authorize(conn, config) + + @doc "The bearer from a request's `Authorization` header, or why not. The one reader of that header." + @spec bearer(Plug.Conn.t()) :: + {:ok, String.t()} | {:error, :no_bearer | :malformed_authorization} + def bearer(conn) do + case Plug.Conn.get_req_header(conn, "authorization") do + ["Bearer " <> token] when byte_size(token) > 0 -> {:ok, token} + [] -> {:error, :no_bearer} + _ -> {:error, :malformed_authorization} + end + end +end diff --git a/lib/trinity/mcp/auth/client.ex b/lib/trinity/mcp/auth/client.ex new file mode 100644 index 0000000..03ef903 --- /dev/null +++ b/lib/trinity/mcp/auth/client.ex @@ -0,0 +1,243 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.Client do + @moduledoc """ + The OAuth 2.1 client role (slice 062, ADR-0007 decision 8): how Trinity obtains a token for a + protected MCP server, so that 060's driver can present it and perform no flow of its own. + + On a `401` whose `WWW-Authenticate` names `resource_metadata`, the driver hands the URL here: + the Protected Resource Metadata (RFC 9728) names the authorization server; its metadata (RFC + 8414, OIDC second) names the endpoints; `begin/3` builds the authorization URL with PKCE (S256), + `state`, the `resource` (RFC 8707) and the client's identity, holds the request in the store, + and the owner opens the URL; `finish/2` takes the callback (`state` to the pending request, + `iss` checked against the issuer when the AS says it sends one, RFC 9207), exchanges the code + at the token endpoint with the verifier and the resource, and stores the token for the + resource. The client identifies itself by the configured `client_id` (pre-registered at the + enterprise AS, the production path), by a Client ID Metadata Document URL when configured, or, + behind `dcr: true` and only when the AS offers registration, by registering once. + """ + + alias Trinity.MCP.Auth.{Config, Discovery} + alias Trinity.MCP.Auth.Client.Store + + @default_scope "trinity:tools:read" + + @doc "The `resource_metadata` URL a `WWW-Authenticate: Bearer …` challenge names." + @spec challenge(String.t() | [String.t()]) :: {:ok, String.t()} | :error + def challenge([header | _]), do: challenge(header) + + def challenge(header) when is_binary(header) do + case Regex.run(~r/resource_metadata="([^"]+)"/, header) do + [_, url] -> {:ok, url} + _ -> :error + end + end + + def challenge(_), do: :error + + @doc "The resource and its authorization server's metadata, from the PRM's URL." + @spec discover(String.t(), keyword()) :: + {:ok, %{resource: String.t(), issuer: String.t(), as: map()}} | {:error, term()} + def discover(prm_url, opts \\ []) do + with {:ok, %{"resource" => resource, "authorization_servers" => [issuer | _]}} <- + Discovery.protected_resource(prm_url, opts), + {:ok, as} <- Discovery.authorization_server(issuer, opts) do + {:ok, %{resource: resource, issuer: issuer, as: as}} + else + {:ok, _} -> {:error, :no_authorization_server_in_metadata} + {:error, _} = error -> error + end + end + + @doc """ + Begins the code flow for a discovered server: the URL the owner opens. `redirect_uri` is the + host's callback; `scope` the scopes asked (`trinity:tools:read` by default). + """ + @spec begin(map(), Config.t(), keyword()) :: + {:ok, %{url: String.t(), state: String.t()}} | {:error, term()} + def begin(%{resource: resource, issuer: issuer, as: as}, %Config{} = config, opts) do + with {:ok, client_id} <- client_id(as, issuer, config, opts), + {:ok, endpoint} <- fetch(as, "authorization_endpoint") do + verifier = Base.url_encode64(:crypto.strong_rand_bytes(48), padding: false) + challenge = :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + state = Base.url_encode64(:crypto.strong_rand_bytes(24), padding: false) + redirect_uri = Keyword.fetch!(opts, :redirect_uri) + scope = Keyword.get(opts, :scope, @default_scope) + + params = %{ + "response_type" => "code", + "client_id" => client_id, + "redirect_uri" => redirect_uri, + "code_challenge" => challenge, + "code_challenge_method" => "S256", + "state" => state, + "resource" => resource, + "scope" => scope + } + + :ok = + Store.put_pending(config.store_dir, state, %{ + "verifier" => verifier, + "client_id" => client_id, + "redirect_uri" => redirect_uri, + "resource" => resource, + "issuer" => issuer, + "token_endpoint" => as["token_endpoint"], + "iss_expected" => as["authorization_response_iss_parameter_supported"] == true + }) + + {:ok, %{url: endpoint <> "?" <> URI.encode_query(params), state: state}} + end + end + + @doc "Finishes the flow from the callback's params: the token stored for the resource." + @spec finish(map(), Config.t(), keyword()) :: {:ok, map()} | {:error, term()} + def finish(params, %Config{} = config, opts \\ []) do + with {:ok, state} <- fetch(params, "state"), + {:ok, pending} <- Store.take_pending(config.store_dir, state) |> ok_or(:unknown_state), + :ok <- issuer_check(params, pending), + :ok <- error_check(params), + {:ok, code} <- fetch(params, "code"), + {:ok, token} <- exchange(code, pending, opts) do + entry = %{ + "issuer" => pending["issuer"], + "resource" => pending["resource"], + "access_token" => token["access_token"], + "expires_at" => System.os_time(:second) + (token["expires_in"] || 300), + "scope" => token["scope"] + } + + :ok = Store.put_token(config.store_dir, pending["resource"], entry) + {:ok, Map.delete(entry, "access_token")} + end + end + + @doc "The stored bearer for a resource, if one is held and not expired." + @spec bearer(Config.t(), String.t()) :: String.t() | nil + def bearer(%Config{store_dir: dir}, resource) when is_binary(dir) do + case Store.token(dir, resource) do + {:ok, %{"access_token" => t}} -> t + :error -> nil + end + end + + def bearer(_config, _resource), do: nil + + # RFC 9207: when the AS says it sends `iss`, the callback must carry the issuer expected; + # a callback from another issuer is a mix-up and is refused. + defp issuer_check(params, %{"iss_expected" => true, "issuer" => issuer}) do + if params["iss"] == issuer, do: :ok, else: {:error, {:wrong_issuer, params["iss"]}} + end + + defp issuer_check(params, %{"issuer" => issuer}) do + case params["iss"] do + nil -> :ok + ^issuer -> :ok + other -> {:error, {:wrong_issuer, other}} + end + end + + defp error_check(%{"error" => error} = params), + do: {:error, {:authorization_error, error, params["error_description"]}} + + defp error_check(_), do: :ok + + defp exchange(code, pending, opts) do + form = [ + grant_type: "authorization_code", + code: code, + redirect_uri: pending["redirect_uri"], + client_id: pending["client_id"], + code_verifier: pending["verifier"], + resource: pending["resource"] + ] + + options = + Keyword.merge( + [form: form, retry: false, decode_body: false, receive_timeout: 10_000], + Keyword.get(opts, :req_options, []) + ) + + case Req.post(pending["token_endpoint"], options) do + {:ok, %Req.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"access_token" => t} = token} when is_binary(t) -> {:ok, token} + _ -> {:error, :token_response_unreadable} + end + + {:ok, %Req.Response{status: status, body: body}} -> + {:error, {:token_endpoint, status, describe(body)}} + + {:error, reason} -> + {:error, {:transport, reason}} + end + end + + defp describe(body) when is_binary(body) do + case Jason.decode(body) do + {:ok, %{"error" => e}} -> e + _ -> binary_part(body, 0, min(byte_size(body), 120)) + end + end + + defp describe(other), do: inspect(other) + + # The client's identity, in order: the configured id; the metadata document's URL; a + # registration made once and remembered, only when allowed and offered. + defp client_id(as, issuer, %Config{} = config, opts) do + cond do + is_binary(config.client_id) -> + {:ok, config.client_id} + + is_binary(config.client_metadata_url) -> + {:ok, config.client_metadata_url} + + id = Store.client_id(config.store_dir, issuer) -> + {:ok, id} + + config.dcr and is_binary(as["registration_endpoint"]) -> + register(as["registration_endpoint"], issuer, config, opts) + + true -> + {:error, :no_client_identity} + end + end + + defp register(endpoint, issuer, config, opts) do + body = %{ + "client_name" => "Trinity", + "redirect_uris" => [Keyword.fetch!(opts, :redirect_uri)], + "token_endpoint_auth_method" => "none", + "grant_types" => ["authorization_code"], + "response_types" => ["code"] + } + + case Req.post(endpoint, json: body, retry: false, decode_body: false) do + {:ok, %Req.Response{status: status, body: raw}} when status in [200, 201] -> + case Jason.decode(raw) do + {:ok, %{"client_id" => id}} -> + :ok = Store.put_client_id(config.store_dir, issuer, id) + {:ok, id} + + _ -> + {:error, :registration_unreadable} + end + + {:ok, %Req.Response{status: status}} -> + {:error, {:registration_refused, status}} + + {:error, reason} -> + {:error, {:transport, reason}} + end + end + + defp fetch(map, key) do + case Map.get(map, key) do + v when is_binary(v) and v != "" -> {:ok, v} + _ -> {:error, {:missing, key}} + end + end + + defp ok_or({:ok, v}, _), do: {:ok, v} + defp ok_or(:error, reason), do: {:error, reason} +end diff --git a/lib/trinity/mcp/auth/client/store.ex b/lib/trinity/mcp/auth/client/store.ex new file mode 100644 index 0000000..0f79c6f --- /dev/null +++ b/lib/trinity/mcp/auth/client/store.ex @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.Client.Store do + @moduledoc """ + Where the client role keeps what it obtained (slice 062): one file per resource under the + store directory the host names (`/secrets/oauth/`), mode 0600, holding the issuer, + the resource, the access token, its expiry and its scope; the pending authorization + requests (state, verifier, the AS's metadata) under `pending/` until their callback comes, + ten minutes at most; a registered client id per issuer when DCR was used. `Trinity.Secrets` + is slice 100's keychain, and this file store is what stands until then, named as such. + """ + + @pending_ttl_s 600 + + @doc "The stored token for a resource, when it has not expired." + @spec token(Path.t(), String.t()) :: {:ok, map()} | :error + def token(dir, resource) do + with {:ok, %{"access_token" => t, "expires_at" => exp} = entry} <- + read(token_path(dir, resource)), + true <- is_binary(t) and is_integer(exp) and exp > System.os_time(:second) do + {:ok, entry} + else + _ -> :error + end + end + + @doc "Stores a token for a resource." + @spec put_token(Path.t(), String.t(), map()) :: :ok + def put_token(dir, resource, %{} = entry), do: write!(token_path(dir, resource), entry) + + @doc "Forgets a resource's token." + @spec delete_token(Path.t(), String.t()) :: :ok + def delete_token(dir, resource) do + _ = File.rm(token_path(dir, resource)) + :ok + end + + @doc "Holds a pending authorization request under its state." + @spec put_pending(Path.t(), String.t(), map()) :: :ok + def put_pending(dir, state, %{} = pending), + do: write!(pending_path(dir, state), Map.put(pending, "created_at", System.os_time(:second))) + + @doc "Takes a pending request by state (removed once taken; refused when expired)." + @spec take_pending(Path.t(), String.t()) :: {:ok, map()} | :error + def take_pending(dir, state) when is_binary(state) do + path = pending_path(dir, state) + + with {:ok, %{"created_at" => at} = pending} <- read(path), + _ <- File.rm(path), + true <- System.os_time(:second) - at < @pending_ttl_s do + {:ok, pending} + else + _ -> :error + end + end + + @doc "The client id registered at an issuer (DCR), if any." + @spec client_id(Path.t(), String.t()) :: String.t() | nil + def client_id(dir, issuer) do + case read(client_path(dir, issuer)) do + {:ok, %{"client_id" => id}} -> id + _ -> nil + end + end + + @doc "Remembers the client id registered at an issuer." + @spec put_client_id(Path.t(), String.t(), String.t()) :: :ok + def put_client_id(dir, issuer, id), + do: write!(client_path(dir, issuer), %{"client_id" => id, "issuer" => issuer}) + + defp token_path(dir, resource), do: Path.join(dir, "token-" <> hash(resource) <> ".json") + defp pending_path(dir, state), do: Path.join([dir, "pending", hash(state) <> ".json"]) + defp client_path(dir, issuer), do: Path.join(dir, "client-" <> hash(issuer) <> ".json") + + defp hash(s), + do: :crypto.hash(:sha256, s) |> Base.url_encode64(padding: false) |> binary_part(0, 22) + + defp read(path) do + with {:ok, json} <- File.read(path), {:ok, %{} = map} <- Jason.decode(json), do: {:ok, map} + end + + defp write!(path, map) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Jason.encode!(map)) + File.chmod!(path, 0o600) + end +end diff --git a/lib/trinity/mcp/auth/config.ex b/lib/trinity/mcp/auth/config.ex new file mode 100644 index 0000000..7f021fb --- /dev/null +++ b/lib/trinity/mcp/auth/config.ex @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.Config do + @moduledoc """ + The authorization configuration (slice 062), built by the host from `config :trinity, + :mcp_auth` and the paths it owns (the boundary reads no path of its own). `profile` is + `:local`, `:production` or `:personal`; `resource` is this server's identifier (its `/mcp` + URL), the audience every token must carry; `issuer` names the authorization server of + record (the external one in production; this server's own in the personal profile); + `allowed_algs` is the signature allow list; `introspection` selects RFC 7662 for opaque + tokens. `token_path` is the local profile's bearer file; `key_dir` the personal profile's + signing keys; `store_dir` the client role's token store. + """ + + @type profile :: :local | :production | :personal + + @type t :: %__MODULE__{ + profile: profile(), + resource: String.t() | nil, + issuer: String.t() | nil, + audience: String.t() | nil, + allowed_algs: [String.t()], + introspection: boolean(), + introspection_credentials: {String.t(), String.t()} | nil, + jwks_ttl_s: pos_integer(), + token_path: Path.t() | nil, + token_env: String.t() | nil, + key_dir: Path.t() | nil, + store_dir: Path.t() | nil, + dcr: boolean(), + access_token_ttl_s: pos_integer(), + client_id: String.t() | nil, + client_metadata_url: String.t() | nil, + scopes: [String.t()], + local_authority?: boolean() + } + + defstruct profile: :local, + resource: nil, + issuer: nil, + audience: nil, + allowed_algs: ~w(ES256 ES384 EdDSA RS256), + introspection: false, + introspection_credentials: nil, + jwks_ttl_s: 3_600, + token_path: nil, + token_env: "TRINITY_MCP_SERVER_TOKEN", + key_dir: nil, + store_dir: nil, + dcr: false, + access_token_ttl_s: 600, + client_id: nil, + client_metadata_url: nil, + scopes: ~w(trinity:tools:read trinity:tools:artifact trinity:recall), + # The host says whether the local authority is in force (`Trinity.Authority.Local`); + # the personal profile refuses to start when it is not. + local_authority?: true + + @doc "Builds a configuration from a keyword (the host's), refusing an incomplete profile." + @spec new(keyword()) :: {:ok, t()} | {:error, term()} + def new(opts) when is_list(opts) do + config = struct(__MODULE__, Map.new(opts)) + config = %{config | audience: config.audience || config.resource} + + case check(config) do + :ok -> {:ok, config} + {:error, _} = error -> error + end + end + + @doc "The same, raising." + @spec new!(keyword()) :: t() + def new!(opts) do + case new(opts) do + {:ok, config} -> config + {:error, reason} -> raise ArgumentError, "mcp auth configuration: #{inspect(reason)}" + end + end + + defp check(%__MODULE__{profile: :local}), do: :ok + + defp check(%__MODULE__{profile: :production} = c) do + cond do + not url?(c.issuer) -> + {:error, + {:issuer, "the production profile needs the external authorization server's issuer URL"}} + + not url?(c.resource) -> + {:error, {:resource, "the production profile needs this server's resource identifier"}} + + "none" in Enum.map(c.allowed_algs, &String.downcase/1) -> + {:error, {:allowed_algs, "none is never allowed"}} + + Enum.any?(c.allowed_algs, &String.starts_with?(&1, "HS")) -> + {:error, {:allowed_algs, "a shared-secret algorithm is never allowed"}} + + true -> + :ok + end + end + + defp check(%__MODULE__{profile: :personal} = c) do + cond do + not url?(c.resource) -> + {:error, {:resource, "the personal profile needs this server's resource identifier"}} + + is_nil(c.key_dir) -> + {:error, {:key_dir, "the personal profile needs a key directory"}} + + not c.local_authority? -> + {:error, + {:profile, + "the personal profile has no embedded issuer under an external authority adapter"}} + + true -> + :ok + end + end + + defp check(%__MODULE__{profile: other}), + do: {:error, {:profile, "unknown profile #{inspect(other)}"}} + + defp url?(s) when is_binary(s) do + case URI.new(s) do + {:ok, %URI{scheme: scheme, host: host}} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + true + + _ -> + false + end + end + + defp url?(_), do: false +end diff --git a/lib/trinity/mcp/auth/discovery.ex b/lib/trinity/mcp/auth/discovery.ex new file mode 100644 index 0000000..9486ad6 --- /dev/null +++ b/lib/trinity/mcp/auth/discovery.ex @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.Discovery do + @moduledoc """ + Finding an authorization server's metadata (slice 062): RFC 8414 (`/.well-known/oauth-authorization-server`, + the well-known segment inserted before the issuer's path when it has one) first, OpenID + Connect discovery (`/.well-known/openid-configuration`) second. The document's `issuer` must + equal the issuer asked for (RFC 8414 section 3.3), or the document is refused: a server + answering for another issuer is the substitution the check exists to catch. Also the RFC + 9728 Protected Resource Metadata a resource server publishes, fetched the same way from a + `resource_metadata` URL a `401` names. + """ + + @type metadata :: %{required(String.t()) => term()} + + @doc "The authorization server's metadata for an issuer." + @spec authorization_server(String.t(), keyword()) :: {:ok, metadata()} | {:error, term()} + def authorization_server(issuer, opts \\ []) when is_binary(issuer) do + urls(issuer) + |> Enum.reduce_while({:error, :not_found}, fn url, _ -> + case fetch_json(url, opts) do + {:ok, %{"issuer" => ^issuer} = doc} -> {:halt, {:ok, doc}} + {:ok, %{"issuer" => other}} -> {:halt, {:error, {:issuer_mismatch, other}}} + {:ok, _} -> {:cont, {:error, :no_issuer_in_document}} + {:error, {:status, 404}} -> {:cont, {:error, :not_found}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + @doc "The two well-known URLs for an issuer, in the order tried." + @spec urls(String.t()) :: [String.t()] + def urls(issuer) do + uri = URI.parse(issuer) + path = String.trim_trailing(uri.path || "", "/") + base = %{uri | path: nil, query: nil, fragment: nil} |> URI.to_string() + + [ + base <> "/.well-known/oauth-authorization-server" <> path, + base <> path <> "/.well-known/openid-configuration" + ] + end + + @doc "A Protected Resource Metadata document from its URL." + @spec protected_resource(String.t(), keyword()) :: {:ok, metadata()} | {:error, term()} + def protected_resource(url, opts \\ []) when is_binary(url) do + case fetch_json(url, opts) do + {:ok, %{"resource" => _} = doc} -> {:ok, doc} + {:ok, _} -> {:error, :no_resource_in_document} + {:error, _} = error -> error + end + end + + @doc "A JSON document from a URL, with the request options the host passes (`req_options:`)." + @spec fetch_json(String.t(), keyword()) :: {:ok, map()} | {:error, term()} + def fetch_json(url, opts \\ []) do + options = + Keyword.merge( + [receive_timeout: 10_000, retry: false, redirect: false, decode_body: false], + Keyword.get(opts, :req_options, []) + ) + + case Req.get(url, options) do + {:ok, %Req.Response{status: 200, body: body}} when is_binary(body) -> + case Jason.decode(body) do + {:ok, %{} = doc} -> {:ok, doc} + _ -> {:error, :not_json} + end + + {:ok, %Req.Response{status: status}} -> + {:error, {:status, status}} + + {:error, reason} -> + {:error, {:transport, reason}} + end + end +end diff --git a/lib/trinity/mcp/auth/embedded.ex b/lib/trinity/mcp/auth/embedded.ex new file mode 100644 index 0000000..9c65405 --- /dev/null +++ b/lib/trinity/mcp/auth/embedded.ex @@ -0,0 +1,487 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.Embedded do + @moduledoc """ + The personal profile (slice 062): the resource server of `External` plus a small authorization + server on the owner's machine, for the owner's own MCP clients. Not the default, and refused + where it must be: `start_link/1` refuses when the host says an external authority adapter is + in force (`Config.local_authority?` false), so a regulated deployment has no embedded issuer + by construction; every token it mints carries `"profile" => "personal"`, which the production + profile refuses whatever key signed it. + + What it speaks: RFC 8414 metadata; authorization-code with PKCE (S256 only) and RFC 8707 + `resource` (this server's identifier, required); the owner's consent in the browser as the + login (the local Trinity user); RFC 9207 `iss` on the response; Client ID Metadata Documents + (the `client_id` is the https URL of the client's metadata, fetched and checked) with RFC 7591 + registration only behind `dcr: true`; ES256 access tokens with `aud` = the resource identifier, + ten minutes of life, the JWKS at the host's route, rotation by `kid` (`Embedded.Keys`). + + This process holds the pending authorization requests and the issued codes (single use, ten + minutes); the personal profile is one instance, so memory is its store. Registered clients + (DCR) live in `mcp-as-clients.json` beside the keys. + """ + @behaviour Trinity.MCP.Auth + use GenServer + + alias Trinity.MCP.Auth + alias Trinity.MCP.Auth.{Config, Discovery, Principal, Scopes, Token} + alias Trinity.MCP.Auth.Embedded.Keys + + @code_ttl_s 600 + @request_ttl_s 600 + @cimd_ttl_s 3_600 + + ## The behaviour (the resource server half) + + @impl Trinity.MCP.Auth + def authorize(conn, %Config{} = config) do + with {:ok, token} <- Auth.bearer(conn), + {:ok, header} <- Token.header(token), + {:ok, entry} <- key_for(config, header["kid"]) do + Token.verify_with_key(token, entry.jwk, %{config | issuer: config.issuer || config.resource}) + end + end + + @impl Trinity.MCP.Auth + def resource_metadata(%Config{} = config) do + %{ + "resource" => config.resource, + "authorization_servers" => [issuer(config)], + "bearer_methods_supported" => ["header"], + "scopes_supported" => Scopes.known() + } + end + + defp key_for(%Config{key_dir: dir}, kid) when is_binary(kid) do + case Keys.find(dir, kid) do + {:ok, entry} -> {:ok, entry} + :error -> {:error, {:unknown_kid, kid}} + end + end + + defp key_for(_config, nil), do: {:error, :no_kid} + + @doc "The issuer of the personal profile: the configured one, else the resource identifier's origin." + @spec issuer(Config.t()) :: String.t() + def issuer(%Config{issuer: issuer}) when is_binary(issuer), do: issuer + + def issuer(%Config{resource: resource}) do + uri = URI.parse(resource) + %{uri | path: nil, query: nil, fragment: nil} |> URI.to_string() + end + + ## The authorization server half: metadata + + @doc "The RFC 8414 metadata; the endpoints are the host's routes under the issuer." + @spec metadata(Config.t()) :: map() + def metadata(%Config{} = config) do + iss = issuer(config) + + %{ + "issuer" => iss, + "authorization_endpoint" => iss <> "/oauth/authorize", + "token_endpoint" => iss <> "/oauth/token", + "jwks_uri" => iss <> "/.well-known/jwks.json", + "registration_endpoint" => if(config.dcr, do: iss <> "/oauth/register"), + "response_types_supported" => ["code"], + "grant_types_supported" => ["authorization_code"], + "code_challenge_methods_supported" => ["S256"], + "token_endpoint_auth_methods_supported" => ["none"], + "scopes_supported" => Scopes.known(), + "client_id_metadata_document_supported" => true, + "authorization_response_iss_parameter_supported" => true, + "resource_indicators_supported" => true + } + |> Enum.reject(fn {_, v} -> is_nil(v) end) + |> Map.new() + end + + @doc "The JWKS document." + @spec jwks(Config.t()) :: map() + def jwks(%Config{key_dir: dir}), do: Keys.jwks(dir) + + ## The process + + @doc "Starts the authorization server's state; refused under an external authority adapter." + @spec start_link(Config.t()) :: GenServer.on_start() | {:error, :external_authority_in_force} + def start_link(%Config{} = config) do + if config.local_authority? do + GenServer.start_link(__MODULE__, config, name: __MODULE__) + else + {:error, :external_authority_in_force} + end + end + + @impl GenServer + def init(config) do + Keys.ensure!(config.key_dir) + {:ok, %{config: config, requests: %{}, codes: %{}, clients: load_clients(config), cimd: %{}}} + end + + ## Authorization requests + + @typedoc "What the consent page shows: the request id, the client's name, the scopes asked." + @type pending :: %{ + id: String.t(), + client_id: String.t(), + client_name: String.t(), + scope: [String.t()], + resource: String.t() + } + + @doc """ + Validates an authorization request (the query of `GET /oauth/authorize`) and holds it for the + owner's consent: the client known (CIMD or registered), the redirect URI one of the client's, + PKCE S256 present, `resource` this server's identifier, the scopes known. The reply names the + pending request for the consent page, or the refusal (with the redirect to send it to when the + redirect URI itself was fine). + """ + @spec begin(map()) :: {:ok, pending()} | {:error, term()} | {:redirect_error, String.t()} + def begin(params) when is_map(params), do: GenServer.call(__MODULE__, {:begin, params}) + + @doc "The owner's decision on a pending request: the URL to send the client to." + @spec decide(String.t(), :approve | :deny, String.t()) :: {:ok, String.t()} | {:error, term()} + def decide(request_id, decision, sub) when decision in [:approve, :deny], + do: GenServer.call(__MODULE__, {:decide, request_id, decision, sub}) + + @doc "The token endpoint: an authorization code with its verifier for an access token." + @spec token(map()) :: {:ok, map()} | {:error, term()} + def token(params) when is_map(params), do: GenServer.call(__MODULE__, {:token, params}) + + @doc "RFC 7591 registration, when enabled: the client's metadata for its id." + @spec register(map()) :: {:ok, map()} | {:error, term()} + def register(metadata) when is_map(metadata), + do: GenServer.call(__MODULE__, {:register, metadata}) + + @doc "The registered clients (DCR), by id." + @spec clients() :: %{String.t() => map()} + def clients, do: GenServer.call(__MODULE__, :clients) + + @impl GenServer + def handle_call({:begin, params}, _from, state) do + now = System.os_time(:second) + state = expire(state, now) + + case validate_request(params, state) do + {:ok, client, state} -> + id = random() + + pending = %{ + id: id, + client_id: params["client_id"], + client_name: client["client_name"] || params["client_id"], + scope: Principal.scopes(params["scope"]), + resource: params["resource"], + redirect_uri: params["redirect_uri"], + state: params["state"], + code_challenge: params["code_challenge"], + exp: now + @request_ttl_s + } + + {:reply, {:ok, Map.take(pending, [:id, :client_id, :client_name, :scope, :resource])}, + put_in(state.requests[id], pending)} + + {:error, reason, state} -> + {:reply, {:error, reason}, state} + + {:redirect_error, url, state} -> + {:reply, {:redirect_error, url}, state} + end + end + + def handle_call({:decide, id, decision, sub}, _from, state) do + now = System.os_time(:second) + state = expire(state, now) + + case Map.pop(state.requests, id) do + {nil, _} -> + {:reply, {:error, :unknown_request}, state} + + {pending, requests} -> + state = %{state | requests: requests} + + case decision do + :deny -> + {:reply, + {:ok, + redirect(pending.redirect_uri, + error: "access_denied", + state: pending.state, + iss: issuer(state.config) + )}, state} + + :approve -> + code = random() + + entry = %{ + client_id: pending.client_id, + redirect_uri: pending.redirect_uri, + code_challenge: pending.code_challenge, + resource: pending.resource, + scope: pending.scope, + sub: sub, + exp: now + @code_ttl_s + } + + url = + redirect(pending.redirect_uri, + code: code, + state: pending.state, + iss: issuer(state.config) + ) + + {:reply, {:ok, url}, put_in(state.codes[code], entry)} + end + end + end + + def handle_call({:token, params}, _from, state) do + now = System.os_time(:second) + state = expire(state, now) + + with "authorization_code" <- Map.get(params, "grant_type", "authorization_code"), + {entry, codes} when is_map(entry) <- Map.pop(state.codes, params["code"]), + state = %{state | codes: codes}, + :ok <- same(entry.client_id, params["client_id"], :invalid_client), + :ok <- same(entry.redirect_uri, params["redirect_uri"], :invalid_redirect_uri), + :ok <- same(entry.resource, params["resource"] || entry.resource, :invalid_target), + :ok <- pkce(entry.code_challenge, params["code_verifier"]) do + {:reply, {:ok, issue(entry, state.config, now)}, state} + else + {nil, _} -> {:reply, {:error, :invalid_grant}, state} + grant when is_binary(grant) -> {:reply, {:error, :unsupported_grant_type}, state} + {:error, reason} -> {:reply, {:error, reason}, state} + end + end + + def handle_call({:register, _metadata}, _from, %{config: %{dcr: false}} = state), + do: {:reply, {:error, :registration_disabled}, state} + + def handle_call({:register, metadata}, _from, state) do + with {:ok, uris} <- redirect_uris(metadata["redirect_uris"]) do + client_id = "dcr-" <> random() + + client = %{ + "client_id" => client_id, + "client_name" => metadata["client_name"] || client_id, + "redirect_uris" => uris, + "token_endpoint_auth_method" => "none", + "grant_types" => ["authorization_code"], + "response_types" => ["code"] + } + + state = put_in(state.clients[client_id], client) + save_clients(state) + {:reply, {:ok, client}, state} + else + {:error, reason} -> {:reply, {:error, reason}, state} + end + end + + def handle_call(:clients, _from, state), do: {:reply, state.clients, state} + + ## Validation + + defp validate_request(params, state) do + with :ok <- required(params, ~w(client_id redirect_uri response_type code_challenge resource)), + :ok <- same(params["response_type"], "code", :unsupported_response_type), + :ok <- + same(params["code_challenge_method"] || "S256", "S256", :invalid_code_challenge_method), + {:ok, client, state} <- client(params["client_id"], state), + :ok <- redirect_allowed(client, params["redirect_uri"]) do + cond do + params["resource"] != state.config.resource -> + {:redirect_error, + redirect(params["redirect_uri"], + error: "invalid_target", + state: params["state"], + iss: issuer(state.config) + ), state} + + not Enum.all?(Principal.scopes(params["scope"]), &(&1 in Scopes.known())) -> + {:redirect_error, + redirect(params["redirect_uri"], + error: "invalid_scope", + state: params["state"], + iss: issuer(state.config) + ), state} + + true -> + {:ok, client, state} + end + else + {:error, reason} -> {:error, reason, state} + {:error, reason, state} -> {:error, reason, state} + end + end + + defp required(params, keys) do + case Enum.reject(keys, &(is_binary(params[&1]) and params[&1] != "")) do + [] -> :ok + missing -> {:error, {:invalid_request, "missing " <> Enum.join(missing, ", ")}} + end + end + + defp same(a, a, _), do: :ok + defp same(_, _, reason), do: {:error, reason} + + # A client is a registered one (DCR) or a Client ID Metadata Document: the id is an https + # URL whose document names itself and its redirect URIs. Fetched once an hour. + defp client(client_id, state) do + cond do + Map.has_key?(state.clients, client_id) -> + {:ok, state.clients[client_id], state} + + String.starts_with?(client_id, "https://") or + (String.starts_with?(client_id, "http://") and state.config.dcr) -> + cimd(client_id, state) + + true -> + {:error, :unknown_client, state} + end + end + + defp cimd(url, state) do + now = System.os_time(:second) + + case Map.get(state.cimd, url) do + {doc, at} when now - at < @cimd_ttl_s -> + {:ok, doc, state} + + _ -> + case Discovery.fetch_json(url) do + {:ok, %{"client_id" => ^url, "redirect_uris" => uris} = doc} when is_list(uris) -> + {:ok, doc, put_in(state.cimd[url], {doc, now})} + + {:ok, _} -> + {:error, {:invalid_client_metadata, url}, state} + + {:error, reason} -> + {:error, {:client_metadata_unreachable, reason}, state} + end + end + end + + # A registered redirect URI matches exactly; a loopback one (RFC 8252 section 7.3) matches + # with any port, since a native client binds an ephemeral one. + defp redirect_allowed(%{"redirect_uris" => uris}, given) when is_list(uris) do + if Enum.any?(uris, &redirect_match?(&1, given)), + do: :ok, + else: {:error, :invalid_redirect_uri} + end + + defp redirect_allowed(_, _), do: {:error, :invalid_redirect_uri} + + defp redirect_match?(registered, given) when registered == given, do: true + + defp redirect_match?(registered, given) do + with %URI{scheme: "http", host: rh} = r <- URI.parse(registered), + %URI{scheme: "http", host: gh} = g <- URI.parse(given), + true <- rh in ["127.0.0.1", "localhost", "[::1]"] and rh == gh do + r.path == g.path + else + _ -> false + end + end + + defp redirect_uris(uris) when is_list(uris) and uris != [] do + if Enum.all?( + uris, + &(is_binary(&1) and + match?({:ok, %URI{scheme: s}} when s in ["http", "https"], URI.new(&1))) + ), + do: {:ok, uris}, + else: {:error, :invalid_redirect_uri} + end + + defp redirect_uris(_), do: {:error, :invalid_redirect_uri} + + defp pkce(challenge, verifier) when is_binary(challenge) and is_binary(verifier) do + computed = :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + + if byte_size(computed) == byte_size(challenge) and :crypto.hash_equals(computed, challenge), + do: :ok, + else: {:error, :invalid_grant} + end + + defp pkce(_, _), do: {:error, :invalid_grant} + + ## Issuing + + defp issue(entry, config, now) do + key = Keys.current!(config.key_dir) + ttl = config.access_token_ttl_s + + claims = %{ + "iss" => issuer(config), + "sub" => entry.sub, + "aud" => config.resource, + "client_id" => entry.client_id, + "scope" => Enum.join(entry.scope, " "), + "iat" => now, + "exp" => now + ttl, + "jti" => random(), + "profile" => "personal" + } + + {_, token} = + key.jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{ + "alg" => "ES256", + "kid" => key.kid, + "typ" => "at+jwt" + }) + |> JOSE.JWS.compact() + + %{ + "access_token" => token, + "token_type" => "Bearer", + "expires_in" => ttl, + "scope" => claims["scope"] + } + end + + ## Helpers + + defp redirect(uri, params) do + query = + params + |> Enum.reject(fn {_, v} -> is_nil(v) end) + |> Map.new(fn {k, v} -> {Atom.to_string(k), v} end) + + parsed = URI.parse(uri) + existing = if parsed.query, do: URI.decode_query(parsed.query), else: %{} + %{parsed | query: URI.encode_query(Map.merge(existing, query))} |> URI.to_string() + end + + defp random, do: Base.url_encode64(:crypto.strong_rand_bytes(24), padding: false) + + defp expire(state, now) do + %{ + state + | requests: Map.filter(state.requests, fn {_, r} -> r.exp > now end), + codes: Map.filter(state.codes, fn {_, c} -> c.exp > now end) + } + end + + defp clients_path(%Config{key_dir: dir}), do: Path.join(dir, "mcp-as-clients.json") + + defp load_clients(config) do + case File.read(clients_path(config)) do + {:ok, json} -> + case Jason.decode(json) do + {:ok, %{} = clients} -> clients + _ -> %{} + end + + _ -> + %{} + end + end + + defp save_clients(%{config: config, clients: clients}) do + path = clients_path(config) + File.write!(path, Jason.encode!(clients)) + File.chmod!(path, 0o600) + end +end diff --git a/lib/trinity/mcp/auth/embedded/keys.ex b/lib/trinity/mcp/auth/embedded/keys.ex new file mode 100644 index 0000000..4ebb8a6 --- /dev/null +++ b/lib/trinity/mcp/auth/embedded/keys.ex @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.Embedded.Keys do + @moduledoc """ + The personal profile's signing keys (slice 062): ES256 (P-256) key pairs as JWK files under + the key directory the host names, `mcp-as-.jwk.json`, mode 0600, each with the time it + was made. The newest signs; every key's public half is in the JWKS, so a token signed before + a rotation verifies by `kid` until it expires (AC5); `rotate!/1` makes a new key and keeps + the old ones, and `prune!/2` drops keys older than the longest token life plus a margin. + """ + + @type entry :: %{kid: String.t(), jwk: JOSE.JWK.t(), created_at: integer(), path: Path.t()} + + @doc "Every key in the directory, newest first; a fresh one is made when there is none." + @spec ensure!(Path.t()) :: [entry()] + def ensure!(dir) do + case all(dir) do + [] -> [generate!(dir)] + keys -> keys + end + end + + @doc "The keys in the directory, newest first." + @spec all(Path.t()) :: [entry()] + def all(dir) do + dir + |> Path.join("mcp-as-*.jwk.json") + |> Path.wildcard() + |> Enum.map(&read!/1) + |> Enum.sort_by(& &1.created_at, :desc) + end + + @doc "The signing key: the newest." + @spec current!(Path.t()) :: entry() + def current!(dir), do: dir |> ensure!() |> hd() + + @doc "The key for a `kid`." + @spec find(Path.t(), String.t()) :: {:ok, entry()} | :error + def find(dir, kid) do + case Enum.find(all(dir), &(&1.kid == kid)) do + nil -> :error + entry -> {:ok, entry} + end + end + + @doc "Makes a new key, which becomes the signing key; the old ones stay for verification." + @spec rotate!(Path.t()) :: entry() + def rotate!(dir), do: generate!(dir) + + @doc "Drops keys older than `max_age_s`, keeping the newest whatever its age." + @spec prune!(Path.t(), pos_integer()) :: [entry()] + def prune!(dir, max_age_s) do + now = System.os_time(:second) + + case all(dir) do + [newest | rest] -> + {old, kept} = Enum.split_with(rest, &(now - &1.created_at > max_age_s)) + Enum.each(old, &File.rm!(&1.path)) + [newest | kept] + + [] -> + [] + end + end + + @doc "The public keys as a JWKS document." + @spec jwks(Path.t()) :: map() + def jwks(dir) do + keys = + for e <- ensure!(dir) do + {_, public} = e.jwk |> JOSE.JWK.to_public() |> JOSE.JWK.to_map() + Map.merge(public, %{"kid" => e.kid, "use" => "sig", "alg" => "ES256"}) + end + + %{"keys" => keys} + end + + defp generate!(dir) do + File.mkdir_p!(dir) + jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + kid = jwk |> JOSE.JWK.thumbprint() |> binary_part(0, 12) + created_at = System.os_time(:second) + {_, private} = JOSE.JWK.to_map(jwk) + path = Path.join(dir, "mcp-as-#{kid}.jwk.json") + + File.write!( + path, + Jason.encode!(%{"kid" => kid, "created_at" => created_at, "jwk" => private}) + ) + + File.chmod!(path, 0o600) + %{kid: kid, jwk: JOSE.JWK.from_map(private), created_at: created_at, path: path} + end + + defp read!(path) do + %{"kid" => kid, "created_at" => created_at, "jwk" => private} = + path |> File.read!() |> Jason.decode!() + + %{kid: kid, jwk: JOSE.JWK.from_map(private), created_at: created_at, path: path} + end +end diff --git a/lib/trinity/mcp/auth/external.ex b/lib/trinity/mcp/auth/external.ex new file mode 100644 index 0000000..1db6329 --- /dev/null +++ b/lib/trinity/mcp/auth/external.ex @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.External do + @moduledoc """ + The production profile (slice 062): Trinity as an OAuth 2.1 resource server against an + external authorization server. A bearer is a JWT the issuer signed (`Token.verify/3`: the + named checks, audience-bound to this server's resource identifier), or, with + `introspection: true`, an opaque token asked about at the issuer's RFC 7662 endpoint + (`active`, then the same claim checks on what it answers). Trinity issues nothing here; the + metadata it publishes (RFC 9728) names the external issuer as the authorization server. + """ + @behaviour Trinity.MCP.Auth + + alias Trinity.MCP.Auth + alias Trinity.MCP.Auth.{Config, Discovery, Principal, Token} + + @impl true + def authorize(conn, %Config{introspection: false} = config) do + with {:ok, token} <- Auth.bearer(conn), do: Token.verify(token, config) + end + + def authorize(conn, %Config{introspection: true} = config) do + with {:ok, token} <- Auth.bearer(conn), do: introspect(token, config) + end + + @impl true + def resource_metadata(%Config{resource: resource, issuer: issuer}) do + %{ + "resource" => resource, + "authorization_servers" => [issuer], + "bearer_methods_supported" => ["header"], + "scopes_supported" => Trinity.MCP.Auth.Scopes.known() + } + end + + # RFC 7662: the issuer's introspection endpoint from its metadata, the token posted with the + # resource server's own credentials when it has them, and the answer's claims checked as a + # JWT's would be (`active` first). + defp introspect(token, %Config{} = config) do + with {:ok, meta} <- Discovery.authorization_server(config.issuer), + {:ok, endpoint} <- endpoint(meta), + {:ok, %Req.Response{status: 200, body: body}} <- + Req.post(endpoint, + form: [token: token, token_type_hint: "access_token"], + auth: basic(config.introspection_credentials), + retry: false, + decode_body: false + ), + {:ok, %{"active" => true} = claims} <- Jason.decode(body), + :ok <- Token.check(claims, config, System.os_time(:second)) do + {:ok, Principal.from_claims(claims, :production)} + else + {:ok, %{"active" => false}} -> {:error, :inactive} + {:ok, %Req.Response{status: status}} -> {:error, {:introspection_status, status}} + {:error, _} = error -> error + _ -> {:error, :introspection_failed} + end + end + + defp endpoint(%{"introspection_endpoint" => e}) when is_binary(e), do: {:ok, e} + defp endpoint(_), do: {:error, :no_introspection_endpoint} + + defp basic(nil), do: nil + defp basic({id, secret}), do: {:basic, id <> ":" <> secret} +end diff --git a/lib/trinity/mcp/auth/jwks.ex b/lib/trinity/mcp/auth/jwks.ex new file mode 100644 index 0000000..8969bfb --- /dev/null +++ b/lib/trinity/mcp/auth/jwks.ex @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.JWKS do + @moduledoc """ + An issuer's signing keys (slice 062): fetched from the `jwks_uri` its metadata names, kept in + a cache keyed by issuer for the configured time, refreshed once when a token names a `kid` + the cache does not hold (a rotation), and never more often than once a minute for a `kid` + that is not there after a refresh (a flood of unknown kids is not a reason to hammer the + issuer). The keys are `JOSE.JWK` structs; a key without a `kid` is matched by `alg` alone. + """ + + alias Trinity.MCP.Auth.Discovery + + @refresh_floor_ms 60_000 + + @doc "The key for a `kid` (or the only key, when the token names none) of an issuer." + @spec key(String.t(), String.t() | nil, keyword()) :: {:ok, JOSE.JWK.t()} | {:error, term()} + def key(issuer, kid, opts \\ []) do + with {:ok, keys} <- keys(issuer, opts) do + case find(keys, kid) do + {:ok, key} -> + {:ok, key} + + :error -> + with {:ok, keys} <- refresh(issuer, opts) do + case find(keys, kid) do + {:ok, key} -> {:ok, key} + :error -> {:error, {:unknown_kid, kid}} + end + end + end + end + end + + @doc "The issuer's keys, from the cache or fetched." + @spec keys(String.t(), keyword()) :: {:ok, [JOSE.JWK.t()]} | {:error, term()} + def keys(issuer, opts \\ []) do + ttl_ms = Keyword.get(opts, :ttl_s, 3_600) * 1_000 + + case :persistent_term.get({__MODULE__, issuer}, nil) do + {keys, at, _} when is_list(keys) -> + if System.monotonic_time(:millisecond) - at < ttl_ms, + do: {:ok, keys}, + else: refresh(issuer, opts) + + nil -> + refresh(issuer, opts) + end + end + + @doc "Fetches the keys again (at most once a minute after a miss); the cache is replaced." + @spec refresh(String.t(), keyword()) :: {:ok, [JOSE.JWK.t()]} | {:error, term()} + def refresh(issuer, opts \\ []) do + now = System.monotonic_time(:millisecond) + force? = Keyword.get(opts, :force, false) + + case :persistent_term.get({__MODULE__, issuer}, nil) do + {keys, _at, refreshed} + when is_integer(refreshed) and now - refreshed < @refresh_floor_ms and not force? -> + {:ok, keys} + + _ -> + with {:ok, meta} <- Discovery.authorization_server(issuer, opts), + {:ok, uri} <- Map.fetch(meta, "jwks_uri") |> ok_or(:no_jwks_uri), + {:ok, %{"keys" => raw}} when is_list(raw) <- Discovery.fetch_json(uri, opts) do + keys = for k <- raw, is_map(k), do: JOSE.JWK.from_map(k) + :persistent_term.put({__MODULE__, issuer}, {keys, now, now}) + {:ok, keys} + else + {:error, _} = error -> error + _ -> {:error, :no_keys_in_document} + end + end + end + + @doc "Forgets an issuer's cache (a test's reset)." + @spec forget(String.t()) :: :ok + def forget(issuer) do + :persistent_term.erase({__MODULE__, issuer}) + :ok + end + + defp ok_or({:ok, v}, _), do: {:ok, v} + defp ok_or(:error, reason), do: {:error, reason} + + defp find(keys, nil) do + case keys do + [key] -> {:ok, key} + _ -> :error + end + end + + defp find(keys, kid) do + case Enum.find(keys, &(kid_of(&1) == kid)) do + nil -> :error + key -> {:ok, key} + end + end + + @doc "A key's `kid`." + @spec kid_of(JOSE.JWK.t()) :: String.t() | nil + def kid_of(%JOSE.JWK{fields: fields}), do: Map.get(fields, "kid") +end diff --git a/lib/trinity/mcp/auth/local.ex b/lib/trinity/mcp/auth/local.ex new file mode 100644 index 0000000..d3c2f27 --- /dev/null +++ b/lib/trinity/mcp/auth/local.ex @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.Local do + @moduledoc """ + The default profile (slice 061's static bearer, moved behind the behaviour at 062): the + token from the configured environment variable, else the token file the host names + (`Config.token_path`, generated once, mode 0600), compared in constant time before the body + is read. The principal it admits is the local owner: no issuer, subject `owner`, every scope. + Not an authorization server, and no JWT anywhere. + """ + @behaviour Trinity.MCP.Auth + + alias Trinity.MCP.Auth + alias Trinity.MCP.Auth.{Config, Principal, Scopes} + + @impl true + def authorize(conn, %Config{} = config) do + expected = token(config) + + with {:ok, given} <- Auth.bearer(conn) do + if byte_size(given) == byte_size(expected) and :crypto.hash_equals(given, expected), + do: {:ok, %Principal{sub: "owner", scope: Scopes.known(), profile: :local}}, + else: {:error, :wrong_bearer} + end + end + + @impl true + def resource_metadata(_config), do: nil + + @doc "The token in force: the environment variable, else the file's." + @spec token(Config.t()) :: String.t() + def token(%Config{token_env: env, token_path: path}) do + case env && System.get_env(env) do + t when is_binary(t) and t != "" -> t + _ -> File.read!(ensure_token!(path)) |> String.trim() + end + end + + @doc "Generates the token file when absent (mode 0600); returns its path." + @spec ensure_token!(Path.t()) :: Path.t() + def ensure_token!(path) when is_binary(path) do + unless File.exists?(path) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) <> "\n") + File.chmod!(path, 0o600) + end + + path + end +end diff --git a/lib/trinity/mcp/auth/principal.ex b/lib/trinity/mcp/auth/principal.ex new file mode 100644 index 0000000..3070753 --- /dev/null +++ b/lib/trinity/mcp/auth/principal.ex @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.Principal do + @moduledoc """ + Who a validated request is from (slice 062): the issuer, the subject, the scopes, the client, + the token's id when it has one, and the profile that admitted it. What the server puts in + the context and the receipts carry; never the token. + """ + + @type t :: %__MODULE__{ + iss: String.t() | nil, + sub: String.t() | nil, + scope: [String.t()], + client_id: String.t() | nil, + jti: String.t() | nil, + profile: :local | :production | :personal, + exp: integer() | nil + } + + defstruct iss: nil, sub: nil, scope: [], client_id: nil, jti: nil, profile: :local, exp: nil + + @doc "The principal as a receipt's subject carries it (strings only)." + @spec to_receipt(t()) :: map() + def to_receipt(%__MODULE__{} = p) do + %{ + "iss" => p.iss, + "sub" => p.sub, + "scope" => Enum.join(p.scope, " "), + "client_id" => p.client_id, + "profile" => Atom.to_string(p.profile) + } + end + + @doc "A principal from validated claims." + @spec from_claims(map(), :production | :personal) :: t() + def from_claims(claims, profile) when is_map(claims) do + %__MODULE__{ + iss: claims["iss"], + sub: claims["sub"], + scope: scopes(claims["scope"]), + client_id: claims["client_id"] || claims["azp"], + jti: claims["jti"], + profile: profile, + exp: claims["exp"] + } + end + + @doc "Scopes from a claim: a space-separated string or a list." + @spec scopes(term()) :: [String.t()] + def scopes(nil), do: [] + def scopes(s) when is_binary(s), do: String.split(s, " ", trim: true) + def scopes(l) when is_list(l), do: Enum.filter(l, &is_binary/1) + def scopes(_), do: [] +end diff --git a/lib/trinity/mcp/auth/scopes.ex b/lib/trinity/mcp/auth/scopes.ex new file mode 100644 index 0000000..237529d --- /dev/null +++ b/lib/trinity/mcp/auth/scopes.ex @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.Scopes do + @moduledoc """ + What a scope lets a token do (slice 062, AC4), checked before the permission gate: a tool + whose effect is `:none` needs `trinity:tools:read` (`trinity:recall` is its alias, the SLICE's + word); an `:artifact` tool needs `trinity:tools:artifact`. A `:catalog` tool is never + exportable (061), so no scope names it. The gate still decides afterwards: a scope is what + the token may ask for, not what happens. + """ + + @read ~w(trinity:tools:read trinity:recall) + @artifact ~w(trinity:tools:artifact) + + @doc "The scopes this server understands." + @spec known() :: [String.t()] + def known, do: @read ++ @artifact + + @doc "True when the scopes cover a call to a tool of that effect." + @spec covers?([String.t()], :none | :artifact | :catalog) :: boolean() + def covers?(scopes, :none), + do: Enum.any?(scopes, &(&1 in @read)) or Enum.any?(scopes, &(&1 in @artifact)) + + def covers?(scopes, :artifact), do: Enum.any?(scopes, &(&1 in @artifact)) + def covers?(_scopes, :catalog), do: false + + @doc "The scope a tool of that effect needs, for a refusal's message." + @spec required(:none | :artifact | :catalog) :: String.t() + def required(:none), do: "trinity:tools:read" + def required(:artifact), do: "trinity:tools:artifact" + def required(:catalog), do: "(none: not exportable)" +end diff --git a/lib/trinity/mcp/auth/token.ex b/lib/trinity/mcp/auth/token.ex new file mode 100644 index 0000000..9eef3d3 --- /dev/null +++ b/lib/trinity/mcp/auth/token.ex @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.Token do + @moduledoc """ + A bearer as a JWT, validated (slice 062). The checks, each one a named refusal and a test: + the compact form parses; the header's `alg` is in the allow list (`none` and the shared-secret + family never are, by `Config`); the header names a `kid` the issuer's JWKS holds (or the JWKS + holds one key); the signature verifies under that key with that algorithm and no other + (`JOSE.JWS.verify_strict/3`); `iss` equals the configured issuer; `aud` holds the resource + identifier (a string or a list; audience-bound, or refused); `exp` is in the future and `nbf`, + when present, is not; and in the production profile a token carrying `"profile": "personal"` + is refused whatever key signed it, since the personal profile's issuer never mints production + authority. Sixty seconds of leeway on the clock claims. + """ + + alias Trinity.MCP.Auth.{Config, JWKS, Principal} + + @leeway_s 60 + + @doc "Validates a JWT against the configuration's issuer and resource; the principal, or why not." + @spec verify(String.t(), Config.t(), keyword()) :: {:ok, Principal.t()} | {:error, term()} + def verify(token, %Config{} = config, opts \\ []) when is_binary(token) do + with {:ok, header} <- header(token), + alg = header["alg"], + :ok <- allowed_alg(alg, config), + {:ok, key} <- + JWKS.key(config.issuer, header["kid"], Keyword.merge(opts, ttl_s: config.jwks_ttl_s)), + {:ok, claims} <- verify_signature(token, key, alg), + :ok <- check(claims, config, Keyword.get(opts, :now, System.os_time(:second))) do + {:ok, Principal.from_claims(claims, config.profile)} + end + end + + @doc "The claims a JWT carries, checked against the configuration but not signed by the issuer's JWKS: the personal profile's own tokens, with the key the caller names." + @spec verify_with_key(String.t(), JOSE.JWK.t(), Config.t(), keyword()) :: + {:ok, Principal.t()} | {:error, term()} + def verify_with_key(token, %JOSE.JWK{} = key, %Config{} = config, opts \\ []) do + with {:ok, header} <- header(token), + :ok <- allowed_alg(header["alg"], config), + {:ok, claims} <- verify_signature(token, key, header["alg"]), + :ok <- check(claims, config, Keyword.get(opts, :now, System.os_time(:second))) do + {:ok, Principal.from_claims(claims, config.profile)} + end + end + + @doc "The unverified header of a compact JWT (to pick the key); nothing of it is trusted." + @spec header(String.t()) :: {:ok, map()} | {:error, :malformed} + def header(token) do + with [h, _p, _s] <- String.split(token, "."), + {:ok, json} <- Base.url_decode64(h, padding: false), + {:ok, %{"alg" => alg} = header} when is_binary(alg) <- Jason.decode(json) do + {:ok, header} + else + _ -> {:error, :malformed} + end + end + + defp allowed_alg(alg, %Config{allowed_algs: algs}) do + if is_binary(alg) and alg in algs, do: :ok, else: {:error, {:alg_not_allowed, alg}} + end + + defp verify_signature(token, key, alg) do + case JOSE.JWS.verify_strict(key, [alg], token) do + {true, payload, _jws} -> + case Jason.decode(payload) do + {:ok, %{} = claims} -> {:ok, claims} + _ -> {:error, :malformed} + end + + {false, _, _} -> + {:error, :bad_signature} + + _ -> + {:error, :bad_signature} + end + rescue + _ -> {:error, :bad_signature} + end + + @doc "The claim checks, named." + @spec check(map(), Config.t(), integer()) :: :ok | {:error, term()} + def check(claims, %Config{} = config, now) do + cond do + claims["iss"] != config.issuer -> + {:error, {:wrong_issuer, claims["iss"]}} + + not audience?(claims["aud"], config.audience) -> + {:error, {:wrong_audience, claims["aud"]}} + + not is_integer(claims["exp"]) -> + {:error, :no_expiry} + + claims["exp"] + @leeway_s <= now -> + {:error, :expired} + + is_integer(claims["nbf"]) and claims["nbf"] - @leeway_s > now -> + {:error, :not_yet_valid} + + config.profile == :production and claims["profile"] == "personal" -> + {:error, :personal_token_in_production} + + true -> + :ok + end + end + + defp audience?(aud, expected) when is_binary(aud), do: aud == expected + defp audience?(aud, expected) when is_list(aud), do: expected in aud + defp audience?(_, _), do: false +end diff --git a/lib/trinity/mcp/auth_host.ex b/lib/trinity/mcp/auth_host.ex new file mode 100644 index 0000000..6766bd1 --- /dev/null +++ b/lib/trinity/mcp/auth_host.ex @@ -0,0 +1,232 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.AuthHost do + @moduledoc """ + The tree's side of the authorization boundary (slice 062): builds `Trinity.MCP.Auth.Config` + from `config :trinity, :mcp_auth` and the paths and facts only the tree knows (the data + directory, the keys directory, whether the local authority is in force), starts the personal + profile's authorization server when that profile is chosen (and refuses to boot it under an + external authority adapter, by raising with the reason), authorizes a request and receipts + every refusal on the MCP session's scope, and runs the client role's flow for the `/mcp` page. + The boundary itself reads no path, writes no receipt and touches no session; this module does. + """ + + require Logger + + alias Trinity.MCP.Auth + alias Trinity.MCP.Auth.{Config, Principal} + alias Trinity.Receipts + + @key {__MODULE__, :config} + + @doc "The configuration in force, built once per VM (reset with `reload/0`)." + @spec config() :: Config.t() + def config do + case :persistent_term.get(@key, nil) do + %Config{} = c -> c + nil -> reload() + end + end + + @doc "Rebuilds the configuration from the application environment." + @spec reload() :: Config.t() + def reload do + env = Application.get_env(:trinity, :mcp_auth, []) + data_dir = Trinity.Paths.ensure_data_dir() + profile = Keyword.get(env, :profile, :local) + + opts = + env + |> Keyword.put(:profile, profile) + |> Keyword.put_new(:token_path, Path.join(data_dir, "mcp-server-token")) + |> Keyword.put_new(:key_dir, Trinity.Receipts.KeyCustody.keys_dir()) + |> Keyword.put_new(:store_dir, Path.join([data_dir, "secrets", "oauth"])) + |> Keyword.put(:local_authority?, Trinity.Authority.impl() == Trinity.Authority.Local) + + config = Config.new!(opts) + :persistent_term.put(@key, config) + config + end + + @doc "Starts what the profile needs at boot: the personal profile's authorization server." + @spec boot() :: :ok + def boot do + case config() do + %Config{profile: :personal} = c -> + case Auth.Embedded.start_link(c) do + {:ok, _pid} -> + Logger.info("mcp auth: personal profile, issuer #{Auth.Embedded.issuer(c)}") + :ok + + {:error, {:already_started, _}} -> + :ok + + {:error, :external_authority_in_force} -> + raise "mcp auth: the personal profile has no embedded issuer under an external authority adapter (#{Trinity.Authority.selected_name()}); choose the production profile" + end + + %Config{profile: profile} -> + Logger.info("mcp auth: #{profile} profile") + :ok + end + end + + @doc "Authorizes a request under the profile; a refusal is receipted with what could be read of the caller." + @spec authorize(Plug.Conn.t()) :: {:ok, Principal.t()} | {:error, term()} + def authorize(conn) do + config = config() + + case Auth.authorize(conn, config) do + {:ok, principal} -> + {:ok, principal} + + {:error, reason} -> + refusal_receipt(conn, config, reason) + {:error, reason} + end + end + + @doc "The `WWW-Authenticate` value a refusal answers with, per profile." + @spec challenge(Config.t()) :: String.t() + def challenge(%Config{profile: :local}), do: "Bearer" + + def challenge(%Config{resource: resource}), + do: ~s(Bearer resource_metadata="#{prm_url(resource)}") + + @doc "The Protected Resource Metadata URL for a resource identifier (RFC 9728, path-aware)." + @spec prm_url(String.t()) :: String.t() + def prm_url(resource) do + uri = URI.parse(resource) + path = String.trim_trailing(uri.path || "", "/") + origin = %{uri | path: nil, query: nil, fragment: nil} |> URI.to_string() + origin <> "/.well-known/oauth-protected-resource" <> path + end + + @doc "The resource metadata the profile publishes, or nil (the local profile publishes none)." + @spec resource_metadata() :: map() | nil + def resource_metadata do + c = config() + Auth.impl(c).resource_metadata(c) + end + + # What a refusal says about who was refused: the unverified `iss` and `sub` of a JWT-shaped + # bearer, flagged as unverified (they are the caller's claim, not a fact), the reason, and the + # peer address. The bearer itself is never in the receipt. + defp refusal_receipt(conn, config, reason) do + claimed = + with {:ok, token} <- Auth.bearer(conn), + {:ok, claims} <- unverified_claims(token) do + %{"claimed_iss" => claims["iss"], "claimed_sub" => claims["sub"], "unverified" => true} + else + _ -> %{} + end + + scope = Receipts.session_scope(Trinity.MCP.Server.Session.id()) + + Receipts.append(scope, %{ + kind: "decision", + subject: + Map.merge(claimed, %{ + "phase" => "auth", + "profile" => Atom.to_string(config.profile), + "peer" => peer(conn), + "origin" => "mcp" + }), + decision: %{"outcome" => "deny", "basis" => "auth", "reason" => describe(reason)}, + subject_ref: "mcp-auth:" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false), + meta: %{} + }) + rescue + e -> Logger.warning("mcp auth: refusal not receipted: #{Exception.message(e)}") + end + + defp unverified_claims(token) do + with [_h, p, _s] <- String.split(token, "."), + {:ok, json} <- Base.url_decode64(p, padding: false), + {:ok, %{} = claims} <- Jason.decode(json) do + {:ok, claims} + else + _ -> :error + end + end + + defp peer(%Plug.Conn{remote_ip: ip}) when is_tuple(ip), do: ip |> :inet.ntoa() |> to_string() + defp peer(_), do: nil + + @doc "A refusal's reason as a receipt reads it: a word or two, never a token." + @spec describe(term()) :: String.t() + def describe(reason) when is_atom(reason), do: Atom.to_string(reason) + + def describe({tag, detail}) when is_atom(tag) and (is_binary(detail) or is_atom(detail)), + do: "#{tag}: #{detail}" + + def describe({tag, _}) when is_atom(tag), do: Atom.to_string(tag) + def describe(other), do: inspect(other) + + ## The client role, for the page + + @doc "Begins the client flow for a challenge URL: the URL the owner opens. `redirect_uri:` is the host's callback (the web layer knows its own URL)." + @spec client_begin(String.t(), keyword()) :: {:ok, String.t()} | {:error, term()} + def client_begin(prm_url, opts) do + with {:ok, discovered} <- Auth.Client.discover(prm_url, opts), + {:ok, %{url: url}} <- Auth.Client.begin(discovered, config(), opts) do + {:ok, url} + end + end + + @doc "Finishes the client flow from the callback's params." + @spec client_finish(map(), keyword()) :: {:ok, map()} | {:error, term()} + def client_finish(params, opts \\ []), do: Auth.Client.finish(params, config(), opts) + + @doc "A stored bearer for a server URL (the resource), when the client role obtained one." + @spec client_bearer(String.t()) :: String.t() | nil + def client_bearer(resource), do: Auth.Client.bearer(config(), resource) + + ## The endpoints the web layer serves (the personal profile's authorization server) + + @doc "The RFC 8414 metadata, when the profile has an authorization server of its own." + @spec as_metadata() :: map() | nil + def as_metadata do + case config() do + %Config{profile: :personal} = c -> Auth.Embedded.metadata(c) + _ -> nil + end + end + + @doc "The JWKS, when the profile has keys of its own." + @spec jwks() :: map() | nil + def jwks do + case config() do + %Config{profile: :personal} = c -> Auth.Embedded.jwks(c) + _ -> nil + end + end + + @doc "True when the personal profile's authorization server is serving." + @spec embedded?() :: boolean() + def embedded?, do: config().profile == :personal and is_pid(Process.whereis(Auth.Embedded)) + + @doc "An authorization request begun (the consent page's data), or why not." + @spec authorize_begin(map()) :: {:ok, map()} | {:error, term()} | {:redirect_error, String.t()} + def authorize_begin(params), do: Auth.Embedded.begin(params) + + @doc "The owner's decision on a pending request: the redirect for the client." + @spec authorize_decide(String.t(), :approve | :deny) :: {:ok, String.t()} | {:error, term()} + def authorize_decide(request_id, decision), + do: Auth.Embedded.decide(request_id, decision, "owner") + + @doc "The token endpoint." + @spec token(map()) :: {:ok, map()} | {:error, term()} + def token(params), do: Auth.Embedded.token(params) + + @doc "The registration endpoint (DCR), when enabled." + @spec register(map()) :: {:ok, map()} | {:error, term()} + def register(metadata), do: Auth.Embedded.register(metadata) + + @doc "Rotates the personal profile's signing key." + @spec rotate_key!() :: String.t() + def rotate_key! do + %Config{key_dir: dir} = config() + Auth.Embedded.Keys.rotate!(dir).kid + end +end diff --git a/lib/trinity/mcp/boot.ex b/lib/trinity/mcp/boot.ex index f1e6969..8a39530 100644 --- a/lib/trinity/mcp/boot.ex +++ b/lib/trinity/mcp/boot.ex @@ -38,8 +38,14 @@ defmodule Trinity.MCP.Boot do defp server_side do Trinity.MCP.Server.Envelope.ensure_key!() - if System.get_env(Trinity.MCP.Server.Auth.Local.variable()) in [nil, ""], - do: Trinity.MCP.Server.Auth.Local.ensure_token!() + # Slice 062: the profile's configuration, and the personal profile's authorization server + # when chosen (refused under an external authority adapter: the raise says why); the local + # profile's token file when no variable sets the bearer. + config = Trinity.MCP.AuthHost.reload() + Trinity.MCP.AuthHost.boot() + + if config.profile == :local and System.get_env(config.token_env) in [nil, ""], + do: Trinity.MCP.Auth.Local.ensure_token!(config.token_path) {entries, refusals} = Trinity.MCP.Server.Exports.resolve() diff --git a/lib/trinity/mcp/client.ex b/lib/trinity/mcp/client.ex index d955b4c..bfc013e 100644 --- a/lib/trinity/mcp/client.ex +++ b/lib/trinity/mcp/client.ex @@ -152,6 +152,7 @@ defmodule Trinity.MCP.Client do attempts: 0, backoff_ms: Keyword.get(opts, :backoff_ms, @default_backoff_ms), ttl_ms: nil, + auth_challenge: nil, relist_timer: nil, reconnect_timer: nil } @@ -178,10 +179,29 @@ defmodule Trinity.MCP.Client do {:error, reason, state} -> Logger.warning("mcp #{state.config.name}: connect failed: #{inspect(reason)}") - {:noreply, retry_later(%{state | status: :down, last_error: inspect(reason)})} + + state = %{ + state + | status: :down, + last_error: inspect(reason), + auth_challenge: challenge_of(reason) + } + + {:noreply, retry_later(state)} + end + end + + # Slice 062: a 401 whose challenge names a resource metadata URL is what the client role + # starts from; the page shows "authorize" when this is set. + defp challenge_of({:discover_failed, {:unauthorized, header}}) do + case Trinity.MCP.Auth.Client.challenge(header) do + {:ok, url} -> url + :error -> nil end end + defp challenge_of(_), do: nil + @impl true def handle_call(:info, _from, state) do {:reply, @@ -201,7 +221,8 @@ defmodule Trinity.MCP.Client do registered: state.registered, last_error: state.last_error, attempts: state.attempts, - transport_os_pid: os_pid(state) + transport_os_pid: os_pid(state), + auth_challenge: state.auth_challenge }}, state} end diff --git a/lib/trinity/mcp/client/auth.ex b/lib/trinity/mcp/client/auth.ex index 01a5b4f..6322135 100644 --- a/lib/trinity/mcp/client/auth.ex +++ b/lib/trinity/mcp/client/auth.ex @@ -2,22 +2,29 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.MCP.Client.Auth do @moduledoc """ - Where the driver gets a bearer token for a protected server (slice 060). This is slice - 062's seam: at this slice a token is a static value in the environment variable - `TRINITY_MCP__TOKEN` (the server's name upcased, `-` as `_`), read at connect and - never stored; 062's client role replaces this module's body with the token it obtained - and answers the `401` the transport reports. Nothing here performs an OAuth flow. + Where the driver gets a bearer token for a protected server (slice 060; slice 062 fills the + seam): the token the client role obtained for the server's URL (`Trinity.MCP.Auth.Client`, + through the host), else a static value in the environment variable `TRINITY_MCP__TOKEN` + (the server's name upcased, `-` as `_`), read at connect and never stored. Nothing here + performs an OAuth flow: a `401` with a challenge is recorded on the client for the `/mcp` + page, where the owner starts the flow. """ alias Trinity.MCP.ServerConfig - @doc "The bearer for a server, or nil." + @doc "The bearer for a server, or nil: the token the client role obtained for its URL (slice 062), else the environment variable's." @spec bearer(ServerConfig.t()) :: String.t() | nil - def bearer(%ServerConfig{name: name}) do - case System.get_env(variable(name)) do - nil -> nil - "" -> nil - token -> token + def bearer(%ServerConfig{name: name, url: url}) do + case url && Trinity.MCP.AuthHost.client_bearer(url) do + token when is_binary(token) -> + token + + _ -> + case System.get_env(variable(name)) do + nil -> nil + "" -> nil + token -> token + end end end diff --git a/lib/trinity/mcp/server.ex b/lib/trinity/mcp/server.ex index 9ae3467..7128731 100644 --- a/lib/trinity/mcp/server.ex +++ b/lib/trinity/mcp/server.ex @@ -103,7 +103,8 @@ defmodule Trinity.MCP.Server do entry -> with :ok <- validate(args, entry), - {:ok, ctx, pending} <- context(name, args, params) do + {:ok, ctx, pending} <- context(name, args, params), + :ok <- scope_allows(ctx, entry) do run_or_hold(id, era, state, entry, args, ctx, pending) else {:error, {:invalid_arguments, reason}} -> @@ -111,10 +112,44 @@ defmodule Trinity.MCP.Server do {:error, {:state, reason}} -> error(id, -32_602, "requestState #{reason}") + + {:error, {:insufficient_scope, needed}} -> + error(id, -32_001, "insufficient scope: this tool needs #{needed}") end end end + # Slice 062: the token's scopes must cover the tool's effect before the gate is asked; a + # refusal here is a decision receipt of outcome deny, basis scope, on the session's scope. + defp scope_allows(%Context{principal: nil}, _entry), do: :ok + + defp scope_allows(%Context{principal: principal} = ctx, entry) do + scopes = Trinity.MCP.Auth.Principal.scopes(principal["scope"]) + + if Trinity.MCP.Auth.Scopes.covers?(scopes, entry.effect) do + :ok + else + needed = Trinity.MCP.Auth.Scopes.required(entry.effect) + + Trinity.Receipts.append(Trinity.Receipts.session_scope(ctx.session_id), %{ + kind: "decision", + subject: %{ + "session_id" => ctx.session_id, + "call_id" => ctx.call_id, + "tool" => entry.name, + "effect" => Atom.to_string(entry.effect), + "origin" => "mcp", + "principal" => principal + }, + decision: %{"outcome" => "deny", "basis" => "scope", "reason" => "needs #{needed}"}, + subject_ref: "decision:#{ctx.session_id}:#{ctx.call_id}", + meta: %{} + }) + + {:error, {:insufficient_scope, needed}} + end + end + defp run_or_hold(id, era, state, entry, args, ctx, nil), do: run(id, era, state, entry, args, ctx) @@ -159,6 +194,8 @@ defmodule Trinity.MCP.Server do # The persona is the session's, as the Session process hands its tools (the memory tools # take the persona from the context and nowhere else). + # The principal is what the plug left for this process (slice 062); over stdio, where no + # bearer exists, it is nil and the local owner is the caller. defp ctx(session, name, call_id, trace) do %Context{ session_id: session.id, @@ -167,10 +204,14 @@ defmodule Trinity.MCP.Server do call_id: call_id, tool: name, origin: "mcp", - trace: trace + trace: trace, + principal: principal_map(Trinity.MCP.Server.Plug.principal()) } end + defp principal_map(nil), do: nil + defp principal_map(p), do: Trinity.MCP.Auth.Principal.to_receipt(p) + defp open(state) do case Envelope.open(state) do {:ok, payload} -> {:ok, payload} diff --git a/lib/trinity/mcp/server/auth/local.ex b/lib/trinity/mcp/server/auth/local.ex deleted file mode 100644 index 3a504d6..0000000 --- a/lib/trinity/mcp/server/auth/local.ex +++ /dev/null @@ -1,72 +0,0 @@ -# SPDX-FileCopyrightText: Sudo Apt Holdings LLC -# SPDX-License-Identifier: Apache-2.0 -defmodule Trinity.MCP.Server.Auth.Local do - @moduledoc """ - The default `:authorize` hook of Trinity's MCP server (slice 061): a static bearer, compared - in constant time, before the body is read. The token is `TRINITY_MCP_SERVER_TOKEN` when - set; otherwise one generated at boot into `/mcp-server-token` (mode 0600), which - the owner copies into a client's configuration. The full OAuth 2.1 resource-server profile - is slice 062's; this module is what stands until then, beside the loopback bind the - headless profile defaults to. - """ - import Plug.Conn, only: [get_req_header: 2] - - Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) - - @env "TRINITY_MCP_SERVER_TOKEN" - @token_file "mcp-server-token" - - @doc "The hook: `:ok` with the right bearer, `{:error, reason}` otherwise (the reason goes to the log, never to the caller)." - @spec authorize(Plug.Conn.t()) :: :ok | {:error, term()} - def authorize(conn) do - expected = token() - - case get_req_header(conn, "authorization") do - ["Bearer " <> given] when byte_size(given) > 0 -> - if byte_size(given) == byte_size(expected) and :crypto.hash_equals(given, expected), - do: :ok, - else: {:error, :wrong_bearer} - - [] -> - {:error, :no_bearer} - - _ -> - {:error, :malformed_authorization} - end - end - - @doc "The token in force: the environment variable, else the generated file's." - # sobelow_skip reason: Traversal.FileModule: the path is the data directory's plus a constant - # name (token_path/0), never a request's. - @sobelow_skip ["Traversal.FileModule"] - @spec token() :: String.t() - def token do - case System.get_env(@env) do - t when is_binary(t) and t != "" -> t - _ -> File.read!(ensure_token!()) |> String.trim() - end - end - - @doc "The token file's path." - @spec token_path() :: Path.t() - def token_path, do: Path.join(Trinity.Paths.ensure_data_dir(), @token_file) - - @doc "Generates the token file when absent (mode 0600); returns its path." - # sobelow_skip reason: Traversal.FileModule: the path is the data directory's, never a request's. - @sobelow_skip ["Traversal.FileModule"] - @spec ensure_token!() :: Path.t() - def ensure_token! do - path = token_path() - - unless File.exists?(path) do - File.write!(path, Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) <> "\n") - File.chmod!(path, 0o600) - end - - path - end - - @doc "The environment variable's name." - @spec variable() :: String.t() - def variable, do: @env -end diff --git a/lib/trinity/mcp/server/plug.ex b/lib/trinity/mcp/server/plug.ex index 83ae80b..e876872 100644 --- a/lib/trinity/mcp/server/plug.ex +++ b/lib/trinity/mcp/server/plug.ex @@ -6,7 +6,7 @@ defmodule Trinity.MCP.Server.Plug do mounted in the endpoint ahead of `Plug.Parsers` (the transport reads the raw body itself) and answering `POST /mcp` alone; every other request passes through to the router (the `/mcp` page keeps GET). The options: the wrapper as - `:server`, `Trinity.MCP.Server.Catalog`, `Trinity.MCP.Server.Auth.Local` as `:authorize`, the + `:server`, `Trinity.MCP.Server.Catalog`, the profile's authorization (`Trinity.MCP.AuthHost`, slice 062) before the transport and its own hook reading what it left, the loopback origins (a request with no `Origin`, which every CLI client sends, is served; a browser page on another origin is refused), and `tools_ttl_ms` and `tools_cache_scope` from `config :trinity, :mcp_server`. This module exists so the router, in the web boundary, never @@ -14,7 +14,14 @@ defmodule Trinity.MCP.Server.Plug do """ @behaviour Plug - alias Trinity.MCP.Server.{Auth, Catalog} + alias Trinity.MCP.AuthHost + alias Trinity.MCP.Server.Catalog + + # Slice 062: the request's principal, set by this plug for the wrapper to read. The + # transport dispatches in the request's own process, so the process dictionary is the + # channel between the plug (which sees the connection) and the wrapper (which sees the + # message); it is set after authorization and cleared with the process. + @principal_key :trinity_mcp_principal @impl true def init(_opts) do @@ -24,7 +31,11 @@ defmodule Trinity.MCP.Server.Plug do server: Trinity.MCP.Server, catalog: Catalog, dispatch: nil, - authorize: &Auth.Local.authorize/1, + # Slice 062: the profile's authorization runs in `call/2` before the transport, which + # then finds the principal it left; the transport's own hook is that second look. + authorize: fn _conn -> + if Process.get(@principal_key), do: :ok, else: {:error, :unauthorized} + end, allowed_origins: Keyword.get(config, :allowed_origins, loopback_origins()), server_name: Keyword.get(config, :server_name, "trinity"), tools_ttl_ms: Keyword.get(config, :tools_ttl_ms, 60_000), @@ -33,11 +44,32 @@ defmodule Trinity.MCP.Server.Plug do end @impl true - def call(%Plug.Conn{method: "POST", path_info: ["mcp"]} = conn, opts), - do: conn |> BeamMCP.Transport.HTTP.call(opts) |> Plug.Conn.halt() + def call(%Plug.Conn{method: "POST", path_info: ["mcp"]} = conn, opts) do + case AuthHost.authorize(conn) do + {:ok, principal} -> + Process.put(@principal_key, principal) + conn |> BeamMCP.Transport.HTTP.call(opts) |> Plug.Conn.halt() + + {:error, _reason} -> + # The reason went to the receipt and the log; the caller gets the challenge and + # nothing decoded (the body is unread). + conn + |> Plug.Conn.put_resp_header("www-authenticate", AuthHost.challenge(AuthHost.config())) + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 401, + ~s({"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}}) + ) + |> Plug.Conn.halt() + end + end def call(conn, _opts), do: conn + @doc "The principal the plug left for the wrapper, in this process." + @spec principal() :: Trinity.MCP.Auth.Principal.t() | nil + def principal, do: Process.get(@principal_key) + @doc "The origins a browser on this machine may present." @spec loopback_origins() :: [String.t()] def loopback_origins do diff --git a/lib/trinity/tools/context.ex b/lib/trinity/tools/context.ex index 88f2a56..f6fc9d4 100644 --- a/lib/trinity/tools/context.ex +++ b/lib/trinity/tools/context.ex @@ -19,7 +19,8 @@ defmodule Trinity.Tools.Context do call_id: String.t() | nil, tool: String.t() | nil, origin: String.t() | nil, - trace: map() | nil + trace: map() | nil, + principal: map() | nil } defstruct session_id: nil, @@ -29,5 +30,6 @@ defmodule Trinity.Tools.Context do call_id: nil, tool: nil, origin: nil, - trace: nil + trace: nil, + principal: nil end diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index 63e24c6..2e240d6 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -153,6 +153,13 @@ defmodule Trinity.Versions do note: "Uses `Oban.Engines.Lite` on SQLite and `Oban.Engines.Basic` on Postgres (the adapter is compile time, ADR-0002, so the engine is too). ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050 (2.24.1 measured on hex.pm 2026-09-22, Apache-2.0)." }, + %{ + name: "jose", + pin: "~> 1.11", + lock: "jose", + note: + "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." + }, %{ name: "oban_web", pin: "~> 2.13", diff --git a/lib/trinity_web/controllers/oauth_controller.ex b/lib/trinity_web/controllers/oauth_controller.ex new file mode 100644 index 0000000..2809327 --- /dev/null +++ b/lib/trinity_web/controllers/oauth_controller.ex @@ -0,0 +1,130 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.OAuthController do + @moduledoc """ + The authorization endpoints (slice 062), every one a thin call into `Trinity.MCP.AuthHost`: + the RFC 9728 Protected Resource Metadata and, in the personal profile, the RFC 8414 metadata, + the JWKS, the authorization endpoint with the owner's consent page, the token endpoint and the + registration endpoint; and the client role's callback, where the owner lands after authorizing + Trinity at another server's authorization server. + """ + use TrinityWeb, :controller + + alias Trinity.MCP.AuthHost + + ## Metadata + + def protected_resource(conn, _params) do + case AuthHost.resource_metadata() do + nil -> send_resp(conn, 404, "") + doc -> json(conn, doc) + end + end + + def authorization_server(conn, _params) do + case AuthHost.as_metadata() do + nil -> send_resp(conn, 404, "") + doc -> json(conn, doc) + end + end + + def jwks(conn, _params) do + case AuthHost.jwks() do + nil -> send_resp(conn, 404, "") + doc -> json(conn, doc) + end + end + + ## The personal profile's authorization server + + def authorize(conn, params) do + if AuthHost.embedded?() do + case AuthHost.authorize_begin(params) do + {:ok, pending} -> + render(conn, :consent, pending: pending) + + {:redirect_error, url} -> + redirect(conn, external: url) + + {:error, reason} -> + conn |> put_status(400) |> text("invalid request: #{AuthHost.describe(reason)}") + end + else + send_resp(conn, 404, "") + end + end + + def consent(conn, %{"request_id" => id, "decision" => decision}) + when decision in ["approve", "deny"] do + case AuthHost.authorize_decide(id, String.to_existing_atom(decision)) do + {:ok, url} -> + redirect(conn, external: url) + + {:error, reason} -> + conn |> put_status(400) |> text("no such request: #{AuthHost.describe(reason)}") + end + end + + def token(conn, params) do + if AuthHost.embedded?() do + case AuthHost.token(params) do + {:ok, token} -> conn |> put_resp_header("cache-control", "no-store") |> json(token) + {:error, reason} -> conn |> put_status(400) |> json(%{"error" => oauth_error(reason)}) + end + else + send_resp(conn, 404, "") + end + end + + def register(conn, params) do + if AuthHost.embedded?() do + case AuthHost.register(params) do + {:ok, client} -> conn |> put_status(201) |> json(client) + {:error, :registration_disabled} -> send_resp(conn, 404, "") + {:error, reason} -> conn |> put_status(400) |> json(%{"error" => oauth_error(reason)}) + end + else + send_resp(conn, 404, "") + end + end + + ## The client role's callback + + def callback(conn, params) do + case AuthHost.client_finish(params) do + {:ok, entry} -> + conn + |> put_flash( + :info, + gettext("Authorized at %{issuer}; the token is stored for %{resource}.", + issuer: entry["issuer"], + resource: entry["resource"] + ) + ) + |> redirect(to: ~p"/mcp") + + {:error, reason} -> + conn + |> put_flash( + :error, + gettext("Authorization failed: %{reason}", reason: AuthHost.describe(reason)) + ) + |> redirect(to: ~p"/mcp") + end + end + + # The RFC 6749 error names for what the server refused. + defp oauth_error(reason) + when reason in [ + :invalid_grant, + :invalid_client, + :invalid_redirect_uri, + :unsupported_grant_type, + :invalid_target, + :invalid_scope + ], + do: Atom.to_string(reason) + + defp oauth_error({:invalid_request, _}), do: "invalid_request" + defp oauth_error(_), do: "invalid_request" +end diff --git a/lib/trinity_web/controllers/oauth_html.ex b/lib/trinity_web/controllers/oauth_html.ex new file mode 100644 index 0000000..69d5b32 --- /dev/null +++ b/lib/trinity_web/controllers/oauth_html.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.OAuthHTML do + @moduledoc "The consent page of the personal profile's authorization server (slice 062)." + use TrinityWeb, :html + + embed_templates "oauth_html/*" +end diff --git a/lib/trinity_web/controllers/oauth_html/consent.html.heex b/lib/trinity_web/controllers/oauth_html/consent.html.heex new file mode 100644 index 0000000..b609dd5 --- /dev/null +++ b/lib/trinity_web/controllers/oauth_html/consent.html.heex @@ -0,0 +1,37 @@ + + <:bar> + {gettext("Authorize a client")} + + + diff --git a/lib/trinity_web/router.ex b/lib/trinity_web/router.ex index 7e70441..229c166 100644 --- a/lib/trinity_web/router.ex +++ b/lib/trinity_web/router.ex @@ -18,6 +18,23 @@ defmodule TrinityWeb.Router do plug :accepts, ["json"] end + # Slice 062: the OAuth surfaces a client or another server reads without a browser session + # (metadata, keys, the token and registration endpoints; the body form-encoded or JSON). + pipeline :oauth do + plug :accepts, ["json"] + end + + scope "/", TrinityWeb do + pipe_through :oauth + + get "/.well-known/oauth-protected-resource", OAuthController, :protected_resource + get "/.well-known/oauth-protected-resource/*path", OAuthController, :protected_resource + get "/.well-known/oauth-authorization-server", OAuthController, :authorization_server + get "/.well-known/jwks.json", OAuthController, :jwks + post "/oauth/token", OAuthController, :token + post "/oauth/register", OAuthController, :register + end + # Slice 013: the chat. One local user, so no scope is fetched; the session id is the URL. scope "/", TrinityWeb do pipe_through :browser @@ -46,6 +63,12 @@ defmodule TrinityWeb.Router do live "/s/:id/receipts", ReceiptsLive, :session live "/receipts/boot", ReceiptsLive, :boot end + + # Slice 062: the owner's pages of the authorization flows (a session and CSRF: the consent + # is a form the owner submits; the callback lands in the browser). + get "/oauth/authorize", OAuthController, :authorize + post "/oauth/consent", OAuthController, :consent + get "/oauth/callback", OAuthController, :callback end # Other scopes may use custom stacks. diff --git a/mix.exs b/mix.exs index 48754be..1109814 100644 --- a/mix.exs +++ b/mix.exs @@ -176,6 +176,8 @@ defmodule Trinity.MixProject do # Postgres; Oban Web is the dashboard, Apache-2.0 on hex since its 2.12 line. {:oban, "~> 2.24"}, {:oban_web, "~> 2.13"}, + # Slice 062: JWT validation and issuance (JWK, JWS, JWKS) over OTP's crypto. + {:jose, "~> 1.11"}, # Slice 013 (owner decision, 2026-09-20): the linux package builds mdex's NIF from # source for musl (MDEX_NATIVE_BUILD=1 and TRINITY_NIF_TARGET in config/config.exs), # because neither precompiled artifact loads in Burrito's musl ERTS (NOTES finding 13). diff --git a/mix.lock b/mix.lock index 0915b4d..8e8dd5c 100644 --- a/mix.lock +++ b/mix.lock @@ -40,6 +40,7 @@ "igniter": {:hex, :igniter, "0.8.4", "f79f1bbdc2fb7b9ca030a22d12a585b060cbf5b94b9d3f23b1148578a9e05d11", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "a9b1cbec996ccb100b4f7d8130129b2dd3f18eb4224ac9a0e907e428ca90dbd7"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "jcs": {:hex, :jcs, "0.2.0", "e0524c23b576e8247f9f5f09d1b82cb3f92c7b132932b82d5d656461831c6c99", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f52e86571f56fab695682bf0ab7fd697768acb20de16b30809e9654d1ec1c9dd"}, + "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "jsv": {:hex, :jsv, "0.23.0", "db238039fc2e437bca3e226b09eacd72ebd588d6e014d0d4150330f6ca6762fa", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:idna, "~> 6.0 or ~> 7.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:texture, ">= 1.2.1", [hex: :texture, repo: "hexpm", optional: false]}], "hexpm", "3876f6ada437b3a7ec6214c9b942bd218f25d245b92a7970a034a7cf06dbe925"}, "lazy_html": {:hex, :lazy_html, "0.1.12", "31a55ee622918fce988c94b06232227b42daa64e4eab14ac32081d0f3fd8db6f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "8a0da594776caee58782c6f93b2abaa5bdb809daf8d43351a561f7de9dc2e2a8"}, "llm_db": {:hex, :llm_db, "2026.9.4", "f56fdc8012477a0fa33247841f980c9a71e80408a5b69f63692ea238f46f8636", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}, {:zoi, "~> 0.10", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "196a162cfc8826746ee84eeb395dd3a17268bfff2154ba378799bc1511e722ce"}, From e18e3c9a9437350d453622e09f800054defe2ac6 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 06:33:56 -0400 Subject: [PATCH 03/13] test(s061): a refused bearer is 401 with a challenge since 062 Signed-off-by: Ayla Croft --- test/trinity/mcp/server_test.exs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/trinity/mcp/server_test.exs b/test/trinity/mcp/server_test.exs index 0f93bfc..09d283b 100644 --- a/test/trinity/mcp/server_test.exs +++ b/test/trinity/mcp/server_test.exs @@ -288,10 +288,14 @@ defmodule Trinity.MCP.ServerTest do {"mcp-method", "server/discover"} ] - assert {:ok, %Req.Response{status: 403}} = + # Slice 062: a refusal is 401 with a WWW-Authenticate challenge (RFC 6750), decided by the + # profile before the transport; 061's transport-side refusal answered 403. + assert {:ok, %Req.Response{status: 401} = r} = Req.post(url, headers: headers, body: body, retry: false) - assert {:ok, %Req.Response{status: 403}} = + assert ["Bearer" <> _] = Req.Response.get_header(r, "www-authenticate") + + assert {:ok, %Req.Response{status: 401}} = Req.post(url, headers: [{"authorization", "Bearer nope"} | headers], body: body, From a29dcb4134238b851e2b25c25a099ea405a1ada3 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 06:38:14 -0400 Subject: [PATCH 04/13] test(s062): the fake enterprise AS; AC1, AC4 and AC5 green; the receipts name the principal; no token material Signed-off-by: Ayla Croft --- lib/trinity/mcp/auth/jwks.ex | 32 +- test/support/mcp/fake_as.ex | 283 ++++++++++++++++++ .../trinity/mcp/auth/resource_server_test.exs | 236 +++++++++++++++ test/trinity/mcp/auth/token_test.exs | 114 +++++++ 4 files changed, 648 insertions(+), 17 deletions(-) create mode 100644 test/support/mcp/fake_as.ex create mode 100644 test/trinity/mcp/auth/resource_server_test.exs create mode 100644 test/trinity/mcp/auth/token_test.exs diff --git a/lib/trinity/mcp/auth/jwks.ex b/lib/trinity/mcp/auth/jwks.ex index 8969bfb..dce8ac3 100644 --- a/lib/trinity/mcp/auth/jwks.ex +++ b/lib/trinity/mcp/auth/jwks.ex @@ -48,28 +48,26 @@ defmodule Trinity.MCP.Auth.JWKS do end end - @doc "Fetches the keys again (at most once a minute after a miss); the cache is replaced." + @doc "Fetches the keys again; the cache is replaced (the time of the last miss-driven refresh kept)." @spec refresh(String.t(), keyword()) :: {:ok, [JOSE.JWK.t()]} | {:error, term()} def refresh(issuer, opts \\ []) do now = System.monotonic_time(:millisecond) - force? = Keyword.get(opts, :force, false) - case :persistent_term.get({__MODULE__, issuer}, nil) do - {keys, _at, refreshed} - when is_integer(refreshed) and now - refreshed < @refresh_floor_ms and not force? -> - {:ok, keys} + missed = + case :persistent_term.get({__MODULE__, issuer}, nil) do + {_, _, m} -> m + _ -> nil + end - _ -> - with {:ok, meta} <- Discovery.authorization_server(issuer, opts), - {:ok, uri} <- Map.fetch(meta, "jwks_uri") |> ok_or(:no_jwks_uri), - {:ok, %{"keys" => raw}} when is_list(raw) <- Discovery.fetch_json(uri, opts) do - keys = for k <- raw, is_map(k), do: JOSE.JWK.from_map(k) - :persistent_term.put({__MODULE__, issuer}, {keys, now, now}) - {:ok, keys} - else - {:error, _} = error -> error - _ -> {:error, :no_keys_in_document} - end + with {:ok, meta} <- Discovery.authorization_server(issuer, opts), + {:ok, uri} <- Map.fetch(meta, "jwks_uri") |> ok_or(:no_jwks_uri), + {:ok, %{"keys" => raw}} when is_list(raw) <- Discovery.fetch_json(uri, opts) do + keys = for k <- raw, is_map(k), do: JOSE.JWK.from_map(k) + :persistent_term.put({__MODULE__, issuer}, {keys, now, missed}) + {:ok, keys} + else + {:error, _} = error -> error + _ -> {:error, :no_keys_in_document} end end diff --git a/test/support/mcp/fake_as.ex b/test/support/mcp/fake_as.ex new file mode 100644 index 0000000..fd908d1 --- /dev/null +++ b/test/support/mcp/fake_as.ex @@ -0,0 +1,283 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.FakeAS do + @moduledoc """ + A fake enterprise authorization server (slice 062): RFC 8414 metadata, an authorization endpoint + that consents on its own (redirecting with `code`, `state` and `iss`), a token endpoint with + PKCE (S256) and RFC 8707 `resource`, a JWKS, RFC 7662 introspection, RFC 7591 registration, + and a control surface a test drives: `mint/2` (a token with whatever claims, for the resource + server's refusals), `rotate/1` (a new signing key; the old ones stay in the JWKS), `forget_old/1` + (the old keys dropped from the JWKS, so a token they signed becomes unverifiable). One instance + per test, behind Bandit on a loopback port; `issuer/1` is its URL. + """ + use Agent + + @behaviour Plug + + import Plug.Conn + + def start!(opts \\ []) do + {:ok, agent} = + Agent.start_link(fn -> + %{keys: [new_key()], codes: %{}, clients: %{}, path: Keyword.get(opts, :path, "")} + end) + + {:ok, server} = + Bandit.start_link( + plug: {__MODULE__, agent}, + ip: {127, 0, 0, 1}, + port: 0, + startup_log: false + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(server) + issuer = "http://127.0.0.1:#{port}" <> Map.get(Agent.get(agent, & &1), :path) + Agent.update(agent, &Map.put(&1, :issuer, issuer)) + + ExUnit.Callbacks.on_exit(fn -> + try do + GenServer.stop(server) + catch + :exit, _ -> :ok + end + end) + + %{agent: agent, issuer: issuer, server: server} + end + + @doc "A token this AS signs, with the claims given over the defaults (sub, aud, scope, exp)." + def mint(%{agent: agent, issuer: issuer}, claims) when is_map(claims) do + now = System.os_time(:second) + key = current_key(agent) + + base = %{ + "iss" => issuer, + "sub" => "u@example.com", + "aud" => "http://resource.example/mcp", + "scope" => "trinity:tools:read", + "iat" => now, + "exp" => now + 300, + "jti" => random(), + "client_id" => "test-client" + } + + sign(key, Map.merge(base, claims)) + end + + @doc "A token signed with an unrelated key (the JWKS never held it)." + def mint_foreign(%{issuer: issuer}, claims \\ %{}) do + now = System.os_time(:second) + + base = %{ + "iss" => issuer, + "sub" => "x", + "aud" => "http://resource.example/mcp", + "exp" => now + 300 + } + + sign(new_key(), Map.merge(base, claims)) + end + + @doc "Rotates the signing key; the old ones stay published." + def rotate(%{agent: agent}) do + Agent.update(agent, fn s -> %{s | keys: [new_key() | s.keys]} end) + current_key(agent).kid + end + + @doc "Drops every key but the current one from the JWKS." + def forget_old(%{agent: agent}), do: Agent.update(agent, fn s -> %{s | keys: [hd(s.keys)]} end) + + @doc "The current signing key's kid." + def kid(%{agent: agent}), do: current_key(agent).kid + + @doc "How many times the JWKS was fetched." + def jwks_fetches(%{agent: agent}), do: Agent.get(agent, &Map.get(&1, :jwks_fetches, 0)) + + ## The Plug + + @impl true + def init(agent), do: agent + + @impl true + def call(conn, agent) do + state = Agent.get(agent, & &1) + path = String.replace_prefix(conn.request_path, state.path, "") + + case {conn.method, path} do + {"GET", "/.well-known/oauth-authorization-server"} -> + json(conn, 200, metadata(state)) + + {"GET", "/.well-known/oauth-authorization-server" <> _} -> + json(conn, 200, metadata(state)) + + {"GET", "/jwks"} -> + Agent.update(agent, &Map.update(&1, :jwks_fetches, 1, fn n -> n + 1 end)) + json(conn, 200, %{"keys" => Enum.map(state.keys, &public_jwk/1)}) + + {"GET", "/authorize"} -> + authorize(conn, agent, state) + + {"POST", "/token"} -> + token(conn, agent, state) + + {"POST", "/introspect"} -> + introspect(conn, state) + + {"POST", "/register"} -> + register(conn, agent) + + # A Client ID Metadata Document a test client publishes (the personal profile's CIMD path). + {"GET", "/client.json"} -> + json(conn, 200, %{ + "client_id" => state.issuer <> "/client.json", + "client_name" => "Fake CIMD client", + "redirect_uris" => ["http://127.0.0.1/callback"], + "token_endpoint_auth_method" => "none" + }) + + _ -> + send_resp(conn, 404, "") + end + end + + defp metadata(state) do + iss = state.issuer + + %{ + "issuer" => iss, + "authorization_endpoint" => iss <> "/authorize", + "token_endpoint" => iss <> "/token", + "jwks_uri" => iss <> "/jwks", + "introspection_endpoint" => iss <> "/introspect", + "registration_endpoint" => iss <> "/register", + "response_types_supported" => ["code"], + "code_challenge_methods_supported" => ["S256"], + "authorization_response_iss_parameter_supported" => true + } + end + + # Consents on its own: the code remembers the challenge, the resource and the client. + defp authorize(conn, agent, state) do + conn = fetch_query_params(conn) + p = conn.query_params + code = random() + + Agent.update(agent, fn s -> + put_in(s.codes[code], %{ + challenge: p["code_challenge"], + resource: p["resource"], + client_id: p["client_id"], + redirect_uri: p["redirect_uri"], + scope: p["scope"] || "trinity:tools:read" + }) + end) + + query = URI.encode_query(%{"code" => code, "state" => p["state"], "iss" => state.issuer}) + sep = if String.contains?(p["redirect_uri"], "?"), do: "&", else: "?" + + conn + |> put_resp_header("location", p["redirect_uri"] <> sep <> query) + |> send_resp(302, "") + end + + defp token(conn, agent, state) do + {:ok, body, conn} = read_body(conn) + p = URI.decode_query(body) + + with %{} = entry <- + Agent.get_and_update(agent, fn s -> Map.pop(s.codes, p["code"]) end) + |> then(&elem(&1, 0)), + true <- pkce_ok?(entry.challenge, p["code_verifier"]), + true <- entry.resource == p["resource"] do + now = System.os_time(:second) + + claims = %{ + "iss" => state.issuer, + "sub" => "u@example.com", + "aud" => entry.resource, + "scope" => entry.scope, + "client_id" => entry.client_id, + "iat" => now, + "exp" => now + 300, + "jti" => random() + } + + json(conn, 200, %{ + "access_token" => sign(hd(state.keys), claims), + "token_type" => "Bearer", + "expires_in" => 300, + "scope" => entry.scope + }) + else + _ -> json(conn, 400, %{"error" => "invalid_grant"}) + end + end + + defp introspect(conn, state) do + {:ok, body, conn} = read_body(conn) + p = URI.decode_query(body) + + case verify(state.keys, p["token"]) do + {:ok, claims} -> + json(conn, 200, Map.put(claims, "active", claims["exp"] > System.os_time(:second))) + + :error -> + json(conn, 200, %{"active" => false}) + end + end + + defp register(conn, agent) do + {:ok, body, conn} = read_body(conn) + meta = Jason.decode!(body) + id = "dcr-" <> random() + Agent.update(agent, fn s -> put_in(s.clients[id], meta) end) + json(conn, 201, Map.put(meta, "client_id", id)) + end + + ## Keys and tokens + + defp new_key do + jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + %{jwk: jwk, kid: jwk |> JOSE.JWK.thumbprint() |> binary_part(0, 10)} + end + + defp current_key(agent), do: Agent.get(agent, &hd(&1.keys)) + + defp public_jwk(%{jwk: jwk, kid: kid}) do + {_, m} = jwk |> JOSE.JWK.to_public() |> JOSE.JWK.to_map() + Map.merge(m, %{"kid" => kid, "use" => "sig", "alg" => "ES256"}) + end + + defp sign(%{jwk: jwk, kid: kid}, claims) do + {_, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "ES256", "kid" => kid}) + |> JOSE.JWS.compact() + + token + end + + defp verify(keys, token) when is_binary(token) do + Enum.find_value(keys, :error, fn %{jwk: jwk} -> + case JOSE.JWS.verify_strict(jwk, ["ES256"], token) do + {true, payload, _} -> {:ok, Jason.decode!(payload)} + _ -> nil + end + end) + rescue + _ -> :error + end + + defp verify(_, _), do: :error + + defp pkce_ok?(challenge, verifier) when is_binary(challenge) and is_binary(verifier), + do: :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) == challenge + + defp pkce_ok?(_, _), do: false + + defp random, do: Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false) + + defp json(conn, status, body) do + conn |> put_resp_content_type("application/json") |> send_resp(status, Jason.encode!(body)) + end +end diff --git a/test/trinity/mcp/auth/resource_server_test.exs b/test/trinity/mcp/auth/resource_server_test.exs new file mode 100644 index 0000000..e72ce24 --- /dev/null +++ b/test/trinity/mcp/auth/resource_server_test.exs @@ -0,0 +1,236 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.ResourceServerTest do + @moduledoc """ + Slice 062 through the endpoint, in the production profile against the fake enterprise AS: + AC1 (unauthenticated is 401 with `resource_metadata`; the PRM names the AS; wrong audience and + expired are 401; each refusal receipted with what could be read), AC4 (a read scope cannot + call an artifact tool; the artifact scope can, subject to the gate), and the owner's three + constraints: no dispatch without an audience-bound token (the local profile's bearer means + nothing here), the receipts of a call name issuer, subject and scope, and no token material + reaches the context or the rows. + """ + use Trinity.DataCase, async: false + @moduletag :capture_log + + alias Trinity.MCP.AuthHost + alias Trinity.MCP.Auth.JWKS + alias Trinity.MCP.FakeAS + alias Trinity.MCP.Server.{Exports, Replay} + alias Trinity.Permissions + alias Trinity.Receipts + + @meta %{ + "io.modelcontextprotocol/protocolVersion" => "2026-07-28", + "io.modelcontextprotocol/clientCapabilities" => %{"elicitation" => %{"form" => %{}}} + } + + setup do + as = FakeAS.start!() + {:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) + resource = "http://127.0.0.1:#{port}/mcp" + + Application.put_env(:trinity, :mcp_auth, + profile: :production, + issuer: as.issuer, + resource: resource + ) + + Application.put_env(:trinity, :mcp_server, tools: Exports.defaults() ++ ["memory"]) + AuthHost.reload() + Replay.reset() + scope = Receipts.session_scope(Trinity.MCP.Server.Session.id()) + + on_exit(fn -> + Application.delete_env(:trinity, :mcp_auth) + Application.delete_env(:trinity, :mcp_server) + AuthHost.reload() + JWKS.forget(as.issuer) + Receipts.stop_writer(scope) + end) + + {:ok, as: as, resource: resource, scope: scope, port: port} + end + + defp post(resource, token, method, params) do + body = %{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => method, + "params" => Map.put(params, "_meta", @meta) + } + + headers = + [ + {"content-type", "application/json"}, + {"mcp-protocol-version", "2026-07-28"}, + {"mcp-method", method} + ] ++ + if(params["name"], do: [{"mcp-name", params["name"]}], else: []) ++ + if(token, do: [{"authorization", "Bearer " <> token}], else: []) + + {:ok, r} = + Req.post(resource, + headers: headers, + body: Jason.encode!(body), + retry: false, + decode_body: false + ) + + {r.status, Req.Response.get_header(r, "www-authenticate"), Jason.decode!(r.body)} + end + + defp decisions(scope), do: scope |> Receipts.list() |> Enum.filter(&(&1.kind == "decision")) + + test "AC1: 401 with resource_metadata; the PRM names the AS; wrong audience, expired and the static bearer are 401; each refusal receipted", + %{as: as, resource: resource, scope: scope, port: port} do + assert {401, ["Bearer resource_metadata=\"" <> rest], %{"error" => %{"code" => -32001}}} = + post(resource, nil, "server/discover", %{}) + + prm_url = String.trim_trailing(rest, "\"") + assert prm_url == "http://127.0.0.1:#{port}/.well-known/oauth-protected-resource/mcp" + + {:ok, %Req.Response{status: 200, body: prm}} = Req.get(prm_url, retry: false) + assert %{"resource" => ^resource, "authorization_servers" => [issuer]} = prm + assert issuer == as.issuer + + assert {401, _, _} = + post( + resource, + FakeAS.mint(as, %{"aud" => "http://other/mcp"}), + "server/discover", + %{} + ) + + assert {401, _, _} = + post( + resource, + FakeAS.mint(as, %{"aud" => resource, "exp" => System.os_time(:second) - 300}), + "server/discover", + %{} + ) + + # 061's static bearer is not a token in this profile. + System.put_env("TRINITY_MCP_SERVER_TOKEN", "static-bearer") + on_exit(fn -> System.delete_env("TRINITY_MCP_SERVER_TOKEN") end) + assert {401, _, _} = post(resource, "static-bearer", "server/discover", %{}) + + # The right token reaches the core. + assert {200, [], %{"result" => %{"supportedVersions" => ["2026-07-28"]}}} = + post(resource, FakeAS.mint(as, %{"aud" => resource}), "server/discover", %{}) + + reasons = + for r <- decisions(scope), + r.subject["phase"] == "auth", + do: Jason.decode!(r.signed_payload)["decision"]["reason"] + + assert "no_bearer" in reasons + assert Enum.any?(reasons, &String.starts_with?(&1, "wrong_audience")) + assert "expired" in reasons + assert "malformed" in reasons + + claimed = + for r <- decisions(scope), + r.subject["phase"] == "auth", + r.subject["claimed_sub"], + do: {r.subject["claimed_sub"], r.subject["unverified"]} + + assert {"u@example.com", true} in claimed + refute Enum.any?(Receipts.list(scope), &(inspect(&1) =~ "eyJ")) + end + + test "AC4 and the receipts: read scope refused on an artifact tool, artifact scope reaches the gate, receipts name the principal, no token material", + %{as: as, resource: resource, scope: scope} do + {:ok, rule} = Permissions.put_rule(%{tool: "skills_list", pattern: "*", decision: "allow"}) + on_exit(fn -> Permissions.revoke_rule(rule.id) end) + + read_only = + FakeAS.mint(as, %{ + "aud" => resource, + "scope" => "trinity:tools:read", + "sub" => "reader@example.com" + }) + + assert {200, _, + %{ + "error" => %{ + "code" => -32001, + "message" => "insufficient scope: this tool needs trinity:tools:artifact" + } + }} = + post(resource, read_only, "tools/call", %{ + "name" => "memory", + "arguments" => %{"action" => "add", "key" => "k", "body" => "b"} + }) + + [deny] = for r <- decisions(scope), r.subject["tool"] == "memory", do: r + + assert %{ + "principal" => %{ + "iss" => iss, + "sub" => "reader@example.com", + "scope" => "trinity:tools:read" + } + } = deny.subject + + assert iss == as.issuer + + assert Jason.decode!(deny.signed_payload)["decision"] == %{ + "outcome" => "deny", + "basis" => "scope", + "reason" => "needs trinity:tools:artifact" + } + + # The artifact scope reaches the gate, which holds the call for the owner (061's loop). + writer = + FakeAS.mint(as, %{ + "aud" => resource, + "scope" => "trinity:tools:artifact", + "sub" => "writer@example.com" + }) + + assert {200, _, %{"result" => %{"resultType" => "input_required"}}} = + post(resource, writer, "tools/call", %{ + "name" => "memory", + "arguments" => %{"action" => "add", "key" => "k", "body" => "b"} + }) + + # A read under an allow rule runs, and its decision and query receipts name the principal. + assert {200, _, %{"result" => %{"resultType" => "complete", "isError" => false}}} = + post(resource, read_only, "tools/call", %{ + "name" => "skills_list", + "arguments" => %{} + }) + + named = + for r <- Receipts.list(scope), + r.subject["tool"] == "skills_list", + do: {r.kind, r.subject["principal"]} + + assert {"decision", + %{ + "iss" => as.issuer, + "sub" => "reader@example.com", + "scope" => "trinity:tools:read", + "client_id" => "test-client", + "profile" => "production" + }} in named + + assert {"query", + %{ + "iss" => as.issuer, + "sub" => "reader@example.com", + "scope" => "trinity:tools:read", + "client_id" => "test-client", + "profile" => "production" + }} in named + + # No token material: not in any receipt, not in any message of the MCP session. + refute Enum.any?(Receipts.list(scope), &(inspect(&1) =~ "eyJ")) + + refute Enum.any?( + Trinity.Sessions.history(Trinity.MCP.Server.Session.id()), + &(inspect(&1) =~ "eyJ") + ) + end +end diff --git a/test/trinity/mcp/auth/token_test.exs b/test/trinity/mcp/auth/token_test.exs new file mode 100644 index 0000000..1b0f28a --- /dev/null +++ b/test/trinity/mcp/auth/token_test.exs @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.TokenTest do + @moduledoc """ + Slice 062 AC1's claim checks, each red on its own, against the fake enterprise AS: the + production profile's resource server accepts an audience-bound token the issuer signed and + refuses the wrong issuer, a missing or wrong audience, an expired token, one not yet valid, an + algorithm outside the allow list, a key the JWKS never held, and the personal profile's mark. + AC5: the issuer rotates its key; a token the old key signed verifies by its kid until expiry; a + new token uses the new key; a key the JWKS no longer publishes fails. + """ + use ExUnit.Case, async: false + @moduletag :capture_log + + alias Trinity.MCP.Auth.{Config, JWKS, Token} + alias Trinity.MCP.FakeAS + + @resource "http://resource.example/mcp" + + setup do + as = FakeAS.start!() + on_exit(fn -> JWKS.forget(as.issuer) end) + config = Config.new!(profile: :production, issuer: as.issuer, resource: @resource) + {:ok, as: as, config: config} + end + + test "AC1: an audience-bound token the issuer signed is a principal with issuer, subject, scope and client", + %{as: as, config: config} do + token = FakeAS.mint(as, %{"scope" => "trinity:tools:read trinity:tools:artifact"}) + assert {:ok, p} = Token.verify(token, config) + assert p.iss == as.issuer and p.sub == "u@example.com" and p.client_id == "test-client" + + assert p.scope == ["trinity:tools:read", "trinity:tools:artifact"] and + p.profile == :production + end + + test "each claim check is its own refusal", %{as: as, config: config} do + now = System.os_time(:second) + + assert {:error, {:wrong_issuer, "http://other"}} = + Token.verify(FakeAS.mint(as, %{"iss" => "http://other"}), config) + + assert {:error, {:wrong_audience, nil}} = + Token.verify(FakeAS.mint(as, %{"aud" => nil}), config) + + assert {:error, {:wrong_audience, "http://other/mcp"}} = + Token.verify(FakeAS.mint(as, %{"aud" => "http://other/mcp"}), config) + + assert {:ok, _} = + Token.verify(FakeAS.mint(as, %{"aud" => ["http://other/mcp", @resource]}), config) + + assert {:error, :expired} = Token.verify(FakeAS.mint(as, %{"exp" => now - 120}), config) + assert {:error, :no_expiry} = Token.verify(FakeAS.mint(as, %{"exp" => nil}), config) + assert {:error, :not_yet_valid} = Token.verify(FakeAS.mint(as, %{"nbf" => now + 600}), config) + + assert {:error, :personal_token_in_production} = + Token.verify(FakeAS.mint(as, %{"profile" => "personal"}), config) + + assert {:error, {:unknown_kid, _}} = Token.verify(FakeAS.mint_foreign(as), config) + assert {:error, :malformed} = Token.verify("not.a.jwt", config) + assert {:error, :malformed} = Token.verify("nope", config) + + # The allow list: ES256 is what the issuer uses; a configuration allowing RS256 alone refuses it. + rs_only = %{config | allowed_algs: ["RS256"]} + assert {:error, {:alg_not_allowed, "ES256"}} = Token.verify(FakeAS.mint(as, %{}), rs_only) + + assert {:error, {:allowed_algs, _}} = + Config.new( + profile: :production, + issuer: as.issuer, + resource: @resource, + allowed_algs: ["none"] + ) + + assert {:error, {:allowed_algs, _}} = + Config.new( + profile: :production, + issuer: as.issuer, + resource: @resource, + allowed_algs: ["HS256"] + ) + end + + test "AC5: after the issuer rotates its key, old tokens verify by kid until expiry and new tokens use the new key; a key no longer published fails", + %{as: as, config: config} do + old_kid = FakeAS.kid(as) + old_token = FakeAS.mint(as, %{}) + assert {:ok, _} = Token.verify(old_token, config) + + new_kid = FakeAS.rotate(as) + assert new_kid != old_kid + new_token = FakeAS.mint(as, %{}) + {:ok, %{"kid" => kid}} = Token.header(new_token) + assert kid == new_kid + + # The new kid is unknown to the cache: one refresh, then both verify. + fetches = FakeAS.jwks_fetches(as) + assert {:ok, _} = Token.verify(new_token, config) + assert FakeAS.jwks_fetches(as) == fetches + 1 + assert {:ok, _} = Token.verify(old_token, config) + + # The old key withdrawn from the JWKS: its token fails once the cache sees the new set. + FakeAS.forget_old(as) + JWKS.refresh(as.issuer, force: true) + assert {:error, {:unknown_kid, ^old_kid}} = Token.verify(old_token, config) + assert {:ok, _} = Token.verify(new_token, config) + end + + test "the issuer's metadata is refused when it names another issuer", %{as: as} do + assert {:ok, %{"issuer" => iss}} = Trinity.MCP.Auth.Discovery.authorization_server(as.issuer) + assert iss == as.issuer + assert {:error, _} = Trinity.MCP.Auth.Discovery.authorization_server(as.issuer <> "/other") + end +end From 274ad20864619a2e06505b993d1a3e3cc96efa05 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 06:45:45 -0400 Subject: [PATCH 05/13] test(s062): AC2 green through the personal profile's endpoints (code + PKCE + resource, iss on the redirect, the RS accepts its own issuer); DCR 404 unless enabled; rotation strictly newer; a loopback CIMD document over http; the JWKS miss floor wired Signed-off-by: Ayla Croft --- lib/trinity/mcp/auth/embedded.ex | 21 +- lib/trinity/mcp/auth/embedded/keys.ex | 16 +- lib/trinity/mcp/auth/jwks.ex | 23 +- test/trinity/mcp/auth/embedded_test.exs | 309 ++++++++++++++++++++++++ 4 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 test/trinity/mcp/auth/embedded_test.exs diff --git a/lib/trinity/mcp/auth/embedded.ex b/lib/trinity/mcp/auth/embedded.ex index 9c65405..dac46db 100644 --- a/lib/trinity/mcp/auth/embedded.ex +++ b/lib/trinity/mcp/auth/embedded.ex @@ -30,6 +30,7 @@ defmodule Trinity.MCP.Auth.Embedded do @code_ttl_s 600 @request_ttl_s 600 @cimd_ttl_s 3_600 + @loopback_hosts ["127.0.0.1", "localhost", "::1"] ## The behaviour (the resource server half) @@ -38,7 +39,7 @@ defmodule Trinity.MCP.Auth.Embedded do with {:ok, token} <- Auth.bearer(conn), {:ok, header} <- Token.header(token), {:ok, entry} <- key_for(config, header["kid"]) do - Token.verify_with_key(token, entry.jwk, %{config | issuer: config.issuer || config.resource}) + Token.verify_with_key(token, entry.jwk, %{config | issuer: issuer(config)}) end end @@ -326,14 +327,16 @@ defmodule Trinity.MCP.Auth.Embedded do defp same(_, _, reason), do: {:error, reason} # A client is a registered one (DCR) or a Client ID Metadata Document: the id is an https - # URL whose document names itself and its redirect URIs. Fetched once an hour. + # URL whose document names itself and its redirect URIs, fetched once an hour. The one + # http exception is a loopback host (RFC 8252 section 7.3's reasoning: a process on this + # machine, which in the personal profile is the owner's), so a local client can publish its + # document without a certificate; recorded in NOTES as the deviation from the CIMD draft. defp client(client_id, state) do cond do Map.has_key?(state.clients, client_id) -> {:ok, state.clients[client_id], state} - String.starts_with?(client_id, "https://") or - (String.starts_with?(client_id, "http://") and state.config.dcr) -> + cimd_url?(client_id) -> cimd(client_id, state) true -> @@ -341,6 +344,14 @@ defmodule Trinity.MCP.Auth.Embedded do end end + defp cimd_url?(client_id) do + case URI.new(client_id) do + {:ok, %URI{scheme: "https", host: host}} when is_binary(host) and host != "" -> true + {:ok, %URI{scheme: "http", host: host}} -> host in @loopback_hosts + _ -> false + end + end + defp cimd(url, state) do now = System.os_time(:second) @@ -377,7 +388,7 @@ defmodule Trinity.MCP.Auth.Embedded do defp redirect_match?(registered, given) do with %URI{scheme: "http", host: rh} = r <- URI.parse(registered), %URI{scheme: "http", host: gh} = g <- URI.parse(given), - true <- rh in ["127.0.0.1", "localhost", "[::1]"] and rh == gh do + true <- rh in @loopback_hosts and rh == gh do r.path == g.path else _ -> false diff --git a/lib/trinity/mcp/auth/embedded/keys.ex b/lib/trinity/mcp/auth/embedded/keys.ex index 4ebb8a6..cdf178f 100644 --- a/lib/trinity/mcp/auth/embedded/keys.ex +++ b/lib/trinity/mcp/auth/embedded/keys.ex @@ -45,7 +45,17 @@ defmodule Trinity.MCP.Auth.Embedded.Keys do @doc "Makes a new key, which becomes the signing key; the old ones stay for verification." @spec rotate!(Path.t()) :: entry() - def rotate!(dir), do: generate!(dir) + def rotate!(dir) do + # Strictly newer than the key in force, whatever the clock says (two keys in one second + # would otherwise tie, and the tie would pick the signer by file name). + after_at = + case all(dir) do + [newest | _] -> newest.created_at + 1 + [] -> 0 + end + + generate!(dir, after_at) + end @doc "Drops keys older than `max_age_s`, keeping the newest whatever its age." @spec prune!(Path.t(), pos_integer()) :: [entry()] @@ -75,11 +85,11 @@ defmodule Trinity.MCP.Auth.Embedded.Keys do %{"keys" => keys} end - defp generate!(dir) do + defp generate!(dir, after_at \\ 0) do File.mkdir_p!(dir) jwk = JOSE.JWK.generate_key({:ec, "P-256"}) kid = jwk |> JOSE.JWK.thumbprint() |> binary_part(0, 12) - created_at = System.os_time(:second) + created_at = max(System.os_time(:second), after_at) {_, private} = JOSE.JWK.to_map(jwk) path = Path.join(dir, "mcp-as-#{kid}.jwk.json") diff --git a/lib/trinity/mcp/auth/jwks.ex b/lib/trinity/mcp/auth/jwks.ex index dce8ac3..756e9c7 100644 --- a/lib/trinity/mcp/auth/jwks.ex +++ b/lib/trinity/mcp/auth/jwks.ex @@ -22,7 +22,7 @@ defmodule Trinity.MCP.Auth.JWKS do {:ok, key} :error -> - with {:ok, keys} <- refresh(issuer, opts) do + with {:ok, keys} <- refresh_on_miss(issuer, opts) do case find(keys, kid) do {:ok, key} -> {:ok, key} :error -> {:error, {:unknown_kid, kid}} @@ -32,6 +32,20 @@ defmodule Trinity.MCP.Auth.JWKS do end end + # A miss refreshes once; a second miss inside the floor is answered from the cache, so an + # unknown `kid` a caller keeps presenting is not a fetch per request. + defp refresh_on_miss(issuer, opts) do + now = System.monotonic_time(:millisecond) + + case :persistent_term.get({__MODULE__, issuer}, nil) do + {keys, _, missed} when is_integer(missed) and now - missed < @refresh_floor_ms -> + {:ok, keys} + + _ -> + refresh(issuer, Keyword.put(opts, :missed_at, now)) + end + end + @doc "The issuer's keys, from the cache or fetched." @spec keys(String.t(), keyword()) :: {:ok, [JOSE.JWK.t()]} | {:error, term()} def keys(issuer, opts \\ []) do @@ -48,14 +62,15 @@ defmodule Trinity.MCP.Auth.JWKS do end end - @doc "Fetches the keys again; the cache is replaced (the time of the last miss-driven refresh kept)." + @doc "Fetches the keys again; the cache is replaced (the time of the last miss-driven refresh kept, or set by `missed_at:`)." @spec refresh(String.t(), keyword()) :: {:ok, [JOSE.JWK.t()]} | {:error, term()} def refresh(issuer, opts \\ []) do now = System.monotonic_time(:millisecond) missed = - case :persistent_term.get({__MODULE__, issuer}, nil) do - {_, _, m} -> m + case {Keyword.get(opts, :missed_at), :persistent_term.get({__MODULE__, issuer}, nil)} do + {at, _} when is_integer(at) -> at + {nil, {_, _, m}} -> m _ -> nil end diff --git a/test/trinity/mcp/auth/embedded_test.exs b/test/trinity/mcp/auth/embedded_test.exs new file mode 100644 index 0000000..5f743b5 --- /dev/null +++ b/test/trinity/mcp/auth/embedded_test.exs @@ -0,0 +1,309 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.EmbeddedTest do + @moduledoc """ + Slice 062 AC2, the personal profile: a CIMD-registered test client completes the code flow + with PKCE and a resource indicator through the endpoints, receives an audience-bound token + the resource server accepts; DCR answers 404 unless enabled; the token carries the personal + mark and is refused by the production profile; the profile refuses to start under an + external authority adapter; the signing key rotates by kid. + """ + use Trinity.DataCase, async: false + @moduletag :capture_log + + alias Trinity.MCP.AuthHost + alias Trinity.MCP.Auth.{Config, Embedded, Token} + alias Trinity.MCP.FakeAS + alias Trinity.MCP.Server.Replay + + @meta %{ + "io.modelcontextprotocol/protocolVersion" => "2026-07-28", + "io.modelcontextprotocol/clientCapabilities" => %{} + } + + setup do + cimd = FakeAS.start!() + {:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) + base = "http://127.0.0.1:#{port}" + resource = base <> "/mcp" + + key_dir = + Path.join(System.tmp_dir!(), "trinity-as-keys-#{System.unique_integer([:positive])}") + + Application.put_env(:trinity, :mcp_auth, + profile: :personal, + resource: resource, + key_dir: key_dir + ) + + AuthHost.reload() + :ok = AuthHost.boot() + Replay.reset() + + on_exit(fn -> + if pid = Process.whereis(Embedded), do: GenServer.stop(pid) + Application.delete_env(:trinity, :mcp_auth) + AuthHost.reload() + File.rm_rf(key_dir) + end) + + {:ok, + base: base, resource: resource, client_id: cimd.issuer <> "/client.json", key_dir: key_dir} + end + + defp discover(base, token) do + body = %{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "server/discover", + "params" => %{"_meta" => @meta} + } + + headers = + [ + {"content-type", "application/json"}, + {"mcp-protocol-version", "2026-07-28"}, + {"mcp-method", "server/discover"} + ] ++ + if(token, do: [{"authorization", "Bearer " <> token}], else: []) + + {:ok, r} = + Req.post(base <> "/mcp", + headers: headers, + body: Jason.encode!(body), + retry: false, + decode_body: false + ) + + r.status + end + + # The flow a client runs: the authorization request, the owner's consent, the callback's + # code, the token endpoint with the verifier. + defp code_flow(base, resource, client_id, opts \\ []) do + verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + challenge = :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + + query = %{ + "response_type" => "code", + "client_id" => client_id, + "redirect_uri" => "http://127.0.0.1:5555/callback", + "code_challenge" => challenge, + "code_challenge_method" => "S256", + "state" => "xyz", + "resource" => Keyword.get(opts, :resource, resource), + "scope" => Keyword.get(opts, :scope, "trinity:tools:read") + } + + {:ok, page} = + Req.get(base <> "/oauth/authorize?" <> URI.encode_query(query), + retry: false, + decode_body: false, + redirect: false + ) + + case page.status do + 200 -> + [_, request_id] = Regex.run(~r/name="request_id" value="([^"]+)"/, page.body) + [_, csrf] = Regex.run(~r/name="_csrf_token"[^>]*value="([^"]+)"/, page.body) + cookie = page |> Req.Response.get_header("set-cookie") |> Enum.join("; ") + + {:ok, redirect} = + Req.post(base <> "/oauth/consent", + form: [ + request_id: request_id, + decision: Keyword.get(opts, :decision, "approve"), + _csrf_token: csrf + ], + headers: [{"cookie", cookie}], + retry: false, + redirect: false, + decode_body: false + ) + + [location] = Req.Response.get_header(redirect, "location") + params = location |> URI.parse() |> Map.get(:query) |> URI.decode_query() + {:consent, params, verifier} + + 302 -> + [location] = Req.Response.get_header(page, "location") + {:redirect_error, location |> URI.parse() |> Map.get(:query) |> URI.decode_query()} + + other -> + {:refused, other, page.body} + end + end + + defp exchange(base, code, verifier, client_id, resource) do + {:ok, r} = + Req.post(base <> "/oauth/token", + form: [ + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:5555/callback", + client_id: client_id, + code_verifier: verifier, + resource: resource + ], + retry: false, + decode_body: false + ) + + {r.status, Jason.decode!(r.body)} + end + + test "AC2: the code flow with PKCE and a resource indicator issues an audience-bound token the RS accepts; the metadata and JWKS are published; iss rides the redirect", + %{base: base, resource: resource, client_id: client_id} do + {:ok, %Req.Response{status: 200, body: meta}} = + Req.get(base <> "/.well-known/oauth-authorization-server", retry: false) + + assert %{ + "issuer" => ^base, + "code_challenge_methods_supported" => ["S256"], + "client_id_metadata_document_supported" => true + } = meta + + refute Map.has_key?(meta, "registration_endpoint") + + {:ok, %Req.Response{status: 200, body: %{"keys" => [%{"kid" => kid, "kty" => "EC"}]}}} = + Req.get(base <> "/.well-known/jwks.json", retry: false) + + {:ok, + %Req.Response{ + status: 200, + body: %{"resource" => ^resource, "authorization_servers" => [^base]} + }} = Req.get(base <> "/.well-known/oauth-protected-resource/mcp", retry: false) + + assert 401 == discover(base, nil) + + {:consent, %{"code" => code, "state" => "xyz", "iss" => ^base}, verifier} = + code_flow(base, resource, client_id) + + assert {200, + %{"access_token" => token, "token_type" => "Bearer", "scope" => "trinity:tools:read"}} = + exchange(base, code, verifier, client_id, resource) + + {:ok, %{"kid" => ^kid}} = Token.header(token) + assert 200 == discover(base, token) + + # Single use: the code is spent; a wrong verifier is refused. + assert {400, %{"error" => "invalid_grant"}} = + exchange(base, code, verifier, client_id, resource) + + {:consent, %{"code" => code2}, _} = code_flow(base, resource, client_id) + + assert {400, %{"error" => "invalid_grant"}} = + exchange(base, code2, "wrong-verifier", client_id, resource) + + # The token names the personal profile and the production profile refuses it whatever key signed it. + {:ok, claims} = + with [_, p, _] <- String.split(token, "."), + {:ok, json} <- Base.url_decode64(p, padding: false), + do: Jason.decode(json) + + assert %{"profile" => "personal", "aud" => ^resource, "iss" => ^base} = claims + production = Config.new!(profile: :production, issuer: base, resource: resource) + + assert {:error, :personal_token_in_production} = + Token.check(claims, production, System.os_time(:second)) + end + + test "a wrong resource, an unknown scope and a denied consent are refusals; DCR is 404 unless enabled; an unknown client is refused", + %{base: base, resource: resource, client_id: client_id} do + assert {:redirect_error, %{"error" => "invalid_target"}} = + code_flow(base, resource, client_id, resource: "http://other/mcp") + + assert {:redirect_error, %{"error" => "invalid_scope"}} = + code_flow(base, resource, client_id, scope: "admin:all") + + assert {:consent, %{"error" => "access_denied", "state" => "xyz"}, _} = + code_flow(base, resource, client_id, decision: "deny") + + assert {:refused, 400, body} = + code_flow(base, resource, "https://nowhere.invalid/client.json") + + assert body =~ "client_metadata_unreachable" + + {:ok, r} = + Req.post(base <> "/oauth/register", + json: %{"redirect_uris" => ["http://127.0.0.1/cb"]}, + retry: false + ) + + assert r.status == 404 + end + + test "DCR registers a client when enabled, and the registered client completes the flow", %{ + base: base, + resource: resource, + key_dir: key_dir + } do + Application.put_env(:trinity, :mcp_auth, + profile: :personal, + resource: resource, + key_dir: key_dir, + dcr: true + ) + + AuthHost.reload() + GenServer.stop(Embedded) + :ok = AuthHost.boot() + + {:ok, r} = + Req.post(base <> "/oauth/register", + json: %{ + "client_name" => "dcr test", + "redirect_uris" => ["http://127.0.0.1:5555/callback"] + }, + retry: false + ) + + assert %{"client_id" => "dcr-" <> _ = id} = r.body + {:consent, %{"code" => code}, verifier} = code_flow(base, resource, id) + assert {200, %{"access_token" => _}} = exchange(base, code, verifier, id, resource) + end + + test "the personal profile refuses to start under an external authority adapter", %{ + resource: resource, + key_dir: key_dir + } do + assert {:error, {:profile, msg}} = + Config.new( + profile: :personal, + resource: resource, + key_dir: key_dir, + local_authority?: false + ) + + assert msg =~ "no embedded issuer under an external authority adapter" + + assert {:error, :external_authority_in_force} = + Embedded.start_link(%Config{ + profile: :personal, + resource: resource, + key_dir: key_dir, + local_authority?: false + }) + end + + test "rotating the signing key: a token the old key signed verifies by kid; new tokens use the new key", + %{base: base, resource: resource, client_id: client_id} do + {:consent, %{"code" => code}, verifier} = code_flow(base, resource, client_id) + {200, %{"access_token" => old}} = exchange(base, code, verifier, client_id, resource) + {:ok, %{"kid" => old_kid}} = Token.header(old) + + new_kid = AuthHost.rotate_key!() + assert new_kid != old_kid + + {:ok, %Req.Response{body: %{"keys" => keys}}} = + Req.get(base <> "/.well-known/jwks.json", retry: false) + + assert Enum.map(keys, & &1["kid"]) |> Enum.sort() == Enum.sort([old_kid, new_kid]) + + assert 200 == discover(base, old) + {:consent, %{"code" => code2}, verifier2} = code_flow(base, resource, client_id) + {200, %{"access_token" => new}} = exchange(base, code2, verifier2, client_id, resource) + {:ok, %{"kid" => ^new_kid}} = Token.header(new) + assert 200 == discover(base, new) + end +end From c9a7f5579b697b26c85059fa79a0c37cd3018b27 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 06:50:47 -0400 Subject: [PATCH 06/13] test(s062): AC7 (the boundary's deps: [], the census over the package, the one reader of the header), AC8 (the client role against the fake AS: 401 to challenge, PKCE, the token stored 0600 and presented, the RS accepts it; wrong iss refused; DCR once), introspection (RFC 7662), the configuration's refusals, the personal profile refused at boot under an external adapter, no key material in production; the fake AS's token endpoint kept its state; the client clears its challenge on connect Signed-off-by: Ayla Croft --- lib/trinity/mcp/client.ex | 4 +- test/support/mcp/fake_as.ex | 5 +- test/trinity/mcp/auth/boundary_test.exs | 110 ++++++++++++ test/trinity/mcp/auth/client_role_test.exs | 193 +++++++++++++++++++++ test/trinity/mcp/auth/embedded_test.exs | 59 +++++++ test/trinity/mcp/auth/token_test.exs | 58 +++++++ 6 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 test/trinity/mcp/auth/boundary_test.exs create mode 100644 test/trinity/mcp/auth/client_role_test.exs diff --git a/lib/trinity/mcp/client.ex b/lib/trinity/mcp/client.ex index bfc013e..6a839b4 100644 --- a/lib/trinity/mcp/client.ex +++ b/lib/trinity/mcp/client.ex @@ -171,7 +171,9 @@ defmodule Trinity.MCP.Client do "mcp #{state.config.name}: connected at #{state.revision}, #{map_size(state.tools)} tools" ) - {:noreply, %{state | status: :ready, attempts: 0, last_error: nil} |> schedule_relist()} + {:noreply, + %{state | status: :ready, attempts: 0, last_error: nil, auth_challenge: nil} + |> schedule_relist()} {:refused, reason, state} -> Logger.warning("mcp #{state.config.name}: refused: #{inspect(reason)}") diff --git a/test/support/mcp/fake_as.ex b/test/support/mcp/fake_as.ex index fd908d1..a4447ed 100644 --- a/test/support/mcp/fake_as.ex +++ b/test/support/mcp/fake_as.ex @@ -185,8 +185,9 @@ defmodule Trinity.MCP.FakeAS do p = URI.decode_query(body) with %{} = entry <- - Agent.get_and_update(agent, fn s -> Map.pop(s.codes, p["code"]) end) - |> then(&elem(&1, 0)), + Agent.get_and_update(agent, fn s -> + {s.codes[p["code"]], %{s | codes: Map.delete(s.codes, p["code"])}} + end), true <- pkce_ok?(entry.challenge, p["code_verifier"]), true <- entry.resource == p["resource"] do now = System.os_time(:second) diff --git a/test/trinity/mcp/auth/boundary_test.exs b/test/trinity/mcp/auth/boundary_test.exs new file mode 100644 index 0000000..4a9160e --- /dev/null +++ b/test/trinity/mcp/auth/boundary_test.exs @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.BoundaryTest do + @moduledoc """ + Slice 062 AC7: `Trinity.MCP.Auth` is a boundary with no dependency on the tree (`deps: []`, + read from the attribute the boundary library persists), so it has no compile-time path to + `Trinity.Sessions`, `Trinity.Tools`, `Trinity.Receipts` or `Trinity.Repo`; the source census + over the package (`git ls-files lib/trinity/mcp/auth.ex lib/trinity/mcp/auth`, the population + PROOF.md pastes) names no module of the tree outside itself in code. And the owner's "no token + material" constraint as a census: the one reader of the `Authorization` header under `lib/` is + `Trinity.MCP.Auth.bearer/1`; the server side (`lib/trinity/mcp/server*`) never names the + header, the raw token or a `Bearer` string. + """ + use ExUnit.Case, async: true + + @package ["lib/trinity/mcp/auth.ex", "lib/trinity/mcp/auth"] + @server ["lib/trinity/mcp/server.ex", "lib/trinity/mcp/server"] + + defp files(population) do + {out, 0} = System.cmd("git", ["ls-files" | population]) + String.split(out, "\n", trim: true) + end + + # The source with its comments and heredoc docs removed: a moduledoc may name a module of the + # tree in prose; a call to it is what the census refuses. + defp code(src) do + src + |> String.replace(~r/@(module)?doc\s+"""(.|\n)*?"""/, "") + |> String.replace(~r/@(module)?doc\s+"[^"\n]*"/, "") + |> String.replace(~r/#[^\n]*/, "") + end + + test "the boundary lists no dependency and exports the package" do + [%{opts: opts, app: :trinity}] = + Keyword.get(Trinity.MCP.Auth.module_info(:attributes), Boundary) + + assert Keyword.fetch!(opts, :deps) == [] + assert Config in Keyword.fetch!(opts, :exports) + assert Client in Keyword.fetch!(opts, :exports) + end + + test "the population is the package's files, and no file of it names a module of the tree in code" do + files = files(@package) + + assert Enum.sort(files) == [ + "lib/trinity/mcp/auth.ex", + "lib/trinity/mcp/auth/client.ex", + "lib/trinity/mcp/auth/client/store.ex", + "lib/trinity/mcp/auth/config.ex", + "lib/trinity/mcp/auth/discovery.ex", + "lib/trinity/mcp/auth/embedded.ex", + "lib/trinity/mcp/auth/embedded/keys.ex", + "lib/trinity/mcp/auth/external.ex", + "lib/trinity/mcp/auth/jwks.ex", + "lib/trinity/mcp/auth/local.ex", + "lib/trinity/mcp/auth/principal.ex", + "lib/trinity/mcp/auth/scopes.ex", + "lib/trinity/mcp/auth/token.ex" + ] + + for f <- files do + src = f |> File.read!() |> code() + + refs = + Regex.scan(~r/\bTrinity\.[A-Z][A-Za-z.]*/, src) + |> List.flatten() + |> Enum.reject(&String.starts_with?(&1, "Trinity.MCP.Auth")) + |> Enum.uniq() + + assert refs == [], "#{f} reaches the tree: #{inspect(refs)}" + refute src =~ ~r/Trinity\.(Sessions|Tools|Receipts|Repo|Authority)\b/ + end + end + + test "the one reader of the Authorization header under lib/ is the boundary; the server side never names a token" do + {out, 0} = System.cmd("git", ["ls-files", "lib/*.ex", "lib/**/*.ex", "lib/**/*.heex"]) + files = out |> String.split("\n", trim: true) |> Enum.uniq() + assert length(files) > 100 + + readers = for f <- files, File.read!(f) =~ ~r/get_req_header\([^)]*"authorization"/, do: f + assert readers == ["lib/trinity/mcp/auth.ex"] + + for f <- files(@server) do + src = f |> File.read!() |> code() + refute src =~ ~r/"authorization"/i, "#{f} names the header" + refute src =~ ~r/access_token|Bearer /, "#{f} names a token" + refute src =~ ~r/Auth\.bearer/, "#{f} reads the bearer" + end + end + + test "a principal's receipt form carries issuer, subject, scope, client and profile, and nothing else" do + principal = %Trinity.MCP.Auth.Principal{ + iss: "https://as.example", + sub: "u@example.com", + scope: ["trinity:tools:read"], + client_id: "c", + jti: "j", + profile: :production, + exp: 1 + } + + assert Trinity.MCP.Auth.Principal.to_receipt(principal) == %{ + "iss" => "https://as.example", + "sub" => "u@example.com", + "scope" => "trinity:tools:read", + "client_id" => "c", + "profile" => "production" + } + end +end diff --git a/test/trinity/mcp/auth/client_role_test.exs b/test/trinity/mcp/auth/client_role_test.exs new file mode 100644 index 0000000..35557dd --- /dev/null +++ b/test/trinity/mcp/auth/client_role_test.exs @@ -0,0 +1,193 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Auth.ClientRoleTest do + @moduledoc """ + Slice 062 AC8, the client role against the fake enterprise AS: 060's driver meets a `401` + whose challenge names the resource metadata and records it; the host begins the code flow + (PKCE S256, `resource`, the configured client id) from that URL; the callback finishes it + (RFC 9207 `iss` checked, the code exchanged with the verifier); the token lands in the store + (mode 0600) and `Trinity.MCP.Client.Auth.bearer/1` returns it; the driver reconnects and the + resource server, in the production profile against the same issuer, accepts it (audience-bound + to this server). A callback from another issuer is refused; an unknown state is refused; DCR + registers the client once, only when allowed and offered; without an identity the flow does + not begin. No token in the client's info, the receipts or the session. The driver performs no + flow, by census over its files. + """ + use Trinity.DataCase, async: false + @moduletag :capture_log + + import Bitwise + import Trinity.MCP.ServersUnderTest + + alias Trinity.MCP.{AuthHost, Client, FakeAS, ServerConfig} + alias Trinity.MCP.Auth.Client.Store + alias Trinity.MCP.Auth.{JWKS, Token} + alias Trinity.MCP.Server.{Exports, Replay} + alias Trinity.Receipts + + setup do + as = FakeAS.start!() + {:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) + base = "http://127.0.0.1:#{port}" + resource = base <> "/mcp" + store = Path.join(System.tmp_dir!(), "oauth-store-#{System.unique_integer([:positive])}") + + configure(as, resource, store, client_id: "trinity-test") + Application.put_env(:trinity, :mcp_server, tools: Exports.defaults()) + Replay.reset() + scope = Receipts.session_scope(Trinity.MCP.Server.Session.id()) + + on_exit(fn -> + Application.delete_env(:trinity, :mcp_auth) + Application.delete_env(:trinity, :mcp_server) + AuthHost.reload() + JWKS.forget(as.issuer) + Receipts.stop_writer(scope) + File.rm_rf(store) + end) + + {:ok, as: as, base: base, resource: resource, store: store, scope: scope} + end + + defp configure(as, resource, store, extra) do + Application.put_env( + :trinity, + :mcp_auth, + [profile: :production, issuer: as.issuer, resource: resource, store_dir: store] ++ extra + ) + + AuthHost.reload() + end + + defp query(url), do: url |> URI.parse() |> Map.get(:query) |> URI.decode_query() + + test "AC8: 401 to challenge, PKCE against the AS, the token stored and presented, the RS accepts it", + %{as: as, base: base, resource: resource, store: store, scope: scope} do + start!("self", {:http, resource}) + info = await("self", :down) + assert info.auth_challenge == base <> "/.well-known/oauth-protected-resource/mcp" + assert info.last_error =~ "unauthorized" + + # The host begins the flow from the challenge: the URL names the AS, S256, the resource. + redirect_uri = base <> "/oauth/callback" + {:ok, url} = AuthHost.client_begin(info.auth_challenge, redirect_uri: redirect_uri) + assert String.starts_with?(url, as.issuer <> "/authorize?") + + assert %{ + "response_type" => "code", + "client_id" => "trinity-test", + "code_challenge_method" => "S256", + "code_challenge" => challenge, + "resource" => ^resource, + "redirect_uri" => ^redirect_uri, + "state" => state, + "scope" => "trinity:tools:read" + } = query(url) + + assert byte_size(challenge) == 43 + [pending] = Path.wildcard(Path.join([store, "pending", "*.json"])) + assert (File.stat!(pending).mode &&& 0o777) == 0o600 + + # The AS consents; the owner lands on the callback with code, state and iss. + {:ok, %Req.Response{status: 302} = r} = Req.get(url, retry: false, redirect: false) + [location] = Req.Response.get_header(r, "location") + assert String.starts_with?(location, redirect_uri <> "?") + assert %{"code" => _, "state" => ^state, "iss" => iss} = query(location) + assert iss == as.issuer + + {:ok, %Req.Response{status: 302} = back} = Req.get(location, retry: false, redirect: false) + assert Req.Response.get_header(back, "location") == ["/mcp"] + + # The token is in the store for the resource, mode 0600, the pending request gone. + [token_file] = Path.wildcard(Path.join(store, "token-*.json")) + assert (File.stat!(token_file).mode &&& 0o777) == 0o600 + assert Path.wildcard(Path.join([store, "pending", "*.json"])) == [] + assert {:ok, %{"issuer" => issuer, "resource" => ^resource}} = Store.token(store, resource) + assert issuer == as.issuer + + token = Client.Auth.bearer(%ServerConfig{name: "self", url: resource}) + assert is_binary(token) + assert {:ok, %{"alg" => "ES256"}} = Token.header(token) + # Audience-bound: the resource server's own check admits it (aud holds this server). + assert {:ok, principal} = Token.verify(token, AuthHost.config()) + assert principal.iss == as.issuer and principal.sub == "u@example.com" + assert principal.scope == ["trinity:tools:read"] and principal.client_id == "trinity-test" + + # The driver presents it and the resource server accepts it: audience-bound to this server. + :ok = Client.reconnect("self") + ready = await("self", :ready) + assert ready.revision == "2026-07-28" and ready.auth_challenge == nil + + # No token material: not in the client's info, the receipts, or the MCP session. + refute inspect(ready) =~ "eyJ" + refute Enum.any?(Receipts.list(scope), &(inspect(&1) =~ "eyJ")) + + refute Enum.any?( + Trinity.Sessions.history(Trinity.MCP.Server.Session.id()), + &(inspect(&1) =~ "eyJ") + ) + end + + test "a callback from another issuer is refused; an unknown state is refused; DCR once when allowed and offered; no identity, no flow", + %{as: as, base: base, resource: resource, store: store} do + prm = base <> "/.well-known/oauth-protected-resource/mcp" + redirect_uri = base <> "/oauth/callback" + + {:ok, url} = AuthHost.client_begin(prm, redirect_uri: redirect_uri) + %{"state" => state} = query(url) + + assert {:error, {:wrong_issuer, "http://other"}} = + AuthHost.client_finish(%{"state" => state, "code" => "c", "iss" => "http://other"}) + + # The pending request was consumed by the refusal: the same state is now unknown. + assert {:error, :unknown_state} = + AuthHost.client_finish(%{"state" => state, "code" => "c", "iss" => as.issuer}) + + assert {:error, :unknown_state} = AuthHost.client_finish(%{"state" => "nope", "code" => "c"}) + assert Store.token(store, resource) == :error + + # An AS error on the callback is the AS's refusal, not a token. + {:ok, url} = AuthHost.client_begin(prm, redirect_uri: redirect_uri) + %{"state" => state} = query(url) + + assert {:error, {:authorization_error, "access_denied", _}} = + AuthHost.client_finish(%{ + "state" => state, + "error" => "access_denied", + "iss" => as.issuer + }) + + # No configured identity and no registration allowed: the flow does not begin. + configure(as, resource, store, []) + assert {:error, :no_client_identity} = AuthHost.client_begin(prm, redirect_uri: redirect_uri) + + # Registration allowed and offered: registered once, remembered per issuer. + configure(as, resource, store, dcr: true) + {:ok, url} = AuthHost.client_begin(prm, redirect_uri: redirect_uri) + assert %{"client_id" => "dcr-" <> _ = id} = query(url) + assert Store.client_id(store, as.issuer) == id + {:ok, url2} = AuthHost.client_begin(prm, redirect_uri: redirect_uri) + assert %{"client_id" => ^id} = query(url2) + end + + test "the driver performs no flow: the census over its files" do + {out, 0} = + System.cmd("git", ["ls-files", "lib/trinity/mcp/client.ex", "lib/trinity/mcp/client"]) + + files = String.split(out, "\n", trim: true) + assert "lib/trinity/mcp/client/auth.ex" in files + + for f <- files do + src = File.read!(f) + refute src =~ ~r/code_verifier|code_challenge|authorization_endpoint|token_endpoint/, f + refute src =~ ~r/Auth\.Client\.(begin|finish|discover)/, f + refute src =~ ~r/Store\./, f + end + + # What the driver does name: the challenge parser and the host's stored bearer. + assert File.read!("lib/trinity/mcp/client.ex") =~ "Trinity.MCP.Auth.Client.challenge(header)" + + assert File.read!("lib/trinity/mcp/client/auth.ex") =~ + "Trinity.MCP.AuthHost.client_bearer(url)" + end +end diff --git a/test/trinity/mcp/auth/embedded_test.exs b/test/trinity/mcp/auth/embedded_test.exs index 5f743b5..504722b 100644 --- a/test/trinity/mcp/auth/embedded_test.exs +++ b/test/trinity/mcp/auth/embedded_test.exs @@ -284,6 +284,65 @@ defmodule Trinity.MCP.Auth.EmbeddedTest do key_dir: key_dir, local_authority?: false }) + + # At boot: with an external adapter selected (the test adapter stands in), the host's + # configuration, the first thing the MCP boot builds, raises, and no authorization + # server is running afterwards. + GenServer.stop(Embedded) + :persistent_term.put({Trinity.Authority.Selection, :selected}, Trinity.TestAuthority.Full) + + on_exit(fn -> + :persistent_term.put({Trinity.Authority.Selection, :selected}, Trinity.Authority.Local) + end) + + assert Trinity.Authority.impl() == Trinity.TestAuthority.Full + + assert_raise ArgumentError, + ~r/no embedded issuer under an external authority adapter/, + fn -> AuthHost.reload() end + + assert Process.whereis(Embedded) == nil + + # The production profile under the same adapter boots: Trinity issues nothing there. + Application.put_env(:trinity, :mcp_auth, + profile: :production, + issuer: "https://as.example", + resource: resource + ) + + assert %Config{profile: :production, local_authority?: false} = AuthHost.reload() + assert :ok = AuthHost.boot() + assert Process.whereis(Embedded) == nil + assert AuthHost.as_metadata() == nil and AuthHost.jwks() == nil + end + + test "the production profile holds no key material of its own: no keys, no JWKS, no AS metadata", + %{base: base, resource: resource, key_dir: key_dir} do + GenServer.stop(Embedded) + File.rm_rf!(key_dir) + + Application.put_env(:trinity, :mcp_auth, + profile: :production, + issuer: "https://as.example", + resource: resource, + key_dir: key_dir + ) + + AuthHost.reload() + :ok = AuthHost.boot() + assert Path.wildcard(Path.join(key_dir, "mcp-as-*")) == [] + assert AuthHost.jwks() == nil + {:ok, r} = Req.get(base <> "/.well-known/jwks.json", retry: false) + assert r.status == 404 + {:ok, r} = Req.get(base <> "/.well-known/oauth-authorization-server", retry: false) + assert r.status == 404 + + {:ok, r} = + Req.post(base <> "/oauth/token", form: [grant_type: "authorization_code"], retry: false) + + assert r.status == 404 + {:ok, r} = Req.get(base <> "/oauth/authorize?client_id=x", retry: false, redirect: false) + assert r.status == 404 end test "rotating the signing key: a token the old key signed verifies by kid; new tokens use the new key", diff --git a/test/trinity/mcp/auth/token_test.exs b/test/trinity/mcp/auth/token_test.exs index 1b0f28a..76891c0 100644 --- a/test/trinity/mcp/auth/token_test.exs +++ b/test/trinity/mcp/auth/token_test.exs @@ -106,6 +106,64 @@ defmodule Trinity.MCP.Auth.TokenTest do assert {:ok, _} = Token.verify(new_token, config) end + test "introspection: an opaque token is asked about at the issuer (RFC 7662), the answer's claims checked as a JWT's are", + %{as: as, config: config} do + config = %{config | introspection: true, introspection_credentials: {"trinity-rs", "secret"}} + + conn = fn token -> + Plug.Test.conn(:post, "/mcp") + |> Plug.Conn.put_req_header("authorization", "Bearer " <> token) + end + + now = System.os_time(:second) + + assert {:ok, p} = Trinity.MCP.Auth.External.authorize(conn.(FakeAS.mint(as, %{})), config) + assert p.iss == as.issuer and p.sub == "u@example.com" and p.profile == :production + + assert {:error, :inactive} = + Trinity.MCP.Auth.External.authorize( + conn.(FakeAS.mint(as, %{"exp" => now - 120})), + config + ) + + assert {:error, :inactive} = + Trinity.MCP.Auth.External.authorize(conn.("opaque-and-unknown"), config) + + assert {:error, {:wrong_audience, "http://other/mcp"}} = + Trinity.MCP.Auth.External.authorize( + conn.(FakeAS.mint(as, %{"aud" => "http://other/mcp"})), + config + ) + + assert {:error, :personal_token_in_production} = + Trinity.MCP.Auth.External.authorize( + conn.(FakeAS.mint(as, %{"profile" => "personal"})), + config + ) + + assert {:error, :no_bearer} = + Trinity.MCP.Auth.External.authorize(Plug.Test.conn(:post, "/mcp"), config) + end + + test "the configuration refuses an incomplete production profile", %{as: as} do + assert {:error, {:issuer, _}} = Config.new(profile: :production, resource: @resource) + + assert {:error, {:issuer, _}} = + Config.new(profile: :production, issuer: "not a url", resource: @resource) + + assert {:error, {:resource, _}} = Config.new(profile: :production, issuer: as.issuer) + + assert {:error, {:profile, _}} = + Config.new(profile: :enterprise, issuer: as.issuer, resource: @resource) + + assert {:ok, %Config{audience: @resource}} = + Config.new(profile: :production, issuer: as.issuer, resource: @resource) + + assert_raise ArgumentError, ~r/mcp auth configuration/, fn -> + Config.new!(profile: :production) + end + end + test "the issuer's metadata is refused when it names another issuer", %{as: as} do assert {:ok, %{"issuer" => iss}} = Trinity.MCP.Auth.Discovery.authorization_server(as.issuer) assert iss == as.issuer From 601bac17ba4e963856d3521d0f3be995d0b31585 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 06:51:27 -0400 Subject: [PATCH 07/13] feat(s062): the /mcp page shows a server's 401 challenge and 'authorize', which begins the client role's flow and sends the owner to the authorization server Signed-off-by: Ayla Croft --- lib/trinity_web/live/mcp_live.ex | 36 +++++++++++++++++++-- test/trinity_web/live/mcp_live_test.exs | 42 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/lib/trinity_web/live/mcp_live.ex b/lib/trinity_web/live/mcp_live.ex index 0365c85..1197518 100644 --- a/lib/trinity_web/live/mcp_live.ex +++ b/lib/trinity_web/live/mcp_live.ex @@ -7,11 +7,14 @@ defmodule TrinityWeb.MCPLive do remove; the tools of a server with their effect and tier; and a form to add a server (stdio: a command with arguments and the environment variables to pass; http: a URL). Everything here is `Trinity.MCP.Servers`; the page refreshes itself every two seconds - while open, since a client's health changes on its own. + while open, since a client's health changes on its own. Slice 062: a server that answered + `401` with a resource metadata URL shows the challenge and "authorize", which begins the + client role's flow (`Trinity.MCP.AuthHost.client_begin/2`) and sends the owner to the + authorization server; the callback lands at `/oauth/callback` and returns here. """ use TrinityWeb, :live_view - alias Trinity.MCP.{Client, ServerConfig, Servers} + alias Trinity.MCP.{AuthHost, Client, ServerConfig, Servers} alias Trinity.Tools @refresh_ms 2_000 @@ -76,6 +79,21 @@ defmodule TrinityWeb.MCPLive do end end + def handle_event("authorize", %{"prm" => prm_url}, socket) do + case AuthHost.client_begin(prm_url, redirect_uri: url(~p"/oauth/callback")) do + {:ok, authorize_url} -> + {:noreply, redirect(socket, external: authorize_url)} + + {:error, reason} -> + {:noreply, + put_flash( + socket, + :error, + gettext("Authorization could not begin: %{reason}", reason: AuthHost.describe(reason)) + )} + end + end + def handle_event("view", %{"name" => name}, socket), do: {:noreply, assign(socket, viewing: name)} @@ -199,6 +217,20 @@ defmodule TrinityWeb.MCPLive do

{info.last_error}

+
+ {gettext("Needs authorization")} + {info.auth_challenge} + +
{gettext("Tools")} diff --git a/test/trinity_web/live/mcp_live_test.exs b/test/trinity_web/live/mcp_live_test.exs index b0d98d7..2e06fa7 100644 --- a/test/trinity_web/live/mcp_live_test.exs +++ b/test/trinity_web/live/mcp_live_test.exs @@ -139,6 +139,48 @@ defmodule TrinityWeb.MCPLiveTest do refute has_element?(view, "#answer-#{aid}") end + # Slice 062: the client role's one piece of page: the challenge and "authorize". + test "a server that answered 401 with resource metadata shows the challenge; authorize sends the owner to the AS", + %{conn: conn} do + as = Trinity.MCP.FakeAS.start!() + {:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) + resource = "http://127.0.0.1:#{port}/mcp" + store = Path.join(System.tmp_dir!(), "oauth-store-#{System.unique_integer([:positive])}") + + Application.put_env(:trinity, :mcp_auth, + profile: :production, + issuer: as.issuer, + resource: resource, + client_id: "trinity-page", + store_dir: store + ) + + Trinity.MCP.AuthHost.reload() + + on_exit(fn -> + Application.delete_env(:trinity, :mcp_auth) + Trinity.MCP.AuthHost.reload() + Trinity.MCP.Auth.JWKS.forget(as.issuer) + File.rm_rf(store) + end) + + start!("prot", {:http, resource}) + await("prot", :down) + + {:ok, view, _} = live(conn, ~p"/mcp") + assert has_element?(view, "#server-prot", "Needs authorization") + assert has_element?(view, "#server-prot", "/.well-known/oauth-protected-resource/mcp") + + assert {:error, {:redirect, %{to: url}}} = + view |> element("#server-prot button", "authorize") |> render_click() + + assert String.starts_with?(url, as.issuer <> "/authorize?") + query = url |> URI.parse() |> Map.get(:query) |> URI.decode_query() + assert query["client_id"] == "trinity-page" and query["resource"] == resource + assert query["redirect_uri"] == TrinityWeb.Endpoint.url() <> "/oauth/callback" + assert query["code_challenge_method"] == "S256" + end + test "declining a server's request denies the approval", %{conn: conn} do request = %{ "kind" => "mcp_input", From d03e15dac79a3f8782953f5f672e2d2b4afcdc7b Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 06:56:41 -0400 Subject: [PATCH 08/13] docs(s062): the amendment: EMA leaves the slice as a deferral under the owner's decision (AC3, AC6 removed; the profile table); the recovery, the deviations and the findings in NOTES; docs 01, 06, 07, 08, 09, mcp-server and the README as built; TRINITY_MCP_AUTH_* in runtime.exs; the ROADMAP row and its change-log line Signed-off-by: Ayla Croft --- README.md | 18 +++++- ROADMAP.md | 3 +- config/runtime.exs | 51 +++++++++++++++ docs/01-architecture.md | 2 +- docs/06-risk-register.md | 2 +- docs/07-security-model.md | 68 ++++++++++++++++++-- docs/08-standards.md | 4 +- docs/09-standards-register.md | 4 +- docs/mcp-server.md | 33 ++++++++++ slices/062-mcp-server-auth/NOTES.md | 97 +++++++++++++++++++++++++++++ slices/062-mcp-server-auth/SLICE.md | 26 ++++++-- 11 files changed, 289 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index cb04105..157d8f7 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,17 @@ them: 23 slices, each merged with a merge commit and tagged `slice/NNN` (`git ta the assistant's own, and one that needs your approval waits for it on the permissions page while the client carries a sealed state it can retry with. A headless release runs the same tree as a server, in a container or under systemd (`docs/mcp-server.md`). +- **Knows who is calling.** Trinity's MCP server is an OAuth 2.1 resource server for the + authorization server you already run (`production`: a valid audience-bound token or `401` + with the metadata a client needs to find that server; every refusal and every call is + receipted with the caller's issuer, subject and scope, and the token itself never reaches a + tool, a message or a receipt) or, for your own clients on your own machine, a small + authorization server of its own (`personal`: a consent page in your browser, short-lived + tokens that no production deployment accepts). A token's scope says what it may ask for; + the permission gate still decides whether it happens. As a client, Trinity answers a + protected server's `401` with "authorize" on the `/mcp` page and keeps the token it obtains. + Trinity issues no production authority: the personal issuer refuses to start under an + external authority adapter, by construction (`docs/07-security-model.md`). - **Runs on a schedule.** Tasks on the `/tasks` page: a prompt, a persona, the skills to hint, and when (a cron expression, a one-shot time, or a phrase like "every weekday at 9am" the model turns into cron). Each run is a fresh conversation you can open, its result waits on @@ -54,7 +65,7 @@ them: 23 slices, each merged with a merge commit and tagged `slice/NNN` (`git ta curator that marks old memories stale and archives the untouched ones (never deleting) run on the same queues; `/oban` shows the jobs. -Not there yet: MCP's authorization roles (M5a), messaging gateways and subagents (M5b), +Not there yet: messaging gateways and subagents (M5b), the native desktop shell and signed releases (M6), executable skills in a sandbox (M7). `ROADMAP.md` carries the live status of every slice, and the [Milestones](#milestones) section below explains how to read it. @@ -203,8 +214,9 @@ pinned at 0.8.0 and reached only through the `Trinity.MCP` boundary; slice 059's measures what it ships against the 2026-07-28 checklist, and the MCP phase (milestone M5a) builds Trinity's client driver (slice 060: a thin driver over the core's decoder and validator, stdio and Streamable HTTP, 2026-07-28 preferred and 2025-11-25 as the fallback, a server's mid-call -question answered as an approval), the approval loop and authorization above it. The client side -and the authorization server are Trinity's own work. `docs/adr/0007-mcp-2026-07-28-target-and-library.md` +question answered as an approval), the approval loop and authorization above it (slice 062: the +resource server, the client role and the personal profile's authorization server, all above the +core). The client side and the authorization roles are Trinity's own work. `docs/adr/0007-mcp-2026-07-28-target-and-library.md` records the protocol target and the layering. ## License diff --git a/ROADMAP.md b/ROADMAP.md index e8bbde0..f3125e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,7 +55,7 @@ standards register names the rows that ask for them. | 059 | MCP capability gap against beam_mcp, and the server seam probe | 6 MCP | S/M | 020 | approved | | 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved | | 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | approved | -| 062 | MCP authorization: OAuth client role, RS, embedded AS, Enterprise Managed Authorization (ID-JAG) | 6 MCP | L | 061 | in_progress | +| 062 | MCP authorization: OAuth client role, resource server, personal-profile authorization server (EMA removed 2026-09-22) | 6 MCP | L | 061 | in_progress | | 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | planned | | 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned | | 072 | Gateway: Discord (Nostrum) | 7 Gateways | S | 070 | planned | @@ -123,3 +123,4 @@ After 012, multiple branches are independent. If running more than one coding ag | 2026-09-06 | Plan corrections, round 2. **Supersedes the slice count in the entry above:** that entry derived **38** on 2026-09-05, before slices 082, 083 and 084 were withdrawn and slice 034 was added. Re-derived this date, not adjusted by arithmetic: `find slices -name SLICE.md | wc -l` → **36**. The entry above is not rewritten. `scripts/plan_check.sh` now enforces this count, the acceptance-criteria numbering, the Definition-of-Done ranges, ROADMAP/SLICE.md agreement, and the absence of references to paths not in `git ls-files`. | | 2026-09-20 | The MCP phase replanned under owner decisions of 2026-09-08, recorded in ADR-0007 decisions 5 to 8: beam_mcp 0.8.0 is the server core; 059 measures the capability gap and probes the `:server` seam (S/M); 060 is Trinity's thin driver (L, the M/L condition decided); the OAuth client role moves from 060 to 062; 061 carries a named blocker on the MRTR wrapper; the four `M5 Always-on` headers on 059 to 062 read M5a Automates, as this file has since 2026-09-08. R14 re-scoped, R15 closed. Re-derived this date: `find slices -name SLICE.md | wc -l` → **36**. | | 2026-09-20 | Four slices added under the accepted 2026-09-20 plan: 002 (supply chain, early: SBOM, provenance, TLS floor; counted in M9), 003 (FIPS build leg from source; counted in M2 because it runs 024's FIPS properties), 025 (encryption at rest and the key-custody seam) and 026 (store-and-forward receipts; blocked on the external plane's answer). Re-derived this date: `find slices -name SLICE.md | wc -l` → **40**. | +| 2026-09-22 | Slice 062 amended under the owner's decision: the production profile is an OAuth client and resource server against an external authorization server, the embedded authorization server is the non-default personal profile refused under an external authority adapter, and Enterprise Managed Authorization (ID-JAG redemption at Trinity's own server) leaves the slice as a deferral with a lift condition (slice 062 NOTES, "Amendment"). AC3 and AC6 removed; the row's title follows. | diff --git a/config/runtime.exs b/config/runtime.exs index bd53464..5147afe 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -58,6 +58,57 @@ if config_env() != :test do config :trinity, :web, search_provider: Trinity.Tools.Web.SearchProvider.Brave end +# Slice 062: the MCP authorization profile. `TRINITY_MCP_AUTH_PROFILE` is `local` (the default: +# 061's static bearer on the loopback, no JWT anywhere), `production` (an OAuth 2.1 resource +# server against the external authorization server `TRINITY_MCP_AUTH_ISSUER` names, for this +# server's identifier `TRINITY_MCP_AUTH_RESOURCE`, its `/mcp` URL; Trinity issues nothing) or +# `personal` (the same resource server plus the embedded authorization server on the owner's +# machine; refused at boot under an external authority adapter). `TRINITY_MCP_AUTH_INTROSPECTION=1` +# asks the issuer about opaque tokens (RFC 7662) instead of validating JWTs, with +# `TRINITY_MCP_AUTH_INTROSPECTION_CLIENT` and `_SECRET` as the resource server's credentials when +# the issuer wants them; `TRINITY_MCP_AUTH_DCR=1` enables RFC 7591 registration (the embedded +# server's endpoint; the client role's registration when an issuer offers it); +# `TRINITY_MCP_AUTH_CLIENT_ID` is the client role's pre-registered identity at the enterprise +# authorization server, `TRINITY_MCP_AUTH_CLIENT_METADATA_URL` its Client ID Metadata Document. +# The suite sets `config :trinity, :mcp_auth` itself (docs/07-security-model.md). +auth_profile = System.get_env("TRINITY_MCP_AUTH_PROFILE") + +if config_env() != :test and auth_profile not in [nil, ""] do + present = fn name -> + case System.get_env(name) do + nil -> nil + "" -> nil + value -> value + end + end + + introspection_credentials = + case {present.("TRINITY_MCP_AUTH_INTROSPECTION_CLIENT"), + present.("TRINITY_MCP_AUTH_INTROSPECTION_SECRET")} do + {id, secret} when is_binary(id) and is_binary(secret) -> {id, secret} + _ -> nil + end + + profile_atom = + case auth_profile do + "local" -> :local + "production" -> :production + "personal" -> :personal + other -> raise "TRINITY_MCP_AUTH_PROFILE is not local, production or personal: #{other}" + end + + config :trinity, :mcp_auth, + profile: profile_atom, + issuer: present.("TRINITY_MCP_AUTH_ISSUER"), + resource: present.("TRINITY_MCP_AUTH_RESOURCE"), + audience: present.("TRINITY_MCP_AUTH_AUDIENCE"), + introspection: System.get_env("TRINITY_MCP_AUTH_INTROSPECTION") in ["1", "true"], + introspection_credentials: introspection_credentials, + dcr: System.get_env("TRINITY_MCP_AUTH_DCR") in ["1", "true"], + client_id: present.("TRINITY_MCP_AUTH_CLIENT_ID"), + client_metadata_url: present.("TRINITY_MCP_AUTH_CLIENT_METADATA_URL") +end + # Slice 013. `TRINITY_FAKE_PROVIDER=1 mix phx.server` runs the chat on the scripted provider: # the registry becomes the fake's two entries and a fresh stream answers with its markdown # demo, so the UI can be exercised and screenshotted with no key and no egress. Development diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 75e9b95..123f21c 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -90,7 +90,7 @@ without anything failing. | `Trinity.Memory` | Always-on tiers with their budget and consolidator (030), search (031), semantic store and retrieval (032), compaction (023) | LLM (summaries/embeddings), Repo | | `Trinity.Skills` | SKILL.md parsing, registry, loader, manager, scanner (as built at 040: parser, sources, registry, index, the three tools; at 041: staging, promotion, manager, scanner, diff, learn, `skill_manage` and `learn`) | Repo, Permissions, **Effects**, **Receipts**, Sandbox (as built at 041: Tools, Memory, Permissions, Receipts and LLM; Effects is not a dependency: the promotion is not a tool call, it writes its own effect receipt; Tools never depends on Skills) | | `Trinity.Scheduler` | Oban workers for agent tasks, delivery (as built at 050: the `tasks` and `task_runs` rows, `next_run_at` computed from a cron expression or a one-shot datetime, `Workers.Tick` (the one Cron plugin entry, every minute, enqueuing `Workers.RunTask` for what is due), `RunTask` (one turn in a fresh `origin: "cron"` session, the summary, the delivery), `Delivery` with `Delivery.Desktop`, `Parse.human/2` (a phrase to cron through the model, checked by Oban's parser); Oban itself on the app's repo, Lite on SQLite and Basic on Postgres, queues `agent_tasks`, `memory`, `maintenance`; the observer as `Trinity.Memory.ObserverWorker` and the curator as `Trinity.Memory.Curator` live in Memory) | Sessions, LLM, **Repo** (Gateways at 070) | -| `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060); at 061: `Server` (the module above the core, `@behaviour BeamMCP.Server`, handed to both transports through `:server`; `tools/call` answered through the membrane, everything else the core's), `Server.Catalog` (the exported tools, sorted), `Server.Exports` (the configured names, `:catalog` refused), `Server.Session` (the `origin: "mcp"` session with its own persona), `Server.Envelope` and `Server.Replay` (the sealed `requestState` and the nonce table), `Server.Auth.Local` (the static bearer, 062's seam on the server side), `Server.Plug` (the `POST /mcp` endpoint, mounted in `TrinityWeb.Endpoint` ahead of the parsers) and `Server.Stdio` with `mix trinity.mcp.stdio`) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`; at 061 the core's `Server`, `Catalog`, `ToolSpec` and both transports are listed too, and `Trinity` exports `Tools.Registry` and `Effects.Runner` to it) | +| `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060); at 061: `Server` (the module above the core, `@behaviour BeamMCP.Server`, handed to both transports through `:server`; `tools/call` answered through the membrane, everything else the core's), `Server.Catalog` (the exported tools, sorted), `Server.Exports` (the configured names, `:catalog` refused), `Server.Session` (the `origin: "mcp"` session with its own persona), `Server.Envelope` and `Server.Replay` (the sealed `requestState` and the nonce table), `Server.Auth.Local` (the static bearer, 062's seam on the server side), `Server.Plug` (the `POST /mcp` endpoint, mounted in `TrinityWeb.Endpoint` ahead of the parsers) and `Server.Stdio` with `mix trinity.mcp.stdio`; at 062: `Trinity.MCP.Auth`, a boundary of its own under `Trinity.MCP` with `deps: []` (the behaviour `authorize/2` answering a `Principal`, never the token; `Config`, `Token`, `JWKS`, `Discovery`, `Scopes`; the profiles `Local`, `External` and `Embedded` with `Embedded.Keys`; the client role `Client` with `Client.Store`), and `AuthHost` in `Trinity.MCP` (the configuration from the tree's paths and the authority selection, the personal profile's boot, the refusal receipts, the client role's flow for the page); `Server.Auth.Local` is gone, `Server.Plug` authorizes ahead of the transport and answers `401` with a challenge, `Server` checks scope before the gate) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`; at 061 the core's `Server`, `Catalog`, `ToolSpec` and both transports are listed too, and `Trinity` exports `Tools.Registry` and `Effects.Runner` to it) | | `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing | Sessions, **Permissions**, PubSub | | `Trinity.Subagents` | Delegation, result collection | Sessions, Tools | | `Trinity.Sandbox` | Luerl runners, resource limits | none | diff --git a/docs/06-risk-register.md b/docs/06-risk-register.md index b2b0669..0bd8846 100644 --- a/docs/06-risk-register.md +++ b/docs/06-risk-register.md @@ -23,4 +23,4 @@ | R22 | Single maintainer; Growth needs two unaffiliated production users and commits from two orgs | High | Med | 6-month Sandbox checkpoint | Sandbox tolerates it; GOVERNANCE.md documents intent to grow; recruit co-maintainers via the Elixir community once public | 120, 122 | | R24 | A native NIF in the desktop bundle: `mdex_native` (the markdown renderer's Rust core, added at 013) is the first, and slice 001 proved the packaging chain with a pure-BEAM release | Med | Med | The `package` workflow fails on one of its three operating systems after 013, or 100's cross-target build finds no artifact for a target | `rustler_precompiled` ships artifacts for every target the chain builds for (013 NOTES.md, measured against the checksum file); the `package` run on 013's `mix.lock` change is the first proof on three runners; lift condition: that run green on all three, cited in 013's PROOF.md; a fallback is `MDEX_NATIVE_BUILD=1` with the pinned Rust toolchain already in the tree | 013, 100 | | R24 | Fired 2026-09-20, in slice 013: neither precompiled `mdex_native` artifact loads in Burrito's musl ERTS (both need glibc's `libgcc_s`; `_dl_find_object: symbol not found`). Lifted the same day by the owner's decision: the linux package builds the NIF from source for musl with Zig as the linker (`scripts/zig-cc-musl`, config/config.exs, `rustler` at build time), measured on a fresh install of the packaged binary rendering the real provider's answer; the `--smoke` path now prints `TRINITY_SMOKE_MARKDOWN=ok` and exits 3 otherwise, and the `package` workflow reads that line on every target, so a dead NIF cannot pass again. Residual: macOS and Windows use the precompiled artifact, and the `package` workflow has yet to run green there since the slice/011 tag (an older failure at "Serves HTTP 200", now diagnosed by the step's log). The row above stands as written | none | none | none | none | 013 | -| R23 | EMA / ID-JAG is beta everywhere (vendors label it so); IETF draft still moving | Med | Med | Spec revision breaks the exchange | Keep the AS small and behaviour-driven; pin the draft version in the ADR; conformance tests against two IdPs' documented flows | 062 | +| R23 | EMA / ID-JAG is beta everywhere (vendors label it so); IETF draft still moving | Low (re-scoped 2026-09-22) | Med | Spec revision breaks the exchange at the external authorization server, which is where it happens: Trinity redeems no assertion since the owner's decision of 2026-09-22 (slice 062 NOTES) | The resource server validates whatever audience-bound token that server issues, so a draft revision is that server's upgrade, not Trinity's; the row stays until the deferral lifts | 062 | diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 4365d85..5a7399b 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -193,10 +193,12 @@ root loads as any other and is not scanned (a follow-up in the slice's NOTES). - **Exports are a closed list** the operator configures: core entries with effect `:none` or `:artifact`; `:catalog` is refused at boot and never listed; the list is sorted, so `tools/list` is deterministic. -- **A bearer before the body.** `Trinity.MCP.Server.Auth.Local`: the token from the environment or the - generated file, compared in constant time in the transport's `:authorize` hook; a refusal decodes - nothing. Loopback is the default bind; a wider bind is the operator's setting and belongs behind a - proxy that authenticates the web pages too (062 brings the OAuth resource server). +- **A bearer before the body.** At 061 `Trinity.MCP.Server.Auth.Local`: the token from the environment or + the generated file, compared in constant time in the transport's `:authorize` hook; a refusal decodes + nothing. Since 062 that module is `Trinity.MCP.Auth.Local`, the `:local` profile behind the + authorization boundary (next section), and the plug authorizes ahead of the transport; a refusal is + `401` with a challenge rather than 061's `403`. Loopback is the default bind; a wider bind is the + operator's setting and belongs behind a proxy that authenticates the web pages too. - **Approvals over the wire are the owner's, never the client's.** A held call answers `input_required` with a `requestState` sealed by `Trinity.MCP.Server.Envelope`: AES-256-GCM under `/mcp-state.key`, binding the approval id, the session, the call id, the tool, a digest of @@ -216,6 +218,64 @@ root loads as any other and is not scanned (a follow-up in the slice's NOTES). already has the host's privileges. Its standard output is the wire and the VM's log is moved to standard error. +## MCP authorization (Slice 062, as built) + +Identity is not authority (ADR-0008 decision 4). Everything in this section answers *who is calling and +with what scopes*; whether an effect happens is the gate's answer, or the selected authority adapter's, +and nothing here changes that. The owner's decision of 2026-09-22 shapes the profiles: production is an +OAuth client and resource server against an external enterprise authorization server; the embedded +authorization server is a non-default personal profile that cannot be the path minting authority for a +regulated effect. + +- **Three profiles, one behaviour.** `config :trinity, :mcp_auth, profile:` names `:local` (the default: + 061's static bearer on the loopback, no JWT anywhere), `:production` (Trinity as an OAuth 2.1 resource + server for the external issuer the configuration names; Trinity issues nothing) or `:personal` (the same + resource server plus a small authorization server for the owner's own clients on the owner's machine). + `Trinity.MCP.Auth` is the boundary: `authorize/2` answers with a principal (issuer, subject, scopes, + client, profile) and never with the token; `deps: []` on the tree, so nothing of sessions, tools or + receipts is reachable from it (AC7, held by the boundary compiler and a census test). +- **Audience-bound or 401.** In the production and personal profiles a bearer is a JWT validated before + the body is read: `alg` in the allow list (ES256, ES384, EdDSA, RS256; never `none`, never a shared + secret), the signature under the issuer's published key by `kid` (RFC 8414 or OIDC discovery of the + issuer, its JWKS cached and refreshed once on an unknown `kid`), `iss` equal to the configured issuer, + `aud` holding this server's resource identifier, `exp` present and future, `nbf` honoured when present. + With `introspection: true` an opaque token is asked about at the issuer's RFC 7662 endpoint and its + answer is checked the same way. A refusal is `401` with `WWW-Authenticate: Bearer resource_metadata=…` + (RFC 9728; the metadata names the issuer), a decision receipt of outcome `deny`, basis `auth`, on the + MCP session's scope, naming the reason and what could be read of the caller (an unverified `iss` and + `sub`, marked so), and nothing decoded. 061's static bearer means nothing in these profiles. +- **The personal profile cannot mint production authority.** It refuses to start (the configuration + errors, the boot raises) when `Trinity.Authority.impl/0` is not `Trinity.Authority.Local`: a regulated + deployment has no embedded issuer by construction. Every token it mints carries `"profile": "personal"`, + and the production profile refuses that claim whatever key signed it. Under the production profile no + signing key exists, the JWKS and the authorization-server metadata answer `404`, and the token and + registration endpoints do too. +- **The personal profile's authorization server**, when chosen: RFC 8414 metadata, authorization code with + PKCE (S256 only), RFC 8707 `resource` required and equal to this server, RFC 9207 `iss` on every + response, the owner's consent in the browser as the login, Client ID Metadata Documents (the client id is + the `https` URL of its document; `http` only on a loopback host) with RFC 7591 registration only behind + `dcr: true`, ES256 tokens of ten minutes bound to this server, keys under the custody directory rotated + by `kid` (an old key's tokens verify until they expire; AC5). +- **Scope before the gate.** A `tools/call` under a principal is checked against the tool's effect before + the gate is asked: `trinity:tools:read` (or `trinity:recall`) for a `:none` tool, `trinity:tools:artifact` + for an `:artifact` one, nothing for `:catalog` (never exportable). A miss is a JSON-RPC error and a + decision receipt of outcome `deny`, basis `scope`; the gate is not consulted. A hit reaches the gate as + any call does: a scope is what the token may ask for, never an allow rule. +- **No token material past the boundary.** The principal rides in `Trinity.Tools.Context.principal` in + its receipt form; every decision and query receipt of the call carries `subject.principal` with `iss`, + `sub`, `scope`, `client_id` and `profile`. The one reader of the `Authorization` header under `lib/` is + `Trinity.MCP.Auth.bearer/1` (a census test); the tests scan receipts, the MCP session's messages and the + client's state for a JWT prefix and find none. +- **The client role** (`Trinity.MCP.Auth.Client`, above 060's driver): on a `401` naming resource metadata + the driver records the challenge and the `/mcp` page offers "authorize"; the host fetches the PRM and the + issuer's metadata (its `issuer` must equal the one asked for), begins the code flow with PKCE, `state` + and `resource`, sends the owner to the authorization server, and finishes at `/oauth/callback` (`iss` + checked against the issuer, the code exchanged with the verifier). The token is stored per resource + under `/secrets/oauth/` (mode 0600, until slice 100's keychain) and the driver presents it, + performing no flow of its own (a census over its files). The client identifies itself by the configured + `client_id` (pre-registered at the enterprise server), by a Client ID Metadata Document URL, or by + registering once when the server offers it and `dcr: true`. + ## Secrets - Env vars in dev; OS keychain via `Trinity.Secrets` from Slice 100 (Tauri stronghold/store or a keychain NIF). diff --git a/docs/08-standards.md b/docs/08-standards.md index 0d36bf9..197c3fa 100644 --- a/docs/08-standards.md +++ b/docs/08-standards.md @@ -30,8 +30,8 @@ Microsoft, OpenAI. Trinity targets AAIF-governed standards first; vendor-specifi | **MCP 2026-07-28** | agent ↔ tools/data | Current stable. Date-versioned; there is no "2.0". Tier-1 SDKs went to major version 2 alongside it, which is where the nickname comes from; do not use it in any record. Tier-1 SDKs v2 shipped day-of. | **Primary target** for both client and server. Serve 2025-11-25 clients via compatibility for ≥ 12 months. | 059, 060, 061, 062 | | MCP 2025-11-25 | " | Legacy; 12-month deprecation policy applies to removed features | Client: connect to older servers. Server: compat profile. | 060, 061 | | MCP 2024-11-05 | " | Obsolete | Not a target. Trinity speaks 2026-07-28 and serves 2025-11-25 for compatibility; anything older is out of scope. | none | -| MCP extensions: **Tasks**, **MCP Apps**, **Enterprise Managed Authorization (EMA)** | " | Official, versioned extensions. EMA = the IETF Identity Assertion JWT Authorization Grant (ID-JAG, "Cross-App Access"): the enterprise IdP mints an assertion via RFC 8693 token exchange; the MCP server's **own authorization server** redeems it via the RFC 7523 JWT-bearer grant and issues its access token. Adopted by MCP June 2026; vendors label it beta. | Tasks: yes. MCP Apps: later. **EMA: yes**, Trinity ships a small embedded AS so the enterprise-managed flow works without a third-party auth product (062). Identity is not authority: the gate, or the selected authority adapter, still decides whether an effect happens. | 061, 062 | -| MCP authorization (OAuth 2.1, RFC 9728 PRM, RFC 8707 resource indicators, RFC 8414/OIDC discovery, CIMD, RFC 9207) | auth | Normative in 2026-07-28; DCR deprecated → CIMD | Trinity's MCP server = OAuth 2.1 **resource server** for non-loopback clients; the authorization server is an owner decision, and it is an identity concern rather than an authority one (ADR-0008). Client = OAuth 2.1 client with PKCE + resource indicators; CIMD first, DCR fallback. | 060, 062 | +| MCP extensions: **Tasks**, **MCP Apps**, **Enterprise Managed Authorization (EMA)** | " | Official, versioned extensions. EMA = the IETF Identity Assertion JWT Authorization Grant (ID-JAG, "Cross-App Access"): the enterprise IdP mints an assertion via RFC 8693 token exchange; the MCP server's **own authorization server** redeems it via the RFC 7523 JWT-bearer grant and issues its access token. Adopted by MCP June 2026; vendors label it beta. | Tasks: yes. MCP Apps: later. **EMA: satisfied by the external authorization server** (owner decision 2026-09-22, recorded in slice 062 NOTES): the enterprise IdP's ID-JAG is redeemed at the enterprise authorization server, which issues the audience-bound token Trinity's resource server validates; Trinity redeems no assertion and issues no production token. The embedded authorization server is the non-default personal profile, refused under an external authority adapter. Identity is not authority: the gate, or the selected authority adapter, still decides whether an effect happens. | 061, 062 | +| MCP authorization (OAuth 2.1, RFC 9728 PRM, RFC 8707 resource indicators, RFC 8414/OIDC discovery, CIMD, RFC 9207) | auth | Normative in 2026-07-28; DCR deprecated → CIMD | Trinity's MCP server = OAuth 2.1 **resource server** (RFC 9728 metadata, `401` with a challenge, JWT validation by the issuer's JWKS or RFC 7662 introspection) against the external authorization server the production profile names; an identity concern rather than an authority one (ADR-0008). Client = OAuth 2.1 client with PKCE + resource indicators + RFC 9207 `iss`; a pre-registered id or CIMD first, DCR only when allowed and offered. The personal profile's own authorization server: RFC 8414, code + PKCE (S256), CIMD, DCR behind a flag. Built at 062. | 060, 062 | | **Agent Skills** (agentskills.io, `SKILL.md`) | procedural knowledge | Open spec (Dec 2025), adopted by Claude Code, Codex, Gemini CLI, Copilot, Cursor, goose, Letta, 20+ others; `npx skills` distribution | Already ADR-0006. Add: honour `allowed-tools` frontmatter (experimental) as a permission hint; support `npx skills`-style GitHub install. | 040, 041 | | **AGENTS.md** | project context | AAIF founding project; simple markdown convention | Load `AGENTS.md` from the session's project root(s) into the context tier, with precedence and size cap. | 033 | | **A2A v1.0** | agent ↔ agent | Stable, Apache-2.0, LF/AAIF; Agent Cards, Tasks, streaming | Not v1. Optional later: publish an Agent Card and accept A2A tasks so other agents can delegate to Trinity; map A2A Task ↔ Trinity subagent. | 081 (optional) | diff --git a/docs/09-standards-register.md b/docs/09-standards-register.md index be4e12f..f6faaf9 100644 --- a/docs/09-standards-register.md +++ b/docs/09-standards-register.md @@ -46,7 +46,9 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status | Control | Where Trinity satisfies it | Evidence path | Status | Decider | |---|---|---|---|---| | Non-person entity identity from the deployment's PKI, with an accountable sponsor | X.509 per instance; sponsor field in the boot receipt; SPIFFE as an issuance path only | slice 062 amendment note | `not claimed`; no criterion yet | owner | -| Identity is not authority | OAuth and EMA answer who; the gate and the authority adapter answer whether | slice 062, slice 024 | `:unknown` until both land | owner | +| Identity is not authority | OAuth answers who (slice 062: the resource server hands the tool layer a principal, never the token; scope is checked before the gate and is never an allow rule); the gate and the authority adapter answer whether (slice 024) | `test/trinity/mcp/auth/resource_server_test.exs` (a scoped token still reaches the gate), `test/trinity/mcp/auth/boundary_test.exs` (the auth boundary reaches nothing of the tree) | tree property, held by tests since 2026-09-22; the deployment's adapter is the real-world half | owner | +| Trinity issues no production authority | The production profile validates the external issuer's tokens and mints none; the personal profile's issuer refuses to start under an external authority adapter and its tokens are marked and refused in production | `test/trinity/mcp/auth/embedded_test.exs` (refused at boot; no key material in production), `test/trinity/mcp/auth/token_test.exs` (the mark refused) | tree property, held by tests since 2026-09-22 | owner | +| Enterprise Managed Authorization (ID-JAG) | Not in this tree: the external authorization server redeems the assertion (owner decision 2026-09-22, slice 062 NOTES "Deferred") | docs/08 row; slice 062 NOTES | `not claimed`; deferred with a lift condition | owner | ## Supply chain diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 7d4f468..37846f9 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -21,6 +21,39 @@ Two files the server keeps in the data directory: `mcp-server-token`, the bearer (generated once, mode 0600; `TRINITY_MCP_SERVER_TOKEN` overrides it), and `keys/mcp-state.key`, the key that seals the state a held call carries between round trips (docs/07, "MCP server"). +## Authorization profiles (slice 062) + +`TRINITY_MCP_AUTH_PROFILE` (or `config :trinity, :mcp_auth, profile:`) chooses how `/mcp` knows who is +calling. Whatever it says, every call still goes through the permission gate: a token says who you are +and what you may ask for, never that a thing may happen. + +- **`local`** (the default): the static bearer above, on the loopback. No JWT anywhere. +- **`production`**: Trinity is an OAuth 2.1 resource server for an authorization server you already run. + Set `TRINITY_MCP_AUTH_ISSUER` (its issuer URL; Trinity reads its RFC 8414 or OpenID metadata and its + JWKS) and `TRINITY_MCP_AUTH_RESOURCE` (this server's `/mcp` URL as clients reach it: the audience every + token must carry). A request without a valid token is `401` with `WWW-Authenticate: Bearer + resource_metadata="…/.well-known/oauth-protected-resource/mcp"`, which is how a 2026-07-28 client finds + your authorization server on its own. `TRINITY_MCP_AUTH_INTROSPECTION=1` asks the issuer about opaque + tokens instead (RFC 7662; `_INTROSPECTION_CLIENT` and `_SECRET` when it wants credentials). Trinity + issues nothing in this profile and holds no signing key; the static bearer is not a token here. A + token's scopes decide what it may ask for: `trinity:tools:read` for the read-only tools, + `trinity:tools:artifact` for the ones that write (and then the gate asks you, as always). +- **`personal`**: the same resource server plus a small authorization server of Trinity's own, for your + own MCP clients on your own machine. Set `TRINITY_MCP_AUTH_RESOURCE`; the issuer is that URL's origin. + A client that supports Client ID Metadata Documents connects with no registration (its id is the URL of + its document); `TRINITY_MCP_AUTH_DCR=1` opens RFC 7591 registration for the rest. Each authorization is a + consent page in your browser (`/oauth/authorize`), the token lives ten minutes, and it carries a mark + (`"profile": "personal"`) that the production profile refuses whatever key signed it. This profile + refuses to start when an external authority adapter is selected (`TRINITY_AUTHORITY` other than + `local`): a regulated deployment has no embedded issuer. + +Trinity as a client of a protected server: add the server on the `/mcp` page as usual; when it answers +`401` with resource metadata, the row shows the challenge and an "authorize" button that sends you to +that server's authorization server and back to `/oauth/callback`. The token is stored under +`/secrets/oauth/` (mode 0600) and presented on every request; `TRINITY_MCP_AUTH_CLIENT_ID` is the +client id you registered there (or `_CLIENT_METADATA_URL` for a metadata document; `_DCR=1` to register on +the spot when the server allows it). + ## Connecting a client over HTTP The URL is `http://127.0.0.1:/mcp` (the port the app prints at start, `4000` in the headless diff --git a/slices/062-mcp-server-auth/NOTES.md b/slices/062-mcp-server-auth/NOTES.md index 8e86e4a..51b3f03 100644 --- a/slices/062-mcp-server-auth/NOTES.md +++ b/slices/062-mcp-server-auth/NOTES.md @@ -144,3 +144,100 @@ empty. Recorded now so the queue is known at G1. Not built here: SAML, MCP Apps, refresh tokens for EMA, the OIDC upstream login for the personal profile's consent (the local owner is the user), the keychain (slice 100), non-person X.509 identity (the SLICE's own "later amendment"). + +## Amendment: EMA leaves the slice, 2026-09-22 + +The owner's answer to the G1 question, quoted: "Remove Enterprise Managed Authorization from this slice. +Do not implement Trinity-as-issuer of production access tokens from an ID-JAG. The external enterprise AS +already issues audience-bound tokens the resource server accepts. ID-JAG/EMA stays out of 062; record it in +NOTES as deferred, not 'personal-profile EMA.'" And the profile table the owner set, as the tree holds it: + +| Profile | Default | Trinity issues tokens? | Allowed under an external authority adapter | +|---|---|---|---| +| `:local` | yes | no (061's static bearer) | yes | +| `:production` | no | never | required for a regulated deployment | +| `:personal` | no | only the embedded AS, on the owner's machine | no: `Config.new/1` errors and the boot raises when `Trinity.Authority.impl/0` is not `Trinity.Authority.Local` | + +Answer (a) of the G1 question, so: AC3 and AC6 are `[removed]` in SLICE.md in this commit (the slice +process: the amendment here, the criteria there, one commit); the SLICE's goal paragraph keeps its EMA +text as history under an amendment note. Nothing of EMA was built, in any profile. + +**Deferred: Enterprise Managed Authorization (ID-JAG redemption at Trinity's authorization server).** +Owner: the product owner. Since: 2026-09-22. Lift condition, checkable by a stranger: a row in +docs/09-standards-register.md that asks Trinity to be the issuer of an access token for an enterprise +identity, carrying the owner's written reversal of the 2026-09-22 decision. Until then the standards row +for EMA reads "satisfied by the external authorization server" (docs/08), R23 in docs/06 is re-scoped to +that server, and no slice builds the redemption. + +## Recovery, 2026-09-22 + +The session that built the tree above crashed mid-implementation. Found on `slice/062-mcp-auth` at +recovery: four commits past `b4a7bb9` (`git log --oneline main..HEAD`: `08c2014` the G1 plan, `34e56d2` +the boundary and the host, `e18e3c9` 061's test at 401, `a29dcb4` the fake AS with AC1, AC4, AC5 green) and +one untracked file, `test/trinity/mcp/auth/embedded_test.exs`, the AC2 test that was red. No stash. The +compile under `--warnings-as-errors` failed on one unused attribute (`@refresh_floor_ms` in JWKS, the +floor its moduledoc promised and the code never wired); a forced compile cleared a stale boundary manifest +naming the deleted `Server.Auth.Local`. The four AC2 failures had two causes, both in the tree and neither +in the test's flow: the personal profile's resource-server half checked `iss` against the resource +identifier while its issuing half wrote the resource's origin (`Embedded.issuer/1` is now the one source), +and the CIMD path accepted an `http://` document URL only under `dcr: true`, which the fake client on a +loopback port never had. The rotation test then found a tie: two keys made in one second sorted by file +name, so the older could stay the signer (`Keys.rotate!/1` now makes the new key strictly newer). + +## Deviations from SLICE.md and the G1 plan (recorded before the commits that carry them) + +1. **EMA and its admin UI (trusted IdPs, domain and group mapping) are out**, above. With them the + SLICE's "admin UI for IdPs, clients (CIMD URLs), scopes": a CIMD client needs no registration (its id + is its document's URL), DCR clients are a file beside the keys, and the scopes are the three + `Trinity.MCP.Auth.Scopes` knows. No settings page was built for the profile either: the profile is + configuration (`config :trinity, :mcp_auth`; `TRINITY_MCP_AUTH_*` in `config/runtime.exs`), and the + `/mcp` page carries the client role's one control, "authorize" on a server that answered `401`. The + owner's instruction at recovery: no product UI beyond that. +2. **`TrustedHeaders` (a gateway that authenticates ahead of Trinity) is not a profile.** The production + profile has two modes, JWT validation and RFC 7662 introspection; a gateway that strips the bearer and + asserts identity in headers would be the one profile where the token is not what the resource server + reads, and the owner's constraint "no tool dispatch without an audience-bound token" is why it is not + here. A follow-up if a deployment asks. +3. **The principal travels in the request process's dictionary** from `Trinity.MCP.Server.Plug` (which + sees the connection) to `Trinity.MCP.Server` (which sees the message), because the core's transport + dispatches in the conn's process and offers no per-request context. The G1 plan said NOTES would say + so. It is set after authorization, read once into `Trinity.Tools.Context.principal`, and dies with the + process; the wrapper never sees the conn. +4. **`Trinity.Tools.Context.principal` is a map in receipt form** (`%{"iss", "sub", "scope", "client_id", + "profile"}`), not the `Principal` struct: `Trinity.Tools` does not depend on `Trinity.MCP.Auth` and + need not; the map is what the receipts carry, so nothing converts on the way out. +5. **A CIMD document over `http://` is accepted on a loopback host and nowhere else.** The CIMD draft + wants `https`; the personal profile runs on the owner's machine, where a local client publishing its + metadata on `127.0.0.1` is a process of the owner's (RFC 8252 section 7.3's reasoning for loopback + redirects, applied to the document). The `dcr: true` coupling the crashed session had is gone. +6. **No `mix trinity.mcp.rotate_key` task.** `Trinity.MCP.AuthHost.rotate_key!/0` rotates (the test + drives it); a task is a follow-up, since the personal profile has no operator to hand it to yet. +7. **The keys and the client role's store are files**, under 024's key custody directory + (`mcp-as-.jwk.json`, mode 0600) and `/secrets/oauth/` (mode 0600, one file per + resource), named in every moduledoc as what stands until slice 100's keychain. +8. **The JWKS cache is a `:persistent_term` per issuer**, refreshed on an unknown `kid` at most once a + minute per issuer (the floor the moduledoc promised, wired at recovery). A flood of unknown kids is + answered from the cache; a rotation is one fetch. + +## Findings + +- **F1.** The fake AS's token endpoint (`test/support/mcp/fake_as.ex`) replaced its whole state with the + codes map on the first exchange (`Agent.get_and_update` with `Map.pop`'s tuple); the crashed session had + not reached it. Found by the AC8 test, fixed in the same commit. +- **F2.** `URI.parse/1` gives `"::1"` as the host of `http://[::1]/…`, so a loopback list holding `"[::1]"` + never matched it. One list (`@loopback_hosts`) holds the three now. +- **F3.** 060's client kept `auth_challenge` after a successful reconnect; the page would have offered + "authorize" on a ready server. Cleared on connect. +- **F4.** The consent page's CSRF input renders `name="_csrf_token" type="hidden" hidden value=…`; the AC2 + test's first regex assumed `name … value` adjacent. + +## Follow-ups + +- A `mix trinity.mcp.rotate_key` task and a prune schedule for the personal profile's old keys + (`Keys.prune!/2` exists; nothing calls it). +- Refresh tokens for the client role (the store holds an access token and its expiry; when it expires the + owner authorizes again). The enterprise AS decides whether it issues them. +- The web pages' own authentication (the reverse proxy stands in; docs/07 "MCP server"). +- The `TrustedHeaders` profile, only if a deployment asks (deviation 2). +- The `[::1]` origin in `Server.Plug.loopback_origins/0` is a browser `Origin` header (bracketed there, + rightly); F2 is about `URI.host`, not that list. diff --git a/slices/062-mcp-server-auth/SLICE.md b/slices/062-mcp-server-auth/SLICE.md index bba584b..3109bb2 100644 --- a/slices/062-mcp-server-auth/SLICE.md +++ b/slices/062-mcp-server-auth/SLICE.md @@ -16,6 +16,15 @@ indicators, CIMD first with DCR as the legacy fallback, RFC 9207 `iss` checking, beam_mcp, whose will-not-implement entry 8 keeps OAuth out of the core; the `:authorize` and `:authorize_body` hooks are where the RS attaches. +**Amended 2026-09-22 under the owner's decision (NOTES.md, "Amendment: EMA leaves the slice").** Quoted: +"production profile is OAuth client + resource server against an external enterprise AS. Embedded AS is a +non-default personal profile and must not be the path that can mint authority for healthcare or DoD effects." +Component (3) below, Enterprise Managed Authorization, is removed from this slice: Trinity does not redeem an +ID-JAG and does not issue production access tokens; the external authorization server does both, and Trinity's +resource server accepts what it issues. The embedded authorization server is the `:personal` profile, not the +default, refused at boot under an external authority adapter, and every token it mints is marked and refused in +`:production`. AC3 and AC6 are removed (below); the goal's text above them is history, not the spec. + **Non-person identity, noted for a later amendment.** A deployment that requires it gives each Trinity instance an X.509 credential from the deployment's own PKI, and the boot receipt carries a sponsor field naming the accountable person. SPIFFE is an issuance path for that credential, not an identity model of its own. No @@ -48,14 +57,15 @@ clients (CIMD URLs), scopes; conformance tests modelled on the spec's flows; doc 1. [auto] RS: unauthenticated → 401 with `resource_metadata`; PRM lists our AS; wrong `aud`/expired → 401; each receipted (tests). 2. [auto] AS: a CIMD-registered test client completes authorization-code + PKCE with resource indicator and receives an audience-bound token that the RS accepts; DCR path works only when explicitly enabled (tests). -3. [paused] EMA: a fake enterprise IdP (in-repo, publishes OIDC discovery + JWKS) issues an ID-JAG for user `u@example.com`; +3. [removed] EMA: a fake enterprise IdP (in-repo, publishes OIDC discovery + JWKS) issues an ID-JAG for user `u@example.com`; our AS validates it, maps domain → org and group → scopes, issues an access token; the RS accepts it; a token for an unverified domain is refused; a replayed/expired ID-JAG is refused (tests, with the exact claim checks named). Paused at G1, 2026-09-22, under the owner's decision that Trinity is not the issuer of production authority (NOTES.md, "Paused"); the owner's answer decides whether it leaves the slice. + Removed 2026-09-22 by the owner's answer (NOTES.md, "Amendment: EMA leaves the slice"): Trinity is not the issuer; the external AS redeems the ID-JAG and AC1 covers the token it issues. Deferred, not built in any profile. 4. [auto] Scope mapping: `trinity:recall` cannot call an `:artifact` tool; `trinity:tools:artifact` can, subject to the gate (tests). 5. [auto] Key rotation: rotate the AS signing key; old tokens verify until expiry via JWKS `kid`; new tokens use the new key (test). -6. [paused] Manual: one real MCP client that supports EMA (per the MCP client matrix at the time) connects through the fake IdP - flow; screenshots. If none is available on the developer machine, recorded as not measured. +6. [removed] Manual: one real MCP client that supports EMA (per the MCP client matrix at the time) connects through the fake IdP + flow; screenshots. If none is available on the developer machine, recorded as not measured. Removed 2026-09-22 with AC3. 7. [auto] The library boundary: `Trinity.MCP.Auth.*` has no dependency on `Trinity.Sessions`/`Trinity.Tools` (boundary check), so it can be extracted as its own package (slice 123). 8. [auto] Client role: against a test AS (in-repo fake supporting CIMD, PKCE and resource indicators), the client @@ -66,12 +76,16 @@ clients (CIMD URLs), scopes; conformance tests modelled on the spec's flows; doc Every `[manual]` criterion below needs a person. Listed here so the owner sees the queue at G1 rather than at review time. - **AC6**: Manual: one real MCP client that supports EMA (per the MCP client matrix at the time) connects through the fake IdP. + Removed 2026-09-22 with AC3. The queue is empty; the personal profile's consent page is in `proof/` as a + screenshot for the owner's eye, not as a criterion. ## Definition of Done -- [ ] gate green · [ ] AC1–8 proven · [ ] docs/08 synced · [ ] ROADMAP → done · [ ] commit + tag +- [ ] gate green · [ ] AC1–8 proven (3 and 6 removed, recorded as such) · [ ] docs/08 synced · [ ] ROADMAP → done · [ ] commit + tag ## Commit & tag -`feat(s062): complete slice 062 (MCP authorization: RS, embedded AS, EMA)` · tag `slice/062` +`feat(s062): complete slice 062 (MCP authorization: client role, resource server, personal-profile AS)` · tag `slice/062` +(amended 2026-09-22; was "RS, embedded AS, EMA") ## Risks / open questions -- R23: ID-JAG draft revision pinned in NOTES.md; re-check at each phase boundary. +- R23: ID-JAG draft revision pinned in NOTES.md; re-check at each phase boundary. Moot for this slice since + 2026-09-22: no ID-JAG is redeemed here (docs/06 re-scopes the row). From 4a902489fe3ff6be6b2f174b433490b5307497f4 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 07:03:48 -0400 Subject: [PATCH 09/13] fix(s062): the personal profile's authorization server is a child of the MCP supervisor, not linked to its booter (the suite found it dying with the test process); the gate's readability and complexity findings (the claim checks in three named steps, the key lookup flattened, the fake AS's route table, alias order); sobelow's traversal findings on the host's own paths, each with its reason Signed-off-by: Ayla Croft --- lib/trinity/mcp/auth/client.ex | 2 +- lib/trinity/mcp/auth/client/store.ex | 12 ++++ lib/trinity/mcp/auth/embedded.ex | 42 ++++++++------ lib/trinity/mcp/auth/embedded/keys.ex | 9 +++ lib/trinity/mcp/auth/jwks.ex | 21 +++---- lib/trinity/mcp/auth/local.ex | 6 ++ lib/trinity/mcp/auth/token.ex | 45 ++++++++------- lib/trinity/mcp/auth_host.ex | 16 +++++- test/support/mcp/fake_as.ex | 56 ++++++++----------- test/trinity/mcp/auth/client_role_test.exs | 2 +- test/trinity/mcp/auth/embedded_test.exs | 14 +++-- .../trinity/mcp/auth/resource_server_test.exs | 6 +- 12 files changed, 136 insertions(+), 95 deletions(-) diff --git a/lib/trinity/mcp/auth/client.ex b/lib/trinity/mcp/auth/client.ex index 03ef903..9488b20 100644 --- a/lib/trinity/mcp/auth/client.ex +++ b/lib/trinity/mcp/auth/client.ex @@ -17,8 +17,8 @@ defmodule Trinity.MCP.Auth.Client do behind `dcr: true` and only when the AS offers registration, by registering once. """ - alias Trinity.MCP.Auth.{Config, Discovery} alias Trinity.MCP.Auth.Client.Store + alias Trinity.MCP.Auth.{Config, Discovery} @default_scope "trinity:tools:read" diff --git a/lib/trinity/mcp/auth/client/store.ex b/lib/trinity/mcp/auth/client/store.ex index 0f79c6f..88c3045 100644 --- a/lib/trinity/mcp/auth/client/store.ex +++ b/lib/trinity/mcp/auth/client/store.ex @@ -12,6 +12,8 @@ defmodule Trinity.MCP.Auth.Client.Store do @pending_ttl_s 600 + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + @doc "The stored token for a resource, when it has not expired." @spec token(Path.t(), String.t()) :: {:ok, map()} | :error def token(dir, resource) do @@ -29,6 +31,9 @@ defmodule Trinity.MCP.Auth.Client.Store do def put_token(dir, resource, %{} = entry), do: write!(token_path(dir, resource), entry) @doc "Forgets a resource's token." + # sobelow_skip reason: Traversal.FileModule: the directory is the host's store and the name + # a sha256 of the resource, never a path of the caller's. + @sobelow_skip ["Traversal.FileModule"] @spec delete_token(Path.t(), String.t()) :: :ok def delete_token(dir, resource) do _ = File.rm(token_path(dir, resource)) @@ -41,6 +46,9 @@ defmodule Trinity.MCP.Auth.Client.Store do do: write!(pending_path(dir, state), Map.put(pending, "created_at", System.os_time(:second))) @doc "Takes a pending request by state (removed once taken; refused when expired)." + # sobelow_skip reason: Traversal.FileModule: the directory is the host's store and the name + # a sha256 of the state, never a path of the caller's. + @sobelow_skip ["Traversal.FileModule"] @spec take_pending(Path.t(), String.t()) :: {:ok, map()} | :error def take_pending(dir, state) when is_binary(state) do path = pending_path(dir, state) @@ -75,10 +83,14 @@ defmodule Trinity.MCP.Auth.Client.Store do defp hash(s), do: :crypto.hash(:sha256, s) |> Base.url_encode64(padding: false) |> binary_part(0, 22) + # sobelow_skip reason: Traversal.FileModule: every path given is the store's plus a sha256-derived name. + @sobelow_skip ["Traversal.FileModule"] defp read(path) do with {:ok, json} <- File.read(path), {:ok, %{} = map} <- Jason.decode(json), do: {:ok, map} end + # sobelow_skip reason: Traversal.FileModule: every path given is the store's plus a sha256-derived name. + @sobelow_skip ["Traversal.FileModule"] defp write!(path, map) do File.mkdir_p!(Path.dirname(path)) File.write!(path, Jason.encode!(map)) diff --git a/lib/trinity/mcp/auth/embedded.ex b/lib/trinity/mcp/auth/embedded.ex index dac46db..5169022 100644 --- a/lib/trinity/mcp/auth/embedded.ex +++ b/lib/trinity/mcp/auth/embedded.ex @@ -27,6 +27,8 @@ defmodule Trinity.MCP.Auth.Embedded do alias Trinity.MCP.Auth.{Config, Discovery, Principal, Scopes, Token} alias Trinity.MCP.Auth.Embedded.Keys + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + @code_ttl_s 600 @request_ttl_s 600 @cimd_ttl_s 3_600 @@ -259,23 +261,25 @@ defmodule Trinity.MCP.Auth.Embedded do do: {:reply, {:error, :registration_disabled}, state} def handle_call({:register, metadata}, _from, state) do - with {:ok, uris} <- redirect_uris(metadata["redirect_uris"]) do - client_id = "dcr-" <> random() - - client = %{ - "client_id" => client_id, - "client_name" => metadata["client_name"] || client_id, - "redirect_uris" => uris, - "token_endpoint_auth_method" => "none", - "grant_types" => ["authorization_code"], - "response_types" => ["code"] - } - - state = put_in(state.clients[client_id], client) - save_clients(state) - {:reply, {:ok, client}, state} - else - {:error, reason} -> {:reply, {:error, reason}, state} + case redirect_uris(metadata["redirect_uris"]) do + {:ok, uris} -> + client_id = "dcr-" <> random() + + client = %{ + "client_id" => client_id, + "client_name" => metadata["client_name"] || client_id, + "redirect_uris" => uris, + "token_endpoint_auth_method" => "none", + "grant_types" => ["authorization_code"], + "response_types" => ["code"] + } + + state = put_in(state.clients[client_id], client) + save_clients(state) + {:reply, {:ok, client}, state} + + {:error, reason} -> + {:reply, {:error, reason}, state} end end @@ -477,6 +481,8 @@ defmodule Trinity.MCP.Auth.Embedded do defp clients_path(%Config{key_dir: dir}), do: Path.join(dir, "mcp-as-clients.json") + # sobelow_skip reason: Traversal.FileModule: the path is the key directory's plus a constant name, never a request's. + @sobelow_skip ["Traversal.FileModule"] defp load_clients(config) do case File.read(clients_path(config)) do {:ok, json} -> @@ -490,6 +496,8 @@ defmodule Trinity.MCP.Auth.Embedded do end end + # sobelow_skip reason: Traversal.FileModule: the path is the key directory's plus a constant name, never a request's. + @sobelow_skip ["Traversal.FileModule"] defp save_clients(%{config: config, clients: clients}) do path = clients_path(config) File.write!(path, Jason.encode!(clients)) diff --git a/lib/trinity/mcp/auth/embedded/keys.ex b/lib/trinity/mcp/auth/embedded/keys.ex index cdf178f..56d8219 100644 --- a/lib/trinity/mcp/auth/embedded/keys.ex +++ b/lib/trinity/mcp/auth/embedded/keys.ex @@ -11,6 +11,8 @@ defmodule Trinity.MCP.Auth.Embedded.Keys do @type entry :: %{kid: String.t(), jwk: JOSE.JWK.t(), created_at: integer(), path: Path.t()} + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + @doc "Every key in the directory, newest first; a fresh one is made when there is none." @spec ensure!(Path.t()) :: [entry()] def ensure!(dir) do @@ -58,6 +60,8 @@ defmodule Trinity.MCP.Auth.Embedded.Keys do end @doc "Drops keys older than `max_age_s`, keeping the newest whatever its age." + # sobelow_skip reason: Traversal.FileModule: the paths are the key directory's own files, listed by this module. + @sobelow_skip ["Traversal.FileModule"] @spec prune!(Path.t(), pos_integer()) :: [entry()] def prune!(dir, max_age_s) do now = System.os_time(:second) @@ -85,6 +89,9 @@ defmodule Trinity.MCP.Auth.Embedded.Keys do %{"keys" => keys} end + # sobelow_skip reason: Traversal.FileModule: the directory is the host's key directory and + # the name a thumbprint's, never a request's. + @sobelow_skip ["Traversal.FileModule"] defp generate!(dir, after_at \\ 0) do File.mkdir_p!(dir) jwk = JOSE.JWK.generate_key({:ec, "P-256"}) @@ -102,6 +109,8 @@ defmodule Trinity.MCP.Auth.Embedded.Keys do %{kid: kid, jwk: JOSE.JWK.from_map(private), created_at: created_at, path: path} end + # sobelow_skip reason: Traversal.FileModule: the path is one the wildcard over the key directory returned. + @sobelow_skip ["Traversal.FileModule"] defp read!(path) do %{"kid" => kid, "created_at" => created_at, "jwk" => private} = path |> File.read!() |> Jason.decode!() diff --git a/lib/trinity/mcp/auth/jwks.ex b/lib/trinity/mcp/auth/jwks.ex index 756e9c7..d93965a 100644 --- a/lib/trinity/mcp/auth/jwks.ex +++ b/lib/trinity/mcp/auth/jwks.ex @@ -16,19 +16,14 @@ defmodule Trinity.MCP.Auth.JWKS do @doc "The key for a `kid` (or the only key, when the token names none) of an issuer." @spec key(String.t(), String.t() | nil, keyword()) :: {:ok, JOSE.JWK.t()} | {:error, term()} def key(issuer, kid, opts \\ []) do - with {:ok, keys} <- keys(issuer, opts) do - case find(keys, kid) do - {:ok, key} -> - {:ok, key} - - :error -> - with {:ok, keys} <- refresh_on_miss(issuer, opts) do - case find(keys, kid) do - {:ok, key} -> {:ok, key} - :error -> {:error, {:unknown_kid, kid}} - end - end - end + with {:ok, keys} <- keys(issuer, opts), + :error <- find(keys, kid), + {:ok, keys} <- refresh_on_miss(issuer, opts), + :error <- find(keys, kid) do + {:error, {:unknown_kid, kid}} + else + {:ok, %JOSE.JWK{} = key} -> {:ok, key} + {:error, _} = error -> error end end diff --git a/lib/trinity/mcp/auth/local.ex b/lib/trinity/mcp/auth/local.ex index d3c2f27..742696b 100644 --- a/lib/trinity/mcp/auth/local.ex +++ b/lib/trinity/mcp/auth/local.ex @@ -13,6 +13,8 @@ defmodule Trinity.MCP.Auth.Local do alias Trinity.MCP.Auth alias Trinity.MCP.Auth.{Config, Principal, Scopes} + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + @impl true def authorize(conn, %Config{} = config) do expected = token(config) @@ -28,6 +30,8 @@ defmodule Trinity.MCP.Auth.Local do def resource_metadata(_config), do: nil @doc "The token in force: the environment variable, else the file's." + # sobelow_skip reason: Traversal.FileModule: the path is the host's token file (Config.token_path), never a request's. + @sobelow_skip ["Traversal.FileModule"] @spec token(Config.t()) :: String.t() def token(%Config{token_env: env, token_path: path}) do case env && System.get_env(env) do @@ -37,6 +41,8 @@ defmodule Trinity.MCP.Auth.Local do end @doc "Generates the token file when absent (mode 0600); returns its path." + # sobelow_skip reason: Traversal.FileModule: the path is the host's token file, never a request's. + @sobelow_skip ["Traversal.FileModule"] @spec ensure_token!(Path.t()) :: Path.t() def ensure_token!(path) when is_binary(path) do unless File.exists?(path) do diff --git a/lib/trinity/mcp/auth/token.ex b/lib/trinity/mcp/auth/token.ex index 9eef3d3..1e02ad9 100644 --- a/lib/trinity/mcp/auth/token.ex +++ b/lib/trinity/mcp/auth/token.ex @@ -77,33 +77,38 @@ defmodule Trinity.MCP.Auth.Token do _ -> {:error, :bad_signature} end - @doc "The claim checks, named." + @doc "The claim checks, named: who issued it and for whom, then when it is good, then the mark." @spec check(map(), Config.t(), integer()) :: :ok | {:error, term()} def check(claims, %Config{} = config, now) do - cond do - claims["iss"] != config.issuer -> - {:error, {:wrong_issuer, claims["iss"]}} - - not audience?(claims["aud"], config.audience) -> - {:error, {:wrong_audience, claims["aud"]}} - - not is_integer(claims["exp"]) -> - {:error, :no_expiry} - - claims["exp"] + @leeway_s <= now -> - {:error, :expired} - - is_integer(claims["nbf"]) and claims["nbf"] - @leeway_s > now -> - {:error, :not_yet_valid} + with :ok <- check_parties(claims, config), + :ok <- check_time(claims, now) do + check_mark(claims, config) + end + end - config.profile == :production and claims["profile"] == "personal" -> - {:error, :personal_token_in_production} + defp check_parties(claims, config) do + cond do + claims["iss"] != config.issuer -> {:error, {:wrong_issuer, claims["iss"]}} + not audience?(claims["aud"], config.audience) -> {:error, {:wrong_audience, claims["aud"]}} + true -> :ok + end + end - true -> - :ok + defp check_time(claims, now) do + cond do + not is_integer(claims["exp"]) -> {:error, :no_expiry} + claims["exp"] + @leeway_s <= now -> {:error, :expired} + is_integer(claims["nbf"]) and claims["nbf"] - @leeway_s > now -> {:error, :not_yet_valid} + true -> :ok end end + # The personal profile's issuer never mints production authority, whatever key signed it. + defp check_mark(%{"profile" => "personal"}, %Config{profile: :production}), + do: {:error, :personal_token_in_production} + + defp check_mark(_claims, _config), do: :ok + defp audience?(aud, expected) when is_binary(aud), do: aud == expected defp audience?(aud, expected) when is_list(aud), do: expected in aud defp audience?(_, _), do: false diff --git a/lib/trinity/mcp/auth_host.ex b/lib/trinity/mcp/auth_host.ex index 6766bd1..02aa773 100644 --- a/lib/trinity/mcp/auth_host.ex +++ b/lib/trinity/mcp/auth_host.ex @@ -48,12 +48,15 @@ defmodule Trinity.MCP.AuthHost do config end - @doc "Starts what the profile needs at boot: the personal profile's authorization server." + @doc """ + Starts what the profile needs at boot: the personal profile's authorization server, as a + child of `Trinity.MCP.Supervisor` (supervised, and not tied to whoever called this). + """ @spec boot() :: :ok def boot do case config() do %Config{profile: :personal} = c -> - case Auth.Embedded.start_link(c) do + case DynamicSupervisor.start_child(Trinity.MCP.Supervisor, {Auth.Embedded, c}) do {:ok, _pid} -> Logger.info("mcp auth: personal profile, issuer #{Auth.Embedded.issuer(c)}") :ok @@ -71,6 +74,15 @@ defmodule Trinity.MCP.AuthHost do end end + @doc "Stops the personal profile's authorization server, if it runs (a test's reset)." + @spec stop_embedded() :: :ok + def stop_embedded do + case Process.whereis(Auth.Embedded) do + nil -> :ok + pid -> DynamicSupervisor.terminate_child(Trinity.MCP.Supervisor, pid) + end + end + @doc "Authorizes a request under the profile; a refusal is receipted with what could be read of the caller." @spec authorize(Plug.Conn.t()) :: {:ok, Principal.t()} | {:error, term()} def authorize(conn) do diff --git a/test/support/mcp/fake_as.ex b/test/support/mcp/fake_as.ex index a4447ed..65bf6b3 100644 --- a/test/support/mcp/fake_as.ex +++ b/test/support/mcp/fake_as.ex @@ -102,44 +102,34 @@ defmodule Trinity.MCP.FakeAS do def call(conn, agent) do state = Agent.get(agent, & &1) path = String.replace_prefix(conn.request_path, state.path, "") + route(conn.method, path, conn, agent, state) + end - case {conn.method, path} do - {"GET", "/.well-known/oauth-authorization-server"} -> - json(conn, 200, metadata(state)) - - {"GET", "/.well-known/oauth-authorization-server" <> _} -> - json(conn, 200, metadata(state)) - - {"GET", "/jwks"} -> - Agent.update(agent, &Map.update(&1, :jwks_fetches, 1, fn n -> n + 1 end)) - json(conn, 200, %{"keys" => Enum.map(state.keys, &public_jwk/1)}) - - {"GET", "/authorize"} -> - authorize(conn, agent, state) - - {"POST", "/token"} -> - token(conn, agent, state) - - {"POST", "/introspect"} -> - introspect(conn, state) - - {"POST", "/register"} -> - register(conn, agent) + defp route("GET", "/.well-known/oauth-authorization-server" <> _, conn, _agent, state), + do: json(conn, 200, metadata(state)) - # A Client ID Metadata Document a test client publishes (the personal profile's CIMD path). - {"GET", "/client.json"} -> - json(conn, 200, %{ - "client_id" => state.issuer <> "/client.json", - "client_name" => "Fake CIMD client", - "redirect_uris" => ["http://127.0.0.1/callback"], - "token_endpoint_auth_method" => "none" - }) + defp route("GET", "/jwks", conn, agent, state) do + Agent.update(agent, &Map.update(&1, :jwks_fetches, 1, fn n -> n + 1 end)) + json(conn, 200, %{"keys" => Enum.map(state.keys, &public_jwk/1)}) + end - _ -> - send_resp(conn, 404, "") - end + defp route("GET", "/authorize", conn, agent, state), do: authorize(conn, agent, state) + defp route("POST", "/token", conn, agent, state), do: token(conn, agent, state) + defp route("POST", "/introspect", conn, _agent, state), do: introspect(conn, state) + defp route("POST", "/register", conn, agent, _state), do: register(conn, agent) + + # A Client ID Metadata Document a test client publishes (the personal profile's CIMD path). + defp route("GET", "/client.json", conn, _agent, state) do + json(conn, 200, %{ + "client_id" => state.issuer <> "/client.json", + "client_name" => "Fake CIMD client", + "redirect_uris" => ["http://127.0.0.1/callback"], + "token_endpoint_auth_method" => "none" + }) end + defp route(_method, _path, conn, _agent, _state), do: send_resp(conn, 404, "") + defp metadata(state) do iss = state.issuer diff --git a/test/trinity/mcp/auth/client_role_test.exs b/test/trinity/mcp/auth/client_role_test.exs index 35557dd..d8f63fc 100644 --- a/test/trinity/mcp/auth/client_role_test.exs +++ b/test/trinity/mcp/auth/client_role_test.exs @@ -19,9 +19,9 @@ defmodule Trinity.MCP.Auth.ClientRoleTest do import Bitwise import Trinity.MCP.ServersUnderTest - alias Trinity.MCP.{AuthHost, Client, FakeAS, ServerConfig} alias Trinity.MCP.Auth.Client.Store alias Trinity.MCP.Auth.{JWKS, Token} + alias Trinity.MCP.{AuthHost, Client, FakeAS, ServerConfig} alias Trinity.MCP.Server.{Exports, Replay} alias Trinity.Receipts diff --git a/test/trinity/mcp/auth/embedded_test.exs b/test/trinity/mcp/auth/embedded_test.exs index 504722b..65ba0d1 100644 --- a/test/trinity/mcp/auth/embedded_test.exs +++ b/test/trinity/mcp/auth/embedded_test.exs @@ -11,8 +11,8 @@ defmodule Trinity.MCP.Auth.EmbeddedTest do use Trinity.DataCase, async: false @moduletag :capture_log - alias Trinity.MCP.AuthHost alias Trinity.MCP.Auth.{Config, Embedded, Token} + alias Trinity.MCP.AuthHost alias Trinity.MCP.FakeAS alias Trinity.MCP.Server.Replay @@ -41,7 +41,7 @@ defmodule Trinity.MCP.Auth.EmbeddedTest do Replay.reset() on_exit(fn -> - if pid = Process.whereis(Embedded), do: GenServer.stop(pid) + AuthHost.stop_embedded() Application.delete_env(:trinity, :mcp_auth) AuthHost.reload() File.rm_rf(key_dir) @@ -176,6 +176,10 @@ defmodule Trinity.MCP.Auth.EmbeddedTest do assert 401 == discover(base, nil) + # The authorization server is a supervised child, not a process tied to whoever booted it. + pids = for {_, pid, _, _} <- DynamicSupervisor.which_children(Trinity.MCP.Supervisor), do: pid + assert Process.whereis(Embedded) in pids + {:consent, %{"code" => code, "state" => "xyz", "iss" => ^base}, verifier} = code_flow(base, resource, client_id) @@ -246,7 +250,7 @@ defmodule Trinity.MCP.Auth.EmbeddedTest do ) AuthHost.reload() - GenServer.stop(Embedded) + AuthHost.stop_embedded() :ok = AuthHost.boot() {:ok, r} = @@ -288,7 +292,7 @@ defmodule Trinity.MCP.Auth.EmbeddedTest do # At boot: with an external adapter selected (the test adapter stands in), the host's # configuration, the first thing the MCP boot builds, raises, and no authorization # server is running afterwards. - GenServer.stop(Embedded) + AuthHost.stop_embedded() :persistent_term.put({Trinity.Authority.Selection, :selected}, Trinity.TestAuthority.Full) on_exit(fn -> @@ -318,7 +322,7 @@ defmodule Trinity.MCP.Auth.EmbeddedTest do test "the production profile holds no key material of its own: no keys, no JWKS, no AS metadata", %{base: base, resource: resource, key_dir: key_dir} do - GenServer.stop(Embedded) + AuthHost.stop_embedded() File.rm_rf!(key_dir) Application.put_env(:trinity, :mcp_auth, diff --git a/test/trinity/mcp/auth/resource_server_test.exs b/test/trinity/mcp/auth/resource_server_test.exs index e72ce24..e41cfde 100644 --- a/test/trinity/mcp/auth/resource_server_test.exs +++ b/test/trinity/mcp/auth/resource_server_test.exs @@ -13,8 +13,8 @@ defmodule Trinity.MCP.Auth.ResourceServerTest do use Trinity.DataCase, async: false @moduletag :capture_log - alias Trinity.MCP.AuthHost alias Trinity.MCP.Auth.JWKS + alias Trinity.MCP.AuthHost alias Trinity.MCP.FakeAS alias Trinity.MCP.Server.{Exports, Replay} alias Trinity.Permissions @@ -84,7 +84,7 @@ defmodule Trinity.MCP.Auth.ResourceServerTest do test "AC1: 401 with resource_metadata; the PRM names the AS; wrong audience, expired and the static bearer are 401; each refusal receipted", %{as: as, resource: resource, scope: scope, port: port} do - assert {401, ["Bearer resource_metadata=\"" <> rest], %{"error" => %{"code" => -32001}}} = + assert {401, ["Bearer resource_metadata=\"" <> rest], %{"error" => %{"code" => -32_001}}} = post(resource, nil, "server/discover", %{}) prm_url = String.trim_trailing(rest, "\"") @@ -154,7 +154,7 @@ defmodule Trinity.MCP.Auth.ResourceServerTest do assert {200, _, %{ "error" => %{ - "code" => -32001, + "code" => -32_001, "message" => "insufficient scope: this tool needs trinity:tools:artifact" } }} = From 9490ffaf97d1c75081c38402df60b288f560ebc4 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 07:12:53 -0400 Subject: [PATCH 10/13] fix(s062): AC7's compile-time half actually holds: the authorization package is a top-level boundary, since a sub-boundary inherits its ancestors' deps and the empty list under Trinity.MCP was a claim the compiler never checked; Trinity.MCP names it as a dependency, the planted line is a fixture and the refusal is PROOF's Signed-off-by: Ayla Croft --- lib/trinity/mcp.ex | 3 + lib/trinity/mcp/auth.ex | 11 ++++ scripts/dev_mcp_auth_consent.sh | 55 ++++++++++++++++++ .../proof/personal-profile-consent.png | Bin 0 -> 39104 bytes .../proof/personal-profile-endpoints.txt | 23 ++++++++ .../support/mcp/planted_auth_reference.ex.txt | 6 ++ test/trinity/mcp/auth/boundary_test.exs | 24 ++++++-- 7 files changed, 118 insertions(+), 4 deletions(-) create mode 100755 scripts/dev_mcp_auth_consent.sh create mode 100644 slices/062-mcp-server-auth/proof/personal-profile-consent.png create mode 100644 slices/062-mcp-server-auth/proof/personal-profile-endpoints.txt create mode 100644 test/support/mcp/planted_auth_reference.ex.txt diff --git a/lib/trinity/mcp.ex b/lib/trinity/mcp.ex index 3e1b4b5..2435018 100644 --- a/lib/trinity/mcp.ex +++ b/lib/trinity/mcp.ex @@ -27,6 +27,9 @@ defmodule Trinity.MCP do # Slice 061: the server reaches the core's server, catalog, tool spec and HTTP transport. deps: [ Trinity, + # Slice 062: the authorization package, a top-level boundary of its own with no deps on + # the tree (its moduledoc says why); this boundary is what receipts and boots it. + Trinity.MCP.Auth, BeamMCP.JSON, BeamMCP.Schema, BeamMCP.Server, diff --git a/lib/trinity/mcp/auth.ex b/lib/trinity/mcp/auth.ex index ad55e25..59801bf 100644 --- a/lib/trinity/mcp/auth.ex +++ b/lib/trinity/mcp/auth.ex @@ -13,6 +13,17 @@ defmodule Trinity.MCP.Auth do `deps: []` on the tree, which is what lets slice 123 extract it. """ use Boundary, + # `:strict`, and not the default: a sub-boundary inherits its ancestors' deps unless it says + # so (boundary's own rule), and `Trinity.MCP` depends on `Trinity`. Without this line + # `deps: []` reads as a claim the compiler never checks, which is the thing AC7 asserts. + # A top-level boundary, not a sub-boundary of `Trinity.MCP`: a sub-boundary inherits its + # ancestors' deps unless it is `:strict`, and a `:strict` one may still list only what an + # ancestor knows, so under `Trinity.MCP` (which depends on `Trinity`) a `deps: []` would + # read as a claim the compiler never checks. Top level, with no deps, it is checked: a call + # to anything of the tree is a compile error, which is what AC7 asserts and what lets slice + # 123 lift the package out. The externals it uses (Plug.Conn, Req, Jason, JOSE) are other + # applications, checked only where `mix.exs` says (`check: [apps: [:beam_mcp]]`). + top_level?: true, deps: [], exports: [ Config, diff --git a/scripts/dev_mcp_auth_consent.sh b/scripts/dev_mcp_auth_consent.sh new file mode 100755 index 0000000..69008c2 --- /dev/null +++ b/scripts/dev_mcp_auth_consent.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# The personal profile's consent page (slice 062): the test environment's application on its own +# database (trinity_screenshots.db, never the suite's) with `profile: :personal`, so that +# /oauth/authorize renders the page the owner answers. The port is printed as PORT=, and the +# authorization URL a client would open as URL=<...> (a Client ID Metadata Document is served by +# this script at /cimd-client.json through a tiny Bandit plug on the same machine). +# Run `mix assets.build` first and remove any stale `priv/static/assets/**/*.gz`. +cd "$(dirname "$0")/.." +export MIX_ENV=test +exec systemd-run --user --scope -p MemoryMax=32G --quiet -- mix run --no-start --no-halt -e ' +repo = Application.get_env(:trinity, Trinity.Repo) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_screenshots.db") +Application.put_env(:trinity, Trinity.Repo, repo) +rrepo = Application.get_env(:trinity, Trinity.Repo.Receipts) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_screenshots_receipts.db") +Application.put_env(:trinity, Trinity.Repo.Receipts, rrepo) +endpoint = Application.get_env(:trinity, TrinityWeb.Endpoint) |> Keyword.put(:check_origin, false) +Application.put_env(:trinity, TrinityWeb.Endpoint, endpoint) +Application.put_env(:trinity, :mcp_boot, false) +{:ok, _} = Application.ensure_all_started(:ecto_sql) +for r <- [Trinity.Repo, Trinity.Repo.Receipts] do + {:ok, pid} = r.start_link() + Ecto.Migrator.run(r, :up, all: true) + GenServer.stop(pid) +end +{:ok, _} = Application.ensure_all_started(:trinity) +{:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) +base = "http://127.0.0.1:#{port}" +key_dir = Path.join(System.tmp_dir!(), "trinity-as-keys-screenshot") +File.rm_rf!(key_dir) +Application.put_env(:trinity, :mcp_auth, profile: :personal, resource: base <> "/mcp", key_dir: key_dir) +Trinity.MCP.AuthHost.reload() +:ok = Trinity.MCP.AuthHost.boot() + +# The client publishing its metadata document, on its own loopback port. +defmodule CIMD do + @behaviour Plug + def init(o), do: o + def call(conn, _) do + body = Jason.encode!(%{"client_id" => "http://127.0.0.1:#{conn.port}/cimd-client.json", "client_name" => "Reference MCP client", "redirect_uris" => ["http://127.0.0.1:5555/callback"], "token_endpoint_auth_method" => "none"}) + conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, body) + end +end +{:ok, cimd} = Bandit.start_link(plug: CIMD, ip: {127,0,0,1}, port: 0, startup_log: false) +# The eval process ends when this script does and --no-halt keeps the VM: unlinked, the +# document server outlives it as the endpoint does. +Process.unlink(cimd) +{:ok, {_, cport}} = ThousandIsland.listener_info(cimd) +client_id = "http://127.0.0.1:#{cport}/cimd-client.json" +verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) +challenge = :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) +query = URI.encode_query(%{"response_type" => "code", "client_id" => client_id, "redirect_uri" => "http://127.0.0.1:5555/callback", "code_challenge" => challenge, "code_challenge_method" => "S256", "state" => "xyz", "resource" => base <> "/mcp", "scope" => "trinity:tools:read trinity:tools:artifact"}) +IO.puts("PORT=#{port}") +IO.puts("URL=#{base}/oauth/authorize?#{query}") +' diff --git a/slices/062-mcp-server-auth/proof/personal-profile-consent.png b/slices/062-mcp-server-auth/proof/personal-profile-consent.png new file mode 100644 index 0000000000000000000000000000000000000000..21c3674426ef9d73d94022744f34e2272d069ea8 GIT binary patch literal 39104 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

y3{V#sNe2|TnEtg^4^$R&SP_8I}fh@{SS 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 literal 0 HcmV?d00001 diff --git a/slices/062-mcp-server-auth/proof/personal-profile-endpoints.txt b/slices/062-mcp-server-auth/proof/personal-profile-endpoints.txt new file mode 100644 index 0000000..4ff9f01 --- /dev/null +++ b/slices/062-mcp-server-auth/proof/personal-profile-endpoints.txt @@ -0,0 +1,23 @@ +# The personal profile, live (scripts/dev_mcp_auth_consent.sh), 2026-09-22 + +$ curl -s http://127.0.0.1:35717/.well-known/oauth-protected-resource/mcp +{"authorization_servers":["http://127.0.0.1:35717"],"bearer_methods_supported":["header"],"resource":"http://127.0.0.1:35717/mcp","scopes_supported":["trinity:tools:read","trinity:recall","trinity:tools:artifact"]} + +$ curl -s http://127.0.0.1:35717/.well-known/oauth-authorization-server +{"authorization_endpoint":"http://127.0.0.1:35717/oauth/authorize","authorization_response_iss_parameter_supported":true,"client_id_metadata_document_supported":true,"code_challenge_methods_supported":["S256"],"grant_types_supported":["authorization_code"],"issuer":"http://127.0.0.1:35717","jwks_uri":"http://127.0.0.1:35717/.well-known/jwks.json","resource_indicators_supported":true,"response_types_supported":["code"],"scopes_supported":["trinity:tools:read","trinity:recall","trinity:tools:artifact"],"token_endpoint":"http://127.0.0.1:35717/oauth/token","token_endpoint_auth_methods_supported":["none"]} + +$ curl -s http://127.0.0.1:35717/.well-known/jwks.json +{"keys":[{"alg":"ES256","crv":"P-256","kid":"eTYaRRGaUDQq","kty":"EC","use":"sig","x":"YJlkroicSdIUNDqUP6SSPpcMkaKY_IcFKBma5G7l4Zk","y":"NcsbQmMg6MPZyISsDAd4YUm-mvqs_kSkU1c5AOAjUPU"}]} + +$ curl -si -X POST http://127.0.0.1:35717/mcp -H 'content-type: application/json' -H 'mcp-protocol-version: 2026-07-28' -H 'mcp-method: server/discover' -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}' +HTTP/1.1 401 Unauthorized +content-length: 76 +vary: accept-encoding +cache-control: max-age=0, private, must-revalidate +www-authenticate: Bearer resource_metadata="http://127.0.0.1:35717/.well-known/oauth-protected-resource/mcp" +content-type: application/json; charset=utf-8 + +{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}} + +$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:35717/oauth/register -H 'content-type: application/json' -d '{"redirect_uris":["http://127.0.0.1/cb"]}' # DCR off by default +404 diff --git a/test/support/mcp/planted_auth_reference.ex.txt b/test/support/mcp/planted_auth_reference.ex.txt new file mode 100644 index 0000000..d9c8857 --- /dev/null +++ b/test/support/mcp/planted_auth_reference.ex.txt @@ -0,0 +1,6 @@ +# The line slice 062's PROOF.md appends to lib/trinity/mcp/auth/scopes.ex, inside the module, +# to show the boundary compiler refusing a call from the authorization package into the tree +# (AC7). It is kept here rather than in PROOF.md alone so that a test can assert the planted +# line is a real call, as slice 059 does for its own planted reference. + + def planted, do: Trinity.Sessions.history("x") diff --git a/test/trinity/mcp/auth/boundary_test.exs b/test/trinity/mcp/auth/boundary_test.exs index 4a9160e..f03b991 100644 --- a/test/trinity/mcp/auth/boundary_test.exs +++ b/test/trinity/mcp/auth/boundary_test.exs @@ -2,9 +2,14 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.MCP.Auth.BoundaryTest do @moduledoc """ - Slice 062 AC7: `Trinity.MCP.Auth` is a boundary with no dependency on the tree (`deps: []`, - read from the attribute the boundary library persists), so it has no compile-time path to - `Trinity.Sessions`, `Trinity.Tools`, `Trinity.Receipts` or `Trinity.Repo`; the source census + Slice 062 AC7: `Trinity.MCP.Auth` is a **top-level** boundary with no dependency on the tree + (`top_level?: true, deps: []`, read from the attribute the boundary library persists), so the + compiler refuses a call from it to `Trinity.Sessions`, `Trinity.Tools`, `Trinity.Receipts` or + `Trinity.Repo` (PROOF.md pastes that refusal from a planted line, as slice 059's boundary proof + does: a test cannot run the project's own compiler over a planted file without changing the + tree it runs in). Top level and not nested, because a sub-boundary inherits its ancestors' deps + and `Trinity.MCP` depends on `Trinity`: nested, the empty list would be a claim nothing checks. + The source census over the package (`git ls-files lib/trinity/mcp/auth.ex lib/trinity/mcp/auth`, the population PROOF.md pastes) names no module of the tree outside itself in code. And the owner's "no token material" constraint as a census: the one reader of the `Authorization` header under `lib/` is @@ -30,13 +35,24 @@ defmodule Trinity.MCP.Auth.BoundaryTest do |> String.replace(~r/#[^\n]*/, "") end - test "the boundary lists no dependency and exports the package" do + test "the boundary is top level, lists no dependency, and exports the package" do [%{opts: opts, app: :trinity}] = Keyword.get(Trinity.MCP.Auth.module_info(:attributes), Boundary) assert Keyword.fetch!(opts, :deps) == [] + assert Keyword.fetch!(opts, :top_level?) == true assert Config in Keyword.fetch!(opts, :exports) assert Client in Keyword.fetch!(opts, :exports) + + # The tree's side names it as a dependency, which is the only way in. + [%{opts: mcp_opts}] = Keyword.get(Trinity.MCP.module_info(:attributes), Boundary) + assert Trinity.MCP.Auth in Keyword.fetch!(mcp_opts, :deps) + end + + test "the planted line the proof compiles is a real call from the package into the tree" do + plant = File.read!("test/support/mcp/planted_auth_reference.ex.txt") + assert plant =~ "Trinity.Sessions.history" + assert plant =~ "lib/trinity/mcp/auth/scopes.ex" end test "the population is the package's files, and no file of it names a module of the tree in code" do From d2e9bab239785262fc9a4e9f5ccdcd49f0648b3b Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 07:16:19 -0400 Subject: [PATCH 11/13] docs(s062): PROOF.md: the gate, the coverage, and the evidence for AC1, AC2, AC4, AC5, AC7 and AC8 (AC3 and AC6 removed); the boundary refusal and the census red, each pasted from its run; the consent page and the live endpoints under proof/ Signed-off-by: Ayla Croft --- slices/062-mcp-server-auth/PROOF.md | 283 ++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 slices/062-mcp-server-auth/PROOF.md diff --git a/slices/062-mcp-server-auth/PROOF.md b/slices/062-mcp-server-auth/PROOF.md new file mode 100644 index 0000000..9a104f6 --- /dev/null +++ b/slices/062-mcp-server-auth/PROOF.md @@ -0,0 +1,283 @@ +# Proof for slice 062: MCP authorization (client role, resource server, personal-profile authorization server) + +Agent: Claude Opus 5 (1M context) · Date: 2026-09-22 · Branch: slice/062-mcp-auth · Final commit: written at the close, below + +## Summary + +Trinity's MCP server is an OAuth 2.1 resource server (`:production`, against an external +authorization server the deployment names) with a small authorization server of its own for the +owner's machine (`:personal`, non-default) and 061's static bearer as the default (`:local`). +Trinity's client role obtains tokens from a protected server and 060's driver presents them. What +was hard: a crashed session had to be recovered first (NOTES, "Recovery"), and two claims the tree +made turned out not to hold until this slice made them: the personal profile's resource server +checked an issuer its own issuing half never wrote, and the authorization package's `deps: []` was +a claim the compiler never checked because a sub-boundary inherits its ancestors' deps (fixed as a +top-level boundary; the refusal is pasted under AC7). Deferred: Enterprise Managed Authorization +leaves the slice under the owner's decision of 2026-09-22 (AC3 and AC6 removed; NOTES, +"Amendment"), with a lift condition; follow-ups are in NOTES. + +## Gate + +``` +$ mix gate +Compiling 250 files (.ex) +Generated trinity app +Checking 362 source files (this might take a while) ... +Analysis took 0.4 seconds (0.04s to load, 0.4s running 69 checks on 362 files) +2913 mods/funs, found no issues. +############################################## +# Running Sobelow - v0.15.0 # +############################################## +... SCAN COMPLETE ... +Finished in 116.7 seconds (0.6s async, 116.1s sync) +Result: 524 passed, 18 excluded +trinity.coverage: 050 80.31% vs 061 80.05%: OK +== 1. Acceptance criteria numbered contiguously from 1 == +== 2. Definition of Done range equals the criterion count == +== 3. Manual verification queue section present == +== 4. ROADMAP size and status agree with SLICE.md == +== 5. Change-log slice count equals the tree == +== 6. No reference to a plan path absent from git ls-files == +== 6b. No slice depends on a slice that does not exist == +== 7. No board identifiers in the tree == +== 8. Commit messages: no attribution trailers, every commit signed off == +== 10. No reference to the pre-move owner path == +== 9. Secrets are ignored, as CLAUDE.md claims == +== 11. Slice lifecycle matches the branch == +== 12. Every workflow and Dependabot file parses as YAML == +plan_check: PASS +gate exit 0 +``` + +Sobelow's traversal findings on this slice's file paths are skipped in the source with a reason +each (`# sobelow_skip reason:` above every `@sobelow_skip`, which `test/sobelow_skips_test.exs` +requires): every path is the host's own (the data directory, the key directory, the token store), +never a request's. + +## Tests + +``` +$ mix test --cover +Finished in 106.4 seconds (0.7s async, 105.7s sync) +Result: 524 passed, 18 excluded +| Percentage | Module | +| 80.92% | Total | +``` + +The slice's own files and the two MCP suites it touches: + +``` +$ mix test test/trinity/mcp/auth test/trinity/mcp/server_test.exs test/trinity_web/live/mcp_live_test.exs test/trinity/mcp/boundary_test.exs +Finished in 0.9 seconds (0.1s async, 0.8s sync) +Result: 37 passed +``` + +## Acceptance criteria evidence + +Every criterion below is `[auto]`. AC3 and AC6 were removed on 2026-09-22 under the owner's +decision (SLICE.md and NOTES.md, "Amendment"); nothing of Enterprise Managed Authorization is +built, so nothing of it is proven here. + +### AC1: RS: unauthenticated → 401 with `resource_metadata`; PRM lists our AS; wrong `aud`/expired → 401; each receipted + +``` +$ mix test test/trinity/mcp/auth/token_test.exs test/trinity/mcp/auth/resource_server_test.exs --trace + * test AC1: an audience-bound token the issuer signed is a principal with issuer, subject, scope and client (3.2ms) + * test each claim check is its own refusal (4.3ms) + * test AC1: 401 with resource_metadata; the PRM names the AS; wrong audience, expired and the static bearer are 401; each refusal receipted (20.3ms) + * test introspection: an opaque token is asked about at the issuer (RFC 7662), the answer's claims checked as a JWT's are (7.4ms) + * test the configuration refuses an incomplete production profile (5.7ms) + * test the issuer's metadata is refused when it names another issuer (2.3ms) +``` + +The refusals, each its own named error, are asserted in `token_test.exs` one at a time: wrong +`iss`, missing `aud`, wrong `aud`, expired, no `exp`, `nbf` in the future, `alg` outside the allow +list, a key the JWKS never held, a malformed token, and the personal profile's mark in production; +`Config.new/1` refuses `none` and any `HS*` in the allow list, and refuses a production profile +with no issuer or no resource. Through the endpoint (`resource_server_test.exs`) the same refusals +are `401` with `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp"`, +the PRM at that URL names the fake enterprise AS as the authorization server, 061's static bearer +is refused in this profile, each refusal leaves a decision receipt of outcome `deny`, basis `auth`, +with the reason and the caller's unverified `iss`/`sub` marked as unverified, and no receipt of the +session matches `eyJ`. + +The same endpoints on a running application, in the personal profile +(`scripts/dev_mcp_auth_consent.sh`; full transcript in `proof/personal-profile-endpoints.txt`): + +``` +$ curl -si -X POST http://127.0.0.1:35717/mcp -H 'content-type: application/json' \ + -H 'mcp-protocol-version: 2026-07-28' -H 'mcp-method: server/discover' \ + -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}' +HTTP/1.1 401 Unauthorized +www-authenticate: Bearer resource_metadata="http://127.0.0.1:35717/.well-known/oauth-protected-resource/mcp" +content-type: application/json; charset=utf-8 + +{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}} +``` + +### AC2: AS: a CIMD-registered client completes code + PKCE with a resource indicator and receives an audience-bound token the RS accepts; DCR only when enabled + +``` +$ mix test test/trinity/mcp/auth/embedded_test.exs --trace + * test AC2: the code flow with PKCE and a resource indicator issues an audience-bound token the RS accepts; the metadata and JWKS are published; iss rides the redirect (8.1ms) + * test a wrong resource, an unknown scope and a denied consent are refusals; DCR is 404 unless enabled; an unknown client is refused (34.1ms) + * test DCR registers a client when enabled, and the registered client completes the flow (5.0ms) + * test the personal profile refuses to start under an external authority adapter (9.5ms) + * test the production profile holds no key material of its own: no keys, no JWKS, no AS metadata (4.4ms) + * test rotating the signing key: a token the old key signed verifies by kid; new tokens use the new key (10.1ms) +``` + +The AC2 test drives the real endpoints over HTTP: the RFC 8414 metadata (`S256` only, CIMD +supported, no `registration_endpoint` while DCR is off), the JWKS, the PRM, a `401` with no token, +then `GET /oauth/authorize` with `code_challenge`/`S256`/`resource`/`state`, the owner's consent +form posted with its CSRF token, the redirect carrying `code`, `state` and RFC 9207 `iss`, the +token endpoint with the verifier and the resource, and the resulting token accepted by the resource +server at `/mcp` (200). The code is single use, a wrong verifier is `invalid_grant`, a wrong +`resource` is an `invalid_target` redirect, an unknown scope `invalid_scope`, a denied consent +`access_denied`, an unreachable client document a refusal, and `POST /oauth/register` is `404` +until `dcr: true`. The issued token carries `"profile": "personal"`, `aud` = this server and `iss` += this server, and the production profile refuses that claim. + +Manual view for the owner: `proof/personal-profile-consent.png` (the consent page as it renders, +naming the client, the resource, the scopes asked and what a personal token is not good for). + +### AC4: Scope mapping: `trinity:recall` cannot call an `:artifact` tool; `trinity:tools:artifact` can, subject to the gate + +``` +$ mix test test/trinity/mcp/auth/resource_server_test.exs --trace + * test AC4 and the receipts: read scope refused on an artifact tool, artifact scope reaches the gate, receipts name the principal, no token material (30.7ms) +``` + +A token scoped `trinity:tools:read` calling the `memory` tool (`:artifact`) is answered +`insufficient scope: this tool needs trinity:tools:artifact` and leaves a decision receipt of +outcome `deny`, basis `scope`, reason `needs trinity:tools:artifact`, with the principal; the gate +is never asked. The same call under `trinity:tools:artifact` reaches the gate and is held for the +owner (`input_required`), which is the point: a scope is not an allow rule. A read tool under an +allow rule runs, and both its decision and its query receipt carry +`principal = %{iss, sub, scope, client_id, profile}`. No receipt and no message of the MCP session +matches `eyJ`. + +### AC5: Key rotation: old tokens verify until expiry via JWKS `kid`; new tokens use the new key + +``` +$ mix test test/trinity/mcp/auth/token_test.exs --trace + * test AC5: after the issuer rotates its key, old tokens verify by kid until expiry and new tokens use the new key; a key no longer published fails (3.9ms) +``` + +Against the external issuer (the fake enterprise AS): after a rotation the new `kid` is unknown to +the cache, one JWKS fetch follows (asserted by counting the fake's fetches), both tokens then +verify, and a key withdrawn from the JWKS makes its token `{:unknown_kid, …}`. For the personal +profile's own keys the same property is driven through the endpoints (`embedded_test.exs`, +"rotating the signing key"): after `AuthHost.rotate_key!/0` the JWKS publishes both kids, a token +signed before the rotation is still accepted at `/mcp`, and a token issued after it carries the new +`kid`. + +### AC7: `Trinity.MCP.Auth.*` has no dependency on `Trinity.Sessions`/`Trinity.Tools` (boundary check) + +The compiler holds it. Planting the line in `test/support/mcp/planted_auth_reference.ex.txt` inside +`lib/trinity/mcp/auth/scopes.ex` and compiling: + +``` +$ printf '\n def planted, do: Trinity.Sessions.history("x")\n' >> …/scopes.ex # inside the module +$ mix compile --force --warnings-as-errors +warning: forbidden reference to Trinity.Sessions + (references from Trinity.MCP.Auth to Trinity.Sessions are not allowed) + lib/trinity/mcp/auth/scopes.ex:33 +compile exit 1 +``` + +That refusal only appears since this slice made the package a **top-level** boundary: a +sub-boundary inherits its ancestors' deps unless it says otherwise, and `Trinity.MCP` depends on +`Trinity`, so the package's `deps: []` compiled clean with the same planted line before the fix. +`Trinity.MCP` now names `Trinity.MCP.Auth` as a dependency, which is the only way in. + +The census and the no-token-material rule: + +``` +$ mix test test/trinity/mcp/auth/boundary_test.exs --trace + * test the boundary is top level, lists no dependency, and exports the package (2.8ms) + * test the population is the package's files, and no file of it names a module of the tree in code (3.1ms) + * test the one reader of the Authorization header under lib/ is the boundary; the server side never names a token (14.0ms) + * test a principal's receipt form carries issuer, subject, scope, client and profile, and nothing else (0.00ms) + * test the planted line the proof compiles is a real call from the package into the tree (0.1ms) +``` + +The population is `git ls-files lib/trinity/mcp/auth.ex lib/trinity/mcp/auth` (thirteen files, +listed in the test); the census reads each file with its comments and docs stripped and refuses any +`Trinity.*` reference outside the package. Demonstrated red with the same planted line: + +``` +$ mix test test/trinity/mcp/auth/boundary_test.exs + 1) test the population is the package's files, and no file of it names a module of the tree in code + lib/trinity/mcp/auth/scopes.ex reaches the tree: ["Trinity.Sessions.history"] +Result: 3/4 passed +``` + +The header census derives its population from `git ls-files lib/*.ex lib/**/*.ex lib/**/*.heex` +(over a hundred files, asserted) and holds that `lib/trinity/mcp/auth.ex` is the only reader of the +`Authorization` header, and that no file of `lib/trinity/mcp/server*` names the header, a token or +`Auth.bearer`. + +### AC8: Client role: against a test AS the client obtains an audience-bound token the RS accepts; wrong `iss` rejected; the 060 driver performs no flow + +``` +$ mix test test/trinity/mcp/auth/client_role_test.exs --trace + * test AC8: 401 to challenge, PKCE against the AS, the token stored and presented, the RS accepts it (9.0ms) + * test a callback from another issuer is refused; an unknown state is refused; DCR once when allowed and offered; no identity, no flow (7.0ms) + * test the driver performs no flow: the census over its files (5.7ms) +``` + +060's driver connects to Trinity's own `/mcp` in the production profile, is refused `401`, and +records the challenge (`auth_challenge` in its info). The host begins the flow: the authorization +URL names the fake enterprise AS, `response_type=code`, the configured `client_id`, `S256` with a +43-character challenge, the resource and a state; the pending request on disk is mode `0600`. The +AS consents, the callback is fetched as a browser would fetch it, and afterwards the token file is +mode `0600`, the pending file is gone, `Client.Auth.bearer/1` returns the token, and the resource +server admits it as a principal with the expected issuer, subject, scope and client. The driver +then reconnects and reaches `ready` at 2026-07-28. Neither the client's info, nor the receipts, nor +the MCP session's messages match `eyJ`. A callback carrying another `iss` is +`{:wrong_issuer, …}`, an unknown `state` is refused, an AS error is surfaced as such, a missing +client identity refuses to begin the flow, and DCR registers once and is then remembered per +issuer. The census over `git ls-files lib/trinity/mcp/client.ex lib/trinity/mcp/client` holds that +no file of the driver names a verifier, a challenge, an authorization or token endpoint, the +client-role functions or the store. + +The page (`/mcp`), which is all of the UI this slice added: + +``` +$ mix test test/trinity_web/live/mcp_live_test.exs --trace + * test a server that answered 401 with resource metadata shows the challenge; authorize sends the owner to the AS (162.1ms) +``` + +## Manual verification for the reviewer + +Nothing in the acceptance criteria requires a person: AC6, the slice's only `[manual]` criterion, +was removed with AC3. Two artefacts are in `proof/` for the owner's eye rather than as criteria: + +- `proof/personal-profile-consent.png`: the consent page in the personal profile. +- `proof/personal-profile-endpoints.txt`: the PRM, the RFC 8414 metadata, the JWKS, the `401` with + its challenge and the `404` from the registration endpoint, from a running application. + +To see either again: `scripts/dev_mcp_auth_consent.sh` prints `PORT=` and the `URL=` a client would +open (its database is `trinity_screenshots.db`, never the suite's). Kill it by the pid +`pgrep -af beam.smp` prints. + +## Deviations from SLICE.md + +See NOTES.md, "Deviations from SLICE.md and the G1 plan": eight, the first being the amendment that +removed Enterprise Managed Authorization and its admin UI from the slice. + +## Versions touched + +`VERSIONS.md` updated: yes, one row, `jose ~> 1.11` (1.11.12, MIT), added by the G1 commit and +verified by the gate's `versions.verify` and `versions.gen --check` steps. No other dependency +changed; `joken` was considered and not taken (NOTES, "Read before code"). + +## Git + +``` +$ git log --oneline main..HEAD +``` + +(The branch's commits, in order, are listed by that command; the closing commit's sha is appended +below when the slice closes, as the house rule requires.) From 73b2b02837b6d082e90d026bd0f4873343f3bdd9 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 09:52:29 -0400 Subject: [PATCH 12/13] feat(s062): complete slice 062 (MCP authorization: client role, resource server, personal-profile AS) Signed-off-by: Ayla Croft --- ROADMAP.md | 2 +- slices/062-mcp-server-auth/PROOF.md | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f3125e5..6823b5f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,7 +55,7 @@ standards register names the rows that ask for them. | 059 | MCP capability gap against beam_mcp, and the server seam probe | 6 MCP | S/M | 020 | approved | | 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved | | 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | approved | -| 062 | MCP authorization: OAuth client role, resource server, personal-profile authorization server (EMA removed 2026-09-22) | 6 MCP | L | 061 | in_progress | +| 062 | MCP authorization: OAuth client role, resource server, personal-profile authorization server (EMA removed 2026-09-22) | 6 MCP | L | 061 | done | | 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | planned | | 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned | | 072 | Gateway: Discord (Nostrum) | 7 Gateways | S | 070 | planned | diff --git a/slices/062-mcp-server-auth/PROOF.md b/slices/062-mcp-server-auth/PROOF.md index 9a104f6..1c61c60 100644 --- a/slices/062-mcp-server-auth/PROOF.md +++ b/slices/062-mcp-server-auth/PROOF.md @@ -49,6 +49,22 @@ plan_check: PASS gate exit 0 ``` +On CI, at `d2e9bab` (the tree PROOF describes), all four checks green: + +``` +$ gh run view 35735635653 --json conclusion,jobs +{"conclusion":"success", + "jobs":["fips-tag: success","gate: success","postgres: success","fips: success"], + "sha":"d2e9bab239785262fc9a4e9f5ccdcd49f0648b3b"} +``` + +https://github.com/ScriptKittyOS/Trinity/actions/runs/35735635653 — the SQLite gate, the Postgres +leg and the FIPS leg (slice 003's image, the mode entered) each ran this slice's suites. The +`package` workflow did not run and was not expected to: its push trigger is tags only +(`slice/**`, `v*`), so although this slice changes `mix.exs`, `mix.lock` and `config/runtime.exs` +for the `jose` row, the three-OS packaging evidence arrives with the `slice/062` tag, as it has for +every slice. + Sobelow's traversal findings on this slice's file paths are skipped in the source with a reason each (`# sobelow_skip reason:` above every `@sobelow_skip`, which `test/sobelow_skips_test.exs` requires): every path is the host's own (the data directory, the key directory, the token store), From 5e58a3c190176b493d9d7558abb2b3269361ce5c Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 09:58:35 -0400 Subject: [PATCH 13/13] docs(s062): the closing correction: the final tree, its sha and its run Signed-off-by: Ayla Croft --- slices/062-mcp-server-auth/PROOF.md | 44 +++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/slices/062-mcp-server-auth/PROOF.md b/slices/062-mcp-server-auth/PROOF.md index 1c61c60..f0e9105 100644 --- a/slices/062-mcp-server-auth/PROOF.md +++ b/slices/062-mcp-server-auth/PROOF.md @@ -295,5 +295,45 @@ changed; `joken` was considered and not taken (NOTES, "Read before code"). $ git log --oneline main..HEAD ``` -(The branch's commits, in order, are listed by that command; the closing commit's sha is appended -below when the slice closes, as the house rule requires.) +## Closing correction, 2026-09-22 + +Appended, not rewritten: the header above says "Final commit: written at the close, below", and +this is that line. **The closing commit is `73b2b02`** (`feat(s062): complete slice 062 (MCP +authorization: client role, resource server, personal-profile AS)`), which carries `ROADMAP.md`'s +row at `done` and the CI section of this file. Its own CI run is green on all four checks: + +``` +$ gh run view 35736404050 --json conclusion,jobs,headSha +{"conclusion":"success", + "jobs":["gate: success","fips-tag: success","postgres: success","fips: success"], + "sha":"73b2b02837b6d082e90d026bd0f4873343f3bdd9"} +``` + +https://github.com/ScriptKittyOS/Trinity/actions/runs/35736404050 + +The run named under "Gate" above (35735635653, at `d2e9bab`) measured the same tree without the +ROADMAP row and this section; both are green and both are left standing, since a record is not +rewritten. The branch, in order: + +``` +$ git log --oneline main..HEAD +73b2b02 feat(s062): complete slice 062 (MCP authorization: client role, resource server, personal-profile AS) +d2e9bab docs(s062): PROOF.md: the gate, the coverage, and the evidence for AC1, AC2, AC4, AC5, AC7 and AC8 … +9490ffa fix(s062): AC7's compile-time half actually holds: the authorization package is a top-level boundary … +4a90248 fix(s062): the personal profile's authorization server is a child of the MCP supervisor … +d03e15d docs(s062): the amendment: EMA leaves the slice as a deferral under the owner's decision … +601bac1 feat(s062): the /mcp page shows a server's 401 challenge and 'authorize' … +c9a7f55 test(s062): AC7 …, AC8 …, introspection (RFC 7662), the configuration's refusals … +274ad20 test(s062): AC2 green through the personal profile's endpoints … +a29dcb4 test(s062): the fake enterprise AS; AC1, AC4 and AC5 green; the receipts name the principal … +e18e3c9 test(s061): a refused bearer is 401 with a challenge since 062 +34e56d2 feat(s062): the authorization boundary (profiles local, production, personal), the host … +08c2014 docs(s062): G1 plan under the owner's profile decision; EMA paused as the issuer question +``` + +Twelve commits: the first four are the crashed session's (NOTES, "Recovery"), the rest this one's. +One of them is scoped `s061`, correctly: it amends 061's own test for the `401` this slice brings. + +**Not done, and deliberately.** No pull request is open, nothing is merged, and no tag exists: the +slice's status is `done`, which is the agent's half, and `approved` is yours (G4). The `slice/062` +tag and the three-OS `package` run that the tag triggers both come after that.