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 9d5dd6f..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, RS, embedded AS, Enterprise Managed Authorization (ID-JAG) | 6 MCP | L | 061 | planned | +| 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 | @@ -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/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/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/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..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, @@ -46,7 +49,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..59801bf --- /dev/null +++ b/lib/trinity/mcp/auth.ex @@ -0,0 +1,71 @@ +# 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, + # `: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, + 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..9488b20 --- /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.Client.Store + alias Trinity.MCP.Auth.{Config, Discovery} + + @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..88c3045 --- /dev/null +++ b/lib/trinity/mcp/auth/client/store.ex @@ -0,0 +1,99 @@ +# 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 + + 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 + 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." + # 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)) + :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)." + # 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) + + 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) + + # 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)) + 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..5169022 --- /dev/null +++ b/lib/trinity/mcp/auth/embedded.ex @@ -0,0 +1,506 @@ +# 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 + + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @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) + + @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: issuer(config)}) + 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 + 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 + + 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. 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} + + cimd_url?(client_id) -> + cimd(client_id, state) + + true -> + {:error, :unknown_client, state} + 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) + + 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 @loopback_hosts 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") + + # 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} -> + case Jason.decode(json) do + {:ok, %{} = clients} -> clients + _ -> %{} + end + + _ -> + %{} + 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)) + 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..56d8219 --- /dev/null +++ b/lib/trinity/mcp/auth/embedded/keys.ex @@ -0,0 +1,120 @@ +# 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()} + + 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 + 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 + # 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." + # 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) + + 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 + + # 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"}) + kid = jwk |> JOSE.JWK.thumbprint() |> binary_part(0, 12) + created_at = max(System.os_time(:second), after_at) + {_, 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 + + # 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!() + + %{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..d93965a --- /dev/null +++ b/lib/trinity/mcp/auth/jwks.ex @@ -0,0 +1,111 @@ +# 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), + :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 + + # 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 + 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; 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 {Keyword.get(opts, :missed_at), :persistent_term.get({__MODULE__, issuer}, nil)} do + {at, _} when is_integer(at) -> at + {nil, {_, _, 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, missed}) + {:ok, keys} + else + {:error, _} = error -> error + _ -> {:error, :no_keys_in_document} + 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..742696b --- /dev/null +++ b/lib/trinity/mcp/auth/local.ex @@ -0,0 +1,56 @@ +# 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} + + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @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." + # 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 + 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." + # 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 + 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..1e02ad9 --- /dev/null +++ b/lib/trinity/mcp/auth/token.ex @@ -0,0 +1,115 @@ +# 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: 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 + with :ok <- check_parties(claims, config), + :ok <- check_time(claims, now) do + check_mark(claims, config) + end + end + + 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 + + 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 +end diff --git a/lib/trinity/mcp/auth_host.ex b/lib/trinity/mcp/auth_host.ex new file mode 100644 index 0000000..02aa773 --- /dev/null +++ b/lib/trinity/mcp/auth_host.ex @@ -0,0 +1,244 @@ +# 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, 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 DynamicSupervisor.start_child(Trinity.MCP.Supervisor, {Auth.Embedded, 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 "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 + 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..6a839b4 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 } @@ -170,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)}") @@ -178,10 +181,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 +223,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/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/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"}, 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/NOTES.md b/slices/062-mcp-server-auth/NOTES.md new file mode 100644 index 0000000..51b3f03 --- /dev/null +++ b/slices/062-mcp-server-auth/NOTES.md @@ -0,0 +1,243 @@ +# 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"). + +## 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/PROOF.md b/slices/062-mcp-server-auth/PROOF.md new file mode 100644 index 0000000..f0e9105 --- /dev/null +++ b/slices/062-mcp-server-auth/PROOF.md @@ -0,0 +1,339 @@ +# 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 +``` + +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), +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 +``` + +## 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. diff --git a/slices/062-mcp-server-auth/SLICE.md b/slices/062-mcp-server-auth/SLICE.md index c9b6154..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,13 +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. [auto] 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). + 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. [manual] 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 @@ -65,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). 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 0000000..21c3674 Binary files /dev/null and b/slices/062-mcp-server-auth/proof/personal-profile-consent.png differ 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/fake_as.ex b/test/support/mcp/fake_as.ex new file mode 100644 index 0000000..65bf6b3 --- /dev/null +++ b/test/support/mcp/fake_as.ex @@ -0,0 +1,274 @@ +# 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, "") + route(conn.method, path, conn, agent, state) + end + + defp route("GET", "/.well-known/oauth-authorization-server" <> _, conn, _agent, state), + do: json(conn, 200, metadata(state)) + + 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 + + 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 + + %{ + "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 -> + {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) + + 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/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 new file mode 100644 index 0000000..f03b991 --- /dev/null +++ b/test/trinity/mcp/auth/boundary_test.exs @@ -0,0 +1,126 @@ +# 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 **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 + `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 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 + 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..d8f63fc --- /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.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 + + 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 new file mode 100644 index 0000000..65ba0d1 --- /dev/null +++ b/test/trinity/mcp/auth/embedded_test.exs @@ -0,0 +1,372 @@ +# 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.Auth.{Config, Embedded, Token} + alias Trinity.MCP.AuthHost + 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 -> + AuthHost.stop_embedded() + 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) + + # 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) + + 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() + AuthHost.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 + }) + + # 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. + AuthHost.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 + AuthHost.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", + %{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 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..e41cfde --- /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.Auth.JWKS + alias Trinity.MCP.AuthHost + 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" => -32_001}}} = + 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" => -32_001, + "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..76891c0 --- /dev/null +++ b/test/trinity/mcp/auth/token_test.exs @@ -0,0 +1,172 @@ +# 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 "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 + assert {:error, _} = Trinity.MCP.Auth.Discovery.authorization_server(as.issuer <> "/other") + end +end 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, 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",