Skip to content

docs: host-owned authentication proposal - #404

Open
TylerLeonhardt wants to merge 2 commits into
mainfrom
tyleonha/host-owned-auth-proposal
Open

docs: host-owned authentication proposal#404
TylerLeonhardt wants to merge 2 commits into
mainfrom
tyleonha/host-owned-auth-proposal

Conversation

@TylerLeonhardt

@TylerLeonhardt TylerLeonhardt commented Aug 18, 2026

Copy link
Copy Markdown
Member

Proposes an opt-in mode in which an agent host holds its own credentials, instead of relying on a client to broker them. Docs-only — no types/ changes, so no changelog fragment.

An HTML explainer of the same design is attached below.

Why

Today the client fetches the token and pushes it with authenticate. That assumes a client is present and can do OAuth, which breaks for scheduled work, headless runners, and thin clients (a phone, a browser tab, a CI runner). It also means five clients on one machine push five tokens for the same account — #153.

The reverse case is real too: a sandboxed host should be held at arm's length and handed only short-lived tokens. So this isn't "host-owned is better" — it's "AHP can only express one of the two, and it needs both."

Shape

  • ahp-accounts:// — a singleton channel carrying what the host has (accounts), what it needs (challenges), and what's in flight (pendingAuth). Never a token.
  • Two commandsauthBegin / authComplete. Both return data that must not enter state; for authComplete that's a security requirement, since actions are echoed to every subscriber and an action carrying the callback would broadcast a live authorization code.
  • Seven actionsSet/Updated/Removed across the three keyed collections.
  • Six user flows, walked through in §5.

authenticate is untouched and remains the default. A host that declines the capability is fully conformant — doctrine names "a required credential flow" as an explicit anti-goal.

Things reviewers may want to push on

  • HostAccount is an identity only — no resource, scopes, expiry, renewability, or status. Credentials aren't modelled at all (§7, §9).
  • AccountConsumer is asymmetric — the agent variant carries a required resource, the MCP one doesn't. This mirrors an asymmetry AHP already has: AgentInfo.protectedResources and AuthRequiredErrorData.resources are arrays, while McpAuthRequirement.resource is a single object.
  • authBegin takes no scopes — the correct request is what the host already holds plus what's being demanded now, and the client can't know the first half (§8.6).
  • Scopes accumulate and are shared — a step-up driven by one consumer widens the credential the others use. Called out in §8.4 rather than left implicit.
  • The MCP consumer key is (uri, name) — AHP has no stable cross-session identifier for an MCP server today. Four weaknesses documented in §7.2 rather than papered over.

If you were at the whiteboard

Everything from the board survives. Six things changed shape, and the reasoning is in §9:

On the board In the proposal Why
getUrlToOpen() / finishAuth() authBegin / authComplete with an attemptId Two calls with nothing linking them can't survive two clients signing in at once, a client crashing between them, or a host holding two live PKCE verifiers. Same field #221 asks for.
Walk every flow until one works Host advertises at initialize, client picks per attempt Each failed try burns a real browser window or a live device code, and "until one works" can't tell unsupported from cancelled.
Device code = second priority Device code = the baseline The motivating case is absent or remote clients. Auth-code-with-loopback assumes the client runs an HTTP server and sits beside a browser.
"RT" drawn on the board Nothing in the protocol — host policy Whether to request a refresh credential is decided by whoever stores it. The user's control is the consent screen. Three attempts to give AHP an opinion here all failed (§9).
"Account status channel" ahp-accounts://, not a root-state field Seeing the account list should be refusable separately from seeing root state.
One account per resource N accounts + a consumer binding Without a binding the only semantics is "last signed-in wins" — which is the #153 bug.

Where your earlier review landed

All five points applied:

  1. "No reason for a client to ask for flows in initialize." Dropped the client capability. InitializeResult.hostAuth.flows is the menu; the client names what it will drive per attempt in authBegin, which is where the decision actually matters.
  2. "Should accounts be a map?" Kept an array — every keyed collection in AHP (chats, customizations, activeClients, files, annotations, agents, terminals) is one, and the Set/Updated/Removed convention is written for arrays. A map would be the only one of its kind.
  3. "Why do id and resource need to be different things?" They no longer coexist on the account. HostAccount is an identity and carries no resource at all; resource names what is being asked for, accountId names which account answers.
  4. "Should HostAccount own AccountConsumer[]?" Yes — adopted exactly. A binding to a non-existent account is now unrepresentable, and removal is atomic.
  5. "We usually prefix like auth*." Renamed to authBegin / authComplete.

