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
44 changes: 41 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,51 @@ whoami-aauth-dev → Deployments).
|---------|----------|
| No HTTP signature | 401 + `Accept-Signature` header |
| Signature failed | 401 + `Signature-Error` header |
| `aa-agent+jwt` in Signature-Key | 401 + `AAuth-Requirement` with resource token |
| `aa-agent+jwt`, no `?scope=` | 200 + agent identity `{ iss, sub, ps? }` |
| `aa-agent+jwt` with `?scope=` | 401 + `AAuth-Requirement: requirement=person-token` |
| `aa-person+jwt`, no `?scope=` | 200 + person identity `{ iss, sub }` |
| `aa-person+jwt` with `?scope=` | 401 + `AAuth-Requirement: requirement=auth-token` with resource token |
| `aa-auth+jwt` in Signature-Key | 200 + identity claims JSON |

The `?scope=` query parameter adds identity scopes to the resource
token. The `whoami` scope is always included.
`?scope=` changes the question from "who is this agent" to "who is the
person this agent acts for", and is what escalates the access mode. The
`whoami` scope is always included on the resource token.

## AAuth -11 invariants worth not breaking

- whoami deliberately demonstrates **two** access modes at one endpoint:
agent identity access (agent token, no scope) and person identity
access (person token). Do not collapse them.
- A resource MUST have verified a person token before it issues a
resource token. When `?scope=` is set and only an agent token was
presented, the challenge is returned without reading or fetching
anything from the agent token — nothing in it would change the answer,
and following its `iss` would let an attacker drive outbound requests.
- A person's identity is the pair `(iss, sub)`. `sub` is opaque and
unique only within its issuer. `identityRecordKey` in `src/aauth.ts`
is the only way a record key is produced, and it cannot be given a
`sub` without an `iss`. An **agent** identifier is different: global,
self-qualifying (`aauth:local@domain`), and still never parsed.
- Of the tokens a resource reads, only the agent token carries an agent
identifier. -11 removed `agent` from person, resource and auth tokens
— do not log or record one on those paths.
- Signing algorithms are fully specified (RFC 9864): emit **and accept**
`Ed25519` only; the polymorphic `EdDSA` is rejected in
`JWT_ALG_PARAMS`. This is a flag day with the issuers — Wallet's
`svr/issuer/sign.js` must ship `Ed25519` for AAuth token types in the
same window. Separately, `alg` is **stripped** before
`crypto.subtle.importKey` in `src/crypto.ts` (both in `verifyJWT` and
`importSigningKey`) — workerd rejects an OKP JWK whose `alg` is
`Ed25519`, and `generate-key.mjs` stamps exactly that on the
`SIGNING_KEY` secret. Both strips are load-bearing and Node-based
tests will not catch their removal.
- R3 annotations are sparse. whoami's one AAuth operation requires
exactly the resource-wide `access_mode`, so it publishes no
`x-aauth-access-mode`.

## Testing

- `npm test` — vitest unit tests in `test/` (signed requests, token
verification, resource token shape, directed identifiers).
- `bash scripts/test.sh` — curl-based smoke tests against the deployed URL.
- `bash scripts/test.sh http://localhost:8787` — test against local dev.
42 changes: 34 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

