diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4d434e..20fab8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,7 @@ jobs: - run: npm ci - name: Build packages (dependency order) run: | - for pkg in interaction-code local-keys mcp-agent mcp-server bootstrap fetch mcp-openclaw mcp-stdio; do + for pkg in protocol interaction-code local-keys agent resource bootstrap fetch mcp-openclaw mcp-stdio; do echo "Building $pkg..." (cd "$pkg" && npm run build) done @@ -88,7 +88,7 @@ jobs: # all-must-match gate. - name: Publish changed packages with provenance run: | - for pkg in interaction-code local-keys mcp-agent mcp-server bootstrap fetch mcp-openclaw mcp-stdio; do + for pkg in protocol interaction-code local-keys agent resource bootstrap fetch mcp-openclaw mcp-stdio; do version=$(node -p "require('./$pkg/package.json').version") published=$(npm view "@aauth/$pkg" version 2>/dev/null || echo "0.0.0") if [ "$version" = "$published" ]; then diff --git a/README.md b/README.md index b211a21..15777b9 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ AAuth is an agent-aware authentication protocol that lets AI agents prove their |---------|-------------| | [`@aauth/bootstrap`](./bootstrap) | CLI for setting up AAuth agent keys, person server registration, and hosting | | [`@aauth/fetch`](./fetch) | CLI for making AAuth-authenticated HTTP requests | -| [`@aauth/mcp-agent`](./mcp-agent) | Agent-side AAuth: signed fetch, challenge-response, token exchange | -| [`@aauth/mcp-server`](./mcp-server) | Server-side AAuth: token verification, challenge building, resource tokens | +| [`@aauth/protocol`](./protocol) | Wire format: AAuth-Requirement, AAuth-Capabilities, access_mode planning, typ/dwk constants | +| [`@aauth/agent`](./agent) | Agent-side AAuth: signed fetch, person tokens, challenge-response, token exchange | +| [`@aauth/resource`](./resource) | Resource-side AAuth: token verification, challenge building, resource tokens, R3 | | [`@aauth/local-keys`](./local-keys) | Library for managing AAuth agent signing keys across hardware and software backends | | [`@aauth/hardware-keys`](./hardware-keys) | Native bindings for YubiKey PIV and macOS Secure Enclave | | [`@aauth/mcp-stdio`](./mcp-stdio) | stdio-to-HTTP proxy with AAuth signatures | diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..7562354 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,181 @@ +# @aauth/agent + +The agent-side AAuth protocol library. Signs HTTP requests, obtains person tokens, handles AAuth challenge-response flows, exchanges resource tokens for auth tokens at the person server, and polls 202 deferred responses. + +Renamed from `@aauth/mcp-agent`: the package contains no MCP and never did. Its only runtime dependencies are [`@aauth/protocol`](../protocol) and `@hellocoop/httpsig`. + +Part of [aauth-dev/packages-js](https://github.com/aauth-dev/packages-js). Protocol spec: [dickhardt/AAuth](https://github.com/dickhardt/AAuth). + +## Install + +```bash +npm install @aauth/agent +``` + +## Usage + +### `createAAuthFetch(options): FetchLike` + +Creates a protocol-aware fetch that handles the full AAuth flow: signs requests, obtains a person token when a resource challenges with `requirement=person-token`, parses 401 `AAuth-Requirement` challenges, exchanges resource tokens with the person server, caches auth tokens, handles `AAuth-Access` session tokens, and retries. + +```ts +import { createAAuthFetch } from '@aauth/agent' + +const fetch = createAAuthFetch({ + getKeyMaterial: async () => ({ + signingKey: privateKeyJwk, + signatureKey: { type: 'jwt', jwt: agentToken } + }), + // Person server — the `ps` claim of the agent token. + authServerUrl: 'https://ps.example', + // Optional: declare protocol capabilities + capabilities: ['interaction', 'clarification'], + // Optional: the mission the agent is operating under, as the base64url + // SHA-256 of the approved mission blob. Forwarded when a person token is + // requested; it then flows person token → resource token → auth token. + missionS256: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + // Optional callbacks + onInteraction: (url, code) => { + console.log(`Visit ${url}?code=${code}`) + }, + onClarification: async (question) => { + return prompt(question) + }, + // Optional hints for the person server + justification: 'Read project files', + loginHint: 'user@example.com', + tenant: 'acme.com', + domainHint: 'acme.com', +}) + +const response = await fetch('https://resource.example/api') +``` + +There is no `AAuth-Mission` header in protocol -11 — it and its IANA registration were removed. A mission reaches a resource only inside a PS-issued token, as the `mission_s256` claim. + +### `requestPersonToken(options): Promise` + +Requests a person token from the PS's `person_token_endpoint`. A person token identifies the person the agent acts for to one resource. A resource MUST have verified one before it issues a resource token, and the agent MUST present one on every authorization endpoint request. + +```ts +import { requestPersonToken } from '@aauth/agent' + +const { personToken, expiresIn } = await requestPersonToken({ + signedFetch: psSignedFetch, // createSignedFetch(..., { signBody: true }) + personServerUrl: 'https://ps.example', + resource: 'https://resource.example', + missionS256: '...', // optional + subagentToken: '...', // optional — parent requesting for a sub-agent + onInteraction: (url, code) => { /* the PS may ask the person first */ }, +}) +``` + +The request is a signed POST presenting the agent token via `Signature-Key: sig=jwt;jwt="…"`, with body `{resource, mission_s256?, subagent_token?}`. A `202` with `requirement=interaction` is polled at its `Location` like any other deferred response. `upstream_token` (call chaining) is not implemented. + +Present the token in place of the agent token: + +```http +Signature-Key: sig=jwt;jwt="" +``` + +### `createPersonTokenCache(options): PersonTokenCache` + +Caches person tokens per `(resource, mission_s256)` — a person token is scoped to one resource and, when it carries `mission_s256`, to one mission. + +```ts +import { createPersonTokenCache } from '@aauth/agent' + +const personTokens = createPersonTokenCache({ + signedFetch: psSignedFetch, + personServerUrl: 'https://ps.example', +}) + +const token = await personTokens.get('https://resource.example', missionS256) + +// One rotation of the agent's signing key invalidates every cached token at +// once — they all bind that key through `cnf`. Flush and re-request lazily. +personTokens.clear() +``` + +`set(resource, missionS256, token, expiresIn)` seeds a token obtained elsewhere, such as the `person_tokens` map a PS returns with a mission approval. + +### `createSignedFetch(getKeyMaterial, options?): FetchLike` + +Creates a fetch that signs requests with HTTP Message Signatures but does not handle AAuth challenges. Use this when you only need request signing. + +```ts +import { createSignedFetch } from '@aauth/agent' + +const signedFetch = createSignedFetch(async () => ({ + signingKey: privateKeyJwk, + signatureKey: { type: 'hwk' } +}), { + capabilities: ['interaction'], +}) + +// For PS and AS endpoints: a request carrying a body additionally signs +// `content-digest` and `content-type`. +const psSignedFetch = createSignedFetch(getKeyMaterial, { signBody: true }) +``` + +Set `signBody` only for PS and AS endpoints. Resources declare what they need through `additional_signature_components` in their metadata, so a blanket body mandate toward a resource would be wrong. + +### `exchangeToken(options): Promise` + +Exchanges a resource token for an auth token at the person server. Handles metadata discovery (`/.well-known/aauth-person.json`), 202 deferred responses, and interaction polling. + +```ts +import { exchangeToken } from '@aauth/agent' + +const { authToken, expiresIn } = await exchangeToken({ + signedFetch: psSignedFetch, + authServerUrl: 'https://ps.example', + resourceToken: '...', + justification: 'Read project files', +}) +``` + +The auth token request has no mission parameter — the mission reaches the PS inside the resource token, which copied it from the person token. + +### `fetchAuthServerMetadata(options)` / `resolveAuthServerMetadata(options)` + +Fetches and validates `/.well-known/aauth-person.json`. Both `auth_token_endpoint` (renamed from `token_endpoint` in -11) and `person_token_endpoint` (new in -11) are REQUIRED; a person server publishing neither cannot complete a flow, and the document is rejected. `resolveAuthServerMetadata` returns a caller-supplied cached copy when there is one. + +### `pollDeferred(options): Promise` + +Polls a 202 Location URL until a terminal response. Handles `Retry-After`, `Prefer: wait`, clarification chat, and interaction codes. + +```ts +import { pollDeferred } from '@aauth/agent' + +const { response, error } = await pollDeferred({ + signedFetch, + locationUrl: 'https://ps.example/pending/abc123', + interactionCode: 'ABCD1234', + onInteraction: (url, code) => { /* show to user */ }, + maxPollDuration: 900, // seconds, default 900 +}) +``` + +## Protocol primitives + +Header parsing (`parseRequirementHeader`, `buildCapabilitiesHeader`, …), `access_mode` planning, token `typ` and `dwk` constants, and JWT decoding live in [`@aauth/protocol`](../protocol). This package consumes them and defines none of them. + +## Key Material Callback + +All signing functions take a `GetKeyMaterial` callback. This decouples key management from the protocol — you provide keys however you want: + +```ts +type GetKeyMaterial = () => Promise<{ + signingKey: JsonWebKey // Ed25519 private key for HTTP signatures + signatureKey: + | { type: 'jwt', jwt: string } // agent, person, or auth token + | { type: 'hwk' } // bare public key (pseudonym) +}> +``` + +For local development, use [`@aauth/local-keys`](../local-keys) to provide this callback from the OS keychain. + +## License + +MIT diff --git a/mcp-agent/package.json b/agent/package.json similarity index 69% rename from mcp-agent/package.json rename to agent/package.json index 45d5aad..6574362 100644 --- a/mcp-agent/package.json +++ b/agent/package.json @@ -1,7 +1,7 @@ { - "name": "@aauth/mcp-agent", - "version": "2.0.0", - "description": "Authenticated MCP transport with HTTP Signatures for AAuth agents", + "name": "@aauth/agent", + "version": "3.0.0", + "description": "Agent-side AAuth protocol library — HTTP Signatures, person tokens, token exchange, deferred polling", "type": "module", "exports": { ".": { @@ -18,7 +18,7 @@ }, "keywords": [ "aauth", - "mcp", + "agent", "http-signatures" ], "author": "Dick Hardt ", @@ -29,10 +29,11 @@ "repository": { "type": "git", "url": "https://github.com/aauth-dev/packages-js", - "directory": "mcp-agent" + "directory": "agent" }, "dependencies": { - "@hellocoop/httpsig": "^2.0.0" + "@aauth/protocol": "^1.0.0", + "@hellocoop/httpsig": "^2.2.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/mcp-agent/src/aauth-fetch.test.ts b/agent/src/aauth-fetch.test.ts similarity index 56% rename from mcp-agent/src/aauth-fetch.test.ts rename to agent/src/aauth-fetch.test.ts index e963c59..8cdeb40 100644 --- a/mcp-agent/src/aauth-fetch.test.ts +++ b/agent/src/aauth-fetch.test.ts @@ -18,6 +18,27 @@ vi.mock('./token-exchange.js', () => ({ exchangeToken: mockExchangeToken, })) +// The person-token client is stubbed here; its own suite covers minting, +// deferred interaction, and (resource, mission_s256) cache keying. +const { mockPersonTokenGet, mockCreatePersonTokenCache } = vi.hoisted(() => { + const mockPersonTokenGet = vi.fn() + return { + mockPersonTokenGet, + mockCreatePersonTokenCache: vi.fn((_options: Record) => ({ + get: mockPersonTokenGet, + peek: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + clear: vi.fn(), + size: 0, + })), + } +}) + +vi.mock('./person-token.js', () => ({ + createPersonTokenCache: mockCreatePersonTokenCache, +})) + const { mockPollDeferred } = vi.hoisted(() => ({ mockPollDeferred: vi.fn(), })) @@ -29,6 +50,12 @@ vi.mock('./deferred.js', () => ({ import { createAAuthFetch } from './aauth-fetch.js' import type { KeyMaterial } from './types.js' +const MISSION = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + +/** The options object of the most recent httpsig fetch call. */ +const lastCall = (): Record => + mockHttpSigFetch.mock.calls[mockHttpSigFetch.mock.calls.length - 1][1] + describe('createAAuthFetch', () => { const fakeKeyMaterial: KeyMaterial = { signingKey: { kty: 'OKP', crv: 'Ed25519', x: 'testkey' }, @@ -74,8 +101,9 @@ describe('createAAuthFetch', () => { const onAuthToken = vi.fn() const fetch = createAAuthFetch({ getKeyMaterial, - authServerUrl: 'https://auth.example', + personServerUrl: 'https://auth.example', justification: 'read files', + missionS256: MISSION, onAuthToken, }) const result = await fetch('https://resource.example/api', { method: 'GET' }) @@ -154,7 +182,7 @@ describe('createAAuthFetch', () => { const fetch = createAAuthFetch({ getKeyMaterial, - authServerUrl: 'https://auth.example', + personServerUrl: 'https://auth.example', }) // First request @@ -279,7 +307,7 @@ describe('createAAuthFetch', () => { const fetch = createAAuthFetch({ getKeyMaterial, - authServerUrl: 'https://auth.example', + personServerUrl: 'https://auth.example', loginHint: 'user@acme.com', tenant: 'acme.com', domainHint: 'acme.com', @@ -292,4 +320,168 @@ describe('createAAuthFetch', () => { domainHint: 'acme.com', })) }) + + describe('person tokens', () => { + it('401 requirement=person-token → mints one for the resource and retries with it', async () => { + mockHttpSigFetch.mockResolvedValueOnce(new Response('', { + status: 401, + headers: { 'aauth-requirement': 'requirement=person-token' }, + })) + mockPersonTokenGet.mockResolvedValueOnce('eyJ.person.token') + const okResponse = new Response('{"data":"ok"}', { status: 200 }) + mockHttpSigFetch.mockResolvedValueOnce(okResponse) + + const onPersonToken = vi.fn() + const fetch = createAAuthFetch({ + getKeyMaterial, + personServerUrl: 'https://ps.example', + missionS256: MISSION, + onPersonToken, + }) + const result = await fetch('https://resource.example/api') + + expect(result).toBe(okResponse) + + // One cache per fetch instance, pointed at the agent's person server... + expect(mockCreatePersonTokenCache).toHaveBeenCalledOnce() + expect(mockCreatePersonTokenCache).toHaveBeenCalledWith(expect.objectContaining({ + personServerUrl: 'https://ps.example', + })) + // ...and the token is asked for by resource identifier, under the mission. + expect(mockPersonTokenGet).toHaveBeenCalledWith('https://resource.example', MISSION) + + // The retry presents it via Signature-Key in place of the agent token. + expect(mockHttpSigFetch).toHaveBeenCalledTimes(2) + expect(mockHttpSigFetch.mock.calls[1][1].signatureKey) + .toEqual({ type: 'jwt', jwt: 'eyJ.person.token' }) + expect(onPersonToken).toHaveBeenCalledWith('eyJ.person.token', 'https://resource.example') + }) + + it('asks for a missionless person token when no mission is configured', async () => { + mockHttpSigFetch.mockResolvedValueOnce(new Response('', { + status: 401, + headers: { 'aauth-requirement': 'requirement=person-token' }, + })) + mockPersonTokenGet.mockResolvedValueOnce('pt') + mockHttpSigFetch.mockResolvedValueOnce(new Response('ok', { status: 200 })) + + const fetch = createAAuthFetch({ getKeyMaterial, personServerUrl: 'https://ps.example' }) + await fetch('https://resource.example/api') + + expect(mockPersonTokenGet).toHaveBeenCalledWith('https://resource.example', undefined) + }) + + it('after a person token the auth-token challenge still runs', async () => { + // person-token challenge → person token → resource now issues a resource + // token, which the agent takes to its PS. + mockHttpSigFetch.mockResolvedValueOnce(new Response('', { + status: 401, + headers: { 'aauth-requirement': 'requirement=person-token' }, + })) + mockPersonTokenGet.mockResolvedValueOnce('pt') + mockHttpSigFetch.mockResolvedValueOnce(new Response('', { + status: 401, + headers: { 'aauth-requirement': 'requirement=auth-token; resource-token="rt-with-mission"' }, + })) + mockExchangeToken.mockResolvedValueOnce({ authToken: 'at', expiresIn: 3600 }) + const okResponse = new Response('ok', { status: 200 }) + mockHttpSigFetch.mockResolvedValueOnce(okResponse) + + const fetch = createAAuthFetch({ + getKeyMaterial, + personServerUrl: 'https://ps.example', + missionS256: MISSION, + }) + const result = await fetch('https://resource.example/api') + + expect(result).toBe(okResponse) + expect(mockExchangeToken).toHaveBeenCalledWith(expect.objectContaining({ + resourceToken: 'rt-with-mission', + })) + expect(mockHttpSigFetch.mock.calls[2][1].signatureKey) + .toEqual({ type: 'jwt', jwt: 'at' }) + }) + + it('returns the 401 as-is when the agent has no person server', async () => { + const challenge = new Response('', { + status: 401, + headers: { 'aauth-requirement': 'requirement=person-token' }, + }) + mockHttpSigFetch.mockResolvedValueOnce(challenge) + + // No personServerUrl → no person server → the requirement is unsatisfiable. + const fetch = createAAuthFetch({ getKeyMaterial }) + const result = await fetch('https://resource.example/api') + + expect(result).toBe(challenge) + expect(mockCreatePersonTokenCache).not.toHaveBeenCalled() + }) + }) + + describe('PS/AS body signing', () => { + it('hands token exchange a PS-flavoured signedFetch, and the resource one an unflavoured one', async () => { + mockHttpSigFetch.mockResolvedValueOnce(new Response('', { + status: 401, + headers: { 'aauth-requirement': 'requirement=auth-token; resource-token="rt"' }, + })) + mockExchangeToken.mockResolvedValueOnce({ authToken: 'at', expiresIn: 3600 }) + mockHttpSigFetch.mockResolvedValueOnce(new Response('ok', { status: 200 })) + + const fetch = createAAuthFetch({ + getKeyMaterial, + personServerUrl: 'https://ps.example', + missionS256: MISSION, + }) + await fetch('https://resource.example/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"q":1}', + }) + + // The resource-facing POST carries no component list — a resource states + // its own needs via additional_signature_components. + expect(mockHttpSigFetch.mock.calls[0][1].components).toBeUndefined() + + // Prove the fetch passed to exchangeToken signs bodies: run a POST + // through it and check the covered components. + const psSignedFetch = mockExchangeToken.mock.calls[0][0].signedFetch + mockHttpSigFetch.mockResolvedValueOnce(new Response('{}', { status: 200 })) + await psSignedFetch('https://ps.example/aauth/token/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"resource_token":"rt"}', + }) + const psComponents: string[] = lastCall().components + expect(psComponents).toContain('content-digest') + expect(psComponents).toContain('content-type') + }) + + it('gives the person token client the same PS-flavoured fetch', async () => { + mockHttpSigFetch.mockResolvedValueOnce(new Response('', { + status: 401, + headers: { 'aauth-requirement': 'requirement=person-token' }, + })) + mockPersonTokenGet.mockResolvedValueOnce('pt') + mockHttpSigFetch.mockResolvedValueOnce(new Response('ok', { status: 200 })) + + const fetch = createAAuthFetch({ + getKeyMaterial, + personServerUrl: 'https://ps.example', + missionS256: MISSION, + }) + await fetch('https://resource.example/api') + + const psSignedFetch = mockCreatePersonTokenCache.mock.calls[0][0].signedFetch as + (url: string, init: RequestInit) => Promise + mockHttpSigFetch.mockResolvedValueOnce(new Response('{}', { status: 200 })) + await psSignedFetch('https://ps.example/aauth/token/person', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resource: 'https://resource.example', mission_s256: MISSION }), + }) + const psComponents: string[] = lastCall().components + expect(psComponents).toContain('content-digest') + expect(psComponents).toContain('content-type') + }) + }) }) diff --git a/mcp-agent/src/aauth-fetch.ts b/agent/src/aauth-fetch.ts similarity index 71% rename from mcp-agent/src/aauth-fetch.ts rename to agent/src/aauth-fetch.ts index 34dfc93..98b9a55 100644 --- a/mcp-agent/src/aauth-fetch.ts +++ b/agent/src/aauth-fetch.ts @@ -1,24 +1,40 @@ import { fetch as httpSigFetch, DEFAULT_COMPONENTS_GET, DEFAULT_COMPONENTS_BODY } from '@hellocoop/httpsig' import { createSignedFetch } from './signed-fetch.js' -import { parseAAuthHeader, buildCapabilitiesHeader, buildMissionHeader } from './aauth-header.js' +import { parseRequirementHeader } from '@aauth/protocol' +import type { Capability } from '@aauth/protocol' import { exchangeToken } from './token-exchange.js' -import type { AuthServerMetadata } from './token-exchange.js' +import type { PersonServerMetadata } from './token-exchange.js' +import { createPersonTokenCache } from './person-token.js' +import type { PersonTokenCache } from './person-token.js' import { pollDeferred } from './deferred.js' -import { decodeJwtPayload } from './decode-jwt.js' -import { summarizeResponseHeaders, decodeSignatureKey, captureSentFromHttpsig, peekResponseBody } from './log-helpers.js' +import { + summarizeResponseHeaders, + decodeSignatureKey, + captureSentFromHttpsig, + peekResponseBody, + decodeJwtPayloadSafe, +} from './log-helpers.js' import type { GetKeyMaterial, FetchLike, OnEvent, CapturedSent } from './types.js' -import type { Capability, AAuthMission } from './aauth-header.js' export interface AAuthFetchOptions { getKeyMaterial: GetKeyMaterial - authServerUrl?: string - /** Cached auth-server metadata; when provided, token exchange skips the /.well-known fetch. */ - authServerMetadata?: AuthServerMetadata + /** + * The agent's person server — the `ps` claim of its agent token. Both the + * person-token hop (`person_token_endpoint`) and the auth-token hop + * (`auth_token_endpoint`) go here, which is why the name says "person" and + * not "auth": under -11 the PS has two token endpoints and "auth" would name + * the wrong one. + */ + personServerUrl?: string + /** Cached PS metadata; when provided, both hops skip the /.well-known fetch. */ + personServerMetadata?: PersonServerMetadata /** Called with freshly-fetched metadata so the caller can persist it. */ - onMetadata?: (metadata: AuthServerMetadata) => void + onMetadata?: (metadata: PersonServerMetadata) => void /** Called with the auth token minted during a challenge exchange, so the caller * can surface it as a reusable credential (e.g. `fetch --with-token`). */ onAuthToken?: (authToken: string, expiresIn: number) => void + /** Called with each person token minted for a resource. */ + onPersonToken?: (personToken: string, resource: string) => void /** Called with an opaque AAuth-Access token received from a resource (two-party * mode), including rolling-refresh replacements, so the caller can surface it * for reuse. */ @@ -34,7 +50,15 @@ export interface AAuthFetchOptions { tenant?: string domainHint?: string capabilities?: Capability[] - mission?: AAuthMission + /** + * The mission the agent is operating under — the base64url SHA-256 of the + * approved mission blob. Forwarded to the PS when a person token is + * requested; from there the PS stamps it into the person token, the resource + * copies it into the resource token, and the PS or AS copies it into the auth + * token. There is no `AAuth-Mission` header in -11; a mission only ever + * reaches a resource inside a token. + */ + missionS256?: string prompt?: string /** Total consent-poll timeout in seconds (default 900) — see pollDeferred. */ maxPollDuration?: number @@ -61,10 +85,11 @@ interface CachedOpaque { export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { const { getKeyMaterial, - authServerUrl: configuredAuthServer, - authServerMetadata, + personServerUrl: configuredPersonServer, + personServerMetadata, onMetadata, onAuthToken, + onPersonToken, onOpaqueToken, opaqueToken: seedOpaqueToken, onInteraction, @@ -75,7 +100,7 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { tenant, domainHint, capabilities, - mission, + missionS256, prompt, maxPollDuration, } = options @@ -88,7 +113,29 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { const sentTracker: { latest: CapturedSent | undefined } = { latest: undefined } const onSigned = onEvent ? (sent: CapturedSent) => { sentTracker.latest = sent } : undefined - const signedFetch = createSignedFetch(getKeyMaterial, { capabilities, mission, onSigned }) + // Two flavours of signed fetch. Resource-facing requests sign the base + // components only — a resource states any extra it needs through + // `additional_signature_components`. PS/AS-facing requests additionally sign + // `content-digest` and `content-type` on any request with a body, which -11 + // makes unconditional at those endpoints. + const signedFetch = createSignedFetch(getKeyMaterial, { capabilities, onSigned }) + const psSignedFetch = createSignedFetch(getKeyMaterial, { capabilities, signBody: true, onSigned }) + + const personTokens: PersonTokenCache | undefined = configuredPersonServer + ? createPersonTokenCache({ + signedFetch: psSignedFetch, + personServerUrl: configuredPersonServer, + personServerMetadata, + onMetadata, + onInteraction, + onClarification, + onEvent, + maxPollDuration, + getKeyMaterial, + sentTracker, + }) + : undefined + const tokenCache = new Map() const opaqueCache = new Map() @@ -106,7 +153,7 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { const cached = findCachedToken(tokenCache, resourceOrigin) if (cached) { // Use cached auth token — sign with auth token instead of agent token - const response = await fetchWithAuthToken(url, init, cached.authToken, getKeyMaterial, onSigned) + const response = await fetchWithToken(url, init, cached.authToken, getKeyMaterial, onSigned) // If the cached token is rejected, fall through to challenge flow if (response.status !== 401) { cacheOpaqueToken(opaqueCache, resourceOrigin, response, onOpaqueToken) @@ -139,7 +186,7 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { agent_token: decodeSignatureKey(km.signatureKey), }) } - const response = await signedFetch(url, init) + let response = await signedFetch(url, init) const responseBody = onEvent ? await peekResponseBody(response) : undefined onEvent?.({ step: 'signed_request', @@ -153,6 +200,38 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { }, }) + // 401 requirement=person-token: the resource wants the person's identity + // before it will serve or issue anything. Get one from the PS for this + // resource and retry with it in place of the agent token. An agent with no + // person server cannot satisfy this and returns the 401 to its caller. + if (response.status === 401 && personTokens) { + const requirementHeader = response.headers.get('aauth-requirement') + if (requirementHeader && parseRequirementHeader(requirementHeader).requirement === 'person-token') { + onEvent?.({ step: 'challenge_received', phase: 'info', requirement: 'person-token' }) + const personToken = await personTokens.get(resourceOrigin, missionS256) + onPersonToken?.(personToken, resourceOrigin) + onEvent?.({ + step: 'retry_with_person_token', + phase: 'start', + url: urlStr, + person_token: decodeJwtPayloadSafe(personToken), + }) + response = await fetchWithToken(url, init, personToken, getKeyMaterial, onSigned) + const retryBody = onEvent ? await peekResponseBody(response) : undefined + onEvent?.({ + step: 'retry_with_person_token', + phase: 'done', + status: response.status, + request_headers: sentTracker.latest?.headers, + request_body: sentTracker.latest?.body, + response: { + headers: summarizeResponseHeaders(response.headers), + ...(retryBody !== undefined ? { body: retryBody } : {}), + }, + }) + } + } + // 200: success — check for AAuth-Access token if (response.status === 200) { cacheOpaqueToken(opaqueCache, resourceOrigin, response, onOpaqueToken) @@ -166,25 +245,25 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { return response // Not an AAuth challenge } - const challenge = parseAAuthHeader(aauthHeader) + const challenge = parseRequirementHeader(aauthHeader) if (challenge.requirement === 'auth-token' && challenge.resourceToken) { onEvent?.({ step: 'challenge_received', phase: 'info', requirement: 'auth-token', - resourceToken: decodeJwtPayload(challenge.resourceToken), + resourceToken: decodeJwtPayloadSafe(challenge.resourceToken), }) // The agent sends the resource token to its own auth server - const authServerUrl = configuredAuthServer + const authServerUrl = configuredPersonServer if (!authServerUrl) { - throw new Error('auth-token challenge received but no authServerUrl configured') + throw new Error('auth-token challenge received but no personServerUrl configured') } const result = await exchangeToken({ - signedFetch, + signedFetch: psSignedFetch, authServerUrl, - authServerMetadata, + authServerMetadata: personServerMetadata, onMetadata, resourceToken: challenge.resourceToken, justification, @@ -216,9 +295,9 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { step: 'retry_with_auth_token', phase: 'start', url: urlStr, - auth_token: decodeJwtPayload(result.authToken), + auth_token: decodeJwtPayloadSafe(result.authToken), }) - const retryResponse = await fetchWithAuthToken( + const retryResponse = await fetchWithToken( url, init, result.authToken, getKeyMaterial, onSigned, ) const retryBody = onEvent ? await peekResponseBody(retryResponse) : undefined @@ -271,7 +350,7 @@ async function handleResourceInteraction( const aauthHeader = response.headers.get('aauth-requirement') if (aauthHeader) { try { - const challenge = parseAAuthHeader(aauthHeader) + const challenge = parseRequirementHeader(aauthHeader) if (challenge.requirement === 'interaction' && challenge.url && challenge.code) { interactionUrl = challenge.url interactionCode = challenge.code @@ -294,13 +373,15 @@ async function handleResourceInteraction( } /** - * Send a signed request using the auth token as the signature key. - * The auth token replaces the agent token in the Signature-Key header. + * Send a signed request presenting `token` as the signature key — a person + * token or an auth token, in place of the agent token. Both are presented the + * same way (`Signature-Key: sig=jwt;jwt="…"`) and both carry the request's + * signing key in `cnf.jwk`, so verification proceeds identically. */ -async function fetchWithAuthToken( +async function fetchWithToken( url: string | URL, init: RequestInit | undefined, - authToken: string, + token: string, getKeyMaterial: GetKeyMaterial, onSigned?: (sent: CapturedSent) => void, ): Promise { @@ -309,7 +390,7 @@ async function fetchWithAuthToken( const { response, sent } = await httpSigFetch(url, { ...init, signingKey, - signatureKey: { type: 'jwt', jwt: authToken }, + signatureKey: { type: 'jwt', jwt: token }, returnSent: true, }) onSigned(captureSentFromHttpsig(sent)) @@ -318,7 +399,7 @@ async function fetchWithAuthToken( return await httpSigFetch(url, { ...init, signingKey, - signatureKey: { type: 'jwt', jwt: authToken }, + signatureKey: { type: 'jwt', jwt: token }, }) } diff --git a/mcp-agent/src/deferred.test.ts b/agent/src/deferred.test.ts similarity index 100% rename from mcp-agent/src/deferred.test.ts rename to agent/src/deferred.test.ts diff --git a/mcp-agent/src/deferred.ts b/agent/src/deferred.ts similarity index 79% rename from mcp-agent/src/deferred.ts rename to agent/src/deferred.ts index 5a60cc7..a503a7d 100644 --- a/mcp-agent/src/deferred.ts +++ b/agent/src/deferred.ts @@ -1,4 +1,4 @@ -import { parseAAuthHeader } from './aauth-header.js' +import { parseRequirementHeader } from '@aauth/protocol' import { summarizeResponseHeaders, peekResponseBody } from './log-helpers.js' import type { FetchLike, OnEvent, CapturedSent } from './types.js' @@ -15,12 +15,32 @@ export interface DeferredOptions { sentTracker?: { latest?: CapturedSent } } +/** + * A parsed AAuth error response (§Error Response Format). + * + * -11 adopted RFC 9457 problem details: `Content-Type: application/problem+json`, + * a REQUIRED `error` extension member carrying the error code, and an OPTIONAL + * `detail` carrying the human-readable explanation. + * + * `error_description` is the pre-11 spelling of `detail`, and Wallet still + * emits it. Both are read and `detail` wins when a server sends both, so an + * agent gets the explanation either way through the cutover. Drop the + * leniency only once no PS in the fleet emits the old member. + */ export interface AAuthError { error: string + /** RFC 9457 `detail`. */ + detail?: string + /** Pre-11 spelling of `detail`. */ error_description?: string error_uri?: string } +/** The human-readable half of an AAuth error, whichever spelling arrived. */ +export function describeAAuthError(error: AAuthError | undefined): string | undefined { + return error?.detail ?? error?.error_description ?? error?.error +} + export interface DeferredResult { response: Response error?: AAuthError @@ -126,7 +146,7 @@ export async function pollDeferred(options: DeferredOptions): Promise { +/** + * Read an AAuth error out of a response body, non-destructively. + * + * Exported because every failing path needs it, not only the deferred one: + * a PS that refuses a person token or a resource token says exactly what was + * wrong, and dropping that leaves the agent reporting a bare status code. + * + * Accepts `application/problem+json` (-11) and `application/json` (pre-11 and + * servers that have not cut over). + */ +export async function parseErrorBody(response: Response): Promise { if (response.status === 200) return undefined const contentType = response.headers.get('content-type') || '' - if (!contentType.includes('application/json')) return undefined + if (!/\bapplication\/(problem\+)?json\b/.test(contentType)) return undefined try { const body = await response.clone().json() as Record if (body.error && typeof body.error === 'string') { return { error: body.error, + detail: typeof body.detail === 'string' ? body.detail : undefined, error_description: typeof body.error_description === 'string' ? body.error_description : undefined, error_uri: typeof body.error_uri === 'string' ? body.error_uri : undefined, } diff --git a/agent/src/index.ts b/agent/src/index.ts new file mode 100644 index 0000000..db5d943 --- /dev/null +++ b/agent/src/index.ts @@ -0,0 +1,47 @@ +export { createSignedFetch, PS_COMPONENTS_BODY } from './signed-fetch.js' +export { createAAuthFetch } from './aauth-fetch.js' +export { + exchangeToken, + fetchPersonServerMetadata, + resolvePersonServerMetadata, + /** @deprecated pre-11 names */ + fetchAuthServerMetadata, + resolveAuthServerMetadata, + TokenExchangeError, +} from './token-exchange.js' +export { + requestPersonToken, + createPersonTokenCache, + PersonTokenError, +} from './person-token.js' +export { pollDeferred, parseErrorBody, describeAAuthError } from './deferred.js' +export type { + GetKeyMaterial, + KeyMaterial, + SignatureKeyJwt, + SignatureKeyJktJwt, + SignatureKeyHwk, + FetchLike, + AAuthEvent, + OnEvent, + CapturedSent, +} from './types.js' +export type { SignedFetchOptions } from './signed-fetch.js' +export type { DeferredOptions, DeferredResult, AAuthError } from './deferred.js' +export type { + TokenExchangeOptions, + TokenExchangeResult, + PersonServerMetadata, + /** @deprecated pre-11 name for PersonServerMetadata */ + AuthServerMetadata, + PersonServerMetadataOptions, + /** @deprecated pre-11 name for PersonServerMetadataOptions */ + AuthServerMetadataOptions, +} from './token-exchange.js' +export type { + PersonTokenOptions, + PersonTokenResult, + PersonTokenCache, + PersonTokenCacheOptions, +} from './person-token.js' +export type { AAuthFetchOptions } from './aauth-fetch.js' diff --git a/mcp-agent/src/log-helpers.ts b/agent/src/log-helpers.ts similarity index 77% rename from mcp-agent/src/log-helpers.ts rename to agent/src/log-helpers.ts index 336876a..9dfc622 100644 --- a/mcp-agent/src/log-helpers.ts +++ b/agent/src/log-helpers.ts @@ -1,6 +1,22 @@ import type { SignatureKeyJwt, SignatureKeyJktJwt, SignatureKeyHwk, CapturedSent } from './types.js' import type { SentRequest } from '@hellocoop/httpsig' -import { decodeJwtPayload } from './decode-jwt.js' +import { decodeJwtPayload } from '@aauth/protocol' + +/** + * Decode a JWT payload for --log narration, swallowing malformed input. + * + * `@aauth/protocol`'s decodeJwtPayload throws on a token it cannot decode. + * Narration is best-effort — a token we cannot read must not abort a protocol + * flow — so every logging path goes through this wrapper. Security decisions + * never read a decoded payload; they verify signatures. + */ +export function decodeJwtPayloadSafe(jwt: string): Record | undefined { + try { + return decodeJwtPayload(jwt) + } catch { + return undefined + } +} /** * Response headers exposed in --log events. Filtered to AAuth-relevant set so @@ -42,7 +58,7 @@ export function decodeSignatureKey( sk: SignatureKeyJwt | SignatureKeyJktJwt | SignatureKeyHwk, ): Record | undefined { const jwt = jwtFromSignatureKey(sk) - return jwt ? decodeJwtPayload(jwt) : undefined + return jwt ? decodeJwtPayloadSafe(jwt) : undefined } /** diff --git a/agent/src/person-token.test.ts b/agent/src/person-token.test.ts new file mode 100644 index 0000000..a2e8c92 --- /dev/null +++ b/agent/src/person-token.test.ts @@ -0,0 +1,452 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockPollDeferred } = vi.hoisted(() => ({ + mockPollDeferred: vi.fn(), +})) + +vi.mock('./deferred.js', async (importOriginal) => ({ + ...(await importOriginal()), + pollDeferred: mockPollDeferred, +})) + +import { + requestPersonToken, + createPersonTokenCache, + PersonTokenError, +} from './person-token.js' + +const MISSION = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' +const RESOURCE = 'https://resource.example' + +const metadata = { + auth_token_endpoint: 'https://ps.example/aauth/token/auth', + person_token_endpoint: 'https://ps.example/aauth/token/person', + jwks_uri: 'https://ps.example/jwks', +} + +function json(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }) +} + +describe('requestPersonToken', () => { + let mockFetch: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockFetch = vi.fn() + }) + + it('sends the whole consent-flow parameter set the endpoint accepts', async () => { + // The person token endpoint takes the auth token endpoint's full parameter + // set (contract, §Person Token Endpoint). `tenant` in particular is the + // agreed resolution to AAuth issue #88 — nothing else selects which tenant + // a person token carries — and `capabilities` answers #89, since the + // AAuth-Capabilities header is ruled out on PS endpoints. + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(json({ person_token: 'eyJ.p', expires_in: 3600 })) + + await requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + missionS256: MISSION, + subagentToken: 'eyJ.subagent', + justification: 'draft a reply', + loginHint: 'alice@example.com', + tenant: 'acme-corp', + domainHint: 'example.com', + prompt: 'consent', + platform: 'desktop', + device: 'alice-macbook', + capabilities: ['interaction'], + }) + + const body = JSON.parse(mockFetch.mock.calls[1][1].body as string) as Record + expect(body).toEqual({ + resource: RESOURCE, + mission_s256: MISSION, + subagent_token: 'eyJ.subagent', + justification: 'draft a reply', + login_hint: 'alice@example.com', + tenant: 'acme-corp', + domain_hint: 'example.com', + prompt: 'consent', + platform: 'desktop', + device: 'alice-macbook', + capabilities: ['interaction'], + }) + // Call chaining is out of scope; it must never appear. + expect(body.upstream_token).toBeUndefined() + }) + + it('omits every optional parameter the caller did not set', async () => { + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(json({ person_token: 'eyJ.p', expires_in: 3600 })) + + await requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + }) + + expect(JSON.parse(mockFetch.mock.calls[1][1].body as string)).toEqual({ resource: RESOURCE }) + }) + + it('carries the PS error code and detail on a direct refusal', async () => { + // §Error Response Format: RFC 9457 problem details with a REQUIRED `error` + // extension member and an OPTIONAL `detail`. Reporting only the status + // turns "you stripped the mission" into "400". + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(new Response( + JSON.stringify({ error: 'invalid_request', detail: 'resource is required' }), + { status: 400, headers: { 'Content-Type': 'application/problem+json' } }, + )) + + const err = await requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + }).catch((e: unknown) => e as PersonTokenError) + + expect(err).toBeInstanceOf(PersonTokenError) + expect((err as PersonTokenError).status).toBe(400) + expect((err as PersonTokenError).error).toBe('invalid_request') + expect((err as PersonTokenError).detail).toBe('resource is required') + expect((err as PersonTokenError).message).toBe('resource is required') + }) + + it('accepts the pre-11 error_description spelling of detail', async () => { + // What deployed servers still emit, including mockin. + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(new Response( + JSON.stringify({ error: 'invalid_jwt', error_description: 'alg "EdDSA" — Ed25519 required' }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + )) + + const err = await requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + }).catch((e: unknown) => e as PersonTokenError) + + expect((err as PersonTokenError).error).toBe('invalid_jwt') + expect((err as PersonTokenError).detail).toBe('alg "EdDSA" — Ed25519 required') + }) + + it('discovers the endpoint, POSTs resource + mission_s256, returns the token', async () => { + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(json({ person_token: 'eyJ.person.token', expires_in: 3600 })) + + const result = await requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + missionS256: MISSION, + }) + + expect(result).toEqual({ personToken: 'eyJ.person.token', expiresIn: 3600 }) + + expect(mockFetch).toHaveBeenNthCalledWith(1, + 'https://ps.example/.well-known/aauth-person.json', + { method: 'GET' }, + ) + expect(mockFetch).toHaveBeenNthCalledWith(2, + 'https://ps.example/aauth/token/person', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Prefer: 'wait=45', + }), + }), + ) + expect(JSON.parse(mockFetch.mock.calls[1][1].body)).toEqual({ + resource: RESOURCE, + mission_s256: MISSION, + }) + }) + + it('omits mission_s256 when the agent is not on a mission', async () => { + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt', expires_in: 3600 })) + + await requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + }) + + expect(JSON.parse(mockFetch.mock.calls[1][1].body)).toEqual({ resource: RESOURCE }) + }) + + it('carries subagent_token when a parent requests for a sub-agent', async () => { + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt', expires_in: 3600 })) + + await requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + missionS256: MISSION, + subagentToken: 'eyJ.subagent.token', + }) + + expect(JSON.parse(mockFetch.mock.calls[1][1].body)).toEqual({ + resource: RESOURCE, + mission_s256: MISSION, + subagent_token: 'eyJ.subagent.token', + }) + }) + + it('never sends upstream_token — call chaining is deferred', async () => { + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt', expires_in: 3600 })) + + await requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + missionS256: MISSION, + }) + + expect(JSON.parse(mockFetch.mock.calls[1][1].body)).not.toHaveProperty('upstream_token') + }) + + it('uses provided metadata and skips the /.well-known fetch', async () => { + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt', expires_in: 3600 })) + + await requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + personServerMetadata: metadata, + resource: RESOURCE, + missionS256: MISSION, + }) + + expect(mockFetch).toHaveBeenCalledOnce() + expect(mockFetch.mock.calls[0][0]).toBe('https://ps.example/aauth/token/person') + }) + + it('202 with requirement=interaction — polls the Location URL', async () => { + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(new Response(null, { + status: 202, + headers: { + Location: '/aauth/token/person/pending/abc123', + 'aauth-requirement': 'requirement=interaction; url="https://ps.example/interact"; code="A1B2-C3D4"', + }, + })) + mockPollDeferred.mockResolvedValueOnce({ + response: json({ person_token: 'eyJ.deferred.token', expires_in: 1800 }), + }) + + const onInteraction = vi.fn() + const result = await requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + missionS256: MISSION, + onInteraction, + }) + + expect(result).toEqual({ personToken: 'eyJ.deferred.token', expiresIn: 1800 }) + expect(mockPollDeferred).toHaveBeenCalledOnce() + const pollOpts = mockPollDeferred.mock.calls[0][0] + expect(pollOpts.locationUrl).toBe('https://ps.example/aauth/token/person/pending/abc123') + expect(pollOpts.interactionUrl).toBe('https://ps.example/interact') + expect(pollOpts.interactionCode).toBe('A1B2-C3D4') + expect(pollOpts.onInteraction).toBe(onInteraction) + }) + + it('throws on a 202 with no Location header', async () => { + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(new Response(null, { status: 202 })) + + await expect(requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + })).rejects.toThrow('202 response missing Location header') + }) + + it('throws PersonTokenError with the PS error detail on refusal', async () => { + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(new Response(null, { + status: 202, + headers: { Location: 'https://ps.example/aauth/token/person/pending/x' }, + })) + mockPollDeferred.mockResolvedValueOnce({ + response: new Response(null, { status: 403 }), + error: { error: 'access_denied', error_description: 'Person declined' }, + }) + + const promise = requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + missionS256: MISSION, + }) + await expect(promise).rejects.toBeInstanceOf(PersonTokenError) + await expect(promise).rejects.toThrow('Person declined') + }) + + it('throws PersonTokenError on an unexpected status', async () => { + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(new Response('nope', { status: 500 })) + + await expect(requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + })).rejects.toThrow('Person token request failed with status 500') + }) + + it('rejects a PS with no person_token_endpoint as non-conformant', async () => { + mockFetch.mockResolvedValueOnce(json({ auth_token_endpoint: 'https://ps.example/aauth/token/auth' })) + + await expect(requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + })).rejects.toThrow('missing person_token_endpoint') + }) + + it('throws when the 200 body has no person_token', async () => { + mockFetch.mockResolvedValueOnce(json(metadata)) + mockFetch.mockResolvedValueOnce(json({ expires_in: 3600 })) + + await expect(requestPersonToken({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + resource: RESOURCE, + })).rejects.toThrow('Person token response missing person_token') + }) +}) + +describe('createPersonTokenCache', () => { + let mockFetch: ReturnType + + const cacheFor = () => createPersonTokenCache({ + signedFetch: mockFetch, + personServerUrl: 'https://ps.example', + personServerMetadata: metadata, + }) + + beforeEach(() => { + vi.clearAllMocks() + mockFetch = vi.fn() + }) + + it('mints once and serves the cached token afterwards', async () => { + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-1', expires_in: 3600 })) + + const cache = cacheFor() + expect(await cache.get(RESOURCE, MISSION)).toBe('pt-1') + expect(await cache.get(RESOURCE, MISSION)).toBe('pt-1') + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('keys on (resource, mission_s256) — one token per combination', async () => { + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-a-m1', expires_in: 3600 })) + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-a-m2', expires_in: 3600 })) + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-b-m1', expires_in: 3600 })) + + const cache = cacheFor() + expect(await cache.get(RESOURCE, MISSION)).toBe('pt-a-m1') + expect(await cache.get(RESOURCE, 'other-mission')).toBe('pt-a-m2') + expect(await cache.get('https://other.example', MISSION)).toBe('pt-b-m1') + expect(cache.size).toBe(3) + + // Each combination is still served from cache. + expect(await cache.get(RESOURCE, MISSION)).toBe('pt-a-m1') + expect(mockFetch).toHaveBeenCalledTimes(3) + }) + + it('a missionless token is a different entry from a mission-scoped one', async () => { + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-no-mission', expires_in: 3600 })) + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-mission', expires_in: 3600 })) + + const cache = cacheFor() + expect(await cache.get(RESOURCE)).toBe('pt-no-mission') + expect(await cache.get(RESOURCE, MISSION)).toBe('pt-mission') + expect(cache.size).toBe(2) + }) + + it('shares one in-flight request between concurrent gets for the same key', async () => { + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-1', expires_in: 3600 })) + + const cache = cacheFor() + const [a, b] = await Promise.all([ + cache.get(RESOURCE, MISSION), + cache.get(RESOURCE, MISSION), + ]) + + expect(a).toBe('pt-1') + expect(b).toBe('pt-1') + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('clear() drops every entry — one key rotation invalidates them all', async () => { + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-a', expires_in: 3600 })) + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-b', expires_in: 3600 })) + + const cache = cacheFor() + await cache.get(RESOURCE, MISSION) + await cache.get('https://other.example', MISSION) + expect(cache.size).toBe(2) + + // Every cached token binds the old key through cnf. + cache.clear() + expect(cache.size).toBe(0) + expect(cache.peek(RESOURCE, MISSION)).toBeUndefined() + + // ...and they are re-requested lazily, on next use of each resource. + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-a-rotated', expires_in: 3600 })) + expect(await cache.get(RESOURCE, MISSION)).toBe('pt-a-rotated') + expect(cache.size).toBe(1) + }) + + it('treats a token inside the expiry buffer as gone', async () => { + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-fresh', expires_in: 3600 })) + + const cache = cacheFor() + // 30s of life left — inside the 60s buffer. + cache.set(RESOURCE, MISSION, 'pt-nearly-dead', 30) + expect(cache.peek(RESOURCE, MISSION)).toBeUndefined() + expect(await cache.get(RESOURCE, MISSION)).toBe('pt-fresh') + }) + + it('set() seeds a token obtained elsewhere (e.g. a mission approval)', async () => { + const cache = cacheFor() + cache.set(RESOURCE, MISSION, 'pt-from-mission-approval', 3600) + + expect(cache.peek(RESOURCE, MISSION)).toBe('pt-from-mission-approval') + expect(await cache.get(RESOURCE, MISSION)).toBe('pt-from-mission-approval') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('delete() drops a single entry', async () => { + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-1', expires_in: 3600 })) + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-2', expires_in: 3600 })) + + const cache = cacheFor() + await cache.get(RESOURCE, MISSION) + cache.delete(RESOURCE, MISSION) + expect(await cache.get(RESOURCE, MISSION)).toBe('pt-2') + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('does not cache a failed request', async () => { + mockFetch.mockResolvedValueOnce(new Response('boom', { status: 500 })) + mockFetch.mockResolvedValueOnce(json({ person_token: 'pt-ok', expires_in: 3600 })) + + const cache = cacheFor() + await expect(cache.get(RESOURCE, MISSION)).rejects.toBeInstanceOf(PersonTokenError) + expect(cache.size).toBe(0) + expect(await cache.get(RESOURCE, MISSION)).toBe('pt-ok') + }) +}) diff --git a/agent/src/person-token.ts b/agent/src/person-token.ts new file mode 100644 index 0000000..59f77d6 --- /dev/null +++ b/agent/src/person-token.ts @@ -0,0 +1,390 @@ +import { parseRequirementHeader } from '@aauth/protocol' +import { pollDeferred, parseErrorBody, describeAAuthError } from './deferred.js' +import type { AAuthError } from './deferred.js' +import { resolvePersonServerMetadata, resolveUrl } from './token-exchange.js' +import type { AuthServerMetadata } from './token-exchange.js' +import { + summarizeResponseHeaders, + decodeSignatureKey, + peekResponseBody, + decodeJwtPayloadSafe, +} from './log-helpers.js' +import type { FetchLike, GetKeyMaterial, OnEvent, CapturedSent } from './types.js' + +export class PersonTokenError extends Error { + /** The PS's error code (§Error Response Format `error`), when it sent one. */ + readonly error?: string + /** The PS's human-readable explanation — RFC 9457 `detail`, or the pre-11 + * `error_description` when that is what arrived. */ + readonly detail?: string + + constructor( + public readonly status: number, + public readonly aauthError?: AAuthError, + ) { + super( + describeAAuthError(aauthError) + ?? `Person token request failed with status ${status}`, + ) + this.name = 'PersonTokenError' + this.error = aauthError?.error + this.detail = aauthError?.detail ?? aauthError?.error_description + } +} + +export interface PersonTokenOptions { + /** + * Signed fetch that presents the agent token via `Signature-Key`. It MUST be + * a PS-flavoured fetch (`createSignedFetch(..., { signBody: true })`) so the + * JSON body is covered by `content-digest` and `content-type`. + */ + signedFetch: FetchLike + /** Person server URL — the `ps` claim of the agent token. */ + personServerUrl: string + /** Cached PS metadata; when provided, skips the /.well-known fetch. */ + personServerMetadata?: AuthServerMetadata + /** Called with freshly-fetched metadata so callers can persist it. */ + onMetadata?: (metadata: AuthServerMetadata) => void + /** REQUIRED. HTTPS URL of the resource the token is for; becomes its `aud`. */ + resource: string + /** OPTIONAL. The mission the agent is operating under; becomes `mission_s256`. */ + missionS256?: string + /** + * OPTIONAL. A sub-agent's agent token, when a parent obtains a person token + * on its behalf. The issued token's `cnf` is then the sub-agent's key. + */ + subagentToken?: string + + // ------------------------------------------------------------------------- + // The consent-flow parameter set, shared with the auth token endpoint. + // + // Both endpoints have the same deferred shape: either can return `202 + // requirement=interaction`, either may need to identify the person, either + // renders consent and creates a connected-agents entry. Every parameter + // serving that flow at one serves it at the other, and the person token is + // *first* contact — so these arguably matter more here. + // ------------------------------------------------------------------------- + + /** Why the agent wants this. Shown to the person during consent. */ + justification?: string + /** Which person, when the PS does not yet know. First contact is exactly + * when it may not. */ + loginHint?: string + /** + * Which tenant the person token should carry, for a person holding a + * personal context plus one or more managed ones. Becomes the token's + * `tenant` claim, which the resource then copies into the resource token and + * the PS verifies on the exchange — so getting it wrong fails the flow, not + * just the hint. Resolves AAuth issue #88. + */ + tenant?: string + domainHint?: string + prompt?: string + /** The platform the agent runs on, for the dashboard entry the PS creates on + * first connection to a resource. */ + platform?: string + /** The device the agent runs on. Same purpose as `platform`. */ + device?: string + /** + * What this agent can drive. An agent that cannot drive an interaction + * should not be sent down the deferred path — see AAuth issue #89. Sent in + * the request body: `AAuth-Capabilities` is ruled out on PS endpoints. + */ + capabilities?: string[] + + onInteraction?: (url: string, code: string) => void + onClarification?: (question: string) => Promise + onEvent?: OnEvent + /** Total interaction-poll timeout in seconds (default 900) — see pollDeferred. */ + maxPollDuration?: number + /** Optional: lets the agent token be decoded into :start events under --log. */ + getKeyMaterial?: GetKeyMaterial + /** Shared sent-request tracker; see TokenExchangeOptions. */ + sentTracker?: { latest?: CapturedSent } +} + +export interface PersonTokenResult { + personToken: string + expiresIn: number +} + +const PREFER_WAIT = 45 + +/** + * Request a person token from the PS's `person_token_endpoint`. + * + * A person token identifies the person the agent acts for to one resource. A + * resource MUST have verified one before it issues a resource token, and the + * agent MUST present one on every authorization endpoint request — so this is + * the first PS call of a flow, not an optional extra. + * + * The request is a signed POST presenting the agent token via + * `Signature-Key: sig=jwt;jwt="…"`, with body `{resource, mission_s256?, + * subagent_token?}` plus the consent-flow parameter set both PS token + * endpoints share — `justification`, `login_hint`, `tenant`, `domain_hint`, + * `prompt`, `platform`, `device`, `capabilities`. `upstream_token` (call + * chaining) is deliberately not implemented. + * + * A `202` with `requirement=interaction` is polled at its `Location` like any + * other deferred response — the PS may ask the person whether this agent may + * act at the resource as them before it will name them. + */ +export async function requestPersonToken(options: PersonTokenOptions): Promise { + const { + signedFetch, + personServerUrl, + resource, + missionS256, + subagentToken, + onInteraction, + onClarification, + onEvent, + getKeyMaterial, + sentTracker, + } = options + + const metadata = await resolvePersonServerMetadata({ + signedFetch, + authServerUrl: personServerUrl, + authServerMetadata: options.personServerMetadata, + onMetadata: options.onMetadata, + onEvent, + getKeyMaterial, + sentTracker, + }) + + const body: Record = { resource } + if (missionS256) body.mission_s256 = missionS256 + if (subagentToken) body.subagent_token = subagentToken + // `upstream_token` is deliberately absent — call chaining is out of scope. + if (options.justification) body.justification = options.justification + if (options.loginHint) body.login_hint = options.loginHint + if (options.tenant) body.tenant = options.tenant + if (options.domainHint) body.domain_hint = options.domainHint + if (options.prompt) body.prompt = options.prompt + if (options.platform) body.platform = options.platform + if (options.device) body.device = options.device + if (options.capabilities?.length) body.capabilities = options.capabilities + + if (onEvent) { + const agentToken = getKeyMaterial + ? decodeSignatureKey((await getKeyMaterial()).signatureKey) + : undefined + onEvent({ + step: 'ps_person_token_request', + phase: 'start', + url: metadata.person_token_endpoint, + resource, + mission_s256: missionS256, + agent_token: agentToken, + }) + } + + const requestBody = JSON.stringify(body) + const response = await signedFetch(metadata.person_token_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Prefer: `wait=${PREFER_WAIT}`, + }, + body: requestBody, + }) + const responseBody = onEvent ? await peekResponseBody(response) : undefined + onEvent?.({ + step: 'ps_person_token_request', + phase: 'done', + status: response.status, + request_headers: sentTracker?.latest?.headers, + request_body: sentTracker?.latest?.body ?? requestBody, + response: { + headers: summarizeResponseHeaders(response.headers), + ...(responseBody !== undefined ? { body: responseBody } : {}), + }, + }) + + if (response.status === 200) { + return emitReceived(parsePersonTokenResponse(await response.json() as Record), onEvent) + } + + if (response.status === 202) { + const locationUrl = response.headers.get('location') + if (!locationUrl) { + throw new Error('202 response missing Location header') + } + + let interactionUrl: string | undefined + let interactionCode: string | undefined + const requirementHeader = response.headers.get('aauth-requirement') + if (requirementHeader) { + const challenge = parseRequirementHeader(requirementHeader) + if (challenge.requirement === 'interaction' && challenge.url && challenge.code) { + interactionUrl = challenge.url + interactionCode = challenge.code + } + } + + const result = await pollDeferred({ + signedFetch, + locationUrl: resolveUrl(personServerUrl, locationUrl), + interactionUrl, + interactionCode, + onInteraction, + onClarification, + onEvent, + maxPollDuration: options.maxPollDuration, + sentTracker, + }) + + if (result.response.status === 200) { + return emitReceived( + parsePersonTokenResponse(await result.response.json() as Record), + onEvent, + ) + } + + throw new PersonTokenError(result.response.status, result.error) + } + + // The PS said why. Carry it: without this a mission-stripping or tenant + // rejection reads as a bare status code. + throw new PersonTokenError(response.status, await parseErrorBody(response)) +} + +function emitReceived(parsed: PersonTokenResult, onEvent?: OnEvent): PersonTokenResult { + onEvent?.({ + step: 'person_token_received', + phase: 'info', + expiresIn: parsed.expiresIn, + personToken: decodeJwtPayloadSafe(parsed.personToken), + }) + return parsed +} + +function parsePersonTokenResponse(body: Record): PersonTokenResult { + if (!body.person_token || typeof body.person_token !== 'string') { + throw new Error('Person token response missing person_token') + } + if (!body.expires_in || typeof body.expires_in !== 'number') { + throw new Error('Person token response missing expires_in') + } + return { + personToken: body.person_token, + expiresIn: body.expires_in, + } +} + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +/** Refresh this many ms before `exp` so a token is not spent on the wire. */ +const EXPIRY_BUFFER_MS = 60_000 + +export type PersonTokenCacheOptions = Omit< + PersonTokenOptions, + 'resource' | 'missionS256' | 'subagentToken' +> + +export interface PersonTokenCache { + /** + * The person token for this `(resource, missionS256)`, from cache when one is + * live and from the PS otherwise. Concurrent calls for the same key share one + * request. + */ + get(resource: string, missionS256?: string, subagentToken?: string): Promise + /** The cached token for this key, without minting one. */ + peek(resource: string, missionS256?: string): string | undefined + /** Seed a token obtained elsewhere — e.g. the `person_tokens` map a PS returns with a mission approval. */ + set(resource: string, missionS256: string | undefined, personToken: string, expiresIn: number): void + /** Drop one entry, e.g. after a resource rejected the token. */ + delete(resource: string, missionS256?: string): void + /** + * Drop every entry. Call this when the agent's signing key rotates: each + * cached person token binds that key through `cnf`, so one rotation + * invalidates all of them at once. Entries are re-requested lazily, on next + * use of each resource, rather than re-minted as a set. + */ + clear(): void + /** Number of live entries — for tests and diagnostics. */ + readonly size: number +} + +interface CachedPersonToken { + personToken: string + expiresAt: number +} + +/** + * Cache person tokens per `(resource, mission_s256)`. + * + * A person token is scoped to one resource and, when it carries `mission_s256`, + * to one mission, so an agent working across several resources or several + * concurrent missions holds one per combination. + */ +export function createPersonTokenCache(options: PersonTokenCacheOptions): PersonTokenCache { + const cache = new Map() + const inFlight = new Map>() + + const key = (resource: string, missionS256?: string): string => + `${resource}|${missionS256 ?? ''}` + + const live = (k: string): string | undefined => { + const entry = cache.get(k) + if (!entry) return undefined + if (entry.expiresAt > Date.now() + EXPIRY_BUFFER_MS) return entry.personToken + cache.delete(k) + return undefined + } + + return { + async get(resource, missionS256, subagentToken) { + const k = key(resource, missionS256) + const cached = live(k) + if (cached) return cached + + const pending = inFlight.get(k) + if (pending) return pending + + const request = requestPersonToken({ + ...options, + resource, + missionS256, + subagentToken, + }).then((result) => { + cache.set(k, { + personToken: result.personToken, + expiresAt: Date.now() + result.expiresIn * 1000, + }) + return result.personToken + }).finally(() => { + inFlight.delete(k) + }) + + inFlight.set(k, request) + return request + }, + + peek(resource, missionS256) { + return live(key(resource, missionS256)) + }, + + set(resource, missionS256, personToken, expiresIn) { + cache.set(key(resource, missionS256), { + personToken, + expiresAt: Date.now() + expiresIn * 1000, + }) + }, + + delete(resource, missionS256) { + cache.delete(key(resource, missionS256)) + }, + + clear() { + cache.clear() + }, + + get size() { + return cache.size + }, + } +} diff --git a/mcp-agent/src/signed-fetch.test.ts b/agent/src/signed-fetch.test.ts similarity index 59% rename from mcp-agent/src/signed-fetch.test.ts rename to agent/src/signed-fetch.test.ts index 9baf4ae..c0e5a47 100644 --- a/mcp-agent/src/signed-fetch.test.ts +++ b/agent/src/signed-fetch.test.ts @@ -8,7 +8,7 @@ vi.mock('@hellocoop/httpsig', () => ({ fetch: mockHttpSigFetch, })) -import { createSignedFetch } from './signed-fetch.js' +import { createSignedFetch, PS_COMPONENTS_BODY } from './signed-fetch.js' describe('createSignedFetch', () => { const fakeKeyMaterial = { @@ -92,28 +92,74 @@ describe('createSignedFetch', () => { expect(headers.get('aauth-capabilities')).toBe('interaction, clarification') }) - it('sets AAuth-Mission header when mission provided', async () => { + it('does not set AAuth-Capabilities when not provided', async () => { mockHttpSigFetch.mockResolvedValue(new Response()) - const signedFetch = createSignedFetch(getKeyMaterial, { - mission: { approver: 'https://ps.example', s256: 'abc123' }, - }) + const signedFetch = createSignedFetch(getKeyMaterial) await signedFetch('https://example.com') const call = mockHttpSigFetch.mock.calls[0] const headers = new Headers(call[1].headers) - expect(headers.get('aauth-mission')).toBe('approver="https://ps.example"; s256="abc123"') + expect(headers.has('aauth-capabilities')).toBe(false) }) - it('does not set AAuth-Capabilities or AAuth-Mission when not provided', async () => { + it('does not send an AAuth-Mission header — the header was removed in -11', async () => { mockHttpSigFetch.mockResolvedValue(new Response()) - const signedFetch = createSignedFetch(getKeyMaterial) + const signedFetch = createSignedFetch(getKeyMaterial, { capabilities: ['interaction'] }) await signedFetch('https://example.com') const call = mockHttpSigFetch.mock.calls[0] const headers = new Headers(call[1].headers) - expect(headers.has('aauth-capabilities')).toBe(false) expect(headers.has('aauth-mission')).toBe(false) }) + + describe('PS/AS body signing', () => { + it('covers content-digest and content-type on a PS request with a body', async () => { + mockHttpSigFetch.mockResolvedValue(new Response()) + + const psFetch = createSignedFetch(getKeyMaterial, { signBody: true }) + await psFetch('https://ps.example/aauth/token/person', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + resource: 'https://resource.example', + mission_s256: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + }), + }) + + const components: string[] = mockHttpSigFetch.mock.calls[0][1].components + expect(components).toEqual([...PS_COMPONENTS_BODY]) + expect(components).toContain('content-digest') + expect(components).toContain('content-type') + // The four base components stay mandatory. + expect(components).toEqual(expect.arrayContaining([ + '@method', '@authority', '@path', 'signature-key', + ])) + }) + + it('passes no component list on a bodyless PS request', async () => { + mockHttpSigFetch.mockResolvedValue(new Response()) + + const psFetch = createSignedFetch(getKeyMaterial, { signBody: true }) + await psFetch('https://ps.example/.well-known/aauth-person.json', { method: 'GET' }) + + expect(mockHttpSigFetch.mock.calls[0][1].components).toBeUndefined() + }) + + it('never mandates body components toward a resource', async () => { + mockHttpSigFetch.mockResolvedValue(new Response()) + + const resourceFetch = createSignedFetch(getKeyMaterial) + await resourceFetch('https://resource.example/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"q":1}', + }) + + // A resource declares what it needs via additional_signature_components; + // the agent must not impose content-digest on it. + expect(mockHttpSigFetch.mock.calls[0][1].components).toBeUndefined() + }) + }) }) diff --git a/agent/src/signed-fetch.ts b/agent/src/signed-fetch.ts new file mode 100644 index 0000000..e1cd54d --- /dev/null +++ b/agent/src/signed-fetch.ts @@ -0,0 +1,113 @@ +import { fetch as httpSigFetch } from '@hellocoop/httpsig' +import type { SentRequest, HttpSigFetchOptions } from '@hellocoop/httpsig' +import { buildCapabilitiesHeader } from '@aauth/protocol' +import type { Capability } from '@aauth/protocol' +import type { GetKeyMaterial, FetchLike, CapturedSent } from './types.js' + +/** + * Covered components for a request that carries a body to a PS or AS endpoint. + * + * Protocol -11 (#covered-components): on such a request the signature MUST + * additionally cover `content-digest` and `content-type`, because PS and AS + * request bodies carry the members that decide what is authorized + * (`resource`, `mission_s256`, `justification`, …) and only the tokens among + * them are self-protecting. @hellocoop/httpsig generates the Content-Digest + * header only when the covered-component list names it, so the list has to be + * passed explicitly — its DEFAULT_COMPONENTS_BODY omits `content-digest`. + * + * Resources are deliberately excluded: they declare what they need through + * `additional_signature_components` in their metadata, so a blanket body + * mandate toward a resource would be wrong. + */ +export const PS_COMPONENTS_BODY: readonly string[] = [ + '@method', + '@authority', + '@path', + 'content-type', + 'content-digest', + 'signature-key', +] + +export interface SignedFetchOptions { + capabilities?: Capability[] + /** + * Set on a fetch aimed at PS or AS endpoints. Requests carrying a body then + * sign `content-digest` and `content-type` as well (PS_COMPONENTS_BODY). + * Leave unset for resource-facing fetches — a resource states its own extra + * components via `additional_signature_components`. + */ + signBody?: boolean + /** + * Forwarded to @hellocoop/httpsig (2.2.0). Default 'auto' appends + * content-digest whenever the body is digestible; 'omit' restores the + * pre-2.2 defect of signing a body without covering it — only tests + * simulating a non-conforming client should ever pass it. + */ + contentDigest?: 'auto' | 'require' | 'omit' + /** + * Called synchronously after each signed request returns, with the actual + * on-the-wire headers + body. Used by the AAuth flow to capture the + * signed request data for --log rendering. + */ + onSigned?: (sent: CapturedSent) => void +} + +function headersToRecord(headers: Headers): Record { + const out: Record = {} + headers.forEach((value, key) => { out[key] = value }) + return out +} + +function captureSent(sent: SentRequest): CapturedSent { + let body: string | undefined + if (typeof sent.body === 'string') { + body = sent.body + } + return { + method: sent.method, + url: sent.url, + headers: headersToRecord(sent.headers), + body, + } +} + +export function createSignedFetch(getKeyMaterial: GetKeyMaterial, options?: SignedFetchOptions): FetchLike { + const capabilities = options?.capabilities ?? [] + + return async (url: string | URL, init?: RequestInit): Promise => { + const { signingKey, signatureKey } = await getKeyMaterial() + // Map jkt-jwt to jwt for @hellocoop/httpsig (same wire format) + const httpSigKey = signatureKey.type === 'jkt-jwt' + ? { type: 'jwt' as const, jwt: signatureKey.jwt } + : signatureKey + + const fetchInit: HttpSigFetchOptions = { + ...init, + signingKey, + signatureKey: httpSigKey, + } + + if (capabilities.length) { + const headers = new Headers(init?.headers) + headers.set('aauth-capabilities', buildCapabilitiesHeader(capabilities)) + fetchInit.headers = headers + } + + if (options?.signBody && init?.body != null) { + fetchInit.components = [...PS_COMPONENTS_BODY] + // PS/AS mandate (§10.3): fail loudly on a non-digestible body rather + // than silently dropping content-digest coverage. + fetchInit.contentDigest = 'require' + } + if (options?.contentDigest) { + fetchInit.contentDigest = options.contentDigest + } + + if (options?.onSigned) { + const { response, sent } = await httpSigFetch(url, { ...fetchInit, returnSent: true }) + options.onSigned(captureSent(sent)) + return response + } + return await httpSigFetch(url, fetchInit) + } +} diff --git a/mcp-agent/src/token-exchange.test.ts b/agent/src/token-exchange.test.ts similarity index 80% rename from mcp-agent/src/token-exchange.test.ts rename to agent/src/token-exchange.test.ts index 781bbb7..53458eb 100644 --- a/mcp-agent/src/token-exchange.test.ts +++ b/agent/src/token-exchange.test.ts @@ -4,7 +4,8 @@ const { mockPollDeferred } = vi.hoisted(() => ({ mockPollDeferred: vi.fn(), })) -vi.mock('./deferred.js', () => ({ +vi.mock('./deferred.js', async (importOriginal) => ({ + ...(await importOriginal()), pollDeferred: mockPollDeferred, })) @@ -14,7 +15,8 @@ describe('exchangeToken', () => { let mockFetch: ReturnType const metadata = { - token_endpoint: 'https://auth.example/aauth/token', + auth_token_endpoint: 'https://auth.example/aauth/token/auth', + person_token_endpoint: 'https://auth.example/aauth/token/person', jwks_uri: 'https://auth.example/aauth/jwks', } @@ -58,7 +60,7 @@ describe('exchangeToken', () => { // Verify token request expect(mockFetch).toHaveBeenNthCalledWith(2, - 'https://auth.example/aauth/token', + 'https://auth.example/aauth/token/auth', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ @@ -86,7 +88,7 @@ describe('exchangeToken', () => { const result = await exchangeToken({ signedFetch: mockFetch, authServerUrl: 'https://auth.example', - authServerMetadata: { token_endpoint: 'https://auth.example/aauth/token' }, + authServerMetadata: metadata, resourceToken: 'eyJ.resource.token', }) @@ -94,7 +96,7 @@ describe('exchangeToken', () => { // The very first (and only) call is the token POST — no metadata GET. expect(mockFetch).toHaveBeenCalledTimes(1) expect(mockFetch).toHaveBeenNthCalledWith(1, - 'https://auth.example/aauth/token', + 'https://auth.example/aauth/token/auth', expect.objectContaining({ method: 'POST' }), ) }) @@ -109,7 +111,7 @@ describe('exchangeToken', () => { await exchangeToken({ signedFetch: mockFetch, authServerUrl: 'https://auth.example', - authServerMetadata: { token_endpoint: 'https://auth.example/aauth/token' }, + authServerMetadata: metadata, resourceToken: 'rt', onEvent, onMetadata, @@ -136,7 +138,8 @@ describe('exchangeToken', () => { }) expect(onMetadata).toHaveBeenCalledWith(expect.objectContaining({ - token_endpoint: 'https://auth.example/aauth/token', + auth_token_endpoint: 'https://auth.example/aauth/token/auth', + person_token_endpoint: 'https://auth.example/aauth/token/person', })) }) @@ -218,6 +221,30 @@ describe('exchangeToken', () => { }) }) + it('does not send mission_s256 — the mission travels inside the resource token', async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(metadata), { status: 200 })) + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ + auth_token: 'tok', expires_in: 3600, + }), { status: 200 })) + + await exchangeToken({ + signedFetch: mockFetch, + authServerUrl: 'https://auth.example', + // A resource token minted under a mission carries mission_s256, copied + // from the person token the agent presented. The auth token request + // itself has no mission parameter (#agent-token-request). + resourceToken: 'eyJ.resource.token.with.mission_s256', + justification: 'book the flights', + }) + + const body = JSON.parse(mockFetch.mock.calls[1][1].body) + expect(body).not.toHaveProperty('mission_s256') + expect(body).toEqual({ + resource_token: 'eyJ.resource.token.with.mission_s256', + justification: 'book the flights', + }) + }) + it('throws on failed metadata fetch', async () => { mockFetch.mockResolvedValueOnce(new Response('not found', { status: 404 })) @@ -228,7 +255,7 @@ describe('exchangeToken', () => { })).rejects.toThrow('Failed to fetch auth server metadata: 404') }) - it('throws on metadata missing token_endpoint', async () => { + it('throws on metadata missing auth_token_endpoint', async () => { mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ jwks_uri: 'x' }), { status: 200, })) @@ -237,7 +264,22 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'rt', - })).rejects.toThrow('Auth server metadata missing token_endpoint') + })).rejects.toThrow('Auth server metadata missing auth_token_endpoint') + }) + + it('rejects a PS with no person_token_endpoint as non-conformant', async () => { + // -11 makes person_token_endpoint REQUIRED: without it the PS cannot mint + // the person token a resource demands before issuing a resource token, so + // nothing downstream of this document can succeed. + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ + auth_token_endpoint: 'https://auth.example/aauth/token/auth', + }), { status: 200 })) + + await expect(exchangeToken({ + signedFetch: mockFetch, + authServerUrl: 'https://auth.example', + resourceToken: 'rt', + })).rejects.toThrow('missing person_token_endpoint') }) it('throws on unexpected token endpoint status', async () => { diff --git a/mcp-agent/src/token-exchange.ts b/agent/src/token-exchange.ts similarity index 54% rename from mcp-agent/src/token-exchange.ts rename to agent/src/token-exchange.ts index 15c7fba..428ad59 100644 --- a/mcp-agent/src/token-exchange.ts +++ b/agent/src/token-exchange.ts @@ -1,20 +1,27 @@ import type { FetchLike, GetKeyMaterial, OnEvent, CapturedSent } from './types.js' -import { pollDeferred } from './deferred.js' +import { pollDeferred, parseErrorBody, describeAAuthError } from './deferred.js' import type { AAuthError } from './deferred.js' -import { parseAAuthHeader } from './aauth-header.js' -import { decodeJwtPayload } from './decode-jwt.js' -import { summarizeResponseHeaders, decodeSignatureKey, peekResponseBody } from './log-helpers.js' +import { parseRequirementHeader } from '@aauth/protocol' +import { summarizeResponseHeaders, decodeSignatureKey, peekResponseBody, decodeJwtPayloadSafe } from './log-helpers.js' export class TokenExchangeError extends Error { + /** The server's error code (§Error Response Format `error`), when it sent one. */ + readonly error?: string + /** The server's human-readable explanation — RFC 9457 `detail`, or the pre-11 + * `error_description` when that is what arrived. */ + readonly detail?: string + constructor( public readonly status: number, public readonly aauthError?: AAuthError, ) { - const msg = aauthError?.error_description - || aauthError?.error - || `Token exchange failed with status ${status}` - super(msg) + super( + describeAAuthError(aauthError) + ?? `Token exchange failed with status ${status}`, + ) this.name = 'TokenExchangeError' + this.error = aauthError?.error + this.detail = aauthError?.detail ?? aauthError?.error_description } } @@ -22,9 +29,9 @@ export interface TokenExchangeOptions { signedFetch: FetchLike authServerUrl: string /** Cached auth-server metadata; when provided, skips the /.well-known fetch. */ - authServerMetadata?: AuthServerMetadata + authServerMetadata?: PersonServerMetadata /** Called with freshly-fetched metadata (only when authServerMetadata wasn't provided) so callers can persist it. */ - onMetadata?: (metadata: AuthServerMetadata) => void + onMetadata?: (metadata: PersonServerMetadata) => void resourceToken: string justification?: string localhostCallback?: string @@ -33,6 +40,10 @@ export interface TokenExchangeOptions { domainHint?: string capabilities?: string[] prompt?: string + /** The platform the agent runs on, for the PS's connected-agents entry. */ + platform?: string + /** The device the agent runs on. Same purpose as `platform`. */ + device?: string onInteraction?: (url: string, code: string) => void onClarification?: (question: string) => Promise onEvent?: OnEvent @@ -57,20 +68,51 @@ export interface TokenExchangeResult { expiresIn: number } -export interface AuthServerMetadata { - token_endpoint: string +/** + * Person-server metadata, from `/.well-known/aauth-person.json`. + * + * Named for what it is. Under -11 the PS has *two* token endpoints, so a name + * containing "auth" says the wrong thing about which one — see + * `AuthServerMetadata` below for the retained alias. + * + * `auth_token_endpoint` was named `token_endpoint` before protocol -11, and + * `person_token_endpoint` is new in -11. Both are REQUIRED: a PS that does not + * publish `person_token_endpoint` cannot issue the person token a resource now + * demands before it will issue a resource token, so it is non-conformant and + * the whole flow is dead at that server. + */ +export interface PersonServerMetadata { + auth_token_endpoint: string + person_token_endpoint: string + mission_endpoint?: string + permission_endpoint?: string + audit_endpoint?: string + interaction_endpoint?: string + mission_control_endpoint?: string + revocation_endpoint?: string jwks_uri?: string } +/** + * @deprecated The pre-11 name for {@link PersonServerMetadata}. Retained so a + * consumer written against `@aauth/mcp-agent` 2.0.0 still compiles. + */ +export type AuthServerMetadata = PersonServerMetadata + const PREFER_WAIT = 45 /** * Exchange a resource token for an auth token via the auth server. * * 1. Fetches auth server metadata (/.well-known/aauth-person.json) - * 2. POSTs to token_endpoint with resource_token + hints, Prefer: wait=45 + * 2. POSTs to auth_token_endpoint with resource_token + hints, Prefer: wait=45 * 3. If 200: returns tokens directly * 4. If 202: polls via pollDeferred until terminal response + * + * `mission_s256` is not a parameter here: the mission reaches the PS inside the + * resource token, which copied it from the person token the agent presented + * (#person-token-endpoint). The agent names the mission once, when it requests + * the person token. */ export async function exchangeToken(options: TokenExchangeOptions): Promise { const { @@ -91,14 +133,15 @@ export async function exchangeToken(options: TokenExchangeOptions): Promise void + onEvent?: OnEvent + getKeyMaterial?: GetKeyMaterial + sentTracker?: { latest?: CapturedSent } +} + +/** + * @deprecated pre-11 name for {@link PersonServerMetadataOptions}. + */ +export type AuthServerMetadataOptions = PersonServerMetadataOptions + +/** + * Return the person server's metadata, from the caller's cache when it has one + * and from `/.well-known/aauth-person.json` otherwise. Shared by the auth-token + * exchange and the person-token client so one flow fetches the document once. + * + * Named for the document, not the hop: this fetches `aauth-person.json`, which + * only a person server publishes. `exchangeToken`'s own `authServerUrl` keeps + * its name because in four-party access that hop's server really is the AS. + */ +export async function resolvePersonServerMetadata( + options: PersonServerMetadataOptions, +): Promise { + if (options.authServerMetadata) { + options.onEvent?.({ step: 'ps_metadata_cached', phase: 'info' }) + return options.authServerMetadata + } + const metadata = await fetchPersonServerMetadata(options) + options.onMetadata?.(metadata) + return metadata } -async function fetchMetadata( - signedFetch: FetchLike, - authServerUrl: string, - onEvent?: OnEvent, - getKeyMaterial?: GetKeyMaterial, - sentTracker?: { latest?: CapturedSent }, -): Promise { +/** + * @deprecated pre-11 name for {@link resolvePersonServerMetadata}. + */ +export const resolveAuthServerMetadata = resolvePersonServerMetadata + +/** + * Fetch and validate `/.well-known/aauth-person.json`. + * + * Both `auth_token_endpoint` and `person_token_endpoint` are REQUIRED in -11; + * a document missing either is rejected here rather than half-way through a + * flow that cannot complete. + */ +export async function fetchPersonServerMetadata({ + signedFetch, + authServerUrl, + onEvent, + getKeyMaterial, + sentTracker, +}: PersonServerMetadataOptions): Promise { const metadataUrl = `${authServerUrl.replace(/\/$/, '')}/.well-known/aauth-person.json` if (onEvent) { const agentToken = getKeyMaterial @@ -244,13 +342,24 @@ async function fetchMetadata( } const metadata = await response.json() as Record - if (!metadata.token_endpoint) { - throw new Error('Auth server metadata missing token_endpoint') + if (!metadata.auth_token_endpoint) { + throw new Error('Auth server metadata missing auth_token_endpoint') + } + if (!metadata.person_token_endpoint) { + // A PS with no person token endpoint cannot mint the person token a + // resource requires before it issues a resource token — nothing downstream + // of this document can succeed. + throw new Error('Auth server metadata missing person_token_endpoint — person server is not AAuth -11 conformant') } - return metadata as unknown as AuthServerMetadata + return metadata as unknown as PersonServerMetadata } +/** + * @deprecated pre-11 name for {@link fetchPersonServerMetadata}. + */ +export const fetchAuthServerMetadata = fetchPersonServerMetadata + function parseTokenResponse(body: Record): TokenExchangeResult { if (!body.auth_token || typeof body.auth_token !== 'string') { throw new Error('Token response missing auth_token') @@ -264,7 +373,8 @@ function parseTokenResponse(body: Record): TokenExchangeResult } } -function resolveUrl(base: string, url: string): string { +/** Resolve a possibly-relative `Location` against the server it came from. */ +export function resolveUrl(base: string, url: string): string { if (url.startsWith('http://') || url.startsWith('https://')) { return url } diff --git a/mcp-agent/src/types.ts b/agent/src/types.ts similarity index 100% rename from mcp-agent/src/types.ts rename to agent/src/types.ts diff --git a/mcp-agent/tsconfig.json b/agent/tsconfig.json similarity index 100% rename from mcp-agent/tsconfig.json rename to agent/tsconfig.json diff --git a/bootstrap/README.md b/bootstrap/README.md index a5b56b3..fd81e06 100644 --- a/bootstrap/README.md +++ b/bootstrap/README.md @@ -15,7 +15,7 @@ npx @aauth/bootstrap create npx @aauth/bootstrap create --keystore secure-enclave --person-server https://person.example ``` -`create` detects available keystores (YubiKey PIV, macOS Secure Enclave, software), generates a key in the chosen one (default: software/EdDSA), binds it to the agent provider, and binds a person server. Then load a skill to publish your keys on GitHub Pages, GitLab Pages, Cloudflare Pages, or Netlify. +`create` detects available keystores (YubiKey PIV, macOS Secure Enclave, software), generates a key in the chosen one (default: software/Ed25519), binds it to the agent provider, and binds a person server. Then load a skill to publish your keys on GitHub Pages, GitLab Pages, Cloudflare Pages, or Netlify. Output is **pretty-printed JSON** on stdout (pipe it to `jq`); errors are `{ "error": "…" }` on stderr with a non-zero exit. Help and `skill` output are markdown. diff --git a/bootstrap/package.json b/bootstrap/package.json index 5129681..dec6c95 100644 --- a/bootstrap/package.json +++ b/bootstrap/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/bootstrap", - "version": "1.2.4", + "version": "2.0.0", "description": "CLI for bootstrapping AAuth agent keys and configuration", "type": "module", "bin": { @@ -30,6 +30,6 @@ "directory": "bootstrap" }, "dependencies": { - "@aauth/local-keys": "^1.1.0" + "@aauth/local-keys": "^2.0.0" } } diff --git a/bootstrap/skills/setup.md b/bootstrap/skills/setup.md index e53aedc..0bfb9ed 100644 --- a/bootstrap/skills/setup.md +++ b/bootstrap/skills/setup.md @@ -55,6 +55,22 @@ npx @aauth/bootstrap create [--keystore ] [--algorith It fails if the agent provider already exists — delete it first to re-create. +### Binding a person server can fail — that failure is real + +Step 3 fetches `/.well-known/aauth-person.json` and requires +`issuer`, `jwks_uri`, `auth_token_endpoint`, and `person_token_endpoint`. + +`person_token_endpoint` is where the agent gets a person token, and a person +token is the first thing the agent needs at a resource it has not used. A person +server that does not publish one cannot serve this agent anywhere, so `create` +stops instead of writing a binding whose every later call would fail. + +If you see `missing required field: person_token_endpoint`, the person server +has not been updated for this version of the protocol. Do not work around it — +there is nothing to work around; the identity would not function. Report it to +the user and either wait for the server to publish the endpoint or pass +`--person-server ` for one that does. + ## Keystore priority Prefer hardware over software (the private key never leaves the device). When @@ -66,7 +82,12 @@ and doesn't require plugging anything in: 2. **`yubikey-piv`** — YubiKey PIV slot 9e, no PIN, ES256. Portable across machines, but requires the YubiKey to be plugged in to sign. Prefer when the user explicitly wants a portable hardware key. -3. **`software`** — OS keychain, EdDSA (default) or ES256. Use only if no hardware is present. +3. **`software`** — OS keychain, Ed25519 (default) or ES256. Use only if no hardware is present. + +Whatever the keystore, the public JWK you publish carries a fully-specified +`alg` — `Ed25519` or `ES256`. AAuth rejects the polymorphic `EdDSA` identifier +(RFC 9864). If a JWKS you are about to publish says `"alg": "EdDSA"`, it was +written by an older version — re-read it from `list` before publishing. Pick the keystore from the `keystores` array that `list` reported. If both `secure-enclave` and `yubikey-piv` are available, default to `secure-enclave` @@ -162,12 +183,19 @@ Local config + a successful `git push` are not proof. A signed call that comes b npx @aauth/fetch https://whoami.aauth.dev ``` +Under the hood this exercises the whole chain: the agent signs with the key you +just published, POSTs its agent token to the person server's +`person_token_endpoint` to get a person token for `https://whoami.aauth.dev`, +and presents that person token to the resource. The resource never sees the +agent token — what it verifies is the person server's assertion of who you are. + Expect a body like `{ "sub": "aauth:local@", "ps": "" }`. If you see your `sub`, the install works end-to-end — the key signs, the JWKS resolves on the public URL, and the resource accepts the signature. If you get an error instead, debug before continuing. Common causes: - Pages hasn't finished propagating yet — wait a minute and retry. - `.nojekyll` is missing — GitHub Pages is hiding the `.well-known/` directory. - The JWKS URL returns 404 — the publish step didn't land. Re-check the platform skill. +- The person token request failed — the person server is reachable but did not issue. That is a person-server problem, not a publishing one; the JWKS checks above will all pass. This is the single source of truth for "did setup work." Do not declare success without it. diff --git a/bootstrap/src/bootstrap-ps.test.ts b/bootstrap/src/bootstrap-ps.test.ts index d6497e3..f8225cd 100644 --- a/bootstrap/src/bootstrap-ps.test.ts +++ b/bootstrap/src/bootstrap-ps.test.ts @@ -8,7 +8,8 @@ const AGENT_URL = 'https://agent.example' const validMetadata = { issuer: PS_URL, - token_endpoint: `${PS_URL}/aauth/token`, + auth_token_endpoint: `${PS_URL}/aauth/token`, + person_token_endpoint: `${PS_URL}/aauth/person`, jwks_uri: `${PS_URL}/.well-known/jwks.json`, interaction_endpoint: `${PS_URL}/aauth/interact`, } @@ -122,14 +123,82 @@ describe('bootstrapWithPS', () => { ).rejects.toThrow(/missing required field: issuer/) }) - it('throws when metadata is missing token_endpoint', async () => { - const { token_endpoint, ...rest } = validMetadata - void token_endpoint + it('throws when metadata is missing auth_token_endpoint', async () => { + const { auth_token_endpoint, ...rest } = validMetadata + void auth_token_endpoint mockFetch.mockResolvedValueOnce(mockMetadataResponse(rest)) await expect( bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }), - ).rejects.toThrow(/missing required field: token_endpoint/) + ).rejects.toThrow(/missing required field: auth_token_endpoint/) + }) + + it('throws when metadata is missing person_token_endpoint, naming the field', async () => { + const { person_token_endpoint, ...rest } = validMetadata + void person_token_endpoint + mockFetch.mockResolvedValueOnce(mockMetadataResponse(rest)) + + await expect( + bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }), + ).rejects.toThrow(/missing required field: person_token_endpoint/) + }) + + it('says why a missing person_token_endpoint is fatal, not deferred to first use', async () => { + const { person_token_endpoint, ...rest } = validMetadata + void person_token_endpoint + mockFetch.mockResolvedValueOnce(mockMetadataResponse(rest)) + + const error = await bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }) + .then(() => null, (e: Error) => e) + + expect(error).toBeInstanceOf(Error) + const message = (error as Error).message + expect(message).toContain('person_token_endpoint') + expect(message).toContain(PS_URL) + expect(message).toContain(`${PS_URL}/.well-known/aauth-person.json`) + expect(message).toMatch(/cannot issue person tokens/) + }) + + it('does not bind or cache anything when person_token_endpoint is missing', async () => { + const { person_token_endpoint, ...rest } = validMetadata + void person_token_endpoint + mockFetch.mockResolvedValueOnce(mockMetadataResponse(rest)) + + await expect( + bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }), + ).rejects.toThrow() + + // Binding to a PS that cannot issue person tokens would fail at first use — + // so nothing is written: no agent config, no cached metadata. + expect(getAgentConfig(AGENT_URL)).toBeNull() + expect(readCachedMetadata('ps.example')).toBeNull() + }) + + it('lists every missing required field at once (the -10 metadata a live PS still serves)', async () => { + // What https://person.hello.coop publishes today: -10 field names, no person endpoint. + mockFetch.mockResolvedValueOnce( + mockMetadataResponse({ + issuer: PS_URL, + token_endpoint: `${PS_URL}/aauth/token`, + interaction_endpoint: `${PS_URL}/auth`, + jwks_uri: `${PS_URL}/.well-known/jwks.json`, + }), + ) + + await expect( + bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }), + ).rejects.toThrow(/missing required fields: auth_token_endpoint, person_token_endpoint/) + }) + + it('explains the -10 → -11 rename when the PS still publishes token_endpoint', async () => { + const { auth_token_endpoint, ...rest } = validMetadata + mockFetch.mockResolvedValueOnce( + mockMetadataResponse({ ...rest, token_endpoint: auth_token_endpoint }), + ) + + await expect( + bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }), + ).rejects.toThrow(/auth_token_endpoint[\s\S]*token_endpoint/) }) it('throws when metadata is missing jwks_uri', async () => { diff --git a/bootstrap/src/bootstrap-ps.ts b/bootstrap/src/bootstrap-ps.ts index 67043e0..8789514 100644 --- a/bootstrap/src/bootstrap-ps.ts +++ b/bootstrap/src/bootstrap-ps.ts @@ -6,12 +6,68 @@ export interface BootstrapPSOptions { local?: string } +/** + * Person server metadata, `/.well-known/aauth-person.json` + * (draft-hardt-oauth-aauth-protocol §Person Server Metadata). + * + * Protocol -11 renamed `token_endpoint` to `auth_token_endpoint` and added + * `person_token_endpoint`, REQUIRED of every PS. + */ interface PSMetadata { issuer: string - token_endpoint: string + /** REQUIRED — renamed from `token_endpoint` in -11. */ + auth_token_endpoint: string + /** REQUIRED, new in -11 — where the agent obtains a person token for a resource. */ + person_token_endpoint: string jwks_uri: string authorization_endpoint?: string + mission_endpoint?: string + permission_endpoint?: string + audit_endpoint?: string interaction_endpoint?: string + mission_control_endpoint?: string + revocation_endpoint?: string + /** The -10 name for `auth_token_endpoint`. Read only to explain the failure. */ + token_endpoint?: string +} + +const REQUIRED_FIELDS = [ + 'issuer', + 'auth_token_endpoint', + 'person_token_endpoint', + 'jwks_uri', +] as const + +/** + * Name every REQUIRED field the PS did not publish, or null if it published all + * of them. + * + * Why a missing `person_token_endpoint` is fatal here rather than at first use: + * under -11 the agent's first step at a resource it has not used is obtaining a + * person token from its PS, so a PS that cannot issue one cannot serve this + * agent at any resource. Binding to it would succeed and then fail on every call. + */ +function metadataError(metadata: PSMetadata, personServerUrl: string, metadataUrl: string): string | null { + const missing = REQUIRED_FIELDS.filter((field) => !metadata[field]) + if (missing.length === 0) return null + + const label = missing.length === 1 ? 'field' : 'fields' + let message = + `PS metadata missing required ${label}: ${missing.join(', ')} — ` + + `${personServerUrl} is not a conformant AAuth person server. See ${metadataUrl}.` + + if (missing.includes('auth_token_endpoint') && metadata.token_endpoint) { + message += + ' It publishes `token_endpoint`, the AAuth -10 name for that field;' + + ' -11 renamed it to `auth_token_endpoint`.' + } + if (missing.includes('person_token_endpoint')) { + message += + ' Without `person_token_endpoint` it cannot issue person tokens, and obtaining a person' + + " token is the agent's first step at a resource it has not used — so binding to this" + + ' person server would succeed here and fail on every call. Bind one that publishes it.' + } + return message } /** @@ -19,11 +75,13 @@ interface PSMetadata { * * Per draft-hardt-aauth-bootstrap §Self-Hosted Enrollment, publication of the * JWKS is the enrollment — there is no separate enrollment step. The PS - * binding to a person happens lazily on the agent's first /aauth/token call, - * per draft-hardt-oauth-aauth-protocol §Agent-Person Binding. + * binding to a person happens lazily on the agent's first call to the PS — + * under -11 that is the person token request it makes before its first call to + * a resource — per draft-hardt-oauth-aauth-protocol §Agent-Person Binding. * * This function: - * 1. Fetches and validates PS metadata + * 1. Fetches and validates PS metadata — including that the PS publishes a + * `person_token_endpoint`, without which it cannot serve this agent at all * 2. Persists agentId + personServerUrl to ~/.aauth/config.json * 3. Caches the fetched PS metadata (public) to ~/.aauth/cache/ so fetch can * skip the runtime /.well-known/aauth-person.json round-trip until it expires @@ -34,16 +92,11 @@ interface PSMetadata { export async function bootstrapWithPS(options: BootstrapPSOptions): Promise { const { agentUrl, personServerUrl, local = 'local' } = options - const { metadata, cacheControl } = await fetchPSMetadata(personServerUrl) + const { metadata, cacheControl, metadataUrl } = await fetchPSMetadata(personServerUrl) - if (!metadata.issuer) { - throw new Error('PS metadata missing required field: issuer') - } - if (!metadata.token_endpoint) { - throw new Error('PS metadata missing required field: token_endpoint') - } - if (!metadata.jwks_uri) { - throw new Error('PS metadata missing required field: jwks_uri') + const error = metadataError(metadata, personServerUrl.replace(/\/$/, ''), metadataUrl) + if (error) { + throw new Error(error) } const normalisedIssuer = metadata.issuer.replace(/\/$/, '') @@ -74,12 +127,12 @@ export async function bootstrapWithPS(options: BootstrapPSOptions): Promise { +): Promise<{ metadata: PSMetadata; cacheControl: string | null; metadataUrl: string }> { const url = `${personServerUrl.replace(/\/$/, '')}/.well-known/aauth-person.json` const response = await fetch(url) if (!response.ok) { throw new Error(`Failed to fetch PS metadata at ${url}: ${response.status}`) } const metadata = await response.json() as PSMetadata - return { metadata, cacheControl: response.headers.get('cache-control') } + return { metadata, cacheControl: response.headers.get('cache-control'), metadataUrl: url } } diff --git a/bootstrap/src/cli.ts b/bootstrap/src/cli.ts index b64623f..72be145 100644 --- a/bootstrap/src/cli.ts +++ b/bootstrap/src/cli.ts @@ -16,7 +16,7 @@ import { validateUrl, ensureAgentUrls, } from '@aauth/local-keys' -import type { LocalKeyMeta } from '@aauth/local-keys' +import type { LocalKeyMeta, KeychainData } from '@aauth/local-keys' import { deleteAgent, uninstall, listBackups } from './teardown.js' import { bootstrapWithPS } from './bootstrap-ps.js' import { listSkills, getSkill } from './skills.js' @@ -34,6 +34,7 @@ import { resolveKeystoreAlgorithm, resolveAgentId, resolveLifetime, + withFullySpecifiedAlg, } from './resolve.js' /** A JWK is opaque here — we only pass it through to JSON output. */ @@ -70,10 +71,10 @@ async function resolvePublicJwk(agentUrl: string, kid: string, meta: LocalKeyMet if (meta.backend === 'software') { const data = readKeychain(agentUrl) const jwk = data?.keys[kid] - return jwk ? toPublicJwk(jwk) : null + return jwk ? withFullySpecifiedAlg(toPublicJwk(jwk)) : null } try { - return await getBackend(meta.backend).getPublicKey(meta.keyId) + return withFullySpecifiedAlg(await getBackend(meta.backend).getPublicKey(meta.keyId)) } catch { return null } @@ -139,13 +140,26 @@ async function cmdCreate(positional: string[], flags: Record + const privateJwk = withFullySpecifiedAlg(generated.privateJwk) as KeychainData['keys'][string] kid = pub.kid as string publicJwk = { ...pub, aauth: { device: deviceLabel, created } } as Jwk writeKeychain(url, { current: kid, keys: { [kid]: privateJwk } }) @@ -153,15 +167,11 @@ async function cmdCreate(positional: string[], flags: Record + publicJwk = { ...pub, kid, aauth: { device: deviceLabel, created } } as Jwk addKeyToAgent(url, kid, { backend: keystore, algorithm, keyId: ref.keyId, deviceLabel }) } - // Bind a person server (fetches + validates its metadata, persists agentId + ps). - const psError = validateUrl(personServer) - if (psError) return fail(`person-server: ${personServer} — ${psError}`) - await bootstrapWithPS({ agentUrl: url, personServerUrl: personServer, local }) - const cfg = getAgentConfig(url) printResult({ agentProvider: url, diff --git a/bootstrap/src/render.test.ts b/bootstrap/src/render.test.ts index f249652..7930d51 100644 --- a/bootstrap/src/render.test.ts +++ b/bootstrap/src/render.test.ts @@ -13,11 +13,11 @@ import type { SkillSummary } from './skills.js' describe('shapeKeystores', () => { it('maps BackendInfo to the keystore output shape', () => { const backends: BackendInfo[] = [ - { backend: 'software', description: 'OS keychain', algorithms: ['EdDSA', 'ES256'], deviceId: 'local' }, + { backend: 'software', description: 'OS keychain', algorithms: ['Ed25519', 'ES256'], deviceId: 'local' }, { backend: 'secure-enclave', description: 'macOS Secure Enclave', algorithms: ['ES256'], deviceId: 'local' }, ] expect(shapeKeystores(backends)).toEqual([ - { keystore: 'software', description: 'OS keychain', algorithms: ['EdDSA', 'ES256'] }, + { keystore: 'software', description: 'OS keychain', algorithms: ['Ed25519', 'ES256'] }, { keystore: 'secure-enclave', description: 'macOS Secure Enclave', algorithms: ['ES256'] }, ]) }) @@ -90,6 +90,29 @@ describe('help text', () => { expect(COMMAND_HELP[cmd]).toContain('$ npx @aauth/bootstrap') } }) + + it('every JWK shown carries a fully-specified alg — never the polymorphic EdDSA', () => { + for (const cmd of Object.keys(COMMAND_HELP)) { + expect(COMMAND_HELP[cmd]).not.toMatch(/"alg":\s*"EdDSA"/) + } + // The Ed25519 examples are still there — this isn't passing by deletion. + expect(COMMAND_HELP.list).toMatch(/"alg":\s*"Ed25519"/) + expect(COMMAND_HELP.create).toMatch(/"alg":\s*"Ed25519"/) + expect(COMMAND_HELP.token).toMatch(/"alg":\s*"Ed25519"/) + }) + + it('`token` help says the person token, not the agent token, is what a resource sees', () => { + expect(COMMAND_HELP.token).toContain('person_token_endpoint') + expect(COMMAND_HELP.token).toMatch(/person token/) + expect(COMMAND_HELP.token).toMatch(/not what a resource wants/) + }) + + it('`create` help states that a person server must publish person_token_endpoint', () => { + expect(COMMAND_HELP.create).toContain('person_token_endpoint') + expect(COMMAND_HELP.create).toContain('auth_token_endpoint') + // The -10 name is gone from the narration. + expect(COMMAND_HELP.create).not.toMatch(/(? { diff --git a/bootstrap/src/render.ts b/bootstrap/src/render.ts index df030fa..f090ad7 100644 --- a/bootstrap/src/render.ts +++ b/bootstrap/src/render.ts @@ -122,7 +122,7 @@ EXAMPLE $ npx @aauth/bootstrap list { "keystores": [ - { "keystore": "software", "description": "Software keys stored in OS keychain", "algorithms": ["EdDSA", "ES256"] } + { "keystore": "software", "description": "Software keys stored in OS keychain", "algorithms": ["Ed25519", "ES256"] } ], "agentProviders": [ { @@ -131,7 +131,7 @@ EXAMPLE "personServer": "https://person.hello.coop", "keys": [ { "kid": "bd3f9c…", "keystore": "software", - "publicJwk": { "kty": "OKP", "crv": "Ed25519", "x": "…", "alg": "EdDSA" } } + "publicJwk": { "kty": "OKP", "crv": "Ed25519", "x": "…", "alg": "Ed25519" } } ] } ] @@ -144,6 +144,12 @@ EXAMPLE - binds a person server (default: person.hello.coop, unless --person-server) Fails if the agent provider already exists (delete it first to re-create). + Binding fetches the person server's /.well-known/aauth-person.json and + requires it to publish issuer, jwks_uri, auth_token_endpoint, and + person_token_endpoint. A person server without person_token_endpoint cannot + issue the person token an agent needs at a resource, so create fails there + rather than binding to a server every later call would fail against. + USAGE npx @aauth/bootstrap create [flags] @@ -165,7 +171,7 @@ EXAMPLE "personServer": "https://person.hello.coop", "keys": [ { "kid": "bd3f9c…", "keystore": "software", - "publicJwk": { "kty": "OKP", "crv": "Ed25519", "x": "…", "alg": "EdDSA", + "publicJwk": { "kty": "OKP", "crv": "Ed25519", "x": "…", "alg": "Ed25519", "aauth": { "device": "mac-mini", "created": "2026-05-22" } } } ] }`, @@ -232,12 +238,17 @@ EXAMPLE }`, token: `DESCRIPTION - Generate an agent token — the credential an agent presents to make authenticated calls. + Generate an agent token — the credential that names this agent to its person server. With one agent provider configured it needs no arguments — the agent provider and its agent-id come from config. Output is the agent token (\`signatureKey\`) plus the ephemeral private key (\`signingKey\`) you sign requests with — the token's \`cnf\` binds to its public half. + The agent token is not what a resource wants. At a resource it has not used, an + agent first POSTs this agent token to its person server's \`person_token_endpoint\` + with the resource URL, and presents the person token it gets back — the person + server, not the agent, is what asserts to the resource whom the agent acts for. + USAGE npx @aauth/bootstrap token [flags] diff --git a/bootstrap/src/resolve.test.ts b/bootstrap/src/resolve.test.ts index 5ac6e75..e0f5ac2 100644 --- a/bootstrap/src/resolve.test.ts +++ b/bootstrap/src/resolve.test.ts @@ -4,6 +4,7 @@ import { resolveKeystoreAlgorithm, resolveAgentId, resolveLifetime, + withFullySpecifiedAlg, } from './resolve.js' describe('resolveProvider', () => { @@ -23,8 +24,8 @@ describe('resolveProvider', () => { }) describe('resolveKeystoreAlgorithm', () => { - it('defaults to software + EdDSA', () => { - expect(resolveKeystoreAlgorithm(undefined, undefined)).toEqual({ keystore: 'software', algorithm: 'EdDSA' }) + it('defaults to software + Ed25519', () => { + expect(resolveKeystoreAlgorithm(undefined, undefined)).toEqual({ keystore: 'software', algorithm: 'Ed25519' }) }) it('defaults a hardware keystore to ES256', () => { expect(resolveKeystoreAlgorithm('secure-enclave', undefined)).toEqual({ keystore: 'secure-enclave', algorithm: 'ES256' }) @@ -34,6 +35,37 @@ describe('resolveKeystoreAlgorithm', () => { }) }) +describe('withFullySpecifiedAlg', () => { + const okp = { kty: 'OKP', crv: 'Ed25519', x: 'abc', use: 'sig', kid: '2026-08-11_a1c' } + + it('replaces the polymorphic EdDSA with Ed25519 (RFC 9864)', () => { + expect(withFullySpecifiedAlg({ ...okp, alg: 'EdDSA' })).toEqual({ ...okp, alg: 'Ed25519' }) + }) + + it('keeps every other JWK member intact, including private material', () => { + const priv = { ...okp, d: 'secret', alg: 'EdDSA' } + expect(withFullySpecifiedAlg(priv)).toEqual({ ...priv, alg: 'Ed25519' }) + }) + + it('uses the curve — an Ed448 key labelled EdDSA becomes Ed448', () => { + const jwk = { kty: 'OKP', crv: 'Ed448', x: 'abc', alg: 'EdDSA' } + expect(withFullySpecifiedAlg(jwk)).toEqual({ ...jwk, alg: 'Ed448' }) + }) + + it('leaves an already fully-specified alg alone', () => { + const ed = { ...okp, alg: 'Ed25519' } + expect(withFullySpecifiedAlg(ed)).toBe(ed) + const ec = { kty: 'EC', crv: 'P-256', alg: 'ES256' } + expect(withFullySpecifiedAlg(ec)).toBe(ec) + }) + + it('passes through null and non-objects rather than throwing', () => { + expect(withFullySpecifiedAlg(null)).toBeNull() + expect(withFullySpecifiedAlg(undefined)).toBeUndefined() + expect(withFullySpecifiedAlg('not a jwk')).toBe('not a jwk') + }) +}) + describe('resolveAgentId', () => { const host = 'me.github.io' it('explicit wins over everything', () => { diff --git a/bootstrap/src/resolve.ts b/bootstrap/src/resolve.ts index 7c17a58..d4dba20 100644 --- a/bootstrap/src/resolve.ts +++ b/bootstrap/src/resolve.ts @@ -18,13 +18,35 @@ export function resolveProvider( return { error: 'Multiple agent providers configured. Pass --agent-provider .' } } -/** Keystore + algorithm with defaults: software→EdDSA, any hardware keystore→ES256. */ +/** + * Stamp a fully-specified signing algorithm on a JWK. + * + * Protocol -10 §Signature Algorithms requires fully-specified algorithm + * identifiers (RFC 9864): an Ed25519 key's `alg` is `Ed25519`; the polymorphic + * `EdDSA` MUST NOT be used. `@aauth/local-keys` 2.0.0 dropped `EdDSA` from + * `KeyAlgorithm` and stamps `Ed25519` itself, so this only has to upgrade JWKs + * generated by an earlier version — keychains and `jwks.json` files written + * before -11 still carry `EdDSA`. + */ +export function withFullySpecifiedAlg(jwk: unknown): unknown { + if (!jwk || typeof jwk !== 'object') return jwk + const rec = jwk as Record + if (rec.alg !== 'EdDSA') return jwk + return { ...rec, alg: rec.crv === 'Ed448' ? 'Ed448' : 'Ed25519' } +} + +/** + * Keystore + algorithm with defaults: software→Ed25519, any hardware keystore→ES256. + * + * `Ed25519` — not the polymorphic `EdDSA` — is `@aauth/local-keys` 2.0.0's + * `KeyAlgorithm` for software keys, and it is what ends up in the JWK's `alg`. + */ export function resolveKeystoreAlgorithm( keystoreFlag: string | undefined, algorithmFlag: string | undefined, ): { keystore: KeyBackend; algorithm: KeyAlgorithm } { const keystore = (keystoreFlag ?? 'software') as KeyBackend - const algorithm = (algorithmFlag ?? (keystore === 'software' ? 'EdDSA' : 'ES256')) as KeyAlgorithm + const algorithm = (algorithmFlag ?? (keystore === 'software' ? 'Ed25519' : 'ES256')) as KeyAlgorithm return { keystore, algorithm } } diff --git a/bootstrap/src/teardown.test.ts b/bootstrap/src/teardown.test.ts index ad5604c..1867a76 100644 --- a/bootstrap/src/teardown.test.ts +++ b/bootstrap/src/teardown.test.ts @@ -41,7 +41,7 @@ const softwareAgent = (url: string): AgentConfig => ({ agentServerUrl: `${url}/.well-known/aauth-agent.json`, jwksUri: `${url}/.well-known/jwks.json`, hosting: { platform: 'github-pages', repo: 'me/me.github.io' }, - keys: { '2026-05-22_ab': { backend: 'software', algorithm: 'EdDSA', keyId: url, deviceLabel: 'mac' } }, + keys: { '2026-05-22_ab': { backend: 'software', algorithm: 'Ed25519', keyId: url, deviceLabel: 'mac' } }, }) let dir: string diff --git a/e2e/aauth-protocol.test.ts b/e2e/aauth-protocol.test.ts index 1acf0c0..79de01e 100644 --- a/e2e/aauth-protocol.test.ts +++ b/e2e/aauth-protocol.test.ts @@ -1,514 +1,1324 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +/** + * AAuth -11 cross-package end-to-end suite. + * + * The only test in the wave that runs the packages together, against a real + * person server: **mockin** (WP-19), started as a child process. Nothing here + * stubs the protocol — every JWT is signed by the party the spec says signs it, + * every request is a real signed HTTP request, and every rejection is the real + * implementation rejecting it. + * + * Packages under test: `@aauth/protocol`, `@aauth/agent`, `@aauth/resource`, + * and `@hellocoop/httpsig` underneath all three. + * + * --------------------------------------------------------------------------- + * WHAT THIS SUITE CANNOT PROVE — read before assuming coverage + * --------------------------------------------------------------------------- + * + * **`mission_endpoint` is unimplemented by agreement, so every mission + * constraint except equality is unverifiable.** mockin accepts *any* string as + * a `mission_s256`: there is no mission document to look up, so §Resource Token + * Verification step 7 (mission active, current time before `expires_at`) is + * never evaluated, and no `expires_at` reaches any issuer. That means + * `clampToMission` and `missionExpiresAt` in `@aauth/resource`, and the "no + * token carrying `mission_s256` may outlive the mission" rule generally, have + * **unit coverage only**. The mission tests below prove exactly one thing — + * that a `mission_s256` survives the person token -> resource token -> auth + * token path unchanged, and that changing it in either direction is caught. + * + * **`upstream_token` / call chaining** is rejected at both mockin endpoints and + * unimplemented in `@aauth/agent`, so there is nothing to exercise. + * + * **`revocation_endpoint` and `mission_control_endpoint`** are not published by + * mockin. + * + * **mockin does not check that a resource token's `iss` equals the `aud` of the + * person token it names.** Its jti store records the `aud` and never compares + * it. So "resource A redeems a person token minted for resource B" is not a + * rejection this suite can assert against mockin. + * + * **One person only.** `login_hint`, `prompt` and `domain_hint` are validated + * and recorded but select nothing, so nothing here tests choosing between + * people. `tenant` is different — mockin acts on it, so §9's tenant tests + * drive the real request parameter. + * + * **No PS classifies operations into `r3_per_call`.** mockin's `autoGrantR3` + * grants the whole fetched document every time — there is no classifier, no + * risk heuristic and no consent screen, so `r3_per_call` exists only when the + * `r3_grants` mock switch puts it there. §9 uses that switch to stand in for + * the person's decision and proves the resource and agent halves of the + * per-call round trip. Whether a PS routes the right operations to `r3_per_call` + * is untested, and not testable here. + * + * **"You may only propose what you were granted in principle" is unverified.** + * mockin does not remember the class R3 document between exchanges, does not + * require a proposal's operations to be a subset of what it granted, and + * connects the two `POST /aauth/token` calls in no way at all. A resource that + * proposed an operation the person never granted as `r3_per_call` would be + * approved. §9 has the rest of the R3 limits at its head. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' import { - generateKeyPair, - exportJWK, - calculateJwkThumbprint, -} from 'jose' - -// --- Mock wiring --- -// httpSigFetch replaces @hellocoop/httpsig.fetch so that -// mcp-agent's createSignedFetch/createAAuthFetch call our mock server harness. - -const { mockHttpSigFetch } = vi.hoisted(() => ({ - mockHttpSigFetch: vi.fn(), -})) - -vi.mock('@hellocoop/httpsig', () => ({ - fetch: mockHttpSigFetch, -})) - -// MCP SDK mocks for ServerManager tests -const { - mockConnect, mockListTools, mockCallTool, MockClient, - mockTransportClose, MockStreamableHTTPClientTransport, mockCreateSignedFetch, -} = vi.hoisted(() => { - const mockConnect = vi.fn().mockResolvedValue(undefined) - const mockListTools = vi.fn() - const mockCallTool = vi.fn() - const MockClient = vi.fn().mockImplementation(() => ({ - connect: mockConnect, - listTools: mockListTools, - callTool: mockCallTool, - })) - const mockTransportClose = vi.fn().mockResolvedValue(undefined) - const MockStreamableHTTPClientTransport = vi.fn().mockReturnValue({ - close: mockTransportClose, - }) - const mockCreateSignedFetch = vi.fn().mockReturnValue(vi.fn()) - return { - mockConnect, mockListTools, mockCallTool, MockClient, - mockTransportClose, MockStreamableHTTPClientTransport, mockCreateSignedFetch, - } -}) - -vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ - Client: MockClient, -})) - -vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ - StreamableHTTPClientTransport: MockStreamableHTTPClientTransport, -})) - -// --- Imports (after mocks) --- + requestPersonToken, + exchangeToken, + createSignedFetch, + fetchAuthServerMetadata, + PersonTokenError, + TokenExchangeError, + PS_COMPONENTS_BODY, +} from '@aauth/agent' +import { planAccessMode, TOKEN_TYP, DWK, SIGNING_ALG } from '@aauth/protocol' +import type { KnownAccessMode } from '@aauth/protocol' +import { clearMetadataCache, computeR3Hash, digestParameter } from '@aauth/resource' import { - buildAAuthHeader, - InteractionManager, - verifyToken, - clearMetadataCache, -} from '@aauth/mcp-server' -import type { VerifiedAgentToken, VerifiedAuthToken } from '@aauth/mcp-server' -import { parseAAuthHeader, createAAuthFetch } from '@aauth/mcp-agent' -import { ServerManager } from '@aauth/mcp-openclaw' - -import { - createTestKeys, - createAgentJwt, - createAuthJwt, - createGetKeyMaterial, - createMockServer, + LoopbackRouter, + startMockin, + startResource, + createAgent, + callResource, + resourceTokenFrom, + requirementOf, + claimsOf, + headerOf, + forgeToken, + serverSignedFetch, + R3_VOCABULARY, + PER_CALL_OPERATION, + PS, + RESOURCE, + AGENT, + AGENT_ID, } from './helpers.js' -import type { TestKeys } from './helpers.js' - -// --- Constants --- - -const AGENT_URL = 'https://agent.example' -const AGENT_ID = 'aauth:test@example.com' -const AUTH_SERVER_URL = 'https://auth.example' -const RESOURCE_URL = 'https://resource.example' -const INTERACTION_URL = 'https://auth.example/interact' - -// ============================================================================= -// Suite 1: AAuth-Requirement header round-trip -// ============================================================================= +import type { Mockin, PsEndpointField, TestAgent, TestResource } from './helpers.js' +import type { FetchLike } from '@aauth/agent' + +const router = new LoopbackRouter() +let mockin: Mockin +let resource: TestResource +let agent: TestAgent + +/** An agent whose token carries no `ps` claim — it has no person server. */ +let psLessAgent: TestAgent + +beforeAll(async () => { + // `@hellocoop/httpsig`'s verify() resolves a `sig=jwks_uri` Signature-Key on + // the global fetch, with no injection point, so identifier resolution has to + // be global for the R3 fetch-authorization path to run. + router.install() + mockin = await startMockin(router) + resource = await startResource({ router, personServer: PS }) + // Order matters: every agent publishes at the same identifier, so the last + // one created owns the route. `psLessAgent` is only ever read as claims, so + // the real agent must be created after it. + psLessAgent = await createAgent({ router }) + agent = await createAgent({ router, personServer: PS }) + mockin.trust(AGENT, agent.jwks) + mockin.trust(RESOURCE, resource.jwks) + await mockin.reset() +}, 60_000) + +afterAll(async () => { + await resource?.stop() + await agent?.stop() + await psLessAgent?.stop() + await mockin?.stop() + router.uninstall() +}) -describe('AAuth-Requirement header round-trip (server builds → agent parses)', () => { - it('round-trips auth-token challenge', () => { - const header = buildAAuthHeader('auth-token', { - resourceToken: 'rt_abc123', - }) - const parsed = parseAAuthHeader(header) +beforeEach(async () => { + // `DELETE /mock` empties the person-token `jti` store, the pending map and + // the entity cache, and restores every switch to its default. A person token + // minted by an earlier test is dead after this, which is the isolation we + // want: each test mints its own. + await mockin.reset() + resource.mint = {} + resource.accept = ['agent', 'person', 'auth'] + resource.accessMode = undefined + resource.scopeGateReached = false + resource.resetR3() +}) - expect(parsed.requirement).toBe('auth-token') - expect(parsed.resourceToken).toBe('rt_abc123') +// --------------------------------------------------------------------------- +// A named walk of the flow, so each test can join it at the step it cares about +// --------------------------------------------------------------------------- + +interface Chain { + personToken: string + resourceToken: string + authToken: string +} + +async function getPersonToken( + options: { missionS256?: string; tenant?: string; agentUnder?: TestAgent } = {}, +): Promise { + const who = options.agentUnder ?? agent + const { personToken } = await requestPersonToken({ + signedFetch: who.psFetch, + personServerUrl: PS, + resource: RESOURCE, + ...(options.missionS256 ? { missionS256: options.missionS256 } : {}), + ...(options.tenant ? { tenant: options.tenant } : {}), }) - - it('round-trips interaction challenge', () => { - const header = buildAAuthHeader('interaction', { - url: 'https://auth.example/interact', - code: 'CODE1234', - }) - const parsed = parseAAuthHeader(header) - - expect(parsed.requirement).toBe('interaction') - expect(parsed.url).toBe('https://auth.example/interact') - expect(parsed.code).toBe('CODE1234') + return personToken +} + +/** Present a person token at the resource and take the resource token out of + * the `401 requirement=auth-token` challenge it answers with. */ +async function getResourceToken(personToken: string): Promise { + const challenged = await callResource(agent.presenting(personToken)) + expect(challenged.status).toBe(401) + return resourceTokenFrom(challenged.headers) +} + +async function getAuthToken(resourceToken: string): Promise { + const { authToken } = await exchangeToken({ + signedFetch: agent.psFetch, + authServerUrl: PS, + resourceToken, }) - - it('round-trips approval level', () => { - const header = buildAAuthHeader('approval') - const parsed = parseAAuthHeader(header) - expect(parsed.requirement).toBe('approval') + return authToken +} + +/** + * Redeem a resource token and capture the PS's refusal. + * + * `exchangeToken` now parses the error body on the direct path too, so the + * error code and explanation the PS sent are on the thrown + * `TokenExchangeError` — no wire-reading helper needed. + */ +async function redeemExpectingRefusal(resourceToken: string): Promise { + try { + await getAuthToken(resourceToken) + } catch (err) { + if (err instanceof TokenExchangeError) return err + throw err + } + throw new Error('expected the PS to refuse this resource token') +} + +/** + * A raw signed POST to a PS token endpoint, for the handful of assertions that + * have to see the wire. + * + * The endpoint is resolved from `/.well-known/aauth-person.json`, exactly as + * `@aauth/agent` resolves it. No test in this file hard-codes a token endpoint + * path: the metadata fields exist so a PS can move its endpoints, and mockin + * did — both now sit under a shared `/aauth/token/` prefix, with the bare + * `/aauth/token` answering 404 and naming the two real ones. + */ +async function psPost( + field: PsEndpointField, + body: Record, + fetchFn: FetchLike = agent.psFetch, +): Promise { + return fetchFn(await mockin.endpoint(field), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), }) +} + +async function walkTheChain( + options: { missionS256?: string; tenant?: string } = {}, +): Promise { + const personToken = await getPersonToken(options) + const resourceToken = await getResourceToken(personToken) + const authToken = await getAuthToken(resourceToken) + return { personToken, resourceToken, authToken } +} + +// =========================================================================== +// 1. The full chain, end to end +// =========================================================================== + +describe('the three-party flow, end to end', () => { + it('agent token -> person token -> resource token -> auth token -> 200', async () => { + // The agent has only its own identity. A resource that needs to know who + // the person is answers `requirement=person-token`. + const cold = await callResource(agent.resourceFetch) + expect(cold.status).toBe(401) + expect(requirementOf(cold.headers)?.requirement).toBe('person-token') + + // --- Person token, from the PS's person_token_endpoint --- + const personToken = await getPersonToken() + const person = claimsOf(personToken) + expect(headerOf(personToken)).toMatchObject({ typ: TOKEN_TYP.person, alg: SIGNING_ALG }) + expect(person).toMatchObject({ + iss: PS, + dwk: DWK.person, + aud: RESOURCE, + }) + expect(typeof person.sub).toBe('string') + expect(typeof person.jti).toBe('string') + // `cnf.jwk` is the agent's HTTP-signing key, so possession is provable. + expect((person.cnf as { jwk: { x: string } }).jwk.x) + .toBe((agent.signingKey.publicJwk as { x: string }).x) + // A person token conveys identity, never authorization. + expect(person.scope).toBeUndefined() + expect(person.account).toBeUndefined() + // exp <= 1 hour. + expect((person.exp as number) - (person.iat as number)).toBeLessThanOrEqual(3600) + + // --- Resource token, minted by @aauth/resource --- + const resourceToken = await getResourceToken(personToken) + const rt = claimsOf(resourceToken) + expect(headerOf(resourceToken)).toMatchObject({ typ: TOKEN_TYP.resource, alg: SIGNING_ALG }) + expect(rt).toMatchObject({ + iss: RESOURCE, + dwk: DWK.resource, + aud: PS, + // ps, sub and person_token_jti are copied from the person token — this is + // what lets the PS resolve which person token this resource verified. + ps: person.iss, + sub: person.sub, + person_token_jti: person.jti, + agent_jkt: agent.signingKey.thumbprint, + scope: 'read', + }) + // Removed in -11: no delegation claims anywhere in the chain. + expect(rt.agent).toBeUndefined() + expect(rt.mission).toBeUndefined() + expect(rt.approver).toBeUndefined() + // SHOULD NOT exceed 5 minutes. + expect((rt.exp as number) - (rt.iat as number)).toBeLessThanOrEqual(300) + + // --- Auth token, from the PS's auth_token_endpoint --- + const authToken = await getAuthToken(resourceToken) + const at = claimsOf(authToken) + expect(headerOf(authToken)).toMatchObject({ typ: TOKEN_TYP.auth, alg: SIGNING_ALG }) + expect(at).toMatchObject({ + iss: PS, + dwk: DWK.person, + aud: RESOURCE, + ps: PS, + // The PS's directed identifier for this person at this resource is the + // same value in both tokens. If these ever diverge, a resource cannot + // recognize a returning person. + sub: person.sub, + }) + expect(at.agent).toBeUndefined() + expect(at.act).toBeUndefined() + + // --- And the call the whole thing existed to make --- + const answered = await callResource(agent.presenting(authToken)) + expect(answered.status).toBe(200) + expect(answered.body).toMatchObject({ ok: true, ps: PS, sub: person.sub, scope: 'read' }) + }, 30_000) + + it('clamps the person token to the agent token that asked for it', async () => { + // §Person Token Structure: exp is not beyond the agent token presented at + // request time. A short-lived agent token must produce a short-lived + // person token even though the PS's own ceiling is an hour. + const shortLived = await createAgent({ router, personServer: PS, lifetimeSeconds: 120 }) + try { + mockin.trust(AGENT, shortLived.jwks) + await mockin.reset() + const personToken = await getPersonToken({ agentUnder: shortLived }) + const person = claimsOf(personToken) + const agentExp = claimsOf(shortLived.agentToken).exp as number + expect(person.exp as number).toBeLessThanOrEqual(agentExp) + expect((person.exp as number) - (person.iat as number)).toBeLessThanOrEqual(120) + } finally { + await shortLived.stop() + mockin.trust(AGENT, agent.jwks) + router.register(AGENT, agent.origin) + } + }, 30_000) + + it('rejects a resource token naming a person token this PS never issued', async () => { + // The jti store is what makes step 6 of §Resource Token Verification + // possible at all. Clearing it is the same as a PS restart. + const personToken = await getPersonToken() + resource.mint = { forgePersonTokenJti: '00000000-0000-0000-0000-000000000000' } + const resourceToken = await getResourceToken(personToken) + + const refused = await redeemExpectingRefusal(resourceToken) + expect(refused.status).toBe(400) + expect(refused.error).toBe('invalid_resource_token') + expect(refused.detail).toMatch(/names no person token/) + }, 30_000) }) -// ============================================================================= -// Suite 2: verifyToken with real tokens -// ============================================================================= +// =========================================================================== +// 2. The 202 deferred path — the common path, not an edge case +// =========================================================================== + +describe('deferred person tokens (202)', () => { + it('auto-approved: requestPersonToken polls the 202 through to a token', async () => { + // A PS defers on first contact with a resource the person has not used. + // `person_requirement` defers the *person* token independently of the auth + // token, which is the only way to exercise this path. + await mockin.configure({ person_requirement: 'interaction' }) + + const interactions: Array<{ url: string; code: string }> = [] + const { personToken } = await requestPersonToken({ + signedFetch: agent.psFetch, + personServerUrl: PS, + resource: RESOURCE, + onInteraction: (url, code) => { interactions.push({ url, code }) }, + }) -describe('verifyToken with real tokens', () => { - let keys: TestKeys - const originalFetch = globalThis.fetch + // The 202 carried the interaction url and code, and the agent surfaced them. + expect(interactions).toHaveLength(1) + expect(interactions[0].url).toBe(`${PS}/aauth/consent`) + expect(interactions[0].code).toMatch(/^[A-Za-z0-9]{8}$/) + + expect(headerOf(personToken)).toMatchObject({ typ: TOKEN_TYP.person }) + expect(claimsOf(personToken)).toMatchObject({ aud: RESOURCE }) + }, 60_000) + + it('the raw 202: Location, Retry-After and requirement=interaction', async () => { + // Asserted at the HTTP level, because everything above depends on the shape + // of this response and `@aauth/agent` hides it. + await mockin.configure({ person_requirement: 'interaction', auto_approve: false }) + + const deferred = await psPost('person_token_endpoint', { resource: RESOURCE }) + expect(deferred.status).toBe(202) + const location = deferred.headers.get('location') + expect(location).toMatch(new RegExp(`^${PS}/aauth/pending/`)) + expect(deferred.headers.get('retry-after')).toBe('0') + + const challenge = requirementOf(deferred.headers) + expect(challenge?.requirement).toBe('interaction') + expect(challenge?.url).toBe(`${PS}/aauth/consent`) + const code = challenge!.code! + + // Poll before consent: still pending. + const pending = await agent.psFetch(location!, { method: 'GET' }) + expect(pending.status).toBe(202) + expect(pending.headers.get('retry-after')).toBe('5') + + // The person approves in a browser, at the URL the 202 named. + const consented = await mockin.consent(code, challenge!.url!) + expect(consented.status).toBe(200) + + // Poll after consent: the token. + const done = await agent.psFetch(location!, { method: 'GET' }) + expect(done.status).toBe(200) + const body = await done.json() as { person_token: string; expires_in: number } + expect(headerOf(body.person_token)).toMatchObject({ typ: TOKEN_TYP.person }) + expect(body.expires_in).toBeGreaterThan(0) + }, 60_000) + + it('a real interaction: the agent polls 202 until the consent URL is visited', async () => { + await mockin.configure({ person_requirement: 'interaction', auto_approve: false }) + + // Count the polls, so a pass proves `pollDeferred` took its 202 branch and + // came back — not that consent happened to win a race. + let polls = 0 + const counting: typeof agent.psFetch = (url, init) => { + if ((init?.method ?? 'GET') === 'GET' && String(url).includes('/aauth/pending/')) polls++ + return agent.psFetch(url, init) + } - beforeEach(async () => { - keys = await createTestKeys() - clearMetadataCache() - }) + let consentedCode: string | undefined + const { personToken } = await requestPersonToken({ + signedFetch: counting, + personServerUrl: PS, + resource: RESOURCE, + // What an agent with the `interaction` capability does: open the URL. + // Delayed, so the first poll is guaranteed to find the request still + // pending — a person takes longer than a round trip. + onInteraction: (url, code) => { + consentedCode = code + setTimeout(() => { void mockin.consent(code, url) }, 250) + }, + }) - afterEach(() => { - globalThis.fetch = originalFetch - }) + expect(consentedCode).toMatch(/^[A-Za-z0-9]{8}$/) + expect(polls).toBeGreaterThanOrEqual(2) + expect(claimsOf(personToken)).toMatchObject({ iss: PS, aud: RESOURCE }) + + // And the deferred person token is a real one: it carries the whole chain. + const resourceToken = await getResourceToken(personToken) + const authToken = await getAuthToken(resourceToken) + const answered = await callResource(agent.presenting(authToken)) + expect(answered.status).toBe(200) + }, 60_000) + + it('the auth token endpoint defers the same way', async () => { + // `requirement` is the auth-token switch; `person_requirement` the person + // one. Both deferrals can be live at once, and each is polled at its own + // Location. + const personToken = await getPersonToken() + const resourceToken = await getResourceToken(personToken) + + await mockin.configure({ requirement: 'interaction', auto_approve: false }) + + let sawInteraction = false + const { authToken } = await exchangeToken({ + signedFetch: agent.psFetch, + authServerUrl: PS, + resourceToken, + onInteraction: (url, code) => { + sawInteraction = true + void mockin.consent(code, url) + }, + }) - it('verifies a real agent+jwt → VerifiedAgentToken', async () => { - const agentJwt = await createAgentJwt(keys, AGENT_URL, AGENT_ID) + expect(sawInteraction).toBe(true) + expect(headerOf(authToken)).toMatchObject({ typ: TOKEN_TYP.auth }) + expect(claimsOf(authToken)).toMatchObject({ aud: RESOURCE, ps: PS }) + }, 60_000) +}) - const server = createMockServer({ - keys, - resourceUrl: RESOURCE_URL, - authServerUrl: AUTH_SERVER_URL, - agentUrl: AGENT_URL, - sub: AGENT_ID, - }) - globalThis.fetch = server.globalFetch as typeof fetch +// =========================================================================== +// 3. Mission stripping, both directions +// =========================================================================== + +describe('mission_s256', () => { + const MISSION = 'q1nS8dQOgYpZ5m6cq0FzB3TnyeF3cO7t_v6i9Xw2r0k' + + it('survives person token -> resource token -> auth token unchanged', async () => { + // The honest case. Proves the fleet's own minting path — the agent naming + // the mission once at the person token endpoint, `@aauth/resource` copying + // it forward — produces something the PS accepts. + const chain = await walkTheChain({ missionS256: MISSION }) + + expect(claimsOf(chain.personToken).mission_s256).toBe(MISSION) + expect(claimsOf(chain.resourceToken).mission_s256).toBe(MISSION) + expect(claimsOf(chain.authToken).mission_s256).toBe(MISSION) + + const answered = await callResource(agent.presenting(chain.authToken)) + expect(answered.status).toBe(200) + expect(answered.body).toMatchObject({ mission_s256: MISSION }) + + // NOTE: this asserts equality and nothing more. mockin does not implement + // `mission_endpoint`, so it accepts any value as a mission hash: it never + // resolves a mission, never checks the mission is active, and never + // supplies an `expires_at`. §Resource Token Verification step 7, and every + // `expires_at` clamp in the fleet, remain unverified end to end. + }, 30_000) + + it('rejects a resource token that dropped the mission the person token carried', async () => { + // Stripping is the case that matters: a resource that quietly omits + // `mission_s256` would otherwise widen a mission-scoped grant into an + // unscoped one. + const personToken = await getPersonToken({ missionS256: MISSION }) + expect(claimsOf(personToken).mission_s256).toBe(MISSION) + + resource.mint = { stripMission: true } + const resourceToken = await getResourceToken(personToken) + expect(claimsOf(resourceToken).mission_s256).toBeUndefined() + + const refused = await redeemExpectingRefusal(resourceToken) + expect(refused.status).toBe(400) + expect(refused.error).toBe('invalid_resource_token') + // The direction is in the message: the person token had it, the resource + // token does not. + expect(refused.detail) + .toMatch(/mission_s256 mismatch: person token has .+, resource_token has \(none\)/) + }, 30_000) + + it('rejects a resource token that invented a mission the person token did not carry', async () => { + const personToken = await getPersonToken() + expect(claimsOf(personToken).mission_s256).toBeUndefined() + + resource.mint = { inventMission: MISSION } + const resourceToken = await getResourceToken(personToken) + expect(claimsOf(resourceToken).mission_s256).toBe(MISSION) + + const refused = await redeemExpectingRefusal(resourceToken) + expect(refused.status).toBe(400) + expect(refused.detail) + .toMatch(/mission_s256 mismatch: person token has \(none\), resource_token has /) + }, 30_000) +}) - const result = await verifyToken({ - jwt: agentJwt, - httpSignatureThumbprint: keys.agentEphemeral.thumbprint, - }) +// =========================================================================== +// 4. tenant copy-through +// =========================================================================== + +describe('tenant', () => { + const TENANT = 'acme-corp' + + it('the agent names the tenant in the person token request', async () => { + // AAuth issue #88: nothing otherwise selects which tenant a person token + // carries when a person holds a personal context plus several managed ones. + // The `tenant` request parameter is the resolution, and it is the agent + // that sends it — the PS cannot guess. + const personToken = await getPersonToken({ tenant: TENANT }) + expect(claimsOf(personToken).tenant).toBe(TENANT) + + // And a different value comes back, so this is the parameter deciding it + // and not a fixed server-side default. + await mockin.reset() + const other = await getPersonToken({ tenant: 'globex' }) + expect(claimsOf(other).tenant).toBe('globex') + }, 30_000) + + it('survives person token -> resource token -> auth token', async () => { + const chain = await walkTheChain({ tenant: TENANT }) + + expect(claimsOf(chain.personToken).tenant).toBe(TENANT) + expect(claimsOf(chain.resourceToken).tenant).toBe(TENANT) + expect(claimsOf(chain.authToken).tenant).toBe(TENANT) + + const answered = await callResource(agent.presenting(chain.authToken)) + expect(answered.status).toBe(200) + expect(answered.body).toMatchObject({ tenant: TENANT }) + }, 30_000) + + it('fails the exchange, not just the hint, when the resource omits it', async () => { + // Three resource-side branches in the fleet omitted this. Step 6 rejects on + // mismatch *or omission*, so for a tenant-bearing person it kills the + // exchange outright. + const personToken = await getPersonToken({ tenant: TENANT }) + expect(claimsOf(personToken).tenant).toBe(TENANT) + + resource.mint = { stripTenant: true } + const resourceToken = await getResourceToken(personToken) + expect(claimsOf(resourceToken).tenant).toBeUndefined() + + const refused = await redeemExpectingRefusal(resourceToken) + expect(refused.status).toBe(400) + expect(refused.error).toBe('invalid_resource_token') + expect(refused.detail) + .toMatch(/tenant mismatch: person token has acme-corp, resource_token has \(none\)/) + }, 30_000) + + it('rejects a resource token that changed the tenant', async () => { + const personToken = await getPersonToken({ tenant: TENANT }) + resource.mint = { overrideTenant: 'other-corp' } + const resourceToken = await getResourceToken(personToken) + expect(claimsOf(resourceToken).tenant).toBe('other-corp') + + const refused = await redeemExpectingRefusal(resourceToken) + expect(refused.status).toBe(400) + expect(refused.detail) + .toMatch(/tenant mismatch: person token has acme-corp, resource_token has other-corp/) + }, 30_000) +}) - expect(result.type).toBe('agent') - const agent = result as VerifiedAgentToken - expect(agent.iss).toBe(AGENT_URL) - expect(agent.dwk).toBe('aauth-agent.json') - expect(agent.sub).toBe(AGENT_ID) - expect(agent.cnf.jwk).toEqual(keys.agentEphemeral.pubJwk) - expect(agent.iat).toBeTypeOf('number') - expect(agent.exp).toBeTypeOf('number') - }) +// =========================================================================== +// 5. `typ` discrimination — a person token is not authorization +// =========================================================================== + +describe('typ discrimination', () => { + it('rejects an aa-person+jwt where an auth token is required, as 401 not 403', async () => { + // A person token and a PS-issued auth token share iss, dwk, aud, sub and + // cnf. They differ only in `typ`. Without the check, a person token passes + // signature, iss, aud and cnf verification and lands on the scope gate as + // 403 insufficient_scope — a wrong-credential problem reported as a + // permissions problem. + const personToken = await getPersonToken() + + resource.accept = ['auth'] + resource.scopeGateReached = false + + const rejected = await callResource(agent.presenting(personToken)) + + expect(rejected.status).toBe(401) + expect(rejected.body).toMatchObject({ error: 'token_type_not_accepted' }) + expect(rejected.status).not.toBe(403) + // The decisive assertion: it never reached the scope check. + expect(resource.scopeGateReached).toBe(false) + }, 30_000) + + it('the same person token is accepted where a person token is what is wanted', async () => { + const personToken = await getPersonToken() + resource.accept = ['person'] + const challenged = await callResource(agent.presenting(personToken)) + expect(challenged.status).toBe(401) + expect(requirementOf(challenged.headers)?.requirement).toBe('auth-token') + }, 30_000) + + it('a real auth token is refused by a call site that only takes person tokens', async () => { + const chain = await walkTheChain() + resource.accept = ['person'] + const rejected = await callResource(agent.presenting(chain.authToken)) + expect(rejected.status).toBe(401) + expect(rejected.body).toMatchObject({ error: 'token_type_not_accepted' }) + }, 30_000) +}) - it('verifies a real auth+jwt → VerifiedAuthToken', async () => { - const authJwt = await createAuthJwt(keys, { - iss: AUTH_SERVER_URL, - aud: RESOURCE_URL, - agent: AGENT_URL, - sub: 'user-456', - scope: 'files.read', - }) +// =========================================================================== +// 6. planAccessMode against a resource that actually declares each mode +// =========================================================================== + +describe('planAccessMode', () => { + const MODES: KnownAccessMode[] = [ + 'agent-token', 'person-token', 'session-token', 'auth-token', 'per-call', + ] + /** Decided in the package contract, not a judgement call. */ + const UNSATISFIABLE_WITHOUT_PS = new Set(['person-token', 'auth-token', 'per-call']) + + async function declaredMode(): Promise { + const res = await router.fetch(`${RESOURCE}/.well-known/aauth-resource.json`) + const metadata = await res.json() as { access_mode?: string } + return metadata.access_mode + } - const server = createMockServer({ - keys, - resourceUrl: RESOURCE_URL, - authServerUrl: AUTH_SERVER_URL, - agentUrl: AGENT_URL, - sub: AGENT_ID, + for (const mode of MODES) { + it(`${mode}: satisfiable with a person server`, async () => { + resource.accessMode = mode + const plan = planAccessMode(await declaredMode(), { hasPersonServer: true }) + expect(plan).toEqual({ kind: 'satisfiable', mode }) }) - globalThis.fetch = server.globalFetch as typeof fetch - const result = await verifyToken({ - jwt: authJwt, - httpSignatureThumbprint: keys.agentEphemeral.thumbprint, + it(`${mode}: ${UNSATISFIABLE_WITHOUT_PS.has(mode) ? 'unsatisfiable' : 'satisfiable'} without one`, async () => { + resource.accessMode = mode + const plan = planAccessMode(await declaredMode(), { hasPersonServer: false }) + if (UNSATISFIABLE_WITHOUT_PS.has(mode)) { + expect(plan.kind).toBe('unsatisfiable') + expect((plan as { mode: string }).mode).toBe(mode) + expect((plan as { reason: string }).reason).toContain('person server') + } else { + expect(plan).toEqual({ kind: 'satisfiable', mode }) + } }) + } - expect(result.type).toBe('auth') - const auth = result as VerifiedAuthToken - expect(auth.iss).toBe(AUTH_SERVER_URL) - expect(auth.dwk).toBe('aauth-person.json') - expect(auth.aud).toBe(RESOURCE_URL) - expect(auth.agent).toBe(AGENT_URL) - expect(auth.sub).toBe('user-456') - expect(auth.scope).toBe('files.read') + it('a resource declaring nothing is undeclared, never an error', async () => { + resource.accessMode = undefined + expect(await declaredMode()).toBeUndefined() + expect(planAccessMode(await declaredMode(), { hasPersonServer: false })) + .toEqual({ kind: 'undeclared' }) }) - it('throws key_binding_failed on thumbprint mismatch', async () => { - const agentJwt = await createAgentJwt(keys, AGENT_URL, AGENT_ID) - - const wrongKey = await generateKeyPair('EdDSA', { crv: 'Ed25519' }) - const wrongPubJwk = await exportJWK(wrongKey.publicKey) - const wrongThumbprint = await calculateJwkThumbprint(wrongPubJwk, 'sha256') + it('an unrecognized value is undeclared — call the resource and read the requirement', async () => { + resource.accessMode = 'mode-invented-after-this-release' + expect(planAccessMode(await declaredMode(), { hasPersonServer: true })) + .toEqual({ kind: 'undeclared' }) + }) - const server = createMockServer({ - keys, - resourceUrl: RESOURCE_URL, - authServerUrl: AUTH_SERVER_URL, - agentUrl: AGENT_URL, - sub: AGENT_ID, - }) - globalThis.fetch = server.globalFetch as typeof fetch + it("the ps-less agent's own token is what makes hasPersonServer false", async () => { + expect(claimsOf(agent.agentToken).ps).toBe(PS) + expect(claimsOf(psLessAgent.agentToken).ps).toBeUndefined() - await expect( - verifyToken({ jwt: agentJwt, httpSignatureThumbprint: wrongThumbprint }), - ).rejects.toThrow('cnf.jwk thumbprint does not match') + resource.accessMode = 'auth-token' + const declared = await declaredMode() + const setup = { hasPersonServer: claimsOf(psLessAgent.agentToken).ps !== undefined } + expect(planAccessMode(declared, setup).kind).toBe('unsatisfiable') }) }) -// ============================================================================= -// Suite 3: Full 401 challenge-response (direct grant) -// ============================================================================= - -describe('Full 401 challenge-response (direct grant)', () => { - let keys: TestKeys - const originalFetch = globalThis.fetch - - beforeEach(async () => { - keys = await createTestKeys() - clearMetadataCache() - vi.clearAllMocks() +// =========================================================================== +// 7. Ed25519 emitted, EdDSA rejected, on every token type at both ends +// =========================================================================== + +describe('signature algorithms', () => { + it('every token in the chain is emitted with the fully-specified Ed25519', async () => { + const chain = await walkTheChain() + for (const [name, jwt] of Object.entries({ + 'agent token': agent.agentToken, + 'person token': chain.personToken, + 'resource token': chain.resourceToken, + 'auth token': chain.authToken, + })) { + expect(headerOf(jwt).alg, name).toBe('Ed25519') + expect(headerOf(jwt).alg, name).not.toBe('EdDSA') + } + }, 30_000) + + it('every published key carries alg: Ed25519', async () => { + // `jwks_uri` from the metadata document, not a guessed path. + const psJwksUri = (await mockin.metadata()).jwks_uri as string + const psJwks = await (await router.fetch(psJwksUri)).json() as { keys: Array<{ alg: string }> } + const rsJwks = await (await router.fetch(`${RESOURCE}/jwks.json`)).json() as { keys: Array<{ alg: string }> } + const agentJwks = await (await router.fetch(`${AGENT}/jwks.json`)).json() as { keys: Array<{ alg: string }> } + for (const jwks of [psJwks, rsJwks, agentJwks]) { + expect(jwks.keys.length).toBeGreaterThan(0) + for (const key of jwks.keys) expect(key.alg).toBe('Ed25519') + } }) - afterEach(() => { - globalThis.fetch = originalFetch - }) + it('cnf.jwk carries alg: Ed25519 in the person and auth tokens', async () => { + const chain = await walkTheChain() + for (const jwt of [chain.personToken, chain.authToken]) { + const cnf = claimsOf(jwt).cnf as { jwk: { alg: string; crv: string } } + expect(cnf.jwk.alg).toBe('Ed25519') + expect(cnf.jwk.crv).toBe('Ed25519') + } + }, 30_000) + + it('the PS rejects an agent token signed EdDSA', async () => { + const eddsaAgent = await createAgent({ router, personServer: PS, alg: 'EdDSA' }) + try { + mockin.trust(AGENT, eddsaAgent.jwks) + await mockin.reset() + let refused: PersonTokenError | undefined + try { + await getPersonToken({ agentUnder: eddsaAgent }) + } catch (err) { + refused = err as PersonTokenError + } + expect(refused).toBeInstanceOf(PersonTokenError) + expect(refused!.status).toBe(401) + expect(refused!.error).toBe('invalid_jwt') + expect(refused!.detail).toMatch(/EdDSA/) + expect(refused!.detail).toMatch(/Ed25519 required/) + } finally { + await eddsaAgent.stop() + mockin.trust(AGENT, agent.jwks) + router.register(AGENT, agent.origin) + } + }, 30_000) + + it('the PS rejects a resource token signed EdDSA', async () => { + const personToken = await getPersonToken() + resource.mint = { alg: 'EdDSA' } + const resourceToken = await getResourceToken(personToken) + expect(headerOf(resourceToken).alg).toBe('EdDSA') + + const refused = await redeemExpectingRefusal(resourceToken) + expect(refused.status).toBe(400) + expect(refused.error).toBe('invalid_resource_token') + expect(refused.detail).toMatch(/EdDSA/) + expect(refused.detail).toMatch(/Ed25519 required/) + }, 30_000) + + it('the resource rejects an EdDSA-signed token of every kind', async () => { + // mockin never emits EdDSA, so the resource-side half needs forged tokens. + // `verifyToken` refuses the polymorphic identifier before it fetches + // anything, on all three typ values. + const now = Math.floor(Date.now() / 1000) + const common = { + iss: PS, + cnf: { jwk: agent.signingKey.publicJwk }, + iat: now, + exp: now + 300, + } + const forged = { + [TOKEN_TYP.person]: await forgeToken( + agent.identityKey, { alg: 'EdDSA', typ: TOKEN_TYP.person }, + { ...common, dwk: DWK.person, aud: RESOURCE, sub: 'sub-1', jti: 'jti-1' }, + ), + [TOKEN_TYP.auth]: await forgeToken( + agent.identityKey, { alg: 'EdDSA', typ: TOKEN_TYP.auth }, + { ...common, dwk: DWK.person, aud: RESOURCE, sub: 'sub-1', ps: PS }, + ), + [TOKEN_TYP.agent]: await forgeToken( + agent.identityKey, { alg: 'EdDSA', typ: TOKEN_TYP.agent }, + { ...common, iss: AGENT, dwk: DWK.agent, sub: AGENT_ID }, + ), + } - it('agent request → 401 → exchangeToken → auth server creates auth+jwt → retry → 200', async () => { - const agentJwt = await createAgentJwt(keys, AGENT_URL, AGENT_ID) - const getKeyMaterial = createGetKeyMaterial(keys, agentJwt) - - const server = createMockServer({ - keys, - resourceUrl: RESOURCE_URL, - authServerUrl: AUTH_SERVER_URL, - agentUrl: AGENT_URL, - sub: AGENT_ID, - requireAuthToken: true, - }) + for (const [typ, jwt] of Object.entries(forged)) { + const rejected = await callResource(agent.presenting(jwt)) + expect(rejected.status, typ).toBe(401) + expect(String((rejected.body as { error_description: string }).error_description), typ) + .toMatch(/EdDSA/) + } + }, 30_000) +}) - // Wire mocks - mockHttpSigFetch.mockImplementation(server.httpSigFetch) - globalThis.fetch = server.globalFetch as typeof fetch +// =========================================================================== +// 8. content-digest and content-type on bodied PS requests +// =========================================================================== + +describe('body signing toward the PS', () => { + it('the agent covers content-digest and content-type by default', async () => { + // §Covered Components: a bodied request to a PS or AS MUST additionally + // sign `content-digest` and `content-type`. `createSignedFetch(..., + // { signBody: true })` is what the -11 agent uses toward a PS. + expect(PS_COMPONENTS_BODY).toContain('content-digest') + expect(PS_COMPONENTS_BODY).toContain('content-type') + + let sent: Record | undefined + const observed = router.route(createSignedFetch(agent.keyMaterial, { + signBody: true, + onSigned: s => { sent = s.headers }, + })) + + const res = await psPost('person_token_endpoint', { resource: RESOURCE }, observed) + + expect(res.status).toBe(200) + expect(sent!['content-digest']).toMatch(/^sha-256=:.+:$/) + expect(sent!['signature-input']).toContain('"content-digest"') + expect(sent!['signature-input']).toContain('"content-type"') + }, 30_000) + + it('the PS rejects a bodied request whose signature does not cover them', async () => { + // httpsig 2.2.0's 'auto' appends content-digest for any digestible body, + // so a merely-forgetful client no longer exists; simulating a + // non-conforming signer now takes an explicit contentDigest: 'omit'. + // The PS-side rejection is still a real rejection, not a warning. + const unsigned = router.route(createSignedFetch(agent.keyMaterial, { contentDigest: 'omit' })) + const res = await psPost('person_token_endpoint', { resource: RESOURCE }, unsigned) + + expect(res.status).toBe(401) + expect(await res.json()).toMatchObject({ error: 'signature_verification_failed' }) + expect(res.headers.get('signature-error')).toContain('content-digest') + expect(res.headers.get('accept-signature')).toContain('content-digest') + }, 30_000) + + it('and accepts the same request once require_body_signing is off', async () => { + // Proves the previous rejection came from the body-signing rule and nothing + // else: one switch, same request, different answer. + await mockin.configure({ require_body_signing: false }) + + const unsigned = router.route(createSignedFetch(agent.keyMaterial)) + const res = await psPost('person_token_endpoint', { resource: RESOURCE }, unsigned) + + expect(res.status).toBe(200) + const body = await res.json() as { person_token: string } + expect(headerOf(body.person_token)).toMatchObject({ typ: TOKEN_TYP.person }) + }, 30_000) + + it('the auth token endpoint requires it too', async () => { + const personToken = await getPersonToken() + const resourceToken = await getResourceToken(personToken) + + const unsigned = router.route(createSignedFetch(agent.keyMaterial, { contentDigest: 'omit' })) + const res = await psPost('auth_token_endpoint', { resource_token: resourceToken }, unsigned) + + expect(res.status).toBe(401) + expect(res.headers.get('signature-error')).toContain('content-digest') + }, 30_000) +}) - const aAuthFetch = createAAuthFetch({ - getKeyMaterial, - authServerUrl: AUTH_SERVER_URL, +// =========================================================================== +// 9. R3 — resource request records +// +// WHAT MOCKIN CANNOT PROVE HERE, stated once so no assertion below implies it: +// +// * **mockin never routes an operation to `r3_per_call` on its own.** +// `autoGrantR3` grants the whole document, every time; there is no +// classifier, no risk heuristic, no consent screen. The only way an +// `r3_per_call` claim exists is the `r3_grants` mock switch, which +// replaces the grant wholesale with whatever object you hand it. So the +// tests below use that switch to *stand in for the person's decision*, and +// prove the resource and agent halves of the per-call round trip. Whether +// a PS classifies correctly is untested and untestable here. +// * **mockin does not link a proposal to a prior class grant.** It does not +// remember the first R3 document, does not require the proposal's +// operations to be a subset of what was granted, and does not connect the +// two `POST /aauth/token` calls in any way. "You may only propose what you +// were granted in principle" is therefore unverified end to end. +// * **There is no proposal approval endpoint.** `POST /aauth/pending/:id` +// accepts an `updated_resource_token` and never reads it. +// * `r3_operations` is a **resource-facing** request member, not a PS one. +// mockin has no such parameter; R3 reaches the PS only as the resource +// token's `r3_uri` / `r3_s256`. +// =========================================================================== + +describe('R3', () => { + const CLASS_OPERATIONS = [{ operationId: 'listMessages' }, PER_CALL_OPERATION] + + /** The agent's R3 authorization request — the body `@aauth/fetch + * --operations` sends to a resource's authorize endpoint. */ + async function authorize(personToken: string, account?: string) { + const res = await agent.presenting(personToken)(`${RESOURCE}/authorize`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + r3_operations: { vocabulary: R3_VOCABULARY, operations: CLASS_OPERATIONS }, + ...(account ? { account } : {}), + }), }) - const result = await aAuthFetch(`${RESOURCE_URL}/api/data`) - - expect(result.status).toBe(200) - const body = await result.json() - expect(body.status).toBe('ok') - expect(body.user).toBe('user-123') - - // httpSigFetch should have been called multiple times: - // 1. initial request to resource (→ 401) - // 2. metadata fetch to auth server - // 3. token POST to auth server - // 4. retry to resource with auth token (→ 200) - expect(mockHttpSigFetch.mock.calls.length).toBeGreaterThanOrEqual(4) - }) + expect(res.status).toBe(200) + return await res.json() as { resource_token: string; r3_uri: string; r3_s256: string } + } - it('second request reuses cached token, no re-exchange', async () => { - const agentJwt = await createAgentJwt(keys, AGENT_URL, AGENT_ID) - const getKeyMaterial = createGetKeyMaterial(keys, agentJwt) - - const server = createMockServer({ - keys, - resourceUrl: RESOURCE_URL, - authServerUrl: AUTH_SERVER_URL, - agentUrl: AGENT_URL, - sub: AGENT_ID, - requireAuthToken: true, + async function invoke( + authToken: string, + operation: unknown, + parameters: Record, + ) { + const res = await agent.presenting(authToken)(`${RESOURCE}/invoke`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ operation, parameters }), }) + const text = await res.text() + let body: unknown = text + try { body = JSON.parse(text) } catch { /* keep the text */ } + return { status: res.status, headers: res.headers, body } + } - mockHttpSigFetch.mockImplementation(server.httpSigFetch) - globalThis.fetch = server.globalFetch as typeof fetch + it('r3_operations -> resource token with r3_uri/r3_s256 -> the PS fetches it -> r3_granted', async () => { + const personToken = await getPersonToken() + const authorized = await authorize(personToken) - const aAuthFetch = createAAuthFetch({ - getKeyMaterial, - authServerUrl: AUTH_SERVER_URL, - }) + // The resource token references the document; it never carries it. + const rt = claimsOf(authorized.resource_token) + expect(rt.r3_uri).toBe(authorized.r3_uri) + expect(rt.r3_s256).toBe(authorized.r3_s256) + expect(rt.operations).toBeUndefined() + expect(rt.vocabulary).toBeUndefined() - // First request — full challenge-response - const result1 = await aAuthFetch(`${RESOURCE_URL}/api/data`) - expect(result1.status).toBe(200) + // The PS has not seen the document yet. + expect(resource.r3Served).toHaveLength(0) - const callCountAfterFirst = mockHttpSigFetch.mock.calls.length + const authToken = await getAuthToken(authorized.resource_token) - // Second request — should reuse cached auth token - const result2 = await aAuthFetch(`${RESOURCE_URL}/api/other`) - expect(result2.status).toBe(200) + // It fetched it — over a signed request it had to be entitled to make. + expect(resource.r3Served).toHaveLength(1) + expect(await computeR3Hash(resource.r3Served[0])).toBe(authorized.r3_s256) - // Second request should only need 1 call (the resource request with cached token) - const callCountAfterSecond = mockHttpSigFetch.mock.calls.length - expect(callCountAfterSecond - callCountAfterFirst).toBe(1) - }) - - it('justification and hints pass through to token endpoint body', async () => { - const agentJwt = await createAgentJwt(keys, AGENT_URL, AGENT_ID) - const getKeyMaterial = createGetKeyMaterial(keys, agentJwt) - - let capturedBody: Record | undefined - const server = createMockServer({ - keys, - resourceUrl: RESOURCE_URL, - authServerUrl: AUTH_SERVER_URL, - agentUrl: AGENT_URL, - sub: AGENT_ID, - requireAuthToken: true, - onTokenRequest: (body) => { capturedBody = body }, + const at = claimsOf(authToken) + expect(at.r3_uri).toBe(authorized.r3_uri) + expect(at.r3_s256).toBe(authorized.r3_s256) + expect(at.r3_granted).toEqual({ + vocabulary: R3_VOCABULARY, + operations: CLASS_OPERATIONS, }) - mockHttpSigFetch.mockImplementation(server.httpSigFetch) - globalThis.fetch = server.globalFetch as typeof fetch - - const aAuthFetch = createAAuthFetch({ - getKeyMaterial, - authServerUrl: AUTH_SERVER_URL, - justification: 'read user files', - loginHint: 'alice@acme.com', - tenant: 'acme.com', - domainHint: 'acme.com', + // A granted operation runs. + const invoked = await invoke(authToken, CLASS_OPERATIONS[0], {}) + expect(invoked.status).toBe(200) + expect(invoked.body).toMatchObject({ via: 'r3_granted' }) + }, 30_000) + + it('the account of the authorization request reaches the document and the token', async () => { + const personToken = await getPersonToken() + const authorized = await authorize(personToken, 'work@example.com') + expect(claimsOf(authorized.resource_token).account).toBe('work@example.com') + + await getAuthToken(authorized.resource_token) + const served = JSON.parse(resource.r3Served[0]) as { account?: string } + expect(served.account).toBe('work@example.com') + }, 30_000) + + it('rejects a resource token carrying r3_uri without r3_s256', async () => { + // Both or neither. One without the other is a document nobody can pin. + const personToken = await getPersonToken() + const authorized = await authorize(personToken) + // Minted by hand: `createResourceToken` refuses to emit one without the + // other, so only a resource that bypassed the package can produce this. + const { r3_s256: _dropped, ...claims } = claimsOf(authorized.resource_token) + const half = await forgeToken( + resource.signingKey, { typ: TOKEN_TYP.resource }, claims, + ) + + const refused = await redeemExpectingRefusal(half) + expect(refused.error).toBe('invalid_resource_token') + expect(refused.detail).toMatch(/both r3_uri and r3_s256 or neither/) + }, 30_000) + + // ------------------------------------------------------------------------- + // Byte-stable serving + // ------------------------------------------------------------------------- + + it('serves identical bytes on every fetch, and they hash to the r3_s256 in the token', async () => { + // The whole scheme rests on this. A resource that parses its stored + // document and re-stringifies it on the way out changes key order or + // whitespace, the hash stops matching, and every exchange fails with an + // error that names neither cause. + const personToken = await getPersonToken() + const authorized = await authorize(personToken) + + // mockin does not cache R3 documents — it re-fetches on every exchange, so + // two exchanges are two real fetches of the same URI. + await getAuthToken(authorized.resource_token) + const second = await authorize(personToken) + expect(second.r3_uri).toBe(authorized.r3_uri) // content-addressed + expect(second.r3_s256).toBe(authorized.r3_s256) + await getAuthToken(second.resource_token) + + expect(resource.r3Served).toHaveLength(2) + expect(resource.r3Served[0]).toBe(resource.r3Served[1]) + for (const body of resource.r3Served) { + expect(await computeR3Hash(body)).toBe(authorized.r3_s256) + } + }, 30_000) + + it('a document whose bytes changed under its URI is refused by the PS', async () => { + // The negative of the above: if re-serialization ever did change the bytes, + // this is the failure it produces. Simulated by mutating the store, because + // `@aauth/resource` has no code path that re-serializes. + const personToken = await getPersonToken() + const authorized = await authorize(personToken) + await resource.tamperR3(authorized.r3_uri, '{"vocabulary":"urn:aauth:vocabulary:openapi","operations":[{"operationId":"listMessages"}]}') + + const refused = await redeemExpectingRefusal(authorized.resource_token) + expect(refused.error).toBe('invalid_resource_token') + expect(refused.detail).toMatch(/r3_s256 mismatch/) + }, 30_000) + + // ------------------------------------------------------------------------- + // Fetch authorization — §R3 Document Access Restriction + // ------------------------------------------------------------------------- + + it('refuses an agent: it authenticates, but not as a server', async () => { + // An agent presents `Signature-Key: sig=jwt`. That proves which agent it + // is, and an agent is never an entitled fetcher — the document describes + // what the *person* is being asked to authorize, and the agent must not be + // able to read it. There is no server identifier in a `sig=jwt` + // presentation at all, so the check has nothing to compare and refuses. + const personToken = await getPersonToken() + const authorized = await authorize(personToken) + + const res = await agent.resourceFetch(authorized.r3_uri, { method: 'GET' }) + expect(res.status).toBe(401) + expect(await res.json()).toEqual({ error: 'signature_required' }) + expect(resource.r3Served).toHaveLength(0) + }, 30_000) + + it('refuses a server that authenticates correctly but is not the entitled one', async () => { + // The signature verifies, the key resolves at + // `{id}/.well-known/{dwk}`, and `id` is a real server identifier — it is + // just not the `aud` of the resource token, nor the agent's PS. 403. + const personToken = await getPersonToken() + const authorized = await authorize(personToken) + + const intruder = serverSignedFetch(router, agent.identityKey, AGENT, 'aauth-agent.json') + const res = await intruder(authorized.r3_uri, { method: 'GET' }) + expect(res.status).toBe(403) + expect(await res.json()).toEqual({ error: 'forbidden' }) + expect(resource.r3Served).toHaveLength(0) + }, 30_000) + + it('an unsigned fetch gets nothing', async () => { + const personToken = await getPersonToken() + const authorized = await authorize(personToken) + const res = await router.fetch(authorized.r3_uri) + expect(res.status).toBe(401) + expect(resource.r3Served).toHaveLength(0) + }, 30_000) + + // ------------------------------------------------------------------------- + // Per-call proposals + // ------------------------------------------------------------------------- + + /** + * Walk the per-call round trip and return everything a test needs to assert. + * + * `r3_grants` stands in for the person's decision at both steps: first to put + * `sendMessage` in `r3_per_call` rather than `r3_granted`, then to approve + * the specific proposal. mockin has no consent screen for either. + */ + async function perCallRoundTrip(parameters: Record) { + const personToken = await getPersonToken() + const authorized = await authorize(personToken) + + await mockin.configure({ + r3_grants: { + granted: { vocabulary: R3_VOCABULARY, operations: [CLASS_OPERATIONS[0]] }, + per_call: { vocabulary: R3_VOCABULARY, operations: [PER_CALL_OPERATION] }, + }, }) - await aAuthFetch(`${RESOURCE_URL}/api/data`) - - expect(capturedBody).toBeDefined() - expect(capturedBody!.resource_token).toBeDefined() - expect(capturedBody!.justification).toBe('read user files') - expect(capturedBody!.login_hint).toBe('alice@acme.com') - expect(capturedBody!.tenant).toBe('acme.com') - expect(capturedBody!.domain_hint).toBe('acme.com') - }) -}) - -// ============================================================================= -// Suite 4: Deferred/interaction grant -// ============================================================================= + const classToken = await getAuthToken(authorized.resource_token) + expect(claimsOf(classToken).r3_per_call) + .toEqual({ vocabulary: R3_VOCABULARY, operations: [PER_CALL_OPERATION] }) -describe('Deferred/interaction grant', () => { - let keys: TestKeys - const originalFetch = globalThis.fetch + // Invoking the per-call operation produces a proposal, not a result. + const challenged = await invoke(classToken, PER_CALL_OPERATION, parameters) + expect(challenged.status).toBe(401) + const proposalToken = resourceTokenFrom(challenged.headers) - beforeEach(async () => { - keys = await createTestKeys() - clearMetadataCache() - vi.clearAllMocks() - }) - - afterEach(() => { - globalThis.fetch = originalFetch - }) + // The person approves this specific call. + await mockin.configure({ r3_grants: null }) + const perCallToken = await getAuthToken(proposalToken) - it('token endpoint returns 202 → onInteraction receives url and code → resolve → poll gets 200 → retry succeeds', async () => { - const agentJwt = await createAgentJwt(keys, AGENT_URL, AGENT_ID) - const getKeyMaterial = createGetKeyMaterial(keys, agentJwt) + return { authorized, classToken, proposalToken, perCallToken } + } - const interactionManager = new InteractionManager({ - baseUrl: AUTH_SERVER_URL, - interactionUrl: INTERACTION_URL, - }) - const server = createMockServer({ - keys, - resourceUrl: RESOURCE_URL, - authServerUrl: AUTH_SERVER_URL, - agentUrl: AGENT_URL, - sub: AGENT_ID, - requireAuthToken: true, - deferredMode: true, - interactionManager, + it('per-call: proposal -> approval -> retry, and the resource enforces the parameters', async () => { + const parameters = { to: 'alice@example.com', subject: 'Q3 numbers' } + const { proposalToken, perCallToken } = await perCallRoundTrip(parameters) + + // The proposal is a full R3 document scoped to this one call. + const proposal = resource.lastProposal! + expect(proposal.document.operations).toEqual([PER_CALL_OPERATION]) + expect(proposal.document.parameters).toEqual(parameters) + + // The resource token references it. It does not carry the parameters. + const rt = claimsOf(proposalToken) + expect(rt.r3_uri).toBe(proposal.r3_uri) + expect(rt.r3_s256).toBe(proposal.r3_s256) + expect(JSON.stringify(rt)).not.toContain('alice@example.com') + + // Neither does the auth token: the PS saw the parameters in the document it + // fetched, and put only the operation in the grant. + const at = claimsOf(perCallToken) + expect(at.r3_s256).toBe(proposal.r3_s256) + expect(JSON.stringify(at)).not.toContain('alice@example.com') + expect((at.r3_granted as { operations: unknown[] }).operations).toEqual([PER_CALL_OPERATION]) + + // The retry with the approved parameters succeeds. + const done = await invoke(perCallToken, PER_CALL_OPERATION, parameters) + expect(done.status).toBe(200) + expect(done.body).toMatchObject({ approved: parameters }) + }, 60_000) + + it('per-call: an approval for one recipient is not replayable against another', async () => { + const { perCallToken } = await perCallRoundTrip({ + to: 'alice@example.com', + subject: 'Q3 numbers', }) - mockHttpSigFetch.mockImplementation(server.httpSigFetch) - globalThis.fetch = server.globalFetch as typeof fetch - - let receivedUrl: string | undefined - let receivedCode: string | undefined - const onInteraction = (url: string, code: string) => { - receivedUrl = url - receivedCode = code - - // Simulate external resolution: resolve the pending request - // after a short delay to allow the poll to start - setTimeout(async () => { - // Create an auth token for resolution - const authJwt = await createAuthJwt(keys, { - iss: AUTH_SERVER_URL, - aud: RESOURCE_URL, - agent: AGENT_URL, - sub: 'user-deferred', - }) - - // Find calls to /pending/ to get the ID - const pendingCalls = mockHttpSigFetch.mock.calls.filter( - (call: unknown[]) => String(call[0]).includes('/pending/'), - ) - if (pendingCalls.length > 0) { - const pendingUrl = String(pendingCalls[0][0]) - const pendingId = pendingUrl.split('/pending/')[1] - interactionManager.resolve(pendingId, { auth_token: authJwt, expires_in: 3600 }) - } - }, 200) - } - - const aAuthFetch = createAAuthFetch({ - getKeyMaterial, - authServerUrl: AUTH_SERVER_URL, - onInteraction, + const replayed = await invoke(perCallToken, PER_CALL_OPERATION, { + to: 'attacker@example.com', + subject: 'Q3 numbers', }) - - const result = await aAuthFetch(`${RESOURCE_URL}/api/data`) - - expect(result.status).toBe(200) - expect(receivedUrl).toBe(INTERACTION_URL) - expect(receivedCode).toBeDefined() - expect(receivedCode!.length).toBeGreaterThan(0) - }) - - it('InteractionManager createPending builds correct AAuth-Requirement header with url and code', () => { - const manager = new InteractionManager({ - baseUrl: AUTH_SERVER_URL, - interactionUrl: INTERACTION_URL, + expect(replayed.status).toBe(403) + expect(replayed.body).toMatchObject({ error: 'proposal_parameter_mismatch' }) + expect(String((replayed.body as { error_description: string }).error_description)) + .toMatch(/Parameter "to" differs from the approved proposal/) + }, 60_000) + + it('per-call: a parameter the proposal did not carry is rejected', async () => { + const { perCallToken } = await perCallRoundTrip({ to: 'alice@example.com' }) + + const extra = await invoke(perCallToken, PER_CALL_OPERATION, { + to: 'alice@example.com', + bcc: 'attacker@example.com', }) - const { headers, pending } = manager.createPending() - - expect(pending.code).toBeDefined() - expect(pending.code).toMatch(/^[0-9A-Z]{4}-[0-9A-Z]{4}$/) - expect(headers.Location).toMatch(/\/pending\//) - expect(headers['AAuth-Requirement']).toContain('requirement=interaction') - expect(headers['AAuth-Requirement']).toContain(`url="${INTERACTION_URL}"`) - expect(headers['AAuth-Requirement']).toContain(`code="${pending.code}"`) - - // The header round-trips through parse - const parsed = parseAAuthHeader(headers['AAuth-Requirement']) - expect(parsed.requirement).toBe('interaction') - expect(parsed.url).toBe(INTERACTION_URL) - expect(parsed.code).toBe(pending.code) - }) -}) - -// ============================================================================= -// Suite 5: ServerManager with AAuth signing -// ============================================================================= - -describe('ServerManager with AAuth signing (mocked MCP SDK)', () => { - beforeEach(() => { - vi.clearAllMocks() - mockListTools.mockResolvedValue({ - tools: [{ name: 'read_file' }, { name: 'write_file' }], + expect(extra.status).toBe(403) + expect(String((extra.body as { error_description: string }).error_description)) + .toMatch(/Parameter "bcc" was not in the approved proposal/) + + const missing = await invoke(perCallToken, PER_CALL_OPERATION, {}) + expect(missing.status).toBe(403) + expect(String((missing.body as { error_description: string }).error_description)) + .toMatch(/Approved parameter "to" is missing from the call/) + }, 60_000) + + it('per-call: the invoked operation must be the approved one', async () => { + const { perCallToken } = await perCallRoundTrip({ to: 'alice@example.com' }) + const wrong = await invoke(perCallToken, CLASS_OPERATIONS[0], { to: 'alice@example.com' }) + expect(wrong.status).toBe(403) + expect(wrong.body).toMatchObject({ error: 'proposal_operation_mismatch' }) + }, 60_000) + + // ------------------------------------------------------------------------- + // Digest parameters + // ------------------------------------------------------------------------- + + it('a digest parameter reaches the PS as a hash and an excerpt, never as the value', async () => { + const secret = 'Dear Alice,\n\nthe merger closes on the 14th. Wire the deposit to account 4471-9930.\n\nBob' + const digest = await digestParameter(secret, { + media_type: 'text/plain', + excerptLength: 24, }) - }) - - it('createSignedFetch called with getKeyMaterial → connectAll succeeds', async () => { - const keys = await createTestKeys() - const agentJwt = await createAgentJwt(keys, AGENT_URL, AGENT_ID) - const getKeyMaterial = createGetKeyMaterial(keys, agentJwt) - - const manager = new ServerManager({ - servers: { myfiles: `${RESOURCE_URL}/mcp` }, - getKeyMaterial, + expect(digest.s256).toBe(await computeR3Hash(secret)) + expect(digest.excerpt).toBe('Dear Alice,\n\nthe merger …') + expect(secret).not.toContain(digest.excerpt!) // the excerpt is truncated + + const { perCallToken } = await perCallRoundTrip({ to: 'alice@example.com', body: digest }) + + // Neither the document the PS fetched nor the token it issued carries the + // value — only the hash, the excerpt and the media type. + const servedText = resource.r3Served.join('\n') + expect(servedText).toContain(digest.s256) + expect(servedText).toContain('text/plain') + expect(servedText).not.toContain('4471-9930') + expect(JSON.stringify(claimsOf(perCallToken))).not.toContain('4471-9930') + + // At call time the agent presents the full bytes, and the resource verifies + // them against the approved digest. + const done = await invoke(perCallToken, PER_CALL_OPERATION, { + to: 'alice@example.com', + body: secret, }) + expect(done.status).toBe(200) + }, 60_000) - await manager.connectAll() - - // Verify MCP SDK was called correctly - expect(MockStreamableHTTPClientTransport).toHaveBeenCalledOnce() - const [url, opts] = MockStreamableHTTPClientTransport.mock.calls[0] - expect(url).toBeInstanceOf(URL) - expect(url.href).toBe(`${RESOURCE_URL}/mcp`) - // The transport should have received a fetch function - expect(opts.fetch).toBeTypeOf('function') + it('a digest parameter whose bytes changed is rejected', async () => { + const secret = 'Wire the deposit to account 4471-9930.' + const digest = await digestParameter(secret, { media_type: 'text/plain' }) + const { perCallToken } = await perCallRoundTrip({ to: 'alice@example.com', body: digest }) - expect(MockClient).toHaveBeenCalledWith({ - name: 'aauth-myfiles', - version: '0.0.1', + const tampered = await invoke(perCallToken, PER_CALL_OPERATION, { + to: 'alice@example.com', + body: 'Wire the deposit to account 0000-0000.', }) - expect(mockConnect).toHaveBeenCalledOnce() - expect(mockListTools).toHaveBeenCalledOnce() - }) - - it('callTool routes to correct server with original tool name', async () => { - const keys = await createTestKeys() - const agentJwt = await createAgentJwt(keys, AGENT_URL, AGENT_ID) - const getKeyMaterial = createGetKeyMaterial(keys, agentJwt) - - mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'file data' }] }) + expect(tampered.status).toBe(403) + expect(String((tampered.body as { error_description: string }).error_description)) + .toMatch(/Parameter "body" does not hash to the approved s256/) + }, 60_000) +}) - const manager = new ServerManager({ - servers: { myfiles: `${RESOURCE_URL}/mcp` }, - getKeyMaterial, +// =========================================================================== +// Things the PS refuses that no other test covers +// =========================================================================== + +describe('deferred features the fleet must not have shipped', () => { + it('upstream_token is rejected at both endpoints — call chaining is out of scope', async () => { + // `@aauth/agent` deliberately does not send it. Asserted at the HTTP level + // so the refusal is on record, and so a future implementation cannot land + // silently on a PS that does not support it. + for (const [field, extra] of [ + ['person_token_endpoint', { resource: RESOURCE }], + ['auth_token_endpoint', { resource_token: 'x' }], + ] as const) { + const res = await psPost(field, { ...extra, upstream_token: 'anything' }) + expect(res.status, field).toBe(400) + expect(await res.json(), field).toMatchObject({ error: 'invalid_request' }) + } + }, 30_000) + + it('the person token endpoint requires a resource', async () => { + const res = await psPost('person_token_endpoint', {}) + expect(res.status).toBe(400) + // §Error Response Format: RFC 9457 problem details — `application/problem+json`, + // a REQUIRED `error` extension member, an OPTIONAL `detail`. Asserted raw + // here because it is the one place the suite sees the media type; every + // other refusal is read off `PersonTokenError` / `TokenExchangeError`, + // which accept the pre-11 `error_description` spelling too (Wallet still + // emits it). + expect(res.headers.get('content-type')).toContain('application/problem+json') + expect(await res.json()).toMatchObject({ + error: 'invalid_request', + detail: 'resource is required', }) - - await manager.connectAll() - - const tools = manager.getTools() - expect(tools).toContainEqual({ - prefixedName: 'myfiles_read_file', - serverName: 'myfiles', - originalName: 'read_file', + }, 30_000) + + it('the bare /aauth/token prefix is a 404 that names both real endpoints', async () => { + // The two token endpoints sit under a shared prefix, and the prefix itself + // is not an endpoint. A caller that hard-coded the old path gets told where + // to go instead of a generic not-found — which is the only reason this + // suite's move cost one commit rather than an afternoon. + const res = await psPost('person_token_endpoint', { resource: RESOURCE }) + expect(res.status).toBe(200) + + const stale = await agent.psFetch(`${PS}/aauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ resource: RESOURCE }), }) + expect(stale.status).toBe(404) + const body = await stale.json() as { error: string; detail: string } + expect(body.detail).toContain(`${PS}/aauth/token/auth`) + expect(body.detail).toContain(`${PS}/aauth/token/person`) + }, 30_000) + + it('the PS publishes auth_token_endpoint and person_token_endpoint, not token_endpoint', async () => { + // The one place literals belong: this test is about the metadata document + // itself, so the expected values are written out. Everywhere else resolves + // through them. + const metadata = await (await router.fetch(`${PS}/.well-known/aauth-person.json`)).json() as Record + expect(metadata.auth_token_endpoint).toBe(`${PS}/aauth/token/auth`) + expect(metadata.person_token_endpoint).toBe(`${PS}/aauth/token/person`) + // Renamed in -11. A PS still publishing the old name is pre-11. + expect(metadata.token_endpoint).toBeUndefined() + // Not published, so nothing in this suite can exercise missions beyond + // equality — see the file header. + expect(metadata.mission_endpoint).toBeUndefined() + expect(metadata.revocation_endpoint).toBeUndefined() + expect(metadata.mission_control_endpoint).toBeUndefined() + }) - const result = await manager.callTool('myfiles_read_file', { path: '/test.txt' }) - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'read_file', - arguments: { path: '/test.txt' }, + it('@aauth/agent refuses a PS whose metadata is still -10 shaped', async () => { + // A PS that publishes no `person_token_endpoint` cannot mint the person + // token a resource now demands, so nothing downstream can succeed. Fail at + // the metadata document rather than half-way through a flow. + const serving = (doc: Record) => async () => + new Response(JSON.stringify(doc), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + + await expect(fetchAuthServerMetadata({ + signedFetch: serving({ token_endpoint: `${PS}/aauth/token/auth`, jwks_uri: `${PS}/aauth/jwks.json` }), + authServerUrl: PS, + })).rejects.toThrow(/auth_token_endpoint/) + + await expect(fetchAuthServerMetadata({ + signedFetch: serving({ auth_token_endpoint: `${PS}/aauth/token/auth` }), + authServerUrl: PS, + })).rejects.toThrow(/person_token_endpoint/) + + // The real document passes both checks. + clearMetadataCache() + const metadata = await fetchAuthServerMetadata({ + signedFetch: agent.psFetch, + authServerUrl: PS, }) - expect(result).toEqual({ content: [{ type: 'text', text: 'file data' }] }) - }) + expect(metadata.auth_token_endpoint).toBe(`${PS}/aauth/token/auth`) + expect(metadata.person_token_endpoint).toBe(`${PS}/aauth/token/person`) + }, 30_000) }) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 17db8db..69365ba 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,327 +1,1054 @@ -import { - generateKeyPair, - exportJWK, - SignJWT, - calculateJwkThumbprint, -} from 'jose' +/** + * Cross-package e2e fixtures for AAuth -11. + * + * The point of this suite is that nothing here is a mock of the protocol. The + * person server is **mockin** (WP-19), started as a real process — the only + * implementation in existence issuing -11 person tokens. The agent side is + * `@aauth/agent` + `@hellocoop/httpsig` making real signed HTTP requests. The + * resource side is `@aauth/resource` inside a real `node:http` server that + * verifies those signatures. So a test that passes here is a statement about + * four packages agreeing on the wire, not about a stub. + * + * ## The one piece of scaffolding, and why it is unavoidable + * + * AAuth server identifiers are `https://host` with **no port** (Protocol + * §Server Identifiers, enforced by `isServerIdentifier` in `@aauth/resource` + * and by mockin's `validateResourceIdentifier`). Nothing on loopback can be + * one. So every party gets a real identifier — `https://ps.mockin.test`, + * `https://rs.mockin.test`, `https://agent.mockin.test` — which is what goes + * into `iss`, `aud`, `ps` and every comparison, and a {@link LoopbackRouter} + * rewrites *only the transport* to `http://127.0.0.1:port`. No claim, no + * signature input, and no comparison is altered: the requests are signed over + * the loopback `@authority` they are actually sent to, exactly as a real client + * signs the authority it dials. + */ + +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { spawn } from 'node:child_process' +import type { ChildProcess } from 'node:child_process' +import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { AddressInfo } from 'node:net' + +import { generateKeyPair, exportJWK, SignJWT, calculateJwkThumbprint } from 'jose' import type { JWK, KeyLike } from 'jose' +import { verify as httpSigVerify } from '@hellocoop/httpsig' + +import { fetch as httpSigFetch } from '@hellocoop/httpsig' +import { createSignedFetch } from '@aauth/agent' +import type { FetchLike, GetKeyMaterial } from '@aauth/agent' +import { + SIGNING_ALG, TOKEN_TYP, DWK, + decodeJwtHeader, decodeJwtPayload, parseRequirementHeader, +} from '@aauth/protocol' +import type { AAuthChallenge } from '@aauth/protocol' import { verifyToken, - buildAAuthHeader, createResourceToken, - InteractionManager, - clearMetadataCache, -} from '@aauth/mcp-server' -import type { VerifiedToken } from '@aauth/mcp-server' -import type { GetKeyMaterial } from '@aauth/mcp-agent' - -// --- Key Material --- - -export interface TestKeys { - agentRoot: { privateKey: KeyLike; publicKey: KeyLike; pubJwk: JWK } - agentEphemeral: { privateKey: KeyLike; publicKey: KeyLike; pubJwk: JWK; privJwk: JsonWebKey; thumbprint: string } - authServer: { privateKey: KeyLike; publicKey: KeyLike; pubJwk: JWK } - resource: { privateKey: KeyLike; publicKey: KeyLike; pubJwk: JWK } + buildAAuthHeader, + AAuthTokenError, + MemoryR3Store, + publishR3Document, + publishProposal, + serveR3Document, + verifyProposalParameters, + getR3ByHash, + isProposal, + R3Error, +} from '@aauth/resource' +import type { + PersonTokenReference, + VerifiedPersonToken, + VerifiedAuthToken, + TokenKind, + R3Document, + R3OperationSet, + R3ParameterValue, +} from '@aauth/resource' + +export { decodeJwtHeader, decodeJwtPayload } + +/** + * The platform fetch, captured before {@link LoopbackRouter.install} replaces + * the global. Everything in this file dials loopback through this one, so + * installing the router can never recurse. + */ +const realFetch: typeof globalThis.fetch = globalThis.fetch.bind(globalThis) + +/** + * mockin's server entry point, resolved from `node_modules`. + * + * It is a devDependency (`@hellocoop/mockin`), not a workspace and not a + * sibling checkout. That matters: this file previously pointed at a worktree + * beside this one, which worked on the machine the suite was written on and + * nowhere else — CI could not start the person server at all, so all 58 e2e + * tests failed to collect while the rest of the suite stayed green. A + * devDependency resolves identically on a laptop and a runner, and pins which + * version of the -11 surface is under test. + * + * Resolved through `package.json` and the `bin` map rather than as a bare + * specifier, because mockin is a **bin-only package**: no `main`, no `exports`, + * just `bin: { mockin: 'src/server.js' }`. `resolve('@hellocoop/mockin')` has + * no entry point to find and throws. Reading `bin` also means the path is + * mockin's to change. + */ +const MOCKIN_PKG = createRequire(import.meta.url).resolve('@hellocoop/mockin/package.json') +const MOCKIN_ENTRY = join( + dirname(MOCKIN_PKG), + (JSON.parse(readFileSync(MOCKIN_PKG, 'utf8')) as { bin: Record }).bin.mockin, +) + +// --------------------------------------------------------------------------- +// Identifiers +// --------------------------------------------------------------------------- + +export const PS = 'https://ps.mockin.test' +export const RESOURCE = 'https://rs.mockin.test' +export const AGENT = 'https://agent.mockin.test' +export const AGENT_ID = 'aauth:e2e@agent.mockin.test' + +// --------------------------------------------------------------------------- +// Loopback routing +// --------------------------------------------------------------------------- + +/** + * Maps AAuth server identifiers onto the loopback origins the test processes + * actually listen on. Transport only — see the file header. + */ +export class LoopbackRouter { + private readonly origins = new Map() + + register(identifier: string, origin: string): void { + this.origins.set(identifier, origin.replace(/\/$/, '')) + } + + /** `https://rs.mockin.test/api` -> `http://127.0.0.1:54321/api`. */ + rewrite(url: string | URL): string { + const s = typeof url === 'string' ? url : url.toString() + for (const [identifier, origin] of this.origins) { + if (s === identifier) return origin + if (s.startsWith(`${identifier}/`)) return origin + s.slice(identifier.length) + } + return s + } + + /** Plain fetch that resolves identifiers. For JWKS/metadata discovery. */ + get fetch(): (input: string, init?: RequestInit) => Promise { + return (input, init) => realFetch(this.rewrite(input), init) + } + + /** + * Replace the global fetch with the resolving one. + * + * Needed because `@hellocoop/httpsig`'s `verify()` resolves a + * `Signature-Key: sig=jwks_uri` by fetching `{id}/.well-known/{dwk}` on the + * global fetch, with no injection point — and that is how the PS identifies + * itself when it fetches an R3 document. Rewriting is idempotent, so a + * request that already went through `route()` is unaffected. + */ + install(): void { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => + realFetch( + typeof input === 'string' || input instanceof URL ? this.rewrite(input) : input, + init, + )) as typeof globalThis.fetch + } + + uninstall(): void { + globalThis.fetch = realFetch + } + + /** Wrap a signed fetch so callers pass identifiers and the signature covers + * the loopback authority the request is really sent to. */ + route(signedFetch: FetchLike): FetchLike { + return (url, init) => signedFetch(this.rewrite(url), init) + } } -export async function createTestKeys(): Promise { - const [agentRootPair, ephPair, authPair, resourcePair] = await Promise.all([ - generateKeyPair('EdDSA', { crv: 'Ed25519' }), - generateKeyPair('EdDSA', { crv: 'Ed25519' }), - generateKeyPair('EdDSA', { crv: 'Ed25519' }), - generateKeyPair('EdDSA', { crv: 'Ed25519' }), - ]) - - const agentRootPubJwk = { ...await exportJWK(agentRootPair.publicKey), kid: 'agent-root-1' } - const ephPubJwk = await exportJWK(ephPair.publicKey) - const ephPrivJwk = await exportJWK(ephPair.privateKey) - const ephThumbprint = await calculateJwkThumbprint(ephPubJwk, 'sha256') - const authPubJwk = { ...await exportJWK(authPair.publicKey), kid: 'auth-1' } - const resourcePubJwk = { ...await exportJWK(resourcePair.publicKey), kid: 'resource-1' } +// --------------------------------------------------------------------------- +// Keys +// --------------------------------------------------------------------------- + +export interface TestKey { + privateKey: KeyLike + /** Published form: fully-specified `alg: Ed25519` (RFC 9864), never `EdDSA`. */ + publicJwk: JWK + privateJwk: JsonWebKey + thumbprint: string + kid: string +} +export async function ed25519Key(kid: string): Promise { + const { privateKey, publicKey } = await generateKeyPair('Ed25519', { extractable: true }) + const publicJwk = { ...(await exportJWK(publicKey)), alg: SIGNING_ALG, kid, use: 'sig' } + const privateJwk = (await exportJWK(privateKey)) as JsonWebKey return { - agentRoot: { privateKey: agentRootPair.privateKey, publicKey: agentRootPair.publicKey, pubJwk: agentRootPubJwk }, - agentEphemeral: { privateKey: ephPair.privateKey, publicKey: ephPair.publicKey, pubJwk: ephPubJwk, privJwk: ephPrivJwk, thumbprint: ephThumbprint }, - authServer: { privateKey: authPair.privateKey, publicKey: authPair.publicKey, pubJwk: authPubJwk }, - resource: { privateKey: resourcePair.privateKey, publicKey: resourcePair.publicKey, pubJwk: resourcePubJwk }, + privateKey, + publicJwk, + // `kid` is not in the lib.dom `JsonWebKey`, but @hellocoop/httpsig reads it + // and RFC 7517 defines it. + privateJwk: { ...privateJwk, alg: SIGNING_ALG, kid } as JsonWebKey, + thumbprint: await calculateJwkThumbprint(publicJwk, 'sha256'), + kid, } } -// --- Token Factories --- +// --------------------------------------------------------------------------- +// mockin — the person server +// --------------------------------------------------------------------------- -export async function createAgentJwt(keys: TestKeys, agentUrl: string, sub: string): Promise { - const now = Math.floor(Date.now() / 1000) - return new SignJWT({ - iss: agentUrl, - dwk: 'aauth-agent.json', - sub, - cnf: { jwk: keys.agentEphemeral.pubJwk }, - iat: now, - exp: now + 3600, - }) - .setProtectedHeader({ alg: 'EdDSA', typ: 'aa-agent+jwt', kid: 'agent-root-1' }) - .sign(keys.agentRoot.privateKey) +/** The mock switches this suite drives. See mockin's `src/aauth/mock.js`. */ +export interface MockinConfig { + /** Defer the **person** token independently of the auth token. */ + person_requirement?: 'interaction' | 'approval' | null + /** Defer the **auth** token. */ + requirement?: 'interaction' | 'approval' | 'clarification' | null + /** false makes an interaction real: poll -> 202 until `GET /aauth/consent`. */ + auto_approve?: boolean + /** false relaxes the content-digest + content-type coverage requirement. */ + require_body_signing?: boolean + /** Stamped on person tokens when the request body names no tenant. */ + tenant?: string | null + /** + * Replaces the R3 grant wholesale. mockin's own behaviour is to grant the + * whole fetched document and never populate `r3_per_call`, so this switch is + * the only source of an `r3_per_call` claim — it stands in for the person's + * decision, which mockin does not model. + */ + r3_grants?: { granted?: R3OperationSet | null; per_call?: R3OperationSet | null } | null + token_lifetime?: number + /** Preloaded entity discovery, so mockin never leaves the process. */ + trusted_servers?: Record } -export async function createAuthJwt( - keys: TestKeys, - opts: { iss: string; aud: string; agent: string; sub?: string; scope?: string }, -): Promise { +/** The two token endpoint members of `/.well-known/aauth-person.json`. */ +export type PsEndpointField = 'person_token_endpoint' | 'auth_token_endpoint' + +export interface Mockin { + /** `https://ps.mockin.test` — its `iss`, and the `aud` of every resource token. */ + readonly issuer: string + readonly origin: string + /** + * `/.well-known/aauth-person.json`, as published. + * + * Read once per process and cached, because the document does not change + * while the server runs. Assertions that pin what the PS *publishes* compare + * against literals; everything else resolves through {@link Mockin.endpoint}. + */ + metadata(): Promise> + /** + * The URL of a token endpoint, resolved from the metadata document the way an + * agent resolves it. + * + * Nothing in this suite should hard-code an endpoint path. The fields exist + * so a PS can move its endpoints, and a test that reaches past them is a test + * that will break when one does — which is exactly what happened when mockin + * moved both under a shared `/aauth/token/` prefix. + */ + endpoint(field: PsEndpointField): Promise + /** Patch the mock switches. Only the keys you pass are applied. */ + configure(patch: MockinConfig): Promise + /** `DELETE /mock` — clears the person-token `jti` store, pending requests, + * the entity cache **and `trusted_servers`**, then reinstalls the latter. */ + reset(): Promise + /** + * Drive an interaction to completion, as a browser would. + * + * Pass the `url` the PS handed out in its `AAuth-Requirement` when you have + * it — that is the URL an agent is told to open, and hard-coding a path here + * would be the same mistake as hard-coding a token endpoint. + */ + consent(code: string, url?: string): Promise + /** Register an entity so mockin resolves its metadata + JWKS in-process. */ + trust(identifier: string, jwks: { keys: JWK[] }, dwkDoc?: Record): void + stop(): Promise +} + +async function freePort(): Promise { + const probe = createServer() + await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)) + const port = (probe.address() as AddressInfo).port + await new Promise(resolve => probe.close(() => resolve())) + return port +} + +export async function startMockin(router: LoopbackRouter): Promise { + const port = await freePort() + const origin = `http://127.0.0.1:${port}` + router.register(PS, origin) + + const child: ChildProcess = spawn( + process.execPath, + ['--no-warnings', MOCKIN_ENTRY], + { + // ISSUER is what lands in `iss`, in every `Location`, and in the `aud` + // a resource token must match exactly. It must be a server identifier. + env: { ...process.env, PORT: String(port), IP: '127.0.0.1', ISSUER: PS }, + cwd: dirname(MOCKIN_ENTRY), + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + + const stderr: string[] = [] + child.stderr?.on('data', (b: Buffer) => stderr.push(b.toString())) + + const deadline = Date.now() + 20_000 + for (;;) { + if (child.exitCode !== null) { + throw new Error(`mockin exited with ${child.exitCode}: ${stderr.join('')}`) + } + try { + const res = await realFetch(`${origin}/.well-known/aauth-person.json`) + if (res.ok) break + } catch { /* not listening yet */ } + if (Date.now() > deadline) throw new Error(`mockin did not start: ${stderr.join('')}`) + await new Promise(r => setTimeout(r, 50)) + } + + const trusted: Record = {} + let metadataCache: Record | undefined + + const metadata = async (): Promise> => { + if (!metadataCache) { + const res = await realFetch(`${origin}/.well-known/aauth-person.json`) + if (!res.ok) throw new Error(`PS metadata fetch failed: ${res.status}`) + metadataCache = await res.json() as Record + } + return metadataCache + } + + const put = async (patch: MockinConfig): Promise => { + const res = await realFetch(`${origin}/mock/aauth`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch), + }) + if (!res.ok) throw new Error(`PUT /mock/aauth failed: ${res.status} ${await res.text()}`) + } + + return { + issuer: PS, + origin, + metadata, + async endpoint(field) { + const value = (await metadata())[field] + if (typeof value !== 'string' || !value) { + throw new Error(`PS metadata publishes no ${field}`) + } + return value + }, + configure: patch => put(patch), + async reset() { + const res = await realFetch(`${origin}/mock`, { method: 'DELETE' }) + if (!res.ok) throw new Error(`DELETE /mock failed: ${res.status}`) + // DELETE /mock resets trusted_servers along with everything else. + await put({ trusted_servers: trusted }) + }, + consent(code, url) { + const base = url ? router.rewrite(url) : `${origin}/aauth/consent` + return realFetch(`${base}?code=${encodeURIComponent(code)}`) + }, + trust(identifier, jwks, dwkDoc) { + trusted[identifier] = { + metadata: { issuer: identifier, jwks_uri: `${identifier}/jwks.json`, ...dwkDoc }, + jwks, + } + }, + async stop() { + child.kill('SIGKILL') + await new Promise(r => child.once('exit', r)) + }, + } +} + +// --------------------------------------------------------------------------- +// The agent +// --------------------------------------------------------------------------- + +export interface AgentOptions { + router: LoopbackRouter + /** Omit to model an agent with no person server: the agent token carries no + * `ps` claim, which is what `planAccessMode` reads. */ + personServer?: string + /** Header `alg`. Only a test proving `EdDSA` is rejected passes anything else. */ + alg?: string + lifetimeSeconds?: number + parentAgent?: string +} + +export interface TestAgent { + readonly identifier: string + /** Loopback origin of this agent's own metadata + JWKS server. */ + readonly origin: string + readonly sub: string + /** The long-lived key published at the agent server's JWKS; signs the agent token. */ + readonly identityKey: TestKey + /** The per-session key that signs HTTP requests and is bound by `cnf.jwk`. */ + readonly signingKey: TestKey + readonly agentToken: string + readonly jwks: { keys: JWK[] } + readonly keyMaterial: GetKeyMaterial + /** + * Signed fetch for **PS and AS endpoints**. `signBody: true`, so a bodied + * request additionally covers `content-digest` and `content-type` + * (Protocol §Covered Components). This is the default an agent should use + * toward a PS, and item 8 of the suite proves the requirement is real. + */ + readonly psFetch: FetchLike + /** + * Signed fetch for **resources**. Deliberately no `signBody`: a resource + * states its own extra components via `additional_signature_components`, + * so the blanket PS mandate does not apply. + */ + readonly resourceFetch: FetchLike + /** + * A resource-facing signed fetch presenting `token` in `Signature-Key` + * instead of the agent token — how a person token and then an auth token + * reach a resource. The HTTP signature stays on the same key, which is the + * key `cnf.jwk` binds in every one of them. + */ + presenting(token: string): FetchLike + stop(): Promise +} + +export async function createAgent(options: AgentOptions): Promise { + const { router, personServer, alg = SIGNING_ALG, lifetimeSeconds = 3600 } = options + + const identityKey = await ed25519Key('agent-identity-1') + const signingKey = await ed25519Key('agent-session-1') + const now = Math.floor(Date.now() / 1000) const claims: Record = { - iss: opts.iss, - dwk: 'aauth-person.json', - aud: opts.aud, - agent: opts.agent, - cnf: { jwk: keys.agentEphemeral.pubJwk }, + iss: AGENT, + dwk: DWK.agent, + sub: AGENT_ID, + cnf: { jwk: signingKey.publicJwk }, iat: now, - exp: now + 3600, + exp: now + lifetimeSeconds, } - if (opts.sub) claims.sub = opts.sub - if (opts.scope) claims.scope = opts.scope + if (personServer) claims.ps = personServer + if (options.parentAgent) claims.parent_agent = options.parentAgent - return new SignJWT(claims) - .setProtectedHeader({ alg: 'EdDSA', typ: 'aa-auth+jwt', kid: 'auth-1' }) - .sign(keys.authServer.privateKey) -} + const agentToken = await new SignJWT(claims) + .setProtectedHeader({ alg, typ: TOKEN_TYP.agent, kid: identityKey.kid }) + .sign(identityKey.privateKey) -// --- GetKeyMaterial factory --- + const keyMaterial: GetKeyMaterial = async () => ({ + signingKey: signingKey.privateJwk, + signatureKey: { type: 'jwt', jwt: agentToken }, + }) -export function createGetKeyMaterial(keys: TestKeys, agentJwt: string): GetKeyMaterial { - return async () => ({ - signingKey: keys.agentEphemeral.privJwk, - signatureKey: { type: 'jwt' as const, jwt: agentJwt }, + // The agent server. A resource verifying an agent token discovers the key + // that signed it at `{iss}/.well-known/aauth-agent.json` -> `jwks_uri`, so + // this has to be a real document over real HTTP for that path to be tested. + const jwks = { keys: [identityKey.publicJwk] } + const agentServer = createServer((req, res) => { + const path = new URL(req.url ?? '/', AGENT).pathname + const body = path === '/.well-known/aauth-agent.json' + ? { issuer: AGENT, name: 'e2e agent', jwks_uri: `${AGENT}/jwks.json` } + : path === '/jwks.json' + ? jwks + : undefined + res.writeHead(body ? 200 : 404, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body ?? { error: 'not_found' })) }) + await new Promise(resolve => agentServer.listen(0, '127.0.0.1', resolve)) + const origin = `http://127.0.0.1:${(agentServer.address() as AddressInfo).port}` + // Every agent shares the identifier `AGENT`, so the last one created owns the + // route. A test that spins up a second agent restores the route afterwards. + router.register(AGENT, origin) + + return { + stop: () => new Promise(resolve => { agentServer.close(() => resolve()) }), + identifier: AGENT, + origin, + sub: AGENT_ID, + identityKey, + signingKey, + agentToken, + jwks, + keyMaterial, + psFetch: router.route(createSignedFetch(keyMaterial, { signBody: true })), + resourceFetch: router.route(createSignedFetch(keyMaterial)), + presenting(token: string) { + return router.route(createSignedFetch(async () => ({ + signingKey: signingKey.privateJwk, + signatureKey: { type: 'jwt', jwt: token }, + }))) + }, + } +} + +// --------------------------------------------------------------------------- +// The resource +// --------------------------------------------------------------------------- + +/** + * How the resource under test should mint the resource token, so a test can + * make it misbehave in exactly one way and watch mockin catch it. + */ +export interface MintBehaviour { + /** Drop `mission_s256` the person token carried — mission stripping. */ + stripMission?: boolean + /** Claim a `mission_s256` the person token did not carry. */ + inventMission?: string + /** Drop `tenant` the person token carried. */ + stripTenant?: boolean + /** Replace `tenant` with something else. */ + overrideTenant?: string + /** Sign the resource token with the polymorphic `EdDSA`. */ + alg?: string + /** Name a `person_token_jti` this PS never issued. */ + forgePersonTokenJti?: string + scope?: string + lifetimeSeconds?: number } -// --- Mock Server --- - -export interface MockServerConfig { - keys: TestKeys - resourceUrl: string - authServerUrl: string - agentUrl: string - sub: string - requireAuthToken?: boolean - deferredMode?: boolean - interactionManager?: InteractionManager - onTokenRequest?: (body: Record) => void +export interface ResourceOptions { + router: LoopbackRouter + /** The PS this resource sends resource tokens to — the `aud` it mints. */ + personServer: string + /** Published in `/.well-known/aauth-resource.json`. */ + accessMode?: string + /** Which token kinds `/api` accepts. Defaults to `['auth']`. */ + accept?: readonly TokenKind[] + mint?: MintBehaviour } -export interface MockServer { - httpSigFetch: (url: string | URL, init?: Record) => Promise - globalFetch: (url: string | URL, init?: RequestInit) => Promise +export interface ResourceCall { + status: number + headers: Headers + body: unknown } -export function createMockServer(config: MockServerConfig): MockServer { - const { - keys, - resourceUrl, - authServerUrl, - agentUrl, - sub, - requireAuthToken = true, - deferredMode = false, - onTokenRequest, - } = config - - const interactionManager = config.interactionManager ?? ( - deferredMode ? new InteractionManager({ baseUrl: authServerUrl, interactionUrl: `${authServerUrl}/interact` }) : undefined - ) +export interface TestResource { + readonly identifier: string + readonly origin: string + readonly signingKey: TestKey + readonly jwks: { keys: JWK[] } + /** + * Base for published R3 URIs. + * + * `http://localhost:port/r3`, not the `https://rs.mockin.test` identifier, + * because the PS fetches this URL for real — it is a document location, not + * a server identifier, and no spec rule makes it one. `publishR3Document` + * allows exactly `https://` or `http://localhost` for this reason. + */ + readonly r3BaseUri: string + /** Every R3 body this resource has served, in order. Byte-for-byte. */ + readonly r3Served: string[] + /** Mutable, so one server can be re-pointed between assertions. */ + mint: MintBehaviour + accept: readonly TokenKind[] + accessMode: string | undefined + /** True once the request reached the scope gate — the check that must NOT be + * reached when the wrong credential type is presented (item 5). */ + scopeGateReached: boolean + /** The last per-call proposal this resource published. */ + readonly lastProposal: { r3_uri: string; r3_s256: string; document: R3Document } | undefined + /** Fresh R3 store, empty served-bytes log. */ + resetR3(): void + /** + * Replace the bytes stored under an `r3_uri` while leaving the recorded + * `s256` alone — the state a resource would be in if something between the + * store and the wire re-serialized the document. `@aauth/resource` has no + * code path that does this, which is the point. + */ + tamperR3(uri: string, body: string): Promise + stop(): Promise +} + +const RESOURCE_SCOPE = 'read' - // Resource server sign function for createResourceToken - const resourceSign = async (payload: Record, header: Record): Promise => { - return new SignJWT(payload) - .setProtectedHeader(header as { alg: string; typ: string }) - .sign(keys.resource.privateKey) +/** The class R3 document's vocabulary. One of R3 -02's seven. */ +export const R3_VOCABULARY = 'urn:aauth:vocabulary:openapi' +/** The operation this resource treats as `r3_per_call` — sending mail is the + * canonical "approved in principle, not for any particular call" case. */ +export const PER_CALL_OPERATION = { operationId: 'sendMessage' } + +/** + * A resource, as `@aauth/resource` intends one to be written. + * + * `GET /api` runs the full ladder: verify the HTTP signature, verify whatever + * token the `Signature-Key` carried, and answer with the requirement that + * moves the agent forward. + * + * no token / agent token -> 401 `requirement=person-token` + * person token -> mint a resource token, 401 `requirement=auth-token` + * auth token -> 200 + */ +export async function startResource(options: ResourceOptions): Promise { + const { router, personServer } = options + const signingKey = await ed25519Key('resource-1') + + const state = { + mint: options.mint ?? {}, + accept: options.accept ?? (['agent', 'person', 'auth'] as const), + accessMode: options.accessMode, + scopeGateReached: false, + r3Served: [] as string[], + /** + * The `jti` of the person token this resource most recently verified. + * AAuth issue #90: a per-call challenge fires on a request carrying an + * auth token, which has no `person_token_jti`, yet the resource token it + * must issue makes that claim REQUIRED — so a resource has to retain the + * person tokens it verified. + */ + retainedPersonTokenJti: undefined as string | undefined, + lastProposal: undefined as + | { r3_uri: string; r3_s256: string; document: R3Document } + | undefined, } - // httpSigFetch: replaces @hellocoop/httpsig.fetch - // Receives signingKey + signatureKey in init, routes by URL - const httpSigFetch = async (url: string | URL, init?: Record): Promise => { - const urlStr = typeof url === 'string' ? url : url.toString() + // The R3 store. `MemoryR3Store` is the package's own conforming + // implementation; a real resource backs this with KV. + let r3Store = new MemoryR3Store() + let r3BaseUri = '' + + const sign = (payload: Record, header: Record) => + new SignJWT(payload) + .setProtectedHeader({ ...header, alg: state.mint.alg ?? header.alg } as never) + .sign(signingKey.privateKey) + + const json = (res: ServerResponse, status: number, body: unknown, headers: Record = {}) => { + res.writeHead(status, { 'content-type': 'application/json', ...headers }) + res.end(JSON.stringify(body)) + } - // Extract httpsig key material from init - const signingKey = init?.signingKey as JsonWebKey | undefined - const signatureKey = init?.signatureKey as { type: string; jwt?: string } | undefined + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + void (async () => { + const chunks: Buffer[] = [] + for await (const c of req) chunks.push(c as Buffer) + const rawBody = Buffer.concat(chunks) + const url = new URL(req.url ?? '/', RESOURCE) - // --- Resource server routes --- - if (urlStr.startsWith(resourceUrl)) { - if (!signingKey || !signatureKey?.jwt) { - return new Response('Missing signature', { status: 400 }) + if (url.pathname === '/.well-known/aauth-resource.json') { + return json(res, 200, { + issuer: RESOURCE, + // `name`, never `client_name` — RFC 7591's spelling appears nowhere + // in the AAuth specs. + name: 'e2e resource', + jwks_uri: `${RESOURCE}/jwks.json`, + ...(state.accessMode !== undefined ? { access_mode: state.accessMode } : {}), + }) + } + if (url.pathname === '/jwks.json') { + return json(res, 200, { keys: [signingKey.publicJwk] }) } - const thumbprint = await calculateJwkThumbprint(signingKey, 'sha256') + const verifySignature = () => httpSigVerify( + { + method: req.method ?? 'GET', + authority: req.headers.host ?? '', + path: url.pathname, + query: url.search.replace(/^\?/, '') || undefined, + headers: req.headers as Record, + body: rawBody.length ? rawBody : undefined, + }, + // Protocol -10: accept only fully-specified identifiers. `EdDSA` is + // not in this set, at the HTTP-signature layer as well as the JWT one. + { supportedAlgorithms: ['Ed25519'] }, + ) - try { - const verified = await verifyToken({ - jwt: signatureKey.jwt, - httpSignatureThumbprint: thumbprint, + // --------------------------------------------------------------------- + // GET /r3/ — serve a published R3 document. + // + // AAuth-R3 §R3 Document Access Restriction: only the AS that is `aud` of + // a resource token carrying this `r3_uri`, or the PS of the agent it was + // issued to, may fetch. The entitled party is established from the + // verified signature — `sig=jwks_uri`'s `id`, which is a server + // identifier the signature proves, never a header the caller sets. + // An agent presenting `sig=jwt` has no such identifier and is refused. + // --------------------------------------------------------------------- + if (url.pathname.startsWith('/r3/')) { + const verified = await verifySignature() + const signer = verified.verified ? verified.jwks_uri?.id : undefined + const response = await serveR3Document({ + store: r3Store, + key: `${r3BaseUri}/${url.pathname.slice('/r3/'.length)}`, + signer, }) + if (response.status === 200) state.r3Served.push(response.body) + res.writeHead(response.status, response.headers) + // Serve the stored bytes verbatim. Never JSON.parse and re-stringify: + // the hash is over these exact bytes. + return res.end(response.body) + } - if (verified.type === 'auth') { - // Auth token verified -> 200 - return new Response(JSON.stringify({ status: 'ok', user: verified.sub }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } + if (url.pathname !== '/api' && url.pathname !== '/authorize' && url.pathname !== '/invoke') { + return json(res, 404, { error: 'not_found' }) + } - if (verified.type === 'agent' && requireAuthToken) { - // Agent token only, but resource requires auth token -> 401 challenge - const resourceToken = await createResourceToken( - { - resource: resourceUrl, - authServer: authServerUrl, - agent: verified.iss, - agentJkt: thumbprint, - }, - resourceSign, - ) - const aauthHeader = buildAAuthHeader('auth-token', { - resourceToken, - }) - return new Response('Auth token required', { - status: 401, - headers: { 'AAuth-Requirement': aauthHeader }, - }) - } + const result = await verifySignature() + + if (!result.verified) { + return json(res, 401, { error: 'signature_verification_failed', error_description: result.error }, { + 'aauth-requirement': buildAAuthHeader('agent-token'), + }) + } + if (!result.jwt?.raw) { + return json(res, 401, { error: 'agent_token_required' }, { + 'aauth-requirement': buildAAuthHeader('agent-token'), + }) + } - // Agent token accepted (requireAuthToken = false) - return new Response(JSON.stringify({ status: 'ok', agent: verified.iss }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, + let verified + try { + verified = await verifyToken({ + jwt: result.jwt.raw, + httpSignatureThumbprint: result.thumbprint, + resource: RESOURCE, + accept: state.accept, + fetch: router.fetch, }) } catch (err) { - return new Response(`Token verification failed: ${(err as Error).message}`, { - status: 401, + const e = err as AAuthTokenError + // Every token-verification failure is a 401-class rejection. It never + // degrades into the 403 scope answer below — that is the whole point of + // `accept` being a required parameter. + return json(res, 401, { error: e.code, error_description: e.message }, { + 'aauth-requirement': buildAAuthHeader('agent-token'), }) } - } - // --- Auth server metadata --- - if (urlStr === `${authServerUrl}/.well-known/aauth-person.json`) { - return new Response(JSON.stringify({ - token_endpoint: `${authServerUrl}/aauth/token`, - jwks_uri: `${authServerUrl}/jwks`, - }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } + if (verified.type === 'agent') { + return json(res, 401, { error: 'person_token_required' }, { + 'aauth-requirement': buildAAuthHeader('person-token'), + }) + } - // --- Auth server token endpoint --- - if (urlStr === `${authServerUrl}/aauth/token`) { - const bodyStr = init?.body as string | undefined - const body = bodyStr ? JSON.parse(bodyStr) as Record : {} + // --------------------------------------------------------------------- + // POST /authorize — the R3 authorization request. + // + // The agent names the operations it wants (`r3_operations`, the shape + // `@aauth/fetch --operations` sends). The resource writes an R3 document + // describing exactly those, publishes it, and answers with a resource + // token referencing it by `r3_uri` / `r3_s256`. The document itself never + // travels in the token. + // --------------------------------------------------------------------- + if (url.pathname === '/authorize') { + if (verified.type !== 'person') { + return json(res, 401, { error: 'person_token_required' }, { + 'aauth-requirement': buildAAuthHeader('person-token'), + }) + } + const person = verified as VerifiedPersonToken + state.retainedPersonTokenJti = person.jti + const body = rawBody.length + ? JSON.parse(rawBody.toString('utf8')) as { + r3_operations?: R3OperationSet + account?: string + } + : {} + if (!body.r3_operations) return json(res, 400, { error: 'invalid_request' }) - if (onTokenRequest) { - onTokenRequest(body) - } + const document: R3Document = { + vocabulary: body.r3_operations.vocabulary, + operations: body.r3_operations.operations, + ...(body.account !== undefined ? { account: body.account } : {}), + display: { summary: 'Read and send messages on your behalf' }, + } + const published = await publishR3Document({ + document, + baseUri: r3BaseUri, + store: r3Store, + // The `aud` of the resource token about to be minted, plus the + // agent's PS. Nobody else may read this document. + authorized: [personServer, person.iss], + }) - if (deferredMode && interactionManager) { - // Deferred: return 202 with pending - const { headers } = interactionManager.createPending() - return new Response(null, { - status: 202, - headers, + const resourceToken = await createResourceToken( + { + resource: RESOURCE, + audience: personServer, + personToken: { + iss: person.iss, + sub: person.sub, + jti: person.jti, + ...(person.mission_s256 ? { mission_s256: person.mission_s256 } : {}), + ...(person.tenant ? { tenant: person.tenant } : {}), + }, + agentJkt: result.thumbprint, + scope: state.mint.scope ?? RESOURCE_SCOPE, + ...(body.account !== undefined ? { account: body.account } : {}), + r3: { uri: published.r3_uri, s256: published.r3_s256 }, + kid: signingKey.kid, + }, + sign, + ) + return json(res, 200, { + resource_token: resourceToken, + r3_uri: published.r3_uri, + r3_s256: published.r3_s256, }) } - // Direct mode: create real aa-auth+jwt and return it - const authJwt = await createAuthJwt(keys, { - iss: authServerUrl, - aud: resourceUrl, - agent: agentUrl, - sub: 'user-123', - }) + // --------------------------------------------------------------------- + // POST /invoke — call one R3 operation with an auth token. + // + // operation in `r3_granted` -> run it + // operation in `r3_per_call` -> build a proposal from the *concrete* + // parameters of this call, publish it, + // and challenge with a resource token + // that references it + // retry carrying `r3_s256` -> recover the approved proposal and + // verify the presented parameters + // --------------------------------------------------------------------- + if (url.pathname === '/invoke') { + if (verified.type !== 'auth') { + return json(res, 401, { error: 'auth_token_required' }, { + 'aauth-requirement': buildAAuthHeader('agent-token'), + }) + } + const auth = verified as VerifiedAuthToken + const body = JSON.parse(rawBody.toString('utf8')) as { + operation: unknown + parameters: Record + } + const inSet = (set: R3OperationSet | undefined) => + !!set?.operations?.some(op => JSON.stringify(op) === JSON.stringify(body.operation)) - return new Response(JSON.stringify({ - auth_token: authJwt, - expires_in: 3600, - }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } + // A retry: the auth token names an approved *proposal*. + // + // `r3_s256` alone does not say which — a class-grant auth token + // carries the class document's hash. What distinguishes a proposal is + // its REQUIRED `parameters`, which is what `isProposal` looks at. + const referenced = auth.r3_s256 ? await getR3ByHash(r3Store, auth.r3_s256) : null + if (isProposal(referenced)) { + try { + const { parameters } = await verifyProposalParameters({ + store: r3Store, + r3_s256: referenced.s256, + presented: body.parameters, + operation: body.operation, + }) + return json(res, 200, { ok: true, invoked: body.operation, approved: parameters }) + } catch (err) { + const e = err as R3Error + return json(res, 403, { error: e.code, error_description: e.message }) + } + } - // --- Auth server pending endpoint (for deferred polling) --- - if (urlStr.startsWith(`${authServerUrl}/pending/`) && interactionManager) { - const id = urlStr.split('/pending/')[1] - const pending = interactionManager.getPending(id) + if (inSet(auth.r3_granted)) { + return json(res, 200, { ok: true, invoked: body.operation, via: 'r3_granted' }) + } - if (!pending) { - return new Response('Not found', { status: 410 }) + if (inSet(auth.r3_per_call)) { + const published = await publishProposal({ + vocabulary: R3_VOCABULARY, + operation: body.operation, + parameters: body.parameters, + display: { summary: 'Send one message', detail: 'This exact message, once.' }, + store: r3Store, + baseUri: r3BaseUri, + authorized: [personServer, auth.ps], + }) + state.lastProposal = { + r3_uri: published.r3_uri, + r3_s256: published.r3_s256, + document: JSON.parse(published.body) as R3Document, + } + const resourceToken = await createResourceToken( + { + resource: RESOURCE, + audience: personServer, + personToken: { + iss: auth.ps, + sub: auth.sub, + // §Resource Token Structure makes `person_token_jti` REQUIRED, + // but a per-call challenge fires on a request carrying an + // *auth* token, which has no such claim — AAuth issue #90. The + // resource retains the person token it verified and re-uses its + // jti; this test resource keeps exactly one. + jti: state.retainedPersonTokenJti ?? '', + ...(auth.mission_s256 ? { mission_s256: auth.mission_s256 } : {}), + ...(auth.tenant ? { tenant: auth.tenant } : {}), + }, + agentJkt: result.thumbprint, + scope: state.mint.scope ?? RESOURCE_SCOPE, + r3: { uri: published.r3_uri, s256: published.r3_s256 }, + kid: signingKey.kid, + }, + sign, + ) + return json(res, 401, { error: 'per_call_approval_required' }, { + 'aauth-requirement': buildAAuthHeader('auth-token', { resourceToken }), + }) + } + + return json(res, 403, { error: 'insufficient_scope', error_description: 'operation not granted' }) } - // Check if already resolved - try { - const result = await Promise.race([ - pending.promise, - new Promise((_, reject) => setTimeout(() => reject(new Error('still_pending')), 50)), - ]) as { auth_token: string; expires_in: number } - - // Resolved! - return new Response(JSON.stringify(result), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } catch { - // Still pending -> 202 - return new Response(null, { - status: 202, - headers: { - 'Retry-After': '0', - 'Cache-Control': 'no-store', + if (verified.type === 'person') { + const person = verified as VerifiedPersonToken + state.retainedPersonTokenJti = person.jti + const ref: PersonTokenReference = { + iss: person.iss, + sub: person.sub, + jti: state.mint.forgePersonTokenJti ?? person.jti, + } + // Honest path: copy `mission_s256` and `tenant` through unchanged. + // §Resource Token Structure — a resource MUST NOT omit either. + if (person.mission_s256 && !state.mint.stripMission) ref.mission_s256 = person.mission_s256 + if (state.mint.inventMission) ref.mission_s256 = state.mint.inventMission + if (person.tenant && !state.mint.stripTenant) ref.tenant = person.tenant + + const resourceToken = await createResourceToken( + { + resource: RESOURCE, + audience: personServer, + personToken: ref, + agentJkt: result.thumbprint, + scope: state.mint.scope ?? RESOURCE_SCOPE, + ...(state.mint.overrideTenant ? { tenant: state.mint.overrideTenant } : {}), + ...(state.mint.lifetimeSeconds ? { lifetime: state.mint.lifetimeSeconds } : {}), + kid: signingKey.kid, }, + sign, + ) + return json(res, 401, { error: 'auth_token_required' }, { + 'aauth-requirement': buildAAuthHeader('auth-token', { resourceToken }), }) } - } - return new Response('Not Found', { status: 404 }) + // Auth token. Only here does the resource look at scope. + state.scopeGateReached = true + const scopes = (verified.scope ?? '').split(' ').filter(Boolean) + if (!scopes.includes(RESOURCE_SCOPE)) { + return json(res, 403, { error: 'insufficient_scope', scope: RESOURCE_SCOPE }) + } + return json(res, 200, { + ok: true, + ps: verified.ps, + sub: verified.sub, + scope: verified.scope, + ...(verified.tenant ? { tenant: verified.tenant } : {}), + ...(verified.mission_s256 ? { mission_s256: verified.mission_s256 } : {}), + }) + })().catch((err: Error) => { + if (!res.headersSent) json(res, 500, { error: 'server_error', error_description: err.message }) + }) + }) + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + const origin = `http://127.0.0.1:${port}` + // The PS dials this for real, so it must be a URL that resolves outside this + // process. `localhost` rather than `127.0.0.1` because `publishR3Document` + // requires https or `http://localhost` — an R3 document MUST be served over + // HTTPS, with that one loopback exception for local development. + r3BaseUri = `http://localhost:${port}/r3` + router.register(RESOURCE, origin) + + return { + identifier: RESOURCE, + origin, + r3BaseUri, + signingKey, + jwks: { keys: [signingKey.publicJwk] }, + get r3Served() { return state.r3Served }, + get lastProposal() { return state.lastProposal }, + resetR3() { + state.r3Served.length = 0 + state.lastProposal = undefined + // Documents are content-addressed, so the same request produces the same + // URI in every test. A fresh store is the only real isolation. + r3Store = new MemoryR3Store() + }, + async tamperR3(uri, body) { + const record = await r3Store.get(uri) + if (!record) throw new Error(`no R3 record at ${uri}`) + await r3Store.put(uri, { ...record, body }) + await r3Store.put(record.s256, { ...record, body }) + }, + get mint() { return state.mint }, + set mint(v) { state.mint = v }, + get accept() { return state.accept }, + set accept(v) { state.accept = v }, + get accessMode() { return state.accessMode }, + set accessMode(v) { state.accessMode = v }, + get scopeGateReached() { return state.scopeGateReached }, + set scopeGateReached(v) { state.scopeGateReached = v }, + stop: () => new Promise(resolve => { (server as Server).close(() => resolve()) }), } +} - // globalFetch: for verifyToken's internal JWKS/metadata lookups - const globalFetch = async (url: string | URL, _init?: RequestInit): Promise => { - const urlStr = typeof url === 'string' ? url : url.toString() +// --------------------------------------------------------------------------- +// Reading the wire +// --------------------------------------------------------------------------- - // Agent metadata - if (urlStr === `${agentUrl}/.well-known/aauth-agent.json`) { - return new Response(JSON.stringify({ jwks_uri: `${agentUrl}/jwks` }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } +export async function callResource(fetchFn: FetchLike, path = '/api'): Promise { + const res = await fetchFn(`${RESOURCE}${path}`, { method: 'GET' }) + const text = await res.text() + let body: unknown = text + try { body = JSON.parse(text) } catch { /* keep the text */ } + return { status: res.status, headers: res.headers, body } +} - // Agent JWKS - if (urlStr === `${agentUrl}/jwks`) { - return new Response(JSON.stringify({ keys: [keys.agentRoot.pubJwk] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } +/** The `resourceToken` out of a `401 requirement=auth-token` challenge. */ +export function resourceTokenFrom(headers: Headers): string { + const header = headers.get('aauth-requirement') + if (!header) throw new Error('response carried no AAuth-Requirement') + const challenge = parseRequirementHeader(header) + if (challenge.requirement !== 'auth-token' || !challenge.resourceToken) { + throw new Error(`not an auth-token challenge: ${header}`) + } + return challenge.resourceToken +} - // Auth server metadata - if (urlStr === `${authServerUrl}/.well-known/aauth-person.json`) { - return new Response(JSON.stringify({ - jwks_uri: `${authServerUrl}/jwks`, - token_endpoint: `${authServerUrl}/aauth/token`, - }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } +/** + * A signed fetch that identifies itself as a **server**, via + * `Signature-Key: sig=jwks_uri;id="…";dwk="…";kid="…"`. + * + * This is how a PS identifies itself when it fetches an R3 document: not with + * an agent token, but with a signature whose key resolves at + * `{id}/.well-known/{dwk}`. `id` is therefore a server identifier the + * signature proves, which is what §R3 Document Access Restriction compares. + * + * Used here to sign as a party that authenticates correctly and is still not + * entitled to the document. + */ +export function serverSignedFetch( + router: LoopbackRouter, + key: TestKey, + id: string, + dwk: string, +): FetchLike { + return (url, init) => httpSigFetch(router.rewrite(url), { + ...init, + signingKey: key.privateJwk, + signatureKey: { type: 'jwks_uri', id, kid: key.kid, dwk }, + // Exactly what a PS signs on an R3 GET. + components: ['@method', '@authority', '@path', 'signature-key'], + }) +} - // Auth server JWKS - if (urlStr === `${authServerUrl}/jwks`) { - return new Response(JSON.stringify({ keys: [keys.authServer.pubJwk] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } +/** The parsed `AAuth-Requirement` of a response, or undefined. */ +export function requirementOf(headers: Headers): AAuthChallenge | undefined { + const header = headers.get('aauth-requirement') + return header ? parseRequirementHeader(header) : undefined +} - return new Response('Not Found', { status: 404 }) - } +export function claimsOf(jwt: string): Record { + return decodeJwtPayload(jwt) +} - return { httpSigFetch, globalFetch } +export function headerOf(jwt: string): Record { + return decodeJwtHeader(jwt) } + +/** Mint a token of any shape, signed by any key — for the negative tests that + * need a credential no conformant issuer would produce. */ +export async function forgeToken( + key: TestKey, + header: { alg?: string; typ: string }, + claims: Record, +): Promise { + return new SignJWT(claims) + .setProtectedHeader({ alg: SIGNING_ALG, kid: key.kid, ...header } as never) + .sign(key.privateKey) +} + +export { SIGNING_ALG, TOKEN_TYP, DWK, RESOURCE_SCOPE } diff --git a/fetch/README.md b/fetch/README.md index be1b42b..61753b4 100644 --- a/fetch/README.md +++ b/fetch/README.md @@ -27,6 +27,32 @@ npx @aauth/fetch https://whoami.aauth.dev npx @aauth/fetch "https://whoami.aauth.dev?scope=email+profile" ``` +## Seeing what each operation needs + +Fetch a resource's OpenAPI document and fetch reads its operation access +annotations (`x-aauth-access-mode` / `x-aauth-budget`, AAuth R3) out of the body, +grouping the operations by the credential each one needs — on stderr, so stdout is +still the raw spec for `jq`: + +``` +$ npx @aauth/fetch https://notes.aauth.dev/openapi.json > openapi.json +Operation access annotations (advisory — the resource may return any AAuth-Requirement at runtime): + + agent-token — your agent token alone — no person involved + getHealth GET /health + + auth-token — an auth token — costs one authorization round trip + listNotes GET /notes + exportNotes POST /notes/export [budget] + + per-call — authorized per invocation — will stop and wait for a person + purchaseReport POST /reports/{id}/purchase [budget] +``` + +Annotations are advisory: a resource may return any `AAuth-Requirement` at runtime +regardless of what it published, so fetch prints them and enforces nothing. When +your agent has no person server, the groups it cannot complete say so. + ## Authorize-then-call (recommended for multi-call workflows) Capture an auth token once, then reuse it for subsequent calls. @@ -39,10 +65,9 @@ npx @aauth/fetch authorize "https://whoami.aauth.dev?scope=email" npx @aauth/fetch authorize https://notes.aauth.dev/authorize \ --operations listNotes,createNote -# Gateway resources (multiple services behind one proxy) qualify ids by -# service, and can bind the grant to one of your accounts at the resource: +# Bind the grant to one of your accounts at the resource: npx @aauth/fetch authorize https://googleapis-com.proxy.aauth.dev/authorize \ - --operations gmail:gmail.users.messages.send,calendar:calendar.events.list \ + --operations gmail.users.messages.send,calendar.events.list \ --account dick@example.com ``` @@ -85,15 +110,16 @@ AAuth: Modes: --agent-only Sign with agent token only; don't handle 401 --auth-token --signing-key Use an existing auth token + signing key (three-party) - --aauth-access-token Reuse an AAuth-Access token (two-party; no signing key) + --session-token Reuse a session token (two-party, carried in + AAuth-Access; no signing key) --emit Emit the reusable credential(s) to stdout alongside the body. Three-party: { auth_token, expires_in, signingKey, response } - Two-party: { aauth_access_token, response } (no signingKey) + Two-party: { session_token, response } (no signingKey) `response` is the body (same as bare fetch) Authorize (with the `authorize` command): - --operations R3 operation ids (comma-separated); gateway - resources qualify each as service:operationId + --operations R3 operation ids (comma-separated), as they + appear in the resource's vocabulary --scope Requested scopes --account Upstream account at the resource to bind the authorization to (e.g. a Google email) @@ -125,7 +151,7 @@ links to https://www.aauth.dev, the llms.txt index, and the AAuth protocol spec. ## Related Packages - [`@aauth/bootstrap`](../bootstrap) — set up agent keys and configure a person server (run this first) -- [`@aauth/mcp-agent`](../mcp-agent) — programmatic agent-side AAuth for use inside applications +- [`@aauth/agent`](../agent) — programmatic agent-side AAuth for use inside applications ## License diff --git a/fetch/package.json b/fetch/package.json index 4f1d8ed..1eede1b 100644 --- a/fetch/package.json +++ b/fetch/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/fetch", - "version": "2.0.0", + "version": "3.0.0", "description": "CLI for making AAuth-authenticated HTTP requests", "type": "module", "bin": { @@ -15,8 +15,9 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@aauth/local-keys": "^1.2.0", - "@aauth/mcp-agent": "^2.0.0", + "@aauth/agent": "^3.0.0", + "@aauth/local-keys": "^2.0.0", + "@aauth/protocol": "^1.0.0", "open": "^11.0.0", "qrcode-terminal": "^0.12.0" }, diff --git a/fetch/skills/fetch.md b/fetch/skills/fetch.md index ed6dd1c..2d58573 100644 --- a/fetch/skills/fetch.md +++ b/fetch/skills/fetch.md @@ -36,14 +36,60 @@ curl https://example.aauth.dev/.well-known/aauth-resource.json The metadata tells you: - What scopes are available (`scope_descriptions`) +- `access_mode` — the credential flow the resource expects: `agent-token`, `person-token`, `session-token`, `auth-token`, or `per-call` - Whether it uses R3 vocabularies (`r3_vocabularies`) and which authorization endpoint to use - The resource's signing keys (`jwks_uri`) -For R3 resources with OpenAPI vocabularies: +`access_mode` is an IANA registry, not a closed list. A value you don't recognize means +*no declaration*: call the resource and read the `AAuth-Requirement` it returns. The +declaration is advisory either way — a resource may return any requirement at runtime. + +For R3 resources with OpenAPI vocabularies, fetch the spec to see the operationIds — and +the access annotations on them: + ```bash -# Fetch the OpenAPI spec to see available operationIds -curl https://notes.aauth.dev/openapi.json +npx @aauth/fetch https://notes.aauth.dev/openapi.json > openapi.json +``` + +### Operation access annotations + +An OpenAPI Operation Object may carry `x-aauth-access-mode` and `x-aauth-budget` +(AAuth R3 §Operation Access Annotations). When the document you fetched has them, +fetch prints them on stderr grouped by the credential each operation needs — stdout +stays the raw spec: + ``` +Operation access annotations (advisory — the resource may return any AAuth-Requirement at runtime): + + agent-token — your agent token alone — no person involved + getHealth GET /health + + person-token — a person token from your person server + getProfile GET /me + + auth-token — an auth token — costs one authorization round trip + listNotes GET /notes + exportNotes POST /notes/export [budget] + + per-call — authorized per invocation — will stop and wait for a person + purchaseReport POST /reports/{id}/purchase [budget] +``` + +Read this before planning unattended work: `agent-token` operations are free, +`auth-token` operations cost one authorization round trip, and `per-call` operations +will block on a person every time. `[budget]` means the operation draws down a budget, +so state a ceiling in your authorization request. + +Three rules: +- **Advisory, always.** A resource MAY return any `AAuth-Requirement` at runtime + regardless of what it published. Be ready for a `401` on any operation, including + one annotated as needing nothing more than you already hold. Never treat an + annotation as permission. +- **Sparse.** An operation with no annotation takes the resource's own `access_mode`. +- **Replaces, not intersects.** A `person-token` annotation on a resource declaring + `access_mode: auth-token` *lowers* the requirement for that operation. + +With `--explain`, the same data arrives structured as an `operation_annotations` event. ## One-shot request (simplest) @@ -89,10 +135,10 @@ OUT=$(npx @aauth/fetch authorize https://notes.aauth.dev/authorize --operations **Output shape — fields appear only when relevant:** - Three-party (PS-asserted): `{ auth_token, expires_in, signingKey, response? }`. `response` is the resource body (omitted by `authorize` since it makes no resource call); `signingKey` is the ephemeral private key the auth_token is `cnf`-bound to — needed on every reuse. -- Two-party (resource-managed): `{ aauth_access_token, response? }`. **No `signingKey`** — the AAuth-Access token binds per-request to the agent identity, so reuse only needs the token. +- Two-party (resource-managed): `{ session_token, response? }`. **No `signingKey`** — the session token binds per-request to the agent identity, so reuse only needs the token. - Agent-token-only 200 (resource accepted the agent token directly): `{ signingKey, signatureKey, response }` — both emitted so you can reuse that exact agent token without re-minting. -(Spec-defined fields use snake_case — `auth_token`/`expires_in`/`aauth_access_token`; our own artifacts like `signingKey`/`signatureKey` stay camelCase.) +(Spec-defined fields use snake_case — `auth_token`/`expires_in`/`session_token`; our own artifacts like `signingKey`/`signatureKey` stay camelCase.) ### Step 2: Reuse the captured token @@ -154,21 +200,24 @@ you **must** use the same key on every reuse (it isn't re-minted). ### Two-party (resource-managed) reuse Some resources manage authorization themselves instead of delegating to a person -server. After authorizing, they hand back an **`AAuth-Access` token** (in the -`aauth_access_token` field of `authorize` / `--emit` output). Reuse it with -`--aauth-access-token` — it's sent under the `AAuth` scheme and bound to the request +server. After authorizing, they hand back a **session token** in the `AAuth-Access` +header (the `session_token` field of `authorize` / `--emit` output). Reuse it with +`--session-token` — it's sent under the `AAuth` scheme and bound to the request signature, so **no signing key is needed** (your agent identity from config signs it): ```bash OUT=$(npx @aauth/fetch --emit https://resource.example/api) -export AAUTH_ACCESS_TOKEN=$(jq -r .aauth_access_token <<<"$OUT") # or pass --aauth-access-token "$TOKEN" -npx @aauth/fetch https://resource.example/api # reuses the AAuth-Access token +export AAUTH_SESSION_TOKEN=$(jq -r .session_token <<<"$OUT") # or pass --session-token "$TOKEN" +npx @aauth/fetch https://resource.example/api # reuses the session token ``` -The resource may return a new `AAuth-Access` token on any response (rolling refresh); -`--emit` surfaces the latest one. Unlike the three-party auth token, this token +The resource may return a new `AAuth-Access` header on any response (rolling refresh); +`--emit` surfaces the latest one. Unlike the three-party auth token, the session token is opaque and resource-specific — only send it back to the resource that issued it. +(AAuth -11 named this credential the *session token*; it was previously unnamed, and +these were `--aauth-access-token` / `AAUTH_ACCESS_TOKEN` / `aauth_access_token`.) + ### Token expiration Auth tokens have a limited lifetime (typically 1 hour). If a call returns a 401 after previously working, the token has expired. Re-run the `authorize` step (or `--emit`) to get fresh tokens. @@ -197,7 +246,7 @@ echo '{"url":"https://notes.aauth.dev/notes","method":"GET","auth_token":"..."," | npx @aauth/fetch --json ``` -The `--operations` flag takes comma-separated operationIds from the resource's OpenAPI spec. The person server presents these to the user for consent, showing what data access and actions are being requested. +The `--operations` flag takes comma-separated operationIds from the resource's OpenAPI spec, exactly as they appear there — an id is scoped to the one discovery endpoint the resource advertises for that vocabulary, so it carries no qualifier. The person server presents these to the user for consent, showing what data access and actions are being requested. ## Agent identifier @@ -323,13 +372,16 @@ Each distinct summary/description prints **once per step**: repeated events (the `consent_poll` heartbeat) carry only their payload, and a branch change (the final poll's 200) brings fresh lines. -Step vocabulary, in flow order: `agent_token_request` (call signed with your -agent token; a 2xx here is identity-based access, a 401 starts the three-party -flow) → `requirement_parsed` → `ps_metadata` → `ps_token_request` (202 = -consent needed, 200 = consent on file) → `interaction_required` → -`consent_poll` (repeats; the final 200's body carries the issued `auth_token`) -→ `auth_token_request` (the authorized call). R3 flows start with -`authorize_request` instead of a 401. +Step vocabulary, in flow order — `*_endpoint` is a person-server hop, `*_request` is +a resource call: `ps_metadata` → `person_token_endpoint` (get a person token for this +resource; under AAuth -11 a resource verifies one before it will issue a resource +token) → `agent_token_request` (a 2xx here is identity-based access, a 401 starts the +three-party flow) → `requirement_parsed` → `auth_token_endpoint` (renamed from +`token_endpoint`; 202 = consent needed, 200 = consent on file) → +`interaction_required` → `consent_poll` (repeats; the final 200's body carries the +issued `auth_token`) → `auth_token_request` (the authorized call). R3 flows start with +`authorize_request` instead of a 401. `operation_annotations` appears whenever the +body just fetched was an annotated OpenAPI document. ### Following along (for agents narrating a flow) @@ -337,12 +389,14 @@ When rendering a flow for a human, one section per exchange: the `summary` as the section's header line, the `description` as a quoted line, then the `request` / `response` payloads as fenced JSON. Two rules keep it readable: -- **Elide repeated JWTs by role.** Three token roles ride in these events, each +- **Elide repeated JWTs by role.** Four token roles ride in these events, each ~500+ chars: the **agent-token** (the `signature-key` JWT in - `agent_token_request`, `ps_token_request`, `consent_poll`), the - **resource-token** (first in the 401's `aauth-requirement` header, again in - `ps_token_request`'s body), and the **auth-token** (first in the final - `consent_poll` 200 body, again in `auth_token_request`'s `signature-key`). + `person_token_endpoint`, `agent_token_request`, `auth_token_endpoint`, + `consent_poll`), the **person-token** (the `person_token_endpoint` 200 body, + again on the resource call that draws the 401), the **resource-token** (first in + the 401's `aauth-requirement` header, again in `auth_token_endpoint`'s body), and + the **auth-token** (first in the final `consent_poll` 200 body, again in + `auth_token_request`'s `signature-key`). Render the *first* JWT of each role verbatim — it is the substance of that step — and elide later ones to `"…agent-token…"` etc. The `step` and field names tell you which role is in flight; no decoding needed. @@ -371,7 +425,7 @@ plain-text prompt is suppressed in favor of that event. ## Caching -- **Tokens are never cached to disk.** The `auth_token` and any access token are +- **Tokens are never cached to disk.** The `auth_token` and any session token are output (with `authorize` / `--emit`) for you to reuse as you see fit — there is no automatic on-disk token reuse. See "Step 2: Reuse the captured token". - **Person-server metadata is cached** (it's public, not a secret) under diff --git a/fetch/src/annotations.test.ts b/fetch/src/annotations.test.ts new file mode 100644 index 0000000..e690f41 --- /dev/null +++ b/fetch/src/annotations.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from 'vitest' +import { + isOpenApiDocument, + readOperationAnnotations, + annotationsAsJson, + renderOperationAnnotations, +} from './annotations.js' + +const withPs = { hasPersonServer: true } +const noPs = { hasPersonServer: false } + +/** An OpenAPI 3.1 doc whose operations carry the R3 -02 access annotations. */ +function doc(operations: Record>): unknown { + const paths: Record = {} + for (const [operationId, op] of Object.entries(operations)) { + paths[`/${operationId}`] = { post: { operationId, ...op } } + } + return { openapi: '3.1.0', info: { title: 't', version: '1' }, paths } +} + +describe('isOpenApiDocument', () => { + it('accepts OpenAPI 3.x and Swagger 2.0 documents', () => { + expect(isOpenApiDocument({ openapi: '3.1.0', paths: {} })).toBe(true) + expect(isOpenApiDocument({ swagger: '2.0', paths: {} })).toBe(true) + expect(isOpenApiDocument({ openapi: '3.1.0', webhooks: {} })).toBe(true) + }) + + it('rejects anything else — an ordinary body is never annotated', () => { + expect(isOpenApiDocument({ data: 'ok' })).toBe(false) + expect(isOpenApiDocument({ paths: {} })).toBe(false) // no version + expect(isOpenApiDocument({ openapi: '3.1.0' })).toBe(false) // no operations + expect(isOpenApiDocument('a string')).toBe(false) + expect(isOpenApiDocument(null)).toBe(false) + expect(isOpenApiDocument([{ openapi: '3.1.0', paths: {} }])).toBe(false) + }) +}) + +describe('readOperationAnnotations', () => { + it('reads x-aauth-access-mode and x-aauth-budget off the Operation Object', () => { + const [a] = readOperationAnnotations( + doc({ purchaseDataset: { 'x-aauth-access-mode': 'per-call', 'x-aauth-budget': true } }), + withPs, + ) + expect(a).toMatchObject({ + operationId: 'purchaseDataset', + method: 'POST', + path: '/purchaseDataset', + declared: 'per-call', + budget: true, + plan: { kind: 'satisfiable', mode: 'per-call' }, + }) + }) + + it('carries every registered access mode through', () => { + const anns = readOperationAnnotations( + doc({ + a: { 'x-aauth-access-mode': 'agent-token' }, + b: { 'x-aauth-access-mode': 'person-token' }, + c: { 'x-aauth-access-mode': 'auth-token' }, + d: { 'x-aauth-access-mode': 'per-call' }, + }), + withPs, + ) + expect(anns.map((x) => x.plan)).toEqual([ + { kind: 'satisfiable', mode: 'agent-token' }, + { kind: 'satisfiable', mode: 'person-token' }, + { kind: 'satisfiable', mode: 'auth-token' }, + { kind: 'satisfiable', mode: 'per-call' }, + ]) + }) + + // access_mode is an IANA registry, not a closed list: an unrecognized value is + // not an error and not a declaration — call the resource and read what it returns. + it('treats an unrecognized access mode as no declaration at all', () => { + const [a] = readOperationAnnotations(doc({ x: { 'x-aauth-access-mode': 'quantum-token' } }), withPs) + expect(a.declared).toBe('quantum-token') + expect(a.plan).toEqual({ kind: 'undeclared' }) + }) + + // R3 §Access Mode Annotation: a resource that manages its own authorization does + // so for the whole resource, so session-token cannot appear on an operation. + it('ignores session-token on an operation and says why', () => { + const [a] = readOperationAnnotations(doc({ x: { 'x-aauth-access-mode': 'session-token' } }), withPs) + expect(a.plan).toEqual({ kind: 'undeclared' }) + expect(a.note).toMatch(/not valid on an operation/) + }) + + // R3 §Budget Annotation: a budget rides in the auth token, so a budget annotation + // implies auth-token, and MUST NOT be combined with agent-token or person-token. + it('a budget annotation alone implies auth-token', () => { + const [a] = readOperationAnnotations(doc({ x: { 'x-aauth-budget': true } }), withPs) + expect(a.declared).toBeUndefined() + expect(a.budget).toBe(true) + expect(a.plan).toEqual({ kind: 'satisfiable', mode: 'auth-token' }) + expect(a.note).toMatch(/budget implies `auth-token`/) + }) + + it('upgrades a budgeted agent-token/person-token operation to auth-token', () => { + for (const declared of ['agent-token', 'person-token']) { + const [a] = readOperationAnnotations( + doc({ x: { 'x-aauth-access-mode': declared, 'x-aauth-budget': true } }), + withPs, + ) + expect(a.declared).toBe(declared) + expect(a.plan).toEqual({ kind: 'satisfiable', mode: 'auth-token' }) + expect(a.note).toMatch(/R3 requires `auth-token`/) + } + }) + + // Annotations are sparse — an unannotated operation takes the resource's own + // access_mode and has nothing to surface. + it('omits operations with neither annotation', () => { + expect(readOperationAnnotations(doc({ plain: { summary: 'nothing to see' } }), withPs)).toEqual([]) + }) + + it('reports what this agent cannot complete, without erroring', () => { + const anns = readOperationAnnotations( + doc({ + free: { 'x-aauth-access-mode': 'agent-token' }, + gated: { 'x-aauth-access-mode': 'auth-token' }, + }), + noPs, + ) + expect(anns[0].plan).toEqual({ kind: 'satisfiable', mode: 'agent-token' }) + expect(anns[1]).toMatchObject({ plan: { kind: 'unsatisfiable', mode: 'auth-token' } }) + }) + + it('falls back to METHOD path when the spec omits operationId', () => { + const [a] = readOperationAnnotations( + { openapi: '3.1.0', paths: { '/x': { get: { 'x-aauth-access-mode': 'agent-token' } } } }, + withPs, + ) + expect(a.operationId).toBe('GET /x') + }) + + it('reads 3.1 webhooks as well as paths', () => { + const anns = readOperationAnnotations( + { openapi: '3.1.0', webhooks: { onEvent: { post: { operationId: 'onEvent', 'x-aauth-access-mode': 'auth-token' } } } }, + withPs, + ) + expect(anns.map((a) => a.operationId)).toEqual(['onEvent']) + }) +}) + +describe('annotationsAsJson', () => { + it('emits the declared mode, the planned mode, and the blocking reason', () => { + const json = annotationsAsJson( + readOperationAnnotations(doc({ gated: { 'x-aauth-access-mode': 'auth-token' } }), noPs), + ) + expect(json[0]).toMatchObject({ + operationId: 'gated', + access_mode: 'auth-token', + budget: false, + plan: 'unsatisfiable', + planned_access_mode: 'auth-token', + }) + expect(typeof json[0].reason).toBe('string') + }) +}) + +describe('renderOperationAnnotations', () => { + const annotations = readOperationAnnotations( + doc({ + getHealth: { 'x-aauth-access-mode': 'agent-token' }, + getMe: { 'x-aauth-access-mode': 'person-token' }, + listDatasets: { 'x-aauth-access-mode': 'auth-token' }, + purchaseDataset: { 'x-aauth-access-mode': 'per-call', 'x-aauth-budget': true }, + experimental: { 'x-aauth-access-mode': 'quantum-token' }, + }), + withPs, + ) + + it('groups by credential, cheapest first, and marks budgeted operations', () => { + const text = renderOperationAnnotations(annotations) + const order = ['agent-token', 'person-token', 'auth-token', 'per-call', 'undeclared'] + .map((m) => text.indexOf(` ${m} —`)) + expect(order).toEqual([...order].sort((a, b) => a - b)) + expect(order.every((i) => i > -1)).toBe(true) + expect(text).toMatch(/purchaseDataset.*\[budget\]/) + expect(text).toMatch(/per-call — .*stop and wait for a person/) + }) + + it('leads with the advisory caveat — the runtime requirement is authoritative', () => { + expect(renderOperationAnnotations(annotations).split('\n')[0]) + .toMatch(/advisory — the resource may return any AAuth-Requirement at runtime/) + }) + + it('states an unsatisfiable group once, not once per operation', () => { + const text = renderOperationAnnotations( + readOperationAnnotations( + doc({ a: { 'x-aauth-access-mode': 'auth-token' }, b: { 'x-aauth-access-mode': 'auth-token' } }), + noPs, + ), + ) + expect(text.match(/not satisfiable with your setup/g)).toHaveLength(1) + }) + + it('renders nothing when there is nothing annotated', () => { + expect(renderOperationAnnotations([])).toBe('') + }) +}) diff --git a/fetch/src/annotations.ts b/fetch/src/annotations.ts new file mode 100644 index 0000000..53aad08 --- /dev/null +++ b/fetch/src/annotations.ts @@ -0,0 +1,193 @@ +import { planAccessMode } from '@aauth/protocol' +import type { AccessModePlan, AgentSetup, KnownAccessMode } from '@aauth/protocol' + +/** + * R3 -02 operation access annotations, read out of a fetched OpenAPI document. + * + * An agent cannot read a resource's R3 document, so R3 by itself tells it nothing + * about what any one operation needs. The vocabulary — here the OpenAPI spec, which + * the agent has to parse to make the call at all — carries that per operation: + * `x-aauth-access-mode` and `x-aauth-budget` on the Operation Object + * (AAuth R3 §Operation Access Annotations, §Vocabulary Encodings). + * + * These are ADVISORY. A resource MAY return any `AAuth-Requirement` at runtime + * regardless of what it published, so nothing here is ever enforced: fetch reads + * the annotations, prints them, and still makes whatever call it was asked to make. + */ + +/** The OpenAPI extension keys R3 §Vocabulary Encodings defines for the Operation Object. */ +const ACCESS_MODE_KEY = 'x-aauth-access-mode' +const BUDGET_KEY = 'x-aauth-budget' + +/** HTTP methods that make a Path Item Object's value an Operation Object. */ +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] + +export interface OperationAnnotation { + /** OpenAPI `operationId`; falls back to `METHOD path` when the spec omits it. */ + operationId: string + method: string + path: string + /** `x-aauth-access-mode` exactly as published (undefined when absent). */ + declared?: string + /** `x-aauth-budget === true`. */ + budget: boolean + /** What the agent should plan for, after the R3 budget rules below. */ + plan: AccessModePlan + /** Why `plan` differs from `declared`, when it does. */ + note?: string +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +/** + * True when `doc` looks like an OpenAPI (or Swagger 2.0) description — a version + * string plus a `paths` or `webhooks` object. Cheap and conservative: fetch only + * annotates a body it is confident about, and prints nothing otherwise. + */ +export function isOpenApiDocument(doc: unknown): doc is Record { + if (!isRecord(doc)) return false + const versioned = typeof doc.openapi === 'string' || typeof doc.swagger === 'string' + return versioned && (isRecord(doc.paths) || isRecord(doc.webhooks)) +} + +/** + * Resolve the access mode an agent should plan for, applying R3 §Budget Annotation: + * + * - `session-token` MUST NOT appear on an operation (a resource that manages its own + * authorization does so for the whole resource) — treat it as no annotation. + * - a budget annotation with no access mode implies `auth-token` (a budget rides in + * the auth token's `budget` claim). + * - a resource MUST NOT combine `x-aauth-budget: true` with `agent-token` or + * `person-token`; an agent that meets that combination MUST treat it as `auth-token`. + * + * Anything left unrecognized is handed to `planAccessMode`, which reports it as + * `undeclared` — `access_mode` is an IANA registry, so an unknown value means + * "call the resource and read the `AAuth-Requirement` it returns", never an error. + */ +function resolveMode(declared: string | undefined, budget: boolean): { mode?: string; note?: string } { + let mode = declared + let note: string | undefined + + if (mode === 'session-token') { + mode = undefined + note = '`session-token` is not valid on an operation (R3 §Access Mode Annotation) — ignored' + } + + if (budget) { + if (mode === undefined) { + mode = 'auth-token' + note = note ?? 'budget implies `auth-token`; a budget rides in the auth token' + } else if (mode === 'agent-token' || mode === 'person-token') { + note = `budget with \`${mode}\` is invalid; R3 requires \`auth-token\`` + mode = 'auth-token' + } + } + + return note === undefined ? { mode } : { mode, note } +} + +/** + * Walk an OpenAPI document's Operation Objects and return the annotated ones, + * each planned against this agent's setup. Operations with neither annotation are + * omitted: annotations are sparse, and an unannotated operation simply takes the + * resource's own `access_mode`. + */ +export function readOperationAnnotations(doc: unknown, setup: AgentSetup): OperationAnnotation[] { + if (!isOpenApiDocument(doc)) return [] + const out: OperationAnnotation[] = [] + + for (const container of [doc.paths, doc.webhooks]) { + if (!isRecord(container)) continue + for (const [path, pathItem] of Object.entries(container)) { + if (!isRecord(pathItem)) continue + for (const method of HTTP_METHODS) { + const operation = pathItem[method] + if (!isRecord(operation)) continue + + const declaredRaw = operation[ACCESS_MODE_KEY] + const declared = typeof declaredRaw === 'string' ? declaredRaw : undefined + const budget = operation[BUDGET_KEY] === true + if (declared === undefined && !budget) continue + + const { mode, note } = resolveMode(declared, budget) + out.push({ + operationId: typeof operation.operationId === 'string' + ? operation.operationId + : `${method.toUpperCase()} ${path}`, + method: method.toUpperCase(), + path, + ...(declared !== undefined ? { declared } : {}), + budget, + plan: planAccessMode(mode, setup), + ...(note !== undefined ? { note } : {}), + }) + } + } + } + + return out +} + +/** The JSON form carried on the `operation_annotations` event (snake_case for spec fields). */ +export function annotationsAsJson(annotations: OperationAnnotation[]): Array> { + return annotations.map((a) => ({ + operationId: a.operationId, + method: a.method, + path: a.path, + ...(a.declared !== undefined ? { access_mode: a.declared } : {}), + budget: a.budget, + plan: a.plan.kind, + ...(a.plan.kind !== 'undeclared' ? { planned_access_mode: a.plan.mode } : {}), + ...(a.plan.kind === 'unsatisfiable' ? { reason: a.plan.reason } : {}), + ...(a.note !== undefined ? { note: a.note } : {}), + })) +} + +/** Render order, and what each mode costs the agent. */ +const GROUPS: Array<{ mode: KnownAccessMode | 'undeclared'; label: string }> = [ + { mode: 'agent-token', label: 'your agent token alone — no person involved' }, + { mode: 'person-token', label: 'a person token from your person server' }, + { mode: 'session-token', label: 'a session token the resource issues (resource-managed)' }, + { mode: 'auth-token', label: 'an auth token — costs one authorization round trip' }, + { mode: 'per-call', label: 'authorized per invocation — will stop and wait for a person' }, + { mode: 'undeclared', label: 'not a recognized access mode — call it and read the AAuth-Requirement it returns' }, +] + +function groupOf(a: OperationAnnotation): KnownAccessMode | 'undeclared' { + return a.plan.kind === 'undeclared' ? 'undeclared' : a.plan.mode +} + +/** + * The human view, for stderr. Grouped by the credential each operation needs, in + * increasing order of what it costs the agent, so a reader can see at a glance + * which operations are free, which cost a round trip, and which will block on a + * person. Returns '' when there is nothing annotated. + */ +export function renderOperationAnnotations(annotations: OperationAnnotation[]): string { + if (!annotations.length) return '' + const width = Math.max(...annotations.map((a) => a.operationId.length)) + const lines = [ + 'Operation access annotations (advisory — the resource may return any AAuth-Requirement at runtime):', + ] + + for (const { mode, label } of GROUPS) { + const group = annotations.filter((a) => groupOf(a) === mode) + if (!group.length) continue + lines.push('') + lines.push(` ${mode} — ${label}`) + // planAccessMode reports the same reason for every operation in a group (it is a + // property of the agent's setup, not of the operation), so state it once. + const blocked = group.find((a) => a.plan.kind === 'unsatisfiable') + if (blocked && blocked.plan.kind === 'unsatisfiable') { + lines.push(` not satisfiable with your setup: ${blocked.plan.reason}`) + } + for (const a of group) { + const marks = [a.budget ? '[budget]' : '', a.note ? `— ${a.note}` : ''].filter(Boolean).join(' ') + lines.push(` ${a.operationId.padEnd(width)} ${a.method} ${a.path}${marks ? ` ${marks}` : ''}`) + } + } + + return lines.join('\n') +} diff --git a/fetch/src/args.test.ts b/fetch/src/args.test.ts index f86686f..2072187 100644 --- a/fetch/src/args.test.ts +++ b/fetch/src/args.test.ts @@ -7,7 +7,7 @@ const argv = (...rest: string[]) => ['node', 'aauth-fetch', ...rest] describe('parseArgs', () => { const originalEnv = { ...process.env } beforeEach(() => { - for (const k of ['AAUTH_AGENT_URL', 'AAUTH_LOCAL', 'AAUTH_AUTH_TOKEN', 'AAUTH_SIGNING_KEY', 'AAUTH_ACCESS_TOKEN', 'AAUTH_PERSON_SERVER']) { + for (const k of ['AAUTH_AGENT_URL', 'AAUTH_LOCAL', 'AAUTH_AUTH_TOKEN', 'AAUTH_SIGNING_KEY', 'AAUTH_SESSION_TOKEN', 'AAUTH_ACCESS_TOKEN', 'AAUTH_PERSON_SERVER']) { delete process.env[k] } }) @@ -62,12 +62,23 @@ describe('parseArgs', () => { expect(a).toMatchObject({ local: 'claude', personServer: 'https://ps', authToken: 'jwt', signingKey: '{}' }) }) - it('parses --aauth-access-token (flag and AAUTH_ACCESS_TOKEN env)', () => { - expect(parseArgs(argv('https://x', '--aauth-access-token', 'tok-1')).opaqueToken).toBe('tok-1') - process.env.AAUTH_ACCESS_TOKEN = 'tok-env' - expect(parseArgs(argv('https://x')).opaqueToken).toBe('tok-env') + // -11 named the resource-managed credential the *session token*; the flag, + // env var and JSON field follow (was --aauth-access-token / AAUTH_ACCESS_TOKEN). + it('parses --session-token (flag and AAUTH_SESSION_TOKEN env)', () => { + expect(parseArgs(argv('https://x', '--session-token', 'tok-1')).sessionToken).toBe('tok-1') + process.env.AAUTH_SESSION_TOKEN = 'tok-env' + expect(parseArgs(argv('https://x')).sessionToken).toBe('tok-env') // flag wins over env - expect(parseArgs(argv('https://x', '--aauth-access-token', 'tok-flag')).opaqueToken).toBe('tok-flag') + expect(parseArgs(argv('https://x', '--session-token', 'tok-flag')).sessionToken).toBe('tok-flag') + }) + + it('no longer accepts the -10 --aauth-access-token spelling', () => { + // Unknown long flags are ignored by the parser. + const a = parseArgs(argv('https://x', '--aauth-access-token', 'tok-old')) + expect(a.sessionToken).toBeUndefined() + expect(a.url).toBe('https://x') + process.env.AAUTH_ACCESS_TOKEN = 'tok-old-env' + expect(parseArgs(argv('https://x')).sessionToken).toBeUndefined() }) it('parses --agent-only', () => { diff --git a/fetch/src/args.ts b/fetch/src/args.ts index 1477911..1981c2d 100644 --- a/fetch/src/args.ts +++ b/fetch/src/args.ts @@ -18,8 +18,13 @@ export interface FetchArgs { personServer?: string authToken?: string signingKey?: string - /** Opaque AAuth-Access token (two-party reuse) sent under the AAuth scheme. */ - opaqueToken?: string + /** + * Session token (resource-managed / two-party reuse) — the opaque credential a + * resource issues via `AAuth-Access` and the agent presents back under the + * `AAuth` scheme. Named `session-token` since AAuth -11; it was previously + * unnamed in the spec and this flag called it `aauth-access-token`. + */ + sessionToken?: string // Mode (modifiers) agentOnly: boolean @@ -144,21 +149,21 @@ export const FLAGS: FlagSpec[] = [ summary: 'Use an existing auth token (with --signing-key; three-party reuse)', json: 'auth_token', env: 'AAUTH_AUTH_TOKEN' }, { long: 'signing-key', kind: 'value', field: 'signingKey', metavar: '', group: 'Mode', summary: 'Ephemeral signing key for --auth-token (the auth token is cnf-bound to it)', json: 'signingKey', jsonKind: 'json', env: 'AAUTH_SIGNING_KEY' }, - { long: 'aauth-access-token', kind: 'value', field: 'opaqueToken', metavar: '', group: 'Mode', - summary: 'Reuse an AAuth-Access token (two-party / resource-managed); no signing key needed', json: 'aauth_access_token', env: 'AAUTH_ACCESS_TOKEN' }, + { long: 'session-token', kind: 'value', field: 'sessionToken', metavar: '', group: 'Mode', + summary: 'Reuse a session token (two-party / resource-managed, carried in AAuth-Access); no signing key needed', json: 'session_token', env: 'AAUTH_SESSION_TOKEN' }, { long: 'emit', kind: 'boolean', field: 'emit', group: 'Mode', summary: 'Emit the reusable credential(s) to stdout alongside the response', json: 'emit', jsonKind: 'boolean', details: [ 'Shape (fields appear only when relevant):', ' { auth_token, expires_in, signingKey, response } three-party', - ' { aauth_access_token, response } two-party', + ' { session_token, response } two-party', '`response` is the resource body (same as bare fetch); `signingKey` is emitted', 'only with `auth_token` (three-party reuse needs it).', ] }, // Authorize { long: 'operations', kind: 'value', field: 'operations', metavar: '', group: 'Authorize', - summary: 'R3 operation ids to authorize (comma-separated). Gateway resources qualify each as service:operationId', json: 'operations' }, + summary: 'R3 operation ids to authorize (comma-separated), as they appear in the resource\'s vocabulary', json: 'operations' }, { long: 'scope', kind: 'value', field: 'scope', metavar: '', group: 'Authorize', summary: 'Requested scopes', json: 'scope' }, { long: 'account', kind: 'value', field: 'account', metavar: '', group: 'Authorize', diff --git a/fetch/src/cli.ts b/fetch/src/cli.ts index 9ce2004..15d1b13 100644 --- a/fetch/src/cli.ts +++ b/fetch/src/cli.ts @@ -3,7 +3,7 @@ import { createRequire } from 'node:module' import { realpathSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import type { AuthServerMetadata } from '@aauth/mcp-agent' +import type { PersonServerMetadata } from '@aauth/agent' import { parseArgs } from './args.js' import { readJsonInput, mergeJsonInput } from './json-input.js' import { renderSkill } from './skill.js' @@ -76,7 +76,7 @@ export async function run(): Promise { } const personServer = resolvePersonServer(args.agentProvider, args.personServer) const cachedMetadata = resolvePersonServerMetadata(personServer) - const onMetadata = (m: AuthServerMetadata) => savePersonServerMetadata(personServer, m) + const onMetadata = (m: PersonServerMetadata) => savePersonServerMetadata(personServer, m) const getKeyMaterial = buildGetKeyMaterial(args, personServer) const url = args.url await runWithMetadataSelfHeal(personServer, cachedMetadata, (metadata) => @@ -98,12 +98,12 @@ export async function run(): Promise { const url = args.url if (args.authToken && args.signingKey) { - await handlePreAuthed({ ...args, url, authToken: args.authToken, signingKey: args.signingKey }, init) + await handlePreAuthed({ ...args, url, authToken: args.authToken, signingKey: args.signingKey }, init, personServer) } else if (args.agentOnly) { - await handleAgentOnly({ ...args, url }, init, getKeyMaterial) + await handleAgentOnly({ ...args, url }, init, getKeyMaterial, personServer) } else { const cachedMetadata = resolvePersonServerMetadata(personServer) - const onMetadata = (m: AuthServerMetadata) => savePersonServerMetadata(personServer, m) + const onMetadata = (m: PersonServerMetadata) => savePersonServerMetadata(personServer, m) await runWithMetadataSelfHeal(personServer, cachedMetadata, (metadata) => handleFullFlow({ ...args, url }, init, getKeyMaterial, personServer, metadata, onMetadata), ) diff --git a/fetch/src/handlers.test.ts b/fetch/src/handlers.test.ts index 5510f2f..5935f33 100644 --- a/fetch/src/handlers.test.ts +++ b/fetch/src/handlers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { KeyMaterial, AAuthFetchOptions } from '@aauth/mcp-agent' +import type { KeyMaterial, AAuthFetchOptions } from '@aauth/agent' // --- Mocks --- @@ -23,8 +23,8 @@ const { mockExchangeToken } = vi.hoisted(() => ({ mockExchangeToken: vi.fn(), })) -const { mockParseAAuthHeader } = vi.hoisted(() => ({ - mockParseAAuthHeader: vi.fn(), +const { mockParseRequirementHeader } = vi.hoisted(() => ({ + mockParseRequirementHeader: vi.fn(), })) const { FakeTokenExchangeError } = vi.hoisted(() => ({ @@ -36,14 +36,18 @@ const { FakeTokenExchangeError } = vi.hoisted(() => ({ }, })) -vi.mock('@aauth/mcp-agent', () => ({ +vi.mock('@aauth/agent', () => ({ createSignedFetch: mockCreateSignedFetch, createAAuthFetch: mockCreateAAuthFetch, exchangeToken: mockExchangeToken, - parseAAuthHeader: mockParseAAuthHeader, TokenExchangeError: FakeTokenExchangeError, })) +vi.mock('@aauth/protocol', async (importOriginal) => ({ + ...(await importOriginal()), + parseRequirementHeader: mockParseRequirementHeader, +})) + vi.mock('@aauth/local-keys', () => ({ createAgentToken: vi.fn(), readConfig: vi.fn(() => ({ agents: {} })), @@ -68,7 +72,7 @@ import { tryParseJson, } from './handlers.js' import { readConfig, getAgentConfig, readCachedMetadata, writeCachedMetadata, evictCachedMetadata } from '@aauth/local-keys' -import { TokenExchangeError } from '@aauth/mcp-agent' +import { TokenExchangeError } from '@aauth/agent' import open from 'open' // --- Helpers --- @@ -180,7 +184,7 @@ describe('resolvePersonServer', () => { describe('resolvePersonServerMetadata', () => { beforeEach(() => vi.clearAllMocks()) - const meta = { token_endpoint: 'https://ps.com/aauth/token', jwks_uri: 'https://ps.com/jwks' } + const meta = { auth_token_endpoint: 'https://ps.com/aauth/token/auth', person_token_endpoint: 'https://ps.com/aauth/token/person', jwks_uri: 'https://ps.com/jwks' } it('returns the cached metadata for the PS host', () => { vi.mocked(readCachedMetadata).mockReturnValueOnce(meta) @@ -201,7 +205,7 @@ describe('resolvePersonServerMetadata', () => { describe('savePersonServerMetadata', () => { beforeEach(() => vi.clearAllMocks()) - const meta = { token_endpoint: 'https://ps.com/aauth/token', jwks_uri: 'https://ps.com/jwks' } + const meta = { auth_token_endpoint: 'https://ps.com/aauth/token/auth', person_token_endpoint: 'https://ps.com/aauth/token/person', jwks_uri: 'https://ps.com/jwks' } it('writes the fetched metadata to the cache, keyed by PS host', () => { savePersonServerMetadata('https://ps.com', meta) @@ -216,7 +220,7 @@ describe('savePersonServerMetadata', () => { describe('runWithMetadataSelfHeal', () => { beforeEach(() => vi.clearAllMocks()) - const meta = { token_endpoint: 'https://ps.com/aauth/token' } + const meta = { auth_token_endpoint: 'https://ps.com/aauth/token/auth' } it('passes the cached metadata through on success (no eviction)', async () => { const run = vi.fn().mockResolvedValue(undefined) @@ -366,6 +370,119 @@ describe('handleAgentOnly', () => { }) }) +// R3 -02 operation access annotations: fetching a resource's OpenAPI document is +// how an agent learns what each operation needs, so fetch reads the annotations out +// of the body it just printed and shows them. Advisory — nothing is gated on them. +describe('operation access annotations', () => { + beforeEach(() => vi.clearAllMocks()) + + const openapi = JSON.stringify({ + openapi: '3.1.0', + paths: { + '/health': { get: { operationId: 'getHealth', 'x-aauth-access-mode': 'agent-token' } }, + '/me': { get: { operationId: 'getMe', 'x-aauth-access-mode': 'person-token' } }, + '/datasets': { get: { operationId: 'listDatasets', 'x-aauth-access-mode': 'auth-token' } }, + '/datasets/{id}/purchase': { + post: { operationId: 'purchaseDataset', 'x-aauth-access-mode': 'per-call', 'x-aauth-budget': true }, + }, + '/unannotated': { get: { operationId: 'plainOp' } }, + }, + }) + + /** Fetch `body` with --agent-only and return everything written to stderr. */ + async function runAgentOnly(body: string, personServer?: string): Promise { + const lines: string[] = [] + const origWrite = process.stderr.write.bind(process.stderr) + process.stderr.write = ((s: string) => { lines.push(String(s)); return true }) as typeof process.stderr.write + const stdout = captureStdout() + mockSignedFetch.mockResolvedValueOnce(new Response(body, { status: 200 })) + try { + await handleAgentOnly( + { url: 'https://api.example/openapi.json', explain: false, debug: false }, + { method: 'GET', headers: new Headers() }, + fakeGetKeyMaterial, + personServer, + ) + } finally { + process.stderr.write = origWrite + stdout.restore() + } + return lines.join('') + } + + it('groups annotated operations by the credential each needs', async () => { + const err = await runAgentOnly(openapi, 'https://ps.example.com') + expect(err).toContain('Operation access annotations') + expect(err).toContain('advisory') + expect(err).toMatch(/agent-token —[\s\S]*getHealth/) + expect(err).toMatch(/person-token —[\s\S]*getMe/) + expect(err).toMatch(/auth-token —[\s\S]*listDatasets/) + expect(err).toMatch(/per-call —[\s\S]*purchaseDataset/) + expect(err).toContain('[budget]') + // Annotations are sparse: an unannotated operation takes the resource's own + // access_mode and is not listed. + expect(err).not.toContain('plainOp') + }) + + it('says which operations this agent cannot complete (no person server)', async () => { + const err = await runAgentOnly(openapi, undefined) + expect(err).toContain('not satisfiable with your setup') + expect(err).toContain('no person server') + }) + + it('prints nothing for a body that is not an OpenAPI document', async () => { + const err = await runAgentOnly('{"data":"ok"}', 'https://ps.example.com') + expect(err).not.toContain('Operation access annotations') + }) + + it('never gates the call — the body still reaches stdout', async () => { + const stdout = captureStdout() + const origWrite = process.stderr.write.bind(process.stderr) + process.stderr.write = (() => true) as typeof process.stderr.write + mockSignedFetch.mockResolvedValueOnce(new Response(openapi, { status: 200 })) + try { + await handleAgentOnly( + { url: 'https://api.example/openapi.json', explain: false, debug: false }, + { method: 'GET', headers: new Headers() }, + fakeGetKeyMaterial, + undefined, + ) + } finally { + process.stderr.write = origWrite + stdout.restore() + } + expect(stdout.output[0]).toContain('purchaseDataset') + }) + + it('with --explain, rides the event stream as an operation_annotations event', async () => { + const lines: string[] = [] + const origWrite = process.stderr.write.bind(process.stderr) + process.stderr.write = ((s: string) => { lines.push(String(s)); return true }) as typeof process.stderr.write + const stdout = captureStdout() + mockSignedFetch.mockResolvedValueOnce(new Response(openapi, { status: 200 })) + try { + await handleAgentOnly( + { url: 'https://api.example/openapi.json', explain: true, debug: false }, + { method: 'GET', headers: new Headers() }, + fakeGetKeyMaterial, + 'https://ps.example.com', + ) + } finally { + process.stderr.write = origWrite + stdout.restore() + } + + const events = lines.filter((l) => l.trim().startsWith('{')).map((l) => JSON.parse(l) as Record) + const event = events.find((e) => e.step === 'operation_annotations') + expect(event).toBeDefined() + const annotations = event!.annotations as Array> + expect(annotations.map((a) => a.operationId)).toEqual(['getHealth', 'getMe', 'listDatasets', 'purchaseDataset']) + expect(annotations[3]).toMatchObject({ access_mode: 'per-call', budget: true, plan: 'satisfiable' }) + // stderr is captured (not a TTY) → prose would corrupt the JSONL stream. + expect(lines.join('')).not.toContain('Operation access annotations (advisory') + }) +}) + describe('handlePreAuthed', () => { beforeEach(() => vi.clearAllMocks()) @@ -441,7 +558,7 @@ describe('handleFullFlow', () => { } expect(mockCreateAAuthFetch).toHaveBeenCalledWith(expect.objectContaining({ - authServerUrl: 'https://ps.example.com', + personServerUrl: 'https://ps.example.com', })) // getKeyMaterial is pinned, so it's a wrapper — not the original const passedGetKM = mockCreateAAuthFetch.mock.calls[0][0].getKeyMaterial @@ -452,7 +569,7 @@ describe('handleFullFlow', () => { it('passes cached PS metadata through to createAAuthFetch', async () => { mockAAuthFetch.mockResolvedValueOnce(new Response('{}', { status: 200 })) - const meta = { token_endpoint: 'https://ps.example.com/aauth/token' } + const meta = { auth_token_endpoint: 'https://ps.example.com/aauth/token/auth' } const stdout = captureStdout() try { @@ -468,7 +585,7 @@ describe('handleFullFlow', () => { } expect(mockCreateAAuthFetch).toHaveBeenCalledWith(expect.objectContaining({ - authServerMetadata: meta, + personServerMetadata: meta, })) }) @@ -488,7 +605,7 @@ describe('handleFullFlow', () => { } expect(mockCreateAAuthFetch).toHaveBeenCalledWith(expect.objectContaining({ - authServerUrl: undefined, + personServerUrl: undefined, })) }) @@ -564,8 +681,8 @@ describe('handleFullFlow', () => { expect(stdout.output[0]).not.toContain('signingKey') }) - it('--emit includes aauth_access_token in two-party mode', async () => { - // Simulate a resource handing back an AAuth-Access token. + it('--emit includes session_token in two-party mode', async () => { + // Simulate a resource handing back a session token via AAuth-Access. mockCreateAAuthFetch.mockImplementationOnce((opts) => { opts.onOpaqueToken?.('opaque-xyz') return mockAAuthFetch @@ -585,19 +702,19 @@ describe('handleFullFlow', () => { } const result = JSON.parse(stdout.output[0]) - expect(result.aauth_access_token).toBe('opaque-xyz') + expect(result.session_token).toBe('opaque-xyz') expect(result.auth_token).toBeUndefined() // Two-party reuse binds per-request to the agent identity — no signingKey to carry. expect(result.signingKey).toBeUndefined() }) - it('--aauth-access-token seeds the AAuth-Access token into createAAuthFetch', async () => { + it('--session-token seeds the session token into createAAuthFetch', async () => { mockAAuthFetch.mockResolvedValueOnce(new Response('ok', { status: 200 })) const stdout = captureStdout() try { await handleFullFlow( - { url: 'https://resource.example/api', nonInteractive: false, explain: false, opaqueToken: 'reuse-me' }, + { url: 'https://resource.example/api', nonInteractive: false, explain: false, sessionToken: 'reuse-me' }, { method: 'GET', headers: new Headers() }, fakeGetKeyMaterial, undefined, @@ -606,6 +723,8 @@ describe('handleFullFlow', () => { stdout.restore() } + // The agent package still names this option `opaqueToken`; the protocol only + // named the credential (`session token`) in -11. expect(mockCreateAAuthFetch).toHaveBeenCalledWith(expect.objectContaining({ opaqueToken: 'reuse-me', })) @@ -634,10 +753,10 @@ describe('handleAuthorize', () => { expect(result.signingKey).toEqual(fakeKeyMaterial.signingKey) expect(result.signatureKey).toEqual(fakeKeyMaterial.signatureKey) expect(result.response).toEqual({ identity: 'me' }) // response IS the body - expect(result.aauth_access_token).toBeUndefined() // no AAuth-Access header → no field + expect(result.session_token).toBeUndefined() // no AAuth-Access header → no field }) - it('surfaces aauth_access_token from a two-party 200 (AAuth-Access header)', async () => { + it('surfaces session_token from a two-party 200 (AAuth-Access header)', async () => { mockSignedFetch.mockResolvedValueOnce(new Response('{"data":1}', { status: 200, headers: { 'aauth-access': 'opaque-aaa' }, @@ -655,7 +774,7 @@ describe('handleAuthorize', () => { } const result = JSON.parse(stdout.output[0]) - expect(result.aauth_access_token).toBe('opaque-aaa') + expect(result.session_token).toBe('opaque-aaa') expect(result.response).toEqual({ data: 1 }) // response IS the body // Two-party reuse doesn't need a signing key — none should be emitted. expect(result.signingKey).toBeUndefined() @@ -667,7 +786,7 @@ describe('handleAuthorize', () => { status: 401, headers: { 'aauth-requirement': 'requirement=auth-token; resource-token="rt123"' }, })) - mockParseAAuthHeader.mockReturnValueOnce({ + mockParseRequirementHeader.mockReturnValueOnce({ requirement: 'auth-token', resourceToken: 'rt123', }) @@ -723,7 +842,7 @@ describe('handleAuthorize', () => { status: 401, headers: { 'aauth-requirement': 'requirement=auth-token; resource-token="rt"' }, })) - mockParseAAuthHeader.mockReturnValueOnce({ + mockParseRequirementHeader.mockReturnValueOnce({ requirement: 'auth-token', resourceToken: 'rt', }) @@ -750,7 +869,7 @@ describe('handleAuthorize', () => { status: 401, headers: { 'aauth-requirement': 'requirement=approval' }, })) - mockParseAAuthHeader.mockReturnValueOnce({ + mockParseRequirementHeader.mockReturnValueOnce({ requirement: 'approval', }) @@ -870,6 +989,35 @@ describe('handleAuthorize', () => { expect(result.signingKey).toEqual(fakeKeyMaterial.signingKey) }) + // R3 -02 deleted `urn:aauth:vocabulary:openapi-gateway` (continued as + // dickhardt/AAuth#72). A colon in an id no longer selects a second vocabulary — + // ids are scoped to the one discovery endpoint the resource advertises, so they + // go over the wire verbatim. + it('R3: sends a colon-bearing id verbatim under the openapi vocabulary (no gateway)', async () => { + mockSignedFetch.mockResolvedValueOnce(new Response('{"resource_token":"rt"}', { status: 200 })) + mockExchangeToken.mockResolvedValueOnce({ authToken: 'eyJ.auth', expiresIn: 60 }) + + const stdout = captureStdout() + try { + await handleAuthorize( + { url: 'https://gw.aauth.dev/authorize', operations: 'gmail:messages.send, listNotes', nonInteractive: false, explain: false }, + fakeGetKeyMaterial, + 'https://ps.example.com', + ) + } finally { + stdout.restore() + } + + const body = JSON.parse((mockSignedFetch.mock.calls[0][1] as RequestInit).body as string) + expect(body.r3_operations.vocabulary).toBe('urn:aauth:vocabulary:openapi') + expect(body.r3_operations.operations).toEqual([ + { operationId: 'gmail:messages.send' }, + { operationId: 'listNotes' }, + ]) + // No mixed-form rejection either — there is no second form to mix with. + expect(process.exitCode).not.toBe(1) + }) + it('R3: errors when the authorize endpoint returns non-200', async () => { mockSignedFetch.mockResolvedValueOnce(new Response('{"error":"forbidden"}', { status: 403 })) diff --git a/fetch/src/handlers.ts b/fetch/src/handlers.ts index 0432b6d..8f52fe8 100644 --- a/fetch/src/handlers.ts +++ b/fetch/src/handlers.ts @@ -9,16 +9,20 @@ import { import { createAAuthFetch, createSignedFetch, - parseAAuthHeader, exchangeToken, TokenExchangeError, -} from '@aauth/mcp-agent' -import type { GetKeyMaterial, Capability, OnEvent, CapturedSent, AuthServerMetadata } from '@aauth/mcp-agent' +} from '@aauth/agent' +import type { GetKeyMaterial, OnEvent, CapturedSent, PersonServerMetadata } from '@aauth/agent' +// The challenge parser and the capability vocabulary moved to `@aauth/protocol` +// in -11; `parseAAuthHeader` is `parseRequirementHeader` there. +import { parseRequirementHeader } from '@aauth/protocol' +import type { Capability } from '@aauth/protocol' import { mkdirSync, openSync, writeSync, closeSync } from 'node:fs' import { homedir } from 'node:os' import { join, dirname } from 'node:path' import open from 'open' import { makeExplainRenderer, makeDebugRenderer, prettyJson, qrAscii } from './render.js' +import { readOperationAnnotations, renderOperationAnnotations, annotationsAsJson } from './annotations.js' import { promptValue } from './args.js' const STDOUT_TTY = process.stdout.isTTY === true @@ -162,10 +166,10 @@ function personServerHost(personServer: string | undefined): string | undefined */ export function resolvePersonServerMetadata( personServer: string | undefined, -): AuthServerMetadata | undefined { +): PersonServerMetadata | undefined { const host = personServerHost(personServer) if (!host) return undefined - return (readCachedMetadata(host) as AuthServerMetadata | null) ?? undefined + return (readCachedMetadata(host) as PersonServerMetadata | null) ?? undefined } /** @@ -176,7 +180,7 @@ export function resolvePersonServerMetadata( */ export function savePersonServerMetadata( personServer: string | undefined, - metadata: AuthServerMetadata, + metadata: PersonServerMetadata, ): void { const host = personServerHost(personServer) if (!host) return @@ -203,8 +207,8 @@ function isStaleEndpointError(err: unknown): boolean { */ export async function runWithMetadataSelfHeal( personServer: string | undefined, - cachedMetadata: AuthServerMetadata | undefined, - run: (metadata: AuthServerMetadata | undefined) => Promise, + cachedMetadata: PersonServerMetadata | undefined, + run: (metadata: PersonServerMetadata | undefined) => Promise, ): Promise { try { await run(cachedMetadata) @@ -238,8 +242,41 @@ export function buildRequestInit(args: { method: string; data?: string; headers: // === output === +/** + * What the annotation surfacing needs to know: whether this agent has a person + * server (so `planAccessMode` can say which operations it cannot complete), and + * the active event renderer, if any. + */ +export interface AnnotationContext { + hasPersonServer: boolean + onEvent?: OnEvent +} + +/** + * R3 -02: when the body we just fetched is an OpenAPI document carrying operation + * access annotations, show them — which operations need only an agent token, which + * need a person token, which cost an auth token, and which are `per-call` and will + * stop and wait for a person. + * + * Advisory only. Nothing here changes what fetch sends or gates any call: a resource + * MAY return any `AAuth-Requirement` at runtime regardless of what it published. + * + * Goes to stderr so stdout stays the raw body for `jq`. With a renderer active the + * annotations also ride the event stream as an `operation_annotations` info event; + * when stderr is captured (not a TTY) the prose block is dropped so the JSONL stream + * stays parseable — same rule the consent prompt follows. + */ +function surfaceOperationAnnotations(body: string, ctx: AnnotationContext | undefined): void { + if (!ctx) return + const annotations = readOperationAnnotations(tryParseJson(body), { hasPersonServer: ctx.hasPersonServer }) + if (!annotations.length) return + ctx.onEvent?.({ step: 'operation_annotations', phase: 'info', annotations: annotationsAsJson(annotations) }) + if (ctx.onEvent && !STDERR_TTY) return + process.stderr.write(`${renderOperationAnnotations(annotations)}\n`) +} + /** Print the resource response on stdout: pretty JSON when JSON, raw otherwise. */ -export async function outputResponse(response: Response): Promise { +export async function outputResponse(response: Response, annotations?: AnnotationContext): Promise { const body = await response.text() const parsed = tryParseJson(body) if (parsed !== undefined) { @@ -247,6 +284,7 @@ export async function outputResponse(response: Response): Promise { } else { console.log(body) } + surfaceOperationAnnotations(body, annotations) } function printResult(value: unknown): void { @@ -317,8 +355,8 @@ export async function handleAuthorize( }, getKeyMaterial: GetKeyMaterial, personServer: string | undefined, - personServerMetadata?: AuthServerMetadata, - onMetadata?: (m: AuthServerMetadata) => void, + personServerMetadata?: PersonServerMetadata, + onMetadata?: (m: PersonServerMetadata) => void, ): Promise { const onEvent = eventRenderer(args) // We support exactly one capability: interaction — declared unless the caller @@ -336,28 +374,18 @@ export async function handleAuthorize( let resourceToken: string | undefined if (args.operations) { + // Bare operation ids, sent verbatim. R3 -02 §Operation Identifier Scope: an id is + // scoped to the one discovery endpoint the resource advertises for the vocabulary, + // so it resolves unambiguously and carries no qualifier. (The `openapi-gateway` + // vocabulary, which qualified ids as `service:operationId`, was deleted in -02 — + // its service labels were grant-bearing identifiers that silently invalidated + // grants when renamed. Continued as dickhardt/AAuth#72.) const operationIds = args.operations.split(',').map(s => s.trim()) - // Qualified ids (service:operationId) select the openapi-gateway vocabulary - // — the resource fronts multiple OpenAPI-described services (AAuth R3 - // §OpenAPI Gateway Vocabulary). All-or-none: mixing bare and qualified ids - // is ambiguous. - const qualified = operationIds.filter(id => id.includes(':')) - if (qualified.length && qualified.length !== operationIds.length) { - return fail('Mixed operation id forms: qualify every id as service:operationId (gateway) or none (plain openapi)') - } const r3Body = { - r3_operations: qualified.length - ? { - vocabulary: 'urn:aauth:vocabulary:openapi-gateway', - operations: operationIds.map(id => { - const i = id.indexOf(':') - return { service: id.slice(0, i), operationId: id.slice(i + 1) } - }), - } - : { - vocabulary: 'urn:aauth:vocabulary:openapi', - operations: operationIds.map(id => ({ operationId: id })), - }, + r3_operations: { + vocabulary: 'urn:aauth:vocabulary:openapi', + operations: operationIds.map(id => ({ operationId: id })), + }, // AAuth `account` extension (dickhardt/AAuth#52): bind the authorization // to one of the user's accounts at the resource (e.g. a Google email). ...(args.account ? { account: args.account } : {}), @@ -386,14 +414,14 @@ export async function handleAuthorize( if (response.status === 200) { const b = await response.text() const parsed = tryParseJson(b) - // Two-party: the resource may hand back an AAuth-Access token to - // reuse (via --aauth-access-token) on subsequent calls. - const opaqueToken = response.headers.get('aauth-access') ?? undefined - if (opaqueToken) { - // Two-party reuse needs only the opaque token (binds per-request to the + // Two-party: the resource may hand back a session token (AAuth-Access) to + // reuse (via --session-token) on subsequent calls. + const sessionToken = response.headers.get('aauth-access') ?? undefined + if (sessionToken) { + // Two-party reuse needs only the session token (binds per-request to the // agent identity); no signing key to carry. return printResult({ - aauth_access_token: opaqueToken, + session_token: sessionToken, response: parsed === undefined ? b : parsed, }) } @@ -411,7 +439,7 @@ export async function handleAuthorize( } const aauthHeader = response.headers.get('aauth-requirement') if (!aauthHeader) return fail('401 response without AAuth-Requirement header') - const challenge = parseAAuthHeader(aauthHeader) + const challenge = parseRequirementHeader(aauthHeader) if (challenge.requirement !== 'auth-token' || !challenge.resourceToken) { return fail(`Unexpected challenge requirement: ${challenge.requirement}`) } @@ -455,6 +483,7 @@ export async function handleAuthorize( export async function handlePreAuthed( args: { url: string; authToken: string; signingKey: string; explain: boolean; debug: boolean }, init: RequestInit, + personServer?: string, ): Promise { let signingKey: JsonWebKey try { @@ -473,7 +502,7 @@ export async function handlePreAuthed( onEvent?.({ step: 'auth_token_request', phase: 'start', url: args.url, method: (init.method as string) ?? 'GET' }) const response = await signedFetch(args.url, init) if (onEvent) onEvent({ step: 'auth_token_request', phase: 'done', status: response.status, request_headers: sent.latest?.headers, request_body: sent.latest?.body, response: await doneResponse(response) }) - await outputResponse(response) + await outputResponse(response, { hasPersonServer: personServer !== undefined, onEvent }) } // === agent-only === @@ -482,6 +511,7 @@ export async function handleAgentOnly( args: { url: string; explain: boolean; debug: boolean }, init: RequestInit, getKeyMaterial: GetKeyMaterial, + personServer?: string, ): Promise { const onEvent = eventRenderer(args) const sent: { latest?: CapturedSent } = {} @@ -489,7 +519,7 @@ export async function handleAgentOnly( onEvent?.({ step: 'signed_request', phase: 'start', url: args.url, method: (init.method as string) ?? 'GET' }) const response = await signedFetch(args.url, init) if (onEvent) onEvent({ step: 'signed_request', phase: 'done', status: response.status, request_headers: sent.latest?.headers, request_body: sent.latest?.body, response: await doneResponse(response) }) - await outputResponse(response) + await outputResponse(response, { hasPersonServer: personServer !== undefined, onEvent }) } // === default full flow === @@ -499,13 +529,13 @@ export async function handleFullFlow( url: string; agentProvider?: string; browser?: boolean; nonInteractive: boolean; explain: boolean; debug: boolean; loginHint?: string; domainHint?: string; tenant?: string; justification?: string; promptLogin?: boolean; promptConsent?: boolean; pollTimeout?: string; - emit?: boolean; opaqueToken?: string; + emit?: boolean; sessionToken?: string; }, init: RequestInit, getKeyMaterial: GetKeyMaterial, personServer: string | undefined, - personServerMetadata?: AuthServerMetadata, - onMetadata?: (m: AuthServerMetadata) => void, + personServerMetadata?: PersonServerMetadata, + onMetadata?: (m: PersonServerMetadata) => void, ): Promise { const onEvent = eventRenderer(args) const keyMaterial = await getKeyMaterial() @@ -513,22 +543,24 @@ export async function handleFullFlow( // --emit: capture the credentials surfaced during the flow so we can // emit them (alongside the response) for reuse — the three-party auth token, - // and/or a two-party AAuth-Access token. + // and/or a two-party session token. let minted: { authToken: string; expiresIn: number } | undefined - let opaqueToken: string | undefined = args.opaqueToken + let sessionToken: string | undefined = args.sessionToken const aAuthFetch = createAAuthFetch({ getKeyMaterial: pinnedGetKeyMaterial, - authServerUrl: personServer, - authServerMetadata: personServerMetadata, + personServerUrl: personServer, + personServerMetadata, onMetadata, - // --aauth-access-token: reuse a previously-issued AAuth-Access token on this call. - opaqueToken: args.opaqueToken, + // --session-token: reuse a previously-issued session token on this call. The + // agent package still calls this credential `opaqueToken` on its options — the + // protocol only named it (`session token`) in -11. + opaqueToken: args.sessionToken, onAuthToken: args.emit ? (authToken, expiresIn) => { minted = { authToken, expiresIn } } : undefined, onOpaqueToken: args.emit - ? (token) => { opaqueToken = token } + ? (token) => { sessionToken = token } : undefined, justification: args.justification, loginHint: args.loginHint, @@ -542,28 +574,30 @@ export async function handleFullFlow( }) const response = await aAuthFetch(args.url, init) + const annotationContext = { hasPersonServer: personServer !== undefined, ...(onEvent ? { onEvent } : {}) } if (args.emit) { // Combined object: the reusable credential(s) + the resource response in one // call. `response` is the body directly (same shape as bare fetch). Fields // appear only when relevant: // - auth_token/expires_in: only when an auth token was minted (three-party). - // - aauth_access_token: only in two-party mode. + // - session_token: only in two-party mode. // - signingKey: only with auth_token (cnf-bound — required for three-party - // reuse). Two-party reuse needs only the aauth_access_token (binds per-request + // reuse). Two-party reuse needs only the session_token (binds per-request // to the agent identity), so no signingKey is emitted there. const body = await response.text() const parsed = tryParseJson(body) printResult({ ...(minted ? { auth_token: minted.authToken, expires_in: minted.expiresIn } : {}), - ...(opaqueToken ? { aauth_access_token: opaqueToken } : {}), + ...(sessionToken ? { session_token: sessionToken } : {}), ...(minted ? { signingKey: keyMaterial.signingKey } : {}), response: parsed === undefined ? body : parsed, }) + surfaceOperationAnnotations(body, annotationContext) return } - await outputResponse(response) + await outputResponse(response, annotationContext) } export function tryParseJson(text: string): unknown { diff --git a/fetch/src/help.ts b/fetch/src/help.ts index b1f3eca..2e65402 100644 --- a/fetch/src/help.ts +++ b/fetch/src/help.ts @@ -34,11 +34,12 @@ export function topLevelHelp(version: string): string { return `DESCRIPTION AAuth fetch v${version} — make a signed, authenticated request to and print its response. Runs the full AAuth flow adaptively: sign with the agent token - and send; on a 401/202 challenge, exchange the resource token for an auth token - (consent if needed) and retry; for a resource-managed (two-party) resource, carry - the opaque AAuth-Access token instead. Result on stdout is the response body - (pretty JSON when JSON, else raw); --emit adds the reusable credential - alongside it. + and send; on a 401/202 challenge, get a person token from your person server and + exchange the resource token for an auth token (consent if needed), then retry; for + a resource-managed (two-party) resource, carry the session token from AAuth-Access + instead. Result on stdout is the response body (pretty JSON when JSON, else raw); + --emit adds the reusable credential alongside it. Fetching a resource's OpenAPI + document also prints its per-operation access annotations on stderr. USAGE ${renderUsage()} diff --git a/fetch/src/json-input.test.ts b/fetch/src/json-input.test.ts index f3c75c1..dfa2dec 100644 --- a/fetch/src/json-input.test.ts +++ b/fetch/src/json-input.test.ts @@ -78,12 +78,12 @@ describe('mergeJsonInput', () => { expect(result.authToken).toBe('eyJ.json.token') }) - it('overrides opaqueToken from JSON aauth_access_token', () => { + it('overrides sessionToken from JSON session_token', () => { const result = mergeJsonInput(baseArgs(), { url: 'https://x.com', - aauth_access_token: 'access.json.token', + session_token: 'session.json.token', }) - expect(result.opaqueToken).toBe('access.json.token') + expect(result.sessionToken).toBe('session.json.token') }) it('stringifies signingKey object from JSON', () => { diff --git a/fetch/src/json-input.ts b/fetch/src/json-input.ts index 0b5e6db..1802ea4 100644 --- a/fetch/src/json-input.ts +++ b/fetch/src/json-input.ts @@ -9,7 +9,7 @@ export interface JsonRequest { // Spec-defined fields use the spec's snake_case names; our own artifacts // (signingKey, agentProvider, personServer, agentOnly, local) stay camelCase. auth_token?: string - aauth_access_token?: string + session_token?: string signingKey?: JsonWebKey agentProvider?: string local?: string diff --git a/fetch/src/render.test.ts b/fetch/src/render.test.ts index 397d85a..5de8a27 100644 --- a/fetch/src/render.test.ts +++ b/fetch/src/render.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { colorizeJson, prettyJson, makeExplainRenderer, makeDebugRenderer } from './render.js' import { renderSkill } from './skill.js' -import type { AAuthEvent } from '@aauth/mcp-agent' +import type { AAuthEvent } from '@aauth/agent' describe('colorizeJson / prettyJson', () => { const json = JSON.stringify({ a: 'hi', n: 42 }, null, 2) @@ -116,19 +116,24 @@ describe('makeExplainRenderer', () => { expect(objs[2].description).toBeTruthy() // info }) - // Locks the vocabulary for a full default-flow consent trace. Each event maps - // to (step, kind, description) — kind is which key carries the payload. + // Locks the vocabulary for a full default-flow consent trace, in AAuth -11 order: + // the agent gets a person token from its PS `person_token_endpoint` before the + // resource will issue a resource token, and exchanges that resource token at the + // PS `auth_token_endpoint` (renamed from `token_endpoint`). Each event maps to + // (step, kind, description) — kind is which key carries the payload. // auth_token_received and consent_resolved are recap-only info events that // the renderer drops; their meaning is folded into the next request's // description. it('maps a full consent trace to the display vocabulary', () => { const objs = collect([ + { step: 'ps_metadata_request', phase: 'start', url: 'https://ps/.well-known' }, + { step: 'ps_metadata_request', phase: 'done', status: 200 }, + { step: 'person_token_request', phase: 'start', url: 'https://ps/person-token' }, + { step: 'person_token_request', phase: 'done', status: 200 }, { step: 'signed_request', phase: 'start', url: 'https://r', method: 'GET' }, { step: 'signed_request', phase: 'done', status: 401 }, { step: 'challenge_received', phase: 'info', requirement: 'auth-token' }, - { step: 'ps_metadata_request', phase: 'start', url: 'https://ps/.well-known' }, - { step: 'ps_metadata_request', phase: 'done', status: 200 }, - { step: 'ps_token_request', phase: 'start', url: 'https://ps/token' }, + { step: 'ps_token_request', phase: 'start', url: 'https://ps/auth-token' }, { step: 'ps_token_request', phase: 'done', status: 202 }, { step: 'interaction_required', phase: 'info', url: 'https://ps/auth', code: 'A1B2' }, { step: 'consent_poll', phase: 'start', url: 'https://ps/pending' }, @@ -140,13 +145,15 @@ describe('makeExplainRenderer', () => { const kind = (o: Record): string => o.request !== undefined ? 'request' : o.response !== undefined ? 'response' : 'info' expect(objs.map((o) => [kind(o), o.step, o.description])).toEqual([ - ['request', 'agent_token_request', 'Call the resource with your agent token.'], - ['response', 'agent_token_request', 'The resource requires a person-issued auth token — this begins the three-party flow (agent ↔ person server ↔ resource). The `AAuth-Requirement` header carries a resource token: the agent presents it to the person server to get authorized.'], - ['info', 'requirement_parsed', 'Parsed `AAuth-Requirement` — must exchange the resource token for an auth token at the person server.'], ['request', 'ps_metadata', "Fetch the person server's metadata at `/.well-known/aauth-person.json`."], - ['response', 'ps_metadata', "Received the person server's endpoints."], - ['request', 'ps_token_request', 'POST the resource token to the person server `token_endpoint` to mint an auth token. `Prefer: wait=45` long-polls — the server may hold the connection up to 45s before returning.'], - ['response', 'ps_token_request', 'User interaction required before the auth token is issued (`AAuth-Requirement: requirement=interaction`) — the person must approve in a browser; the agent polls the pending `location` until they do.'], + ['response', 'ps_metadata', "Received the person server's endpoints — `person_token_endpoint` and `auth_token_endpoint`."], + ['request', 'person_token_endpoint', 'POST the resource to the person server `person_token_endpoint`, signed with your agent token. Since AAuth -11 this hop comes first: a resource verifies a person token before it will issue a resource token.'], + ['response', 'person_token_endpoint', 'Received the person token — audienced to that one resource, bound (`cnf`) to the key the agent is signing with, and carrying a directed `sub` for the person.'], + ['request', 'agent_token_request', 'Call the resource with your agent token.'], + ['response', 'agent_token_request', 'The resource requires a person-issued auth token — this begins the three-party flow (agent ↔ person server ↔ resource). The `AAuth-Requirement` header carries a resource token, issued against the person token the resource verified: the agent presents it to the person server `auth_token_endpoint` to get authorized.'], + ['info', 'requirement_parsed', 'Parsed `AAuth-Requirement` — must exchange the resource token for an auth token at the person server `auth_token_endpoint`.'], + ['request', 'auth_token_endpoint', 'POST the resource token to the person server `auth_token_endpoint` (renamed from `token_endpoint` in AAuth -11) to mint an auth token. `Prefer: wait=45` long-polls — the server may hold the connection up to 45s before returning.'], + ['response', 'auth_token_endpoint', 'User interaction required before the auth token is issued (`AAuth-Requirement: requirement=interaction`) — the person must approve in a browser; the agent polls the pending `location` until they do.'], ['info', 'interaction_required', 'Direct the person to the approval URL — show them the QR or open the link.'], ['request', 'consent_poll', 'Poll the pending URL — checking whether the person has acted. `Prefer: wait=45` long-polls so the response returns immediately on consent rather than burning round-trips.'], ['response', 'consent_poll', 'The person approved — the body carries the freshly issued auth token, bound (`cnf`) to the same ephemeral key the agent has been signing with.'], @@ -155,8 +162,9 @@ describe('makeExplainRenderer', () => { ]) // The summaries form the recap: one gist line per exchange, in order. expect(objs.filter((o) => o.summary !== undefined).map((o) => o.summary)).toEqual([ - 'agent → resource · agent-token → 401 + resource-token', 'agent → person server · metadata discovery', + 'agent → person server · agent-token → 200 + person-token', + 'agent → resource · agent-token → 401 + resource-token', 'agent → person server · resource-token → 202 pending + approval code', 'person → person server · approve in browser', 'agent → person server · poll → 200 + auth-token (person approved)', @@ -164,6 +172,36 @@ describe('makeExplainRenderer', () => { ]) }) + // The PS person-token hop is registered under both the agent package's internal + // step name and the endpoint-shaped spelling, so neither falls through unlabelled. + it('narrates the person-token hop under either internal step name', () => { + for (const step of ['person_token_request', 'person_token_endpoint']) { + const objs = collect([ + { step, phase: 'start', url: 'https://ps/person-token', method: 'POST' }, + { step, phase: 'done', status: 202 }, + ]) + expect(objs[0].step).toBe('person_token_endpoint') + expect(objs[0].summary).toBe('agent → person server · agent-token → 202 pending + approval code') + expect(objs[1].description).toMatch(/User interaction required before the person token is issued/) + } + }) + + // R3 -02 operation access annotations, read out of a fetched OpenAPI document. + it('renders operation_annotations as an info event carrying the annotations', () => { + const annotations = [ + { operationId: 'getHealth', method: 'GET', path: '/health', access_mode: 'agent-token', budget: false, plan: 'satisfiable', planned_access_mode: 'agent-token' }, + { operationId: 'purchaseDataset', method: 'POST', path: '/p', access_mode: 'per-call', budget: true, plan: 'satisfiable', planned_access_mode: 'per-call' }, + ] + const objs = collect([{ step: 'operation_annotations', phase: 'info', annotations }]) + expect(objs).toHaveLength(1) + expect(objs[0].step).toBe('operation_annotations') + expect(objs[0].annotations).toEqual(annotations) + // Advisory, and the description says so — nothing downstream may enforce them. + expect(objs[0].description as string).toMatch(/Advisory/) + expect(objs[0].request).toBeUndefined() + expect(objs[0].response).toBeUndefined() + }) + // signed_request's REQUEST describes the call itself the same way regardless // of status — the response description carries the branch (challenge vs. 200). it('signed_request describes the call the same way; response carries the branch', () => { @@ -217,21 +255,21 @@ describe('makeExplainRenderer', () => { for (const o of objs) expect(o.step).toBe('consent_poll') }) - // The R3 entry step + the ps_token_request 200 (consent already on file) branch. - it('maps the R3 authorize_request entry and the cached-consent ps_token_request', () => { + // The R3 entry step + the auth-token endpoint's 200 (consent already on file) branch. + it('maps the R3 authorize_request entry and the cached-consent auth_token_endpoint', () => { const objs = collect([ { step: 'r3_authorize_request', phase: 'start', url: 'https://r/authorize', method: 'POST' }, { step: 'r3_authorize_request', phase: 'done', status: 200 }, - { step: 'ps_token_request', phase: 'start', url: 'https://ps/token' }, + { step: 'ps_token_request', phase: 'start', url: 'https://ps/auth-token' }, { step: 'ps_token_request', phase: 'done', status: 200 }, ]) expect(objs[0].step).toBe('authorize_request') expect(objs[0].description).toBe("POST the requested operations to the resource's authorize endpoint, signed with your agent token.") expect(objs[1].step).toBe('authorize_request') expect(objs[1].response).toBeDefined() - expect(objs[2].step).toBe('ps_token_request') - expect(objs[2].description).toBe('POST the resource token to the person server `token_endpoint` to mint an auth token. `Prefer: wait=45` long-polls — the server may hold the connection up to 45s before returning.') - expect(objs[3].step).toBe('ps_token_request') + expect(objs[2].step).toBe('auth_token_endpoint') + expect(objs[2].description).toBe('POST the resource token to the person server `auth_token_endpoint` (renamed from `token_endpoint` in AAuth -11) to mint an auth token. `Prefer: wait=45` long-polls — the server may hold the connection up to 45s before returning.') + expect(objs[3].step).toBe('auth_token_endpoint') expect(objs[3].response).toBeDefined() }) diff --git a/fetch/src/render.ts b/fetch/src/render.ts index 723947f..881d8a2 100644 --- a/fetch/src/render.ts +++ b/fetch/src/render.ts @@ -1,5 +1,5 @@ import { createRequire } from 'node:module' -import type { AAuthEvent, OnEvent } from '@aauth/mcp-agent' +import type { AAuthEvent, OnEvent } from '@aauth/agent' /** A JWK / arbitrary JSON value — only passed through to output. */ type Json = unknown @@ -53,7 +53,7 @@ export function prettyJson(value: Json, isTty: boolean): string { // === verbose (-v) event rendering === /** - * Presentation for each protocol step. The mcp-agent (and the fetch handlers) + * Presentation for each protocol step. The agent package (and the fetch handlers) * emit internal step names; here we map them to the `step` shown in `-v` and the * `description` for each kind of event. * @@ -90,6 +90,48 @@ interface StepSpec { /** True for a 2xx status (identity/authorized success branches). */ const ok = (s?: number): boolean => s !== undefined && s >= 200 && s < 300 +/** + * The agent's two hops at the person server, in AAuth -11 order. Both are named + * for the PS metadata endpoint they hit — `*_endpoint` is a person-server hop, + * `*_request` is a resource call — so the display vocabulary reads as the flow: + * + * person_token_endpoint → agent_token_request → auth_token_endpoint → auth_token_request + * + * -11 inserted the first of these: a resource MUST have verified a person token + * before it will issue the resource token that an auth token is obtained with, so + * the agent gets a person token from its PS before the authorization endpoint will + * issue anything. The second is the old `token_endpoint`, renamed. + */ +const PERSON_TOKEN_ENDPOINT: StepSpec = { + display: 'person_token_endpoint', + summary: (s) => + s === 202 + ? 'agent → person server · agent-token → 202 pending + approval code' + : ok(s) + ? `agent → person server · agent-token → ${s} + person-token` + : `agent → person server · agent-token → ${s ?? '…'}`, + req: 'POST the resource to the person server `person_token_endpoint`, signed with your agent token. Since AAuth -11 this hop comes first: a resource verifies a person token before it will issue a resource token.', + res: (s) => + s === 202 + ? 'User interaction required before the person token is issued (`AAuth-Requirement: requirement=interaction`) — the person must act in a browser; the agent polls the pending `location` until they do.' + : 'Received the person token — audienced to that one resource, bound (`cnf`) to the key the agent is signing with, and carrying a directed `sub` for the person.', +} + +const AUTH_TOKEN_ENDPOINT: StepSpec = { + display: 'auth_token_endpoint', + summary: (s) => + s === 202 + ? 'agent → person server · resource-token → 202 pending + approval code' + : ok(s) + ? `agent → person server · resource-token → ${s} + auth-token (consent on file)` + : `agent → person server · resource-token → ${s ?? '…'}`, + req: 'POST the resource token to the person server `auth_token_endpoint` (renamed from `token_endpoint` in AAuth -11) to mint an auth token. `Prefer: wait=45` long-polls — the server may hold the connection up to 45s before returning.', + res: (s) => + s === 202 + ? 'User interaction required before the auth token is issued (`AAuth-Requirement: requirement=interaction`) — the person must approve in a browser; the agent polls the pending `location` until they do.' + : 'Received the auth token — consent was already on file.', +} + const STEPS: Record = { // The two resource calls are named by the token they carry (not by position): // the agent-token call may get a 401; the auth-token call is the authorized one. @@ -104,7 +146,7 @@ const STEPS: Record = { req: 'Call the resource with your agent token.', res: (s) => s === 401 - ? 'The resource requires a person-issued auth token — this begins the three-party flow (agent ↔ person server ↔ resource). The `AAuth-Requirement` header carries a resource token: the agent presents it to the person server to get authorized.' + ? 'The resource requires a person-issued auth token — this begins the three-party flow (agent ↔ person server ↔ resource). The `AAuth-Requirement` header carries a resource token, issued against the person token the resource verified: the agent presents it to the person server `auth_token_endpoint` to get authorized.' : ok(s) ? 'Identity-based access: the resource accepted the signature alone as proof of the agent identity — no person server round-trip, no consent.' : "Received the resource's response.", @@ -142,31 +184,30 @@ const STEPS: Record = { }, challenge_received: { display: 'requirement_parsed', - info: 'Parsed `AAuth-Requirement` — must exchange the resource token for an auth token at the person server.', + info: 'Parsed `AAuth-Requirement` — must exchange the resource token for an auth token at the person server `auth_token_endpoint`.', }, ps_metadata_request: { display: 'ps_metadata', summary: 'agent → person server · metadata discovery', req: "Fetch the person server's metadata at `/.well-known/aauth-person.json`.", - res: "Received the person server's endpoints.", + res: "Received the person server's endpoints — `person_token_endpoint` and `auth_token_endpoint`.", }, ps_metadata_cached: { display: 'ps_metadata', info: 'Person server endpoints come from its `/.well-known/aauth-person.json` metadata — using a locally cached copy.', }, - ps_token_request: { - display: 'ps_token_request', - summary: (s) => - s === 202 - ? 'agent → person server · resource-token → 202 pending + approval code' - : ok(s) - ? `agent → person server · resource-token → ${s} + auth-token (consent on file)` - : `agent → person server · resource-token → ${s ?? '…'}`, - req: 'POST the resource token to the person server `token_endpoint` to mint an auth token. `Prefer: wait=45` long-polls — the server may hold the connection up to 45s before returning.', - res: (s) => - s === 202 - ? 'User interaction required before the auth token is issued (`AAuth-Requirement: requirement=interaction`) — the person must approve in a browser; the agent polls the pending `location` until they do.' - : 'Received the auth token — consent was already on file.', + // Both PS hops are registered under the internal step name the agent package + // emits and under the endpoint-shaped spelling, so a rename on that side drops + // the payload into the same narration rather than falling through unlabelled. + person_token_request: PERSON_TOKEN_ENDPOINT, + person_token_endpoint: PERSON_TOKEN_ENDPOINT, + ps_token_request: AUTH_TOKEN_ENDPOINT, + auth_token_endpoint: AUTH_TOKEN_ENDPOINT, + // R3 -02: the fetched vocabulary itself carries per-operation access annotations. + operation_annotations: { + display: 'operation_annotations', + summary: 'vocabulary → agent · per-operation access modes', + info: 'The fetched OpenAPI document annotates operations with the credential each needs (`x-aauth-access-mode`) and whether it draws budget (`x-aauth-budget`). Advisory: the resource may return any `AAuth-Requirement` at runtime, so plan with these but never rely on them.', }, interaction_required: { display: 'interaction_required', @@ -234,6 +275,9 @@ function infoFields(e: AAuthEvent): Record { const out: Record = {} if (typeof e.requirement === 'string') out.requirement = e.requirement if (typeof e.interaction_url === 'string') out.interaction_url = e.interaction_url + // operation_annotations: the per-operation access modes read out of a fetched + // OpenAPI document, already in their JSON form. + if (Array.isArray(e.annotations)) out.annotations = e.annotations // interaction_required: surface the pieces (url, code), the assembled approval_url, // and a scannable QR — so a log-only consumer can render the CTA without // assembling anything itself or scraping stderr. @@ -272,7 +316,7 @@ function responseBody(e: AAuthEvent): unknown { } /** - * `--explain`: the teaching view. Render each mcp-agent event as a pretty JSON + * `--explain`: the teaching view. Render each agent-package event as a pretty JSON * object on stderr, keyed by `step`: * - phase 'start' is buffered (the real signed headers aren't known until the * response arrives) and emitted as part of the request event once 'done' fires; diff --git a/local-keys/README.md b/local-keys/README.md index 3c5f137..a12aad1 100644 --- a/local-keys/README.md +++ b/local-keys/README.md @@ -18,10 +18,12 @@ npm install @aauth/local-keys |---------|-----------|----------|---------| | `yubikey-piv` | ES256, RS256 | Cross-platform | YubiKey slot 9e (no PIN) | | `secure-enclave` | ES256 | macOS (Apple Silicon) | Secure Enclave hardware | -| `software` | EdDSA, ES256 | All | OS keychain | +| `software` | Ed25519, ES256 | All | OS keychain | Hardware keys are always preferred over software keys. If a YubiKey is unplugged, signing automatically falls back to the next available key. +Algorithm identifiers are fully specified per [RFC 9864](https://www.rfc-editor.org/rfc/rfc9864.html), as AAuth -11 §Signature Algorithms requires: `Ed25519`, never the polymorphic `EdDSA`. Every JWK this package emits carries an `alg` that agrees with its `kty`/`crv`, and keys generated before 2.0.0 are normalized on read — see `withFullySpecifiedAlg` / `assertFullySpecifiedAlg`. + ## API ### `createAgentToken(options): Promise` diff --git a/local-keys/package.json b/local-keys/package.json index 5d4360b..99c6418 100644 --- a/local-keys/package.json +++ b/local-keys/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/local-keys", - "version": "1.3.0", + "version": "2.0.0", "description": "Manage AAuth agent signing keys across hardware and software backends", "type": "module", "exports": { @@ -37,7 +37,7 @@ }, "dependencies": { "@napi-rs/keyring": "^1.1.3", - "jose": "^5.0.0" + "jose": "^6.0.0" }, "optionalDependencies": { "@aauth/hardware-keys": "^1.0.0" diff --git a/local-keys/src/__tests__/agent-token-alg.test.ts b/local-keys/src/__tests__/agent-token-alg.test.ts new file mode 100644 index 0000000..386843b --- /dev/null +++ b/local-keys/src/__tests__/agent-token-alg.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { exportJWK, generateKeyPair, importJWK, jwtVerify } from 'jose' +import type { JWK } from 'jose' + +// signAgentToken's key discovery (network + OS keychain + hardware) is not what +// is under test here — the algorithm identifiers it puts on the wire are. +vi.mock('../keychain.js', () => ({ readKeychain: vi.fn() })) +vi.mock('../config.js', () => ({ getAgentConfig: vi.fn(() => null) })) +vi.mock('../resolve-key.js', () => ({ resolveKey: vi.fn() })) + +const { readKeychain } = await import('../keychain.js') +const { resolveKey } = await import('../resolve-key.js') +const { signAgentToken } = await import('../agent-token.js') + +const AGENT_URL = 'https://agent.example' +const SUB = 'aauth:test@agent.example' + +async function edKeychainKey(alg: string): Promise<{ jwk: JWK; publicJwk: JWK }> { + const { publicKey, privateKey } = await generateKeyPair('Ed25519', { crv: 'Ed25519', extractable: true }) + const jwk = await exportJWK(privateKey) + jwk.kid = 'kid-1' + jwk.alg = alg + return { jwk, publicJwk: await exportJWK(publicKey) } +} + +function header(jwt: string): Record { + return JSON.parse(Buffer.from(jwt.split('.')[0], 'base64url').toString()) +} + +beforeEach(() => { + vi.mocked(resolveKey).mockResolvedValue({ + backend: 'software', + keyId: 'kid-1', + kid: 'kid-1', + algorithm: 'Ed25519', + publicJwk: {}, + }) +}) + +describe('agent token algorithm identifiers', () => { + it('signs with alg=Ed25519 and confirms an Ed25519 cnf.jwk', async () => { + const { jwk, publicJwk } = await edKeychainKey('Ed25519') + vi.mocked(readKeychain).mockReturnValue({ current: 'kid-1', keys: { 'kid-1': jwk } }) + + const result = await signAgentToken({ agentUrl: AGENT_URL, sub: SUB }) + const jwt = result.signatureKey.jwt + + expect(header(jwt).alg).toBe('Ed25519') + expect(header(jwt).typ).toBe('aa-agent+jwt') + + const { payload } = await jwtVerify(jwt, await importJWK(publicJwk, 'Ed25519'), { + algorithms: ['Ed25519'], + }) + expect(payload.iss).toBe(AGENT_URL) + expect((payload.cnf as { jwk: JWK }).jwk.alg).toBe('Ed25519') + // The ephemeral private key handed to the HTTP-signature layer. + expect(result.signingKey.alg).toBe('Ed25519') + }) + + it('upgrades a keychain key still stamped with the pre-11 EdDSA', async () => { + const { jwk, publicJwk } = await edKeychainKey('EdDSA') + vi.mocked(readKeychain).mockReturnValue({ current: 'kid-1', keys: { 'kid-1': jwk } }) + + const result = await signAgentToken({ agentUrl: AGENT_URL, sub: SUB }) + const jwt = result.signatureKey.jwt + + expect(header(jwt).alg).toBe('Ed25519') + expect(JSON.stringify(result)).not.toContain('EdDSA') + + const { payload } = await jwtVerify(jwt, await importJWK(publicJwk, 'Ed25519'), { + algorithms: ['Ed25519'], + }) + expect((payload.cnf as { jwk: JWK }).jwk.alg).toBe('Ed25519') + }) + + it('refuses a keychain key whose alg contradicts its curve', async () => { + const { jwk } = await edKeychainKey('ES256') + vi.mocked(readKeychain).mockReturnValue({ current: 'kid-1', keys: { 'kid-1': jwk } }) + + await expect(signAgentToken({ agentUrl: AGENT_URL, sub: SUB })).rejects.toThrow( + /disagrees with/, + ) + }) + + it('never puts EdDSA in a hardware-signed header, even from a pre-11 config', async () => { + // A ~/.aauth/config.json written before -11 records algorithm: "EdDSA". + vi.mocked(resolveKey).mockResolvedValue({ + backend: 'yubikey-piv', + keyId: '9e', + kid: 'kid-hw', + algorithm: 'EdDSA' as 'Ed25519', + publicJwk: {}, + }) + const backends = await import('../backends/index.js') + vi.spyOn(backends, 'getBackend').mockReturnValue({ + discover: () => null, + generateKey: vi.fn(), + signHash: vi.fn(async () => ({ signature: Buffer.alloc(64), algorithm: 'Ed25519' as const })), + listKeys: vi.fn(async () => []), + getPublicKey: vi.fn(), + getDeviceLabel: () => 'test', + }) + + const result = await signAgentToken({ agentUrl: AGENT_URL, sub: SUB }) + expect(header(result.signatureKey.jwt).alg).toBe('Ed25519') + vi.restoreAllMocks() + }) +}) diff --git a/local-keys/src/__tests__/backends.test.ts b/local-keys/src/__tests__/backends.test.ts index 37ccd0f..f435191 100644 --- a/local-keys/src/__tests__/backends.test.ts +++ b/local-keys/src/__tests__/backends.test.ts @@ -12,7 +12,7 @@ describe('Backend Discovery', () => { expect(backends.length).toBeGreaterThanOrEqual(1) const software = backends.find((b) => b.backend === 'software') expect(software).toBeDefined() - expect(software!.algorithms).toContain('EdDSA') + expect(software!.algorithms).toContain('Ed25519') expect(software!.algorithms).toContain('ES256') }) @@ -30,10 +30,10 @@ describe('Backend Discovery', () => { describe('Software Backend', () => { const backend = getBackend('software') - it('generates EdDSA key', async () => { - const key = await backend.generateKey('EdDSA') + it('generates Ed25519 key', async () => { + const key = await backend.generateKey('Ed25519') expect(key.backend).toBe('software') - expect(key.algorithm).toBe('EdDSA') + expect(key.algorithm).toBe('Ed25519') expect(key.keyId).toMatch(/^\d{4}-\d{2}-\d{2}_[0-9a-f]{3}$/) expect(key.publicJwk.kty).toBe('OKP') expect(key.publicJwk.crv).toBe('Ed25519') @@ -201,7 +201,7 @@ describe('Secure Enclave Backend', () => { it.skipIf(!seInfo)('rejects non-ES256 algorithms', async () => { const backend = getBackend('secure-enclave') - await expect(backend.generateKey('EdDSA')).rejects.toThrow( + await expect(backend.generateKey('Ed25519')).rejects.toThrow( 'only supports ES256', ) }) diff --git a/local-keys/src/__tests__/config.test.ts b/local-keys/src/__tests__/config.test.ts index a86e7cc..9db0e0f 100644 --- a/local-keys/src/__tests__/config.test.ts +++ b/local-keys/src/__tests__/config.test.ts @@ -91,7 +91,7 @@ describe('Config', () => { writeConfig({ agents: {} }) addKeyToAgent('https://gone.example', 'kid1', { backend: 'software', - algorithm: 'EdDSA', + algorithm: 'Ed25519', keyId: 'kid1', deviceLabel: 'macbook-pro', }) diff --git a/local-keys/src/__tests__/jwk-alg.test.ts b/local-keys/src/__tests__/jwk-alg.test.ts new file mode 100644 index 0000000..902453e --- /dev/null +++ b/local-keys/src/__tests__/jwk-alg.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest' +import { exportJWK, generateKeyPair } from 'jose' +import { + deriveFullySpecifiedAlg, + assertFullySpecifiedAlg, + hasFullySpecifiedAlg, + withFullySpecifiedAlg, + normalizeAlgId, +} from '../jwk-alg.js' +import { generateKey, toPublicJwk } from '../keygen.js' +import { getBackend } from '../backends/index.js' + +const ed = { kty: 'OKP', crv: 'Ed25519', x: 'x' } +const p256 = { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' } + +describe('deriveFullySpecifiedAlg', () => { + it('derives from curve', () => { + expect(deriveFullySpecifiedAlg(ed)).toBe('Ed25519') + expect(deriveFullySpecifiedAlg({ kty: 'OKP', crv: 'Ed448' })).toBe('Ed448') + expect(deriveFullySpecifiedAlg(p256)).toBe('ES256') + expect(deriveFullySpecifiedAlg({ kty: 'EC', crv: 'P-384' })).toBe('ES384') + expect(deriveFullySpecifiedAlg({ kty: 'EC', crv: 'P-521' })).toBe('ES512') + }) + + it('returns undefined when the material does not determine it', () => { + expect(deriveFullySpecifiedAlg({ kty: 'RSA' })).toBeUndefined() + expect(deriveFullySpecifiedAlg({ kty: 'OKP', crv: 'X25519' })).toBeUndefined() + }) +}) + +describe('assertFullySpecifiedAlg', () => { + it('accepts a fully-specified alg that agrees with the key', () => { + expect(() => assertFullySpecifiedAlg({ ...ed, alg: 'Ed25519' })).not.toThrow() + expect(() => assertFullySpecifiedAlg({ ...p256, alg: 'ES256' })).not.toThrow() + expect(() => assertFullySpecifiedAlg({ kty: 'RSA', alg: 'RS256' })).not.toThrow() + }) + + it('rejects an absent alg', () => { + expect(() => assertFullySpecifiedAlg(ed)).toThrow(/'alg' is absent/) + }) + + it('rejects the polymorphic EdDSA and points at Ed25519', () => { + expect(() => assertFullySpecifiedAlg({ ...ed, alg: 'EdDSA' })).toThrow( + /alg=EdDSA MUST NOT be used — use Ed25519/, + ) + }) + + it('rejects none, symmetric algs and oct keys', () => { + expect(() => assertFullySpecifiedAlg({ ...ed, alg: 'none' })).toThrow(/MUST NOT be used/) + expect(() => assertFullySpecifiedAlg({ kty: 'oct', alg: 'HS256' })).toThrow(/symmetric/) + expect(() => assertFullySpecifiedAlg({ kty: 'EC', crv: 'P-256', alg: 'HS256' })).toThrow( + /MUST NOT be used/, + ) + }) + + it('rejects a key whose crv disagrees with its alg', () => { + expect(() => assertFullySpecifiedAlg({ ...ed, alg: 'ES256' })).toThrow(/disagrees with/) + expect(() => assertFullySpecifiedAlg({ ...p256, alg: 'ES384' })).toThrow(/disagrees with/) + }) + + it('rejects a key whose kty disagrees with its alg', () => { + expect(() => assertFullySpecifiedAlg({ kty: 'RSA', alg: 'ES256' })).toThrow( + /not valid for kty=RSA/, + ) + }) + + it('hasFullySpecifiedAlg mirrors the assertion', () => { + expect(hasFullySpecifiedAlg({ ...ed, alg: 'Ed25519' })).toBe(true) + expect(hasFullySpecifiedAlg({ ...ed, alg: 'EdDSA' })).toBe(false) + expect(hasFullySpecifiedAlg(ed)).toBe(false) + }) +}) + +describe('withFullySpecifiedAlg', () => { + it('fills in a missing alg from the key material', () => { + expect(withFullySpecifiedAlg(ed).alg).toBe('Ed25519') + expect(withFullySpecifiedAlg(p256).alg).toBe('ES256') + }) + + it('upgrades the legacy polymorphic EdDSA', () => { + expect(withFullySpecifiedAlg({ ...ed, alg: 'EdDSA' }).alg).toBe('Ed25519') + }) + + it('leaves an already-correct alg alone and does not mutate its input', () => { + const input = { ...ed, alg: 'Ed25519' } + const out = withFullySpecifiedAlg(input) + expect(out.alg).toBe('Ed25519') + expect(out).not.toBe(input) + expect(input.alg).toBe('Ed25519') + }) + + it('throws rather than rewriting an alg the material contradicts', () => { + expect(() => withFullySpecifiedAlg({ ...ed, alg: 'ES256' })).toThrow(/disagrees with/) + }) + + it('uses the fallback only where the material cannot determine the alg', () => { + expect(withFullySpecifiedAlg({ kty: 'RSA', n: 'n', e: 'AQAB' }, 'RS256').alg).toBe('RS256') + // A fallback never overrides what the curve says. + expect(withFullySpecifiedAlg(ed, 'ES256').alg).toBe('Ed25519') + }) + + it('refuses to guess when nothing determines the alg', () => { + expect(() => withFullySpecifiedAlg({ kty: 'RSA', n: 'n', e: 'AQAB' })).toThrow( + /needs a fully-specified alg/, + ) + }) +}) + +describe('normalizeAlgId', () => { + it('maps the pre-11 config value', () => { + expect(normalizeAlgId('EdDSA')).toBe('Ed25519') + expect(normalizeAlgId('ES256')).toBe('ES256') + expect(normalizeAlgId('Ed25519')).toBe('Ed25519') + }) +}) + +describe('every JWK this package emits carries a fully-specified alg', () => { + it('generateKey (Ed25519)', async () => { + const { privateJwk, publicJwk } = await generateKey('Ed25519') + expect(publicJwk.alg).toBe('Ed25519') + expect(privateJwk.alg).toBe('Ed25519') + expect(() => assertFullySpecifiedAlg(publicJwk)).not.toThrow() + }) + + it('generateKey (ES256)', async () => { + const { privateJwk, publicJwk } = await generateKey('ES256') + expect(publicJwk.alg).toBe('ES256') + expect(privateJwk.alg).toBe('ES256') + }) + + it('toPublicJwk strips `d` and re-derives alg from the curve', async () => { + const { privateJwk } = await generateKey('Ed25519') + // Simulate a key stored before -11. + const stale = { ...privateJwk, alg: 'EdDSA' } + const pub = toPublicJwk(stale) + expect(pub.d).toBeUndefined() + expect(pub.alg).toBe('Ed25519') + expect(pub.use).toBe('sig') + }) + + it('the software backend generates keys with alg=Ed25519', async () => { + const key = await getBackend('software').generateKey('Ed25519') + expect(key.algorithm).toBe('Ed25519') + expect(key.publicJwk.alg).toBe('Ed25519') + expect(() => assertFullySpecifiedAlg(key.publicJwk)).not.toThrow() + }) + + it('jose exportJWK output alone would fail the check', async () => { + // Why the normalization exists: jose emits no `alg` at all. + const { publicKey } = await generateKeyPair('Ed25519', { crv: 'Ed25519', extractable: true }) + const bare = await exportJWK(publicKey) + expect(bare.alg).toBeUndefined() + expect(hasFullySpecifiedAlg(bare)).toBe(false) + expect(withFullySpecifiedAlg(bare).alg).toBe('Ed25519') + }) +}) diff --git a/local-keys/src/agent-token.ts b/local-keys/src/agent-token.ts index efbfe4b..d1a970c 100644 --- a/local-keys/src/agent-token.ts +++ b/local-keys/src/agent-token.ts @@ -1,12 +1,31 @@ import { createHash, randomUUID } from 'node:crypto' import { importJWK, SignJWT, generateKeyPair, exportJWK } from 'jose' -import type { JWK } from 'jose' import { readKeychain } from './keychain.js' import { getAgentConfig } from './config.js' import { getBackend } from './backends/index.js' import { resolveKey } from './resolve-key.js' +import { publicJwkWithAlg, normalizeAlgId } from './jwk-alg.js' import type { SignAgentTokenOptions, AgentTokenResult, ResolvedKey } from './types.js' +/** + * Generate the ephemeral key the agent token confirms in `cnf.jwk`. + * + * Both JWKs carry a fully-specified `alg` (RFC 9864): `@hellocoop/httpsig` 2.0 + * (signature-key -08) takes the signing algorithm from the JWK's `alg` and + * rejects the polymorphic `EdDSA`, and AAuth -11 §Signature Algorithms requires + * the same of every key it conveys. `exportJWK` sets no `alg` at all, so it is + * derived from the key material here. + */ +async function generateEphemeralKey(alg: 'Ed25519' | 'ES256') { + // jose 6: keys are non-extractable by default, and these are exported to JWK below + const opts = alg === 'ES256' ? { crv: 'P-256', extractable: true } : { crv: 'Ed25519', extractable: true } + const { publicKey, privateKey } = await generateKeyPair(alg, opts) + return { + privateJwk: publicJwkWithAlg(await exportJWK(privateKey), alg, 'ephemeral private key'), + publicJwk: publicJwkWithAlg(await exportJWK(publicKey), alg, 'ephemeral public key'), + } +} + /** * Sign an agent token for the given agent URL. * @@ -46,24 +65,20 @@ async function signWithSoftwareKey( throw new Error(`No software keys found in keychain for ${agentUrl}`) } - const rootJwk = data.keys[kid] || data.keys[data.current] - if (!rootJwk) { + const storedJwk = data.keys[kid] || data.keys[data.current] + if (!storedJwk) { throw new Error(`Key ${kid} not found in keychain for ${agentUrl}`) } + // Keys minted before AAuth -11 sit in the keychain with `alg: "EdDSA"`. + // Derive the fully-specified alg from the key material so neither the JWT + // header nor anything downstream ever sees the polymorphic identifier. + const rootJwk = publicJwkWithAlg(storedJwk, undefined, `keychain key ${kid}`) const actualKid = rootJwk.kid || kid - const alg = rootJwk.alg || (rootJwk.crv === 'P-256' ? 'ES256' : 'EdDSA') - - const ephAlg = alg === 'ES256' ? 'ES256' : 'EdDSA' - const ephOpts = alg === 'ES256' ? { crv: 'P-256' } : { crv: 'Ed25519' } - const { publicKey: ephPub, privateKey: ephPriv } = await generateKeyPair(ephAlg, ephOpts) - const ephPrivJwk = await exportJWK(ephPriv) - const ephPubJwk = await exportJWK(ephPub) - // @hellocoop/httpsig 2.0 (signature-key -08 / RFC 9864) requires every JWK to - // carry a fully-specified alg; the polymorphic 'EdDSA' is rejected. - const ephJwkAlg = ephAlg === 'ES256' ? 'ES256' : 'Ed25519' - ephPrivJwk.alg = ephJwkAlg - ephPubJwk.alg = ephJwkAlg + const alg = rootJwk.alg as string + + const ephAlg = alg === 'ES256' ? 'ES256' : 'Ed25519' + const { privateJwk: ephPrivJwk, publicJwk: ephPubJwk } = await generateEphemeralKey(ephAlg) const rootKey = await importJWK(rootJwk, alg) const now = Math.floor(Date.now() / 1000) @@ -101,19 +116,12 @@ async function signWithHardwareKey( const { agentUrl, sub, lifetime, personServerUrl } = opts const driver = getBackend(resolved.backend) - const alg = resolved.algorithm === 'RS256' ? 'RS256' : resolved.algorithm - - // Ephemeral signing key — always software, always ES256 or EdDSA - const ephAlg = alg === 'RS256' ? 'ES256' : alg - const ephOpts = ephAlg === 'ES256' ? { crv: 'P-256' } : { crv: 'Ed25519' } - const { publicKey: ephPub, privateKey: ephPriv } = await generateKeyPair(ephAlg, ephOpts) - const ephPrivJwk = await exportJWK(ephPriv) - const ephPubJwk = await exportJWK(ephPub) - // @hellocoop/httpsig 2.0 (signature-key -08 / RFC 9864) requires every JWK to - // carry a fully-specified alg; the polymorphic 'EdDSA' is rejected. - const ephJwkAlg = ephAlg === 'ES256' ? 'ES256' : 'Ed25519' - ephPrivJwk.alg = ephJwkAlg - ephPubJwk.alg = ephJwkAlg + // A pre-11 config entry can still say "EdDSA"; never put that in a header. + const alg = normalizeAlgId(resolved.algorithm) + + // Ephemeral signing key — always software, always ES256 or Ed25519 + const ephAlg = alg === 'ES256' || alg === 'RS256' ? 'ES256' : 'Ed25519' + const { privateJwk: ephPrivJwk, publicJwk: ephPubJwk } = await generateEphemeralKey(ephAlg) const now = Math.floor(Date.now() / 1000) diff --git a/local-keys/src/backends/secure-enclave.ts b/local-keys/src/backends/secure-enclave.ts index baeceb2..69be2fc 100644 --- a/local-keys/src/backends/secure-enclave.ts +++ b/local-keys/src/backends/secure-enclave.ts @@ -5,6 +5,7 @@ import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import type { JWK } from 'jose' import { machineLabel } from '../device-label.js' +import { publicJwkWithAlg } from '../jwk-alg.js' import type { BackendInfo, KeyReference, @@ -95,7 +96,8 @@ export const secureEnclaveBackend: KeyBackendDriver = { const label = `com.aauth.agent.${date}_${hex}` const result = callHelper('generate', label) as Record - const publicJwk = result.publicJwk as JWK + // se-helper emits a bare EC JWK with no `alg`; AAuth -11 requires one. + const publicJwk = publicJwkWithAlg(result.publicJwk as JWK, 'ES256', 'secure enclave key') return { backend: 'secure-enclave', @@ -138,7 +140,7 @@ export const secureEnclaveBackend: KeyBackendDriver = { async getPublicKey(keyId: string): Promise { const result = callHelper('public-key', keyId) as Record - return result.publicJwk as JWK + return publicJwkWithAlg(result.publicJwk as JWK, 'ES256', `secure enclave key ${keyId}`) }, getDeviceLabel(): string { diff --git a/local-keys/src/backends/software.ts b/local-keys/src/backends/software.ts index 1a4e581..b2c16f8 100644 --- a/local-keys/src/backends/software.ts +++ b/local-keys/src/backends/software.ts @@ -2,6 +2,7 @@ import { generateKeyPair, exportJWK } from 'jose' import type { JWK } from 'jose' import { readKeychain, writeKeychain, deleteKeychain, listAgentUrls } from '../keychain.js' import { machineLabel } from '../device-label.js' +import { publicJwkWithAlg } from '../jwk-alg.js' import type { BackendInfo, KeyReference, @@ -23,7 +24,7 @@ export const softwareBackend: KeyBackendDriver = { return { backend: 'software', description: 'Software keys stored in OS keychain', - algorithms: ['EdDSA', 'ES256'], + algorithms: ['Ed25519', 'ES256'], deviceId: 'local', } }, @@ -31,27 +32,27 @@ export const softwareBackend: KeyBackendDriver = { async generateKey(algorithm: KeyAlgorithm): Promise { const kid = generateKid() let alg: string - let opts: Record + let opts: { crv: string; extractable: boolean } - if (algorithm === 'EdDSA') { - alg = 'EdDSA' - opts = { crv: 'Ed25519' } + if (algorithm === 'Ed25519') { + alg = 'Ed25519' + // jose 6: keys are non-extractable by default, and these are exported to JWK below + opts = { crv: 'Ed25519', extractable: true } } else if (algorithm === 'ES256') { alg = 'ES256' - opts = { crv: 'P-256' } + opts = { crv: 'P-256', extractable: true } } else { throw new Error(`Software backend does not support ${algorithm}`) } const { publicKey, privateKey } = await generateKeyPair(alg, opts) - const privateJwk = await exportJWK(privateKey) - const publicJwk = await exportJWK(publicKey) + // Fully-specified alg (RFC 9864) — `Ed25519`, never the polymorphic `EdDSA`. + const privateJwk = publicJwkWithAlg(await exportJWK(privateKey), alg, 'generated private key') + const publicJwk = publicJwkWithAlg(await exportJWK(publicKey), alg, 'generated public key') privateJwk.kid = kid - privateJwk.alg = alg privateJwk.use = 'sig' publicJwk.kid = kid - publicJwk.alg = alg publicJwk.use = 'sig' return { @@ -81,15 +82,21 @@ export const softwareBackend: KeyBackendDriver = { const data = readKeychain(url) if (!data) continue for (const [kid, jwk] of Object.entries(data.keys)) { - const alg: KeyAlgorithm = - jwk.crv === 'P-256' ? 'ES256' : 'EdDSA' + // The keychain may hold pre-11 JWKs stamped `alg: "EdDSA"`; derive the + // fully-specified alg from the key material rather than trusting it. + const alg: KeyAlgorithm = jwk.crv === 'P-256' ? 'ES256' : 'Ed25519' const { d: _d, ...pub } = jwk - refs.push({ - backend: 'software', - algorithm: alg, - keyId: kid, - publicJwk: { ...pub, use: 'sig', alg: alg === 'ES256' ? 'ES256' : 'EdDSA' }, - }) + try { + refs.push({ + backend: 'software', + algorithm: alg, + keyId: kid, + publicJwk: { ...publicJwkWithAlg(pub, alg, `keychain key ${kid}`), use: 'sig' }, + }) + } catch { + // Key material we can't pin a fully-specified alg to is unusable + // under AAuth -11 — skip it rather than emitting a polymorphic alg. + } } } return refs @@ -103,7 +110,9 @@ export const softwareBackend: KeyBackendDriver = { const jwk = data.keys[keyId] if (jwk) { const { d: _d, ...pub } = jwk - return { ...pub, use: 'sig' } + // Re-derive `alg`: a key stored before -11 carries `EdDSA`, and callers + // hand this JWK straight to a verifier that rejects it. + return { ...publicJwkWithAlg(pub, undefined, `keychain key ${keyId}`), use: 'sig' } } } throw new Error(`Software key not found: ${keyId}`) diff --git a/local-keys/src/backends/yubikey-piv.ts b/local-keys/src/backends/yubikey-piv.ts index 22ca786..0a29586 100644 --- a/local-keys/src/backends/yubikey-piv.ts +++ b/local-keys/src/backends/yubikey-piv.ts @@ -3,6 +3,7 @@ import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' import type { JWK } from 'jose' import { yubikeyLabel } from '../device-label.js' +import { publicJwkWithAlg } from '../jwk-alg.js' import type { BackendInfo, KeyReference, @@ -85,7 +86,12 @@ export const yubikeyPivBackend: KeyBackendDriver = { const algStr = algorithm === 'RS256' ? 'RS256' : 'ES256' const result = addon.generateKey('yubikey-piv', algStr) - const publicJwk = JSON.parse(result.publicJwk) as JWK + // The native addon emits a bare JWK with no `alg`; AAuth -11 requires one. + const publicJwk = publicJwkWithAlg( + JSON.parse(result.publicJwk) as JWK, + algStr, + `yubikey-piv key ${result.keyId}`, + ) return { backend: 'yubikey-piv', @@ -115,12 +121,24 @@ export const yubikeyPivBackend: KeyBackendDriver = { try { const keys = addon.listKeys('yubikey-piv') - return keys.map((k) => ({ - backend: 'yubikey-piv' as const, - algorithm: k.algorithm as KeyAlgorithm, - keyId: k.keyId, - publicJwk: JSON.parse(k.publicJwk) as JWK, - })) + const refs: KeyReference[] = [] + for (const k of keys) { + try { + refs.push({ + backend: 'yubikey-piv' as const, + algorithm: k.algorithm as KeyAlgorithm, + keyId: k.keyId, + publicJwk: publicJwkWithAlg( + JSON.parse(k.publicJwk) as JWK, + k.algorithm, + `yubikey-piv key ${k.keyId}`, + ), + }) + } catch { + // No fully-specified alg derivable — unusable under AAuth -11. + } + } + return refs } catch { return [] } diff --git a/local-keys/src/index.ts b/local-keys/src/index.ts index 0cfbb0d..340965b 100644 --- a/local-keys/src/index.ts +++ b/local-keys/src/index.ts @@ -24,8 +24,21 @@ export { writeCachedMetadata, evictCachedMetadata, parseMaxAge, + isPersonServerMetadata, + missingPersonServerMembers, PS_METADATA_FILE, + CACHE_SCHEMA_VERSION, } from './metadata-cache.js' +export type { PersonServerMetadata } from './metadata-cache.js' +export { + deriveFullySpecifiedAlg, + assertFullySpecifiedAlg, + hasFullySpecifiedAlg, + withFullySpecifiedAlg, + publicJwkWithAlg, + normalizeAlgId, +} from './jwk-alg.js' +export type { FullySpecifiedAlg, AlgBearingJwk } from './jwk-alg.js' export { resolveKey, checkKeyAvailability } from './resolve-key.js' export { machineLabel, yubikeyLabel } from './device-label.js' export { KeyDeletionUnsupportedError } from './types.js' diff --git a/local-keys/src/jwk-alg.ts b/local-keys/src/jwk-alg.ts new file mode 100644 index 0000000..1d87f4c --- /dev/null +++ b/local-keys/src/jwk-alg.ts @@ -0,0 +1,186 @@ +import type { JWK } from 'jose' + +/** + * Fully-specified JWS algorithm identifiers (RFC 9864) for the key types AAuth + * conveys. + * + * AAuth protocol §Signature Algorithms: + * + * - The `alg` member MUST be present and MUST be a fully-specified identifier — + * one that determines the signature operation completely, including curve and + * hash where applicable. A verifier MUST reject a key whose `alg` is absent. + * - The polymorphic `EdDSA` identifier MUST NOT be used. Use `Ed25519` (or + * `Ed448`), which RFC 9864 registered as its fully-specified replacements. + * - `none`, any algorithm whose JOSE Implementation Requirement is `Prohibited`, + * and symmetric algorithms (`oct`, `HS256`, `HS384`, `HS512`) MUST NOT be used. + * - A verifier MUST reject a key whose `kty` or, where present, `crv` disagrees + * with its `alg`. + * + * This package is where AAuth agent keys are generated, stored and read back, so + * it owns the rule rather than each consumer patching keys up on the way out. + * `@aauth/proxy` carried a private `withFullySpecifiedAlg` for exactly this; it + * should import this one instead — the version here also enforces the last rule + * (kty/crv vs. alg disagreement), which the proxy copy did not. + */ +export type FullySpecifiedAlg = + | 'Ed25519' + | 'Ed448' + | 'ES256' + | 'ES384' + | 'ES512' + | 'RS256' + | 'RS384' + | 'RS512' + | 'PS256' + | 'PS384' + | 'PS512' + +/** Minimal shape needed to reason about a key's algorithm. */ +export interface AlgBearingJwk { + kty?: string + crv?: string + alg?: string +} + +/** `alg` values this profile forbids outright, whatever the key looks like. */ +const PROHIBITED_ALGS = new Set([ + 'none', + 'EdDSA', // polymorphic — RFC 9864 deprecated it in favour of Ed25519 / Ed448 + 'HS256', + 'HS384', + 'HS512', + 'RSA1_5', +]) + +/** `alg` values that are fully specified but not derivable from `kty` alone. */ +const RSA_ALGS = new Set(['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512']) + +const OKP_CURVE_ALG: Record = { + Ed25519: 'Ed25519', + Ed448: 'Ed448', +} + +const EC_CURVE_ALG: Record = { + 'P-256': 'ES256', + 'P-384': 'ES384', + 'P-521': 'ES512', +} + +/** + * The one fully-specified `alg` the key material implies, or `undefined` when + * the material does not determine it (RSA leaves padding and hash open). + */ +export function deriveFullySpecifiedAlg(jwk: AlgBearingJwk): FullySpecifiedAlg | undefined { + if (jwk.kty === 'OKP' && jwk.crv) return OKP_CURVE_ALG[jwk.crv] + if (jwk.kty === 'EC' && jwk.crv) return EC_CURVE_ALG[jwk.crv] + return undefined +} + +/** + * Verifier-side check. Throws when the key violates §Signature Algorithms: + * absent `alg`, a polymorphic/prohibited/symmetric `alg`, or an `alg` that + * disagrees with the key's own `kty`/`crv`. + */ +export function assertFullySpecifiedAlg(jwk: AlgBearingJwk, context = 'JWK'): void { + if (jwk.kty === 'oct') { + throw new Error(`${context}: symmetric keys (kty=oct) MUST NOT be used`) + } + if (!jwk.alg) { + throw new Error( + `${context}: 'alg' is absent; AAuth requires a fully-specified alg (RFC 9864)`, + ) + } + if (PROHIBITED_ALGS.has(jwk.alg)) { + const hint = + jwk.alg === 'EdDSA' + ? ` — use ${deriveFullySpecifiedAlg(jwk) ?? 'Ed25519'} instead` + : '' + throw new Error(`${context}: alg=${jwk.alg} MUST NOT be used${hint}`) + } + + const derived = deriveFullySpecifiedAlg(jwk) + if (derived) { + if (jwk.alg !== derived) { + throw new Error( + `${context}: alg=${jwk.alg} disagrees with kty=${jwk.kty} crv=${jwk.crv} (expected ${derived})`, + ) + } + return + } + + if (jwk.kty === 'RSA') { + if (!RSA_ALGS.has(jwk.alg)) { + throw new Error(`${context}: alg=${jwk.alg} is not valid for kty=RSA`) + } + return + } + + throw new Error( + `${context}: cannot validate alg=${jwk.alg} against kty=${jwk.kty} crv=${jwk.crv}`, + ) +} + +/** True when the key satisfies {@link assertFullySpecifiedAlg}. */ +export function hasFullySpecifiedAlg(jwk: AlgBearingJwk): boolean { + try { + assertFullySpecifiedAlg(jwk) + return true + } catch { + return false + } +} + +/** + * Return the key with a fully-specified `alg`, deriving it from `kty`/`crv`. + * + * A legacy `alg` the key material contradicts — notably the polymorphic `EdDSA` + * on an Ed25519 key, which is what every key this package minted before AAuth + * -11 carries — is replaced by the derived value. A contradiction the material + * does *not* explain (`alg: ES256` on an Ed25519 key) is a corrupt or + * substituted key and throws rather than being silently rewritten. + * + * When the material does not determine the algorithm (RSA), `fallbackAlg` is + * used; without one, this throws rather than guessing. + */ +export function withFullySpecifiedAlg( + jwk: T, + fallbackAlg?: string, + context = 'JWK', +): T { + const derived = deriveFullySpecifiedAlg(jwk) + + if (derived) { + if (jwk.alg && jwk.alg !== derived && !PROHIBITED_ALGS.has(jwk.alg)) { + throw new Error( + `${context}: alg=${jwk.alg} disagrees with kty=${jwk.kty} crv=${jwk.crv} (expected ${derived})`, + ) + } + return { ...jwk, alg: derived } + } + + const candidate = jwk.alg && !PROHIBITED_ALGS.has(jwk.alg) ? jwk.alg : fallbackAlg + if (candidate && jwk.kty === 'RSA' && RSA_ALGS.has(candidate)) { + return { ...jwk, alg: candidate } + } + + throw new Error( + `${context}: needs a fully-specified alg (RFC 9864); cannot derive one from ` + + `kty=${jwk.kty} crv=${jwk.crv}`, + ) +} + +/** {@link withFullySpecifiedAlg} for a `jose` JWK. */ +export function publicJwkWithAlg(jwk: JWK, fallbackAlg?: string, context = 'JWK'): JWK { + return withFullySpecifiedAlg(jwk as AlgBearingJwk, fallbackAlg, context) as JWK +} + +/** + * Map a stored algorithm identifier onto its fully-specified form. + * + * `~/.aauth/config.json` written before AAuth -11 records `algorithm: "EdDSA"` + * for software Ed25519 keys. Reading it back must not put `EdDSA` into a JWT + * header or a JWK, so normalize at the boundary instead of migrating the file. + */ +export function normalizeAlgId(alg: string): string { + return alg === 'EdDSA' ? 'Ed25519' : alg +} diff --git a/local-keys/src/keygen.ts b/local-keys/src/keygen.ts index 044870c..f8f0d59 100644 --- a/local-keys/src/keygen.ts +++ b/local-keys/src/keygen.ts @@ -1,5 +1,6 @@ import { generateKeyPair, exportJWK } from 'jose' import type { JWK } from 'jose' +import { publicJwkWithAlg } from './jwk-alg.js' import type { GeneratedKeyPair } from './types.js' export function generateKid(): string { @@ -11,30 +12,38 @@ export function generateKid(): string { return `${date}_${hex}` } +/** + * Generate an agent signing key. + * + * The emitted JWKs carry a fully-specified `alg` (RFC 9864) — `Ed25519`, never + * the polymorphic `EdDSA`. These keys are published in the agent server's + * `jwks.json`, where a -11 verifier rejects anything less. + */ export async function generateKey( - algorithm: 'EdDSA' | 'ES256' = 'EdDSA', + algorithm: 'Ed25519' | 'ES256' = 'Ed25519', ): Promise { const kid = generateKid() - const alg = algorithm === 'ES256' ? 'ES256' : 'EdDSA' - const opts = alg === 'ES256' ? { crv: 'P-256' } : { crv: 'Ed25519' } + const alg = algorithm === 'ES256' ? 'ES256' : 'Ed25519' + // jose 6: keys are non-extractable by default, and these are exported to JWK below + const opts = alg === 'ES256' ? { crv: 'P-256', extractable: true } : { crv: 'Ed25519', extractable: true } const { publicKey, privateKey } = await generateKeyPair(alg, opts) - const privateJwk = await exportJWK(privateKey) - const publicJwk = await exportJWK(publicKey) + const privateJwk = publicJwkWithAlg(await exportJWK(privateKey), alg, 'generated private key') + const publicJwk = publicJwkWithAlg(await exportJWK(publicKey), alg, 'generated public key') privateJwk.kid = kid - privateJwk.alg = alg privateJwk.use = 'sig' publicJwk.kid = kid - publicJwk.alg = alg publicJwk.use = 'sig' return { privateJwk, publicJwk } } -/** Strip private material from a JWK, deriving `alg` from the curve. */ +/** + * Strip private material from a JWK and give it a fully-specified `alg` derived + * from the key material. Throws when the stored `alg` contradicts the curve. + */ export function toPublicJwk(jwk: JWK): JWK { const { d: _d, ...pub } = jwk - const alg = pub.alg ?? (pub.crv === 'P-256' ? 'ES256' : 'EdDSA') - return { ...pub, use: 'sig', alg } + return { ...publicJwkWithAlg(pub, undefined, 'public JWK'), use: 'sig' } } diff --git a/local-keys/src/metadata-cache.test.ts b/local-keys/src/metadata-cache.test.ts index f46f1b7..eba5ef5 100644 --- a/local-keys/src/metadata-cache.test.ts +++ b/local-keys/src/metadata-cache.test.ts @@ -1,15 +1,65 @@ import { describe, it, expect, afterEach, vi } from 'vitest' +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' import { readCachedMetadata, writeCachedMetadata, evictCachedMetadata, parseMaxAge, + isPersonServerMetadata, + missingPersonServerMembers, + CACHE_SCHEMA_VERSION, + PS_METADATA_FILE, } from './metadata-cache.js' // A host unlikely to collide with a real cached PS. Round-trips go through the // real ~/.aauth/cache/, so every test evicts its entry afterwards. const HOST = 'cache-test.invalid' -const doc = { token_endpoint: 'https://cache-test.invalid/aauth/token', jwks_uri: 'x', issuer: 'y' } + +/** A valid AAuth -11 person server metadata document (#ps-metadata). */ +const doc = { + issuer: 'https://cache-test.invalid', + auth_token_endpoint: 'https://cache-test.invalid/aauth/token/auth', + person_token_endpoint: 'https://cache-test.invalid/aauth/token/person', + jwks_uri: 'https://cache-test.invalid/.well-known/jwks.json', +} + +/** What a pre-11 entry looks like: `token_endpoint`, no person token endpoint. */ +const legacyDoc = { + issuer: 'https://cache-test.invalid', + token_endpoint: 'https://cache-test.invalid/aauth/token', + jwks_uri: 'https://cache-test.invalid/.well-known/jwks.json', +} + +const CACHE_DIR = join(homedir(), '.aauth', 'cache') +const INDEX_FILE = join(CACHE_DIR, 'index.json') +const hostDir = join(CACHE_DIR, HOST.replace(/\./g, '-')) + +/** Plant an entry exactly as a pre-11 @aauth/local-keys would have written it. */ +function plantLegacyEntry(): void { + mkdirSync(hostDir, { recursive: true }) + writeFileSync(join(hostDir, PS_METADATA_FILE), JSON.stringify(legacyDoc, null, 2) + '\n') + let index: Record = {} + try { + index = JSON.parse(readFileSync(INDEX_FILE, 'utf-8')) + } catch { /* no index yet */ } + // No `schema` member — that is what makes it pre-11. + index[`${HOST.replace(/\./g, '-')}/${PS_METADATA_FILE}`] = { + expires_at: Math.floor(Date.now() / 1000) + 3600, + } + mkdirSync(CACHE_DIR, { recursive: true }) + writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2) + '\n') +} + +function indexEntry(): { expires_at: number; schema?: number } | undefined { + try { + const index = JSON.parse(readFileSync(INDEX_FILE, 'utf-8')) + return index[`${HOST.replace(/\./g, '-')}/${PS_METADATA_FILE}`] + } catch { + return undefined + } +} afterEach(() => { evictCachedMetadata(HOST) @@ -32,12 +82,51 @@ describe('parseMaxAge', () => { }) }) +describe('person server metadata shape (-11)', () => { + it('accepts a document with both token endpoints', () => { + expect(isPersonServerMetadata(doc)).toBe(true) + expect(missingPersonServerMembers(doc)).toEqual([]) + }) + + it('rejects a pre-11 document carrying token_endpoint', () => { + expect(isPersonServerMetadata(legacyDoc)).toBe(false) + expect(missingPersonServerMembers(legacyDoc)).toEqual([ + 'auth_token_endpoint', + 'person_token_endpoint', + ]) + }) + + it('rejects a document with auth_token_endpoint but no person_token_endpoint', () => { + const { person_token_endpoint, ...rest } = doc + void person_token_endpoint + expect(isPersonServerMetadata(rest)).toBe(false) + expect(missingPersonServerMembers(rest)).toEqual(['person_token_endpoint']) + }) + + it('rejects non-objects', () => { + expect(isPersonServerMetadata(null)).toBe(false) + expect(isPersonServerMetadata('nope')).toBe(false) + expect(missingPersonServerMembers(undefined)).toHaveLength(4) + }) +}) + describe('metadata cache round-trip', () => { it('writes then reads back the exact doc', () => { writeCachedMetadata(HOST, doc, 3600) expect(readCachedMetadata(HOST)).toEqual(doc) }) + it('preserves members it does not know about', () => { + const extended = { ...doc, mission_endpoint: 'https://cache-test.invalid/m', future_member: 1 } + writeCachedMetadata(HOST, extended, 3600) + expect(readCachedMetadata(HOST)).toEqual(extended) + }) + + it('stamps the current schema version on write', () => { + writeCachedMetadata(HOST, doc, 3600) + expect(indexEntry()?.schema).toBe(CACHE_SCHEMA_VERSION) + }) + it('returns null after the entry expires', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) @@ -57,3 +146,62 @@ describe('metadata cache round-trip', () => { expect(readCachedMetadata(HOST)).toBeNull() }) }) + +describe('-11 cache invalidation', () => { + it('reports a miss for a pre-11 entry rather than reading token_endpoint', () => { + plantLegacyEntry() + // The entry is unexpired and the doc parses; only the shape is stale. + expect(readCachedMetadata(HOST)).toBeNull() + }) + + it('evicts the pre-11 entry so the miss is not permanent', () => { + plantLegacyEntry() + readCachedMetadata(HOST) + expect(indexEntry()).toBeUndefined() + + // A refetch repopulates it in the -11 shape. + writeCachedMetadata(HOST, doc, 3600) + expect(readCachedMetadata(HOST)).toEqual(doc) + }) + + it('evicts an entry stamped with a future schema version', () => { + writeCachedMetadata(HOST, doc, 3600) + const index = JSON.parse(readFileSync(INDEX_FILE, 'utf-8')) + index[`${HOST.replace(/\./g, '-')}/${PS_METADATA_FILE}`].schema = CACHE_SCHEMA_VERSION + 1 + writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2) + '\n') + + expect(readCachedMetadata(HOST)).toBeNull() + expect(indexEntry()).toBeUndefined() + }) + + it('misses on a current-schema entry whose body is not -11 metadata', () => { + // Belt and braces: the schema stamp says 2 but the body is pre-11. + writeCachedMetadata(HOST, doc, 3600) + writeFileSync(join(hostDir, PS_METADATA_FILE), JSON.stringify(legacyDoc, null, 2) + '\n') + + expect(readCachedMetadata(HOST)).toBeNull() + expect(indexEntry()).toBeUndefined() + }) +}) + +describe('write rejects non-conforming PS metadata', () => { + it('names the -10 → -11 rename when the doc carries token_endpoint', () => { + expect(() => writeCachedMetadata(HOST, legacyDoc, 3600)).toThrow( + /pre-11 'token_endpoint', renamed to 'auth_token_endpoint'/, + ) + expect(readCachedMetadata(HOST)).toBeNull() + }) + + it('rejects a PS with no person_token_endpoint', () => { + const { person_token_endpoint, ...rest } = doc + void person_token_endpoint + expect(() => writeCachedMetadata(HOST, rest, 3600)).toThrow(/missing person_token_endpoint/) + }) + + it('does not validate the shape of other cached files', () => { + const other = { anything: true } + writeCachedMetadata(HOST, other, 3600, 'aauth-resource.json') + expect(readCachedMetadata(HOST, 'aauth-resource.json')).toEqual(other) + evictCachedMetadata(HOST, 'aauth-resource.json') + }) +}) diff --git a/local-keys/src/metadata-cache.ts b/local-keys/src/metadata-cache.ts index 9aafdd4..98d32c2 100644 --- a/local-keys/src/metadata-cache.ts +++ b/local-keys/src/metadata-cache.ts @@ -10,11 +10,35 @@ import { randomUUID } from 'node:crypto' * * Layout (`~/.aauth/cache/`, alongside `config.json`): * cache//aauth-person.json ← the raw fetched doc - * cache/index.json ← { "/": { expires_at } } + * cache/index.json ← { "/": { expires_at, schema } } * * Freshness is purely time-based: use the cached doc while `now < expires_at`, * otherwise refetch. `expires_at` = fetch time + the server's `Cache-Control: * max-age` if it sent one, else a 1-day default. No etag/revalidation. + * + * ## Schema version — AAuth -11 + * + * -11 renamed the PS metadata field `token_endpoint` to `auth_token_endpoint` + * and added `person_token_endpoint` as REQUIRED (#ps-metadata). Entries written + * before -11 therefore hold a document whose auth token endpoint is under a name + * no -11 reader looks at, and which has no person token endpoint at all. + * + * Those entries are **invalidated, not migrated**. Migration is not possible in + * the direction that matters: `person_token_endpoint` is REQUIRED and there is + * nothing in a pre-11 document to derive it from, so a "migrated" entry would be + * a document that still fails -11 validation while now looking current. The + * rename alone is mechanical, but half a migration is worse than none — the + * whole point is that a cache holding `token_endpoint` must never read back as a + * PS with no auth token endpoint. The cache is a latency optimization over a + * public document, so the cost of invalidating is one HTTP GET per PS. + * + * Two independent gates enforce that: + * + * 1. every entry carries `schema`, and a read at any other version evicts the + * entry and reports a miss; and + * 2. reads and writes of `aauth-person.json` validate the document shape, so an + * entry that somehow carries the right version but the wrong body is still a + * miss. */ const CACHE_DIR = join(homedir(), '.aauth', 'cache') @@ -24,8 +48,62 @@ const DEFAULT_TTL_SECONDS = 24 * 60 * 60 /** Standard filename for person-server metadata. */ export const PS_METADATA_FILE = 'aauth-person.json' +/** + * Stored-shape version. Bumped to 2 for AAuth -11 (`auth_token_endpoint` + + * `person_token_endpoint`). Entries at any other version are evicted on read. + */ +export const CACHE_SCHEMA_VERSION = 2 + +/** + * Person Server metadata, `/.well-known/aauth-person.json` (AAuth -11 + * #ps-metadata). Unknown members are preserved verbatim — the cache stores what + * the server sent. + */ +export interface PersonServerMetadata { + /** REQUIRED. The PS's HTTPS URL; the `iss` of tokens it issues. */ + issuer: string + /** REQUIRED. Where agents send token requests. Renamed from `token_endpoint` in -11. */ + auth_token_endpoint: string + /** REQUIRED, new in -11. Where agents request a person token for a resource. */ + person_token_endpoint: string + /** REQUIRED. The PS's JWKS. */ + jwks_uri: string + + name?: string + description?: string + logo_uri?: string + logo_dark_uri?: string + documentation_uri?: string + tos_uri?: string + policy_uri?: string + mission_endpoint?: string + permission_endpoint?: string + audit_endpoint?: string + interaction_endpoint?: string + mission_control_endpoint?: string + revocation_endpoint?: string + scopes_supported?: string[] + claims_supported?: string[] + + [member: string]: unknown +} + +/** The members #ps-metadata marks REQUIRED. */ +const PS_REQUIRED_MEMBERS = [ + 'issuer', + 'auth_token_endpoint', + 'person_token_endpoint', + 'jwks_uri', +] as const + +interface CacheIndexEntry { + expires_at: number + /** {@link CACHE_SCHEMA_VERSION} at write time. Absent on pre-11 entries. */ + schema?: number +} + interface CacheIndex { - [key: string]: { expires_at: number } + [key: string]: CacheIndexEntry } /** Hostname → cache dir segment, dots→dashes: `person.hello.coop` → `person-hello-coop`. */ @@ -68,23 +146,99 @@ export function parseMaxAge(cacheControl: string | null | undefined): number | u return m ? parseInt(m[1], 10) : undefined } -/** The cached doc if present and not expired, else null. */ -export function readCachedMetadata(host: string, file = PS_METADATA_FILE): unknown | null { +/** + * Members of {@link PS_REQUIRED_MEMBERS} the document is missing. Empty when the + * document is a valid -11 PS metadata document. + */ +export function missingPersonServerMembers(doc: unknown): string[] { + if (!doc || typeof doc !== 'object') return [...PS_REQUIRED_MEMBERS] + const d = doc as Record + return PS_REQUIRED_MEMBERS.filter((m) => typeof d[m] !== 'string' || d[m] === '') +} + +/** Type guard for a document that satisfies -11 #ps-metadata. */ +export function isPersonServerMetadata(doc: unknown): doc is PersonServerMetadata { + return missingPersonServerMembers(doc).length === 0 +} + +/** + * Throw a diagnosis for a document that is not valid -11 PS metadata, calling + * out the -10 → -11 rename when the document still carries `token_endpoint`. + */ +function rejectPersonServerDoc(doc: unknown, missing: string[], what: string): never { + const legacy = + doc && typeof doc === 'object' && typeof (doc as Record).token_endpoint === 'string' + ? " — it carries the pre-11 'token_endpoint', renamed to 'auth_token_endpoint' in AAuth -11" + : '' + throw new Error( + `${what} is not valid AAuth -11 person server metadata: missing ${missing.join(', ')}${legacy}`, + ) +} + +/** + * The cached doc if present, current-schema, unexpired and well-formed, else + * null. A stale-schema or malformed entry is evicted so the next fetch replaces + * it rather than the miss recurring forever. + */ +export function readCachedMetadata( + host: string, + file = PS_METADATA_FILE, +): T | null { const entry = readIndex()[entryKey(host, file)] - if (!entry || Math.floor(Date.now() / 1000) >= entry.expires_at) return null + if (!entry) return null + + // Gate 1: stored shape. Pre-11 entries have no `schema` at all. + if (entry.schema !== CACHE_SCHEMA_VERSION) { + evictCachedMetadata(host, file) + return null + } + + if (Math.floor(Date.now() / 1000) >= entry.expires_at) return null + + let doc: unknown try { - return JSON.parse(readFileSync(docPath(host, file), 'utf-8')) + doc = JSON.parse(readFileSync(docPath(host, file), 'utf-8')) } catch { return null } + + // Gate 2: document shape, for the one file whose shape this package knows. + if (file === PS_METADATA_FILE && !isPersonServerMetadata(doc)) { + evictCachedMetadata(host, file) + return null + } + + return doc as T } -/** Persist a fetched doc + its expiry (server `max-age` if given, else the 1-day default). */ -export function writeCachedMetadata(host: string, doc: unknown, maxAgeSeconds?: number, file = PS_METADATA_FILE): void { +/** + * Persist a fetched doc + its expiry (server `max-age` if given, else the 1-day + * default). + * + * Writing `aauth-person.json` throws when the document is not valid -11 PS + * metadata: a PS without a `person_token_endpoint` is not one this stack can + * use, and caching it would only defer the failure to a later read. + */ +export function writeCachedMetadata( + host: string, + doc: unknown, + maxAgeSeconds?: number, + file = PS_METADATA_FILE, +): void { + if (file === PS_METADATA_FILE) { + const missing = missingPersonServerMembers(doc) + if (missing.length > 0) { + rejectPersonServerDoc(doc, missing, `Person server metadata for ${host}`) + } + } + atomicWrite(docPath(host, file), JSON.stringify(doc, null, 2) + '\n') const index = readIndex() const ttl = maxAgeSeconds && maxAgeSeconds > 0 ? maxAgeSeconds : DEFAULT_TTL_SECONDS - index[entryKey(host, file)] = { expires_at: Math.floor(Date.now() / 1000) + ttl } + index[entryKey(host, file)] = { + expires_at: Math.floor(Date.now() / 1000) + ttl, + schema: CACHE_SCHEMA_VERSION, + } writeIndex(index) } diff --git a/local-keys/src/resolve-key.ts b/local-keys/src/resolve-key.ts index 7c9a3cc..903d30e 100644 --- a/local-keys/src/resolve-key.ts +++ b/local-keys/src/resolve-key.ts @@ -2,7 +2,8 @@ import { calculateJwkThumbprint } from 'jose' import type { JWK } from 'jose' import { getAgentConfig } from './config.js' import { discoverBackends, getBackend } from './backends/index.js' -import type { ResolvedKey, KeyBackend } from './types.js' +import { normalizeAlgId, publicJwkWithAlg } from './jwk-alg.js' +import type { ResolvedKey, KeyBackend, KeyAlgorithm } from './types.js' /** * Resolve a signing key for an agent URL. @@ -112,7 +113,7 @@ interface LocalKey { backend: KeyBackend keyId: string kid: string - algorithm: 'EdDSA' | 'ES256' | 'RS256' + algorithm: KeyAlgorithm publicJwk: JWK thumbprint: string } @@ -133,7 +134,7 @@ async function discoverLocalKeys(): Promise { backend: k.backend, keyId: k.keyId, kid: k.publicJwk.kid || k.keyId, - algorithm: k.algorithm, + algorithm: normalizeAlgId(k.algorithm) as KeyAlgorithm, publicJwk: k.publicJwk, thumbprint, }) @@ -153,6 +154,14 @@ async function discoverLocalKeys(): Promise { /** * Match JWKS keys against local keys. Prefers hardware over software. + * + * Matching is by JWK thumbprint (RFC 7638), which covers only the key material — + * `alg` is not an input. That is deliberate: an agent whose published + * `jwks.json` still carries the pre-11 `alg: "EdDSA"` must still resolve to its + * local key. The resolved algorithm is taken from the local key, never from the + * remote document, so a stale published `alg` cannot reach a signature or a + * `cnf.jwk`. Verifying a remote key's `alg` is the verifier's job — see + * `assertFullySpecifiedAlg` in `jwk-alg.ts`. */ async function matchJwksToLocal( jwksKeys: JWK[], @@ -196,7 +205,10 @@ async function matchJwksToLocal( * (e.g. YubiKey unplugged). */ async function resolveFromConfig( - configKeys: Record, + // `algorithm` is widened to string: a `~/.aauth/config.json` written before + // AAuth -11 records "EdDSA", which is no longer a KeyAlgorithm. It is + // normalized to "Ed25519" below rather than migrating the file. + configKeys: Record, localKeys: LocalKey[], ): Promise { const backends = discoverBackends() @@ -224,14 +236,15 @@ async function resolveFromConfig( if (meta.backend !== 'software' && !lazyHardwareMatch) { try { const driver = getBackend(meta.backend) - const pubJwk = await driver.getPublicKey(meta.keyId) - if (pubJwk && pubJwk.kty) { + const rawJwk = await driver.getPublicKey(meta.keyId) + if (rawJwk && rawJwk.kty) { + const algorithm = normalizeAlgId(meta.algorithm) as KeyAlgorithm lazyHardwareMatch = { backend: meta.backend, keyId: meta.keyId, kid, - algorithm: meta.algorithm, - publicJwk: pubJwk, + algorithm, + publicJwk: publicJwkWithAlg(rawJwk, algorithm, `${meta.backend} key ${meta.keyId}`), } } } catch { diff --git a/local-keys/src/types.ts b/local-keys/src/types.ts index 98ade51..bc8f009 100644 --- a/local-keys/src/types.ts +++ b/local-keys/src/types.ts @@ -3,7 +3,15 @@ import type { JWK } from 'jose' // === Key Backend Types === export type KeyBackend = 'software' | 'yubikey-piv' | 'secure-enclave' -export type KeyAlgorithm = 'EdDSA' | 'ES256' | 'RS256' + +/** + * Signing algorithms, as fully-specified identifiers (RFC 9864). + * + * `Ed25519` replaces the polymorphic `EdDSA` used before AAuth -11 — see + * `jwk-alg.ts`. Config files written by earlier versions still record `EdDSA`; + * `normalizeAlgId` maps those on read. + */ +export type KeyAlgorithm = 'Ed25519' | 'ES256' | 'RS256' export interface BackendInfo { backend: KeyBackend diff --git a/mcp-agent/README.md b/mcp-agent/README.md deleted file mode 100644 index 7dda045..0000000 --- a/mcp-agent/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# @aauth/mcp-agent - -Agent-side AAuth for MCP. Handles signed HTTP requests, AAuth challenge-response flows, token exchange with auth servers, and 202 deferred/interaction polling. - -Part of [aauth-dev/packages-js](https://github.com/aauth-dev/packages-js). Protocol spec: [dickhardt/AAuth](https://github.com/dickhardt/AAuth). - -## Install - -```bash -npm install @aauth/mcp-agent -``` - -## Usage - -### `createAAuthFetch(options): FetchLike` - -Creates a protocol-aware fetch that handles the full AAuth flow automatically: signs requests, parses 401 challenges, exchanges tokens with the auth server, caches auth tokens, handles `AAuth-Access` opaque tokens (two-party mode), and retries. - -```ts -import { createAAuthFetch } from '@aauth/mcp-agent' - -const fetch = createAAuthFetch({ - getKeyMaterial: async () => ({ - signingKey: privateKeyJwk, - signatureKey: { type: 'jwt', jwt: agentToken } - }), - // Optional: declare protocol capabilities - capabilities: ['interaction', 'clarification'], - // Optional: mission context (sets AAuth-Mission header) - mission: { approver: 'https://ps.example', s256: '...' }, - // Optional callbacks - onInteraction: (url, code) => { - console.log(`Visit ${url}?code=${code}`) - }, - onClarification: async (question) => { - return prompt(question) - }, - // Optional hints for the auth server - justification: 'Read project files', - loginHint: 'user@example.com', - tenant: 'acme.com', - domainHint: 'acme.com', -}) - -const response = await fetch('https://resource.example/api') -``` - -When `capabilities` is set, every signed request includes the `AAuth-Capabilities` header. When `mission` is set, every signed request includes the `AAuth-Mission` header. - -The fetch automatically caches and reuses `AAuth-Access` opaque tokens returned by resources in two-party mode, sending them back via `Authorization: Bearer` on subsequent requests. - -### `createSignedFetch(getKeyMaterial, options?): FetchLike` - -Creates a fetch that signs requests with HTTP Message Signatures but does not handle AAuth challenges. Use this when you only need request signing. - -```ts -import { createSignedFetch } from '@aauth/mcp-agent' - -const signedFetch = createSignedFetch(async () => ({ - signingKey: privateKeyJwk, - signatureKey: { type: 'hwk' } -}), { - capabilities: ['interaction'], - mission: { approver: 'https://ps.example', s256: '...' }, -}) -``` - -### `parseAAuthHeader(headerValue): AAuthChallenge` - -Parses an `AAuth-Requirement` response header into a structured challenge. - -```ts -import { parseAAuthHeader } from '@aauth/mcp-agent' - -const challenge = parseAAuthHeader(response.headers.get('aauth-requirement')) -// { requirement: 'auth-token', resourceToken: '...' } -``` - -Returns: - -```ts -interface AAuthChallenge { - requirement: 'auth-token' | 'approval' | 'interaction' | 'clarification' | 'claims' - resourceToken?: string - url?: string - code?: string -} -``` - -### `exchangeToken(options): Promise` - -Exchanges a resource token for an auth token at the person server. Handles metadata discovery (`/.well-known/aauth-person.json`), 202 deferred responses, and interaction polling. - -```ts -import { exchangeToken } from '@aauth/mcp-agent' - -const { authToken, expiresIn } = await exchangeToken({ - signedFetch, - authServerUrl: 'https://ps.example', - resourceToken: '...', - justification: 'Read project files', -}) -``` - -### `pollDeferred(options): Promise` - -Polls a 202 Location URL until a terminal response. Handles `Retry-After`, `Prefer: wait`, clarification chat, and interaction codes. - -```ts -import { pollDeferred } from '@aauth/mcp-agent' - -const { response, error } = await pollDeferred({ - signedFetch, - locationUrl: 'https://auth.example/pending/abc123', - interactionCode: 'ABCD1234', - onInteraction: (code, endpoint) => { /* show to user */ }, - maxPollDuration: 300, // seconds, default 300 -}) -``` - -## Key Material Callback - -All signing functions take a `GetKeyMaterial` callback. This decouples key management from the protocol — you provide keys however you want: - -```ts -type GetKeyMaterial = () => Promise<{ - signingKey: JsonWebKey // Ed25519 private key for HTTP signatures - signatureKey: - | { type: 'jwt', jwt: string } // agent or auth token - | { type: 'hwk' } // bare public key (pseudonym) -}> -``` - -For local development, use [`@aauth/local-keys`](../local-keys) to provide this callback from the OS keychain. - -## License - -MIT diff --git a/mcp-agent/src/aauth-header.test.ts b/mcp-agent/src/aauth-header.test.ts deleted file mode 100644 index fb4752c..0000000 --- a/mcp-agent/src/aauth-header.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - parseAAuthHeader, - buildCapabilitiesHeader, - parseCapabilitiesHeader, - buildMissionHeader, - parseMissionHeader, -} from './aauth-header.js' - -describe('parseAAuthHeader', () => { - it('parses requirement=approval', () => { - const result = parseAAuthHeader('requirement=approval') - expect(result).toEqual({ requirement: 'approval' }) - }) - - it('parses requirement=auth-token with resource-token', () => { - const header = 'requirement=auth-token; resource-token="eyJhbGciOiJFZERTQSJ9.test"' - const result = parseAAuthHeader(header) - expect(result).toEqual({ - requirement: 'auth-token', - resourceToken: 'eyJhbGciOiJFZERTQSJ9.test', - }) - }) - - it('parses requirement=interaction with url and code', () => { - const header = 'requirement=interaction; url="https://auth.example/interact"; code="ABCD1234"' - const result = parseAAuthHeader(header) - expect(result).toEqual({ - requirement: 'interaction', - url: 'https://auth.example/interact', - code: 'ABCD1234', - }) - }) - - it('handles extra whitespace', () => { - const header = ' requirement=auth-token ; resource-token="tok123" ' - const result = parseAAuthHeader(header) - expect(result).toEqual({ - requirement: 'auth-token', - resourceToken: 'tok123', - }) - }) - - it('throws on empty header', () => { - expect(() => parseAAuthHeader('')).toThrow('Empty AAuth-Requirement header') - expect(() => parseAAuthHeader(' ')).toThrow('Empty AAuth-Requirement header') - }) - - it('throws on missing requirement=', () => { - expect(() => parseAAuthHeader('pseudonym')).toThrow('Missing requirement=') - }) - - it('throws on unknown requirement level', () => { - expect(() => parseAAuthHeader('requirement=unknown')).toThrow('Unknown requirement level') - }) - - it('throws on auth-token missing resource-token', () => { - expect(() => parseAAuthHeader('requirement=auth-token')) - .toThrow('auth-token challenge missing resource-token') - }) - - it('throws on interaction missing url', () => { - expect(() => parseAAuthHeader('requirement=interaction; code="ABC"')) - .toThrow('interaction challenge missing url') - }) - - it('throws on interaction missing code', () => { - expect(() => parseAAuthHeader('requirement=interaction; url="https://x"')) - .toThrow('interaction challenge missing code') - }) - - it('ignores unknown parameters', () => { - const header = 'requirement=approval; unknown="value"' - const result = parseAAuthHeader(header) - expect(result).toEqual({ requirement: 'approval' }) - }) -}) - -describe('buildCapabilitiesHeader / parseCapabilitiesHeader', () => { - it('builds a capabilities header', () => { - expect(buildCapabilitiesHeader(['interaction', 'clarification'])) - .toBe('interaction, clarification') - }) - - it('parses a capabilities header', () => { - expect(parseCapabilitiesHeader('interaction, clarification, payment')) - .toEqual(['interaction', 'clarification', 'payment']) - }) - - it('ignores unknown capabilities', () => { - expect(parseCapabilitiesHeader('interaction, unknown, payment')) - .toEqual(['interaction', 'payment']) - }) - - it('handles whitespace variations', () => { - expect(parseCapabilitiesHeader('interaction,clarification , payment')) - .toEqual(['interaction', 'clarification', 'payment']) - }) -}) - -describe('buildMissionHeader / parseMissionHeader', () => { - const mission = { - approver: 'https://ps.example', - s256: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', - } - - it('builds a mission header', () => { - expect(buildMissionHeader(mission)) - .toBe('approver="https://ps.example"; s256="dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"') - }) - - it('round-trips mission header', () => { - const header = buildMissionHeader(mission) - expect(parseMissionHeader(header)).toEqual(mission) - }) - - it('throws on missing approver', () => { - expect(() => parseMissionHeader('s256="abc"')) - .toThrow('Invalid AAuth-Mission header') - }) - - it('throws on missing s256', () => { - expect(() => parseMissionHeader('approver="https://ps.example"')) - .toThrow('Invalid AAuth-Mission header') - }) -}) diff --git a/mcp-agent/src/aauth-header.ts b/mcp-agent/src/aauth-header.ts deleted file mode 100644 index d4b37f5..0000000 --- a/mcp-agent/src/aauth-header.ts +++ /dev/null @@ -1,136 +0,0 @@ -export type RequirementLevel = 'auth-token' | 'approval' | 'interaction' | 'clarification' | 'claims' - -/** @deprecated Use RequirementLevel instead */ -export type RequireLevel = RequirementLevel - -export type Capability = 'interaction' | 'clarification' | 'payment' - -export interface AAuthChallenge { - requirement: RequirementLevel - resourceToken?: string - url?: string - code?: string -} - -export interface AAuthMission { - approver: string - s256: string -} - -/** - * Build an AAuth-Capabilities request header value. - * Per the spec, this is an RFC 8941 List of Tokens. - * - * AAuth-Capabilities: interaction, clarification, payment - */ -export function buildCapabilitiesHeader(capabilities: Capability[]): string { - return capabilities.join(', ') -} - -/** - * Parse an AAuth-Capabilities request header value into capability tokens. - */ -export function parseCapabilitiesHeader(headerValue: string): Capability[] { - const valid: Capability[] = ['interaction', 'clarification', 'payment'] - return headerValue.split(',') - .map(s => s.trim()) - .filter((s): s is Capability => valid.includes(s as Capability)) -} - -/** - * Build an AAuth-Mission request header value. - * - * AAuth-Mission: approver="https://ps.example"; s256="hash..." - */ -export function buildMissionHeader(mission: AAuthMission): string { - return `approver="${mission.approver}"; s256="${mission.s256}"` -} - -/** - * Parse an AAuth-Mission request header value. - */ -export function parseMissionHeader(headerValue: string): AAuthMission { - const approverMatch = headerValue.match(/approver="([^"]+)"/) - const s256Match = headerValue.match(/s256="([^"]+)"/) - if (!approverMatch || !s256Match) { - throw new Error('Invalid AAuth-Mission header: missing approver or s256') - } - return { approver: approverMatch[1], s256: s256Match[1] } -} - -/** - * Parse an AAuth-Requirement response header value into a structured challenge. - * - * Formats: - * AAuth-Requirement: requirement=auth-token; resource-token="..." - * AAuth-Requirement: requirement=approval - * AAuth-Requirement: requirement=interaction; url="https://..."; code="ABCD1234" - * AAuth-Requirement: requirement=clarification - * AAuth-Requirement: requirement=claims - */ -export function parseAAuthHeader(headerValue: string): AAuthChallenge { - const trimmed = headerValue.trim() - if (!trimmed) { - throw new Error('Empty AAuth-Requirement header') - } - - // Parse the requirement= value (unquoted token) - const requirementMatch = trimmed.match(/^requirement=([a-z-]+)/) - if (!requirementMatch) { - throw new Error('Missing requirement= in AAuth-Requirement header') - } - - const validLevels: RequirementLevel[] = ['auth-token', 'approval', 'interaction', 'clarification', 'claims'] - const requirementStr = requirementMatch[1] - if (!validLevels.includes(requirementStr as RequirementLevel)) { - throw new Error(`Unknown requirement level: ${requirementStr}`) - } - const requirement = requirementStr as RequirementLevel - - const challenge: AAuthChallenge = { requirement } - - // Parse semicolon-separated parameters - const params = trimmed.slice(requirementMatch[0].length) - if (params.trim()) { - const paramPairs = params.split(';').slice(1) // skip first empty segment - for (const pair of paramPairs) { - const eqIdx = pair.indexOf('=') - if (eqIdx === -1) continue - const key = pair.slice(0, eqIdx).trim() - let value = pair.slice(eqIdx + 1).trim() - // Strip quotes - if (value.startsWith('"') && value.endsWith('"')) { - value = value.slice(1, -1) - } - switch (key) { - case 'resource-token': - challenge.resourceToken = value - break - case 'url': - challenge.url = value - break - case 'code': - challenge.code = value - break - } - } - } - - // Validate required params for specific levels - if (requirement === 'auth-token') { - if (!challenge.resourceToken) { - throw new Error('auth-token challenge missing resource-token') - } - } - - if (requirement === 'interaction') { - if (!challenge.url) { - throw new Error('interaction challenge missing url') - } - if (!challenge.code) { - throw new Error('interaction challenge missing code') - } - } - - return challenge -} diff --git a/mcp-agent/src/decode-jwt.ts b/mcp-agent/src/decode-jwt.ts deleted file mode 100644 index db794bf..0000000 --- a/mcp-agent/src/decode-jwt.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Decode a JWT's payload (no signature verification). - * Returns parsed JSON, or undefined if the token is malformed. - * - * Used by --log narration to surface token contents — not for security checks. - */ -export function decodeJwtPayload(jwt: string): Record | undefined { - const parts = jwt.split('.') - if (parts.length < 2) return undefined - try { - const payload = Buffer.from(parts[1], 'base64url').toString('utf8') - return JSON.parse(payload) as Record - } catch { - return undefined - } -} diff --git a/mcp-agent/src/index.ts b/mcp-agent/src/index.ts deleted file mode 100644 index ab3c58b..0000000 --- a/mcp-agent/src/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -export { createSignedFetch } from './signed-fetch.js' -export { createAAuthFetch } from './aauth-fetch.js' -export { - parseAAuthHeader, - buildCapabilitiesHeader, - parseCapabilitiesHeader, - buildMissionHeader, - parseMissionHeader, -} from './aauth-header.js' -export { exchangeToken, TokenExchangeError } from './token-exchange.js' -export { pollDeferred } from './deferred.js' -export { decodeJwtPayload } from './decode-jwt.js' -export type { - GetKeyMaterial, - KeyMaterial, - SignatureKeyJwt, - SignatureKeyJktJwt, - SignatureKeyHwk, - FetchLike, - AAuthEvent, - OnEvent, - CapturedSent, -} from './types.js' -export type { AAuthChallenge, RequirementLevel, RequireLevel, Capability, AAuthMission } from './aauth-header.js' -export type { SignedFetchOptions } from './signed-fetch.js' -export type { DeferredOptions, DeferredResult, AAuthError } from './deferred.js' -export type { TokenExchangeOptions, TokenExchangeResult, AuthServerMetadata } from './token-exchange.js' -export type { AAuthFetchOptions } from './aauth-fetch.js' diff --git a/mcp-agent/src/signed-fetch.ts b/mcp-agent/src/signed-fetch.ts deleted file mode 100644 index 0c43a78..0000000 --- a/mcp-agent/src/signed-fetch.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { fetch as httpSigFetch } from '@hellocoop/httpsig' -import type { SentRequest } from '@hellocoop/httpsig' -import { buildCapabilitiesHeader, buildMissionHeader } from './aauth-header.js' -import type { GetKeyMaterial, FetchLike, CapturedSent } from './types.js' -import type { Capability, AAuthMission } from './aauth-header.js' - -export interface SignedFetchOptions { - capabilities?: Capability[] - mission?: AAuthMission - /** - * Called synchronously after each signed request returns, with the actual - * on-the-wire headers + body. Used by the AAuth flow to capture the - * signed request data for --log rendering. - */ - onSigned?: (sent: CapturedSent) => void -} - -function headersToRecord(headers: Headers): Record { - const out: Record = {} - headers.forEach((value, key) => { out[key] = value }) - return out -} - -function captureSent(sent: SentRequest): CapturedSent { - let body: string | undefined - if (typeof sent.body === 'string') { - body = sent.body - } - return { - method: sent.method, - url: sent.url, - headers: headersToRecord(sent.headers), - body, - } -} - -export function createSignedFetch(getKeyMaterial: GetKeyMaterial, options?: SignedFetchOptions): FetchLike { - const hasExtraHeaders = (options?.capabilities?.length ?? 0) > 0 || !!options?.mission - - return async (url: string | URL, init?: RequestInit): Promise => { - const { signingKey, signatureKey } = await getKeyMaterial() - // Map jkt-jwt to jwt for @hellocoop/httpsig (same wire format) - const httpSigKey = signatureKey.type === 'jkt-jwt' - ? { type: 'jwt' as const, jwt: signatureKey.jwt } - : signatureKey - - const wantSent = !!options?.onSigned - - if (hasExtraHeaders) { - const headers = new Headers(init?.headers) - if (options?.capabilities?.length) { - headers.set('aauth-capabilities', buildCapabilitiesHeader(options.capabilities)) - } - if (options?.mission) { - headers.set('aauth-mission', buildMissionHeader(options.mission)) - } - if (wantSent) { - const { response, sent } = await httpSigFetch(url, { - ...init, - headers, - signingKey, - signatureKey: httpSigKey, - returnSent: true, - }) - options!.onSigned!(captureSent(sent)) - return response - } - return await httpSigFetch(url, { - ...init, - headers, - signingKey, - signatureKey: httpSigKey, - }) - } - - if (wantSent) { - const { response, sent } = await httpSigFetch(url, { - ...init, - signingKey, - signatureKey: httpSigKey, - returnSent: true, - }) - options!.onSigned!(captureSent(sent)) - return response - } - return await httpSigFetch(url, { - ...init, - signingKey, - signatureKey: httpSigKey, - }) - } -} diff --git a/mcp-openclaw/README.md b/mcp-openclaw/README.md index e918fd9..72f684d 100644 --- a/mcp-openclaw/README.md +++ b/mcp-openclaw/README.md @@ -23,6 +23,7 @@ Add to `~/.openclaw/openclaw.json`: "config": { "agent_url": "https://user.github.io", "delegate": "openclaw", + "person_server": "https://ps.example", "mcp_servers": { "my-files": "https://files-api.example.com/mcp", "my-db": "https://db-api.example.com/mcp" @@ -36,6 +37,22 @@ Add to `~/.openclaw/openclaw.json`: Tools from remote servers are registered with a prefix: `my-files_read_file`, `my-db_query`, etc. +`person_server` is optional — it defaults to the `ps` claim of the agent token. It is used for +both the token's `ps` claim and the person token requests below. + +## Person tokens + +An MCP server is an AAuth resource. Under AAuth -11 a resource MUST have verified a person token +before it issues a resource token, so the plugin obtains one per server from the person server's +`person_token_endpoint`, for `resource` = the MCP server's origin. The person token is presented +via `Signature-Key` in place of the agent token. + +It is obtained up front when the server's `/.well-known/aauth-resource.json` declares +`access_mode: person-token` or `auth-token`, and otherwise on demand, when the server answers +`401` with `AAuth-Requirement: requirement=person-token`. A server whose declared access mode this +agent cannot satisfy — `auth-token` with no person server, say — is skipped rather than connected; +see `getSkippedServers()`. + ## API ### `register(api, config)` @@ -62,6 +79,8 @@ const manager = new ServerManager({ signingKey: privateKeyJwk, signatureKey: { type: 'jwt', jwt: agentToken } }), + // optional — defaults to the agent token's `ps` claim + personServerUrl: 'https://ps.example', }) await manager.connectAll() @@ -70,6 +89,10 @@ await manager.connectAll() const tools = manager.getTools() // [{ prefixedName: 'my-files_read', serverName: 'my-files', originalName: 'read', description: '...' }] +// Servers skipped because this agent cannot satisfy their access mode +const skipped = manager.getSkippedServers() +// [{ name: 'my-db', url: '...', mode: 'auth-token', reason: 'agent has no person server' }] + // Call a tool const result = await manager.callTool('my-files_read', { path: '/data.json' }) diff --git a/mcp-openclaw/openclaw.plugin.json b/mcp-openclaw/openclaw.plugin.json index 772c22d..f0d782c 100644 --- a/mcp-openclaw/openclaw.plugin.json +++ b/mcp-openclaw/openclaw.plugin.json @@ -19,6 +19,14 @@ "type": "number", "description": "Token lifetime in seconds (default: 3600)" }, + "person_server": { + "type": "string", + "description": "Person server URL (default: the agent token's ps claim)" + }, + "mission_s256": { + "type": "string", + "description": "Mission the agent is operating under, stamped into person tokens" + }, "mcp_servers": { "type": "object", "description": "Map of server names to URLs", diff --git a/mcp-openclaw/package.json b/mcp-openclaw/package.json index d93eb0a..2daba64 100644 --- a/mcp-openclaw/package.json +++ b/mcp-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/mcp-openclaw", - "version": "2.0.0", + "version": "3.0.0", "description": "OpenClaw plugin for AAuth-authenticated MCP server connections", "type": "module", "exports": { @@ -34,8 +34,9 @@ "directory": "mcp-openclaw" }, "dependencies": { - "@aauth/mcp-agent": "^2.0.0", - "@aauth/local-keys": "^1.0.0", + "@aauth/agent": "^3.0.0", + "@aauth/protocol": "^1.0.0", + "@aauth/local-keys": "^2.0.0", "@modelcontextprotocol/sdk": "^1.15.1" }, "devDependencies": { diff --git a/mcp-openclaw/src/index.ts b/mcp-openclaw/src/index.ts index 3297dec..397c785 100644 --- a/mcp-openclaw/src/index.ts +++ b/mcp-openclaw/src/index.ts @@ -5,6 +5,10 @@ export interface PluginConfig { agent_url?: string local?: string token_lifetime?: number + /** Person server. Defaults to the one in the agent's local config. */ + person_server?: string + /** Mission the agent is operating under; stamped into person tokens. */ + mission_s256?: string mcp_servers: Record } @@ -18,31 +22,47 @@ export const id = 'aauth-mcp' export function register(api: OpenClawPluginApi): void { const config = api.getConfig() - const { agent_url, local, token_lifetime, mcp_servers } = config + const { agent_url, local, token_lifetime, person_server, mission_s256, mcp_servers } = config const getKeyMaterial = () => createAgentToken({ agentUrl: agent_url, local: local ?? 'openclaw', tokenLifetime: token_lifetime, + personServerUrl: person_server, }) const manager = new ServerManager({ servers: mcp_servers, getKeyMaterial, + personServerUrl: person_server, + missionS256: mission_s256, }) manager.connectAll().then(() => { + for (const skipped of manager.getSkippedServers()) { + console.warn( + `[aauth] skipping MCP server ${skipped.name} (${skipped.url}): ${skipped.reason}`, + ) + } const tools = manager.getTools() for (const tool of tools) { api.registerTool(tool.prefixedName, (args) => manager.callTool(tool.prefixedName, args), ) } + }).catch((error: unknown) => { + console.error( + `[aauth] failed to connect MCP servers: ${error instanceof Error ? error.message : String(error)}`, + ) }) api.onShutdown(() => manager.shutdown()) } export { ServerManager } from './server-manager.js' -export type { ServerManagerOptions } from './server-manager.js' +export type { + ServerManagerOptions, + PersonServerMetadata, + SkippedServer, +} from './server-manager.js' diff --git a/mcp-openclaw/src/server-manager.test.ts b/mcp-openclaw/src/server-manager.test.ts index 44800ef..2a7af4e 100644 --- a/mcp-openclaw/src/server-manager.test.ts +++ b/mcp-openclaw/src/server-manager.test.ts @@ -1,8 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { UnsupportedRequirementError } from '@aauth/protocol' const { mockConnect, mockListTools, mockCallTool, MockClient, - mockTransportClose, MockStreamableHTTPClientTransport, mockCreateSignedFetch, + mockTransportClose, MockStreamableHTTPClientTransport, + mockCreateSignedFetch, mockRequestPersonToken, signedRequests, responseQueue, } = vi.hoisted(() => { const mockConnect = vi.fn().mockResolvedValue(undefined) const mockListTools = vi.fn() @@ -16,8 +18,25 @@ const { const MockStreamableHTTPClientTransport = vi.fn().mockReturnValue({ close: mockTransportClose, }) - const mockCreateSignedFetch = vi.fn().mockReturnValue(vi.fn()) - return { mockConnect, mockListTools, mockCallTool, MockClient, mockTransportClose, MockStreamableHTTPClientTransport, mockCreateSignedFetch } + + // Every signed request made through a mocked createSignedFetch, with the key + // material that would have gone into its Signature-Key header. + const signedRequests: Array<{ url: string; signatureKey: unknown }> = [] + const responseQueue: Response[] = [] + const mockCreateSignedFetch = vi.fn((getKeyMaterial: () => Promise<{ signatureKey: unknown }>) => + vi.fn(async (url: string | URL) => { + const { signatureKey } = await getKeyMaterial() + signedRequests.push({ url: String(url), signatureKey }) + return responseQueue.shift() ?? new Response(null, { status: 200 }) + }), + ) + const mockRequestPersonToken = vi.fn() + + return { + mockConnect, mockListTools, mockCallTool, MockClient, + mockTransportClose, MockStreamableHTTPClientTransport, + mockCreateSignedFetch, mockRequestPersonToken, signedRequests, responseQueue, + } }) vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ @@ -28,20 +47,67 @@ vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ StreamableHTTPClientTransport: MockStreamableHTTPClientTransport, })) -vi.mock('@aauth/mcp-agent', () => ({ +vi.mock('@aauth/agent', () => ({ createSignedFetch: mockCreateSignedFetch, + requestPersonToken: mockRequestPersonToken, })) import { ServerManager } from './server-manager.js' +function b64u(value: object): string { + return Buffer.from(JSON.stringify(value)).toString('base64url') +} + +function jwt(payload: Record): string { + return `${b64u({ alg: 'Ed25519', typ: 'aa-agent+jwt' })}.${b64u(payload)}.sig` +} + +const AGENT_TOKEN = jwt({ sub: 'aauth:openclaw@example.com', ps: 'https://ps.example' }) +const PS_LESS_AGENT_TOKEN = jwt({ sub: 'aauth:openclaw@example.com' }) +const PERSON_TOKEN = jwt({ aud: 'https://files.example.com' }) + +/** Key material getter for an agent token with the given payload. */ +function keyMaterial(agentToken = AGENT_TOKEN) { + return vi.fn().mockResolvedValue({ + signingKey: { kty: 'OKP', crv: 'Ed25519' }, + signatureKey: { type: 'jwt', jwt: agentToken }, + }) +} + +/** Stub the resource-metadata fetch with a declared `access_mode`. */ +function stubResourceMetadata(accessMode?: string): void { + vi.stubGlobal('fetch', vi.fn(async () => ( + accessMode === undefined + ? new Response(null, { status: 404 }) + : new Response(JSON.stringify({ access_mode: accessMode }), { status: 200 }) + ))) +} + +/** The `fetch` the transport was constructed with, for the nth server. */ +function transportFetch(index = 0): (url: string, init?: RequestInit) => Promise { + return MockStreamableHTTPClientTransport.mock.calls[index][1].fetch +} + describe('ServerManager', () => { - const getKeyMaterial = vi.fn() + let getKeyMaterial: ReturnType beforeEach(() => { vi.clearAllMocks() + signedRequests.length = 0 + responseQueue.length = 0 + getKeyMaterial = keyMaterial() + stubResourceMetadata() mockListTools.mockResolvedValue({ tools: [{ name: 'read_file' }, { name: 'write_file' }], }) + mockRequestPersonToken.mockResolvedValue({ + personToken: PERSON_TOKEN, + expiresIn: 3600, + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() }) it('connectAll creates transport and client per server', async () => { @@ -52,6 +118,7 @@ describe('ServerManager', () => { await manager.connectAll() + // The agent-token fetch (person server) is built from getKeyMaterial itself. expect(mockCreateSignedFetch).toHaveBeenCalledWith(getKeyMaterial) expect(MockStreamableHTTPClientTransport).toHaveBeenCalledOnce() const [url] = MockStreamableHTTPClientTransport.mock.calls[0] @@ -140,4 +207,239 @@ describe('ServerManager', () => { expect(mockTransportClose).toHaveBeenCalledTimes(2) }) + + describe('person token', () => { + it('is not requested when the resource declares agent-token access', async () => { + stubResourceMetadata('agent-token') + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + }) + + await manager.connectAll() + await transportFetch()('https://files.example.com/mcp') + + expect(mockRequestPersonToken).not.toHaveBeenCalled() + expect(signedRequests.at(-1)?.signatureKey).toEqual({ type: 'jwt', jwt: AGENT_TOKEN }) + }) + + it('is obtained up front when the resource declares person-token access', async () => { + stubResourceMetadata('person-token') + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + missionS256: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + }) + + await manager.connectAll() + + expect(mockRequestPersonToken).toHaveBeenCalledOnce() + expect(mockRequestPersonToken.mock.calls[0][0]).toMatchObject({ + // The PS comes from the agent token's `ps` claim, the resource is the + // MCP server's origin — it becomes the person token's `aud`. + personServerUrl: 'https://ps.example', + resource: 'https://files.example.com', + missionS256: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + }) + }) + + it('is obtained up front when the resource declares auth-token access', async () => { + stubResourceMetadata('auth-token') + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + }) + + await manager.connectAll() + + expect(mockRequestPersonToken).toHaveBeenCalledOnce() + }) + + it('replaces the agent token in the signature key once held', async () => { + stubResourceMetadata('person-token') + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + }) + + await manager.connectAll() + await transportFetch()('https://files.example.com/mcp') + + expect(signedRequests.at(-1)?.signatureKey).toEqual({ type: 'jwt', jwt: PERSON_TOKEN }) + }) + + it('uses the configured person server over the agent token ps claim', async () => { + stubResourceMetadata('person-token') + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + personServerUrl: 'https://other-ps.example', + }) + + await manager.connectAll() + + expect(mockRequestPersonToken.mock.calls[0][0]).toMatchObject({ + personServerUrl: 'https://other-ps.example', + }) + }) + + it('caches PS metadata from the first request and reports it', async () => { + // -11 renamed `token_endpoint` to `auth_token_endpoint` and added the + // REQUIRED `person_token_endpoint`. + const metadata = { + auth_token_endpoint: 'https://ps.example/aauth/token/auth', + person_token_endpoint: 'https://ps.example/aauth/token/person', + } + const onPersonServerMetadata = vi.fn() + mockRequestPersonToken.mockImplementation(async (options: { + onMetadata?: (m: typeof metadata) => void + }) => { + options.onMetadata?.(metadata) + return { personToken: PERSON_TOKEN, expiresIn: 3600 } + }) + stubResourceMetadata('person-token') + + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + onPersonServerMetadata, + }) + + await manager.connectAll() + expect(onPersonServerMetadata).toHaveBeenCalledWith(metadata) + expect(mockRequestPersonToken.mock.calls[0][0].personServerMetadata).toBeUndefined() + + // A later person token request reuses the cached copy — no second + // /.well-known fetch at the PS. + responseQueue.push( + new Response(null, { + status: 401, + headers: { 'AAuth-Requirement': 'requirement=person-token' }, + }), + new Response('{}', { status: 200 }), + ) + await transportFetch()('https://files.example.com/mcp') + + expect(mockRequestPersonToken).toHaveBeenCalledTimes(2) + expect(mockRequestPersonToken.mock.calls[1][0].personServerMetadata).toEqual(metadata) + }) + }) + + describe('AAuth-Requirement challenges', () => { + it('obtains a person token and retries on requirement=person-token', async () => { + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + }) + await manager.connectAll() + expect(mockRequestPersonToken).not.toHaveBeenCalled() + + responseQueue.push( + new Response(null, { + status: 401, + headers: { 'AAuth-Requirement': 'requirement=person-token' }, + }), + new Response('{}', { status: 200 }), + ) + + const response = await transportFetch()('https://files.example.com/mcp', { + method: 'POST', + body: '{"jsonrpc":"2.0"}', + }) + + expect(response.status).toBe(200) + expect(mockRequestPersonToken).toHaveBeenCalledOnce() + expect(mockRequestPersonToken.mock.calls[0][0]).toMatchObject({ + resource: 'https://files.example.com', + }) + // First attempt on the agent token, retry on the person token. + expect(signedRequests.map((r) => r.signatureKey)).toEqual([ + { type: 'jwt', jwt: AGENT_TOKEN }, + { type: 'jwt', jwt: PERSON_TOKEN }, + ]) + }) + + it('surfaces an unrecognized requirement as an error', async () => { + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + }) + await manager.connectAll() + + responseQueue.push( + new Response(null, { + status: 401, + headers: { 'AAuth-Requirement': 'requirement=quantum-token' }, + }), + ) + + await expect( + transportFetch()('https://files.example.com/mcp'), + ).rejects.toThrow(UnsupportedRequirementError) + expect(mockRequestPersonToken).not.toHaveBeenCalled() + }) + + it('passes through a 401 with no AAuth-Requirement header', async () => { + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + }) + await manager.connectAll() + + responseQueue.push(new Response(null, { status: 401 })) + + const response = await transportFetch()('https://files.example.com/mcp') + + expect(response.status).toBe(401) + expect(mockRequestPersonToken).not.toHaveBeenCalled() + }) + }) + + describe('access mode planning', () => { + it('skips a resource this agent cannot satisfy', async () => { + stubResourceMetadata('auth-token') + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial: keyMaterial(PS_LESS_AGENT_TOKEN), + }) + + await manager.connectAll() + + expect(MockStreamableHTTPClientTransport).not.toHaveBeenCalled() + expect(manager.getTools()).toEqual([]) + const [skipped] = manager.getSkippedServers() + expect(skipped).toMatchObject({ + name: 'myfiles', + url: 'https://files.example.com/mcp', + mode: 'auth-token', + }) + expect(skipped.reason).toBeTruthy() + }) + + it('connects when the resource declares a mode it does not recognize', async () => { + stubResourceMetadata('some-future-mode') + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + }) + + await manager.connectAll() + + expect(manager.getSkippedServers()).toEqual([]) + expect(MockStreamableHTTPClientTransport).toHaveBeenCalledOnce() + expect(mockRequestPersonToken).not.toHaveBeenCalled() + }) + + it('connects when the resource publishes no metadata', async () => { + const manager = new ServerManager({ + servers: { myfiles: 'https://files.example.com/mcp' }, + getKeyMaterial, + }) + + await manager.connectAll() + + expect(manager.getSkippedServers()).toEqual([]) + expect(MockStreamableHTTPClientTransport).toHaveBeenCalledOnce() + }) + }) }) diff --git a/mcp-openclaw/src/server-manager.ts b/mcp-openclaw/src/server-manager.ts index 54520f7..6edb8cd 100644 --- a/mcp-openclaw/src/server-manager.ts +++ b/mcp-openclaw/src/server-manager.ts @@ -1,25 +1,76 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { createSignedFetch } from '@aauth/mcp-agent' -import type { GetKeyMaterial } from '@aauth/mcp-agent' +import { createSignedFetch, requestPersonToken } from '@aauth/agent' +import type { PersonServerMetadata as AgentPersonServerMetadata, FetchLike, GetKeyMaterial, KeyMaterial } from '@aauth/agent' +import { decodeJwtPayload, parseRequirementHeader, planAccessMode } from '@aauth/protocol' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' interface ManagedServer { name: string + url: string client: Client transport: Transport tools: Map // prefixed name → original name } +/** + * Person server metadata, `/.well-known/aauth-person.json`. + * + * `auth_token_endpoint` is the -11 name for what was `token_endpoint`; + * `person_token_endpoint` is new and REQUIRED — it is where the agent obtains + * the person token a resource must have verified before it will issue a + * resource token. + * + * `@aauth/agent` owns this shape. Re-exported here so a consumer of this + * package needs one import, not two. + */ +export type PersonServerMetadata = AgentPersonServerMetadata + +/** A configured server this agent's setup cannot use, and why. */ +export interface SkippedServer { + name: string + url: string + /** The `access_mode` the resource declared. */ + mode: string + reason: string +} + export interface ServerManagerOptions { servers: Record // name → url getKeyMaterial: GetKeyMaterial + /** Person server URL. Defaults to the `ps` claim of the agent token. */ + personServerUrl?: string + /** Cached PS metadata; when provided the person token request skips the + * /.well-known fetch. */ + personServerMetadata?: PersonServerMetadata + /** Called with freshly-fetched PS metadata so the caller can persist it. */ + onPersonServerMetadata?: (metadata: PersonServerMetadata) => void + /** Mission the agent is operating under; stamped into the person token. */ + missionS256?: string + onInteraction?: (url: string, code: string) => void +} + +interface CachedPersonToken { + token: string + expiresAt: number } +/** Refresh a person token this far before it expires. */ +const PERSON_TOKEN_SKEW_MS = 60_000 + export class ServerManager { private servers = new Map() + /** server name → person token for that server's resource. */ + private personTokens = new Map() + private skipped = new Map() + private personServerMetadata?: PersonServerMetadata + /** Signs with the agent token — used to talk to the person server. */ + private agentSignedFetch: FetchLike - constructor(private options: ServerManagerOptions) {} + constructor(private options: ServerManagerOptions) { + this.agentSignedFetch = createSignedFetch(options.getKeyMaterial) + this.personServerMetadata = options.personServerMetadata + } async connectAll(): Promise { const entries = Object.entries(this.options.servers) @@ -27,9 +78,28 @@ export class ServerManager { } private async connect(name: string, url: string): Promise { - const signedFetch = createSignedFetch(this.options.getKeyMaterial) + // The resource identifier is the origin — the `resource` the person token + // is requested for and the token's `aud`. + const resource = new URL(url).origin + + const plan = planAccessMode(await this.fetchAccessMode(resource), { + hasPersonServer: (await this.personServer()) !== undefined, + }) + if (plan.kind === 'unsatisfiable') { + this.skipped.set(name, { name, url, mode: plan.mode, reason: plan.reason }) + return + } + + // `person-token` and `auth-token` both put a person token on the wire from + // the first call: a resource MUST have verified one before it issues a + // resource token. Every other mode starts on the agent token and upgrades + // if the resource challenges with requirement=person-token. + if (plan.kind === 'satisfiable' && (plan.mode === 'person-token' || plan.mode === 'auth-token')) { + await this.ensurePersonToken(name, resource) + } + const transport = new StreamableHTTPClientTransport(new URL(url), { - fetch: signedFetch, + fetch: this.createResourceFetch(name, resource), }) const client = new Client({ name: `aauth-${name}`, version: '0.0.1' }) @@ -41,7 +111,109 @@ export class ServerManager { toolMap.set(`${name}_${tool.name}`, tool.name) } - this.servers.set(name, { name, client, transport, tools: toolMap }) + this.servers.set(name, { name, url, client, transport, tools: toolMap }) + } + + /** + * A signed fetch for one resource that satisfies a person-token challenge. + * + * The key material getter picks up the person token as soon as there is one, + * so a retry after `requirement=person-token` presents it via Signature-Key + * in place of the agent token. + */ + private createResourceFetch(name: string, resource: string): FetchLike { + const signedFetch = createSignedFetch(() => this.keyMaterialFor(name)) + return async (url: string | URL, init?: RequestInit): Promise => { + const response = await signedFetch(url, init) + if (response.status !== 401) return response + + const header = response.headers.get('aauth-requirement') + if (!header) return response + + // A requirement value this agent does not recognize throws + // UnsupportedRequirementError out of parseRequirementHeader — the + // response is not satisfiable and the error reaches the caller. + const challenge = parseRequirementHeader(header) + if (challenge.requirement !== 'person-token') return response + + // Only retry when the request body can be sent again. + if (init?.body != null && typeof init.body !== 'string') return response + + this.personTokens.delete(name) + await this.ensurePersonToken(name, resource) + return signedFetch(url, init) + } + } + + /** The agent token, or the person token for `name` once there is one. */ + private async keyMaterialFor(name: string): Promise { + const keyMaterial = await this.options.getKeyMaterial() + const cached = this.personTokens.get(name) + if (!cached || cached.expiresAt <= Date.now() + PERSON_TOKEN_SKEW_MS) { + return keyMaterial + } + return { + signingKey: keyMaterial.signingKey, + signatureKey: { type: 'jwt', jwt: cached.token }, + } + } + + private async ensurePersonToken(name: string, resource: string): Promise { + const cached = this.personTokens.get(name) + if (cached && cached.expiresAt > Date.now() + PERSON_TOKEN_SKEW_MS) { + return cached.token + } + + const personServerUrl = await this.personServer() + if (!personServerUrl) { + throw new Error( + `${resource} requires a person token and this agent has no person server ` + + '(no "ps" claim in its agent token)', + ) + } + + const result = await requestPersonToken({ + signedFetch: this.agentSignedFetch, + personServerUrl, + personServerMetadata: this.personServerMetadata, + onMetadata: (metadata: PersonServerMetadata) => { + this.personServerMetadata = metadata + this.options.onPersonServerMetadata?.(metadata) + }, + resource, + missionS256: this.options.missionS256, + onInteraction: this.options.onInteraction, + }) + + this.personTokens.set(name, { + token: result.personToken, + expiresAt: Date.now() + result.expiresIn * 1000, + }) + return result.personToken + } + + /** Configured person server, else the agent token's `ps` claim. */ + private async personServer(): Promise { + if (this.options.personServerUrl) return this.options.personServerUrl + const { signatureKey } = await this.options.getKeyMaterial() + if (signatureKey.type === 'hwk') return undefined + const ps = decodeJwtPayload(signatureKey.jwt).ps + return typeof ps === 'string' ? ps : undefined + } + + /** + * The resource's declared `access_mode`, or undefined when it publishes no + * metadata — planAccessMode treats that as undeclared, never an error. + */ + private async fetchAccessMode(resource: string): Promise { + try { + const response = await fetch(`${resource}/.well-known/aauth-resource.json`) + if (!response.ok) return undefined + const metadata = await response.json() as Record + return typeof metadata.access_mode === 'string' ? metadata.access_mode : undefined + } catch { + return undefined + } } getTools(): Array<{ prefixedName: string; serverName: string; originalName: string; description?: string }> { @@ -58,6 +230,11 @@ export class ServerManager { return result } + /** Servers not connected because this agent cannot satisfy their access mode. */ + getSkippedServers(): SkippedServer[] { + return Array.from(this.skipped.values()) + } + async callTool( prefixedName: string, args: Record, @@ -77,5 +254,7 @@ export class ServerManager { ) await Promise.all(closers) this.servers.clear() + this.personTokens.clear() + this.skipped.clear() } } diff --git a/mcp-server/README.md b/mcp-server/README.md deleted file mode 100644 index 70acd2f..0000000 --- a/mcp-server/README.md +++ /dev/null @@ -1,163 +0,0 @@ -# @aauth/mcp-server - -Server-side AAuth for MCP. Verifies signed requests, validates agent and auth tokens, builds AAuth challenge headers, creates resource tokens, and manages 202 interaction flows. - -Part of [aauth-dev/packages-js](https://github.com/aauth-dev/packages-js). Protocol spec: [dickhardt/AAuth](https://github.com/dickhardt/AAuth). - -## Install - -```bash -npm install @aauth/mcp-server -``` - -## Usage - -### `verifyToken(options): Promise` - -Verifies a JWT from a signed request. Supports both `aa-agent+jwt` and `aa-auth+jwt` token types. Fetches issuer metadata and JWKS automatically (cached). - -```ts -import { verifyToken } from '@aauth/mcp-server' - -const result = await verifyToken({ - jwt: tokenFromSignatureKeyHeader, - httpSignatureThumbprint: thumbprintFromVerifiedSignature, -}) - -if (result.type === 'agent') { - // VerifiedAgentToken: iss, sub, cnf, iat, exp - console.log(`Agent: ${result.sub}`) -} - -if (result.type === 'auth') { - // VerifiedAuthToken: iss, aud, agent, cnf, sub?, scope?, iat, exp - console.log(`Authorized agent: ${result.agent}, scope: ${result.scope}`) -} -``` - -Throws `AAuthTokenError` with a spec-defined error code on failure: - -| Code | Meaning | -|------|---------| -| `invalid_agent_token` | Agent token verification failed | -| `invalid_auth_token` | Auth token verification failed | -| `key_binding_failed` | Request signing key doesn't match token `cnf.jwk` | - -### `buildAAuthHeader(requirement, params?): string` - -Builds an `AAuth-Requirement` response header. - -```ts -import { buildAAuthHeader } from '@aauth/mcp-server' - -// 401 — require auth token -const header = buildAAuthHeader('auth-token', { resourceToken: '...' }) -response.setHeader('aauth-requirement', header) - -// 202 — require interaction -buildAAuthHeader('interaction', { url: 'https://example.com/interact', code: 'ABCD1234' }) - -// Simple levels (no params) -buildAAuthHeader('approval') -buildAAuthHeader('clarification') -buildAAuthHeader('claims') -``` - -### `buildAAuthAccessHeader(token): string` - -Builds an `AAuth-Access` response header for two-party mode. The token is opaque to the agent — the resource wraps its own authorization state. - -```ts -import { buildAAuthAccessHeader } from '@aauth/mcp-server' - -response.setHeader('aauth-access', buildAAuthAccessHeader(wrappedToken)) -``` - -### `parseCapabilitiesHeader(headerValue): Capability[]` - -Parses an `AAuth-Capabilities` request header. - -```ts -import { parseCapabilitiesHeader } from '@aauth/mcp-server' - -const caps = parseCapabilitiesHeader(request.headers.get('aauth-capabilities')) -// ['interaction', 'clarification', 'payment'] -``` - -### `parseMissionHeader(headerValue): Mission` - -Parses an `AAuth-Mission` request header into a `Mission` object that can be passed directly to `createResourceToken`. - -```ts -import { parseMissionHeader } from '@aauth/mcp-server' - -const mission = parseMissionHeader(request.headers.get('aauth-mission')) -// { approver: 'https://ps.example', s256: '...' } -``` - -### `createResourceToken(options, sign): Promise` - -Creates an `aa-resource+jwt` token for inclusion in 401 AAuth challenges. - -```ts -import { createResourceToken } from '@aauth/mcp-server' - -const resourceToken = await createResourceToken( - { - resource: 'https://api.example.com', - authServer: 'https://ps.example', // PS URL (three-party) or AS URL (four-party) - agent: 'aauth:claude@user.github.io', - agentJkt: thumbprint, // JWK Thumbprint of agent's signing key - scope: 'files.read', - mission: parseMissionHeader(request.headers.get('aauth-mission')), // optional - lifetime: 300, // seconds, default 300 - }, - async (payload, header) => { - // Sign the JWT with your resource server's key - return signedJwtString - } -) -``` - -### `InteractionManager` - -Manages pending requests for 202 deferred response flows. - -```ts -import { InteractionManager } from '@aauth/mcp-server' - -const manager = new InteractionManager({ - baseUrl: 'https://api.example.com', - pendingPath: '/pending', // default - codeLength: 8, // default - ttl: 600, // seconds, default -}) - -// Create a pending request (returns headers for 202 response) -const { headers, pending } = manager.createPending() -// headers: { Location, Retry-After, Cache-Control, AAuth } -// pending: { id, code, promise, resolve, reject } - -// Resolve when the interaction completes -manager.resolve(pending.id, { granted: true }) - -// Or reject -manager.reject(pending.id, 'denied') - -// Cleanup expired entries -manager.cleanup() -``` - -### `clearMetadataCache()` - -Clears the cached issuer metadata and JWKS used by `verifyToken`. - -```ts -import { clearMetadataCache } from '@aauth/mcp-server' - -clearMetadataCache() -``` - -## License - -MIT diff --git a/mcp-server/src/aauth-header.test.ts b/mcp-server/src/aauth-header.test.ts deleted file mode 100644 index 2d1cbed..0000000 --- a/mcp-server/src/aauth-header.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - buildAAuthHeader, - buildAAuthAccessHeader, - parseCapabilitiesHeader, - parseMissionHeader, -} from './aauth-header.js' - -describe('buildAAuthHeader', () => { - it('builds approval header', () => { - expect(buildAAuthHeader('approval')).toBe('requirement=approval') - }) - - it('builds auth-token header with resource-token', () => { - const result = buildAAuthHeader('auth-token', { - resourceToken: 'eyJhbGciOiJFZERTQSJ9.test', - }) - expect(result).toBe( - 'requirement=auth-token; resource-token="eyJhbGciOiJFZERTQSJ9.test"', - ) - }) - - it('builds interaction header with url and code', () => { - const result = buildAAuthHeader('interaction', { - url: 'https://auth.example/interact', - code: 'ABCD1234', - }) - expect(result).toBe('requirement=interaction; url="https://auth.example/interact"; code="ABCD1234"') - }) - - it('auth-token header is parseable (round-trip check)', () => { - const header = buildAAuthHeader('auth-token', { - resourceToken: 'tok.en.here', - }) - // Should contain the exact format - expect(header).toContain('requirement=auth-token') - expect(header).toContain('resource-token="tok.en.here"') - }) - - it('throws on auth-token missing params', () => { - expect(() => (buildAAuthHeader as Function)('auth-token')) - .toThrow('auth-token requires resourceToken') - expect(() => (buildAAuthHeader as Function)('auth-token', {})) - .toThrow('auth-token requires resourceToken') - }) - - it('throws on interaction missing url or code', () => { - expect(() => (buildAAuthHeader as Function)('interaction')) - .toThrow('interaction requires url and code') - expect(() => (buildAAuthHeader as Function)('interaction', { code: 'X' })) - .toThrow('interaction requires url and code') - expect(() => (buildAAuthHeader as Function)('interaction', { url: 'https://x' })) - .toThrow('interaction requires url and code') - }) -}) - -describe('buildAAuthAccessHeader', () => { - it('returns the opaque token as-is', () => { - expect(buildAAuthAccessHeader('wrapped-opaque-token')).toBe('wrapped-opaque-token') - }) -}) - -describe('parseCapabilitiesHeader', () => { - it('parses valid capabilities', () => { - expect(parseCapabilitiesHeader('interaction, clarification, payment')) - .toEqual(['interaction', 'clarification', 'payment']) - }) - - it('ignores unknown capabilities', () => { - expect(parseCapabilitiesHeader('interaction, unknown')) - .toEqual(['interaction']) - }) -}) - -describe('parseMissionHeader', () => { - it('parses a valid mission header', () => { - const header = 'approver="https://ps.example"; s256="abc123"' - expect(parseMissionHeader(header)).toEqual({ - approver: 'https://ps.example', - s256: 'abc123', - }) - }) - - it('throws on missing fields', () => { - expect(() => parseMissionHeader('approver="https://ps.example"')) - .toThrow('Invalid AAuth-Mission header') - }) -}) diff --git a/mcp-server/src/aauth-header.ts b/mcp-server/src/aauth-header.ts deleted file mode 100644 index ef0a819..0000000 --- a/mcp-server/src/aauth-header.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { Mission } from './resource-token.js' - -type RequirementLevel = 'auth-token' | 'approval' | 'interaction' | 'clarification' | 'claims' - -export type Capability = 'interaction' | 'clarification' | 'payment' - -/** - * Parse an AAuth-Capabilities request header value into capability tokens. - */ -export function parseCapabilitiesHeader(headerValue: string): Capability[] { - const valid: Capability[] = ['interaction', 'clarification', 'payment'] - return headerValue.split(',') - .map(s => s.trim()) - .filter((s): s is Capability => valid.includes(s as Capability)) -} - -/** - * Parse an AAuth-Mission request header value into a Mission object. - */ -export function parseMissionHeader(headerValue: string): Mission { - const approverMatch = headerValue.match(/approver="([^"]+)"/) - const s256Match = headerValue.match(/s256="([^"]+)"/) - if (!approverMatch || !s256Match) { - throw new Error('Invalid AAuth-Mission header: missing approver or s256') - } - return { approver: approverMatch[1], s256: s256Match[1] } -} - -/** - * Build an AAuth-Access response header value (opaque access token for two-party mode). - */ -export function buildAAuthAccessHeader(token: string): string { - return token -} - -/** - * Build an AAuth-Requirement response header value per the AAuth spec. - */ -export function buildAAuthHeader(requirement: 'auth-token', params: { resourceToken: string }): string -export function buildAAuthHeader(requirement: 'approval'): string -export function buildAAuthHeader(requirement: 'interaction', params: { url: string; code: string }): string -export function buildAAuthHeader(requirement: 'clarification'): string -export function buildAAuthHeader(requirement: 'claims'): string -export function buildAAuthHeader( - requirement: RequirementLevel, - params?: { resourceToken?: string; url?: string; code?: string }, -): string { - switch (requirement) { - case 'approval': - return 'requirement=approval' - - case 'clarification': - return 'requirement=clarification' - - case 'claims': - return 'requirement=claims' - - case 'auth-token': { - if (!params?.resourceToken) { - throw new Error('auth-token requires resourceToken') - } - return `requirement=auth-token; resource-token="${params.resourceToken}"` - } - - case 'interaction': { - if (!params?.url || !params?.code) { - throw new Error('interaction requires url and code') - } - return `requirement=interaction; url="${params.url}"; code="${params.code}"` - } - - default: - throw new Error(`Unknown requirement level: ${requirement}`) - } -} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts deleted file mode 100644 index e96500f..0000000 --- a/mcp-server/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { - buildAAuthHeader, - buildAAuthAccessHeader, - parseCapabilitiesHeader, - parseMissionHeader, -} from './aauth-header.js' -export { InteractionManager } from './interaction.js' -export { createResourceToken } from './resource-token.js' -export { verifyToken, AAuthTokenError, clearMetadataCache } from './verify-token.js' -export type { Capability } from './aauth-header.js' -export type { PendingRequest, InteractionManagerOptions } from './interaction.js' -export type { ResourceTokenOptions, Mission, SignFn } from './resource-token.js' -export type { VerifyTokenOptions, VerifiedAgentToken, VerifiedAuthToken, VerifiedToken } from './verify-token.js' diff --git a/mcp-server/src/resource-token.test.ts b/mcp-server/src/resource-token.test.ts deleted file mode 100644 index 582f55d..0000000 --- a/mcp-server/src/resource-token.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createResourceToken } from './resource-token.js' - -describe('createResourceToken', () => { - const mockSign = vi.fn().mockResolvedValue('eyJ.signed.jwt') - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('creates a resource token with required fields', async () => { - const token = await createResourceToken({ - resource: 'https://resource.example', - authServer: 'https://auth.example', - agent: 'https://dickhardt.github.io', - agentJkt: 'jkt_thumbprint_123', - }, mockSign) - - expect(token).toBe('eyJ.signed.jwt') - - // Verify payload - const [payload, header] = mockSign.mock.calls[0] - expect(payload.iss).toBe('https://resource.example') - expect(payload.dwk).toBe('aauth-resource.json') - expect(payload.aud).toBe('https://auth.example') - expect(payload.agent).toBe('https://dickhardt.github.io') - expect(payload.agent_jkt).toBe('jkt_thumbprint_123') - expect(payload.iat).toBeTypeOf('number') - expect(payload.exp).toBeTypeOf('number') - expect(payload.exp - payload.iat).toBe(300) // default lifetime - expect(payload.scope).toBeUndefined() - - // Verify header - expect(header.alg).toBe('EdDSA') - expect(header.typ).toBe('aa-resource+jwt') - }) - - it('includes scope when provided', async () => { - await createResourceToken({ - resource: 'https://resource.example', - authServer: 'https://auth.example', - agent: 'https://dickhardt.github.io', - agentJkt: 'jkt_123', - scope: 'files.read files.write', - }, mockSign) - - const [payload] = mockSign.mock.calls[0] - expect(payload.scope).toBe('files.read files.write') - }) - - it('uses custom lifetime', async () => { - await createResourceToken({ - resource: 'https://resource.example', - authServer: 'https://auth.example', - agent: 'https://dickhardt.github.io', - agentJkt: 'jkt_123', - lifetime: 600, - }, mockSign) - - const [payload] = mockSign.mock.calls[0] - expect(payload.exp - payload.iat).toBe(600) - }) - - it('calls sign function with correct payload and header', async () => { - const customSign = vi.fn().mockResolvedValue('custom.jwt.token') - - const token = await createResourceToken({ - resource: 'https://api.acme.com', - authServer: 'https://auth.hello.coop', - agent: 'https://agent.example', - agentJkt: 'thumb', - scope: 'logs:read', - lifetime: 120, - }, customSign) - - expect(customSign).toHaveBeenCalledOnce() - expect(token).toBe('custom.jwt.token') - - const [payload, header] = customSign.mock.calls[0] - expect(payload).toMatchObject({ - iss: 'https://api.acme.com', - aud: 'https://auth.hello.coop', - agent: 'https://agent.example', - agent_jkt: 'thumb', - scope: 'logs:read', - }) - expect(header).toEqual({ - alg: 'EdDSA', - typ: 'aa-resource+jwt', - }) - }) -}) diff --git a/mcp-server/src/resource-token.ts b/mcp-server/src/resource-token.ts deleted file mode 100644 index ef10e90..0000000 --- a/mcp-server/src/resource-token.ts +++ /dev/null @@ -1,70 +0,0 @@ -import crypto from 'node:crypto' - -export interface Mission { - approver: string // person server URL (approver of the mission) - s256: string // SHA-256 hash of approved mission text (base64url) -} - -export interface ResourceTokenOptions { - resource: string // resource URL (iss) - authServer: string // auth server URL (aud) - agent: string // agent identifier - agentJkt: string // JWK thumbprint of agent's signing key - scope?: string // space-separated scopes - mission?: Mission // mission context (when resource is mission-aware) - lifetime?: number // default: 300s -} - -export type SignFn = (payload: Record, header: Record) => Promise - -/** - * Create a resource token (typ: aa-resource+jwt) for an AAuth 401 challenge. - * - * The resource token is signed by the resource and sent to the agent, - * who forwards it to the auth server to obtain an auth token. - * - * The caller provides a sign function — this decouples signing from - * any particular key management (KMS, vault, ephemeral, etc.). - */ -export async function createResourceToken( - options: ResourceTokenOptions, - sign: SignFn, -): Promise { - const { - resource, - authServer, - agent, - agentJkt, - scope, - mission, - lifetime = 300, - } = options - - const now = Math.floor(Date.now() / 1000) - - const payload: Record = { - iss: resource, - dwk: 'aauth-resource.json', - aud: authServer, - jti: crypto.randomUUID(), - agent, - agent_jkt: agentJkt, - iat: now, - exp: now + lifetime, - } - - if (scope) { - payload.scope = scope - } - - if (mission) { - payload.mission = mission - } - - const header: Record = { - alg: 'EdDSA', - typ: 'aa-resource+jwt', - } - - return sign(payload, header) -} diff --git a/mcp-server/src/verify-token.test.ts b/mcp-server/src/verify-token.test.ts deleted file mode 100644 index 7d92d95..0000000 --- a/mcp-server/src/verify-token.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { - generateKeyPair, - exportJWK, - SignJWT, - calculateJwkThumbprint, -} from 'jose' -import type { KeyLike, JWK } from 'jose' -import { verifyToken, AAuthTokenError, clearMetadataCache } from './verify-token.js' - -// --- Helpers --- - -async function createKeys() { - // Root key pair — signs the JWT (issuer's key, published in JWKS) - const root = await generateKeyPair('EdDSA', { crv: 'Ed25519' }) - const rootPubJwk = { ...await exportJWK(root.publicKey), kid: 'root-1' } - - // Ephemeral key pair — signs the HTTP request (bound via cnf.jwk) - const eph = await generateKeyPair('EdDSA', { crv: 'Ed25519' }) - const ephPubJwk = await exportJWK(eph.publicKey) - - const ephThumbprint = await calculateJwkThumbprint(ephPubJwk, 'sha256') - - return { root, rootPubJwk, eph, ephPubJwk, ephThumbprint } -} - -async function signToken( - rootPrivateKey: KeyLike, - typ: string, - claims: Record, - kid = 'root-1', -): Promise { - const now = Math.floor(Date.now() / 1000) - return new SignJWT({ iat: now, exp: now + 3600, ...claims }) - .setProtectedHeader({ alg: 'EdDSA', typ, kid }) - .sign(rootPrivateKey) -} - -function mockFetchForJwks(rootPubJwk: JWK, issuer: string, metadataPath: string) { - const jwksUrl = `${issuer}/jwks` - const metadataUrl = `${issuer}${metadataPath}` - - return vi.fn(async (url: string | URL) => { - const urlStr = url.toString() - if (urlStr === metadataUrl) { - return new Response(JSON.stringify({ jwks_uri: jwksUrl }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } - if (urlStr === jwksUrl) { - return new Response(JSON.stringify({ keys: [rootPubJwk] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } - return new Response('Not Found', { status: 404 }) - }) -} - -// --- Tests --- - -describe('verifyToken', () => { - const originalFetch = globalThis.fetch - - beforeEach(() => { - clearMetadataCache() - }) - - afterEach(() => { - globalThis.fetch = originalFetch - }) - - it('verifies a valid agent token', async () => { - const { root, rootPubJwk, ephPubJwk, ephThumbprint } = await createKeys() - const iss = 'https://agent.example' - - globalThis.fetch = mockFetchForJwks( - rootPubJwk, iss, '/.well-known/aauth-agent.json', - ) as typeof fetch - - const jwt = await signToken(root.privateKey, 'aa-agent+jwt', { - iss, - dwk: 'aauth-agent.json', - sub: 'aauth:test@example.com', - cnf: { jwk: ephPubJwk }, - }) - - const result = await verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }) - - expect(result.type).toBe('agent') - expect(result.iss).toBe(iss) - expect(result.dwk).toBe('aauth-agent.json') - if (result.type === 'agent') { - expect(result.sub).toBe('aauth:test@example.com') - } - expect(result.cnf.jwk).toEqual(ephPubJwk) - expect(result.iat).toBeTypeOf('number') - expect(result.exp).toBeTypeOf('number') - }) - - it('verifies a valid auth token', async () => { - const { root, rootPubJwk, ephPubJwk, ephThumbprint } = await createKeys() - const iss = 'https://auth.example' - - globalThis.fetch = mockFetchForJwks( - rootPubJwk, iss, '/.well-known/aauth-person.json', - ) as typeof fetch - - const jwt = await signToken(root.privateKey, 'aa-auth+jwt', { - iss, - dwk: 'aauth-person.json', - aud: 'https://resource.example', - agent: 'https://agent.example', - sub: 'user-123', - scope: 'files.read', - cnf: { jwk: ephPubJwk }, - }) - - const result = await verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }) - - expect(result.type).toBe('auth') - expect(result.iss).toBe(iss) - expect(result.dwk).toBe('aauth-person.json') - if (result.type === 'auth') { - expect(result.aud).toBe('https://resource.example') - expect(result.agent).toBe('https://agent.example') - expect(result.sub).toBe('user-123') - expect(result.scope).toBe('files.read') - } - }) - - it('throws on unknown typ', async () => { - const { root, ephPubJwk, ephThumbprint } = await createKeys() - - const jwt = await signToken(root.privateKey, 'unknown+jwt', { - iss: 'https://example.com', - dwk: 'aauth-agent.json', - sub: 'test', - cnf: { jwk: ephPubJwk }, - }) - - await expect( - verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }), - ).rejects.toThrow('Unknown JWT typ') - }) - - it('throws on missing iss claim', async () => { - const { root, ephPubJwk, ephThumbprint } = await createKeys() - - // Sign without iss - const jwt = await signToken(root.privateKey, 'aa-agent+jwt', { - dwk: 'aauth-agent.json', - sub: 'aauth:test@example.com', - cnf: { jwk: ephPubJwk }, - }) - - await expect( - verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }), - ).rejects.toThrow('Missing required claim: iss') - }) - - it('throws on missing dwk claim', async () => { - const { root, ephPubJwk, ephThumbprint } = await createKeys() - - const jwt = await signToken(root.privateKey, 'aa-agent+jwt', { - iss: 'https://agent.example', - sub: 'aauth:test@example.com', - cnf: { jwk: ephPubJwk }, - }) - - await expect( - verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }), - ).rejects.toThrow('Missing required claim: dwk') - }) - - it('throws on missing sub for agent token', async () => { - const { root, ephPubJwk, ephThumbprint } = await createKeys() - - const jwt = await signToken(root.privateKey, 'aa-agent+jwt', { - iss: 'https://agent.example', - dwk: 'aauth-agent.json', - cnf: { jwk: ephPubJwk }, - }) - - await expect( - verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }), - ).rejects.toThrow('Missing required claim: sub') - }) - - it('throws on missing aud for auth token', async () => { - const { root, ephPubJwk, ephThumbprint } = await createKeys() - - const jwt = await signToken(root.privateKey, 'aa-auth+jwt', { - iss: 'https://auth.example', - dwk: 'aauth-person.json', - agent: 'https://agent.example', - cnf: { jwk: ephPubJwk }, - }) - - await expect( - verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }), - ).rejects.toThrow('Missing required claim: aud') - }) - - it('throws on missing agent for auth token', async () => { - const { root, ephPubJwk, ephThumbprint } = await createKeys() - - const jwt = await signToken(root.privateKey, 'aa-auth+jwt', { - iss: 'https://auth.example', - dwk: 'aauth-person.json', - aud: 'https://resource.example', - cnf: { jwk: ephPubJwk }, - }) - - await expect( - verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }), - ).rejects.toThrow('Missing required claim: agent') - }) - - it('throws on missing cnf.jwk', async () => { - const { root, ephThumbprint } = await createKeys() - - const jwt = await signToken(root.privateKey, 'aa-agent+jwt', { - iss: 'https://agent.example', - dwk: 'aauth-agent.json', - sub: 'aauth:test@example.com', - }) - - await expect( - verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }), - ).rejects.toThrow('Missing required claim: cnf.jwk') - }) - - it('throws on expired token', async () => { - const { root, ephPubJwk, ephThumbprint } = await createKeys() - - const past = Math.floor(Date.now() / 1000) - 3600 - const jwt = await new SignJWT({ - iss: 'https://agent.example', - dwk: 'aauth-agent.json', - sub: 'aauth:test@example.com', - cnf: { jwk: ephPubJwk }, - iat: past - 3600, - exp: past, // expired 1 hour ago - }) - .setProtectedHeader({ alg: 'EdDSA', typ: 'aa-agent+jwt', kid: 'root-1' }) - .sign(root.privateKey) - - await expect( - verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }), - ).rejects.toThrow('Token has expired') - }) - - it('throws key_binding_failed on thumbprint mismatch', async () => { - const { root, ephPubJwk } = await createKeys() - // Use a different key's thumbprint - const other = await generateKeyPair('EdDSA', { crv: 'Ed25519' }) - const otherPubJwk = await exportJWK(other.publicKey) - const wrongThumbprint = await calculateJwkThumbprint(otherPubJwk, 'sha256') - - const jwt = await signToken(root.privateKey, 'aa-agent+jwt', { - iss: 'https://agent.example', - dwk: 'aauth-agent.json', - sub: 'aauth:test@example.com', - cnf: { jwk: ephPubJwk }, - }) - - try { - await verifyToken({ jwt, httpSignatureThumbprint: wrongThumbprint }) - expect.fail('Should have thrown') - } catch (err) { - expect(err).toBeInstanceOf(AAuthTokenError) - expect((err as AAuthTokenError).code).toBe('key_binding_failed') - } - }) - - it('throws on JWKS fetch failure', async () => { - const { root, ephPubJwk, ephThumbprint } = await createKeys() - const iss = 'https://agent.example' - - globalThis.fetch = vi.fn(async () => - new Response('Server Error', { status: 500 }), - ) as typeof fetch - - const jwt = await signToken(root.privateKey, 'aa-agent+jwt', { - iss, - dwk: 'aauth-agent.json', - sub: 'aauth:test@example.com', - cnf: { jwk: ephPubJwk }, - }) - - await expect( - verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }), - ).rejects.toThrow('Failed to fetch metadata') - }) - - it('throws when kid not found in JWKS', async () => { - const { root, ephPubJwk, ephThumbprint } = await createKeys() - const iss = 'https://agent.example' - - // Publish a JWKS with a different kid - const otherRoot = await generateKeyPair('EdDSA', { crv: 'Ed25519' }) - const otherPubJwk = { ...await exportJWK(otherRoot.publicKey), kid: 'other-key' } - - globalThis.fetch = mockFetchForJwks( - otherPubJwk, iss, '/.well-known/aauth-agent.json', - ) as typeof fetch - - const jwt = await signToken(root.privateKey, 'aa-agent+jwt', { - iss, - dwk: 'aauth-agent.json', - sub: 'aauth:test@example.com', - cnf: { jwk: ephPubJwk }, - }) - - await expect( - verifyToken({ jwt, httpSignatureThumbprint: ephThumbprint }), - ).rejects.toThrow('JWT signature verification failed') - }) -}) diff --git a/mcp-server/src/verify-token.ts b/mcp-server/src/verify-token.ts deleted file mode 100644 index 37a9a22..0000000 --- a/mcp-server/src/verify-token.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { - jwtVerify, - createLocalJWKSet, - calculateJwkThumbprint, - decodeProtectedHeader, - decodeJwt, -} from 'jose' -import type { JWK, JSONWebKeySet } from 'jose' - -// --- Types --- - -export interface VerifyTokenOptions { - jwt: string // raw JWT (from httpsig result.jwt.raw) - httpSignatureThumbprint: string // thumbprint of HTTP signing key (from httpsig result.thumbprint) -} - -export interface VerifiedAgentToken { - type: 'agent' - iss: string - dwk: string - sub: string - cnf: { jwk: JWK } - iat: number - exp: number -} - -export interface VerifiedAuthToken { - type: 'auth' - iss: string - dwk: string - aud: string | string[] - agent: string - cnf: { jwk: JWK } - sub?: string - scope?: string - tenant?: string - iat: number - exp: number -} - -export type VerifiedToken = VerifiedAgentToken | VerifiedAuthToken - -// --- Error class --- - -export class AAuthTokenError extends Error { - constructor(public code: string, message: string) { - super(message) - } -} - -// --- Metadata cache --- - -const metadataCache = new Map() -const METADATA_CACHE_TTL = 600_000 // 10 minutes - -async function resolveJwksUri(iss: string, metadataPath: string): Promise { - const metadataUrl = `${iss}${metadataPath}` - const cached = metadataCache.get(metadataUrl) - if (cached && Date.now() - cached.fetchedAt < METADATA_CACHE_TTL) { - return cached.jwksUri - } - - const res = await fetch(metadataUrl) - if (!res.ok) { - throw new AAuthTokenError( - 'metadata_fetch_failed', - `Failed to fetch metadata from ${metadataUrl}: ${res.status}`, - ) - } - - const metadata = await res.json() as { jwks_uri?: string } - if (!metadata.jwks_uri) { - throw new AAuthTokenError( - 'metadata_fetch_failed', - `No jwks_uri in metadata from ${metadataUrl}`, - ) - } - - metadataCache.set(metadataUrl, { jwksUri: metadata.jwks_uri, fetchedAt: Date.now() }) - return metadata.jwks_uri -} - -// Exposed for testing -export function clearMetadataCache(): void { - metadataCache.clear() -} - -// --- Main function --- - -const CLOCK_SKEW = 60 // 60 seconds - -export async function verifyToken(options: VerifyTokenOptions): Promise { - const { jwt: rawJwt, httpSignatureThumbprint } = options - - // 1. Decode header — check typ - const header = decodeProtectedHeader(rawJwt) - const typ = header.typ - - if (typ !== 'aa-agent+jwt' && typ !== 'aa-auth+jwt') { - throw new AAuthTokenError( - typ === 'aa-agent+jwt' ? 'invalid_agent_token' : 'invalid_auth_token', - `Unknown JWT typ: ${typ}`, - ) - } - - const isAgent = typ === 'aa-agent+jwt' - const errorCode = isAgent ? 'invalid_agent_token' : 'invalid_auth_token' - - // 2. Decode and validate required claims - const claims = decodeJwt(rawJwt) - - if (!claims.iss) { - throw new AAuthTokenError(errorCode, 'Missing required claim: iss') - } - if (claims.iat === undefined) { - throw new AAuthTokenError(errorCode, 'Missing required claim: iat') - } - if (claims.exp === undefined) { - throw new AAuthTokenError(errorCode, 'Missing required claim: exp') - } - - const dwk = (claims as Record).dwk as string | undefined - if (!dwk) { - throw new AAuthTokenError(errorCode, 'Missing required claim: dwk') - } - - const cnf = claims.cnf as { jwk?: JWK } | undefined - if (!cnf?.jwk) { - throw new AAuthTokenError(errorCode, 'Missing required claim: cnf.jwk') - } - - if (isAgent) { - if (!claims.sub) { - throw new AAuthTokenError(errorCode, 'Missing required claim: sub') - } - } else { - if (!claims.aud) { - throw new AAuthTokenError(errorCode, 'Missing required claim: aud') - } - if (!(claims as Record).agent) { - throw new AAuthTokenError(errorCode, 'Missing required claim: agent') - } - } - - // 3. Check expiration - const now = Math.floor(Date.now() / 1000) - if (claims.exp < now - CLOCK_SKEW) { - throw new AAuthTokenError(errorCode, 'Token has expired') - } - - // 4. Key binding — cnf.jwk thumbprint must match httpSignatureThumbprint - const cnfThumbprint = await calculateJwkThumbprint(cnf.jwk, 'sha256') - if (cnfThumbprint !== httpSignatureThumbprint) { - throw new AAuthTokenError( - 'key_binding_failed', - 'cnf.jwk thumbprint does not match HTTP signature key', - ) - } - - // 5. Resolve JWKS URI from metadata using dwk claim - const metadataPath = `/.well-known/${dwk}` - - const jwksUri = await resolveJwksUri(claims.iss, metadataPath) - - // 6. Fetch JWKS and verify JWT signature - const jwksRes = await fetch(jwksUri) - if (!jwksRes.ok) { - throw new AAuthTokenError( - errorCode, - `Failed to fetch JWKS from ${jwksUri}: ${jwksRes.status}`, - ) - } - const jwksData = await jwksRes.json() as JSONWebKeySet - - try { - const jwks = createLocalJWKSet(jwksData) - await jwtVerify(rawJwt, jwks, { - clockTolerance: CLOCK_SKEW, - }) - } catch (err) { - if (err instanceof AAuthTokenError) throw err - throw new AAuthTokenError( - errorCode, - `JWT signature verification failed: ${(err as Error).message}`, - ) - } - - // Build result - if (isAgent) { - return { - type: 'agent', - iss: claims.iss, - dwk, - sub: claims.sub as string, - cnf: { jwk: cnf.jwk }, - iat: claims.iat as number, - exp: claims.exp as number, - } - } - - const result: VerifiedAuthToken = { - type: 'auth', - iss: claims.iss, - dwk, - aud: claims.aud as string | string[], - agent: (claims as Record).agent as string, - cnf: { jwk: cnf.jwk }, - iat: claims.iat as number, - exp: claims.exp as number, - } - if (claims.sub) result.sub = claims.sub as string - if ((claims as Record).scope) { - result.scope = (claims as Record).scope as string - } - if ((claims as Record).tenant) { - result.tenant = (claims as Record).tenant as string - } - - return result -} diff --git a/mcp-stdio/README.md b/mcp-stdio/README.md index 8a77f02..ae5a171 100644 --- a/mcp-stdio/README.md +++ b/mcp-stdio/README.md @@ -13,18 +13,29 @@ npm install @aauth/mcp-stdio ## CLI ```bash -npx @aauth/mcp-stdio --server https://api.example.com/mcp --agent https://user.github.io +npx @aauth/mcp-stdio https://api.example.com/mcp --agent-url https://user.github.io ``` ### Options +The remote MCP server URL is the first positional argument and is required. + | Flag | Env var | Description | |------|---------|-------------| -| `--server`, `-s` | `AAUTH_MCP_SERVER` | Remote MCP server URL (required) | -| `--agent`, `-a` | `AAUTH_AGENT_URL` | Agent identity URL (required) | -| `--delegate`, `-d` | `AAUTH_DELEGATE` | Delegate name (default: `claude`) | +| `--agent-url` | `AAUTH_AGENT_URL` | Agent URL (default: from `~/.aauth/config.json`) | +| `--local` | `AAUTH_LOCAL` | Local part of the agent identifier | +| `--person-server` | `AAUTH_PERSON_SERVER` | Person server URL (default: from `~/.aauth/config.json`) | | `--token-lifetime` | `AAUTH_TOKEN_LIFETIME` | Agent token lifetime in seconds (default: `3600`) | +### Person server + +The proxy needs a person server to reach a resource that asks for a person or an +auth token. It stamps the PS as the agent token's `ps` claim, obtains a person +token from the PS's `person_token_endpoint` when a resource answers +`requirement=person-token`, and exchanges the resource token that follows at the +PS's `auth_token_endpoint`. Without one it can only reach resources that serve on +agent identity alone, and it says so on stderr at startup. + ### Claude Code Configuration Add to your MCP server config: @@ -34,7 +45,7 @@ Add to your MCP server config: "mcpServers": { "my-server": { "command": "npx", - "args": ["@aauth/mcp-stdio", "--server", "https://api.example.com/mcp", "--agent", "https://user.github.io"] + "args": ["@aauth/mcp-stdio", "https://api.example.com/mcp", "--agent-url", "https://user.github.io"] } } } @@ -47,10 +58,10 @@ Or with environment variables: "mcpServers": { "my-server": { "command": "npx", - "args": ["@aauth/mcp-stdio"], + "args": ["@aauth/mcp-stdio", "https://api.example.com/mcp"], "env": { - "AAUTH_MCP_SERVER": "https://api.example.com/mcp", - "AAUTH_AGENT_URL": "https://user.github.io" + "AAUTH_AGENT_URL": "https://user.github.io", + "AAUTH_PERSON_SERVER": "https://ps.example.com" } } } @@ -67,15 +78,25 @@ Bridges two MCP transports for bidirectional message forwarding. import { bridgeTransports } from '@aauth/mcp-stdio' ``` +### `serializeAuthFlows(fetch): ProxyFetch` + +Wraps a fetch so that only one POST — and so only one AAuth flow, and one +browser interaction — is in flight at a time. GET passes straight through, since +the transport's GET is the long-lived SSE stream. + +```ts +import { serializeAuthFlows } from '@aauth/mcp-stdio' +``` + ### `parseArgs(argv): StdioArgs` -Parses CLI arguments with env var fallbacks. +Parses CLI arguments with env var fallbacks. Takes the full `process.argv`. ```ts import { parseArgs } from '@aauth/mcp-stdio' -const args = parseArgs(process.argv.slice(2)) -// { serverUrl, agentUrl, delegate?, tokenLifetime? } +const args = parseArgs(process.argv) +// { serverUrl, agentUrl?, local?, personServer?, tokenLifetime? } ``` ## License diff --git a/mcp-stdio/package.json b/mcp-stdio/package.json index 077c4e4..08d9014 100644 --- a/mcp-stdio/package.json +++ b/mcp-stdio/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/mcp-stdio", - "version": "2.0.0", + "version": "3.0.0", "description": "Stdio-to-HTTP proxy for MCP with AAuth signatures", "type": "module", "exports": { @@ -36,8 +36,8 @@ "directory": "mcp-stdio" }, "dependencies": { - "@aauth/local-keys": "^1.0.0", - "@aauth/mcp-agent": "^2.0.0", + "@aauth/agent": "^3.0.0", + "@aauth/local-keys": "^2.0.0", "@modelcontextprotocol/sdk": "^1.15.1", "open": "^11.0.0" }, diff --git a/mcp-stdio/src/args.test.ts b/mcp-stdio/src/args.test.ts index 424de38..281ad10 100644 --- a/mcp-stdio/src/args.test.ts +++ b/mcp-stdio/src/args.test.ts @@ -12,6 +12,7 @@ describe('parseArgs', () => { delete process.env.AAUTH_AGENT_URL delete process.env.AAUTH_LOCAL delete process.env.AAUTH_TOKEN_LIFETIME + delete process.env.AAUTH_PERSON_SERVER }) afterEach(() => { @@ -30,6 +31,7 @@ describe('parseArgs', () => { agentUrl: 'https://agent.example.com', local: undefined, tokenLifetime: undefined, + personServer: undefined, }) }) @@ -39,6 +41,7 @@ describe('parseArgs', () => { 'https://example.com/mcp', '--agent-url', 'https://agent.example.com', '--local', 'claude', + '--person-server', 'https://ps.example.com', '--token-lifetime', '7200', ]) @@ -46,10 +49,53 @@ describe('parseArgs', () => { serverUrl: 'https://example.com/mcp', agentUrl: 'https://agent.example.com', local: 'claude', + personServer: 'https://ps.example.com', tokenLifetime: 7200, }) }) + it('parses --person-server', () => { + const result = parseArgs([ + 'node', 'cli.js', + 'https://example.com/mcp', + '--person-server', 'https://ps.example.com', + ]) + + expect(result.personServer).toBe('https://ps.example.com') + }) + + it('falls back to AAUTH_PERSON_SERVER env var', () => { + process.env.AAUTH_PERSON_SERVER = 'https://env-ps.example.com' + + const result = parseArgs([ + 'node', 'cli.js', + 'https://example.com/mcp', + ]) + + expect(result.personServer).toBe('https://env-ps.example.com') + }) + + it('CLI --person-server overrides env var', () => { + process.env.AAUTH_PERSON_SERVER = 'https://env-ps.example.com' + + const result = parseArgs([ + 'node', 'cli.js', + 'https://example.com/mcp', + '--person-server', 'https://cli-ps.example.com', + ]) + + expect(result.personServer).toBe('https://cli-ps.example.com') + }) + + it('leaves personServer undefined so cli.ts can fall back to config', () => { + const result = parseArgs([ + 'node', 'cli.js', + 'https://example.com/mcp', + ]) + + expect(result.personServer).toBeUndefined() + }) + it('falls back to AAUTH_AGENT_URL env var', () => { process.env.AAUTH_AGENT_URL = 'https://env-agent.example.com' diff --git a/mcp-stdio/src/args.ts b/mcp-stdio/src/args.ts index 98364b1..51651ff 100644 --- a/mcp-stdio/src/args.ts +++ b/mcp-stdio/src/args.ts @@ -3,10 +3,13 @@ export interface StdioArgs { agentUrl?: string local?: string tokenLifetime?: number + /** Person server (PS) URL. The agent token's `ps` claim, and the origin whose + * metadata carries `person_token_endpoint` and `auth_token_endpoint`. */ + personServer?: string } function usage(): never { - console.error(`Usage: aauth-mcp-stdio [--agent-url ] [--local ] [--token-lifetime ] + console.error(`Usage: aauth-mcp-stdio [--agent-url ] [--local ] [--person-server ] [--token-lifetime ] Arguments: server-url Remote MCP server URL @@ -14,12 +17,14 @@ Arguments: Options: --agent-url Agent URL (or AAUTH_AGENT_URL env var, or from ~/.aauth/config.json) --local Local part of agent identifier (or AAUTH_LOCAL env var) + --person-server Person server URL (or AAUTH_PERSON_SERVER env var, or from ~/.aauth/config.json) --token-lifetime Token lifetime in seconds (or AAUTH_TOKEN_LIFETIME env var, default: 3600) --version Print version and exit Environment variables: AAUTH_AGENT_URL Agent URL AAUTH_LOCAL Local part of agent identifier + AAUTH_PERSON_SERVER Person server URL AAUTH_TOKEN_LIFETIME Token lifetime in seconds`) process.exit(1) } @@ -39,6 +44,7 @@ export function parseArgs(argv: string[]): StdioArgs { let agentUrl: string | undefined let local: string | undefined let tokenLifetime: number | undefined + let personServer: string | undefined for (let i = 1; i < args.length; i++) { switch (args[i]) { @@ -48,6 +54,9 @@ export function parseArgs(argv: string[]): StdioArgs { case '--local': local = args[++i] break + case '--person-server': + personServer = args[++i] + break case '--token-lifetime': tokenLifetime = parseInt(args[++i], 10) if (isNaN(tokenLifetime)) { @@ -63,16 +72,20 @@ export function parseArgs(argv: string[]): StdioArgs { agentUrl = agentUrl ?? process.env.AAUTH_AGENT_URL local = local ?? process.env.AAUTH_LOCAL + personServer = personServer ?? process.env.AAUTH_PERSON_SERVER const envLifetime = process.env.AAUTH_TOKEN_LIFETIME if (!tokenLifetime && envLifetime) { tokenLifetime = parseInt(envLifetime, 10) } - // agentUrl is now optional — createAgentToken will resolve from ~/.aauth/config.json + // agentUrl is optional — createAgentToken resolves it from ~/.aauth/config.json. + // personServer is optional here too — cli.ts falls back to the agent's + // configured personServerUrl before deciding the PS is genuinely absent. return { serverUrl, agentUrl, local, tokenLifetime, + personServer, } } diff --git a/mcp-stdio/src/cli.ts b/mcp-stdio/src/cli.ts index 063b64c..119d7af 100644 --- a/mcp-stdio/src/cli.ts +++ b/mcp-stdio/src/cli.ts @@ -2,12 +2,12 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { createAAuthFetch } from '@aauth/mcp-agent' -import { createAgentToken } from '@aauth/local-keys' +import { createAAuthFetch } from '@aauth/agent' +import { createAgentToken, getAgentConfig, readConfig } from '@aauth/local-keys' import open from 'open' import { createRequire } from 'node:module' import { parseArgs } from './args.js' -import { bridgeTransports } from './proxy.js' +import { bridgeTransports, serializeAuthFlows } from './proxy.js' if (process.argv.includes('--version')) { const pkg = createRequire(import.meta.url)('../package.json') as { version: string } @@ -15,7 +15,34 @@ if (process.argv.includes('--version')) { process.exit(0) } -const { serverUrl, agentUrl, local, tokenLifetime } = parseArgs(process.argv) +const { serverUrl, agentUrl, local, tokenLifetime, personServer } = parseArgs(process.argv) + +/** + * Resolve the person server: explicit flag/env first, then the PS recorded for + * this agent provider at bootstrap. Matches how `@aauth/fetch` resolves it. + */ +function resolvePersonServer(): string | undefined { + if (personServer) return personServer + if (agentUrl) return getAgentConfig(agentUrl)?.personServerUrl + const providers = Object.entries(readConfig().agents) + if (providers.length === 1) return providers[0][1].personServerUrl + return undefined +} + +const personServerUrl = resolvePersonServer() + +// Without a PS the agent has no `ps` claim, so it can obtain neither a person +// token nor an auth token. Two-party resources (agent identity only, or an +// AAuth-Access session token) still work, so this is a warning, not a fatal — +// but a resource answering `requirement=person-token` will fail, and saying so +// up front beats an opaque error mid-session. +if (!personServerUrl) { + console.error( + '[aauth-stdio] No person server configured — this agent can only reach resources ' + + 'that accept agent identity alone. Pass --person-server (or set ' + + 'AAUTH_PERSON_SERVER) to satisfy requirement=person-token and requirement=auth-token.', + ) +} const innerFetch = createAAuthFetch({ getKeyMaterial: () => @@ -23,39 +50,24 @@ const innerFetch = createAAuthFetch({ agentUrl, local, tokenLifetime, + // Stamps the `ps` claim on the agent token. The resource reads it to + // decide where a resource token's `aud` points, and the PS reads the + // signing key to bind the person token's `cnf`. + ...(personServerUrl ? { personServerUrl } : {}), }), - onInteraction: (code, interactionEndpoint) => { - const url = `${interactionEndpoint}?code=${code}` - console.error(`[aauth-stdio] Opening browser for consent: ${url}`) - open(url) + // The PS whose metadata carries `person_token_endpoint` (person-token hop) and + // `auth_token_endpoint` (the -11 name for what -10 called `token_endpoint`). + ...(personServerUrl ? { personServerUrl } : {}), + // pollDeferred and the PS/resource interaction paths call this as + // (url, code) — the interaction endpoint first, the user-visible code second. + onInteraction: (url: string, code: string) => { + const interactionUrl = `${url}?code=${code}` + console.error(`[aauth-stdio] Opening browser for consent: ${interactionUrl}`) + open(interactionUrl) }, }) -// Serialize requests that trigger auth — createAAuthFetch has no internal mutex, -// so concurrent 401s would each open a browser tab. This wrapper ensures only one -// auth flow runs at a time; others wait then retry with the cached token. -let authInFlight: Promise | null = null -const aAuthFetch: typeof innerFetch = async (url, init) => { - const method = (init as RequestInit)?.method ?? 'GET' - - // Only serialize POST requests — GET (SSE) is long-lived and must not block - if (method !== 'POST') { - return innerFetch(url, init) - } - - if (authInFlight) { - await authInFlight - return innerFetch(url, init) - } - let resolve: () => void - authInFlight = new Promise((r) => { resolve = r }) - try { - return await innerFetch(url, init) - } finally { - authInFlight = null - resolve!() - } -} +const aAuthFetch = serializeAuthFlows(innerFetch) const remote = new StreamableHTTPClientTransport(new URL(serverUrl), { fetch: aAuthFetch, diff --git a/mcp-stdio/src/index.ts b/mcp-stdio/src/index.ts index 9bf228f..a27b3ea 100644 --- a/mcp-stdio/src/index.ts +++ b/mcp-stdio/src/index.ts @@ -1,3 +1,4 @@ -export { bridgeTransports } from './proxy.js' +export { bridgeTransports, serializeAuthFlows } from './proxy.js' +export type { ProxyFetch } from './proxy.js' export { parseArgs } from './args.js' export type { StdioArgs } from './args.js' diff --git a/mcp-stdio/src/proxy.test.ts b/mcp-stdio/src/proxy.test.ts index de74a4e..5db1ac3 100644 --- a/mcp-stdio/src/proxy.test.ts +++ b/mcp-stdio/src/proxy.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { bridgeTransports } from './proxy.js' +import { bridgeTransports, serializeAuthFlows } from './proxy.js' +import type { ProxyFetch } from './proxy.js' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' function createMockTransport(): Transport { @@ -79,3 +80,131 @@ describe('bridgeTransports', () => { expect(local.close).not.toHaveBeenCalled() }) }) + +function deferred(): { promise: Promise; resolve: (v: T) => void; reject: (e: unknown) => void } { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +const ok = () => new Response('ok', { status: 200 }) + +describe('serializeAuthFlows', () => { + it('passes GET straight through without gating', async () => { + const started: string[] = [] + const gate = deferred() + const inner: ProxyFetch = async (url) => { + started.push(String(url)) + await gate.promise + return ok() + } + const fetchLike = serializeAuthFlows(inner) + + const a = fetchLike('https://r.example/a') + const b = fetchLike('https://r.example/b') + await Promise.resolve() + + // Both GETs are in flight — the SSE stream must never wait on a token. + expect(started).toEqual(['https://r.example/a', 'https://r.example/b']) + + gate.resolve() + await Promise.all([a, b]) + }) + + it('runs POSTs one at a time', async () => { + const started: number[] = [] + const gates = [deferred(), deferred(), deferred()] + let n = 0 + const inner: ProxyFetch = async () => { + const i = n++ + started.push(i) + await gates[i].promise + return ok() + } + const fetchLike = serializeAuthFlows(inner) + + const calls = [0, 1, 2].map(() => fetchLike('https://r.example/mcp', { method: 'POST' })) + await Promise.resolve() + expect(started).toEqual([0]) + + gates[0].resolve() + await calls[0] + await Promise.resolve() + expect(started).toEqual([0, 1]) + + gates[1].resolve() + await calls[1] + await Promise.resolve() + expect(started).toEqual([0, 1, 2]) + + gates[2].resolve() + await calls[2] + }) + + it('lets a waiting POST reuse the token the first flow obtained', async () => { + // Stands in for the -11 flow: the first POST walks person-token then + // auth-token and caches the result; queued POSTs must see the cached token + // rather than each starting their own flow (and their own browser tab). + let token: string | undefined + let flows = 0 + const release = deferred() + const seen: (string | undefined)[] = [] + + const inner: ProxyFetch = async () => { + seen.push(token) + if (!token) { + flows++ + await release.promise + token = 'auth-token-1' + } + return ok() + } + const fetchLike = serializeAuthFlows(inner) + + const first = fetchLike('https://r.example/mcp', { method: 'POST' }) + const second = fetchLike('https://r.example/mcp', { method: 'POST' }) + await Promise.resolve() + + release.resolve() + await Promise.all([first, second]) + + expect(flows).toBe(1) + expect(seen).toEqual([undefined, 'auth-token-1']) + }) + + it('releases the gate when a POST rejects', async () => { + let n = 0 + const inner: ProxyFetch = async () => { + if (n++ === 0) throw new Error('interaction timed out') + return ok() + } + const fetchLike = serializeAuthFlows(inner) + + await expect(fetchLike('https://r.example/mcp', { method: 'POST' })) + .rejects.toThrow('interaction timed out') + + const retry = await fetchLike('https://r.example/mcp', { method: 'POST' }) + expect(retry.status).toBe(200) + }) + + it('does not make a GET wait behind an in-flight POST', async () => { + const started: string[] = [] + const post = deferred() + const inner: ProxyFetch = async (url, init) => { + started.push(`${init?.method ?? 'GET'} ${url}`) + if (init?.method === 'POST') await post.promise + return ok() + } + const fetchLike = serializeAuthFlows(inner) + + const p = fetchLike('https://r.example/mcp', { method: 'POST' }) + const g = fetchLike('https://r.example/mcp') + await g + + expect(started).toEqual(['POST https://r.example/mcp', 'GET https://r.example/mcp']) + + post.resolve() + await p + }) +}) diff --git a/mcp-stdio/src/proxy.ts b/mcp-stdio/src/proxy.ts index d748e33..ac1cca3 100644 --- a/mcp-stdio/src/proxy.ts +++ b/mcp-stdio/src/proxy.ts @@ -1,6 +1,51 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' +/** The fetch shape `StreamableHTTPClientTransport` is handed, and the shape + * `createAAuthFetch` returns. Declared here so this module stays free of + * `@aauth/agent` — the AAuth flow itself lives there, not in the proxy. */ +export type ProxyFetch = (url: string | URL, init?: RequestInit) => Promise + +/** + * Serialize the requests that can trigger an AAuth flow. + * + * `createAAuthFetch` has no internal mutex. A fresh flow against a resource is + * now several round trips — 401 `requirement=person-token`, POST the PS's + * `person_token_endpoint`, retry, 401 `requirement=auth-token`, POST the PS's + * `auth_token_endpoint` (possibly with a browser interaction in between), + * retry — so the window in which concurrent requests would each start their own + * flow, and each open their own browser tab, is wider than it was under -10. + * + * Only POSTs are gated. The transport's GET is the long-lived SSE stream and + * must never wait behind an auth flow. + * + * Waiters re-take the gate one at a time rather than all resuming together, so + * the second request through uses the token the first one obtained. + */ +export function serializeAuthFlows(inner: ProxyFetch): ProxyFetch { + let inFlight: Promise | null = null + + return async (url, init) => { + const method = init?.method ?? 'GET' + if (method !== 'POST') { + return inner(url, init) + } + + while (inFlight) { + await inFlight + } + + let release!: () => void + inFlight = new Promise((resolve) => { release = resolve }) + try { + return await inner(url, init) + } finally { + inFlight = null + release() + } + } +} + export async function bridgeTransports( local: Transport, remote: Transport, diff --git a/package-lock.json b/package-lock.json index 2b72a3f..295bcf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,26 +9,41 @@ "version": "1.0.0", "license": "MIT", "workspaces": [ + "protocol", "interaction-code", "local-keys", "bootstrap", "hardware-keys", - "mcp-agent", - "mcp-server", + "agent", + "resource", "mcp-stdio", "mcp-openclaw", "fetch" ], "devDependencies": { + "@hellocoop/mockin": "^2.0.0", "vitest": "^3.0.0" } }, + "agent": { + "name": "@aauth/agent", + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "@aauth/protocol": "^1.0.0", + "@hellocoop/httpsig": "^2.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, "bootstrap": { "name": "@aauth/bootstrap", - "version": "1.2.4", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@aauth/local-keys": "^1.1.0" + "@aauth/local-keys": "^2.0.0" }, "bin": { "aauth-bootstrap": "dist/cli.js" @@ -36,11 +51,12 @@ }, "fetch": { "name": "@aauth/fetch", - "version": "2.0.0", + "version": "3.0.0", "license": "MIT", "dependencies": { - "@aauth/local-keys": "^1.2.0", - "@aauth/mcp-agent": "^2.0.0", + "@aauth/agent": "^3.0.0", + "@aauth/local-keys": "^2.0.0", + "@aauth/protocol": "^1.0.0", "open": "^11.0.0", "qrcode-terminal": "^0.12.0" }, @@ -77,11 +93,11 @@ }, "local-keys": { "name": "@aauth/local-keys", - "version": "1.3.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "@napi-rs/keyring": "^1.1.3", - "jose": "^5.0.0" + "jose": "^6.0.0" }, "devDependencies": { "@types/node": "^20.0.0", @@ -91,25 +107,23 @@ "@aauth/hardware-keys": "^1.0.0" } }, - "mcp-agent": { - "name": "@aauth/mcp-agent", - "version": "2.0.0", + "local-keys/node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", "license": "MIT", - "dependencies": { - "@hellocoop/httpsig": "^2.0.0" - }, - "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.0.0" + "funding": { + "url": "https://github.com/sponsors/panva" } }, "mcp-openclaw": { "name": "@aauth/mcp-openclaw", - "version": "2.0.0", + "version": "3.0.0", "license": "MIT", "dependencies": { - "@aauth/local-keys": "^1.0.0", - "@aauth/mcp-agent": "^2.0.0", + "@aauth/agent": "^3.0.0", + "@aauth/local-keys": "^2.0.0", + "@aauth/protocol": "^1.0.0", "@modelcontextprotocol/sdk": "^1.15.1" }, "devDependencies": { @@ -117,26 +131,13 @@ "typescript": "^5.0.0" } }, - "mcp-server": { - "name": "@aauth/mcp-server", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "@aauth/interaction-code": "^0.1.0", - "jose": "^5.0.0" - }, - "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.0.0" - } - }, "mcp-stdio": { "name": "@aauth/mcp-stdio", - "version": "2.0.0", + "version": "3.0.0", "license": "MIT", "dependencies": { - "@aauth/local-keys": "^1.0.0", - "@aauth/mcp-agent": "^2.0.0", + "@aauth/agent": "^3.0.0", + "@aauth/local-keys": "^2.0.0", "@modelcontextprotocol/sdk": "^1.15.1", "open": "^11.0.0" }, @@ -148,6 +149,10 @@ "typescript": "^5.0.0" } }, + "node_modules/@aauth/agent": { + "resolved": "agent", + "link": true + }, "node_modules/@aauth/bootstrap": { "resolved": "bootstrap", "link": true @@ -223,22 +228,22 @@ "resolved": "local-keys", "link": true }, - "node_modules/@aauth/mcp-agent": { - "resolved": "mcp-agent", - "link": true - }, "node_modules/@aauth/mcp-openclaw": { "resolved": "mcp-openclaw", "link": true }, - "node_modules/@aauth/mcp-server": { - "resolved": "mcp-server", - "link": true - }, "node_modules/@aauth/mcp-stdio": { "resolved": "mcp-stdio", "link": true }, + "node_modules/@aauth/protocol": { + "resolved": "protocol", + "link": true + }, + "node_modules/@aauth/resource": { + "resolved": "resource", + "link": true + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -681,15 +686,243 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/fast-uri": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", + "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/@fastify/cors": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", + "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "mnemonist": "0.40.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/formbody": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz", + "integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.1.2", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/proxy-addr/node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@hellocoop/constants": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hellocoop/constants/-/constants-1.1.6.tgz", + "integrity": "sha512-AVQVoNvP0nMHI+T5HoJI0Vrz5o3Xr1v1EQWdRJTbdOhQtrU0fvjt9chfUdCJtNuYd/gDq2EMAJslGldPVNjBkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@hellocoop/httpsig": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.0.1.tgz", - "integrity": "sha512-nmAI+A3YQKOfeZ9cnKjtNXVVJ6sUwazwhM0zQV/t8XpbMKJdYoj3IfaTph9LUMlAvBlDN70wJ2jnUYOr0GQW6g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.2.0.tgz", + "integrity": "sha512-UdWonQL79Nb/NmY5YKSmcdaC/Drjx5lINixdDk8yr2WN93q2zmAV28U/aV5YW35fZWu3n3G0cIavygNKoUPekA==", "license": "MIT", "engines": { "node": ">=18" } }, + "node_modules/@hellocoop/mockin": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hellocoop/mockin/-/mockin-2.0.0.tgz", + "integrity": "sha512-7/uCjdUvVJHSiPms5ZKTfhPwKbFx7INFIkOooUmp+BkNt+/1JIxTJoh06Dc+WZhaXHHK9VHuJIkUBmoCv+rADQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/cors": "^10.0.0", + "@fastify/formbody": "^8.0.0", + "@hellocoop/constants": "*", + "@hellocoop/httpsig": "^2.0.0", + "fastify": "^5.0.0", + "jose": "^5.0.0", + "pkce-challenge": "^4.0.1" + }, + "bin": { + "mockin": "src/server.js" + }, + "engines": { + "node": "~22" + } + }, + "node_modules/@hellocoop/mockin/node_modules/pkce-challenge": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", + "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -1009,6 +1242,13 @@ "node": ">= 10" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -1548,6 +1788,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "dev": true, + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1604,6 +1851,37 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1865,6 +2143,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2090,16 +2378,24 @@ "express": ">= 4.11" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "dev": true, "funding": [ { "type": "github", @@ -2110,9 +2406,121 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" - }, - "node_modules/fdir": { + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", + "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.11.3", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.11.3.tgz", + "integrity": "sha512-W6hzDP8s0iSeL7LGwY6Oc/ZxuXWOvFEMs6p2L0Si415YRo27W5pBKdOTXxhemBDeSTAcpYf5evRA9onF2OYhPA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", @@ -2151,6 +2559,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2411,6 +2834,7 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -2423,6 +2847,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2435,6 +2879,59 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2507,6 +3004,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mnemonist": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", + "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2562,6 +3069,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2668,6 +3192,46 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "dev": true, + "license": "MIT" + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -2718,6 +3282,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2754,6 +3335,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "dev": true, + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2778,6 +3366,16 @@ "node": ">= 0.10" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2787,6 +3385,34 @@ "node": ">=0.10.0" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -2867,12 +3493,75 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -2918,6 +3607,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -3024,6 +3720,16 @@ "dev": true, "license": "ISC" }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3034,6 +3740,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3070,6 +3786,26 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3131,6 +3867,16 @@ "node": ">=14.0.0" } }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3452,6 +4198,41 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "protocol": { + "name": "@aauth/protocol", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@hellocoop/httpsig": "^2.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, + "resource": { + "name": "@aauth/resource", + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@aauth/interaction-code": "^0.1.0", + "@aauth/protocol": "^1.0.0", + "jose": "^6.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, + "resource/node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } } } } diff --git a/package.json b/package.json index 4010f98..e913094 100644 --- a/package.json +++ b/package.json @@ -18,15 +18,17 @@ "test": "vitest run" }, "devDependencies": { + "@hellocoop/mockin": "^2.0.0", "vitest": "^3.0.0" }, "workspaces": [ + "protocol", "interaction-code", "local-keys", "bootstrap", "hardware-keys", - "mcp-agent", - "mcp-server", + "agent", + "resource", "mcp-stdio", "mcp-openclaw", "fetch" diff --git a/protocol/INTEGRATION.md b/protocol/INTEGRATION.md new file mode 100644 index 0000000..9385ef9 --- /dev/null +++ b/protocol/INTEGRATION.md @@ -0,0 +1,131 @@ +# Integrating `@aauth/protocol` + +Four steps. None of them are in this commit — every file they touch is shared +with the other AAuth -11 work packages, so WP-1 left them alone rather than +collide. Apply them in one integration pass. + +The package is `protocol/`, version `1.0.0`, ESM. Its one runtime dependency is +`@hellocoop/httpsig ^2.1.0`, for the RFC 8941 structured field parser on that +package's `/structured-fields` subpath; every consumer of `@aauth/protocol` +installs it anyway. Its only devDependencies are `@types/node ^20.0.0` and +`typescript ^5.0.0`, both already in the tree at the same ranges as every +sibling package. + +--- + +## 1. Workspaces entry — `packages-js/package.json` + +Add `"protocol"` to the `workspaces` array. It has no dependencies on any +sibling, so position does not matter; first is fine, and matches the fact that +`@aauth/agent` and `@aauth/resource` depend on it rather than the reverse. + +```json + "workspaces": [ + "protocol", + "interaction-code", + "local-keys", + "bootstrap", + "hardware-keys", + "mcp-agent", + "mcp-server", + "mcp-stdio", + "mcp-openclaw", + "fetch" + ] +``` + +## 2. Lockfile node — `packages-js/package-lock.json` + +Two entries, matching how every other workspace appears: + +```json + "node_modules/@aauth/protocol": { + "resolved": "protocol", + "link": true + }, +``` + +```json + "protocol": { + "name": "@aauth/protocol", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, +``` + +No `dependencies` key — the absence is asserted by a test in +`protocol/src/index.test.ts`, so do not add one. + +Verify with **`npm ci`**, not `npm install`. `npm install` is forbidden on macOS +in this repo: it prunes the cross-platform `@aauth/hardware-keys-*` optional +nodes. See `packages-js/CLAUDE.md`. + +## 3. Vitest alias — `packages-js/vitest.config.ts` + +**Required.** Without it, any sibling test that imports the package by name +fails to resolve. WP-3 (`@aauth/agent`) and WP-4 (`@aauth/resource`) both +consume `@aauth/protocol` and both need this line. + +Add to the `resolve.alias` map, exactly: + +```ts + '@aauth/protocol': path.resolve(__dirname, 'protocol/src/index.ts'), +``` + +In place, the map reads: + +```ts + resolve: { + alias: { + '@aauth/protocol': path.resolve(__dirname, 'protocol/src/index.ts'), + '@aauth/mcp-server': path.resolve(__dirname, 'mcp-server/src/index.ts'), + '@aauth/mcp-agent': path.resolve(__dirname, 'mcp-agent/src/index.ts'), + '@aauth/mcp-openclaw': path.resolve(__dirname, 'mcp-openclaw/src/index.ts'), + '@aauth/local-keys': path.resolve(__dirname, 'local-keys/src/index.ts'), + '@aauth/hardware-keys': path.resolve(__dirname, 'hardware-keys/index.js'), + }, + }, +``` + +The other WPs rename `mcp-server` and `mcp-agent` to `resource` and `agent`; +those key changes are theirs to report. This entry is additive and does not +conflict with them. + +`protocol/src/*.test.ts` needs no alias — the tests import by relative path, so +they pass today from the worktree root with nothing else configured. + +## 4. npm trusted-publisher bootstrap + +`@aauth/protocol` is a brand-new package name, so **`release.yml` cannot publish +it until the first version exists on the registry.** Per +`packages-js/CLAUDE.md`, publish `1.0.0` by hand, then register the trusted +publisher: + +``` +npm trust github @aauth/protocol \ + --repository aauth-dev/packages-js \ + --file release.yml \ + --allow-publish +``` + +Do this before the wave's coordinated release, or the release run fails on this +package and leaves `@aauth/agent` and `@aauth/resource` pointing at a dependency +that does not exist. + +--- + +## Downstream dependency range + +Packages consuming this one should declare: + +```json + "@aauth/protocol": "^1.0.0" +``` + +WP-3 (`@aauth/agent` 3.0.0) and WP-4 (`@aauth/resource` 2.0.0) each need it. +Whether they declare it is their report to make; this note records the range so +all three agree. diff --git a/protocol/README.md b/protocol/README.md new file mode 100644 index 0000000..ac2487f --- /dev/null +++ b/protocol/README.md @@ -0,0 +1,116 @@ +# @aauth/protocol + +The AAuth wire format, on its own. Header build/parse, `access_mode` planning, +protocol constants, and unverified JWT decoding. No I/O, no crypto. + +Tracks `draft-hardt-oauth-aauth-protocol-11`. + +One runtime dependency: [`@hellocoop/httpsig`](https://www.npmjs.com/package/@hellocoop/httpsig), +imported for its RFC 8941 structured field parser +(`@hellocoop/httpsig/structured-fields`). `AAuth-Requirement` is a Dictionary +and `AAuth-Capabilities` is a List of Tokens, and every consumer of this +package signs its requests with `@hellocoop/httpsig` anyway — so the parser is +already installed, and a second implementation of the same grammar is a second +place for the quoting and escaping rules to be got wrong. + +``` +npm install @aauth/protocol +``` + +## AAuth-Requirement + +```ts +import { parseRequirementHeader, buildRequirementHeader, UnsupportedRequirementError } + from '@aauth/protocol' + +// resource side +res.setHeader('AAuth-Requirement', buildRequirementHeader({ + requirement: 'auth-token', + resourceToken, +})) + +// agent side +try { + const challenge = parseRequirementHeader(res.headers.get('AAuth-Requirement')!) +} catch (e) { + if (e instanceof UnsupportedRequirementError) { + // MUST NOT treat the response as satisfiable. Surface e.value to the caller. + } +} +``` + +Recognized values: `agent-token`, `person-token`, `auth-token`, `approval`, +`interaction`, `clarification`, `claims`. Anything else throws +`UnsupportedRequirementError`, carrying the raw `value`. For a `202` the caller +MAY keep polling `Location` in case a later response carries a value it knows. + +`requirement=auth-token` requires a `resource-token` parameter and +`requirement=interaction` requires both `url` and `code`; a header missing one +is malformed and throws a plain `Error`. Unknown parameters are ignored. + +## AAuth-Capabilities + +```ts +buildCapabilitiesHeader(['interaction', 'clarification']) // "interaction, clarification" +parseCapabilitiesHeader('interaction, quantum-consent') // ["interaction"] +``` + +Parsing filters unrecognized values and never throws — recipients MUST ignore +what they do not recognize. Building does not filter: an agent unions its own +capabilities with the ones its PS reports, which may be newer than this library. + +An absent header is not an empty one. When the header is absent, recipients MUST +NOT assume any capabilities. + +## access_mode + +`access_mode` in `/.well-known/aauth-resource.json` is advisory — the runtime +`AAuth-Requirement` is authoritative. `planAccessMode` gives an agent one of +three answers and never throws. + +```ts +const plan = planAccessMode(metadata.access_mode, { hasPersonServer: false }) + +switch (plan.kind) { + case 'undeclared': // absent or unrecognized — call the resource anyway + case 'satisfiable': // plan.mode is reachable with this setup + case 'unsatisfiable': // skip the resource, show plan.reason +} +``` + +Unrecognized values are `undeclared`, not errors: the value space is the AAuth +Access Mode Value Registry, and an agent that stops on an unknown value breaks +every time a value is registered. + +`unsatisfiable` comes from an agent token with no `ps` claim. Three of the five +modes reach a person server, and without one none of them can complete: + +| Mode | No person server | Why | +| --- | --- | --- | +| `agent-token` | satisfiable | Identity only; no PS in the flow. | +| `session-token` | satisfiable | Resource-managed; the resource issues its own credential. | +| `person-token` | **unsatisfiable** | The agent must sign with a person token, which only a PS issues. | +| `auth-token` | **unsatisfiable** | The resource token is exchanged for an auth token at the PS. | +| `per-call` | **unsatisfiable** | Terminates in an auth token — the grant is the `r3_per_call` claim. | + +## Constants + +`TOKEN_TYP` (the four `typ` values), `DWK` (the four well-known key documents), +`SIGNING_ALG` — `Ed25519`, fully specified per RFC 9864. The polymorphic +`EdDSA` MUST NOT be used. + +## JWT decoding + +`decodeJwtHeader` and `decodeJwtPayload` parse a token's segments and throw on +anything malformed. **No signature verification.** They prove nothing; never +make a trust decision on their output. + +## Not here + +`AAuth-Mission` was removed in -11, along with its IANA registration. A mission +reaches a resource only inside a PS-issued token, as the `mission_s256` claim. +There are no mission header helpers in this package and there will not be. + +## License + +MIT diff --git a/mcp-server/package.json b/protocol/package.json similarity index 68% rename from mcp-server/package.json rename to protocol/package.json index 5937a82..a722d79 100644 --- a/mcp-server/package.json +++ b/protocol/package.json @@ -1,7 +1,7 @@ { - "name": "@aauth/mcp-server", + "name": "@aauth/protocol", "version": "1.0.0", - "description": "AAuth server-side building blocks: challenge headers, interaction management, resource tokens", + "description": "AAuth wire format — AAuth-Requirement and AAuth-Capabilities headers, access_mode planning, protocol constants", "type": "module", "exports": { ".": { @@ -18,8 +18,9 @@ }, "keywords": [ "aauth", - "mcp", - "server" + "agent-auth", + "http-headers", + "access-mode" ], "author": "Dick Hardt ", "license": "MIT", @@ -29,11 +30,10 @@ "repository": { "type": "git", "url": "https://github.com/aauth-dev/packages-js", - "directory": "mcp-server" + "directory": "protocol" }, "dependencies": { - "@aauth/interaction-code": "^0.1.0", - "jose": "^5.0.0" + "@hellocoop/httpsig": "^2.2.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/protocol/src/access-mode.test.ts b/protocol/src/access-mode.test.ts new file mode 100644 index 0000000..4b2a8bf --- /dev/null +++ b/protocol/src/access-mode.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest' +import { + planAccessMode, + isKnownAccessMode, + type AgentSetup, + type KnownAccessMode, +} from './access-mode.js' + +const withPS: AgentSetup = { hasPersonServer: true } +const noPS: AgentSetup = { hasPersonServer: false } + +describe('planAccessMode — undeclared', () => { + it('is undeclared when access_mode is absent', () => { + expect(planAccessMode(undefined, withPS)).toEqual({ kind: 'undeclared' }) + expect(planAccessMode(undefined, noPS)).toEqual({ kind: 'undeclared' }) + }) + + // The value space is a registry (AAuth Access Mode Value Registry). An agent + // that errors on an unknown value breaks every time a new value is registered. + it('is undeclared — never an error — for a value registered after this release', () => { + expect(planAccessMode('quantum-attestation', withPS)).toEqual({ kind: 'undeclared' }) + expect(planAccessMode('quantum-attestation', noPS)).toEqual({ kind: 'undeclared' }) + }) + + it('is undeclared for the -10 spelling of session-token', () => { + expect(planAccessMode('aauth-access-token', withPS)).toEqual({ kind: 'undeclared' }) + }) + + it('is undeclared for an empty or whitespace value', () => { + expect(planAccessMode('', withPS)).toEqual({ kind: 'undeclared' }) + expect(planAccessMode(' ', withPS)).toEqual({ kind: 'undeclared' }) + }) + + it('is case sensitive — a miscased value is simply unrecognized', () => { + expect(planAccessMode('Auth-Token', noPS)).toEqual({ kind: 'undeclared' }) + }) + + it('never throws, whatever it is handed', () => { + for (const v of ['', ' ', '!!', 'agent token', 'per-call ', undefined]) { + expect(() => planAccessMode(v, noPS)).not.toThrow() + } + }) +}) + +// The whole 5 x 2 matrix, per the contract's decision table. Without a person +// server exactly three of the five modes are unsatisfiable: every mode whose +// flow reaches a PS. agent-token is identity only; session-token is +// resource-managed, and the resource issues its own credential. +const MATRIX: Array<{ mode: KnownAccessMode; withPS: 'satisfiable'; noPS: 'satisfiable' | 'unsatisfiable' }> = [ + { mode: 'agent-token', withPS: 'satisfiable', noPS: 'satisfiable' }, + { mode: 'session-token', withPS: 'satisfiable', noPS: 'satisfiable' }, + { mode: 'person-token', withPS: 'satisfiable', noPS: 'unsatisfiable' }, + { mode: 'auth-token', withPS: 'satisfiable', noPS: 'unsatisfiable' }, + { mode: 'per-call', withPS: 'satisfiable', noPS: 'unsatisfiable' }, +] + +describe('planAccessMode — every known mode against both setups', () => { + it('covers all five KnownAccessMode values', () => { + expect(MATRIX.map((r) => r.mode).sort()).toEqual( + ['agent-token', 'auth-token', 'per-call', 'person-token', 'session-token'], + ) + }) + + it.each(MATRIX)('$mode with a person server is $withPS', ({ mode }) => { + expect(planAccessMode(mode, withPS)).toEqual({ kind: 'satisfiable', mode }) + }) + + it.each(MATRIX)('$mode with no person server is $noPS', ({ mode, noPS: expected }) => { + const plan = planAccessMode(mode, noPS) + expect(plan.kind).toBe(expected) + expect(plan).toMatchObject({ mode }) + }) + + it('trims surrounding whitespace before matching', () => { + expect(planAccessMode(' auth-token ', withPS)).toEqual({ + kind: 'satisfiable', + mode: 'auth-token', + }) + }) +}) + +describe('planAccessMode — unsatisfiable', () => { + const unsatisfiable = MATRIX.filter((r) => r.noPS === 'unsatisfiable') + + it.each(unsatisfiable)('$mode names itself and the missing ps claim', ({ mode }) => { + const plan = planAccessMode(mode, noPS) + if (plan.kind !== 'unsatisfiable') throw new Error('expected unsatisfiable') + expect(plan.mode).toBe(mode) + expect(plan.reason).toContain(mode) + expect(plan.reason).toContain('"ps"') + expect(plan.reason).toMatch(/person server/) + }) + + it.each(unsatisfiable)('$mode reason is human readable, not a code', ({ mode }) => { + const plan = planAccessMode(mode, noPS) + if (plan.kind !== 'unsatisfiable') throw new Error('expected unsatisfiable') + expect(plan.reason.length).toBeGreaterThan(40) + expect(plan.reason).not.toMatch(/^[A-Z_]+$/) + }) + + // Spec: "a PS-less agent (no `ps` claim in its agent token) cannot complete + // the auth-token flow." per-call terminates in an auth token too — the grant + // arrives as the r3_per_call auth token claim — so it fails for the same reason. + it('per-call explains that it terminates in an auth token', () => { + const plan = planAccessMode('per-call', noPS) + if (plan.kind !== 'unsatisfiable') throw new Error('expected unsatisfiable') + expect(plan.reason).toMatch(/auth token/) + expect(plan.reason).toContain('r3_per_call') + }) + + it('gives each mode its own reason', () => { + const reasons = unsatisfiable.map(({ mode }) => { + const plan = planAccessMode(mode, noPS) + if (plan.kind !== 'unsatisfiable') throw new Error('expected unsatisfiable') + return plan.reason + }) + expect(new Set(reasons).size).toBe(reasons.length) + }) +}) + +describe('isKnownAccessMode', () => { + it('accepts the five modes and nothing else', () => { + for (const v of ['agent-token', 'person-token', 'session-token', 'auth-token', 'per-call']) { + expect(isKnownAccessMode(v)).toBe(true) + } + for (const v of ['approval', 'interaction', 'aauth-access-token', '', 'Per-Call']) { + expect(isKnownAccessMode(v)).toBe(false) + } + }) +}) diff --git a/protocol/src/access-mode.ts b/protocol/src/access-mode.ts new file mode 100644 index 0000000..d7e0550 --- /dev/null +++ b/protocol/src/access-mode.ts @@ -0,0 +1,100 @@ +/** + * `access_mode` planning (resource metadata, §Resource Metadata). + * + * `access_mode` is advisory. The runtime `AAuth-Requirement` is authoritative: + * a resource MAY return any requirement regardless of what it declared, and MAY + * apply different modes to different endpoints. So an unrecognized value is + * never an error — the agent proceeds as it would with no declaration at all. + */ + +/** The `access_mode` values this library recognizes. */ +export type KnownAccessMode = + | 'agent-token' + | 'person-token' + | 'session-token' + | 'auth-token' + | 'per-call' + +const KNOWN_ACCESS_MODES: readonly KnownAccessMode[] = [ + 'agent-token', + 'person-token', + 'session-token', + 'auth-token', + 'per-call', +] + +/** What the agent can do, for deciding whether a declared mode is reachable. */ +export interface AgentSetup { + /** false when the agent token carries no `ps` claim. */ + hasPersonServer: boolean +} + +export type AccessModePlan = + /** + * Absent, or a value not in KnownAccessMode. Call the resource and read the + * `AAuth-Requirement` it returns. NEVER an error — the value space is a + * registry, and stopping on an unknown value breaks on every new entry. + */ + | { kind: 'undeclared' } + | { kind: 'satisfiable'; mode: KnownAccessMode } + /** + * Recognized, and this agent cannot complete it. Skip the resource, state + * why. With no person server that is `person-token`, `auth-token` and + * `per-call` — every mode whose flow reaches a PS. + */ + | { kind: 'unsatisfiable'; mode: KnownAccessMode; reason: string } + +/** Is `value` an `access_mode` this library recognizes? */ +export function isKnownAccessMode(value: string): value is KnownAccessMode { + return (KNOWN_ACCESS_MODES as readonly string[]).includes(value) +} + +/** + * The three modes an agent with no person server cannot complete, and what to + * tell the caller. `agent-token` and `session-token` are absent: neither has a + * PS anywhere in its flow. + */ +const NO_PERSON_SERVER_REASON: Partial> = { + 'person-token': + 'Resource declares access_mode="person-token", so the agent must sign with a person ' + + 'token obtained from its person server. This agent token carries no "ps" claim, so there ' + + 'is no person server to obtain one from.', + 'auth-token': + 'Resource declares access_mode="auth-token", so the agent must exchange the resource ' + + 'token for an auth token at its person server. This agent token carries no "ps" claim, ' + + 'so it cannot complete the flow — and the initial call would need a person token it ' + + 'also cannot obtain.', + 'per-call': + 'Resource declares access_mode="per-call", which terminates in an auth token: the agent ' + + 'takes the resource token to its person server, and the grant arrives as the ' + + '"r3_per_call" auth token claim. This agent token carries no "ps" claim, so there is no ' + + 'person server to grant it.', +} + +/** + * Decide what a declared `access_mode` means for this agent. + * + * Three outcomes, and only three: + * - absent or unrecognized -> `undeclared`, call the resource anyway + * - recognized and reachable -> `satisfiable` + * - recognized and unreachable -> `unsatisfiable`, with a reason to show + */ +export function planAccessMode(declared: string | undefined, setup: AgentSetup): AccessModePlan { + if (declared === undefined || declared === null) { + return { kind: 'undeclared' } + } + + const mode = declared.trim() + if (!isKnownAccessMode(mode)) { + return { kind: 'undeclared' } + } + + if (!setup.hasPersonServer) { + const reason = NO_PERSON_SERVER_REASON[mode] + if (reason !== undefined) { + return { kind: 'unsatisfiable', mode, reason } + } + } + + return { kind: 'satisfiable', mode } +} diff --git a/protocol/src/capabilities.test.ts b/protocol/src/capabilities.test.ts new file mode 100644 index 0000000..34c2973 --- /dev/null +++ b/protocol/src/capabilities.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest' +import { buildCapabilitiesHeader, parseCapabilitiesHeader, isCapability } from './capabilities.js' + +describe('buildCapabilitiesHeader', () => { + it('renders an RFC 8941 List of Tokens', () => { + expect(buildCapabilitiesHeader(['interaction', 'clarification', 'payment'])).toBe( + 'interaction, clarification, payment', + ) + }) + + it('renders a single capability', () => { + expect(buildCapabilitiesHeader(['interaction'])).toBe('interaction') + }) + + it('renders the empty list as an empty string', () => { + expect(buildCapabilitiesHeader([])).toBe('') + }) + + it('passes through values it does not recognize', () => { + // The agent unions its own capabilities with the ones its PS reports at + // mission approval; the PS may name a capability newer than this library. + expect(buildCapabilitiesHeader(['interaction', 'future-capability'])).toBe( + 'interaction, future-capability', + ) + }) + + it('drops empty entries', () => { + expect(buildCapabilitiesHeader(['interaction', '', ' ', 'payment'])).toBe('interaction, payment') + }) + + // Capability values are Tokens. A value that is not one would produce a + // header no RFC 8941 parser could read, so it is refused rather than emitted. + it('refuses a value that is not a valid Token', () => { + expect(() => buildCapabilitiesHeader(['inter action'])).toThrow(TypeError) + expect(() => buildCapabilitiesHeader(['"quoted"'])).toThrow(TypeError) + expect(() => buildCapabilitiesHeader(['1payment'])).toThrow(TypeError) + }) +}) + +describe('parseCapabilitiesHeader', () => { + it('parses the full list', () => { + expect(parseCapabilitiesHeader('interaction, clarification, payment')).toEqual([ + 'interaction', + 'clarification', + 'payment', + ]) + }) + + it('tolerates missing and extra whitespace', () => { + expect(parseCapabilitiesHeader('interaction,clarification')).toEqual([ + 'interaction', + 'clarification', + ]) + expect(parseCapabilitiesHeader(' interaction , payment ')).toEqual(['interaction', 'payment']) + }) + + // "Recipients MUST ignore unrecognized capability values." + it('filters unrecognized values and never throws', () => { + expect(parseCapabilitiesHeader('interaction, quantum-consent, payment')).toEqual([ + 'interaction', + 'payment', + ]) + }) + + it('returns an empty array when nothing is recognized', () => { + expect(parseCapabilitiesHeader('quantum-consent, telepathy')).toEqual([]) + }) + + it('returns an empty array for an empty header value', () => { + expect(parseCapabilitiesHeader('')).toEqual([]) + expect(parseCapabilitiesHeader(' ')).toEqual([]) + }) + + it('never throws on garbage', () => { + for (const junk of [',,,', '"', 'a=b; c', '\t', 'interaction;q=1']) { + expect(() => parseCapabilitiesHeader(junk)).not.toThrow() + } + }) + + // A List that does not parse is ignored whole rather than surfaced: the spec + // says recipients MUST ignore values they do not recognize, and an absent + // header and an unreadable one lead to the same place. + it('returns an empty array for a malformed List', () => { + expect(parseCapabilitiesHeader('interaction, ,')).toEqual([]) + expect(parseCapabilitiesHeader('interaction, "unterminated')).toEqual([]) + expect(parseCapabilitiesHeader('interaction,')).toEqual([]) + }) + + it('is case sensitive', () => { + expect(parseCapabilitiesHeader('Interaction, PAYMENT')).toEqual([]) + }) + + it('round-trips the recognized values', () => { + const caps = ['interaction', 'clarification', 'payment'] + expect(parseCapabilitiesHeader(buildCapabilitiesHeader(caps))).toEqual(caps) + }) +}) + +describe('isCapability', () => { + it('accepts the three defined values and nothing else', () => { + for (const v of ['interaction', 'clarification', 'payment']) expect(isCapability(v)).toBe(true) + for (const v of ['approval', 'claims', '', 'Payment']) expect(isCapability(v)).toBe(false) + }) +}) diff --git a/protocol/src/capabilities.ts b/protocol/src/capabilities.ts new file mode 100644 index 0000000..04c5c48 --- /dev/null +++ b/protocol/src/capabilities.ts @@ -0,0 +1,69 @@ +import { + parseList, + serializeList, + isInnerList, + bareItemToString, + Token, + type List, +} from '@hellocoop/httpsig/structured-fields' + +/** The capability values defined by AAuth -11 (§AAuth-Capabilities). */ +export type Capability = 'interaction' | 'clarification' | 'payment' + +const CAPABILITIES: readonly Capability[] = ['interaction', 'clarification', 'payment'] + +/** Is `value` a capability value this library recognizes? */ +export function isCapability(value: string): value is Capability { + return (CAPABILITIES as readonly string[]).includes(value) +} + +/** + * Build an `AAuth-Capabilities` request header value — an RFC 8941 List of + * Tokens. + * + * AAuth-Capabilities: interaction, clarification, payment + * + * Values are not filtered: an agent unions its own capabilities with those its + * PS reports, and the PS may name a capability newer than this library. They + * are validated as Tokens, though — a value that cannot be serialized as one + * would produce a header no recipient could parse, so it throws instead. + */ +export function buildCapabilitiesHeader(capabilities: string[]): string { + const list: List = capabilities + .map((c) => c.trim()) + .filter((c) => c.length > 0) + .map((c) => [new Token(c), new Map()]) + + return serializeList(list) +} + +/** + * Parse an `AAuth-Capabilities` request header value. + * + * Unrecognized values are filtered out and never throw: "Recipients MUST ignore + * unrecognized capability values." A header that is not a well-formed List is + * ignored whole, for the same reason. An absent header is not the same as an + * empty one — when the header is absent, recipients MUST NOT assume any + * capabilities. + */ +export function parseCapabilitiesHeader(headerValue: string): string[] { + let list: List + try { + list = parseList(headerValue) + } catch { + return [] + } + + const out: string[] = [] + for (const member of list) { + if (isInnerList(member)) continue + let value: string + try { + value = bareItemToString(member[0]) + } catch { + continue + } + if (isCapability(value)) out.push(value) + } + return out +} diff --git a/protocol/src/constants.ts b/protocol/src/constants.ts new file mode 100644 index 0000000..8ff97f5 --- /dev/null +++ b/protocol/src/constants.ts @@ -0,0 +1,24 @@ +/** JWT `typ` header values for the four AAuth token types. */ +export const TOKEN_TYP = { + agent: 'aa-agent+jwt', + person: 'aa-person+jwt', + resource: 'aa-resource+jwt', + auth: 'aa-auth+jwt', +} as const + +/** + * Discoverable well-known key (`dwk`) document names. The `dwk` claim names + * the `/.well-known/` document under `iss` that publishes the signing key. + */ +export const DWK = { + agent: 'aauth-agent.json', + person: 'aauth-person.json', + resource: 'aauth-resource.json', + access: 'aauth-access.json', +} as const + +/** + * The AAuth signing algorithm, fully specified per RFC 9864. The polymorphic + * `EdDSA` identifier MUST NOT be used. + */ +export const SIGNING_ALG = 'Ed25519' as const diff --git a/protocol/src/index.test.ts b/protocol/src/index.test.ts new file mode 100644 index 0000000..364814d --- /dev/null +++ b/protocol/src/index.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import * as protocol from './index.js' + +describe('public surface', () => { + it('exports exactly the contracted runtime surface', () => { + expect(Object.keys(protocol).sort()).toEqual( + [ + 'DWK', + 'SIGNING_ALG', + 'TOKEN_TYP', + 'UnsupportedRequirementError', + 'buildCapabilitiesHeader', + 'buildRequirementHeader', + 'decodeJwtHeader', + 'decodeJwtPayload', + 'isCapability', + 'isKnownAccessMode', + 'isRequirementValue', + 'parseCapabilitiesHeader', + 'parseRequirementHeader', + 'planAccessMode', + ].sort(), + ) + }) + + // AAuth-Mission and its IANA registration were removed in -11. A mission + // reaches a resource only inside a PS-issued token, as the mission_s256 claim. + it('exports no mission header helpers', () => { + for (const name of ['buildMissionHeader', 'parseMissionHeader', 'AAuthMission']) { + expect(protocol).not.toHaveProperty(name) + } + }) + + // The package has one runtime dependency and it is deliberate: the RFC 8941 + // parser lives in @hellocoop/httpsig, which anything importing this package + // pulls in anyway. What must not appear is a second one. + it('depends on @hellocoop/httpsig and nothing else', async () => { + const pkg = await import('../package.json', { with: { type: 'json' } }) + const deps = (pkg.default as Record).dependencies as Record + expect(Object.keys(deps)).toEqual(['@hellocoop/httpsig']) + }) +}) diff --git a/protocol/src/index.ts b/protocol/src/index.ts new file mode 100644 index 0000000..c2c4981 --- /dev/null +++ b/protocol/src/index.ts @@ -0,0 +1,36 @@ +/** + * @aauth/protocol — the AAuth wire format, on its own. + * + * Header build/parse, `access_mode` planning, protocol constants and + * unverified JWT decoding. No I/O, no crypto. + * + * One runtime dependency: `@hellocoop/httpsig`, for its RFC 8941 structured + * field parser. Anything that speaks AAuth signs its requests with that + * package already, so this costs nothing at the install and saves a second + * hand-rolled parser of the same grammar. + * + * Tracks draft-hardt-oauth-aauth-protocol-11. + */ + +// ---------- AAuth-Requirement ---------- +export type { RequirementValue, AAuthChallenge } from './requirement.js' +export { + UnsupportedRequirementError, + isRequirementValue, + buildRequirementHeader, + parseRequirementHeader, +} from './requirement.js' + +// ---------- AAuth-Capabilities ---------- +export type { Capability } from './capabilities.js' +export { isCapability, buildCapabilitiesHeader, parseCapabilitiesHeader } from './capabilities.js' + +// ---------- access_mode ---------- +export type { KnownAccessMode, AgentSetup, AccessModePlan } from './access-mode.js' +export { isKnownAccessMode, planAccessMode } from './access-mode.js' + +// ---------- constants ---------- +export { TOKEN_TYP, DWK, SIGNING_ALG } from './constants.js' + +// ---------- JWT decoding (no verification) ---------- +export { decodeJwtHeader, decodeJwtPayload } from './jwt.js' diff --git a/protocol/src/jwt.test.ts b/protocol/src/jwt.test.ts new file mode 100644 index 0000000..36bb4d2 --- /dev/null +++ b/protocol/src/jwt.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import { decodeJwtHeader, decodeJwtPayload } from './jwt.js' +import { TOKEN_TYP, DWK, SIGNING_ALG } from './constants.js' + +function b64url(value: unknown): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url') +} + +function makeJwt(header: unknown, payload: unknown): string { + return `${b64url(header)}.${b64url(payload)}.c2ln` +} + +const personToken = makeJwt( + { alg: SIGNING_ALG, typ: TOKEN_TYP.person, kid: 'k1' }, + { + iss: 'https://ps.example', + dwk: DWK.person, + aud: 'https://resource.example', + sub: 'directed-subject', + cnf: { jwk: { kty: 'OKP', crv: 'Ed25519', alg: SIGNING_ALG, x: 'xx' } }, + jti: 'j1', + iat: 1_700_000_000, + exp: 1_700_003_600, + }, +) + +describe('decodeJwtHeader', () => { + it('decodes the header', () => { + expect(decodeJwtHeader(personToken)).toEqual({ + alg: 'Ed25519', + typ: 'aa-person+jwt', + kid: 'k1', + }) + }) + + it('handles base64url segments containing - and _', () => { + const jwt = makeJwt({ typ: TOKEN_TYP.agent, x: '>>>???' }, { a: 1 }) + expect(decodeJwtHeader(jwt).x).toBe('>>>???') + }) +}) + +describe('decodeJwtPayload', () => { + it('decodes the payload', () => { + const payload = decodeJwtPayload(personToken) + expect(payload.iss).toBe('https://ps.example') + expect(payload.dwk).toBe('aauth-person.json') + expect(payload.jti).toBe('j1') + }) + + it('decodes UTF-8 beyond ASCII', () => { + expect(decodeJwtPayload(makeJwt({}, { name: 'Ünïcøde ✓' })).name).toBe('Ünïcøde ✓') + }) +}) + +describe('decoding failures', () => { + it('throws when the token is not three segments', () => { + expect(() => decodeJwtPayload('a.b')).toThrow(/3 dot-separated segments/) + expect(() => decodeJwtHeader('not-a-jwt')).toThrow(/3 dot-separated segments/) + }) + + it('throws on an empty string', () => { + expect(() => decodeJwtPayload('')).toThrow(/not a string/) + }) + + it('throws when a segment is not JSON', () => { + const jwt = `${Buffer.from('not json', 'utf8').toString('base64url')}.${b64url({})}.sig` + expect(() => decodeJwtHeader(jwt)).toThrow(/not valid JSON/) + }) + + it('throws when a segment is JSON but not an object', () => { + expect(() => decodeJwtPayload(makeJwt({}, [1, 2, 3]))).toThrow(/not a JSON object/) + expect(() => decodeJwtPayload(makeJwt({}, 'a string'))).toThrow(/not a JSON object/) + expect(() => decodeJwtPayload(makeJwt({}, null))).toThrow(/not a JSON object/) + }) +}) + +describe('constants', () => { + it('names the four token typ values', () => { + expect(TOKEN_TYP).toEqual({ + agent: 'aa-agent+jwt', + person: 'aa-person+jwt', + resource: 'aa-resource+jwt', + auth: 'aa-auth+jwt', + }) + }) + + it('names the four dwk documents', () => { + expect(DWK).toEqual({ + agent: 'aauth-agent.json', + person: 'aauth-person.json', + resource: 'aauth-resource.json', + access: 'aauth-access.json', + }) + }) + + it('uses the fully-specified RFC 9864 algorithm, not polymorphic EdDSA', () => { + expect(SIGNING_ALG).toBe('Ed25519') + expect(SIGNING_ALG).not.toBe('EdDSA') + }) +}) diff --git a/protocol/src/jwt.ts b/protocol/src/jwt.ts new file mode 100644 index 0000000..3c7f2ee --- /dev/null +++ b/protocol/src/jwt.ts @@ -0,0 +1,47 @@ +/** + * JWT decoding with NO signature verification. + * + * These read a token's own claims for routing, logging and dispatch. They prove + * nothing. Never make a trust decision on their output. + */ + +function decodeSegment(jwt: string, index: number, label: string): Record { + if (typeof jwt !== 'string' || jwt.length === 0) { + throw new Error(`Cannot decode JWT ${label}: not a string`) + } + + const parts = jwt.split('.') + if (parts.length < 3) { + throw new Error(`Cannot decode JWT ${label}: expected 3 dot-separated segments, got ${parts.length}`) + } + + let json: string + try { + json = Buffer.from(parts[index], 'base64url').toString('utf8') + } catch { + throw new Error(`Cannot decode JWT ${label}: segment is not valid base64url`) + } + + let parsed: unknown + try { + parsed = JSON.parse(json) + } catch { + throw new Error(`Cannot decode JWT ${label}: segment is not valid JSON`) + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Cannot decode JWT ${label}: segment is not a JSON object`) + } + + return parsed as Record +} + +/** Decode a JWT's header. No signature verification. */ +export function decodeJwtHeader(jwt: string): Record { + return decodeSegment(jwt, 0, 'header') +} + +/** Decode a JWT's payload. No signature verification. */ +export function decodeJwtPayload(jwt: string): Record { + return decodeSegment(jwt, 1, 'payload') +} diff --git a/protocol/src/requirement.test.ts b/protocol/src/requirement.test.ts new file mode 100644 index 0000000..ba27b22 --- /dev/null +++ b/protocol/src/requirement.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from 'vitest' +import { + buildRequirementHeader, + parseRequirementHeader, + UnsupportedRequirementError, + isRequirementValue, + type AAuthChallenge, +} from './requirement.js' + +describe('parseRequirementHeader — recognized values', () => { + it('parses every -11 requirement value that takes no parameters', () => { + for (const value of ['agent-token', 'person-token', 'approval', 'clarification', 'claims']) { + expect(parseRequirementHeader(`requirement=${value}`)).toEqual({ requirement: value }) + } + }) + + it('parses auth-token with its resource-token', () => { + expect(parseRequirementHeader('requirement=auth-token; resource-token="eyJhbGci.eyJzdWIi.sig"')) + .toEqual({ requirement: 'auth-token', resourceToken: 'eyJhbGci.eyJzdWIi.sig' }) + }) + + it('parses interaction with url and code', () => { + expect(parseRequirementHeader('requirement=interaction; url="https://example.com/interact"; code="A1B2-C3D4"')) + .toEqual({ + requirement: 'interaction', + url: 'https://example.com/interact', + code: 'A1B2-C3D4', + }) + }) + + it('accepts a folded header (the spec prints interaction across lines)', () => { + const folded = + 'requirement=interaction;\n url="https://example.com/interact";\n code="A1B2-C3D4"' + expect(parseRequirementHeader(folded).requirement).toBe('interaction') + expect(parseRequirementHeader(folded).code).toBe('A1B2-C3D4') + }) + + it('ignores unknown parameters', () => { + expect(parseRequirementHeader('requirement=approval; retry-hint="soon"; nonce=7')) + .toEqual({ requirement: 'approval' }) + }) + + it('does not split on a semicolon inside a quoted string', () => { + expect(parseRequirementHeader('requirement=interaction; url="https://example.com/a;b"; code="XYZ"')) + .toEqual({ requirement: 'interaction', url: 'https://example.com/a;b', code: 'XYZ' }) + }) + + it('finds the requirement member among other dictionary members', () => { + expect(parseRequirementHeader('other=1, requirement=approval')).toEqual({ requirement: 'approval' }) + }) +}) + +describe('parseRequirementHeader — unrecognized values', () => { + // Protocol -11: "An agent that does not recognize the requirement value MUST NOT + // treat the response as satisfiable. It surfaces the unsupported requirement to + // the caller as an error." + it('throws UnsupportedRequirementError, not a generic Error', () => { + expect(() => parseRequirementHeader('requirement=payment')).toThrow(UnsupportedRequirementError) + }) + + it('carries the raw value on the error so the caller can report it', () => { + try { + parseRequirementHeader('requirement=some-future-requirement; extra="x"') + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(UnsupportedRequirementError) + expect((e as UnsupportedRequirementError).value).toBe('some-future-requirement') + expect((e as UnsupportedRequirementError).name).toBe('UnsupportedRequirementError') + expect((e as Error).message).toContain('some-future-requirement') + } + }) + + it('rejects the -10 spelling that -11 does not define', () => { + expect(() => parseRequirementHeader('requirement=aauth-access-token')).toThrow( + UnsupportedRequirementError, + ) + }) + + it('is case sensitive — Token values are', () => { + expect(() => parseRequirementHeader('requirement=Approval')).toThrow(UnsupportedRequirementError) + }) +}) + +describe('parseRequirementHeader — malformed headers', () => { + it('rejects an empty header', () => { + expect(() => parseRequirementHeader('')).toThrow(/empty/i) + expect(() => parseRequirementHeader(' ')).toThrow(/empty/i) + }) + + it('rejects a header with no requirement member', () => { + expect(() => parseRequirementHeader('resource-token="eyJ..."')).toThrow(/no requirement member/i) + }) + + it('rejects an empty requirement value', () => { + expect(() => parseRequirementHeader('requirement=""')).toThrow(/empty requirement value/i) + }) + + it('rejects a header that is not a well-formed Dictionary', () => { + // `requirement=` with nothing after it is a parse error, not an empty + // value: RFC 8941 has no production for a member with no value. + expect(() => parseRequirementHeader('requirement=')).toThrow(/malformed/i) + expect(() => parseRequirementHeader('requirement=approval, ,')).toThrow(/malformed/i) + expect(() => parseRequirementHeader('requirement="unterminated')).toThrow(/malformed/i) + }) + + // RFC 8941 §3.3.3 permits only \\ and \" inside a String. `sf.ts` unescaped + // \; the real parser refuses the header. + it('rejects an invalid escape inside a quoted value', () => { + expect(() => + parseRequirementHeader('requirement=auth-token; resource-token="a\\nb"'), + ).toThrow(/malformed/i) + }) + + // Kept on purpose: a sender that omits the quotes produces a Token, and + // base64url plus `.` is entirely inside the Token character set. + it('accepts a bare unquoted resource-token', () => { + expect(parseRequirementHeader('requirement=auth-token; resource-token=eyJhbGci.eyJzdWIi.sig')) + .toEqual({ requirement: 'auth-token', resourceToken: 'eyJhbGci.eyJzdWIi.sig' }) + }) + + // RFC 8941 §3.2: "the last instance takes precedence". `sf.ts` took the first. + it('takes the last of duplicate dictionary members', () => { + expect(parseRequirementHeader('requirement=approval, requirement=claims')).toEqual({ + requirement: 'claims', + }) + }) + + // The old `\s+ -> ' '` pre-pass collapsed runs of whitespace everywhere, + // including inside a quoted value. + it('preserves whitespace inside a quoted value', () => { + expect( + parseRequirementHeader('requirement=interaction; url="https://x.example/a b"; code="A B"'), + ).toEqual({ requirement: 'interaction', url: 'https://x.example/a b', code: 'A B' }) + }) + + it('rejects auth-token with no resource-token parameter', () => { + expect(() => parseRequirementHeader('requirement=auth-token')).toThrow(/resource-token/) + expect(() => parseRequirementHeader('requirement=auth-token; url="https://x.example"')).toThrow( + /resource-token/, + ) + }) + + it('rejects interaction missing url, code, or both', () => { + expect(() => parseRequirementHeader('requirement=interaction')).toThrow(/url or code/) + expect(() => parseRequirementHeader('requirement=interaction; url="https://x.example"')).toThrow( + /url or code/, + ) + expect(() => parseRequirementHeader('requirement=interaction; code="A1B2"')).toThrow(/url or code/) + }) + + it('malformed-parameter errors are not UnsupportedRequirementError', () => { + expect(() => parseRequirementHeader('requirement=auth-token')).not.toThrow( + UnsupportedRequirementError, + ) + }) +}) + +describe('buildRequirementHeader', () => { + it('builds the parameterless values', () => { + expect(buildRequirementHeader({ requirement: 'approval' })).toBe('requirement=approval') + expect(buildRequirementHeader({ requirement: 'agent-token' })).toBe('requirement=agent-token') + expect(buildRequirementHeader({ requirement: 'person-token' })).toBe('requirement=person-token') + expect(buildRequirementHeader({ requirement: 'clarification' })).toBe('requirement=clarification') + expect(buildRequirementHeader({ requirement: 'claims' })).toBe('requirement=claims') + }) + + // RFC 8941 serialization puts no space after the `;` that introduces a + // parameter. The spaced form parses identically and is still accepted on + // the way in; this is what we emit. + it('builds auth-token with a quoted resource-token', () => { + expect(buildRequirementHeader({ requirement: 'auth-token', resourceToken: 'eyJ.a.b' })).toBe( + 'requirement=auth-token;resource-token="eyJ.a.b"', + ) + }) + + it('builds interaction with url and code', () => { + expect( + buildRequirementHeader({ + requirement: 'interaction', + url: 'https://example.com/interact', + code: 'A1B2-C3D4', + }), + ).toBe('requirement=interaction;url="https://example.com/interact";code="A1B2-C3D4"') + }) + + it('throws when a required parameter is missing', () => { + expect(() => buildRequirementHeader({ requirement: 'auth-token' })).toThrow(/resourceToken/) + expect(() => + buildRequirementHeader({ requirement: 'interaction', url: 'https://x.example' }), + ).toThrow(/url and code/) + }) + + it('throws UnsupportedRequirementError on a value it does not know', () => { + expect(() => + buildRequirementHeader({ requirement: 'made-up' as never }), + ).toThrow(UnsupportedRequirementError) + }) + + it('round-trips, including values needing escapes', () => { + const challenges: AAuthChallenge[] = [ + { requirement: 'approval' }, + { requirement: 'agent-token' }, + { requirement: 'person-token' }, + { requirement: 'clarification' }, + { requirement: 'claims' }, + { requirement: 'auth-token', resourceToken: 'eyJhbGciOiJFZDI1NTE5In0.eyJpc3MiOiJ4In0.sig' }, + { requirement: 'interaction', url: 'https://example.com/i', code: 'A1B2-C3D4' }, + { requirement: 'interaction', url: 'https://example.com/a"b\\c', code: 'ZZZZ' }, + ] + for (const challenge of challenges) { + expect(parseRequirementHeader(buildRequirementHeader(challenge))).toEqual(challenge) + } + }) +}) + +describe('isRequirementValue', () => { + it('accepts all seven -11 values and nothing else', () => { + const all = [ + 'agent-token', + 'person-token', + 'auth-token', + 'approval', + 'interaction', + 'clarification', + 'claims', + ] + for (const v of all) expect(isRequirementValue(v)).toBe(true) + for (const v of ['payment', 'session-token', 'per-call', '', 'APPROVAL']) { + expect(isRequirementValue(v)).toBe(false) + } + }) +}) diff --git a/protocol/src/requirement.ts b/protocol/src/requirement.ts new file mode 100644 index 0000000..5d68644 --- /dev/null +++ b/protocol/src/requirement.ts @@ -0,0 +1,194 @@ +import { + parseDictionary, + serializeDictionary, + isInnerList, + bareItemToString, + Token, + type Dictionary, + type Parameters, +} from '@hellocoop/httpsig/structured-fields' + +/** + * The `requirement` values defined by AAuth -11 (§Requirement Values). + * The value space is an extension point; extensions are recorded in the AAuth + * Requirement Value Registry and are NOT recognized by this library. + */ +export type RequirementValue = + | 'agent-token' + | 'person-token' + | 'auth-token' + | 'approval' + | 'interaction' + | 'clarification' + | 'claims' + +const REQUIREMENT_VALUES: readonly RequirementValue[] = [ + 'agent-token', + 'person-token', + 'auth-token', + 'approval', + 'interaction', + 'clarification', + 'claims', +] + +export interface AAuthChallenge { + requirement: RequirementValue + /** REQUIRED when `requirement === 'auth-token'`. */ + resourceToken?: string + /** REQUIRED when `requirement === 'interaction'`. */ + url?: string + /** REQUIRED when `requirement === 'interaction'`. */ + code?: string +} + +/** + * Thrown when the `requirement=` value is not recognized. + * + * Protocol -11: "An agent that does not recognize the `requirement` value MUST + * NOT treat the response as satisfiable. It surfaces the unsupported + * requirement to the caller as an error." + * + * For a `202` response the caller MAY keep polling the `Location` URL in case a + * later response carries a requirement it does understand — that decision + * belongs to the caller, which is why the raw `value` is carried on the error. + */ +export class UnsupportedRequirementError extends Error { + readonly value: string + + constructor(value: string) { + super(`Unsupported AAuth requirement value: ${value}`) + this.name = 'UnsupportedRequirementError' + this.value = value + } +} + +/** Is `value` a requirement value this library recognizes? */ +export function isRequirementValue(value: string): value is RequirementValue { + return (REQUIREMENT_VALUES as readonly string[]).includes(value) +} + +/** + * Build an `AAuth-Requirement` response header value. + * + * requirement=auth-token;resource-token="eyJ..." + * requirement=interaction;url="https://example.com/interact";code="A1B2-C3D4" + * requirement=approval + * + * The header is serialized as an RFC 8941 Dictionary, so parameters are + * emitted in the canonical form — `;` with no following space. A recipient + * that splits on `; ` is not an RFC 8941 parser; the spaced form and this one + * parse identically. + * + * Throws when the challenge is missing a parameter its requirement value + * requires. + */ +export function buildRequirementHeader(challenge: AAuthChallenge): string { + const { requirement } = challenge + + if (!isRequirementValue(requirement)) { + throw new UnsupportedRequirementError(String(requirement)) + } + + const parameters: Parameters = new Map() + + if (requirement === 'auth-token') { + if (!challenge.resourceToken) { + throw new Error('requirement=auth-token requires a resourceToken') + } + parameters.set('resource-token', challenge.resourceToken) + } + + if (requirement === 'interaction') { + if (!challenge.url || !challenge.code) { + throw new Error('requirement=interaction requires both url and code') + } + parameters.set('url', challenge.url) + parameters.set('code', challenge.code) + } + + const dictionary: Dictionary = new Map() + dictionary.set('requirement', [new Token(requirement), parameters]) + + return serializeDictionary(dictionary) +} + +/** + * Parse an `AAuth-Requirement` response header value. + * + * The header is an RFC 8941 Dictionary whose `requirement` member carries the + * requirement-specific data as parameters. Unknown parameters are ignored, as + * are any other dictionary members. + * + * @throws {UnsupportedRequirementError} the `requirement=` value is not one this + * library recognizes — the response MUST NOT be treated as satisfiable. + * @throws {Error} the header is empty, is not a well-formed Dictionary, has no + * `requirement` member, or omits a parameter its requirement value requires. + */ +export function parseRequirementHeader(headerValue: string): AAuthChallenge { + // RFC 7230 obs-fold: a header continued across lines arrives with the line + // break and its leading whitespace embedded. Unfold those, and only those — + // whitespace *inside* a quoted parameter value is part of the value. + const unfolded = headerValue.replace(/\r?\n[ \t]+/g, ' ').trim() + if (!unfolded) { + throw new Error('Empty AAuth-Requirement header') + } + + let dictionary: Dictionary + try { + dictionary = parseDictionary(unfolded) + } catch (e) { + throw new Error( + `Malformed AAuth-Requirement header: ${e instanceof Error ? e.message : String(e)}`, + ) + } + + const member = dictionary.get('requirement') + if (member === undefined) { + throw new Error('AAuth-Requirement header has no requirement member') + } + if (isInnerList(member)) { + throw new Error('AAuth-Requirement requirement member must be an Item, not an Inner List') + } + + const [bareValue, parameters] = member + let rawValue: string + try { + rawValue = bareItemToString(bareValue) + } catch { + throw new Error('AAuth-Requirement requirement value must be a Token or a String') + } + if (!rawValue) { + throw new Error('AAuth-Requirement header has an empty requirement value') + } + if (!isRequirementValue(rawValue)) { + throw new UnsupportedRequirementError(rawValue) + } + + const challenge: AAuthChallenge = { requirement: rawValue } + + // Recipients MUST ignore unknown parameters. A recognized parameter whose + // value cannot be read as text is treated as absent, so the requirement's + // own "missing parameter" error is what surfaces. + for (const key of ['resource-token', 'url', 'code'] as const) { + if (!parameters.has(key)) continue + let value: string + try { + value = bareItemToString(parameters.get(key)!) + } catch { + continue + } + if (key === 'resource-token') challenge.resourceToken = value + else if (key === 'url') challenge.url = value + else challenge.code = value + } + + if (challenge.requirement === 'auth-token' && !challenge.resourceToken) { + throw new Error('requirement=auth-token is missing the resource-token parameter') + } + if (challenge.requirement === 'interaction' && (!challenge.url || !challenge.code)) { + throw new Error('requirement=interaction is missing the url or code parameter') + } + + return challenge +} diff --git a/mcp-server/tsconfig.json b/protocol/tsconfig.json similarity index 84% rename from mcp-server/tsconfig.json rename to protocol/tsconfig.json index 8f9b2d3..984d4af 100644 --- a/mcp-server/tsconfig.json +++ b/protocol/tsconfig.json @@ -12,5 +12,6 @@ "sourceMap": true, "skipLibCheck": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/resource/README.md b/resource/README.md new file mode 100644 index 0000000..4a08ace --- /dev/null +++ b/resource/README.md @@ -0,0 +1,267 @@ +# @aauth/resource + +The resource-side reference implementation of [AAuth](https://github.com/dickhardt/AAuth). Verify +what an agent presents, mint what a resource issues, publish and enforce R3. + +Formerly `@aauth/mcp-server`. It contains no MCP and never did — it is the library a resource uses, +whatever protocol the resource speaks. + +Runs on Cloudflare Workers. This package imports no `node:*` built-ins; it uses only `crypto`, +`crypto.subtle`, `fetch`, `TextEncoder` and `TextDecoder`. + +Header construction and parsing live in [`@aauth/protocol`](https://www.npmjs.com/package/@aauth/protocol) +and are re-exported here, so a resource has one import. + +## Install + +```bash +npm install @aauth/resource +``` + +## Verifying what the agent presented + +```ts +import { verifyToken, AAuthTokenError } from '@aauth/resource' + +const verified = await verifyToken({ + jwt: sig.jwt.raw, // from @hellocoop/httpsig + httpSignatureThumbprint: sig.thumbprint, + resource: 'https://notes.example', // this resource's own identifier + accept: ['auth'], // what THIS endpoint requires +}) +``` + +`accept` is required and there is no default. A person token and a PS-issued auth token carry the +same `iss`, `dwk`, `aud`, `sub` and `cnf`; only `typ` distinguishes them. An endpoint that requires +authorization passes `['auth']`. Passing `['auth', 'person']` there accepts an identity assertion as +a grant, and the mistake fails open — so the check is a parameter you must state, not one you can +forget. + +`verifyToken` performs: `typ` recognition → the `accept` check → required-claim structure → `exp` +in the future and `iat` not in the future → key binding (`cnf.jwk` against the HTTP signing key) → +`kid` selection and signature verification against the JWKS discovered at `{iss}/.well-known/{dwk}` +→ `iss` a valid HTTPS server identifier → `aud` equal to `resource`. Nothing is acted on before the +signature verifies. + +The polymorphic `EdDSA` identifier is rejected in both the JWT header and `cnf.jwk`, per RFC 9864. + +### Results + +| `type` | Shape | +| --- | --- | +| `'agent'` | `iss`, `sub`, `jti?`, `ps?`, `parent_agent?`, `cnf`, `iat`, `exp`, `claims` | +| `'person'` | `iss`, `aud`, `sub`, `jti`, `mission_s256?`, `tenant?`, `cnf`, `iat`, `exp`, `claims` | +| `'auth'` | `iss`, `aud`, `ps`, `sub`, `scope?`, `account?`, `mission_s256?`, `tenant?`, `r3_uri?`, `r3_s256?`, `r3_granted?`, `r3_per_call?`, `cnf`, `iat`, `exp`, `claims` | + +There is no `agent` claim in AAuth -11 and none is surfaced. A resource learns which agent it is +talking to from the agent token, and binds authorization to the key via `agent_jkt` / `cnf`. + +`sub` is unique within the issuer, not globally. Treat `(iss, sub)` as the identity, treat `sub` as +opaque, and never match a `sub` from one issuer against a record established under another, however +the values compare. + +### Errors + +`AAuthTokenError` carries a stable `code`. Branch on `code`, never on `message`. + +| Code | Meaning | +| --- | --- | +| `unsupported_token_type` | `typ` is not an AAuth token type | +| `token_type_not_accepted` | Recognized, but not allowed at this call site — including a person token where an auth token is required | +| `invalid_agent_token` / `invalid_person_token` / `invalid_auth_token` | Structure, discovery or signature failed | +| `token_expired` | `exp` is in the past | +| `aud_mismatch` | `aud` is not this resource | +| `key_binding_failed` | `cnf.jwk` is not the key that signed the request | +| `metadata_fetch_failed` | `{iss}/.well-known/{dwk}` could not be read | +| `invalid_configuration` | `accept` or `resource` was not supplied correctly | + +## Challenging + +```ts +import { buildAAuthHeader } from '@aauth/resource' + +buildAAuthHeader('agent-token') // 401 — present your agent token +buildAAuthHeader('person-token') // 401 — obtain a person token from your PS and retry +buildAAuthHeader('auth-token', { resourceToken }) +buildAAuthHeader('interaction', { url, code }) // 202 +buildAAuthHeader('approval') +buildAAuthHeader('clarification') +buildAAuthHeader('claims') +``` + +A resource MUST have verified a person token before it issues a resource token, and MUST challenge +with `requirement=person-token` when it has not. + +`buildMissionHeader`, `parseMissionHeader` and the `AAuthMission` type are gone. The `AAuth-Mission` +header and its IANA registration were removed in -11. A mission reaches a resource only inside a +PS-issued token, as `mission_s256`. + +## Minting a resource token + +```ts +import { createResourceToken } from '@aauth/resource' + +const resourceToken = await createResourceToken( + { + resource: 'https://notes.example', // iss + audience: psUrl, // aud: the PS (three-party) or the AS (four-party) + personToken: verifiedPersonToken, // ps, sub, person_token_jti, mission_s256, tenant come from here + agentJkt: sig.thumbprint, + scope: 'notes.read notes.write', + kid: publicJwk.kid, + r3: { uri: r3_uri, s256: r3_s256 }, // optional; both or neither + missionExpiresAt, // optional clamp + }, + async (payload, header) => signJwt(header, payload, privateKey), +) +``` + +The header handed to your signer is `{ alg: 'Ed25519', typ: 'aa-resource+jwt', kid? }`. Sign it as +given — `alg` is the fully-specified RFC 9864 identifier, and the polymorphic `EdDSA` MUST NOT be +used. + +`mission_s256` is copied from the person token unchanged and is REQUIRED when the person token +carried one; a resource MUST NOT omit it. The PS resolves the person token by `person_token_jti` and +compares, so dropping it is detected as mission stripping. + +`clampToMission(exp, missionExpiresAt)` is exported for anything else a resource derives from a +mission-scoped token: no token carrying `mission_s256` may outlive its mission. + +## R3 documents + +```ts +import { publishR3Document, serveR3Document } from '@aauth/resource' + +const { r3_uri, r3_s256 } = await publishR3Document({ + document: { + vocabulary: 'urn:aauth:vocabulary:mcp', + operations: [{ tool: 'create_note' }], + display: { summary: 'Create notes in your notebook' }, + }, + baseUri: 'https://notes.example/r3', + store, + authorized: [psUrl, agentToken.ps], // see below +}) +``` + +**Serialize once, serve those exact bytes.** `r3_s256` is the SHA-256 of the bytes as served, with +no canonicalization step. Any re-stringify — middleware that parses and re-encodes JSON, a framework +`json()` helper, CDN minification, key reordering — changes the bytes and breaks hash verification at +the PS and AS. `publishR3Document` serializes once and stores the result; `serveR3Document` returns +those bytes verbatim. Write the returned `body` to the wire as-is; do not pass it through a JSON +response helper. + +R3 -02 removed the `version` field. Including one is rejected. + +### The store you supply + +```ts +interface R3Store { + get(key: string): Promise + put(key: string, record: R3Record, ttlSeconds?: number): Promise +} + +interface R3Record { + uri: string // the r3_uri this is served at + s256: string // BASE64URL(SHA-256(body)) — the r3_s256 claim + body: string // the exact bytes to serve + authorized: string[] // server identifiers entitled to fetch it + createdAt: number + expiresAt?: number +} +``` + +Two methods, both keyed by opaque string. Back it with Workers KV +(`put(key, JSON.stringify(record), { expirationTtl })` / `get(key, 'json')`), Redis, or anything +else — `body` is a string and survives a JSON round trip unchanged. `MemoryR3Store` is a conforming +in-memory implementation for tests and single-process deployments. + +Each record is written under two keys: its `s256` and its `uri`. The fetch path knows only the URI; +the per-call retry path knows only the hash from the auth token. Both resolve. + +### Access restriction + +Agents must never read an R3 document — agent opacity depends entirely on it. Exactly two parties +are entitled: + +- the AS named in the `aud` of a resource token carrying that `r3_uri`; and +- the PS named by the `ps` claim of the agent token the agent presented. + +Pass both to `publishR3Document` as `authorized`. In three-party access they are the same party. + +```ts +const res = await serveR3Document({ store, key: url.pathname.split('/').pop()!, signer }) +// 401 unsigned · 403 wrong signer · 404 unknown · 200 with the exact stored bytes +``` + +`signer` is the server identifier established from the *verified* HTTP Message Signature on the +fetch — not a header the caller controls. Comparison is exact string equality. + +## Per-call proposals + +An `r3_per_call` operation is authorized in principle but not for any specific call. When the agent +invokes one, build a proposal: a full R3 document scoped to that one invocation, carrying a REQUIRED +`parameters` object. + +```ts +import { publishProposal, digestParameter, verifyProposalParameters } from '@aauth/resource' + +// 1. Challenge +const proposal = await publishProposal({ + vocabulary: 'urn:aauth:vocabulary:mcp', + operation: { tool: 'send_email' }, + parameters: { + to: 'mom@example.com', + subject: 'Dinner Sunday?', + body: await digestParameter(emailBody, { media_type: 'text/plain' }), + }, + display: { summary: 'Send an email as you', detail: '## Action\nSend an email\n\n## To\nmom@example.com' }, + store, + baseUri: 'https://mail.example/r3', + authorized: [asUrl, agentToken.ps], +}) +// mint a resource token with r3: { uri: proposal.r3_uri, s256: proposal.r3_s256 } + +// 2. …the AS evaluates the parameters, the PS renders `display`, the agent retries… + +// 3. Enforced retry +await verifyProposalParameters({ + store, + r3_s256: authToken.r3_s256!, + presented: call.arguments, + operation: { tool: 'send_email' }, +}) +``` + +`verifyProposalParameters` rejects on any difference: a parameter the proposal did not carry, an +approved parameter missing from the call, a structural value that does not deep-equal the approved +one, or a digest parameter whose bytes do not satisfy `BASE64URL(SHA-256(presented)) === s256`. An +approval to email one recipient cannot be replayed against another. + +`digestParameter` keeps a large or sensitive value out of every token and away from the PS: only the +hash and a short excerpt appear in the proposal. The full bytes travel agent → resource at call +time, where they are verified against the digest. + +The token carries only `r3_uri` / `r3_s256`, never the parameters. + +## Interaction (202 deferred responses) + +```ts +import { InteractionManager } from '@aauth/resource' + +const manager = new InteractionManager({ + baseUrl: 'https://notes.example', + interactionUrl: 'https://notes.example/interact', +}) + +const { headers, pending } = manager.createPending() +// headers: Location, Retry-After, Cache-Control, AAuth-Requirement +manager.resolve(pending.id, { granted: true }) +``` + +This is the only part of the package with a transitive `node:crypto` dependency, via +`@aauth/interaction-code`. On Workers it needs `nodejs_compat`, which workerd supports. + +## License + +MIT diff --git a/resource/package.json b/resource/package.json new file mode 100644 index 0000000..2a65d14 --- /dev/null +++ b/resource/package.json @@ -0,0 +1,45 @@ +{ + "name": "@aauth/resource", + "version": "2.0.0", + "description": "AAuth resource-side reference implementation: token verification, resource tokens, R3 documents and per-call proposals, challenge headers, interaction management", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "aauth", + "resource", + "r3", + "authorization" + ], + "author": "Dick Hardt ", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/aauth-dev/packages-js", + "directory": "resource" + }, + "dependencies": { + "@aauth/interaction-code": "^0.1.0", + "@aauth/protocol": "^1.0.0", + "jose": "^6.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/resource/src/challenge.test.ts b/resource/src/challenge.test.ts new file mode 100644 index 0000000..142a42a --- /dev/null +++ b/resource/src/challenge.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { + buildAAuthHeader, + buildAAuthAccessHeader, + parseCapabilitiesHeader, + parseRequirementHeader, +} from './challenge.js' + +describe('buildAAuthHeader', () => { + it('builds requirement=person-token', () => { + expect(buildAAuthHeader('person-token')).toBe('requirement=person-token') + }) + + it('builds requirement=agent-token', () => { + expect(buildAAuthHeader('agent-token')).toBe('requirement=agent-token') + }) + + it('builds the parameterless requirements', () => { + expect(buildAAuthHeader('approval')).toBe('requirement=approval') + expect(buildAAuthHeader('clarification')).toBe('requirement=clarification') + expect(buildAAuthHeader('claims')).toBe('requirement=claims') + }) + + it('builds requirement=auth-token with the resource token', () => { + const header = buildAAuthHeader('auth-token', { resourceToken: 'eyJ.abc.def' }) + // RFC 8941 serialization: no space after the `;` introducing a parameter. + expect(header).toBe('requirement=auth-token;resource-token="eyJ.abc.def"') + expect(parseRequirementHeader(header)).toEqual({ + requirement: 'auth-token', + resourceToken: 'eyJ.abc.def', + }) + }) + + it('builds requirement=interaction with url and code', () => { + const header = buildAAuthHeader('interaction', { + url: 'https://resource.example/interact', + code: 'A1B2-C3D4', + }) + expect(header).toContain('requirement=interaction') + expect(header).toContain('url="https://resource.example/interact"') + expect(header).toContain('code="A1B2-C3D4"') + }) + + it('rejects auth-token with no resource token', () => { + expect(() => + (buildAAuthHeader as (r: string, p?: unknown) => string)('auth-token'), + ).toThrow() + }) +}) + +describe('parseCapabilitiesHeader', () => { + it('filters unrecognized values rather than throwing', () => { + expect(parseCapabilitiesHeader('interaction, payment, telepathy')).toEqual([ + 'interaction', + 'payment', + ]) + }) +}) + +describe('buildAAuthAccessHeader', () => { + it('passes a token68 through', () => { + expect(buildAAuthAccessHeader('abc-123_XY')).toBe('abc-123_XY') + }) + + it('rejects an empty or whitespace-bearing value', () => { + expect(() => buildAAuthAccessHeader('')).toThrow() + expect(() => buildAAuthAccessHeader('a b')).toThrow() + }) +}) + +describe('mission header helpers', () => { + it('are gone — AAuth-Mission was removed in -11', async () => { + const mod = await import('./index.js') as Record + expect(mod.buildMissionHeader).toBeUndefined() + expect(mod.parseMissionHeader).toBeUndefined() + }) +}) diff --git a/resource/src/challenge.ts b/resource/src/challenge.ts new file mode 100644 index 0000000..ae7ecc3 --- /dev/null +++ b/resource/src/challenge.ts @@ -0,0 +1,76 @@ +import { + buildRequirementHeader, + parseRequirementHeader, + parseCapabilitiesHeader, + buildCapabilitiesHeader, + UnsupportedRequirementError, + TOKEN_TYP, + DWK, + SIGNING_ALG, +} from '@aauth/protocol' +import type { + AAuthChallenge, + RequirementValue, + Capability, + KnownAccessMode, +} from '@aauth/protocol' + +/** + * Header construction and parsing live in `@aauth/protocol`; this package + * re-exports the pieces a resource needs so a resource has one import. + * + * `buildMissionHeader` / `parseMissionHeader` / `AAuthMission` are GONE. The + * `AAuth-Mission` header and its IANA registration were removed in AAuth -11. + * A mission reaches a resource only inside a PS-issued token, as `mission_s256`. + */ +export { + buildRequirementHeader, + parseRequirementHeader, + parseCapabilitiesHeader, + buildCapabilitiesHeader, + UnsupportedRequirementError, + TOKEN_TYP, + DWK, + SIGNING_ALG, +} +export type { AAuthChallenge, RequirementValue, Capability, KnownAccessMode } + +/** Requirements that carry no parameters. */ +export type SimpleRequirement = + | 'agent-token' | 'person-token' | 'approval' | 'clarification' | 'claims' + +/** + * Build an `AAuth-Requirement` response header value. + * + * Resource-side convenience over `buildRequirementHeader`, with the overloads + * that make the required parameters a compile error to omit. + * + * 401 + `requirement=agent-token` — the resource needs the agent's identity. + * 401 + `requirement=person-token` — the resource needs the person's identity + * before it will issue a resource token. + * 401 + `requirement=auth-token` — carries the resource token. + * 202 + `requirement=interaction` — carries the interaction url and code. + */ +export function buildAAuthHeader(requirement: 'auth-token', params: { resourceToken: string }): string +export function buildAAuthHeader(requirement: 'interaction', params: { url: string; code: string }): string +export function buildAAuthHeader(requirement: SimpleRequirement): string +export function buildAAuthHeader( + requirement: RequirementValue, + params?: { resourceToken?: string; url?: string; code?: string }, +): string { + return buildRequirementHeader({ requirement, ...params } as AAuthChallenge) +} + +/** + * Build an `AAuth-Access` response header value — the resource's own session + * token, opaque to the agent. + * + * The value is a `token68` (RFC 9110 §11.2): no whitespace, no control + * characters, non-empty. + */ +export function buildAAuthAccessHeader(token: string): string { + if (!/^[A-Za-z0-9._~+/-]+=*$/.test(token)) { + throw new Error('AAuth-Access value must be a non-empty token68') + } + return token +} diff --git a/resource/src/errors.ts b/resource/src/errors.ts new file mode 100644 index 0000000..84973fd --- /dev/null +++ b/resource/src/errors.ts @@ -0,0 +1,21 @@ +/** + * Error raised by token verification. + * + * `code` is the stable identifier — callers MUST branch on `code`, never on + * `message`. The messages are for logs and may change. + */ +export class AAuthTokenError extends Error { + constructor(public code: string, message: string) { + super(message) + this.name = 'AAuthTokenError' + } +} + +/** Error raised by R3 document publication, fetch authorization, and per-call + * proposal verification. */ +export class R3Error extends Error { + constructor(public code: string, message: string) { + super(message) + this.name = 'R3Error' + } +} diff --git a/resource/src/index.ts b/resource/src/index.ts new file mode 100644 index 0000000..3b44e4b --- /dev/null +++ b/resource/src/index.ts @@ -0,0 +1,109 @@ +/** + * `@aauth/resource` — the resource-side reference implementation of AAuth. + * + * Verify what an agent presents, mint what a resource issues, publish and + * enforce R3. Runs on Cloudflare Workers: no `node:*` imports, only `crypto`, + * `crypto.subtle`, `fetch`, `TextEncoder`/`TextDecoder`. + */ + +// --- Challenges and headers (thin layer over @aauth/protocol) --- +export { + buildAAuthHeader, + buildAAuthAccessHeader, + buildRequirementHeader, + parseRequirementHeader, + parseCapabilitiesHeader, + buildCapabilitiesHeader, + UnsupportedRequirementError, + TOKEN_TYP, + DWK, + SIGNING_ALG, +} from './challenge.js' +export type { + AAuthChallenge, + RequirementValue, + SimpleRequirement, + Capability, + KnownAccessMode, +} from './challenge.js' + +// --- Token verification --- +export { verifyToken } from './verify-token.js' +export { AAuthTokenError, R3Error } from './errors.js' +export { clearMetadataCache, discoverJwks } from './jwks.js' +export type { FetchLike } from './jwks.js' +export type { + TokenKind, + VerifyTokenOptions, + VerifiedAgentToken, + VerifiedPersonToken, + VerifiedAuthToken, + VerifiedToken, +} from './verify-token.js' + +// --- Resource tokens --- +export { + createResourceToken, + clampToMission, + DEFAULT_RESOURCE_TOKEN_LIFETIME, +} from './resource-token.js' +export type { + ResourceTokenOptions, + PersonTokenReference, + SignFn, +} from './resource-token.js' + +// --- R3 documents --- +export { + serializeR3Document, + computeR3Hash, + publishR3Document, + generateR3Id, + getR3ByUri, + getR3ByHash, + parseR3Record, + serveR3Document, + isAuthorizedR3Fetcher, + assertAuthorizedR3Fetcher, + verifyR3Hash, + MemoryR3Store, + R3_MEDIA_TYPE, + R3_DEFAULT_TTL_SECONDS, +} from './r3.js' +export type { + R3Document, + R3Display, + R3OperationSet, + R3ParameterDigest, + R3ParameterValue, + R3Record, + R3Store, + R3Response, + PublishR3Options, + PublishedR3, + SerializedR3, + ServeR3Options, +} from './r3.js' + +// --- Per-call proposals --- +export { + buildProposal, + publishProposal, + digestParameter, + isParameterDigest, + isProposal, + verifyProposalParameters, +} from './proposal.js' +export type { + ProposalOptions, + PublishProposalOptions, + VerifyProposalOptions, + VerifiedProposal, +} from './proposal.js' + +// --- Interaction (202 deferred responses) --- +export { InteractionManager } from './interaction.js' +export type { PendingRequest, InteractionManagerOptions } from './interaction.js' + +// --- Utilities --- +export { isServerIdentifier, sha256Base64url, base64url } from './util.js' diff --git a/mcp-server/src/interaction.test.ts b/resource/src/interaction.test.ts similarity index 99% rename from mcp-server/src/interaction.test.ts rename to resource/src/interaction.test.ts index 1f44c8c..c7edd67 100644 --- a/mcp-server/src/interaction.test.ts +++ b/resource/src/interaction.test.ts @@ -19,7 +19,7 @@ describe('InteractionManager', () => { expect(pending.createdAt).toBeGreaterThan(0) expect(pending.promise).toBeInstanceOf(Promise) - expect(headers.Location).toMatch(/^https:\/\/resource\.example\/pending\/[a-f0-9]+$/) + expect(headers.Location).toMatch(/^https:\/\/resource\.example\/pending\/[A-Za-z0-9_-]+$/) expect(headers['Retry-After']).toBe('0') expect(headers['Cache-Control']).toBe('no-store') expect(headers['AAuth-Requirement']).toContain('requirement=interaction') diff --git a/mcp-server/src/interaction.ts b/resource/src/interaction.ts similarity index 96% rename from mcp-server/src/interaction.ts rename to resource/src/interaction.ts index d7778fb..ebf2c98 100644 --- a/mcp-server/src/interaction.ts +++ b/resource/src/interaction.ts @@ -1,6 +1,6 @@ -import { randomBytes } from 'node:crypto' import { generateCode } from '@aauth/interaction-code' -import { buildAAuthHeader } from './aauth-header.js' +import { buildAAuthHeader } from './challenge.js' +import { randomToken } from './util.js' export interface PendingRequest { id: string @@ -47,7 +47,7 @@ export class InteractionManager { * Create a pending request. Returns 202 response headers and the pending handle. */ createPending(): { headers: Record; pending: PendingRequest } { - const id = randomBytes(16).toString('hex') + const id = randomToken(16) const code = generateCode() let resolve!: (value: T) => void diff --git a/resource/src/jwks.ts b/resource/src/jwks.ts new file mode 100644 index 0000000..b9ab81f --- /dev/null +++ b/resource/src/jwks.ts @@ -0,0 +1,77 @@ +import type { JSONWebKeySet } from 'jose' +import { AAuthTokenError } from './errors.js' + +/** + * Key discovery per I-D.hardt-httpbis-signature-key: the `dwk` claim names the + * issuer's well-known metadata document, fetched from `{iss}/.well-known/{dwk}`. + * The document either carries `jwks_uri` or embeds `jwks` inline. + */ + +export type FetchLike = (input: string, init?: RequestInit) => Promise + +interface CacheEntry { + jwks: JSONWebKeySet + fetchedAt: number +} + +const jwksCache = new Map() +const DEFAULT_TTL_MS = 600_000 // 10 minutes + +/** Exposed for tests and for operators that rotate keys out of band. */ +export function clearMetadataCache(): void { + jwksCache.clear() +} + +export interface DiscoverOptions { + iss: string + dwk: string + fetch?: FetchLike + cacheTtlMs?: number + errorCode?: string +} + +export async function discoverJwks(options: DiscoverOptions): Promise { + const { iss, dwk } = options + const doFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init)) + const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS + const code = options.errorCode ?? 'jwks_discovery_failed' + + const metadataUrl = `${iss}/.well-known/${dwk}` + const cached = jwksCache.get(metadataUrl) + if (cached && Date.now() - cached.fetchedAt < ttl) return cached.jwks + + const res = await doFetch(metadataUrl) + if (!res.ok) { + throw new AAuthTokenError( + 'metadata_fetch_failed', + `Failed to fetch metadata from ${metadataUrl}: ${res.status}`, + ) + } + const metadata = await res.json() as { jwks_uri?: string; jwks?: JSONWebKeySet } + + let jwks: JSONWebKeySet + if (metadata.jwks && Array.isArray(metadata.jwks.keys)) { + jwks = metadata.jwks + } else if (metadata.jwks_uri) { + const jwksRes = await doFetch(metadata.jwks_uri) + if (!jwksRes.ok) { + throw new AAuthTokenError( + code, + `Failed to fetch JWKS from ${metadata.jwks_uri}: ${jwksRes.status}`, + ) + } + jwks = await jwksRes.json() as JSONWebKeySet + } else { + throw new AAuthTokenError( + 'metadata_fetch_failed', + `No jwks_uri or jwks in metadata from ${metadataUrl}`, + ) + } + + if (!jwks || !Array.isArray(jwks.keys)) { + throw new AAuthTokenError(code, `Malformed JWKS for ${iss}`) + } + + jwksCache.set(metadataUrl, { jwks, fetchedAt: Date.now() }) + return jwks +} diff --git a/resource/src/proposal.test.ts b/resource/src/proposal.test.ts new file mode 100644 index 0000000..8dec8b1 --- /dev/null +++ b/resource/src/proposal.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + buildProposal, + publishProposal, + digestParameter, + isParameterDigest, + isProposal, + verifyProposalParameters, + publishR3Document, + serveR3Document, + createResourceToken, + MemoryR3Store, + R3Error, +} from './index.js' +import type { SignFn } from './index.js' +import { RESOURCE, PS, MISSION_S256 } from './testing.js' + +const AS = 'https://as.example' +const AGENT_PS = 'https://agent-ps.example' + +let store: MemoryR3Store + +beforeEach(() => { + store = new MemoryR3Store() +}) + +const BODY = 'Hi Mom, are you free for dinner on Sunday? It has been a while.' + +async function emailProposal() { + return publishProposal({ + vocabulary: 'urn:aauth:vocabulary:mcp', + operation: { tool: 'send_email' }, + parameters: { + to: 'mom@example.com', + subject: 'Dinner Sunday?', + body: await digestParameter(BODY, { media_type: 'text/plain', excerptLength: 20 }), + }, + display: { + summary: 'Send an email as you', + detail: '## Action\nSend an email\n\n## To\nmom@example.com', + }, + store, + baseUri: `${RESOURCE}/r3`, + authorized: [AS, AGENT_PS], + }) +} + +describe('buildProposal', () => { + it('requires parameters', () => { + expect(() => + buildProposal({ + vocabulary: 'urn:aauth:vocabulary:mcp', + operation: { tool: 'send_email' }, + parameters: undefined as never, + }), + ).toThrow('requires a `parameters` object') + }) + + it('scopes the document to one operation', () => { + const p = buildProposal({ + vocabulary: 'urn:aauth:vocabulary:mcp', + operation: { tool: 'send_email' }, + parameters: { to: 'mom@example.com' }, + }) + expect(p.operations).toEqual([{ tool: 'send_email' }]) + expect(p.parameters).toEqual({ to: 'mom@example.com' }) + expect((p as Record).version).toBeUndefined() + }) +}) + +describe('digestParameter', () => { + it('produces s256, excerpt and media_type', async () => { + const d = await digestParameter(BODY, { media_type: 'text/plain', excerptLength: 20 }) + expect(d.s256).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(d.excerpt).toBe('Hi Mom, are you free…') + expect(d.media_type).toBe('text/plain') + expect(isParameterDigest(d)).toBe(true) + // The full value never appears in the proposal. + expect(JSON.stringify(d)).not.toContain('dinner on Sunday') + }) +}) + +describe('the per-call flow', () => { + it('challenges with a resource token that carries only the reference', async () => { + const published = await emailProposal() + + let payload: Record = {} + const sign: SignFn = async p => { payload = p; return 'jwt' } + await createResourceToken( + { + resource: RESOURCE, + audience: AS, + personToken: { iss: PS, sub: 'u-1', jti: 'pt-1', mission_s256: MISSION_S256 }, + agentJkt: 'jkt-1', + scope: 'email.send', + r3: { uri: published.r3_uri, s256: published.r3_s256 }, + }, + sign, + ) + + expect(payload.r3_uri).toBe(published.r3_uri) + expect(payload.r3_s256).toBe(published.r3_s256) + expect(payload.mission_s256).toBe(MISSION_S256) + // The parameters are in the proposal, never in the token. + expect(JSON.stringify(payload)).not.toContain('mom@example.com') + }) + + it('accepts the retry when the parameters match exactly', async () => { + const published = await emailProposal() + const verified = await verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { to: 'mom@example.com', subject: 'Dinner Sunday?', body: BODY }, + operation: { tool: 'send_email' }, + }) + expect(verified.document.operations).toEqual([{ tool: 'send_email' }]) + }) + + it('rejects a different recipient — an approval cannot be replayed', async () => { + const published = await emailProposal() + try { + await verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { to: 'boss@example.com', subject: 'Dinner Sunday?', body: BODY }, + }) + expect.fail('should have thrown') + } catch (err) { + expect((err as R3Error).code).toBe('proposal_parameter_mismatch') + expect((err as Error).message).toContain('"to"') + } + }) + + it('rejects a digest parameter whose bytes changed by one character', async () => { + const published = await emailProposal() + await expect( + verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { to: 'mom@example.com', subject: 'Dinner Sunday?', body: `${BODY} ` }, + }), + ).rejects.toThrow('does not hash to the approved s256') + }) + + it('accepts digest bytes presented as a Uint8Array', async () => { + const published = await emailProposal() + const bytes = new TextEncoder().encode(BODY) + const verified = await verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { to: 'mom@example.com', subject: 'Dinner Sunday?', body: bytes }, + }) + expect(verified.parameters.to).toBe('mom@example.com') + }) + + it('rejects a digest parameter presented as a non-byte value', async () => { + const published = await emailProposal() + await expect( + verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { to: 'mom@example.com', subject: 'Dinner Sunday?', body: { s256: 'x' } }, + }), + ).rejects.toThrow('must present its bytes') + }) + + it('rejects a missing parameter', async () => { + const published = await emailProposal() + await expect( + verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { to: 'mom@example.com', body: BODY }, + }), + ).rejects.toThrow('missing from the call') + }) + + it('rejects a parameter that was never approved', async () => { + const published = await emailProposal() + await expect( + verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { to: 'mom@example.com', subject: 'Dinner Sunday?', body: BODY, bcc: 'x@y.z' }, + }), + ).rejects.toThrow('was not in the approved proposal') + }) + + it('rejects a different operation', async () => { + const published = await emailProposal() + await expect( + verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { to: 'mom@example.com', subject: 'Dinner Sunday?', body: BODY }, + operation: { tool: 'delete_email' }, + }), + ).rejects.toThrow('not the one that was approved') + }) + + it('compares structured parameters deeply, ignoring key order', async () => { + const published = await publishProposal({ + vocabulary: 'urn:aauth:vocabulary:mcp', + operation: { tool: 'transfer' }, + parameters: { amount: { value: 100, currency: 'USD' }, tags: ['a', 'b'] }, + store, + baseUri: `${RESOURCE}/r3`, + authorized: [AS], + }) + + await expect( + verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { amount: { currency: 'USD', value: 100 }, tags: ['a', 'b'] }, + }), + ).resolves.toBeTruthy() + + await expect( + verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { amount: { currency: 'USD', value: 1000 }, tags: ['a', 'b'] }, + }), + ).rejects.toThrow('differs from the approved proposal') + + await expect( + verifyProposalParameters({ + store, + r3_s256: published.r3_s256, + presented: { amount: { currency: 'USD', value: 100 }, tags: ['b', 'a'] }, + }), + ).rejects.toThrow('differs from the approved proposal') + }) + + it('rejects an unknown r3_s256', async () => { + await expect( + verifyProposalParameters({ store, r3_s256: 'not-a-real-hash', presented: {} }), + ).rejects.toThrow('No approved proposal') + }) + + it('rejects a class R3 document presented as a proposal', async () => { + const classDoc = await publishR3Document({ + document: { vocabulary: 'urn:aauth:vocabulary:mcp', operations: [{ tool: 'read' }] }, + baseUri: `${RESOURCE}/r3`, + store, + authorized: [AS], + }) + await expect( + verifyProposalParameters({ store, r3_s256: classDoc.r3_s256, presented: {} }), + ).rejects.toThrow('not a per-call proposal') + }) +}) + +describe('a proposal has the same fetch restriction as a class document', () => { + it('serves to the AS and PS, never the agent', async () => { + const published = await emailProposal() + expect((await serveR3Document({ store, key: published.r3_uri, signer: AS })).status).toBe(200) + expect((await serveR3Document({ store, key: published.r3_uri, signer: AGENT_PS })).status).toBe(200) + + const denied = await serveR3Document({ + store, key: published.r3_uri, signer: 'https://agent.example', + }) + expect(denied.status).toBe(403) + expect(denied.body).not.toContain('mom@example.com') + }) +}) + +describe('isProposal — the r3_s256 dispatch discriminator', () => { + const classDocument = { + vocabulary: 'urn:aauth:vocabulary:openapi', + operations: [{ operationId: 'sendMessage' }], + } + const proposalDocument = { ...classDocument, parameters: { to: 'alice@example.com' } } + + const record = (document: object) => ({ + uri: 'https://resource.example/r3/x', + s256: 'x', + body: JSON.stringify(document), + authorized: ['https://ps.example'], + createdAt: 0, + }) + + it('is false for a class document — the case that makes presence of r3_s256 useless', () => { + // A class-grant auth token carries this document's hash. Branching on + // `if (auth.r3_s256)` would send every granted call into + // verifyProposalParameters and fail with `invalid_proposal`. + expect(isProposal(record(classDocument))).toBe(false) + expect(isProposal(classDocument)).toBe(false) + }) + + it('is true for a per-call proposal', () => { + expect(isProposal(record(proposalDocument))).toBe(true) + expect(isProposal(proposalDocument)).toBe(true) + }) + + it('is true for a proposal with no parameters to vary', () => { + // An empty `parameters` object still marks the document as per-call: the + // approval is for one invocation, whether or not it takes arguments. + expect(isProposal({ ...classDocument, parameters: {} })).toBe(true) + }) + + it('is false for a store miss, so a lookup can be passed straight in', () => { + expect(isProposal(null)).toBe(false) + expect(isProposal(undefined)).toBe(false) + }) + + it('is false when parameters is not an object', () => { + expect(isProposal({ ...classDocument, parameters: [] as never })).toBe(false) + expect(isProposal({ ...classDocument, parameters: 'to=alice' as never })).toBe(false) + }) + + it('is false for unparseable stored bytes rather than throwing', () => { + expect(isProposal({ ...record(classDocument), body: 'not json' })).toBe(false) + }) +}) diff --git a/resource/src/proposal.ts b/resource/src/proposal.ts new file mode 100644 index 0000000..8f1966a --- /dev/null +++ b/resource/src/proposal.ts @@ -0,0 +1,305 @@ +import { R3Error } from './errors.js' +import { deepEqual, sha256Base64url, timingSafeEqualString, toBytes, type Bytes } from './util.js' +import { + publishR3Document, + parseR3Record, + type R3Document, + type R3Display, + type R3ParameterDigest, + type R3ParameterValue, + type R3Record, + type R3Store, + type PublishedR3, +} from './r3.js' + +/** + * Per-call proposals (AAuth-R3 §Per-Call Proposals). + * + * An `r3_per_call` operation is authorized in principle but not for any + * specific call. When the agent invokes one, the resource builds a proposal — + * a full R3 document scoped to that one invocation, carrying a REQUIRED + * `parameters` object — persists it, and challenges with a resource token + * referencing it by `r3_uri`/`r3_s256`. The token carries the reference, never + * the parameters. + * + * On the retry the resource recovers the proposal by `r3_s256` and MUST verify + * the agent's actual parameters against it. Any difference is a rejection: an + * approval to email one recipient cannot be replayed against another. + * + * ## Dispatch: `r3_s256` alone does not mean "per-call retry" + * + * A class-grant auth token carries the **class** document's `r3_s256`, and a + * per-call retry carries the **proposal's**. Both are present, both are + * strings, and nothing in the token distinguishes them. Branching on + * `if (auth.r3_s256)` therefore sends every ordinary granted call into + * {@link verifyProposalParameters}, which fails with `invalid_proposal` — + * an error that points at the document rather than at the dispatch logic that + * is actually wrong. + * + * What separates them is the proposal's REQUIRED `parameters`. So resolve the + * reference and look, with {@link isProposal}: + * + * ```ts + * const record = auth.r3_s256 ? await getR3ByHash(store, auth.r3_s256) : null + * + * if (isProposal(record)) { + * // A per-call retry. The parameters of this call must match the approval. + * await verifyProposalParameters({ store, r3_s256: record.s256, presented, operation }) + * } else if (inSet(auth.r3_granted, operation)) { + * // Authorized as a class. Run it. + * } else if (inSet(auth.r3_per_call, operation)) { + * // Authorized in principle. Build a proposal for this call and challenge. + * } + * ``` + */ + +// --- Digest parameters --- + +/** + * Represent a large or sensitive parameter by a digest instead of the inline + * value. Only the hash and the excerpt reach the PS; the full bytes travel + * agent → resource at call time. + */ +export async function digestParameter( + value: Bytes, + options: { excerpt?: string; media_type?: string; excerptLength?: number } = {}, +): Promise { + const digest: R3ParameterDigest = { s256: await sha256Base64url(value) } + const excerpt = options.excerpt ?? (typeof value === 'string' + ? truncate(value, options.excerptLength ?? 120) + : undefined) + if (excerpt !== undefined) digest.excerpt = excerpt + if (options.media_type !== undefined) digest.media_type = options.media_type + return digest +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}…` +} + +/** + * Is this a per-call proposal rather than a class R3 document? + * + * The one correct answer to the dispatch question above. A proposal is exactly + * an R3 document carrying `parameters`; a class document never does. Accepts a + * stored {@link R3Record} (parsed here), a parsed {@link R3Document}, or + * `null`/`undefined` for "no document referenced", so a call site can pass a + * store lookup straight in. + * + * `record.body` is parsed, not re-serialized — the stored bytes are never + * touched, and nothing here can disturb the hash. + */ +export function isProposal( + value: R3Record | R3Document | null | undefined, +): value is R3Record | R3Document { + if (!value || typeof value !== 'object') return false + const document = isR3Record(value) ? safeParse(value) : (value as R3Document) + if (!document) return false + const parameters = document.parameters + return !!parameters && typeof parameters === 'object' && !Array.isArray(parameters) +} + +function isR3Record(value: R3Record | R3Document): value is R3Record { + return typeof (value as R3Record).body === 'string' + && typeof (value as R3Record).s256 === 'string' +} + +function safeParse(record: R3Record): R3Document | undefined { + try { + return parseR3Record(record) + } catch { + // Unparseable stored bytes are not a proposal. Whatever else is wrong with + // them is verifyProposalParameters' problem, not the dispatcher's. + return undefined + } +} + +export function isParameterDigest(value: unknown): value is R3ParameterDigest { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + typeof (value as R3ParameterDigest).s256 === 'string' + ) +} + +// --- Building a proposal --- + +export interface ProposalOptions { + /** Vocabulary URI — the same one the class R3 document uses. */ + vocabulary: string + /** The single `r3_per_call` operation being invoked, in that vocabulary. */ + operation: unknown + /** REQUIRED. The concrete parameters of this call. A value MAY be a + * `{ s256, excerpt?, media_type? }` digest in place of the inline value. */ + parameters: Record + account?: string + display?: R3Display +} + +/** + * Build a per-call proposal document. Same structure and content addressing as + * a class R3 document, plus the REQUIRED `parameters`. + */ +export function buildProposal(options: ProposalOptions): R3Document { + const { vocabulary, operation, parameters, account, display } = options + if (!parameters || typeof parameters !== 'object' || Array.isArray(parameters)) { + throw new R3Error('invalid_proposal', 'A per-call proposal requires a `parameters` object') + } + const document: R3Document = { + vocabulary, + operations: [operation], + ...(account !== undefined ? { account } : {}), + ...(display !== undefined ? { display } : {}), + parameters, + } + return document +} + +export interface PublishProposalOptions extends ProposalOptions { + store: R3Store + baseUri?: string + uri?: string + /** The `aud` of the resource token you are about to mint, and the agent's + * PS. See `publishR3Document`. */ + authorized: Array + /** Default 3600 — a proposal must outlive the approval round trip, which is + * bounded by the auth token's 1 hour. */ + ttlSeconds?: number +} + +/** Build, serialize once, and persist a proposal. Returns the `r3_uri` / + * `r3_s256` for the resource token. */ +export async function publishProposal(options: PublishProposalOptions): Promise { + const { store, baseUri, uri, authorized, ttlSeconds = 3600, ...rest } = options + return publishR3Document({ + document: buildProposal(rest), + store, + ...(baseUri !== undefined ? { baseUri } : {}), + ...(uri !== undefined ? { uri } : {}), + authorized, + ttlSeconds, + }) +} + +// --- Enforced retry --- + +export interface VerifyProposalOptions { + store: R3Store + /** `r3_s256` from the per-call auth token the agent presented. */ + r3_s256: string + /** The parameters the agent actually supplied on this call. A value for a + * digest parameter may be a string, `Uint8Array`, or `ArrayBuffer`. */ + presented: Record + /** When given, the proposal's single operation must deep-equal this. */ + operation?: unknown +} + +export interface VerifiedProposal { + document: R3Document + /** The approved parameters, as stored. */ + parameters: Record +} + +/** + * Recover the approved proposal by `r3_s256` and verify the agent's actual + * parameters against it. + * + * - Every approved parameter MUST be present, and no parameter beyond them. + * - A digest parameter MUST satisfy `BASE64URL(SHA-256(presented)) === s256`. + * - Every other parameter MUST deep-equal the approved value. + * + * Throws `R3Error` on any difference. + */ +export async function verifyProposalParameters( + options: VerifyProposalOptions, +): Promise { + const { store, r3_s256, presented } = options + + if (typeof r3_s256 !== 'string' || !r3_s256) { + throw new R3Error('proposal_not_referenced', 'The auth token carries no r3_s256') + } + const record = await store.get(r3_s256) + if (!record) { + throw new R3Error('proposal_not_found', `No approved proposal for r3_s256 ${r3_s256}`) + } + if (!timingSafeEqualString(record.s256, r3_s256)) { + throw new R3Error('proposal_hash_mismatch', 'Stored proposal hash does not match r3_s256') + } + + const document = parseR3Record(record) + const approved = document.parameters + if (!approved || typeof approved !== 'object') { + throw new R3Error( + 'invalid_proposal', + 'The stored R3 document is not a per-call proposal — it has no `parameters`', + ) + } + + if (options.operation !== undefined) { + const ops = document.operations ?? [] + if (ops.length !== 1 || !deepEqual(ops[0], options.operation)) { + throw new R3Error( + 'proposal_operation_mismatch', + 'The invoked operation is not the one that was approved', + ) + } + } + + if (!presented || typeof presented !== 'object' || Array.isArray(presented)) { + throw new R3Error('proposal_parameter_mismatch', 'Presented parameters must be an object') + } + + const approvedKeys = Object.keys(approved) + const presentedKeys = Object.keys(presented) + + for (const key of presentedKeys) { + if (!Object.prototype.hasOwnProperty.call(approved, key)) { + throw new R3Error( + 'proposal_parameter_mismatch', + `Parameter "${key}" was not in the approved proposal`, + ) + } + } + + for (const key of approvedKeys) { + if (!Object.prototype.hasOwnProperty.call(presented, key)) { + throw new R3Error( + 'proposal_parameter_mismatch', + `Approved parameter "${key}" is missing from the call`, + ) + } + const approvedValue = approved[key] + const presentedValue = presented[key] + + if (isParameterDigest(approvedValue)) { + if ( + typeof presentedValue !== 'string' && + !(presentedValue instanceof Uint8Array) && + !(presentedValue instanceof ArrayBuffer) + ) { + throw new R3Error( + 'proposal_parameter_mismatch', + `Parameter "${key}" was approved as a digest; the call must present its bytes`, + ) + } + const actual = await sha256Base64url(toBytes(presentedValue as Bytes)) + if (!timingSafeEqualString(actual, approvedValue.s256)) { + throw new R3Error( + 'proposal_parameter_mismatch', + `Parameter "${key}" does not hash to the approved s256`, + ) + } + continue + } + + if (!deepEqual(approvedValue, presentedValue)) { + throw new R3Error( + 'proposal_parameter_mismatch', + `Parameter "${key}" differs from the approved proposal`, + ) + } + } + + return { document, parameters: approved } +} diff --git a/resource/src/r3.test.ts b/resource/src/r3.test.ts new file mode 100644 index 0000000..db3eada --- /dev/null +++ b/resource/src/r3.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + serializeR3Document, + computeR3Hash, + publishR3Document, + serveR3Document, + isAuthorizedR3Fetcher, + assertAuthorizedR3Fetcher, + verifyR3Hash, + getR3ByHash, + getR3ByUri, + parseR3Record, + MemoryR3Store, + R3Error, +} from './index.js' +import type { R3Document } from './index.js' +import { RESOURCE, PS } from './testing.js' + +const AS = 'https://as.example' +const AGENT_PS = 'https://agent-ps.example' + +const doc: R3Document = { + vocabulary: 'urn:aauth:vocabulary:mcp', + operations: [{ tool: 'create_calendar_event' }, { tool: 'modify_calendar_event' }], + account: 'dick@example.com', + display: { + summary: 'Create and modify events on your work calendar (dick@example.com)', + implications: 'Meetings can be scheduled or rescheduled.', + data_accessed: 'Event titles, times, attendees', + irreversible: 'Sent meeting invitations cannot be unsent', + }, +} + +let store: MemoryR3Store + +beforeEach(() => { + store = new MemoryR3Store() +}) + +describe('content addressing', () => { + it('hashes the bytes as served', async () => { + const { body, s256 } = await serializeR3Document(doc) + expect(await computeR3Hash(body)).toBe(s256) + expect(await verifyR3Hash(body, s256)).toBe(true) + }) + + it('serves byte-identical bytes on every request for the same r3_uri', async () => { + const published = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [PS], + }) + + const first = await serveR3Document({ store, key: published.r3_uri, signer: PS }) + const second = await serveR3Document({ store, key: published.r3_uri, signer: PS }) + + expect(first.body).toBe(second.body) + expect(first.body).toBe(published.body) + expect(await computeR3Hash(first.body)).toBe(published.r3_s256) + }) + + it('breaks if the served bytes are re-stringified — the reason we store them', async () => { + const { body, s256 } = await serializeR3Document(doc) + // A framework `json()` helper, CDN minification, or any parse/re-encode. + const reStringified = JSON.stringify(JSON.parse(body), null, 2) + expect(await computeR3Hash(reStringified)).not.toBe(s256) + }) + + it('a document with different bytes gets a different hash and URI', async () => { + const a = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [PS], + }) + const b = await publishR3Document({ + document: { ...doc, account: 'other@example.com' }, + baseUri: `${RESOURCE}/r3`, store, authorized: [PS], + }) + expect(b.r3_s256).not.toBe(a.r3_s256) + expect(b.r3_uri).not.toBe(a.r3_uri) + }) + + it('rejects a document carrying the removed `version` field', async () => { + await expect( + serializeR3Document({ ...doc, version: '1' } as unknown as R3Document), + ).rejects.toThrow('removed the `version` field') + }) + + it('requires vocabulary and operations', async () => { + await expect(serializeR3Document({ operations: [{}] } as unknown as R3Document)) + .rejects.toThrow('requires a `vocabulary`') + await expect(serializeR3Document({ vocabulary: 'urn:x', operations: [] })) + .rejects.toThrow('requires a non-empty `operations`') + }) +}) + +describe('publication', () => { + it('stores under both the hash and the URI', async () => { + const p = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [PS, AGENT_PS], + }) + + const byHash = await getR3ByHash(store, p.r3_s256) + const byUri = await getR3ByUri(store, p.r3_uri) + expect(byHash?.body).toBe(p.body) + expect(byUri?.body).toBe(p.body) + expect(p.r3_uri).toBe(`${RESOURCE}/r3/${p.r3_s256}`) + }) + + it('accepts an explicit URI', async () => { + const p = await publishR3Document({ + document: doc, uri: `${RESOURCE}/r3/opaque-id`, store, authorized: [PS], + }) + expect(p.r3_uri).toBe(`${RESOURCE}/r3/opaque-id`) + expect((await getR3ByUri(store, p.r3_uri))?.s256).toBe(p.r3_s256) + }) + + it('refuses to publish with no entitled fetcher', async () => { + await expect( + publishR3Document({ document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [undefined] }), + ).rejects.toThrow('at least one authorized fetcher') + }) + + it('requires HTTPS', async () => { + await expect( + publishR3Document({ document: doc, uri: 'http://resource.example/r3/x', store, authorized: [PS] }), + ).rejects.toThrow('MUST be served over HTTPS') + }) + + it('round-trips through a JSON-encoding store without changing the bytes', async () => { + // Cloudflare KV stores `JSON.stringify(record)`; the body string survives. + const p = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [PS], + }) + const record = (await getR3ByHash(store, p.r3_s256))! + const roundTripped = JSON.parse(JSON.stringify(record)) as typeof record + expect(roundTripped.body).toBe(p.body) + expect(await computeR3Hash(roundTripped.body)).toBe(p.r3_s256) + }) + + it('parses back for inspection', async () => { + const p = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [PS], + }) + expect(parseR3Record((await getR3ByHash(store, p.r3_s256))!)).toEqual(doc) + }) +}) + +describe('R3 document access restriction', () => { + it('serves to the AS named in the resource token aud', async () => { + const p = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [AS, AGENT_PS], + }) + const res = await serveR3Document({ store, key: p.r3_uri, signer: AS }) + expect(res.status).toBe(200) + expect(res.body).toBe(p.body) + expect(res.headers.ETag).toBe(`"${p.r3_s256}"`) + }) + + it('serves to the PS named by the agent token ps claim', async () => { + const p = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [AS, AGENT_PS], + }) + expect((await serveR3Document({ store, key: p.r3_uri, signer: AGENT_PS })).status).toBe(200) + }) + + it('rejects the agent — agents must never read R3 documents', async () => { + const p = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [AS, AGENT_PS], + }) + const res = await serveR3Document({ store, key: p.r3_uri, signer: 'https://agent.example' }) + expect(res.status).toBe(403) + expect(res.body).not.toContain('create_calendar_event') + }) + + it('rejects some other PS', async () => { + const p = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [AS, AGENT_PS], + }) + expect((await serveR3Document({ store, key: p.r3_uri, signer: PS })).status).toBe(403) + }) + + it('rejects an unsigned request', async () => { + const p = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [AS], + }) + const res = await serveR3Document({ store, key: p.r3_uri, signer: undefined }) + expect(res.status).toBe(401) + expect(JSON.parse(res.body).error).toBe('signature_required') + }) + + it('404s an unknown document', async () => { + expect((await serveR3Document({ store, key: `${RESOURCE}/r3/nope`, signer: AS })).status) + .toBe(404) + }) + + it('compares identifiers by exact string equality', async () => { + const p = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [AS], + }) + const record = (await getR3ByHash(store, p.r3_s256))! + expect(isAuthorizedR3Fetcher(record, AS)).toBe(true) + expect(isAuthorizedR3Fetcher(record, `${AS}/`)).toBe(false) + expect(isAuthorizedR3Fetcher(record, AS.toUpperCase())).toBe(false) + expect(isAuthorizedR3Fetcher(record, '')).toBe(false) + }) + + it('assertAuthorizedR3Fetcher throws with a code', async () => { + const p = await publishR3Document({ + document: doc, baseUri: `${RESOURCE}/r3`, store, authorized: [AS], + }) + const record = (await getR3ByHash(store, p.r3_s256))! + expect(() => assertAuthorizedR3Fetcher(record, undefined)) + .toThrow(R3Error) + try { + assertAuthorizedR3Fetcher(record, 'https://agent.example') + expect.fail('should have thrown') + } catch (err) { + expect((err as R3Error).code).toBe('r3_fetch_forbidden') + } + }) +}) diff --git a/resource/src/r3.ts b/resource/src/r3.ts new file mode 100644 index 0000000..5e51774 --- /dev/null +++ b/resource/src/r3.ts @@ -0,0 +1,410 @@ +import { R3Error } from './errors.js' +import { sha256Base64url, timingSafeEqualString, randomToken, nowSeconds } from './util.js' + +/** + * AAuth Rich Resource Requests (R3) — resource side. + * + * The two rules that everything here exists to enforce: + * + * 1. **Content addressing has no canonicalization step.** `r3_s256` is the + * SHA-256 of the bytes as served. Serialize once, store the bytes, serve + * those exact bytes on every request for the same `r3_uri`. Any + * re-stringify — middleware that parses and re-encodes JSON, a framework + * `json()` helper, CDN minification — changes the bytes and breaks hash + * verification at the PS and AS. + * + * 2. **Agents must never read an R3 document.** Only the AS named in the + * `aud` of a resource token carrying that `r3_uri`, and the PS named by the + * `ps` claim of the agent token the agent presented, may fetch it. Agent + * opacity — the agent carries the hash of a document it cannot read — + * depends entirely on this. + */ + +// --- Document shape (AAuth-R3 §R3 Document) --- + +export interface R3Display { + /** REQUIRED when `display` is present. Short plain-language consent line. */ + summary: string + implications?: string + data_accessed?: string + irreversible?: string + /** Per-call proposals only: Markdown detail for the approval screen. */ + detail?: string +} + +/** + * A parameter value represented by a digest instead of inline, for a large or + * sensitive payload. The full bytes travel agent → resource at call time; only + * the hash and a short excerpt reach the PS. + */ +export interface R3ParameterDigest { + /** `BASE64URL(SHA-256(value-bytes))` of the value as presented at call time. */ + s256: string + excerpt?: string + media_type?: string +} + +export type R3ParameterValue = + | string | number | boolean | null + | R3ParameterDigest + | unknown[] + | Record + +/** `{ vocabulary, operations }` — the shape shared by an R3 document's + * `operations`, the agent's `r3_operations` request, and the auth token's + * `r3_granted` / `r3_per_call` claims. */ +export interface R3OperationSet { + vocabulary: string + operations: unknown[] +} + +export interface R3Document { + /** REQUIRED. Vocabulary URI; MUST be one the resource advertises in + * `r3_vocabularies`. */ + vocabulary: string + /** REQUIRED. Operations covered, in the vocabulary's structure. */ + operations: unknown[] + /** Present when the authorization request carried an `account`. */ + account?: string + display?: R3Display + /** REQUIRED on a per-call proposal, absent on a class document. */ + parameters?: Record +} + +// R3 -02 removed the `version` field. Do not add it back; it changes the bytes +// and therefore the hash, and no verifier reads it. + +// --- Store (supplied by the caller) --- + +/** + * The persisted form of one R3 document. + * + * `body` is the authoritative artifact: the exact serialized JSON text whose + * SHA-256 is `s256`. Serve it verbatim. Never parse it and re-serialize it + * on the way out. + */ +export interface R3Record { + /** The `r3_uri` this record is served at. */ + uri: string + /** `BASE64URL(SHA-256(body))`, unpadded — the `r3_s256` claim value. */ + s256: string + /** The exact bytes to serve, as a UTF-8 string. */ + body: string + /** + * Server identifiers entitled to fetch this document: the `aud` of the + * resource token carrying this `r3_uri` (the PS in three-party, the AS in + * four-party), plus the agent's PS from the agent token's `ps` claim. + * Compared by exact string equality. Every other signer is rejected. + */ + authorized: string[] + createdAt: number + /** Unix seconds. Advisory — a store with native TTL should also expire it. */ + expiresAt?: number +} + +/** + * The store a caller supplies. Two methods, both keyed by opaque string. + * + * `aauth-proxy` backs this with Workers KV (`put(key, JSON.stringify(record), + * { expirationTtl })`), `notes` with its own KV namespace, tests with a `Map`. + * `MemoryR3Store` below is a conforming implementation. + * + * A record is written under two keys: its `s256` and its `uri`. Lookups by + * hash (the per-call retry path, which has only `r3_s256` from the auth token) + * and by URI (the fetch path) both resolve. + */ +export interface R3Store { + get(key: string): Promise + put(key: string, record: R3Record, ttlSeconds?: number): Promise +} + +/** In-memory `R3Store`, for tests and single-process deployments. */ +export class MemoryR3Store implements R3Store { + private map = new Map() + + async get(key: string): Promise { + const entry = this.map.get(key) + if (!entry) return null + if (entry.expiresAt !== undefined && nowSeconds() > entry.expiresAt) { + this.map.delete(key) + return null + } + return entry.record + } + + async put(key: string, record: R3Record, ttlSeconds?: number): Promise { + this.map.set(key, { + record, + expiresAt: ttlSeconds === undefined ? undefined : nowSeconds() + ttlSeconds, + }) + } + + get size(): number { + return this.map.size + } +} + +// --- Serialization and content addressing --- + +/** Media type R3 documents are served with. */ +export const R3_MEDIA_TYPE = 'application/json' + +/** Default document lifetime in the store, in seconds. */ +export const R3_DEFAULT_TTL_SECONDS = 600 + +export interface SerializedR3 { + /** The exact bytes. Serve verbatim. */ + body: string + /** `BASE64URL(SHA-256(body))`, unpadded. */ + s256: string +} + +/** + * Serialize an R3 document once and hash the bytes. + * + * Call this exactly once per document. Hashing a re-serialization of the same + * object is not guaranteed to produce the same bytes, and the hash the PS and + * AS compute is over what the resource actually sends. + */ +export async function serializeR3Document(document: R3Document): Promise { + assertValidR3Document(document) + const body = JSON.stringify(document) + return { body, s256: await sha256Base64url(body) } +} + +/** `BASE64URL(SHA-256(body))` over bytes already serialized elsewhere. */ +export async function computeR3Hash(body: string | Uint8Array): Promise { + return sha256Base64url(body) +} + +function assertValidR3Document(document: R3Document): void { + if (!document || typeof document !== 'object') { + throw new R3Error('invalid_r3_document', 'R3 document must be an object') + } + if (typeof document.vocabulary !== 'string' || !document.vocabulary) { + throw new R3Error('invalid_r3_document', 'R3 document requires a `vocabulary`') + } + if (!Array.isArray(document.operations) || document.operations.length === 0) { + throw new R3Error('invalid_r3_document', 'R3 document requires a non-empty `operations`') + } + if (document.display && typeof document.display.summary !== 'string') { + throw new R3Error('invalid_r3_document', 'R3 `display` requires a `summary`') + } + if ('version' in (document as unknown as Record)) { + throw new R3Error('invalid_r3_document', 'R3 -02 removed the `version` field') + } +} + +// --- Publication --- + +export interface PublishR3Options { + document: R3Document + /** Base for the generated URI, e.g. `https://resource.example/r3`. The + * document is published at `{baseUri}/{r3_s256}`, so the same bytes always + * resolve to the same URI. Ignored when `uri` is given. */ + baseUri?: string + /** Explicit `r3_uri`, when the resource has its own URI scheme. */ + uri?: string + store: R3Store + /** + * Server identifiers entitled to fetch this document. Pass the `aud` of the + * resource token you are about to mint, and the agent's PS (the agent + * token's `ps` claim, or the person token's `iss`). Duplicates collapse. + */ + authorized: Array + ttlSeconds?: number +} + +export interface PublishedR3 { + r3_uri: string + r3_s256: string + /** The exact bytes now in the store. */ + body: string +} + +/** + * Serialize, hash, and persist an R3 document, returning the `r3_uri` and + * `r3_s256` to put in the resource token. + * + * The record is written under both its hash and its URI so that the per-call + * retry (which knows only `r3_s256`) and the document fetch (which knows only + * `r3_uri`) both resolve. + */ +export async function publishR3Document(options: PublishR3Options): Promise { + const { document, store, ttlSeconds = R3_DEFAULT_TTL_SECONDS } = options + const { body, s256 } = await serializeR3Document(document) + + const uri = options.uri ?? (options.baseUri + ? `${options.baseUri.replace(/\/$/, '')}/${s256}` + : undefined) + if (!uri) { + throw new R3Error('invalid_r3_uri', 'publishR3Document requires `uri` or `baseUri`') + } + if (!uri.startsWith('https://') && !uri.startsWith('http://localhost')) { + throw new R3Error('invalid_r3_uri', 'An R3 document MUST be served over HTTPS') + } + + const authorized = [...new Set(options.authorized.filter((v): v is string => !!v))] + if (authorized.length === 0) { + throw new R3Error( + 'invalid_r3_authorization', + 'publishR3Document requires at least one authorized fetcher — an R3 document ' + + 'with no entitled party can never be read, and one that skips the check is ' + + 'readable by the agent', + ) + } + + const record: R3Record = { + uri, + s256, + body, + authorized, + createdAt: nowSeconds(), + expiresAt: nowSeconds() + ttlSeconds, + } + + await store.put(s256, record, ttlSeconds) + if (uri !== s256) await store.put(uri, record, ttlSeconds) + + return { r3_uri: uri, r3_s256: s256, body } +} + +/** Generate an opaque R3 identifier, for a resource that does not want its URIs + * to be the content hash. */ +export function generateR3Id(): string { + return randomToken(16) +} + +// --- Retrieval --- + +export async function getR3ByUri(store: R3Store, uri: string): Promise { + return store.get(uri) +} + +export async function getR3ByHash(store: R3Store, s256: string): Promise { + return store.get(s256) +} + +/** Parse the stored bytes back into a document. Use for inspection and + * per-call parameter comparison only — never to re-serve. */ +export function parseR3Record(record: R3Record): R3Document { + return JSON.parse(record.body) as R3Document +} + +// --- Fetch authorization (AAuth-R3 §R3 Document Access Restriction) --- + +export interface R3FetchAuthorization { + /** + * The server identifier of the party that signed the fetch, established from + * the verified HTTP Message Signature (its key's `iss`/issuer URL). This is + * NOT taken from a header the caller controls. + */ + signer: string +} + +/** + * True when `signer` is entitled to this document. Entitlement is the exact + * string equality of `signer` against one of the identifiers recorded at + * publication: the resource token's `aud`, or the agent's PS. + */ +export function isAuthorizedR3Fetcher(record: R3Record, signer: string): boolean { + if (typeof signer !== 'string' || signer.length === 0) return false + return record.authorized.some(a => a === signer) +} + +export interface ServeR3Options { + store: R3Store + /** The requested `r3_uri`, or the bare `r3_s256` / id it ends with. */ + key: string + /** Identifier of the party whose HTTP Message Signature verified. Pass + * `undefined` for an unsigned request — it is rejected. */ + signer: string | undefined +} + +export interface R3Response { + status: number + headers: Record + /** The exact stored bytes on 200; a JSON error body otherwise. */ + body: string +} + +/** + * Build the response for a GET of an R3 document URI. + * + * Returns `401` for an unsigned request, `403` for a signer that is not the + * entitled AS or PS, `404` when the document is unknown or expired, and `200` + * with the exact stored bytes otherwise. + * + * The 200 body MUST be written to the wire as-is. Do not pass it through a + * JSON response helper. + */ +export async function serveR3Document(options: ServeR3Options): Promise { + const { store, key, signer } = options + const noStore = { 'Cache-Control': 'no-store' } + + if (!signer) { + return { + status: 401, + headers: { ...noStore, 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'signature_required' }), + } + } + + const record = await store.get(key) + if (!record) { + return { + status: 404, + headers: { ...noStore, 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'not_found' }), + } + } + if (record.expiresAt !== undefined && nowSeconds() > record.expiresAt) { + return { + status: 404, + headers: { ...noStore, 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'not_found' }), + } + } + + if (!isAuthorizedR3Fetcher(record, signer)) { + // Deliberately not "which party would be allowed" — an agent probing this + // endpoint learns nothing about the document or its audience. + return { + status: 403, + headers: { ...noStore, 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'forbidden' }), + } + } + + return { + status: 200, + headers: { + 'Content-Type': R3_MEDIA_TYPE, + // Content-addressed: the bytes at this URI never change. + 'Cache-Control': 'private, max-age=600', + ETag: `"${record.s256}"`, + }, + body: record.body, + } +} + +/** Throwing form of the entitlement check, for callers routing their own + * responses. */ +export function assertAuthorizedR3Fetcher(record: R3Record, signer: string | undefined): void { + if (!signer) { + throw new R3Error('signature_required', 'R3 document fetch MUST carry an HTTP Message Signature') + } + if (!isAuthorizedR3Fetcher(record, signer)) { + throw new R3Error( + 'r3_fetch_forbidden', + `${signer} is not entitled to this R3 document`, + ) + } +} + +/** Verify fetched bytes against a claimed `r3_s256`. */ +export async function verifyR3Hash(body: string | Uint8Array, expected: string): Promise { + return timingSafeEqualString(await sha256Base64url(body), expected) +} + +export { R3Error } from './errors.js' diff --git a/resource/src/resource-token.test.ts b/resource/src/resource-token.test.ts new file mode 100644 index 0000000..145ba27 --- /dev/null +++ b/resource/src/resource-token.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi } from 'vitest' +import { createResourceToken, clampToMission, AAuthTokenError } from './index.js' +import type { SignFn, PersonTokenReference } from './index.js' +import { RESOURCE, PS, MISSION_S256 } from './testing.js' + +/** Captures what the package hands to the signer, without signing anything. */ +function capturingSign() { + const captured: { payload?: Record; header?: Record } = {} + const sign: SignFn = vi.fn(async (payload, header) => { + captured.payload = payload + captured.header = header + return 'signed.jwt.value' + }) + return { sign, captured } +} + +const personToken: PersonTokenReference = { + iss: PS, + sub: '8f14e45fceea167a5a36dedd4bea2543', + jti: 'pt-3ab910', + mission_s256: MISSION_S256, +} + +function base(over: Record = {}) { + return { + resource: RESOURCE, + audience: PS, + personToken, + agentJkt: 'NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs', + scope: 'notes.read notes.write', + ...over, + } +} + +describe('createResourceToken', () => { + it('signs with the fully-specified Ed25519 alg, never EdDSA', async () => { + const { sign, captured } = capturingSign() + await createResourceToken(base({ kid: 'resource-key-1' }), sign) + + expect(captured.header).toEqual({ + alg: 'Ed25519', + typ: 'aa-resource+jwt', + kid: 'resource-key-1', + }) + expect(captured.header!.alg).not.toBe('EdDSA') + }) + + it('emits the -11 claim set', async () => { + const { sign, captured } = capturingSign() + const now = 1_741_824_000 + await createResourceToken(base({ now }), sign) + const p = captured.payload! + + expect(p.iss).toBe(RESOURCE) + expect(p.dwk).toBe('aauth-resource.json') + expect(p.aud).toBe(PS) + expect(typeof p.jti).toBe('string') + expect(p.ps).toBe(PS) + expect(p.sub).toBe('8f14e45fceea167a5a36dedd4bea2543') + expect(p.person_token_jti).toBe('pt-3ab910') + expect(p.agent_jkt).toBe('NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs') + expect(p.scope).toBe('notes.read notes.write') + expect(p.iat).toBe(now) + expect(p.exp).toBe(now + 300) + }) + + it('carries none of the removed -10 claims', async () => { + const { sign, captured } = capturingSign() + await createResourceToken(base(), sign) + const p = captured.payload! + + expect(p.agent).toBeUndefined() + expect(p.mission).toBeUndefined() + expect(p.approver).toBeUndefined() + }) + + it('copies mission_s256 from the person token unchanged', async () => { + const { sign, captured } = capturingSign() + await createResourceToken(base(), sign) + expect(captured.payload!.mission_s256).toBe(MISSION_S256) + }) + + it('omits mission_s256 when the person token carried none', async () => { + const { sign, captured } = capturingSign() + await createResourceToken( + base({ personToken: { iss: PS, sub: 'u1', jti: 'pt-2' } }), + sign, + ) + expect(captured.payload!.mission_s256).toBeUndefined() + }) + + it('copies tenant from the person token, and lets the resource override it', async () => { + const { sign, captured } = capturingSign() + await createResourceToken( + base({ personToken: { ...personToken, tenant: 'acme' } }), + sign, + ) + expect(captured.payload!.tenant).toBe('acme') + + const second = capturingSign() + await createResourceToken( + base({ personToken: { ...personToken, tenant: 'acme' }, tenant: 'acme-eu' }), + second.sign, + ) + expect(second.captured.payload!.tenant).toBe('acme-eu') + }) + + it('adds the optional account, interaction and R3 claims', async () => { + const { sign, captured } = capturingSign() + await createResourceToken( + base({ + account: 'dick@example.com', + interaction: { url: 'https://resource.example/interact', code: 'A1B2-C3D4' }, + r3: { uri: 'https://resource.example/r3/abc', s256: 'aBcDeF' }, + }), + sign, + ) + const p = captured.payload! + expect(p.account).toBe('dick@example.com') + expect(p.interaction).toEqual({ url: 'https://resource.example/interact', code: 'A1B2-C3D4' }) + expect(p.r3_uri).toBe('https://resource.example/r3/abc') + expect(p.r3_s256).toBe('aBcDeF') + }) + + it('rejects a half-specified R3 reference', async () => { + const { sign } = capturingSign() + await expect( + createResourceToken(base({ r3: { uri: 'https://r.example/r3/a', s256: '' } }), sign), + ).rejects.toThrow('REQUIRED together') + }) + + it('clamps exp to the mission expires_at', async () => { + const { sign, captured } = capturingSign() + const now = 1_741_824_000 + await createResourceToken(base({ now, missionExpiresAt: now + 60 }), sign) + expect(captured.payload!.exp).toBe(now + 60) + }) + + it('does not extend exp when the mission outlives the token', async () => { + const { sign, captured } = capturingSign() + const now = 1_741_824_000 + await createResourceToken(base({ now, missionExpiresAt: now + 86_400 }), sign) + expect(captured.payload!.exp).toBe(now + 300) + }) + + it('refuses to mint under an already-expired mission', async () => { + const { sign } = capturingSign() + const now = 1_741_824_000 + await expect( + createResourceToken(base({ now, missionExpiresAt: now - 1 }), sign), + ).rejects.toThrow('mission expires_at is in the past') + }) + + it('requires a person token', async () => { + const { sign } = capturingSign() + await expect( + createResourceToken(base({ personToken: { iss: PS, sub: 'u1' } }), sign), + ).rejects.toThrow('needs iss, sub and jti') + }) + + it('requires scope', async () => { + const { sign } = capturingSign() + await expect(createResourceToken(base({ scope: '' }), sign)).rejects.toThrow('scope is a REQUIRED') + }) + + it('rejects an iss or aud that is not a server identifier', async () => { + const { sign } = capturingSign() + await expect(createResourceToken(base({ resource: 'https://resource.example/' }), sign)) + .rejects.toThrow('iss must be a valid HTTPS server identifier') + await expect(createResourceToken(base({ audience: 'http://ps.example' }), sign)) + .rejects.toThrow('aud must be a valid HTTPS server identifier') + }) + + it('reports errors as AAuthTokenError with a code', async () => { + const { sign } = capturingSign() + try { + await createResourceToken(base({ scope: '' }), sign) + expect.fail('should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(AAuthTokenError) + expect((err as AAuthTokenError).code).toBe('invalid_scope') + } + }) +}) + +describe('clampToMission', () => { + it('is a no-op without a mission', () => { + expect(clampToMission(1000)).toBe(1000) + }) + + it('never extends', () => { + expect(clampToMission(1000, 2000)).toBe(1000) + }) + + it('shortens to the mission', () => { + expect(clampToMission(2000, 1000)).toBe(1000) + }) +}) diff --git a/resource/src/resource-token.ts b/resource/src/resource-token.ts new file mode 100644 index 0000000..2f83277 --- /dev/null +++ b/resource/src/resource-token.ts @@ -0,0 +1,213 @@ +import { TOKEN_TYP, DWK, SIGNING_ALG } from '@aauth/protocol' +import { AAuthTokenError } from './errors.js' +import { isServerIdentifier, nowSeconds, randomId } from './util.js' +import type { VerifiedPersonToken } from './verify-token.js' + +/** + * Resource token minting (AAuth Protocol §Resource Token Structure). + * + * A resource MUST have verified a person token before it issues a resource + * token, and MUST challenge with `requirement=person-token` when it has not. + * Only a person server can act on a resource token, so one issued to an agent + * that cannot name a person is one nobody can redeem — hence `personToken` is + * required here rather than a loose set of claims. + */ + +/** Default lifetime. The spec says SHOULD NOT exceed 5 minutes. */ +export const DEFAULT_RESOURCE_TOKEN_LIFETIME = 300 + +/** The claims a resource token copies out of the person token it verified. */ +export interface PersonTokenReference { + /** `iss` of the person token — the PS whose namespace `sub` belongs to. */ + iss: string + /** `sub` of the person token — directed, opaque, meaningful only with `iss`. */ + sub: string + /** `jti` of the person token — binds this resource token to that one. */ + jti: string + /** Copied unchanged when present. A resource MUST NOT omit it. */ + mission_s256?: string + tenant?: string +} + +export interface ResourceTokenOptions { + /** `iss` — the resource's own server identifier. */ + resource: string + /** `aud` — the PS in three-party access, the AS in four-party. */ + audience: string + /** The person token this resource verified. `ps`, `sub`, `person_token_jti`, + * `mission_s256` and `tenant` are copied from it. */ + personToken: VerifiedPersonToken | PersonTokenReference + /** JWK thumbprint (RFC 7638) of the agent's current signing key. For a + * parent-mediated sub-agent authorization this is the sub-agent's key. */ + agentJkt: string + /** REQUIRED. Space-separated scope values. Pass the scopes the request needs; + * an R3-only resource that expresses everything through `r3_uri` still + * states a scope, because the claim is REQUIRED in the token. */ + scope: string + /** Echoes the `account` parameter of the request that produced this token. */ + account?: string + /** Overrides the `tenant` copied from the person token. */ + tenant?: string + /** The resource's own user-facing flow, needed before the PS can issue. */ + interaction?: { url: string; code: string } + /** R3: both are REQUIRED together when either is present. */ + r3?: { uri: string; s256: string } + /** Seconds. Default 300. */ + lifetime?: number + /** + * The mission's `expires_at`, in Unix seconds. When the person token carries + * `mission_s256`, no token may outlive the mission — `exp` is clamped to it. + */ + missionExpiresAt?: number + /** JWT header `kid`. Include it; verifiers select the key by `kid`. */ + kid?: string + /** Override "now", in seconds. For tests. */ + now?: number +} + +/** + * Caller-supplied signer. Decouples this package from any particular key + * management — a Workers `crypto.subtle` key, a KMS, a service binding. + * + * The header is handed over complete: `{ alg: 'Ed25519', typ: + * 'aa-resource+jwt', kid? }`. Sign it as given. `alg` is the fully-specified + * RFC 9864 identifier; the polymorphic `EdDSA` MUST NOT be used. + */ +export type SignFn = ( + payload: Record, + header: Record, +) => Promise + +/** + * Clamp an expiry to a mission's `expires_at`. + * + * No token carrying `mission_s256` may outlive the mission it was issued + * under. Applies to resource tokens here, and to anything else a resource + * derives from a mission-scoped person token. + */ +export function clampToMission(exp: number, missionExpiresAt?: number): number { + if (missionExpiresAt === undefined) return exp + return Math.min(exp, missionExpiresAt) +} + +function personRef( + token: VerifiedPersonToken | PersonTokenReference, +): PersonTokenReference { + if (!token || typeof token !== 'object') { + throw new AAuthTokenError( + 'person_token_required', + 'createResourceToken requires the person token this resource verified', + ) + } + const { iss, sub, jti } = token as PersonTokenReference + if (!iss || !sub || !jti) { + throw new AAuthTokenError( + 'person_token_required', + 'The person token reference needs iss, sub and jti', + ) + } + const ref: PersonTokenReference = { iss, sub, jti } + if (token.mission_s256) ref.mission_s256 = token.mission_s256 + if (token.tenant) ref.tenant = token.tenant + return ref +} + +/** + * Mint a resource token (`typ: aa-resource+jwt`). + * + * The resource signs it and hands it to the agent in a + * `requirement=auth-token` challenge; the agent forwards it to its PS (or the + * resource's AS) to obtain an auth token. + */ +export async function createResourceToken( + options: ResourceTokenOptions, + sign: SignFn, +): Promise { + const { + resource, + audience, + agentJkt, + scope, + account, + interaction, + r3, + lifetime = DEFAULT_RESOURCE_TOKEN_LIFETIME, + missionExpiresAt, + kid, + } = options + + if (!isServerIdentifier(resource)) { + throw new AAuthTokenError( + 'invalid_resource_identifier', + `Resource token iss must be a valid HTTPS server identifier, got: ${resource}`, + ) + } + if (!isServerIdentifier(audience)) { + throw new AAuthTokenError( + 'invalid_audience', + `Resource token aud must be a valid HTTPS server identifier, got: ${audience}`, + ) + } + if (typeof agentJkt !== 'string' || !agentJkt) { + throw new AAuthTokenError('invalid_agent_jkt', 'agentJkt is REQUIRED') + } + if (typeof scope !== 'string' || !scope) { + throw new AAuthTokenError('invalid_scope', 'scope is a REQUIRED resource token claim') + } + if (r3 && (!r3.uri || !r3.s256)) { + throw new AAuthTokenError( + 'invalid_r3_reference', + 'r3_uri and r3_s256 are REQUIRED together — a resource including R3 information MUST include both', + ) + } + + const person = personRef(options.personToken) + + const now = options.now ?? nowSeconds() + const exp = clampToMission(now + lifetime, missionExpiresAt) + if (exp <= now) { + throw new AAuthTokenError( + 'mission_expired', + 'The mission expires_at is in the past — no token may outlive the mission', + ) + } + + const payload: Record = { + iss: resource, + dwk: DWK.resource, + aud: audience, + jti: randomId(), + ps: person.iss, + sub: person.sub, + person_token_jti: person.jti, + agent_jkt: agentJkt, + iat: now, + exp, + scope, + } + + if (account !== undefined) payload.account = account + + // REQUIRED when the person token carried one, copied unchanged. A resource + // MUST NOT omit it: the PS resolves the person token by `person_token_jti` + // and compares, so dropping it is detected as mission stripping. + if (person.mission_s256) payload.mission_s256 = person.mission_s256 + + const tenant = options.tenant ?? person.tenant + if (tenant) payload.tenant = tenant + + if (interaction) payload.interaction = { url: interaction.url, code: interaction.code } + + if (r3) { + payload.r3_uri = r3.uri + payload.r3_s256 = r3.s256 + } + + const header: Record = { + alg: SIGNING_ALG, + typ: TOKEN_TYP.resource, + } + if (kid) header.kid = kid + + return sign(payload, header) +} diff --git a/resource/src/testing.ts b/resource/src/testing.ts new file mode 100644 index 0000000..4ab717c --- /dev/null +++ b/resource/src/testing.ts @@ -0,0 +1,66 @@ +/** + * Test helpers, shared by this package's test files. Not exported from the + * package entry point (`index.ts`) and not part of the public API. + */ +import { generateKeyPair, exportJWK, SignJWT, calculateJwkThumbprint } from 'jose' +import type { JWK } from 'jose' +import { vi } from 'vitest' + +export const RESOURCE = 'https://resource.example' +export const PS = 'https://ps.example' +export const AP = 'https://ap.example' + +export interface TestKeys { + /** Issuer key: signs JWTs, published in the issuer's JWKS. */ + issuerPrivate: CryptoKey + issuerJwk: JWK + /** Agent key: signs the HTTP request, carried in `cnf.jwk`. */ + agentJwk: JWK + agentThumbprint: string +} + +export async function createTestKeys(kid = 'issuer-1'): Promise { + const issuer = await generateKeyPair('Ed25519', { extractable: true }) + const issuerJwk = { ...(await exportJWK(issuer.publicKey)), alg: 'Ed25519', kid } + + const agent = await generateKeyPair('Ed25519', { extractable: true }) + const agentJwk = { ...(await exportJWK(agent.publicKey)), alg: 'Ed25519' } + + return { + issuerPrivate: issuer.privateKey as CryptoKey, + issuerJwk, + agentJwk, + agentThumbprint: await calculateJwkThumbprint(agentJwk, 'sha256'), + } +} + +export async function signTestJwt( + key: CryptoKey, + typ: string, + claims: Record, + header: Record = {}, +): Promise { + const now = Math.floor(Date.now() / 1000) + return new SignJWT({ iat: now, exp: now + 3600, ...claims }) + .setProtectedHeader({ alg: 'Ed25519', typ, kid: 'issuer-1', ...header } as never) + .sign(key) +} + +/** A fetch that serves `{iss}/.well-known/{dwk}` with an inline JWKS. */ +export function mockJwksFetch(entries: Array<{ iss: string; dwk: string; keys: JWK[] }>) { + return vi.fn(async (input: string) => { + const url = input.toString() + for (const e of entries) { + if (url === `${e.iss}/.well-known/${e.dwk}`) { + return new Response(JSON.stringify({ issuer: e.iss, jwks: { keys: e.keys } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + return new Response('Not Found', { status: 404 }) + }) +} + +/** A mission, present in every test that exercises mission plumbing. */ +export const MISSION_S256 = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' diff --git a/resource/src/util.ts b/resource/src/util.ts new file mode 100644 index 0000000..a9295fa --- /dev/null +++ b/resource/src/util.ts @@ -0,0 +1,96 @@ +/** + * Runtime helpers. Everything here uses only globals that Cloudflare workerd + * provides (`crypto`, `crypto.subtle`, `TextEncoder`, `fetch`) — no `node:*` + * imports, so the package loads on Workers with or without `nodejs_compat`. + */ + +const encoder = new TextEncoder() + +export type Bytes = string | Uint8Array | ArrayBuffer + +export function toBytes(value: Bytes): Uint8Array { + if (typeof value === 'string') return encoder.encode(value) + if (value instanceof Uint8Array) return value + return new Uint8Array(value) +} + +export function base64url(bytes: ArrayBuffer | Uint8Array): string { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) + let binary = '' + for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** `BASE64URL(SHA-256(value))`, unpadded — the encoding every `*_s256` claim uses. */ +export async function sha256Base64url(value: Bytes): Promise { + const bytes = toBytes(value) + const buf = new Uint8Array(bytes).buffer as ArrayBuffer + const digest = await crypto.subtle.digest('SHA-256', buf) + return base64url(digest) +} + +/** Constant-time-ish comparison for hash and thumbprint equality. */ +export function timingSafeEqualString(a: string, b: string): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + return diff === 0 +} + +export function randomId(): string { + return crypto.randomUUID() +} + +export function randomToken(byteLength = 16): string { + const bytes = new Uint8Array(byteLength) + crypto.getRandomValues(bytes) + return base64url(bytes) +} + +export function nowSeconds(): number { + return Math.floor(Date.now() / 1000) +} + +/** + * Server Identifier requirements, AAuth Protocol §Server Identifiers: + * https scheme, host only (no port, path, query, fragment), no trailing slash, + * lowercase. Comparison of two identifiers is exact string equality. + */ +export function isServerIdentifier(value: unknown): value is string { + if (typeof value !== 'string' || value.length === 0) return false + if (value !== value.toLowerCase()) return false + if (value.endsWith('/')) return false + let url: URL + try { + url = new URL(value) + } catch { + return false + } + if (url.protocol !== 'https:') return false + if (url.port !== '') return false + if (url.search !== '' || url.hash !== '') return false + if (url.pathname !== '/' && url.pathname !== '') return false + // `new URL('https://a.example')` normalizes pathname to '/', so reject only a + // literal trailing slash in the input (already handled above) or any deeper path. + return value === `${url.protocol}//${url.host}` +} + +/** Deep structural equality. Object key order is ignored; array order is not. */ +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + if (typeof a !== typeof b) return false + if (a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + return a.every((item, i) => deepEqual(item, b[i])) + } + if (typeof a === 'object') { + const ao = a as Record + const bo = b as Record + const ak = Object.keys(ao) + const bk = Object.keys(bo) + if (ak.length !== bk.length) return false + return ak.every(k => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k])) + } + return false +} diff --git a/resource/src/verify-token.test.ts b/resource/src/verify-token.test.ts new file mode 100644 index 0000000..def01f0 --- /dev/null +++ b/resource/src/verify-token.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { generateKeyPair, exportJWK, SignJWT, calculateJwkThumbprint } from 'jose' +import { verifyToken, AAuthTokenError, clearMetadataCache } from './index.js' +import type { VerifiedPersonToken, VerifiedAuthToken, VerifiedAgentToken } from './index.js' +import { + createTestKeys, signTestJwt, mockJwksFetch, RESOURCE, PS, AP, MISSION_S256, + type TestKeys, +} from './testing.js' + +let keys: TestKeys +let fetchMock: ReturnType + +beforeEach(async () => { + clearMetadataCache() + keys = await createTestKeys() + fetchMock = mockJwksFetch([ + { iss: AP, dwk: 'aauth-agent.json', keys: [keys.issuerJwk] }, + { iss: PS, dwk: 'aauth-person.json', keys: [keys.issuerJwk] }, + { iss: PS, dwk: 'aauth-access.json', keys: [keys.issuerJwk] }, + ]) +}) + +function opts(jwt: string, over: Record = {}) { + return { + jwt, + httpSignatureThumbprint: keys.agentThumbprint, + resource: RESOURCE, + accept: ['agent', 'person', 'auth'] as const, + fetch: fetchMock as never, + ...over, + } +} + +function agentClaims(over: Record = {}) { + return { + iss: AP, + dwk: 'aauth-agent.json', + sub: 'aauth:test@agent.example', + jti: 'at-1', + cnf: { jwk: keys.agentJwk }, + ...over, + } +} + +function personClaims(over: Record = {}) { + return { + iss: PS, + dwk: 'aauth-person.json', + aud: RESOURCE, + sub: '8f14e45fceea167a5a36dedd4bea2543', + jti: 'pt-3ab910', + cnf: { jwk: keys.agentJwk }, + mission_s256: MISSION_S256, + ...over, + } +} + +function authClaims(over: Record = {}) { + return { + iss: PS, + dwk: 'aauth-person.json', + aud: RESOURCE, + jti: 'auth-1', + ps: PS, + sub: '8f14e45fceea167a5a36dedd4bea2543', + cnf: { jwk: keys.agentJwk }, + scope: 'notes.read', + mission_s256: MISSION_S256, + ...over, + } +} + +describe('agent token', () => { + it('verifies and surfaces sub, ps and parent_agent', async () => { + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-agent+jwt', agentClaims({ ps: PS })) + const result = (await verifyToken(opts(jwt))) as VerifiedAgentToken + + expect(result.type).toBe('agent') + expect(result.iss).toBe(AP) + expect(result.sub).toBe('aauth:test@agent.example') + expect(result.ps).toBe(PS) + expect(result.jti).toBe('at-1') + expect(result.cnf.jwk).toEqual(keys.agentJwk) + }) + + it('rejects a ps that is not a server identifier', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-agent+jwt', agentClaims({ ps: 'https://ps.example/' }), + ) + await expect(verifyToken(opts(jwt))).rejects.toThrow('server identifier') + }) + + it('rejects the wrong dwk', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-agent+jwt', agentClaims({ dwk: 'aauth-person.json' }), + ) + await expect(verifyToken(opts(jwt))).rejects.toThrow('Expected dwk "aauth-agent.json"') + }) +}) + +describe('person token', () => { + it('verifies a person token and carries the mission through', async () => { + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-person+jwt', personClaims()) + const result = (await verifyToken(opts(jwt))) as VerifiedPersonToken + + expect(result.type).toBe('person') + expect(result.iss).toBe(PS) + expect(result.dwk).toBe('aauth-person.json') + expect(result.aud).toBe(RESOURCE) + expect(result.sub).toBe('8f14e45fceea167a5a36dedd4bea2543') + expect(result.jti).toBe('pt-3ab910') + expect(result.mission_s256).toBe(MISSION_S256) + }) + + it('surfaces tenant when present', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', personClaims({ tenant: 'acme' }), + ) + const result = (await verifyToken(opts(jwt))) as VerifiedPersonToken + expect(result.tenant).toBe('acme') + }) + + it('requires dwk aauth-person.json', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', personClaims({ dwk: 'aauth-access.json' }), + ) + await expect(verifyToken(opts(jwt))).rejects.toThrow('Expected dwk "aauth-person.json"') + }) + + it('requires jti', async () => { + const claims = personClaims() + delete (claims as Record).jti + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-person+jwt', claims) + await expect(verifyToken(opts(jwt))).rejects.toThrow('Missing required claim: jti') + }) + + it('rejects an aud that is not this resource', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', personClaims({ aud: 'https://other.example' }), + ) + try { + await verifyToken(opts(jwt)) + expect.fail('should have thrown') + } catch (err) { + expect((err as AAuthTokenError).code).toBe('aud_mismatch') + } + }) + + it('rejects scope or account on a person token', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', personClaims({ scope: 'notes.write' }), + ) + await expect(verifyToken(opts(jwt))).rejects.toThrow('MUST NOT carry scope or account') + }) + + it('rejects a cnf.jwk that is not the HTTP signing key', async () => { + const other = await generateKeyPair('Ed25519', { extractable: true }) + const otherJwk = { ...(await exportJWK(other.publicKey)), alg: 'Ed25519' } + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', personClaims({ cnf: { jwk: otherJwk } }), + ) + try { + await verifyToken(opts(jwt)) + expect.fail('should have thrown') + } catch (err) { + expect((err as AAuthTokenError).code).toBe('key_binding_failed') + } + }) + + it('rejects a cnf.jwk with no alg member', async () => { + const bare = { ...keys.agentJwk } + delete (bare as Record).alg + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', personClaims({ cnf: { jwk: bare } }), + ) + await expect(verifyToken(opts(jwt))).rejects.toThrow('alg is REQUIRED') + }) + + it('rejects a structurally incomplete cnf.jwk before decoding it', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, + 'aa-person+jwt', + personClaims({ cnf: { jwk: { kty: 'OKP', crv: 'Ed25519', alg: 'Ed25519' } } }), + ) + await expect(verifyToken(opts(jwt))).rejects.toThrow('structurally incomplete: missing x') + }) +}) + +describe('a person token is not an auth token', () => { + it('is rejected wherever an auth token is required', async () => { + // Same iss, dwk, aud, sub and cnf as the auth token below. Only typ differs. + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-person+jwt', personClaims()) + try { + await verifyToken(opts(jwt, { accept: ['auth'] })) + expect.fail('a person token MUST NOT be accepted where an auth token is required') + } catch (err) { + expect(err).toBeInstanceOf(AAuthTokenError) + expect((err as AAuthTokenError).code).toBe('token_type_not_accepted') + } + }) + + it('rejects it before any network call, so a hostile PS cannot help', async () => { + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-person+jwt', personClaims()) + await expect(verifyToken(opts(jwt, { accept: ['auth'] }))).rejects.toThrow() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('symmetrically rejects an auth token where a person token is required', async () => { + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-auth+jwt', authClaims()) + try { + await verifyToken(opts(jwt, { accept: ['person'] })) + expect.fail('should have thrown') + } catch (err) { + expect((err as AAuthTokenError).code).toBe('token_type_not_accepted') + } + }) + + it('rejects an agent token where an auth token is required', async () => { + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-agent+jwt', agentClaims()) + try { + await verifyToken(opts(jwt, { accept: ['auth'] })) + expect.fail('should have thrown') + } catch (err) { + expect((err as AAuthTokenError).code).toBe('token_type_not_accepted') + } + }) +}) + +describe('auth token', () => { + it('verifies and surfaces ps, sub, mission and R3 claims', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, + 'aa-auth+jwt', + authClaims({ + account: 'acct-9', + r3_uri: 'https://resource.example/r3/abc', + r3_s256: 'aBcD', + r3_granted: { vocabulary: 'urn:aauth:vocabulary:mcp', operations: [{ tool: 'read_note' }] }, + r3_per_call: { vocabulary: 'urn:aauth:vocabulary:mcp', operations: [{ tool: 'send_email' }] }, + }), + ) + const result = (await verifyToken(opts(jwt, { accept: ['auth'] }))) as VerifiedAuthToken + + expect(result.type).toBe('auth') + expect(result.ps).toBe(PS) + expect(result.sub).toBe('8f14e45fceea167a5a36dedd4bea2543') + expect(result.scope).toBe('notes.read') + expect(result.account).toBe('acct-9') + expect(result.mission_s256).toBe(MISSION_S256) + expect(result.r3_uri).toBe('https://resource.example/r3/abc') + expect(result.r3_granted?.operations).toEqual([{ tool: 'read_note' }]) + expect(result.r3_per_call?.operations).toEqual([{ tool: 'send_email' }]) + // No `agent` claim exists in -11. + expect((result as unknown as { agent?: string }).agent).toBeUndefined() + }) + + it('requires ps', async () => { + const claims = authClaims() + delete (claims as Record).ps + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-auth+jwt', claims) + await expect(verifyToken(opts(jwt, { accept: ['auth'] }))) + .rejects.toThrow('Missing required claim: ps') + }) + + it('requires sub', async () => { + const claims = authClaims() + delete (claims as Record).sub + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-auth+jwt', claims) + await expect(verifyToken(opts(jwt, { accept: ['auth'] }))) + .rejects.toThrow('Missing required claim: sub') + }) + + it('accepts dwk aauth-access.json from an AS', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-auth+jwt', authClaims({ dwk: 'aauth-access.json' }), + ) + const result = (await verifyToken(opts(jwt, { accept: ['auth'] }))) as VerifiedAuthToken + expect(result.dwk).toBe('aauth-access.json') + }) + + it('rejects any other dwk', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-auth+jwt', authClaims({ dwk: 'aauth-resource.json' }), + ) + await expect(verifyToken(opts(jwt, { accept: ['auth'] }))) + .rejects.toThrow('Auth token dwk must be') + }) + + it('accepts an aud array containing this resource', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-auth+jwt', authClaims({ aud: ['https://other.example', RESOURCE] }), + ) + const result = await verifyToken(opts(jwt, { accept: ['auth'] })) + expect(result.type).toBe('auth') + }) +}) + +describe('algorithm policy', () => { + it('rejects the polymorphic EdDSA in the JWT header', async () => { + const ed = await generateKeyPair('Ed25519', { extractable: true }) + const jwk = { ...(await exportJWK(ed.publicKey)), alg: 'Ed25519', kid: 'issuer-1' } + const agentThumb = await calculateJwkThumbprint(keys.agentJwk, 'sha256') + const now = Math.floor(Date.now() / 1000) + const jwt = await new SignJWT({ ...personClaims(), iat: now, exp: now + 3600 }) + .setProtectedHeader({ alg: 'EdDSA', typ: 'aa-person+jwt', kid: 'issuer-1' }) + .sign(ed.privateKey) + + void jwk + await expect( + verifyToken(opts(jwt, { httpSignatureThumbprint: agentThumb })), + ).rejects.toThrow('MUST NOT be used') + }) + + it('rejects an unknown typ', async () => { + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-mystery+jwt', personClaims()) + try { + await verifyToken(opts(jwt)) + expect.fail('should have thrown') + } catch (err) { + expect((err as AAuthTokenError).code).toBe('unsupported_token_type') + } + }) +}) + +describe('time and key discovery', () => { + it('rejects an expired token with a stable code', async () => { + const past = Math.floor(Date.now() / 1000) - 7200 + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', { ...personClaims(), iat: past, exp: past + 60 }, + ) + try { + await verifyToken(opts(jwt)) + expect.fail('should have thrown') + } catch (err) { + expect((err as AAuthTokenError).code).toBe('token_expired') + expect((err as Error).message).toBe('Token has expired') + } + }) + + it('rejects an iat in the future', async () => { + const future = Math.floor(Date.now() / 1000) + 7200 + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', { ...personClaims(), iat: future, exp: future + 600 }, + ) + await expect(verifyToken(opts(jwt))).rejects.toThrow('iat is in the future') + }) + + it('rejects an iss that is not a server identifier', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', personClaims({ iss: 'http://ps.example' }), + ) + await expect(verifyToken(opts(jwt))).rejects.toThrow('not a valid HTTPS server identifier') + }) + + it('rejects when no JWKS key matches kid', async () => { + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', personClaims(), { kid: 'rotated-out' }, + ) + await expect(verifyToken(opts(jwt))).rejects.toThrow('No key with kid "rotated-out"') + }) + + it('rejects a signature made by a key the issuer does not publish', async () => { + const rogue = await generateKeyPair('Ed25519', { extractable: true }) + const jwt = await signTestJwt(rogue.privateKey as CryptoKey, 'aa-person+jwt', personClaims()) + await expect(verifyToken(opts(jwt))).rejects.toThrow('signature verification failed') + }) + + it('surfaces a metadata fetch failure', async () => { + const failing = vi.fn(async () => new Response('nope', { status: 500 })) + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-person+jwt', personClaims()) + await expect(verifyToken(opts(jwt, { fetch: failing }))) + .rejects.toThrow('Failed to fetch metadata') + }) + + it('caches discovery across calls', async () => { + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-person+jwt', personClaims()) + await verifyToken(opts(jwt)) + await verifyToken(opts(jwt)) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('call-site configuration', () => { + it('requires an accept list', async () => { + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-person+jwt', personClaims()) + await expect(verifyToken(opts(jwt, { accept: [] }))).rejects.toThrow('requires an `accept` list') + }) + + it('requires the resource identifier to be a server identifier', async () => { + const jwt = await signTestJwt(keys.issuerPrivate, 'aa-person+jwt', personClaims()) + await expect(verifyToken(opts(jwt, { resource: 'https://resource.example/' }))) + .rejects.toThrow('must be a valid HTTPS server identifier') + }) +}) diff --git a/resource/src/verify-token.ts b/resource/src/verify-token.ts new file mode 100644 index 0000000..d1460ca --- /dev/null +++ b/resource/src/verify-token.ts @@ -0,0 +1,447 @@ +import { jwtVerify, importJWK, calculateJwkThumbprint } from 'jose' +import type { JWK, JSONWebKeySet } from 'jose' +import { TOKEN_TYP, DWK, decodeJwtHeader, decodeJwtPayload } from '@aauth/protocol' +import { AAuthTokenError } from './errors.js' +import { discoverJwks, type FetchLike } from './jwks.js' +import { isServerIdentifier, nowSeconds, timingSafeEqualString } from './util.js' +import type { R3OperationSet } from './r3.js' + +// --- Types --- + +/** The three AAuth JWTs an agent can present to a resource via `Signature-Key`. */ +export type TokenKind = 'agent' | 'person' | 'auth' + +export interface VerifyTokenOptions { + /** Raw JWT, from `@hellocoop/httpsig`'s `result.jwt.raw`. */ + jwt: string + /** JWK thumbprint of the key that signed the HTTP request, from `result.thumbprint`. */ + httpSignatureThumbprint: string + /** + * The resource's own server identifier (AAuth Protocol §Server Identifiers). + * A person token's or auth token's `aud` MUST equal it. + */ + resource: string + /** + * Which token kinds this call site accepts. REQUIRED — there is no safe + * default. + * + * A person token and a PS-issued auth token carry the same `iss`, `dwk`, + * `aud`, `sub` and `cnf`; only `typ` distinguishes them (AAuth Protocol + * §Person Token Is Not Authorization). A call site that requires + * authorization MUST pass `['auth']`. Including `'person'` there accepts an + * identity assertion as a grant, and the mistake fails open. + */ + accept: readonly TokenKind[] + /** Injectable fetch, for Workers bindings and tests. Defaults to global fetch. */ + fetch?: FetchLike + /** Seconds of clock skew tolerated on `exp` and `iat`. Default 60. */ + clockToleranceSeconds?: number + /** Override "now", in seconds since the epoch. For tests. */ + now?: number +} + +interface VerifiedBase { + iss: string + dwk: string + jti?: string + cnf: { jwk: JWK } + iat: number + exp: number + /** Every payload claim, for claims this package does not model. */ + claims: Record +} + +export interface VerifiedAgentToken extends VerifiedBase { + type: 'agent' + /** Agent identifier (`aauth:local@domain`), stable across key rotations. */ + sub: string + jti?: string + /** The agent's person server, when the agent token declares one. This is the + * PS entitled to fetch R3 documents referenced by tokens issued to this + * agent (AAuth-R3 §R3 Document Access Restriction). */ + ps?: string + /** Sub-agent marker: the identifier of this agent's parent. */ + parent_agent?: string +} + +export interface VerifiedPersonToken extends VerifiedBase { + type: 'person' + /** The person server. `(iss, sub)` is the identity — see `sub`. */ + iss: string + dwk: string + /** This resource's identifier. */ + aud: string + /** + * Directed user identifier. Unique within `iss`, NOT globally. A resource + * MUST treat `(iss, sub)` as the identifier, MUST treat the value as opaque, + * and MUST NOT match a `sub` from one issuer against a record established + * under another, however the values compare. + */ + sub: string + /** REQUIRED on a person token — binds a resource token back to this one. */ + jti: string + mission_s256?: string + tenant?: string +} + +export interface VerifiedAuthToken extends VerifiedBase { + type: 'auth' + aud: string | string[] + /** The person server the person is represented by. Equal to `iss` when a PS + * issued the token. */ + ps: string + /** Directed user identifier. REQUIRED. `(iss, sub)` is the identity. */ + sub: string + scope?: string + account?: string + mission_s256?: string + tenant?: string + r3_uri?: string + r3_s256?: string + r3_granted?: R3OperationSet + /** Operations authorized in principle but requiring a per-call proposal. + * Renamed from `r3_conditional` in R3 -02. */ + r3_per_call?: R3OperationSet +} + +export type VerifiedToken = VerifiedAgentToken | VerifiedPersonToken | VerifiedAuthToken + +export { AAuthTokenError } from './errors.js' +export { clearMetadataCache } from './jwks.js' + +// --- Algorithm policy (AAuth Protocol §Signature Algorithms) --- + +const PROHIBITED_ALGS = new Set(['none', 'EdDSA', 'HS256', 'HS384', 'HS512']) + +function assertFullySpecifiedAlg(alg: unknown, code: string, what: string): asserts alg is string { + if (typeof alg !== 'string' || alg.length === 0) { + throw new AAuthTokenError(code, `${what}: alg is REQUIRED and must be fully specified`) + } + if (PROHIBITED_ALGS.has(alg)) { + throw new AAuthTokenError( + code, + `${what}: alg "${alg}" MUST NOT be used — use a fully-specified identifier such as Ed25519`, + ) + } +} + +/** + * Structural checks on a `cnf.jwk`, per AAuth Protocol §Request-Context + * Binding step 6. Reject a structurally incomplete key before attempting to + * decode it. + */ +function assertUsableConfirmationKey(jwk: unknown, code: string): asserts jwk is JWK { + if (!jwk || typeof jwk !== 'object') { + throw new AAuthTokenError(code, 'Missing required claim: cnf.jwk') + } + const k = jwk as Record + const kty = k.kty + if (typeof kty !== 'string') { + throw new AAuthTokenError(code, 'cnf.jwk is structurally incomplete: missing kty') + } + if (kty === 'oct') { + throw new AAuthTokenError(code, 'cnf.jwk uses a symmetric key type') + } + const required: Record = { + OKP: ['crv', 'x'], + EC: ['crv', 'x', 'y'], + RSA: ['n', 'e'], + } + const members = required[kty] + if (!members) { + throw new AAuthTokenError(code, `cnf.jwk uses an unsupported key type: ${kty}`) + } + for (const m of members) { + if (typeof k[m] !== 'string') { + throw new AAuthTokenError(code, `cnf.jwk is structurally incomplete: missing ${m}`) + } + } + assertFullySpecifiedAlg(k.alg, code, 'cnf.jwk') + if (kty === 'OKP' && k.alg === 'Ed25519' && k.crv !== 'Ed25519') { + throw new AAuthTokenError(code, 'cnf.jwk crv disagrees with alg') + } + if (kty === 'EC' && k.alg === 'ES256' && k.crv !== 'P-256') { + throw new AAuthTokenError(code, 'cnf.jwk crv disagrees with alg') + } +} + +// --- Helpers --- + +const TYP_TO_KIND: Record = { + [TOKEN_TYP.agent]: 'agent', + [TOKEN_TYP.person]: 'person', + [TOKEN_TYP.auth]: 'auth', +} + +const ERROR_CODE: Record = { + agent: 'invalid_agent_token', + person: 'invalid_person_token', + auth: 'invalid_auth_token', +} + +function requireString( + claims: Record, + name: string, + code: string, +): string { + const v = claims[name] + if (typeof v !== 'string' || v.length === 0) { + throw new AAuthTokenError(code, `Missing required claim: ${name}`) + } + return v +} + +function optionalString(claims: Record, name: string): string | undefined { + const v = claims[name] + return typeof v === 'string' && v.length > 0 ? v : undefined +} + +function audMatches(aud: unknown, resource: string): boolean { + if (typeof aud === 'string') return aud === resource + if (Array.isArray(aud)) return aud.some(a => a === resource) + return false +} + +function selectKey(jwks: JSONWebKeySet, kid: unknown, code: string): JWK { + const keys = jwks.keys ?? [] + if (typeof kid === 'string' && kid.length > 0) { + const match = keys.find(k => k.kid === kid) + if (!match) { + throw new AAuthTokenError(code, `No key with kid "${kid}" in issuer JWKS`) + } + return match + } + if (keys.length === 1) return keys[0] + throw new AAuthTokenError(code, 'JWT header has no kid and issuer JWKS has more than one key') +} + +// --- Main function --- + +/** + * Verify an AAuth JWT presented via `Signature-Key: sig=jwt;jwt="…"`. + * + * Performs, in order: `typ` recognition, the `accept` check, required-claim + * structure, expiry, key binding (`cnf.jwk` against the HTTP signing key), + * `kid` selection and signature verification against the issuer's JWKS + * discovered at `{iss}/.well-known/{dwk}`, then `iss` and `aud`. + * + * Nothing in the returned value is acted upon before the signature verifies. + */ +export async function verifyToken(options: VerifyTokenOptions): Promise { + const { + jwt: rawJwt, + httpSignatureThumbprint, + resource, + accept, + clockToleranceSeconds = 60, + } = options + + if (!Array.isArray(accept) || accept.length === 0) { + throw new AAuthTokenError( + 'invalid_configuration', + 'verifyToken requires an `accept` list naming the token kinds this call site allows', + ) + } + if (!isServerIdentifier(resource)) { + throw new AAuthTokenError( + 'invalid_configuration', + `verifyToken \`resource\` must be a valid HTTPS server identifier, got: ${resource}`, + ) + } + + // 1. Decode the header. Recognize typ, then check it is acceptable here. + let header: Record + try { + header = decodeJwtHeader(rawJwt) + } catch { + throw new AAuthTokenError('malformed_token', 'Value is not a well-formed JWT') + } + const typ = header.typ + const kind = typeof typ === 'string' ? TYP_TO_KIND[typ] : undefined + + if (!kind) { + throw new AAuthTokenError('unsupported_token_type', `Unknown JWT typ: ${String(typ)}`) + } + if (!accept.includes(kind)) { + // AAuth Protocol §Person Token Is Not Authorization: a recipient MUST + // reject an aa-person+jwt wherever an auth token is required. + throw new AAuthTokenError( + 'token_type_not_accepted', + `A ${typ} is not accepted here; this endpoint requires: ${accept.join(', ')}`, + ) + } + + const code = ERROR_CODE[kind] + assertFullySpecifiedAlg(header.alg, code, 'JWT header') + + // 2. Required claims and structure. + let claims: Record + try { + claims = decodeJwtPayload(rawJwt) + } catch { + throw new AAuthTokenError('malformed_token', 'JWT payload is not decodable JSON') + } + + const iss = requireString(claims, 'iss', code) + const dwk = requireString(claims, 'dwk', code) + const iat = claims.iat + const exp = claims.exp + if (typeof iat !== 'number') throw new AAuthTokenError(code, 'Missing required claim: iat') + if (typeof exp !== 'number') throw new AAuthTokenError(code, 'Missing required claim: exp') + + const expectedDwk = kind === 'agent' + ? DWK.agent + : kind === 'person' + ? DWK.person + : undefined // auth tokens: aauth-person.json (PS) or aauth-access.json (AS) + + if (expectedDwk && dwk !== expectedDwk) { + throw new AAuthTokenError(code, `Expected dwk "${expectedDwk}", got "${dwk}"`) + } + if (kind === 'auth' && dwk !== DWK.person && dwk !== DWK.access) { + throw new AAuthTokenError( + code, + `Auth token dwk must be "${DWK.person}" or "${DWK.access}", got "${dwk}"`, + ) + } + + const cnf = claims.cnf as { jwk?: unknown } | undefined + assertUsableConfirmationKey(cnf?.jwk, code) + const confirmationJwk = cnf!.jwk as JWK + + // REQUIRED on all three: the agent identifier, or the directed person identifier. + const sub = requireString(claims, 'sub', code) + + if (kind === 'person') { + requireString(claims, 'aud', code) + requireString(claims, 'jti', code) + } + if (kind === 'auth') { + if (!claims.aud) throw new AAuthTokenError(code, 'Missing required claim: aud') + requireString(claims, 'ps', code) + } + + // 3. Expiry and issuance time. + const now = options.now ?? nowSeconds() + if (exp < now - clockToleranceSeconds) { + throw new AAuthTokenError('token_expired', 'Token has expired') + } + if (iat > now + clockToleranceSeconds) { + throw new AAuthTokenError(code, 'Token iat is in the future') + } + + // 4. Key binding: cnf.jwk MUST be the key that signed the HTTP request. + const cnfThumbprint = await calculateJwkThumbprint(confirmationJwk, 'sha256') + if (!timingSafeEqualString(cnfThumbprint, httpSignatureThumbprint)) { + throw new AAuthTokenError( + 'key_binding_failed', + 'cnf.jwk thumbprint does not match HTTP signature key', + ) + } + + // 5. Issuer identity, then signature over the issuer's discovered key. + if (!isServerIdentifier(iss)) { + throw new AAuthTokenError(code, `iss is not a valid HTTPS server identifier: ${iss}`) + } + + const jwks = await discoverJwks({ iss, dwk, fetch: options.fetch, errorCode: code }) + const signingKey = selectKey(jwks, header.kid, code) + if (typeof signingKey.alg === 'string' && signingKey.alg !== header.alg) { + throw new AAuthTokenError( + code, + `JWKS key alg "${signingKey.alg}" disagrees with JWT header alg "${header.alg}"`, + ) + } + + try { + const key = await importJWK(signingKey, header.alg as string) + await jwtVerify(rawJwt, key, { + algorithms: [header.alg as string], + clockTolerance: clockToleranceSeconds, + typ: typ as string, + }) + } catch (err) { + if (err instanceof AAuthTokenError) throw err + throw new AAuthTokenError( + code, + `JWT signature verification failed: ${(err as Error).message}`, + ) + } + + // 6. Audience. An agent token has none; a person or auth token names us. + if (kind === 'person' || kind === 'auth') { + if (!audMatches(claims.aud, resource)) { + throw new AAuthTokenError( + 'aud_mismatch', + `Token aud does not match this resource (${resource})`, + ) + } + } + + const base = { + iss, + dwk, + cnf: { jwk: confirmationJwk }, + iat, + exp, + claims, + ...(optionalString(claims, 'jti') ? { jti: optionalString(claims, 'jti') } : {}), + } + + if (kind === 'agent') { + const ps = optionalString(claims, 'ps') + if (ps !== undefined && !isServerIdentifier(ps)) { + throw new AAuthTokenError(code, `ps is not a valid HTTPS server identifier: ${ps}`) + } + const result: VerifiedAgentToken = { type: 'agent', ...base, sub } + if (ps) result.ps = ps + const parent = optionalString(claims, 'parent_agent') + if (parent) result.parent_agent = parent + return result + } + + if (kind === 'person') { + const result: VerifiedPersonToken = { + type: 'person', + ...base, + aud: claims.aud as string, + sub, + jti: claims.jti as string, + } + const mission = optionalString(claims, 'mission_s256') + if (mission) result.mission_s256 = mission + const tenant = optionalString(claims, 'tenant') + if (tenant) result.tenant = tenant + if (claims.scope !== undefined || claims.account !== undefined) { + throw new AAuthTokenError(code, 'A person token MUST NOT carry scope or account') + } + return result + } + + const ps = claims.ps as string + if (!isServerIdentifier(ps)) { + throw new AAuthTokenError(code, `ps is not a valid HTTPS server identifier: ${ps}`) + } + const result: VerifiedAuthToken = { + type: 'auth', + ...base, + aud: claims.aud as string | string[], + ps, + sub, + } + const scope = optionalString(claims, 'scope') + if (scope) result.scope = scope + const account = optionalString(claims, 'account') + if (account) result.account = account + const mission = optionalString(claims, 'mission_s256') + if (mission) result.mission_s256 = mission + const tenant = optionalString(claims, 'tenant') + if (tenant) result.tenant = tenant + const r3Uri = optionalString(claims, 'r3_uri') + if (r3Uri) result.r3_uri = r3Uri + const r3Hash = optionalString(claims, 'r3_s256') + if (r3Hash) result.r3_s256 = r3Hash + if (claims.r3_granted) result.r3_granted = claims.r3_granted as R3OperationSet + if (claims.r3_per_call) result.r3_per_call = claims.r3_per_call as R3OperationSet + return result +} diff --git a/resource/tsconfig.json b/resource/tsconfig.json new file mode 100644 index 0000000..7887a90 --- /dev/null +++ b/resource/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022", "DOM"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/testing.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts index a6101f4..f8f2221 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,10 +4,15 @@ import path from 'path' export default defineConfig({ resolve: { alias: { - '@aauth/mcp-server': path.resolve(__dirname, 'mcp-server/src/index.ts'), - '@aauth/mcp-agent': path.resolve(__dirname, 'mcp-agent/src/index.ts'), + '@aauth/protocol': path.resolve(__dirname, 'protocol/src/index.ts'), + '@aauth/agent': path.resolve(__dirname, 'agent/src/index.ts'), + '@aauth/resource': path.resolve(__dirname, 'resource/src/index.ts'), '@aauth/mcp-openclaw': path.resolve(__dirname, 'mcp-openclaw/src/index.ts'), '@aauth/local-keys': path.resolve(__dirname, 'local-keys/src/index.ts'), + // Required once `resource/vitest.config.ts` is deleted: `@aauth/resource` + // imports `@aauth/interaction-code`, whose package entry points at an + // unbuilt `dist/`. Resolve it from source like every other workspace. + '@aauth/interaction-code': path.resolve(__dirname, 'interaction-code/src/index.ts'), '@aauth/hardware-keys': path.resolve(__dirname, 'hardware-keys/index.js'), }, },