docs: host-owned authentication proposal - #404
Conversation
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>
- 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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[]; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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` | |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
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.authBegin/authComplete. Both return data that must not enter state; forauthCompletethat's a security requirement, since actions are echoed to every subscriber and an action carrying the callback would broadcast a live authorization code.Set/Updated/Removedacross the three keyed collections.authenticateis 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
HostAccountis an identity only — no resource, scopes, expiry, renewability, or status. Credentials aren't modelled at all (§7, §9).AccountConsumeris asymmetric — the agent variant carries a requiredresource, the MCP one doesn't. This mirrors an asymmetry AHP already has:AgentInfo.protectedResourcesandAuthRequiredErrorData.resourcesare arrays, whileMcpAuthRequirement.resourceis a single object.authBegintakes noscopes— 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).(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:
getUrlToOpen()/finishAuth()authBegin/authCompletewith anattemptIdinitialize, client picks per attemptahp-accounts://, not a root-state fieldWhere your earlier review landed
All five points applied:
initialize." Dropped the client capability.InitializeResult.hostAuth.flowsis the menu; the client names what it will drive per attempt inauthBegin, which is where the decision actually matters.accountsbe a map?" Kept an array — every keyed collection in AHP (chats,customizations,activeClients,files,annotations,agents,terminals) is one, and theSet/Updated/Removedconvention is written for arrays. A map would be the only one of its kind.idandresourceneed to be different things?" They no longer coexist on the account.HostAccountis an identity and carries noresourceat all;resourcenames what is being asked for,accountIdnames which account answers.HostAccountownAccountConsumer[]?" Yes — adopted exactly. A binding to a non-existent account is now unrepresentable, and removal is atomic.auth*." Renamed toauthBegin/authComplete.Two of those cascaded further than the original note. Dropping the account's
resource(3) exposed thatHostAccountwas conflating identity with credential, which ended with credentials not being modelled at all. AndAccountConsumer(4) turned out to need aresourceof its own on the agent variant — one Copilot can hold a GitHub account and an Entra account at once.Relates
attemptId+challengeId)authenticatetoken) #268 (sealed tokens); §10 argues the two should be reconciled rather than designed in parallelOpen questions (§10)
CustomizationBase.stableId· a URI normalization rule for(uri, name)· whetherMcpAuthRequiredReasonshould be renamed once agent providers raise challenges too.