diff --git a/design.md b/design.md index d384194..fbb6c52 100644 --- a/design.md +++ b/design.md @@ -1,6 +1,6 @@ # agent proxy — the user's AAuth agent in MCP form -**Status: v1 invoke spine ✅ proven live (2026-05-25); discovery layer redesigned 2026-06-09.** The agent proxy drives the authorize-first R3 flow end-to-end against a real Person Server + AAuth resource, signing with the bootstrapped `@aauth/local-keys` identity. Discovery generalizes to multi-resource: signed-call registry client, three-layer state (added / discoverable / per-resource ops), vocabulary-adapter abstraction (OpenAPI today, AsyncAPI partial, MCP-tools/GraphQL later). v.next (sub-agents, WASM runtime) is still ahead. +**Status: v1 invoke spine ✅ proven live (2026-05-25); discovery layer redesigned 2026-06-09; AAuth -11 / R3 -02 landed 2026-08-11 (v0.6.0).** The agent proxy drives the authorize-first R3 flow end-to-end against a real Person Server + AAuth resource, signing with the bootstrapped `@aauth/local-keys` identity. -11 adds person-token acquisition and caching in front of the authorize-first path, the `auth_token_endpoint` / `person_token_endpoint` split in PS metadata, and three-way `access_mode` planning; R3 -02 adds operation access annotations and the per-call proposal flow, and removes the openapi-gateway vocabulary. Discovery generalizes to multi-resource: signed-call registry client, three-layer state (added / discoverable / per-resource ops), vocabulary-adapter abstraction (OpenAPI today, AsyncAPI partial, MCP-tools/GraphQL later). v.next (sub-agents, WASM runtime) is still ahead. Reference implementation of an AAuth agent for MCP-aware agent hosts. The agent proxy represents the user as an AAuth agent, exposes that agent's capabilities to an LLM via MCP, and relays AAuth interactions to the user's Person Server. Published as `@aauth/proxy` from `aauth-dev/praca`. @@ -67,7 +67,7 @@ Each tool's description embeds a short literal snapshot of L1 ("currently added **Operations within a resource (L3):** - `list_operations(resource, query?)` — return ops across all vocabularies the resource advertises, as `{ opId, kind, summary, method?, path?, channel?, tags }[]`. `query` is **either** free-text (matched against `summary`/`tags`/`opId`) **or** a path/channel prefix (`/crm/v3/objects/contacts/*`). Bounded result size with explicit "N more — refine query" marker. - `get_operations(resource, op_ids[])` — batch fetch full schemas for one or more operations. Schemas dominate token cost, so this is intentionally separate from `list_operations` (per Speakeasy / OpenMCP). -- `invoke(resource, op_id, args)` — execute. Routes internally on the op's `kind`: `sync.request` → R3 HTTP call; `async.send` → publish via the resource's send channel; `async.receive` → returns `async_subscribe_requires_subagent` (v.next). On first call to an aauth-access-token or auth-token resource that hasn't been authorized, returns the interaction URL — the LLM hands it to the user, then retries. +- `invoke(resource, op_id, args)` — execute. Routes internally on the op's `kind`: `sync.request` → R3 HTTP call; `async.send` → publish via the resource's send channel; `async.receive` → returns `async_subscribe_requires_subagent` (v.next). On first call to a session-token or auth-token resource that hasn't been authorized, returns the interaction URL — the LLM hands it to the user, then retries. An operation whose access mode this agent cannot complete is refused without a request being made (see "Access modes"). `kind` values: `sync.request` | `async.send` | `async.receive`. The LLM never sees `vocab`; that's an agent-proxy-internal routing detail (see "Vocabularies"). OpIds are the natural value from the vocab doc; the agent proxy deterministically prefixes (`openapi:`/`asyncapi:`) only when two vocabularies at the same resource happen to expose colliding ids. @@ -103,6 +103,7 @@ Three layers, all file-backed, all per-machine. | `catalog/registry.json` | **L2** — cached `GET registry.aauth.dev/resources` result | refreshed on startup + 24h background; ETag-conditional | | `catalog/{host}/{vocab}.json` | **L3** — cached vocabulary docs (OpenAPI / AsyncAPI / …) per resource | fetched on first `list_operations`/`get_operations`; cached with TTL | | `connections/{host}.json` | per-resource session state — stored auth-tokens, refresh state, last interaction | written by R3 flow | +| `person-tokens.json` | PS-issued person tokens, keyed `(resource, mission_s256)`, plus the thumbprint of the agent key they all bind | written on person-token acquisition; flushed whole on key rotation | | `pending-interactions.json` | open interactions awaiting user resolution | written/cleared by interaction relay | JSON files for v1; promote to SQLite if concurrent writes get painful. File-lock for concurrent writes (multiple host clients OK). @@ -143,7 +144,9 @@ The agent calls `add_resource(host_or_url)`. The agent proxy: After `add_resource`: - `access_mode: agent-token` resources are immediately invokable. -- `access_mode: aauth-access-token` / `auth-token` resources are *added* but `invoke` will return an interaction URL on first call; `connect(resource)` is the explicit pre-auth path. +- `access_mode: person-token` / `auth-token` resources need a person token from the PS first; the agent proxy obtains one lazily on the first `invoke` (see "Person tokens"). +- `access_mode: session-token` resources are *added* but `invoke` will return an interaction URL on first call so the user can complete the resource's own consent flow; `connect(resource)` is the explicit pre-auth path. +- A resource declaring a mode this agent cannot complete is listed with a `skip_reason` and never called. `add_resource` is the canonical entry point for both registry-found and direct-URL resources. No registry inclusion is required — direct URL is first-class. The agent proxy never gatekeeps on registry membership. @@ -153,6 +156,53 @@ Per-resource ops are fetched on first `list_operations`/`get_operations` call ag `list_operations` returns a bounded summary list (no schemas); `get_operations` is the explicit "give me the full schemas for these op_ids" call. This separation matters because schemas dominate token cost — Speakeasy's published numbers show schema-bearing tool listings 5-10× larger than summary-only listings. (See "Tool surface".) +## Person tokens + +AAuth -11 makes the person token load-bearing: a resource MUST have verified one before it issues a resource token, and the agent MUST present one via `Signature-Key` on every authorization endpoint request. The agent proxy therefore obtains a person token before the authorize-first path, not only for `access_mode: person-token` resources. + +Acquisition is a signed POST to the PS's `person_token_endpoint` (published in `/.well-known/aauth-person.json` alongside `auth_token_endpoint`, renamed from `token_endpoint` in -11), presenting the agent token via `Signature-Key`, with `{ resource, mission_s256? }` as the body. Requests carrying a body to a PS or AS additionally cover `content-digest` and `content-type` in the signature. `200` returns `{ person_token, expires_in }`; `202` with `requirement=interaction` is the deferred path — the PS wants the user to approve this agent acting at this resource, and the agent proxy surfaces it like any other interaction rather than blocking. + +**Caching.** A person token is scoped to one resource and, when it carries `mission_s256`, to one mission, so the cache key is the pair. Every person token binds the same key through `cnf`, so a signing-key rotation invalidates the whole set at once — the store records the RFC 7638 thumbprint it was populated under and flushes everything the moment a different one is presented. There is no partial invalidation and no migration. + +**Missions.** `mission_s256` is forwarded to the person token endpoint, stamped into the person token, copied by the resource into the resource token, and copied by the PS into the auth token. It appears in no auth-token request body — the claim travels inside the tokens. No PS implements `mission_endpoint` yet; the claim path is built regardless. + +## Access modes + +`access_mode` is an IANA registry, not a closed list, and the declaration is advisory: a resource MAY return any `AAuth-Requirement` at runtime whatever it published. The agent proxy plans three ways and only three: + +| Plan | When | What the agent proxy does | +|---|---|---| +| **undeclared** | absent, or a value this build does not recognize | call the resource and read the `AAuth-Requirement`. Never an error. | +| **satisfiable** | recognized, and this agent's setup can complete it | plan against it and skip the speculative call | +| **unsatisfiable** | recognized, and this agent cannot complete it | skip the resource / operation, with the reason stated | + +The third case is the one that pays. An agent whose agent token carries no `ps` claim has no person server, so it cannot obtain a person token and cannot complete `person-token`, `auth-token` or `per-call` — and it should learn that while planning, not at a 401. `find_resources` and `list_resources` carry a `skip_reason` on such resources; `invoke` refuses them without sending a request. + +Whatever the plan, the runtime loop is the same: make the request, read any `AAuth-Requirement`, satisfy it, retry. The plan only chooses the opening credential. + +### Operation access annotations + +An agent cannot read R3 documents, so R3 alone tells it nothing about what any one operation needs. The vocabulary is what it *can* read — it has to parse that to make the call at all — so R3 -02 puts the annotations there: + +| Vocabulary | Location | Access mode | Budget | +|---|---|---|---| +| OpenAPI / AsyncAPI | Operation Object | `x-aauth-access-mode` | `x-aauth-budget` | +| MCP | Tool `_meta` | `aauth.dev/access-mode` | `aauth.dev/budget` | + +The agent proxy reads them off the vocab doc it already fetches for L3 and flattens them onto every `list_operations` / `get_operations` result as `access_mode` (always present — the mode that actually applies to that operation) and `budget: true` (only when set). Three rules: + +- **Sparse.** An unannotated operation takes the resource-wide `access_mode`. +- **Replacing, not intersecting.** A `person-token` annotation on an `auth-token` resource *lowers* the requirement for that operation — which is what lets a metered resource serve balance and history calls without an authorization round trip. +- **Advisory.** Never enforced, in either direction. The runtime requirement is authoritative. + +`session-token` MUST NOT appear in an annotation; a value seen anyway is dropped. `budget: true` implies at least `auth-token`, since a budget rides in the auth token's `budget` claim. + +The LLM sees this before it plans: which operations need only the agent token, which cost an authorization round trip, and which are `per-call` and will block on a person every time. + +### Per-call + +A `per-call` operation is authorized in principle but not for any specific call. The resource challenges the invocation, builds a **proposal document** carrying that call's concrete `parameters`, persists it under its content hash, and returns a resource token whose `r3_uri`/`r3_s256` reference it — the token never carries the parameters. The agent proxy exchanges that resource token at the PS for a per-call auth token (the grant lands in `r3_per_call`, renamed from `r3_conditional` in R3 -02) and retries **the identical call**: the resource recovers the proposal by hash and rejects any parameter that differs. The request init is fixed for the whole invoke flow so the retry is byte-identical by construction. + ## Operator selection > **Deferred (2026-06-09).** Operator-selection policy needs `kind`/`wraps`/`operator` signals per registry entry to operate on. The live `registry.aauth.dev` does not carry those fields today — entries are `{ issuer, name, description, access_mode, logo_uri?, added, submitted_by }`. Until the agent proxy can either (a) get those fields surfaced in the registry or (b) derive them from each resource's well-known (`kind` is implicit from the resource being a proxy at all; `wraps` is not currently advertised; `operator` would need a new well-known field), there's nothing to choose between, so the design below is held against future need rather than implemented. The deferred path is most likely (a) — extend the registry schema once we have a second operator fronting the same upstream. @@ -196,10 +246,12 @@ The vocabulary is **internal to the agent proxy**. The LLM never sees the URN, t |---|---|---| | `urn:aauth:vocabulary:openapi` | v1 adapter, full | OpenAPI 3.x. All ops have `kind: sync.request`. | | `urn:aauth:vocabulary:asyncapi` | v1 adapter, partial | AsyncAPI 3.x. `send` operations → `kind: async.send` (invokable). `receive` operations → `kind: async.receive` (listed; `invoke` returns `async_subscribe_requires_subagent`). | -| `urn:aauth:vocabulary:mcp-tools` | future | MCP tool-list as a vocab — useful for resources that ARE MCP servers fronted by AAuth. | +| `urn:aauth:vocabulary:mcp` | future | MCP tool-list as a vocab — useful for resources that ARE MCP servers fronted by AAuth. | | `urn:aauth:vocabulary:graphql` | future | GraphQL schema as a vocab. | -URN convention is agent-proxy-design today; the right long-term home is the AAuth spec itself (alongside `r3_vocabularies`). Lift it when a second adapter ships. +The URN registry now lives in the R3 spec (`urn:aauth:vocabulary:`), which defines seven standard vocabularies. + +`urn:aauth:vocabulary:openapi-gateway` was **removed in R3 -02** (AAuth issue #72), and its adapter with it. Operation identifiers are scoped to the one discovery endpoint a resource advertises per vocabulary, so there is no composite `service:operationId` identity and no `{service, operationId}` entry shape in `r3_operations` / `r3_granted` / `r3_per_call`. A resource fronting several backend services either presents them as one valid definition at its discovery endpoint (renaming collisions) or exposes them under separate resource identifiers, where `aud` distinguishes them. ### Adapter interface @@ -210,6 +262,7 @@ interface VocabAdapter { listOperations(doc: VocabDoc, query?: string): OpSummary[] getOperations(doc: VocabDoc, opIds: string[]): OpDetail[] buildInvocation(doc: VocabDoc, opId: string, args: unknown): InvocationPlan + annotationsFor(doc: VocabDoc, opId: string): OperationAnnotations // access mode + budget } type InvocationPlan = diff --git a/package.json b/package.json index 5dbc9b8..7d5c5f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/proxy", - "version": "0.5.0", + "version": "1.0.0", "description": "The user's AAuth agent in MCP form — discovery, identity, interaction relay", "type": "module", "exports": { @@ -45,7 +45,7 @@ "url": "https://github.com/aauth-dev/proxy" }, "dependencies": { - "@aauth/local-keys": "^1.1.0", + "@aauth/local-keys": "^2.0.0", "@hellocoop/httpsig": "^2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "uqr": "^0.1.3", diff --git a/src/__tests__/agent-escalation.test.ts b/src/__tests__/agent-escalation.test.ts index 056c4dc..6f8075c 100644 --- a/src/__tests__/agent-escalation.test.ts +++ b/src/__tests__/agent-escalation.test.ts @@ -1,6 +1,7 @@ -// Unit tests for the per-call auth-token escalation path in invokeAtResource. -// Covers the case where a resource in agent-token mode returns a 401 with an -// AAuth-Requirement header, triggering an exchange at the PS and a retry. +// Unit tests for invokeAtResource's credential flow: the per-call escalation +// path, person-token acquisition and caching, and the three-way access_mode +// plan. Every test carries a mission, so the mission_s256 claim path is +// exercised even though no PS implements mission_endpoint yet. import { describe, it, expect, vi, beforeEach } from 'vitest' import type { L1Entry } from '../store.js' @@ -31,42 +32,82 @@ function makeResponse( }) } -const l1: L1Entry = { - resource: 'res.example', - origin: 'https://res.example', - issuer: 'https://res.example', - name: 'Test Resource', - description: 'Test', - access_mode: 'agent-token', - picked_vocabs: [{ vocabUri: 'urn:aauth:vocabulary:openapi', docUrl: 'https://res.example/openapi.json' }], - added: '2026-01-01T00:00:00.000Z', +const PS_METADATA = { + issuer: 'https://ps.example', + auth_token_endpoint: 'https://ps.example/token', + person_token_endpoint: 'https://ps.example/person', + jwks_uri: 'https://ps.example/.well-known/jwks.json', } -const cfg: ProxyConfig = { - psUrl: 'https://ps.example', - agentPrivateJwk: { kty: 'OKP', crv: 'Ed25519', alg: 'Ed25519', x: 'AAAA', d: 'BBBB' } as never, - agentToken: 'agent.jwt.here', +const MISSION = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + +// A minimal unsigned aa-agent+jwt. Only the payload is read, and only for `ps`. +function agentToken(claims: Record): string { + const b64 = (o: unknown) => + Buffer.from(JSON.stringify(o)).toString('base64url') + return `${b64({ alg: 'Ed25519', typ: 'aa-agent+jwt' })}.${b64(claims)}.sig` } -beforeEach(() => { - vi.resetAllMocks() +const WITH_PS = agentToken({ iss: 'https://agent.example', ps: 'https://ps.example' }) +const WITHOUT_PS = agentToken({ iss: 'https://agent.example' }) + +function l1(overrides: Partial = {}): L1Entry { + return { + resource: 'res.example', + origin: 'https://res.example', + issuer: 'https://res.example', + name: 'Test Resource', + description: 'Test', + access_mode: 'agent-token', + picked_vocabs: [ + { vocabUri: 'urn:aauth:vocabulary:openapi', docUrl: 'https://res.example/openapi.json' }, + ], + added: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +function config(overrides: Partial = {}): ProxyConfig { + return { + psUrl: 'https://ps.example', + agentPrivateJwk: { kty: 'OKP', crv: 'Ed25519', alg: 'Ed25519', x: 'AAAA', d: 'BBBB' } as never, + agentToken: WITH_PS, + missionS256: MISSION, + ...overrides, + } +} + +/** Route to a plain `sync.request`, optionally carrying an access annotation. */ +function routeTo(accessMode: string, annotations: Record = {}): void { mockRouteOperation.mockResolvedValue({ adapter: { vocabUri: 'urn:aauth:vocabulary:openapi' }, plan: { kind: 'sync.request', method: 'GET', path: '/whoami', query: 'scope=profile' }, + annotations, + accessMode, }) +} + +// A fresh Response per call — a Response body can only be read once, and tests +// that invoke twice fetch the PS well-known twice. +function mockPSWellKnown(): ReturnType { + return vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async () => makeResponse(200, PS_METADATA)) as never +} + +beforeEach(() => { + vi.resetAllMocks() + routeTo('agent-token') }) describe('agent-token per-call escalation', () => { - it('retries with auth token when resource issues a 401 auth-token challenge', async () => { - // global fetch — PS well-known - const globalFetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - makeResponse(200, { token_endpoint: 'https://ps.example/token' }), - ) + it('retries with an auth token when the resource issues a 401 auth-token challenge', async () => { + const globalFetchSpy = mockPSWellKnown() // signedFetch call sequence: - // 1. resource call → 401 challenge - // 2. PS token exchange → auth token - // 3. resource retry with auth token → 200 + // 1. resource call → 401 challenge (the per-call proposal reference) + // 2. PS auth token exchange → auth token + // 3. resource retry with the auth token → 200 mockSignedFetch .mockResolvedValueOnce( makeResponse(401, { error: 'auth_token_required' }, { @@ -76,25 +117,73 @@ describe('agent-token per-call escalation', () => { .mockResolvedValueOnce(makeResponse(200, { auth_token: 'auth_tok_xyz' })) .mockResolvedValueOnce(makeResponse(200, { sub: 'user@example.com', name: 'Alice' })) - const result = await invokeAtResource(cfg, l1, 'whoami', { query: 'scope=profile' }) + const result = await invokeAtResource(config(), l1(), 'whoami', { query: 'scope=profile' }) - expect(result).toEqual({ kind: 'result', status: 200, body: { sub: 'user@example.com', name: 'Alice' } }) + expect(result).toEqual({ + kind: 'result', + status: 200, + body: { sub: 'user@example.com', name: 'Alice' }, + }) - // PS well-known was fetched expect(globalFetchSpy).toHaveBeenCalledWith('https://ps.example/.well-known/aauth-person.json') - // PS token exchange carried the resource token + // The exchange went to auth_token_endpoint (renamed from token_endpoint in -11). const [psUrl, psInit] = mockSignedFetch.mock.calls[1] expect(psUrl).toBe('https://ps.example/token') const psBody = JSON.parse(psInit.body) expect(psBody.resource_token).toBe('rt_abc123') expect(psBody.capabilities).toContain('interaction') + + // Content-digest is covered on a PS request carrying a body. + expect(psInit.components).toContain('content-digest') + expect(psInit.components).toContain('content-type') }) - it('surfaces an interaction when PS returns 202 during escalation', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - makeResponse(200, { token_endpoint: 'https://ps.example/token' }), + it('retries the per-call operation with byte-identical parameters', async () => { + mockPSWellKnown() + mockRouteOperation.mockResolvedValue({ + adapter: { vocabUri: 'urn:aauth:vocabulary:openapi' }, + plan: { + kind: 'sync.request', + method: 'POST', + path: '/send', + headers: { 'content-type': 'application/json' }, + body: '{"to":"mom@example.com"}', + }, + annotations: { access_mode: 'per-call' }, + accessMode: 'per-call', + }) + + mockSignedFetch + // person token for the authorize-first path + .mockResolvedValueOnce(makeResponse(200, { person_token: 'pt_1', expires_in: 3600 })) + // per-call operations are challenged on invocation, not pre-authorized + .mockResolvedValueOnce( + makeResponse(401, {}, { + 'aauth-requirement': 'requirement=auth-token; resource-token="rt_proposal"', + }), + ) + .mockResolvedValueOnce(makeResponse(200, { auth_token: 'auth_percall' })) + .mockResolvedValueOnce(makeResponse(200, { sent: true })) + + const result = await invokeAtResource( + config(), + l1({ access_mode: 'per-call' }), + 'sendEmail', + {}, ) + expect(result).toEqual({ kind: 'result', status: 200, body: { sent: true } }) + + // The challenged call and the retry present exactly the same body — the + // resource verifies actual parameters against the approved proposal. + const challenged = mockSignedFetch.mock.calls[1] + const retried = mockSignedFetch.mock.calls[3] + expect(retried[0]).toBe(challenged[0]) + expect(retried[1].body).toBe(challenged[1].body) + }) + + it('surfaces an interaction when the PS returns 202 during escalation', async () => { + mockPSWellKnown() mockSignedFetch .mockResolvedValueOnce( @@ -104,12 +193,13 @@ describe('agent-token per-call escalation', () => { ) .mockResolvedValueOnce( makeResponse(202, {}, { - 'aauth-requirement': 'requirement=interaction; url="https://ps.example/interact"; code="code_xyz"', + 'aauth-requirement': + 'requirement=interaction; url="https://ps.example/interact"; code="code_xyz"', location: 'https://ps.example/pending/abc', }), ) - const result = await invokeAtResource(cfg, l1, 'whoami', { query: 'scope=profile' }) + const result = await invokeAtResource(config(), l1(), 'whoami', { query: 'scope=profile' }) expect(result).toEqual({ kind: 'interaction', @@ -124,10 +214,229 @@ describe('agent-token per-call escalation', () => { it('returns the 401 as-is when no AAuth-Requirement header is present', async () => { mockSignedFetch.mockResolvedValueOnce(makeResponse(401, { error: 'unauthorized' })) - const result = await invokeAtResource(cfg, l1, 'whoami') + const result = await invokeAtResource(config(), l1(), 'whoami') expect(result).toEqual({ kind: 'result', status: 401, body: { error: 'unauthorized' } }) - // No PS call expect(mockSignedFetch).toHaveBeenCalledTimes(1) }) }) + +describe('person tokens', () => { + it('obtains one for the resource, carrying the mission, and presents it', async () => { + mockPSWellKnown() + routeTo('person-token') + mockSignedFetch + .mockResolvedValueOnce(makeResponse(200, { person_token: 'pt_abc', expires_in: 3600 })) + .mockResolvedValueOnce(makeResponse(200, { ok: true })) + + const result = await invokeAtResource( + config(), + l1({ access_mode: 'person-token' }), + 'whoami', + ) + expect(result).toEqual({ kind: 'result', status: 200, body: { ok: true } }) + + const [ptUrl, ptInit] = mockSignedFetch.mock.calls[0] + expect(ptUrl).toBe('https://ps.example/person') + expect(JSON.parse(ptInit.body)).toEqual({ + resource: 'https://res.example', + mission_s256: MISSION, + }) + // The agent token is presented while requesting the person token … + expect(ptInit.signatureKey).toEqual({ type: 'jwt', jwt: WITH_PS }) + // … and the person token replaces it on the resource call. + expect(mockSignedFetch.mock.calls[1][1].signatureKey).toEqual({ type: 'jwt', jwt: 'pt_abc' }) + }) + + it('caches by (resource, mission) and re-uses on a second invoke', async () => { + mockPSWellKnown() + routeTo('person-token') + const cfg = config() + mockSignedFetch + .mockResolvedValueOnce(makeResponse(200, { person_token: 'pt_abc', expires_in: 3600 })) + .mockResolvedValueOnce(makeResponse(200, { ok: 1 })) + .mockResolvedValueOnce(makeResponse(200, { ok: 2 })) + + await invokeAtResource(cfg, l1({ access_mode: 'person-token' }), 'whoami') + await invokeAtResource(cfg, l1({ access_mode: 'person-token' }), 'whoami') + + // Three signed calls total: one person-token request, two resource calls. + expect(mockSignedFetch).toHaveBeenCalledTimes(3) + expect(mockSignedFetch.mock.calls[2][1].signatureKey).toEqual({ type: 'jwt', jwt: 'pt_abc' }) + }) + + it('flushes the whole cache when the agent signing key rotates', async () => { + mockPSWellKnown() + routeTo('person-token') + const store = (await import('../store.js')).createMemoryPersonTokenStore() + const first = config({ personTokens: store }) + const rotated = config({ + personTokens: store, + agentPrivateJwk: { kty: 'OKP', crv: 'Ed25519', alg: 'Ed25519', x: 'ZZZZ', d: 'YYYY' } as never, + }) + + mockSignedFetch + .mockResolvedValueOnce(makeResponse(200, { person_token: 'pt_key1', expires_in: 3600 })) + .mockResolvedValueOnce(makeResponse(200, { ok: 1 })) + .mockResolvedValueOnce(makeResponse(200, { person_token: 'pt_key2', expires_in: 3600 })) + .mockResolvedValueOnce(makeResponse(200, { ok: 2 })) + + await invokeAtResource(first, l1({ access_mode: 'person-token' }), 'whoami') + await invokeAtResource(rotated, l1({ access_mode: 'person-token' }), 'whoami') + + // Every person token binds the same cnf, so the rotation invalidates the + // cached one and a fresh token is requested. + expect(mockSignedFetch).toHaveBeenCalledTimes(4) + expect(mockSignedFetch.mock.calls[3][1].signatureKey).toEqual({ type: 'jwt', jwt: 'pt_key2' }) + }) + + it('surfaces the PS 202 deferred interaction on the person token endpoint', async () => { + mockPSWellKnown() + routeTo('person-token') + mockSignedFetch.mockResolvedValueOnce( + makeResponse(202, { status: 'pending' }, { + 'aauth-requirement': + 'requirement=interaction; url="https://ps.example/interact"; code="P1Q2"', + location: 'https://ps.example/person/pending/1', + }), + ) + + const result = await invokeAtResource(config(), l1({ access_mode: 'person-token' }), 'whoami') + + expect(result).toEqual({ + kind: 'interaction', + interaction: { + url: 'https://ps.example/interact', + code: 'P1Q2', + pollUrl: 'https://ps.example/person/pending/1', + }, + }) + }) + + it('acquires one when a resource challenges with requirement=person-token', async () => { + mockPSWellKnown() + mockSignedFetch + .mockResolvedValueOnce( + makeResponse(401, {}, { 'aauth-requirement': 'requirement=person-token' }), + ) + .mockResolvedValueOnce(makeResponse(200, { person_token: 'pt_late', expires_in: 3600 })) + .mockResolvedValueOnce(makeResponse(200, { ok: true })) + + const result = await invokeAtResource(config(), l1(), 'whoami') + expect(result).toEqual({ kind: 'result', status: 200, body: { ok: true } }) + expect(mockSignedFetch.mock.calls[1][0]).toBe('https://ps.example/person') + }) +}) + +describe('authorize-first', () => { + it('presents the person token at the authorization endpoint with bare operation ids', async () => { + mockPSWellKnown() + mockSignedFetch + .mockResolvedValueOnce(makeResponse(200, { person_token: 'pt_authz', expires_in: 3600 })) + .mockResolvedValueOnce(makeResponse(200, { resource_token: 'rt_authz' })) + .mockResolvedValueOnce(makeResponse(200, { auth_token: 'at_authz' })) + .mockResolvedValueOnce(makeResponse(200, { ok: true })) + + const entry = l1({ + access_mode: 'auth-token', + authorization_endpoint: 'https://res.example/authorize', + }) + routeTo('auth-token') + + const result = await invokeAtResource(config(), entry, 'whoami') + expect(result).toEqual({ kind: 'result', status: 200, body: { ok: true } }) + + const [authzUrl, authzInit] = mockSignedFetch.mock.calls[1] + expect(authzUrl).toBe('https://res.example/authorize') + // A resource MUST have verified a person token before it issues a resource token. + expect(authzInit.signatureKey).toEqual({ type: 'jwt', jwt: 'pt_authz' }) + // No gateway {service, operationId} entry shape — R3 -02 removed it. + expect(JSON.parse(authzInit.body)).toEqual({ + r3_operations: { + vocabulary: 'urn:aauth:vocabulary:openapi', + operations: [{ operationId: 'whoami' }], + }, + }) + }) +}) + +describe('three-way access_mode plan', () => { + it('skips a recognized mode this agent cannot complete, without calling out', async () => { + routeTo('auth-token') + const result = await invokeAtResource( + config({ agentToken: WITHOUT_PS }), + l1({ access_mode: 'auth-token' }), + 'whoami', + ) + + expect(result.kind).toBe('skipped') + if (result.kind !== 'skipped') throw new Error('expected skipped') + expect(result.mode).toBe('auth-token') + expect(result.reason).toContain('`ps` claim') + // Nothing was sent — not to the resource, not to the PS. + expect(mockSignedFetch).not.toHaveBeenCalled() + }) + + it('treats an unrecognized access_mode as undeclared and reads the runtime requirement', async () => { + routeTo('urn:example:some-future-mode') + mockSignedFetch.mockResolvedValueOnce(makeResponse(200, { ok: true })) + + const result = await invokeAtResource( + config({ agentToken: WITHOUT_PS }), + l1({ access_mode: 'urn:example:some-future-mode' }), + 'whoami', + ) + + // Never an error: the declaration is advisory. + expect(result).toEqual({ kind: 'result', status: 200, body: { ok: true } }) + expect(mockSignedFetch.mock.calls[0][1].signatureKey).toEqual({ type: 'jwt', jwt: WITHOUT_PS }) + }) + + it('lets an operation annotation lower the requirement below the resource default', async () => { + // Resource-wide auth-token, but this operation is annotated agent-token — + // the annotation REPLACES the default, so no PS round trip happens. + routeTo('agent-token', { access_mode: 'agent-token' }) + mockSignedFetch.mockResolvedValueOnce(makeResponse(200, { ok: true })) + + const result = await invokeAtResource( + config({ agentToken: WITHOUT_PS }), + l1({ access_mode: 'auth-token' }), + 'ping', + ) + + expect(result).toEqual({ kind: 'result', status: 200, body: { ok: true } }) + expect(mockSignedFetch).toHaveBeenCalledTimes(1) + }) + + it('skips when an operation annotation raises the requirement above the resource default', async () => { + routeTo('per-call', { access_mode: 'per-call' }) + const result = await invokeAtResource( + config({ agentToken: WITHOUT_PS }), + l1({ access_mode: 'agent-token' }), + 'purchase', + ) + + expect(result.kind).toBe('skipped') + if (result.kind !== 'skipped') throw new Error('expected skipped') + expect(result.mode).toBe('per-call') + }) +}) + +describe('session-token resources', () => { + it('captures AAuth-Access and presents it in Authorization on the next call', async () => { + const cfg = config() + routeTo('session-token') + mockSignedFetch + .mockResolvedValueOnce(makeResponse(200, { ok: 1 }, { 'aauth-access': 'sess_abc' })) + .mockResolvedValueOnce(makeResponse(200, { ok: 2 })) + + const entry = l1({ access_mode: 'session-token' }) + await invokeAtResource(cfg, entry, 'whoami') + await invokeAtResource(cfg, entry, 'whoami') + + const second = mockSignedFetch.mock.calls[1][1] + expect(second.headers.authorization).toBe('AAuth sess_abc') + // The session token must be covered by the signature. + expect(second.components).toContain('authorization') + }) +}) diff --git a/src/__tests__/annotations.test.ts b/src/__tests__/annotations.test.ts new file mode 100644 index 0000000..a8ddfe2 --- /dev/null +++ b/src/__tests__/annotations.test.ts @@ -0,0 +1,164 @@ +// Operation access annotations (R3 -02 §Operation Access Annotations): reading +// them off an OpenAPI Operation Object and an MCP Tool `_meta`, and resolving +// them against the resource-wide access_mode on the way to the LLM. + +import { describe, it, expect } from 'vitest' +import { + effectiveAccessMode, + OpenAPIAdapter, + readMcpToolAnnotations, + readOpenApiAnnotations, +} from '../vocab/index.js' +import { getOperationsForResource, listOperationsForResource } from '../resource.js' +import type { L1Entry } from '../store.js' + +const doc = { + openapi: '3.1.0', + paths: { + '/balance': { + get: { + operationId: 'getBalance', + summary: 'Read the account balance', + 'x-aauth-access-mode': 'person-token', + }, + }, + '/datasets/{id}/purchase': { + post: { + operationId: 'purchaseDataset', + summary: 'Buy a dataset', + 'x-aauth-access-mode': 'per-call', + 'x-aauth-budget': true, + }, + }, + '/datasets': { + get: { operationId: 'listDatasets', summary: 'List datasets' }, + }, + '/meter': { + post: { + operationId: 'meterUsage', + summary: 'Report usage', + 'x-aauth-budget': true, + }, + }, + '/oops': { + get: { + operationId: 'misdeclared', + // MUST NOT appear in an annotation — dropped, resource-wide applies. + 'x-aauth-access-mode': 'session-token', + }, + }, + }, +} + +const adapter = new OpenAPIAdapter() +const loaded = { raw: doc as never, ops: undefined as never } + +function l1(accessMode: string): L1Entry { + return { + resource: 'res.example', + origin: 'https://res.example', + issuer: 'https://res.example', + name: 'Test', + description: '', + access_mode: accessMode, + picked_vocabs: [ + { vocabUri: 'urn:aauth:vocabulary:openapi', docUrl: 'https://res.example/openapi.json' }, + ], + added: '2026-01-01T00:00:00.000Z', + } +} + +// The doc cache is injectable, so the adapter never has to fetch. +function cacheWith(): { get(k: string): Promise; set(k: string, d: unknown): Promise } { + const parsed = { raw: doc, ops: undefined } + return { + async get() { + // resource.ts re-indexes from `raw` when `ops` isn't a Map. + return parsed + }, + async set() {}, + } +} + +describe('reading annotations', () => { + it('reads the OpenAPI specification extensions', () => { + expect(readOpenApiAnnotations({ 'x-aauth-access-mode': 'per-call', 'x-aauth-budget': true })) + .toEqual({ access_mode: 'per-call', budget: true }) + expect(readOpenApiAnnotations({ operationId: 'plain' })).toEqual({}) + }) + + it('reads the MCP tool _meta keys', () => { + expect( + readMcpToolAnnotations({ + _meta: { 'aauth.dev/access-mode': 'per-call', 'aauth.dev/budget': true }, + }), + ).toEqual({ access_mode: 'per-call', budget: true }) + expect(readMcpToolAnnotations({ _meta: {} })).toEqual({}) + expect(readMcpToolAnnotations(undefined)).toEqual({}) + }) + + it('drops session-token, which MUST NOT appear in an annotation', () => { + expect(readOpenApiAnnotations({ 'x-aauth-access-mode': 'session-token' })).toEqual({}) + }) + + it('surfaces annotations through the adapter', () => { + const ops = adapter.listOperations(loaded) + const purchase = ops.find((o) => o.opId === 'purchaseDataset') + expect(purchase?.annotations).toEqual({ access_mode: 'per-call', budget: true }) + // Sparse: unannotated operations carry no annotations field at all. + expect(ops.find((o) => o.opId === 'listDatasets')?.annotations).toBeUndefined() + expect(adapter.annotationsFor(loaded, 'getBalance')).toEqual({ access_mode: 'person-token' }) + expect(adapter.annotationsFor(loaded, 'listDatasets')).toEqual({}) + }) +}) + +describe('effectiveAccessMode', () => { + it('takes the resource-wide mode when the operation is unannotated', () => { + expect(effectiveAccessMode({}, 'auth-token')).toBe('auth-token') + expect(effectiveAccessMode(undefined, undefined)).toBe('agent-token') + }) + + it('replaces rather than intersects — an annotation may LOWER the requirement', () => { + expect(effectiveAccessMode({ access_mode: 'person-token' }, 'auth-token')).toBe('person-token') + }) + + it('raises a budgeted operation to auth-token', () => { + expect(effectiveAccessMode({ budget: true }, 'agent-token')).toBe('auth-token') + expect(effectiveAccessMode({ access_mode: 'person-token', budget: true }, 'agent-token')).toBe( + 'auth-token', + ) + // per-call already outranks the budget implication. + expect(effectiveAccessMode({ access_mode: 'per-call', budget: true }, 'agent-token')).toBe( + 'per-call', + ) + }) +}) + +describe('what the LLM sees', () => { + it('flattens each operation to its effective access_mode and budget', async () => { + const ops = await listOperationsForResource(l1('auth-token'), undefined, cacheWith()) + const byId = Object.fromEntries(ops.map((o) => [o.opId, o])) + + expect(byId.getBalance.access_mode).toBe('person-token') // annotation lowers it + expect(byId.listDatasets.access_mode).toBe('auth-token') // sparse → resource-wide + expect(byId.purchaseDataset.access_mode).toBe('per-call') + expect(byId.purchaseDataset.budget).toBe(true) + expect(byId.meterUsage.access_mode).toBe('auth-token') // budget implies auth-token + expect(byId.misdeclared.access_mode).toBe('auth-token') // session-token dropped + + // The raw annotation object never reaches the LLM — only the resolved value. + for (const op of ops) expect(op.annotations).toBeUndefined() + expect(byId.listDatasets.budget).toBeUndefined() + }) + + it('carries the same fields on get_operations details', async () => { + const details = await getOperationsForResource( + l1('agent-token'), + ['purchaseDataset'], + cacheWith(), + ) + expect(details[0].access_mode).toBe('per-call') + expect(details[0].budget).toBe(true) + expect(details[0].bodySchema).toBeUndefined() + }) +}) diff --git a/src/access-mode.ts b/src/access-mode.ts new file mode 100644 index 0000000..6cf1e75 --- /dev/null +++ b/src/access-mode.ts @@ -0,0 +1,88 @@ +// access_mode planning. +// +// `access_mode` is an IANA registry (protocol §Resource Metadata, §AAuth Access +// Mode Value Registry), not a closed list: -11 defines four values, R3 -02 adds +// `per-call`, and extensions may add more. Three outcomes, and only three: +// +// undeclared — absent, or a value this agent does not recognize. Call the +// resource and read the AAuth-Requirement it returns. NEVER an +// error: the declaration is advisory, the runtime requirement +// is authoritative. +// satisfiable — recognized, and this agent's setup can complete it. Plan +// against it and skip the speculative call. +// unsatisfiable — recognized, and this agent cannot complete it. Skip the +// resource (or operation) and say why. An agent whose agent +// token carries no `ps` claim cannot obtain a person token, +// and so cannot complete person-token, auth-token or per-call +// access — it should learn that here, not at a 401. +// +// Mirrors the `@aauth/protocol` surface named in the AAuth -11 package contract +// (planAccessMode / AgentSetup / AccessModePlan / KnownAccessMode). Kept local +// while @aauth/protocol is unpublished; swap the implementation for an import +// when it ships — the types are identical by construction. + +export type KnownAccessMode = + | 'agent-token' + | 'person-token' + | 'session-token' + | 'auth-token' + | 'per-call' + +export const KNOWN_ACCESS_MODES: readonly KnownAccessMode[] = [ + 'agent-token', + 'person-token', + 'session-token', + 'auth-token', + 'per-call', +] + +export function isKnownAccessMode(v: string | undefined): v is KnownAccessMode { + return v !== undefined && (KNOWN_ACCESS_MODES as readonly string[]).includes(v) +} + +export interface AgentSetup { + /** false when the agent token carries no `ps` claim. */ + hasPersonServer: boolean +} + +export type AccessModePlan = + | { kind: 'undeclared' } + | { kind: 'satisfiable'; mode: KnownAccessMode } + | { kind: 'unsatisfiable'; mode: KnownAccessMode; reason: string } + +const NO_PS = + 'this agent token carries no `ps` claim, so the agent has no person server to obtain a person token from' + +export function planAccessMode(declared: string | undefined, setup: AgentSetup): AccessModePlan { + if (!isKnownAccessMode(declared)) return { kind: 'undeclared' } + + switch (declared) { + // Identity only: every request already carries an agent token. + case 'agent-token': + return { kind: 'satisfiable', mode: declared } + + // Resource-managed: the resource runs its own consent flow and hands back a + // session token via AAuth-Access. No PS involved. + case 'session-token': + return { kind: 'satisfiable', mode: declared } + + // All three need a person token, which only a person server issues. A + // resource MUST have verified a person token before it issues a resource + // token, so auth-token and per-call are unreachable without one too. + case 'person-token': + case 'auth-token': + case 'per-call': + return setup.hasPersonServer + ? { kind: 'satisfiable', mode: declared } + : { + kind: 'unsatisfiable', + mode: declared, + reason: `access_mode "${declared}" requires a person token, and ${NO_PS}`, + } + } +} + +/** One-line reason string for a plan, for surfacing to the LLM. */ +export function planReason(plan: AccessModePlan): string | undefined { + return plan.kind === 'unsatisfiable' ? plan.reason : undefined +} diff --git a/src/agent.ts b/src/agent.ts index 9fa1df7..0f50642 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1,26 +1,36 @@ -// agent proxy — the user's AAuth agent. v0 core: the authorize-first R3 invoke flow -// against the resource proxy, exchanging at the PS. +// agent proxy — the user's AAuth agent. The invoke flow against an AAuth +// resource, obtaining person tokens and auth tokens at the PS. // -// invoke() is non-blocking: when an interaction is required (PS consent or the -// resource's OAuth bootstrap) it RETURNS the interaction (url + code + poll URL) -// rather than completing it, so a caller — the MCP server — can surface the URL -// to the user. invokeComplete() drives it to completion for programmatic use, -// performing the interaction via a callback and polling. +// The shape of the flow is the one the protocol describes: pick an opening +// credential from what the resource declared, make the request, read any +// AAuth-Requirement, satisfy it, retry. The declaration only saves round trips — +// the runtime requirement is authoritative and can escalate at any point. // -// Built directly on @hellocoop/httpsig (the @aauth/mcp-agent package is -// 401-challenge-driven and has no R3/authorize-first path, mirroring the +// invoke() is non-blocking: when an interaction is required (PS consent, a person +// token the PS wants the user to approve, or the resource's own OAuth bootstrap) +// it RETURNS the interaction (url + code + poll URL) rather than completing it, +// so a caller — the MCP server — can surface the URL to the user. +// invokeComplete() drives it to completion for programmatic use, performing the +// interaction via a callback and polling. +// +// Built directly on @hellocoop/httpsig (the @aauth/agent package is +// 401-challenge-driven and has no authorize-first path, mirroring the // resource-side finding). import { fetch as signedFetch } from '@hellocoop/httpsig' -import { fetchResource, routeOperation, toL1Entry } from './resource.js' -import type { L1Entry } from './store.js' +import { planAccessMode } from './access-mode.js' +import type { AccessModePlan, KnownAccessMode } from './access-mode.js' +import { agentTokenPs, jwkThumbprint } from './jwt.js' +import { routeOperation } from './resource.js' +import { createMemoryPersonTokenStore } from './store.js' +import type { L1Entry, PersonTokenStore } from './store.js' export type AgentSigningKey = Parameters[1]['signingKey'] /** * Optional hints forwarded verbatim as extra body parameters in every POST to - * the PS token endpoint (AAuth spec §8.1). All fields are optional; include only - * those the host has learned about the user. + * the PS auth token endpoint (protocol §Agent Token Request). All fields are + * optional; include only those the host has learned about the user. */ export interface PSTokenHints { login_hint?: string // user identifier hint (e.g. Hello wallet sub or email) @@ -35,12 +45,33 @@ export interface PSTokenHints { capabilities?: string[] // overrides the default ['interaction'] sent to the PS } +/** Opaque per-resource session token from the AAuth-Access header. */ +export interface SessionTokenStore { + get(resource: string): Promise + set(resource: string, token: string): Promise +} + export interface ProxyConfig { psUrl: string agentPrivateJwk: AgentSigningKey // the agent's private JWK agentToken: string // aa-agent+jwt (cnf = agent pubkey, ps = psUrl) - /** Extra parameters forwarded to every PS token endpoint request. */ + /** + * The mission this agent is operating under, as the base64url SHA-256 of the + * approved mission JSON. Forwarded to the PS's person token endpoint, which + * stamps it into the person token; from there the resource copies it into the + * resource token and the PS into the auth token. No PS implements + * `mission_endpoint` yet — the claim path is built regardless. + */ + missionS256?: string + /** Extra parameters forwarded to every PS auth token endpoint request. */ psHints?: PSTokenHints + /** + * Person-token cache. Keyed (resource, mission_s256); flushed whole when the + * agent's signing key changes. Defaults to a per-config in-memory store. + */ + personTokens?: PersonTokenStore + /** Session-token store for `session-token` resources. Defaults to per-config memory. */ + sessionTokens?: SessionTokenStore /** * Called with each auth_token received from the PS before it is used. * Hosts can use this to record or validate the PS sub across exchanges. @@ -51,10 +82,15 @@ export interface ProxyConfig { export interface InvokeArgs { pathParams?: Record query?: string - body?: unknown // string or object; vocab adapters serialize non-string bodies as JSON. The proxy packs it into r3_context on escalation. + body?: unknown // string or object; vocab adapters serialize non-string bodies as JSON. contentType?: string // defaults to application/json when a body is present } +export interface InvokeOptions { + /** Overrides ProxyConfig.missionS256 for this call. */ + missionS256?: string +} + export interface Interaction { url: string code: string @@ -64,39 +100,119 @@ export interface Interaction { export type InvokeResult = | { kind: 'result'; status: number; body: unknown } | { kind: 'interaction'; interaction: Interaction } + /** + * The resource (or this operation) declares an access mode this agent's setup + * cannot complete — typically `auth-token` or `person-token` at an agent whose + * agent token carries no `ps` claim. No request was made. Case (c) of the + * three-way access_mode plan: the agent learns this before planning rather + * than at a 401. + */ + | { kind: 'skipped'; resource: string; opId: string; mode: KnownAccessMode; reason: string } export type InteractionHandler = (url: string, code: string) => Promise | void -function signWith(cfg: ProxyConfig, token: string) { - return (url: string, init: { method?: string; headers?: Record; body?: string } = {}) => - signedFetch(url, { ...init, signingKey: cfg.agentPrivateJwk, signatureKey: { type: 'jwt', jwt: token } }) +// ── Credentials and signing ── +// +// Exactly one credential is presented per request, via Signature-Key, except in +// resource-managed mode where the agent token identifies the agent and the +// session token rides in Authorization: AAuth (and MUST be covered by the +// signature, protocol §AAuth-Access Response Header). + +type Credential = + | { kind: 'agent' } + | { kind: 'person'; jwt: string } + | { kind: 'auth'; jwt: string } + | { kind: 'session'; token: string } + +interface SignedRequestInit { + method?: string + headers?: Record + body?: string +} + +// The AAuth HTTP Message Signatures profile's base covered components. httpsig +// applies these itself when no list is passed; we pass an explicit list whenever +// something must be added to it. +const BASE_GET = ['@method', '@authority', '@path', 'signature-key'] +const BASE_BODY = ['@method', '@authority', '@path', 'content-type', 'signature-key'] + +function components(opts: { + hasBody: boolean + authorization?: boolean + /** PS and AS endpoints: a body MUST additionally be covered by content-digest. */ + psOrAs?: boolean +}): string[] | undefined { + const base = opts.hasBody ? [...BASE_BODY] : [...BASE_GET] + let extended = false + if (opts.hasBody && opts.psOrAs) { + base.splice(base.indexOf('content-type') + 1, 0, 'content-digest') + extended = true + } + if (opts.authorization) { + base.splice(base.length - 1, 0, 'authorization') + extended = true + } + return extended ? base : undefined } +function signWith(cfg: ProxyConfig, cred: Credential, opts: { psOrAs?: boolean } = {}) { + return (url: string, init: SignedRequestInit = {}): Promise => { + const headers = { ...(init.headers ?? {}) } + if (cred.kind === 'session') headers.authorization = `AAuth ${cred.token}` + const jwt = + cred.kind === 'person' || cred.kind === 'auth' ? cred.jwt : cfg.agentToken + const list = components({ + hasBody: init.body !== undefined, + authorization: cred.kind === 'session', + psOrAs: opts.psOrAs, + }) + return signedFetch(url, { + ...init, + headers, + signingKey: cfg.agentPrivateJwk, + signatureKey: { type: 'jwt', jwt }, + ...(list ? { components: list } : {}), + }) + } +} + +type SignedFetch = ReturnType + export function makeAgentPoll(cfg: ProxyConfig): (url: string) => Promise { return (url: string) => - signWith(cfg, cfg.agentToken)(url, { method: 'GET', headers: { Prefer: 'wait=20' } }) + signWith(cfg, { kind: 'agent' })(url, { method: 'GET', headers: { Prefer: 'wait=20' } }) } -function parseInteraction(requirement: string | null): { url: string; code: string } | undefined { - if (!requirement || !requirement.includes('requirement=interaction')) return undefined - const url = /url="([^"]+)"/.exec(requirement)?.[1] - const code = /code="([^"]+)"/.exec(requirement)?.[1] - return url && code ? { url, code } : undefined +// ── AAuth-Requirement ── + +interface ParsedRequirement { + requirement: string + resourceToken?: string + url?: string + code?: string } -// A per-call escalation challenge (W2): the resource returns 401 with -// `requirement=auth-token; resource-token="…"` for a conditional/irreversible op. -// The agent takes that resource token (which carries the call as r3_context) to -// the PS for a per-call auth token, then retries. -function parseAuthTokenChallenge(requirement: string | null): string | undefined { - if (!requirement || !requirement.includes('requirement=auth-token')) return undefined - return /resource-token="([^"]+)"/.exec(requirement)?.[1] +// Parses the AAuth-Requirement header. Unrecognized `requirement=` values are +// returned as-is: the caller decides, and treats anything it cannot satisfy as a +// terminal response rather than guessing. +function parseRequirement(headerValue: string | null): ParsedRequirement | undefined { + if (!headerValue) return undefined + const requirement = /requirement=([A-Za-z0-9_-]+)/.exec(headerValue)?.[1] + if (!requirement) return undefined + return { + requirement, + resourceToken: /resource-token="([^"]+)"/.exec(headerValue)?.[1], + url: /url="([^"]+)"/.exec(headerValue)?.[1], + code: /code="([^"]+)"/.exec(headerValue)?.[1], + } } function interactionFrom(res: Response): Interaction | undefined { - const parsed = parseInteraction(res.headers.get('aauth-requirement')) + const parsed = parseRequirement(res.headers.get('aauth-requirement')) const pollUrl = res.headers.get('location') ?? '' - return parsed && pollUrl ? { ...parsed, pollUrl } : undefined + return parsed?.requirement === 'interaction' && parsed.url && parsed.code && pollUrl + ? { url: parsed.url, code: parsed.code, pollUrl } + : undefined } async function safeBody(res: Response): Promise { @@ -108,20 +224,146 @@ async function safeBody(res: Response): Promise { } } +// ── PS metadata ── + interface PSMetadata { - token_endpoint: string + /** Renamed from `token_endpoint` in -11. */ + auth_token_endpoint: string + /** New in -11, REQUIRED. */ + person_token_endpoint?: string interaction_endpoint?: string + mission_endpoint?: string } async function psMetadata(psUrl: string): Promise { - return (await (await fetch(`${psUrl.replace(/\/$/, '')}/.well-known/aauth-person.json`)).json()) as PSMetadata + return (await ( + await fetch(`${psUrl.replace(/\/$/, '')}/.well-known/aauth-person.json`) + ).json()) as PSMetadata +} + +// ── Per-config default stores ── +// +// Keyed on the ProxyConfig object, which the identity provider resolves +// per-principal. A process-global cache would leak person and session tokens +// across tenants in a multi-user host. + +const defaultPersonTokens = new WeakMap() +const defaultSessionTokens = new WeakMap() + +function personTokenStore(cfg: ProxyConfig): PersonTokenStore { + if (cfg.personTokens) return cfg.personTokens + let store = defaultPersonTokens.get(cfg) + if (!store) { + store = createMemoryPersonTokenStore() + defaultPersonTokens.set(cfg, store) + } + return store +} + +function sessionTokenStore(cfg: ProxyConfig): SessionTokenStore { + if (cfg.sessionTokens) return cfg.sessionTokens + let store = defaultSessionTokens.get(cfg) + if (!store) { + const m = new Map() + store = { + async get(resource) { + return m.get(resource) + }, + async set(resource, token) { + m.set(resource, token) + }, + } + defaultSessionTokens.set(cfg, store) + } + return store +} + +// ── Person tokens ── + +type PersonTokenOutcome = + | { kind: 'token'; personToken: string } + | { kind: 'interaction'; interaction: Interaction } + | { kind: 'result'; status: number; body: unknown } + +/** + * Obtain a person token for one resource, from cache or from the PS. + * + * A resource MUST have verified a person token before it issues a resource token, + * and the agent MUST present one on every authorization endpoint request + * (protocol §Person Token, §Authorization Endpoint Request) — so this sits in + * front of the whole authorize-first path, not only of `person-token` resources. + * + * The PS MAY require the user to approve the agent acting at this resource before + * issuing, and answers `202` with `requirement=interaction`. That is surfaced + * like any other interaction; the caller drives it and retries, and the second + * request gets a `200`. + */ +export async function obtainPersonToken( + cfg: ProxyConfig, + ps: PSMetadata, + resource: string, + missionS256?: string, +): Promise { + if (!ps.person_token_endpoint) { + return { + kind: 'result', + status: 0, + body: { + error: 'ps_missing_person_token_endpoint', + error_description: `${cfg.psUrl} publishes no person_token_endpoint; AAuth -11 requires one`, + }, + } + } + + const store = personTokenStore(cfg) + const jkt = await jwkThumbprint(cfg.agentPrivateJwk as { kty?: string }) + const key = { resource, ...(missionS256 ? { mission_s256: missionS256 } : {}) } + + const cached = await store.get(key, jkt) + if (cached) return { kind: 'token', personToken: cached } + + const res = await signWith(cfg, { kind: 'agent' }, { psOrAs: true })(ps.person_token_endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + resource, + ...(missionS256 ? { mission_s256: missionS256 } : {}), + }), + }) + + if (res.status === 202) { + const interaction = interactionFrom(res) + if (interaction) return { kind: 'interaction', interaction } + } + if (!res.ok) return { kind: 'result', status: res.status, body: await safeBody(res) } + + const { person_token, expires_in } = (await res.json()) as { + person_token: string + expires_in?: number + } + if (!person_token) { + return { kind: 'result', status: res.status, body: { error: 'ps_returned_no_person_token' } } + } + const expiresAt = Math.floor(Date.now() / 1000) + (expires_in ?? 3600) + await store.set(key, jkt, person_token, expiresAt) + return { kind: 'token', personToken: person_token } +} + +/** + * Drop every cached person token. Call when the agent's signing key rotates — + * every person token binds the same key through `cnf`, so none of them survive. + * `obtainPersonToken` also detects rotation on its own via the key thumbprint; + * this is the explicit hook for a host that knows a rotation happened. + */ +export async function flushPersonTokens(cfg: ProxyConfig): Promise { + await personTokenStore(cfg).flush() } // POST the interaction to the PS so it can try to reach the user (live web // session, registered mobile push). On 2xx the PS owns user-reach; the agent -// blocks on the resource pollUrl until the user completes there. On any -// non-2xx (including the spec-pending interaction_unavailable error — see -// AAuth#34) the agent falls back to driving the URL itself. +// blocks on the pollUrl until the user completes there. On any non-2xx +// (including the spec-pending interaction_unavailable error — see AAuth#34) the +// agent falls back to driving the URL itself. async function relayInteractionToPS( signAgent: SignedFetch, endpoint: string, @@ -169,29 +411,31 @@ export async function pollUntilDone( return res } -type SignedFetch = ReturnType - type ExchangeOutcome = | { kind: 'token'; authToken: string } | { kind: 'interaction'; interaction: Interaction } | { kind: 'result'; status: number; body: unknown } -// Exchange a resource token at the PS for an auth token. capabilities tells the +// Exchange a resource token at the PS for an auth token. `capabilities` tells the // PS the agent can relay interactions to the user, so it returns a 202 consent // interaction (surfaced for the caller to drive + retry) rather than requiring a -// registered mobile device. On PS endpoints this is a token-request parameter, +// registered mobile device. On PS endpoints this is a request-body parameter, // not a header (the AAuth-Capabilities header is for resource requests). // -// cfg.psHints (if set) are spread into the body — all §8.1 optional params. -// cfg.onAuthToken (if set) is called with the auth_token before it is returned. +// The mission does not appear here: it travels in the person token's +// `mission_s256`, which the resource copies into the resource token and the PS +// into the auth token. +// +// cfg.psHints (if set) are spread into the body — all §Agent Token Request +// optional params. cfg.onAuthToken (if set) is called with the auth_token before +// it is returned. async function exchangeAtPS( - signAgent: SignedFetch, - tokenEndpoint: string, - resourceToken: string, cfg: ProxyConfig, + authTokenEndpoint: string, + resourceToken: string, ): Promise { const { capabilities, ...otherHints } = cfg.psHints ?? {} - const res = await signAgent(tokenEndpoint, { + const res = await signWith(cfg, { kind: 'agent' }, { psOrAs: true })(authTokenEndpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -210,14 +454,52 @@ async function exchangeAtPS( return { kind: 'token', authToken: auth_token } } -// Resource-parameterized invoke. The new tool surface in server.ts calls into -// this directly with an L1 entry from store.ts; the legacy invoke() below -// wraps it for cfg.resourceProxyBase-based callers (tests, demo scripts). +// ── Authorize-first ── + +/** + * POST the resource's authorization endpoint, declaring the operation, and take + * back a resource token. The request MUST present a person token via + * Signature-Key (protocol §Authorization Endpoint Request) — an agent token gets + * `requirement=person-token`. + */ +async function authorizeAtResource( + cfg: ProxyConfig, + endpoint: string, + personToken: string, + vocabulary: string, + operationId: string, +): Promise<{ kind: 'resourceToken'; resourceToken: string } | { kind: 'result'; status: number; body: unknown }> { + const res = await signWith(cfg, { kind: 'person', jwt: personToken })(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + r3_operations: { + vocabulary, + // Bare identifiers, scoped to the one discovery endpoint the resource + // advertises for this vocabulary (R3 -02 §Operation Identifier Scope). + operations: [{ operationId }], + }, + }), + }) + if (!res.ok) return { kind: 'result', status: res.status, body: await safeBody(res) } + const { resource_token } = (await res.json()) as { resource_token?: string } + if (!resource_token) { + // The resource handled authorization itself and issued no resource token. + return { kind: 'result', status: res.status, body: await safeBody(res) } + } + return { kind: 'resourceToken', resourceToken: resource_token } +} + +// ── invoke ── + +const MAX_ROUNDS = 6 + export async function invokeAtResource( cfg: ProxyConfig, l1: L1Entry, operationId: string, args: InvokeArgs = {}, + opts: InvokeOptions = {}, ): Promise { const route = await routeOperation(l1, operationId, args) if (route.plan.kind !== 'sync.request') { @@ -229,107 +511,205 @@ export async function invokeAtResource( } const plan = route.plan const apiUrl = `${l1.origin}${plan.path}${plan.query ? `?${plan.query}` : ''}` - const requestInit = { + // Fixed for the whole flow. A per-call retry MUST present exactly the + // parameters the proposal was approved for (R3 -02 §Per-Call Proposals step 3): + // the resource recovers the proposal by its hash and rejects any difference. + const init: SignedRequestInit = { method: plan.method, ...(plan.headers ? { headers: plan.headers } : {}), ...(plan.body !== undefined ? { body: plan.body } : {}), } - // agent-token access mode: sign with the agent token + go. No PS exchange. - // If the resource returns a 401 auth-token challenge, escalate to the PS. - if (l1.access_mode === 'agent-token') { - const signAgent = signWith(cfg, cfg.agentToken) - let res = await signAgent(apiUrl, requestInit) - const challenge = - res.status === 401 ? parseAuthTokenChallenge(res.headers.get('aauth-requirement')) : undefined - if (challenge) { - const ps = await psMetadata(cfg.psUrl) - const ex = await exchangeAtPS(signAgent, ps.token_endpoint, challenge, cfg) - if (ex.kind !== 'token') return ex - res = await signWith(cfg, ex.authToken)(apiUrl, requestInit) - if (res.status === 202) { - const interaction = interactionFrom(res) - if (interaction) return { kind: 'interaction', interaction } - } + const missionS256 = opts.missionS256 ?? cfg.missionS256 + + // Three-way access_mode plan against the mode that applies to THIS operation: + // its own annotation when it carries one, the resource-wide access_mode + // otherwise (R3 -02 §Applying Annotations). + const accessPlan: AccessModePlan = planAccessMode(route.accessMode, { + hasPersonServer: agentTokenPs(cfg.agentToken) !== undefined, + }) + if (accessPlan.kind === 'unsatisfiable') { + return { + kind: 'skipped', + resource: l1.resource, + opId: operationId, + mode: accessPlan.mode, + reason: accessPlan.reason, } - return { kind: 'result', status: res.status, body: await safeBody(res) } } - // aauth-access-token / auth-token: R3 flow. - if (!l1.authorization_endpoint) { - throw new Error( - `resource ${l1.resource}: no authorization_endpoint (access_mode ${l1.access_mode} requires R3)`, - ) + let ps: PSMetadata | undefined + const needPS = async (): Promise => { + ps ??= await psMetadata(cfg.psUrl) + return ps } - const signAgent = signWith(cfg, cfg.agentToken) - const ps = await psMetadata(cfg.psUrl) + const sessions = sessionTokenStore(cfg) + + // ── Opening credential ── + // + // The plan only decides where to start. Everything after this point is the + // requirement loop, which is identical in every mode. + let cred: Credential = { kind: 'agent' } + + if (accessPlan.kind === 'satisfiable') { + switch (accessPlan.mode) { + case 'agent-token': + break + + case 'session-token': { + // Resource-managed. Present the session token if we already hold one; + // otherwise call with the agent token and let the resource start its own + // consent flow with a 202 interaction. + const held = await sessions.get(l1.resource) + if (held) cred = { kind: 'session', token: held } + break + } - // 1. authorize-first: declare the operation, get a resource token. - const authzRes = await signAgent(l1.authorization_endpoint, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - r3_operations: { - vocabulary: route.adapter.vocabUri, - // Entry shape is vocabulary-specific: {operationId} for plain openapi, - // {service, operationId} for openapi-gateway. - operations: [route.adapter.formatOperationEntry?.(operationId) ?? { operationId }], - }, - }), - }) - if (!authzRes.ok) - return { kind: 'result', status: authzRes.status, body: await safeBody(authzRes) } - const { resource_token } = (await authzRes.json()) as { resource_token: string } - - // 2. exchange at the PS for an auth token (surface a consent interaction if any). - const ex = await exchangeAtPS(signAgent, ps.token_endpoint, resource_token, cfg) - if (ex.kind !== 'token') return ex - - // 3. call the resource. Writes carry the body; the agent signs over it so the - // proxy's content-digest check (and the r3_context it packs on escalation) - // match the actual call. - const callWith = (token: string) => signWith(cfg, token)(apiUrl, requestInit) - - let apiRes = await callWith(ex.authToken) - - // First contact: a resource-issued interaction (e.g. OAuth bootstrap). Try - // the PS's interaction_endpoint first so it can use its own user-reach - // channels (live web session, mobile push). On any non-2xx — including the - // spec-pending interaction_unavailable error (AAuth#34) and any PS that - // hasn't implemented the endpoint yet — surface the interaction so the - // caller can drive it (layer 2: local OS open / layer 3: text+QR). - if (apiRes.status === 202) { - const interaction = interactionFrom(apiRes) - if (interaction) { - const engaged = ps.interaction_endpoint - ? await relayInteractionToPS(signAgent, ps.interaction_endpoint, interaction) - : false - if (!engaged) return { kind: 'interaction', interaction } - const poll: Poller = (url) => - signWith(cfg, cfg.agentToken)(url, { method: 'GET', headers: { Prefer: 'wait=20' } }) - const completed = await pollUntilDone(poll, interaction.pollUrl, 180_000) - if (completed.status === 202) return { kind: 'interaction', interaction } - apiRes = await callWith(ex.authToken) + case 'person-token': { + const pt = await obtainPersonToken(cfg, await needPS(), l1.issuer, missionS256) + if (pt.kind !== 'token') return pt + cred = { kind: 'person', jwt: pt.personToken } + break + } + + case 'auth-token': + case 'per-call': { + // Authorize-first when the resource publishes an authorization_endpoint: + // declare the operation, take back a resource token, exchange it at the + // PS. Without one, the resource issues resource tokens via 401 instead + // (protocol §Resource Access and Resource Tokens) — start with the + // person token and let the requirement loop pick up the challenge. + const pt = await obtainPersonToken(cfg, await needPS(), l1.issuer, missionS256) + if (pt.kind !== 'token') return pt + cred = { kind: 'person', jwt: pt.personToken } + + if (l1.authorization_endpoint) { + const authz = await authorizeAtResource( + cfg, + l1.authorization_endpoint, + pt.personToken, + route.adapter.vocabUri, + operationId, + ) + if (authz.kind !== 'resourceToken') return authz + const ex = await exchangeAtPS(cfg, (await needPS()).auth_token_endpoint, authz.resourceToken) + if (ex.kind !== 'token') return ex + cred = { kind: 'auth', jwt: ex.authToken } + } + break + } } } - // Per-call escalation (W2): a conditional/irreversible op returns a 401 - // auth-token challenge. Take its resource token (which carries the call as - // r3_context) to the PS for a per-call auth token, then retry the same call. - // If the PS needs consent first, it returns a 202 interaction we surface. - const challenge = - apiRes.status === 401 ? parseAuthTokenChallenge(apiRes.headers.get('aauth-requirement')) : undefined - if (challenge) { - const stepEx = await exchangeAtPS(signAgent, ps.token_endpoint, challenge, cfg) - if (stepEx.kind !== 'token') return stepEx - apiRes = await callWith(stepEx.authToken) - if (apiRes.status === 202) { - const interaction = interactionFrom(apiRes) - if (interaction) return { kind: 'interaction', interaction } + // ── Requirement loop ── + // + // Make the request, read any AAuth-Requirement, satisfy it, retry. `satisfied` + // stops the loop from chasing the same requirement twice with the same + // credential, which is what a resource that will never be satisfiable looks + // like from here. + const satisfied = new Set() + + for (let round = 0; round < MAX_ROUNDS; round++) { + const res = await signWith(cfg, cred)(apiUrl, init) + + // A resource MAY replace the agent's session token on any response. + const access = res.headers.get('aauth-access') + if (access) { + await sessions.set(l1.resource, access) + if (cred.kind !== 'auth' && cred.kind !== 'person') cred = { kind: 'session', token: access } + } + + const req = parseRequirement(res.headers.get('aauth-requirement')) + if (!req) return { kind: 'result', status: res.status, body: await safeBody(res) } + + const marker = `${req.requirement}:${cred.kind}` + if (satisfied.has(marker)) { + return { kind: 'result', status: res.status, body: await safeBody(res) } + } + satisfied.add(marker) + + switch (req.requirement) { + case 'agent-token': { + // The resource wants the agent's own identity token specifically. + if (cred.kind === 'agent') { + return { kind: 'result', status: res.status, body: await safeBody(res) } + } + cred = { kind: 'agent' } + continue + } + + case 'person-token': { + const pt = await obtainPersonToken(cfg, await needPS(), l1.issuer, missionS256) + if (pt.kind !== 'token') return pt + cred = { kind: 'person', jwt: pt.personToken } + continue + } + + case 'auth-token': { + // Also the per-call path: for an `r3_per_call` operation the resource + // builds a proposal from this call's concrete parameters, persists it + // under its hash, and returns a resource token carrying only the + // `r3_uri`/`r3_s256` reference. The agent exchanges it and retries the + // identical call (R3 -02 §Per-Call Proposals). + if (!req.resourceToken) { + return { kind: 'result', status: res.status, body: await safeBody(res) } + } + const ex = await exchangeAtPS(cfg, (await needPS()).auth_token_endpoint, req.resourceToken) + if (ex.kind !== 'token') return ex + cred = { kind: 'auth', jwt: ex.authToken } + continue + } + + case 'interaction': { + const interaction = interactionFrom(res) + if (!interaction) { + return { kind: 'result', status: res.status, body: await safeBody(res) } + } + // Try the PS's interaction endpoint first so it can use its own + // user-reach channels (live web session, mobile push). On any non-2xx — + // including the spec-pending interaction_unavailable error (AAuth#34) + // and any PS that hasn't implemented the endpoint yet — surface the + // interaction so the caller can drive it (layer 2: local OS open; + // layer 3: text + QR). + const meta = await needPS().catch(() => undefined) + const engaged = meta?.interaction_endpoint + ? await relayInteractionToPS( + signWith(cfg, { kind: 'agent' }), + meta.interaction_endpoint, + interaction, + ) + : false + if (!engaged) return { kind: 'interaction', interaction } + const completed = await pollUntilDone(makeAgentPoll(cfg), interaction.pollUrl, 180_000) + if (completed.status === 202) return { kind: 'interaction', interaction } + const settled = completed.headers.get('aauth-access') + if (settled) { + await sessions.set(l1.resource, settled) + cred = { kind: 'session', token: settled } + } + continue + } + + default: + // A requirement value this build does not know. The agent MUST NOT + // treat the response as satisfiable; surface it verbatim. + return { + kind: 'result', + status: res.status, + body: { + error: 'unsupported_requirement', + requirement: req.requirement, + detail: await safeBody(res), + }, + } } } - return { kind: 'result', status: apiRes.status, body: await safeBody(apiRes) } + return { + kind: 'result', + status: 429, + body: { error: 'requirement_loop', detail: `${l1.resource} kept challenging after ${MAX_ROUNDS} rounds` }, + } } // Drive invokeAtResource to completion: perform each interaction via @@ -345,13 +725,15 @@ export async function invokeAtResourceComplete( maxRounds = 5, pollTimeoutMs = 180_000, onPoll?: (elapsedMs: number) => void | Promise, + opts: InvokeOptions = {}, ): Promise<{ status: number; body: unknown }> { - // Poll deferred URLs signed with the agent token (long-poll via Prefer: wait). - const poll: Poller = (url) => - signWith(cfg, cfg.agentToken)(url, { method: 'GET', headers: { Prefer: 'wait=20' } }) + const poll = makeAgentPoll(cfg) for (let round = 0; round < maxRounds; round++) { - const result = await invokeAtResource(cfg, l1, operationId, args) + const result = await invokeAtResource(cfg, l1, operationId, args, opts) if (result.kind === 'result') return { status: result.status, body: result.body } + if (result.kind === 'skipped') { + return { status: 0, body: { error: 'access_mode_unsatisfiable', ...result } } + } await onInteraction(result.interaction.url, result.interaction.code) await pollUntilDone(poll, result.interaction.pollUrl, pollTimeoutMs, onPoll) } @@ -361,5 +743,5 @@ export async function invokeAtResourceComplete( // Signed DELETE to an admin endpoint on the resource (e.g. /admin/tokens). // Uses the agent token so the resource can verify the caller owns the key. export async function deleteAtAdmin(cfg: ProxyConfig, l1: L1Entry, path: string): Promise { - return signWith(cfg, cfg.agentToken)(`${l1.origin}${path}`, { method: 'DELETE' }) + return signWith(cfg, { kind: 'agent' })(`${l1.origin}${path}`, { method: 'DELETE' }) } diff --git a/src/identity-local.ts b/src/identity-local.ts index 93828b5..32bd3d7 100644 --- a/src/identity-local.ts +++ b/src/identity-local.ts @@ -98,6 +98,9 @@ function isJwtExpired(jwt: string, bufferSecs = 60): boolean { export function createLocalKeysIdentityProvider(): IdentityProvider { let cached: ProxyConfig | null = null return { + peek() { + return cached ?? undefined + }, async resolve({ local }) { if (cached) { // Static env-var token can't be re-minted — return as-is. diff --git a/src/identity.ts b/src/identity.ts index 6b94def..9ed052f 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -19,4 +19,9 @@ export interface IdentityProvider { // The stdio provider sources it from the MCP client's name; a provider is // free to ignore it and derive identity from its own context. resolve(ctx: { local?: string }): Promise + // Already-resolved config, or undefined when nothing is cached. MUST do no + // work — no enclave signature, no network. Tools that want to annotate their + // output with what the agent's setup can complete (see access-mode.ts) use + // this so a cheap listing call never provokes a YubiKey touch. + peek?(): ProxyConfig | undefined } diff --git a/src/index.ts b/src/index.ts index 6d08207..d2b5ddb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,17 +4,31 @@ // adapters. The Node-only @aauth/local-keys identity adapter is exported // separately from "@aauth/proxy/local". -export { invokeAtResource, invokeAtResourceComplete, makeAgentPoll, pollUntilDone } from './agent.js' +export { + flushPersonTokens, + invokeAtResource, + invokeAtResourceComplete, + makeAgentPoll, + obtainPersonToken, + pollUntilDone, +} from './agent.js' export type { AgentSigningKey, Interaction, InteractionHandler, InvokeArgs, + InvokeOptions, InvokeResult, ProxyConfig, PSTokenHints, + SessionTokenStore, } from './agent.js' +export { isKnownAccessMode, KNOWN_ACCESS_MODES, planAccessMode, planReason } from './access-mode.js' +export type { AccessModePlan, AgentSetup, KnownAccessMode } from './access-mode.js' + +export { agentTokenPs, decodeJwtHeader, decodeJwtPayload, jwkThumbprint, jwtExp } from './jwt.js' + export { buildProxyTools } from './tools.js' export type { ProxyDeps } from './tools.js' @@ -42,7 +56,17 @@ export type { export { createFsRegistryCache, fetchRegistry, registryUrl } from './registry.js' export type { CachedIndex, RegistryCache, RegistryEntry, RegistryIndex } from './registry.js' -export { createFsL1Store } from './store.js' -export type { AccessMode, L1Entry, L1Store } from './store.js' +export { + createFsL1Store, + createFsPersonTokenStore, + createMemoryPersonTokenStore, +} from './store.js' +export type { + AccessMode, + L1Entry, + L1Store, + PersonTokenKey, + PersonTokenStore, +} from './store.js' export * from './vocab/index.js' diff --git a/src/jwt.ts b/src/jwt.ts new file mode 100644 index 0000000..830c3f2 --- /dev/null +++ b/src/jwt.ts @@ -0,0 +1,88 @@ +// Unverified JWT decoding + RFC 7638 JWK thumbprints. +// +// Decoding only — nothing here verifies a signature. The agent proxy reads its +// own agent token to learn whether it has a `ps` claim (which decides whether +// the person-token / auth-token flows are reachable at all) and reads `exp` off +// tokens it holds. Verification is the recipient's job. +// +// Platform-neutral: base64url by hand and WebCrypto for SHA-256, so the core +// stays workerd-safe (no node:crypto, no Buffer). + +export function decodeJwtHeader(jwt: string): Record { + return decodeSegment(jwt, 0) +} + +export function decodeJwtPayload(jwt: string): Record { + return decodeSegment(jwt, 1) +} + +function decodeSegment(jwt: string, index: number): Record { + const seg = jwt.split('.')[index] + if (!seg) throw new Error('malformed JWT') + const b64 = seg.replace(/-/g, '+').replace(/_/g, '/') + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4) + const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)) + return JSON.parse(new TextDecoder().decode(bytes)) as Record +} + +/** The `ps` claim on an agent token. Absent → the agent has no person server and + * cannot obtain a person token, and so cannot complete `person-token`, + * `auth-token` or `per-call` access. Malformed tokens read as no PS. */ +export function agentTokenPs(agentToken: string): string | undefined { + try { + const ps = decodeJwtPayload(agentToken).ps + return typeof ps === 'string' && ps ? ps : undefined + } catch { + return undefined + } +} + +/** Seconds-since-epoch `exp`, or undefined when absent/unparseable. */ +export function jwtExp(jwt: string): number | undefined { + try { + const exp = decodeJwtPayload(jwt).exp + return typeof exp === 'number' ? exp : undefined + } catch { + return undefined + } +} + +interface ThumbprintableJwk { + kty?: string + crv?: string + x?: string + y?: string + n?: string + e?: string +} + +function base64url(bytes: ArrayBuffer): string { + let s = '' + for (const b of new Uint8Array(bytes)) s += String.fromCharCode(b) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * RFC 7638 JWK thumbprint (SHA-256, base64url). Used as the cache key discriminator + * for person tokens: every person token binds the agent's key through `cnf`, so a + * change in this value invalidates all of them at once (protocol §Person Token + * Endpoint). + */ +export async function jwkThumbprint(jwk: ThumbprintableJwk): Promise { + let canonical: string + switch (jwk.kty) { + case 'OKP': + canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }) + break + case 'EC': + canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }) + break + case 'RSA': + canonical = JSON.stringify({ e: jwk.e, kty: jwk.kty, n: jwk.n }) + break + default: + throw new Error(`jwkThumbprint: unsupported kty ${jwk.kty}`) + } + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)) + return base64url(digest) +} diff --git a/src/resource.ts b/src/resource.ts index ef30ac9..dde136c 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -9,12 +9,13 @@ // user explicitly typed the host they want to add. import { canonicalizeHost } from './host.js' -import { getAdapter, supportedVocabUris } from './vocab/index.js' +import { effectiveAccessMode, getAdapter, supportedVocabUris } from './vocab/index.js' import type { AccessMode, L1Entry } from './store.js' import type { InvocationPlan, InvokeArgs, OpDetail, + OperationAnnotations, OpSummary, VocabAdapter, } from './vocab/index.js' @@ -22,20 +23,20 @@ import type { export interface AAuthResourceMeta { issuer: string client_name?: string + name?: string description?: string access_mode?: AccessMode logo_uri?: string authorization_endpoint?: string - // Value is a doc URL for most vocabularies; openapi-gateway advertises an - // object of service label → per-service OpenAPI URL (R3 §Resource Metadata - // Extensions). - r3_vocabularies?: Record> + // One discovery endpoint per vocabulary (R3 -02 §Resource Metadata Extensions, + // §Operation Identifier Scope). + r3_vocabularies?: Record jwks_uri?: string } export interface PickedVocab { vocabUri: string - docUrl: string | Record + docUrl: string adapter: VocabAdapter } @@ -77,21 +78,21 @@ function validate(meta: AAuthResourceMeta, host: string, origin: string): void { if (meta.issuer.replace(/\/+$/, '') !== origin) { throw new Error(`resource ${host}: issuer mismatch (got ${meta.issuer}, expected ${origin})`) } - if ( - meta.access_mode !== undefined && - !['agent-token', 'aauth-access-token', 'auth-token'].includes(meta.access_mode) - ) { - throw new Error(`resource ${host}: invalid access_mode ${meta.access_mode}`) - } + // access_mode is NOT validated against a closed list. The value set is an IANA + // registry (protocol §AAuth Access Mode Value Registry) and the declaration is + // advisory, so an unrecognized value is not an error — the agent adds the + // resource, treats the mode as undeclared, and reads the runtime + // AAuth-Requirement instead (see access-mode.ts). + // // description is enforced at the registry on submit; agent-proxy-side is lenient // so direct-URL adds of resources without a description still work. } -function pickVocabs(advertised: Record>): PickedVocab[] { +function pickVocabs(advertised: Record): PickedVocab[] { const out: PickedVocab[] = [] for (const uri of supportedVocabUris()) { const docUrl = advertised[uri] - if (!docUrl) continue + if (typeof docUrl !== 'string' || !docUrl) continue const adapter = getAdapter(uri) if (!adapter) continue out.push({ vocabUri: uri, docUrl, adapter }) @@ -155,6 +156,18 @@ function rehydrate(picked: L1Entry['picked_vocabs']): PickedVocab[] { return out } +// Resolve an operation's access annotations against the resource-wide +// access_mode and flatten the result onto the summary the LLM reads: `access_mode` +// is always the mode that actually applies to THIS operation, and `budget` appears +// only when the operation draws one down. Advisory throughout — the agent never +// enforces either, and a resource may return any AAuth-Requirement at runtime. +function withEffectiveAccess(op: T, resourceWide: string | undefined): T { + const resolved: T = { ...op, access_mode: effectiveAccessMode(op.annotations, resourceWide) } + if (op.annotations?.budget === true) resolved.budget = true + delete resolved.annotations + return resolved +} + export async function listOperationsForResource( l1: L1Entry, query?: string, @@ -164,7 +177,7 @@ export async function listOperationsForResource( for (const v of rehydrate(l1.picked_vocabs)) { const doc = await loadDoc(l1.resource, v, docCache) for (const summary of v.adapter.listOperations(doc, query)) { - out.push(summary) + out.push(withEffectiveAccess(summary, l1.access_mode)) } } return out @@ -179,7 +192,7 @@ export async function getOperationsForResource( for (const v of rehydrate(l1.picked_vocabs)) { const doc = await loadDoc(l1.resource, v, docCache) for (const detail of v.adapter.getOperations(doc, opIds)) { - out.push(detail) + out.push(withEffectiveAccess(detail, l1.access_mode)) } } return out @@ -188,6 +201,10 @@ export async function getOperationsForResource( export interface RoutedOperation { adapter: VocabAdapter plan: InvocationPlan + /** This operation's own access annotations, {} when it carries none. */ + annotations: OperationAnnotations + /** The mode that applies to this call: annotation if present, else the resource's. */ + accessMode: string } // Resolve an opId on a resource by trying each picked vocab in order. First @@ -204,7 +221,13 @@ export async function routeOperation( const doc = await loadDoc(l1.resource, v, docCache) try { const plan = v.adapter.buildInvocation(doc, opId, args) - return { adapter: v.adapter, plan } + const annotations = v.adapter.annotationsFor(doc, opId) + return { + adapter: v.adapter, + plan, + annotations, + accessMode: effectiveAccessMode(annotations, l1.access_mode), + } } catch { // try the next adapter } @@ -225,7 +248,7 @@ export function toL1Entry(r: FetchedResource): L1Entry { resource: r.host, origin: r.origin, issuer: r.meta.issuer.replace(/\/+$/, ''), - name: r.meta.client_name?.trim() || r.host, + name: r.meta.name?.trim() || r.meta.client_name?.trim() || r.host, description: r.meta.description ?? '', access_mode: inferredMode, ...(r.meta.logo_uri ? { logo_uri: r.meta.logo_uri } : {}), diff --git a/src/store.ts b/src/store.ts index 9630ded..cc6da15 100644 Binary files a/src/store.ts and b/src/store.ts differ diff --git a/src/tools.ts b/src/tools.ts index a4104fa..ab1b40e 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -11,9 +11,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { renderUnicodeCompact } from 'uqr' import { z } from 'zod' +import { planAccessMode } from './access-mode.js' +import type { AgentSetup } from './access-mode.js' import { deleteAtAdmin, invokeAtResource } from './agent.js' import type { InvokeResult, ProxyConfig } from './agent.js' import { canonicalizeHost } from './host.js' +import { agentTokenPs } from './jwt.js' import type { IdentityProvider } from './identity.js' import { fetchRegistry } from './registry.js' import type { RegistryCache } from './registry.js' @@ -90,6 +93,28 @@ export async function buildProxyTools(server: McpServer, deps: ProxyDeps): Promi const describeWithL1 = (base: string): string => `${base}\n\nCurrently added resources: ${l1Snapshot}` + // What this agent's setup can complete. An agent token with no `ps` claim has + // no person server, so nothing beyond `agent-token` and `session-token` is + // reachable — the listing tools say so up front instead of letting the LLM + // plan against a resource that will 401. + // + // Peek only: a listing tool must not provoke an enclave signature to mint an + // agent token. When nothing is resolved yet the annotation is simply omitted — + // it is advisory, and `invoke` still refuses an unsatisfiable mode outright. + function peekSetup(): AgentSetup | undefined { + const cfg = identity.peek?.() + return cfg ? { hasPersonServer: agentTokenPs(cfg.agentToken) !== undefined } : undefined + } + + // The `skip_reason` field on a listed resource: present only when this agent + // cannot complete the mode the resource declares. Advisory — an unrecognized + // or absent access_mode never produces one. + function skipReason(accessMode: string | undefined, setup: AgentSetup | undefined): string | undefined { + if (!setup) return undefined + const plan = planAccessMode(accessMode, setup) + return plan.kind === 'unsatisfiable' ? plan.reason : undefined + } + async function requireL1( resource: string, ): Promise<{ ok: true; l1: L1Entry } | { ok: false; msg: string }> { @@ -110,13 +135,14 @@ export async function buildProxyTools(server: McpServer, deps: ProxyDeps): Promi 'find_resources', { description: describeWithL1( - 'Search the AAuth registry for discoverable resources by free-text query against name/description. Returns each result tagged `added: true` if already in your local resource set.', + 'Search the AAuth registry for discoverable resources by free-text query against name/description. Returns each result tagged `added: true` if already in your local resource set. A result carrying `skip_reason` declares an access_mode this agent cannot complete — do not add or plan against it.', ), inputSchema: { query: z.string().optional() }, }, async ({ query }) => { const c = await getConfig() if (!c.ok) return text(BOOTSTRAP_GUIDANCE) + const setup: AgentSetup = { hasPersonServer: agentTokenPs(c.cfg.agentToken) !== undefined } try { const index = await fetchRegistry(c.cfg, registryCache) const q = (query ?? '').trim().toLowerCase() @@ -132,12 +158,14 @@ export async function buildProxyTools(server: McpServer, deps: ProxyDeps): Promi }) .map((r) => { const host = canonicalizeHost(r.issuer)?.host ?? r.issuer + const reason = skipReason(r.access_mode, setup) return { resource: host, name: r.name, description: r.description, access_mode: r.access_mode, added: added.has(host), + ...(reason ? { skip_reason: reason } : {}), ...(r.logo_uri ? { logo_uri: r.logo_uri } : {}), } }) @@ -176,18 +204,23 @@ export async function buildProxyTools(server: McpServer, deps: ProxyDeps): Promi 'list_resources', { description: - 'Return your locally added resources with name, description, access_mode, last_used, and how many vocabularies the agent proxy picked. Cheap; safe to call anytime.', + 'Return your locally added resources with name, description, access_mode, last_used, and how many vocabularies the agent proxy picked. A resource carrying `skip_reason` declares an access_mode this agent cannot complete — invoke will refuse it without calling out. Cheap; safe to call anytime.', }, async () => { - const entries = (await l1.list()).map((e) => ({ - resource: e.resource, - name: e.name, - description: e.description, - access_mode: e.access_mode, - vocabularies: e.picked_vocabs.map((v) => v.vocabUri), - added: e.added, - last_used: e.last_used, - })) + const setup = peekSetup() + const entries = (await l1.list()).map((e) => { + const reason = skipReason(e.access_mode, setup) + return { + resource: e.resource, + name: e.name, + description: e.description, + access_mode: e.access_mode, + vocabularies: e.picked_vocabs.map((v) => v.vocabUri), + added: e.added, + last_used: e.last_used, + ...(reason ? { skip_reason: reason } : {}), + } + }) return json(entries) }, ) @@ -214,7 +247,14 @@ export async function buildProxyTools(server: McpServer, deps: ProxyDeps): Promi 'list_operations', { description: describeWithL1( - 'List operations a resource exposes. Optional `query` is either free-text (matched against opId/summary/tags) or an OpenAPI path prefix (e.g. "/crm/v3/objects/contacts/*"). Returns summaries only — schemas are fetched via get_operations to keep token cost flat. Each op carries `kind` (sync.request/async.send/async.receive).', + 'List operations a resource exposes. Optional `query` is either free-text (matched against opId/summary/tags) or an OpenAPI path prefix (e.g. "/crm/v3/objects/contacts/*"). Returns summaries only — schemas are fetched via get_operations to keep token cost flat.\n\n' + + 'Each op carries `kind` (sync.request/async.send/async.receive) and `access_mode`, the credential that operation needs — read it before you plan:\n' + + '- `agent-token` — no authorization step; the agent already holds what it needs.\n' + + '- `person-token` — one call to the person server first; no user prompt in the common case.\n' + + '- `session-token` — the resource runs its own login/consent flow once.\n' + + '- `auth-token` — an authorization round trip through the person server; may prompt the user.\n' + + '- `per-call` — the resource authorizes each invocation against that call\'s parameters. It WILL block on a person every time. Do not plan unattended work around these.\n\n' + + '`budget: true` means invoking the operation draws down a spending budget. All of this is advisory — the resource may still challenge at runtime.', ), inputSchema: { resource: z.string(), query: z.string().optional() }, }, @@ -234,7 +274,7 @@ export async function buildProxyTools(server: McpServer, deps: ProxyDeps): Promi 'get_operations', { description: describeWithL1( - 'Batch fetch full schemas (params, request body, response) for one or more operations on a resource. Separate from list_operations because schemas dominate token cost.', + 'Batch fetch full schemas (params, request body, response) for one or more operations on a resource. Separate from list_operations because schemas dominate token cost. Each detail also carries the operation\'s `access_mode` and `budget`, as list_operations returns them.', ), inputSchema: { resource: z.string(), op_ids: z.array(z.string()) }, }, @@ -254,7 +294,7 @@ export async function buildProxyTools(server: McpServer, deps: ProxyDeps): Promi 'invoke', { description: describeWithL1( - 'Invoke an operation on a resource. Pass `path_params`, `query`, `body` (object) as needed. If authorization is required, the client opens the auth URL automatically — call invoke again after authorization completes. async.receive operations return `subscribe_requires_subagent` (v.next).', + 'Invoke an operation on a resource. Pass `path_params`, `query`, `body` (object) as needed. If authorization is required, the client opens the auth URL automatically — call invoke again after authorization completes. async.receive operations return `subscribe_requires_subagent` (v.next). An operation whose access_mode this agent cannot complete is refused without any request being made, with the reason stated.', ), inputSchema: { resource: z.string(), @@ -288,6 +328,17 @@ export async function buildProxyTools(server: McpServer, deps: ProxyDeps): Promi return text(`invoke error: ${(err as Error).message}`) } + // Case (c) of the access_mode plan: recognized, and this agent cannot + // complete it. No request was made and none will be — say why and let the + // LLM route around the resource rather than retry into a 401. + if (result.kind === 'skipped') { + return text( + `Skipped ${result.resource} / ${result.opId}: ${result.reason}.\n\n` + + `This operation's access_mode is "${result.mode}". Retrying will not help. ` + + `Use a different resource or operation, or bootstrap an agent identity bound to a person server.`, + ) + } + if (result.kind === 'interaction') { // onComplete resolves the UserStore pending-auth waiter when the poll finishes. const onComplete = () => deps.authPending?.resolve(found.l1.resource) diff --git a/src/vocab/annotations.ts b/src/vocab/annotations.ts new file mode 100644 index 0000000..9484621 --- /dev/null +++ b/src/vocab/annotations.ts @@ -0,0 +1,88 @@ +// Operation access annotations (R3 -02 §Operation Access Annotations). +// +// An agent cannot read R3 documents, so R3 alone tells it nothing about what any +// one operation requires. What it CAN read is the vocabulary — the OpenAPI +// document, the MCP tool list — because it has to parse that to make the call at +// all. The annotations state, alongside each operation, which credential it needs +// and whether it draws down a budget. +// +// Encodings (R3 -02 §Vocabulary Encodings): +// +// OpenAPI Operation Object x-aauth-access-mode x-aauth-budget +// MCP Tool `_meta` aauth.dev/access-mode aauth.dev/budget +// AsyncAPI Operation Object x-aauth-access-mode x-aauth-budget +// +// Three rules govern how they combine with the resource-wide `access_mode` +// (§Applying Annotations): +// +// 1. Sparse. An operation with no annotation takes the resource's access_mode. +// 2. Replacing, not intersecting. A `person-token` annotation on a resource +// declaring `auth-token` LOWERS the requirement for that operation. +// 3. Advisory. A resource MAY return any AAuth-Requirement at runtime whatever +// it published. Annotations let the agent plan; the runtime requirement is +// authoritative. Nothing here is ever enforced. + +export const OPENAPI_ACCESS_MODE_KEY = 'x-aauth-access-mode' +export const OPENAPI_BUDGET_KEY = 'x-aauth-budget' +export const MCP_ACCESS_MODE_KEY = 'aauth.dev/access-mode' +export const MCP_BUDGET_KEY = 'aauth.dev/budget' + +export interface OperationAnnotations { + /** The operation's own access mode, when it carries one. Never `session-token`: + * R3 forbids that value in an annotation, and a value we see anyway is dropped. */ + access_mode?: string + /** true when invoking this operation draws down a budget. */ + budget?: boolean +} + +function readAnnotations( + source: Record | undefined, + accessModeKey: string, + budgetKey: string, +): OperationAnnotations { + if (!source) return {} + const out: OperationAnnotations = {} + const mode = source[accessModeKey] + // `session-token` MUST NOT appear in an annotation: a resource that manages + // its own authorization does so for the whole resource and says so in + // access_mode. Drop it rather than honour it. + if (typeof mode === 'string' && mode && mode !== 'session-token') out.access_mode = mode + const budget = source[budgetKey] + if (typeof budget === 'boolean') out.budget = budget + return out +} + +/** Read the two annotations off an OpenAPI (or AsyncAPI) Operation Object. */ +export function readOpenApiAnnotations(op: Record | undefined): OperationAnnotations { + return readAnnotations(op, OPENAPI_ACCESS_MODE_KEY, OPENAPI_BUDGET_KEY) +} + +/** Read the two annotations off an MCP Tool's `_meta`. */ +export function readMcpToolAnnotations( + tool: { _meta?: Record } | undefined, +): OperationAnnotations { + return readAnnotations(tool?._meta, MCP_ACCESS_MODE_KEY, MCP_BUDGET_KEY) +} + +/** + * The access mode that actually applies to one operation. + * + * - An annotation replaces the resource-wide default outright (never intersects). + * - `budget: true` implies at least `auth-token`, because a budget is carried in + * the auth token's `budget` claim. Where the access mode annotation is absent, + * `budget: true` implies `auth-token` rather than the resource default; a + * resource MUST NOT pair `budget: true` with `agent-token` or `person-token`, + * and an agent that sees that combination anyway MUST treat it as `auth-token`. + * - Absent everything, the protocol default is `agent-token`. + */ +export function effectiveAccessMode( + annotations: OperationAnnotations | undefined, + resourceWide: string | undefined, +): string { + const annotated = annotations?.access_mode + const base = annotated ?? resourceWide ?? 'agent-token' + if (annotations?.budget === true && (base === 'agent-token' || base === 'person-token')) { + return 'auth-token' + } + return base +} diff --git a/src/vocab/index.ts b/src/vocab/index.ts index 8734b61..7cd711d 100644 --- a/src/vocab/index.ts +++ b/src/vocab/index.ts @@ -1,21 +1,27 @@ // Vocabulary adapter registry. The agent proxy picks adapters at resource-load time by // walking `r3_vocabularies` from the well-known doc and selecting every URN // it has an adapter for. +// +// `urn:aauth:vocabulary:openapi-gateway` was removed in R3 -02 (aauth-dev/AAuth +// issue #72): operation identifiers are scoped to the one discovery endpoint a +// resource advertises per vocabulary (R3 -02 §Operation Identifier Scope), so a +// resource fronting several backend services either presents them as a single +// valid definition or exposes them under separate resource identifiers. There is +// no composite `service:operationId` identity and no `{service, operationId}` +// entry shape any more. import { OpenAPIAdapter } from './openapi.js' -import { OpenAPIGatewayAdapter } from './openapi-gateway.js' import type { VocabAdapter } from './types.js' export * from './types.js' +export * from './annotations.js' export { OpenAPIAdapter } from './openapi.js' -export { OpenAPIGatewayAdapter } from './openapi-gateway.js' const ADAPTERS: Record = { 'urn:aauth:vocabulary:openapi': new OpenAPIAdapter(), - 'urn:aauth:vocabulary:openapi-gateway': new OpenAPIGatewayAdapter() as VocabAdapter, // 'urn:aauth:vocabulary:asyncapi': new AsyncAPIAdapter(), // Phase 3 - // 'urn:aauth:vocabulary:mcp-tools': new MCPToolsAdapter(), // future - // 'urn:aauth:vocabulary:graphql': new GraphQLAdapter(), // future + // 'urn:aauth:vocabulary:mcp': new MCPAdapter(), // future + // 'urn:aauth:vocabulary:graphql': new GraphQLAdapter(), // future } export function getAdapter(vocabUri: string): VocabAdapter | undefined { diff --git a/src/vocab/openapi-gateway.ts b/src/vocab/openapi-gateway.ts deleted file mode 100644 index 2450afd..0000000 --- a/src/vocab/openapi-gateway.ts +++ /dev/null @@ -1,88 +0,0 @@ -// OpenAPI Gateway vocabulary adapter (urn:aauth:vocabulary:openapi-gateway, -// AAuth R3 §OpenAPI Gateway Vocabulary). For resources that front MULTIPLE -// OpenAPI-described services behind a single resource identifier — e.g. a -// Google APIs proxy exposing gmail/calendar/people/… as separate specs. -// -// Discovery: the r3_vocabularies value is not a single URL but an object -// mapping stable service labels → per-service OpenAPI spec URLs. Operation -// identity is the pair (service, operationId); the agent-facing composite id -// is `service:operationId` (unambiguous — ':' appears in no operationId). -// R3 operation entries are { service, operationId } objects. - -import { OpenAPIAdapter, type OpenAPIVocabDoc } from './openapi.js' -import type { - InvocationPlan, - InvokeArgs, - OpDetail, - OpSummary, - VocabAdapter, -} from './types.js' - -export interface GatewayVocabDoc { - services: Record -} - -// Reuses the plain adapter's per-doc indexing/search/planning; this class only -// adds the service dimension. -const openapi = new OpenAPIAdapter() - -function splitId(compositeId: string): { service: string; operationId: string } { - const i = compositeId.indexOf(':') - if (i === -1) throw new Error(`openapi-gateway: operation id must be service:operationId, got ${compositeId}`) - return { service: compositeId.slice(0, i), operationId: compositeId.slice(i + 1) } -} - -export class OpenAPIGatewayAdapter implements VocabAdapter { - readonly vocabUri = 'urn:aauth:vocabulary:openapi-gateway' - - async load(source: string | Record): Promise { - if (typeof source === 'string' || source === null) { - throw new Error('openapi-gateway: discovery value must be an object mapping service labels to OpenAPI URLs') - } - const services: Record = {} - await Promise.all( - Object.entries(source).map(async ([label, url]) => { - services[label] = await openapi.load(url) - }), - ) - return { services } - } - - listOperations(doc: GatewayVocabDoc, query?: string): OpSummary[] { - const out: OpSummary[] = [] - for (const [label, serviceDoc] of Object.entries(doc.services)) { - for (const op of openapi.listOperations(serviceDoc, query)) { - out.push({ ...op, opId: `${label}:${op.opId}`, tags: [label, ...(op.tags ?? [])] }) - } - } - return out - } - - getOperations(doc: GatewayVocabDoc, opIds: string[]): OpDetail[] { - const out: OpDetail[] = [] - for (const compositeId of opIds) { - const { service, operationId } = splitId(compositeId) - const serviceDoc = doc.services[service] - if (!serviceDoc) continue - for (const detail of openapi.getOperations(serviceDoc, [operationId])) { - out.push({ ...detail, opId: compositeId, tags: [service, ...(detail.tags ?? [])] }) - } - } - return out - } - - buildInvocation(doc: GatewayVocabDoc, compositeId: string, args: InvokeArgs): InvocationPlan { - const { service, operationId } = splitId(compositeId) - const serviceDoc = doc.services[service] - if (!serviceDoc) throw new Error(`openapi-gateway: unknown service ${service}`) - // Per-service docs publish agent-facing paths relative to the resource - // origin (the gateway's generated specs label-prefix their paths), so the - // plain adapter's plan is already correct. - return openapi.buildInvocation(serviceDoc, operationId, args) - } - - // R3 operation entries carry the pair, not the composite string. - formatOperationEntry(compositeId: string): Record { - return splitId(compositeId) - } -} diff --git a/src/vocab/openapi.ts b/src/vocab/openapi.ts index 079f30b..3372dad 100644 --- a/src/vocab/openapi.ts +++ b/src/vocab/openapi.ts @@ -5,6 +5,8 @@ // - path prefix: query starts with '/'; optional trailing /* — matches path prefix // Both are bounded; the caller decides on a result cap. +import { readOpenApiAnnotations } from './annotations.js' +import type { OperationAnnotations } from './annotations.js' import type { InvocationPlan, InvokeArgs, @@ -16,6 +18,9 @@ import type { const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const type HttpMethod = (typeof HTTP_METHODS)[number] +// Specification extensions (x-*) are permitted on the Operation Object, which is +// where R3 -02 puts the access annotations; the index signature keeps them +// reachable without naming each one. interface OpenAPIOperation { operationId?: string summary?: string @@ -24,6 +29,7 @@ interface OpenAPIOperation { parameters?: unknown[] requestBody?: unknown responses?: Record + [ext: string]: unknown } type OpenAPIPathItem = Partial> & { @@ -45,6 +51,7 @@ interface ResolvedOp { parameters?: unknown[] requestBody?: unknown responses?: Record + annotations: OperationAnnotations } export interface OpenAPIVocabDoc { @@ -68,6 +75,7 @@ function indexOperations(doc: OpenAPIDoc): Map { parameters: [...(item.parameters ?? []), ...(op.parameters ?? [])], requestBody: op.requestBody, responses: op.responses, + annotations: readOpenApiAnnotations(op), }) } } @@ -104,6 +112,13 @@ function getOps(doc: OpenAPIVocabDoc): Map { return ops } +// Annotations are sparse: emit the field only when the operation carries one, so +// unannotated documents cost nothing extra in the listing the LLM reads. +function annotationsField(op: ResolvedOp): { annotations?: OperationAnnotations } { + const a = op.annotations ?? {} + return Object.keys(a).length > 0 ? { annotations: a } : {} +} + export class OpenAPIAdapter implements VocabAdapter { readonly vocabUri = 'urn:aauth:vocabulary:openapi' @@ -126,6 +141,7 @@ export class OpenAPIAdapter implements VocabAdapter { method: op.method, path: op.path, tags: op.tags, + ...annotationsField(op), }) } return out @@ -144,6 +160,7 @@ export class OpenAPIAdapter implements VocabAdapter { method: op.method, path: op.path, tags: op.tags, + ...annotationsField(op), paramsSchema: op.parameters, bodySchema: op.requestBody, responseSchema: op.responses, @@ -152,6 +169,10 @@ export class OpenAPIAdapter implements VocabAdapter { return out } + annotationsFor(doc: OpenAPIVocabDoc, opId: string): OperationAnnotations { + return getOps(doc).get(opId)?.annotations ?? {} + } + buildInvocation(doc: OpenAPIVocabDoc, opId: string, args: InvokeArgs): InvocationPlan { const op = getOps(doc).get(opId) if (!op) throw new Error(`openapi: unknown operation ${opId}`) diff --git a/src/vocab/types.ts b/src/vocab/types.ts index 333e608..d1684d4 100644 --- a/src/vocab/types.ts +++ b/src/vocab/types.ts @@ -4,9 +4,14 @@ // - enumerate operations as bounded summaries (for list_operations) // - return full schemas for chosen op_ids (for get_operations) // - turn (opId, args) into an InvocationPlan the agent proxy's `invoke` can execute +// - read each operation's access annotations (R3 -02 §Operation Access +// Annotations) off the vocabulary document // // See design.md §"Vocabularies". The LLM never sees `vocab` — it sees -// `kind` (sync.request | async.send | async.receive) on each OpSummary. +// `kind` (sync.request | async.send | async.receive) on each OpSummary, plus +// the operation's effective `access_mode` / `budget`. + +import type { OperationAnnotations } from './annotations.js' export type OpKind = 'sync.request' | 'async.send' | 'async.receive' @@ -18,6 +23,20 @@ export interface OpSummary { path?: string // sync.request channel?: string // async.* tags?: string[] + /** + * The operation's own access annotations, when the vocabulary document carries + * them. Sparse by design — absent means "takes the resource-wide access_mode". + * resource.ts resolves these against the L1 entry before the LLM sees them. + */ + annotations?: OperationAnnotations + /** + * The access mode that actually applies to this operation: the annotation when + * present, the resource-wide `access_mode` otherwise. Filled in by resource.ts, + * not by adapters. Advisory — the runtime AAuth-Requirement is authoritative. + */ + access_mode?: string + /** true when invoking this operation draws down a budget. Omitted when false. */ + budget?: boolean } export interface OpDetail extends OpSummary { @@ -64,14 +83,14 @@ export interface InvokeArgs { export interface VocabAdapter { readonly vocabUri: string - // The r3_vocabularies discovery value: a single doc URL for most - // vocabularies; openapi-gateway advertises an object of service label → URL. - load(source: string | Record): Promise + // The r3_vocabularies discovery value: one doc URL per vocabulary. Operation + // identifiers are scoped to that one endpoint (R3 -02 §Operation Identifier + // Scope), so a resource fronting several backends either presents them as one + // definition or exposes them under separate resource identifiers. + load(source: string): Promise listOperations(doc: Doc, query?: string): OpSummary[] getOperations(doc: Doc, opIds: string[]): OpDetail[] buildInvocation(doc: Doc, opId: string, args: InvokeArgs): InvocationPlan - // Shape of one entry in r3_operations / r3_granted for this vocabulary. - // Default (absent): { operationId: opId }. openapi-gateway splits the - // composite id into { service, operationId }. - formatOperationEntry?(opId: string): Record + /** This operation's access annotations, or {} when it carries none. */ + annotationsFor(doc: Doc, opId: string): OperationAnnotations }