diff --git a/auto-runtime.example.yaml b/auto-runtime.example.yaml index 736810487..adfa76435 100644 --- a/auto-runtime.example.yaml +++ b/auto-runtime.example.yaml @@ -100,9 +100,12 @@ model_aliases: # leaving the section out lets people add any connection. providers adds # providers of your own beside those the runtime ships, which a person connects # by name and an entry of tool_servers may name; one named as a shipped -# provider takes its place. +# provider takes its place. auto_clients lets a provider take Auto's client, +# through Auto's consent service, where you register none at its vendor: on in +# local mode and off otherwise when left out. # connections: # people_may_add: true +# auto_clients: true # allowed_hosts: ['*.example.com', 'crm.example.com'] # credential_kinds: [none, reference, secret, client_credentials, authorization] # providers: diff --git a/auto-runtime.schema.json b/auto-runtime.schema.json index 76745cd21..92f20cb75 100644 --- a/auto-runtime.schema.json +++ b/auto-runtime.schema.json @@ -407,6 +407,10 @@ }, "description": "The clients whoever runs the runtime registered at authorization servers, each with the issuers it is registered at, its client_id, and its client_secret, or its private_key with its algorithm, as a reference, or neither for a public client" }, + "auto_clients": { + "type": "boolean", + "description": "Whether a provider whose way takes a registered client takes Auto's client too, through Auto's consent service, where none is registered at its issuer; true in local mode and false otherwise when left out. AUTO_CLIENTS wins over it" + }, "providers": { "type": "array", "items": { @@ -583,7 +587,7 @@ } }, "additionalProperties": false, - "description": "What people may add as connections through their agents, tool servers of an org which every playbook of the org may use; the clients whoever runs the runtime registered at authorization servers; and the providers it adds beside those the runtime ships. CONNECTIONS wins over it" + "description": "What people may add as connections through their agents, tool servers of an org which every playbook of the org may use; the clients whoever runs the runtime registered at authorization servers, and whether Auto's clients serve as well; and the providers it adds beside those the runtime ships. CONNECTIONS wins over it" }, "usage_ping": { "type": "boolean", diff --git a/docs/assets/openapi.json b/docs/assets/openapi.json index 9a7566ba2..a8618a772 100644 --- a/docs/assets/openapi.json +++ b/docs/assets/openapi.json @@ -1664,6 +1664,7 @@ "type": "string", "enum": [ "registered", + "auto", "metadata_document", "dynamic" ], @@ -2052,6 +2053,7 @@ "type": "string", "enum": [ "registered", + "auto", "metadata_document", "dynamic" ], @@ -2408,6 +2410,7 @@ "type": "string", "enum": [ "registered", + "auto", "metadata_document", "dynamic" ], @@ -2760,6 +2763,7 @@ "type": "string", "enum": [ "registered", + "auto", "metadata_document", "dynamic" ], @@ -3128,6 +3132,7 @@ "type": "string", "enum": [ "registered", + "auto", "metadata_document", "dynamic" ], @@ -3233,6 +3238,16 @@ ], "additionalProperties": false, "description": "The consent started and not finished, while it is open" + }, + "client_source": { + "type": "string", + "enum": [ + "registered", + "auto", + "metadata_document", + "dynamic" + ], + "description": "For a connection connected by consent, the source of the client a start would take now, by the rule the start takes it by: auto where it would begin through Auto's consent service. Left out where the runtime has no client the way takes" } }, "required": [ @@ -3554,6 +3569,7 @@ "type": "string", "enum": [ "registered", + "auto", "metadata_document", "dynamic" ], @@ -3974,6 +3990,7 @@ "type": "string", "enum": [ "registered", + "auto", "metadata_document", "dynamic" ], @@ -4206,10 +4223,21 @@ "expires_at" ], "additionalProperties": false + }, + "client_source": { + "type": "string", + "enum": [ + "registered", + "auto", + "metadata_document", + "dynamic" + ], + "description": "The source of the client the consent was started with; auto where it began through Auto's consent service, whose finish takes a handle in place of a code" } }, "required": [ - "authorize" + "authorize", + "client_source" ], "additionalProperties": false, "description": "Where the person signs in to the provider and allows Auto" @@ -4282,7 +4310,7 @@ "post": { "operationId": "finish", "summary": "Finish consent", - "description": "Finishes the consent of a connection with the code and the state its authorization server sent the person back with, which connects it, and answers it connected. The runtime keeps the tokens sealed in its secret store, and no answer, record or history of the org ever holds them. On a runtime that signs people in, only the person who added the connection may finish it.", + "description": "Finishes the consent of a connection with the code and the state its authorization server sent the person back with, which connects it, and answers it connected. A consent begun through Auto's consent service is finished with the handle and the state the service sent the person back with instead. The runtime keeps the tokens sealed in its secret store, and no answer, record or history of the org ever holds them. On a runtime that signs people in, only the person who added the connection may finish it.", "tags": [ "connections" ], @@ -4309,34 +4337,68 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://on.auto/schemas/runtime/finish/input", "type": "object", - "additionalProperties": false, - "properties": { - "token": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "The token of the link add_connection gave" - }, - "code": { - "type": "string", - "minLength": 1, - "description": "The code the authorization server sent the person back with" - }, - "state": { - "type": "string", - "minLength": 1, - "description": "The state the authorization server sent the person back with" + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "description": "The finish of a consent begun with a client of this runtime's, which answers a code", + "properties": { + "token": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "The token of the link add_connection gave" + }, + "state": { + "type": "string", + "minLength": 1, + "description": "The state the person was sent back with" + }, + "code": { + "type": "string", + "minLength": 1, + "description": "The code the authorization server sent the person back with" + }, + "iss": { + "type": "string", + "minLength": 1, + "description": "The issuer the authorization server sent the person back with, where it sent one" + } + }, + "required": [ + "token", + "state", + "code" + ] }, - "iss": { - "type": "string", - "minLength": 1, - "description": "The issuer the authorization server sent the person back with, where it sent one" + { + "type": "object", + "additionalProperties": false, + "description": "The finish of a consent begun through Auto's consent service", + "properties": { + "token": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "The token of the link add_connection gave" + }, + "state": { + "type": "string", + "minLength": 1, + "description": "The state the person was sent back with" + }, + "handle": { + "type": "string", + "minLength": 1, + "description": "The handle Auto's consent service sent the person back with, which its tokens are handed over for" + } + }, + "required": [ + "token", + "state", + "handle" + ] } - }, - "required": [ - "token", - "code", - "state" ] } } @@ -4533,6 +4595,7 @@ "type": "string", "enum": [ "registered", + "auto", "metadata_document", "dynamic" ], diff --git a/docs/reference/http.md b/docs/reference/http.md index 673662e23..d774d50b0 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -1074,9 +1074,9 @@ A connection is a tool server that a person adds to the org through their agent, | `approve` | `POST /connections/{id}/approve` | any, with the link's token and the adder's session | `token` | | `give_credential` | `POST /connections/{id}/credential` | any, with the link's token and the adder's session | `token`, and `value` for a `secret`, or `client_secret`, or `private_key` with `algorithm`, for `client_credentials`, with `client_id` where the connection's credential holds none | | `start` | `POST /connections/{id}/consent` | any, with the link's token and the adder's session | `token` | -| `finish` | `POST /connections/{id}/consent/code` | any, with the link's token and the adder's session | `token`, `code`, `state`, and `iss` where the authorization server sent one | +| `finish` | `POST /connections/{id}/consent/code` | any, with the link's token and the adder's session | `token`, `code`, `state`, and `iss` where the authorization server sent one; or `token`, `state` and `handle` for a consent begun through Auto's consent service | -Every operation answers the connection: `id`, a UUIDv7; `name`; `type`, `mcp` or `openapi`; `provider`, the name of the provider it was added from, or `null` for a server given by hand; `placeholders`, the values given for the provider's placeholders, or `null`; `entry`; `credential`, how it is credentialled, never its value, and for a connection connected by consent the client it was made with and the scope the person allowed; `status`, `pending`, `connected`, `broken` or `removed`; `added_by`; `added_at`; `connected_at`; `connected_by`, the id of the caller who connected it, by approving it or giving its credential, or `null`; `credential_ends_with`, the last four characters of a secret or client secret of twelve characters or more, which the secret store keeps beside the value, or `null`; and `broken`, why it is broken, or `null`. `add_connection` answers `201` with the connection and `connect`, `{ url, expires_at }`: `url` is Studio's page `connect` under the Studio the person can reach, `/studio/connect?server=&org=&connection=&token=` where the runtime serves Studio, as it does in local mode, and `https://studio.on.auto/connect?server=…` otherwise; the runtime's origin is `PUBLIC_URL`, or the server's own address in local mode, and the link expires ten minutes on. `list_connections` answers them newest first in `connections`, the removed ones only with `include_removed`. `read_link` answers the connection with `link`, `{ expires_at }`, when its link expires, so the page can say how long the person has, and `consent`, `{ started_at, expires_at }`, while a consent is started and not finished; `approve`, `give_credential` and `finish`, which use the link up, answer the connection alone. +Every operation answers the connection: `id`, a UUIDv7; `name`; `type`, `mcp` or `openapi`; `provider`, the name of the provider it was added from, or `null` for a server given by hand; `placeholders`, the values given for the provider's placeholders, or `null`; `entry`; `credential`, how it is credentialled, never its value, and for a connection connected by consent the client it was made with and the scope the person allowed; `status`, `pending`, `connected`, `broken` or `removed`; `added_by`; `added_at`; `connected_at`; `connected_by`, the id of the caller who connected it, by approving it or giving its credential, or `null`; `credential_ends_with`, the last four characters of a secret or client secret of twelve characters or more, which the secret store keeps beside the value, or `null`; and `broken`, why it is broken, or `null`. `add_connection` answers `201` with the connection and `connect`, `{ url, expires_at }`: `url` is Studio's page `connect` under the Studio the person can reach, `/studio/connect?server=&org=&connection=&token=` where the runtime serves Studio, as it does in local mode, and `https://studio.on.auto/connect?server=…` otherwise; the runtime's origin is `PUBLIC_URL`, or the server's own address in local mode, and the link expires ten minutes on. `list_connections` answers them newest first in `connections`, the removed ones only with `include_removed`. `read_link` answers the connection with `link`, `{ expires_at }`, when its link expires, so the page can say how long the person has, `consent`, `{ started_at, expires_at }`, while a consent is started and not finished, and, for a connection whose credential is `authorization`, `client_source`, the source of the client a start would take now, by the rule the start takes it by, `auto` where it would begin through Auto's consent service, left out where the runtime has no client the provider's way takes; `approve`, `give_credential` and `finish`, which use the link up, answer the connection alone. - With `provider`, the provider gives the server and how it is credentialled. `name` is the provider's when left out; `placeholders` gives the value of each placeholder the provider takes; `credential` may give its `kind` alone, one of the provider's ways, and when it is left out the addition takes the first way `list_providers` marks offered, or the provider's first where it offers none, which the addition then refuses in its words; and `entry` may give `playbooks`, `allowed` and `record_content` alone. `type`, the server's fields of `entry` and every field of `credential` but `kind` are refused, each at its field, since the provider gives them; `credential.client_id` is refused since the person gives the client id on the page. The connection's `entry` and `credential` are the provider's with each value in its place, and every rule below applies to them as to a server given by hand. Without `provider`, `name`, `type`, `entry` and `credential` are each required and `placeholders` is refused. - `entry` holds the server without its credential: `url` for `mcp`; `document` and `base_url` for `openapi`; and `headers`, `query_params`, `allowed`, `playbooks`, `record_content` and `request_id`, as an entry of the configuration file has them. `url` and `document` are each an `https` URL, or an `http` URL on a loopback address. @@ -1084,7 +1084,7 @@ Every operation answers the connection: `id`, a UUIDv7; `name`; `type`, `mcp` or - A `secret` or a `reference` sent in a header may name its `scheme`, such as `Bearer`, which the runtime writes, with a space, before the value it sends, so the person gives the token alone, the variable of a reference holds the token alone, and the server receives `Authorization: Bearer `. A `scheme` holds the characters of a header name and no `$`, and a credential that places no value in a header takes none. `give_credential` refuses a value that already starts with the scheme and a space, in any case, at `/value`. - The `client_id` of client credentials is the org's, so the agent may leave it out and the person gives it on Studio's page with the client secret or the private key: a connection whose credential holds none answers its record without it, `give_credential` takes `client_id`, and the record holds it from then on. `give_credential` refuses a `client_id` at `/client_id` where the credential holds one, refuses one missing where it holds none, and refuses one that looks like a credential, since the record keeps the client id in the open and the secret or the key goes in its own field, where it is sealed. `add_connection` refuses a `client_id` that looks like a credential at `/credential/client_id`. - `get_connection` and the five routes of the link are served over HTTP alone, and no MCP endpoint serves them. `get_connection` reads one connection of the org by its id; an agent sees whether the person approved a connection in the `status` `list_connections` answers for each. The three routes of the link are Studio's page's alone. Each takes the token, which the runtime keeps only as its SHA-256, and matches it to the open link of the connection: one given less than ten minutes ago that nothing has used up. -- `start` and `finish` connect a connection whose credential is `authorization`. `start` answers `201` with `authorize`, `{ url, expires_at }`: `url` is the page of the provider's authorization server where the person signs in and allows Auto, which carries the client, the return address, the page `connect-return` of the Studio the connection's link opens, the Studio host's `/connect-return`, `STUDIO_ORIGIN` followed by it, `https://studio.on.auto/connect-return` unless it is set, or, where the runtime serves Studio, `/studio/connect-return` on the runtime's own address, the scope, a state and a challenge, and `expires_at` is ten minutes on, the time the consent has of its own; a second start replaces the first. The server sends the person back to the return address with a code, the state and its issuer, and Studio sends them to `finish` with `token`, `code`, `state` and `iss`, which redeems the code, keeps the tokens sealed in the runtime's secret store and connects the connection. `finish` is bound to the start and not to the link's ten minutes, and refuses a state the consent was not started with at `/state`, an issuer that is not the server's at `/iss`, and a code the server refuses at `/code`, in Auto's own words; a refused code leaves the start open. The scope it records is the one the server granted, its scopes separated by single spaces, or the one asked where the server says none; a grant of no scope where one was asked, or of a scope that is not one, is refused, an empty grant where none was asked records none, and a finish refused once the tokens exist revokes them first. A connection connected by consent then serves its tools, and the runtime renews its tokens; [Let people connect by consent](../self-host/connections.md#let-people-connect-by-consent) says how. +- `start` and `finish` connect a connection whose credential is `authorization`. `start` answers `201` with `authorize`, `{ url, expires_at }`, and `client_source`, the source of the client it started with: `url` is the page of the provider's authorization server where the person signs in and allows Auto, which carries the client, the return address, the page `connect-return` of the Studio the connection's link opens, the Studio host's `/connect-return`, `STUDIO_ORIGIN` followed by it, `https://studio.on.auto/connect-return` unless it is set, or, where the runtime serves Studio, `/studio/connect-return` on the runtime's own address, the scope, a state and a challenge, and `expires_at` is ten minutes on, the time the consent has of its own; a second start replaces the first. The server sends the person back to the return address with a code, the state and its issuer, and Studio sends them to `finish` with `token`, `code`, `state` and `iss`, which redeems the code, keeps the tokens sealed in the runtime's secret store and connects the connection. `finish` is bound to the start and not to the link's ten minutes, and refuses a state the consent was not started with at `/state`, an issuer that is not the server's at `/iss`, and a code the server refuses at `/code`, in Auto's own words; a refused code leaves the start open. The scope it records is the one the server granted, its scopes separated by single spaces, or the one asked where the server says none; a grant of no scope where one was asked, or of a scope that is not one, is refused, an empty grant where none was asked records none, and a finish refused once the tokens exist revokes them first. A connection connected by consent then serves its tools, and the runtime renews its tokens; [Let people connect by consent](../self-host/connections.md#let-people-connect-by-consent) says how. A start of `client_source` `auto` began through Auto's consent service, with Auto's client at the vendor: its `url` is the page Auto's consent service made, whose return is the Studio host, which sends the person back to the Studio the link opened with a handle and the state, and `finish` takes `token`, `state` and `handle`, takes the tokens from the service and connects the connection. A finish of the other shape than its start's is refused as `invalid_input` at the field it gave, `/code` or `/handle`, saying which the consent answers, and a handle the service refuses at `/handle`; [Consent through Auto](../self-host/connections.md#consent-through-auto) says when a runtime starts one. - `approve` connects a connection whose credential is `none` or `reference` and uses its link up. `give_credential` keeps the value sealed in the runtime's secret store, connects the connection and uses its link up: giving the credential is its approval. - A connection is added by a person: on a runtime that signs people in, `add_connection` refuses a key, and a runtime of API keys alone, which signs nobody in, refuses every connection, since no person could approve it. - On a runtime that signs people in, `approve`, `give_credential`, `start` and `finish` need the session of the person who added the connection. In local mode the token alone binds. diff --git a/docs/self-host/configuration.md b/docs/self-host/configuration.md index 092d88e21..733ca76e6 100644 --- a/docs/self-host/configuration.md +++ b/docs/self-host/configuration.md @@ -74,6 +74,7 @@ Inside an argument, only the value is judged, never the name the argument gives | Machines and pipelines that call the API | `API_KEYS`, with an entry the key command prints for each | [API keys](security.md#api-keys) | | Auto in a container on your own machine, without sign-in | `ACCESS_MODE=local` and `PUBLIC_URL=http://localhost:5555`, with the port published on `127.0.0.1` alone | [Run with Docker](docker.md#on-your-own-machine) | | Studio served by the runtime, or fetched from another host | `SERVE_STUDIO` and `STUDIO_ORIGIN` | [Studio served by the runtime](security.md#studio-served-by-the-runtime) | +| Consent at a provider without registering a client | `AUTO_CLIENTS=true`, on by default in local mode | [Consent through Auto](connections.md#consent-through-auto) | | The requests interaction functions may keep open | `INTERACTION_OPEN_REQUESTS` | [Interaction functions](#interaction-functions) | | The model processes and the builds of prediction functions | `PREDICTION_PROCESSES`, `PREDICTION_PROCESS_MEMORY`, `PREDICTION_BUILDS_AT_ONCE`, `PREDICTION_BUILDS_PER_DAY` and `PREDICTION_MAX_FUNCTIONS` | [Prediction functions](#prediction-functions) | | The folder prediction functions keep model files in | `PREDICTION_CACHE_DIR` | [Prediction functions](#prediction-functions) | @@ -100,15 +101,17 @@ Every setting is an environment variable. The tables below group them by area. [Authentication and security](security.md) sets out how these work together. -| Variable | Default | Purpose | -| ----------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PUBLIC_URL` | none | The URL at which callers reach the runtime, which its links point at. Without `ACCESS_MODE`, it turns sign-in through the Auto identity service on, and every access token names it as its audience; [Let people sign in](security.md#let-people-sign-in) gives the URLs it accepts. Beside `ACCESS_MODE=local`, it signs no one in, is taken only on `localhost`, `127.0.0.1` or `[::1]`, and is needed where the server listens beyond the loopback, as in a container ([Run with Docker](docker.md#on-your-own-machine)) | -| `IDENTITY_ISSUER` | `https://clerk.on.auto` | The identity service that people sign in through, an `https` URL with no path, query or fragment. A development runtime names the development instance of the service | -| `API_KEYS` | none | The API keys the server accepts, as a compact JSON array of the entries the key command prints; `api_keys` in the file. See [API keys](security.md#api-keys) | -| `ACCESS_MODE` | none | `local` declares [local mode](security.md#local-mode) on any address, for a server whose port only this computer reaches, such as a container whose port Docker publishes on `127.0.0.1` alone; never with a wider publish, and never beside containers on the same Docker network that you would not let act as you. It stops the start beside `API_KEYS`, beside a `PUBLIC_URL` that is not on `localhost`, `127.0.0.1` or `[::1]`, and without a `PUBLIC_URL` where the server listens beyond the loopback, as in a container; `access_mode` in the file. See [Run with Docker](docker.md#on-your-own-machine) | -| `ALLOWED_ORIGINS` | none | Comma-separated origins a browser page may call the API from, besides the Studio host's and, where the runtime serves Studio, its own; `allowed_origins` in the file. See [Calling from a browser](security.md#calling-from-a-browser) | -| `STUDIO_ORIGIN` | `https://studio.on.auto` | The Studio host: an `https` address, or an `http` address on `localhost`, `127.0.0.1` or `[::1]`, with no path. A page there may call the API, and the runtime fetches Studio from it when it serves Studio; `studio_origin` in the file. See [Studio served by the runtime](security.md#studio-served-by-the-runtime) | -| `SERVE_STUDIO` | `true` in local mode, `false` otherwise | `true` or `false`: whether the runtime serves Studio itself at `/studio/` on its own address, fetched from `STUDIO_ORIGIN` and kept beside a SQLite ledger file. It works in local mode alone for now, and the server warns at start where it is on elsewhere; `serve_studio` in the file. See [Studio served by the runtime](security.md#studio-served-by-the-runtime) | +| Variable | Default | Purpose | +| --------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PUBLIC_URL` | none | The URL at which callers reach the runtime, which its links point at. Without `ACCESS_MODE`, it turns sign-in through the Auto identity service on, and every access token names it as its audience; [Let people sign in](security.md#let-people-sign-in) gives the URLs it accepts. Beside `ACCESS_MODE=local`, it signs no one in, is taken only on `localhost`, `127.0.0.1` or `[::1]`, and is needed where the server listens beyond the loopback, as in a container ([Run with Docker](docker.md#on-your-own-machine)) | +| `IDENTITY_ISSUER` | `https://clerk.on.auto` | The identity service that people sign in through, an `https` URL with no path, query or fragment. A development runtime names the development instance of the service | +| `API_KEYS` | none | The API keys the server accepts, as a compact JSON array of the entries the key command prints; `api_keys` in the file. See [API keys](security.md#api-keys) | +| `ACCESS_MODE` | none | `local` declares [local mode](security.md#local-mode) on any address, for a server whose port only this computer reaches, such as a container whose port Docker publishes on `127.0.0.1` alone; never with a wider publish, and never beside containers on the same Docker network that you would not let act as you. It stops the start beside `API_KEYS`, beside a `PUBLIC_URL` that is not on `localhost`, `127.0.0.1` or `[::1]`, and without a `PUBLIC_URL` where the server listens beyond the loopback, as in a container; `access_mode` in the file. See [Run with Docker](docker.md#on-your-own-machine) | +| `ALLOWED_ORIGINS` | none | Comma-separated origins a browser page may call the API from, besides the Studio host's and, where the runtime serves Studio, its own; `allowed_origins` in the file. See [Calling from a browser](security.md#calling-from-a-browser) | +| `STUDIO_ORIGIN` | `https://studio.on.auto` | The Studio host: an `https` address, or an `http` address on `localhost`, `127.0.0.1` or `[::1]`, with no path. A page there may call the API, and the runtime fetches Studio from it when it serves Studio; `studio_origin` in the file. See [Studio served by the runtime](security.md#studio-served-by-the-runtime) | +| `SERVE_STUDIO` | `true` in local mode, `false` otherwise | `true` or `false`: whether the runtime serves Studio itself at `/studio/` on its own address, fetched from `STUDIO_ORIGIN` and kept beside a SQLite ledger file. It works in local mode alone for now, and the server warns at start where it is on elsewhere; `serve_studio` in the file. See [Studio served by the runtime](security.md#studio-served-by-the-runtime) | +| `AUTO_CLIENTS` | `true` in local mode, `false` otherwise | `true` or `false`: whether a provider whose way takes a registered client takes Auto's client too, through Auto's consent service, where `registered_clients` lists none at its issuer; it wins over `auto_clients` of the `connections` section. The tokens of such a consent pass through Auto at the consent and at every renewal. See [Consent through Auto](connections.md#consent-through-auto) | +| `CONSENT_SERVICE_URL` | `https://consent.on.auto` | Auto's consent service, an `https` URL with no path, query or fragment, or an `http` URL on `localhost`, `127.0.0.1` or `[::1]` for a test. The runtime reaches it whatever `allowed_hosts` says, as it reaches the Auto identity service | ### Storage @@ -515,14 +518,15 @@ connections: variables: [CRM_API_KEY] ``` -| Key | Default | Purpose | -| -------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `people_may_add` | `true` | `false` refuses every new connection, and the runtime uses none already added, though `list_connections` still lists them as they are | -| `allowed_hosts` | every host | The hosts a connection may reach, each a host or `*.` and a host for every host under it; the host of an `mcp` connection's `url`, of an `openapi` connection's `document` and `base_url`, of the `issuer` of client credentials, and of the issuer and the endpoints of a provider's way of consent. Left out, a connection may reach every host, addresses inside your network such as `localhost` or `10.0.0.5` among them | -| `registered_clients` | none | The clients you registered at authorization servers, each with its `issuers`, its `client_id`, and its `client_secret`, or its `private_key` with its `algorithm`, as a reference, or neither for a public client, as [Register your clients](connections.md#register-your-clients) sets out | -| `credential_kinds` | every kind | The kinds of credential a connection may take: `none`, `reference`, `secret`, `client_credentials` and `authorization` | -| `variables` | none | The environment variables a `reference` credential may name, such as `CRM_API_KEY`. None of them may be a variable the runtime reads for itself: any variable this page lists, such as `SECRETS_KEY`, `API_KEYS`, `PUBLIC_URL` or a provider's key; a credential the cloud libraries read, such as `AWS_SECRET_ACCESS_KEY`, `AZURE_CLIENT_SECRET`, `AZURE_CLIENT_CERTIFICATE_PASSWORD` or `AZURE_PASSWORD`; a gateway's `api_key_env`; or a variable that a reference of this file, of `TOOL_SERVERS` or of `CONNECTIONS` names | -| `providers` | none | Providers of your own beside those the runtime ships, each an entry of a provider, which a person connects by its name and an entry of `tool_servers` may name; one named as a shipped provider takes its place. See [Add providers of your own](connections.md#add-providers-of-your-own) | +| Key | Default | Purpose | +| -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `people_may_add` | `true` | `false` refuses every new connection, and the runtime uses none already added, though `list_connections` still lists them as they are | +| `allowed_hosts` | every host | The hosts a connection may reach, each a host or `*.` and a host for every host under it; the host of an `mcp` connection's `url`, of an `openapi` connection's `document` and `base_url`, of the `issuer` of client credentials, and of the issuer and the endpoints of a provider's way of consent. Left out, a connection may reach every host, addresses inside your network such as `localhost` or `10.0.0.5` among them | +| `registered_clients` | none | The clients you registered at authorization servers, each with its `issuers`, its `client_id`, and its `client_secret`, or its `private_key` with its `algorithm`, as a reference, or neither for a public client, as [Register your clients](connections.md#register-your-clients) sets out | +| `auto_clients` | `true` in local mode, `false` otherwise | Whether a provider whose way takes a registered client takes Auto's client too, through Auto's consent service, where `registered_clients` lists none at its issuer; `AUTO_CLIENTS` wins over it. See [Consent through Auto](connections.md#consent-through-auto) | +| `credential_kinds` | every kind | The kinds of credential a connection may take: `none`, `reference`, `secret`, `client_credentials` and `authorization` | +| `variables` | none | The environment variables a `reference` credential may name, such as `CRM_API_KEY`. None of them may be a variable the runtime reads for itself: any variable this page lists, such as `SECRETS_KEY`, `API_KEYS`, `PUBLIC_URL` or a provider's key; a credential the cloud libraries read, such as `AWS_SECRET_ACCESS_KEY`, `AZURE_CLIENT_SECRET`, `AZURE_CLIENT_CERTIFICATE_PASSWORD` or `AZURE_PASSWORD`; a gateway's `api_key_env`; or a variable that a reference of this file, of `TOOL_SERVERS` or of `CONNECTIONS` names | +| `providers` | none | Providers of your own beside those the runtime ships, each an entry of a provider, which a person connects by its name and an entry of `tool_servers` may name; one named as a shipped provider takes its place. See [Add providers of your own](connections.md#add-providers-of-your-own) | `add_connection` refuses with `403` a connection one of these rules does not take, with the sentence of the rule, and with `409` a connection named as an entry of `tool_servers`. A key the section does not know, a pattern that is not a host, a kind there is not, a variable the runtime reads for itself and an entry of `providers` the runtime cannot take stop the server at start, naming the line. diff --git a/docs/self-host/connections.md b/docs/self-host/connections.md index 9b0a2bc47..1c57c2aef 100644 --- a/docs/self-host/connections.md +++ b/docs/self-host/connections.md @@ -2,7 +2,7 @@ A connection is a tool server that a person adds to an org through their agent, rather than one you set up in Auto's settings file. The org owns it, and every playbook of the org may use it, or only the playbooks it names. The person approves it, and gives its credential where it takes one, on a page of Studio. The agent never sees the credential. -This page is for whoever runs Auto. It says how a connection is added, the providers a person may connect by name, what a connection holds, how a function uses it, the rules you set for it in `auto-runtime.yaml`, and the clients you register there. A tool server you set up yourself is still added as [Add a tool server](tool-servers.md) shows. +This page is for whoever runs Auto. It says how a connection is added, the providers a person may connect by name, what a connection holds, how a function uses it, the rules you set for it in `auto-runtime.yaml`, consent through Auto's clients, and the clients you register there. A tool server you set up yourself is still added as [Add a tool server](tool-servers.md) shows. ## How a person adds a connection @@ -45,7 +45,7 @@ A provider is a service Auto knows by its name: its server, an MCP server or an | `slack-api` | Read channels, history, threads and people, post and update messages and add reactions as a Slack app's bot | The Slack Web API, `slack.com`, from a document Auto ships | Gives the bot token of a Slack app installed in the workspace | | `zoom` | Read meetings, past meetings and their participants, cloud recordings, transcript details, AI summaries and Team Chat, and create and update meetings and send chat messages, as the person who connects | The Zoom API, `api.zoom.us`, from a document Auto ships | Signs in to Zoom | -A person who signs in connects the provider by consent, through a client you register at the vendor or, at Profound, one Auto names or registers itself, and at Adobe's MCP server one Auto registers itself, as [Let people connect by consent](#let-people-connect-by-consent) says for each. An agent that adds a provider by its name alone takes the first way `list_providers` marks offered, so where you registered no client at GitHub, `github` is added by its token, and `adobe-analytics`, whose first way is a Server-to-Server credential, is added by that credential. +A person who signs in connects the provider by consent, through a client you register at the vendor, through Auto's client where you register none and [consent through Auto](#consent-through-auto) is on, or, at Profound, one Auto names or registers itself, and at Adobe's MCP server one Auto registers itself, as [Let people connect by consent](#let-people-connect-by-consent) says for each. An agent that adds a provider by its name alone takes the first way `list_providers` marks offered, so where you registered no client at GitHub, `github` is added by its token, and `adobe-analytics`, whose first way is a Server-to-Server credential, is added by that credential. `google-ads` and `search-ads-360` take one placeholder, `manager_id`, the customer ID of the manager account a person reaches other accounts through, ten digits without hyphens, which Auto sends as `login-customer-id`; a person who connects an account directly leaves it out, and Auto then sends no `login-customer-id`. The documents of Google's HTTP APIs are made from the descriptions Google publishes of them, of the methods a function needs, and every operation of each is a tool. `google-search-console` reaches domain properties, named as `sc-domain:example.com`, since the address of a property of a URL prefix holds a `/`, which Auto never places in a path; and a report's file of `campaign-manager-360` or `display-video-360-reports` is a download or sits in Cloud Storage, which a call does not fetch. @@ -188,6 +188,36 @@ connections: `allowed_hosts` holds more than the addresses a connection names: the server an HTTP API's document names, where `base_url` is left out, is checked once Auto reads the document, and every request a call makes is checked before it is sent, those to an authorization server and those a redirect leads to among them. A call of an HTTP API follows no redirect, and the MCP client follows one only within the origin it asked. +## Consent through Auto + +Auto registers its own client at the vendors of the providers it ships and has it verified there, and its consent service lists the vendors where it has one. A runtime that has no client of yours at a vendor can take a person's consent through Auto's client instead, through Auto's consent service, so the person connects the provider and nobody registers anything at the vendor. It serves a runtime on your laptop, a container on your own machine in [local mode](security.md#local-mode), and a runtime whose people use the hosted Studio, `https://studio.on.auto`. + +1. The person presses Continue on Studio's page. Where you list no client at the provider's issuer under `registered_clients`, Auto asks its consent service to start the consent with Auto's client, and sends it the challenge of a secret the runtime alone holds. +2. The person signs in to the provider and allows Auto, under Auto's name. The provider sends them back to the Studio host, `https://studio.on.auto`, where they sign in to Auto, once, if they are not signed in already. +3. The consent service redeems the code with Auto's client and holds the tokens. The Studio host sends the person on to the Studio they started from: the one the runtime serves on their own computer, or the hosted Studio. That Studio finishes the consent, and the runtime takes the tokens from the service, once, with its secret, and keeps them sealed in its secret store, as it keeps every credential. + +The person sees the provider's page, Auto's sign-in once, a moment on the Studio host and their own Studio again; nothing of a port or an address of theirs is registered anywhere. + +What passes through Auto, and what Auto keeps: + +- The code and the tokens pass through the consent service at the consent, and the refresh token and the new tokens at every renewal, since only Auto's client can redeem and refresh them. They pass in transit, and wait for the runtime for at most ten minutes; the service keeps no token after that. +- The service keeps a start for ten minutes, the tokens it hands to the runtime for ten minutes or until the runtime takes them, the hash of the connection key it gives the runtime with them, beside the issuer and the person who allowed the consent, and a line for each redeem, renewal and revocation naming the person, the issuer and the Studio the person went back to. The runtime keeps the connection key sealed with the tokens and sends it with every renewal and revocation, so a refresh token alone renews nothing. +- The runtime renews the tokens through the service, under the same rules as a renewal at the provider, and removing the connection revokes them through it. + +Turn it on with `auto_clients` in the `connections` section, or `AUTO_CLIENTS` in the environment, which wins over it. It is `true` by default in local mode, where only the person's own browser on this computer reaches the runtime's Studio, and `false` otherwise, so a team whose people use the hosted Studio turns it on knowing that its tokens pass through Auto at the consent and at every renewal: + +```yaml +connections: + auto_clients: true +``` + +A client you register always comes first: where `registered_clients` lists a client at a provider's issuer, Auto uses it and asks the service nothing for that provider. Auto's client serves a provider whose way takes a registered client, `registered` among its `clients` or no `clients` at all, at a vendor whose client the service lists; the runtime asks the service for its list when it is first needed and again every hour, waiting at most five seconds for it, and logs at WARN when the service does not answer, after which no provider takes Auto's client for five minutes, when the runtime asks again. Where the service lists a client at the provider's vendor, `list_providers` shows the way as offered, `search` offers the provider to an agent and `add_connection` takes it. The runtime reaches the service at `CONSENT_SERVICE_URL`, `https://consent.on.auto` unless it is set, whatever `allowed_hosts` says, as it reaches the Auto identity service. + +Register your own clients instead, as the section below sets out: + +- on a runtime on a private network with a Studio of its own, served at an address other than the person's own computer: the service sends a person back only to their own computer or to the hosted Studio, so Auto's client is not offered there; a provider whose way lists another client takes that one, and a start with none is refused before the person is sent anywhere, saying that whoever runs the runtime registers a client; +- wherever no token is to pass through Auto, and leave `auto_clients` off. + ## Register your clients A registered client is the client identity you registered with a service's authorization server: a client id with a secret, a private key, or nothing for a public client. `registered_clients`, in the `connections` section of `auto-runtime.yaml`, lists the clients you registered, one entry for each client, with the issuers it is registered at: @@ -245,6 +275,7 @@ Auto takes the first source it has: | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `registered` | A client you register at the provider's authorization server, with the return address of the runtime as its redirect address: the Studio host's `/connect-return`, `STUDIO_ORIGIN` followed by it, `https://studio.on.auto/connect-return` unless it is set, or the runtime's address followed by `/studio/connect-return` where Auto serves Studio, as it does in local mode. A runtime has one return address at a time, the one its connect link opens, so a client is registered with both only where it serves several runtimes, some serving Studio and some not. List the client under `registered_clients` with the issuer the provider names, as [Register your clients](#register-your-clients) shows | | `metadata_document` | Sign-in at an `https` `PUBLIC_URL`: where Auto signs people in there, it names its client by the address `{PUBLIC_URL}/oauth/client.json`, where the authorization server advertises that it takes such a client, and serves the document at that address without a key, naming that address and the return address Auto sends people back to, as the [authentication reference](../reference/authentication.md#client-metadata-document) lists | +| `auto` | Nothing, where [consent through Auto](#consent-through-auto) is on and Auto's consent service lists a client at the issuer: Auto's client, through the service, taken by a way that takes `registered`, after a client you registered and before the two below. A way never lists it in `clients` | | `dynamic` | Nothing: where the authorization server offers it, Auto registers itself once for each issuer, keeps the registration sealed in its secret store, and uses it again for every later connection there until the secret the server gave it ends | Until Auto has a source a provider's way takes, `list_providers` shows the way as one you set up first, `search` leaves the provider out of the providers it lists to an agent, and `add_connection` refuses it. `allowed_hosts` applies to the issuer and to every endpoint the way names when a connection is added, to every endpoint the authorization server's metadata names when a consent starts, before the person is sent anywhere, and to every request a consent, a renewal of its tokens or their revocation sends. Leaving `authorization` out of `credential_kinds` lists no provider whose only way it is. diff --git a/packages/connections/README.md b/packages/connections/README.md index c8b328e01..90bf93b4a 100644 --- a/packages/connections/README.md +++ b/packages/connections/README.md @@ -23,7 +23,7 @@ The connections of an org: tool servers a person adds to the org through their a `ConnectionSchema` is the schema of the record. Times are ISO 8601 UTC strings read from Effect's `Clock`. -The fields `in`, `name` and `scheme` of a placement, `issuer` and `scope`, and the fields of consent, `parameters`, the three endpoints and `clients`, are `wayFields` of `@on.auto/providers`, the one place a provider's way and a connection's credential take them from; the credential adds what a way never has, `variable`, `client_id`, a client id as `ClientIdSchema` of `@on.auto/tool-servers` holds one, and `client_source`, which is `ClientSourceSchema` of the same package. The credential is one shape wherever it appears: what `add_connection` takes, what every operation answers in the record, and what `connection_added` keeps, with the client id the person gives on the page set on it once the connection is connected. Its fields are optional in the schema, each described with the kind it belongs to, and a check on the schema holds each kind to exactly its fields, so a credential that lacks one or gives another is `invalid_input` at the field, in words that name it, as `Expected name, which a reference credential takes` or `Expected no in: a none credential takes no other field`. The same check refuses a `reference` whose `variable` is no variable name (`variableNamePattern` of `@on.auto/config`), a `secret` or a `reference` sent as a header whose `name` is no header name (`headerNamePattern` of `@on.auto/tool-servers`), an `issuer`, or an endpoint of an `authorization` credential, the addresses `wayAddresses` of `@on.auto/providers` names, that is neither https nor on a loopback address (`isPinnableUrl` of `@on.auto/tool-servers`), and what `consentIssuesOf` of `@on.auto/providers` refuses in an `authorization` credential, as in its provider's way. A `scheme` is refused in words of its own: of a kind that places no value, `Expected no scheme: a none credential places no value in a header for the runtime to write a scheme before`; beside an `in` other than `header`, `Expected no scheme, which goes before a value sent in a header: this credential is sent in a query parameter`; holding `$`, which the reading of the placed entry takes for a reference or an escape, `Expected a scheme without $, which the reading of the entry takes for a reference or an escape`; and holding a character that is no HTTP token's, the characters of a header name (`headerNamePattern`), `Expected a scheme of the characters a header name holds, such as Bearer`. +The fields `in`, `name` and `scheme` of a placement, `issuer` and `scope`, and the fields of consent, `parameters`, the three endpoints and `clients`, are `wayFields` of `@on.auto/providers`, the one place a provider's way and a connection's credential take them from; the credential adds what a way never has, `variable`, `client_id`, a client id as `ClientIdSchema` of `@on.auto/tool-servers` holds one, and `client_source`, which is `ClientSourceSchema` of the same package, `auto` among its sources, the client of Auto's consent service. The credential is one shape wherever it appears: what `add_connection` takes, what every operation answers in the record, and what `connection_added` keeps, with the client id the person gives on the page set on it once the connection is connected. Its fields are optional in the schema, each described with the kind it belongs to, and a check on the schema holds each kind to exactly its fields, so a credential that lacks one or gives another is `invalid_input` at the field, in words that name it, as `Expected name, which a reference credential takes` or `Expected no in: a none credential takes no other field`. The same check refuses a `reference` whose `variable` is no variable name (`variableNamePattern` of `@on.auto/config`), a `secret` or a `reference` sent as a header whose `name` is no header name (`headerNamePattern` of `@on.auto/tool-servers`), an `issuer`, or an endpoint of an `authorization` credential, the addresses `wayAddresses` of `@on.auto/providers` names, that is neither https nor on a loopback address (`isPinnableUrl` of `@on.auto/tool-servers`), and what `consentIssuesOf` of `@on.auto/providers` refuses in an `authorization` credential, as in its provider's way. A `scheme` is refused in words of its own: of a kind that places no value, `Expected no scheme: a none credential places no value in a header for the runtime to write a scheme before`; beside an `in` other than `header`, `Expected no scheme, which goes before a value sent in a header: this credential is sent in a query parameter`; holding `$`, which the reading of the placed entry takes for a reference or an escape, `Expected a scheme without $, which the reading of the entry takes for a reference or an escape`; and holding a character that is no HTTP token's, the characters of a header name (`headerNamePattern`), `Expected a scheme of the characters a header name holds, such as Bearer`. ## The facts and the fold @@ -49,30 +49,30 @@ There is no read model: each call folds the stream (`connectionsFold`, the fold ## The operations -`makeConnectionOperations(services)` makes the ten operations, all org operations, so their routes are relative to the org, as one list for a catalog. `add_connection`, `list_connections` and `remove_connection` are also MCP tools, and `list_connections` shows an agent each connection's `status`; `get_connection`, which reads one connection by its id, `list_providers`, which a page reads, and the five of Studio's page are defined with `defineHttpOperation`, and so served over HTTP alone. The services are what the server composes: `secrets`, the `keep`, `read`, `replace`, `claim`, `endingsOf` and `forget` of the `SecretStore` of [`@on.auto/secrets`](../secrets/README.md), `SecretKeeping`, whose read answers a `KeptRow` and whose replacement and claim answer `TokensReplaced` and `TokensClaimed` of `@on.auto/tool-servers`; `policy`; `fileEntries`, the names of the entries of `TOOL_SERVERS`; `modelProviders`, the names of the model providers and gateways; `providers`, the one `ProviderList` the server reads at start, the providers it ships and those the setting adds, and `providerValues`, the values the environment sets for the variables its references name, which are variables the runtime reads for itself and `connections.variables` may not name; `pages`, the Studio settings and the runtime's origin, read when a link or a return address is made; `access`, who the runtime admits: `people` where it signs people in, `keys` where it takes API keys alone, and `local` in local mode; and `consent`, the `ConsentClient` of `@on.auto/tool-servers`, the runtime acting as a client at an authorization server, which the server makes once with the clients whoever runs it registered, `PUBLIC_URL` and `makeClientRegistrations`, and gives its tool access too. - -| Operation | Kind | Route | Permission | Answer | Rejections of the handler | -| ------------------- | ------- | ------------------------------------- | ----------- | ------------------------------------------- | -------------------------------------------------------------------- | -| `add_connection` | command | `POST /connections` | `org:write` | the record and `connect`, `201` | `invalid_input`, `forbidden`, `conflict` | -| `list_connections` | query | `GET /connections` | `org:read` | `{ connections }`, newest first | none | -| `get_connection` | query | `GET /connections/{id}` | `org:read` | the record | `not_found` | -| `remove_connection` | command | `DELETE /connections/{id}` | `org:write` | the record, removed | `not_found`, `conflict` | -| `read_link` | query | `GET /connections/{id}/link/{token}` | any | the record and `link` | `not_found` | -| `approve` | command | `POST /connections/{id}/approve` | any | the record | `not_found`, `forbidden`, `conflict` | -| `give_credential` | command | `POST /connections/{id}/credential` | any | the record | `not_found`, `forbidden`, `conflict`, `invalid_input` | -| `start` | command | `POST /connections/{id}/consent` | any | `{ authorize: { url, expires_at } }`, `201` | `not_found`, `forbidden`, `conflict`, `invalid_input`, `unavailable` | -| `finish` | command | `POST /connections/{id}/consent/code` | any | the record | `not_found`, `forbidden`, `conflict`, `invalid_input`, `unavailable` | -| `list_providers` | query | `GET /providers` | `org:read` | `{ providers, may_add, refusal }` | none | +`makeConnectionOperations(services)` makes the ten operations, all org operations, so their routes are relative to the org, as one list for a catalog. `add_connection`, `list_connections` and `remove_connection` are also MCP tools, and `list_connections` shows an agent each connection's `status`; `get_connection`, which reads one connection by its id, `list_providers`, which a page reads, and the five of Studio's page are defined with `defineHttpOperation`, and so served over HTTP alone. The services are what the server composes: `secrets`, the `keep`, `read`, `replace`, `claim`, `endingsOf` and `forget` of the `SecretStore` of [`@on.auto/secrets`](../secrets/README.md), `SecretKeeping`, whose read answers a `KeptRow` and whose replacement and claim answer `TokensReplaced` and `TokensClaimed` of `@on.auto/tool-servers`; `policy`; `fileEntries`, the names of the entries of `TOOL_SERVERS`; `modelProviders`, the names of the model providers and gateways; `providers`, the one `ProviderList` the server reads at start, the providers it ships and those the setting adds, and `providerValues`, the values the environment sets for the variables its references name, which are variables the runtime reads for itself and `connections.variables` may not name; `pages`, the Studio settings and the runtime's origin, read when a link or a return address is made; `access`, who the runtime admits: `people` where it signs people in, `keys` where it takes API keys alone, and `local` in local mode; and `consent`, the `ConsentClient` of `@on.auto/tool-servers`, the runtime acting as a client at an authorization server, which the server makes once with the clients whoever runs it registered, `PUBLIC_URL`, `makeClientRegistrations` and the switch of Auto's clients, and gives its tool access too. + +| Operation | Kind | Route | Permission | Answer | Rejections of the handler | +| ------------------- | ------- | ------------------------------------- | ----------- | ------------------------------------- | -------------------------------------------------------------------- | +| `add_connection` | command | `POST /connections` | `org:write` | the record and `connect`, `201` | `invalid_input`, `forbidden`, `conflict` | +| `list_connections` | query | `GET /connections` | `org:read` | `{ connections }`, newest first | none | +| `get_connection` | query | `GET /connections/{id}` | `org:read` | the record | `not_found` | +| `remove_connection` | command | `DELETE /connections/{id}` | `org:write` | the record, removed | `not_found`, `conflict` | +| `read_link` | query | `GET /connections/{id}/link/{token}` | any | the record and `link` | `not_found` | +| `approve` | command | `POST /connections/{id}/approve` | any | the record | `not_found`, `forbidden`, `conflict` | +| `give_credential` | command | `POST /connections/{id}/credential` | any | the record | `not_found`, `forbidden`, `conflict`, `invalid_input` | +| `start` | command | `POST /connections/{id}/consent` | any | `{ authorize, client_source }`, `201` | `not_found`, `forbidden`, `conflict`, `invalid_input`, `unavailable` | +| `finish` | command | `POST /connections/{id}/consent/code` | any | the record | `not_found`, `forbidden`, `conflict`, `invalid_input`, `unavailable` | +| `list_providers` | query | `GET /providers` | `org:read` | `{ providers, may_add, refusal }` | none | - `add_connection` takes one flat input, two ways in (`AdditionSchema` of `src/operations/connection-fields.ts`, whose check holds each way to its fields as the credential's check holds each kind to its own). Its `credential` is a schema of its own, the fields a credential given by hand takes, kept apart from the record's, which holds the fields of a way of consent beside them. With `provider`, the name of a provider, it takes `placeholders`, `name`, the provider's when left out, `credential.kind`, and where it is left out the first way the listing marks offered to the caller, `isOfferedWay` of `src/providers/provider-listing.ts`, the rule `list_providers` marks each way by, or the provider's first where it offers none, which the checks then refuse in their words, and of `entry` only `playbooks`, `allowed` and `record_content`, and refuses `type`, the server part of `entry` and every field of `credential` but `kind`, each at its field, `Expected no url: the provider gives the server`. Without, it takes `name`, `type`, `entry` and `credential` as a server given by hand, each required, `Expected type, which a server given by hand takes, or provider`, and refuses `placeholders`, and a credential of the kind `authorization` at `/credential/kind`, `Expected another kind: consent is offered through a provider alone, so name the provider, as search lists it, in provider`, since an entry given by hand could name any server and be handed tokens a person consented to at a trusted issuer. A provider is resolved first (`src/providers/provider-additions.ts`): `providerNamed` of `@on.auto/providers` refuses a name the runtime has no provider of at `/provider`, to which this package adds where the names are, `There is no provider named crm here; search lists the providers a person may connect`, and `additionOfProvider` turns the provider and what the caller gave into the name, the type, the entry and the credential a server given by hand would have been, refusing a placeholder or a kind at the pointer the caller wrote. Everything after is the path of a server given by hand, over the resolved addition: the credential's check, the entry's checks, the reading, the policy and the names, none skipped and none run twice; a problem they find in a field the provider resolved is reported at `/provider`, a name's problem at `/name` where the caller gave the name and at `/provider` where it is the provider's, and a problem of `playbooks`, `allowed` or `record_content` at its field. A provider's document beside its entries is read from the folder of the providers, as a file's document is read from the configuration's folder, where a document given by hand must be a URL. A provider's header or query value may hold a reference, `${NAME}`, for a value of the operator's, such as a developer token: the record keeps it as the provider wrote it, the entry's checks take it since the provider and not the caller wrote it, while an entry given by hand still refuses every reference, and the reading substitutes it from `providerValues`, so a provider whose value is not set is refused at `/provider` in the reading's words, `Refers to the environment variable METRICS_DEVELOPER_TOKEN, which is not set`. The record and `connection_added` carry `provider` and `placeholders`, and the resolved entry, so a later change to a provider's entry changes no connection there is. - `add_connection` first refuses a caller who cannot add a connection, `forbidden` (`adderRefusalOf`): where the runtime signs people in, a caller that is not a signed-in person (`isSignedInPerson` of `@on.auto/identity`), since a key's connection would wait for a person who never comes, and, where it takes keys alone, every caller, with the sentence that this runtime signs nobody in, so no person can approve a connection. In local mode the local developer adds any. It then checks the entry, every problem at its pointer in one `invalid_input` (`additionIssuesOf` in `src/entries/entry-checks.ts`): the name with `serverNameProblems`; an `mcp` connection's `url` that is neither https nor on a loopback address (`isPinnableUrl` of `@on.auto/tool-servers`), and a `document` that is not a URL, which would be a file on the runtime's disk; the entry with `credentialProblems` of `@on.auto/config`, so a value that looks like a credential is refused, and with `substituted` of the same package against an empty environment, so any `${…}` is refused as a reference; and the credential: a header the runtime sets itself (`headersTheRuntimeSets` of `@on.auto/tool-servers`, which the headers of an entry are checked against too) or the entry sends already, a query parameter or a security scheme for an `mcp` connection, and a query parameter the entry sends already. Where all of these pass (`entryIssuesOf`), it reads the entry with its credential placed, a value standing in for the one the person gives (`readAddition`), as the source reads it to serve it with the real value (see [The calls](#the-calls)), so what the file's reading refuses is refused here, at its pointer under `/entry`, and no connection reaches the ledger that its use would refuse for its entry. Client credentials that leave their `client_id` to the page are read with a client id standing in for it too, since the reading builds the `auth` block, which takes one. So is a credential of the kind `authorization`, whose client is recorded only when its consent finishes, so that a provider's `{client_id}` is read with a value in its place, as it is served; the kinds whose credential takes a `client_id` are `takesAClientId` of `src/registry/credential.ts`, which asks `holdsAClientId` of `@on.auto/providers`. Every other check runs on what was given. What the reading refuses in a client id, one that looks like a credential, is refused at `/credential/client_id`, the field the agent wrote, in words of its own, `Looks like a credential, which a client id never is: leave the secret out, and the person gives it on the page the link opens`, which replace the reading's (`heldCredentialAsClientId`, keyed on the reading's `/entry/auth/client_id`). A secret may go in `Authorization`, which the runtime sets itself only for client credentials and an entry never sends, since its name names a credential. It then applies the policy, `forbidden` with the sentence of the rule, refuses the name of an entry of the file, `conflict` of the kind `taken`, and records the connection with a link: a token of 32 random bytes in base64url, of which the fact keeps the SHA-256 alone. `connect.url` is the page `connect` under the base of the Studio the person can reach, `studioBaseOf` of `@on.auto/identity`, `/studio/` where the runtime serves Studio and the root of the Studio host otherwise, as `connect?server=&org=&connection=&token=` (`connectPageOf`). Its definition gives `linkToOpen`, the page and a message saying what to do there, which the MCP tool asks a client to open (`@on.auto/api`). -- `list_providers` (`src/providers`) answers the providers the policy could allow here, sorted by name, each with `name`, `title`, `description`, `website`, `type`, its ways, `credentials`, as `{ kind, in?, name?, scheme?, issuer?, scope?, description?, offered, because? }`, and the placeholders a person gives, `placeholders`, as `{ name, description, pattern?, example?, required, choices? }`. A way is listed where `credential_kinds` takes its kind and the hosts it reaches, `hostAlternativesOf` of `@on.auto/providers`, could pass `allowed_hosts`: a fixed host a pattern takes; a host with a placeholder where every host is allowed, where an allowed host has its shape, the placeholder standing for letters, digits and hyphens, or where a pattern `*.` and a host ends the part of the host after its last placeholder; none where `allowed_hosts` is empty; and, of a placeholder whose choices set the host, the hosts of any one choice. A way is `offered: false` while a variable its provider's references name is not set, with `because` saying whoever runs the runtime sets it up first, and a way of the kind `authorization` is `offered: false` where the consent client has none of the client sources it lists, `sourcesOf` of `ConsentClient`, judged with the way's issuer as the provider writes it, with `because` `The provider drive takes consent through a client whoever runs Auto registers, which is not set up here`; `add_connection` refuses such a way at `/credential/kind` in the same words, judged with the issuer resolved. A provider with no way listed is left out. `may_add` says whether this caller may add a connection here, and `refusal`, where it may not, the first sentence that refuses, in this order: the sentence a caller without `org:write` is refused with (`permissionRefusalOf` of `@on.auto/operations`), which the dispatcher checks before any handler runs, then the adder check `add_connection` begins with, then `people_may_add`; it is `null` otherwise. Nothing of an entry is secret, so the list is answered whole. -- `makeProviderOffering(services)` (`src/providers/provider-offering.ts`) is the `ProviderOffering` of `@on.auto/tool-servers`, which the server gives the org's `search`: to a caller for whom `may_add` would be `true`, by the same judgement, `refusalToAdd`, it answers the providers `list_providers` lists with at least one way `offered`, each by `name`, `title` and `description`, reading `offered` from the listing and judging nothing again, so a provider none of whose ways is offered is left out of `search` while the page lists it with its ways marked, and to any other caller nothing, so `search` lists the providers a person may connect only to a caller who may add one. -- `read_link` matches the token as `approve` does, never binding a person, and answers the record and `link`, `{ expires_at }`, the time `connection_link_issued` holds and the match checks, so the page can show the connection and how long the person has, and `consent`, `{ started_at, expires_at }`, while a start is open. `approve` and `give_credential` answer the record alone, since they use the link up. +- `list_providers` (`src/providers`) answers the providers the policy could allow here, sorted by name, each with `name`, `title`, `description`, `website`, `type`, its ways, `credentials`, as `{ kind, in?, name?, scheme?, issuer?, scope?, description?, offered, because? }`, and the placeholders a person gives, `placeholders`, as `{ name, description, pattern?, example?, required, choices? }`. A way is listed where `credential_kinds` takes its kind and the hosts it reaches, `hostAlternativesOf` of `@on.auto/providers`, could pass `allowed_hosts`: a fixed host a pattern takes; a host with a placeholder where every host is allowed, where an allowed host has its shape, the placeholder standing for letters, digits and hyphens, or where a pattern `*.` and a host ends the part of the host after its last placeholder; none where `allowed_hosts` is empty; and, of a placeholder whose choices set the host, the hosts of any one choice. A way is `offered: false` while a variable its provider's references name is not set, with `because` saying whoever runs the runtime sets it up first, and a way of the kind `authorization` is `offered: false` where the consent client has none of the client sources it lists, `sourcesOf` of what `hadSources` of `ConsentClient` answers for the base of the Studio the page opens, `studioBaseFor`, once the clients of Auto's consent service are listed where the client takes that source and the base is one the service sends people back to, judged with the way's issuer as the provider writes it, with `because` `The provider drive takes consent through a client whoever runs Auto registers, which is not set up here`; `add_connection` refuses such a way at `/credential/kind` in the same words, judged with the issuer resolved. A provider with no way listed is left out. `may_add` says whether this caller may add a connection here, and `refusal`, where it may not, the first sentence that refuses, in this order: the sentence a caller without `org:write` is refused with (`permissionRefusalOf` of `@on.auto/operations`), which the dispatcher checks before any handler runs, then the adder check `add_connection` begins with, then `people_may_add`; it is `null` otherwise. Nothing of an entry is secret, so the list is answered whole. +- `makeProviderOffering(services)` (`src/providers/provider-offering.ts`) is the `ProviderOffering` of `@on.auto/tool-servers`, which the server gives the org's `search`, an effect for a caller: to a caller for whom `may_add` would be `true`, by the same judgement, `refusalToAdd`, it answers the providers `list_providers` lists with at least one way `offered`, each by `name`, `title` and `description`, reading `offered` from the listing and judging nothing again, so a provider none of whose ways is offered is left out of `search` while the page lists it with its ways marked, and to any other caller nothing, so `search` lists the providers a person may connect only to a caller who may add one. +- `read_link` matches the token as `approve` does, never binding a person, and answers the record and `link`, `{ expires_at }`, the time `connection_link_issued` holds and the match checks, so the page can show the connection and how long the person has, and `consent`, `{ started_at, expires_at }`, while a start is open. For a connection of the kind `authorization` it answers `client_source` too, the source a start would take now, `sourceOf` of what `hadSources` of the consent client answers for the base of the Studio the link opens, the one rule the start takes its source by, `registered` where whoever runs the runtime registered a client at the issuer, else `auto` where Auto's consent service serves it, then the rest, so the page can say before the person goes that Auto's client is used; it leaves the field out where the runtime has no source the way takes, and for a connection of another kind asks the consent client nothing. The start's own `client_source` is the source it took, which differs only where a metadata document the start meant to take is one the server does not take. `approve` and `give_credential` answer the record alone, since they use the link up. - `give_credential` checks the link and the fields the credential's kind takes, `value` alone for a `secret`, `client_secret` alone or `private_key` with `algorithm` for `client_credentials`, else `invalid_input`. Client credentials take `client_id` beside them where the credential holds none, and refuse it where it holds one, each at `/client_id`: `Expected client_id: the connection's credential holds no client id, so it is given here with the secret or the key` and `Expected no client_id: the connection's credential holds its client id already`. A client id given here is read as `add_connection` reads the entry, with the value standing in for the secret (`clientIdReadingIssuesOf`), and one that looks like a credential is refused at `/client_id` before anything is kept, so no connection is connected with a client id its use would refuse: `Looks like a credential, which a client id never is: the connection's record keeps the client id in the open, so the secret or the key goes in its own field, where it is sealed`. A `secret` whose credential names a `scheme` refuses a value that starts with the scheme and a space, in any case, at `/value`, `Paste the token alone, without Bearer before it: the runtime writes Bearer itself`. It keeps exactly the value in the secret store, a value the store refuses being `invalid_input` at its pointer with the store's sentence, and records the credential's id, and the client id given on the page. A decision that fails after the value was kept, as when the connection was removed meanwhile, forgets it. -- `start` (`src/page/consent-start.ts`) takes `{ token }` and asks the consent client to start (`src/page/consent-asking.ts`): the connection's server, `{ type: mcp, url }` or `{ type: openapi }`, its credential's way, the return address, `returnAddressOf` of `src/links/connect-page.ts`, written beside `connectPageOf` as the page `connect-return` under the base the link's page takes, `studioBaseOf`, beside the page `connect`, since Studio's build names its files relative to the page: `/studio/connect-return` where the runtime serves Studio, since a page on the Studio host cannot reach a runtime on a loopback address, and the Studio host's `/connect-return` otherwise, `STUDIO_ORIGIN` followed by it, so the person returns to the Studio they started from; a runtime has one return address at a time, the one its connect link opens, and a client is registered with both only where it serves several runtimes, some serving Studio and some not, the rule's `reaches`, `reachesUnder` of `src/policy/policy-rules.ts`, which the serving of a connection takes too, and a state of 32 random bytes in base64url, `issuedToken` of `src/links/link-token.ts`, the link's token's own. It keeps the verifier the client made in the secret store, records the start with the state's digest and ten minutes of its own, `tenMinutesOn`, and answers `{ authorize: { url, expires_at } }`, `201`; a start that cannot be recorded, whether the record fails or dies, forgets the verifier it kept, and a start that replaces one forgets the verifier of the one before. What the client refuses is answered in the words of `src/page/consent-words.ts`: another issuer, another resource, no metadata, no S256 challenge and no client the runtime has as `conflict`, `The server of the connection drive names https://elsewhere.example.com as its authorization server, where its provider names https://accounts.drive.example.com, so it cannot be connected as it was added`, or, where the metadata of the authorization server names another issuer, `The authorization server of the connection drive names https://elsewhere.example.com as its issuer in its own metadata, where its provider names https://accounts.drive.example.com, so it cannot be connected as it was added`, a host the rules refuse as `forbidden` in the rule's words, and a server that does not answer as `unavailable`. -- `finish` (`src/page/consent-finish.ts`) takes `{ token, code, state, iss? }`: it finds the open start as the decider does, forgetting the verifier of one whose ten minutes are over, reads the verifier, `not_found` where its row is gone, and asks the consent client to redeem the code with the client the start recorded. It keeps the tokens as one sealed value in a new row of the secret store, forgets the verifier's row, and records the approval and the connection, answering the connected record. An issuer that is not the server's is `invalid_input` at `/iss`, `Expected the issuer of the authorization server of the connection, https://accounts.drive.example.com`, a code the server refuses at `/code`, `Expected a code the authorization server takes; it refused this one`, and the start stays open; a client no longer registered is `conflict`, `The client the connection drive was made with is no longer registered, so the person who added it removes it and adds it again`, a grant of no scope to a scope asked, which the client refuses after revoking the tokens, is `conflict`, `The authorization server of the connection drive granted none of the scopes asked, so the connection is not connected; start again and allow them`, and a grant that is not a scope, refused so too, is `conflict`, `The authorization server of the connection drive granted a scope written with characters a scope does not hold, so it cannot be connected as it was added`; an empty grant where no scope was asked records none. Where the tokens exist and the finish is refused after, a value the store refuses, `conflict` in the store's words, or a decision that fails, as when the connection was removed meanwhile, it revokes the tokens through the client and forgets their row before it answers; the steps from keeping the tokens to the record run under `Effect.onError`, so a step that dies or is interrupted revokes and forgets as one that fails does. -- `remove_connection` forgets the connection's secret once its removal is appended, and the verifier of a start it holds, and forgetting twice is forgetting once. A connection of the kind `authorization` whose row of tokens the store still holds has them revoked first, before the row is forgotten: it reads the row and asks the consent client to revoke them, `revoke` of `ConsentClient`, with the client the connection recorded, bounded as a call is, at the revocation endpoint the metadata or the way names. A revocation that does not happen is logged at WARN in words of its own for each case (`unrevokedWordsOf` of `src/page/consent-words.ts`), and the removal goes on: refused, `The authorization server of the connection drive refused to revoke its tokens, answering HTTP 401, so they end on their own`; not answered, `The authorization server of the connection drive did not answer the revocation of its tokens, so they end on their own`; a host the rules refuse, `The tokens of the connection drive were not revoked, so they end on their own: `; and a client gone or metadata that changed, `The tokens of the connection drive could not be revoked with the client and at the authorization server it was made with, so they end on their own`. None holds a word of the server's or a token; a connection that holds no tokens, pending or removed already, revokes nothing. +- `start` (`src/page/consent-start.ts`) takes `{ token }` and asks the consent client to start (`src/page/consent-asking.ts`) with the base of the Studio the link opens, `studioBaseFor` of `src/links/connect-page.ts`, exactly as the link opens it, which the client judges and sends to Auto's consent service where it starts through it: the connection's server, `{ type: mcp, url }` or `{ type: openapi }`, its credential's way, the return address, `returnAddressOf` of `src/links/connect-page.ts`, written beside `connectPageOf` as the page `connect-return` under the base the link's page takes, `studioBaseOf`, beside the page `connect`, since Studio's build names its files relative to the page: `/studio/connect-return` where the runtime serves Studio, since a page on the Studio host cannot reach a runtime on a loopback address, and the Studio host's `/connect-return` otherwise, `STUDIO_ORIGIN` followed by it, so the person returns to the Studio they started from; a runtime has one return address at a time, the one its connect link opens, and a client is registered with both only where it serves several runtimes, some serving Studio and some not, the rule's `reaches`, `reachesUnder` of `src/policy/policy-rules.ts`, which the serving of a connection takes too, and a state of 32 random bytes in base64url, `issuedToken` of `src/links/link-token.ts`, the link's token's own. It keeps the verifier the client made in the secret store, records the start with the state's digest and ten minutes of its own, `tenMinutesOn`, and answers `{ authorize: { url, expires_at } }`, `201`; a start that cannot be recorded, whether the record fails or dies, forgets the verifier it kept, and a start that replaces one forgets the verifier of the one before. What the client refuses is answered in the words of `src/page/consent-words.ts`: another issuer, another resource, no metadata, no S256 challenge and no client the runtime has as `conflict`, `The server of the connection drive names https://elsewhere.example.com as its authorization server, where its provider names https://accounts.drive.example.com, so it cannot be connected as it was added`, or, where the metadata of the authorization server names another issuer, `The authorization server of the connection drive names https://elsewhere.example.com as its issuer in its own metadata, where its provider names https://accounts.drive.example.com, so it cannot be connected as it was added`, a host the rules refuse as `forbidden` in the rule's words, and a server that does not answer as `unavailable`. The start records the digest of the state the client answers, its own for a client of the runtime's and the service's for `auto`, and answers `client_source`, the source of the client it was started with, beside `authorize`. A Studio neither hosted nor on the person's computer leaves Auto's client out, so a later source the way lists serves the start; where none remains, the start is refused before anything is sent, `conflict`, `The connection drive takes consent through Auto's client only where Studio is Auto's or on this computer, so whoever runs this runtime registers a client`. A start Auto's consent service refuses, since Auto has no client at the issuer or the service does not send people back to the Studio the link opens, is `conflict` too, `Auto's consent service did not start the consent of the connection drive: Auto has no client at its authorization server, or the service does not send people back to the Studio its link opens, so whoever runs this runtime registers a client`. +- `finish` (`src/page/consent-finish.ts`) takes one of two shapes, a union of the input schema, `{ token, state, code, iss? }` for a start with a client of the runtime's, `registered`, `metadata_document` or `dynamic`, and `{ token, state, handle }` for a start of `auto`, whose handle Auto's consent service sends the person back with; the start's client decides, and a finish of the other shape is `invalid_input` at the field it gave, `/code`, `Expected handle: the consent of the connection drive began through Auto's consent service, which answers a handle, not a code`, or `/handle`, `Expected code: the consent of the connection drive began with a client of this runtime's, which answers a code, not a handle`; a handle the service refuses, taken already, ended or answering another start, is `invalid_input` at `/handle`, `Expected a handle Auto's consent service takes; it refused this one`, and leaves the start open as a refused code does. Otherwise it finds the open start as the decider does, forgetting the verifier of one whose ten minutes are over, reads the verifier, `not_found` where its row is gone, and asks the consent client to redeem the code with the client the start recorded. It keeps the tokens as one sealed value in a new row of the secret store, forgets the verifier's row, and records the approval and the connection with the client the consent client answers it redeemed with, the one the start recorded, or for `auto` the one the service's handoff names, answering the connected record. An issuer that is not the server's is `invalid_input` at `/iss`, `Expected the issuer of the authorization server of the connection, https://accounts.drive.example.com`, a code the server refuses at `/code`, `Expected a code the authorization server takes; it refused this one`, and the start stays open; a client no longer registered is `conflict`, `The client the connection drive was made with is no longer registered, so the person who added it removes it and adds it again`, a grant of no scope to a scope asked, which the client refuses after revoking the tokens, is `conflict`, `The authorization server of the connection drive granted none of the scopes asked, so the connection is not connected; start again and allow them`, and a grant that is not a scope, refused so too, is `conflict`, `The authorization server of the connection drive granted a scope written with characters a scope does not hold, so it cannot be connected as it was added`; an empty grant where no scope was asked records none. Where the tokens exist and the finish is refused after, a value the store refuses, `conflict` in the store's words, or a decision that fails, as when the connection was removed meanwhile, it revokes the tokens through the client and forgets their row before it answers; the steps from keeping the tokens to the record run under `Effect.onError`, so a step that dies or is interrupted revokes and forgets as one that fails does. +- `remove_connection` forgets the connection's secret once its removal is appended, and the verifier of a start it holds, and forgetting twice is forgetting once. A connection of the kind `authorization` whose row of tokens the store still holds has them revoked first, before the row is forgotten: it reads the row and asks the consent client to revoke them, `revoke` of `ConsentClient`, with the client the connection recorded, bounded as a call is, at the revocation endpoint the metadata or the way names. A revocation that does not happen is logged at WARN in words of its own for each case (`unrevokedWordsOf` of `src/page/consent-words.ts`), and the removal goes on: refused, `The authorization server of the connection drive refused to revoke its tokens, answering HTTP 401, so they end on their own`; not answered, `The authorization server of the connection drive did not answer the revocation of its tokens, so they end on their own`, or, for a connection of `auto`, `Auto's consent service did not answer the revocation of the tokens of the connection drive, so they end on their own`; a key Auto's consent service does not know, `Auto's consent service does not know the key of the connection drive, so its tokens were not revoked and end on their own`; a host the rules refuse, `The tokens of the connection drive were not revoked, so they end on their own: `; and a client gone or metadata that changed, `The tokens of the connection drive could not be revoked with the client and at the authorization server it was made with, so they end on their own`. None holds a word of the server's or a token; a connection that holds no tokens, pending or removed already, revokes nothing. - The five operations of the page are permitted by every permission, since the token binds them and, where people sign in, the person: their caller must still belong to the org of the route. ## The ending of a credential @@ -81,7 +81,7 @@ There is no read model: each call folds the stream (`connectionsFold`, the fold ## The policy -`readConnectionsSetting(environment)` reads `CONNECTIONS`, a JSON object, `connections` of the configuration file through `ConnectionsSettingSchema`, once, and answers `ConnectionsSetting`: `policy`, what people may add; `registeredClients`, the clients whoever runs the runtime registered at authorization servers (see [The registered clients](#the-registered-clients)), which is no rule about what people may add; `clientVariables`, the variables their references name; and `providers`, the providers whoever runs the runtime adds beside those it ships (see [The providers the setting adds](#the-providers-the-setting-adds)). The policy is `people_may_add`, `true` when left out; `allowed_hosts`, each a host or `*.` and a host, every host when left out; `credential_kinds`, every kind when left out; and `variables`, the variable names a `reference` may give, none when left out. `namingNoOwnVariable(setting, ownVariables)` checks `variables` apart from the reading, since a reader may know the variables it reads for itself only once it has read what refers to them: a name in `variables` that is one of `ownVariables`, or of `clientVariables`, is a problem at its pointer, so the server stops at start rather than let a connection send one of them. A pattern `*.example.com` takes every host under `example.com` and not `example.com` itself, and the hosts checked, at add and at use, are those of an `mcp` connection's `url`, of an `openapi` connection's `document` and `base_url`, or, where its provider's document lies beside the entries and the provider gives no `base_url`, the server that document names, `documentServerOf` of `@on.auto/providers`, and of a `client_credentials` credential's `issuer`, which the client secret or the key's assertion reaches, and of an `authorization` credential's `issuer` and endpoints. A malformed value is `ConnectionsSettingInvalid`, naming each problem at its pointer. +`readConnectionsSetting(environment)` reads `CONNECTIONS`, a JSON object, `connections` of the configuration file through `ConnectionsSettingSchema`, once, and answers `ConnectionsSetting`: `policy`, what people may add; `registeredClients`, the clients whoever runs the runtime registered at authorization servers (see [The registered clients](#the-registered-clients)), which is no rule about what people may add; `autoClients`, `auto_clients` as the setting gives it, whether a way that takes a registered client takes Auto's client too through Auto's consent service, or `undefined` where the setting says nothing, which the server then decides by local mode; `clientVariables`, the variables their references name; and `providers`, the providers whoever runs the runtime adds beside those it ships (see [The providers the setting adds](#the-providers-the-setting-adds)). The policy is `people_may_add`, `true` when left out; `allowed_hosts`, each a host or `*.` and a host, every host when left out; `credential_kinds`, every kind when left out; and `variables`, the variable names a `reference` may give, none when left out. `namingNoOwnVariable(setting, ownVariables)` checks `variables` apart from the reading, since a reader may know the variables it reads for itself only once it has read what refers to them: a name in `variables` that is one of `ownVariables`, or of `clientVariables`, is a problem at its pointer, so the server stops at start rather than let a connection send one of them. A pattern `*.example.com` takes every host under `example.com` and not `example.com` itself, and the hosts checked, at add and at use, are those of an `mcp` connection's `url`, of an `openapi` connection's `document` and `base_url`, or, where its provider's document lies beside the entries and the provider gives no `base_url`, the server that document names, `documentServerOf` of `@on.auto/providers`, and of a `client_credentials` credential's `issuer`, which the client secret or the key's assertion reaches, and of an `authorization` credential's `issuer` and endpoints. A malformed value is `ConnectionsSettingInvalid`, naming each problem at its pointer. ### The registered clients @@ -108,7 +108,7 @@ A client the consent client registers dynamically at an authorization server is 1. The policy of the moment is checked with the rules `add_connection` applies (`useRefusalOf`), and a connection a rule no longer takes is `connection_not_allowed` with the rule's sentence, its credential unread: `credential_kinds`, `variables` for a `reference`, and `allowed_hosts` over the entry's `url`, `document` and `base_url` and the credential's addresses, `wayAddresses`. With `people_may_add` `false` the runtime uses no connection, in words that speak of using rather than adding, `This runtime uses no connection: its operator sets up its tool servers itself`, while `list_connections` still answers each as it is. 2. The connection is checked with `entryIssuesOf`, the checks of `add_connection` that need no value, against the model providers of the moment, so an entry the runtime no longer takes, as one named as a model provider set up since, is `connection_not_allowed`, `The entry of the connection is not one this runtime takes any more: `, its credential unread. 3. The credential is read (`src/serving/given-credentials.ts`): none for `none`; for a `reference`, the value of its `variable`, and a variable the environment leaves unset is `connection_not_allowed`, saying whoever runs the runtime sets it and starts it again; for a `secret` or `client_credentials`, the store's row of its secret's id; for `authorization`, the row of its tokens, the `secret_id` of its `connection_connected`, read as a `secret`'s is and decoded with `keptTokensIn` of `@on.auto/tool-servers`, a value that is no tokens breaking the connection as unreadable, `The credential of the connection crm cannot be read, so the person who added it removes it and adds it again: its value is not the tokens of a consent the runtime keeps`, which places nothing in the entry and is handed to `@on.auto/tool-servers`, decoded, with the rest of the connection's consent once the entry is read, below. A row the store cannot read, `SecretKeyUnknown`, or one that is gone breaks the connection: the source issues `break`, records `connection_broken` with the sentence of why, `The credential of the connection crm cannot be read, so the person who added it removes it and adds it again: ` or `… is no longer in the secret store, so …`, and answers it `connection_broken` in it. Nothing un-breaks a connection; the person removes it and adds it again. -4. The credential is placed as the file places a reference (`src/serving/placed-credentials.ts`): the entry, every `$` in it written `$$` so that it is sent as it is written, with a `secret` or a `reference` as `${…}` in the header, the query parameter or the security scheme its `in` and `name` say, after its `scheme` and a space where it names one, as in `Bearer ${…}`, or `client_credentials` as the `auth` block with its issuer, client id and scope, and the client secret, or the private key, as `${…}` beside the algorithm, the issuer, the client id, the scope and the algorithm each written with `$$` for `$` as the entry is; a `reference` names its own variable, and a value given on the page stands under a variable of its own, in an environment of that one entry. `placedReference` is that placing of a named reference, for client credentials as for a `reference`, to which `placedCredential` gives the variable the value stands under and its environment. In a connection made from a provider, the header and query values are read as the provider wrote them, its references substituted from `providerValues` and scrubbed as every reference's value is, and the rest of the entry as it is written. In one of client credentials or of the kind `authorization`, each `{client_id}` the entry holds stays as the provider wrote it, and the client id of the record's credential, the one the person gave on the page or the client the connection was made with, which `connection_connected` recorded, is handed to `readEntry` as its context's `placedValues` (`clientIdPlacedFor`, the one placing of it), which places it in the header and query values once the entry as written is judged, as its text, so the client id is sent where the provider says and, kept in the open in the record, is scrubbed from nothing; the reading at add places the stand-in client id. `readEntry` of `@on.auto/tool-servers`, the reading of an entry of the file, then resolves it and makes it `ServerSettings`, its type the connection's, its org the org and its `provider` the connection's (`readPlaced`), with `substituted`, `credentialProblems`, `unreferencedSecurity`, `misplacedReferencesOfEntry` and `secretsOfEntry` as they are for the file: every value the reference resolves to is a secret, the whole value of a security scheme too, and a value in a header that is a scheme and a credential, as `Bearer `, a secret whole and its credential alone, so the scrubber catches it in every form `@on.auto/tool-servers` lists. This is `readAddition`, the reading `add_connection` makes with a value standing in for the person's, made once here with the real value; what it refuses is `connection_not_allowed` in the words of step 2, so a check that a value can fail refuses in its own words, as an empty value of a reference does. For `authorization` no credential is placed: the entry is read as it is written, its `{client_id}` placed as above, and its `ServerSettings` take as `auth` the third kind of credential of `@on.auto/tool-servers`, `consent`, which `consentAuthOf` composes: the issuer, the client the connection recorded and the scope it was granted, with the way, the server and the return address `start` asks with, `reaches`, the row step 3 read, and `ConnectionTokens`, the store of that row, whose `read` reads it as step 3 does, whose `replace` and `claim` are the secret store's on it, and whose `broken(ended)` records the break as step 3 records one, the one place a break is recorded, in `The consent of the connection drive was revoked or has ended, so the person who added it removes it and adds it again` or in the sentence `finish` has for a client no longer registered, `The client the connection drive was made with is no longer registered, so the person who added it removes it and adds it again`. Each runs in the context the connection was prepared in, so its clock and its log are the server's. The consent client serves and renews the tokens from there, as its README says. +4. The credential is placed as the file places a reference (`src/serving/placed-credentials.ts`): the entry, every `$` in it written `$$` so that it is sent as it is written, with a `secret` or a `reference` as `${…}` in the header, the query parameter or the security scheme its `in` and `name` say, after its `scheme` and a space where it names one, as in `Bearer ${…}`, or `client_credentials` as the `auth` block with its issuer, client id and scope, and the client secret, or the private key, as `${…}` beside the algorithm, the issuer, the client id, the scope and the algorithm each written with `$$` for `$` as the entry is; a `reference` names its own variable, and a value given on the page stands under a variable of its own, in an environment of that one entry. `placedReference` is that placing of a named reference, for client credentials as for a `reference`, to which `placedCredential` gives the variable the value stands under and its environment. In a connection made from a provider, the header and query values are read as the provider wrote them, its references substituted from `providerValues` and scrubbed as every reference's value is, and the rest of the entry as it is written. In one of client credentials or of the kind `authorization`, each `{client_id}` the entry holds stays as the provider wrote it, and the client id of the record's credential, the one the person gave on the page or the client the connection was made with, which `connection_connected` recorded, is handed to `readEntry` as its context's `placedValues` (`clientIdPlacedFor`, the one placing of it), which places it in the header and query values once the entry as written is judged, as its text, so the client id is sent where the provider says and, kept in the open in the record, is scrubbed from nothing; the reading at add places the stand-in client id. `readEntry` of `@on.auto/tool-servers`, the reading of an entry of the file, then resolves it and makes it `ServerSettings`, its type the connection's, its org the org and its `provider` the connection's (`readPlaced`), with `substituted`, `credentialProblems`, `unreferencedSecurity`, `misplacedReferencesOfEntry` and `secretsOfEntry` as they are for the file: every value the reference resolves to is a secret, the whole value of a security scheme too, and a value in a header that is a scheme and a credential, as `Bearer `, a secret whole and its credential alone, so the scrubber catches it in every form `@on.auto/tool-servers` lists. This is `readAddition`, the reading `add_connection` makes with a value standing in for the person's, made once here with the real value; what it refuses is `connection_not_allowed` in the words of step 2, so a check that a value can fail refuses in its own words, as an empty value of a reference does. For `authorization` no credential is placed: the entry is read as it is written, its `{client_id}` placed as above, and its `ServerSettings` take as `auth` the third kind of credential of `@on.auto/tool-servers`, `consent`, which `consentAuthOf` composes: the issuer, the client the connection recorded and the scope it was granted, with the way, the server and the return address `start` asks with, `reaches`, the row step 3 read, and `ConnectionTokens`, the store of that row, whose `read` reads it as step 3 does, whose `replace` and `claim` are the secret store's on it, and whose `broken(ended)` records the break as step 3 records one, the one place a break is recorded, in `The consent of the connection drive was revoked or has ended, so the person who added it removes it and adds it again` or in the sentence `finish` has for a client no longer registered, `The client the connection drive was made with is no longer registered, so the person who added it removes it and adds it again`, or, for a connection of `auto` whose key Auto's consent service does not know, `Auto's consent service does not know the key of the connection drive, so its tokens cannot be renewed, and the person who added it removes it and adds it again`. Each runs in the context the connection was prepared in, so its clock and its log are the server's. The consent client serves and renews the tokens from there, as its README says. The answer carries `reaches`, which answers a URL whose host `allowed_hosts` does not take with the sentence of the rule, `hostRefusalOf`; the tool access checks every request of the connection with it, the server its document names where `base_url` is left out among them, and the request of a redirect. diff --git a/packages/connections/src/links/connect-page.ts b/packages/connections/src/links/connect-page.ts index d8fe4b698..7977527c6 100644 --- a/packages/connections/src/links/connect-page.ts +++ b/packages/connections/src/links/connect-page.ts @@ -11,6 +11,10 @@ export interface PageOfALink { readonly token: string; } +export function studioBaseFor({ studio, runtime }: ConnectPages): string { + return studioBaseOf(studio, runtime()); +} + export function connectPageOf({ studio, runtime }: ConnectPages, { org, connection, token }: PageOfALink): string { const server = runtime(); const page = new URL('connect', studioBaseOf(studio, server)); @@ -21,6 +25,6 @@ export function connectPageOf({ studio, runtime }: ConnectPages, { org, connecti return page.href; } -export function returnAddressOf({ studio, runtime }: ConnectPages): string { - return new URL('connect-return', studioBaseOf(studio, runtime())).href; +export function returnAddressOf(pages: ConnectPages): string { + return new URL('connect-return', studioBaseFor(pages)).href; } diff --git a/packages/connections/src/operations/add-connection.ts b/packages/connections/src/operations/add-connection.ts index a27061956..3870f4c55 100644 --- a/packages/connections/src/operations/add-connection.ts +++ b/packages/connections/src/operations/add-connection.ts @@ -1,7 +1,7 @@ import { Caller, OrgContext, defineCommand, quoted } from '@on.auto/operations'; import { Effect } from 'effect'; -import { connectPageOf } from '../links/connect-page.ts'; +import { connectPageOf, studioBaseFor } from '../links/connect-page.ts'; import { issuedLink } from '../links/link-token.ts'; import { adderRefusalOf } from '../policy/policy-rules.ts'; import { checkedAdditionOf, refusalOfTheAddition } from '../providers/provider-additions.ts'; @@ -39,7 +39,8 @@ export function defineAddConnection({ return yield* Effect.fail(adderRefusal); } const { org } = yield* OrgContext; - const given = yield* Effect.fromResult(checkedAdditionOf(input, { org, modelProviders }, { ...serving, policy })); + const studioBase = studioBaseFor(pages); + const given = yield* checkedAdditionOf(input, { org, modelProviders }, { ...serving, policy, studioBase }); const refusal = refusalOfTheAddition(given, policy, fileEntries, serving); if (refusal !== undefined) { return yield* Effect.fail(refusal); diff --git a/packages/connections/src/operations/connection-fields.ts b/packages/connections/src/operations/connection-fields.ts index d921f7c44..10204f0d7 100644 --- a/packages/connections/src/operations/connection-fields.ts +++ b/packages/connections/src/operations/connection-fields.ts @@ -1,4 +1,4 @@ -import { givenByTheProvider } from '@on.auto/tool-servers'; +import { ClientSourceSchema, givenByTheProvider } from '@on.auto/tool-servers'; import { Schema } from 'effect'; import { ConnectionEntrySchema, ConnectionTypeSchema } from '../registry/connection-entry.ts'; @@ -10,6 +10,8 @@ import { credentialFieldIssuesOf, } from '../registry/credential.ts'; +export const ClientSourceField = ClientSourceSchema; + export const ExpiresAtField = Schema.String.annotate({ description: 'When the link expires, in ISO 8601 UTC' }); export const AddedSchema = Schema.Struct({ diff --git a/packages/connections/src/operations/connection-operations.ts b/packages/connections/src/operations/connection-operations.ts index 24e53f363..78ecb0b67 100644 --- a/packages/connections/src/operations/connection-operations.ts +++ b/packages/connections/src/operations/connection-operations.ts @@ -2,7 +2,7 @@ import type { RouteRegistration } from '@on.auto/operations'; import { consentOperationsOf } from '../page/consent-operations.ts'; import { defineGiveCredential } from '../page/give-credential.ts'; -import { defineApprove, readLink } from '../page/link-operations.ts'; +import { defineApprove, defineReadLink } from '../page/link-operations.ts'; import { bindsPerson } from '../policy/policy-rules.ts'; import { defineListProviders } from '../providers/list-providers.ts'; import { defineAddConnection } from './add-connection.ts'; @@ -23,7 +23,7 @@ export function makeConnectionOperations(services: ConnectionServices): Connecti defineListConnections(services), defineGetConnection(services), defineRemoveConnection(services), - readLink, + defineReadLink(services), defineApprove(binding), defineGiveCredential({ ...services, ...binding }), ...consentOperationsOf({ ...services, ...binding }), diff --git a/packages/connections/src/page/consent-asking.ts b/packages/connections/src/page/consent-asking.ts index 7094a8132..347297478 100644 --- a/packages/connections/src/page/consent-asking.ts +++ b/packages/connections/src/page/consent-asking.ts @@ -15,7 +15,7 @@ import { import type { KeptConnection } from '../registry/registry.ts'; import { unrevokedWordsOf, type ConsentNamed } from './consent-words.ts'; -export type ConsentAsked = Omit; +export type ConsentAsked = Omit; export type ConsentServices = Pick & { readonly bindsPerson: boolean; @@ -35,22 +35,25 @@ function serverOf({ type, entry }: RecordedConnection): ConsentServer { return type === 'mcp' ? { type, url: required(entry.url) } : { type }; } +export function wayOf({ credential }: RecordedConnection): ConsentAsked['way'] { + return { + issuer: required(credential.issuer), + scope: credential.scope, + parameters: credential.parameters, + authorization_endpoint: credential.authorization_endpoint, + token_endpoint: credential.token_endpoint, + revocation_endpoint: credential.revocation_endpoint, + clients: credential.clients, + }; +} + export function consentAskedOf( connection: RecordedConnection, services: Pick, ): ConsentAsked { - const { credential } = connection; return { server: serverOf(connection), - way: { - issuer: required(credential.issuer), - scope: credential.scope, - parameters: credential.parameters, - authorization_endpoint: credential.authorization_endpoint, - token_endpoint: credential.token_endpoint, - revocation_endpoint: credential.revocation_endpoint, - clients: credential.clients, - }, + way: wayOf(connection), returnAddress: returnAddressOf(services.pages), reaches: reachesUnder(services.policy), }; @@ -90,6 +93,6 @@ export function tokensRevokedOf( onSome: (tokens) => services.consent.revoke({ ...consentAskedOf(connection, services), client, tokens }), }), ), - Effect.catch((failure) => Effect.logWarning(unrevokedWordsOf(namedOf(connection), failure))), + Effect.catch((failure) => Effect.logWarning(unrevokedWordsOf(namedOf(connection), failure, client.client_source))), ); } diff --git a/packages/connections/src/page/consent-finish.ts b/packages/connections/src/page/consent-finish.ts index 102a4f369..e29e063d3 100644 --- a/packages/connections/src/page/consent-finish.ts +++ b/packages/connections/src/page/consent-finish.ts @@ -14,21 +14,35 @@ import { type ConsentServices, type FinishedConsent, } from './consent-asking.ts'; -import { consentRefusalOf, type ConsentRefused } from './consent-words.ts'; +import { finishRefusalOf, wrongShapeOf, type ConsentRefused, type Returned } from './consent-words.ts'; const GivenField = Schema.String.check(Schema.isMinLength(1)); -const FinishingSchema = Schema.Struct({ +const returnedFields = { id: ConnectionIdField, token: TokenField, - code: GivenField.annotate({ description: 'The code the authorization server sent the person back with' }), - state: GivenField.annotate({ description: 'The state the authorization server sent the person back with' }), - iss: Schema.optionalKey( - GivenField.annotate({ - description: 'The issuer the authorization server sent the person back with, where it sent one', + state: GivenField.annotate({ description: 'The state the person was sent back with' }), +}; + +const FinishingSchema = Schema.Union([ + Schema.Struct({ + ...returnedFields, + code: GivenField.annotate({ description: 'The code the authorization server sent the person back with' }), + iss: Schema.optionalKey( + GivenField.annotate({ + description: 'The issuer the authorization server sent the person back with, where it sent one', + }), + ), + }).annotate({ + description: "The finish of a consent begun with a client of this runtime's, which answers a code", + }), + Schema.Struct({ + ...returnedFields, + handle: GivenField.annotate({ + description: "The handle Auto's consent service sent the person back with, which its tokens are handed over for", }), - ), -}); + }).annotate({ description: "The finish of a consent begun through Auto's consent service" }), +]); type Finishing = typeof FinishingSchema.Type; @@ -40,9 +54,17 @@ interface Recording { readonly kept: KeptWithConsent; } +function returnedOf(finishing: Finishing): Returned { + return 'handle' in finishing ? 'handle' : 'code'; +} + +function returnedFieldsOf(finishing: Finishing) { + return 'handle' in finishing ? { handle: finishing.handle } : { code: finishing.code, iss: finishing.iss }; +} + function redeemedOf( kept: KeptWithConsent, - { code, iss }: Finishing, + finishing: Finishing, services: ConsentServices, ): Effect.Effect { const { consent, secrets } = services; @@ -54,14 +76,15 @@ function redeemedOf( } const asked = consentAskedOf(kept.connection, services); const client = { client_id: clientId, client_source: clientSource }; + const named = namedOf(kept.connection); return yield* consent - .finish({ ...asked, client, code, iss, verifier: verifier.value }) - .pipe(Effect.mapError((refusal) => consentRefusalOf(namedOf(kept.connection), refusal))); + .finish({ ...asked, client, ...returnedFieldsOf(finishing), verifier: verifier.value }) + .pipe(Effect.mapError((refusal) => finishRefusalOf(named, refusal, returnedOf(finishing)))); }); } -function recordedWith(finished: FinishedConsent, secretId: string, { use, stateSha256, kept }: Recording) { - const { client_id: clientId, client_source: clientSource } = kept.consent; +function recordedWith(finished: FinishedConsent, secretId: string, { use, stateSha256 }: Recording) { + const { client_id: clientId, client_source: clientSource } = finished.client; const granted = finished.scope === undefined ? {} : { scope: finished.scope }; return recordInRegistry({ type: 'finish', @@ -91,6 +114,7 @@ export function defineFinishConsent(services: ConsentServices) { title: 'Finish consent', description: [ 'Finishes the consent of a connection with the code and the state its authorization server sent the person back with, which connects it, and answers it connected.', + "A consent begun through Auto's consent service is finished with the handle and the state the service sent the person back with instead.", 'The runtime keeps the tokens sealed in its secret store, and no answer, record or history of the org ever holds them.', 'On a runtime that signs people in, only the person who added the connection may finish it.', ].join(' '), @@ -106,6 +130,11 @@ export function defineFinishConsent(services: ConsentServices) { const linked = yield* Effect.fromResult(finishedConnectionOf(yield* loadRegistry, use, acted)); const stateSha256 = tokenSha256Of(finishing.state); const kept = yield* openConsentOf(linked, { at: acted.at, stateSha256 }, services); + const began = kept.consent.client_source === 'auto' ? 'handle' : 'code'; + const wrongShape = wrongShapeOf(namedOf(kept.connection), began, returnedOf(finishing)); + if (wrongShape !== undefined) { + return yield* Effect.fail(wrongShape); + } const finished = yield* redeemedOf(kept, finishing, services); const connected = yield* connectedWith(finished, { use, stateSha256, kept }, services); return connectionShown(connected.connection, null); diff --git a/packages/connections/src/page/consent-start.test.ts b/packages/connections/src/page/consent-start.test.ts index ad9f6371e..776b50098 100644 --- a/packages/connections/src/page/consent-start.test.ts +++ b/packages/connections/src/page/consent-start.test.ts @@ -1,7 +1,14 @@ import { createHash } from 'node:crypto'; import { servedStudio } from '@on.auto/identity/testing'; -import { consentedAt, type ClientRegistration, type FakeMcpServer } from '@on.auto/tool-servers/testing'; +import type { ReturnKind } from '@on.auto/tool-servers'; +import { + autoClientsOf, + consentedAt, + servedThroughAuto, + type ClientRegistration, + type FakeMcpServer, +} from '@on.auto/tool-servers/testing'; import { afterEach, describe, expect, it } from 'vitest'; import { connectionPolicyOf } from '../policy/connection-policy.ts'; @@ -246,3 +253,40 @@ describe('a start refused where the server cannot proceed', () => { expect(harness().operations.map(({ registration }) => registration.name)).toContain('start'); }); }); + +describe('a start through Auto’s consent service for a Studio neither hosted nor on this computer', () => { + it('is refused before the person is sent anywhere where no other source remains, saying whoever runs the runtime registers a client', async () => { + const serving = await servedThroughAuto(); + closing.push(serving.close); + const kinds: { now: ReturnKind } = { now: 'hosted' }; + const autoClients = { ...autoClientsOf(serving.service), returnKindOf: () => kinds.now }; + const h = consentHarnessAt(serving.fake, { consenting: { autoClients } }); + const link = await added(h, { provider: 'files' }); + kinds.now = 'elsewhere'; + + expect(await starting(h, link)).toMatchObject({ + reason: 'conflict', + detail: + "The connection files takes consent through Auto's client only where Studio is Auto's or on this computer, so whoever runs this runtime registers a client", + }); + expect([serving.service.calls().map(({ path }) => path), serving.fake.heard()]).toEqual([['/clients'], []]); + }); +}); + +describe('a start through Auto’s consent service from a Studio host the service does not send people back to', () => { + it('is refused by the service before the person is sent anywhere, saying whoever runs the runtime registers a client', async () => { + const serving = await servedThroughAuto(); + closing.push(serving.close); + const h = consentHarnessAt(serving.fake, { consenting: { autoClients: autoClientsOf(serving.service) } }); + + expect(await starting(h, await added(h, { provider: 'files' }))).toMatchObject({ + reason: 'conflict', + detail: + "Auto's consent service did not start the consent of the connection files: Auto has no client at its authorization server, or the service does not send people back to the Studio its link opens, so whoever runs this runtime registers a client", + }); + expect(serving.service.calls().at(-1)?.body).toMatchObject({ + return: { kind: 'hosted', base: 'https://studio.on.auto/' }, + }); + expect(serving.fake.heard().map(({ path }) => path)).not.toContain('/authorize'); + }); +}); diff --git a/packages/connections/src/page/consent-start.ts b/packages/connections/src/page/consent-start.ts index f1b2d76d6..f00bf6278 100644 --- a/packages/connections/src/page/consent-start.ts +++ b/packages/connections/src/page/consent-start.ts @@ -1,8 +1,9 @@ import { allPermissions, defineHttpOperation } from '@on.auto/operations'; import { Effect, Schema } from 'effect'; +import { studioBaseFor } from '../links/connect-page.ts'; import { issuedToken, tenMinutesOn, tokenSha256Of } from '../links/link-token.ts'; -import { ExpiresAtField } from '../operations/connection-fields.ts'; +import { ClientSourceField, ExpiresAtField } from '../operations/connection-fields.ts'; import { actedNow, loadRegistry, recordInRegistry } from '../operations/registry-access.ts'; import { startableConnectionOf } from '../registry/registry-decisions.ts'; import { consentAskedOf, forgottenVerifierOf, namedOf, type ConsentServices } from './consent-asking.ts'; @@ -18,6 +19,10 @@ const StartedSchema = Schema.Struct({ description: 'When the consent must be finished by, ten minutes on, in ISO 8601 UTC', }), }), + client_source: ClientSourceField.annotate({ + description: + "The source of the client the consent was started with; auto where it began through Auto's consent service, whose finish takes a handle in place of a code", + }), }).annotate({ identifier: 'StartedConsent', description: 'Where the person signs in to the provider and allows Auto' }); export function defineStartConsent(services: ConsentServices) { @@ -43,20 +48,20 @@ export function defineStartConsent(services: ConsentServices) { const asked = consentAskedOf(kept.connection, services); const state = issuedToken(); const started = yield* consent - .start({ ...asked, state: state.token }) + .start({ ...asked, state: state.token, studioBase: studioBaseFor(services.pages) }) .pipe(Effect.mapError((refusal) => consentRefusalOf(namedOf(kept.connection), refusal))); const verifierSecretId = yield* Effect.orDie(secrets.keep(started.verifier)); const expiresAt = yield* tenMinutesOn; yield* recordInRegistry({ type: 'start', ...use, - state_sha256: state.token_sha256, + state_sha256: tokenSha256Of(started.state), verifier_secret_id: verifierSecretId, ...started.client, expires_at: expiresAt, }).pipe(Effect.onError(() => secrets.forget(verifierSecretId))); yield* forgottenVerifierOf(kept, services); - return { authorize: { url: started.url, expires_at: expiresAt } }; + return { authorize: { url: started.url, expires_at: expiresAt }, client_source: started.client.client_source }; }), }); } diff --git a/packages/connections/src/page/consent-through-auto.test.ts b/packages/connections/src/page/consent-through-auto.test.ts new file mode 100644 index 000000000..8e3c93a6c --- /dev/null +++ b/packages/connections/src/page/consent-through-auto.test.ts @@ -0,0 +1,266 @@ +import { servedStudio } from '@on.auto/identity/testing'; +import type { Outcome, Succeeded } from '@on.auto/operations'; +import { keptTokensIn, type ConsentClientOptions, type KeptTokens, type ReturnKind } from '@on.auto/tool-servers'; +import { + autoClientsOf, + handleOf, + servedThroughAuto, + serviceCallsAt, + type ThroughAuto, +} from '@on.auto/tool-servers/testing'; +import { Effect, Option, Redacted } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { added, crm, type Added } from '../testing/additions.ts'; +import { consentHarnessAt, startedAt, starting } from '../testing/consents.ts'; +import { acmeViewer, ada, operationNamed, type Harness } from '../testing/harness.ts'; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +interface Connecting { + readonly serving: ThroughAuto; + readonly h: Harness; + readonly link: Added; +} + +async function throughAuto(fetch: ConsentClientOptions['fetch'] = globalThis.fetch): Promise { + const serving = await servedThroughAuto(); + closing.push(serving.close); + const h = consentHarnessAt(serving.fake, { + consenting: { autoClients: autoClientsOf(serving.service), fetch }, + pages: { studio: { origin: 'https://studio.example.com', served: false }, runtime: () => 'http://localhost:5555' }, + }); + return { serving, h, link: await added(h, { provider: 'files' }) }; +} + +function finishedWith(h: Harness, { id, token }: Added, returned: Readonly>) { + return h.call(operationNamed(h.operations, 'finish'), ada, { id, token, ...returned }); +} + +function readingOf(h: Harness, { id, token }: Added): Promise> { + return h.call(operationNamed(h.operations, 'read_link'), acmeViewer, { id, token }); +} + +async function returnedOf({ serving, h, link }: Connecting) { + const { url } = await startedAt(h, link); + return { state: String(new URL(url).searchParams.get('state')), handle: await handleOf(serving.service, url) }; +} + +async function listingAnotherClient(...[address, init]: readonly [unknown, unknown?]): Promise { + const answer = await globalThis.fetch(String(address), Object.assign({}, init)); + const listing = new URL(String(address)).pathname === '/clients'; + return listing + ? new Response((await answer.text()).replace('auto-runtime', 'a-client-listed-before'), answer) + : answer; +} + +async function connectedAt(connecting: Connecting): Promise { + await finishedWith(connecting.h, connecting.link, await returnedOf(connecting)); + const [kept = ''] = [...connecting.h.secrets.values().values()]; + return Option.getOrThrow(keptTokensIn(Redacted.make(kept))); +} + +async function loggedRemovalOf({ h, link }: Connecting): Promise { + await h.call(operationNamed(h.operations, 'remove_connection'), ada, { id: link.id }); + return h.logged().join('\n'); +} + +describe('the start of a consent through Auto’s consent service', () => { + it('answers that it began with Auto’s client, and records the digest of the state the service made', async () => { + const connecting = await throughAuto(); + + const started = await starting(connecting.h, connecting.link); + const [fact] = (await connecting.h.facts()).slice(-1); + + expect(started).toMatchObject({ output: { client_source: 'auto' } }); + expect(fact).toMatchObject({ + type: 'connection_consent_started', + data: { client_id: 'auto-runtime', client_source: 'auto' }, + }); + }); + + it('sends the service the base of the link exactly as the link opens it', async () => { + const serving = await servedThroughAuto(); + closing.push(serving.close); + const h = consentHarnessAt(serving.fake, { + consenting: { autoClients: autoClientsOf(serving.service, 'loopback') }, + pages: { studio: servedStudio, runtime: () => 'http://localhost:5555' }, + }); + + await starting(h, await added(h, { provider: 'files' })); + + expect(serving.service.calls().at(-1)?.body).toMatchObject({ + return: { kind: 'loopback', base: 'http://localhost:5555/studio/' }, + }); + }); +}); + +describe('the finish of a consent through Auto’s consent service', () => { + it('takes the handle and the state, keeps the tokens with the connection key sealed, and connects the connection', async () => { + const connecting = await throughAuto(); + const returned = await returnedOf(connecting); + + const finished = await finishedWith(connecting.h, connecting.link, returned); + const [kept = ''] = [...connecting.h.secrets.values().values()]; + const key = Option.getOrUndefined(keptTokensIn(Redacted.make(kept)))?.connection_key; + + expect(finished).toMatchObject({ + status: 'succeeded', + output: { status: 'connected', credential: { client_id: 'auto-runtime', client_source: 'auto' } }, + }); + expect(key).toMatch(/^[\w-]{43}$/u); + expect(JSON.stringify([finished, await connecting.h.facts(), connecting.h.logged()])).not.toContain(String(key)); + }); + + it('records the client the service redeemed with, which the handoff names, over the one the start recorded', async () => { + const connecting = await throughAuto(listingAnotherClient); + + const finished = await finishedWith(connecting.h, connecting.link, await returnedOf(connecting)); + const started = (await connecting.h.facts()).find(({ type }) => type === 'connection_consent_started'); + + expect(started).toMatchObject({ data: { client_id: 'a-client-listed-before', client_source: 'auto' } }); + expect(finished).toMatchObject({ output: { credential: { client_id: 'auto-runtime', client_source: 'auto' } } }); + }); + + it('is refused at the handle where the service refuses it, as one taken already', async () => { + const connecting = await throughAuto(); + const returned = await returnedOf(connecting); + const other = await added(connecting.h, { provider: 'files', name: 'files-again' }); + const otherReturned = await returnedOf({ ...connecting, link: other }); + await finishedWith(connecting.h, other, otherReturned); + + expect( + await finishedWith(connecting.h, connecting.link, { ...returned, handle: otherReturned.handle }), + ).toMatchObject({ + reason: 'invalid_input', + issues: [{ pointer: '/handle', detail: "Expected a handle Auto's consent service takes; it refused this one" }], + }); + }); +}); + +describe('a finish of the shape its start did not begin with', () => { + it('is refused at the code for a start through Auto’s consent service, and at the handle for one with a registered client', async () => { + const connecting = await throughAuto(); + const { state } = await returnedOf(connecting); + const registered = consentHarnessAt(connecting.serving.fake); + const link = await added(registered, { provider: 'files' }); + const { url } = await startedAt(registered, link); + const registeredState = String(new URL(url).searchParams.get('state')); + + expect([ + await finishedWith(connecting.h, connecting.link, { state, code: 'a-code' }), + await finishedWith(registered, link, { state: registeredState, handle: 'a-handle' }), + ]).toMatchObject([ + { + reason: 'invalid_input', + issues: [ + { + pointer: '/code', + detail: + "Expected handle: the consent of the connection files began through Auto's consent service, which answers a handle, not a code", + }, + ], + }, + { + reason: 'invalid_input', + issues: [ + { + pointer: '/handle', + detail: + "Expected code: the consent of the connection files began with a client of this runtime's, which answers a code, not a handle", + }, + ], + }, + ]); + }); +}); + +describe('the source a link says a start would take', () => { + it('is Auto’s where the service serves the issuer, the registered client where one is registered, and none for a credential given by hand', async () => { + const connecting = await throughAuto(); + const registered = consentHarnessAt(connecting.serving.fake); + const byHand = await added(connecting.h, crm); + + expect([ + await readingOf(connecting.h, connecting.link), + await readingOf(registered, await added(registered, { provider: 'files' })), + await readingOf(connecting.h, byHand), + ]).toMatchObject([ + { output: { client_source: 'auto' } }, + { output: { client_source: 'registered' } }, + { output: { name: 'crm' } }, + ]); + expect(await readingOf(connecting.h, byHand)).not.toHaveProperty('output.client_source'); + }); +}); + +describe('the removal of a connection connected through Auto’s consent service', () => { + it('revokes its tokens through the service before it forgets them', async () => { + const connecting = await throughAuto(); + await finishedWith(connecting.h, connecting.link, await returnedOf(connecting)); + + await connecting.h.call(operationNamed(connecting.h.operations, 'remove_connection'), ada, { + id: connecting.link.id, + }); + + expect(connecting.serving.service.calls().map(({ path }) => path)).toContain('/tokens/revoke'); + expect(connecting.h.secrets.values().size).toBe(0); + }); + + it('goes on where the service no longer knows its key or does not answer, saying so in words of the service', async () => { + const [forgotten, gone] = await Promise.all([throughAuto(), throughAuto()]); + const tokens = await connectedAt(forgotten); + await connectedAt(gone); + const connectionKey = String(tokens.connection_key); + const { origin: issuer } = forgotten.serving.fake; + await Effect.runPromise(serviceCallsAt(forgotten.serving.service).revoke({ issuer, tokens, connectionKey })); + await gone.serving.service.close(); + + expect([await loggedRemovalOf(forgotten), await loggedRemovalOf(gone)]).toEqual([ + expect.stringContaining( + "Auto's consent service does not know the key of the connection files, so its tokens were not revoked and end on their own", + ), + expect.stringContaining( + "Auto's consent service did not answer the revocation of the tokens of the connection files, so they end on their own", + ), + ]); + expect([forgotten.h.secrets.values().size, gone.h.secrets.values().size]).toEqual([0, 0]); + }); +}); + +describe('a consent through a Studio neither Auto’s nor on this computer', () => { + it('leaves Auto’s client out of the link and the start, which the source the way lists next serves, asking the service nothing', async () => { + const serving = await servedThroughAuto({ dynamicRegistration: true }); + closing.push(serving.close); + const h = consentHarnessAt(serving.fake, { + way: { scope: 'files.read', clients: ['registered', 'dynamic'] }, + consenting: { autoClients: autoClientsOf(serving.service, 'elsewhere') }, + }); + const link = await added(h, { provider: 'files' }); + + expect([await readingOf(h, link), await starting(h, link)]).toMatchObject([ + { output: { client_source: 'dynamic' } }, + { output: { client_source: 'dynamic' } }, + ]); + expect(serving.service.calls()).toEqual([]); + }); + + it('leaves the source out of a link whose way no other source serves', async () => { + const serving = await servedThroughAuto(); + closing.push(serving.close); + const kinds: { now: ReturnKind } = { now: 'hosted' }; + const autoClients = { ...autoClientsOf(serving.service), returnKindOf: () => kinds.now }; + const h = consentHarnessAt(serving.fake, { consenting: { autoClients } }); + const link = await added(h, { provider: 'files' }); + kinds.now = 'elsewhere'; + + const read = await readingOf(h, link); + + expect(read).toMatchObject({ status: 'succeeded', output: { name: 'files' } }); + expect(read).not.toHaveProperty('output.client_source'); + }); +}); diff --git a/packages/connections/src/page/consent-words.ts b/packages/connections/src/page/consent-words.ts index a9d762b4b..a432f1baf 100644 --- a/packages/connections/src/page/consent-words.ts +++ b/packages/connections/src/page/consent-words.ts @@ -1,5 +1,5 @@ import { Conflict, Forbidden, InvalidInput, Unavailable } from '@on.auto/operations'; -import type { ConsentRefusal, RevocationFailure } from '@on.auto/tool-servers'; +import type { ClientSource, ConsentRefusal, RevocationFailure } from '@on.auto/tool-servers'; import { servingWords } from '../serving/serving-words.ts'; @@ -20,6 +20,8 @@ type ConflictingRefusal = Extract< | 'no_challenge' | 'no_client' | 'client_gone' + | 'studio_elsewhere' + | 'start_refused' | 'no_scope' | 'malformed_scope'; } @@ -37,6 +39,10 @@ const conflictsOf: Readonly string>> = no_client: ({ name }) => `The authorization server of the connection ${name} takes none of the clients this runtime has for it, ${notAsAdded}`, client_gone: ({ name }) => servingWords.clientGone(name), + start_refused: ({ name }) => + `Auto's consent service did not start the consent of the connection ${name}: Auto has no client at its authorization server, or the service does not send people back to the Studio its link opens, so whoever runs this runtime registers a client`, + studio_elsewhere: ({ name }) => + `The connection ${name} takes consent through Auto's client only where Studio is Auto's or on this computer, so whoever runs this runtime registers a client`, malformed_scope: ({ name }) => `The authorization server of the connection ${name} granted a scope written with characters a scope does not hold, ${notAsAdded}`, no_scope: ({ name }) => @@ -86,14 +92,44 @@ export function consentRefusalOf(named: ConsentNamed, refusal: ConsentRefusal): : conflictOf(named, refusal); } -export function unrevokedWordsOf({ name }: ConsentNamed, failure: RevocationFailure): string { +const throughAuto = (name: string) => + `Expected handle: the consent of the connection ${name} began through Auto's consent service, which answers a handle, not a code`; + +const throughAClientOfItsOwn = (name: string) => + `Expected code: the consent of the connection ${name} began with a client of this runtime's, which answers a code, not a handle`; + +export type Returned = 'code' | 'handle'; + +export function wrongShapeOf({ name }: ConsentNamed, began: Returned, given: Returned): InvalidInput | undefined { + if (began === given) { + return undefined; + } + return given === 'code' ? refusedAt('/code', throughAuto(name)) : refusedAt('/handle', throughAClientOfItsOwn(name)); +} + +export function finishRefusalOf(named: ConsentNamed, refusal: ConsentRefusal, given: Returned): ConsentRefused { + return given === 'handle' && refusal.refused === 'code' + ? refusedAt('/handle', "Expected a handle Auto's consent service takes; it refused this one") + : consentRefusalOf(named, refusal); +} + +function unansweredRevocationOf(name: string, source: ClientSource): string { + return source === 'auto' + ? `Auto's consent service did not answer the revocation of the tokens of the connection ${name}, so they end on their own` + : `The authorization server of the connection ${name} did not answer the revocation of its tokens, so they end on their own`; +} + +export function unrevokedWordsOf({ name }: ConsentNamed, failure: RevocationFailure, source: ClientSource): string { if (failure.refused === 'not_revoked') { return `The authorization server of the connection ${name} refused to revoke its tokens, answering HTTP ${failure.status}, so they end on their own`; } if (failure.refused === 'host') { return `The tokens of the connection ${name} were not revoked, so they end on their own: ${failure.rule}`; } + if (failure.refused === 'unknown_key') { + return `Auto's consent service does not know the key of the connection ${name}, so its tokens were not revoked and end on their own`; + } return failure.refused === 'unanswered' - ? `The authorization server of the connection ${name} did not answer the revocation of its tokens, so they end on their own` + ? unansweredRevocationOf(name, source) : `The tokens of the connection ${name} could not be revoked with the client and at the authorization server it was made with, so they end on their own`; } diff --git a/packages/connections/src/page/link-operations.test.ts b/packages/connections/src/page/link-operations.test.ts index 6760d314a..375a5fe03 100644 --- a/packages/connections/src/page/link-operations.test.ts +++ b/packages/connections/src/page/link-operations.test.ts @@ -54,6 +54,28 @@ describe('reading a link', () => { }); }); +describe('reading the link of a connection whose credential is not consent', () => { + it('asks the consent client for no client source, where its addition asked with the base of the Studio its link opens', async () => { + const studioBases: string[] = []; + const { consent } = harness(); + const h = harness({ + consent: { + ...consent, + hadSources: (studioBase) => { + studioBases.push(studioBase); + return consent.hadSources(studioBase); + }, + }, + }); + const { id, token } = await added(h, crm); + const askedByTheAddition = [...studioBases]; + + await reading(h, id, token); + + expect([askedByTheAddition, studioBases]).toEqual([['https://studio.on.auto/'], ['https://studio.on.auto/']]); + }); +}); + describe('approving a connection', () => { it('connects one that takes no credential to give, and uses its link up', async () => { const h = harness({ policy: connectionPolicyOf({ variables: ['NOTES_API_KEY'] }) }); diff --git a/packages/connections/src/page/link-operations.ts b/packages/connections/src/page/link-operations.ts index 0eb16a665..8c7c398c6 100644 --- a/packages/connections/src/page/link-operations.ts +++ b/packages/connections/src/page/link-operations.ts @@ -1,11 +1,14 @@ import { allPermissions, defineHttpOperation } from '@on.auto/operations'; +import type { ConsentClient } from '@on.auto/tool-servers'; import { Effect, Schema } from 'effect'; +import { studioBaseFor, type ConnectPages } from '../links/connect-page.ts'; import { tokenSha256Of } from '../links/link-token.ts'; -import { ConnectionIdField, ExpiresAtField, TokenField } from '../operations/connection-fields.ts'; +import { ClientSourceField, ConnectionIdField, ExpiresAtField, TokenField } from '../operations/connection-fields.ts'; import { actedNow, loadRegistry, recordInRegistry } from '../operations/registry-access.ts'; -import { ConnectionSchema, connectionShown } from '../registry/connection.ts'; +import { ConnectionSchema, connectionShown, type RecordedConnection } from '../registry/connection.ts'; import { isOpenConsent, linkedConnectionOf } from '../registry/registry-lookup.ts'; +import { wayOf } from './consent-asking.ts'; export const LinkSchema = Schema.Struct({ id: ConnectionIdField, token: TokenField }); @@ -20,38 +23,67 @@ const LinkedSchema = Schema.Struct({ expires_at: Schema.String.annotate({ description: 'When the consent must be finished by, in ISO 8601 UTC' }), }).annotate({ description: 'The consent started and not finished, while it is open' }), ), + client_source: Schema.optionalKey( + ClientSourceField.annotate({ + description: + "For a connection connected by consent, the source of the client a start would take now, by the rule the start takes it by: auto where it would begin through Auto's consent service. Left out where the runtime has no client the way takes", + }), + ), }).annotate({ identifier: 'LinkedConnection', description: 'The connection an open link names, and when the link expires', }); -export const readLink = defineHttpOperation('org', 'query', { - name: 'read_link', - title: 'Read link', - description: [ - "Reads the connection an open link names, and when the link expires, so Studio's page can show what the person is asked to approve and for how long.", - 'A link is open for ten minutes from when add_connection gave it, until the connection is approved.', - 'A token that is unknown, used up or expired is answered as not found.', - ].join(' '), - route: { method: 'GET', path: '/connections/{id}/link/{token}' }, - body: { kind: 'none' }, - permittedBy: allPermissions, - inputSchema: LinkSchema, - answer: { kind: 'json', outputSchema: LinkedSchema }, - reasons: ['not_found'], - handle: Effect.fnUntraced(function* ({ id, token }) { - const registry = yield* loadRegistry; - const use = { connection: id, token_sha256: tokenSha256Of(token), bindsPerson: false }; - const acted = yield* actedNow; - const kept = yield* Effect.fromResult(linkedConnectionOf(registry, use, acted)).pipe( - Effect.catchTag('forbidden', Effect.die), - ); - const consent = isOpenConsent(kept, acted.at) - ? { consent: { started_at: kept.consent.started_at, expires_at: kept.consent.expires_at } } - : {}; - return { ...connectionShown(kept.connection, null), link: { expires_at: kept.link.expires_at }, ...consent }; - }), -}); +interface LinkReading { + readonly consent: Pick; + readonly pages: ConnectPages; +} + +function sourceOfAStart(connection: RecordedConnection, { consent, pages }: LinkReading) { + if (connection.credential.kind !== 'authorization') { + return Effect.succeed({}); + } + return Effect.map(consent.hadSources(studioBaseFor(pages)), ({ sourceOf }) => { + const source = sourceOf(wayOf(connection)); + return source === undefined ? {} : { client_source: source }; + }); +} + +export function defineReadLink(reading: LinkReading) { + return defineHttpOperation('org', 'query', { + name: 'read_link', + title: 'Read link', + description: [ + "Reads the connection an open link names, and when the link expires, so Studio's page can show what the person is asked to approve and for how long.", + 'A link is open for ten minutes from when add_connection gave it, until the connection is approved.', + 'A token that is unknown, used up or expired is answered as not found.', + ].join(' '), + route: { method: 'GET', path: '/connections/{id}/link/{token}' }, + body: { kind: 'none' }, + permittedBy: allPermissions, + inputSchema: LinkSchema, + answer: { kind: 'json', outputSchema: LinkedSchema }, + reasons: ['not_found'], + handle: Effect.fnUntraced(function* ({ id, token }) { + const registry = yield* loadRegistry; + const use = { connection: id, token_sha256: tokenSha256Of(token), bindsPerson: false }; + const acted = yield* actedNow; + const kept = yield* Effect.fromResult(linkedConnectionOf(registry, use, acted)).pipe( + Effect.catchTag('forbidden', Effect.die), + ); + const started = isOpenConsent(kept, acted.at) + ? { consent: { started_at: kept.consent.started_at, expires_at: kept.consent.expires_at } } + : {}; + const source = yield* sourceOfAStart(kept.connection, reading); + return { + ...connectionShown(kept.connection, null), + link: { expires_at: kept.link.expires_at }, + ...started, + ...source, + }; + }), + }); +} export function defineApprove({ bindsPerson }: { readonly bindsPerson: boolean }) { return defineHttpOperation('org', 'command', { diff --git a/packages/connections/src/policy/connection-policy.test.ts b/packages/connections/src/policy/connection-policy.test.ts index 57a68e9e2..c51fbdf64 100644 --- a/packages/connections/src/policy/connection-policy.test.ts +++ b/packages/connections/src/policy/connection-policy.test.ts @@ -14,6 +14,10 @@ function policyOf(environment: Readonly>) { return Effect.runSync(settingRead(environment)).policy; } +function autoClientsOf(environment: Readonly>) { + return Effect.runSync(settingRead(environment)).autoClients; +} + function refusalOf(text: string, environment: Readonly> = {}) { return Effect.runSync(Effect.flip(settingRead({ ...environment, CONNECTIONS: text }))).message; } @@ -253,3 +257,17 @@ describe('the references in CONNECTIONS that stop the start', () => { expect(refusalOf('{registered')).toBe('The connection settings are invalid. CONNECTIONS: /: Expected JSON'); }); }); + +describe('the switch of Auto’s clients in the setting of connections', () => { + it('reads whether Auto’s clients serve as well, and leaves it to the runtime where the setting says nothing', () => { + expect([ + autoClientsOf({ CONNECTIONS: JSON.stringify({ auto_clients: true }) }), + autoClientsOf({ CONNECTIONS: JSON.stringify({ auto_clients: false }) }), + autoClientsOf({ CONNECTIONS: JSON.stringify({ people_may_add: true }) }), + autoClientsOf({}), + ]).toEqual([true, false, undefined, undefined]); + expect(refusalOf(JSON.stringify({ auto_clients: 'yes' }))).toBe( + 'The connection settings are invalid. CONNECTIONS: /auto_clients: Expected boolean', + ); + }); +}); diff --git a/packages/connections/src/policy/connection-policy.ts b/packages/connections/src/policy/connection-policy.ts index 8bbd8bca5..953aff9be 100644 --- a/packages/connections/src/policy/connection-policy.ts +++ b/packages/connections/src/policy/connection-policy.ts @@ -92,10 +92,16 @@ const ConnectionPolicyFieldsSchema = Schema.Struct(policyFields); export const ConnectionsSettingSchema = Schema.Struct({ ...policyFields, registered_clients: Schema.optionalKey(RegisteredClientsSchema), + auto_clients: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether a provider whose way takes a registered client takes Auto's client too, through Auto's consent service, where none is registered at its issuer; true in local mode and false otherwise when left out. AUTO_CLIENTS wins over it", + }), + ), providers: Schema.optionalKey(AddedProvidersSchema), }).annotate({ description: - 'What people may add as connections through their agents, tool servers of an org which every playbook of the org may use; the clients whoever runs the runtime registered at authorization servers; and the providers it adds beside those the runtime ships. CONNECTIONS wins over it', + "What people may add as connections through their agents, tool servers of an org which every playbook of the org may use; the clients whoever runs the runtime registered at authorization servers, and whether Auto's clients serve as well; and the providers it adds beside those the runtime ships. CONNECTIONS wins over it", }); export type ConnectionPolicyFields = typeof ConnectionPolicyFieldsSchema.Type; @@ -116,6 +122,7 @@ export interface ConnectionPolicy { export interface ConnectionsSetting { readonly policy: ConnectionPolicy; readonly registeredClients: readonly RegisteredClient[]; + readonly autoClients: boolean | undefined; readonly clientVariables: readonly string[]; readonly providers: AddedProviders; } @@ -242,6 +249,7 @@ function settingOf( Result.map(clientsResolved(fields.registered_clients ?? [], environment), (resolved) => ({ policy: connectionPolicyOf(fields), registeredClients: registeredClientsOf(resolved), + autoClients: fields.auto_clients, clientVariables: referencedVariablesOf(clients), providers: fields.providers ?? [], })), @@ -256,6 +264,7 @@ export function readConnectionsSetting( return Effect.succeed({ policy: connectionPolicyOf({}), registeredClients: [], + autoClients: undefined, clientVariables: [], providers: [], }); diff --git a/packages/connections/src/providers/consent-offered.ts b/packages/connections/src/providers/consent-offered.ts index 131cb2977..8b06a5de6 100644 --- a/packages/connections/src/providers/consent-offered.ts +++ b/packages/connections/src/providers/consent-offered.ts @@ -1,18 +1,16 @@ -import type { ClientSource, ConsentClient } from '@on.auto/tool-servers'; - -export type ClientSourcesHad = Pick; +import type { HadSources, WayClientSource } from '@on.auto/tool-servers'; interface WayOfConsent { readonly kind: string; readonly issuer?: string; - readonly clients?: readonly ClientSource[]; + readonly clients?: readonly WayClientSource[]; } export function consentUnsetUp(provider: string): string { return `The provider ${provider} takes consent through a client whoever runs Auto registers, which is not set up here`; } -export function isConsentUnsetUp(way: WayOfConsent, consent: ClientSourcesHad): boolean { +export function isConsentUnsetUp(way: WayOfConsent, consent: HadSources): boolean { return ( way.kind === 'authorization' && consent.sourcesOf({ issuer: String(way.issuer), clients: way.clients }).length === 0 ); diff --git a/packages/connections/src/providers/consent-providers.test.ts b/packages/connections/src/providers/consent-providers.test.ts index c4bdca09f..2e6473c6b 100644 --- a/packages/connections/src/providers/consent-providers.test.ts +++ b/packages/connections/src/providers/consent-providers.test.ts @@ -1,15 +1,29 @@ import { adverts, drive, providersOf } from '@on.auto/providers/testing'; -import type { RegisteredClient } from '@on.auto/tool-servers'; -import { consentClientWith, registeredAt } from '@on.auto/tool-servers/testing'; -import { describe, expect, it } from 'vitest'; +import type { ConsentClientOptions, ReturnKind } from '@on.auto/tool-servers'; +import { + autoClientsOf, + clockAt, + consentClientWith, + registeredAt, + serveFakeConsentService, + type FakeConsentService, +} from '@on.auto/tool-servers/testing'; +import { Effect } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; import { makeProviderOffering } from '../index.ts'; import { connectionPolicyOf, type ConnectionPolicyFields } from '../policy/connection-policy.ts'; import { added, adding } from '../testing/additions.ts'; -import { ada, harness, operationNamed, type Harness } from '../testing/harness.ts'; +import { ada, harness, harnessPages, operationNamed, type Harness } from '../testing/harness.ts'; const consentProviders = providersOf(adverts, drive); +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + const registeredClients = [ registeredAt('https://accounts.drive.example.com'), registeredAt('https://login.adverts.example.com/{tenant}/v2.0'), @@ -208,21 +222,79 @@ describe('a connection of consent given by hand', () => { }); }); -function offeredWith(clients: readonly RegisteredClient[]) { - return makeProviderOffering({ - providers: providersOf(drive), - providerValues: new Map(), - policy: connectionPolicyOf({}), - access: 'people', - consent: consentClientWith({ registeredClients: clients }), - })(ada); +function offeredWith(consenting: Partial) { + return Effect.runPromise( + makeProviderOffering({ + providers: providersOf(drive), + providerValues: new Map(), + policy: connectionPolicyOf({}), + access: 'people', + pages: harnessPages, + consent: consentClientWith(consenting), + })(ada), + ); } +const driveOffered = { name: 'drive', title: 'Drive', description: 'Read and change the files of an account.' }; + describe('the providers search offers a caller who may add', () => { - it('leave out a provider whose way of consent takes no client the runtime has, and take it in once one is registered', () => { - expect([offeredWith([]), offeredWith(registeredClients)]).toEqual([ + it('leave out a provider whose way of consent takes no client the runtime has, and take it in once one is registered', async () => { + expect([await offeredWith({}), await offeredWith({ registeredClients })]).toEqual([[], [driveOffered]]); + }); +}); + +async function serviceOfDrive(): Promise { + const service = await serveFakeConsentService({ + vendor: { origin: 'https://accounts.drive.example.com' }, + now: clockAt(0).now, + }); + closing.push(service.close); + return service; +} + +function switchedOf(service: FakeConsentService, enabled: boolean, kind: ReturnKind = 'hosted') { + return { autoClients: { ...autoClientsOf(service, kind), enabled } }; +} + +const unservedBy = [ + ['the source of Auto is switched off', false, 'hosted'], + ['Studio is neither Auto’s nor on this computer', true, 'elsewhere'], +] as const; + +describe('a provider whose way is consent where Auto’s consent service serves its issuer', () => { + it('is listed offered, added and offered to search, where whoever runs Auto registered no client', async () => { + const service = await serviceOfDrive(); + const h = harness({ providers: consentProviders, consenting: switchedOf(service, true) }); + + expect([ + await h.call(operationNamed(h.operations, 'list_providers'), ada), + await adding(h, { provider: 'drive' }), + await offeredWith(switchedOf(service, true)), + ]).toMatchObject([ + { output: { providers: [{ name: 'adverts' }, { name: 'drive', credentials: [{ offered: true }] }] } }, + { status: 'succeeded', output: { status: 'pending', credential: { kind: 'authorization' } } }, + [driveOffered], + ]); + }); + + it.each(unservedBy)('is set up first, and the service asked nothing, where %s', async (_, enabled, kind) => { + const service = await serviceOfDrive(); + const consenting = switchedOf(service, enabled, kind); + const h = harness({ providers: consentProviders, consenting }); + + expect([ + await h.call(operationNamed(h.operations, 'list_providers'), ada), + await adding(h, { provider: 'drive' }), + await offeredWith(consenting), + ]).toMatchObject([ + { + output: { + providers: [{ name: 'adverts' }, { name: 'drive', credentials: [{ offered: false, because: unsetUp }] }], + }, + }, + { status: 'rejected', issues: [{ pointer: '/credential/kind', detail: unsetUp }] }, [], - [{ name: 'drive', title: 'Drive', description: 'Read and change the files of an account.' }], ]); + expect(service.calls()).toEqual([]); }); }); diff --git a/packages/connections/src/providers/list-providers.ts b/packages/connections/src/providers/list-providers.ts index 3983db6eb..212b6c00d 100644 --- a/packages/connections/src/providers/list-providers.ts +++ b/packages/connections/src/providers/list-providers.ts @@ -1,6 +1,7 @@ import { Caller, defineHttpOperation } from '@on.auto/operations'; import { Effect, Schema } from 'effect'; +import { studioBaseFor } from '../links/connect-page.ts'; import { ProvidersSchema } from './listed-providers.ts'; import { listedProviders } from './provider-listing.ts'; import { refusalToAdd, type OfferingServices } from './provider-offering.ts'; @@ -21,7 +22,8 @@ export function defineListProviders(listing: OfferingServices) { reasons: [], handle: Effect.fnUntraced(function* () { const refusal = refusalToAdd(listing, yield* Caller); - return { providers: listedProviders(listing), may_add: refusal === null, refusal }; + const consent = yield* listing.consent.hadSources(studioBaseFor(listing.pages)); + return { providers: listedProviders({ ...listing, consent }), may_add: refusal === null, refusal }; }), }); } diff --git a/packages/connections/src/providers/provider-additions.ts b/packages/connections/src/providers/provider-additions.ts index 2f6dd7827..eb11caef9 100644 --- a/packages/connections/src/providers/provider-additions.ts +++ b/packages/connections/src/providers/provider-additions.ts @@ -6,7 +6,7 @@ import { type Provider, type ProviderList, } from '@on.auto/providers'; -import { Option, Result, type Redacted } from 'effect'; +import { Effect, Option, Result, type Redacted } from 'effect'; import { additionIssuesOf, @@ -19,7 +19,7 @@ import type { AdditionGiven } from '../operations/connection-fields.ts'; import { policyRefusalOf } from '../policy/policy-rules.ts'; import { credentialFieldIssuesOf } from '../registry/credential.ts'; import type { Providing } from '../serving/placed-credentials.ts'; -import { consentUnsetUp, isConsentUnsetUp, type ClientSourcesHad } from './consent-offered.ts'; +import { consentUnsetUp, isConsentUnsetUp } from './consent-offered.ts'; import { isOfferedWay, type Listing } from './provider-listing.ts'; export interface GivenAddition extends AdditionInput { @@ -81,6 +81,16 @@ export interface ProviderServices { type AdditionServices = ProviderServices & Listing; +type Consent = Listing['consent']; + +interface ClientListing { + readonly hadSources: (studioBase: string) => Effect.Effect; +} + +type ListingServices = Omit & { readonly consent: ClientListing } & { + readonly studioBase: string; +}; + export function providingOf( { provider }: Pick, { providers, providerValues }: ProviderServices, @@ -113,10 +123,10 @@ function placedIssuesOf(issues: readonly Issue[], given: AdditionGiven): readonl interface Checked { readonly given: AdditionGiven; readonly checking: EntryChecking; - readonly consent: ClientSourcesHad; + readonly consent: Consent; } -function consentIssuesOf({ provider, credential }: GivenAddition, consent: ClientSourcesHad): readonly Issue[] { +function consentIssuesOf({ provider, credential }: GivenAddition, consent: Consent): readonly Issue[] { return isConsentUnsetUp(credential, consent) ? [{ pointer: '/credential/kind', detail: consentUnsetUp(String(provider)) }] : []; @@ -140,7 +150,7 @@ function issuesOf(addition: GivenAddition, checked: Checked): readonly Issue[] { : providerIssuesOf(addition, checked); } -export function checkedAdditionOf( +function additionCheckedWith( given: AdditionGiven, checking: Checking, services: AdditionServices, @@ -156,6 +166,16 @@ export function checkedAdditionOf( }); } +export function checkedAdditionOf( + given: AdditionGiven, + checking: Checking, + services: ListingServices, +): Effect.Effect { + return Effect.flatMap(services.consent.hadSources(services.studioBase), (consent) => + Effect.fromResult(additionCheckedWith(given, checking, { ...services, consent })), + ); +} + export function refusalOfTheAddition( addition: GivenAddition, policy: Listing['policy'], diff --git a/packages/connections/src/providers/provider-listing.ts b/packages/connections/src/providers/provider-listing.ts index 699d9cc6a..5639a2d2a 100644 --- a/packages/connections/src/providers/provider-listing.ts +++ b/packages/connections/src/providers/provider-listing.ts @@ -8,18 +8,19 @@ import { type Provider, type ProviderList, } from '@on.auto/providers'; +import type { HadSources } from '@on.auto/tool-servers'; import type { Redacted } from 'effect'; import type { ConnectionPolicy } from '../policy/connection-policy.ts'; import { hostRefusalOf } from '../policy/policy-rules.ts'; -import { consentUnsetUp, isConsentUnsetUp, type ClientSourcesHad } from './consent-offered.ts'; +import { consentUnsetUp, isConsentUnsetUp } from './consent-offered.ts'; import { listedWayOf, type ListedPlaceholder, type ListedProvider, type ListedWay } from './listed-providers.ts'; export interface Listing { readonly providers: ProviderList; readonly policy: ConnectionPolicy; readonly providerValues: ReadonlyMap; - readonly consent: ClientSourcesHad; + readonly consent: HadSources; } type Offering = Pick; diff --git a/packages/connections/src/providers/provider-offering.test.ts b/packages/connections/src/providers/provider-offering.test.ts index 64b3d55f1..ba21ec059 100644 --- a/packages/connections/src/providers/provider-offering.test.ts +++ b/packages/connections/src/providers/provider-offering.test.ts @@ -1,13 +1,13 @@ import type { CallerIdentity } from '@on.auto/operations'; import { fixtureProviders } from '@on.auto/providers/testing'; import { consentClientWith } from '@on.auto/tool-servers/testing'; -import type { Redacted } from 'effect'; +import { Effect, type Redacted } from 'effect'; import { describe, expect, it } from 'vitest'; import { makeProviderOffering } from '../index.ts'; import { connectionPolicyOf, type ConnectionPolicyFields } from '../policy/connection-policy.ts'; import type { RuntimeAccess } from '../policy/policy-rules.ts'; -import { ada, developerTokenSet } from '../testing/harness.ts'; +import { ada, developerTokenSet, harnessPages } from '../testing/harness.ts'; const acmeKey: CallerIdentity = { ...ada, id: 'acme-key' }; @@ -19,13 +19,15 @@ interface RuntimeSettings { } function offeredBy(access: RuntimeAccess, { policy = {}, providerValues = new Map() }: RuntimeSettings = {}) { - return makeProviderOffering({ + const offering = makeProviderOffering({ providers: fixtureProviders, providerValues, policy: connectionPolicyOf(policy), access, + pages: harnessPages, consent: consentClientWith(), }); + return (caller: CallerIdentity) => Effect.runSync(offering(caller)); } function namesOf(offered: readonly { readonly name: string }[] | undefined): readonly string[] | undefined { diff --git a/packages/connections/src/providers/provider-offering.ts b/packages/connections/src/providers/provider-offering.ts index 6dc57fc24..f8234b0ff 100644 --- a/packages/connections/src/providers/provider-offering.ts +++ b/packages/connections/src/providers/provider-offering.ts @@ -1,6 +1,8 @@ import { permissionFor, permissionRefusalOf, type CallerIdentity } from '@on.auto/operations'; import type { OfferedProvider, ProviderOffering } from '@on.auto/tool-servers'; +import { Effect } from 'effect'; +import { studioBaseFor } from '../links/connect-page.ts'; import type { ConnectionServices } from '../operations/connection-services.ts'; import { addingRefusalOf, adderRefusalOf } from '../policy/policy-rules.ts'; import type { ListedProvider } from './listed-providers.ts'; @@ -8,7 +10,7 @@ import { listedProviders } from './provider-listing.ts'; export type OfferingServices = Pick< ConnectionServices, - 'providers' | 'providerValues' | 'policy' | 'access' | 'consent' + 'providers' | 'providerValues' | 'policy' | 'access' | 'consent' | 'pages' >; const addingPermissions = [permissionFor('command', 'org')]; @@ -35,5 +37,10 @@ function offeredAmong(listed: readonly ListedProvider[]): readonly OfferedProvid } export function makeProviderOffering(services: OfferingServices): ProviderOffering { - return (caller) => (refusalToAdd(services, caller) === null ? offeredAmong(listedProviders(services)) : undefined); + return (caller) => + refusalToAdd(services, caller) === null + ? Effect.map(services.consent.hadSources(studioBaseFor(services.pages)), (consent) => + offeredAmong(listedProviders({ ...services, consent })), + ) + : Effect.undefined; } diff --git a/packages/connections/src/serving/consent-serving.test.ts b/packages/connections/src/serving/consent-serving.test.ts index 2d08ceacb..1edd05bf0 100644 --- a/packages/connections/src/serving/consent-serving.test.ts +++ b/packages/connections/src/serving/consent-serving.test.ts @@ -36,6 +36,9 @@ const consentEnded = const clientGone = 'The client the connection files was made with is no longer registered, so the person who added it removes it and adds it again'; +const keyUnknown = + "Auto's consent service does not know the key of the connection files, so its tokens cannot be renewed, and the person who added it removes it and adds it again"; + const notTokens = 'The credential of the connection files cannot be read, so the person who added it removes it and adds it again: its value is not the tokens of a consent the runtime keeps'; @@ -219,18 +222,20 @@ describe('the store of the tokens of a connection connected by consent', () => { expect(read).toEqual({ tokens: renewed, version: 2, claimedUntil: undefined }); }); - it('breaks the connection in the words of an ended consent or of a client gone, recording the break once', async () => { + it('breaks the connection in the words of an ended consent, a client gone or a key Auto’s consent service does not know, recording the break once', async () => { const { h, id } = await connectedByConsent(); const { tokens } = await consentOf(h); const ended = await Effect.runPromise(Effect.flip(tokens.broken('consent_ended'))); const withoutItsClient = await Effect.runPromise(Effect.flip(tokens.broken('client_gone'))); + const withoutItsKey = await Effect.runPromise(Effect.flip(tokens.broken('key_unknown'))); const [listed] = await Effect.runPromise(sourceOf(h).connectionsOf('acme')); const breaks = (await h.facts()).filter(({ type }) => type === 'connection_broken'); - expect([ended, withoutItsClient]).toEqual([ + expect([ended, withoutItsClient, withoutItsKey]).toEqual([ { because: 'connection_broken', detail: consentEnded }, { because: 'connection_broken', detail: clientGone }, + { because: 'connection_broken', detail: keyUnknown }, ]); expect(listed?.state).toEqual({ status: 'unready', diff --git a/packages/connections/src/serving/given-credentials.ts b/packages/connections/src/serving/given-credentials.ts index 1dd730cb4..9eb9098f6 100644 --- a/packages/connections/src/serving/given-credentials.ts +++ b/packages/connections/src/serving/given-credentials.ts @@ -21,6 +21,7 @@ export type Given = Effect.Effect; const endedWords: Readonly string>> = { consent_ended: servingWords.consentEnded, client_gone: servingWords.clientGone, + key_unknown: servingWords.keyUnknown, }; function required(value: A | undefined): A { diff --git a/packages/connections/src/serving/serving-words.ts b/packages/connections/src/serving/serving-words.ts index 675764cde..135e5e62c 100644 --- a/packages/connections/src/serving/serving-words.ts +++ b/packages/connections/src/serving/serving-words.ts @@ -17,6 +17,8 @@ export const servingWords = { consentEnded: (name: string) => `The consent of the connection ${name} was revoked or has ended, so ${removeAndAdd}`, clientGone: (name: string) => `The client the connection ${name} was made with is no longer registered, so ${removeAndAdd}`, + keyUnknown: (name: string) => + `Auto's consent service does not know the key of the connection ${name}, so its tokens cannot be renewed, and ${removeAndAdd}`, unchecked: (name: string, details: readonly string[]) => `The entry of the connection ${name} is not one this runtime takes any more: ${listed(details)}`, } as const; diff --git a/packages/providers/README.md b/packages/providers/README.md index e8894e2f4..cc076dd02 100644 --- a/packages/providers/README.md +++ b/packages/providers/README.md @@ -32,7 +32,7 @@ One YAML file a provider, `src/entries/.yaml`, named for the entry's `name - A reference, `${NAME}`, stands in a header or a query value alone, for a value that belongs to whoever runs the runtime, such as a developer token of their registration at the vendor. A header or a query value is written as a file's entry writes one, `$$` for a `$` that is no reference, since the serving substitutes the provider's references from the runtime's environment; `NAME` is then one of the variables the runtime reads for itself, which `connections.variables` may not name. - The server part and the placeholders are checked with `credentialProblems` of `@on.auto/config`, so no value that looks like a credential ships; a placeholder's name is never judged as a credential's name, and the entry's prose, `title`, `description` and a way's `description`, is not checked for the shape of one. - A way takes the fields of its kind and no other, and no kind is offered twice. A kind the runtime does not take is refused, so no entry ships with one. -- A way of the kind `authorization` names the authorization server by its `issuer`, which also keys the client whoever runs the runtime registered there. `consentIssuesOf` (`src/entry/credential-ways.ts`), which `@on.auto/connections` runs over the credential of such a connection too, refuses a `scope` that is not scopes separated by single spaces, `isScope` of `@on.auto/tool-servers`, the rule a granted scope is held to too, a name among the `parameters` that the authorization request sets itself, `response_type`, `response_mode`, `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method`, `state`, `scope`, `resource`, `request` and `request_uri`, while `prompt` is taken and replaces the one the request would carry, one of `authorization_endpoint` and `token_endpoint` given without the other, or `revocation_endpoint` given without them, for a server that publishes no metadata, which gives its revocation endpoint beside them where it has one, and `clients`, the client sources the server takes, `registered`, `metadata_document` and `dynamic` (`ClientSourceSchema` of `@on.auto/tool-servers`), empty, naming one twice or out of the order the runtime takes them in, `clientSources`, so an entry never lists `dynamic` before a client whoever runs the runtime registered; it is `registered` alone when left out. The parameters' values are checked for a credential and a reference as the server part is, and the endpoints are judged by their values alone, since the names of the fields name a token. +- A way of the kind `authorization` names the authorization server by its `issuer`, which also keys the client whoever runs the runtime registered there. `consentIssuesOf` (`src/entry/credential-ways.ts`), which `@on.auto/connections` runs over the credential of such a connection too, refuses a `scope` that is not scopes separated by single spaces, `isScope` of `@on.auto/tool-servers`, the rule a granted scope is held to too, a name among the `parameters` that the authorization request sets itself, `response_type`, `response_mode`, `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method`, `state`, `scope`, `resource`, `request` and `request_uri`, while `prompt` is taken and replaces the one the request would carry, one of `authorization_endpoint` and `token_endpoint` given without the other, or `revocation_endpoint` given without them, for a server that publishes no metadata, which gives its revocation endpoint beside them where it has one, and `clients`, the client sources the server takes, `registered`, `metadata_document` and `dynamic` (`WayClientSourceSchema` of `@on.auto/tool-servers`), empty, naming one twice or out of the order the runtime takes them in, `wayClientSources`, the runtime's own order less `auto`, which no way names and a way that takes `registered` takes as well, so an entry never lists `dynamic` before a client whoever runs the runtime registered; it is `registered` alone when left out. The parameters' values are checked for a credential and a reference as the server part is, and the endpoints are judged by their values alone, since the names of the fields name a token. The fields of a way, `wayFields`, are the one place of the placement, `in`, `name` and `scheme`, of the issuer and of the scope, and of the fields of consent, `parameters`, the three endpoints and `clients`, from which the credential of a connection picks its own; the rules of a header's name and of a scheme stay with the connection's credential, where the addition checks them. Which of them are addresses is `wayAddresses` (`src/entry/templates.ts`), the issuer and the three endpoints, from which `consentEndpoints`, the templates, the values an entry is checked by, the resolution, the hosts of a way and, in `@on.auto/connections`, the record's checks and the policy's hosts all take them. diff --git a/packages/providers/src/entry/credential-ways.ts b/packages/providers/src/entry/credential-ways.ts index 8410ae1db..d633477cb 100644 --- a/packages/providers/src/entry/credential-ways.ts +++ b/packages/providers/src/entry/credential-ways.ts @@ -1,5 +1,5 @@ import { alternatives, articled, listed } from '@on.auto/operations'; -import { ClientSourceSchema, clientSources, isScope } from '@on.auto/tool-servers'; +import { WayClientSourceSchema, isScope, wayClientSources } from '@on.auto/tool-servers'; import { Schema } from 'effect'; import { wayAddresses, type WayAddress } from './templates.ts'; @@ -16,7 +16,7 @@ export const wayFields = { authorization_endpoint: Schema.String, token_endpoint: Schema.String, revocation_endpoint: Schema.String, - clients: Schema.Array(ClientSourceSchema), + clients: Schema.Array(WayClientSourceSchema), }; export const WayKindSchema = Schema.Literals(['secret', 'client_credentials', 'none', 'authorization']); @@ -153,7 +153,7 @@ function endpointIssuesOf(fields: ConsentFields): readonly WayIssue[] { })); } -const runtimeOrder: readonly string[] = clientSources; +const runtimeOrder: readonly string[] = wayClientSources; function isInTheRuntimesOrder(clients: readonly string[]): boolean { const places = clients.map((source) => runtimeOrder.indexOf(source)); @@ -168,7 +168,7 @@ function clientIssuesOf({ clients }: ConsentFields): readonly WayIssue[] { ? [ { path: ['clients'], - issue: `Expected at least one client source, each once, in the order the runtime takes them: ${listed(clientSources)}`, + issue: `Expected at least one client source, each once, in the order the runtime takes them: ${listed(wayClientSources)}`, }, ] : []; diff --git a/packages/server/src/composition/served-consent.ts b/packages/server/src/composition/served-consent.ts index dfbe4e651..66476fcaa 100644 --- a/packages/server/src/composition/served-consent.ts +++ b/packages/server/src/composition/served-consent.ts @@ -9,6 +9,7 @@ import { type ConsentClient, } from '@on.auto/tool-servers'; +import { autoClientsOf } from '../consent/consent-settings.ts'; import type { Settings } from '../settings/settings.ts'; import { outboundFetch } from './outbound-fetch.ts'; import { runtimeLedger } from './runtime-ledger.ts'; @@ -21,7 +22,7 @@ function publicUrlOf({ signIn }: PublicAddressing): string | undefined { export function consentClientOf( runtime: AppRuntime, - settings: Pick, + settings: Pick, secrets: SecretStore, ): ConsentClient { return makeConsentClient({ @@ -29,6 +30,7 @@ export function consentClientOf( registeredClients: settings.registeredClients, publicUrl: publicUrlOf(settings), registrations: makeClientRegistrations({ registry: runtimeLedger(runtime), secrets }), + autoClients: autoClientsOf(settings.autoClients, settings.studio), }); } diff --git a/packages/server/src/connections/connection-settings.ts b/packages/server/src/connections/connection-settings.ts index 0d30b650b..978dc373e 100644 --- a/packages/server/src/connections/connection-settings.ts +++ b/packages/server/src/connections/connection-settings.ts @@ -17,6 +17,7 @@ import { variablesTheRuntimeReads } from './variables-the-runtime-reads.ts'; export interface ConnectionSettings { readonly connections: ConnectionPolicy; readonly registeredClients: readonly RegisteredClient[]; + readonly autoClientsWritten: boolean | undefined; readonly connectionVariables: ReadonlyMap; readonly providers: ProviderList; readonly providerValues: ReadonlyMap; @@ -90,6 +91,7 @@ export function readConnectionSettings( return { connections, registeredClients, + autoClientsWritten: setting.autoClients, connectionVariables: valuesOf(environment, connections), providers, providerValues: operatorValuesIn(providers, environment), diff --git a/packages/server/src/connections/variables-the-runtime-reads.ts b/packages/server/src/connections/variables-the-runtime-reads.ts index 6179965ff..ce8273d33 100644 --- a/packages/server/src/connections/variables-the-runtime-reads.ts +++ b/packages/server/src/connections/variables-the-runtime-reads.ts @@ -1,8 +1,8 @@ +import { consentVariables } from '../consent/consent-settings.ts'; import { functionVariables } from '../function-settings/function-settings.ts'; import { accessVariables } from '../settings/access-settings.ts'; import { contentVariables } from '../settings/content-settings.ts'; -import { ledgerVariables } from '../settings/ledger-settings.ts'; -import { secretsVariables } from '../settings/secrets-settings.ts'; +import { ledgerAndSecretsVariables } from '../settings/secrets-settings.ts'; import { workflowVariables } from '../settings/workflow-settings.ts'; import { studioVariables } from '../studio/studio-settings.ts'; import { telemetryVariables } from '../telemetry/telemetry-settings.ts'; @@ -12,8 +12,8 @@ import { variablesOfTheLibraries } from './variables-of-the-libraries.ts'; export const variablesTheRuntimeReads: readonly string[] = [ ...accessVariables, ...studioVariables, - ...secretsVariables, - ...ledgerVariables, + ...consentVariables, + ...ledgerAndSecretsVariables, ...contentVariables, ...workflowVariables, ...telemetryVariables, diff --git a/packages/server/src/consent/consent-settings.test.ts b/packages/server/src/consent/consent-settings.test.ts new file mode 100644 index 000000000..703545471 --- /dev/null +++ b/packages/server/src/consent/consent-settings.test.ts @@ -0,0 +1,134 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createApiKey } from '@on.auto/identity'; +import { describe, expect, it, onTestFinished } from 'vitest'; + +import { variablesTheRuntimeReads } from '../connections/variables-the-runtime-reads.ts'; +import { readSettings } from '../settings/settings.ts'; +import { autoClientsOf, returnKindOf } from './consent-settings.ts'; + +const { entry } = createApiKey({ id: 'ci-1', org: 'acme', permissions: ['org:read'], playbooks: '*' }); + +const loopback = { HOST: '127.0.0.1' }; + +const signingIn = { PUBLIC_URL: 'https://runtime.example.com' }; + +function errorFrom(environment: Readonly>): string { + try { + readSettings(environment); + } catch (error) { + return String(error); + } + return ''; +} + +function switchOf(environment: Readonly>): boolean { + return readSettings(environment).autoClients.enabled; +} + +function connectionsOf(setting: Readonly>): string { + return JSON.stringify(setting); +} + +function fileHolding(text: string): string { + const folder = mkdtempSync(join(tmpdir(), 'auto-runtime-consent-')); + onTestFinished(() => { + rmSync(folder, { recursive: true }); + }); + const file = join(folder, 'auto-runtime.yaml'); + writeFileSync(file, text); + return file; +} + +describe('the switch of Auto’s clients', () => { + it('is on by default in local mode, and off by default where people sign in or keys are taken', () => { + expect([switchOf(loopback), switchOf(signingIn), switchOf({ API_KEYS: JSON.stringify([entry]) })]).toEqual([ + true, + false, + false, + ]); + }); + + it('is what AUTO_CLIENTS says, which wins over the setting of connections, and what that setting says otherwise', () => { + expect([ + switchOf({ ...loopback, AUTO_CLIENTS: 'false' }), + switchOf({ ...signingIn, AUTO_CLIENTS: 'true' }), + switchOf({ ...loopback, CONNECTIONS: connectionsOf({ auto_clients: false }) }), + switchOf({ ...signingIn, CONNECTIONS: connectionsOf({ auto_clients: true }) }), + switchOf({ ...loopback, AUTO_CLIENTS: 'true', CONNECTIONS: connectionsOf({ auto_clients: false }) }), + ]).toEqual([false, true, false, true, true]); + }); + + it('is read from connections.auto_clients of the configuration file', () => { + const file = fileHolding('connections:\n auto_clients: false\n'); + + expect(switchOf({ ...loopback, CONFIG_FILE: file })).toBe(false); + }); + + it('is refused at start for a value other than true or false, naming the setting', () => { + expect(errorFrom({ ...loopback, AUTO_CLIENTS: 'yes' })).toContain('AUTO_CLIENTS'); + }); +}); + +describe('the address of Auto’s consent service', () => { + it('is https://consent.on.auto by default, and any https origin, or an http one on the loopback for a test', () => { + expect([ + readSettings(loopback).autoClients.serviceUrl, + readSettings({ ...loopback, CONSENT_SERVICE_URL: 'https://consent.example.com' }).autoClients.serviceUrl, + readSettings({ ...loopback, CONSENT_SERVICE_URL: 'http://127.0.0.1:19650' }).autoClients.serviceUrl, + ]).toEqual(['https://consent.on.auto', 'https://consent.example.com', 'http://127.0.0.1:19650']); + }); + + it('is refused at start where it is http off the loopback, or more than an origin', () => { + expect([ + errorFrom({ ...loopback, CONSENT_SERVICE_URL: 'http://consent.example.com' }), + errorFrom({ ...loopback, CONSENT_SERVICE_URL: 'https://consent.example.com/v1' }), + ]).toEqual([ + 'InvalidSettingsError: CONSENT_SERVICE_URL: Expected an https URL; an http URL is taken only for localhost, 127.0.0.1, or [::1]', + 'InvalidSettingsError: SchemaError(Expected an https URL with no path, query or fragment, such as https://consent.on.auto\n at ["CONSENT_SERVICE_URL"])', + ]); + }); + + it('is named by variables the runtime reads for itself, which no connection may send', () => { + expect(variablesTheRuntimeReads).toEqual(expect.arrayContaining(['AUTO_CLIENTS', 'CONSENT_SERVICE_URL'])); + expect(errorFrom({ ...loopback, CONNECTIONS: connectionsOf({ variables: ['AUTO_CLIENTS'] }) })).toContain( + 'AUTO_CLIENTS is a variable the runtime reads for itself, which no connection may name', + ); + }); +}); + +describe('the return the base of a Studio asks of Auto’s consent service', () => { + it('is to the Studio host for its own base, to a base on this computer by its loopback, and elsewhere otherwise', () => { + const kindOf = returnKindOf({ origin: 'https://studio.on.auto', served: false }); + + expect( + [ + 'https://studio.on.auto/', + 'http://127.0.0.1:5555/studio/', + 'http://localhost:5555/studio/', + 'http://[::1]:5555/studio/', + 'https://runtime.example.com/studio/', + 'https://studio.example.com/', + ].map((base) => kindOf(base)), + ).toEqual(['hosted', 'loopback', 'loopback', 'loopback', 'elsewhere', 'elsewhere']); + }); + + it('is to the loopback for a Studio host on this computer, whose page only this computer reaches', () => { + expect(returnKindOf({ origin: 'http://localhost:5173', served: false })('http://localhost:5173/')).toBe('loopback'); + }); + + it('is judged by the client the runtime makes, beside the switch and the service it names', () => { + const autoClients = autoClientsOf( + { enabled: true, serviceUrl: 'https://consent.example.com' }, + { origin: 'https://studio.on.auto', served: false }, + ); + + expect([autoClients.enabled, autoClients.service, autoClients.returnKindOf('https://studio.on.auto/')]).toEqual([ + true, + { url: 'https://consent.example.com' }, + 'hosted', + ]); + }); +}); diff --git a/packages/server/src/consent/consent-settings.ts b/packages/server/src/consent/consent-settings.ts new file mode 100644 index 000000000..5fd41e15a --- /dev/null +++ b/packages/server/src/consent/consent-settings.ts @@ -0,0 +1,65 @@ +import type { Environment } from '@on.auto/config'; +import { + accessModeOf, + type ApiKey, + type DeclaredAccessMode, + type SignInSettings, + type StudioSettings, +} from '@on.auto/identity'; +import { namesTheLoopback } from '@on.auto/operations'; +import type { AutoClients, ReturnKind } from '@on.auto/tool-servers'; +import { Config, ConfigProvider, Effect, Option } from 'effect'; + +import { asInvalidSettings, InvalidSettingsError } from '../settings/invalid-settings-error.ts'; +import { ConsentServiceOrigin, defaultConsentService, httpOffTheLoopbackIn } from '../settings/origin.ts'; + +export const consentVariables: readonly string[] = ['AUTO_CLIENTS', 'CONSENT_SERVICE_URL']; + +export interface AutoClientsSettings { + readonly enabled: boolean; + readonly serviceUrl: string; +} + +export interface AutoClientsDeciding { + readonly host: string; + readonly apiKeys: readonly ApiKey[] | undefined; + readonly signIn: SignInSettings | undefined; + readonly accessMode: DeclaredAccessMode | undefined; + readonly written: boolean | undefined; +} + +const autoClientsSettings = Config.all({ + enabled: Config.option(Config.Literals(['true', 'false'], 'AUTO_CLIENTS')), + serviceUrl: Config.schema(ConsentServiceOrigin, 'CONSENT_SERVICE_URL').pipe( + Config.withDefault(defaultConsentService), + ), +}); + +function isInLocalMode({ host, apiKeys, signIn, accessMode }: AutoClientsDeciding): boolean { + return accessModeOf({ host, apiKeys, signsIn: signIn !== undefined, accessMode }) === 'local'; +} + +export function readAutoClientsSettings(environment: Environment, deciding: AutoClientsDeciding): AutoClientsSettings { + const { enabled, serviceUrl } = Effect.runSync( + asInvalidSettings(autoClientsSettings.parse(ConfigProvider.fromEnvRecord(environment))), + ); + const refusal = httpOffTheLoopbackIn('CONSENT_SERVICE_URL', serviceUrl); + if (refusal !== undefined) { + throw new InvalidSettingsError({ message: refusal }); + } + const byDefault = () => deciding.written ?? isInLocalMode(deciding); + return { enabled: Option.match(enabled, { onNone: byDefault, onSome: (given) => given === 'true' }), serviceUrl }; +} + +export function returnKindOf({ origin }: StudioSettings): (base: string) => ReturnKind { + return (base) => { + if (namesTheLoopback(new URL(base).hostname)) { + return 'loopback'; + } + return base === `${origin}/` ? 'hosted' : 'elsewhere'; + }; +} + +export function autoClientsOf({ enabled, serviceUrl }: AutoClientsSettings, studio: StudioSettings): AutoClients { + return { enabled, service: { url: serviceUrl }, returnKindOf: returnKindOf(studio) }; +} diff --git a/packages/server/src/consent/consent-through-auto-over-http.test.ts b/packages/server/src/consent/consent-through-auto-over-http.test.ts new file mode 100644 index 000000000..f2c2ef676 --- /dev/null +++ b/packages/server/src/consent/consent-through-auto-over-http.test.ts @@ -0,0 +1,111 @@ +import { handleOf, servedThroughAuto, type ThroughAuto } from '@on.auto/tool-servers/testing'; +import { Schema } from 'effect'; +import { describe, expect, it, onTestFinished } from 'vitest'; + +import { compositionRoot } from '../composition/composition-root.ts'; +import { startServer } from '../lifecycle/lifecycle.ts'; +import { secretsKey } from '../testing/records/kept-credentials.ts'; +import { consentStartedBy } from '../testing/servers/consents-over-http.ts'; +import { servingReasoning, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; + +const connections = '/v1/orgs/local/connections'; + +type Environment = Readonly>; + +const ListedSchema = Schema.Struct({ + providers: Schema.Array( + Schema.Struct({ name: Schema.String, credentials: Schema.Array(Schema.Struct({ offered: Schema.Boolean })) }), + ), +}); + +function filesAt({ fake }: Pick): Environment { + return { + CONNECTIONS: JSON.stringify({ + providers: [ + { + name: 'files', + title: 'Files', + description: 'Read the files of an account.', + website: 'https://developer.files.example.com', + type: 'mcp', + url: fake.url, + credentials: [{ kind: 'authorization', issuer: fake.origin, scope: 'files.read' }], + }, + ], + }), + }; +} + +async function throughAuto(): Promise { + const serving = await servedThroughAuto(); + onTestFinished(serving.close); + return serving; +} + +async function servedWith(environment: Environment): Promise { + const served = await servingReasoning([], { SECRETS_KEY: secretsKey.toString('base64'), ...environment }); + onTestFinished(served.stop); + return served; +} + +async function filesOfferedBy(served: ReasoningServer): Promise { + const { body } = await served.call('GET', '/v1/orgs/local/providers'); + const { providers } = Schema.decodeUnknownSync(ListedSchema)(body); + return providers + .filter(({ name }) => name === 'files') + .flatMap(({ credentials }) => credentials.map(({ offered }) => offered)); +} + +function throughTheService(serving: ThroughAuto): Environment { + return { AUTO_CLIENTS: 'true', CONSENT_SERVICE_URL: serving.service.url, ...filesAt(serving) }; +} + +describe('a provider of consent on a runtime in local mode', () => { + it('is offered through Auto’s consent service where AUTO_CLIENTS is on, and set up first where it is off', async () => { + const serving = await throughAuto(); + + const on = await servedWith(throughTheService(serving)); + const off = await servedWith({ ...throughTheService(serving), AUTO_CLIENTS: 'false' }); + + expect([await filesOfferedBy(on), await filesOfferedBy(off)]).toEqual([[true], [false]]); + }); +}); + +describe('a consent through Auto’s consent service over HTTP', () => { + it('starts from the served Studio on this computer, is finished with the handle, and connects the connection', async () => { + const serving = await throughAuto(); + const served = await servedWith(throughTheService(serving)); + + const { id, token, authorize } = await consentStartedBy(served); + const read = await served.call('GET', `${connections}/${id}/link/${token}`); + const finished = await served.call('POST', `${connections}/${id}/consent/code`, { + body: { + token, + state: String(authorize.searchParams.get('state')), + handle: await handleOf(serving.service, authorize.href), + }, + }); + + expect(read.body).toMatchObject({ client_source: 'auto' }); + expect(serving.service.calls().find(({ path }) => path === '/consents')?.body).toMatchObject({ + return: { kind: 'loopback', base: `${served.origin}/studio/` }, + }); + expect(finished).toMatchObject({ + status: 200, + body: { status: 'connected', credential: { client_id: 'auto-runtime', client_source: 'auto' } }, + }); + }); +}); + +describe('a runtime whose consent service is named on http off the loopback', () => { + it('does not start, naming the setting', async () => { + const starting = startServer( + { HOST: '127.0.0.1', PORT: '0', CONSENT_SERVICE_URL: 'http://consent.example.com' }, + compositionRoot, + ); + + await expect(starting).rejects.toThrow( + 'CONSENT_SERVICE_URL: Expected an https URL; an http URL is taken only for localhost, 127.0.0.1, or [::1]', + ); + }); +}); diff --git a/packages/server/src/lifecycle/composed-stop.test.ts b/packages/server/src/lifecycle/composed-stop.test.ts index 2325e1e0f..e2b9c462b 100644 --- a/packages/server/src/lifecycle/composed-stop.test.ts +++ b/packages/server/src/lifecycle/composed-stop.test.ts @@ -10,6 +10,7 @@ import { } from '../testing/processes/workflow-process.ts'; import { temporaryLedger } from '../testing/records/temporary-ledger.ts'; import { runIdIn, workflowSource } from '../testing/servers/workflow-server.ts'; +import { noConsentThroughAuto } from '../testing/settings/no-consent-through-auto.ts'; import { longerThanTheTestMs } from '../testing/waiting/until.ts'; import { startServer, type RunningServer } from './lifecycle.ts'; @@ -24,6 +25,7 @@ function composedWithALongShutdown(environment: Readonly> PORT: '0', LEDGER_FILE: ledger.fileName, PREDICTION_CACHE_DIR: ledger.cacheDirectory, + ...noConsentThroughAuto, ...environment, }, { ...compositionRoot, shutdownTimeoutMs: longerThanTheTestMs }, diff --git a/packages/server/src/local-runtime/provider-setup.test.ts b/packages/server/src/local-runtime/provider-setup.test.ts index c6dfe1129..50635f358 100644 --- a/packages/server/src/local-runtime/provider-setup.test.ts +++ b/packages/server/src/local-runtime/provider-setup.test.ts @@ -6,6 +6,7 @@ import { startServer, type RunningServer } from '../lifecycle/lifecycle.ts'; import { temporaryLedger, type TemporaryLedger } from '../testing/records/temporary-ledger.ts'; import { request } from '../testing/servers/http-client.ts'; import { alpha } from '../testing/servers/reasoning-server.ts'; +import { noConsentThroughAuto } from '../testing/settings/no-consent-through-auto.ts'; import { providerSetupFor, restartWordsFor } from './local-control.ts'; const setup = providerSetupFor('/Users/ada/.auto-runtime', restartWordsFor({ port: undefined, version: '0.9.1' })); @@ -23,6 +24,7 @@ beforeEach(async () => { PORT: '0', LEDGER_FILE: ledger.fileName, PREDICTION_CACHE_DIR: ledger.cacheDirectory, + ...noConsentThroughAuto, }, { ...compositionRoot, localServingIn: () => ({ providerSetup: setup }) }, ); diff --git a/packages/server/src/playbooks/playbooks-over-http.test.ts b/packages/server/src/playbooks/playbooks-over-http.test.ts index b2f2b158d..ef6157acf 100644 --- a/packages/server/src/playbooks/playbooks-over-http.test.ts +++ b/packages/server/src/playbooks/playbooks-over-http.test.ts @@ -4,6 +4,7 @@ import { compositionRoot } from '../composition/composition-root.ts'; import { startServer, type RunningServer } from '../lifecycle/lifecycle.ts'; import { temporaryLedger, type TemporaryLedger } from '../testing/records/temporary-ledger.ts'; import { request, type TestResponse } from '../testing/servers/http-client.ts'; +import { noConsentThroughAuto } from '../testing/settings/no-consent-through-auto.ts'; const playbooks = '/v1/orgs/acme/playbooks'; @@ -18,6 +19,7 @@ beforeEach(async () => { PORT: '0', LEDGER_FILE: ledger.fileName, PREDICTION_CACHE_DIR: ledger.cacheDirectory, + ...noConsentThroughAuto, }, compositionRoot, ); diff --git a/packages/server/src/reasoning/provider-messages-over-http.test.ts b/packages/server/src/reasoning/provider-messages-over-http.test.ts index b941ac927..93c660fff 100644 --- a/packages/server/src/reasoning/provider-messages-over-http.test.ts +++ b/packages/server/src/reasoning/provider-messages-over-http.test.ts @@ -6,6 +6,7 @@ import { compositionRootWith } from '../composition/composition-root.ts'; import { startServer, type RunningServer } from '../lifecycle/lifecycle.ts'; import { temporaryLedger, type TemporaryLedger } from '../testing/records/temporary-ledger.ts'; import { request, type TestResponse } from '../testing/servers/http-client.ts'; +import { noConsentThroughAuto } from '../testing/settings/no-consent-through-auto.ts'; const alpha = '/v1/orgs/acme/playbooks/alpha'; @@ -34,6 +35,7 @@ beforeEach(async () => { PORT: '0', LEDGER_FILE: ledger.fileName, PREDICTION_CACHE_DIR: ledger.cacheDirectory, + ...noConsentThroughAuto, MODEL_GATEWAYS: gateways, }, compositionRootWith((settings) => diff --git a/packages/server/src/reasoning/unconfigured-provider-over-http.test.ts b/packages/server/src/reasoning/unconfigured-provider-over-http.test.ts index 6bb63b6cd..f5a3a6f40 100644 --- a/packages/server/src/reasoning/unconfigured-provider-over-http.test.ts +++ b/packages/server/src/reasoning/unconfigured-provider-over-http.test.ts @@ -5,6 +5,7 @@ import { startServer, type RunningServer } from '../lifecycle/lifecycle.ts'; import { temporaryLedger, type TemporaryLedger } from '../testing/records/temporary-ledger.ts'; import { request, type TestResponse } from '../testing/servers/http-client.ts'; import { alpha } from '../testing/servers/reasoning-server.ts'; +import { noConsentThroughAuto } from '../testing/settings/no-consent-through-auto.ts'; const verdict = [ '---', @@ -39,6 +40,7 @@ async function executedVerdict(models: Readonly>): Promis PORT: '0', LEDGER_FILE: ledger.fileName, PREDICTION_CACHE_DIR: ledger.cacheDirectory, + ...noConsentThroughAuto, ...models, }, compositionRoot, diff --git a/packages/server/src/settings/access-mode.test.ts b/packages/server/src/settings/access-mode.test.ts index 44dfc94d8..c433cd572 100644 --- a/packages/server/src/settings/access-mode.test.ts +++ b/packages/server/src/settings/access-mode.test.ts @@ -9,6 +9,7 @@ import { startServer } from '../lifecycle/lifecycle.ts'; import { accessTokenOf, issuerOverHttps } from '../sign-in/issuer-over-https.ts'; import { spawnServer } from '../testing/processes/spawned-server.ts'; import { temporaryLedger, type TemporaryLedger } from '../testing/records/temporary-ledger.ts'; +import { noConsentThroughAuto } from '../testing/settings/no-consent-through-auto.ts'; const mainModule = fileURLToPath(new URL('../main.ts', import.meta.url)); @@ -44,7 +45,14 @@ async function statusesOn( credentials: readonly (string | undefined)[], ): Promise { const server = await startServer( - { HOST: '127.0.0.1', PORT: '0', LEDGER_FILE: ledger.fileName, PREDICTION_CACHE_DIR: ledger.cacheDirectory, ...env }, + { + HOST: '127.0.0.1', + PORT: '0', + LEDGER_FILE: ledger.fileName, + PREDICTION_CACHE_DIR: ledger.cacheDirectory, + ...noConsentThroughAuto, + ...env, + }, compositionRoot, ); const responses = await Promise.all( diff --git a/packages/server/src/settings/origin.ts b/packages/server/src/settings/origin.ts index 4173b8cf8..b1acf47e1 100644 --- a/packages/server/src/settings/origin.ts +++ b/packages/server/src/settings/origin.ts @@ -31,6 +31,10 @@ function aWebOriginSuchAs(example: string) { export const PublicOrigin = Schema.String.check(aWebOriginSuchAs('https://runtime.example.com')); +export const defaultConsentService = 'https://consent.on.auto'; + +export const ConsentServiceOrigin = Schema.String.check(aWebOriginSuchAs(defaultConsentService)); + export const StudioOrigin = Schema.String.annotate({ description: `The origin Studio is served from, an https URL, or an http URL on localhost, 127.0.0.1 or [::1], with no path; a page there may call the API, and the runtime fetches Studio from it when it serves Studio. ${defaultStudioOrigin} when left out. STUDIO_ORIGIN wins over it`, }).check(aWebOriginSuchAs(defaultStudioOrigin)); diff --git a/packages/server/src/settings/secrets-settings.ts b/packages/server/src/settings/secrets-settings.ts index 486f89e5c..746062c39 100644 --- a/packages/server/src/settings/secrets-settings.ts +++ b/packages/server/src/settings/secrets-settings.ts @@ -3,7 +3,7 @@ import { secretKeyBytes } from '@on.auto/secrets'; import { Config, ConfigProvider, Effect, Option, Redacted } from 'effect'; import { InvalidSettingsError } from './invalid-settings-error.ts'; -import { besideTheLedgerFile, readLedgerSettings, type LedgerSettings } from './ledger-settings.ts'; +import { besideTheLedgerFile, ledgerVariables, readLedgerSettings, type LedgerSettings } from './ledger-settings.ts'; export type SecretsKeyGiven = | { readonly from: 'environment'; readonly setting: string; readonly key: Redacted.Redacted } @@ -32,6 +32,8 @@ const previousKey: KeySettings = { key: 'SECRETS_KEY_PREVIOUS', file: 'SECRETS_K export const secretsVariables: readonly string[] = [currentKey, previousKey].flatMap(({ key, file }) => [key, file]); +export const ledgerAndSecretsVariables: readonly string[] = [...ledgerVariables, ...secretsVariables]; + function sourcesOf({ key, file }: KeySettings) { return Config.all({ key: Config.map(Config.option(Config.Redacted(key)), Option.getOrUndefined), diff --git a/packages/server/src/settings/served-settings.ts b/packages/server/src/settings/served-settings.ts index ef13a7d89..7d4b4ebc5 100644 --- a/packages/server/src/settings/served-settings.ts +++ b/packages/server/src/settings/served-settings.ts @@ -8,19 +8,31 @@ import { readConnectionSettings, type ConnectionSettings, } from '../connections/connection-settings.ts'; +import { + readAutoClientsSettings, + type AutoClientsDeciding, + type AutoClientsSettings, +} from '../consent/consent-settings.ts'; import { readFunctionSettings, type FunctionSettings } from '../function-settings/function-settings.ts'; import { readWorkflowSettings, type WorkflowSettings } from './workflow-settings.ts'; export interface ServedSettings extends FunctionSettings, ConnectionSettings { readonly workflows: WorkflowSettings; + readonly autoClients: AutoClientsSettings; } -export function readServedSettings(environment: Environment, file: FileUse | undefined): ServedSettings { +export function readServedSettings( + environment: Environment, + file: FileUse | undefined, + access: Omit, +): ServedSettings { const connections = readConnections(environment, file, Result.getOrThrow(readProviders())); const functions = readFunctionSettings(environment, file, makeProviderResolver(connections.providers)); + const connectionSettings = readConnectionSettings(environment, file, functions, connections); return { ...functions, - ...readConnectionSettings(environment, file, functions, connections), + ...connectionSettings, workflows: Effect.runSync(readWorkflowSettings(environment)), + autoClients: readAutoClientsSettings(environment, { ...access, written: connectionSettings.autoClientsWritten }), }; } diff --git a/packages/server/src/settings/settings.test.ts b/packages/server/src/settings/settings.test.ts index 4eb4d446d..30a80d9a2 100644 --- a/packages/server/src/settings/settings.test.ts +++ b/packages/server/src/settings/settings.test.ts @@ -29,6 +29,17 @@ const defaultPrediction = { cacheDirectory: '/var/tmp/auto-runtime', }; +const modelProvidersByDefault = [ + 'anthropic', + 'openai', + 'google', + 'bedrock', + 'bedrock-anthropic', + 'azure', + 'vertex', + 'vertex-anthropic', +]; + describe('readSettings', () => { it('listens on every interface at port 5555, allows no origin, has no API keys and keeps the ledger in data/ by default', () => { const { models, toolServers, interaction, ...server } = readSettings({}); @@ -52,16 +63,7 @@ describe('readSettings', () => { proxy: { enabled: false, environment: {} }, logFormat: 'json', logLevel: 'info', - modelProviders: [ - 'anthropic', - 'openai', - 'google', - 'bedrock', - 'bedrock-anthropic', - 'azure', - 'vertex', - 'vertex-anthropic', - ], + modelProviders: modelProvidersByDefault, reasoning: { mostInputTokens: 2_000_000 }, workflows: { mostDurationMs: 2_592_000_000, mostCallsAtOnce: 32, mostOpenCalls: 1000, sweepEveryMs: 1000 }, computation: { workers: 4 }, @@ -69,6 +71,8 @@ describe('readSettings', () => { prediction: defaultPrediction, connections: { peopleMayAdd: true, allowedHosts: null, credentialKinds: null, variables: [] }, registeredClients: [], + autoClientsWritten: undefined, + autoClients: { enabled: false, serviceUrl: 'https://consent.on.auto' }, connectionVariables: new Map(), providers: shippedList, providerValues: new Map(), @@ -95,7 +99,14 @@ const everySetting = { OPENAI_API_KEY: 'sk-test', INTERACTION_OPEN_REQUESTS: '250', REASONING_MAX_INPUT_TOKENS: '500000', - CONNECTIONS: JSON.stringify({ people_may_add: false, allowed_hosts: ['*.example.com'], credential_kinds: ['none'] }), + CONNECTIONS: JSON.stringify({ + people_may_add: false, + allowed_hosts: ['*.example.com'], + credential_kinds: ['none'], + auto_clients: false, + }), + AUTO_CLIENTS: 'true', + CONSENT_SERVICE_URL: 'https://consent.example.com', }; const everySettingRead = { @@ -133,6 +144,8 @@ const everySettingRead = { prediction: defaultPrediction, connections: { peopleMayAdd: false, allowedHosts: ['*.example.com'], credentialKinds: ['none'], variables: [] }, registeredClients: [], + autoClientsWritten: false, + autoClients: { enabled: true, serviceUrl: 'https://consent.example.com' }, connectionVariables: new Map(), providers: shippedList, providerValues: new Map(), diff --git a/packages/server/src/settings/settings.ts b/packages/server/src/settings/settings.ts index d809b82ca..b6cf61b33 100644 --- a/packages/server/src/settings/settings.ts +++ b/packages/server/src/settings/settings.ts @@ -43,7 +43,7 @@ export function readSettings(given: Environment): Settings { const credentials = readCredentialSettings(environment, host); const { ledger, secrets } = Effect.runSync(readLedgerAndSecrets(environment)); const content = Effect.runSync(readContentSettings(environment)); - const served = readServedSettings(environment, file); + const served = readServedSettings(environment, file, { host, ...credentials }); const telemetry = Effect.runSync(readTelemetrySettings(environment)); const usagePing = Effect.runSync(readUsagePingSettings(environment)); return { diff --git a/packages/server/src/telemetry/request-tracing.test.ts b/packages/server/src/telemetry/request-tracing.test.ts index 00fa4f3c1..97d3339c0 100644 --- a/packages/server/src/telemetry/request-tracing.test.ts +++ b/packages/server/src/telemetry/request-tracing.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, onTestFinished } from 'vitest'; import { compositionRoot } from '../composition/composition-root.ts'; import { defaultServerOptions, startServer, type RunningServer } from '../lifecycle/lifecycle.ts'; import { temporaryLedger } from '../testing/records/temporary-ledger.ts'; +import { noConsentThroughAuto } from '../testing/settings/no-consent-through-auto.ts'; import { metricNamesIn, spansIn } from '../testing/telemetry/otlp-exports.ts'; import { receivingOtlp, type OtlpReceiver } from '../testing/telemetry/otlp-receiver.ts'; import { tracedRequestOf } from './request-tracing.ts'; @@ -84,6 +85,7 @@ describe('a request that reads a link', () => { PORT: '0', LEDGER_FILE: ledger.fileName, PREDICTION_CACHE_DIR: ledger.cacheDirectory, + ...noConsentThroughAuto, OTEL_EXPORTER_OTLP_ENDPOINT: receiver.endpoint, OTEL_EXPORTER_OTLP_PROTOCOL: 'http/json', }, diff --git a/packages/server/src/testing/openapi/served-document-text.ts b/packages/server/src/testing/openapi/served-document-text.ts index 62ca917dc..4b682998e 100644 --- a/packages/server/src/testing/openapi/served-document-text.ts +++ b/packages/server/src/testing/openapi/served-document-text.ts @@ -3,6 +3,7 @@ import { openApiPath } from '@on.auto/api'; import { compositionRoot } from '../../composition/composition-root.ts'; import { startServer } from '../../lifecycle/lifecycle.ts'; import { temporaryLedger } from '../records/temporary-ledger.ts'; +import { noConsentThroughAuto } from '../settings/no-consent-through-auto.ts'; export async function servedDocumentText(): Promise { const ledger = temporaryLedger(); @@ -14,6 +15,7 @@ export async function servedDocumentText(): Promise { LOG_LEVEL: 'error', LEDGER_FILE: ledger.fileName, PREDICTION_CACHE_DIR: ledger.cacheDirectory, + ...noConsentThroughAuto, }, compositionRoot, ); diff --git a/packages/server/src/testing/processes/development-process.ts b/packages/server/src/testing/processes/development-process.ts index e9d2c275f..1d35f5423 100644 --- a/packages/server/src/testing/processes/development-process.ts +++ b/packages/server/src/testing/processes/development-process.ts @@ -8,6 +8,7 @@ import { spawnForTheTest } from '@on.auto/operations/testing'; import { Option, Schema } from 'effect'; import { onTestFinished } from 'vitest'; +import { noConsentThroughAuto } from '../settings/no-consent-through-auto.ts'; import { until } from '../waiting/until.ts'; export interface DevelopmentFiles { @@ -83,6 +84,7 @@ export function startDevelopment(files: DevelopmentFiles, options: DevelopmentOp LEDGER_FILE: files.ledgerFile, PREDICTION_CACHE_DIR: join(files.directory, 'prediction-cache'), DO_NOT_TRACK: '1', + ...noConsentThroughAuto, ...options.environment, }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/server/src/testing/processes/runtime-command.ts b/packages/server/src/testing/processes/runtime-command.ts index 54b712f5e..a98bafe95 100644 --- a/packages/server/src/testing/processes/runtime-command.ts +++ b/packages/server/src/testing/processes/runtime-command.ts @@ -92,6 +92,7 @@ export function spawnRuntimeCommand( HOME: home, PORT: String(port), DO_NOT_TRACK: '1', + AUTO_CLIENTS: 'false', ...settings, }, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/packages/server/src/testing/processes/spawned-server.ts b/packages/server/src/testing/processes/spawned-server.ts index 9a0a2a731..f933a8420 100644 --- a/packages/server/src/testing/processes/spawned-server.ts +++ b/packages/server/src/testing/processes/spawned-server.ts @@ -7,6 +7,7 @@ import { spawnForTheTest } from '@on.auto/operations/testing'; import { onTestFinished } from 'vitest'; import { callInFlightFileVariable } from '../entries/calls-in-flight.ts'; +import { noConsentThroughAuto } from '../settings/no-consent-through-auto.ts'; import { until } from '../waiting/until.ts'; export interface SpawnedServer { @@ -42,6 +43,7 @@ export function spawnServer(entry: string, env: Readonly> NODE_V8_COVERAGE: process.env['NODE_V8_COVERAGE'], PREDICTION_CACHE_DIR: folderOfTheTest('auto-runtime-test-cache-'), DO_NOT_TRACK: '1', + ...noConsentThroughAuto, ...env, }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/server/src/testing/servers/reasoning-server.ts b/packages/server/src/testing/servers/reasoning-server.ts index 489a58804..e49d31986 100644 --- a/packages/server/src/testing/servers/reasoning-server.ts +++ b/packages/server/src/testing/servers/reasoning-server.ts @@ -4,7 +4,8 @@ import { Effect } from 'effect'; import { compositionRootWith } from '../../composition/composition-root.ts'; import { startServer } from '../../lifecycle/lifecycle.ts'; -import { temporaryLedger } from '../records/temporary-ledger.ts'; +import { temporaryLedger, type TemporaryLedger } from '../records/temporary-ledger.ts'; +import { noConsentThroughAuto } from '../settings/no-consent-through-auto.ts'; import { request, type RequestOptions, type TestResponse } from './http-client.ts'; import type { ServedParts } from './served-parts.ts'; import { patientWorkerPool } from './worker-pools.ts'; @@ -19,6 +20,16 @@ export interface ReasoningServer { export const alpha = '/v1/orgs/acme/playbooks/alpha'; +export function loopbackSettingsOver(ledger: TemporaryLedger): Readonly> { + return { + HOST: '127.0.0.1', + PORT: '0', + LEDGER_FILE: ledger.fileName, + PREDICTION_CACHE_DIR: ledger.cacheDirectory, + ...noConsentThroughAuto, + }; +} + type Fetch = typeof globalThis.fetch; export const noNetwork: Fetch = () => Promise.reject(new TypeError('fetch failed: the tests reach no network')); @@ -32,13 +43,7 @@ export async function servingReasoning( const ledger = temporaryLedger(); const scripted = scriptedLanguageModel(...replies); const server = await startServer( - { - HOST: '127.0.0.1', - PORT: '0', - LEDGER_FILE: ledger.fileName, - PREDICTION_CACHE_DIR: ledger.cacheDirectory, - ...environment, - }, + { ...loopbackSettingsOver(ledger), ...environment }, compositionRootWith( (settings) => Effect.map(makeModelAccess(settings, { fetch }), (access) => ({ diff --git a/packages/server/src/testing/servers/traced-server.ts b/packages/server/src/testing/servers/traced-server.ts index 7eaad313e..13fbd6c17 100644 --- a/packages/server/src/testing/servers/traced-server.ts +++ b/packages/server/src/testing/servers/traced-server.ts @@ -8,7 +8,7 @@ import { startServer } from '../../lifecycle/lifecycle.ts'; import { temporaryLedger } from '../records/temporary-ledger.ts'; import { receivingOtlp, type OtlpReceiver } from '../telemetry/otlp-receiver.ts'; import { request, type RequestOptions, type TestResponse } from './http-client.ts'; -import { alpha } from './reasoning-server.ts'; +import { alpha, loopbackSettingsOver } from './reasoning-server.ts'; export interface TracedServer { readonly receiver: OtlpReceiver; @@ -44,10 +44,7 @@ export async function servingTraced(environment: Readonly const { fetch } = provider(); const server = await startServer( { - HOST: '127.0.0.1', - PORT: '0', - LEDGER_FILE: ledger.fileName, - PREDICTION_CACHE_DIR: ledger.cacheDirectory, + ...loopbackSettingsOver(ledger), ANTHROPIC_API_KEY: 'k', GRAPH_API_KEY: toolKey, TOOL_SERVERS: JSON.stringify({ diff --git a/packages/server/src/testing/settings/no-consent-through-auto.ts b/packages/server/src/testing/settings/no-consent-through-auto.ts new file mode 100644 index 000000000..480f70436 --- /dev/null +++ b/packages/server/src/testing/settings/no-consent-through-auto.ts @@ -0,0 +1 @@ +export const noConsentThroughAuto: Readonly> = { AUTO_CLIENTS: 'false' }; diff --git a/packages/tool-servers/README.md b/packages/tool-servers/README.md index 3360e7372..94e218edf 100644 --- a/packages/tool-servers/README.md +++ b/packages/tool-servers/README.md @@ -4,7 +4,7 @@ How a playbook reaches the outside world: the tool servers of its org, MCP serve ## Entry points -- `@on.auto/tool-servers`: the settings, with `ServerEntrySchema`, the schema of one entry, `readEntry`, the reading of one entry of the file, with which `@on.auto/connections` reads a connection's entry, its credential placed, into the connection's `ServerSettings`, and whose context's `placedValues`, the values the runtime places by the name of their placeholder, stand in the entry's header and query values once the entry as written is judged, and whose context's `providers`, a `ProviderResolver`, resolves the provider an entry of the file names (see [An entry that names a provider](#an-entry-that-names-a-provider)), `noProviders` where it is left out; `givenByTheProvider(field)`, the words of a field of a server written beside a provider, and `secretOrKeyExpected`, the words of client credentials given neither a client secret nor a private key with its algorithm; `makeConsentClient`, the runtime acting as a client at the authorization server of a provider's way `authorization`, and the types of its contract, `ConsentClient`, `ClientRegistrations` and `ConnectionTokens`, the store of the tokens of a connection connected by consent, with `KeptRow`, a row as a store reads it, and `HeldTokens`, that row decoded, among them, and `keptTokensIn`, which decodes the value of a row, which `@on.auto/connections` calls and implements, and `ConsentCredential`, the third kind of the credential of `AuthSettings`, the tokens of a consent, which it composes, with `ClientSourceSchema`, `clientSources` and `isSameIssuer`, the client sources an entry may list, in the runtime's order, and the comparison of two issuers, `clientMetadataDocumentOf` and its `ClientMetadataDocument`, the document the runtime names its client by where `PUBLIC_URL` is https, which the server gives `@on.auto/api` to answer, `ClientIdSchema`, the rule of a client id the record and a registration's answer are held to, and `isScope`, the rule of a scope a provider's way and a grant are held to (see [The runtime as a client](#the-runtime-as-a-client)); `serverNameProblems`, the check of a server's name, with which it checks a connection's name, and `servedFieldsOf`, the playbooks, tools, `record_content` and `request_id` an entry is served by, as it writes them or by default where it leaves them out, with which it builds the serving of every connection it lists, and `isPinnableUrl` and `headerNamePattern`; `redactedValuesOf`, the values of a record each redacted under its key, which the reasoning capability takes for the headers and the query parameters of a gateway; `@on.auto/providers` takes the server part of a provider's entry from the fields of `ServerEntrySchema`, as a connection's entry does, and checks a provider's name with `serverNameProblems` and its addresses with `isPinnableUrl`; `namesEveryTool`, whether a tool reference names every tool of its server with `*`, as a reasoning function's `tools` may; the contract of a source of connections, `ConnectionSource` and its types, which `@on.auto/connections` implements, and `noConnections`; `describedToolServers`, `makeToolAccess`, the five operations, `defineSearch` and its org twin `defineSearchInOrg`, with `ProviderOffering` and `OfferedProvider`, the contract of the providers offered to a caller, which `@on.auto/connections` implements, `defineIntrospect`, `defineValidate`, `defineDryRun` and `defineExecute`, `handCallPresenter`, which shows a call made by hand, and the facts of a call that `@on.auto/definitions` builds the run's events from. The access loads the MCP client, its OAuth providers and `node:child_process` (`src/access/linked-access.ts`) through a dynamic import when a run first opens its tools, a search or a read first asks a server or `execute` first calls a tool, so a server that never does any of them never loads them: the server's start loaded 55 files and 0.94 MiB more with them (measured 2026-10-06 with a module load hook, 1,241 files against main's 1,186). Without them it loads 82 files and 218 KiB of this package, the five operations, the reading of the settings of the three types and of an entry that names a provider, `describedToolServers` and the naming of the servers of an org and of its connections among them (measured 2026-10-19 with a module load hook over both entries, the sizes of the files as they are on disk, against 82 files and 217 KiB before the metadata of the runtime's client, which its document and a dynamic registration share, was written in `src/consent/client-sources.ts`, against 81 files and 211 KiB before the tokens of a consent were served, which added `src/consent-tokens/kept-tokens.ts`, the value a token source of a consent reads, and against 77 files and 192 KiB before an entry could name a provider); the reading resolves a provider through the resolver its context gives, so it loads no code of a provider's; the reading of an OpenAPI document, its decoding and the tools derived from it (`src/openapi`, `described-servers.ts` and the types aside) load through a dynamic import too, when an entry of type `openapi` is described at start. +- `@on.auto/tool-servers`: the settings, with `ServerEntrySchema`, the schema of one entry, `readEntry`, the reading of one entry of the file, with which `@on.auto/connections` reads a connection's entry, its credential placed, into the connection's `ServerSettings`, and whose context's `placedValues`, the values the runtime places by the name of their placeholder, stand in the entry's header and query values once the entry as written is judged, and whose context's `providers`, a `ProviderResolver`, resolves the provider an entry of the file names (see [An entry that names a provider](#an-entry-that-names-a-provider)), `noProviders` where it is left out; `givenByTheProvider(field)`, the words of a field of a server written beside a provider, and `secretOrKeyExpected`, the words of client credentials given neither a client secret nor a private key with its algorithm; `makeConsentClient`, the runtime acting as a client at the authorization server of a provider's way `authorization`, and the types of its contract, `ConsentClient`, `ClientRegistrations` and `ConnectionTokens`, the store of the tokens of a connection connected by consent, with `KeptRow`, a row as a store reads it, and `HeldTokens`, that row decoded, among them, and `keptTokensIn`, which decodes the value of a row, which `@on.auto/connections` calls and implements, and `ConsentCredential`, the third kind of the credential of `AuthSettings`, the tokens of a consent, which it composes, with `ClientSourceSchema`, `clientSources` and `isSameIssuer`, the client sources an entry may list, in the runtime's order, and the comparison of two issuers, `clientMetadataDocumentOf` and its `ClientMetadataDocument`, the document the runtime names its client by where `PUBLIC_URL` is https, which the server gives `@on.auto/api` to answer, `ClientIdSchema`, the rule of a client id the record and a registration's answer are held to, and `isScope`, the rule of a scope a provider's way and a grant are held to (see [The runtime as a client](#the-runtime-as-a-client)); `serverNameProblems`, the check of a server's name, with which it checks a connection's name, and `servedFieldsOf`, the playbooks, tools, `record_content` and `request_id` an entry is served by, as it writes them or by default where it leaves them out, with which it builds the serving of every connection it lists, and `isPinnableUrl` and `headerNamePattern`; `redactedValuesOf`, the values of a record each redacted under its key, which the reasoning capability takes for the headers and the query parameters of a gateway; `@on.auto/providers` takes the server part of a provider's entry from the fields of `ServerEntrySchema`, as a connection's entry does, and checks a provider's name with `serverNameProblems` and its addresses with `isPinnableUrl`; `namesEveryTool`, whether a tool reference names every tool of its server with `*`, as a reasoning function's `tools` may; the contract of a source of connections, `ConnectionSource` and its types, which `@on.auto/connections` implements, and `noConnections`; `describedToolServers`, `makeToolAccess`, the five operations, `defineSearch` and its org twin `defineSearchInOrg`, with `ProviderOffering` and `OfferedProvider`, the contract of the providers offered to a caller, which `@on.auto/connections` implements, `defineIntrospect`, `defineValidate`, `defineDryRun` and `defineExecute`, `handCallPresenter`, which shows a call made by hand, and the facts of a call that `@on.auto/definitions` builds the run's events from. The access loads the MCP client, its OAuth providers and `node:child_process` (`src/access/linked-access.ts`) through a dynamic import when a run first opens its tools, a search or a read first asks a server or `execute` first calls a tool, so a server that never does any of them never loads them: the server's start loaded 55 files and 0.94 MiB more with them (measured 2026-10-06 with a module load hook, 1,241 files against main's 1,186). Without them it loads 84 files and 236 KiB of this package, the five operations, the reading of the settings of the three types and of an entry that names a provider, `describedToolServers` and the naming of the servers of an org and of its connections among them (measured 2026-10-20 with a module load hook over both entries, the sizes of the files as they are on disk, against 82 files and 219 KiB before the switch of Auto's consent service and the listing of its clients, `src/consent-through-auto/auto-clients.ts`, and the contract they call, `src/consent/consent-service.ts`, came to load with the consent client, against 82 files and 217 KiB before the metadata of the runtime's client, which its document and a dynamic registration share, was written in `src/consent/client-sources.ts`, against 81 files and 211 KiB before the tokens of a consent were served, which added `src/consent-tokens/kept-tokens.ts`, the value a token source of a consent reads, and against 77 files and 192 KiB before an entry could name a provider); the reading resolves a provider through the resolver its context gives, so it loads no code of a provider's; the reading of an OpenAPI document, its decoding and the tools derived from it (`src/openapi`, `described-servers.ts` and the types aside) load through a dynamic import too, when an entry of type `openapi` is described at start. - `@on.auto/tool-servers/policy`: the pure helpers a caller needs without connecting to anything (`toolReferenceOf`, `toolReferenceShape`, `writtenOf`, `namesEveryTool`, `runBoundMs` and `runIdKey`). It loads no transport code, so the reasoning function adapter, which parses a function's `tools` and bounds its run, does not load the MCP client wherever it is bundled; it takes `ToolAccess` as a type only. - `@on.auto/tool-servers/testing`: the fake server and the helpers of the tests (see Testing). @@ -100,7 +100,7 @@ Five operations at the playbook's scope, under `/tool-servers`, reach the server `defineSearchInOrg(access, lookUpPlaybook, offering?)` makes the same name at the org's scope, `GET /tool-servers/search` relative to the org, an org query permitted by `org:read` or `playbook:read`, as `list_playbooks` is, since a key that may read a playbook may see what serves it. Its input adds an optional `playbook`: without it, it searches every server that serves the org, by naming it in `org` or by `*`, each with `playbooks`, the playbooks it serves, as the listing gives them, `['*']` for an entry that leaves them out; with it, the servers that serve that playbook, as the playbook's own search has them, each with `playbooks` too, and the words of the playbook's search. A key limited to some playbooks is shown in `playbooks` only the playbooks it may access, and `['*']` as it is; the dispatcher refuses it the search of the whole org, since an org operation that takes `playbook` checks the caller's access to the playbook it is given, and none is given, with the detail `The caller may access only some playbooks of this org; name one of them in playbook`. Given a playbook, it first looks it up with `lookUpPlaybook`, a `PlaybookLookup` that needs `OrgReader` and fails with `NotFound`, since the org's playbooks are the registry's and the dispatcher looks up no playbook of an org operation: the server gives `foundPlaybook` of `@on.auto/playbooks`, so a playbook the org does not have is `not_found` in the words of the playbook's own route, and no server is asked. A name no server of the org, or of the playbook, has is `invalid_input` at `/server`, whose words name the org or the playbook. Over MCP, `/mcp` offers this operation in place of the playbook's, under the same name, its `playbook` its own and optional (see the api README's [playbook argument](../api/README.md#over-mcp)), the org endpoint offers it too, and a playbook endpoint offers the playbook's. -The org's search also answers `providers`, the providers offered to the caller that no server of the org serves yet, each `{ name, title, description }` (`src/search/found-providers.ts`). `offering`, a `ProviderOffering`, answers for a caller the providers it may connect, or nothing where it may not add a connection, and offers none where it is left out. The server gives the search `makeProviderOffering` of `@on.auto/connections`, whose README says which providers it offers, and to whom. Whatever `playbook` narrows the servers to, a provider is served, and so left out, when a server among the `settings` that `listServersInOrg` reads names it as its `provider`. A connection made from it does, pending, connected or broken, and so does an entry of the file that names it. Without `terms` they are every such provider by name while there are at most 100, and none when there are more; with `terms`, those `scoreOf` scores, the words of a provider's name and then of its title counting as a name's and its description as a description, best first and then by name, at most 100. `providers` is present, and empty when every provider offered is served, exactly where the offering answers the caller and `server` is not given, and absent otherwise, so a caller who may not add a connection is answered no `providers`; the playbook's search never answers it. +The org's search also answers `providers`, the providers offered to the caller that no server of the org serves yet, each `{ name, title, description }` (`src/search/found-providers.ts`). `offering`, a `ProviderOffering`, answers for a caller, as an effect, since judging a way of consent may first list the clients of Auto's consent service, the providers it may connect, or nothing where it may not add a connection, and offers none where it is left out. The server gives the search `makeProviderOffering` of `@on.auto/connections`, whose README says which providers it offers, and to whom. Whatever `playbook` narrows the servers to, a provider is served, and so left out, when a server among the `settings` that `listServersInOrg` reads names it as its `provider`. A connection made from it does, pending, connected or broken, and so does an entry of the file that names it. Without `terms` they are every such provider by name while there are at most 100, and none when there are more; with `terms`, those `scoreOf` scores, the words of a provider's name and then of its title counting as a name's and its description as a description, best first and then by name, at most 100. `providers` is present, and empty when every provider offered is served, exactly where the offering answers the caller and `server` is not given, and absent otherwise, so a caller who may not add a connection is answered no `providers`; the playbook's search never answers it. `pnpm --filter @on.auto/tool-servers measure:search` (`measure/search-over-a-document.ts`) searches an HTTP API of 500 operations, each with a summary and two query parameters, 500 times after 50 to warm up, through the dispatcher as a caller would. Measured on 2026-10-13 on a 16-core machine, Node 26.10.0, at a load average of 6 to 23 from other work on the machine, twice: with two terms that match every operation, so 100 tools are scored, sorted and answered, a median of 2.91 and 2.93 ms and a p95 of 3.16 and 3.20 ms; without terms, the server alone, 1.69 and 1.68 ms and 2.03 ms. @@ -174,13 +174,23 @@ With an `auth` block, tokens come from the MCP client's client-credentials or pr ## The runtime as a client -`makeConsentClient({ fetch, registeredClients, publicUrl, registrations, requestMs?, requestTimeout?, renewalSpreadMs? })` (`src/consent/consent-client.ts`) is the runtime acting as a client at an authorization server, for the consent a person gives there to a connection made from a provider's way `authorization`. The server makes it with its outbound fetch, the clients whoever runs the runtime registered and `PUBLIC_URL`, and gives it to `@on.auto/connections`, whose page operations start and finish a consent through it. The server makes one, in `packages/server/src/composition/served-consent.ts`, and gives that one to `@on.auto/connections` and to its tool access, `consent` of `makeToolAccess`, since the single flight of its registrations lives in it. Nothing of it reaches the network when it is made: its answers load `src/consent/consent-requests.ts`, and with it `src/consent-tokens/token-requests.ts` and the MCP client's discovery, `startAuthorization`, `exchangeAuthorization` and `refreshAuthorization`, through a dynamic import when a consent first starts or finishes, a connection's tokens are first served or a removal first revokes, and the file that makes it imports the library's types alone. It never goes through the library's `auth()`, whose fallbacks would start a new authorization from a background renewal. The return address it is given with a start, a finish and the tokens of a connection is the caller's, which `@on.auto/connections` makes the page `connect-return` of the Studio the person reaches, under the runtime's own `/studio/` where it serves Studio and under the Studio host otherwise; a runtime has one return address at a time, the one its connect link opens, and a registered client is registered with both only where it serves several runtimes, some serving Studio and some not. It answers: +`makeConsentClient({ fetch, registeredClients, publicUrl, registrations, autoClients?, requestMs?, requestTimeout?, renewalSpreadMs? })` (`src/consent/consent-client.ts`) is the runtime acting as a client at an authorization server, for the consent a person gives there to a connection made from a provider's way `authorization`. The server makes it with its outbound fetch, the clients whoever runs the runtime registered, `PUBLIC_URL` and `autoClients` of `packages/server/src/consent/consent-settings.ts`, `AUTO_CLIENTS` and `CONSENT_SERVICE_URL` with the return a Studio base asks, `loopback` for a base on `localhost`, `127.0.0.1` or `[::1]`, `hosted` for the Studio host's own, `elsewhere` otherwise, and gives it to `@on.auto/connections`, whose page operations start and finish a consent through it. The server makes one, in `packages/server/src/composition/served-consent.ts`, and gives that one to `@on.auto/connections` and to its tool access, `consent` of `makeToolAccess`, since the single flight of its registrations lives in it. Nothing of it reaches the network when it is made: its answers load `src/consent-through-auto/consents-by-source.ts`, which asks the authorization server or Auto's consent service by the client's source, and with it `src/consent/consent-requests.ts`, `src/consent-tokens/token-requests.ts` and the MCP client's discovery, `startAuthorization`, `exchangeAuthorization` and `refreshAuthorization`, through a dynamic import when a consent first starts or finishes, a connection's tokens are first served or a removal first revokes, and the file that makes it imports the library's types alone. It never goes through the library's `auth()`, whose fallbacks would start a new authorization from a background renewal. The return address it is given with a start, a finish and the tokens of a connection is the caller's, which `@on.auto/connections` makes the page `connect-return` of the Studio the person reaches, under the runtime's own `/studio/` where it serves Studio and under the Studio host otherwise; a runtime has one return address at a time, the one its connect link opens, and a registered client is registered with both only where it serves several runtimes, some serving Studio and some not. It answers: -- `sourcesOf(way)`: the client sources the way lists in `clients`, `['registered']` where it lists none, that the runtime has, in the runtime's order, `registered`, `metadata_document` then `dynamic` as `clientSources` lists them, whatever order the way gives, asking nothing (`src/consent/client-sources.ts`): `registered` where a registered client names the way's issuer, the same issuer a trailing slash aside, as the MCP client compares issuers (`isSameIssuer`), or names it as a template, `https://login.example.net/{tenant}/v2.0`, that the issuer is with a value other than `/`, `?` and `#` in each placeholder; `metadata_document` where `PUBLIC_URL` is https, its address `{PUBLIC_URL}/oauth/client.json`; and `dynamic` always. The document at that address is `clientMetadataDocumentOf(publicUrl, returnAddress)`, `{ address, document }`, or nothing where `PUBLIC_URL` is not https: the metadata the runtime registers its client with, `client_name` `Auto Runtime`, `redirect_uris` the return address, `grant_types` `authorization_code` and `refresh_token`, `response_types` `code` and `application_type` `web`, with `client_id` its own address and `token_endpoint_auth_method` `none`, as a Client ID Metadata Document is; the path is written once, in `src/consent/client-sources.ts`, and the server makes the document from the `PUBLIC_URL` it signs people in at, which it makes this client with, and the return address it makes the connections with, and gives it `@on.auto/api`, which answers it at the path of its address; a runtime in local mode, whatever its `PUBLIC_URL`, signs nobody in and serves none. `@on.auto/connections` judges with it whether a provider's way is offered and whether an addition takes it. -- `start({ server, way, returnAddress, reaches, state })`: the authorization request a person is sent to, `{ url, verifier, client }`. It reads the server's metadata as the way says (`src/consent/server-metadata.ts`): for an `mcp` server its protected-resource metadata, through `discoverOAuthProtectedResourceMetadata`, where the server publishes it, whose `authorization_servers` must name the way's issuer, else `another_issuer` by the `server`, and whose `resource` must be the server's own address or one above it on its path, as the library's `checkResourceAllowed` compares them, else `another_resource`, and is the `resource` the request sends; where the server answers 404 there is none, and the request sends no `resource`. Then the authorization server's metadata: built from the way's `authorization_endpoint`, `token_endpoint` and `revocation_endpoint` where it gives them, with `response_types_supported: [code]` and `code_challenge_methods_supported: [S256]` and nothing read, else discovered at the issuer by `discoverAuthorizationServerMetadata`, whose check that the metadata names the issuer it was asked at refuses another as `another_issuer` by the `authorization_server` and whose finding nothing is `no_metadata`. Metadata that takes no `code` or lists no `S256` method is `no_challenge`, and an endpoint it names, the authorization, token, revocation and registration endpoints, whose host `reaches` refuses is `host` with the rule's words, before anything is chosen. The client is the first source of `sourcesOf` the server takes (`src/consent/acting-clients.ts`): the registered client at the issuer, with its secret, its key, whose assertion `createPrivateKeyJwtAuth` signs, or neither; the metadata document, its client id and return address those of the document, where the metadata says `client_id_metadata_document_supported`; or a registration made at the metadata's `registration_endpoint` (`src/consent/dynamic-registration.ts`), a public request of the runtime's own since the library's `registerClient` is deprecated, which sends the metadata the document holds but its client id and its method, whose answer counts only where it is a success that names a client id `ClientIdSchema` takes, else `unanswered`, kept through `registrations` and taken again for every later start at the issuer, `registrations.latestAt`, until the `client_secret_expires_at` it gave passes. The read of the latest and the registration are one flight for each issuer within the process, so a second start at the same issuer waits for the first's, and two starts never register twice. None of them is `no_client`. `startAuthorization` makes the verifier and the challenge and writes the client id, the return address as `redirect_uri`, `state`, the scope asked and the `resource`; the scope asked is the way's `scope`, with `offline_access` added where the metadata was discovered, its `scopes_supported` lists it and the scope lacks it, as the library's `auth()` adds it, and never where the way gives its endpoints or no scope; the way's `parameters` are set after, so its `prompt` replaces the one the library appends for `offline_access`. `verifier` is a `Redacted`, and `client` is `{ client_id, client_source }`, which the connection records. -- `finish({ server, way, returnAddress, reaches, client, code, iss, verifier })`: reads the metadata again as `start` does, takes the client the start recorded and no other, the registered client at the issuer with that client id, the metadata document while `PUBLIC_URL` names it, or the registration `registrations.named` finds by issuer and client id, else `client_gone`, and redeems the code with `exchangeAuthorization`, with the verifier, the return address and the `resource`. The library compares `iss` with the issuer of the metadata, and refuses one that differs, or none where the metadata says the server sends it, before the code is sent, as `iss`; a code the token endpoint refuses as `invalid_grant` is `code`. It answers `{ tokens, scope, revoked }`: `tokens`, the one value the connection keeps, `{ access_token, refresh_token?, expires_at?, scope?, token_type }` as JSON in a `Redacted`, `expires_at` the time `expires_in` ends on Effect's `Clock`, left out where that time is past what a date holds; `scope`, the scope the server granted, split on white space and on commas, which GitHub and Slack write between scopes, and joined by single spaces (`src/consent/scopes.ts`), or the scope asked where it says none, as RFC 6749 has it, where a grant of no scope is `no_scope` when a scope was asked and no scope when none was, and a grant that is not scopes as RFC 6749 writes them, `isScope`, is `malformed_scope`; and `revoked`, an effect that revokes the refresh token, or the access token where there is none, at the metadata's revocation endpoint with the same client (`src/consent/revocation.ts`): an assertion for one with a key, and otherwise the method the library's `selectClientAuthMethod` chooses from the metadata's `revocation_endpoint_auth_methods_supported`, or its `token_endpoint_auth_methods_supported` where it lists none, HTTP Basic, the secret in the form or the client id alone; it does nothing where the metadata names no revocation endpoint. `revoked` never fails: a revocation that fails leaves the token to end on its own. It is made as soon as the code is redeemed, and every step after the exchange runs under it, so a refusal, a defect or an interruption there revokes the tokens before `finish` answers. -- `tokenSource(served, serving)`: the token source of a connection connected by consent (`src/consent-tokens/renewed-tokens.ts`), which `tokensOf` asks for an `auth` of the kind `consent`. `served` is that credential: the way, the server, the return address, `reaches`, the client the connection recorded, `held`, the row of its tokens as the source read it, decoded, its `tokens`, its `version` and its claim, which a store decodes from the value with `keptTokensIn`, so the source never reads a value it cannot serve and holds its tokens among the secrets as it is made, and `tokens`, the store of that row, `ConnectionTokens`, with `read`, `replace(version, value)`, `claim(version, lastingMs)` and `broken(ended)`, which `@on.auto/connections` implements over the secret store. The value is the JSON `finish` keeps, `{ access_token, refresh_token?, expires_at?, scope?, token_type }` (`src/consent-tokens/kept-tokens.ts`). Made, it holds both tokens among the secrets and serves the access token until its renewal time, `renewalTimeOf` from the time it read them to `expires_at`, less the spread of the process, `renewalSpreadMs` of the options, up to five seconds drawn once when the client is made and never more than half the lead, so processes sharing a ledger do not all renew in the same second and a token of a short life is never renewed at every use; a token without an expiry, and one without a refresh token, is served until the server answers 401. At the renewal time, or on a 401, it reads the row again: where its version moved, another process renewed, and it takes the value instead, renewing it in turn only where that value is itself past its renewal time; where another process holds a claim of the row, it waits on its timer for the claim to end and reads again, and gives up as `requestMs` ends, `The tokens of the connection drive are being renewed by another process of the runtime, which did not finish within 30000 ms; the next call tries again`; else it reads the metadata as `start` does, takes the client the connection recorded as `finish` does, claims the row for the lease, the bound of the request it then sends, `requestMs`, and fifteen seconds more for the write of the claim, the write of the replacement and the gap between the clocks of the servers, forty-five seconds where the bound is the default thirty (`leaseOf` of `src/consent-tokens/token-requests.ts`, the one rule of it), so the refresh is cut off before the claim ends and a second claim never sends the same refresh token while the first is in flight or being kept, refreshes with `refreshAuthorization`, the client and the `resource` `finish` sends, keeping the refresh token where the server sends none, as the library does, and replaces the value under the version it read, which ends the claim. Within a process one renewal is in flight for a connection, whatever asks for it. A replacement that matches no row reads the row again: where it is gone, as when the connection was removed meanwhile, the source revokes what it got and fails in the words the row is read with; where its version moved, it takes the value the row holds and revokes nothing, since for a server that does not rotate its refresh tokens the value it got carries the refresh token the row's value lives by. One the store refuses revokes what it got and fails in the store's words. A refresh refused as `invalid_grant` reads the row again: where it still holds the value refused, the source breaks the connection, `broken('consent_ended')`, and where it moved, takes the new value; a 401 with no refresh token breaks it so too, and a client no longer registered breaks it with `broken('client_gone')`, before anything is sent. Any other failure fails the call in its own words, scrubbed as every failure is, and leaves the connection connected: a host the rules refuse in the rule's words, as `HostRefused`, a server that does not answer, `The authorization server of the connection drive did not answer as an authorization server does, so its tokens were not renewed; the next call tries again`, metadata that no longer takes the connection, `The authorization server of the connection drive no longer answers as it did when the connection was made, so its tokens cannot be renewed`, and a refresh the server refuses otherwise in the words of the library. A source remembers the claim of a refresh of its own that failed, refused by the server otherwise or by the store: until that claim ends, it serves the access token it holds while it is before its `expires_at`, and otherwise fails at once in the words of that failure, a 401 among the otherwise, and never waits on its own claim nor says another process holds it; once the claim ends, the next renewal claims the row again. A second 401 after a renewal is a failure, as for client credentials. -- `revoke(asked)`: revokes the tokens of a connection, `asked.tokens` the value of its row, with the client it recorded, through the code `finish`'s `revoked` takes (`src/consent/revocation.ts`), at the revocation endpoint the metadata or the way names, and nothing where neither names one. It fails where the revocation did not happen, with the refusal of the metadata or of the client, `client_gone` among them, `not_revoked` with the `status` the endpoint answered, or, for a request that failed, `host` with the rule or `unanswered`, as `refusalOf` maps a failed request of a start or a finish, so a refusal carries no word of the server's and no token, which `@on.auto/connections` logs in words of its own as `remove_connection` goes on to forget the row. +- `hadSources(studioBase)`: an effect that answers, for the Studio base a link opens, `sourcesOf(way)`, the client sources the way lists in `clients`, `['registered']` where it lists none, that the runtime has, in the runtime's order, `registered`, `auto`, `metadata_document` then `dynamic` as `clientSources` lists them, whatever order the way gives (`src/consent/client-sources.ts`), and `sourceOf(way)`, the first of them, `sourceTakenOf`, the one rule by which a start takes its source and by which `@on.auto/connections` says on a link which source a start would take, the start alone passing on from a metadata document to the next source once the server's metadata says it takes none; it asks nothing but the clients of Auto's consent service, and those only where the client was made with that source on and the base is one the service sends people back to, `auto` being left out of the sources had otherwise (see [Consent through Auto's consent service](#consent-through-autos-consent-service)). A way lists the three sources of `wayClientSources`, never `auto`, which a way that takes `registered` takes as well, since it is a client Auto registered: `registered` where a registered client names the way's issuer, the same issuer a trailing slash aside, as the MCP client compares issuers (`isSameIssuer`), or names it as a template, `https://login.example.net/{tenant}/v2.0`, that the issuer is with a value other than `/`, `?` and `#` in each placeholder; `metadata_document` where `PUBLIC_URL` is https, its address `{PUBLIC_URL}/oauth/client.json`; and `dynamic` always. The document at that address is `clientMetadataDocumentOf(publicUrl, returnAddress)`, `{ address, document }`, or nothing where `PUBLIC_URL` is not https: the metadata the runtime registers its client with, `client_name` `Auto Runtime`, `redirect_uris` the return address, `grant_types` `authorization_code` and `refresh_token`, `response_types` `code` and `application_type` `web`, with `client_id` its own address and `token_endpoint_auth_method` `none`, as a Client ID Metadata Document is; the path is written once, in `src/consent/client-sources.ts`, and the server makes the document from the `PUBLIC_URL` it signs people in at, which it makes this client with, and the return address it makes the connections with, and gives it `@on.auto/api`, which answers it at the path of its address; a runtime in local mode, whatever its `PUBLIC_URL`, signs nobody in and serves none. `@on.auto/connections` judges with it whether a provider's way is offered and whether an addition takes it. +- `start({ server, way, returnAddress, reaches, state, studioBase })`: the authorization request a person is sent to, `{ url, state, verifier, client }`, `state` the one it was given, for every source but `auto`, whose start the next section sets out. It reads the server's metadata as the way says (`src/consent/server-metadata.ts`): for an `mcp` server its protected-resource metadata, through `discoverOAuthProtectedResourceMetadata`, where the server publishes it, whose `authorization_servers` must name the way's issuer, else `another_issuer` by the `server`, and whose `resource` must be the server's own address or one above it on its path, as the library's `checkResourceAllowed` compares them, else `another_resource`, and is the `resource` the request sends; where the server answers 404 there is none, and the request sends no `resource`. Then the authorization server's metadata: built from the way's `authorization_endpoint`, `token_endpoint` and `revocation_endpoint` where it gives them, with `response_types_supported: [code]` and `code_challenge_methods_supported: [S256]` and nothing read, else discovered at the issuer by `discoverAuthorizationServerMetadata`, whose check that the metadata names the issuer it was asked at refuses another as `another_issuer` by the `authorization_server` and whose finding nothing is `no_metadata`. Metadata that takes no `code` or lists no `S256` method is `no_challenge`, and an endpoint it names, the authorization, token, revocation and registration endpoints, whose host `reaches` refuses is `host` with the rule's words, before anything is chosen. The client is the first source of `sourcesOf` the server takes (`src/consent/acting-clients.ts`): the registered client at the issuer, with its secret, its key, whose assertion `createPrivateKeyJwtAuth` signs, or neither; the metadata document, its client id and return address those of the document, where the metadata says `client_id_metadata_document_supported`; or a registration made at the metadata's `registration_endpoint` (`src/consent/dynamic-registration.ts`), a public request of the runtime's own since the library's `registerClient` is deprecated, which sends the metadata the document holds but its client id and its method, whose answer counts only where it is a success that names a client id `ClientIdSchema` takes, else `unanswered`, kept through `registrations` and taken again for every later start at the issuer, `registrations.latestAt`, until the `client_secret_expires_at` it gave passes. The read of the latest and the registration are one flight for each issuer within the process, so a second start at the same issuer waits for the first's, and two starts never register twice. None of them is `no_client`. `startAuthorization` makes the verifier and the challenge and writes the client id, the return address as `redirect_uri`, `state`, the scope asked and the `resource`; the scope asked is the way's `scope`, with `offline_access` added where the metadata was discovered, its `scopes_supported` lists it and the scope lacks it, as the library's `auth()` adds it, and never where the way gives its endpoints or no scope; the way's `parameters` are set after, so its `prompt` replaces the one the library appends for `offline_access`. `verifier` is a `Redacted`, and `client` is `{ client_id, client_source }`, which the connection records. +- `finish({ server, way, returnAddress, reaches, client, code, iss, verifier })`, or `finish({ …, client, handle, verifier })` for a start of `auto` (see the next section): reads the metadata again as `start` does, takes the client the start recorded and no other, the registered client at the issuer with that client id, the metadata document while `PUBLIC_URL` names it, or the registration `registrations.named` finds by issuer and client id, else `client_gone`, and redeems the code with `exchangeAuthorization`, with the verifier, the return address and the `resource`. The library compares `iss` with the issuer of the metadata, and refuses one that differs, or none where the metadata says the server sends it, before the code is sent, as `iss`; a code the token endpoint refuses as `invalid_grant` is `code`. It answers `{ tokens, scope, revoked, client }`: `client`, the client it redeemed with, the one the start recorded, or for `auto` the one the handoff names, which the connection records; `tokens`, the one value the connection keeps, `{ access_token, refresh_token?, expires_at?, scope?, token_type }` as JSON in a `Redacted`, `expires_at` the time `expires_in` ends on Effect's `Clock`, left out where that time is past what a date holds; `scope`, the scope the server granted, split on white space and on commas, which GitHub and Slack write between scopes, and joined by single spaces (`src/consent/scopes.ts`), or the scope asked where it says none, as RFC 6749 has it, where a grant of no scope is `no_scope` when a scope was asked and no scope when none was, and a grant that is not scopes as RFC 6749 writes them, `isScope`, is `malformed_scope`; and `revoked`, an effect that revokes the refresh token, or the access token where there is none, at the metadata's revocation endpoint with the same client (`src/consent/revocation.ts`): an assertion for one with a key, and otherwise the method the library's `selectClientAuthMethod` chooses from the metadata's `revocation_endpoint_auth_methods_supported`, or its `token_endpoint_auth_methods_supported` where it lists none, HTTP Basic, the secret in the form or the client id alone; it does nothing where the metadata names no revocation endpoint. `revoked` never fails: a revocation that fails leaves the token to end on its own. It is made as soon as the code is redeemed, and every step after the exchange runs under it, so a refusal, a defect or an interruption there revokes the tokens before `finish` answers. +- `tokenSource(served, serving)`: the token source of a connection connected by consent (`src/consent-tokens/renewed-tokens.ts`), which `tokensOf` asks for an `auth` of the kind `consent`. `served` is that credential: the way, the server, the return address, `reaches`, the client the connection recorded, `held`, the row of its tokens as the source read it, decoded, its `tokens`, its `version` and its claim, which a store decodes from the value with `keptTokensIn`, so the source never reads a value it cannot serve and holds its tokens among the secrets as it is made, and `tokens`, the store of that row, `ConnectionTokens`, with `read`, `replace(version, value)`, `claim(version, lastingMs)` and `broken(ended)`, which `@on.auto/connections` implements over the secret store. The value is the JSON `finish` keeps, `{ access_token, refresh_token?, expires_at?, scope?, token_type, connection_key? }`, the key alone of a consent through Auto's consent service (`src/consent-tokens/kept-tokens.ts`). Made, it holds both tokens among the secrets and serves the access token until its renewal time, `renewalTimeOf` from the time it read them to `expires_at`, less the spread of the process, `renewalSpreadMs` of the options, up to five seconds drawn once when the client is made and never more than half the lead, so processes sharing a ledger do not all renew in the same second and a token of a short life is never renewed at every use; a token without an expiry, and one without a refresh token, is served until the server answers 401. At the renewal time, or on a 401, it reads the row again: where its version moved, another process renewed, and it takes the value instead, renewing it in turn only where that value is itself past its renewal time; where another process holds a claim of the row, it waits on its timer for the claim to end and reads again, and gives up as `requestMs` ends, `The tokens of the connection drive are being renewed by another process of the runtime, which did not finish within 30000 ms; the next call tries again`; else it reads the metadata as `start` does, takes the client the connection recorded as `finish` does, claims the row for the lease, the bound of the request it then sends, `requestMs`, and fifteen seconds more for the write of the claim, the write of the replacement and the gap between the clocks of the servers, forty-five seconds where the bound is the default thirty (`leaseOf` of `src/consent-tokens/token-requests.ts`, the one rule of it), so the refresh is cut off before the claim ends and a second claim never sends the same refresh token while the first is in flight or being kept, refreshes with `refreshAuthorization`, the client and the `resource` `finish` sends, keeping the refresh token where the server sends none, as the library does, and replaces the value under the version it read, which ends the claim. Within a process one renewal is in flight for a connection, whatever asks for it. A replacement that matches no row reads the row again: where it is gone, as when the connection was removed meanwhile, the source revokes what it got and fails in the words the row is read with; where its version moved, it takes the value the row holds and revokes nothing, since for a server that does not rotate its refresh tokens the value it got carries the refresh token the row's value lives by. One the store refuses revokes what it got and fails in the store's words. A refresh refused as `invalid_grant` reads the row again: where it still holds the value refused, the source breaks the connection, `broken('consent_ended')`, and where it moved, takes the new value; a 401 with no refresh token breaks it so too, and a client no longer registered breaks it with `broken('client_gone')`, before anything is sent. Any other failure fails the call in its own words, scrubbed as every failure is, and leaves the connection connected: a host the rules refuse in the rule's words, as `HostRefused`, a server that does not answer, `The authorization server of the connection drive did not answer as an authorization server does, so its tokens were not renewed; the next call tries again`, metadata that no longer takes the connection, `The authorization server of the connection drive no longer answers as it did when the connection was made, so its tokens cannot be renewed`, and a refresh the server refuses otherwise in the words of the library. A source remembers the claim of a refresh of its own that failed, refused by the server otherwise or by the store: until that claim ends, it serves the access token it holds while it is before its `expires_at`, and otherwise fails at once in the words of that failure, a 401 among the otherwise, and never waits on its own claim nor says another process holds it; once the claim ends, the next renewal claims the row again. A second 401 after a renewal is a failure, as for client credentials. +- `revoke(asked)`: revokes the tokens of a connection, `asked.tokens` the value of its row, with the client it recorded, through the code `finish`'s `revoked` takes (`src/consent/revocation.ts`), at the revocation endpoint the metadata or the way names, and nothing where neither names one. It fails where the revocation did not happen, with the refusal of the metadata or of the client, `client_gone` among them, `not_revoked` with the `status` the endpoint answered, `unknown_key` for a connection of `auto` whose key Auto's consent service does not know, or, for a request that failed, `host` with the rule or `unanswered`, as `refusalOf` maps a failed request of a start or a finish, so a refusal carries no word of the server's and no token, which `@on.auto/connections` logs in words of its own as `remove_connection` goes on to forget the row. + +### Consent through Auto's consent service + +`auto` is the client Auto registered at a vendor, used through Auto's consent service, which starts the consent with that client, redeems the code with the secret Auto alone holds, hands the tokens to the runtime that started it, and refreshes and revokes them for it. `autoClients` of the options, `{ enabled, service: { url }, returnKindOf }`, turns the source on, names the service and judges the Studio base a link opens: `hosted` where it is the Studio host's, `loopback` where its host is on the person's own computer, `elsewhere` otherwise; left out, or `enabled: false`, the client never asks the service anything (`src/consent-through-auto/auto-clients.ts`). `src/consent-through-auto/consent-service.ts` is the runtime's side of the service's contract, the seven calls as typed functions over the client's fetch: listing the clients (`GET /clients`), starting (`POST /consents`), describing a state and redeeming a code, which the hosted Studio makes with a person's session, taking the handoff (`POST /handoffs/{handle}`), refreshing (`POST /tokens/refresh`) and revoking (`POST /tokens/revoke`), each answer decoded with `Schema`, a refusal read by name from the problem document's `refused` at the status the contract gives it: `400` `code_refused`, a `404` handoff, unknown, taken or ended, and a `403` `wrong_verifier`, as the refusal of a code is answered, `code`; `400` `invalid_grant` as a refused refresh, and `403` `unknown_key` as a key the service does not know, of a refresh and of a revocation; `502` `not_revoked` with the status the authorization server answered, `vendor_status`, as a revocation that did not happen, `not_revoked` with that status; the start's `400`, Auto having no client at the issuer or not sending people back to the base, as `start_refused`; and a service that does not answer within the bound, answers a `4xx` or `5xx` without a refusal of its own, a refusal at another status, or what the contract does not hold, as `unanswered`. Every call is bounded as a request to an authorization server is, `requestMs`, follows no redirect, and reaches the service whatever `reaches` says, as the identity issuer is reached. + +- The clients the service lists are asked for when `hadSources` or a start first needs them, within five seconds or `requestMs` where that is shorter, then again once an hour has passed on the effect's clock, one listing in flight at a time; a service that does not answer leaves the list empty for five minutes, after which the next need asks again, and the client logs once at WARN that no provider takes consent through Auto's clients for five minutes. A client the service lists at the way's issuer, or at a template the issuer is, as for a registered client, is the source `auto`. +- A Studio base `elsewhere` leaves `auto` out of the sources had, so a later source the way lists serves the start and the service is asked nothing; where none remains and `auto` would have been had, which only then asks for the service's clients, the start is refused as `studio_elsewhere`, before anything else is sent. A start whose source is `auto` reads the server's metadata as every start does, makes a handoff verifier of 32 random bytes and its S256 challenge, and asks the service to start with the issuer, the scope it would ask, the way's parameters, the resource the server names, the return, `{ kind, base }`, `hosted` or `loopback` and the base exactly as the link opens it, and the challenge. It answers the service's page and state, the verifier and `{ client_id: the one listed, client_source: 'auto' }`. +- A finish with a handle reads the metadata again, refuses a client whose source is not `auto` or a client made with the source off as `client_gone`, takes the handoff with the verifier, checks the scope granted as every finish does, and answers the tokens with `connection_key` inside the one sealed value and the client the handoff names, `{ client_id, client_source: 'auto' }`; its `revoked`, and every refusal after the handoff, revoke through the service. +- The renewal of a connection of `auto` reads the metadata for its resource, refreshes through the service with the refresh token, the connection key and the resource, under the same claim, lease and rules of `src/consent-tokens/renewed-tokens.ts`, keeps the refresh token and the key where the answer carries none, and breaks the connection on two refusals alone, where the row still holds the value refused: `invalid_grant`, in today's words, `broken('consent_ended')`, and `unknown_key`, as a key the service does not know, `broken('key_unknown')`, which a row that holds no key ends in too, asking nothing; and as a client gone where the source is off; any other answer, and a service that does not answer, fails the call, `Auto's consent service did not answer for the connection drive, so its tokens were not renewed; the next call tries again`, and breaks nothing (`src/consent-through-auto/auto-renewals.ts`, which `src/consent-tokens/token-requests.ts` asks by the source). `revoke` of a connection of `auto` asks the service with both tokens and the key, and reads no metadata; a row with no key is `unknown_key` before anything is sent. +- The connection key is held among the secrets with the tokens, so it is scrubbed from what a call records, and no answer of the client holds it outside the sealed value. Every request of the client goes through `fetchWithin` with the `reaches` the caller gives, so it reaches no host the rules refuse, and is bounded as a call is, `requestMs`, 30 s, `toolBounds.callMs`, when left out, by the signal `requestTimeout` gives, `AbortSignal.timeout` when left out, which a test replaces to cut a request off itself. The library's requests follow a redirect that keeps their method and stays within the origin of the request, five at most, each hop through that fetch, as its `fetchWithinOrigin` does; the module's own requests, the registration and the revocation, follow none. A request that fails, is refused for its host or does not answer within its bound is `host` for a host the rules refuse and `unanswered` otherwise, words the caller writes from the refusal, `ConsentRefusal`, which carries no word of the server's. @@ -233,6 +243,7 @@ The arguments, as `JSON.stringify` writes them, and the protocol answer are scru - `serveFakeApi({ key?, client?, version?, requestIdHeader? })` serves a fake HTTP API on a loopback port, with its OpenAPI document, `fakeApiDocument('3.0' | '3.1', serverUrl)`, at `documentUrl` and its operations under `baseUrl`: `search`, `echo`, `get_profiles_id`, `updateProfile`, `removeProfile`, `rows`, `denied`, `photo` and `notes` (`fakeApiToolNames`), and three operations no tool is offered for, `sessionState`, which needs the header `X-Session` and is offered to an entry whose `headers` give it, answering the value it was sent, one that takes an object in its query and one whose body is XML. The 3.0 document writes `nullable` and a boolean `exclusiveMinimum` where the 3.1 one writes their 2020-12 forms, so the tools derived from either are the same. It takes a key in `X-Api-Key` or in the query as `api_key`, or with `client` a token from a fake authorization server on another loopback origin, `issuing.issuer`, whose metadata it publishes by OpenID Connect discovery alone with `openIdConnect: true`; `answerNextWith({ status, headers?, body? }, times?)` answers the next calls so, `seen()` holds every request but the document's, `heard()` the method, the path and the `User-Agent` of every request it and its authorization server heard, the document's among them, and `documentReads()` counts the reads of the document. `describedAccess(servers, options)` is `reportingAccess` for entries of any type, reading their documents first. - The fake authorization server both fakes take as `client`, a `ClientRegistration` (`src/authorization-testing`), grants client credentials to a client that is not public. Beside that it grants the authorization code with PKCE and `S256` at `/authorize`, sending the person back at once to one of the client's `redirectUris` with the code, the state and its issuer, or with the error of a consent it cannot grant, and answering a client or a redirect URI it does not know on its own page; it redeems a code once, for its client, its redirect URI and its verifier, for an access token and a refresh token, and refreshes them, keeping the refresh token unless `rotatesRefreshTokens` asks it to rotate one at each refresh, when a rotated token used again ends its whole grant. It revokes at `/revoke` an access token alone, or a refresh token with every token of its grant, for the client it was issued to. Its clients authenticate in one way for every grant and for its revocation endpoint, by a secret in the Basic header or the form, by an assertion its `publicKey` verifies, or, public, by their id alone (`fake-clients.ts`); with `clientMetadataDocuments` it takes a client by the metadata document its id addresses, and with `dynamicRegistration` it registers one dynamically at `/register`. Its metadata says each of these, and leaves out the two it does not offer. For a consent it also lists the challenge methods `challengeMethods` names in place of `S256`, grants `grantedScope` whatever scope it was asked, gives no refresh token with `issuesRefreshTokens: false`, publishes no metadata with `publishesMetadata: false`, answering 404 at its addresses, lists `scopesSupported` as its `scopes_supported`, takes a client secret in the form alone with `takesBasicSecrets: false` and lists no methods for its revocation endpoint with `listsRevocationMethods: false`, refuses a registration with `refusesRegistration`, answers a registration with the client id `registeredClientId` and ends its secret `registrationSecretSeconds` from now, names `protectedResource` as the resource it protects in place of its own address, lists `protectedScopes` as the `scopes_supported` of its protected-resource metadata, and names its endpoints on `endpointHost` in place of its own host. Its tests drive every branch through `fetch` (`fake-grants.test.ts`, `fake-clients.test.ts`), and the tests of the consent client the options a consent takes. - `src/consent-testing` holds what the tests of a consent share: `servedForConsent`, a fake MCP server whose authorization server knows the client `auto-runtime` with a secret and the return address `returnAddress`; `registeredAt(issuer)`, that client as registered there; `consentClientWith(options)`, a consent client over the global `fetch`, with no registered client, no public address and `memoryRegistrations()`, registrations kept in memory, unless the options say otherwise; `startAskedAt(fake)`, the start of a consent at the fake; `consentedAt(url)`, the code, the state and the issuer the fake sends the person back with; `finishAskedOf(client, asked)`, the finish of the consent a client starts; and `tokensKeptBy(finished)`, the tokens a finish answers, decoded. For the life of the tokens it holds `memoryTokens(value, { now, mostCharacters? })`, a store of a connection's tokens, `ConnectionTokens`, over one value of `valuesInTheClear` of `@on.auto/secrets/testing`, values held in the clear over the rows kept in memory under the rule of the secret store's lease, which `@on.auto/connections` keeps its harness's secrets in too, whose claims are judged by `now`, which counts the leases it is asked to claim, `claims()`, and the breaks, `ends()`, refuses a replacement past `mostCharacters`, dies on a first value past it, and `forget`s its row as a removal does, failing in `goneWords` and `brokenWords(ended)`; `servedAt(fake, client, tokens, way?)`, the credential a source composes over such a store; `connectedByConsent(fake, way?, options?)`, a consent the fake finished under Effect's test clock, and `rowOf`, `servedOf` and `servedTokensOf(served, now, consent, secrets?)`, the row, the credential and the token source over them; `clockAt(moment)`, a clock a test sets; `manualTimer()`, a timer that records each wait and fires when the test says; `consentClientAt(fake, options?)`, a consent client with the fake's registered client and no spread; `hostRefusedAt(path, rule)`, a fetch that refuses the requests to a path as the rules refuse a host; and `gatedRequests(path?)`, a fetch that holds the requests to a path, the token endpoint unless the test names another, until the test releases them, so a test drives two processes over one row and never waits on a clock. +- `serveFakeConsentService({ vendor, now })` (`src/consent-testing/fake-consent-service.ts`, its records and its calls of the vendor in `fake-consent-store.ts`) serves Auto's consent service on a loopback port over the fake authorization server of `vendor`, as the contract the runtime calls holds it: it lists its one client, `auto-runtime`, at the vendor's issuer; starts a consent for a return `hosted` on its own Studio base, `studioBase`, or on a loopback base, `http`, ending in `/`, with a state, a PKCE pair and later a handle and a connection key of 32 random bytes each, refusing another issuer or base with a `400`; describes a state and redeems its code at the vendor with the client's secret for a person signed in, once, answering a call without a session `401` with `WWW-Authenticate: Bearer`; hands the tokens off once, within ten minutes of the redeem, to the verifier the start was challenged with, refusing another as `wrong_verifier`; refreshes and revokes at the vendor for a connection key it handed off, refusing another key as `unknown_key` and a body it does not take with a `400`; answers `code_refused` and `invalid_grant` only where the vendor answered `400` with that error, any other failure of the vendor's as a `502` without a refusal, and a revocation the vendor did not make as `502` `not_revoked` with the vendor's status as `vendor_status`; ends a start and a handoff ten minutes on by `now`; records every call, `calls()`, with its body and its `Authorization`; and is told to refuse the next start, redeem, refresh or revocation, `refuseNext`, or to answer nothing more, `stall`. `src/consent-testing/auto-consents.ts` holds the steps a test takes: `servedThroughAuto(changes?)`, a vendor and the service over it on one test clock; `autoClientsOf(service, kind?)`, the source on with the return kind given; `consentClientThrough(serving, options?)`, a client with the source on; `handleOf(service, url)`, the consent at the vendor and the redeem the hosted Studio makes; `handedOffAskedOf(client, asked, service)`, the finish of the start a client makes; `connectedThroughAuto(serving, way?)`, a connection the service handed off; and `serviceCallsAt(service)`, the runtime's calls of the contract at it. - `fakeStdioServerPath` runs the same tools over stdio under `node`, with `--chatter`, `--pad`, `--stdout`, `--linger-ms` and `--start-once` to make it talk, misbehave or fail to start again. Start it with `process.execPath` directly, never through a package manager: it imports only the MCP server package and the fake's tools, not `effect`, and answers its first message in about 90 ms on a laptop. A spawn is real time, so a test that starts it takes `stdioTestTimeoutMs`, 30 s, and its client `patientTiming`, and a file shares one process across the tests that only need it running. - Its tools answer text (`search`, `echo`, `graph.query.v2`), structured content (`profile`), non-text content (`photo`), a denial marked `isError` (`denied`), an error (`broken`), slowly (`sleep`), at length (`large`), the channels of a workspace (`list_channels`, `fakeChannels`), with its environment (`environment`), or by exiting (`exit`). Their annotations cover every case of a test: `search`, `profile`, `photo`, `denied`, `sleep`, `large`, `broken` and `list_channels` are marked read-only, `graph.query.v2` read-only and destructive at once, `exit` destructive, the long-named tool only additive (`destructiveHint: false`), `echo` and `verbose` have none, `environment` has annotations with a title and no hint, and `verbose` has a title of its own. - `openFakeToolRun`, `reportingAccess`, `noToolServers`, an access with no server, `recordingCallJournal`, `toolRun`, `controlledSignals` and `inTurn` open a run's tools against it, and `playbookToolCalls` dispatches the five operations over a memory ledger, optionally one that refuses every write, as `toolCaller`, a caller that may write the playbook, unless the call names another, such as `toolReader`, which may only read it. diff --git a/packages/tool-servers/src/bounds/call-bounds.ts b/packages/tool-servers/src/bounds/call-bounds.ts index 15287ab91..1945cec8b 100644 --- a/packages/tool-servers/src/bounds/call-bounds.ts +++ b/packages/tool-servers/src/bounds/call-bounds.ts @@ -30,6 +30,18 @@ export const defaultTiming: Timing = { longestRetryWaitMs: toolBounds.longestRetryWaitMs, }; +export interface RequestBounds { + readonly requestMs: number; + readonly requestTimeout: (ms: number) => AbortSignal; +} + +export function requestBoundsOf(given: Partial): RequestBounds { + return { + requestMs: given.requestMs ?? toolBounds.callMs, + requestTimeout: given.requestTimeout ?? ((ms) => AbortSignal.timeout(ms)), + }; +} + export interface CallTally { readonly calls: number; readonly failures: number; diff --git a/packages/tool-servers/src/consent-testing/auto-consents.ts b/packages/tool-servers/src/consent-testing/auto-consents.ts new file mode 100644 index 000000000..adc37befe --- /dev/null +++ b/packages/tool-servers/src/consent-testing/auto-consents.ts @@ -0,0 +1,109 @@ +import { Effect, Function, Redacted } from 'effect'; + +import type { ClientRegistration } from '../authorization-testing/fake-clients.ts'; +import type { AutoClients, ReturnKind } from '../consent-through-auto/auto-clients.ts'; +import { consentServiceOf, type ServiceStartAsked } from '../consent-through-auto/consent-service.ts'; +import type { + ConsentClient, + ConsentClientOptions, + ConsentWay, + HandleFinishAsked, + StartAsked, +} from '../consent/consent-client.ts'; +import type { FakeMcpServer } from '../mcp-testing/fake-mcp-server.ts'; +import { consentClientWith, servedForConsent, startAskedAt } from './consent-fakes.ts'; +import { serveFakeConsentService, type FakeConsentService } from './fake-consent-service.ts'; +import { consentedAt, tokensKeptBy } from './given-consents.ts'; +import { clockAt, type ConsentConnected, type ManualClock } from './served-consents.ts'; + +export interface ThroughAuto { + readonly fake: FakeMcpServer; + readonly service: FakeConsentService; + readonly clock: ManualClock; + readonly close: () => Promise; +} + +export const studioSession = Redacted.make('session-of-a-person-signed-in-to-auto'); + +export function autoClientsOf(service: Pick, kind: ReturnKind = 'hosted'): AutoClients { + return { enabled: true, service: { url: service.url }, returnKindOf: Function.constant(kind) }; +} + +export function serviceCallsAt({ url }: Pick) { + return consentServiceOf({ + fetch: globalThis.fetch, + url, + requestMs: 5000, + requestTimeout: (ms) => AbortSignal.timeout(ms), + }); +} + +export async function handleOf(service: Pick, authorizationUrl: string): Promise { + const { code, state } = await consentedAt(authorizationUrl); + const redeemed = await Effect.runPromise(serviceCallsAt(service).redeem(state, studioSession, { code })); + return redeemed.handle; +} + +export async function handedOffAskedOf( + client: ConsentClient, + asked: StartAsked, + service: Pick, +): Promise { + const start = await Effect.runPromise(client.start(asked)); + const handle = await handleOf(service, start.url); + const { server, way, returnAddress, reaches } = asked; + return { server, way, returnAddress, reaches, client: start.client, verifier: start.verifier, handle }; +} + +export async function servedThroughAuto(changes: Partial = {}): Promise { + const fake = await servedForConsent(changes); + const clock = clockAt(0); + const service = await serveFakeConsentService({ vendor: fake, now: clock.now }); + return { + fake, + service, + clock, + close: async () => { + await Promise.all([service.close(), fake.close()]); + }, + }; +} + +export function consentClientThrough( + { service }: Pick, + options: Partial = {}, +): ConsentClient { + return consentClientWith({ autoClients: autoClientsOf(service), renewalSpreadMs: 0, ...options }); +} + +export async function connectedThroughAuto( + { fake, service }: Pick, + way: Partial = {}, +): Promise { + const consent = consentClientThrough({ service }); + const asked = await handedOffAskedOf(consent, startAskedAt(fake, way), service); + const finished = await Effect.runPromise(consent.finish(asked)); + return { fake, client: asked.client, tokens: finished.tokens, kept: tokensKeptBy(finished) }; +} + +export async function redeemedThrough({ service }: Pick, start: ServiceStartAsked) { + const calls = serviceCallsAt(service); + const started = await Effect.runPromise(calls.start(start)); + const { code } = await consentedAt(started.url); + const redeemed = await Effect.runPromise(calls.redeem(started.state, studioSession, { code })); + return { started, code, handle: redeemed.handle }; +} + +export async function postedToTheService( + { url }: Pick, + path: string, + body: string, + headers: Readonly> = {}, +) { + const answer = await fetch(`${url}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body, + }); + return { status: answer.status, body: await answer.text() }; +} diff --git a/packages/tool-servers/src/consent-testing/consent-fakes.ts b/packages/tool-servers/src/consent-testing/consent-fakes.ts index 4bb7accaa..d4228ee8b 100644 --- a/packages/tool-servers/src/consent-testing/consent-fakes.ts +++ b/packages/tool-servers/src/consent-testing/consent-fakes.ts @@ -12,7 +12,9 @@ import { import { serveFakeMcp, type FakeMcpServer } from '../mcp-testing/fake-mcp-server.ts'; import { memoryRegistrations } from './given-consents.ts'; -export const returnAddress = 'https://studio.example.com/connect-return'; +export const studioBase = 'https://studio.example.com/'; + +export const returnAddress = `${studioBase}connect-return`; export const consentSecret = 'auto-runtime-client-secret-3c9e'; @@ -59,5 +61,6 @@ export function startAskedAt(fake: FakeMcpServer, way: Partial = {}, returnAddress, reaches: everyHost, state, + studioBase, }; } diff --git a/packages/tool-servers/src/consent-testing/fake-consent-service.test.ts b/packages/tool-servers/src/consent-testing/fake-consent-service.test.ts new file mode 100644 index 000000000..cb6df7ddd --- /dev/null +++ b/packages/tool-servers/src/consent-testing/fake-consent-service.test.ts @@ -0,0 +1,216 @@ +import { createHash } from 'node:crypto'; + +import { Effect, Redacted } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { ClientRegistration } from '../authorization-testing/fake-clients.ts'; +import { consentServiceOf, type ServiceStartAsked } from '../consent-through-auto/consent-service.ts'; +import type { FakeMcpServer } from '../mcp-testing/fake-mcp-server.ts'; +import { + postedToTheService, + redeemedThrough, + servedThroughAuto, + serviceCallsAt, + studioSession, + type ThroughAuto, +} from './auto-consents.ts'; +import { studioBase } from './consent-fakes.ts'; +import { consentedAt } from './given-consents.ts'; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +async function serving(changes: Partial = {}): Promise { + const served = await servedThroughAuto(changes); + closing.push(served.close); + return served; +} + +const verifier = 'a-handoff-verifier-of-forty-three-characters'; + +function startOf(vendor: Pick, changes: Partial = {}): ServiceStartAsked { + return { + issuer: vendor.origin, + scope: 'files.read', + parameters: { access_type: 'offline' }, + resource: `${vendor.origin}/mcp`, + returnTo: { kind: 'loopback', base: 'http://127.0.0.1:5555/studio/' }, + challenge: createHash('sha256').update(verifier).digest('base64url'), + ...changes, + }; +} + +function run(effect: Effect.Effect) { + return Effect.runPromise(Effect.result(effect)); +} + +function redeemedAt(served: ThroughAuto) { + return redeemedThrough(served, startOf(served.fake)); +} + +describe('the clients the fake consent service lists', () => { + it('are its one client, at the issuer of the fake authorization server', async () => { + const { fake: vendor, service } = await serving(); + + expect(await Effect.runPromise(serviceCallsAt(service).clients)).toEqual([ + { issuer: vendor.origin, client_id: 'auto-runtime' }, + ]); + }); +}); + +describe('a start at the fake consent service', () => { + it('answers the page of the authorization server with its client, its own state and challenge, and the parameters asked', async () => { + const { fake: vendor, service } = await serving(); + + const started = await Effect.runPromise(serviceCallsAt(service).start(startOf(vendor))); + const asked = Object.fromEntries(new URL(started.url).searchParams); + + expect(asked).toMatchObject({ + client_id: 'auto-runtime', + redirect_uri: 'https://studio.example.com/connect-return', + code_challenge_method: 'S256', + state: started.state, + scope: 'files.read', + resource: `${vendor.origin}/mcp`, + access_type: 'offline', + }); + expect(new URL(started.url).searchParams.get('code_challenge')).not.toBe(startOf(vendor).challenge); + expect(started.expires_at).toBe('1970-01-01T00:10:00.000Z'); + }); + + it('is refused for an issuer it has no client at, a malformed request and when told to', async () => { + const { fake: vendor, service } = await serving(); + const calls = serviceCallsAt(service); + service.refuseNext('start'); + + expect([ + await run(calls.start(startOf(vendor))), + await run(calls.start(startOf(vendor, { issuer: 'https://elsewhere.example.com' }))), + await postedToTheService(service, '/consents', '{"issuer":1}'), + await run(calls.start(startOf(vendor, { returnTo: { kind: 'hosted', base: studioBase }, scope: undefined }))), + ]).toMatchObject([ + { failure: { refused: 'start_refused' } }, + { failure: { refused: 'start_refused' } }, + { status: 400 }, + { _tag: 'Success' }, + ]); + }); +}); + +describe('a start at the fake consent service with a return base it does not send people back to', () => { + it('is refused for a Studio host other than its own, and a loopback base that is not one of http, a loopback host and a closing slash', async () => { + const { fake: vendor, service } = await serving(); + const calls = serviceCallsAt(service); + const startsAt = (kind: 'hosted' | 'loopback', base: string) => + run(calls.start(startOf(vendor, { returnTo: { kind, base } }))); + + expect([ + await startsAt('hosted', 'https://studio.example.org/'), + await startsAt('loopback', 'https://127.0.0.1:5555/studio/'), + await startsAt('loopback', 'http://studio.example.org/'), + await startsAt('loopback', 'http://localhost:5555/studio'), + await startsAt('loopback', 'not an address'), + await startsAt('loopback', 'http://[::1]/'), + ]).toMatchObject([ + ...Array.from({ length: 5 }, () => ({ failure: { refused: 'start_refused' } })), + { _tag: 'Success' }, + ]); + }); +}); + +describe('a state the hosted Studio asks the fake consent service to describe', () => { + it('is described to a person signed in, while it is open, and unknown once its ten minutes are over', async () => { + const { fake: vendor, service, clock } = await serving(); + const calls = serviceCallsAt(service); + const started = await Effect.runPromise(calls.start(startOf(vendor))); + + const open = await run(calls.describe(started.state, studioSession)); + const unsigned = await run(calls.describe(started.state, Redacted.make(''))); + const unknown = await run(calls.describe('another-state', studioSession)); + clock.setTo(600_000); + const ended = await run(calls.describe(started.state, studioSession)); + + expect([open, unsigned, unknown, ended]).toMatchObject([ + { success: { issuer: vendor.origin, return: { kind: 'loopback', base: 'http://127.0.0.1:5555/studio/' } } }, + { failure: { refused: 'unanswered' } }, + { _tag: 'Success', success: undefined }, + { _tag: 'Success', success: undefined }, + ]); + }); + + it('is refused to a call without a session with 401, asking for a bearer', async () => { + const { service } = await serving(); + + const answer = await fetch(`${service.url}/consents/a-state`); + await answer.text(); + + expect([answer.status, answer.headers.get('www-authenticate')]).toEqual([401, 'Bearer']); + }); +}); + +describe('a code the hosted Studio asks the fake consent service to redeem', () => { + it('is redeemed once for a person signed in, and refused when redeemed again, unknown, refused by the authorization server or when told to', async () => { + const serve = await serving(); + const { fake: vendor, service } = serve; + const calls = serviceCallsAt(service); + const { started, code } = await redeemedAt(serve); + const another = await Effect.runPromise(calls.start(startOf(vendor))); + const third = await Effect.runPromise(calls.start(startOf(vendor))); + const thirdCode = (await consentedAt(third.url)).code; + service.refuseNext('redeem'); + + expect([ + await run(calls.redeem(third.state, studioSession, { code: thirdCode })), + await run(calls.redeem(started.state, studioSession, { code })), + await run(calls.redeem('another-state', studioSession, { code })), + await run(calls.redeem(another.state, studioSession, { code: 'a-code-nobody-issued' })), + await run(calls.redeem(another.state, Redacted.make(''), { code: 'x' })), + await postedToTheService(service, `/consents/${another.state}/redeem`, '{}', { + authorization: 'Bearer a-session', + }), + await postedToTheService(service, `/consents/${another.state}/redeem`, '{"code":"x"}'), + ]).toMatchObject([ + { failure: { refused: 'code' } }, + { failure: { refused: 'redeemed' } }, + { failure: { refused: 'no_consent' } }, + { failure: { refused: 'code' } }, + { failure: { refused: 'unanswered' } }, + { status: 404 }, + { status: 401 }, + ]); + }); +}); + +describe('a call the fake consent service does not know', () => { + it('is answered as not found', async () => { + const { service } = await serving(); + + expect([ + await postedToTheService(service, '/consents/a/b/c', '{}'), + await postedToTheService(service, '/clients', '{}'), + ]).toMatchObject([{ status: 404 }, { status: 404 }]); + }); +}); + +describe('the calls the fake consent service heard', () => { + it('are recorded with what each sent, and stalled once it is told to stall', async () => { + const { fake: vendor, service } = await serving(); + const calls = consentServiceOf({ + fetch: globalThis.fetch, + url: service.url, + requestMs: 50, + requestTimeout: (ms) => AbortSignal.timeout(ms), + }); + await Effect.runPromise(calls.clients); + service.stall(); + + expect(await run(calls.start(startOf(vendor)))).toMatchObject({ failure: { refused: 'unanswered' } }); + expect(service.calls()).toMatchObject([ + { method: 'GET', path: '/clients', body: '', authorization: null }, + { method: 'POST', path: '/consents', body: { issuer: vendor.origin, challenge_method: 'S256' } }, + ]); + }); +}); diff --git a/packages/tool-servers/src/consent-testing/fake-consent-service.ts b/packages/tool-servers/src/consent-testing/fake-consent-service.ts new file mode 100644 index 000000000..70bb3454b --- /dev/null +++ b/packages/tool-servers/src/consent-testing/fake-consent-service.ts @@ -0,0 +1,242 @@ +import { Option, Schema } from 'effect'; + +import { serveOnLoopback, type FetchHandler } from '../testing/loopback-server.ts'; +import { returnAddress, studioBase } from './consent-fakes.ts'; +import { + answer, + client, + digestOf, + GivenSchema, + handedOff, + isoAt, + keptOfTheVendor, + postedToTheVendor, + problem, + randomText, + refreshed, + refusalOfTheVendor, + refuses, + ReturnSchema, + revoked, + signInFirst, + tenMinutesMs, + type Asked, + type FakeConsentServiceOptions, + type RefusedCall, + type Return, + type Route, + type Start, + type Store, + type Tokens, +} from './fake-consent-store.ts'; + +export interface ServiceCall { + readonly method: string; + readonly path: string; + readonly body: unknown; + readonly authorization: string | null; +} + +export interface FakeConsentService { + readonly url: string; + readonly clientId: string; + readonly calls: () => readonly ServiceCall[]; + readonly refuseNext: (call: RefusedCall) => void; + readonly stall: () => void; + readonly close: () => Promise; +} + +const decodeStart = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + issuer: GivenSchema, + scope: Schema.optionalKey(Schema.String), + parameters: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + resource: Schema.optionalKey(Schema.String), + return: ReturnSchema, + challenge: GivenSchema, + challenge_method: Schema.Literal('S256'), + }), + ), +); + +const decodeRedeem = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ code: GivenSchema, iss: Schema.optionalKey(Schema.String) })), +); + +const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +const loopbackHosts: ReadonlySet = new Set(['127.0.0.1', 'localhost', '[::1]']); + +function authorizationUrlOf( + store: Store, + state: string, + start: Start, + asked: Readonly>, +): string { + const url = new URL(`${store.options.vendor.origin}/authorize`); + const parameters = { + response_type: 'code', + client_id: client.clientId, + redirect_uri: returnAddress, + code_challenge: digestOf(start.vendorVerifier), + code_challenge_method: 'S256', + state, + }; + for (const [name, value] of Object.entries({ ...parameters, ...asked })) { + if (value !== undefined) { + url.searchParams.set(name, value); + } + } + return url.href; +} + +function isServedBase({ kind, base }: Return): boolean { + if (kind === 'hosted') { + return base === studioBase; + } + const url = URL.parse(base); + return url?.protocol === 'http:' && loopbackHosts.has(url.hostname) && base.endsWith('/'); +} + +function started({ text }: Asked, store: Store): Response { + const asked = Option.getOrUndefined(decodeStart(text)); + if (asked === undefined || refuses(store, 'start')) { + return problem(400, 'The consent cannot be started as it is asked'); + } + if (asked.issuer !== store.options.vendor.origin || !isServedBase(asked.return)) { + return problem(400, 'Auto has no client at this issuer, or does not send people back to this base'); + } + const state = randomText(); + const { now } = store.options; + const start = { + issuer: asked.issuer, + returnTo: asked.return, + challenge: asked.challenge, + vendorVerifier: randomText(), + resource: asked.resource, + startedAt: now(), + redeemed: false, + }; + store.starts.set(state, start); + const { scope, parameters, resource } = asked; + const url = authorizationUrlOf(store, state, start, { ...parameters, scope, resource }); + return answer({ state, url, expires_at: isoAt(start.startedAt + tenMinutesMs) }, 201); +} + +function sessionOf({ request }: Asked): string | undefined { + const header = request.headers.get('authorization') ?? ''; + return header.startsWith('Bearer ') && header.length > 'Bearer '.length ? header.slice('Bearer '.length) : undefined; +} + +function openStartOf({ segments }: Asked, store: Store): readonly [string, Start] | undefined { + const state = String(segments[1]); + const start = store.starts.get(state); + return start !== undefined && store.options.now() < start.startedAt + tenMinutesMs ? [state, start] : undefined; +} + +function described(asked: Asked, store: Store): Response { + if (sessionOf(asked) === undefined) { + return signInFirst(); + } + const open = openStartOf(asked, store); + return open === undefined + ? problem(404, 'There is no open consent of this state') + : answer({ issuer: open[1].issuer, return: open[1].returnTo }); +} + +async function exchangedAtTheVendor(store: Store, code: string, start: Start): Promise { + const vendor = await postedToTheVendor(store, '/token', { + grant_type: 'authorization_code', + code, + code_verifier: start.vendorVerifier, + redirect_uri: returnAddress, + }); + return vendor.ok ? keptOfTheVendor(store, await vendor.text()) : refusalOfTheVendor(vendor, 'code_refused'); +} + +async function redeemedWith(asked: Asked, store: Store, person: string): Promise { + const open = openStartOf(asked, store); + const returned = Option.getOrUndefined(decodeRedeem(asked.text)); + if (open === undefined || returned === undefined) { + return problem(404, 'There is no open consent of this state'); + } + const [state, start] = open; + if (start.redeemed) { + return problem(409, 'The consent of this state was redeemed already'); + } + if (refuses(store, 'redeem')) { + return problem(400, 'The authorization server refused the code', { refused: 'code_refused' }); + } + const tokens = await exchangedAtTheVendor(store, returned.code, start); + if (tokens instanceof Response) { + return tokens; + } + store.starts.set(state, { ...start, redeemed: true }); + const handle = randomText(); + const redeemedAt = store.options.now(); + store.handoffs.set(handle, { issuer: start.issuer, person, challenge: start.challenge, tokens, redeemedAt }); + return answer({ handle, expires_at: isoAt(redeemedAt + tenMinutesMs) }); +} + +function redeemed(asked: Asked, store: Store): Promise | Response { + const person = sessionOf(asked); + return person === undefined ? signInFirst() : redeemedWith(asked, store, person); +} + +function listed(_asked: Asked, store: Store): Response { + return answer({ clients: [{ issuer: store.options.vendor.origin, client_id: client.clientId }] }); +} + +const routes: ReadonlyMap = new Map([ + ['GET clients', listed], + ['POST consents', started], + ['GET consents *', described], + ['POST consents * redeem', redeemed], + ['POST handoffs *', handedOff], + ['POST tokens refresh', refreshed], + ['POST tokens revoke', revoked], +]); + +function routeOf(method: string, segments: readonly string[]): Route | undefined { + const shape = segments.map((segment, index) => (index === 1 && segments[0] !== 'tokens' ? '*' : segment)); + return routes.get([method, ...shape].join(' ')); +} + +function bodyOf(text: string): unknown { + return Option.getOrElse(decodeJson(text), () => text); +} + +export async function serveFakeConsentService(options: FakeConsentServiceOptions): Promise { + const calls: ServiceCall[] = []; + const store: Store = { options, starts: new Map(), handoffs: new Map(), keys: new Map(), refusing: new Set() }; + const stalling = { now: false }; + const handler: FetchHandler = async (request) => { + const url = new URL(request.url); + const text = await request.text(); + const { method } = request; + calls.push({ method, path: url.pathname, body: bodyOf(text), authorization: request.headers.get('authorization') }); + if (stalling.now) { + return Promise.withResolvers().promise; + } + const segments = url.pathname + .split('/') + .slice(1) + .map((segment) => decodeURIComponent(segment)); + const route = routeOf(method, segments); + return route === undefined ? problem(404, 'There is no such call') : route({ request, text, segments }, store); + }; + const listening = await serveOnLoopback(() => handler); + return { + url: listening.origin, + clientId: client.clientId, + calls: () => [...calls], + refuseNext: (call) => { + store.refusing.add(call); + }, + stall: () => { + stalling.now = true; + }, + close: listening.close, + }; +} diff --git a/packages/tool-servers/src/consent-testing/fake-consent-store.ts b/packages/tool-servers/src/consent-testing/fake-consent-store.ts new file mode 100644 index 000000000..2580dbc6f --- /dev/null +++ b/packages/tool-servers/src/consent-testing/fake-consent-store.ts @@ -0,0 +1,257 @@ +import { Buffer } from 'node:buffer'; +import { createHash, randomBytes } from 'node:crypto'; + +import { Option, Schema } from 'effect'; + +import type { FakeMcpServer } from '../mcp-testing/fake-mcp-server.ts'; +import { consentRegistration } from './consent-fakes.ts'; + +export type RefusedCall = 'start' | 'redeem' | 'refresh' | 'revoke'; + +export interface FakeConsentServiceOptions { + readonly vendor: Pick; + readonly now: () => number; +} + +export const GivenSchema = Schema.String.check(Schema.isMinLength(1)); + +export const ReturnSchema = Schema.Struct({ kind: Schema.Literals(['hosted', 'loopback']), base: GivenSchema }); + +const decodeVendorError = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({ error: Schema.String }))); + +const decodeHandoff = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({ verifier: GivenSchema }))); + +const decodeRefresh = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + issuer: GivenSchema, + refresh_token: GivenSchema, + connection_key: Schema.optionalKey(Schema.String), + resource: Schema.optionalKey(Schema.String), + }), + ), +); + +const decodeRevoke = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + issuer: GivenSchema, + access_token: Schema.optionalKey(Schema.String), + refresh_token: Schema.optionalKey(Schema.String), + connection_key: Schema.optionalKey(Schema.String), + }), + ), +); + +const decodeVendorTokens = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.optionalKey(Schema.String), + expires_in: Schema.optionalKey(Schema.Number), + scope: Schema.optionalKey(Schema.String), + token_type: Schema.String, + }), + ), +); + +export type Return = typeof ReturnSchema.Type; + +export type Tokens = Readonly>; + +export interface Start { + readonly issuer: string; + readonly returnTo: Return; + readonly challenge: string; + readonly vendorVerifier: string; + readonly resource: string | undefined; + readonly startedAt: number; + readonly redeemed: boolean; +} + +interface Handoff { + readonly issuer: string; + readonly person: string; + readonly challenge: string; + readonly tokens: Tokens; + readonly redeemedAt: number; +} + +interface Kept { + readonly issuer: string; + readonly person: string; +} + +export interface Store { + readonly options: FakeConsentServiceOptions; + readonly starts: Map; + readonly handoffs: Map; + readonly keys: Map; + readonly refusing: Set; +} + +export interface Asked { + readonly request: Readonly; + readonly text: string; + readonly segments: readonly string[]; +} + +export type Route = (asked: Asked, store: Store) => Promise | Response; + +export const tenMinutesMs = 600_000; + +const randomByteCount = 32; + +export const client = consentRegistration(); + +const problemHeaders = { 'content-type': 'application/problem+json', 'cache-control': 'no-store' }; + +const titles: ReadonlyMap = new Map([ + [400, 'Bad Request'], + [401, 'Unauthorized'], + [403, 'Forbidden'], + [404, 'Not Found'], + [409, 'Conflict'], + [502, 'Bad Gateway'], +]); + +export function problem( + status: number, + detail: string, + members: Readonly> = {}, + headers: Readonly> = {}, +): Response { + const document = { type: 'about:blank', status, title: titles.get(status), detail, ...members }; + return new Response(JSON.stringify(document), { status, headers: { ...problemHeaders, ...headers } }); +} + +export function signInFirst(): Response { + return problem(401, 'Sign in to Auto first', {}, { 'www-authenticate': 'Bearer' }); +} + +export async function refusalOfTheVendor(vendor: Readonly, refused: string): Promise { + const text = await vendor.text(); + const isRefusedGrant = + vendor.status === 400 && Option.getOrUndefined(decodeVendorError(text))?.error === 'invalid_grant'; + return isRefusedGrant + ? problem(400, `The authorization server refused it: ${text}`, { refused }) + : problem(502, `The authorization server answered HTTP ${vendor.status}`); +} + +export function answer(body: unknown, status = 200): Response { + return Response.json(body, { status }); +} + +export function randomText(): string { + return randomBytes(randomByteCount).toString('base64url'); +} + +export function digestOf(text: string): string { + return createHash('sha256').update(text).digest('base64url'); +} + +export function isoAt(moment: number): string { + return new Date(moment).toISOString(); +} + +export function refuses(store: Store, call: RefusedCall): boolean { + return store.refusing.delete(call); +} + +function asTheClient(): Readonly> { + return { + 'content-type': 'application/x-www-form-urlencoded', + authorization: `Basic ${Buffer.from(`${client.clientId}:${String(client.clientSecret)}`).toString('base64')}`, + }; +} + +export function postedToTheVendor( + store: Store, + path: string, + form: Readonly>, +): Promise { + return fetch(`${store.options.vendor.origin}${path}`, { + method: 'POST', + headers: asTheClient(), + body: new URLSearchParams(form), + }); +} + +export function keptOfTheVendor(store: Store, text: string): Tokens { + const tokens = Option.getOrThrow(decodeVendorTokens(text)); + const { expires_in: expiresIn, ...given } = tokens; + return expiresIn === undefined ? given : { ...given, expires_at: isoAt(store.options.now() + expiresIn * 1000) }; +} + +export function handedOff({ segments, text }: Asked, store: Store): Response { + const handle = String(segments[1]); + const handoff = store.handoffs.get(handle); + const presented = Option.getOrUndefined(decodeHandoff(text)); + if (handoff === undefined || presented === undefined || store.options.now() >= handoff.redeemedAt + tenMinutesMs) { + return problem(404, 'There is no handoff of this handle'); + } + if (digestOf(presented.verifier) !== handoff.challenge) { + return problem(403, 'The verifier is not the one the consent was started with', { refused: 'wrong_verifier' }); + } + store.handoffs.delete(handle); + const key = randomText(); + store.keys.set(digestOf(key), { issuer: handoff.issuer, person: handoff.person }); + return answer({ client_id: client.clientId, tokens: handoff.tokens, connection_key: key }); +} + +function isKeptKey(store: Store, issuer: string, key: string | undefined): boolean { + return key !== undefined && store.keys.get(digestOf(key))?.issuer === issuer; +} + +const malformed = 'The request is not one this call takes'; + +const unknownKey = 'The connection key is not one Auto keeps for this issuer'; + +const askedOfTheVendor = 503; + +export async function refreshed({ text }: Asked, store: Store): Promise { + const asked = Option.getOrUndefined(decodeRefresh(text)); + if (asked === undefined) { + return problem(400, malformed); + } + if (!isKeptKey(store, asked.issuer, asked.connection_key)) { + return problem(403, unknownKey, { refused: 'unknown_key' }); + } + if (refuses(store, 'refresh')) { + return problem(400, 'The authorization server refused the refresh token', { refused: 'invalid_grant' }); + } + const vendor = await postedToTheVendor(store, '/token', { + grant_type: 'refresh_token', + refresh_token: asked.refresh_token, + ...(asked.resource === undefined ? {} : { resource: asked.resource }), + }); + return vendor.ok + ? answer({ tokens: keptOfTheVendor(store, await vendor.text()) }) + : refusalOfTheVendor(vendor, 'invalid_grant'); +} + +async function statusOfTheRevocation(store: Store, token: string): Promise { + if (refuses(store, 'revoke')) { + return askedOfTheVendor; + } + const vendor = await postedToTheVendor(store, '/revoke', { token }); + await vendor.text(); + return vendor.status; +} + +export async function revoked({ text }: Asked, store: Store): Promise { + const asked = Option.getOrUndefined(decodeRevoke(text)); + if (asked === undefined) { + return problem(400, malformed); + } + if (!isKeptKey(store, asked.issuer, asked.connection_key)) { + return problem(403, unknownKey, { refused: 'unknown_key' }); + } + const vendorStatus = await statusOfTheRevocation(store, asked.refresh_token ?? String(asked.access_token)); + if (vendorStatus < 200 || vendorStatus >= 300) { + const detail = `The authorization server answered HTTP ${vendorStatus} to the revocation`; + return problem(502, detail, { refused: 'not_revoked', vendor_status: vendorStatus }); + } + store.keys.delete(digestOf(String(asked.connection_key))); + return new Response(null, { status: 204 }); +} diff --git a/packages/tool-servers/src/consent-testing/fake-handoffs.test.ts b/packages/tool-servers/src/consent-testing/fake-handoffs.test.ts new file mode 100644 index 000000000..19758bb56 --- /dev/null +++ b/packages/tool-servers/src/consent-testing/fake-handoffs.test.ts @@ -0,0 +1,209 @@ +import { createHash } from 'node:crypto'; + +import { Effect, Redacted } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { ClientRegistration } from '../authorization-testing/fake-clients.ts'; +import type { ServiceStartAsked } from '../consent-through-auto/consent-service.ts'; +import type { FakeMcpServer } from '../mcp-testing/fake-mcp-server.ts'; +import { serveOnLoopback } from '../testing/loopback-server.ts'; +import { + postedToTheService, + redeemedThrough, + servedThroughAuto, + serviceCallsAt, + studioSession, + type ThroughAuto, +} from './auto-consents.ts'; +import { serveFakeConsentService } from './fake-consent-service.ts'; +import { clockAt } from './served-consents.ts'; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +async function serving(changes: Partial = {}): Promise { + const served = await servedThroughAuto(changes); + closing.push(served.close); + return served; +} + +const verifier = 'a-handoff-verifier-of-forty-three-characters'; + +const aConnectionKey: unknown = expect.stringMatching(/^[\w-]{43}$/u); + +function startOf(vendor: Pick, changes: Partial = {}): ServiceStartAsked { + return { + issuer: vendor.origin, + scope: 'files.read', + parameters: { access_type: 'offline' }, + resource: `${vendor.origin}/mcp`, + returnTo: { kind: 'loopback', base: 'http://127.0.0.1:5555/studio/' }, + challenge: createHash('sha256').update(verifier).digest('base64url'), + ...changes, + }; +} + +function run(effect: Effect.Effect) { + return Effect.runPromise(Effect.result(effect)); +} + +function redeemedAt(served: ThroughAuto) { + return redeemedThrough(served, startOf(served.fake)); +} + +describe('a handoff the fake consent service holds', () => { + it('is taken once with the verifier its start was challenged with, and refused for another, after ten minutes or unknown', async () => { + const serve = await serving(); + const calls = serviceCallsAt(serve.service); + const { handle } = await redeemedAt(serve); + const late = await redeemedAt(serve); + const wrong = await run(calls.takeHandoff(handle, Redacted.make('another-verifier'))); + const taken = await run(calls.takeHandoff(handle, Redacted.make(verifier))); + const again = await run(calls.takeHandoff(handle, Redacted.make(verifier))); + serve.clock.setTo(600_000); + + expect([wrong, taken, again, await run(calls.takeHandoff(late.handle, Redacted.make(verifier)))]).toMatchObject([ + { failure: { refused: 'code' } }, + { + success: { + client_id: 'auto-runtime', + tokens: { token_type: 'Bearer', scope: 'files.read', expires_at: '1970-01-01T01:00:00.000Z' }, + connection_key: aConnectionKey, + }, + }, + { failure: { refused: 'code' } }, + { failure: { refused: 'code' } }, + ]); + expect(await postedToTheService(serve.service, `/handoffs/${handle}`, 'not json')).toMatchObject({ status: 404 }); + }); + + it('names a wrong verifier as one, with 403', async () => { + const serve = await serving(); + const { handle } = await redeemedAt(serve); + + const answered = await postedToTheService(serve.service, `/handoffs/${handle}`, '{"verifier":"another"}'); + + expect([answered.status, JSON.parse(answered.body)]).toMatchObject([403, { refused: 'wrong_verifier' }]); + }); +}); + +describe('the tokens the fake consent service refreshes and revokes', () => { + it('are refused without the connection key the handoff gave, and refreshed or revoked with it, at the authorization server', async () => { + const serve = await serving(); + const calls = serviceCallsAt(serve.service); + const { handle } = await redeemedAt(serve); + const handed = await Effect.runPromise(calls.takeHandoff(handle, Redacted.make(verifier))); + const issuer = serve.fake.origin; + const refreshToken = String(handed.tokens.refresh_token); + const keyed = { issuer, refreshToken, connectionKey: handed.connection_key, resource: `${issuer}/mcp` }; + const kept = { ...handed.tokens, connection_key: handed.connection_key }; + const accessAlone = { + access_token: kept.access_token, + token_type: kept.token_type, + connection_key: kept.connection_key, + }; + + const key = kept.connection_key; + + const unkeyed = await run(calls.refresh({ ...keyed, connectionKey: 'another-key' })); + const refreshed = await run(calls.refresh({ ...keyed, resource: undefined })); + serve.service.refuseNext('refresh'); + const refused = await run(calls.refresh(keyed)); + serve.service.refuseNext('revoke'); + const unrevoked = await run(calls.revoke({ issuer, tokens: kept, connectionKey: key })); + const otherKey = await run(calls.revoke({ issuer, tokens: kept, connectionKey: 'another-key' })); + const revoked = await run(calls.revoke({ issuer, tokens: accessAlone, connectionKey: key })); + const afterwards = await run(calls.refresh(keyed)); + + expect([unkeyed, refreshed, refused, unrevoked, otherKey, revoked, afterwards]).toMatchObject([ + { failure: { refused: 'unknown_key' } }, + { success: { token_type: 'Bearer', scope: 'files.read' } }, + { failure: { refused: 'invalid_grant' } }, + { failure: { refused: 'not_revoked', status: 503 } }, + { failure: { refused: 'unknown_key' } }, + { _tag: 'Success' }, + { failure: { refused: 'unknown_key' } }, + ]); + expect([ + await postedToTheService(serve.service, '/tokens/revoke', '{}'), + await postedToTheService(serve.service, '/tokens/refresh', '{}'), + ]).toMatchObject([{ status: 400 }, { status: 400 }]); + }); +}); + +describe('the tokens the fake consent service refreshes for the authorization server', () => { + it('are refused in the words of the authorization server where it refuses the refresh token', async () => { + const serve = await serving(); + const calls = serviceCallsAt(serve.service); + const handed = await Effect.runPromise( + calls.takeHandoff((await redeemedAt(serve)).handle, Redacted.make(verifier)), + ); + const issuer = serve.fake.origin; + const refreshToken = 'a-refresh-token-nobody-issued'; + + expect( + await run(calls.refresh({ issuer, refreshToken, connectionKey: handed.connection_key, resource: undefined })), + ).toMatchObject({ failure: { refused: 'invalid_grant' } }); + }); +}); + +describe('the tokens of an authorization server that gives no expiry', () => { + it('are handed off without one', async () => { + const serve = await serving({ expiresInSeconds: null }); + const handed = await Effect.runPromise( + serviceCallsAt(serve.service).takeHandoff((await redeemedAt(serve)).handle, Redacted.make(verifier)), + ); + + expect(Object.keys(handed.tokens)).toEqual(['access_token', 'refresh_token', 'scope', 'token_type']); + }); +}); + +async function failingVendor(): Promise> { + const tokens = { access_token: 'a-token', refresh_token: 'a-refresh-token', token_type: 'Bearer', expires_in: 60 }; + const heard = { tokens: 0 }; + const vendor = await serveOnLoopback(() => (request) => { + if (new URL(request.url).pathname === '/revoke') { + return Promise.resolve(Response.json({ error: 'invalid_request' }, { status: 400 })); + } + heard.tokens += 1; + return Promise.resolve( + heard.tokens === 1 ? Response.json(tokens) : Response.json({ error: 'temporarily_unavailable' }, { status: 503 }), + ); + }); + closing.push(vendor.close); + return vendor; +} + +describe('the calls of the fake consent service an authorization server fails', () => { + it('are a 502 without a refusal where the server fails otherwise than by refusing, and not revoked in the status it answered', async () => { + const vendor = await failingVendor(); + const service = await serveFakeConsentService({ vendor, now: clockAt(0).now }); + closing.push(service.close); + const calls = serviceCallsAt(service); + const first = await Effect.runPromise(calls.start(startOf(vendor))); + const { handle } = await Effect.runPromise(calls.redeem(first.state, studioSession, { code: 'a-code' })); + const handed = await Effect.runPromise(calls.takeHandoff(handle, Redacted.make(verifier))); + const second = await Effect.runPromise(calls.start(startOf(vendor))); + const issuer = vendor.origin; + const connectionKey = handed.connection_key; + + expect([ + await run(calls.refresh({ issuer, refreshToken: 'a-refresh-token', connectionKey, resource: undefined })), + await run(calls.revoke({ issuer, tokens: handed.tokens, connectionKey })), + await run(calls.redeem(second.state, studioSession, { code: 'another-code' })), + ]).toMatchObject([ + { failure: { refused: 'unanswered' } }, + { failure: { refused: 'not_revoked', status: 400 } }, + { failure: { refused: 'unanswered' } }, + ]); + expect( + service + .calls() + .map(({ path }) => path) + .slice(-3), + ).toEqual(['/tokens/refresh', '/tokens/revoke', `/consents/${second.state}/redeem`]); + }); +}); diff --git a/packages/tool-servers/src/consent-through-auto/auto-clients.test.ts b/packages/tool-servers/src/consent-through-auto/auto-clients.test.ts new file mode 100644 index 000000000..a59dbd889 --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/auto-clients.test.ts @@ -0,0 +1,160 @@ +import { Duration, Effect, Logger } from 'effect'; +import { TestClock } from 'effect/testing'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { ConsentClient } from '../consent/consent-client.ts'; +import { + autoClientsOf, + consentClientWith, + registeredAt, + servedThroughAuto, + startAskedAt, + studioBase, + type FakeConsentService, + type ThroughAuto, +} from '../testing/index.ts'; +import { serveOnLoopback } from '../testing/loopback-server.ts'; +import { unlistedWords, type ReturnKind } from './auto-clients.ts'; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +async function throughAuto(): Promise { + const serving = await servedThroughAuto(); + closing.push(serving.close); + return serving; +} + +function pathsHeardBy(service: FakeConsentService): readonly string[] { + return service.calls().map(({ path }) => path); +} + +function sourcesAt(client: ConsentClient, issuer: string) { + return Effect.map(client.hadSources(studioBase), ({ sourcesOf }) => sourcesOf({ issuer })); +} + +function loggingInto(log: (line: string) => void) { + return Logger.layer([Logger.map(Logger.formatJson, log)]); +} + +const anIssuer = 'https://accounts.example.com'; + +async function failingOnceAt(): Promise<{ readonly url: string; readonly asked: () => number }> { + const heard = { times: 0 }; + const listing = await serveOnLoopback(() => () => { + heard.times += 1; + return Promise.resolve( + heard.times === 1 + ? new Response('failing', { status: 503 }) + : Response.json({ clients: [{ issuer: anIssuer, client_id: 'auto-runtime' }] }), + ); + }); + closing.push(listing.close); + return { url: listing.origin, asked: () => heard.times }; +} + +describe('the clients Auto’s consent service lists', () => { + it('are asked for when first needed, and again once an hour has passed on the test clock', async () => { + const { fake, service } = await throughAuto(); + const sourcesNow = sourcesAt(consentClientWith({ autoClients: autoClientsOf(service) }), fake.origin); + + const sources = await Effect.runPromise( + Effect.gen(function* () { + const first = yield* sourcesNow; + yield* sourcesNow; + yield* TestClock.adjust(Duration.minutes(59)); + yield* sourcesNow; + yield* TestClock.adjust(Duration.minutes(1)); + return [first, yield* sourcesNow]; + }).pipe(Effect.provide(TestClock.layer())), + ); + + expect(sources).toEqual([['auto'], ['auto']]); + expect(pathsHeardBy(service)).toEqual(['/clients', '/clients']); + }); + + it('are asked for within five seconds where a request may take longer', async () => { + const { fake, service } = await throughAuto(); + const bounds: number[] = []; + const requestTimeout = (ms: number) => { + bounds.push(ms); + return AbortSignal.timeout(ms); + }; + const client = consentClientWith({ autoClients: autoClientsOf(service), requestTimeout }); + + await Effect.runPromise(sourcesAt(client, fake.origin)); + + expect(bounds).toEqual([5000]); + }); +}); + +describe('the clients of a consent service that does not answer', () => { + it('are none for five minutes, which the client says once, naming the service, and asked for again after', async () => { + const service = await failingOnceAt(); + const client = consentClientWith({ autoClients: autoClientsOf(service) }); + const logged: string[] = []; + const logging = loggingInto((line) => { + logged.push(line); + }); + + const sources = await Effect.runPromise( + Effect.gen(function* () { + const failed = yield* sourcesAt(client, anIssuer); + yield* TestClock.adjust(Duration.minutes(4)); + const held = yield* sourcesAt(client, anIssuer); + yield* TestClock.adjust(Duration.minutes(1)); + return [failed, held, yield* sourcesAt(client, anIssuer)]; + }).pipe(Effect.provide([TestClock.layer(), logging])), + ); + + expect(sources).toEqual([[], [], ['auto']]); + expect(service.asked()).toBe(2); + expect(logged).toHaveLength(1); + expect(logged.join('')).toContain(unlistedWords(service.url)); + }); +}); + +describe('the clients of Auto’s consent service where the source of Auto is switched off', () => { + it('are never asked for, so no client of Auto’s is taken', async () => { + const { fake, service } = await throughAuto(); + const client = consentClientWith({ autoClients: { ...autoClientsOf(service), enabled: false } }); + + const sources = await Effect.runPromise(sourcesAt(client, fake.origin)); + const start = await Effect.runPromise(Effect.flip(client.start(startAskedAt(fake)))); + + expect([sources, start]).toEqual([[], { refused: 'no_client' }]); + expect(service.calls()).toEqual([]); + }); +}); + +describe('the clients of Auto’s consent service for a Studio neither hosted nor on this computer', () => { + it('are left out of the sources had, and never asked for', async () => { + const { fake, service } = await throughAuto(); + const elsewhere: ReturnKind = 'elsewhere'; + const client = consentClientWith({ autoClients: autoClientsOf(service, elsewhere) }); + + expect(await Effect.runPromise(sourcesAt(client, fake.origin))).toEqual([]); + expect(service.calls()).toEqual([]); + }); +}); + +describe('a start at an issuer both a registered client and Auto’s consent service serve', () => { + it('takes the registered client, and asks the service for nothing but its clients', async () => { + const { fake, service } = await throughAuto(); + const client = consentClientWith({ + registeredClients: [registeredAt(fake.origin)], + autoClients: autoClientsOf(service), + }); + + const start = await Effect.runPromise(client.start(startAskedAt(fake))); + + expect([start.client, start.state]).toEqual([ + { client_id: 'auto-runtime', client_source: 'registered' }, + 'state-1', + ]); + expect(pathsHeardBy(service)).toEqual(['/clients']); + }); +}); diff --git a/packages/tool-servers/src/consent-through-auto/auto-clients.ts b/packages/tool-servers/src/consent-through-auto/auto-clients.ts new file mode 100644 index 000000000..ef5fed635 --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/auto-clients.ts @@ -0,0 +1,88 @@ +import type { FetchLike } from '@modelcontextprotocol/client'; +import { Duration, Effect, Exit } from 'effect'; + +import { requestBoundsOf, type RequestBounds } from '../bounds/call-bounds.ts'; +import type { ServiceClient } from '../consent/client-sources.ts'; +import { consentServiceOf, type ConsentReturn, type ConsentServiceCalls } from './consent-service.ts'; + +export type ReturnKind = ConsentReturn['kind'] | 'elsewhere'; + +export interface AutoClients { + readonly enabled: boolean; + readonly service: { readonly url: string }; + readonly returnKindOf: (base: string) => ReturnKind; +} + +export type ThroughAuto = + | { readonly enabled: false } + | { + readonly enabled: true; + readonly url: string; + readonly returnKindOf: (base: string) => ReturnKind; + readonly service: ConsentServiceCalls; + readonly listing: ConsentServiceCalls['clients']; + }; + +export type AutoOn = Extract; + +export interface AutoAsking extends Partial { + readonly fetch: FetchLike; + readonly autoClients?: AutoClients; +} + +const sourceOff: ThroughAuto = { enabled: false }; + +const listingBoundMs = 5000; + +const listedFor = Duration.hours(1); + +const heldAfterAFailure = Duration.minutes(5); + +const noServiceClients: readonly ServiceClient[] = []; + +export function unlistedWords(url: string): string { + return `Auto's consent service at ${url} did not list the clients Auto has at authorization servers, so for five minutes no provider takes consent through Auto's clients here`; +} + +export function throughAutoOf(asking: AutoAsking): ThroughAuto { + const { autoClients, fetch } = asking; + if (autoClients?.enabled !== true) { + return sourceOff; + } + const { url } = autoClients.service; + const bounds = requestBoundsOf(asking); + const service = consentServiceOf({ fetch, url, ...bounds }); + const listing = consentServiceOf({ fetch, url, ...bounds, requestMs: Math.min(bounds.requestMs, listingBoundMs) }); + return { enabled: true, url, returnKindOf: autoClients.returnKindOf, service, listing: listing.clients }; +} + +export function autoOf({ client_source: source }: { readonly client_source: string }, through: ThroughAuto) { + return source === 'auto' && through.enabled ? through : undefined; +} + +export function servedKindOf(through: ThroughAuto, base: string): ConsentReturn['kind'] | undefined { + if (!through.enabled) { + return undefined; + } + const kind = through.returnKindOf(base); + return kind === 'elsewhere' ? undefined : kind; +} + +export function serviceClientsListed(through: ThroughAuto): Effect.Effect { + if (!through.enabled) { + return Effect.succeed(noServiceClients); + } + const listing = through.listing.pipe(Effect.tapError(() => Effect.logWarning(unlistedWords(through.url)))); + const listed = Effect.runSync( + Effect.cachedWithTTL(listing, Exit.match({ onSuccess: () => listedFor, onFailure: () => heldAfterAFailure })), + ); + return Effect.orElseSucceed(listed, () => noServiceClients); +} + +export function usableClientsOf( + through: ThroughAuto, + base: string, + listed: Effect.Effect, +): Effect.Effect { + return servedKindOf(through, base) === undefined ? Effect.succeed(noServiceClients) : listed; +} diff --git a/packages/tool-servers/src/consent-through-auto/auto-consents.test.ts b/packages/tool-servers/src/consent-through-auto/auto-consents.test.ts new file mode 100644 index 000000000..0f7ae97fb --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/auto-consents.test.ts @@ -0,0 +1,169 @@ +import { createHash } from 'node:crypto'; + +import { Effect, Redacted } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { ConsentClient, StartAsked } from '../consent/consent-client.ts'; +import { + autoClientsOf, + consentClientWith, + returnAddress, + servedThroughAuto, + startAskedAt, + studioBase, + type ClientRegistration, + type FakeConsentService, + type ThroughAuto, +} from '../testing/index.ts'; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +async function throughAuto(changes: Partial = {}): Promise { + const serving = await servedThroughAuto(changes); + closing.push(serving.close); + return serving; +} + +function started(client: ConsentClient, asked: StartAsked) { + return Effect.runPromise(client.start(asked)); +} + +function refused(client: ConsentClient, asked: StartAsked) { + return Effect.runPromise(Effect.flip(client.start(asked))); +} + +function challengeOf(verifier: Redacted.Redacted): string { + return createHash('sha256').update(Redacted.value(verifier)).digest('base64url'); +} + +function pathsHeardBy(service: FakeConsentService): readonly string[] { + return service.calls().map(({ path }) => path); +} + +const rule = 'The rules of whoever runs Auto do not allow the host 127.0.0.1'; + +function refusingEveryHostBut(origin: string) { + return (url: string): string | undefined => (new URL(url).origin === origin ? undefined : rule); +} + +function refusingTheHostOf(origin: string) { + return (url: string): string | undefined => (new URL(url).origin === origin ? rule : undefined); +} + +const loopbackBase = 'http://127.0.0.1:5555/studio/'; + +interface Asking { + readonly asked: StartAsked; + readonly client: ConsentClient; +} + +describe('the start of a consent through Auto’s consent service', () => { + it('asks the service with the issuer, the scope, the parameters, the resource, the loopback base and the challenge of its own verifier', async () => { + const { fake, service } = await throughAuto(); + const client = consentClientWith({ autoClients: autoClientsOf(service, 'loopback') }); + const asked = { ...startAskedAt(fake, { parameters: { access_type: 'offline' } }), studioBase: loopbackBase }; + + const start = await started(client, asked); + + expect(service.calls().at(-1)?.body).toEqual({ + issuer: fake.origin, + scope: 'files.read', + parameters: { access_type: 'offline' }, + resource: `${fake.origin}/mcp`, + return: { kind: 'loopback', base: loopbackBase }, + challenge: challengeOf(start.verifier), + challenge_method: 'S256', + }); + expect(pathsHeardBy(service)).toEqual(['/clients', '/consents']); + }); + + it('answers the page and the state the service made, and Auto’s client at the issuer', async () => { + const { fake, service } = await throughAuto(); + const client = consentClientWith({ autoClients: autoClientsOf(service, 'loopback') }); + + const start = await started(client, { ...startAskedAt(fake), studioBase: loopbackBase }); + const page = new URL(start.url); + + expect(start.client).toEqual({ client_id: 'auto-runtime', client_source: 'auto' }); + expect([page.origin, page.searchParams.get('state'), page.searchParams.get('redirect_uri')]).toEqual([ + fake.origin, + start.state, + returnAddress, + ]); + expect(start.state).not.toBe('state-1'); + }); +}); + +describe('the start of a consent through Auto’s consent service from the hosted Studio', () => { + it('asks to return to the Studio host, and reaches the service whatever the rules say of its host', async () => { + const { fake, service } = await throughAuto(); + const client = consentClientWith({ autoClients: autoClientsOf(service, 'hosted') }); + + const start = await started(client, { ...startAskedAt(fake), reaches: refusingTheHostOf(service.url) }); + + expect(service.calls().at(-1)?.body).toMatchObject({ return: { kind: 'hosted', base: studioBase } }); + expect(start.client.client_source).toBe('auto'); + }); + + it('is refused by the service, as a start it will not make, where the Studio host is not its own', async () => { + const { fake, service } = await throughAuto(); + const client = consentClientWith({ autoClients: autoClientsOf(service, 'hosted') }); + + expect(await refused(client, { ...startAskedAt(fake), studioBase: 'https://studio.example.org/' })).toEqual({ + refused: 'start_refused', + }); + }); + + it('is refused in the rule’s words where the rules refuse the host of the authorization server', async () => { + const { fake, service } = await throughAuto(); + const client = consentClientWith({ autoClients: autoClientsOf(service) }); + + expect(await refused(client, { ...startAskedAt(fake), reaches: refusingEveryHostBut(service.url) })).toEqual({ + refused: 'host', + rule, + }); + expect(pathsHeardBy(service)).toEqual(['/clients']); + }); +}); + +describe('a start for a Studio neither hosted nor on this computer', () => { + it('is refused before anything is sent where no other source the way takes remains', async () => { + const { fake, service } = await throughAuto(); + const client = consentClientWith({ autoClients: autoClientsOf(service, 'elsewhere') }); + + expect(await refused(client, startAskedAt(fake))).toEqual({ refused: 'studio_elsewhere' }); + expect([pathsHeardBy(service), fake.heard()]).toEqual([['/clients'], []]); + }); + + it('is made with a later source the way takes, leaving Auto’s client out and asking the service nothing', async () => { + const { fake, service } = await throughAuto({ dynamicRegistration: true }); + const client = consentClientWith({ autoClients: autoClientsOf(service, 'elsewhere') }); + + const start = await started(client, startAskedAt(fake, { clients: ['registered', 'dynamic'] })); + + expect(start.client.client_source).toBe('dynamic'); + expect(pathsHeardBy(service)).toEqual([]); + }); +}); + +describe('a start the consent service refuses or does not answer', () => { + it('is refused as a start the service will not make where it refuses it, and unanswered where it is gone or stalls', async () => { + const [refusing, gone, stalling] = await Promise.all([throughAuto(), throughAuto(), throughAuto()]); + const asking = [refusing, gone, stalling].map((serving): Asking => ({ + asked: startAskedAt(serving.fake), + client: consentClientWith({ autoClients: autoClientsOf(serving.service), requestMs: 200 }), + })); + await Effect.runPromise(Effect.all(asking.map(({ client }) => client.hadSources(studioBase)))); + refusing.service.refuseNext('start'); + await gone.service.close(); + stalling.service.stall(); + + const answers = await Promise.all(asking.map(({ client, asked }) => refused(client, asked))); + + expect(answers).toEqual([{ refused: 'start_refused' }, { refused: 'unanswered' }, { refused: 'unanswered' }]); + }); +}); diff --git a/packages/tool-servers/src/consent-through-auto/auto-consents.ts b/packages/tool-servers/src/consent-through-auto/auto-consents.ts new file mode 100644 index 000000000..63dc093e4 --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/auto-consents.ts @@ -0,0 +1,91 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { Effect, Redacted } from 'effect'; + +import { keptHandedOf, keptValueOf } from '../consent-tokens/kept-tokens.ts'; +import type { Requests } from '../consent/acting-clients.ts'; +import type { + ChosenClient, + ConsentRefusal, + FinishedConsent, + HandleFinishAsked, + StartAsked, + StartedConsent, +} from '../consent/consent-client.ts'; +import { grantedScopeOf } from '../consent/scopes.ts'; +import { scopeAskedOf, serverMetadataOf } from '../consent/server-metadata.ts'; +import { autoOf, type AutoOn, type ThroughAuto } from './auto-clients.ts'; +import type { ConsentReturn } from './consent-service.ts'; + +type FetchFor = Requests['fetchFor']; + +interface StartingThrough { + readonly auto: AutoOn; + readonly clientId: string; + readonly kind: ConsentReturn['kind']; +} + +const clientGone: ConsentRefusal = { refused: 'client_gone' }; + +const verifierBytes = 32; + +function handoffVerifier(): { readonly verifier: string; readonly challenge: string } { + const verifier = randomBytes(verifierBytes).toString('base64url'); + return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }; +} + +function chosenOf(clientId: string): ChosenClient { + return { client_id: clientId, client_source: 'auto' }; +} + +export function startedThroughAuto( + asked: StartAsked, + fetchFor: FetchFor, + { auto, clientId, kind }: StartingThrough, +): Effect.Effect { + return Effect.gen(function* () { + const { server, resource } = yield* serverMetadataOf(asked, fetchFor(asked.reaches)); + const { verifier, challenge } = handoffVerifier(); + const { url, state } = yield* auto.service.start({ + issuer: asked.way.issuer, + scope: scopeAskedOf(asked.way, server), + parameters: asked.way.parameters, + resource, + returnTo: { kind, base: asked.studioBase }, + challenge, + }); + return { url, state, verifier: Redacted.make(verifier), client: chosenOf(clientId) }; + }); +} + +export function handedOff( + asked: HandleFinishAsked, + fetchFor: FetchFor, + through: ThroughAuto, +): Effect.Effect { + return Effect.gen(function* () { + const { server } = yield* serverMetadataOf(asked, fetchFor(asked.reaches)); + const auto = autoOf(asked.client, through); + if (auto === undefined) { + return yield* Effect.fail(clientGone); + } + const handoff = yield* auto.service.takeHandoff(asked.handle, asked.verifier); + const { tokens, connection_key: key } = handoff; + const revoked = Effect.ignore( + auto.service.revoke({ + issuer: asked.way.issuer, + tokens: keptHandedOf(tokens, undefined, key), + connectionKey: key, + }), + ); + const scope = yield* grantedScopeOf(tokens.scope, scopeAskedOf(asked.way, server)).pipe( + Effect.onError(() => revoked), + ); + return { + tokens: keptValueOf(keptHandedOf(tokens, scope, key)), + scope, + revoked, + client: chosenOf(handoff.client_id), + }; + }); +} diff --git a/packages/tool-servers/src/consent-through-auto/auto-handoffs.test.ts b/packages/tool-servers/src/consent-through-auto/auto-handoffs.test.ts new file mode 100644 index 000000000..91a64f9f7 --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/auto-handoffs.test.ts @@ -0,0 +1,156 @@ +import { Effect, Redacted } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { admitted } from '../authorization-testing/authorization-requests.ts'; +import type { ConsentClient, FinishedConsent, HandleFinishAsked } from '../consent/consent-client.ts'; +import { + consentClientThrough, + consentClientWith, + handedOffAskedOf, + servedThroughAuto, + startAskedAt, + tokensKeptBy, + type ClientRegistration, + type ThroughAuto, +} from '../testing/index.ts'; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +async function throughAuto(changes: Partial = {}): Promise { + const serving = await servedThroughAuto(changes); + closing.push(serving.close); + return serving; +} + +function finished(client: ConsentClient, asked: HandleFinishAsked): Promise { + return Effect.runPromise(client.finish(asked)); +} + +function refused(client: ConsentClient, asked: HandleFinishAsked) { + return Effect.runPromise(Effect.flip(client.finish(asked))); +} + +function handedOffAt(serving: ThroughAuto, client: ConsentClient = consentClientThrough(serving)) { + return handedOffAskedOf(client, startAskedAt(serving.fake), serving.service); +} + +function heardAt({ service }: Pick, path: string): number { + return service.calls().filter((call) => call.path.startsWith(path)).length; +} + +const aToken: unknown = expect.stringMatching(/^token-/u); + +const aRefreshToken: unknown = expect.stringMatching(/^refresh-/u); + +const aConnectionKey: unknown = expect.stringMatching(/^[\w-]{43}$/u); + +describe('the finish of a consent through Auto’s consent service', () => { + it('takes the handoff with the verifier of its start, and answers the tokens with the connection key inside them', async () => { + const serving = await throughAuto(); + const client = consentClientThrough(serving); + const asked = await handedOffAt(serving, client); + + const done = await finished(client, asked); + const kept = tokensKeptBy(done); + + expect(kept).toEqual({ + access_token: aToken, + refresh_token: aRefreshToken, + expires_at: '1970-01-01T01:00:00.000Z', + scope: 'files.read', + token_type: 'Bearer', + connection_key: aConnectionKey, + }); + expect([done.scope, await admitted(serving.fake, kept.access_token)]).toEqual(['files.read', true]); + expect(serving.service.calls().at(-1)).toMatchObject({ body: { verifier: Redacted.value(asked.verifier) } }); + expect(JSON.stringify(done)).not.toContain(String(kept.connection_key)); + }); + + it('answers the client the service redeemed with, which the handoff names, whatever the start recorded', async () => { + const serving = await throughAuto(); + const client = consentClientThrough(serving); + const asked = await handedOffAt(serving, client); + + const done = await finished(client, { + ...asked, + client: { client_id: 'a-client-listed-before', client_source: 'auto' }, + }); + + expect(done.client).toEqual({ client_id: 'auto-runtime', client_source: 'auto' }); + }); + + it('keeps no refresh token and no end where the authorization server gives neither', async () => { + const serving = await throughAuto({ issuesRefreshTokens: false, expiresInSeconds: null }); + const client = consentClientThrough(serving); + + const done = await finished(client, await handedOffAt(serving, client)); + + expect(Object.keys(tokensKeptBy(done))).toEqual(['access_token', 'scope', 'token_type', 'connection_key']); + }); +}); + +describe('the handoff of a finish through Auto’s consent service', () => { + it('is taken once, and refused for another verifier and once its ten minutes are over', async () => { + const serving = await throughAuto(); + const client = consentClientThrough(serving); + const asked = await handedOffAt(serving, client); + const late = await handedOffAt(serving, client); + const otherVerifier = Redacted.make('another-verifier-of-forty-three-characters-long'); + + const wrong = await refused(client, { ...asked, verifier: otherVerifier }); + const taken = await finished(client, asked); + const again = await refused(client, asked); + serving.clock.setTo(600_000); + + expect([wrong, again, await refused(client, late)]).toEqual([ + { refused: 'code' }, + { refused: 'code' }, + { refused: 'code' }, + ]); + expect(tokensKeptBy(taken).token_type).toBe('Bearer'); + }); + + it('is refused as gone, and not taken, where the source of Auto is off or the start was not made with its client', async () => { + const serving = await throughAuto(); + const asked = await handedOffAt(serving); + const registered: HandleFinishAsked = { + ...asked, + client: { client_id: 'auto-runtime', client_source: 'registered' }, + }; + + expect([ + await refused(consentClientWith(), asked), + await refused(consentClientThrough(serving), registered), + ]).toEqual([{ refused: 'client_gone' }, { refused: 'client_gone' }]); + expect(heardAt(serving, '/handoffs/')).toBe(0); + }); +}); + +describe('the tokens a finish through Auto’s consent service takes', () => { + it('are revoked through the service where the server grants none of the scopes asked', async () => { + const serving = await throughAuto({ grantedScope: '' }); + const client = consentClientThrough(serving); + + expect(await refused(client, await handedOffAt(serving, client))).toEqual({ refused: 'no_scope' }); + expect([ + heardAt(serving, '/tokens/revoke'), + serving.fake.heard().filter(({ path }) => path === '/revoke').length, + ]).toEqual([1, 1]); + }); + + it('are revoked through the service by the revocation the finish offers', async () => { + const serving = await throughAuto(); + const client = consentClientThrough(serving); + const done = await finished(client, await handedOffAt(serving, client)); + + await Effect.runPromise(done.revoked); + + expect([heardAt(serving, '/tokens/revoke'), await admitted(serving.fake, tokensKeptBy(done).access_token)]).toEqual( + [1, false], + ); + }); +}); diff --git a/packages/tool-servers/src/consent-through-auto/auto-renewals.test.ts b/packages/tool-servers/src/consent-through-auto/auto-renewals.test.ts new file mode 100644 index 000000000..7bd85435e --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/auto-renewals.test.ts @@ -0,0 +1,221 @@ +import { Option, Redacted } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { admitted, postedTo } from '../authorization-testing/authorization-requests.ts'; +import { secretsOf } from '../bounds/secrets.ts'; +import { keptValueOf, type KeptTokens } from '../consent-tokens/kept-tokens.ts'; +import { renewalWords } from '../consent-tokens/renewal-words.ts'; +import type { ConsentClient } from '../consent/consent-client.ts'; +import { + autoClientsOf, + brokenWords, + connectedThroughAuto, + consentClientThrough, + consentClientWith, + memoryTokens, + rowOf, + servedOf, + servedThroughAuto, + servedTokensOf, + type ClientRegistration, + type ConsentConnected, + type MemoryTokens, + type ThroughAuto, +} from '../testing/index.ts'; +import { serveOnLoopback } from '../testing/loopback-server.ts'; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +const renewalTime = 3_540_000; + +interface Connected extends ThroughAuto { + readonly connection: ConsentConnected; + readonly first: KeptTokens; + readonly row: MemoryTokens; +} + +async function connectedAt(changes: Partial = {}): Promise { + const serving = await servedThroughAuto(changes); + closing.push(serving.close); + const connection = await connectedThroughAuto(serving); + return { ...serving, connection, first: connection.kept, row: rowOf(connection, serving.clock.now) }; +} + +function sourceOf(connected: Connected, consent: ConsentClient = consentClientThrough(connected)) { + return servedTokensOf(servedOf(connected.connection, connected.row), connected.clock.now, consent).source; +} + +function renewed({ clock }: Connected, source: ReturnType): Promise { + clock.setTo(renewalTime); + return source.token().catch((error: unknown) => error); +} + +function heardAt({ service }: Connected, path: string) { + return service.calls().filter((call) => call.path === path); +} + +function required(value: A | undefined): A { + return Option.getOrThrow(Option.fromNullishOr(value)); +} + +function unauthorizedAt({ fake }: Pick) { + return { response: new Response(null, { status: 401 }), serverUrl: new URL(fake.url), fetchFn: globalThis.fetch }; +} + +function rowHolding(connected: Connected, tokens: KeptTokens): MemoryTokens { + return memoryTokens(keptValueOf(tokens), { now: connected.clock.now }); +} + +async function forbiddingEverything(): Promise { + const forbidding = await serveOnLoopback(() => () => Promise.resolve(new Response('Forbidden', { status: 403 }))); + closing.push(forbidding.close); + return forbidding.origin; +} + +describe('the renewal of the tokens of a consent through Auto’s consent service', () => { + it('asks the service to refresh with the refresh token, the connection key and the resource, under the lease of its claim, and keeps the key with the new tokens', async () => { + const connected = await connectedAt(); + const { first } = connected; + + const token = await renewed(connected, sourceOf(connected)); + + expect(token).not.toBe(first.access_token); + expect(await admitted(connected.fake, token)).toBe(true); + expect(connected.row.claims()).toEqual([45_000]); + expect(connected.row.kept()).toEqual({ + access_token: token, + refresh_token: first.refresh_token, + expires_at: '1970-01-01T01:59:00.000Z', + scope: 'files.read', + token_type: 'Bearer', + connection_key: first.connection_key, + }); + expect(heardAt(connected, '/tokens/refresh').map(({ body }) => body)).toEqual([ + { + issuer: connected.fake.origin, + refresh_token: first.refresh_token, + connection_key: first.connection_key, + resource: `${connected.fake.origin}/mcp`, + }, + ]); + }); + + it('holds the connection key among the secrets it scrubs from the moment it reads the row', async () => { + const connected = await connectedAt(); + const secrets = secretsOf([]); + servedTokensOf( + servedOf(connected.connection, connected.row), + connected.clock.now, + consentClientThrough(connected), + secrets, + ); + + expect(secrets.scrub(String(connected.first.connection_key))).toBe('[redacted]'); + }); +}); + +describe('a renewal through Auto’s consent service that is refused', () => { + it('breaks the connection in the words of an ended consent where the service refuses the refresh token its row still holds', async () => { + const revoked = await connectedAt(); + const refusing = await connectedAt(); + await postedTo(revoked.fake, '/revoke', { token: required(revoked.first.refresh_token) }); + refusing.service.refuseNext('refresh'); + + expect([await renewed(revoked, sourceOf(revoked)), await renewed(refusing, sourceOf(refusing))]).toEqual([ + new Error(brokenWords('consent_ended')), + new Error(brokenWords('consent_ended')), + ]); + expect([revoked.row.ends(), refusing.row.ends()]).toEqual([['consent_ended'], ['consent_ended']]); + }); +}); + +describe('a renewal through Auto’s consent service without a key it knows', () => { + it('breaks the connection in the words of a key the service does not know, where it refuses the key the row holds', async () => { + const connected = await connectedAt(); + const row = rowHolding(connected, { ...connected.first, connection_key: 'a-key-the-service-never-gave' }); + + const failed = await sourceOf({ ...connected, row }) + .onUnauthorized(unauthorizedAt(connected)) + .catch((error: unknown) => error); + + expect(failed).toEqual(new Error(brokenWords('key_unknown'))); + expect(heardAt(connected, '/tokens/refresh')).toHaveLength(1); + }); + + it('breaks the connection so too where its row holds no key, and asks the service nothing', async () => { + const connected = await connectedAt(); + const { access_token: accessToken, refresh_token: refreshToken, token_type: tokenType } = connected.first; + const row = rowHolding(connected, { + access_token: accessToken, + refresh_token: required(refreshToken), + token_type: tokenType, + }); + + const failed = await sourceOf({ ...connected, row }) + .onUnauthorized(unauthorizedAt(connected)) + .catch((error: unknown) => error); + + expect(failed).toEqual(new Error(brokenWords('key_unknown'))); + expect(heardAt(connected, '/tokens/refresh')).toEqual([]); + }); +}); + +describe('a renewal through Auto’s consent service that cannot ask it', () => { + it('breaks the connection as a client gone where the source of Auto is off, and asks the service nothing', async () => { + const connected = await connectedAt(); + + const failed = await renewed(connected, sourceOf(connected, consentClientWith({ renewalSpreadMs: 0 }))); + + expect(failed).toEqual(new Error(brokenWords('client_gone'))); + expect(heardAt(connected, '/tokens/refresh')).toEqual([]); + }); + + it('fails the call within the bound of its request where the service stalls, in its own words, and breaks nothing', async () => { + const connected = await connectedAt(); + connected.service.stall(); + + const failed = await renewed(connected, sourceOf(connected, consentClientThrough(connected, { requestMs: 50 }))); + + expect(failed).toEqual(new Error(renewalWords.serviceUnanswered('files'))); + expect([connected.row.ends(), connected.row.version(), connected.row.claims()]).toEqual([[], 1, [15_050]]); + }); + + it('fails the call and breaks nothing where something answers 403 without a refusal of the service', async () => { + const connected = await connectedAt(); + const forbidding = consentClientWith({ + autoClients: autoClientsOf({ url: await forbiddingEverything() }), + renewalSpreadMs: 0, + }); + + const failed = await renewed(connected, sourceOf(connected, forbidding)); + + expect(failed).toEqual(new Error(renewalWords.serviceUnanswered('files'))); + expect([connected.row.ends(), connected.row.version()]).toEqual([[], 1]); + }); +}); + +describe('tokens renewed through Auto’s consent service that the store refuses to keep', () => { + it('are revoked through the service, and the call fails in the words of the store', async () => { + const connected = await connectedAt(); + const { access_token: accessToken, refresh_token: refreshToken, token_type: tokenType } = connected.first; + const lean = keptValueOf({ + access_token: accessToken, + refresh_token: required(refreshToken), + token_type: tokenType, + connection_key: required(connected.first.connection_key), + }); + const mostCharacters = Redacted.value(lean).length; + const row = rowOf({ ...connected.connection, tokens: lean }, connected.clock.now, mostCharacters); + + const failed = await sourceOf({ ...connected, row }) + .onUnauthorized(unauthorizedAt(connected)) + .catch((error: unknown) => error); + + expect(failed).toEqual(new Error(`A secret to keep holds more than ${mostCharacters} characters`)); + expect(heardAt(connected, '/tokens/revoke')).toHaveLength(1); + }); +}); diff --git a/packages/tool-servers/src/consent-through-auto/auto-renewals.ts b/packages/tool-servers/src/consent-through-auto/auto-renewals.ts new file mode 100644 index 000000000..898d70909 --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/auto-renewals.ts @@ -0,0 +1,99 @@ +import { Effect } from 'effect'; + +import { keptHandedOf, type HandedTokens, type KeptTokens } from '../consent-tokens/kept-tokens.ts'; +import { RefreshEnded } from '../consent-tokens/refresh-endings.ts'; +import { renewalWords } from '../consent-tokens/renewal-words.ts'; +import type { Refreshing } from '../consent-tokens/renewed-tokens.ts'; +import type { Requests } from '../consent/acting-clients.ts'; +import type { + ConsentRefusal, + ConsentServed, + RevocationFailure, + RevokeAsked, + TokensEnded, +} from '../consent/consent-client.ts'; +import { scopeRenewedOf } from '../consent/scopes.ts'; +import { serverMetadataOf } from '../consent/server-metadata.ts'; +import { autoOf, type AutoOn, type ThroughAuto } from './auto-clients.ts'; + +interface Renewed { + readonly handed: HandedTokens; + readonly refreshToken: string; + readonly previous: KeptTokens; + readonly key: string; +} + +interface RefreshAsked { + readonly issuer: string; + readonly resource: string | undefined; + readonly name: string; +} + +const clientGone: ConsentRefusal = { refused: 'client_gone' }; + +const unknownKey: RevocationFailure = { refused: 'unknown_key' }; + +const endings: Readonly> = { + invalid_grant: 'consent_ended', + unknown_key: 'key_unknown', +}; + +function renewedOf({ handed, refreshToken, previous, key }: Renewed): KeptTokens { + const kept = { ...handed, refresh_token: handed.refresh_token ?? refreshToken }; + return keptHandedOf(kept, scopeRenewedOf(handed, previous), key); +} + +type RefreshRefused = { readonly refused: 'unanswered' | keyof typeof endings }; + +function failureOfARefresh({ refused }: RefreshRefused, name: string): Error | RefreshEnded { + return refused === 'unanswered' + ? new Error(renewalWords.serviceUnanswered(name)) + : new RefreshEnded({ ended: endings[refused] }); +} + +function revokedThrough(auto: AutoOn, issuer: string, tokens: KeptTokens): Effect.Effect { + const key = tokens.connection_key; + return key === undefined ? Effect.fail(unknownKey) : auto.service.revoke({ issuer, tokens, connectionKey: key }); +} + +function refreshingOf(auto: AutoOn, { issuer, resource, name }: RefreshAsked): Refreshing { + return { + refreshed: (refreshToken, previous) => { + const key = previous.connection_key; + if (key === undefined) { + return Effect.fail(new RefreshEnded({ ended: 'key_unknown' })); + } + return auto.service.refresh({ issuer, refreshToken, connectionKey: key, resource }).pipe( + Effect.map((handed) => renewedOf({ handed, refreshToken, previous, key })), + Effect.mapError((refusal) => failureOfARefresh(refusal, name)), + ); + }, + revoked: (tokens) => Effect.ignore(revokedThrough(auto, issuer, tokens)), + }; +} + +function refreshingThroughAuto( + served: ConsentServed, + name: string, + { fetchFor, through }: { readonly fetchFor: Requests['fetchFor']; readonly through: ThroughAuto }, +): Effect.Effect { + return Effect.gen(function* () { + const { resource } = yield* serverMetadataOf(served, fetchFor(served.reaches)); + const auto = autoOf(served.client, through); + return auto === undefined + ? yield* Effect.fail(clientGone) + : refreshingOf(auto, { issuer: served.way.issuer, resource, name }); + }); +} + +function revokedThroughAuto(asked: RevokeAsked, through: ThroughAuto): Effect.Effect { + const auto = autoOf(asked.client, through); + return auto === undefined ? Effect.fail(clientGone) : revokedThrough(auto, asked.way.issuer, asked.tokens); +} + +export function autoRenewalsOf(fetchFor: Requests['fetchFor'], through: ThroughAuto) { + return { + refreshing: (served: ConsentServed, name: string) => refreshingThroughAuto(served, name, { fetchFor, through }), + revoke: (asked: RevokeAsked) => revokedThroughAuto(asked, through), + }; +} diff --git a/packages/tool-servers/src/consent-through-auto/auto-revocations.test.ts b/packages/tool-servers/src/consent-through-auto/auto-revocations.test.ts new file mode 100644 index 000000000..f9ecc662f --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/auto-revocations.test.ts @@ -0,0 +1,108 @@ +import { Effect, Option } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { admitted } from '../authorization-testing/authorization-requests.ts'; +import type { KeptTokens } from '../consent-tokens/kept-tokens.ts'; +import type { ConsentClient, RevokeAsked } from '../consent/consent-client.ts'; +import { + connectedThroughAuto, + consentClientThrough, + consentClientWith, + everyHost, + returnAddress, + servedThroughAuto, + type ConsentConnected, + type ThroughAuto, +} from '../testing/index.ts'; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +interface Connected extends ThroughAuto { + readonly connection: ConsentConnected; + readonly first: KeptTokens; +} + +async function connectedAt(): Promise { + const serving = await servedThroughAuto(); + closing.push(serving.close); + const connection = await connectedThroughAuto(serving); + return { ...serving, connection, first: connection.kept }; +} + +function revokeAskedOf({ fake, connection }: Connected, tokens: KeptTokens = connection.kept): RevokeAsked { + return { + server: { type: 'mcp', url: fake.url }, + way: { issuer: fake.origin, scope: 'files.read' }, + returnAddress, + reaches: everyHost, + client: connection.client, + tokens, + }; +} + +function revokedBy(consent: ConsentClient, asked: RevokeAsked) { + return Effect.runPromise(Effect.flip(consent.revoke(asked))); +} + +function heardAt({ service }: Pick, path: string) { + return service.calls().filter((call) => call.path === path); +} + +function required(value: A | undefined): A { + return Option.getOrThrow(Option.fromNullishOr(value)); +} + +describe('the revocation through Auto’s consent service a removal asks for', () => { + it('revokes the tokens with the connection key, and with them the grant at the authorization server', async () => { + const connected = await connectedAt(); + + const revoked = await Effect.runPromise( + Effect.result(consentClientThrough(connected).revoke(revokeAskedOf(connected))), + ); + + expect(revoked).toMatchObject({ _tag: 'Success' }); + expect(await admitted(connected.fake, connected.first.access_token)).toBe(false); + expect(heardAt(connected, '/tokens/revoke').map(({ body }) => body)).toEqual([ + { + issuer: connected.fake.origin, + access_token: connected.first.access_token, + refresh_token: connected.first.refresh_token, + connection_key: connected.first.connection_key, + }, + ]); + }); +}); + +describe('a revocation through Auto’s consent service that does not happen', () => { + it('is refused as not revoked in the status the authorization server answered, unanswered where the service is gone, and as gone where the source of Auto is off', async () => { + const connected = await connectedAt(); + const gone = await connectedAt(); + connected.service.refuseNext('revoke'); + await gone.service.close(); + + expect([ + await revokedBy(consentClientThrough(connected), revokeAskedOf(connected)), + await revokedBy(consentClientThrough(gone), revokeAskedOf(gone)), + await revokedBy(consentClientWith(), revokeAskedOf(connected)), + ]).toEqual([{ refused: 'not_revoked', status: 503 }, { refused: 'unanswered' }, { refused: 'client_gone' }]); + expect(await admitted(connected.fake, connected.first.access_token)).toBe(true); + }); + + it('is refused as a key the service does not know where it no longer keeps it, and where the row holds none, which asks nothing', async () => { + const connected = await connectedAt(); + const client = consentClientThrough(connected); + await Effect.runPromise(client.revoke(revokeAskedOf(connected))); + const { access_token: accessToken, refresh_token: refreshToken, token_type: tokenType } = connected.first; + const unkeyed = { access_token: accessToken, refresh_token: required(refreshToken), token_type: tokenType }; + + expect([ + await revokedBy(client, revokeAskedOf(connected)), + await revokedBy(client, revokeAskedOf(connected, unkeyed)), + ]).toEqual([{ refused: 'unknown_key' }, { refused: 'unknown_key' }]); + expect(heardAt(connected, '/tokens/revoke')).toHaveLength(2); + }); +}); diff --git a/packages/tool-servers/src/consent-through-auto/consent-service.test.ts b/packages/tool-servers/src/consent-through-auto/consent-service.test.ts new file mode 100644 index 000000000..57ca0d336 --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/consent-service.test.ts @@ -0,0 +1,144 @@ +import { Effect, Redacted } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { serviceCallsAt, studioSession } from '../testing/index.ts'; +import { serveOnLoopback } from '../testing/loopback-server.ts'; +import type { ServiceStartAsked } from './consent-service.ts'; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +async function answeringEvery(answer: () => Response): Promise { + const answering = await serveOnLoopback(() => () => Promise.resolve(answer())); + closing.push(answering.close); + return answering.origin; +} + +const answered = 'answered'; + +function outcomeOf(effect: Effect.Effect): Promise { + return Effect.runPromise(Effect.match(effect, { onFailure: (failure) => failure, onSuccess: () => answered })); +} + +const issuer = 'https://accounts.example.com'; + +const start: ServiceStartAsked = { + issuer, + scope: undefined, + parameters: undefined, + resource: undefined, + returnTo: { kind: 'hosted', base: 'https://studio.example.com/' }, + challenge: 'a-challenge', +}; + +function everyCallAt(url: string) { + const calls = serviceCallsAt({ url }); + const tokens = { access_token: 'a', token_type: 'Bearer' }; + return Promise.all([ + outcomeOf(calls.clients), + outcomeOf(calls.start(start)), + outcomeOf(calls.describe('a-state', studioSession)), + outcomeOf(calls.redeem('a-state', studioSession, { code: 'a-code' })), + outcomeOf(calls.takeHandoff('a-handle', Redacted.make('a-verifier'))), + outcomeOf(calls.refresh({ issuer, refreshToken: 'r', connectionKey: 'k', resource: undefined })), + outcomeOf(calls.revoke({ issuer, tokens, connectionKey: 'k' })), + ]); +} + +const unanswered = { refused: 'unanswered' }; + +describe('a consent service that answers a status the contract does not hold', () => { + it('is unanswered for every call, as an authorization server that does not answer is', async () => { + const url = await answeringEvery(() => new Response('not a document', { status: 500 })); + + expect(await everyCallAt(url)).toEqual(Array.from({ length: 7 }, () => unanswered)); + }); + + it('answers a start it refuses as one it will not make, and every other refusal without a word of its own as unanswered', async () => { + const url = await answeringEvery(() => new Response('not a document', { status: 400 })); + + expect(await everyCallAt(url)).toEqual([ + unanswered, + { refused: 'start_refused' }, + unanswered, + unanswered, + unanswered, + unanswered, + unanswered, + ]); + }); +}); + +describe('a consent service that answers what the contract does not hold', () => { + it('is unanswered for every call whose answer it must read, and answered for a revocation, which it need not', async () => { + const url = await answeringEvery(() => new Response('not a document', { status: 200 })); + + expect(await everyCallAt(url)).toEqual([...Array.from({ length: 6 }, () => unanswered), answered]); + }); + + it('is unanswered for a handoff whose tokens end at a time that is none', async () => { + const tokens = { access_token: 'a', token_type: 'Bearer', expires_at: 'tomorrow' }; + const url = await answeringEvery(() => Response.json({ client_id: 'auto-runtime', tokens, connection_key: 'k' })); + + expect(await outcomeOf(serviceCallsAt({ url }).takeHandoff('a-handle', Redacted.make('a-verifier')))).toEqual( + unanswered, + ); + }); +}); + +function problemAnswer(status: number, members: Readonly>): () => Response { + return () => + Response.json({ type: 'about:blank', status, title: 'Refused', detail: 'Refused', ...members }, { status }); +} + +async function refreshAt(answer: () => Response) { + const calls = serviceCallsAt({ url: await answeringEvery(answer) }); + return outcomeOf(calls.refresh({ issuer, refreshToken: 'r', connectionKey: 'k', resource: undefined })); +} + +async function revokeAt(answer: () => Response) { + const calls = serviceCallsAt({ url: await answeringEvery(answer) }); + return outcomeOf(calls.revoke({ issuer, tokens: { access_token: 'a', token_type: 'Bearer' }, connectionKey: 'k' })); +} + +async function handoffAt(answer: () => Response) { + return outcomeOf(serviceCallsAt({ url: await answeringEvery(answer) }).takeHandoff('h', Redacted.make('v'))); +} + +describe('the refusals a consent service names', () => { + it('end a renewal only where they name the refusal at the status the contract gives it, and are unanswered otherwise', async () => { + expect([ + await refreshAt(problemAnswer(400, { refused: 'invalid_grant' })), + await refreshAt(problemAnswer(403, { refused: 'unknown_key' })), + await refreshAt(problemAnswer(403, {})), + await refreshAt(problemAnswer(400, { refused: 'unknown_key' })), + await refreshAt(() => new Response('Forbidden', { status: 403 })), + ]).toEqual([{ refused: 'invalid_grant' }, { refused: 'unknown_key' }, unanswered, unanswered, unanswered]); + }); + + it('say a revocation did not happen only with the status of the authorization server on a 502, and a key unknown on a 403', async () => { + expect([ + await revokeAt(problemAnswer(502, { refused: 'not_revoked', vendor_status: 401 })), + await revokeAt(problemAnswer(502, { refused: 'not_revoked' })), + await revokeAt(problemAnswer(400, { refused: 'not_revoked', vendor_status: 401 })), + await revokeAt(problemAnswer(403, { refused: 'unknown_key' })), + await revokeAt(problemAnswer(403, {})), + ]).toEqual([ + { refused: 'not_revoked', status: 401 }, + unanswered, + unanswered, + { refused: 'unknown_key' }, + unanswered, + ]); + }); + + it('refuse a handoff for a wrong verifier only where they name it', async () => { + expect([ + await handoffAt(problemAnswer(403, { refused: 'wrong_verifier' })), + await handoffAt(problemAnswer(403, {})), + ]).toEqual([{ refused: 'code' }, unanswered]); + }); +}); diff --git a/packages/tool-servers/src/consent-through-auto/consent-service.ts b/packages/tool-servers/src/consent-through-auto/consent-service.ts new file mode 100644 index 000000000..8a50f28ab --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/consent-service.ts @@ -0,0 +1,295 @@ +import type { FetchLike } from '@modelcontextprotocol/client'; +import { Effect, Option, Redacted, Schema } from 'effect'; + +import { HandedTokensSchema, type HandedTokens, type KeptTokens } from '../consent-tokens/kept-tokens.ts'; +import { ClientIdSchema, type ServiceClient } from '../consent/client-sources.ts'; + +export interface ConsentReturn { + readonly kind: 'hosted' | 'loopback'; + readonly base: string; +} + +export interface ServiceRequesting { + readonly fetch: FetchLike; + readonly url: string; + readonly requestMs: number; + readonly requestTimeout: (ms: number) => AbortSignal; +} + +export interface ServiceStartAsked { + readonly issuer: string; + readonly scope: string | undefined; + readonly parameters: Readonly> | undefined; + readonly resource: string | undefined; + readonly returnTo: ConsentReturn; + readonly challenge: string; +} + +interface ServiceRefreshAsked { + readonly issuer: string; + readonly refreshToken: string; + readonly connectionKey: string; + readonly resource: string | undefined; +} + +interface ServiceRevokeAsked { + readonly issuer: string; + readonly tokens: KeptTokens; + readonly connectionKey: string; +} + +const ClientsSchema = Schema.Struct({ + clients: Schema.Array(Schema.Struct({ issuer: Schema.String, client_id: ClientIdSchema })), +}); + +const GivenSchema = Schema.String.check(Schema.isMinLength(1)); + +const ServiceStartedSchema = Schema.Struct({ state: GivenSchema, url: GivenSchema, expires_at: GivenSchema }); + +const DescribedSchema = Schema.Struct({ + issuer: Schema.String, + return: Schema.Struct({ kind: Schema.Literals(['hosted', 'loopback']), base: Schema.optionalKey(GivenSchema) }), +}); + +const RedeemedSchema = Schema.Struct({ handle: GivenSchema, expires_at: GivenSchema }); + +const HandedOffSchema = Schema.Struct({ + client_id: ClientIdSchema, + tokens: HandedTokensSchema, + connection_key: GivenSchema, +}); + +const RefreshedSchema = Schema.Struct({ tokens: HandedTokensSchema }); + +const ProblemSchema = Schema.Struct({ refused: Schema.String, vendor_status: Schema.optionalKey(Schema.Int) }); + +type ServiceStarted = typeof ServiceStartedSchema.Type; + +type DescribedConsent = typeof DescribedSchema.Type; + +type Redeemed = typeof RedeemedSchema.Type; + +type HandedOff = typeof HandedOffSchema.Type; + +type Unanswered = { readonly refused: 'unanswered' }; + +type StartRefusal = Unanswered | { readonly refused: 'start_refused' }; + +type RedeemRefusal = Unanswered | { readonly refused: 'code' | 'no_consent' | 'redeemed' }; + +type HandoffRefusal = Unanswered | { readonly refused: 'code' }; + +type RefreshRefusal = Unanswered | { readonly refused: 'invalid_grant' } | { readonly refused: 'unknown_key' }; + +type RevokeRefusal = + | Unanswered + | { readonly refused: 'unknown_key' } + | { readonly refused: 'not_revoked'; readonly status: number }; + +export interface ConsentServiceCalls { + readonly clients: Effect.Effect; + readonly start: (asked: ServiceStartAsked) => Effect.Effect; + readonly describe: ( + state: string, + session: Redacted.Redacted, + ) => Effect.Effect; + readonly redeem: ( + state: string, + session: Redacted.Redacted, + returned: { readonly code: string; readonly iss?: string }, + ) => Effect.Effect; + readonly takeHandoff: (handle: string, verifier: Redacted.Redacted) => Effect.Effect; + readonly refresh: (asked: ServiceRefreshAsked) => Effect.Effect; + readonly revoke: (asked: ServiceRevokeAsked) => Effect.Effect; +} + +interface Answered { + readonly status: number; + readonly text: string; +} + +interface Sending { + readonly path: string; + readonly method: 'GET' | 'POST'; + readonly body?: unknown; + readonly session?: Redacted.Redacted; +} + +const unanswered: Unanswered = { refused: 'unanswered' }; + +const challengeMethod = 'S256'; + +const status = { + ok: 200, + created: 201, + refused: 400, + forbidden: 403, + notFound: 404, + conflict: 409, + badGateway: 502, +} as const; + +const decodeProblem = Schema.decodeUnknownOption(Schema.fromJsonString(ProblemSchema)); + +const decodeClients = Schema.decodeUnknownOption(Schema.fromJsonString(ClientsSchema)); + +const decodeStarted = Schema.decodeUnknownOption(Schema.fromJsonString(ServiceStartedSchema)); + +const decodeDescribed = Schema.decodeUnknownOption(Schema.fromJsonString(DescribedSchema)); + +const decodeRedeemed = Schema.decodeUnknownOption(Schema.fromJsonString(RedeemedSchema)); + +const decodeHandedOff = Schema.decodeUnknownOption(Schema.fromJsonString(HandedOffSchema)); + +const decodeRefreshed = Schema.decodeUnknownOption(Schema.fromJsonString(RefreshedSchema)); + +function headersOf({ body, session }: Sending): Readonly> { + return { + accept: 'application/json', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...(session === undefined ? {} : { authorization: `Bearer ${Redacted.value(session)}` }), + }; +} + +function sent(requesting: ServiceRequesting, sending: Sending): Effect.Effect { + const { fetch, url, requestMs, requestTimeout } = requesting; + return Effect.tryPromise({ + try: async () => { + const answer = await fetch(new URL(sending.path, url).href, { + method: sending.method, + headers: headersOf(sending), + redirect: 'manual', + signal: requestTimeout(requestMs), + ...(sending.body === undefined ? {} : { body: JSON.stringify(sending.body) }), + }); + return { status: answer.status, text: await answer.text() }; + }, + catch: () => unanswered, + }); +} + +function decodedAs(decode: (text: string) => Option.Option, { text }: Answered): Effect.Effect { + return Option.match(decode(text), { + onNone: () => Effect.fail(unanswered), + onSome: (decoded) => Effect.succeed(decoded), + }); +} + +function problemOf({ text }: Answered): typeof ProblemSchema.Type | undefined { + return Option.getOrUndefined(decodeProblem(text)); +} + +function isRefused(answered: Answered, atStatus: number, refused: string): boolean { + return answered.status === atStatus && problemOf(answered)?.refused === refused; +} + +function pathOf(...parts: readonly string[]): string { + return `/${parts.map((part) => encodeURIComponent(part)).join('/')}`; +} + +function started(answered: Answered): Effect.Effect { + if (answered.status === status.refused) { + return Effect.fail({ refused: 'start_refused' }); + } + return answered.status === status.created ? decodedAs(decodeStarted, answered) : Effect.fail(unanswered); +} + +function described(answered: Answered): Effect.Effect { + if (answered.status === status.notFound) { + return Effect.undefined; + } + return answered.status === status.ok ? decodedAs(decodeDescribed, answered) : Effect.fail(unanswered); +} + +const redeemRefusals: ReadonlyMap = new Map([ + [status.notFound, { refused: 'no_consent' }], + [status.conflict, { refused: 'redeemed' }], +]); + +function redeemed(answered: Answered): Effect.Effect { + if (answered.status === status.ok) { + return decodedAs(decodeRedeemed, answered); + } + if (isRefused(answered, status.refused, 'code_refused')) { + return Effect.fail({ refused: 'code' }); + } + return Effect.fail(redeemRefusals.get(answered.status) ?? unanswered); +} + +function handedOff(answered: Answered): Effect.Effect { + if (answered.status === status.ok) { + return decodedAs(decodeHandedOff, answered); + } + const isRefusedHandle = + answered.status === status.notFound || isRefused(answered, status.forbidden, 'wrong_verifier'); + return Effect.fail(isRefusedHandle ? { refused: 'code' } : unanswered); +} + +function refreshRefusalOf(answered: Answered): RefreshRefusal { + if (isRefused(answered, status.refused, 'invalid_grant')) { + return { refused: 'invalid_grant' }; + } + return isRefused(answered, status.forbidden, 'unknown_key') ? { refused: 'unknown_key' } : unanswered; +} + +function refreshed(answered: Answered): Effect.Effect { + if (answered.status === status.ok) { + return Effect.map(decodedAs(decodeRefreshed, answered), ({ tokens }) => tokens); + } + return Effect.fail(refreshRefusalOf(answered)); +} + +function revokeRefusalOf(answered: Answered): RevokeRefusal { + if (isRefused(answered, status.forbidden, 'unknown_key')) { + return { refused: 'unknown_key' }; + } + const vendorStatus = isRefused(answered, status.badGateway, 'not_revoked') + ? problemOf(answered)?.vendor_status + : undefined; + return vendorStatus === undefined ? unanswered : { refused: 'not_revoked', status: vendorStatus }; +} + +function revoked(answered: Answered): Effect.Effect { + return answered.status >= status.ok && answered.status < 300 ? Effect.void : Effect.fail(revokeRefusalOf(answered)); +} + +function startBodyOf({ issuer, scope, parameters, resource, returnTo, challenge }: ServiceStartAsked) { + return { issuer, scope, parameters, resource, return: returnTo, challenge, challenge_method: challengeMethod }; +} + +function revokeBodyOf({ issuer, tokens, connectionKey }: ServiceRevokeAsked) { + const { access_token: accessToken, refresh_token: refreshToken } = tokens; + return { issuer, access_token: accessToken, refresh_token: refreshToken, connection_key: connectionKey }; +} + +function refreshBodyOf({ issuer, refreshToken, connectionKey, resource }: ServiceRefreshAsked) { + return { issuer, refresh_token: refreshToken, connection_key: connectionKey, resource }; +} + +export function consentServiceOf(requesting: ServiceRequesting): ConsentServiceCalls { + const send = (sending: Sending) => sent(requesting, sending); + return { + clients: Effect.flatMap(send({ path: '/clients', method: 'GET' }), (answered) => + answered.status === status.ok + ? Effect.map(decodedAs(decodeClients, answered), ({ clients }) => clients) + : Effect.fail(unanswered), + ), + start: (asked) => Effect.flatMap(send({ path: '/consents', method: 'POST', body: startBodyOf(asked) }), started), + describe: (state, session) => + Effect.flatMap(send({ path: pathOf('consents', state), method: 'GET', session }), described), + redeem: (state, session, returned) => + Effect.flatMap( + send({ path: pathOf('consents', state, 'redeem'), method: 'POST', session, body: returned }), + redeemed, + ), + takeHandoff: (handle, verifier) => + Effect.flatMap( + send({ path: pathOf('handoffs', handle), method: 'POST', body: { verifier: Redacted.value(verifier) } }), + handedOff, + ), + refresh: (asked) => + Effect.flatMap(send({ path: '/tokens/refresh', method: 'POST', body: refreshBodyOf(asked) }), refreshed), + revoke: (asked) => + Effect.flatMap(send({ path: '/tokens/revoke', method: 'POST', body: revokeBodyOf(asked) }), revoked), + }; +} diff --git a/packages/tool-servers/src/consent-through-auto/consents-by-source.ts b/packages/tool-servers/src/consent-through-auto/consents-by-source.ts new file mode 100644 index 000000000..8e628cff9 --- /dev/null +++ b/packages/tool-servers/src/consent-through-auto/consents-by-source.ts @@ -0,0 +1,84 @@ +import { Effect, Option } from 'effect'; + +import { requestBoundsOf } from '../bounds/call-bounds.ts'; +import { tokenRequestsOf, type TokenRequests } from '../consent-tokens/token-requests.ts'; +import { isServerSource, requestsOf, type Requests } from '../consent/acting-clients.ts'; +import { serviceClientAt, sourcesHad, sourceTakenOf, type ServiceClient } from '../consent/client-sources.ts'; +import type { + ConsentRefusal, + FinishAsked, + FinishedConsent, + LoadedOptions, + StartAsked, + StartedConsent, +} from '../consent/consent-client.ts'; +import { serverConsentsOf, type ServerConsents } from '../consent/consent-requests.ts'; +import { servedKindOf } from './auto-clients.ts'; +import { handedOff, startedThroughAuto } from './auto-consents.ts'; +import { autoRenewalsOf } from './auto-renewals.ts'; + +export interface ConsentRequests extends TokenRequests { + readonly start: (asked: StartAsked) => Effect.Effect; + readonly finish: (asked: FinishAsked) => Effect.Effect; +} + +interface Consenting { + readonly options: LoadedOptions; + readonly requests: Requests; + readonly server: ServerConsents; +} + +const studioElsewhere: ConsentRefusal = { refused: 'studio_elsewhere' }; + +const noServiceClients: readonly ServiceClient[] = []; + +function required(value: A | undefined): A { + return Option.getOrThrow(Option.fromNullishOr(value)); +} + +function takenThroughAutoAlone(asked: StartAsked, options: LoadedOptions): Effect.Effect { + return Effect.map( + options.serviceClients, + (listed) => sourceTakenOf(asked.way, { ...options, serviceClients: listed }) === 'auto', + ); +} + +function started(asked: StartAsked, { options, requests, server }: Consenting) { + return Effect.gen(function* () { + const { auto } = options; + const kind = servedKindOf(auto, asked.studioBase); + const listed = kind === undefined ? noServiceClients : yield* options.serviceClients; + const had = { ...options, serviceClients: listed }; + if (sourceTakenOf(asked.way, had) === 'auto' && auto.enabled && kind !== undefined) { + const { client_id: clientId } = required(serviceClientAt(listed, asked.way.issuer)); + return yield* startedThroughAuto(asked, requests.fetchFor, { auto, clientId, kind }); + } + const sources = sourcesHad(asked.way, had); + if (sources.length === 0 && kind === undefined && (yield* takenThroughAutoAlone(asked, options))) { + return yield* Effect.fail(studioElsewhere); + } + return yield* server.start( + asked, + sources.filter((source) => isServerSource(source)), + ); + }); +} + +function finished(asked: FinishAsked, { options, requests, server }: Consenting) { + return 'handle' in asked ? handedOff(asked, requests.fetchFor, options.auto) : server.finish(asked); +} + +export function consentRequests(options: LoadedOptions): ConsentRequests { + const bounds = requestBoundsOf(options); + const requests = requestsOf({ ...options, ...bounds }); + const consenting: Consenting = { options, requests, server: serverConsentsOf(requests) }; + return { + start: (asked) => started(asked, consenting), + finish: (asked) => finished(asked, consenting), + ...tokenRequestsOf( + requests, + { requestMs: bounds.requestMs, renewalSpreadMs: options.renewalSpreadMs }, + autoRenewalsOf(requests.fetchFor, options.auto), + ), + }; +} diff --git a/packages/tool-servers/src/consent-tokens/consent-calls.test.ts b/packages/tool-servers/src/consent-tokens/consent-calls.test.ts index 533766974..64b89c5ce 100644 --- a/packages/tool-servers/src/consent-tokens/consent-calls.test.ts +++ b/packages/tool-servers/src/consent-tokens/consent-calls.test.ts @@ -22,6 +22,7 @@ import { serveFakeApi, servedForConsent, servedOf, + studioBase, type FakeApiServer, type FakeMcpServer, clockAt, @@ -130,6 +131,7 @@ async function connectedToApi(api: FakeApiServer) { returnAddress, reaches: everyHost, state: 'state-1', + studioBase, }); const finished = await Effect.runPromise(consent.finish(asked).pipe(Effect.provide(TestClock.layer()))); const clock = clockAt(0); diff --git a/packages/tool-servers/src/consent-tokens/kept-tokens.ts b/packages/tool-servers/src/consent-tokens/kept-tokens.ts index 2649be127..1f678cb45 100644 --- a/packages/tool-servers/src/consent-tokens/kept-tokens.ts +++ b/packages/tool-servers/src/consent-tokens/kept-tokens.ts @@ -1,15 +1,25 @@ import { DateTime, Option, Redacted, Schema } from 'effect'; -const KeptTokensSchema = Schema.Struct({ +const tokenFields = { access_token: Schema.String, refresh_token: Schema.optionalKey(Schema.String), expires_at: Schema.optionalKey(Schema.String), scope: Schema.optionalKey(Schema.String), token_type: Schema.String, -}); +}; + +const KeptTokensSchema = Schema.Struct({ ...tokenFields, connection_key: Schema.optionalKey(Schema.String) }); export type KeptTokens = typeof KeptTokensSchema.Type; +const TimeSchema = Schema.String.check( + Schema.makeFilter((text: string) => Number.isFinite(Date.parse(text)) || 'Expected a time in ISO 8601'), +); + +export const HandedTokensSchema = Schema.Struct({ ...tokenFields, expires_at: Schema.optionalKey(TimeSchema) }); + +export type HandedTokens = typeof HandedTokensSchema.Type; + const AnsweredTokensSchema = Schema.Struct({ access_token: Schema.String, refresh_token: Schema.optionalKey(Schema.String), @@ -61,9 +71,29 @@ export function keptOf(answered: AnsweredTokens, scope: string | undefined, nowM }; } +export function keptHandedOf(handed: HandedTokens, scope: string | undefined, key: string): KeptTokens { + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_at: expiresAt, + token_type: tokenType, + } = handed; + return { + access_token: accessToken, + ...(refreshToken === undefined ? {} : { refresh_token: refreshToken }), + ...(expiresAt === undefined ? {} : { expires_at: expiresAt }), + ...(scope === undefined ? {} : { scope }), + token_type: tokenType, + connection_key: key, + }; +} + export function heldAmongSecrets(tokens: KeptTokens, { holder, hold }: TokenHolding): void { hold(`${holder} access token`, tokens.access_token); if (tokens.refresh_token !== undefined) { hold(`${holder} refresh token`, tokens.refresh_token); } + if (tokens.connection_key !== undefined) { + hold(`${holder} connection key`, tokens.connection_key); + } } diff --git a/packages/tool-servers/src/consent-tokens/refresh-endings.ts b/packages/tool-servers/src/consent-tokens/refresh-endings.ts new file mode 100644 index 000000000..85747cb81 --- /dev/null +++ b/packages/tool-servers/src/consent-tokens/refresh-endings.ts @@ -0,0 +1,13 @@ +import { Data } from 'effect'; + +import type { TokensEnded } from '../consent/consent-client.ts'; +import { isInvalidGrant } from '../consent/consent-failures.ts'; + +export class RefreshEnded extends Data.TaggedError('refresh_ended')<{ readonly ended: TokensEnded }> {} + +export function endingOf(failure: unknown): TokensEnded | undefined { + if (failure instanceof RefreshEnded) { + return failure.ended; + } + return isInvalidGrant(failure) ? 'consent_ended' : undefined; +} diff --git a/packages/tool-servers/src/consent-tokens/renewal-words.ts b/packages/tool-servers/src/consent-tokens/renewal-words.ts index e2d56a3bc..c8e473701 100644 --- a/packages/tool-servers/src/consent-tokens/renewal-words.ts +++ b/packages/tool-servers/src/consent-tokens/renewal-words.ts @@ -1,6 +1,8 @@ export const renewalWords = { unanswered: (name: string) => `The authorization server of the connection ${name} did not answer as an authorization server does, so its tokens were not renewed; the next call tries again`, + serviceUnanswered: (name: string) => + `Auto's consent service did not answer for the connection ${name}, so its tokens were not renewed; the next call tries again`, unrenewable: (name: string) => `The authorization server of the connection ${name} no longer answers as it did when the connection was made, so its tokens cannot be renewed`, unfinished: (name: string, waitMs: number) => diff --git a/packages/tool-servers/src/consent-tokens/renewed-tokens.ts b/packages/tool-servers/src/consent-tokens/renewed-tokens.ts index 8ce62608c..cc46852c9 100644 --- a/packages/tool-servers/src/consent-tokens/renewed-tokens.ts +++ b/packages/tool-servers/src/consent-tokens/renewed-tokens.ts @@ -9,9 +9,9 @@ import type { TokenServing, TokensEnded, } from '../consent/consent-client.ts'; -import { isInvalidGrant } from '../consent/consent-failures.ts'; import type { ConnectionUnready } from '../org-servers/connection-source.ts'; import { heldAmongSecrets, keptValueOf, type KeptTokens } from './kept-tokens.ts'; +import { endingOf } from './refresh-endings.ts'; import { renewalWords } from './renewal-words.ts'; export interface Refreshing { @@ -24,7 +24,7 @@ export interface Refreshing { } export interface Renewal { - readonly refreshing: (served: ConsentServed) => Effect.Effect; + readonly refreshing: (served: ConsentServed, name: string) => Effect.Effect; readonly leaseMs: number; readonly waitMs: number; readonly spreadMs: number; @@ -127,9 +127,9 @@ function takenFrom(row: HeldTokens, renewing: Renewing): Held { return heldOf(row.tokens, row.version, renewing); } -function afterRefusedGrant(held: Held, renewing: Renewing): Effect.Effect { +function afterRefusedGrant(held: Held, renewing: Renewing, end: TokensEnded): Effect.Effect { return Effect.flatMap(readOf(renewing), (row) => - row.version === held.version ? ended(renewing, 'consent_ended') : Effect.succeed(takenFrom(row, renewing)), + row.version === held.version ? ended(renewing, end) : Effect.succeed(takenFrom(row, renewing)), ); } @@ -161,8 +161,10 @@ function exchangedFor(claimed: Claimed, renewing: Renewing): Effect.Effect - isInvalidGrant(failure) ? afterRefusedGrant(held, renewing) : failedUnder(claimed, failure), + onFailure: (failure) => { + const end = endingOf(failure); + return end === undefined ? failedUnder(claimed, failure) : afterRefusedGrant(held, renewing, end); + }, onSuccess: (renewed) => keptIn(claimed, renewed, renewing), }), ); @@ -175,7 +177,7 @@ function refreshedFrom(held: Held, renewing: Renewing): Effect.Effect refusedWith(refusal, renewing))); const claim = yield* renewing.served.tokens.claim(held.version, renewing.renewal.leaseMs); return claim.claimed diff --git a/packages/tool-servers/src/consent-tokens/token-requests.ts b/packages/tool-servers/src/consent-tokens/token-requests.ts index 548274989..d704d0747 100644 --- a/packages/tool-servers/src/consent-tokens/token-requests.ts +++ b/packages/tool-servers/src/consent-tokens/token-requests.ts @@ -5,7 +5,7 @@ import type { TokenSource } from '../clients/token-source.ts'; import { libraryClientOf, type ActingClient, type Requests } from '../consent/acting-clients.ts'; import type { ConsentRefusal, ConsentServed, RevocationFailure, RevokeAsked } from '../consent/consent-client.ts'; import { revocationOf } from '../consent/revocation.ts'; -import { normalizedScopeOf } from '../consent/scopes.ts'; +import { scopeRenewedOf } from '../consent/scopes.ts'; import { libraryMetadataOf, serverMetadataOf, type ServerMetadata } from '../consent/server-metadata.ts'; import { answeredTokensOf, keptOf, type AnsweredTokens, type KeptTokens } from './kept-tokens.ts'; import { renewedTokens, type Refreshing, type Sourced } from './renewed-tokens.ts'; @@ -20,6 +20,11 @@ export interface TokenRequesting { readonly renewalSpreadMs: number; } +export interface ServiceRenewals { + readonly refreshing: (served: ConsentServed, name: string) => Effect.Effect; + readonly revoke: (asked: RevokeAsked) => Effect.Effect; +} + interface Refreshed extends ServerMetadata { readonly issuer: string; readonly client: ActingClient; @@ -56,10 +61,6 @@ function refreshedWith( }); } -function scopeRenewedOf({ scope }: AnsweredTokens, previous: KeptTokens): string | undefined { - return scope === undefined ? previous.scope : normalizedScopeOf(scope); -} - function refreshingOf( served: ConsentServed, { clients, fetchFor }: Requests, @@ -89,15 +90,24 @@ function revocationAsked(asked: RevokeAsked, { clients, fetchFor }: Requests): E }); } -export function tokenRequestsOf(requests: Requests, { requestMs, renewalSpreadMs }: TokenRequesting): TokenRequests { +function isThroughTheService({ client }: Pick): boolean { + return client.client_source === 'auto'; +} + +export function tokenRequestsOf( + requests: Requests, + { requestMs, renewalSpreadMs }: TokenRequesting, + service: ServiceRenewals, +): TokenRequests { const renewal = { - refreshing: (served: ConsentServed) => refreshingOf(served, requests), + refreshing: (served: ConsentServed, name: string) => + isThroughTheService(served) ? service.refreshing(served, name) : refreshingOf(served, requests), leaseMs: leaseOf(requestMs), waitMs: requestMs, spreadMs: renewalSpreadMs, }; return { tokenSource: (sourced) => renewedTokens(sourced, renewal), - revoke: (asked) => revocationAsked(asked, requests), + revoke: (asked) => (isThroughTheService(asked) ? service.revoke(asked) : revocationAsked(asked, requests)), }; } diff --git a/packages/tool-servers/src/consent/acting-clients.ts b/packages/tool-servers/src/consent/acting-clients.ts index c48bb57bf..dd0759126 100644 --- a/packages/tool-servers/src/consent/acting-clients.ts +++ b/packages/tool-servers/src/consent/acting-clients.ts @@ -10,7 +10,6 @@ import { Effect, Option, Redacted } from 'effect'; import { clientMetadataDocumentOf, registeredClientAt, - sourcesHad, type ClientMetadataDocument, type ClientSource, type ClientsHad, @@ -40,14 +39,17 @@ export interface ActingClient { readonly authentication: AddClientAuthentication | undefined; } -interface ClientChoosing extends ClientsHad { +interface ClientChoosing extends Omit { readonly registrations: ClientRegistrations; } +export type ServerSource = Exclude; + interface Taking { readonly asked: ConsentAsked; readonly server: AuthorizationServer; readonly fetch: FetchLike; + readonly sources: readonly ServerSource[]; } type RecordedClient = Pick; @@ -131,7 +133,7 @@ function dynamicallyRegistered( } function takenFrom( - source: ClientSource, + source: ServerSource, taking: Taking, choosing: Choosing, ): Effect.Effect { @@ -153,7 +155,7 @@ function takenFrom( function chosenAt(taking: Taking, choosing: Choosing): Effect.Effect { return Effect.gen(function* () { - for (const source of sourcesHad(taking.asked.way, choosing)) { + for (const source of taking.sources) { const taken = yield* takenFrom(source, taking, choosing); if (taken !== undefined) { return taken; @@ -191,6 +193,10 @@ function clientChoice(choosing: ClientChoosing): ClientChoice { }; } +export function isServerSource(source: ClientSource): source is ServerSource { + return source !== 'auto'; +} + export interface Requesting extends ClientChoosing { readonly fetch: FetchLike; readonly requestMs: number; diff --git a/packages/tool-servers/src/consent/client-sources.test.ts b/packages/tool-servers/src/consent/client-sources.test.ts index 74f47604d..4380a66a5 100644 --- a/packages/tool-servers/src/consent/client-sources.test.ts +++ b/packages/tool-servers/src/consent/client-sources.test.ts @@ -6,7 +6,9 @@ import { isSameIssuer, metadataDocumentOf, registeredClientAt, + serviceClientAt, sourcesHad, + sourceTakenOf, type RegisteredClient, type WayOfClients, } from './client-sources.ts'; @@ -52,8 +54,8 @@ describe('the client sources a runtime has for a way', () => { it('are the registered client alone when the way names none, where one is registered at its issuer', () => { expect([ - sourcesHad({ issuer }, { registeredClients: [accounts], publicUrl: undefined }), - sourcesHad({ issuer }, { registeredClients: [tenants], publicUrl: undefined }), + sourcesHad({ issuer }, { registeredClients: [accounts], publicUrl: undefined, serviceClients: [] }), + sourcesHad({ issuer }, { registeredClients: [tenants], publicUrl: undefined, serviceClients: [] }), ]).toEqual([['registered'], []]); }); @@ -62,9 +64,9 @@ describe('the client sources a runtime has for a way', () => { const reversed: WayOfClients = { issuer, clients: ['dynamic', 'registered'] }; expect([ - sourcesHad(way, { registeredClients: [], publicUrl: 'https://runtime.example.com' }), - sourcesHad(way, { registeredClients: [accounts], publicUrl: 'http://127.0.0.1:5555' }), - sourcesHad(reversed, { registeredClients: [accounts], publicUrl: undefined }), + sourcesHad(way, { registeredClients: [], publicUrl: 'https://runtime.example.com', serviceClients: [] }), + sourcesHad(way, { registeredClients: [accounts], publicUrl: 'http://127.0.0.1:5555', serviceClients: [] }), + sourcesHad(reversed, { registeredClients: [accounts], publicUrl: undefined, serviceClients: [] }), ]).toEqual([ ['metadata_document', 'dynamic'], ['registered', 'dynamic'], @@ -80,6 +82,42 @@ describe('the client sources a runtime has for a way', () => { }); }); +describe('the client of Auto’s consent service among the sources a runtime has', () => { + const issuer = 'https://accounts.example.com'; + + it('is taken where the service lists the issuer and the way takes a registered client, after the runtime’s own', () => { + const listed = [{ issuer: `${issuer}/`, client_id: 'auto.apps.example.com' }]; + const had = { registeredClients: [], publicUrl: undefined, serviceClients: listed }; + + expect([ + sourcesHad({ issuer }, had), + sourcesHad({ issuer }, { ...had, registeredClients: [accounts] }), + sourcesHad({ issuer, clients: ['registered', 'dynamic'] }, had), + sourcesHad({ issuer, clients: ['metadata_document', 'dynamic'] }, had), + sourcesHad({ issuer: 'https://elsewhere.example.com' }, had), + ]).toEqual([['auto'], ['registered', 'auto'], ['auto', 'dynamic'], ['dynamic'], []]); + }); + + it('is the source a start takes where no registered client is had at the issuer, and none is taken where no source is had', () => { + const had = { registeredClients: [], publicUrl: undefined, serviceClients: [{ issuer, client_id: 'auto' }] }; + + expect([ + sourceTakenOf({ issuer }, had), + sourceTakenOf({ issuer }, { ...had, registeredClients: [accounts] }), + sourceTakenOf({ issuer, clients: ['metadata_document'] }, had), + ]).toEqual(['auto', 'registered', undefined]); + }); + + it('is found by an issuer the service lists, or by the issuer its template takes', () => { + const listed = [{ issuer: 'https://login.example.net/{tenant}/v2.0', client_id: 'auto-tenants' }]; + + expect([ + serviceClientAt(listed, 'https://login.example.net/acme.example.org/v2.0')?.client_id, + serviceClientAt(listed, 'https://login.example.net/a/b/v2.0'), + ]).toEqual(['auto-tenants', undefined]); + }); +}); + describe('the client metadata document', () => { const returnAddress = 'https://studio.example.com/connect-return'; diff --git a/packages/tool-servers/src/consent/client-sources.ts b/packages/tool-servers/src/consent/client-sources.ts index 07e5fd76c..2996cef18 100644 --- a/packages/tool-servers/src/consent/client-sources.ts +++ b/packages/tool-servers/src/consent/client-sources.ts @@ -1,15 +1,21 @@ import { placeholdersIn, placeholdersReplaced } from '@on.auto/config'; import { Schema, type Redacted } from 'effect'; -export const clientSources = ['registered', 'metadata_document', 'dynamic'] as const; +const clientSources = ['registered', 'auto', 'metadata_document', 'dynamic'] as const; export const ClientSourceSchema = Schema.Literals(clientSources); export type ClientSource = typeof ClientSourceSchema.Type; +export const wayClientSources = ['registered', 'metadata_document', 'dynamic'] as const; + +export const WayClientSourceSchema = Schema.Literals(wayClientSources); + +export type WayClientSource = typeof WayClientSourceSchema.Type; + export const ClientIdSchema = Schema.String.check(Schema.isMinLength(1)); -const defaultClientSources: readonly ClientSource[] = ['registered']; +const defaultClientSources: readonly WayClientSource[] = ['registered']; export type RegisteredCredential = | { readonly kind: 'client_secret'; readonly clientSecret: Redacted.Redacted } @@ -25,14 +31,25 @@ export interface RegisteredClient { readonly tokenEndpointAuthMethod: TokenEndpointAuthMethod | null; } +export interface ServiceClient { + readonly issuer: string; + readonly client_id: string; +} + export interface ClientsHad { readonly registeredClients: readonly RegisteredClient[]; readonly publicUrl: string | undefined; + readonly serviceClients: readonly ServiceClient[]; } export interface WayOfClients { readonly issuer: string; - readonly clients?: readonly ClientSource[] | undefined; + readonly clients?: readonly WayClientSource[] | undefined; +} + +export interface HadSources { + readonly sourcesOf: (way: WayOfClients) => readonly ClientSource[]; + readonly sourceOf: (way: WayOfClients) => ClientSource | undefined; } type ClientMetadata = Readonly<{ @@ -82,6 +99,10 @@ export function registeredClientAt(clients: readonly RegisteredClient[], issuer: return clients.find(({ issuers }) => issuers.some((registered) => isRegisteredAt(registered, issuer))); } +export function serviceClientAt(clients: readonly ServiceClient[], issuer: string): ServiceClient | undefined { + return clients.find((client) => isRegisteredAt(client.issuer, issuer)); +} + export function metadataDocumentOf(publicUrl: string | undefined): string | undefined { return publicUrl?.startsWith('https://') === true ? new URL(metadataDocumentPath, publicUrl).href : undefined; } @@ -113,14 +134,23 @@ function isHad(source: ClientSource, { issuer }: WayOfClients, had: ClientsHad): if (source === 'registered') { return registeredClientAt(had.registeredClients, issuer) !== undefined; } + if (source === 'auto') { + return serviceClientAt(had.serviceClients, issuer) !== undefined; + } return source === 'dynamic' || metadataDocumentOf(had.publicUrl) !== undefined; } function clientSourcesOf({ clients }: WayOfClients): readonly ClientSource[] { - return clients ?? defaultClientSources; + const listed = clients ?? defaultClientSources; + return listed.includes('registered') ? [...listed, 'auto'] : listed; } export function sourcesHad(way: WayOfClients, had: ClientsHad): readonly ClientSource[] { const listed = clientSourcesOf(way); return clientSources.filter((source) => listed.includes(source) && isHad(source, way, had)); } + +export function sourceTakenOf(way: WayOfClients, had: ClientsHad): ClientSource | undefined { + const [first] = sourcesHad(way, had); + return first; +} diff --git a/packages/tool-servers/src/consent/consent-client.ts b/packages/tool-servers/src/consent/consent-client.ts index 9d5f4bf24..d1f46711e 100644 --- a/packages/tool-servers/src/consent/consent-client.ts +++ b/packages/tool-servers/src/consent/consent-client.ts @@ -2,13 +2,27 @@ import type { FetchLike } from '@modelcontextprotocol/client'; import type { Reaches } from '@on.auto/outbound'; import { Effect, type Redacted } from 'effect'; -import { toolBounds } from '../bounds/call-bounds.ts'; import type { Timer } from '../clients/http-client.ts'; import type { ConsentTokenSources, TokenSource } from '../clients/token-source.ts'; +import { + serviceClientsListed, + throughAutoOf, + usableClientsOf, + type AutoClients, + type ThroughAuto, +} from '../consent-through-auto/auto-clients.ts'; +import type { ConsentRequests } from '../consent-through-auto/consents-by-source.ts'; import { heldAmongSecrets, type KeptTokens, type TokenHolding } from '../consent-tokens/kept-tokens.ts'; import type { ConnectionUnready } from '../org-servers/connection-source.ts'; -import { sourcesHad, type ClientSource, type ClientsHad, type WayOfClients } from './client-sources.ts'; -import type { ConsentRequests } from './consent-requests.ts'; +import { + sourcesHad, + sourceTakenOf, + type ClientSource, + type ClientsHad, + type HadSources, + type ServiceClient, + type WayOfClients, +} from './client-sources.ts'; export interface ConsentWay extends WayOfClients { readonly scope?: string | undefined; @@ -29,6 +43,7 @@ export interface ConsentAsked { export interface StartAsked extends ConsentAsked { readonly state: string; + readonly studioBase: string; } export interface ChosenClient { @@ -38,18 +53,29 @@ export interface ChosenClient { export interface StartedConsent { readonly url: string; + readonly state: string; readonly verifier: Redacted.Redacted; readonly client: ChosenClient; } -export interface FinishAsked extends ConsentAsked { +interface FinishAskedOfTheStart extends ConsentAsked { readonly client: ChosenClient; + readonly verifier: Redacted.Redacted; +} + +export interface CodeFinishAsked extends FinishAskedOfTheStart { readonly code: string; readonly iss: string | undefined; - readonly verifier: Redacted.Redacted; } +export interface HandleFinishAsked extends FinishAskedOfTheStart { + readonly handle: string; +} + +export type FinishAsked = CodeFinishAsked | HandleFinishAsked; + export interface FinishedConsent { + readonly client: ChosenClient; readonly tokens: Redacted.Redacted; readonly scope: string | undefined; readonly revoked: Effect.Effect; @@ -66,6 +92,8 @@ export type ConsentRefusal = | { readonly refused: 'unanswered' } | { readonly refused: 'iss' } | { readonly refused: 'code' } + | { readonly refused: 'studio_elsewhere' } + | { readonly refused: 'start_refused' } | { readonly refused: 'no_scope' } | { readonly refused: 'malformed_scope' }; @@ -83,7 +111,7 @@ export type TokensReplaced = { readonly replaced: true; readonly version: number export type TokensClaimed = { readonly claimed: true; readonly until: string } | { readonly claimed: false }; -export type TokensEnded = 'consent_ended' | 'client_gone'; +export type TokensEnded = 'consent_ended' | 'client_gone' | 'key_unknown'; export interface ConnectionTokens { readonly read: Effect.Effect; @@ -112,7 +140,10 @@ export interface RevokeAsked extends ConsentAsked { readonly tokens: KeptTokens; } -export type RevocationFailure = ConsentRefusal | { readonly refused: 'not_revoked'; readonly status: number }; +export type RevocationFailure = + | ConsentRefusal + | { readonly refused: 'not_revoked'; readonly status: number } + | { readonly refused: 'unknown_key' }; export interface KeptRegistration { readonly clientId: string; @@ -125,31 +156,47 @@ export interface ClientRegistrations { readonly keep: (issuer: string, kept: KeptRegistration) => Effect.Effect; } -export interface ConsentClientOptions extends ClientsHad { +export interface ConsentClientOptions extends Omit { readonly fetch: FetchLike; readonly registrations: ClientRegistrations; + readonly autoClients?: AutoClients; readonly requestMs?: number; readonly requestTimeout?: (ms: number) => AbortSignal; readonly renewalSpreadMs?: number; } export interface ConsentClient extends ConsentTokenSources { - readonly sourcesOf: (way: WayOfClients) => readonly ClientSource[]; + readonly hadSources: (studioBase: string) => Effect.Effect; readonly start: (asked: StartAsked) => Effect.Effect; readonly finish: (asked: FinishAsked) => Effect.Effect; readonly revoke: (asked: RevokeAsked) => Effect.Effect; } +export interface LoadedOptions extends ConsentClientOptions { + readonly auto: ThroughAuto; + readonly serviceClients: Effect.Effect; + readonly renewalSpreadMs: number; +} + const mostRenewalSpreadMs = 5000; -async function requestsOf(options: ConsentClientOptions, renewalSpreadMs: number): Promise { - const { consentRequests } = await import('./consent-requests.ts'); - return consentRequests({ - ...options, - requestMs: options.requestMs ?? toolBounds.callMs, - requestTimeout: options.requestTimeout ?? ((ms) => AbortSignal.timeout(ms)), - renewalSpreadMs, - }); +async function requestsOf(options: LoadedOptions): Promise { + const { consentRequests } = await import('../consent-through-auto/consents-by-source.ts'); + return consentRequests(options); +} + +function loadedOf(options: ConsentClientOptions): LoadedOptions { + const auto = throughAutoOf(options); + const renewalSpreadMs = options.renewalSpreadMs ?? Math.floor(Math.random() * mostRenewalSpreadMs); + return { ...options, auto, serviceClients: serviceClientsListed(auto), renewalSpreadMs }; +} + +function hadSourcesOf(options: LoadedOptions, studioBase: string): Effect.Effect { + const usable = usableClientsOf(options.auto, studioBase, options.serviceClients); + return Effect.map(usable, (serviceClients) => ({ + sourcesOf: (way: WayOfClients): readonly ClientSource[] => sourcesHad(way, { ...options, serviceClients }), + sourceOf: (way: WayOfClients) => sourceTakenOf(way, { ...options, serviceClients }), + })); } function servedTokens( @@ -174,15 +221,15 @@ function servedTokens( }; } -export function makeConsentClient(options: ConsentClientOptions): ConsentClient { - const renewalSpreadMs = options.renewalSpreadMs ?? Math.floor(Math.random() * mostRenewalSpreadMs); +export function makeConsentClient(given: ConsentClientOptions): ConsentClient { + const options = loadedOf(given); const loading: { requests?: Promise } = {}; const loaded = Effect.promise(() => { - loading.requests ??= requestsOf(options, renewalSpreadMs); + loading.requests ??= requestsOf(options); return loading.requests; }); return { - sourcesOf: (way) => sourcesHad(way, options), + hadSources: (studioBase) => hadSourcesOf(options, studioBase), start: (asked) => Effect.flatMap(loaded, (requests) => requests.start(asked)), finish: (asked) => Effect.flatMap(loaded, (requests) => requests.finish(asked)), tokenSource: (served, serving) => servedTokens(loaded, served, serving), diff --git a/packages/tool-servers/src/consent/consent-clients.test.ts b/packages/tool-servers/src/consent/consent-clients.test.ts index 61fb44962..4e37eca46 100644 --- a/packages/tool-servers/src/consent/consent-clients.test.ts +++ b/packages/tool-servers/src/consent/consent-clients.test.ts @@ -9,7 +9,7 @@ import { type ClientRegistration, type FakeMcpServer, } from '../testing/index.ts'; -import type { ClientSource } from './client-sources.ts'; +import type { WayClientSource } from './client-sources.ts'; import type { ConsentClient, StartAsked } from './consent-client.ts'; const closing: (() => Promise)[] = []; @@ -32,7 +32,7 @@ function refused(client: ConsentClient, asked: StartAsked) { return Effect.runPromise(Effect.flip(client.start(asked))); } -const documentFirst: readonly ClientSource[] = ['metadata_document', 'dynamic']; +const documentFirst: readonly WayClientSource[] = ['metadata_document', 'dynamic']; const publicUrl = 'https://runtime.example.com'; diff --git a/packages/tool-servers/src/consent/consent-requests.ts b/packages/tool-servers/src/consent/consent-requests.ts index 43d094180..2afaa38c7 100644 --- a/packages/tool-servers/src/consent/consent-requests.ts +++ b/packages/tool-servers/src/consent/consent-requests.ts @@ -2,48 +2,26 @@ import { exchangeAuthorization, startAuthorization } from '@modelcontextprotocol import { DateTime, Effect, Redacted } from 'effect'; import { answeredTokensOf, keptOf, keptValueOf } from '../consent-tokens/kept-tokens.ts'; -import { tokenRequestsOf, type TokenRequesting, type TokenRequests } from '../consent-tokens/token-requests.ts'; -import { libraryClientOf, requestsOf, type Requests } from './acting-clients.ts'; -import type { - ConsentClientOptions, - ConsentRefusal, - FinishAsked, - FinishedConsent, - StartAsked, - StartedConsent, -} from './consent-client.ts'; +import { libraryClientOf, type Requests, type ServerSource } from './acting-clients.ts'; +import type { CodeFinishAsked, ConsentRefusal, FinishedConsent, StartAsked, StartedConsent } from './consent-client.ts'; import { exchangeRefusalOf } from './consent-failures.ts'; import { revocationOf } from './revocation.ts'; -import { isScope, normalizedScopeOf } from './scopes.ts'; +import { grantedScopeOf } from './scopes.ts'; import { libraryMetadataOf, scopeAskedOf, serverMetadataOf } from './server-metadata.ts'; -export interface ConsentRequests extends TokenRequests { - readonly start: (asked: StartAsked) => Effect.Effect; - readonly finish: (asked: FinishAsked) => Effect.Effect; -} - -type Requesting = ConsentClientOptions & TokenRequesting & { readonly requestTimeout: (ms: number) => AbortSignal }; - -const noScope: ConsentRefusal = { refused: 'no_scope' }; - -const malformedScope: ConsentRefusal = { refused: 'malformed_scope' }; - -function grantedScopeOf(granted: string | undefined, asked: string | undefined) { - if (granted === undefined) { - return Effect.succeed(asked); - } - const scope = normalizedScopeOf(granted); - if (scope === '') { - return asked === undefined ? Effect.undefined : Effect.fail(noScope); - } - return isScope(scope) ? Effect.succeed(scope) : Effect.fail(malformedScope); +export interface ServerConsents { + readonly start: ( + asked: StartAsked, + sources: readonly ServerSource[], + ) => Effect.Effect; + readonly finish: (asked: CodeFinishAsked) => Effect.Effect; } function consentOf( exchanged: unknown, asked: string | undefined, revoked: Effect.Effect, -): Effect.Effect { +): Effect.Effect, ConsentRefusal> { return Effect.gen(function* () { const tokens = answeredTokensOf(exchanged); const scope = yield* grantedScopeOf(tokens.scope, asked); @@ -52,11 +30,15 @@ function consentOf( }); } -function started(asked: StartAsked, { clients, fetchFor }: Requests): Effect.Effect { +function started( + asked: StartAsked, + { clients, fetchFor }: Requests, + sources: readonly ServerSource[], +): Effect.Effect { return Effect.gen(function* () { const fetch = fetchFor(asked.reaches); const { server, resource } = yield* serverMetadataOf(asked, fetch); - const client = yield* clients.chosenAt({ asked, server, fetch }); + const client = yield* clients.chosenAt({ asked, server, fetch, sources }); const scope = scopeAskedOf(asked.way, server); const { authorizationUrl, codeVerifier } = yield* Effect.promise(() => startAuthorization(asked.way.issuer, { @@ -71,11 +53,12 @@ function started(asked: StartAsked, { clients, fetchFor }: Requests): Effect.Eff for (const [name, value] of Object.entries(asked.way.parameters ?? {})) { authorizationUrl.searchParams.set(name, value); } - return { url: authorizationUrl.href, verifier: Redacted.make(codeVerifier), client: client.chosen }; + const verifier = Redacted.make(codeVerifier); + return { url: authorizationUrl.href, state: asked.state, verifier, client: client.chosen }; }); } -function finished(asked: FinishAsked, { clients, fetchFor }: Requests): Effect.Effect { +function finished(asked: CodeFinishAsked, { clients, fetchFor }: Requests) { return Effect.gen(function* () { const fetch = fetchFor(asked.reaches); const { server, resource } = yield* serverMetadataOf(asked, fetch); @@ -97,15 +80,16 @@ function finished(asked: FinishAsked, { clients, fetchFor }: Requests): Effect.E }); const tokens = { access_token: exchanged.access_token, refresh_token: exchanged.refresh_token }; const revoking = Effect.ignore(revocationOf({ server, client, tokens, fetch })); - return yield* consentOf(exchanged, scopeAskedOf(asked.way, server), revoking).pipe(Effect.onError(() => revoking)); + return yield* consentOf(exchanged, scopeAskedOf(asked.way, server), revoking).pipe( + Effect.map((consent) => ({ ...consent, client: asked.client })), + Effect.onError(() => revoking), + ); }); } -export function consentRequests(options: Requesting): ConsentRequests { - const requests = requestsOf(options); +export function serverConsentsOf(requests: Requests): ServerConsents { return { - start: (asked) => started(asked, requests), + start: (asked, sources) => started(asked, requests, sources), finish: (asked) => finished(asked, requests), - ...tokenRequestsOf(requests, options), }; } diff --git a/packages/tool-servers/src/consent/consent-start.test.ts b/packages/tool-servers/src/consent/consent-start.test.ts index dba9646e3..f19126345 100644 --- a/packages/tool-servers/src/consent/consent-start.test.ts +++ b/packages/tool-servers/src/consent/consent-start.test.ts @@ -11,6 +11,7 @@ import { returnAddress, servedForConsent, startAskedAt, + studioBase, type ClientRegistration, type FakeMcpServer, } from '../testing/index.ts'; @@ -77,6 +78,7 @@ const explicitWay: StartAsked = { returnAddress, reaches: everyHost, state: 'state-2', + studioBase, }; describe('the start of a consent at an MCP server', () => { @@ -239,14 +241,19 @@ describe('a start that cannot ask the server', () => { }); describe('the client sources a consent client judges', () => { - it('are those of the way the runtime has, without asking anything', () => { - const client = consentClientWith({ registeredClients: [registeredAt(accounts)] }); + it('are those of the way the runtime has, without asking anything where it takes no client of Auto’s', () => { + const client = consentClientWith({ fetch: readsNothing, registeredClients: [registeredAt(accounts)] }); + const { sourcesOf, sourceOf } = Effect.runSync(client.hadSources(studioBase)); expect([ - client.sourcesOf({ issuer: accounts }), - client.sourcesOf({ issuer: accounts, clients: ['metadata_document', 'dynamic'] }), - client.sourcesOf({ issuer: 'https://elsewhere.example.com' }), + sourcesOf({ issuer: accounts }), + sourcesOf({ issuer: accounts, clients: ['metadata_document', 'dynamic'] }), + sourcesOf({ issuer: 'https://elsewhere.example.com' }), ]).toEqual([['registered'], ['dynamic'], []]); + expect([sourceOf({ issuer: accounts }), sourceOf({ issuer: 'https://elsewhere.example.com' })]).toEqual([ + 'registered', + undefined, + ]); }); }); diff --git a/packages/tool-servers/src/consent/scopes.ts b/packages/tool-servers/src/consent/scopes.ts index 576531c64..24b170be3 100644 --- a/packages/tool-servers/src/consent/scopes.ts +++ b/packages/tool-servers/src/consent/scopes.ts @@ -1,3 +1,7 @@ +import { Effect } from 'effect'; + +import type { ConsentRefusal } from './consent-client.ts'; + const scopeToken = '[\\x21\\x23-\\x5B\\x5D-\\x7E]+'; const scopeShape = new RegExp(`^${scopeToken}(?: ${scopeToken})*$`, 'u'); @@ -10,7 +14,7 @@ export function isScope(text: string): boolean { return scopeShape.test(text); } -export function normalizedScopeOf(granted: string): string { +function normalizedScopeOf(granted: string): string { return granted .split(separatorsOfAGrant) .filter((token) => token !== '') @@ -22,3 +26,28 @@ export function withOfflineAccess(scope: string, scopesSupported: readonly strin ? `${scope} ${offlineAccess}` : scope; } + +const noScope: ConsentRefusal = { refused: 'no_scope' }; + +const malformedScope: ConsentRefusal = { refused: 'malformed_scope' }; + +export function grantedScopeOf( + granted: string | undefined, + asked: string | undefined, +): Effect.Effect { + if (granted === undefined) { + return Effect.succeed(asked); + } + const scope = normalizedScopeOf(granted); + if (scope === '') { + return asked === undefined ? Effect.undefined : Effect.fail(noScope); + } + return isScope(scope) ? Effect.succeed(scope) : Effect.fail(malformedScope); +} + +export function scopeRenewedOf( + { scope }: { readonly scope?: string }, + previous: { readonly scope?: string }, +): string | undefined { + return scope === undefined ? previous.scope : normalizedScopeOf(scope); +} diff --git a/packages/tool-servers/src/index.ts b/packages/tool-servers/src/index.ts index 4fb9c5a53..84f850fab 100644 --- a/packages/tool-servers/src/index.ts +++ b/packages/tool-servers/src/index.ts @@ -65,15 +65,19 @@ export { export { ClientIdSchema, ClientSourceSchema, + WayClientSourceSchema, clientMetadataDocumentOf, - clientSources, isSameIssuer, + wayClientSources, type ClientMetadataDocument, type ClientSource, + type HadSources, type RegisteredClient, type RegisteredCredential, type TokenEndpointAuthMethod, + type WayClientSource, } from './consent/client-sources.ts'; +export type { AutoClients, ReturnKind } from './consent-through-auto/auto-clients.ts'; export { makeConsentClient, type ChosenClient, @@ -86,7 +90,9 @@ export { type ConsentServed, type ConsentServer, type ConsentWay, + type CodeFinishAsked, type FinishAsked, + type HandleFinishAsked, type FinishedConsent, type HeldTokens, type KeptRow, diff --git a/packages/tool-servers/src/search/found-providers.ts b/packages/tool-servers/src/search/found-providers.ts index 600d66de4..d8f92fbcd 100644 --- a/packages/tool-servers/src/search/found-providers.ts +++ b/packages/tool-servers/src/search/found-providers.ts @@ -1,5 +1,5 @@ import type { CallerIdentity } from '@on.auto/operations'; -import { Function, Order } from 'effect'; +import { Effect, Order } from 'effect'; import type { OrgListing } from '../listing/listing-ways.ts'; import type { ServerFields } from '../listing/tool-server.ts'; @@ -12,9 +12,9 @@ export interface OfferedProvider { readonly description: string; } -export type ProviderOffering = (caller: CallerIdentity) => readonly OfferedProvider[] | undefined; +export type ProviderOffering = (caller: CallerIdentity) => Effect.Effect; -export const offersNoProvider: ProviderOffering = Function.constUndefined; +export const offersNoProvider: ProviderOffering = () => Effect.undefined; const byName: Order.Order = Order.mapInput(Order.String, ({ name }: OfferedProvider) => name); diff --git a/packages/tool-servers/src/search/search-in-org.ts b/packages/tool-servers/src/search/search-in-org.ts index 78fb7dbf4..84f12614e 100644 --- a/packages/tool-servers/src/search/search-in-org.ts +++ b/packages/tool-servers/src/search/search-in-org.ts @@ -64,7 +64,7 @@ export function defineSearchInOrg( return { ...foundIn(servers, asked), servers: servers.map((listed) => withServedPlaybooks(listed, asked, caller.playbooks)), - ...providersOf(offering(caller), listing, asked), + ...providersOf(yield* offering(caller), listing, asked), }; }), plainLanguage: { diff --git a/packages/tool-servers/src/search/search-providers.test.ts b/packages/tool-servers/src/search/search-providers.test.ts index 76a875386..207403bd4 100644 --- a/packages/tool-servers/src/search/search-providers.test.ts +++ b/packages/tool-servers/src/search/search-providers.test.ts @@ -34,7 +34,7 @@ const chat: OfferedProvider = { const offeredToTheAdder = (offered: readonly OfferedProvider[]): ProviderOffering => ({ id }) => - id === adder.id ? offered : undefined; + Effect.succeed(id === adder.id ? offered : undefined); const services = Layer.mergeAll( memoryLedger().layer, diff --git a/packages/tool-servers/src/testing/index.ts b/packages/tool-servers/src/testing/index.ts index c11dc5daf..f29e52f61 100644 --- a/packages/tool-servers/src/testing/index.ts +++ b/packages/tool-servers/src/testing/index.ts @@ -73,7 +73,25 @@ export { returnAddress, servedForConsent, startAskedAt, + studioBase, } from '../consent-testing/consent-fakes.ts'; +export { + serveFakeConsentService, + type FakeConsentService, + type ServiceCall, +} from '../consent-testing/fake-consent-service.ts'; +export type { FakeConsentServiceOptions, RefusedCall } from '../consent-testing/fake-consent-store.ts'; +export { + autoClientsOf, + connectedThroughAuto, + consentClientThrough, + handedOffAskedOf, + handleOf, + servedThroughAuto, + serviceCallsAt, + studioSession, + type ThroughAuto, +} from '../consent-testing/auto-consents.ts'; export { brokenWords, goneWords,