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
4 changes: 4 additions & 0 deletions agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ const { authToken, expiresIn } = await exchangeToken({
})
```

`presentedToken` is REQUIRED (AAuth -11, issue #152): the token the agent presented to the resource that issued the resource token — the person token on the first challenge of a grant, or the auth token on a step-up or per-call challenge. The resource token's `presented_jti` names it; `exchangeToken` checks that binding before sending, and the PS verifies the token against the resource token (and, in four-party access, passes it to the AS). Its `exp` bounds the auth token issued. `createAAuthFetch` supplies it automatically: the person token it presented, or the cached auth token that drew a step-up challenge.

A `clock_skew` refusal (AAuth -11 §Expiry and the Refresh Margin) means the presented token's `iat` is further ahead of the server's clock than its window. A fresh token from the same issuer carries the same skew, so do not refresh: `TokenExchangeError.retryAfterSeconds`, computed from the server's `Date` header, says how long to wait before presenting the same token again. `createAAuthFetch` returns such a `401` from a resource unchanged and keeps its cached token.

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)`
Expand Down
2 changes: 1 addition & 1 deletion agent/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@aauth/agent",
"version": "3.0.2",
"version": "4.0.0",
"description": "Agent-side AAuth protocol library — HTTP Signatures, person tokens, token exchange, deferred polling",
"type": "module",
"exports": {
Expand Down
128 changes: 123 additions & 5 deletions agent/src/aauth-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,15 @@ describe('createAAuthFetch', () => {
})

it('handles 401 AAuth-Requirement challenge → token exchange → retry', async () => {
// First request → 401 with AAuth-Requirement challenge
// -11: the resource challenges for a person token first, and only a
// request carrying one draws the auth-token challenge — the resource token
// names what the agent presented.
mockHttpSigFetch.mockResolvedValueOnce(new Response('', {
status: 401,
headers: { 'aauth-requirement': 'requirement=person-token' },
}))
mockPersonTokenGet.mockResolvedValueOnce('eyJ.person.token')
// Person-token request → 401 with AAuth-Requirement challenge
const challengeResponse = new Response('unauthorized', {
status: 401,
headers: {
Expand Down Expand Up @@ -115,12 +123,14 @@ describe('createAAuthFetch', () => {
expect(mockExchangeToken).toHaveBeenCalledWith(expect.objectContaining({
authServerUrl: 'https://auth.example',
resourceToken: 'rt123',
// the token the agent presented to the resource, named by presented_jti
presentedToken: 'eyJ.person.token',
justification: 'read files',
}))

// Verify retry used the auth token in signatureKey
expect(mockHttpSigFetch).toHaveBeenCalledTimes(2)
const retryCall = mockHttpSigFetch.mock.calls[1]
expect(mockHttpSigFetch).toHaveBeenCalledTimes(3)
const retryCall = mockHttpSigFetch.mock.calls[2]
expect(retryCall[1].signatureKey).toEqual({ type: 'jwt', jwt: 'eyJ.auth.token' })

// The minted auth token is surfaced for reuse (fetch --with-token / export).
Expand Down Expand Up @@ -167,6 +177,14 @@ describe('createAAuthFetch', () => {
})

it('caches auth token and reuses on second request', async () => {
// -11: the resource challenges for a person token first, and only a
// request carrying one draws the auth-token challenge — the resource token
// names what the agent presented.
mockHttpSigFetch.mockResolvedValueOnce(new Response('', {
status: 401,
headers: { 'aauth-requirement': 'requirement=person-token' },
}))
mockPersonTokenGet.mockResolvedValueOnce('eyJ.person.token')
// First request: 401 challenge → exchange → retry → 200
mockHttpSigFetch.mockResolvedValueOnce(new Response('', {
status: 401,
Expand Down Expand Up @@ -196,8 +214,8 @@ describe('createAAuthFetch', () => {
// No additional exchange call
expect(mockExchangeToken).toHaveBeenCalledOnce()
// But the second request used the cached auth token
expect(mockHttpSigFetch).toHaveBeenCalledTimes(3)
const cachedCall = mockHttpSigFetch.mock.calls[2]
expect(mockHttpSigFetch).toHaveBeenCalledTimes(4)
const cachedCall = mockHttpSigFetch.mock.calls[3]
expect(cachedCall[1].signatureKey).toEqual({ type: 'jwt', jwt: 'eyJ.cached.token' })
})

Expand Down Expand Up @@ -293,6 +311,14 @@ describe('createAAuthFetch', () => {
})

it('passes enterprise hints to token exchange', async () => {
// -11: the resource challenges for a person token first, and only a
// request carrying one draws the auth-token challenge — the resource token
// names what the agent presented.
mockHttpSigFetch.mockResolvedValueOnce(new Response('', {
status: 401,
headers: { 'aauth-requirement': 'requirement=person-token' },
}))
mockPersonTokenGet.mockResolvedValueOnce('eyJ.person.token')
mockHttpSigFetch.mockResolvedValueOnce(new Response('', {
status: 401,
headers: {
Expand Down Expand Up @@ -397,6 +423,7 @@ describe('createAAuthFetch', () => {
expect(result).toBe(okResponse)
expect(mockExchangeToken).toHaveBeenCalledWith(expect.objectContaining({
resourceToken: 'rt-with-mission',
presentedToken: 'pt',
}))
expect(mockHttpSigFetch.mock.calls[2][1].signatureKey)
.toEqual({ type: 'jwt', jwt: 'at' })
Expand All @@ -418,8 +445,99 @@ describe('createAAuthFetch', () => {
})
})

describe('presented tokens (AAuth -11, issue #152)', () => {
it('refuses an auth-token challenge on a request that presented nothing', async () => {
// A resource MUST NOT issue this challenge to a request carrying neither
// a person token nor an auth token: it has nothing to name.
mockHttpSigFetch.mockResolvedValueOnce(new Response('', {
status: 401,
headers: { 'aauth-requirement': 'requirement=auth-token; resource-token="rt"' },
}))
const fetch = createAAuthFetch({ getKeyMaterial, personServerUrl: 'https://ps.example' })
await expect(fetch('https://resource.example/api')).rejects.toThrow(/presented no person token or auth token/)
expect(mockExchangeToken).not.toHaveBeenCalled()
})

it('step-up: a cached auth token drawing requirement=auth-token is what the agent presents', async () => {
// First call: person token → resource token → auth token, cached.
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-1"' },
}))
mockExchangeToken.mockResolvedValueOnce({ authToken: 'at-1', expiresIn: 3600 })
mockHttpSigFetch.mockResolvedValueOnce(new Response('ok', { status: 200 }))
const fetch = createAAuthFetch({ getKeyMaterial, personServerUrl: 'https://ps.example' })
await fetch('https://resource.example/read')

// Second call presents the cached auth token; the resource wants more
// (a step-up) and names that auth token in a new resource token.
mockHttpSigFetch.mockResolvedValueOnce(new Response('', {
status: 401,
headers: { 'aauth-requirement': 'requirement=auth-token; resource-token="rt-2"' },
}))
mockExchangeToken.mockResolvedValueOnce({ authToken: 'at-2', expiresIn: 1800 })
const okResponse = new Response('written', { status: 200 })
mockHttpSigFetch.mockResolvedValueOnce(okResponse)
const result = await fetch('https://resource.example/write', { method: 'POST' })

expect(result).toBe(okResponse)
expect(mockExchangeToken).toHaveBeenLastCalledWith(expect.objectContaining({
resourceToken: 'rt-2',
presentedToken: 'at-1',
}))
// No new person token was requested for the step-up.
expect(mockPersonTokenGet).toHaveBeenCalledTimes(1)
// The retry carried the stepped-up token.
expect(lastCall().signatureKey).toEqual({ type: 'jwt', jwt: 'at-2' })
})

it('clock_skew on a cached auth token: returns the 401 and keeps the token (wait, do not refresh)', async () => {
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-1"' },
}))
mockExchangeToken.mockResolvedValueOnce({ authToken: 'at-1', expiresIn: 3600 })
mockHttpSigFetch.mockResolvedValueOnce(new Response('ok', { status: 200 }))
const fetch = createAAuthFetch({ getKeyMaterial, personServerUrl: 'https://ps.example' })
await fetch('https://resource.example/read')

const skewed = new Response('', {
status: 401,
headers: { 'signature-error': 'error=clock_skew' },
})
mockHttpSigFetch.mockResolvedValueOnce(skewed)
const result = await fetch('https://resource.example/read')
expect(result).toBe(skewed)
expect(mockExchangeToken).toHaveBeenCalledTimes(1)
expect(mockPersonTokenGet).toHaveBeenCalledTimes(1)

// The cached token is still presented next time.
mockHttpSigFetch.mockResolvedValueOnce(new Response('ok', { status: 200 }))
await fetch('https://resource.example/read')
expect(lastCall().signatureKey).toEqual({ type: 'jwt', jwt: 'at-1' })
})
})

describe('PS/AS body signing', () => {
it('hands token exchange a PS-flavoured signedFetch, and the resource one an unflavoured one', async () => {
// -11: the resource challenges for a person token first, and only a
// request carrying one draws the auth-token challenge — the resource token
// names what the agent presented.
mockHttpSigFetch.mockResolvedValueOnce(new Response('', {
status: 401,
headers: { 'aauth-requirement': 'requirement=person-token' },
}))
mockPersonTokenGet.mockResolvedValueOnce('eyJ.person.token')
mockHttpSigFetch.mockResolvedValueOnce(new Response('', {
status: 401,
headers: { 'aauth-requirement': 'requirement=auth-token; resource-token="rt"' },
Expand Down
Loading