diff --git a/agent/README.md b/agent/README.md index 7562354..145e16e 100644 --- a/agent/README.md +++ b/agent/README.md @@ -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)` diff --git a/agent/package.json b/agent/package.json index 1708be5..618480d 100644 --- a/agent/package.json +++ b/agent/package.json @@ -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": { diff --git a/agent/src/aauth-fetch.test.ts b/agent/src/aauth-fetch.test.ts index 8cdeb40..b812d7d 100644 --- a/agent/src/aauth-fetch.test.ts +++ b/agent/src/aauth-fetch.test.ts @@ -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: { @@ -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). @@ -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, @@ -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' }) }) @@ -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: { @@ -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' }) @@ -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"' }, diff --git a/agent/src/aauth-fetch.ts b/agent/src/aauth-fetch.ts index 98b9a55..fcfd503 100644 --- a/agent/src/aauth-fetch.ts +++ b/agent/src/aauth-fetch.ts @@ -143,23 +143,113 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { const urlStr = typeof url === 'string' ? url : url.toString() const resourceOrigin = new URL(urlStr).origin + // Send a resource token to the PS with the token the agent presented, + // cache the auth token, and retry the original request with it. + const exchangeAndRetry = async (resourceToken: string, presented: string): Promise => { + onEvent?.({ + step: 'challenge_received', + phase: 'info', + requirement: 'auth-token', + resourceToken: decodeJwtPayloadSafe(resourceToken), + }) + // The agent sends the resource token to its own auth server + const authServerUrl = configuredPersonServer + if (!authServerUrl) { + throw new Error('auth-token challenge received but no personServerUrl configured') + } + + const result = await exchangeToken({ + signedFetch: psSignedFetch, + authServerUrl, + authServerMetadata: personServerMetadata, + onMetadata, + resourceToken, + presentedToken: presented, + justification, + loginHint, + tenant, + domainHint, + capabilities, + prompt, + onInteraction, + onClarification, + onEvent, + maxPollDuration, + getKeyMaterial, + sentTracker, + }) + + // Cache the auth token + const key = cacheKey(resourceOrigin, authServerUrl) + tokenCache.set(key, { + authToken: result.authToken, + expiresAt: Date.now() + result.expiresIn * 1000, + authServer: authServerUrl, + }) + // Surface it as a reusable credential (e.g. `fetch --with-token`). + onAuthToken?.(result.authToken, result.expiresIn) + + // Retry with auth token + onEvent?.({ + step: 'retry_with_auth_token', + phase: 'start', + url: urlStr, + auth_token: decodeJwtPayloadSafe(result.authToken), + }) + const retryResponse = await fetchWithToken( + url, init, result.authToken, getKeyMaterial, onSigned, + ) + const retryBody = onEvent ? await peekResponseBody(retryResponse) : undefined + onEvent?.({ + step: 'retry_with_auth_token', + phase: 'done', + status: retryResponse.status, + request_headers: sentTracker.latest?.headers, + request_body: sentTracker.latest?.body, + response: { + headers: summarizeResponseHeaders(retryResponse.headers), + ...(retryBody !== undefined ? { body: retryBody } : {}), + }, + }) + cacheOpaqueToken(opaqueCache, resourceOrigin, retryResponse, onOpaqueToken) + return handleResourceInteraction(retryResponse, signedFetch, onInteraction, onClarification) + } + // Seed a provided AAuth-Access token (two-party reuse) so the first request // to this resource sends it. A token the resource later returns replaces it. if (seedOpaqueToken && !opaqueCache.has(resourceOrigin)) { opaqueCache.set(resourceOrigin, { token: seedOpaqueToken }) } + // The token this agent has presented to the resource on this call — the + // person token, or a cached auth token on a step-up. A resource token the + // resource issues names it, and the agent hands it to the PS as + // presented_token (AAuth -11, issue #152). + let presentedToken: string | undefined + // Check cache for a valid auth token for this resource const cached = findCachedToken(tokenCache, resourceOrigin) if (cached) { // Use cached auth token — sign with auth token instead of agent token 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) return handleResourceInteraction(response, signedFetch, onInteraction, onClarification) } - // Cached token rejected — remove and proceed with fresh exchange + // clock_skew: the resource's clock, not our token, is the problem. A + // fresh token carries the same skew, so keep what we have and let the + // caller see the 401 (§Expiry and the Refresh Margin). + if (isClockSkew(response)) return response + const stepUp = stepUpChallenge(response) + if (stepUp) { + // 401 requirement=auth-token on a request that carried a valid auth + // token: a step-up (more scope) or a per-call proposal. The resource + // token names the auth token we presented; present that to the PS. + presentedToken = cached.authToken + return await exchangeAndRetry(stepUp, presentedToken) + } + // Cached token rejected for another reason (expired, revoked) — drop it + // and start again from the person token. tokenCache.delete(cacheKey(resourceOrigin, cached.authServer)) } @@ -217,6 +307,7 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { person_token: decodeJwtPayloadSafe(personToken), }) response = await fetchWithToken(url, init, personToken, getKeyMaterial, onSigned) + presentedToken = personToken const retryBody = onEvent ? await peekResponseBody(response) : undefined onEvent?.({ step: 'retry_with_person_token', @@ -248,72 +339,16 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { const challenge = parseRequirementHeader(aauthHeader) if (challenge.requirement === 'auth-token' && challenge.resourceToken) { - onEvent?.({ - step: 'challenge_received', - phase: 'info', - requirement: 'auth-token', - resourceToken: decodeJwtPayloadSafe(challenge.resourceToken), - }) - // The agent sends the resource token to its own auth server - const authServerUrl = configuredPersonServer - if (!authServerUrl) { - throw new Error('auth-token challenge received but no personServerUrl configured') + if (!presentedToken) { + // §Requirement Responses: a resource MUST NOT issue this challenge + // to a request that carried neither a person token nor an auth + // token — it has nothing to name in presented_jti. Nothing this + // agent can send would satisfy the PS, so say so. + throw new Error( + 'auth-token challenge on a request that presented no person token or auth token; the resource must challenge with requirement=person-token first', + ) } - - const result = await exchangeToken({ - signedFetch: psSignedFetch, - authServerUrl, - authServerMetadata: personServerMetadata, - onMetadata, - resourceToken: challenge.resourceToken, - justification, - loginHint, - tenant, - domainHint, - capabilities, - prompt, - onInteraction, - onClarification, - onEvent, - maxPollDuration, - getKeyMaterial, - sentTracker, - }) - - // Cache the auth token - const key = cacheKey(resourceOrigin, authServerUrl) - tokenCache.set(key, { - authToken: result.authToken, - expiresAt: Date.now() + result.expiresIn * 1000, - authServer: authServerUrl, - }) - // Surface it as a reusable credential (e.g. `fetch --with-token`). - onAuthToken?.(result.authToken, result.expiresIn) - - // Retry with auth token - onEvent?.({ - step: 'retry_with_auth_token', - phase: 'start', - url: urlStr, - auth_token: decodeJwtPayloadSafe(result.authToken), - }) - const retryResponse = await fetchWithToken( - url, init, result.authToken, getKeyMaterial, onSigned, - ) - const retryBody = onEvent ? await peekResponseBody(retryResponse) : undefined - onEvent?.({ - step: 'retry_with_auth_token', - phase: 'done', - status: retryResponse.status, - request_headers: sentTracker.latest?.headers, - request_body: sentTracker.latest?.body, - response: { - headers: summarizeResponseHeaders(retryResponse.headers), - ...(retryBody !== undefined ? { body: retryBody } : {}), - }, - }) - cacheOpaqueToken(opaqueCache, resourceOrigin, retryResponse, onOpaqueToken) - return handleResourceInteraction(retryResponse, signedFetch, onInteraction, onClarification) + return await exchangeAndRetry(challenge.resourceToken, presentedToken) } // non-auth-token challenges (approval, clarification, claims) don't require token exchange @@ -463,6 +498,29 @@ function cacheOpaqueToken( } } +/** A 401 whose Signature-Error names clock_skew: wait, do not refresh. */ +function isClockSkew(response: Response): boolean { + const header = response.headers.get('signature-error') ?? '' + return /(^|[;,\s])error=clock_skew(\s|;|,|$)/.test(header) +} + +/** + * The resource token from a `requirement=auth-token` challenge, when the + * request that drew it carried a valid auth token — a step-up or per-call + * proposal. Undefined for any other 401. + */ +function stepUpChallenge(response: Response): string | undefined { + const header = response.headers.get('aauth-requirement') + if (!header) return undefined + try { + const challenge = parseRequirementHeader(header) + if (challenge.requirement === 'auth-token' && challenge.resourceToken) return challenge.resourceToken + } catch { + // not a parseable challenge + } + return undefined +} + function cacheKey(resourceOrigin: string, authServer: string): string { return `${resourceOrigin}|${authServer}` } diff --git a/agent/src/token-exchange.test.ts b/agent/src/token-exchange.test.ts index 53458eb..5def203 100644 --- a/agent/src/token-exchange.test.ts +++ b/agent/src/token-exchange.test.ts @@ -44,6 +44,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'eyJ.resource.token', + presentedToken: 'eyJ.presented', justification: 'access files', }) @@ -74,6 +75,7 @@ describe('exchangeToken', () => { const body = JSON.parse(mockFetch.mock.calls[1][1].body) expect(body).toEqual({ resource_token: 'eyJ.resource.token', + presented_token: 'eyJ.presented', justification: 'access files', }) }) @@ -90,6 +92,7 @@ describe('exchangeToken', () => { authServerUrl: 'https://auth.example', authServerMetadata: metadata, resourceToken: 'eyJ.resource.token', + presentedToken: 'eyJ.presented', }) expect(result).toEqual({ authToken: 'eyJ.auth.token', expiresIn: 3600 }) @@ -113,6 +116,7 @@ describe('exchangeToken', () => { authServerUrl: 'https://auth.example', authServerMetadata: metadata, resourceToken: 'rt', + presentedToken: 'eyJ.presented', onEvent, onMetadata, }) @@ -134,6 +138,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'rt', + presentedToken: 'eyJ.presented', onMetadata, }) @@ -174,6 +179,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'eyJ.resource.token', + presentedToken: 'eyJ.presented', onInteraction, }) @@ -203,6 +209,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'rt', + presentedToken: 'eyJ.presented', justification: 'read logs', loginHint: 'user@acme.com', tenant: 'acme.com', @@ -213,6 +220,7 @@ describe('exchangeToken', () => { const body = JSON.parse(mockFetch.mock.calls[1][1].body) expect(body).toEqual({ resource_token: 'rt', + presented_token: 'eyJ.presented', justification: 'read logs', login_hint: 'user@acme.com', tenant: 'acme.com', @@ -234,6 +242,7 @@ describe('exchangeToken', () => { // 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', + presentedToken: 'eyJ.presented', justification: 'book the flights', }) @@ -241,6 +250,7 @@ describe('exchangeToken', () => { expect(body).not.toHaveProperty('mission_s256') expect(body).toEqual({ resource_token: 'eyJ.resource.token.with.mission_s256', + presented_token: 'eyJ.presented', justification: 'book the flights', }) }) @@ -252,6 +262,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'rt', + presentedToken: 'eyJ.presented', })).rejects.toThrow('Failed to fetch auth server metadata: 404') }) @@ -264,6 +275,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'rt', + presentedToken: 'eyJ.presented', })).rejects.toThrow('Auth server metadata missing auth_token_endpoint') }) @@ -279,6 +291,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'rt', + presentedToken: 'eyJ.presented', })).rejects.toThrow('missing person_token_endpoint') }) @@ -290,6 +303,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'rt', + presentedToken: 'eyJ.presented', })).rejects.toThrow('Token exchange failed with status 500') }) @@ -301,6 +315,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'rt', + presentedToken: 'eyJ.presented', })).rejects.toThrow('202 response missing Location header') }) @@ -318,6 +333,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'rt', + presentedToken: 'eyJ.presented', })).rejects.toThrow('Token exchange failed with status 403') }) @@ -336,6 +352,7 @@ describe('exchangeToken', () => { signedFetch: mockFetch, authServerUrl: 'https://auth.example', resourceToken: 'rt', + presentedToken: 'eyJ.presented', }) await expect(promise).rejects.toBeInstanceOf(TokenExchangeError) try { @@ -347,4 +364,78 @@ describe('exchangeToken', () => { expect(texErr.message).toBe('User denied the request') } }) + describe('presented_token (AAuth -11, issue #152)', () => { + const b64 = (o: Record) => Buffer.from(JSON.stringify(o)).toString('base64url') + const jwt = (payload: Record) => `${b64({ alg: 'Ed25519' })}.${b64(payload)}.sig` + + it('is REQUIRED', async () => { + await expect(exchangeToken({ + signedFetch: mockFetch, + authServerUrl: 'https://auth.example', + resourceToken: 'rt', + } as never)).rejects.toThrow(/presentedToken/) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('refuses a resource token whose presented_jti does not name the presented token', async () => { + // §Resource Token Verification (agent side) step 5 — the resource named + // some other token; the PS would say invalid_resource_token, and the + // agent can say so without the round trip. + const resourceToken = jwt({ iss: 'https://rs.example', presented_jti: 'pt-other' }) + const presentedToken = jwt({ iss: 'https://ps.example', jti: 'pt-mine' }) + await expect(exchangeToken({ + signedFetch: mockFetch, + authServerUrl: 'https://auth.example', + resourceToken, + presentedToken, + })).rejects.toThrow(/presented_jti "pt-other" does not name the token the agent presented/) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('sends a resource token that names the presented 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 })) + const resourceToken = jwt({ iss: 'https://rs.example', presented_jti: 'pt-mine' }) + const presentedToken = jwt({ iss: 'https://ps.example', jti: 'pt-mine' }) + await exchangeToken({ + signedFetch: mockFetch, + authServerUrl: 'https://auth.example', + resourceToken, + presentedToken, + }) + const body = JSON.parse(mockFetch.mock.calls[1][1].body) + expect(body.presented_token).toBe(presentedToken) + }) + + it('clock_skew: reports how long to wait, from the server Date header', async () => { + // §Expiry and the Refresh Margin: the presented token's iat is ahead of + // the server's clock by more than its window. Refreshing does not help + // — a fresh token carries the same skew — so the error says how long + // to wait before presenting the same token again. + const serverNow = 1_700_000_000 + const presentedToken = jwt({ iss: 'https://ps.example', jti: 'pt-1', iat: serverNow + 150 }) + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(metadata), { status: 200 })) + mockFetch.mockResolvedValueOnce(new Response( + JSON.stringify({ error: 'clock_skew', detail: 'presented_token iat is 150s ahead' }), + { + status: 400, + headers: { + 'Content-Type': 'application/problem+json', + Date: new Date(serverNow * 1000).toUTCString(), + }, + }, + )) + const failure = await exchangeToken({ + signedFetch: mockFetch, + authServerUrl: 'https://auth.example', + resourceToken: 'rt', + presentedToken, + }).catch((e: unknown) => e) + expect(failure).toBeInstanceOf(TokenExchangeError) + expect((failure as TokenExchangeError).error).toBe('clock_skew') + // 150 s ahead, 60 s window → wait 90 s. + expect((failure as TokenExchangeError).retryAfterSeconds).toBe(90) + }) + }) + }) diff --git a/agent/src/token-exchange.ts b/agent/src/token-exchange.ts index 6ff277a..eb0c68b 100644 --- a/agent/src/token-exchange.ts +++ b/agent/src/token-exchange.ts @@ -1,7 +1,7 @@ import type { FetchLike, GetKeyMaterial, OnEvent, CapturedSent } from './types.js' import { pollDeferred, parseErrorBody, describeAAuthError } from './deferred.js' import type { AAuthError } from './deferred.js' -import { parseRequirementHeader } from '@aauth/protocol' +import { parseRequirementHeader, decodeJwtPayload } from '@aauth/protocol' import { summarizeResponseHeaders, decodeSignatureKey, peekResponseBody, decodeJwtPayloadSafe } from './log-helpers.js' export class TokenExchangeError extends Error { @@ -23,6 +23,16 @@ export class TokenExchangeError extends Error { this.error = aauthError?.error this.detail = aauthError?.detail ?? aauthError?.error_description } + + /** + * Set when `error` is `clock_skew` (AAuth -11 §Expiry and the Refresh + * Margin): 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; wait this many seconds and present the same token + * again. Computed from the server's `Date` header; `undefined` when the + * server sent none. + */ + retryAfterSeconds?: number } export interface TokenExchangeOptions { @@ -33,6 +43,15 @@ export interface TokenExchangeOptions { /** Called with freshly-fetched metadata (only when authServerMetadata wasn't provided) so callers can persist it. */ onMetadata?: (metadata: PersonServerMetadata) => void resourceToken: string + /** + * REQUIRED (AAuth -11, issue #152): the token this agent presented to the + * resource that issued `resourceToken` — 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; the PS verifies + * it against the resource token and, in four-party access, passes it to + * the AS. Its `exp` bounds the auth token issued. + */ + presentedToken: string justification?: string localhostCallback?: string loginHint?: string @@ -105,10 +124,18 @@ 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 auth_token_endpoint with resource_token + hints, Prefer: wait=45 + * 2. POSTs to auth_token_endpoint with resource_token, presented_token and + * hints, Prefer: wait=45 * 3. If 200: returns tokens directly * 4. If 202: polls via pollDeferred until terminal response * + * `presented_token` is the token the agent presented to the resource — the + * person token, or on a step-up the auth token — which the resource token's + * `presented_jti` names (AAuth -11, issue #152). Before the POST the agent + * checks that binding itself (§Resource Token Verification by the agent, step + * 5): a resource token naming some other token is refused here rather than + * sent. + * * `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 @@ -131,6 +158,16 @@ export async function exchangeToken(options: TokenExchangeOptions): Promise = { resource_token: resourceToken, + presented_token: presentedToken, } if (justification) body.justification = justification if (localhostCallback) body.localhost_callback = localhostCallback @@ -254,9 +292,58 @@ export async function exchangeToken(options: TokenExchangeOptions): Promise + let pt: Record + try { + rt = decodeJwtPayload(resourceToken) + pt = decodeJwtPayload(presentedToken) + } catch { + return // not decodable here: let the PS say what is wrong with it + } + const named = rt.presented_jti ?? rt.person_token_jti + if (typeof named !== 'string' || typeof pt.jti !== 'string') return + if (named !== pt.jti) { + throw new Error( + `resource token presented_jti "${named}" does not name the token the agent presented (jti "${pt.jti}"); the resource must name the token it verified`, + ) + } +} + +/** + * How long to wait before presenting the same token again after `clock_skew`: + * the presented token's `iat` minus the server's clock (its `Date` header) + * minus the 60 s window the server allows. The server's clock is the one that + * refused, so it is the one to measure against. + */ +function clockSkewWait(presentedToken: string, dateHeader: string | null): number | undefined { + if (!dateHeader) return undefined + const serverNow = Date.parse(dateHeader) + if (Number.isNaN(serverNow)) return undefined + let iat: unknown + try { + iat = decodeJwtPayload(presentedToken).iat + } catch { + return undefined + } + if (typeof iat !== 'number') return undefined + return Math.max(1, iat - Math.floor(serverNow / 1000) - 60) } export interface PersonServerMetadataOptions { diff --git a/e2e/aauth-protocol.test.ts b/e2e/aauth-protocol.test.ts index dc88fb9..6ba08f4 100644 --- a/e2e/aauth-protocol.test.ts +++ b/e2e/aauth-protocol.test.ts @@ -31,10 +31,11 @@ * **`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. + * **The presented token is verified, not looked up (issue #152, mockin 3.0).** + * The agent sends the token it presented to the resource as `presented_token`; + * mockin verifies it under its own key, checks `aud` against the resource + * token's `iss` and `cnf.jwk` against `agent_jkt`, and compares the copied + * claims. There is no jti store on the verification path any more. * * **One person only.** `login_hint`, `prompt` and `domain_hint` are validated * and recorded but select nothing, so nothing here tests choosing between @@ -171,11 +172,18 @@ async function getResourceToken(personToken: string): Promise { return resourceTokenFrom(challenged.headers) } -async function getAuthToken(resourceToken: string): Promise { +/** + * Redeem a resource token at the PS. `presentedToken` is what the agent + * presented to the resource that issued it — the person token, or on a + * step-up the auth token — which the resource token's `presented_jti` names + * and the PS verifies against it (AAuth -11, issue #152). + */ +async function getAuthToken(resourceToken: string, presentedToken: string): Promise { const { authToken } = await exchangeToken({ signedFetch: agent.psFetch, authServerUrl: PS, resourceToken, + presentedToken, }) return authToken } @@ -187,9 +195,9 @@ async function getAuthToken(resourceToken: string): Promise { * error code and explanation the PS sent are on the thrown * `TokenExchangeError` — no wire-reading helper needed. */ -async function redeemExpectingRefusal(resourceToken: string): Promise { +async function redeemExpectingRefusal(resourceToken: string, presentedToken: string): Promise { try { - await getAuthToken(resourceToken) + await getAuthToken(resourceToken, presentedToken) } catch (err) { if (err instanceof TokenExchangeError) return err throw err @@ -224,7 +232,7 @@ async function walkTheChain( ): Promise { const personToken = await getPersonToken(options) const resourceToken = await getResourceToken(personToken) - const authToken = await getAuthToken(resourceToken) + const authToken = await getAuthToken(resourceToken, personToken) return { personToken, resourceToken, authToken } } @@ -287,7 +295,7 @@ describe('the three-party flow, end to end', () => { 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 authToken = await getAuthToken(resourceToken, personToken) const at = claimsOf(authToken) expect(headerOf(authToken)).toMatchObject({ typ: TOKEN_TYP.auth, alg: SIGNING_ALG }) expect(at).toMatchObject({ @@ -329,17 +337,30 @@ describe('the three-party flow, end to end', () => { } }, 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. + it('rejects a resource token whose presented_jti does not name the token the agent presented', async () => { + // Step 6 of §Resource Token Verification (issue #152): the PS verifies + // the presented token and checks the resource token names it by jti. A + // resource that names anything else is caught — and @aauth/agent catches + // it first, before the round trip, so this goes to the wire raw. const personToken = await getPersonToken() resource.mint = { forgePresentedJti: '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/) + await expect(exchangeToken({ + signedFetch: agent.psFetch, + authServerUrl: PS, + resourceToken, + presentedToken: personToken, + })).rejects.toThrow(/does not name the token the agent presented/) + + const res = await psPost('auth_token_endpoint', { + resource_token: resourceToken, + presented_token: personToken, + }) + expect(res.status).toBe(400) + const body = await res.json() as { error: string; detail: string } + expect(body.error).toBe('invalid_resource_token') + expect(body.detail).toMatch(/does not name the presented token/) }, 30_000) }) @@ -435,7 +456,7 @@ describe('deferred person tokens (202)', () => { // 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 authToken = await getAuthToken(resourceToken, personToken) const answered = await callResource(agent.presenting(authToken)) expect(answered.status).toBe(200) }, 60_000) @@ -454,6 +475,7 @@ describe('deferred person tokens (202)', () => { signedFetch: agent.psFetch, authServerUrl: PS, resourceToken, + presentedToken: personToken, onInteraction: (url, code) => { sawInteraction = true void mockin.consent(code, url) @@ -505,13 +527,13 @@ describe('mission_s256', () => { const resourceToken = await getResourceToken(personToken) expect(claimsOf(resourceToken).mission_s256).toBeUndefined() - const refused = await redeemExpectingRefusal(resourceToken) + const refused = await redeemExpectingRefusal(resourceToken, personToken) 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\)/) + .toMatch(/mission_s256 mismatch: presented token has .+, resource_token has \(none\)/) }, 30_000) it('rejects a resource token that invented a mission the person token did not carry', async () => { @@ -522,10 +544,10 @@ describe('mission_s256', () => { const resourceToken = await getResourceToken(personToken) expect(claimsOf(resourceToken).mission_s256).toBe(MISSION) - const refused = await redeemExpectingRefusal(resourceToken) + const refused = await redeemExpectingRefusal(resourceToken, personToken) expect(refused.status).toBe(400) expect(refused.detail) - .toMatch(/mission_s256 mismatch: person token has \(none\), resource_token has /) + .toMatch(/mission_s256 mismatch: presented token has \(none\), resource_token has /) }, 30_000) }) @@ -574,11 +596,11 @@ describe('tenant', () => { const resourceToken = await getResourceToken(personToken) expect(claimsOf(resourceToken).tenant).toBeUndefined() - const refused = await redeemExpectingRefusal(resourceToken) + const refused = await redeemExpectingRefusal(resourceToken, personToken) 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\)/) + .toMatch(/tenant mismatch: presented token has acme-corp, resource_token has \(none\)/) }, 30_000) it('rejects a resource token that changed the tenant', async () => { @@ -587,10 +609,10 @@ describe('tenant', () => { const resourceToken = await getResourceToken(personToken) expect(claimsOf(resourceToken).tenant).toBe('other-corp') - const refused = await redeemExpectingRefusal(resourceToken) + const refused = await redeemExpectingRefusal(resourceToken, personToken) expect(refused.status).toBe(400) expect(refused.detail) - .toMatch(/tenant mismatch: person token has acme-corp, resource_token has other-corp/) + .toMatch(/tenant mismatch: presented token has acme-corp, resource_token has other-corp/) }, 30_000) }) @@ -765,7 +787,7 @@ describe('signature algorithms', () => { const resourceToken = await getResourceToken(personToken) expect(headerOf(resourceToken).alg).toBe('EdDSA') - const refused = await redeemExpectingRefusal(resourceToken) + const refused = await redeemExpectingRefusal(resourceToken, personToken) expect(refused.status).toBe(400) expect(refused.error).toBe('invalid_resource_token') expect(refused.detail).toMatch(/EdDSA/) @@ -945,7 +967,7 @@ describe('R3', () => { // The PS has not seen the document yet. expect(resource.r3Served).toHaveLength(0) - const authToken = await getAuthToken(authorized.resource_token) + const authToken = await getAuthToken(authorized.resource_token, personToken) // It fetched it — over a signed request it had to be entitled to make. expect(resource.r3Served).toHaveLength(1) @@ -970,7 +992,7 @@ describe('R3', () => { const authorized = await authorize(personToken, 'work@example.com') expect(claimsOf(authorized.resource_token).account).toBe('work@example.com') - await getAuthToken(authorized.resource_token) + await getAuthToken(authorized.resource_token, personToken) const served = JSON.parse(resource.r3Served[0]) as { account?: string } expect(served.account).toBe('work@example.com') }, 30_000) @@ -986,7 +1008,7 @@ describe('R3', () => { resource.signingKey, { typ: TOKEN_TYP.resource }, claims, ) - const refused = await redeemExpectingRefusal(half) + const refused = await redeemExpectingRefusal(half, personToken) expect(refused.error).toBe('invalid_resource_token') expect(refused.detail).toMatch(/both r3_uri and r3_s256 or neither/) }, 30_000) @@ -1005,11 +1027,11 @@ describe('R3', () => { // 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) + await getAuthToken(authorized.resource_token, personToken) 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) + await getAuthToken(second.resource_token, personToken) expect(resource.r3Served).toHaveLength(2) expect(resource.r3Served[0]).toBe(resource.r3Served[1]) @@ -1026,7 +1048,7 @@ describe('R3', () => { 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) + const refused = await redeemExpectingRefusal(authorized.resource_token, personToken) expect(refused.error).toBe('invalid_resource_token') expect(refused.detail).toMatch(/r3_s256 mismatch/) }, 30_000) @@ -1093,7 +1115,7 @@ describe('R3', () => { per_call: { vocabulary: R3_VOCABULARY, operations: [PER_CALL_OPERATION] }, }, }) - const classToken = await getAuthToken(authorized.resource_token) + const classToken = await getAuthToken(authorized.resource_token, personToken) expect(claimsOf(classToken).r3_per_call) .toEqual({ vocabulary: R3_VOCABULARY, operations: [PER_CALL_OPERATION] }) @@ -1104,7 +1126,7 @@ describe('R3', () => { // The person approves this specific call. await mockin.configure({ r3_grants: null }) - const perCallToken = await getAuthToken(proposalToken) + const perCallToken = await getAuthToken(proposalToken, classToken) return { authorized, classToken, proposalToken, perCallToken } } diff --git a/e2e/helpers.ts b/e2e/helpers.ts index e223de3..17e3d4c 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -589,14 +589,6 @@ export async function startResource(options: ResourceOptions): Promise ({ mockExchangeToken: vi.fn(), })) +// -11: `authorize` gets a person token first when a person server is +// configured, and presents it to the resource. The token is opaque here. +const { mockRequestPersonToken } = vi.hoisted(() => ({ + mockRequestPersonToken: vi.fn(async () => ({ personToken: 'eyJ.person.token', expiresIn: 3600 })), +})) + const { mockParseRequirementHeader } = vi.hoisted(() => ({ mockParseRequirementHeader: vi.fn(), })) @@ -40,6 +46,7 @@ vi.mock('@aauth/agent', () => ({ createSignedFetch: mockCreateSignedFetch, createAAuthFetch: mockCreateAAuthFetch, exchangeToken: mockExchangeToken, + requestPersonToken: mockRequestPersonToken, TokenExchangeError: FakeTokenExchangeError, })) @@ -814,6 +821,12 @@ describe('handleAuthorize', () => { expect(mockExchangeToken).toHaveBeenCalledWith(expect.objectContaining({ authServerUrl: 'https://ps.example.com', resourceToken: 'rt123', + // the person token the resource was shown (AAuth -11, issue #152) + presentedToken: 'eyJ.person.token', + })) + expect(mockRequestPersonToken).toHaveBeenCalledWith(expect.objectContaining({ + personServerUrl: 'https://ps.example.com', + resource: 'https://resource.example', })) }) diff --git a/fetch/src/handlers.ts b/fetch/src/handlers.ts index 8f52fe8..eadc4de 100644 --- a/fetch/src/handlers.ts +++ b/fetch/src/handlers.ts @@ -11,6 +11,7 @@ import { createSignedFetch, exchangeToken, TokenExchangeError, + requestPersonToken, } from '@aauth/agent' import type { GetKeyMaterial, OnEvent, CapturedSent, PersonServerMetadata } from '@aauth/agent' // The challenge parser and the capability vocabulary moved to `@aauth/protocol` @@ -373,6 +374,39 @@ export async function handleAuthorize( let resourceToken: string | undefined + // -11: a resource issues a resource token only to a request that carried a + // person token (or an auth token), and names it in `presented_jti`; the + // agent then hands the same token to its PS as `presented_token`. So with a + // person server configured, get the person token first and present it on + // the authorize / challenge call. Without one this is two-party access on + // the agent token alone, and no auth-token challenge can be answered. + let personToken: string | undefined + let presentingFetch = signedFetch + if (personServer) { + const resourceOrigin = new URL(args.url).origin + const issued = await requestPersonToken({ + signedFetch, + personServerUrl: personServer, + personServerMetadata, + onMetadata, + resource: resourceOrigin, + justification: args.justification, + loginHint: args.loginHint, + tenant: args.tenant, + domainHint: args.domainHint, + capabilities: capabilities as string[], + onInteraction: makeOnInteraction(args), + onEvent, + }) + personToken = issued.personToken + // The same signing key, presenting the person token instead of the agent + // token, for the calls to the resource. + presentingFetch = createSignedFetch( + async () => ({ signingKey: keyMaterial.signingKey, signatureKey: { type: 'jwt', jwt: personToken! } }), + { capabilities, ...(onEvent ? { onSigned: (s: CapturedSent) => { sent.latest = s } } : {}) }, + ) + } + 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, @@ -391,7 +425,7 @@ export async function handleAuthorize( ...(args.account ? { account: args.account } : {}), } onEvent?.({ step: 'r3_authorize_request', phase: 'start', url: args.url, method: 'POST' }) - const response = await signedFetch(args.url, { + const response = await presentingFetch(args.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(r3Body), @@ -408,7 +442,7 @@ export async function handleAuthorize( const url = new URL(args.url) if (args.scope) url.searchParams.set('scope', args.scope) onEvent?.({ step: 'signed_request', phase: 'start', url: url.toString(), method: 'GET' }) - const response = await signedFetch(url.toString(), { method: 'GET' }) + const response = await presentingFetch(url.toString(), { method: 'GET' }) 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) }) if (response.status === 200) { @@ -447,7 +481,7 @@ export async function handleAuthorize( resourceToken = challenge.resourceToken } - if (!personServer) { + if (!personServer || !personToken) { return fail('Person server URL required for token exchange. Set in config or use --person-server.') } @@ -457,6 +491,7 @@ export async function handleAuthorize( authServerMetadata: personServerMetadata, onMetadata, resourceToken, + presentedToken: personToken, justification: args.justification, loginHint: args.loginHint, tenant: args.tenant, diff --git a/mcp-openclaw/package.json b/mcp-openclaw/package.json index 2daba64..168a30d 100644 --- a/mcp-openclaw/package.json +++ b/mcp-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/mcp-openclaw", - "version": "3.0.0", + "version": "3.1.0", "description": "OpenClaw plugin for AAuth-authenticated MCP server connections", "type": "module", "exports": { @@ -34,7 +34,7 @@ "directory": "mcp-openclaw" }, "dependencies": { - "@aauth/agent": "^3.0.0", + "@aauth/agent": "^4.0.0", "@aauth/protocol": "^1.0.0", "@aauth/local-keys": "^2.0.0", "@modelcontextprotocol/sdk": "^1.15.1" diff --git a/mcp-stdio/package.json b/mcp-stdio/package.json index 08d9014..e9c1bd2 100644 --- a/mcp-stdio/package.json +++ b/mcp-stdio/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/mcp-stdio", - "version": "3.0.0", + "version": "3.1.0", "description": "Stdio-to-HTTP proxy for MCP with AAuth signatures", "type": "module", "exports": { @@ -36,7 +36,7 @@ "directory": "mcp-stdio" }, "dependencies": { - "@aauth/agent": "^3.0.0", + "@aauth/agent": "^4.0.0", "@aauth/local-keys": "^2.0.0", "@modelcontextprotocol/sdk": "^1.15.1", "open": "^11.0.0" diff --git a/package-lock.json b/package-lock.json index e984907..05b31d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,13 +21,13 @@ "fetch" ], "devDependencies": { - "@hellocoop/mockin": "^2.0.0", + "@hellocoop/mockin": "^3.0.0", "vitest": "^3.0.0" } }, "agent": { "name": "@aauth/agent", - "version": "3.0.2", + "version": "4.0.0", "license": "MIT", "dependencies": { "@aauth/protocol": "^1.0.0", @@ -51,10 +51,10 @@ }, "fetch": { "name": "@aauth/fetch", - "version": "3.0.0", + "version": "4.0.0", "license": "MIT", "dependencies": { - "@aauth/agent": "^3.0.0", + "@aauth/agent": "^4.0.0", "@aauth/local-keys": "^2.0.0", "@aauth/protocol": "^1.0.0", "open": "^11.0.0", @@ -107,21 +107,12 @@ "@aauth/hardware-keys": "^1.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", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "mcp-openclaw": { "name": "@aauth/mcp-openclaw", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "dependencies": { - "@aauth/agent": "^3.0.0", + "@aauth/agent": "^4.0.0", "@aauth/local-keys": "^2.0.0", "@aauth/protocol": "^1.0.0", "@modelcontextprotocol/sdk": "^1.15.1" @@ -133,10 +124,10 @@ }, "mcp-stdio": { "name": "@aauth/mcp-stdio", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "dependencies": { - "@aauth/agent": "^3.0.0", + "@aauth/agent": "^4.0.0", "@aauth/local-keys": "^2.0.0", "@modelcontextprotocol/sdk": "^1.15.1", "open": "^11.0.0" @@ -709,9 +700,9 @@ } }, "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==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", "dev": true, "funding": [ { @@ -726,9 +717,9 @@ "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==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz", + "integrity": "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==", "dev": true, "funding": [ { @@ -742,8 +733,8 @@ ], "license": "MIT", "dependencies": { - "fastify-plugin": "^5.0.0", - "mnemonist": "0.40.0" + "fastify-plugin": "^6.0.0", + "toad-cache": "^3.7.0" } }, "node_modules/@fastify/error": { @@ -784,9 +775,9 @@ } }, "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==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-9.0.0.tgz", + "integrity": "sha512-T/af26CSrUARBCvsEmv+DJLPfZlrRKESzqironxP1j7qzuLyKcoZtj6MuTGShuKx1THXugoie2oFbUJxXfGFzA==", "dev": true, "funding": [ { @@ -801,7 +792,7 @@ "license": "MIT", "dependencies": { "fast-querystring": "^1.1.2", - "fastify-plugin": "^5.0.0" + "fastify-plugin": "^6.0.0" } }, "node_modules/@fastify/forwarded": { @@ -883,44 +874,33 @@ } }, "node_modules/@hellocoop/httpsig": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.4.0.tgz", - "integrity": "sha512-eqdDGApckfEBhUb4EeKEPQj5ckp4qVGprGTl8tKN/XgRiTSvAlF7m1SZNQJNcSQLm55wOj4wyrJxzVS8lpxyaw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.6.0.tgz", + "integrity": "sha512-LS/teUB0LsbMJwTlYX2MlUs/P6GrJISuvYGeiRmIROyY7MACc32RdHHd9/UOpWNBnrfhs7RnYRT5VSjfZsIF4g==", "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==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hellocoop/mockin/-/mockin-3.0.0.tgz", + "integrity": "sha512-T4/PZf7xPVB2rhzy7h8tYNY0Y8n6dn7HnjePy9Y6k+0pOX0SCSFJ2UErvxLSFWoFtxkByqHIyLAndm+fk4r2lQ==", "dev": true, "license": "MIT", "dependencies": { - "@fastify/cors": "^10.0.0", - "@fastify/formbody": "^8.0.0", + "@fastify/cors": "^11.3.0", + "@fastify/formbody": "^9.0.0", "@hellocoop/constants": "*", - "@hellocoop/httpsig": "^2.0.0", - "fastify": "^5.0.0", - "jose": "^5.0.0", - "pkce-challenge": "^4.0.1" + "@hellocoop/httpsig": "^2.6.0", + "fastify": "^5.12.3", + "jose": "^6.2.12" }, "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": ">=22" } }, "node_modules/@hono/node-server": { @@ -982,15 +962,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/@napi-rs/cli": { "version": "2.18.4", "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", @@ -2417,9 +2388,9 @@ } }, "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==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", "dev": true, "funding": [ { @@ -2460,9 +2431,9 @@ "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==", + "version": "5.12.3", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz", + "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==", "dev": true, "funding": [ { @@ -2486,7 +2457,7 @@ "find-my-way": "^9.6.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.0.0", + "process-warning": "^5.1.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", @@ -2494,9 +2465,9 @@ } }, "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==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", "dev": true, "funding": [ { @@ -2511,9 +2482,9 @@ "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==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, "license": "ISC", "dependencies": { @@ -2560,9 +2531,9 @@ } }, "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==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", "dev": true, "license": "MIT", "dependencies": { @@ -2831,10 +2802,9 @@ "license": "ISC" }, "node_modules/jose": { - "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, + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -3004,16 +2974,6 @@ "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", @@ -3069,13 +3029,6 @@ "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", @@ -4213,7 +4166,7 @@ }, "resource": { "name": "@aauth/resource", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "dependencies": { "@aauth/interaction-code": "^0.1.0", @@ -4224,15 +4177,6 @@ "@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 e913094..c9d959e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test": "vitest run" }, "devDependencies": { - "@hellocoop/mockin": "^2.0.0", + "@hellocoop/mockin": "^3.0.0", "vitest": "^3.0.0" }, "workspaces": [ diff --git a/resource/README.md b/resource/README.md index cb358b2..d28409a 100644 --- a/resource/README.md +++ b/resource/README.md @@ -127,7 +127,9 @@ 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, presented_jti, mission_s256, tenant come from here + presentedToken: verifiedToken, // the person token, or on a step-up the auth token, the + // request carried: ps, sub, presented_jti, mission_s256, + // tenant come from here agentJkt: sig.thumbprint, scope: 'notes.read notes.write', kid: publicJwk.kid, @@ -142,9 +144,16 @@ The header handed to your signer is `{ alg: 'Ed25519', typ: 'aa-resource+jwt', k 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 `presented_jti` and -compares, so dropping it is detected as mission stripping. +`presentedToken` is whatever `verifyToken` returned for the request being challenged: a +`VerifiedPersonToken` on the first challenge of a grant, a `VerifiedAuthToken` on a step-up or +per-call challenge (AAuth -11, issue #152). `ps` is a person token's `iss` or an auth token's `ps`; +`presented_jti` is that token's `jti`. The agent hands the same token to its PS as `presented_token`, +and the PS (and in four-party the AS) verifies it against the resource token — so a resource keeps +no record of what it verified. `personToken` is accepted as a deprecated alias. + +`mission_s256` is copied from the presented token unchanged and is REQUIRED when that token carried +one; a resource MUST NOT omit it. The PS compares the presented token to the resource token, so +dropping it is detected as mission stripping. `presented_jti` is the claim's name since spec issue #95; the token also carries the deprecated pre-rename alias `person_token_jti` with the same value, so a PS that has not picked up the rename diff --git a/resource/package.json b/resource/package.json index e371dc4..6ef0ad4 100644 --- a/resource/package.json +++ b/resource/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/resource", - "version": "2.4.0", + "version": "2.5.0", "description": "AAuth resource-side reference implementation: token verification, resource tokens, R3 documents and per-call proposals, challenge headers, interaction management", "type": "module", "exports": { diff --git a/resource/src/index.ts b/resource/src/index.ts index fd154ef..1d7682b 100644 --- a/resource/src/index.ts +++ b/resource/src/index.ts @@ -69,6 +69,8 @@ export { export type { ResourceTokenOptions, PersonTokenReference, + PresentedTokenReference, + PresentedToken, SignFn, } from './resource-token.js' diff --git a/resource/src/resource-token.test.ts b/resource/src/resource-token.test.ts index 37d20cb..b4ff98b 100644 --- a/resource/src/resource-token.test.ts +++ b/resource/src/resource-token.test.ts @@ -64,6 +64,39 @@ describe('createResourceToken', () => { expect(p.exp).toBe(now + 300) }) + it('names an auth token on a step-up: ps from the auth token, presented_jti its jti', async () => { + // Issue #152: a step-up or per-call challenge fires on a request carrying + // an auth token, and the resource token names *that* token. + const { sign, captured } = capturingSign() + await createResourceToken( + { + ...base(), + presentedToken: { + type: 'auth', + iss: 'https://as.example', + ps: PS, + sub: '8f14e45fceea167a5a36dedd4bea2543', + jti: 'at-77', + mission_s256: 'm-1', + } as never, + }, + sign, + ) + const p = captured.payload! + expect(p.ps).toBe(PS) + expect(p.sub).toBe('8f14e45fceea167a5a36dedd4bea2543') + expect(p.presented_jti).toBe('at-77') + expect(p.mission_s256).toBe('m-1') + }) + + it('refuses an auth token with no jti — nothing to name', async () => { + const { sign } = capturingSign() + await expect(createResourceToken( + { ...base(), presentedToken: { ps: PS, sub: 's', jti: '' } }, + sign, + )).rejects.toMatchObject({ code: 'presented_token_required' }) + }) + it('dual-emits presented_jti and its deprecated alias person_token_jti', async () => { // Spec issue #95 renamed `person_token_jti` to `presented_jti`. Both are // emitted with the same value until every PS reads the new name; the @@ -164,11 +197,11 @@ describe('createResourceToken', () => { ).rejects.toThrow('mission expires_at is in the past') }) - it('requires a person token', async () => { + it('requires a presented token with a PS, sub and jti', async () => { const { sign } = capturingSign() await expect( createResourceToken(base({ personToken: { iss: PS, sub: 'u1' } }), sign), - ).rejects.toThrow('needs iss, sub and jti') + ).rejects.toThrow('needs a PS (ps or iss), sub and jti') }) it('requires scope', async () => { diff --git a/resource/src/resource-token.ts b/resource/src/resource-token.ts index ed46bd5..05fc46b 100644 --- a/resource/src/resource-token.ts +++ b/resource/src/resource-token.ts @@ -1,7 +1,7 @@ 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' +import type { VerifiedPersonToken, VerifiedAuthToken } from './verify-token.js' /** * Resource token minting (AAuth Protocol §Resource Token Structure). @@ -16,13 +16,19 @@ import type { VerifiedPersonToken } from './verify-token.js' /** 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`. */ +/** + * The claims a resource token copies out of the token the request carried — + * the person token on the first challenge of a grant, or the auth token on a + * step-up or per-call challenge (AAuth -11, issue #152). + */ +export interface PresentedTokenReference { + /** The PS whose namespace `sub` belongs to: a person token's `iss`, an auth + * token's `ps`. Give either; `ps` wins when both are present. */ + ps?: string + iss?: string + /** `sub` of the presented token — directed, opaque, meaningful only with the PS. */ sub: string - /** `jti` of the person token — binds this resource token to that one. + /** `jti` of the presented token — binds this resource token to that one. * Emitted as `presented_jti` (and its pre-rename alias `person_token_jti`). */ jti: string /** Copied unchanged when present. A resource MUST NOT omit it. */ @@ -30,14 +36,23 @@ export interface PersonTokenReference { tenant?: string } +/** @deprecated pre-#152 name for {@link PresentedTokenReference}. */ +export type PersonTokenReference = PresentedTokenReference + +export type PresentedToken = VerifiedPersonToken | VerifiedAuthToken | PresentedTokenReference + 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`, `presented_jti`, - * `mission_s256` and `tenant` are copied from it. */ - personToken: VerifiedPersonToken | PersonTokenReference + /** The token this resource verified on the request: the person token on the + * first challenge, or the auth token on a step-up / per-call challenge. + * `ps`, `sub`, `presented_jti`, `mission_s256` and `tenant` are copied from + * it, and the agent presents the same token to its PS as `presented_token`. */ + presentedToken?: PresentedToken + /** @deprecated pre-#152 name for `presentedToken`. */ + personToken?: PresentedToken /** 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 @@ -91,25 +106,27 @@ export function clampToMission(exp: number, missionExpiresAt?: number): number { return Math.min(exp, missionExpiresAt) } -function personRef( - token: VerifiedPersonToken | PersonTokenReference, -): PersonTokenReference { +function presentedRef(token: PresentedToken | undefined): Required> & Pick { if (!token || typeof token !== 'object') { throw new AAuthTokenError( - 'person_token_required', - 'createResourceToken requires the person token this resource verified', + 'presented_token_required', + 'createResourceToken requires the person token or auth token this resource verified on the request', ) } - const { iss, sub, jti } = token as PersonTokenReference - if (!iss || !sub || !jti) { + const t = token as PresentedTokenReference & { type?: string } + // An auth token names the PS as `ps` (its `iss` may be an AS); a person + // token's PS is its `iss`. + const ps = t.ps ?? t.iss + const { sub, jti } = t + if (!ps || !sub || !jti) { throw new AAuthTokenError( - 'person_token_required', - 'The person token reference needs iss, sub and jti', + 'presented_token_required', + 'The presented token needs a PS (ps or iss), sub and jti — an auth token without a jti cannot be named by a resource token', ) } - const ref: PersonTokenReference = { iss, sub, jti } - if (token.mission_s256) ref.mission_s256 = token.mission_s256 - if (token.tenant) ref.tenant = token.tenant + const ref: Required> & Pick = { ps, sub, jti } + if (t.mission_s256) ref.mission_s256 = t.mission_s256 + if (t.tenant) ref.tenant = t.tenant return ref } @@ -162,7 +179,7 @@ export async function createResourceToken( ) } - const person = personRef(options.personToken) + const person = presentedRef(options.presentedToken ?? options.personToken) const now = options.now ?? nowSeconds() const exp = clampToMission(now + lifetime, missionExpiresAt) @@ -178,11 +195,12 @@ export async function createResourceToken( dwk: DWK.resource, aud: audience, jti: randomId(), - ps: person.iss, + ps: person.ps, sub: person.sub, // `presented_jti` is the -11 name (spec issue #95); `person_token_jti` is // its pre-rename alias, emitted alongside until every PS reads the new - // name. Same value: the jti of the person token this resource verified. + // name. Same value: the jti of the token this resource verified on the + // request — the person token, or on a step-up the auth token (#152). presented_jti: person.jti, person_token_jti: person.jti, // deprecated alias of presented_jti agent_jkt: agentJkt,