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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ The mock API at `PUT /mock/aauth` switches the simulated behaviours:

The auth token request (`POST /aauth/token/auth`) follows AAuth -11: `resource_token` and `presented_token` are REQUIRED. `presented_token` is the token the agent presented to the resource — the person token from `/aauth/token/person`, or on a step-up the auth token — and the resource token's `presented_jti` must name it. Mockin verifies the presented token under its own key (`aud` = the resource, `cnf.jwk` = the resource token's `agent_jkt`) and rejects any `ps` / `sub` / `mission_s256` / `tenant` mismatch with `invalid_resource_token`; a bad or missing presented token is `invalid_request`, `invalid_presented_token` or `expired_presented_token`. The auth token never outlives the presented token.

Expiry is judged against mockin's clock with no tolerance (AAuth -11 §Expiry and the Refresh Margin). A signature `created`, an agent token `iat`, or a presented token `iat` more than 60 seconds ahead of mockin's clock is `clock_skew` — a `401` with `Signature-Error: error=clock_skew` for the signature or the agent token, a `400` problem for a presented token — so an agent knows to wait the difference out rather than refresh.

AAuth errors are RFC 9457 problem details — `Content-Type: application/problem+json` with the AAuth error code in `error` and the explanation in `detail`. The OIDC endpoints keep the OAuth 2.0 `{error, error_description}` shape they are specified to use.