Two of those cascaded further than the original note. Dropping the account's resource (3) exposed that HostAccount was conflating identity with credential, which ended with credentials not being modelled at all. And AccountConsumer (4) turned out to need a resource of its own on the agent variant — one Copilot can hold a GitHub account and an Entra account at once.

Relates

Open questions (§10)

CustomizationBase.stableId · a URI normalization rule for (uri, name) · whether McpAuthRequiredReason should be renamed once agent providers raise challenges too.

Proposes an opt-in mode in which an agent host holds its own credentials
instead of relying on a client to broker them, so scheduled work, headless
runners, and thin clients can authenticate without a client present.

Adds an `ahp-accounts://` channel carrying what the host has (accounts),
what it needs (challenges), and what is in flight (attempts); two commands
(`authBegin` / `authComplete`) and seven keyed-collection actions.

Client-brokered `authenticate` is untouched and remains the default, per
the doctrine anti-goal on required credential flows.

Subsumes #153 and #221, and overlaps the motivation behind #268.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@TylerLeonhardt

Copy link
Copy Markdown
Member Author

ahp-host-owned-auth.html

@TylerLeonhardt
TylerLeonhardt marked this pull request as ready for review August 18, 2026 23:48
roblourens
roblourens previously approved these changes Aug 19, 2026
Comment thread docs/proposals/host-owned-auth.md
Comment thread docs/proposals/host-owned-auth.md Outdated
Comment thread docs/proposals/host-owned-auth.md Outdated
Comment thread docs/proposals/host-owned-auth.md
Comment thread docs/proposals/host-owned-auth.md Outdated
Comment thread docs/proposals/host-owned-auth.md
Comment thread docs/proposals/host-owned-auth.md Outdated
Comment thread docs/proposals/host-owned-auth.md Outdated
Comment thread docs/proposals/host-owned-auth.md
Comment thread docs/proposals/host-owned-auth.md Outdated
- Rename the capability to `InitializeResult.authentication` /
  `AuthenticationCapability`.
- Make flow descriptors objects: `AuthFlowSupport` on the capability and
  `AuthFlowRequest` on the request, so a flow can grow fields later.
- Move `redirectUri` inside the authorization-code variant of
  `AuthFlowRequest`, removing the last "required when" from the surface.
- Discriminate `AuthAttemptState` by `AuthAttemptStatus` rather than by
  flow, so `accountId` and `error` exist only where they mean something.
  Rename the collection `pendingAuth` to `attempts` to match, and the
  entity key `attemptId` to `id`.
- Replace `HostAccount.origin` with `removable`: provenance is
  host-internal, and the only part a client can act on is whether signing
  out does anything. Require `label`.
- Use bare `id` for keys in single-entity actions, per the convention
  `SessionCustomizationRemovedAction` and friends already follow.
- Document the agent consumer's `resource` and the MCP consumer's `uri` as
  opaque identity, not display strings, and drop provider names from the
  type block.
- Trim the consumer-key discussion to the two rules that belong in the
  spec; the rest is host implementation detail.
- State plainly that `AuthRequired` (`-32007`) is unchanged and that
  subscribing to the accounts channel is optional.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
local rich clients.

**PKCE stays host-side, as a MUST.** The authorization code passes through the
client. That's only acceptable because the verifier doesn't, which makes the code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: The client receives the authorization URI and controls the browser/loopback callback, so it can replace the host's code_challenge with one derived from its own verifier while preserving state, client_id, and redirect_uri. PKCE binds the code to the challenge received by the authorization server, so a public relay client can then redeem the code itself; validating state does not detect this. Require authorization-server-enforced request integrity such as signed JAR or PAR with direct requests disabled, require token-endpoint authentication unavailable to the client, or omit this relay flow.

user hasn't opened lately.

