auth · git:20260815.5ec07b1 · 2026-08-15 · sha256 025a9bedf774229d
auth git:20260815.5ec07b1B
Immutable. This exact content never changes and is served at /api/v1/blob/025a9bedf774229d.
---
name: auth
description:
Authenticate an agentproto agent or MCP connector to a service via
@agentproto/auth's credential broker — declare an auth provider
(pat / service-auth / device-code), store the credential ONCE in a
CredentialStore, and resolve fresh Authorization headers at call/connect
time. Use to wire a Bearer/OAuth API (agentpush, a hosted MCP server, an
internal service) into an agent WITHOUT putting secrets in env vars or
.mcp.json.
metadata:
tags: agentproto, auth, aip-50, credentials, broker, mcp, secrets, bearer, oauth
---
## When to use
Reach for this whenever an agent (or a child MCP it mounts) must present a
credential to a remote service and you don't want that secret sprayed into the
daemon's global env or hardcoded in a config file. The hallmark: "how do I give
this agent access to `<service>` without leaking the key to every other agent?"
- A tool/agent calls a Bearer API (agentpush, Stripe, an internal API).
- A hosted/remote MCP server needs an `Authorization` header.
- You want ONE place to store a key, scoped per service, resolved fresh (with
refresh) at use-time instead of a long-lived env var.
Do NOT use for the daemon's own tunnel login (that's `agentproto auth login`, a
device-code flow already wired) — though it uses the same substrate underneath.
## The model — one source of truth, resolved on demand
```
auth provider (declares WHERE/HOW) ──┐
CredentialStore (holds the secret) ──┤─▶ CredentialBroker.resolveHeaders({ path, audience })
│ → { Authorization: "Bearer …" } (fresh, per call)
```
- **Provider** — a manifest declaring a service's `id`, `apiBase`, and `auth`
flow. Config only, never the secret. Registered once (`registerAuthProvider`).
- **CredentialStore** — where the secret lives: `KeychainStore` (macOS CLI),
`FileStore` (AES-256-GCM, headless — key from `AGENTPROTO_STORE_KEY`),
`MemoryStore` (tests). Swap the backend, same interface.
- **Broker** — `resolveHeaders({ path, audience?, signal? })` turns a provider
`path` into ready-to-use headers: serves a fresh stored bearer, else runs the
flow. `audience` (`api` / `mcp` / `tunnel`) scopes the credential so it can't
be reused cross-plane.
- **Flows** — pick one per service: `pat` (static token you store/paste),
`service-auth` (auth.md claim ceremony → short oat + durable assertion),
`device-code` (RFC 8628 → durable daemon token + refresh).
## Recipe
### 1. Declare + register the provider
TS literal (builtin/tests):
```ts
import { defineAuthProvider, registerAuthProvider } from "@agentproto/auth"
registerAuthProvider(defineAuthProvider({
id: "agentpush",
description: "AgentPush API — paste a personal API key.",
apiBase: "https://api.agentpush.example",
audience: "mcp", // or "api"
auth: { flow: "pat", tokenStore: { keychain: "agentpush", account: "{server}" } },
}))
```
Or ship a vendor `*.auth.md` and `parseAuthProviderManifest(...)` → `registerAuthProvider(...)` — a host adds providers without editing this package.
### 2. Store the secret ONCE (out of env, into the store)
```ts
import { KeychainStore, resolveStoreRef, getAuthProvider } from "@agentproto/auth"
const p = getAuthProvider("agentpush")!
const store = new KeychainStore() // or FileStore for headless
await store.write(resolveStoreRef(p.auth.tokenStore, p.apiBase, p.audience),
{ value: process.env.AGENTPUSH_API_KEY!, kind: "pat" })
```
Do this once (onboarding / a `secrets set` step) — then delete the env var.
### 3. Resolve at use-time
```ts
import { CredentialBroker, getAuthProvider } from "@agentproto/auth"
const broker = new CredentialBroker({ store, getProvider: getAuthProvider })
const headers = await broker.resolveHeaders({ path: "agentpush", audience: "mcp" })
// → { Authorization: "Bearer …" } — attach to your fetch / MCP transport
```
Fresh every call; for `service-auth`/`device-code` it auto-refreshes. No secret
touches env or `.mcp.json`.
## Two consumption paths
**A. Agent calls the service in its own code/tool.** Works TODAY: resolve via the
broker (step 3) and attach the header. Nothing else to wire.
**B. Service exposed as a child MCP the agent mounts.** `agent_start.mcpServers[]`
entries carry `{ name, transport, ref?, headers?, credentialRef? }`
(`packages/runtime/src/agent-tools.ts`). Set `credentialRef` to a broker path
and `session-spawn.ts`'s `resolveMcpCredentialHeaders` resolves it into
`headers` at spawn time — merged ON TOP of any static `headers` on the same
entry, then the `credentialRef` itself is dropped before the child ever sees
it (the secret never lives in the child's env or config). Runtime stays
auth-free: it calls a `resolveMcpCredentialHeaders` hook it doesn't implement
(`packages/runtime/src/mcp-credential-deps.ts`, `setMcpCredentialDeps`); the
CLI wires that hook to a real `CredentialBroker` in
`packages/cli/src/commands/serve.ts` (`resolveHeaders({ path: credentialRef,
audience: "mcp" })`). Resolution failures (missing provider, no keychain,
expired credential) are caught and logged as warnings — they never kill the
spawn, so an agent that can't resolve its credential mounts the MCP without
that header rather than failing to start.
## Worked example — agentpush
agentpush uses a static Bearer key. Clean-auth wiring:
1. Register `agentpush` as a `pat` provider (step 1), `audience: "mcp"`.
2. Move `AGENTPUSH_API_KEY` from the daemon env into the store (step 2).
3. In-code callers resolve `broker.resolveHeaders({ path: "agentpush" })` (path A) —
works now, key out of the global env, scoped to this agent.
4. For agentpush-as-child-MCP (path B), pass
`credentialRef: "agentpush"` on the `agent_start.mcpServers[]` entry —
the daemon resolves it to an `Authorization` header at spawn time, no
other wiring needed.
## Common mistakes
- **Storing the key in the daemon's global env** — every spawned agent inherits
it. The whole point of the store is per-service, per-spawn scoping.
- **Skipping `audience`** — set it (`api`/`mcp`/`tunnel`) so a credential minted
for one plane can't be presented on another (defense-in-depth; the normative
boundary is server-side).
- **Caching `resolveHeaders` output** — resolve per call; the broker owns
freshness/refresh. A cached header defeats `service-auth`/`device-code` refresh.
- **`pat` where the service issues short-lived tokens** — use `service-auth`
(mint + refresh) instead of pasting a token that expires.
- **Wrong header shape** — `resolveHeaders` returns a header map; a service that
wants `X-Api-Key` (not `Authorization`) needs the provider/flow to emit that
shape, not a Bearer.