## Invite
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@fastify/cors": "^11.3.0",
"@fastify/formbody": "^9.0.0",
"@hellocoop/constants": "*",
"@hellocoop/httpsig": "^2.3.0",
"@hellocoop/httpsig": "^2.6.0",
"fastify": "^5.12.3",
"jose": "^6.2.12"
},
Expand Down
4 changes: 3 additions & 1 deletion src/aauth/person.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const ERROR_STATUS = {
invalid_request: 400,
invalid_agent_token: 400,
expired_agent_token: 400,
clock_skew: 400,
denied: 403,
user_unreachable: 403,
server_error: 500,
Expand Down Expand Up @@ -117,7 +118,8 @@ export const person = async (req, reply) => {
const sub = await verifyAgentToken(body.subagent_token)
if (sub.error) {
return problem(
reply, 400, 'invalid_agent_token', `subagent_token: ${sub.error}`,
reply, 400, sub.code || 'invalid_agent_token',
`subagent_token: ${sub.error}`,
)
}
if (sub.payload.parent_agent !== aauth.agent_id) {
Expand Down
4 changes: 3 additions & 1 deletion src/aauth/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const ERROR_STATUS = {
invalid_request: 400,
invalid_agent_token: 400,
expired_agent_token: 400,
clock_skew: 400,
invalid_resource_token: 400,
expired_resource_token: 400,
invalid_presented_token: 400,
Expand Down Expand Up @@ -109,7 +110,8 @@ export const token = async (req, reply) => {
const sub = await verifyAgentToken(body.subagent_token)
if (sub.error) {
return problem(
reply, 400, 'invalid_agent_token', `subagent_token: ${sub.error}`,
reply, 400, sub.code || 'invalid_agent_token',
`subagent_token: ${sub.error}`,
)
}
if (sub.payload.parent_agent !== aauth.agent_id) {
Expand Down
20 changes: 19 additions & 1 deletion src/aauth/verify-agent-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ import * as jose from 'jose'
import { getEntity, AGENT_DWK } from './entity-cache.js'
import { ACCEPTED_JWT_ALGS, checkJwtAlg } from './algorithms.js'

// The same 60 s window @hellocoop/httpsig applies to the signature's
// `created`; a verifier that bounds iat SHOULD use it (signature-key draft).
export const IAT_SKEW_SECONDS = 60

/**
* @param {string} raw the compact JWT
* @param {object} [decoded] { header, payload } when the caller already has them
* @returns {Promise<{payload?: object, header?: object, error?: string}>}
* @returns {Promise<{payload?: object, header?: object, error?: string, code?: string}>}
* `code` names a Signature-Error / token endpoint code other than the
* caller's default (today only `clock_skew`).
*/
export async function verifyAgentToken(raw, decoded) {
let header = decoded?.header
Expand Down Expand Up @@ -57,5 +63,17 @@ export async function verifyAgentToken(raw, decoded) {
if (!payload.sub) return { error: 'agent_token missing sub' }
if (!payload.cnf?.jwk) return { error: 'agent_token missing cnf.jwk' }

// `iat` is not a validity check (§Expiry and the Refresh Margin), except
// that one further ahead of our clock than the signature window is the
// issuer's clock disagreeing with ours: clock_skew, distinct from
// invalid_jwt because a fresh token would carry the same skew.
const now = Math.floor(Date.now() / 1000)
if (typeof payload.iat === 'number' && payload.iat > now + IAT_SKEW_SECONDS) {
return {
error: `agent_token iat is ${payload.iat - now}s ahead of this server's clock (window ${IAT_SKEW_SECONDS}s)`,
code: 'clock_skew',
}
}

return { header, payload, metadata: entity.metadata }
}
14 changes: 14 additions & 0 deletions src/aauth/verify-presented-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { ISSUER } from '../config.js'
import { publicJwk } from './keys.js'
import { ACCEPTED_JWT_ALGS, checkJwtAlg } from './algorithms.js'
import { getEntity, PERSON_DWK } from './entity-cache.js'
import { IAT_SKEW_SECONDS } from './verify-agent-token.js'

export const ACCESS_DWK = 'aauth-access.json'

Expand Down Expand Up @@ -133,6 +134,19 @@ export async function verifyPresentedToken(presentedTokenStr, rt) {
)
}

// `iat` is not a validity check, except that one further ahead of our
// clock than the signature window is the issuer's clock disagreeing with
// ours: clock_skew — the agent waits rather than refreshes.
if (typeof payload.iat === 'number') {
const now = Math.floor(Date.now() / 1000)
if (payload.iat > now + IAT_SKEW_SECONDS) {
return fail(
'clock_skew',
`presented_token iat is ${payload.iat - now}s ahead of this server's clock (window ${IAT_SKEW_SECONDS}s)`,
)
}
}

// The two substitutions.
const audOk = Array.isArray(payload.aud)
? payload.aud.includes(rt.resource_url)
Expand Down
5 changes: 4 additions & 1 deletion src/aauth/verify-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ export async function verifyRequest(request) {

const verified = await verifyAgentToken(raw, { header, payload })
if (verified.error) {
return fail(401, 'invalid_jwt', verified.error)
const code = verified.code || 'invalid_jwt'
return fail(401, code, verified.error, {
'Signature-Error': generateSignatureErrorHeader({ error: code }),
})
}

return {
Expand Down
17 changes: 15 additions & 2 deletions test/aauth/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,13 @@ export async function mintAgentToken({
cnf_jwk = ephemeralPublicJwk,
parent_agent = undefined,
ttl = 600,
// seconds added to iat (a positive value fakes an issuer clock ahead of ours)
iatOffset = 0,
// -10 forbids the polymorphic 'EdDSA'; tests override this to prove
// mockin declines it.
alg = 'Ed25519',
} = {}) {
const now = Math.floor(Date.now() / 1000)
const now = Math.floor(Date.now() / 1000) + iatOffset
const payload = {
iss: AGENT_SERVER_URL,
dwk: 'aauth-agent.json',
Expand Down Expand Up @@ -371,18 +373,29 @@ async function sigHeaders({
// JWT scheme — person, token, pending, permission, audit, interaction.
export async function signedRequest({
method, path, body, agentToken, components, signingKey,
// sign as if the agent's clock were this many ms off; mockin (same
// process) verifies on the real clock because the offset is lifted
// before inject
clockOffsetMs = 0,
}) {
const bodyStr = body === undefined
? undefined
: typeof body === 'string' ? body : JSON.stringify(body)
const headers = await sigHeaders({
const realNow = Date.now
if (clockOffsetMs) Date.now = () => realNow() + clockOffsetMs
let headers
try {
headers = await sigHeaders({
method,
path,
body: bodyStr,
components,
signingKey,
signatureKey: { type: 'jwt', jwt: agentToken },
})
} finally {
Date.now = realNow
}
return { headers, payload: bodyStr }
}

Expand Down
47 changes: 47 additions & 0 deletions test/aauth/token.errors.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
endpointPath,
getPersonToken,
personAndResourceToken,
requestPersonToken,
resourceServer,
ephemeralJkt,
} from './helpers.js'
Expand Down Expand Up @@ -139,6 +140,52 @@ describe('AAuth auth_token_endpoint — errors', function () {
expect(response.json().error).to.equal('expired_resource_token')
})

describe('clock_skew (§Expiry and the Refresh Margin)', function () {
it('401 clock_skew when the signature created is ahead of our clock', async function () {
const { agentToken, body } = await personAndResourceToken(fastify)
const { signedRequest } = await import('./helpers.js')
const path = await endpointPath(fastify, 'auth_token_endpoint')
const { headers, payload } = await signedRequest({
method: 'POST', path, body, agentToken, clockOffsetMs: 300_000,
})
const response = await fastify.inject({ method: 'POST', url: path, headers, payload })
expect(response.statusCode).to.equal(401)
expect(response.headers['signature-error']).to.equal('error=clock_skew')
})

it('401 clock_skew when the agent token iat is ahead of our clock', async function () {
const skewed = await mintAgentToken({ iatOffset: 7200, ttl: 3600 })
const { person_token } = await getPersonToken(fastify)
const resourceToken = await mintResourceToken({ presentedToken: person_token })
const response = await postResourceToken(resourceToken, skewed, person_token)
expect(response.statusCode).to.equal(401)
expect(response.json().error).to.equal('clock_skew')
expect(response.headers['signature-error']).to.equal('error=clock_skew')
})

it('an agent token iat inside the window is not skew', async function () {
const near = await mintAgentToken({ iatOffset: 30, ttl: 3600 })
const res = await requestPersonToken(fastify, { agentToken: near })
expect(res.statusCode).to.equal(200)
})

it('400 clock_skew when the presented token iat is ahead of our clock', async function () {
const { person_token, claims } = await getPersonToken(fastify)
const { privateJwk } = await import('../../src/aauth/keys.js')
const { importJWK } = await import('jose')
const key = await importJWK(privateJwk, 'Ed25519')
const now = Math.floor(Date.now() / 1000)
const skewed = await new SignJWT({ ...claims, iat: now + 7200, exp: now + 10800 })
.setProtectedHeader({ alg: 'Ed25519', typ: 'aa-person+jwt', kid: privateJwk.kid })
.sign(key)
const resourceToken = await mintResourceToken({ presentedToken: person_token })
const response = await postResourceToken(resourceToken, undefined, skewed)
expect(response.statusCode).to.equal(400)
expect(response.json().error).to.equal('clock_skew')
expect(response.json().detail).to.match(/ahead of this server/)
})
})

describe('presented token binding (§Resource Token Verification step 6)', function () {
it('rejects a resource token with no presented_jti', async function () {
const { person_token } = await getPersonToken(fastify)
Expand Down