diff --git a/agent/package.json b/agent/package.json index 618480d..93d20e3 100644 --- a/agent/package.json +++ b/agent/package.json @@ -32,7 +32,7 @@ "directory": "agent" }, "dependencies": { - "@aauth/protocol": "^1.0.0", + "@aauth/protocol": "^2.0.0", "@hellocoop/httpsig": "^2.4.0" }, "devDependencies": { diff --git a/fetch/package.json b/fetch/package.json index e0fe73f..7ff69c9 100644 --- a/fetch/package.json +++ b/fetch/package.json @@ -17,7 +17,7 @@ "dependencies": { "@aauth/agent": "^4.0.0", "@aauth/local-keys": "^2.0.0", - "@aauth/protocol": "^1.0.0", + "@aauth/protocol": "^2.0.0", "open": "^11.0.0", "qrcode-terminal": "^0.12.0" }, diff --git a/mcp-openclaw/package.json b/mcp-openclaw/package.json index 168a30d..52ff4ca 100644 --- a/mcp-openclaw/package.json +++ b/mcp-openclaw/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@aauth/agent": "^4.0.0", - "@aauth/protocol": "^1.0.0", + "@aauth/protocol": "^2.0.0", "@aauth/local-keys": "^2.0.0", "@modelcontextprotocol/sdk": "^1.15.1" }, diff --git a/package-lock.json b/package-lock.json index 05b31d0..7c69177 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "version": "4.0.0", "license": "MIT", "dependencies": { - "@aauth/protocol": "^1.0.0", + "@aauth/protocol": "^2.0.0", "@hellocoop/httpsig": "^2.4.0" }, "devDependencies": { @@ -56,7 +56,7 @@ "dependencies": { "@aauth/agent": "^4.0.0", "@aauth/local-keys": "^2.0.0", - "@aauth/protocol": "^1.0.0", + "@aauth/protocol": "^2.0.0", "open": "^11.0.0", "qrcode-terminal": "^0.12.0" }, @@ -114,7 +114,7 @@ "dependencies": { "@aauth/agent": "^4.0.0", "@aauth/local-keys": "^2.0.0", - "@aauth/protocol": "^1.0.0", + "@aauth/protocol": "^2.0.0", "@modelcontextprotocol/sdk": "^1.15.1" }, "devDependencies": { @@ -4154,7 +4154,7 @@ }, "protocol": { "name": "@aauth/protocol", - "version": "1.0.1", + "version": "2.0.0", "license": "MIT", "dependencies": { "@hellocoop/httpsig": "^2.4.0" @@ -4166,11 +4166,11 @@ }, "resource": { "name": "@aauth/resource", - "version": "2.5.0", + "version": "3.0.0", "license": "MIT", "dependencies": { "@aauth/interaction-code": "^0.1.0", - "@aauth/protocol": "^1.0.0", + "@aauth/protocol": "^2.0.0", "jose": "^6.0.0" }, "devDependencies": { diff --git a/protocol/README.md b/protocol/README.md index ac2487f..ab26165 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -45,8 +45,10 @@ Recognized values: `agent-token`, `person-token`, `auth-token`, `approval`, 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. +`requirement=interaction` requires `code`; a header missing one is malformed and +throws a plain `Error`. `url` on `requirement=interaction` is optional: when it +is absent the recipient composes `{interaction_endpoint}?code=…` from the +issuer's published metadata. Unknown parameters are ignored. ## AAuth-Capabilities diff --git a/protocol/package.json b/protocol/package.json index b8bee5d..83fb1f4 100644 --- a/protocol/package.json +++ b/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/protocol", - "version": "1.0.1", + "version": "2.0.0", "description": "AAuth wire format — AAuth-Requirement and AAuth-Capabilities headers, access_mode planning, protocol constants", "type": "module", "exports": { diff --git a/protocol/src/requirement.test.ts b/protocol/src/requirement.test.ts index ba27b22..4c36abd 100644 --- a/protocol/src/requirement.test.ts +++ b/protocol/src/requirement.test.ts @@ -140,12 +140,18 @@ describe('parseRequirementHeader — malformed headers', () => { ) }) - it('rejects interaction missing url, code, or both', () => { - expect(() => parseRequirementHeader('requirement=interaction')).toThrow(/url or code/) + it('parses interaction with code only — the recipient composes the URL from interaction_endpoint', () => { + expect(parseRequirementHeader('requirement=interaction; code="A1B2-C3D4"')).toEqual({ + requirement: 'interaction', + code: 'A1B2-C3D4', + }) + }) + + it('rejects interaction missing code', () => { + expect(() => parseRequirementHeader('requirement=interaction')).toThrow(/missing the code/) expect(() => parseRequirementHeader('requirement=interaction; url="https://x.example"')).toThrow( - /url or code/, + /missing the code/, ) - expect(() => parseRequirementHeader('requirement=interaction; code="A1B2"')).toThrow(/url or code/) }) it('malformed-parameter errors are not UnsupportedRequirementError', () => { @@ -187,7 +193,13 @@ describe('buildRequirementHeader', () => { expect(() => buildRequirementHeader({ requirement: 'auth-token' })).toThrow(/resourceToken/) expect(() => buildRequirementHeader({ requirement: 'interaction', url: 'https://x.example' }), - ).toThrow(/url and code/) + ).toThrow(/requires a code/) + }) + + it('builds interaction with code only', () => { + expect(buildRequirementHeader({ requirement: 'interaction', code: 'A1B2-C3D4' })).toBe( + 'requirement=interaction;code="A1B2-C3D4"', + ) }) it('throws UnsupportedRequirementError on a value it does not know', () => { diff --git a/protocol/src/requirement.ts b/protocol/src/requirement.ts index 5d68644..a87a5e1 100644 --- a/protocol/src/requirement.ts +++ b/protocol/src/requirement.ts @@ -36,7 +36,12 @@ export interface AAuthChallenge { requirement: RequirementValue /** REQUIRED when `requirement === 'auth-token'`. */ resourceToken?: string - /** REQUIRED when `requirement === 'interaction'`. */ + /** + * OPTIONAL with `requirement === 'interaction'`. When absent the recipient + * composes the person-facing URL from the issuer's published + * `interaction_endpoint` (`{interaction_endpoint}?code=…`). Issuers SHOULD + * omit it; it is accepted for compatibility. + */ url?: string /** REQUIRED when `requirement === 'interaction'`. */ code?: string @@ -72,6 +77,7 @@ export function isRequirementValue(value: string): value is RequirementValue { * Build an `AAuth-Requirement` response header value. * * requirement=auth-token;resource-token="eyJ..." + * requirement=interaction;code="A1B2-C3D4" * requirement=interaction;url="https://example.com/interact";code="A1B2-C3D4" * requirement=approval * @@ -100,10 +106,10 @@ export function buildRequirementHeader(challenge: AAuthChallenge): string { } if (requirement === 'interaction') { - if (!challenge.url || !challenge.code) { - throw new Error('requirement=interaction requires both url and code') + if (!challenge.code) { + throw new Error('requirement=interaction requires a code') } - parameters.set('url', challenge.url) + if (challenge.url) parameters.set('url', challenge.url) parameters.set('code', challenge.code) } @@ -186,8 +192,8 @@ export function parseRequirementHeader(headerValue: string): AAuthChallenge { 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') + if (challenge.requirement === 'interaction' && !challenge.code) { + throw new Error('requirement=interaction is missing the code parameter') } return challenge diff --git a/resource/README.md b/resource/README.md index d28409a..725b2e9 100644 --- a/resource/README.md +++ b/resource/README.md @@ -105,7 +105,7 @@ 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('interaction', { code }) // 202 — the agent composes {interaction_endpoint}?code= buildAAuthHeader('approval') buildAAuthHeader('clarification') buildAAuthHeader('claims') @@ -134,12 +134,29 @@ const resourceToken = await createResourceToken( scope: 'notes.read notes.write', kid: publicJwk.kid, r3: { uri: r3_uri, s256: r3_s256 }, // optional; both or neither + interactionCode, // optional: the resource's own flow must run first missionExpiresAt, // optional clamp }, async (payload, header) => signJwt(header, payload, privateKey), ) ``` +`scope` present means the PS will issue an auth token once its own consent is done. A +**connection-only** token — the answer to `POST /connections`, which asks the PS to drive the +resource's upstream OAuth and nothing else — carries `interaction_code` and no `scope`, so the PS +terminates the poll with `connection_established` instead of issuing: + +```ts +await createResourceToken( + { resource, audience, presentedToken, agentJkt, kid, connectionOnly: true, interactionCode, account }, + sign, +) +``` + +`interactionCode` is emitted as the flat `interaction_code` claim; the PS composes +`{interaction_endpoint}?code=…` from the resource's published metadata. The nested +`interaction: { url, code }` claim of 2.x is gone (3.0.0). + 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. @@ -330,13 +347,13 @@ token gets `requirement=person-token`, and a revoked agent token no requirement ```ts import { InteractionManager } from '@aauth/resource' -const manager = new InteractionManager({ - baseUrl: 'https://notes.example', - interactionUrl: 'https://notes.example/interact', -}) +const manager = new InteractionManager({ baseUrl: 'https://notes.example' }) const { headers, pending } = manager.createPending() -// headers: Location, Retry-After, Cache-Control, AAuth-Requirement +// headers: Location, Retry-After, Cache-Control, +// AAuth-Requirement: requirement=interaction;code="XXXX-XXXX" +// The agent composes the URL from the `interaction_endpoint` in your metadata. +// `interactionUrl` (deprecated) keeps emitting `url=` for 2.x-era recipients. manager.resolve(pending.id, { granted: true }) ``` diff --git a/resource/package.json b/resource/package.json index 6ef0ad4..6aa61e6 100644 --- a/resource/package.json +++ b/resource/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/resource", - "version": "2.5.0", + "version": "3.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": { @@ -35,7 +35,7 @@ }, "dependencies": { "@aauth/interaction-code": "^0.1.0", - "@aauth/protocol": "^1.0.0", + "@aauth/protocol": "^2.0.0", "jose": "^6.0.0" }, "devDependencies": { diff --git a/resource/src/challenge.test.ts b/resource/src/challenge.test.ts index 142a42a..eb08fa4 100644 --- a/resource/src/challenge.test.ts +++ b/resource/src/challenge.test.ts @@ -31,6 +31,10 @@ describe('buildAAuthHeader', () => { }) }) + it('builds requirement=interaction with code only', () => { + expect(buildAAuthHeader('interaction', { code: 'A1B2-C3D4' })).toBe('requirement=interaction;code="A1B2-C3D4"') + }) + it('builds requirement=interaction with url and code', () => { const header = buildAAuthHeader('interaction', { url: 'https://resource.example/interact', diff --git a/resource/src/challenge.ts b/resource/src/challenge.ts index ae7ecc3..a1a3ad0 100644 --- a/resource/src/challenge.ts +++ b/resource/src/challenge.ts @@ -49,10 +49,13 @@ export type SimpleRequirement = * 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. + * 202 + `requirement=interaction` — carries the interaction code; the agent + * composes the URL from the resource's + * published `interaction_endpoint`. `url` + * is optional and SHOULD be omitted. */ 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: 'interaction', params: { code: string; url?: string }): string export function buildAAuthHeader(requirement: SimpleRequirement): string export function buildAAuthHeader( requirement: RequirementValue, diff --git a/resource/src/interaction.test.ts b/resource/src/interaction.test.ts index c7edd67..b88caab 100644 --- a/resource/src/interaction.test.ts +++ b/resource/src/interaction.test.ts @@ -27,6 +27,13 @@ describe('InteractionManager', () => { expect(headers['AAuth-Requirement']).toContain(`code="${pending.code}"`) }) + it('emits requirement=interaction with code only when no interactionUrl is configured', () => { + const codeOnly = new InteractionManager({ baseUrl: 'https://resource.example' }) + const { headers, pending } = codeOnly.createPending() + expect(headers['AAuth-Requirement']).toBe(`requirement=interaction;code="${pending.code}"`) + expect(headers['AAuth-Requirement']).not.toContain('url=') + }) + it('generates unique IDs and codes', () => { const a = manager.createPending() const b = manager.createPending() diff --git a/resource/src/interaction.ts b/resource/src/interaction.ts index ebf2c98..2c9b15f 100644 --- a/resource/src/interaction.ts +++ b/resource/src/interaction.ts @@ -13,7 +13,12 @@ export interface PendingRequest { export interface InteractionManagerOptions { baseUrl: string - interactionUrl: string // the interaction endpoint URL for AAuth-Requirement header + /** + * @deprecated The 202 carries `requirement=interaction; code="…"` only; the + * agent composes the URL from the `interaction_endpoint` in the resource's + * metadata. Set this only to keep emitting `url=` for a 2.x-era recipient. + */ + interactionUrl?: string pendingPath?: string // default: '/pending' ttl?: number // default: 600s } @@ -32,13 +37,13 @@ const DEFAULT_TTL = 600 export class InteractionManager { private pending = new Map() private baseUrl: string - private interactionUrl: string + private interactionUrl?: string private pendingPath: string private ttl: number constructor(options: InteractionManagerOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, '') - this.interactionUrl = options.interactionUrl.replace(/\/$/, '') + this.interactionUrl = options.interactionUrl?.replace(/\/$/, '') this.pendingPath = options.pendingPath ?? DEFAULT_PENDING_PATH this.ttl = options.ttl ?? DEFAULT_TTL } @@ -73,7 +78,7 @@ export class InteractionManager { Location: locationUrl, 'Retry-After': '0', 'Cache-Control': 'no-store', - 'AAuth-Requirement': buildAAuthHeader('interaction', { url: this.interactionUrl, code }), + 'AAuth-Requirement': buildAAuthHeader('interaction', this.interactionUrl ? { url: this.interactionUrl, code } : { code }), } return { headers, pending } diff --git a/resource/src/resource-token.test.ts b/resource/src/resource-token.test.ts index b4ff98b..34d7296 100644 --- a/resource/src/resource-token.test.ts +++ b/resource/src/resource-token.test.ts @@ -151,23 +151,78 @@ describe('createResourceToken', () => { expect(second.captured.payload!.tenant).toBe('acme-eu') }) - it('adds the optional account, interaction and R3 claims', async () => { + it('adds the optional account, interaction_code and R3 claims', async () => { const { sign, captured } = capturingSign() await createResourceToken( base({ account: 'dick@example.com', - interaction: { url: 'https://resource.example/interact', code: 'A1B2-C3D4' }, + interactionCode: '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.interaction_code).toBe('A1B2-C3D4') + expect(p.interaction).toBeUndefined() expect(p.r3_uri).toBe('https://resource.example/r3/abc') expect(p.r3_s256).toBe('aBcDeF') }) + it('omits interaction_code and the 2.x nested interaction object when no interaction is needed', async () => { + const { sign, captured } = capturingSign() + await createResourceToken(base(), sign) + expect(captured.payload).not.toHaveProperty('interaction_code') + expect(captured.payload).not.toHaveProperty('interaction') + }) + + describe('connection-only token', () => { + it('carries interaction_code and no scope, no r3', async () => { + const { sign, captured } = capturingSign() + await createResourceToken( + base({ scope: undefined, connectionOnly: true, interactionCode: 'A1B2-C3D4', account: 'dick@example.com' }), + sign, + ) + const p = captured.payload! + expect(p.interaction_code).toBe('A1B2-C3D4') + expect(p.account).toBe('dick@example.com') + expect(p).not.toHaveProperty('scope') + expect(p).not.toHaveProperty('r3_uri') + expect(p.ps).toBe(PS) + expect(p.presented_jti).toBe('pt-3ab910') + expect(p.mission_s256).toBe(MISSION_S256) + }) + + it('rejects a scope — scope present means an auth token will be issued', async () => { + const { sign } = capturingSign() + await expect( + createResourceToken(base({ connectionOnly: true, interactionCode: 'A1B2-C3D4' }), sign), + ).rejects.toMatchObject({ code: 'invalid_scope' }) + }) + + it('requires an interactionCode', async () => { + const { sign } = capturingSign() + await expect( + createResourceToken(base({ scope: undefined, connectionOnly: true }), sign), + ).rejects.toMatchObject({ code: 'interaction_code_required' }) + }) + + it('rejects an R3 reference', async () => { + const { sign } = capturingSign() + await expect( + createResourceToken( + base({ + scope: undefined, + connectionOnly: true, + interactionCode: 'A1B2-C3D4', + r3: { uri: 'https://resource.example/r3/abc', s256: 'aBcDeF' }, + }), + sign, + ), + ).rejects.toMatchObject({ code: 'invalid_r3_reference' }) + }) + }) + it('rejects a half-specified R3 reference', async () => { const { sign } = capturingSign() await expect( @@ -204,9 +259,10 @@ describe('createResourceToken', () => { ).rejects.toThrow('needs a PS (ps or iss), sub and jti') }) - it('requires scope', async () => { + it('requires scope unless connectionOnly', async () => { const { sign } = capturingSign() await expect(createResourceToken(base({ scope: '' }), sign)).rejects.toThrow('scope is a REQUIRED') + await expect(createResourceToken(base({ scope: undefined }), sign)).rejects.toThrow('scope is a REQUIRED') }) it('rejects an iss or aud that is not a server identifier', async () => { diff --git a/resource/src/resource-token.ts b/resource/src/resource-token.ts index 05fc46b..1dd7f85 100644 --- a/resource/src/resource-token.ts +++ b/resource/src/resource-token.ts @@ -56,16 +56,30 @@ export interface ResourceTokenOptions { /** 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 + /** Space-separated scope values. REQUIRED unless `connectionOnly` is set: + * `scope` present means the PS will issue an auth token, so 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 on a token + * that authorizes anything. */ + scope?: string + /** + * A connection-only resource token (the answer to `POST /connections`): it + * carries `interaction_code` and NO `scope`, so the PS drives the resource's + * interaction and terminates with `connection_established` instead of issuing + * an auth token. Requires `interactionCode`; forbids `scope` and `r3`. + */ + connectionOnly?: boolean /** 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 } + /** + * The resource's own user-facing flow, needed before the PS can issue. Emitted + * as the flat `interaction_code` claim; the PS composes the URL from the + * resource's published `interaction_endpoint` (`{interaction_endpoint}?code=…`). + * The nested `interaction: { url, code }` claim of 2.x is gone. + */ + interactionCode?: string /** R3: both are REQUIRED together when either is present. */ r3?: { uri: string; s256: string } /** Seconds. Default 300. */ @@ -146,8 +160,9 @@ export async function createResourceToken( audience, agentJkt, scope, + connectionOnly = false, account, - interaction, + interactionCode, r3, lifetime = DEFAULT_RESOURCE_TOKEN_LIFETIME, missionExpiresAt, @@ -169,9 +184,31 @@ export async function createResourceToken( if (typeof agentJkt !== 'string' || !agentJkt) { throw new AAuthTokenError('invalid_agent_jkt', 'agentJkt is REQUIRED') } - if (typeof scope !== 'string' || !scope) { + if (connectionOnly) { + if (scope !== undefined) { + throw new AAuthTokenError( + 'invalid_scope', + 'A connection-only resource token carries no scope — scope present means the PS will issue an auth token', + ) + } + if (typeof interactionCode !== 'string' || !interactionCode) { + throw new AAuthTokenError( + 'interaction_code_required', + 'A connection-only resource token needs an interactionCode — it exists only to drive the resource interaction', + ) + } + if (r3) { + throw new AAuthTokenError( + 'invalid_r3_reference', + 'A connection-only resource token authorizes no operations and carries no R3 reference', + ) + } + } else if (typeof scope !== 'string' || !scope) { throw new AAuthTokenError('invalid_scope', 'scope is a REQUIRED resource token claim') } + if (interactionCode !== undefined && (typeof interactionCode !== 'string' || !interactionCode)) { + throw new AAuthTokenError('invalid_interaction_code', 'interactionCode must be a non-empty string') + } if (r3 && (!r3.uri || !r3.s256)) { throw new AAuthTokenError( 'invalid_r3_reference', @@ -206,9 +243,12 @@ export async function createResourceToken( agent_jkt: agentJkt, iat: now, exp, - scope, } + // Absent on a connection-only token: the PS reads "no scope" as "issue + // nothing, terminate with connection_established". + if (scope !== undefined) payload.scope = scope + if (account !== undefined) payload.account = account // REQUIRED when the person token carried one, copied unchanged. A resource @@ -219,7 +259,7 @@ export async function createResourceToken( const tenant = options.tenant ?? person.tenant if (tenant) payload.tenant = tenant - if (interaction) payload.interaction = { url: interaction.url, code: interaction.code } + if (interactionCode !== undefined) payload.interaction_code = interactionCode if (r3) { payload.r3_uri = r3.uri