Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"directory": "agent"
},
"dependencies": {
"@aauth/protocol": "^1.0.0",
"@aauth/protocol": "^2.0.0",
"@hellocoop/httpsig": "^2.4.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion fetch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion mcp-openclaw/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion protocol/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
22 changes: 17 additions & 5 deletions protocol/src/requirement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
18 changes: 12 additions & 6 deletions protocol/src/requirement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
29 changes: 23 additions & 6 deletions resource/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 })
```

Expand Down
4 changes: 2 additions & 2 deletions resource/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions resource/src/challenge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 5 additions & 2 deletions resource/src/challenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions resource/src/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 9 additions & 4 deletions resource/src/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ export interface PendingRequest<T = unknown> {

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
}
Expand All @@ -32,13 +37,13 @@ const DEFAULT_TTL = 600
export class InteractionManager {
private pending = new Map<string, PendingRequest>()
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
}
Expand Down Expand Up @@ -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 }
Expand Down
Loading