Part of [AAuth](https://aauth.dev). Live at [whoami.aauth.dev](https://whoami.aauth.dev/.well-known/aauth-resource.json).

A reference resource server demonstrating [AAuth](https://github.com/dickhardt/AAuth) identity claim release. Agents present an `agent_token`, receive a `resource_token` pointing at their Person Server, and come back with an `auth_token` that unlocks the caller's identity claims.
A reference resource server demonstrating [AAuth](https://github.com/dickhardt/AAuth) identity. It answers two questions at one endpoint, and so shows two of AAuth's five access modes.

Present an `agent_token` and it tells you who the *agent* is — agent identity access, the replacement for an API key, with no Person Server involved. Present a `person_token` and it tells you who the *person* is. Ask for identity claims with `?scope=` and it returns a `resource_token` pointing back at that Person Server; come back with the `auth_token` and it releases the claims.

## Try it

Expand All @@ -13,8 +15,9 @@ Drive the full flow in the [AAuth Playground](https://playground.aauth.dev) —
| URL | Description |
|-----|-------------|
| [/](https://whoami.aauth.dev/) | Identity claims endpoint (signed requests only) |
| [/.well-known/aauth-resource.json](https://whoami.aauth.dev/.well-known/aauth-resource.json) | Resource metadata with `scope_descriptions` |
| [/.well-known/aauth-resource.json](https://whoami.aauth.dev/.well-known/aauth-resource.json) | Resource metadata with `access_mode` and `scope_descriptions` |
| [/.well-known/jwks.json](https://whoami.aauth.dev/.well-known/jwks.json) | Public signing key (Ed25519) |
| [/openapi.json](https://whoami.aauth.dev/openapi.json) | R3 vocabulary describing the endpoint |

## How it works

Expand All @@ -24,19 +27,41 @@ Every request to `GET /` must carry an RFC 9421 HTTP Message Signature whose `Si

The resource returns `401` with an `Accept-Signature` header telling the agent which components to sign and that it expects a JWT-keyed signature.

### 2. `aa-agent+jwt` — agent introducing itself
### 2. `aa-agent+jwt` — agent asking who it is

With no `?scope=`, this is **agent identity access**. The resource verifies the agent token — `dwk: aauth-agent.json`, the agent provider's JWKS discovered at `{iss}/.well-known/aauth-agent.json`, `exp`/`iat`, `iss` as a conforming server identifier, `sub` as a valid agent identifier, and `cnf.jwk` equal to the key that signed the request — and returns the agent's `iss` and `sub`, plus `ps` and `parent_agent` when the token carries them. No Person Server, no authorization flow.

With `?scope=`, the caller is asking about a *person* instead, and an agent token cannot produce one: a resource must have verified a person token before it issues a resource token. The response is `401` with `AAuth-Requirement: requirement=person-token`, a header that carries no parameters. Nothing in the agent token is read on that path — it would not change the answer, and following its `iss` would let anyone drive outbound requests from this Worker for free. The agent obtains a person token for `https://whoami.aauth.dev` from its PS's `person_token_endpoint` and retries.

### 3. `aa-person+jwt` — agent naming the person it acts for

The resource verifies the person token: `typ`, `dwk: aauth-person.json`, the PS's JWKS discovered at `{iss}/.well-known/aauth-person.json`, `exp`/`iat`, `iss` as a conforming server identifier, `aud` equal to this resource, and `cnf.jwk` equal to the key that signed the HTTP request. A person token carrying `scope` or `account` is rejected.

With no `?scope=`, the verified token is the answer: the resource returns the person's directed identity, `iss` and `sub`.

With `?scope=`, it mints a short-lived `resource_token` (`aa-resource+jwt`) audienced to the PS that issued the person token. The token copies `ps`, `sub` and `person_token_jti` from that person token, carries `agent_jkt` and `mission_s256` when the person token had one, and expires within five minutes and never after the person token does. The response is `401` with `AAuth-Requirement: requirement=auth-token; resource-token="..."`. The agent takes it to its PS and exchanges it for an `auth_token`.

### 4. `aa-auth+jwt` — agent returning with claims

The resource verifies the auth token against the issuer's JWKS — `aauth-person.json` from a PS, `aauth-access.json` from an AS — checks `aud`, `exp`, `cnf.jwk` against the request-signing key, that `sub` is present, and that `whoami` is in `scope`. It then returns a JSON body with `iss` and `sub` followed by the identity claims, dropping JWT infrastructure claims (`aud`, `exp`, `iat`, `jti`, `cnf`, `dwk`, `scope`, `ps`, `mission_s256`).

## Identity

The two identities this resource returns are not the same shape.

**A person is the pair (`iss`, `sub`).** `sub` is a directed identifier: unique within the issuer that minted it, not globally, and opaque. This resource treats it accordingly — it never parses or normalizes a `sub`, it always releases `iss` alongside it, and it never matches a `sub` received from one issuer against a record established under another, however the values compare. Two person tokens carrying the same `sub` string from different Person Servers are two different people here.

The resource verifies the agent token against the agent server's JWKS, reads the `ps` claim, fetches the PS metadata for its issuer, and mints a short-lived `resource_token` (`aa-resource+jwt`) audienced to that PS. The token carries the requested scopes and the agent's JWK thumbprint.
**An agent is named by `sub` alone**, an agent identifier of the form `aauth:local@domain`. It is global and self-qualifying, and just as opaque: compare it exactly and case-sensitively, and never parse the local part. The `+` in `aauth:planner.7f3c+search1@vendor.example` is for readability in logs; `parent_agent` is the authoritative sub-agent marker.

The response is `401` with an `AAuth-Requirement` header containing the resource token. The agent takes it to its PS and exchanges it for an `auth_token`.
The agent token is the only token this resource reads that carries an agent identifier. AAuth -11 removed `agent` from person, resource and auth tokens, so on the person path none is recorded — what binds a request to an agent there is `agent_jkt`, the thumbprint of its signing key.

### 3. `aa-auth+jwt` — agent returning with claims
## Missions

The resource verifies the auth token against the issuer's JWKS, checks `aud`, `exp`, and that `whoami` is in `scope`, then returns a JSON body containing the identity claims (everything except JWT infrastructure claims like `iss`, `aud`, `exp`, `cnf`, etc.).
A mission reaches a resource only inside a PS-issued token, as the `mission_s256` claim; the `AAuth-Mission` header was removed in -11. When a person token carries one, the resource token copies it unchanged — omitting it is what mission stripping would look like, and the PS detects it by resolving `person_token_jti` against the token it actually issued.

## Scopes

The `whoami` scope is always included on the resource token. Additional identity scopes can be requested via the `?scope=` query parameter and are passed through on `resource_token.scope`. The supported set combines standard OIDC scopes with Hellō identity attributes:
The `whoami` scope is always included on the resource token. Additional identity scopes can be requested via the `?scope=` query parameter and are passed through on `resource_token.scope`. Setting `?scope=` is what changes the question from "who is this agent" to "who is the person this agent acts for", and so escalates the call from `agent-token` to `person-token` and then `auth-token`. The supported set combines standard OIDC scopes with Hellō identity attributes:

```
openid profile name nickname given_name family_name preferred_username
Expand All @@ -59,6 +84,7 @@ Example: `GET /?scope=email%20picture`
```bash
npm install
npm run dev # local dev server
npm test # unit tests (vitest)
npx tsc --noEmit # type check
bash scripts/test.sh # smoke tests against production
bash scripts/test.sh http://localhost:8787 # smoke tests against local dev
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 @@ -12,7 +12,7 @@
"test:deploy": "bash scripts/test.sh"
},
"dependencies": {
"@hellocoop/httpsig": "^2.0.0",
"@hellocoop/httpsig": "^2.2.0",
"hono": "^4.7.0"
},
"devDependencies": {
Expand Down
Loading