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 @@ -37,6 +37,8 @@ The mock API at `PUT /mock/aauth` switches the simulated behaviours:
| `token_lifetime`, `claims`, `r3_grants`, `tenant` | shape the issued tokens (`r3_grants` takes `{ granted, per_call }`) |
| `require_body_signing` | `false` accepts a body signature that does not cover `content-digest` and `content-type` |

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.

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
12 changes: 10 additions & 2 deletions src/aauth/issue-auth-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ function releaseFor(identityScopes) {
* @param {string} [args.mission_s256] copied from the resource token
* @param {string} [args.tenant] copied from the resource token
* @param {string} [args.account] copied from the resource token
* @param {number} [args.presented_exp] exp of the presented_token the
* request carried — the auth token
* MUST NOT expire later (-11 §Auth
* Token Structure, issue #152)
* @param {object} [args.r3] { uri, s256, granted, per_call }
*/
export async function issueAuthToken({
Expand All @@ -105,16 +109,20 @@ export async function issueAuthToken({
mission_s256,
tenant,
account,
presented_exp,
r3,
}) {
const cfg = getConfig()
const lifetime = Math.min(cfg.token_lifetime || MAX_AUTH_TOKEN_TTL, MAX_AUTH_TOKEN_TTL)
const iat = Math.floor(Date.now() / 1000)
let lifetime = Math.min(cfg.token_lifetime || MAX_AUTH_TOKEN_TTL, MAX_AUTH_TOKEN_TTL)
if (Number.isFinite(presented_exp)) {
lifetime = Math.max(1, Math.min(lifetime, presented_exp - iat))
}
const { identity, resource } = classifyScopes(scope)

const release = releaseFor(identity)
const claimsOverride = cfg.claims || {}

const iat = Math.floor(Date.now() / 1000)
const tokenPayload = {
iss: ISSUER,
dwk: 'aauth-person.json',
Expand Down
7 changes: 4 additions & 3 deletions src/aauth/issue-person-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
//
// `sub` comes from subject.js, the same derivation issue-auth-token.js
// uses, so the value in this token is byte-equal to the one the auth
// token will carry for the same `aud`. That equality is what the PS's own
// step-6 check (§Resource Token Verification) compares against.
// token will carry for the same `aud`. The agent presents this token back
// with its auth token request (`presented_token`); step 6 of §Resource
// Token Verification checks the resource token against it.

import { randomUUID } from 'crypto'
import { SignJWT } from 'jose'
Expand Down Expand Up @@ -73,7 +74,7 @@ export async function issuePersonToken({
.setProtectedHeader({ alg: SIGNING_ALG, typ: 'aa-person+jwt', kid })
.sign(privateKey)

// §Resource Token Verification step 6 needs this later.
// §Person Token Endpoint retention (jti, aud, exp) — for revocation.
recordPersonToken({
jti,
ps: ISSUER,
Expand Down
22 changes: 10 additions & 12 deletions src/aauth/person-token-store.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
// aauth/person-token-store.js — issued person tokens, keyed by `jti`.
//
// Protocol -11 §Resource Token Verification step 6:
// Protocol -11 §Person Token Endpoint (issue #152): "A PS MUST record, for
// each person token it issues, the `jti`, the `aud`, and the `exp`, and,
// once it has presented the token to an access server, which one, and MUST
// keep the record until the token's `exp` plus clock skew." The record is
// for revocation, not verification — resource token verification step 6
// verifies the `presented_token` the agent sends, under this PS's own
// signature (verify-presented-token.js), and consults no record.
//
// "A PS MUST look up the person token identified by `person_token_jti`
// among those it issued, and MUST verify that `ps`, `sub`,
// `mission_s256`, and `tenant` match that token exactly, rejecting the
// resource token on any mismatch or omission."
//
// The spec implies this store without stating it (AAuth issue #87). It is
// what makes mission stripping detectable: comparing claims alone would
// not do, because an agent running concurrent missions holds several
// person tokens for the same resource.
//
// A mock keeps it in memory and expires entries with the token itself.
// mockin has no revocation endpoint and no AS federation, so the store is
// introspection only: tests can ask which jtis are live. A mock keeps it
// in memory and expires entries with the token itself.

const issued = new Map() // jti → record

Expand Down
27 changes: 24 additions & 3 deletions src/aauth/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
//
// Auto-approve flow (default):
// 1. HTTPSig + agent_token verified by preHandler (request.aauth)
// 2. Verify resource_token from the body, including -11 step 6: the
// person token named by `person_token_jti` must be one this PS
// issued, with matching ps / sub / mission_s256 / tenant
// 2. Verify resource_token from the body, then -11 step 6 (issue #152):
// the presented_token the agent sent — the person token, or on a
// step-up the auth token, that it presented to the resource — must
// verify, and the resource token's presented_jti / ps / sub /
// mission_s256 / tenant must match it exactly
// 3. If R3, fetch + hash-verify the document
// 4. Inject mock errors / deferred response if configured
// 5. Issue auth_token immediately, 200
Expand All @@ -19,6 +21,7 @@ import { calculateJwkThumbprint } from 'jose'
import { ISSUER } from '../config.js'
import { getConfig, mockErrorFor } from './mock.js'
import { verifyResourceToken } from './verify-resource-token.js'
import { verifyPresentedToken } from './verify-presented-token.js'
import { fetchR3Document, autoGrantR3 } from './r3.js'
import { issueAuthToken } from './issue-auth-token.js'
import { parseRequestParameters, canDriveInteraction } from './request-parameters.js'
Expand All @@ -32,6 +35,9 @@ const ERROR_STATUS = {
expired_agent_token: 400,
invalid_resource_token: 400,
expired_resource_token: 400,
invalid_presented_token: 400,
expired_presented_token: 400,
revoked_presented_token: 400,
invalid_scope: 400,
denied: 403,
user_unreachable: 403,
Expand Down Expand Up @@ -63,6 +69,14 @@ export const token = async (req, reply) => {
if (!body.resource_token) {
return problem(reply, 400, 'invalid_request', 'missing resource_token')
}
// -11 issue #152: REQUIRED — the token the agent presented to the
// resource, whose jti the resource token's presented_jti names.
if (typeof body.presented_token !== 'string' || !body.presented_token) {
return problem(
reply, 400, 'invalid_request',
'missing presented_token: the person token (or, on a step-up, the auth token) presented to the resource, named by the resource token presented_jti',
)
}

// upstream_token is call chaining — deferred fleet-wide, not implemented.
if (body.upstream_token !== undefined) {
Expand Down Expand Up @@ -116,6 +130,11 @@ export const token = async (req, reply) => {
rt.error,
)
}
// Step 6: the presented token against the resource token.
const presented = await verifyPresentedToken(body.presented_token, rt)
if (presented.error) {
return problem(reply, ERROR_STATUS[presented.code] || 400, presented.code, presented.error)
}

let r3 = null
if (rt.r3) {
Expand All @@ -141,6 +160,8 @@ export const token = async (req, reply) => {
mission_s256: rt.mission_s256 || undefined,
tenant: rt.tenant || undefined,
account: rt.account || undefined,
// §Auth Token Structure: never past the presented token's exp.
presented_exp: presented.exp,
r3,
}

Expand Down
211 changes: 211 additions & 0 deletions src/aauth/verify-presented-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// aauth/verify-presented-token.js — step 6 of §Resource Token Verification.
//
// Protocol -11 (issue #152): the agent's auth token request carries
// `presented_token`, the token it 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 / per-call challenge. The resource copied
// `ps`, `sub`, `mission_s256` and `tenant` out of it and named it by
// `presented_jti`. The PS verifies the presented token by its `typ`:
//
// aa-person+jwt — a person token this PS issued (iss = ISSUER)
// aa-auth+jwt — an auth token; issued by this PS on a three-party
// step-up, or by an Access Server in four-party. `ps`
// MUST name this PS. (mockin has no AS federation, so
// an AS-issued auth token verifies against the AS's
// JWKS only if the AS is a trusted server.)
//
// with two substitutions from the resource's own check: `aud` MUST equal
// the resource token's `iss`, and `cnf.jwk` MUST match the resource token's
// `agent_jkt`. A token that fails is `invalid_presented_token`, or
// `expired_presented_token` when only `exp` fails. Then the binding: `jti`
// equals `presented_jti`, `iss` (person) / `ps` (auth) equals the resource
// token's `ps`, and `sub`, `mission_s256`, `tenant` match exactly — any
// mismatch or omission is `invalid_resource_token`. This is what makes
// mission stripping detectable: the agent hands the PS the token the
// resource verified, under its issuer's signature, and the PS compares.
//
// No retained record is consulted. person-token-store.js keeps the
// jti/aud/exp the spec requires for revocation, nothing more.

import * as jose from 'jose'
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'

export const ACCESS_DWK = 'aauth-access.json'

const PERSON_TYP = 'aa-person+jwt'
const AUTH_TYP = 'aa-auth+jwt'

const fail = (code, error) => ({ code, error })

/**
* @param {string} presentedTokenStr the `presented_token` body parameter
* @param {object} rt the verified resource token: resource_url,
* ps, sub, mission_s256, tenant,
* presented_jti, agent_jkt
* @returns {{ code: string, error: string } |
* { kind: 'person'|'auth', jti: string, exp: number, ps: string, sub: string,
* mission_s256: string|null, tenant: string|null, payload: object }}
*/
export async function verifyPresentedToken(presentedTokenStr, rt) {
if (typeof presentedTokenStr !== 'string' || !presentedTokenStr) {
return fail('invalid_presented_token', 'presented_token must be a JWT')
}
let header, payload
try {
header = jose.decodeProtectedHeader(presentedTokenStr)
payload = jose.decodeJwt(presentedTokenStr)
} catch {
return fail('invalid_presented_token', 'malformed presented_token')
}

let kind
if (header.typ === PERSON_TYP) kind = 'person'
else if (header.typ === AUTH_TYP) kind = 'auth'
else {
return fail(
'invalid_presented_token',
`invalid presented_token typ: expected ${PERSON_TYP} or ${AUTH_TYP}, got ${header.typ}`,
)
}
const algError = checkJwtAlg(header.alg)
if (algError) return fail('invalid_presented_token', `presented_token ${algError}`)

// The PS whose person the token is for MUST be this PS.
const psClaim = kind === 'person' ? payload.iss : payload.ps
if (psClaim !== ISSUER) {
return fail(
'invalid_presented_token',
kind === 'person'
? `presented person token iss "${payload.iss}" was not issued by this PS`
: `presented auth token ps "${payload.ps}" does not name this PS`,
)
}

// Key: our own for anything we issued; the issuer's JWKS otherwise
// (an AS-issued auth token on a four-party step-up).
let keyset
if (payload.iss === ISSUER) {
if (payload.dwk !== PERSON_DWK) {
return fail(
'invalid_presented_token',
`presented_token dwk must be ${PERSON_DWK}, got ${payload.dwk}`,
)
}
keyset = jose.createLocalJWKSet({ keys: [publicJwk] })
} else {
if (payload.dwk !== ACCESS_DWK) {
return fail(
'invalid_presented_token',
`presented auth token dwk must be ${ACCESS_DWK}, got ${payload.dwk}`,
)
}
let entity
try {
entity = await getEntity(payload.iss, ACCESS_DWK)
} catch (err) {
return fail(
'invalid_presented_token',
`presented auth token issuer discovery failed: ${err.message}`,
)
}
keyset = jose.createLocalJWKSet(entity.jwks)
}

// jose verifies the signature before any claim, so JWTExpired means
// everything but exp held — the expired_presented_token case.
try {
await jose.jwtVerify(presentedTokenStr, keyset, {
algorithms: ACCEPTED_JWT_ALGS,
})
} catch (err) {
if (err.code === 'ERR_JWT_EXPIRED') {
return fail(
'expired_presented_token',
'presented_token expired: obtain a fresh person token, then a fresh resource token',
)
}
return fail(
'invalid_presented_token',
`presented_token signature: ${err.message}`,
)
}

// The two substitutions.
const audOk = Array.isArray(payload.aud)
? payload.aud.includes(rt.resource_url)
: payload.aud === rt.resource_url
if (!audOk) {
return fail(
'invalid_presented_token',
`presented_token aud mismatch: expected the resource ${rt.resource_url}, got ${payload.aud}`,
)
}
const cnfJwk = payload.cnf?.jwk
if (!cnfJwk) {
return fail('invalid_presented_token', 'presented_token missing cnf.jwk')
}
let jkt
try {
jkt = await jose.calculateJwkThumbprint(cnfJwk)
} catch {
return fail('invalid_presented_token', 'presented_token cnf.jwk is not a valid key')
}
if (jkt !== rt.agent_jkt) {
return fail(
'invalid_presented_token',
`presented_token cnf.jwk thumbprint ${jkt} does not match resource_token agent_jkt ${rt.agent_jkt}`,
)
}
if (!payload.jti) return fail('invalid_presented_token', 'presented_token missing jti')
if (!payload.sub) return fail('invalid_presented_token', 'presented_token missing sub')

// Binding to the resource token.
if (payload.jti !== rt.presented_jti) {
return fail(
'invalid_resource_token',
`resource_token presented_jti "${rt.presented_jti}" does not name the presented token (jti "${payload.jti}")`,
)
}
if (rt.ps !== psClaim) {
return fail(
'invalid_resource_token',
`resource_token ps mismatch: presented token has ${psClaim}, resource_token has ${rt.ps}`,
)
}
if (rt.sub !== payload.sub) {
return fail(
'invalid_resource_token',
`resource_token sub mismatch: presented token has ${payload.sub}, resource_token has ${rt.sub}`,
)
}
// A dropped mission_s256 is exactly the stripping this check exists to
// catch, so absent-vs-present is a mismatch in both directions.
const mission = payload.mission_s256 || null
if ((rt.mission_s256 || null) !== mission) {
return fail(
'invalid_resource_token',
`resource_token mission_s256 mismatch: presented token has ${mission || '(none)'}, resource_token has ${rt.mission_s256 || '(none)'}`,
)
}
const tenant = payload.tenant || null
if ((rt.tenant || null) !== tenant) {
return fail(
'invalid_resource_token',
`resource_token tenant mismatch: presented token has ${tenant || '(none)'}, resource_token has ${rt.tenant || '(none)'}`,
)
}

return {
kind,
jti: payload.jti,
exp: payload.exp,
ps: psClaim,
sub: payload.sub,
mission_s256: mission,
tenant,
payload,
}
}
Loading