The residual failure is a rename or move losing the binding. That fails **safe**
— no match, host default — and clients SHOULD render the binding

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: This is not a safe failure. After a bound MCP declaration is renamed or moved, (uri, name) no longer matches and the host-default fallback can select a different identity. Since this feature supports work/personal accounts and unattended runs, the server can continue under the wrong account without a client present. Keep an unresolved explicit binding in an auth/input-needed state until it is rebound, or migrate it through a stable identifier.

* came from*. Provenance is host-internal and drives the host's own
* revocation rules (§8.4); the only part of it a client can act on is this.
*/
removable: boolean;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: HostAccount combines one identity's credentials across resources, but removal behavior differs by credential origin: host-owned grants are removable, while ambient and pushed credentials are not. If one identity contains both origins, neither boolean value is correct. Model removal at grant/origin granularity, or prohibit mixed-origin aggregation and define how those credentials are represented as separate accounts.

type AccountConsumer =
| {
/** Matches `AgentInfo.provider`. */
provider: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: The agent arm has no kind, while the MCP arm uses kind: 'mcpServer' and the proposal later defines this wire type as a discriminated union with kind: 'agent' | 'mcpServer'. Generated clients need a discriminator shared by every variant to decode the union consistently. Add kind: 'agent' to this arm.

id: string;
/** The only field a client may change, so binding is one action — the host
* reconciles by dropping the consumer from whoever held it before. */
consumers?: AccountConsumer[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Replacing one account's complete consumers array loses concurrent edits from clients that derived updates from the same snapshot. A cross-account move also cannot be atomic: flow 4 publishes accounts/set before removing the consumer from the old account, exposing duplicate ownership. Define a consumer-keyed move/delta action whose reducer removes the prior binding and adds the destination in one transition.

- Every remove action is idempotent and a no-op against an already-settled
target, per the keyed-collection convention. Because client-dispatched actions
are notifications with no response, a rejected mutation surfaces as the host
simply not echoing it — the standard write-ahead reconciliation model rather

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Silently withholding a rejected action contradicts AHP write-ahead reconciliation. A client removes its optimistic mutation from pendingActions only after receiving the matching ordered envelope; rejection should echo the action and origin with ActionEnvelope.rejectionReason without applying it to confirmed state. Otherwise the rejected mutation remains pending and is repeatedly rebased.


| Target | Host uses |
| --- | --- |
| `{ challengeId }` (flows 1–3) | that challenge's `requiredScopes`, falling back to its `resource.scopes_supported` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: This applies to proactive first-run challenges where optional requiredScopes may be absent, so the fallback requests every value in scopes_supported. RFC 9728 describes that field as a capability catalogue, not an instruction to request all scopes. Require the exact least-privilege scopes for static challenges, or use the authorization server's documented default; do not request the whole catalogue.

refusable separately from seeing root state.

- **Pushed tokens MAY appear as accounts.** A host that also accepts
`authenticate` MAY surface them so a client sees one picture. They are

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: authenticate credentials are per-connection, while ahp-accounts:// is singleton state shared across clients. Publishing a pushed credential here exposes an account and permits global bindings without recording which connection owns it, whether peers can use it, which credential wins when a host-owned binding also exists, or what happens on disconnect. Keep pushed credentials out of this channel or model connection-scoped ownership, visibility, lifetime, precedence, and binding rules.

- **Challenges are state on the accounts channel** (§5.2). Rejected scraping
`challengeId` out of `McpServerCustomization.state` and `toolCall.auth`, where
it's missing entirely for agent-level `-32007`. A host-authored list makes the
channel self-sufficient, and entries are **deduplicated** — two sessions blocked

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Two sessions using the same MCP server can be blocked by different simultaneous requiredScopes. Coalescing them into one server-level challenge must either discard one requirement or union both, while section 8.6 forbids unioning unrelated pending demands. Deduplicate over the exact authorization need, including resource, target account, and scope set, so disjoint requirements remain independently actionable.

([#335](https://github.com/microsoft/agent-host-protocol/issues/335)).

- **Surfacing host-default bindings.** `consumers` covers what a user explicitly
bound, so a client can warn "signing out breaks these 3 servers." It doesn't

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Flow 4 requires the client to copy a consumer from AccountsState, and the host must reject targets it did not publish. Because default-bound consumers are omitted from HostAccount.consumers, a working consumer using the host default has no legal authBegin target and cannot perform the documented switch-account flow. Publish effective default bindings or another host-authored consumer catalogue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants