diff --git a/auto-runtime.schema.json b/auto-runtime.schema.json index 92f20cb75..b76d15e66 100644 --- a/auto-runtime.schema.json +++ b/auto-runtime.schema.json @@ -400,16 +400,25 @@ "type": "string", "enum": ["client_secret_basic", "client_secret_post", "private_key_jwt", "none"], "description": "How the client authenticates at the token endpoint where the metadata of the authorization server leaves the choice; chosen from the client and the metadata when left out" + }, + "scopes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "description": "The scopes the client may ask at its issuers, each as a provider's way writes it: a way takes the first client at its issuer that names every scope the way asks. Named for each client where several clients serve one issuer, and left out for a client that may ask any" } }, "required": ["issuers", "client_id"], "additionalProperties": false }, - "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" + "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, and the scopes it may ask, which each client names where several serve one issuer" }, "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" + "description": "Whether a provider whose way takes a registered client takes Auto's client too, through Auto's consent service, where no client registered at its issuer covers the scope the way asks; true in local mode and false otherwise when left out. AUTO_CLIENTS wins over it" }, "providers": { "type": "array", diff --git a/docs/self-host/configuration.md b/docs/self-host/configuration.md index bae2b7624..5cf22ca21 100644 --- a/docs/self-host/configuration.md +++ b/docs/self-host/configuration.md @@ -112,7 +112,7 @@ Every setting is an environment variable. The tables below group them by area. | `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) | +| `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 that covers the scope its way asks; 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 @@ -528,15 +528,17 @@ connections: | -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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) | +| `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, and its `scopes`, the scopes it may ask, which each client names where several serve one issuer, 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 that covers the scope its way asks; `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. -A registered client's `client_secret` and `private_key` are the only keys of a client that hold a reference, and the only ones the server substitutes from the environment, at start, whether the section comes from this file or from `CONNECTIONS`; a provider under `providers` holds one in a header or a query value alone, for a value of your own registration at the vendor, which the server substitutes when it sends that provider's server a request. A reference anywhere else in the section, in a host, a variable, an issuer, a `client_id` or an `algorithm`, stops the server at start; in a key that takes only some values, such as `credential_kinds` or `people_may_add`, it is refused as a value the key does not take. The section keeps the references of `registered_clients` and of `providers` as `tool_servers` keeps its own, so no secret of a client and no value of a provider is written into `CONNECTIONS`. The variables those references name are among the variables the runtime reads for itself, so `variables` may not list them. +A registered client's `client_secret` and `private_key` are the only keys of a client that hold a reference, and the only ones the server substitutes from the environment, at start, whether the section comes from this file or from `CONNECTIONS`; a provider under `providers` holds one in a header or a query value alone, for a value of your own registration at the vendor, which the server substitutes when it sends that provider's server a request. A reference anywhere else in the section, in a host, a variable, an issuer, a `client_id`, an `algorithm` or a scope, stops the server at start; in a key that takes only some values, such as `credential_kinds` or `people_may_add`, it is refused as a value the key does not take. The section keeps the references of `registered_clients` and of `providers` as `tool_servers` keeps its own, so no secret of a client and no value of a provider is written into `CONNECTIONS`. The variables those references name are among the variables the runtime reads for itself, so `variables` may not list them. + +A registered client may name `scopes`, the scopes it may ask at its issuers, each written as a provider's way writes it. A way takes the first client at its issuer whose `scopes` cover the scope the way asks: every scope of the way's `scope`, split on spaces, is one the client names, compared exactly as written. A client that names no `scopes` covers every way, so a client alone at its issuers needs none. Where several clients serve one issuer, each names its `scopes`, and a way none of them covers is one you set up first, as a way whose issuer no client names is. The server stops at start, naming the line or the place and never a value, at a registered client: @@ -545,7 +547,8 @@ The server stops at start, naming the line or the place and never a value, at a - with an empty `client_id` or `algorithm`; - without an issuer; - with an issuer that is neither an https URL nor an http URL on a loopback address; -- with an issuer that an earlier client names too, the same but for a trailing slash; +- without `scopes`, at an issuer another client names too, the same but for a trailing slash, since a client that names none would serve every way there that no client before it covers, and a client after it never; +- with `scopes` that lists no scope, or lists an empty one; - with both a `client_secret` and a `private_key`, since a client authenticates with one; - with a `private_key` and no `algorithm`, or an `algorithm` and no `private_key`; - with a `token_endpoint_auth_method` its kind does not authenticate with: `client_secret_basic` or `client_secret_post` for a client with a secret, `private_key_jwt` for one with a key, and `none` for a public client. diff --git a/docs/self-host/connections.md b/docs/self-host/connections.md index 66a772e97..b93591337 100644 --- a/docs/self-host/connections.md +++ b/docs/self-host/connections.md @@ -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, 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. +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 that covers the scope its way asks 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 both kinds of property: a domain property, named as `sc-domain:example.com`, and a URL-prefix property, named with its scheme and its trailing slash, as `https://www.example.com/`, which Auto sends encoded in the path, as `sites/https%3A%2F%2Fwww.example.com%2F`. 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. @@ -191,9 +191,9 @@ connections: ## 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`. +Auto registers its own client at the vendors of the providers it ships and has it verified there; at a vendor that verifies in tiers, Auto has a client for each tier, and its consent service lists each client with the vendor's issuer and the scopes it covers. 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. +1. The person presses Continue on Studio's page. Where you list no client at the provider's issuer under `registered_clients` that covers the scope its way asks, Auto asks its consent service to start the consent with Auto's client that covers it, 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. @@ -212,7 +212,7 @@ 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. +A client you register always comes first: where `registered_clients` lists a client at a provider's issuer that covers the scope its way asks, 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 where the service lists a client that covers the way's scope; 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 that covers its scope, `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: @@ -237,15 +237,16 @@ connections: client_id: auto-runtime ``` -| Field | What it holds | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `issuers` | The issuers the client is registered at, one at least, each an `https` URL or an `http` URL on a loopback address. One written with a placeholder, such as `{tenant}`, is kept as it is written | -| `client_id` | The client id the service issued | -| `client_secret` | The client secret, as a reference to the variable that holds it, such as `${EXAMPLE_CLIENT_SECRET}` | -| `private_key` | A private key in PEM that signs the client's assertion, as a reference, with `algorithm`, such as `RS256` or `ES256`, in place of a secret | -| `token_endpoint_auth_method` | Where the service's metadata leaves the choice, how the client authenticates: `client_secret_basic` or `client_secret_post` with a secret, `private_key_jwt` with a key, `none` without either | +| Field | What it holds | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `issuers` | The issuers the client is registered at, one at least, each an `https` URL or an `http` URL on a loopback address. One written with a placeholder, such as `{tenant}`, is kept as it is written | +| `client_id` | The client id the service issued | +| `client_secret` | The client secret, as a reference to the variable that holds it, such as `${EXAMPLE_CLIENT_SECRET}` | +| `private_key` | A private key in PEM that signs the client's assertion, as a reference, with `algorithm`, such as `RS256` or `ES256`, in place of a secret | +| `token_endpoint_auth_method` | Where the service's metadata leaves the choice, how the client authenticates: `client_secret_basic` or `client_secret_post` with a secret, `private_key_jwt` with a key, `none` without either | +| `scopes` | The scopes the client may ask, each written as a provider's way writes it, at least one. Name them for each client where several clients serve one issuer, and leave them out for a client that may ask any | -A client that gives neither a secret nor a key is a public client. Each issuer belongs to one client: two clients whose issuers are the same but for a trailing slash stop Auto at start, as a secret written out instead of referenced and a variable that is not set do, and the [configuration reference](configuration.md#connections) lists every check. `client_secret` and `private_key` are the only fields that hold a reference: one anywhere else in the section, such as in an issuer or a `client_id`, stops Auto at start too. Auto keeps the references as they are written, so no secret of a client reaches `CONNECTIONS`, reads the values when it starts, and scrubs each secret and key from everything it records and answers, as it scrubs the secrets of its tool servers. The variables they name are ones Auto reads for itself, so `variables` may not name them. +A client that gives neither a secret nor a key is a public client. A way takes the first client at its issuer whose `scopes` cover the scope it asks, every scope of the way being one the client names, as written, and a client that names no `scopes` covers every way. Where several clients serve one issuer, each names its `scopes`: a client without them at an issuer another client names too, the same but for a trailing slash, stops Auto at start, since it would serve every way there that no client before it covers, and a client after it never, as a secret written out instead of referenced and a variable that is not set do, and the [configuration reference](configuration.md#connections) lists every check. A way no client covers is one you set up first. `client_secret` and `private_key` are the only fields that hold a reference: one anywhere else in the section, such as in an issuer or a `client_id`, stops Auto at start too. Auto keeps the references as they are written, so no secret of a client reaches `CONNECTIONS`, reads the values when it starts, and scrubs each secret and key from everything it records and answers, as it scrubs the secrets of its tool servers. The variables they name are ones Auto reads for itself, so `variables` may not name them. ## Let people connect by consent @@ -272,12 +273,12 @@ connections: Auto takes the first source it has: -| Source | What you set up | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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 | +| Source | What you set up | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `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, and, where several clients serve that issuer, the scopes it may ask, 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 that covers the way's scope: 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. @@ -287,14 +288,14 @@ A connection connected by consent serves its tools as any connected connection d ### The providers Auto ships that take consent -Each way names as its issuer the authorization server its server names in its own metadata, or, for Zoom's HTTP API, which names none, the issuer of Zoom's own metadata; asks the scope its tools need, or, at Zoom, none, so that Zoom grants the scopes of its app; and takes the client sources its authorization server takes. A client you register at the vendor takes the return address of your runtime as its redirect address, as `registered` above says, and goes under `registered_clients` with the issuer below. Every host of a way passes `allowed_hosts`, those of its endpoints among them. Google's thirteen share one issuer and one client, and Microsoft's two, Amazon's two and Adobe's two each take a setup of their vendor's, as [The Google providers](#the-google-providers), [The Microsoft providers](#the-microsoft-providers), [The Amazon providers](#the-amazon-providers) and [The Adobe providers](#the-adobe-providers) set out after the four below. +Each way names as its issuer the authorization server its server names in its own metadata, or, for Zoom's HTTP API, which names none, the issuer of Zoom's own metadata; asks the scope its tools need, or, at Zoom, none, so that Zoom grants the scopes of its app; and takes the client sources its authorization server takes. A client you register at the vendor takes the return address of your runtime as its redirect address, as `registered` above says, and goes under `registered_clients` with the issuer below. Every host of a way passes `allowed_hosts`, those of its endpoints among them. Google's thirteen share one issuer, with a client for each tier of Google's verification you offer, and Microsoft's two, Amazon's two and Adobe's two each take a setup of their vendor's, as [The Google providers](#the-google-providers), [The Microsoft providers](#the-microsoft-providers), [The Amazon providers](#the-amazon-providers) and [The Adobe providers](#the-adobe-providers) set out after the four below. -| Provider | Issuer | Scope asked | Client sources | What you do at the vendor | -| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `github` | `https://github.com/login/oauth`, whose endpoints are on `github.com` | `read:org repo`, and `offline_access`, which GitHub's authorization server lists, so Auto asks for it too and GitHub gives a refresh token | `registered` | Register an OAuth app, as GitHub names that kind, and not a GitHub App, which takes no scope: the scope and an organization's approval are an OAuth app's. Give it the return address as its callback URL, and list its client id and secret with `token_endpoint_auth_method: client_secret_post`, since GitHub takes them in the body of its token request. Where an organization has its OAuth app access restrictions on, as a new organization has, one of its owners approves the app before a connection reads the organization's private repositories or changes its public ones | -| `profound` | `https://auth.tryprofound.com` | `openid offline_access`: Profound's server asks no scope of its own, and its authorization server is an OpenID provider, where `offline_access`, the scope that asks for a refresh token, is asked beside `openid` | `registered`, `metadata_document`, `dynamic` | Nothing: where you list no client at the issuer, Auto names its client by its metadata document where it signs people in at an `https` `PUBLIC_URL`, and registers itself there otherwise | -| `slack` | `https://mcp.slack.com`, whose endpoints are on `slack.com`, so where you set `allowed_hosts`, it allows both `mcp.slack.com` and `slack.com` | `search:read.public search:read.private search:read.im search:read.mpim search:read.users channels:read groups:read im:read mpim:read channels:history groups:history im:history mpim:history users:read reactions:read chat:write reactions:write`, as user token scopes | `registered` | Make a Slack app with the return address among its redirect URLs and these scopes among its user token scopes, turn on its token rotation, and list its client id and secret. Publish the app in the Slack Marketplace or keep it internal to your workspace, since Slack's MCP server admits only those apps, and a workspace admin may have to approve it | -| `zoom` | `https://zoom.us`, with its endpoints written out, `https://zoom.us/oauth/authorize`, `https://zoom.us/oauth/token` and `https://zoom.us/oauth/revoke`, since the metadata Auto reads for an issuer, `https://zoom.us/.well-known/oauth-authorization-server`, names no revocation endpoint, where Zoom's OpenID configuration does | None: the scopes added to the app, since Zoom documents no separator for several scopes in one request and grants the app's own where the request names none, and the connection records those Zoom granted. Add to the app these user-level scopes, each one a person grants for themselves, those that read first: `user:read:user`, `meeting:read:list_meetings`, `meeting:read:meeting`, `meeting:read:past_meeting`, `meeting:read:list_past_participants`, `cloud_recording:read:list_user_recordings`, `cloud_recording:read:list_recording_files`, `cloud_recording:read:meeting_transcript`, `meeting:read:summary`, `team_chat:read:list_user_channels`, `team_chat:read:list_user_messages` and `team_chat:read:thread_message`, then `meeting:write:meeting`, `meeting:update:meeting` and `team_chat:write:user_message` | `registered` | Make a General app in Zoom's App Marketplace, under Develop, Build an app, and choose User-managed, so each person adds it for themselves and it reaches their own data alone; the `:admin` scopes of the same operations belong to an admin-managed app, which `zoom` is not. Give it the return address as its redirect URL and on its allow list, in its development and its production settings both, since Zoom keeps the two apart. List its client id and secret: the development credentials serve your own Zoom account alone, and the production credentials serve a beta, Zoom's review and the published app. Auto takes one client for each issuer, so switching from the one client id to the other breaks every connection made with the earlier one, and the person who added it removes it and adds it again | +| Provider | Issuer | Scope asked | Client sources | What you do at the vendor | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `github` | `https://github.com/login/oauth`, whose endpoints are on `github.com` | `read:org repo`, and `offline_access`, which GitHub's authorization server lists, so Auto asks for it too and GitHub gives a refresh token | `registered` | Register an OAuth app, as GitHub names that kind, and not a GitHub App, which takes no scope: the scope and an organization's approval are an OAuth app's. Give it the return address as its callback URL, and list its client id and secret with `token_endpoint_auth_method: client_secret_post`, since GitHub takes them in the body of its token request. Where an organization has its OAuth app access restrictions on, as a new organization has, one of its owners approves the app before a connection reads the organization's private repositories or changes its public ones | +| `profound` | `https://auth.tryprofound.com` | `openid offline_access`: Profound's server asks no scope of its own, and its authorization server is an OpenID provider, where `offline_access`, the scope that asks for a refresh token, is asked beside `openid` | `registered`, `metadata_document`, `dynamic` | Nothing: where you list no client at the issuer, Auto names its client by its metadata document where it signs people in at an `https` `PUBLIC_URL`, and registers itself there otherwise | +| `slack` | `https://mcp.slack.com`, whose endpoints are on `slack.com`, so where you set `allowed_hosts`, it allows both `mcp.slack.com` and `slack.com` | `search:read.public search:read.private search:read.im search:read.mpim search:read.users channels:read groups:read im:read mpim:read channels:history groups:history im:history mpim:history users:read reactions:read chat:write reactions:write`, as user token scopes | `registered` | Make a Slack app with the return address among its redirect URLs and these scopes among its user token scopes, turn on its token rotation, and list its client id and secret. Publish the app in the Slack Marketplace or keep it internal to your workspace, since Slack's MCP server admits only those apps, and a workspace admin may have to approve it | +| `zoom` | `https://zoom.us`, with its endpoints written out, `https://zoom.us/oauth/authorize`, `https://zoom.us/oauth/token` and `https://zoom.us/oauth/revoke`, since the metadata Auto reads for an issuer, `https://zoom.us/.well-known/oauth-authorization-server`, names no revocation endpoint, where Zoom's OpenID configuration does | None: the scopes added to the app, since Zoom documents no separator for several scopes in one request and grants the app's own where the request names none, and the connection records those Zoom granted. Add to the app these user-level scopes, each one a person grants for themselves, those that read first: `user:read:user`, `meeting:read:list_meetings`, `meeting:read:meeting`, `meeting:read:past_meeting`, `meeting:read:list_past_participants`, `cloud_recording:read:list_user_recordings`, `cloud_recording:read:list_recording_files`, `cloud_recording:read:meeting_transcript`, `meeting:read:summary`, `team_chat:read:list_user_channels`, `team_chat:read:list_user_messages` and `team_chat:read:thread_message`, then `meeting:write:meeting`, `meeting:update:meeting` and `team_chat:write:user_message` | `registered` | Make a General app in Zoom's App Marketplace, under Develop, Build an app, and choose User-managed, so each person adds it for themselves and it reaches their own data alone; the `:admin` scopes of the same operations belong to an admin-managed app, which `zoom` is not. Give it the return address as its redirect URL and on its allow list, in its development and its production settings both, since Zoom keeps the two apart. List its client id and secret: the development credentials serve your own Zoom account alone, and the production credentials serve a beta, Zoom's review and the published app. A connection is renewed with the client it was made with, so switching from the one client id to the other breaks every connection made with the earlier one, and the person who added it removes it and adds it again | Zoom's way writes out its revocation endpoint, so removing a connection to `zoom` asks Zoom to revoke its tokens there. None of the other three authorization servers publishes an endpoint that revokes a token, so removing a connection to one forgets its tokens without revoking them at the vendor. Of the authorization servers of Microsoft's, Amazon's and Adobe's providers, Adobe IMS publishes one, `https://ims-na1.adobelogin.com/ims/revoke`, which the metadata of Adobe's MCP server names too, so removing a connection to `adobe-analytics` or `adobe-analytics-mcp` made by consent revokes its tokens there; Microsoft's and Login with Amazon publish none, so removing a connection to `microsoft-advertising`, `outlook`, `amazon-ads` or `amazon-ads-mcp` revokes nothing at the vendor, and its tokens end on their own. GitHub's access token ends within eight hours and its refresh token after six months unused. Slack's tokens do not expire unless the app turns on token rotation, after which an access token lasts twelve hours: without it, a removed connection's token stays valid at Slack until the person removes the app there. @@ -306,43 +307,68 @@ A Zoom for Government account is served by its own marketplace at `zoomgov.com` ### The Google providers -Thirteen of the providers this version ships are Google's, each a provider of its own with a consent of its own, so a person who connects Drive and Gmail allows Auto twice, once for each. Each takes consent at Google's authorization server, the issuer `https://accounts.google.com`, through a client you register there, `registered`, the one source Google takes, since it registers no client itself and names no client by a metadata document. Each asks Google for `access_type: offline`, without which Google gives no refresh token, and `prompt: consent`, with which it gives one again to a person who allowed Auto before, and asks the least scope its tools need, the scope that reads before the one that writes, each written in full as `https://www.googleapis.com/auth/` and the name below. The table marks each scope Google classifies as restricted, and each provider that asks one names it in its description. - -| Provider | The scopes it asks | The APIs you turn on | -| --------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------- | -| `campaign-manager-360` | `dfareporting`, `dfatrafficking` | Campaign Manager 360 API | -| `display-video-360` | `display-video` | Display & Video 360 API | -| `display-video-360-reports` | `doubleclickbidmanager` | DoubleClick Bid Manager API | -| `gmail` | `gmail.modify` (restricted) | Gmail API and Gmail MCP API | -| `google-ads` | `adwords` | Google Ads API | -| `google-analytics` | `analytics.readonly` | Google Analytics Data API | -| `google-analytics-admin` | `analytics.edit` | Google Analytics Admin API | -| `google-docs` | `drive.readonly` (restricted), `drive.file`, `documents.readonly`, `documents` | Google Docs API and Google Docs MCP API | -| `google-drive` | `drive.readonly` (restricted), `drive.file` | Google Drive API and Google Drive MCP API | -| `google-search-console` | `webmasters` | Google Search Console API | -| `google-sheets` | `drive.readonly` (restricted), `drive.file`, `spreadsheets.readonly`, `spreadsheets` | Google Sheets API and Google Sheets MCP API | -| `google-slides` | `drive.readonly` (restricted), `drive.file`, `presentations.readonly`, `presentations` | Google Slides API and Google Slides MCP API | -| `search-ads-360` | `doubleclicksearch` | Search Ads 360 Reporting API | - -What you do at Google, once for every Google provider you offer: - -1. Make one Google Cloud project, and turn on in it the APIs of each provider you offer, in the table above. -2. Set up its consent screen in the Google Auth Platform: the branding a person sees when they allow Auto, the audience, External for people of any Google account or Internal for the people of your Google Workspace organization alone, and, under data access, the scopes of each provider you offer. -3. Make one client of the type web application, with the return address the `registered` source above names among its authorized redirect addresses, and with Use this client for an AI-powered agent selected, since the Workspace servers refuse the requests of a client without it. List it under `registered_clients` with the issuer `https://accounts.google.com`: +Thirteen of the providers this version ships are Google's, each a provider of its own with a consent of its own, so a person who connects Drive and Gmail allows Auto twice, once for each. Each takes consent at Google's authorization server, the issuer `https://accounts.google.com`, through a client you register there, `registered`, the one source Google takes, since it registers no client itself and names no client by a metadata document. Each asks Google for `access_type: offline`, without which Google gives no refresh token, and `prompt: consent`, with which it gives one again to a person who allowed Auto before, and asks the least scope its tools need, the scope that reads before the one that writes, each written in full as `https://www.googleapis.com/auth/` and the name below. The table marks each scope Google classifies as restricted, and each provider that asks one names it in its description. Google verifies a project at the tier of the most demanding scope its consent screen asks, so the table gives each provider's tier: sensitive for the eight APIs, and restricted for the five Workspace servers, whose scopes Google's own pages fix, `drive.readonly` or `gmail.modify` among them. + +| Provider | The scopes it asks | Google's tier | The APIs you turn on | +| --------------------------- | -------------------------------------------------------------------------------------- | ------------- | ------------------------------------------- | +| `campaign-manager-360` | `dfareporting`, `dfatrafficking` | sensitive | Campaign Manager 360 API | +| `display-video-360` | `display-video` | sensitive | Display & Video 360 API | +| `display-video-360-reports` | `doubleclickbidmanager` | sensitive | DoubleClick Bid Manager API | +| `gmail` | `gmail.modify` (restricted) | restricted | Gmail API and Gmail MCP API | +| `google-ads` | `adwords` | sensitive | Google Ads API | +| `google-analytics` | `analytics.readonly` | sensitive | Google Analytics Data API | +| `google-analytics-admin` | `analytics.edit` | sensitive | Google Analytics Admin API | +| `google-docs` | `drive.readonly` (restricted), `drive.file`, `documents.readonly`, `documents` | restricted | Google Docs API and Google Docs MCP API | +| `google-drive` | `drive.readonly` (restricted), `drive.file` | restricted | Google Drive API and Google Drive MCP API | +| `google-search-console` | `webmasters` | sensitive | Google Search Console API | +| `google-sheets` | `drive.readonly` (restricted), `drive.file`, `spreadsheets.readonly`, `spreadsheets` | restricted | Google Sheets API and Google Sheets MCP API | +| `google-slides` | `drive.readonly` (restricted), `drive.file`, `presentations.readonly`, `presentations` | restricted | Google Slides API and Google Slides MCP API | +| `search-ads-360` | `doubleclicksearch` | sensitive | Search Ads 360 Reporting API | + +What you do at Google, once for each tier you offer, both or one, so that the providers of one tier are verified apart from those of the other: + +1. Make one Google Cloud project for the tier, and turn on in it the APIs of each provider of that tier you offer, in the table above. +2. Set up its consent screen in the Google Auth Platform: the branding a person sees when they allow Auto, the audience, External for people of any Google account or Internal for the people of your Google Workspace organization alone, and, under data access, the scopes of each provider of the tier you offer, and none of the other tier's. +3. Make one client in the project, of the type web application, with the return address the `registered` source above names among its authorized redirect addresses, and, in the project of the restricted tier, with Use this client for an AI-powered agent selected, since the Workspace servers refuse the requests of a client without it. List it under `registered_clients` with the issuer `https://accounts.google.com` and, as `scopes`, the scopes of its consent screen, each written in full, so that each provider takes the client of its tier: ```yaml connections: registered_clients: - issuers: [https://accounts.google.com] client_id: 1234567890-example.apps.googleusercontent.com - client_secret: ${GOOGLE_CLIENT_SECRET} + client_secret: ${GOOGLE_SENSITIVE_CLIENT_SECRET} + scopes: + - https://www.googleapis.com/auth/adwords + - https://www.googleapis.com/auth/analytics.edit + - https://www.googleapis.com/auth/analytics.readonly + - https://www.googleapis.com/auth/dfareporting + - https://www.googleapis.com/auth/dfatrafficking + - https://www.googleapis.com/auth/display-video + - https://www.googleapis.com/auth/doubleclickbidmanager + - https://www.googleapis.com/auth/doubleclicksearch + - https://www.googleapis.com/auth/webmasters + - issuers: [https://accounts.google.com] + client_id: 2345678901-example.apps.googleusercontent.com + client_secret: ${GOOGLE_RESTRICTED_CLIENT_SECRET} + scopes: + - https://www.googleapis.com/auth/documents + - https://www.googleapis.com/auth/documents.readonly + - https://www.googleapis.com/auth/drive.file + - https://www.googleapis.com/auth/drive.readonly + - https://www.googleapis.com/auth/gmail.modify + - https://www.googleapis.com/auth/presentations + - https://www.googleapis.com/auth/presentations.readonly + - https://www.googleapis.com/auth/spreadsheets + - https://www.googleapis.com/auth/spreadsheets.readonly ``` -4. Have the client verified, for every provider you offer: Google verifies the brand of the consent screen and reviews the sensitive scopes the providers ask. Where a provider asks a scope the table above marks restricted, Google also asks for a security assessment by an assessor it approves, since Auto reaches that data from a server, and asks for it again every year. An Internal app, for the people of your Workspace organization alone, needs none of this. + With one tier alone, its one client may leave `scopes` out. + +4. Have each project verified for the providers of its tier. For both tiers Google verifies the brand of the consent screen and reviews a justification of each scope it asks and a video of how Auto uses it; for the restricted tier alone it also asks for a security assessment by an assessor it approves, since Auto reaches that data from a server, and asks for it again every year. Each project is verified on its own, so a finding on the one never touches the other. An Internal app, for the people of your Workspace organization alone, needs none of this. -Until Google verifies the client, its consent screen says the app is not verified to the person who allows Auto. While the publishing status of an External app is Testing, the app takes the test users you list alone, and Google ends each refresh token it gives seven days after it gave it, so Auto cannot renew the tokens of a connection after that: the connection breaks, and the person who added it removes it and adds it again. +Until Google verifies a project, its consent screen says the app is not verified to the person who allows Auto. While the publishing status of an External app is Testing, the app takes the test users you list alone, and Google ends each refresh token it gives seven days after it gave it, so Auto cannot renew the tokens of a connection after that: the connection breaks, and the person who added it removes it and adds it again. -The five Workspace providers, `google-drive`, `google-sheets`, `google-docs`, `google-slides` and `gmail`, are Google's MCP servers in its developer preview: you join the Google Workspace Developer Preview Program, which each of them asks, and the project turns on the MCP API of each server, as `drivemcp.googleapis.com`, beside the API the server calls, as `drive.googleapis.com`. The Google Ads API gives the project Test access when it is turned on, which reaches test accounts alone; the project applies for Explorer access or more on the Google Ads API overview of the console before a person connects a production account. Auto sends Google Ads no developer token, which Google no longer asks for since 9 September 2026. +The five Workspace providers, `google-drive`, `google-sheets`, `google-docs`, `google-slides` and `gmail`, are Google's MCP servers in its developer preview: you join the Google Workspace Developer Preview Program, which each of them asks, and the project of the restricted tier turns on the MCP API of each server, as `drivemcp.googleapis.com`, beside the API the server calls, as `drive.googleapis.com`. The Google Ads API gives the project of the sensitive tier Test access when it is turned on, which reaches test accounts alone; the project applies for Explorer access or more on the Google Ads API overview of the console before a person connects a production account. Auto sends Google Ads no developer token, which Google no longer asks for since 9 September 2026. ### The Microsoft providers diff --git a/packages/connections/README.md b/packages/connections/README.md index a8fbdf71b..e82892c8c 100644 --- a/packages/connections/README.md +++ b/packages/connections/README.md @@ -66,7 +66,7 @@ There is no read model: each call folds the stream (`connectionsFold`, the fold - `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 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. +- `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 and the scope it asks, 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, `secret_refused`, being `invalid_input` at its pointer with the store's sentence, and a store that cannot keep it now, its custodian refusing or not answering, `unavailable`, `The credential of the connection crm cannot be kept now, so the connection is not connected and its link stays open: `, keeping nothing, so the same credential is given again through the same link; it 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. @@ -85,7 +85,7 @@ There is no read model: each call folds the stream (`connectionsFold`, the fold ### The registered clients -`registered_clients` lists the clients whoever runs the runtime registered at authorization servers (`RegisteredClientsSchema` of `src/registered-clients/registered-clients.ts`), each with `issuers`, the issuers it is registered at, at least one, `client_id`, and one of `client_secret` as a reference, `private_key` as a reference with `algorithm`, or neither for a public client, and, where the authorization server's metadata leaves the choice, `token_endpoint_auth_method`, one its kind authenticates with: `client_secret_basic` or `client_secret_post` with a secret, `private_key_jwt` with a key and `none` without either. Each issuer is an https URL, or an http URL on a loopback address, judged by the rule a provider's addresses are judged by, `isPinnableTemplate` of `@on.auto/providers`, so one written with a placeholder, as `https://login.example.com/{tenant}`, is kept as its template. Two clients whose issuers compare equal, a trailing slash aside, as the MCP client compares issuers (`isSameIssuer` of `@on.auto/tool-servers`), are refused at the later one's issuer. A client is a `RegisteredClient` of `@on.auto/tool-servers`, whose consent client finds one by the issuer of a way. The fields of a client that hold a reference are named once, `referenceHoldingFields` of `src/registered-clients/reference-fields.ts`, `client_secret` and `private_key`, and they are the only places of a client a reference stands, as a header or a query value of a provider under `providers` is the only other place of the setting: the check of each client refuses a reference in every other text the client holds, whatever its field, its issuers, its client id and its algorithm among them, before the client's other checks, in words that name the two fields of a client, and `heldAsWritten`, of the same module, refuses one in a host of `allowed_hosts` and a name of `variables` before their own patterns, in words that name those two fields and a provider's header or query value, both built from `referenceHoldingFields`, so the file's reading, which judges the setting as written through the schema, and the reading of an environment `CONNECTIONS` refuse it alike; a reference in `people_may_add`, `credential_kinds` or `token_endpoint_auth_method`, which hold no text a reference could be, is refused as a value the key does not take, and a client whose other fields are refused is judged for its references once they are mended. The reading takes the setting's references as they are written, from the file, whose setting keeps them, and from an environment `CONNECTIONS` alike: a value written out in a client is refused first, before the schema judges the setting, as the file refuses it before its own schema, by `credentialProblems` of `@on.auto/config`, the sorts of credential the setting names, `connectionsSortsOfCredential`, which the file's setting takes too, excepted, in words of its field, a client secret's, a private key's and a client id's each their own, and a reference whose default looks like a credential in words that say to set the variable instead, while an empty default passes; then the fields `referenceHoldingFields` names, and they alone, are substituted from the environment, a variable that is not set refused at its pointer as a reference of the file is, and a value that comes to nothing, as `${EXAMPLE_CLIENT_SECRET:-}` with the variable unset, refused at its pointer too, and the variables they name are `clientVariables`, which `namingNoOwnVariable` counts among the runtime's own, so `variables` may not name them. Every problem is at its pointer and never names a value. A registered client holds its secret or its key as a `Redacted`, and `registeredClientSecrets` answers them, which the server gives its tool access as `runtimeSecrets`, so they are scrubbed from everything a call records and answers, as the secrets of its tool servers are. +`registered_clients` lists the clients whoever runs the runtime registered at authorization servers (`RegisteredClientsSchema` of `src/registered-clients/registered-clients.ts`), each with `issuers`, the issuers it is registered at, at least one, `client_id`, and one of `client_secret` as a reference, `private_key` as a reference with `algorithm`, or neither for a public client, and, where the authorization server's metadata leaves the choice, `token_endpoint_auth_method`, one its kind authenticates with: `client_secret_basic` or `client_secret_post` with a secret, `private_key_jwt` with a key and `none` without either. Each issuer is an https URL, or an http URL on a loopback address, judged by the rule a provider's addresses are judged by, `isPinnableTemplate` of `@on.auto/providers`, so one written with a placeholder, as `https://login.example.com/{tenant}`, is kept as its template. A client may name `scopes`, at least one, none of them empty, the scopes it may ask at its issuers, each as a provider's way writes it. Where clients name issuers that compare equal, a trailing slash aside, as the MCP client compares issuers (`isSameIssuer` of `@on.auto/tool-servers`), each names its scopes: one that names none is refused at its `scopes`, in words that say it would serve every way there that no client before it covers, and a client after it never. A client is a `RegisteredClient` of `@on.auto/tool-servers`, which carries its `scopes`, and whose consent client takes for a way the first client at the way's issuer whose scopes hold every scope the way asks, one that names none holding them all. The fields of a client that hold a reference are named once, `referenceHoldingFields` of `src/registered-clients/reference-fields.ts`, `client_secret` and `private_key`, and they are the only places of a client a reference stands, as a header or a query value of a provider under `providers` is the only other place of the setting: the check of each client refuses a reference in every other text the client holds, whatever its field, its issuers, its client id, its algorithm and its scopes among them, before the client's other checks, in words that name the two fields of a client, and `heldAsWritten`, of the same module, refuses one in a host of `allowed_hosts` and a name of `variables` before their own patterns, in words that name those two fields and a provider's header or query value, both built from `referenceHoldingFields`, so the file's reading, which judges the setting as written through the schema, and the reading of an environment `CONNECTIONS` refuse it alike; a reference in `people_may_add`, `credential_kinds` or `token_endpoint_auth_method`, which hold no text a reference could be, is refused as a value the key does not take, and a client whose other fields are refused is judged for its references once they are mended. The reading takes the setting's references as they are written, from the file, whose setting keeps them, and from an environment `CONNECTIONS` alike: a value written out in a client is refused first, before the schema judges the setting, as the file refuses it before its own schema, by `credentialProblems` of `@on.auto/config`, the sorts of credential the setting names, `connectionsSortsOfCredential`, which the file's setting takes too, excepted, in words of its field, a client secret's, a private key's and a client id's each their own, and a reference whose default looks like a credential in words that say to set the variable instead, while an empty default passes; then the fields `referenceHoldingFields` names, and they alone, are substituted from the environment, a variable that is not set refused at its pointer as a reference of the file is, and a value that comes to nothing, as `${EXAMPLE_CLIENT_SECRET:-}` with the variable unset, refused at its pointer too, and the variables they name are `clientVariables`, which `namingNoOwnVariable` counts among the runtime's own, so `variables` may not name them. Every problem is at its pointer and never names a value. A registered client holds its secret or its key as a `Redacted`, and `registeredClientSecrets` answers them, which the server gives its tool access as `runtimeSecrets`, so they are scrubbed from everything a call records and answers, as the secrets of its tool servers are. ### The clients the runtime registered itself diff --git a/packages/connections/src/policy/connection-policy.ts b/packages/connections/src/policy/connection-policy.ts index 953aff9be..5c9520375 100644 --- a/packages/connections/src/policy/connection-policy.ts +++ b/packages/connections/src/policy/connection-policy.ts @@ -95,7 +95,7 @@ export const ConnectionsSettingSchema = Schema.Struct({ 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", + "Whether a provider whose way takes a registered client takes Auto's client too, through Auto's consent service, where no client registered at its issuer covers the scope the way asks; true in local mode and false otherwise when left out. AUTO_CLIENTS wins over it", }), ), providers: Schema.optionalKey(AddedProvidersSchema), diff --git a/packages/connections/src/providers/consent-offered.ts b/packages/connections/src/providers/consent-offered.ts index 8b06a5de6..f8ddd2ec2 100644 --- a/packages/connections/src/providers/consent-offered.ts +++ b/packages/connections/src/providers/consent-offered.ts @@ -3,6 +3,7 @@ import type { HadSources, WayClientSource } from '@on.auto/tool-servers'; interface WayOfConsent { readonly kind: string; readonly issuer?: string; + readonly scope?: string; readonly clients?: readonly WayClientSource[]; } @@ -12,6 +13,7 @@ export function consentUnsetUp(provider: string): string { export function isConsentUnsetUp(way: WayOfConsent, consent: HadSources): boolean { return ( - way.kind === 'authorization' && consent.sourcesOf({ issuer: String(way.issuer), clients: way.clients }).length === 0 + way.kind === 'authorization' && + consent.sourcesOf({ issuer: String(way.issuer), scope: way.scope, clients: way.clients }).length === 0 ); } diff --git a/packages/connections/src/providers/provider-offering.test.ts b/packages/connections/src/providers/provider-offering.test.ts index ba21ec059..ef39c61aa 100644 --- a/packages/connections/src/providers/provider-offering.test.ts +++ b/packages/connections/src/providers/provider-offering.test.ts @@ -1,6 +1,7 @@ import type { CallerIdentity } from '@on.auto/operations'; -import { fixtureProviders } from '@on.auto/providers/testing'; -import { consentClientWith } from '@on.auto/tool-servers/testing'; +import { drive, fixtureProviders, providersOf } from '@on.auto/providers/testing'; +import type { ConsentClient } from '@on.auto/tool-servers'; +import { consentClientWith, registeredAt } from '@on.auto/tool-servers/testing'; import { Effect, type Redacted } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -16,16 +17,24 @@ const adaReading: CallerIdentity = { ...ada, permissions: ['org:read'] }; interface RuntimeSettings { readonly policy?: ConnectionPolicyFields; readonly providerValues?: ReadonlyMap; + readonly providers?: typeof fixtureProviders; + readonly consent?: ConsentClient; } -function offeredBy(access: RuntimeAccess, { policy = {}, providerValues = new Map() }: RuntimeSettings = {}) { +function offeredBy(access: RuntimeAccess, settings: RuntimeSettings = {}) { + const { + policy = {}, + providerValues = new Map(), + providers = fixtureProviders, + consent = consentClientWith(), + } = settings; const offering = makeProviderOffering({ - providers: fixtureProviders, + providers, providerValues, policy: connectionPolicyOf(policy), access, pages: harnessPages, - consent: consentClientWith(), + consent, }); return (caller: CallerIdentity) => Effect.runSync(offering(caller)); } @@ -73,3 +82,18 @@ describe('the providers offered to a caller', () => { ]).toEqual([undefined, undefined, undefined, undefined]); }); }); + +function driveOfferedWith(scopes: readonly string[]) { + const registered = registeredAt('https://accounts.drive.example.com', { scopes }); + const consent = consentClientWith({ registeredClients: [registered] }); + return namesOf(offeredBy('people', { providers: providersOf(drive), consent })(ada)); +} + +describe('the providers of consent offered to a caller', () => { + it('take in a provider once a client registered at its issuer names every scope its way asks', () => { + expect([driveOfferedWith(['files.read']), driveOfferedWith(['files.write', 'files.read'])]).toEqual([ + [], + ['drive'], + ]); + }); +}); diff --git a/packages/connections/src/registered-clients/registered-clients.test.ts b/packages/connections/src/registered-clients/registered-clients.test.ts index 9cd903fdd..8b4855212 100644 --- a/packages/connections/src/registered-clients/registered-clients.test.ts +++ b/packages/connections/src/registered-clients/registered-clients.test.ts @@ -87,27 +87,55 @@ describe('the issuers of a registered client', () => { ].join('; '), ); }); +}); - it('are the issuers of one client, two that differ by a trailing slash alone being one issuer', () => { - expect( - refusalOf( - withASecret, - { ...publicClient, issuers: ['https://other.example.com', 'https://accounts.example.com/'] }, - { - ...withAKey, - issuers: ['https://accounts.example.com'], - }, - ), - ).toBe( - [ - 'The connection settings are invalid. CONNECTIONS: /registered_clients/1/issuers/1: Names an issuer an earlier registered client names too, a trailing slash aside; register each issuer under one client', - 'CONNECTIONS: /registered_clients/2/issuers/0: Names an issuer an earlier registered client names too, a trailing slash aside; register each issuer under one client', - ].join('; '), - ); +const scopesUnnamed = + 'Expected scopes, the scopes the client may ask, since another registered client names one of its issuers too, a trailing slash aside: a client that names none would serve every way there that no client before it covers, and a client after it never'; + +describe('the scopes of a registered client', () => { + it('are read where a client names them, and left out of a client that may ask any', () => { + const reading = { ...withASecret, scopes: ['files.read'] }; + const sending = { + ...publicClient, + issuers: ['https://accounts.example.com/'], + scopes: ['mail.send', 'files.read'], + }; + + expect(clientsOf(reading, sending, withAKey).map(({ clientId, scopes }) => ({ clientId, scopes }))).toEqual([ + { clientId: '1234.apps.example.com', scopes: ['files.read'] }, + { clientId: 'auto-runtime', scopes: ['mail.send', 'files.read'] }, + { clientId: '9f1c', scopes: undefined }, + ]); + }); + + it('are named by each client at an issuer another client names, a trailing slash aside, whichever stands first', () => { + const sharing = { ...publicClient, issuers: ['https://other.example.com', 'https://accounts.example.com/'] }; + + expect([ + refusalOf(withASecret, { ...sharing, scopes: ['files.read'] }), + refusalOf({ ...withASecret, scopes: ['files.read'] }, sharing), + refusalOf(withASecret, withAKey, sharing), + ]).toEqual([ + `${prefix}/scopes: ${scopesUnnamed}`, + `The connection settings are invalid. CONNECTIONS: /registered_clients/1/scopes: ${scopesUnnamed}`, + `${prefix}/scopes: ${scopesUnnamed}; CONNECTIONS: /registered_clients/2/scopes: ${scopesUnnamed}`, + ]); expect(clientsOf(withASecret, { ...publicClient, issuers: ['https://accounts.example.com/other'] })).toHaveLength( 2, ); }); + + it('are one at least, each written out and not empty', () => { + expect([ + refusalOf({ ...withASecret, scopes: [] }), + refusalOf({ ...withASecret, scopes: ['files.read', ''] }), + refusalOf({ ...withASecret, scopes: ['${EXAMPLE_SCOPE}'] }), + ]).toEqual([ + `${prefix}/scopes: Expected at least one scope the client may ask; leave scopes out for a client that may ask any`, + `${prefix}/scopes/1: Expected a value with a length of at least 1`, + `${prefix}/scopes/0: Holds a reference to an environment variable, which only the client_secret and the private_key of a registered client may hold`, + ]); + }); }); describe('the credential of a registered client', () => { diff --git a/packages/connections/src/registered-clients/registered-clients.ts b/packages/connections/src/registered-clients/registered-clients.ts index 9d023da36..0df89a60d 100644 --- a/packages/connections/src/registered-clients/registered-clients.ts +++ b/packages/connections/src/registered-clients/registered-clients.ts @@ -51,6 +51,18 @@ const RegisteredClientFieldsSchema = Schema.Struct({ 'How the client authenticates at the token endpoint where the metadata of the authorization server leaves the choice; chosen from the client and the metadata when left out', }), ), + scopes: Schema.optionalKey( + Schema.Array(NamedSchema) + .check( + Schema.isMinLength(1, { + message: 'Expected at least one scope the client may ask; leave scopes out for a client that may ask any', + }), + ) + .annotate({ + description: + "The scopes the client may ask at its issuers, each as a provider's way writes it: a way takes the first client at its issuer that names every scope the way asks. Named for each client where several clients serve one issuer, and left out for a client that may ask any", + }), + ), }); type RegisteredClientFields = typeof RegisteredClientFieldsSchema.Type; @@ -150,16 +162,18 @@ function writtenClientIssuesOf(client: RegisteredClientFields): readonly ClientI : [...issuerIssuesOf(client), ...credentialIssuesOf(client), ...methodIssuesOf(client)]; } -const sharedIssuer = - 'Names an issuer an earlier registered client names too, a trailing slash aside; register each issuer under one client'; +const scopesUnnamed = + 'Expected scopes, the scopes the client may ask, since another registered client names one of its issuers too, a trailing slash aside: a client that names none would serve every way there that no client before it covers, and a client after it never'; + +function sharesAnIssuer(client: RegisteredClientFields, other: RegisteredClientFields): boolean { + return client.issuers.some((issuer) => other.issuers.some((named) => isSameIssuer(named, issuer))); +} -function sharedIssuerIssuesOf(clients: readonly RegisteredClientFields[]): readonly ClientIssue[] { +function unnamedScopeIssuesOf(clients: readonly RegisteredClientFields[]): readonly ClientIssue[] { return clients.flatMap((client, position) => - client.issuers.flatMap((issuer, index) => - clients.slice(0, position).some(({ issuers }) => issuers.some((named) => isSameIssuer(named, issuer))) - ? [{ path: [position, 'issuers', index], issue: sharedIssuer }] - : [], - ), + client.scopes === undefined && clients.some((other, index) => index !== position && sharesAnIssuer(client, other)) + ? [{ path: [position, 'scopes'], issue: scopesUnnamed }] + : [], ); } @@ -168,9 +182,9 @@ export const RegisteredClientsSchema = Schema.Array( ) .annotate({ 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', + '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, and the scopes it may ask, which each client names where several serve one issuer', }) - .check(Schema.makeFilter((clients: readonly RegisteredClientFields[]) => sharedIssuerIssuesOf(clients))); + .check(Schema.makeFilter((clients: readonly RegisteredClientFields[]) => unnamedScopeIssuesOf(clients))); function credentialOf({ client_secret: clientSecret, @@ -195,6 +209,7 @@ export function registeredClientsOf(clients: readonly RegisteredClientFields[]): clientId: client.client_id, credential: credentialOf(client), tokenEndpointAuthMethod: client.token_endpoint_auth_method ?? null, + ...(client.scopes === undefined ? {} : { scopes: client.scopes }), })); } diff --git a/packages/server/src/config-file/connections-section.test.ts b/packages/server/src/config-file/connections-section.test.ts index e2be3ca89..e2b793b2f 100644 --- a/packages/server/src/config-file/connections-section.test.ts +++ b/packages/server/src/config-file/connections-section.test.ts @@ -226,12 +226,14 @@ describe('the secrets of the registered clients of the connections section', () }); describe('the issuers and the references of the registered clients of the connections section', () => { - it('stop the start at two clients of one issuer, and at variables naming a variable they refer to, at their lines', () => { + it('stop the start at clients of one issuer that name no scopes, and at variables naming a variable they refer to, at their lines', () => { const sharing = registeredClients.replace('http://127.0.0.1:8443', 'https://accounts.example.com/'); const naming = `${registeredClients} variables: [EXAMPLE_CLIENT_SECRET]\n`; + const scopesUnnamed = + 'Expected scopes, the scopes the client may ask, since another registered client names one of its issuers too, a trailing slash aside: a client that names none would serve every way there that no client before it covers, and a client after it never'; expect(refusalOf(fileOf(sharing), clientSecrets)).toBe( - `${fileInvalid} auto-runtime.yaml:11:17 connections.registered_clients[2].issuers[0]: Names an issuer an earlier registered client names too, a trailing slash aside; register each issuer under one client`, + `${fileInvalid} auto-runtime.yaml:3:7 connections.registered_clients[0].scopes: ${scopesUnnamed}; auto-runtime.yaml:11:7 connections.registered_clients[2].scopes: ${scopesUnnamed}`, ); expect(refusalOf(fileOf(naming), clientSecrets)).toBe( `connections_setting_invalid: The connection settings are invalid. auto-runtime.yaml:13:15 connections.variables[0]: EXAMPLE_CLIENT_SECRET ${ownVariable}`, diff --git a/packages/tool-servers/README.md b/packages/tool-servers/README.md index 9f1f2b095..0c9050a2f 100644 --- a/packages/tool-servers/README.md +++ b/packages/tool-servers/README.md @@ -176,9 +176,9 @@ With an `auth` block, tokens come from the MCP client's client-credentials or pr `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: -- `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. `registrations` fails with the store's tag and words, `{ _tag, detail }`: a latest the store cannot read now is `registration_unavailable`, `{ detail }` in the store's words, and so is a registration made that the store cannot keep now, which is logged at WARN by its issuer and its client id and never its secret, `The client registered at https://accounts.drive.example.com as client-1 cannot be kept now, so this process keeps it at its next consent there: `: the process holds it in memory, and the next start at the issuer keeps it before it reads or registers anything, so no second client is registered while it lasts, and a process that stops first leaves it registered at the server and unused. A registration the store refuses, `secret_refused`, is never held, since it will never be kept: the start fails as `registration_refused` in the refusal's words, and the WARN line says so, `The client registered at https://accounts.drive.example.com as client-1 cannot be kept, so it stays registered there unused: `. A finish, a renewal and a removal that cannot read the client a connection recorded are `registration_unavailable` alike. 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. +- `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, and covers the way's `scope`: a client that names `scopes` covers a way whose every scope, the way's `scope` split on spaces, is one of them, compared as written, and a client that names none covers every way, so `registeredClientFor(clients, way)` is the first client at the issuer that covers the way; `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 first registered client at the issuer that covers the way, `registeredClientFor`, 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. `registrations` fails with the store's tag and words, `{ _tag, detail }`: a latest the store cannot read now is `registration_unavailable`, `{ detail }` in the store's words, and so is a registration made that the store cannot keep now, which is logged at WARN by its issuer and its client id and never its secret, `The client registered at https://accounts.drive.example.com as client-1 cannot be kept now, so this process keeps it at its next consent there: `: the process holds it in memory, and the next start at the issuer keeps it before it reads or registers anything, so no second client is registered while it lasts, and a process that stops first leaves it registered at the server and unused. A registration the store refuses, `secret_refused`, is never held, since it will never be kept: the start fails as `registration_refused` in the refusal's words, and the WARN line says so, `The client registered at https://accounts.drive.example.com as client-1 cannot be kept, so it stays registered there unused: `. A finish, a renewal and a removal that cannot read the client a connection recorded are `registration_unavailable` alike. 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, `registeredClientNamed`, whichever client stands first there, 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. A value the store refuses, tagged `secret_refused`, revokes what it got and fails in the store's words. A replacement the store cannot make now, its custodian refusing or not answering, revokes nothing: the source keeps what it got in memory and fails the call in the store's words, and while its claim lasts serves the access token it holds as a failed claim does, below; once the claim ends, the next renewal claims the row again and keeps what it got there without asking the server again, or, where another process replaced the row meanwhile, takes that value and leaves what it got. The replacement's failure is told apart by its tag, for the renewals at the vendor and through Auto's consent service alike, since both keep through `replace`. 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`, a client registration the store cannot read now, `The client the connection drive was made with cannot be read now, so its tokens were not renewed; the next call tries again: `, 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. @@ -186,7 +186,7 @@ With an `auth` block, tokens come from the MCP client's client-credentials or pr `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`. +- 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, that covers the way, as for a registered client, is the source `auto`, `serviceClientFor(clients, way)`: the service lists each client with its `issuer`, its `client_id` and, where it names them, the `scopes` it may ask, and one listed without `scopes` covers every way. - 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. @@ -242,8 +242,8 @@ The arguments, as `JSON.stringify` writes them, and the protocol answer are scru - `serveFakeMcp({ bearer?, client?, requestIdHeader?, issuesSessionIds?, annotated?, hints?, chat?, data? })` serves it over Streamable HTTP on a loopback port the system picks, with a bearer check or a fake authorization server, sessions, or none with `issuesSessionIds: false`, its tools listed without their annotations with `annotated: false`, its tools' annotations replaced by `hints`, and ways to answer the next requests with a status (`answerNextWith`, for a 429 with `Retry-After`, or `answerNextOf` for the next of one method alone, such as `tools/call`), answer the next tool call after messages that are not JSON-RPC (`answerNextCallAfterNoise`), forget every session (a 404), revoke tokens (a 401), remove a tool and send `list_changed`. With `chat: true` it also holds a chat, `chat`, whose `post_message` posts a message and answers where it landed and whose `thread_replies` lists the messages of a conversation or of one thread after a point, oldest first, and a test replies as a person with `chat.reply`. With `data: true` it also holds tools whose answers a call reads: `thread`, structured content beside a summary text, the replies of `threadReplies`; `pages`, one page and the cursor of the next; `blocks`, two text blocks of JSON; `strict`, which refuses an argument that is not a whole number with -32602; and `promised`, which declares an output schema of a count and answers `brokenPromise`, a count that is not a number. `holdNextOf` holds the next request of one method unanswered, such as a listing that never comes. A request its handler fails on is answered with a 500 and recorded in `failures()`, beside the requests in `seen()` and the method, the path and the `User-Agent` of every request it heard in `heard()`, instead of rejecting where no test awaits it. - `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(custodian?)`, registrations kept in memory, each in a store sealed by a recording custodian of `@on.auto/secrets/testing`, `custodian`, which a test tells to refuse, 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)`; `sealedTokens(value, now)`, the same over a store sealed by a recording custodian, `custodian`, which a test tells to refuse, its read failing as the connections' source fails it, `connection_unavailable` in the store's words; `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; and `cuttingTimeouts()`, request timeouts for a consent client or the calls of the consent service that end no request until the test cuts them, `cut()`, or once what the test waits for holds, `cutWhen(holds)`, and then every one, recording the bounds they were asked for, so a test ends a stalled request once it is heard and never at a time. -- `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, requestTimeout?)`, the runtime's calls of the contract at it, under the request timeouts it is given, the platform's unless a test gives `cuttingTimeouts()`. +- `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, and `registeredAfterAnother(issuer)`, that client behind another at the same issuer, both naming the scope of `startAskedAt`; `consentClientWith(options)`, a consent client over the global `fetch`, with no registered client, no public address and `memoryRegistrations(custodian?)`, registrations kept in memory, each in a store sealed by a recording custodian of `@on.auto/secrets/testing`, `custodian`, which a test tells to refuse, 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)`; `sealedTokens(value, now)`, the same over a store sealed by a recording custodian, `custodian`, which a test tells to refuse, its read failing as the connections' source fails it, `connection_unavailable` in the store's words; `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; and `cuttingTimeouts()`, request timeouts for a consent client or the calls of the consent service that end no request until the test cuts them, `cut()`, or once what the test waits for holds, `cutWhen(holds)`, and then every one, recording the bounds they were asked for, so a test ends a stalled request once it is heard and never at a time. +- `serveFakeConsentService({ vendor, now, scopes? })` (`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, with `scopes` where they are given; 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?, listing?)`, a vendor and the service over it on one test clock, its client naming the `scopes` of `listing`, `ServiceListing`, where it gives them; `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, requestTimeout?)`, the runtime's calls of the contract at it, under the request timeouts it is given, the platform's unless a test gives `cuttingTimeouts()`. - `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/consent-testing/auto-consents.ts b/packages/tool-servers/src/consent-testing/auto-consents.ts index a4c24a5a9..31e5a2f64 100644 --- a/packages/tool-servers/src/consent-testing/auto-consents.ts +++ b/packages/tool-servers/src/consent-testing/auto-consents.ts @@ -23,6 +23,8 @@ export interface ThroughAuto { readonly close: () => Promise; } +export type ServiceListing = Pick[0], 'scopes'>; + export const studioSession = Redacted.make('session-of-a-person-signed-in-to-auto'); export function autoClientsOf(service: Pick, kind: ReturnKind = 'hosted'): AutoClients { @@ -53,10 +55,13 @@ export async function handedOffAskedOf( return { server, way, returnAddress, reaches, client: start.client, verifier: start.verifier, handle }; } -export async function servedThroughAuto(changes: Partial = {}): Promise { +export async function servedThroughAuto( + changes: Partial = {}, + listing: ServiceListing = {}, +): Promise { const fake = await servedForConsent(changes); const clock = clockAt(0); - const service = await serveFakeConsentService({ vendor: fake, now: clock.now }); + const service = await serveFakeConsentService({ vendor: fake, now: clock.now, ...listing }); return { fake, service, diff --git a/packages/tool-servers/src/consent-testing/consent-fakes.ts b/packages/tool-servers/src/consent-testing/consent-fakes.ts index d4228ee8b..2a1df01dc 100644 --- a/packages/tool-servers/src/consent-testing/consent-fakes.ts +++ b/packages/tool-servers/src/consent-testing/consent-fakes.ts @@ -44,6 +44,13 @@ export function registeredAt(issuer: string, changes: Partial }; } +export function registeredAfterAnother(issuer: string): readonly RegisteredClient[] { + return [ + registeredAt(issuer, { clientId: 'standing-first', scopes: ['files.read'] }), + registeredAt(issuer, { scopes: ['files.read'] }), + ]; +} + export function consentClientWith(options: Partial = {}): ConsentClient { return makeConsentClient({ fetch: globalThis.fetch, 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 index 60378f88c..f18b3eb11 100644 --- a/packages/tool-servers/src/consent-testing/fake-consent-service.test.ts +++ b/packages/tool-servers/src/consent-testing/fake-consent-service.test.ts @@ -12,6 +12,7 @@ import { servedThroughAuto, serviceCallsAt, studioSession, + type ServiceListing, type ThroughAuto, } from './auto-consents.ts'; import { studioBase } from './consent-fakes.ts'; @@ -24,8 +25,8 @@ afterEach(async () => { await Promise.all(closing.splice(0).map((close) => close())); }); -async function serving(changes: Partial = {}): Promise { - const served = await servedThroughAuto(changes); +async function serving(changes: Partial = {}, listing: ServiceListing = {}): Promise { + const served = await servedThroughAuto(changes, listing); closing.push(served.close); return served; } @@ -53,11 +54,16 @@ function redeemedAt(served: ThroughAuto) { } describe('the clients the fake consent service lists', () => { - it('are its one client, at the issuer of the fake authorization server', async () => { + it('are its one client, at the issuer of the fake authorization server, with the scopes it names where it names them', async () => { const { fake: vendor, service } = await serving(); + const naming = await serving({}, { scopes: ['files.read'] }); - expect(await Effect.runPromise(serviceCallsAt(service).clients)).toEqual([ - { issuer: vendor.origin, client_id: 'auto-runtime' }, + expect([ + await Effect.runPromise(serviceCallsAt(service).clients), + await Effect.runPromise(serviceCallsAt(naming.service).clients), + ]).toEqual([ + [{ issuer: vendor.origin, client_id: 'auto-runtime' }], + [{ issuer: naming.fake.origin, client_id: 'auto-runtime', scopes: ['files.read'] }], ]); }); }); diff --git a/packages/tool-servers/src/consent-testing/fake-consent-service.ts b/packages/tool-servers/src/consent-testing/fake-consent-service.ts index 70bb3454b..b17dbf38e 100644 --- a/packages/tool-servers/src/consent-testing/fake-consent-service.ts +++ b/packages/tool-servers/src/consent-testing/fake-consent-service.ts @@ -184,8 +184,9 @@ function redeemed(asked: Asked, store: Store): Promise | Response { 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 }] }); +function listed(_asked: Asked, { options: { vendor, scopes } }: Store): Response { + const named = scopes === undefined ? {} : { scopes }; + return answer({ clients: [{ issuer: vendor.origin, client_id: client.clientId, ...named }] }); } const routes: ReadonlyMap = new Map([ diff --git a/packages/tool-servers/src/consent-testing/fake-consent-store.ts b/packages/tool-servers/src/consent-testing/fake-consent-store.ts index 2580dbc6f..e6662926b 100644 --- a/packages/tool-servers/src/consent-testing/fake-consent-store.ts +++ b/packages/tool-servers/src/consent-testing/fake-consent-store.ts @@ -11,6 +11,7 @@ export type RefusedCall = 'start' | 'redeem' | 'refresh' | 'revoke'; export interface FakeConsentServiceOptions { readonly vendor: Pick; readonly now: () => number; + readonly scopes?: readonly string[]; } export const GivenSchema = Schema.String.check(Schema.isMinLength(1)); 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 index a59dbd889..20d3ee493 100644 --- a/packages/tool-servers/src/consent-through-auto/auto-clients.test.ts +++ b/packages/tool-servers/src/consent-through-auto/auto-clients.test.ts @@ -11,6 +11,7 @@ import { startAskedAt, studioBase, type FakeConsentService, + type ServiceListing, type ThroughAuto, } from '../testing/index.ts'; import { serveOnLoopback } from '../testing/loopback-server.ts'; @@ -22,8 +23,8 @@ afterEach(async () => { await Promise.all(closing.splice(0).map((close) => close())); }); -async function throughAuto(): Promise { - const serving = await servedThroughAuto(); +async function throughAuto(listing: ServiceListing = {}): Promise { + const serving = await servedThroughAuto({}, listing); closing.push(serving.close); return serving; } @@ -158,3 +159,30 @@ describe('a start at an issuer both a registered client and Auto’s consent ser expect(pathsHeardBy(service)).toEqual(['/clients']); }); }); + +describe('the clients Auto’s consent service lists with the scopes each may ask', () => { + it('serve a way whose scope they cover, and leave a way they do not cover to be set up first', async () => { + const { fake, service } = await throughAuto({ scopes: ['files.read'] }); + const client = consentClientWith({ autoClients: autoClientsOf(service) }); + + const { sourcesOf } = await Effect.runPromise(client.hadSources(studioBase)); + + expect([ + sourcesOf({ issuer: fake.origin, scope: 'files.read' }), + sourcesOf({ issuer: fake.origin, scope: 'files.read mail.send' }), + ]).toEqual([['auto'], []]); + }); + + it('take a start whose scope the registered client at the issuer does not cover', async () => { + const { fake, service } = await throughAuto({ scopes: ['files.read'] }); + const client = consentClientWith({ + registeredClients: [registeredAt(fake.origin, { scopes: ['mail.send'] })], + autoClients: autoClientsOf(service), + }); + + const start = await Effect.runPromise(client.start(startAskedAt(fake, { scope: 'files.read' }))); + + expect(start.client).toEqual({ client_id: 'auto-runtime', client_source: 'auto' }); + expect(pathsHeardBy(service)).toEqual(['/clients', '/consents']); + }); +}); 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 index 57ca0d336..77e9dfa70 100644 --- a/packages/tool-servers/src/consent-through-auto/consent-service.test.ts +++ b/packages/tool-servers/src/consent-through-auto/consent-service.test.ts @@ -89,6 +89,26 @@ describe('a consent service that answers what the contract does not hold', () => }); }); +describe('the clients a consent service lists', () => { + it('are read with the scopes a client names, and without them where it names none', async () => { + const clients = [ + { issuer, client_id: 'auto-any' }, + { issuer, client_id: 'auto-reading', scopes: ['files.read'] }, + ]; + const url = await answeringEvery(() => Response.json({ clients })); + + expect(await Effect.runPromise(serviceCallsAt({ url }).clients)).toEqual(clients); + }); + + it('are unanswered where a client names an empty scope', async () => { + const url = await answeringEvery(() => + Response.json({ clients: [{ issuer, client_id: 'auto-reading', scopes: [''] }] }), + ); + + expect(await outcomeOf(serviceCallsAt({ url }).clients)).toEqual(unanswered); + }); +}); + function problemAnswer(status: number, members: Readonly>): () => Response { return () => Response.json({ type: 'about:blank', status, title: 'Refused', detail: 'Refused', ...members }, { status }); diff --git a/packages/tool-servers/src/consent-through-auto/consent-service.ts b/packages/tool-servers/src/consent-through-auto/consent-service.ts index 8a50f28ab..59f621300 100644 --- a/packages/tool-servers/src/consent-through-auto/consent-service.ts +++ b/packages/tool-servers/src/consent-through-auto/consent-service.ts @@ -2,7 +2,7 @@ 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'; +import { ClientIdSchema, ServiceClientSchema, type ServiceClient } from '../consent/client-sources.ts'; export interface ConsentReturn { readonly kind: 'hosted' | 'loopback'; @@ -38,9 +38,7 @@ interface ServiceRevokeAsked { readonly connectionKey: string; } -const ClientsSchema = Schema.Struct({ - clients: Schema.Array(Schema.Struct({ issuer: Schema.String, client_id: ClientIdSchema })), -}); +const ClientsSchema = Schema.Struct({ clients: Schema.Array(ServiceClientSchema) }); const GivenSchema = Schema.String.check(Schema.isMinLength(1)); 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 index 8e628cff9..8c8cd33ee 100644 --- a/packages/tool-servers/src/consent-through-auto/consents-by-source.ts +++ b/packages/tool-servers/src/consent-through-auto/consents-by-source.ts @@ -3,7 +3,7 @@ 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 { serviceClientFor, sourcesHad, sourceTakenOf, type ServiceClient } from '../consent/client-sources.ts'; import type { ConsentRefusal, FinishAsked, @@ -50,7 +50,7 @@ function started(asked: StartAsked, { options, requests, server }: Consenting) { 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)); + const { client_id: clientId } = required(serviceClientFor(listed, asked.way)); return yield* startedThroughAuto(asked, requests.fetchFor, { auto, clientId, kind }); } const sources = sourcesHad(asked.way, had); diff --git a/packages/tool-servers/src/consent-tokens/served-tokens.test.ts b/packages/tool-servers/src/consent-tokens/served-tokens.test.ts index a775f289a..e346b8684 100644 --- a/packages/tool-servers/src/consent-tokens/served-tokens.test.ts +++ b/packages/tool-servers/src/consent-tokens/served-tokens.test.ts @@ -8,6 +8,7 @@ import { secretsOf } from '../bounds/secrets.ts'; import { connectedByConsent, consentClientAt, + registeredAfterAnother, registeredAt, rowOf, servedForConsent, @@ -148,6 +149,23 @@ describe('the renewal of the tokens of a connection connected by consent', () => }); }); +describe('the renewal of a connection at an issuer where another registered client stands first', () => { + it('is made with the client the connection was made with', async () => { + const fake = await served(); + const connected = await connectedByConsent(fake); + const clock = clockAt(0); + const row = rowOf(connected, clock.now); + const consent = consentClientAt(fake, { registeredClients: registeredAfterAnother(fake.origin) }); + const { source } = servedTokensOf(servedOf(connected, row), clock.now, consent); + + clock.setTo(anHourLess.minute); + const renewed = await source.token(); + + expect(await admitted(fake, renewed)).toBe(true); + expect([fake.tokenRequests(), row.version()]).toEqual([2, 2]); + }); +}); + describe('the renewal of the tokens of a consent with a key', () => { it('signs the assertion of a client with a key, and keeps no scope where the consent was given none', async () => { const keys = generateKeyPairSync('rsa', { diff --git a/packages/tool-servers/src/consent-tokens/token-revocation.test.ts b/packages/tool-servers/src/consent-tokens/token-revocation.test.ts index 9fe3202e8..16481cb49 100644 --- a/packages/tool-servers/src/consent-tokens/token-revocation.test.ts +++ b/packages/tool-servers/src/consent-tokens/token-revocation.test.ts @@ -8,6 +8,7 @@ import { consentClientAt, everyHost, hostRefusedAt, + registeredAfterAnother, registeredAt, returnAddress, servedForConsent, @@ -64,6 +65,16 @@ describe('the revocation a removal asks for', () => { expect(await admitted(connected.fake, connected.kept.access_token)).toBe(false); }); + it('revokes with the client of the connection where another registered client stands first at the issuer', async () => { + const connected = await connectedAt(); + const consent = consentClientAt(connected.fake, { + registeredClients: registeredAfterAnother(connected.fake.origin), + }); + + expect(await revoked(consent, connected)).toMatchObject({ _tag: 'Success' }); + expect(await admitted(connected.fake, connected.kept.access_token)).toBe(false); + }); + it('revokes the access token where the server gave no refresh token', async () => { const connected = await connectedAt({ issuesRefreshTokens: false }); diff --git a/packages/tool-servers/src/consent/acting-clients.ts b/packages/tool-servers/src/consent/acting-clients.ts index beb7b0cfe..5f2d6bd8d 100644 --- a/packages/tool-servers/src/consent/acting-clients.ts +++ b/packages/tool-servers/src/consent/acting-clients.ts @@ -9,7 +9,8 @@ import { Effect, Option, Redacted } from 'effect'; import { clientMetadataDocumentOf, - registeredClientAt, + registeredClientFor, + registeredClientNamed, type ClientMetadataDocument, type ClientSource, type ClientsHad, @@ -143,7 +144,7 @@ function takenFrom( const { asked, server } = taking; if (source === 'registered') { return Effect.succeed( - registeredActing(required(registeredClientAt(choosing.registeredClients, asked.way.issuer)), asked.returnAddress), + registeredActing(required(registeredClientFor(choosing.registeredClients, asked.way)), asked.returnAddress), ); } if (source === 'metadata_document') { @@ -170,8 +171,8 @@ function chosenAt(taking: Taking, choosing: Choosing): Effect.Effect const clients = [accounts, tenants]; expect([ - registeredClientAt(clients, 'https://accounts.example.com')?.clientId, - registeredClientAt(clients, 'https://login.example.net/acme.example.org/v2.0/')?.clientId, - registeredClientAt(clients, 'https://login.example.net/{tenant}/v2.0')?.clientId, - registeredClientAt(clients, 'https://login.example.net/a/b/v2.0'), - registeredClientAt(clients, 'https://elsewhere.example.com'), + registeredClientFor(clients, { issuer: 'https://accounts.example.com' })?.clientId, + registeredClientFor(clients, { issuer: 'https://login.example.net/acme.example.org/v2.0/' })?.clientId, + registeredClientFor(clients, { issuer: 'https://login.example.net/{tenant}/v2.0' })?.clientId, + registeredClientFor(clients, { issuer: 'https://login.example.net/a/b/v2.0' }), + registeredClientFor(clients, { issuer: 'https://elsewhere.example.com' }), ]).toEqual(['1234.apps.example.com', '9f1c0d2e-example', '9f1c0d2e-example', undefined, undefined]); }); }); +describe('the registered client a way takes at an issuer two clients serve', () => { + const issuer = 'https://accounts.example.com'; + const reading: RegisteredClient = { ...accounts, clientId: 'reading', scopes: ['files.read', 'files.list'] }; + const sending: RegisteredClient = { ...accounts, clientId: 'sending', scopes: ['files.read', 'mail.send'] }; + const clients = [reading, sending]; + + it('is the first whose scopes hold every scope the way asks, each compared as written', () => { + expect([ + registeredClientFor(clients, { issuer, scope: 'mail.send files.read' })?.clientId, + registeredClientFor(clients, { issuer, scope: 'files.read' })?.clientId, + registeredClientFor(clients, { issuer })?.clientId, + registeredClientFor(clients, { issuer, scope: 'files.list mail.send' }), + registeredClientFor(clients, { issuer, scope: 'Files.Read' }), + ]).toEqual(['sending', 'reading', 'reading', undefined, undefined]); + }); + + it('is none for a way neither covers, which is set up first', () => { + const had = { registeredClients: clients, publicUrl: undefined, serviceClients: [] }; + + expect([ + sourcesHad({ issuer, scope: 'files.list mail.send' }, had), + sourceTakenOf({ issuer, scope: 'files.list mail.send' }, had), + sourcesHad({ issuer, scope: 'mail.send' }, had), + ]).toEqual([[], undefined, ['registered']]); + }); + + it('is the one client at the issuer that names no scopes, whatever scope the way asks', () => { + expect([ + registeredClientFor([accounts], { issuer, scope: 'files.list mail.send' })?.clientId, + registeredClientFor([accounts], { issuer })?.clientId, + ]).toEqual(['1234.apps.example.com', '1234.apps.example.com']); + }); + + it('that a connection was made with is found by its client id at the issuer, whichever stands first', () => { + expect([ + registeredClientNamed(clients, issuer, 'sending')?.clientId, + registeredClientNamed(clients, `${issuer}/`, 'reading')?.clientId, + registeredClientNamed(clients, issuer, 'renamed'), + registeredClientNamed(clients, 'https://elsewhere.example.com', 'sending'), + ]).toEqual(['sending', 'reading', undefined, undefined]); + }); +}); + describe('the client sources a runtime has for a way', () => { const issuer = 'https://accounts.example.com'; @@ -112,12 +156,44 @@ describe('the client of Auto’s consent service among the sources a runtime has 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'), + serviceClientFor(listed, { issuer: 'https://login.example.net/acme.example.org/v2.0' })?.client_id, + serviceClientFor(listed, { issuer: 'https://login.example.net/a/b/v2.0' }), ]).toEqual(['auto-tenants', undefined]); }); }); +describe('the client of Auto’s consent service a way takes by its scope', () => { + const issuer = 'https://accounts.example.com'; + + it('is the first the service lists at the issuer whose scopes cover the way, or one it lists without scopes', () => { + const listed = [ + { issuer, client_id: 'auto-reading', scopes: ['files.read'] }, + { issuer, client_id: 'auto-sending', scopes: ['files.read', 'mail.send'] }, + ]; + + expect([ + serviceClientFor(listed, { issuer, scope: 'files.read' })?.client_id, + serviceClientFor(listed, { issuer, scope: 'mail.send files.read' })?.client_id, + serviceClientFor(listed, { issuer, scope: 'files.write' }), + serviceClientFor([{ issuer, client_id: 'auto' }], { issuer, scope: 'files.write' })?.client_id, + ]).toEqual(['auto-reading', 'auto-sending', undefined, 'auto']); + }); + + it('is taken where the registered client at the issuer does not cover the way and the service’s client does', () => { + const had = { + registeredClients: [{ ...accounts, scopes: ['mail.send'] }], + publicUrl: undefined, + serviceClients: [{ issuer, client_id: 'auto', scopes: ['files.read'] }], + }; + + expect([ + sourceTakenOf({ issuer, scope: 'files.read' }, had), + sourceTakenOf({ issuer, scope: 'mail.send' }, had), + sourcesHad({ issuer, scope: 'files.read mail.send' }, had), + ]).toEqual(['auto', 'registered', []]); + }); +}); + 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 2996cef18..3957bfd86 100644 --- a/packages/tool-servers/src/consent/client-sources.ts +++ b/packages/tool-servers/src/consent/client-sources.ts @@ -29,12 +29,16 @@ export interface RegisteredClient { readonly clientId: string; readonly credential: RegisteredCredential; readonly tokenEndpointAuthMethod: TokenEndpointAuthMethod | null; + readonly scopes?: readonly string[]; } -export interface ServiceClient { - readonly issuer: string; - readonly client_id: string; -} +export const ServiceClientSchema = Schema.Struct({ + issuer: Schema.String, + client_id: ClientIdSchema, + scopes: Schema.optionalKey(Schema.Array(Schema.String.check(Schema.isMinLength(1)))), +}); + +export type ServiceClient = typeof ServiceClientSchema.Type; export interface ClientsHad { readonly registeredClients: readonly RegisteredClient[]; @@ -44,6 +48,7 @@ export interface ClientsHad { export interface WayOfClients { readonly issuer: string; + readonly scope?: string | undefined; readonly clients?: readonly WayClientSource[] | undefined; } @@ -95,12 +100,31 @@ function isRegisteredAt(registered: string, issuer: string): boolean { ); } -export function registeredClientAt(clients: readonly RegisteredClient[], issuer: string): RegisteredClient | undefined { - return clients.find(({ issuers }) => issuers.some((registered) => isRegisteredAt(registered, issuer))); +function isRegisteredClientAt({ issuers }: RegisteredClient, issuer: string): boolean { + return issuers.some((registered) => isRegisteredAt(registered, issuer)); +} + +function covers({ scopes }: { readonly scopes?: readonly string[] }, { scope }: WayOfClients): boolean { + return scopes === undefined || (scope?.split(' ') ?? []).every((asked) => scopes.includes(asked)); +} + +export function registeredClientFor( + clients: readonly RegisteredClient[], + way: WayOfClients, +): RegisteredClient | undefined { + return clients.find((client) => isRegisteredClientAt(client, way.issuer) && covers(client, way)); +} + +export function registeredClientNamed( + clients: readonly RegisteredClient[], + issuer: string, + clientId: string, +): RegisteredClient | undefined { + return clients.find((client) => client.clientId === clientId && isRegisteredClientAt(client, issuer)); } -export function serviceClientAt(clients: readonly ServiceClient[], issuer: string): ServiceClient | undefined { - return clients.find((client) => isRegisteredAt(client.issuer, issuer)); +export function serviceClientFor(clients: readonly ServiceClient[], way: WayOfClients): ServiceClient | undefined { + return clients.find((client) => isRegisteredAt(client.issuer, way.issuer) && covers(client, way)); } export function metadataDocumentOf(publicUrl: string | undefined): string | undefined { @@ -130,12 +154,12 @@ export function clientMetadataDocumentOf( }; } -function isHad(source: ClientSource, { issuer }: WayOfClients, had: ClientsHad): boolean { +function isHad(source: ClientSource, way: WayOfClients, had: ClientsHad): boolean { if (source === 'registered') { - return registeredClientAt(had.registeredClients, issuer) !== undefined; + return registeredClientFor(had.registeredClients, way) !== undefined; } if (source === 'auto') { - return serviceClientAt(had.serviceClients, issuer) !== undefined; + return serviceClientFor(had.serviceClients, way) !== undefined; } return source === 'dynamic' || metadataDocumentOf(had.publicUrl) !== undefined; } diff --git a/packages/tool-servers/src/consent/consent-client.ts b/packages/tool-servers/src/consent/consent-client.ts index 7ea200a21..d880deb59 100644 --- a/packages/tool-servers/src/consent/consent-client.ts +++ b/packages/tool-servers/src/consent/consent-client.ts @@ -25,7 +25,6 @@ import { } from './client-sources.ts'; export interface ConsentWay extends WayOfClients { - readonly scope?: string | undefined; readonly parameters?: Readonly> | undefined; readonly authorization_endpoint?: string | undefined; readonly token_endpoint?: string | undefined; diff --git a/packages/tool-servers/src/consent/consent-clients.test.ts b/packages/tool-servers/src/consent/consent-clients.test.ts index 796e657bc..1da18d186 100644 --- a/packages/tool-servers/src/consent/consent-clients.test.ts +++ b/packages/tool-servers/src/consent/consent-clients.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { consentClientWith, memoryRegistrations, + registeredAt, servedForConsent, startAskedAt, type ClientRegistration, @@ -222,3 +223,29 @@ describe('a start at a server that takes no client the runtime has', () => { ]).toEqual([{ refused: 'no_client' }, { refused: 'no_client' }, { refused: 'no_client' }]); }); }); + +describe('the registered client a start takes at an issuer two clients serve', () => { + it('is the one whose scopes cover the scope of the way, the first where both do, and none where neither does', async () => { + const fake = await served(); + const client = consentClientWith({ + registeredClients: [ + registeredAt(fake.origin, { clientId: 'reading', scopes: ['files.read'] }), + registeredAt(fake.origin, { clientId: 'writing', scopes: ['files.read', 'files.write'] }), + ], + }); + + const covered = await started(client, startAskedAt(fake, { scope: 'files.write files.read' })); + + expect([ + covered.client, + new URL(covered.url).searchParams.get('client_id'), + (await started(client, startAskedAt(fake, { scope: 'files.read' }))).client, + await refused(client, startAskedAt(fake, { scope: 'mail.send' })), + ]).toEqual([ + { client_id: 'writing', client_source: 'registered' }, + 'writing', + { client_id: 'reading', client_source: 'registered' }, + { refused: 'no_client' }, + ]); + }); +}); diff --git a/packages/tool-servers/src/testing/index.ts b/packages/tool-servers/src/testing/index.ts index c367f862a..7e2b079c7 100644 --- a/packages/tool-servers/src/testing/index.ts +++ b/packages/tool-servers/src/testing/index.ts @@ -69,6 +69,7 @@ export { consentRegistration, consentSecret, everyHost, + registeredAfterAnother, registeredAt, returnAddress, servedForConsent, @@ -90,6 +91,7 @@ export { servedThroughAuto, serviceCallsAt, studioSession, + type ServiceListing, type ThroughAuto, } from '../consent-testing/auto-consents.ts'; export {