diff --git a/CLAUDE.md b/CLAUDE.md index bcd7533..e0d4e65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 11389fc..0c6b2bd 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/package-lock.json b/package-lock.json index 2ace9ba..18513ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "whoami.aauth.dev", "version": "0.1.0", "dependencies": { - "@hellocoop/httpsig": "^2.0.0", + "@hellocoop/httpsig": "^2.2.0", "hono": "^4.7.0" }, "devDependencies": { @@ -698,9 +698,9 @@ } }, "node_modules/@hellocoop/httpsig": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.0.1.tgz", - "integrity": "sha512-nmAI+A3YQKOfeZ9cnKjtNXVVJ6sUwazwhM0zQV/t8XpbMKJdYoj3IfaTph9LUMlAvBlDN70wJ2jnUYOr0GQW6g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.2.0.tgz", + "integrity": "sha512-UdWonQL79Nb/NmY5YKSmcdaC/Drjx5lINixdDk8yr2WN93q2zmAV28U/aV5YW35fZWu3n3G0cIavygNKoUPekA==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 04a2ee2..c232011 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/openapi.json b/public/openapi.json index 8c0d9fd..1daa22e 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -2,8 +2,8 @@ "openapi": "3.1.0", "info": { "title": "AAuth Who Am I", - "version": "1.0", - "description": "A minimal AAuth resource server that echoes back the identity claims a resource sees from the caller's AAuth credentials. Useful for testing identity-based access.\n\n## Authentication flow\n\n`GET /` has three outcomes depending on what the caller presents:\n\n1. **No HTTP signature** → `401` with `Accept-Signature` header describing the required signature scheme.\n2. **`aa-agent+jwt` in `Signature-Key`** → `401` with `AAuth-Requirement` header containing a freshly-minted `resource_token` the agent can exchange at its Person Server (PS) for an `auth_token`. Add `?scope=` to request identity claims beyond the default agent identity.\n3. **`aa-auth+jwt` in `Signature-Key`** → `200` with identity claims as JSON." + "version": "1.1", + "description": "A minimal AAuth resource server that echoes back the identity a resource sees from the caller's AAuth credentials. It answers two different questions at one endpoint, demonstrating two of AAuth's access modes.\n\n## Authentication flow\n\n`GET /` outcomes depend on what the caller presents in `Signature-Key` and on whether `?scope=` is set:\n\n1. **No HTTP signature** → `401` with `Accept-Signature` header describing the required signature scheme.\n2. **`aa-agent+jwt`, no `?scope=`** → `200` with the agent's own identity. This is agent identity access: the resource verifies the agent token and decides on who the agent is. No Person Server, no authorization flow — the replacement for an API key.\n3. **`aa-agent+jwt` with `?scope=`** → `401` with `AAuth-Requirement: requirement=person-token`. The caller is asking about a person, and an agent token cannot produce one: a resource must have verified a person token before it issues a resource token.\n4. **`aa-person+jwt`** → `200` with the person's directed identity, or — when `?scope=` asks for identity claims — `401` with `AAuth-Requirement: requirement=auth-token` carrying a freshly-minted `resource_token` the agent exchanges at its Person Server (PS) for an `auth_token`.\n5. **`aa-auth+jwt`** → `200` with identity claims as JSON.\n\n## Identity\n\nThe two identities are not the same shape.\n\nA **person** is the pair (`iss`, `sub`). `sub` is a directed identifier: unique within the issuer that minted it, not globally, and opaque. This resource always releases `iss` alongside `sub`, and never matches a `sub` from one issuer against a record established under another, however the values compare.\n\nAn **agent** is named by `sub`, an agent identifier of the form `aauth:local@domain`. It is global and self-qualifying, and equally opaque — the local part must not be parsed. The agent token is the only token this resource reads that carries an agent identifier; `agent` was removed from person, resource and auth tokens in AAuth -11." }, "servers": [ { @@ -16,13 +16,13 @@ "get": { "operationId": "whoami", "summary": "Return identity claims (or initiate AAuth flow)", - "description": "Verifies the HTTP signature on the request and responds based on the JWT type presented in the `Signature-Key` header:\n\n- No signature → `401` + `Accept-Signature`\n- `aa-agent+jwt`, no `?scope=` → `200` with agent's own `sub` (and `ps` if present)\n- `aa-agent+jwt`, with `?scope=` → `401` + `AAuth-Requirement` (resource token minted)\n- `aa-auth+jwt` with `whoami` scope → `200` with identity claims\n\nAll requests must carry an HTTP Message Signature over `@method`, `@authority`, `@path`, and `signature-key` using the `jwt` key scheme.", + "description": "Verifies the HTTP signature on the request and responds based on the JWT type presented in the `Signature-Key` header and whether `?scope=` is set:\n\n- No signature → `401` + `Accept-Signature`\n- `aa-agent+jwt`, no `?scope=` → `200` with the agent's `iss` and `sub`\n- `aa-agent+jwt`, with `?scope=` → `401` + `AAuth-Requirement: requirement=person-token`\n- `aa-person+jwt`, no `?scope=` → `200` with the person's `iss` and `sub`\n- `aa-person+jwt`, with `?scope=` → `401` + `AAuth-Requirement: requirement=auth-token` (resource token minted)\n- `aa-auth+jwt` with `whoami` scope → `200` with identity claims\n\nAll requests must carry an HTTP Message Signature over `@method`, `@authority`, `@path`, and `signature-key` using the `jwt` key scheme.\n\nThis operation carries no `x-aauth-access-mode` annotation. R3 annotations are sparse — an operation with none takes the resource's `access_mode`, which is `agent-token`, the minimum this operation requires. The escalation to `person-token` and then `auth-token` is driven by the `scope` parameter, which a per-operation annotation cannot express; the runtime `AAuth-Requirement` is authoritative.", "parameters": [ { "name": "scope", "in": "query", "required": false, - "description": "Space-separated list of identity scopes to include in the minted `resource_token` (e.g. `email profile`). Only valid when presenting an `aa-agent+jwt`. The `whoami` scope is always included automatically.", + "description": "Space-separated list of identity scopes to include in the minted `resource_token` (e.g. `email profile`). Setting it changes the question from \"who is this agent\" to \"who is the person this agent acts for\", and so escalates the call: an `aa-agent+jwt` is challenged for a person token, and an `aa-person+jwt` receives a resource token for the `auth-token` flow. The `whoami` scope is always included automatically.", "schema": { "type": "string", "example": "email profile" @@ -36,12 +36,13 @@ ], "responses": { "200": { - "description": "Identity claims echoed back. Content depends on the JWT type presented:\n- `aa-auth+jwt`: identity claims from the Person Server (all non-infrastructure claims)\n- `aa-agent+jwt` with no `?scope=`: agent's own `sub` and optionally `ps`", + "description": "Identity echoed back. Content depends on the JWT type presented:\n- `aa-auth+jwt`: `iss` and `sub` plus the identity claims the Person Server released\n- `aa-person+jwt` with no `?scope=`: the person's `iss` and `sub` (and `tenant` when the person token carried one)\n- `aa-agent+jwt` with no `?scope=`: the agent's `iss` and `sub` (and `ps`, `parent_agent` when present)", "content": { "application/json": { "schema": { "oneOf": [ { "$ref": "#/components/schemas/IdentityClaims" }, + { "$ref": "#/components/schemas/PersonIdentity" }, { "$ref": "#/components/schemas/AgentIdentity" } ] }, @@ -49,15 +50,24 @@ "authTokenClaims": { "summary": "Identity claims from auth_token", "value": { - "sub": "https://person.example/.well-known/aauth-person.json#abc123", + "iss": "https://person.example", + "sub": "8f14e45fceea167a5a36dedd4bea2543", "email": "alice@example.com", "name": "Alice Example" } }, + "personIdentity": { + "summary": "Directed identity from person_token (no scope requested)", + "value": { + "iss": "https://person.example", + "sub": "8f14e45fceea167a5a36dedd4bea2543" + } + }, "agentIdentity": { - "summary": "Agent identity (no scope requested)", + "summary": "Agent identity from agent_token (no scope requested)", "value": { - "sub": "https://agent.example", + "iss": "https://agent.example", + "sub": "aauth:assistant-v2@agent.example", "ps": "https://person.example" } } @@ -66,7 +76,7 @@ } }, "400": { - "description": "Bad request — invalid scope or missing required JWT claim", + "description": "Bad request — unsupported JWT type or an unknown scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, @@ -75,16 +85,16 @@ "summary": "Unknown scope", "value": { "error": "invalid_scope", "unknown": ["badscope"] } }, - "missingClaim": { - "summary": "Missing required claim", - "value": { "error": "agent_token missing ps claim" } + "unsupportedType": { + "summary": "Signature-Key JWT is not an AAuth token this endpoint accepts", + "value": { "error": "unsupported JWT type: aa-resource+jwt" } } } } } }, "401": { - "description": "Signature missing, verification failed, or an `auth_token` is required. Inspect the response headers to determine next steps:\n- `Accept-Signature` present → sign your request and retry (`Accept-Signature-Scheme` lists the accepted `Signature-Key` schemes)\n- `Signature-Error` present → your signature was malformed\n- `AAuth-Requirement` present → exchange the `resource-token` at your PS", + "description": "Signature missing, verification failed, or a further credential is required. Inspect the response headers to determine next steps:\n- `Accept-Signature` present → sign your request and retry (`Accept-Signature-Scheme` lists the accepted `Signature-Key` schemes)\n- `Signature-Error` present → your signature was malformed\n- `AAuth-Requirement: requirement=person-token` → obtain a person token for this resource from your PS's person token endpoint and retry\n- `AAuth-Requirement: requirement=auth-token` → exchange the `resource-token` at your PS", "headers": { "Accept-Signature": { "description": "Describes the HTTP signature scheme required. Present when no signature was found on the request.", @@ -101,10 +111,13 @@ } }, "AAuth-Requirement": { - "description": "Contains a freshly-minted `resource_token` the agent exchanges at its Person Server to obtain an `auth_token`. Present when an `aa-agent+jwt` was verified and scopes were requested.", + "description": "Names what the agent must obtain next. `requirement=person-token` carries no parameters — obtain a person token for this resource from your PS's `person_token_endpoint` and retry. `requirement=auth-token` carries a freshly-minted `resource-token` the agent exchanges at the Person Server that issued its person token.", "schema": { "type": "string", - "example": "requirement=auth-token; resource-token=\"eyJ...\"" + "examples": [ + "requirement=person-token", + "requirement=auth-token; resource-token=\"eyJ...\"" + ] } }, "Signature-Error": { @@ -124,10 +137,18 @@ "summary": "Signature verification failed", "value": { "error": "signature_verification_failed", "detail": "signature component mismatch" } }, + "personTokenRequired": { + "summary": "Agent token presented with ?scope= — a person token is required first", + "value": { "error": "person_token_required" } + }, "authTokenRequired": { "summary": "Resource token minted — exchange at PS", "value": { "error": "auth_token_required" } }, + "personTokenAudMismatch": { + "summary": "Person token was issued for a different resource", + "value": { "error": "person_token aud mismatch" } + }, "authTokenExpired": { "summary": "Presented auth_token is expired", "value": { "error": "auth_token expired" } @@ -170,7 +191,7 @@ } }, "502": { - "description": "Could not reach issuer or Person Server", + "description": "Could not reach the token issuer's metadata or JWKS", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, @@ -179,9 +200,9 @@ "summary": "Cannot reach issuer", "value": { "error": "Cannot reach issuer: fetch failed" } }, - "psUnreachable": { - "summary": "Cannot reach Person Server", - "value": { "error": "Cannot reach PS: fetch failed" } + "missingJwksUri": { + "summary": "Issuer metadata has no jwks_uri", + "value": { "error": "Issuer metadata missing jwks_uri" } } } } @@ -194,7 +215,7 @@ "get": { "operationId": "getResourceMetadata", "summary": "AAuth resource server metadata", - "description": "Returns metadata about this resource server in the AAuth Resource Server Metadata format. Agents discover this document to learn the resource's identity, available scopes, JWKS URI, and supported R3 vocabularies.", + "description": "Returns metadata about this resource server in the AAuth Resource Server Metadata format. Agents discover this document to learn the resource's identity, access mode, available scopes, JWKS URI, and supported R3 vocabularies. Unauthenticated — no AAuth credential is required or read.", "responses": { "200": { "description": "Resource server metadata", @@ -204,9 +225,10 @@ "example": { "issuer": "https://whoami.aauth.dev", "jwks_uri": "https://whoami.aauth.dev/.well-known/jwks.json", - "client_name": "AAuth Who Am I", + "name": "AAuth Who Am I", "description": "Echoes back the identity claims a resource sees from your AAuth credentials — a minimal resource for testing identity-based access.", "logo_uri": "https://whoami.aauth.dev/logo.png", + "access_mode": "agent-token", "scope_descriptions": { "whoami": "Echo your provided identity claims" }, @@ -224,7 +246,7 @@ "get": { "operationId": "getJWKS", "summary": "JSON Web Key Set", - "description": "Returns the public key(s) used by this resource server to sign `resource_token` JWTs (`aa-resource+jwt`). Person Servers use this to verify resource tokens they receive.", + "description": "Returns the public key(s) used by this resource server to sign `resource_token` JWTs (`aa-resource+jwt`). Person Servers use this to verify resource tokens they receive. Keys carry the RFC 9864 fully-specified `alg` value `Ed25519`. Unauthenticated — no AAuth credential is required or read.", "responses": { "200": { "description": "JWKS with the resource server's public key(s)", @@ -236,6 +258,7 @@ { "kty": "OKP", "crv": "Ed25519", + "alg": "Ed25519", "x": "base64url-encoded-public-key", "kid": "key-id" } @@ -253,52 +276,91 @@ "httpSignature": { "type": "http", "scheme": "signature", - "description": "HTTP Message Signatures (RFC 9421). The request must carry:\n- A `Signature` header with label `sig`\n- A `Signature-Input` header referencing `@method`, `@authority`, `@path`, and `signature-key`\n- A `Signature-Key` header containing either an `aa-agent+jwt` or `aa-auth+jwt` compact JWT, using the `jwt=` scheme" + "description": "HTTP Message Signatures (RFC 9421). The request must carry:\n- A `Signature` header with label `sig`\n- A `Signature-Input` header referencing `@method`, `@authority`, `@path`, and `signature-key`\n- A `Signature-Key` header containing an `aa-agent+jwt`, `aa-person+jwt`, or `aa-auth+jwt` compact JWT, using the `jwt=` scheme\n\nThe token's `cnf.jwk` must be the key that signed the request, and must carry a fully-specified `alg` (RFC 9864). The polymorphic `EdDSA` identifier is rejected." } }, "schemas": { - "IdentityClaims": { + "AgentIdentity": { "type": "object", - "description": "Identity claims from the Person Server, after stripping JWT infrastructure claims (`iss`, `aud`, `exp`, `iat`, `jti`, `cnf`, `dwk`, `act`, `scope`). The exact set of properties depends on which scopes were granted.", - "additionalProperties": true, + "description": "An agent's own identity, returned when an `aa-agent+jwt` is verified and no `?scope=` was requested. Agent identity access — no Person Server is involved.", + "required": ["iss", "sub"], "properties": { + "iss": { + "type": "string", + "format": "uri", + "description": "The agent provider that issued the agent token and vouches for `sub`.", + "example": "https://agent.example" + }, "sub": { "type": "string", - "description": "Subject identifier (person's stable ID at the PS)", - "example": "https://person.example/.well-known/aauth-person.json#abc123" + "description": "Agent identifier, `aauth:local@domain`. Global and stable across key rotations. Compare it with exact, case-sensitive string comparison; do not parse the local part — the `+` in a sub-agent identifier is for readability, and `parent_agent` is the authoritative marker.", + "example": "aauth:assistant-v2@agent.example" }, - "email": { "type": "string", "example": "alice@example.com" }, - "name": { "type": "string", "example": "Alice Example" }, - "picture": { "type": "string", "format": "uri" } + "ps": { + "type": "string", + "format": "uri", + "description": "The agent's Person Server, when the agent token names one. Distinct from `iss`.", + "example": "https://person.example" + }, + "parent_agent": { + "type": "string", + "description": "Present only on a sub-agent's token, naming its parent.", + "example": "aauth:planner.7f3c@agent.example" + } } }, - "AgentIdentity": { + "PersonIdentity": { "type": "object", - "description": "Agent's own identity, returned when an `aa-agent+jwt` is verified but no `?scope=` was requested.", - "required": ["sub"], + "description": "A person's identity at this resource: the pair (`iss`, `sub`). Neither half means anything without the other.", + "required": ["iss", "sub"], "properties": { + "iss": { + "type": "string", + "format": "uri", + "description": "The issuer whose namespace `sub` belongs to — the Person Server in three-party, the Access Server in four-party.", + "example": "https://person.example" + }, "sub": { "type": "string", - "description": "Agent subject identifier", - "example": "https://agent.example" + "description": "Directed subject identifier. Opaque and unique only within `iss`. Do not parse it, do not normalize it, and do not compare it to a `sub` from another issuer.", + "example": "8f14e45fceea167a5a36dedd4bea2543" }, - "ps": { + "tenant": { "type": "string", - "format": "uri", - "description": "URL of the agent's Person Server (if present in agent_token)", - "example": "https://person.example" + "description": "Organization the person belongs to, when the token carried one. Organizational context, not part of the identifier.", + "example": "acme" } } }, + "IdentityClaims": { + "allOf": [ + { "$ref": "#/components/schemas/PersonIdentity" }, + { + "type": "object", + "description": "Identity claims released by the auth token's issuer, after stripping JWT infrastructure claims (`aud`, `exp`, `iat`, `jti`, `cnf`, `dwk`, `act`, `scope`, `ps`, `mission_s256`). The exact set depends on which scopes were granted.", + "additionalProperties": true, + "properties": { + "email": { "type": "string", "example": "alice@example.com" }, + "name": { "type": "string", "example": "Alice Example" }, + "picture": { "type": "string", "format": "uri" } + } + } + ] + }, "ResourceMetadata": { "type": "object", "required": ["issuer", "jwks_uri"], "properties": { "issuer": { "type": "string", "format": "uri" }, "jwks_uri": { "type": "string", "format": "uri" }, - "client_name": { "type": "string" }, + "name": { "type": "string" }, "description": { "type": "string" }, "logo_uri": { "type": "string", "format": "uri" }, + "access_mode": { + "type": "string", + "description": "The credential flow an agent should expect by default. This resource declares `agent-token`, the minimum that gets an answer: an agent token alone returns the agent's identity. Requesting identity scopes escalates to `person-token` and then `auth-token`. Declaring the minimum means no agent skips this resource for a setup it does have; the runtime `AAuth-Requirement` is authoritative.", + "example": "agent-token" + }, "scope_descriptions": { "type": "object", "additionalProperties": { "type": "string" } @@ -324,6 +386,7 @@ "properties": { "kty": { "type": "string", "example": "OKP" }, "crv": { "type": "string", "example": "Ed25519" }, + "alg": { "type": "string", "example": "Ed25519" }, "x": { "type": "string" }, "kid": { "type": "string" } } diff --git a/scripts/test.sh b/scripts/test.sh index 8f865e5..f1b7146 100644 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -36,6 +36,9 @@ check "jwks_uri present" \ check "scope_descriptions has whoami" \ "$(echo "$RESOURCE_META" | jq -e '.scope_descriptions.whoami' >/dev/null 2>&1 && echo true || echo false)" +check "access_mode is agent-token" \ + "$(echo "$RESOURCE_META" | jq -e '.access_mode == "agent-token"' >/dev/null 2>&1 && echo true || echo false)" + echo # ── .well-known/jwks.json ── @@ -80,6 +83,21 @@ check "returns 401" "$([ "$RESP" = "401" ] && echo true || echo false)" echo +# ── /openapi.json (R3 vocabulary) ── +echo "--- /openapi.json ---" +OPENAPI=$(curl -sf "$BASE/openapi.json") + +check "whoami operation is described" \ + "$(echo "$OPENAPI" | jq -e '.paths["/"].get.operationId == "whoami"' >/dev/null 2>&1 && echo true || echo false)" + +# R3 annotations are sparse: whoami's one AAuth operation requires exactly the +# resource-wide access_mode, so it annotates nothing. An annotation appearing +# here means someone added a second mode without declaring it. +check "no redundant x-aauth-access-mode annotation" \ + "$(echo "$OPENAPI" | jq -e '.paths["/"].get["x-aauth-access-mode"] == null' >/dev/null 2>&1 && echo true || echo false)" + +echo + # ── CORS ── echo "--- CORS ---" CORS_HEADERS=$(curl -sf -D - -o /dev/null -X OPTIONS \ diff --git a/src/aauth.ts b/src/aauth.ts new file mode 100644 index 0000000..257f65b --- /dev/null +++ b/src/aauth.ts @@ -0,0 +1,476 @@ +// AAuth -11 protocol constants and shared verification helpers. +// +// These mirror the surface `@aauth/protocol` 1.0.0 exports (TOKEN_TYP, DWK, +// SIGNING_ALG). They are declared locally because this Worker has no @aauth/* +// dependency yet; when `@aauth/resource` 2.0.0 lands, TOKEN_TYP/DWK/SIGNING_ALG +// and the verification helpers below are what it should replace. + +import { computeJwkThumbprint, verifyJWT } from './crypto' + +export const TOKEN_TYP = { + agent: 'aa-agent+jwt', + person: 'aa-person+jwt', + resource: 'aa-resource+jwt', + auth: 'aa-auth+jwt', +} as const + +export const DWK = { + agent: 'aauth-agent.json', + person: 'aauth-person.json', + resource: 'aauth-resource.json', + access: 'aauth-access.json', +} as const + +// RFC 9864 fully-specified identifier. Emitted and accepted; the polymorphic +// `EdDSA` is neither. +export const SIGNING_ALG = 'Ed25519' as const + +// Algorithms this resource refuses to see anywhere a signature is verified or +// a confirmation key is read. `EdDSA` names a family rather than an +// operation; `none` is unsigned; HS* are symmetric, and a shared secret +// cannot prove possession to a verifier that holds it. +const FORBIDDEN_ALGS = new Set([ + 'none', + 'EdDSA', + 'HS256', + 'HS384', + 'HS512', +]) + +export function isForbiddenAlg(alg: unknown): boolean { + return typeof alg === 'string' && FORBIDDEN_ALGS.has(alg) +} + +// ── Server identifiers (protocol §Server Identifiers) ── +// +// https scheme, host only, no port/path/query/fragment, no trailing slash, +// lowercase. Compared with exact string comparison — never normalized, because +// normalizing is how two different identifiers become one. + +export function isServerIdentifier(value: unknown): value is string { + if (typeof value !== 'string' || value === '') return false + if (value !== value.toLowerCase()) return false + let url: URL + try { + url = new URL(value) + } catch { + return false + } + if (url.protocol !== 'https:') return false + if (url.port !== '') return false + if (url.pathname !== '/' || value.endsWith('/')) return false + if (url.search !== '' || url.hash !== '') return false + if (url.username !== '' || url.password !== '') return false + return value === `https://${url.hostname}` +} + +// ── Agent identifiers (protocol §Agent Identifiers) ── +// +// `aauth:local@domain`. The local part is lowercase ASCII letters, digits, +// hyphen, underscore, plus and period, non-empty and at most 255 characters; +// `+` is the sub-agent delimiter. The domain conforms to the server +// identifier requirements without the scheme. Compared with exact, +// case-sensitive string comparison — an agent identifier is global, unlike a +// directed `sub`, but it is no less opaque: the local part MUST NOT be parsed +// for protocol decisions. + +const AGENT_LOCAL = /^[a-z0-9\-_+.]{1,255}$/ + +export function isAgentIdentifier(value: unknown): value is string { + if (typeof value !== 'string' || !value.startsWith('aauth:')) return false + const rest = value.slice('aauth:'.length) + const at = rest.lastIndexOf('@') + if (at <= 0 || at === rest.length - 1) return false + const local = rest.slice(0, at) + const domain = rest.slice(at + 1) + if (!AGENT_LOCAL.test(local)) return false + return isServerIdentifier(`https://${domain}`) +} + +// ── Directed identifiers (protocol §Directed Identifiers) ── +// +// `sub` is unique within its issuer, not globally. A resource MUST treat +// (iss, sub) as the identifier, MUST treat the value as opaque, and MUST NOT +// match a `sub` received from one issuer against a record established under +// another, however the values compare. + +export interface PersonIdentity { + /** Issuer whose namespace `sub` belongs to. Never dropped. */ + iss: string + /** Directed subject identifier. Opaque — never parsed, never normalized. */ + sub: string +} + +/** + * The key a resource records a person under. The issuer is length-prefixed so + * that no two distinct (iss, sub) pairs can produce the same key by moving the + * boundary between them, and the issuer can never be dropped from the key by + * accident: there is no code path that produces a key from `sub` alone. + */ +export function identityRecordKey(identity: PersonIdentity): string { + return `${identity.iss.length}:${identity.iss}|${identity.sub}` +} + +export function sameIdentity(a: PersonIdentity, b: PersonIdentity): boolean { + return a.iss === b.iss && a.sub === b.sub +} + +// ── Confirmation keys ── + +export interface JwkCheckResult { + ok: boolean + error?: string +} + +/** + * Structural checks on a `cnf.jwk`, per auth token verification step 6: reject + * a JWK missing `kty` or the members required for its key type before any + * attempt to decode it. `alg` is REQUIRED and must be fully specified — + * `EdDSA` names a family, not an operation, and a confirmation key that can be + * read two ways is rejected rather than resolved in favour of either reading. + */ +export function checkConfirmationJwk(jwk: unknown): JwkCheckResult { + if (!jwk || typeof jwk !== 'object') return { ok: false, error: 'cnf.jwk is not an object' } + const k = jwk as Record + if (typeof k.kty !== 'string') return { ok: false, error: 'cnf.jwk missing kty' } + if (typeof k.alg !== 'string' || k.alg === '') { + return { ok: false, error: 'cnf.jwk missing a fully-specified alg' } + } + if (isForbiddenAlg(k.alg)) return { ok: false, error: `cnf.jwk alg not allowed: ${k.alg}` } + + const required: Record = { + OKP: ['crv', 'x'], + EC: ['crv', 'x', 'y'], + RSA: ['n', 'e'], + } + const members = required[k.kty] + if (!members) return { ok: false, error: `cnf.jwk unsupported kty: ${k.kty}` } + for (const m of members) { + if (typeof k[m] !== 'string' || k[m] === '') { + return { ok: false, error: `cnf.jwk missing ${m}` } + } + } + if (k.d !== undefined) return { ok: false, error: 'cnf.jwk carries private key material' } + return { ok: true } +} + +/** + * Verify that a token's `cnf.jwk` is the key that signed the HTTP request. + * `signingJkt` is the thumbprint of the key HTTP Message Signature + * verification actually used. + */ +export async function verifyConfirmationKey( + cnf: unknown, + signingJkt: string, +): Promise<{ ok: true; jkt: string } | { ok: false; error: string }> { + const jwk = (cnf as { jwk?: unknown } | undefined)?.jwk + if (jwk === undefined) return { ok: false, error: 'cnf.jwk is required' } + const structural = checkConfirmationJwk(jwk) + if (!structural.ok) return { ok: false, error: structural.error as string } + + let jkt: string + try { + jkt = await computeJwkThumbprint(jwk as JsonWebKey) + } catch (err) { + return { ok: false, error: `cnf.jwk is not usable key material: ${(err as Error).message}` } + } + if (jkt !== signingJkt) { + return { ok: false, error: 'cnf.jwk does not match the key that signed the request' } + } + return { ok: true, jkt } +} + +// ── Issuer key discovery ── + +export type JwksLookup = + | { ok: true; jwks: { keys: JsonWebKey[] }; metadata: Record } + | { ok: false; status: 401 | 502; error: string } + +/** + * Discover an issuer's JWKS via `{iss}/.well-known/{dwk}` per + * I-D.hardt-httpbis-signature-key, and confirm the metadata's `issuer` matches + * the `iss` that named it. + */ +export async function fetchIssuerJwks(iss: string, dwk: string): Promise { + if (!isServerIdentifier(iss)) { + return { ok: false, status: 401, error: `iss is not a valid server identifier: ${iss}` } + } + try { + const metaRes = await fetch(`${iss}/.well-known/${dwk}`) + if (!metaRes.ok) { + return { ok: false, status: 502, error: `Failed to fetch issuer metadata: ${metaRes.status}` } + } + const metadata = (await metaRes.json()) as Record + if (typeof metadata.issuer === 'string' && metadata.issuer !== iss) { + return { ok: false, status: 401, error: 'issuer metadata does not match iss' } + } + const jwksUri = metadata.jwks_uri + if (typeof jwksUri !== 'string' || jwksUri === '') { + return { ok: false, status: 502, error: 'Issuer metadata missing jwks_uri' } + } + const jwksRes = await fetch(jwksUri) + if (!jwksRes.ok) { + return { ok: false, status: 502, error: `Failed to fetch issuer JWKS: ${jwksRes.status}` } + } + const jwks = (await jwksRes.json()) as { keys: JsonWebKey[] } + return { ok: true, jwks, metadata } + } catch (err) { + return { ok: false, status: 502, error: `Cannot reach issuer: ${(err as Error).message}` } + } +} + +// ── Agent token verification (protocol §Agent Token Verification) ── + +export interface AgentIdentity { + /** Agent provider that issued the token. */ + iss: string + /** Agent identifier, `aauth:local@domain`. Stable across key rotations. */ + sub: string + /** The agent's person server, when the token names one. */ + ps?: string + /** Parent agent identifier — present only on a sub-agent's token. */ + parent_agent?: string +} + +export interface VerifiedAgentToken { + identity: AgentIdentity + jkt: string + exp: number +} + +export type AgentTokenResult = + | { ok: true; token: VerifiedAgentToken } + | { ok: false; status: 401 | 502; reason: string; error: string } + +/** + * Verify an agent token for agent identity access — the mode where the + * resource decides on who the agent is, with no PS involved. + * + * The agent token is the one token a resource reads that still carries an + * agent identifier. -11 removed `agent` from person, resource and auth + * tokens; `sub` here is unaffected. + */ +export async function verifyAgentToken( + jwtRaw: string, + payload: Record, + opts: { signingJkt: string; now: number }, +): Promise { + const { signingJkt, now } = opts + + // 2. dwk MUST be aauth-agent.json. + if (payload.dwk !== DWK.agent) { + return { + ok: false, + status: 401, + reason: 'agent_token_bad_dwk', + error: `agent_token dwk must be ${DWK.agent}`, + } + } + + // 4. iss is the agent provider URL and must be a server identifier. + const iss = payload.iss + if (!isServerIdentifier(iss)) { + return { + ok: false, + status: 401, + reason: 'agent_token_bad_iss', + error: 'agent_token iss is not a valid server identifier', + } + } + + const sub = payload.sub + if (!isAgentIdentifier(sub)) { + return { + ok: false, + status: 401, + reason: 'agent_token_bad_sub', + error: 'agent_token sub is not a valid agent identifier', + } + } + + // 3. exp in the future, iat not in the future. + const exp = payload.exp + if (typeof exp !== 'number' || exp < now) { + return { ok: false, status: 401, reason: 'agent_token_expired', error: 'agent_token expired' } + } + if (typeof payload.iat === 'number' && payload.iat > now + 60) { + return { + ok: false, + status: 401, + reason: 'agent_token_iat_future', + error: 'agent_token iat is in the future', + } + } + + // 5. cnf.jwk must be the key that signed the request. + const cnfResult = await verifyConfirmationKey(payload.cnf, signingJkt) + if (!cnfResult.ok) { + return { ok: false, status: 401, reason: 'agent_token_cnf_mismatch', error: cnfResult.error } + } + + // 6. ps, when present, is a server identifier. + const ps = payload.ps + if (ps !== undefined && !isServerIdentifier(ps)) { + return { + ok: false, + status: 401, + reason: 'agent_token_bad_ps', + error: 'agent_token ps is not a valid server identifier', + } + } + + // 7. parent_agent, when present, is an agent identifier. Its presence marks + // a sub-agent; the single-level rule is the PS's to enforce. + const parentAgent = payload.parent_agent + if (parentAgent !== undefined && !isAgentIdentifier(parentAgent)) { + return { + ok: false, + status: 401, + reason: 'agent_token_bad_parent_agent', + error: 'agent_token parent_agent is not a valid agent identifier', + } + } + + // 2. Discover the agent provider's JWKS and verify the JWT signature. + const lookup = await fetchIssuerJwks(iss, DWK.agent) + if (!lookup.ok) { + return { + ok: false, + status: lookup.status, + reason: 'agent_token_key_discovery_failed', + error: lookup.error, + } + } + try { + await verifyJWT(jwtRaw, lookup.jwks) + } catch (err) { + return { + ok: false, + status: 401, + reason: 'agent_token_jwt_verify_failed', + error: `agent_token verification failed: ${(err as Error).message}`, + } + } + + const identity: AgentIdentity = { iss, sub } + if (typeof ps === 'string') identity.ps = ps + if (typeof parentAgent === 'string') identity.parent_agent = parentAgent + return { ok: true, token: { identity, jkt: cnfResult.jkt, exp } } +} + +// ── Person token verification (protocol §Person Token Verification) ── + +export interface VerifiedPersonToken { + /** (iss, sub) — the identity. Never split. */ + identity: PersonIdentity + jti: string + jkt: string + exp: number + mission_s256?: string + tenant?: string +} + +export type PersonTokenResult = + | { ok: true; token: VerifiedPersonToken } + | { ok: false; status: 401 | 502; reason: string; error: string } + +/** + * Verify a person token. Steps follow §Person Token Verification: + * typ (checked by the caller when routing), dwk + JWKS signature, exp/iat, + * iss, aud, cnf.jwk against the request-signing key. + */ +export async function verifyPersonToken( + jwtRaw: string, + payload: Record, + opts: { resource: string; signingJkt: string; now: number }, +): Promise { + const { resource, signingJkt, now } = opts + + // 2. dwk MUST be aauth-person.json — a person token is only ever a PS token. + if (payload.dwk !== DWK.person) { + return { + ok: false, + status: 401, + reason: 'person_token_bad_dwk', + error: `person_token dwk must be ${DWK.person}`, + } + } + + // 4. iss must be a valid server identifier (checked inside fetchIssuerJwks + // before any request is made, so a malformed iss is never fetched). + const iss = payload.iss + if (typeof iss !== 'string') { + return { ok: false, status: 401, reason: 'person_token_missing_iss', error: 'person_token missing iss' } + } + + // 5. aud must be this resource's own identifier, by exact comparison. + if (payload.aud !== resource) { + return { + ok: false, + status: 401, + reason: 'person_token_aud_mismatch', + error: 'person_token aud mismatch', + } + } + + const sub = payload.sub + if (typeof sub !== 'string' || sub === '') { + return { ok: false, status: 401, reason: 'person_token_missing_sub', error: 'person_token missing sub' } + } + + const jti = payload.jti + if (typeof jti !== 'string' || jti === '') { + return { ok: false, status: 401, reason: 'person_token_missing_jti', error: 'person_token missing jti' } + } + + // 3. exp in the future, iat not in the future. + const exp = payload.exp + if (typeof exp !== 'number' || exp < now) { + return { ok: false, status: 401, reason: 'person_token_expired', error: 'person_token expired' } + } + if (typeof payload.iat === 'number' && payload.iat > now + 60) { + return { ok: false, status: 401, reason: 'person_token_iat_future', error: 'person_token iat is in the future' } + } + + // 6. cnf.jwk is REQUIRED and must be the key that signed the request. + const cnfResult = await verifyConfirmationKey(payload.cnf, signingJkt) + if (!cnfResult.ok) { + return { ok: false, status: 401, reason: 'person_token_cnf_mismatch', error: cnfResult.error } + } + + // A person token MUST NOT carry scope or account. + if (payload.scope !== undefined || payload.account !== undefined) { + return { + ok: false, + status: 401, + reason: 'person_token_carries_authorization', + error: 'person_token MUST NOT carry scope or account', + } + } + + // 2. Discover the PS's JWKS and verify the JWT signature. + const lookup = await fetchIssuerJwks(iss, DWK.person) + if (!lookup.ok) { + return { ok: false, status: lookup.status, reason: 'person_token_key_discovery_failed', error: lookup.error } + } + try { + await verifyJWT(jwtRaw, lookup.jwks) + } catch (err) { + return { + ok: false, + status: 401, + reason: 'person_token_jwt_verify_failed', + error: `person_token verification failed: ${(err as Error).message}`, + } + } + + const token: VerifiedPersonToken = { + identity: { iss, sub }, + jti, + jkt: cnfResult.jkt, + exp, + } + if (typeof payload.mission_s256 === 'string') token.mission_s256 = payload.mission_s256 + if (typeof payload.tenant === 'string') token.tenant = payload.tenant + return { ok: true, token } +} diff --git a/src/crypto.ts b/src/crypto.ts index 3cfa4d7..70bb2e5 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -21,7 +21,13 @@ function base64urlDecode(str: string): Uint8Array { } export async function importSigningKey(jwkJson: string): Promise { - const jwk = JSON.parse(jwkJson) + const { alg: _alg, ...jwk } = JSON.parse(jwkJson) + // `alg` is stripped for the same reason it is stripped in verifyJWT: an OKP + // JWK carrying the RFC 9864 fully-specified "Ed25519" is rejected by + // workerd's importKey, which only accepts "EdDSA" or no alg. The algorithm + // is supplied explicitly below, so the member is redundant either way. This + // is what lets the SIGNING_KEY secret carry alg: "Ed25519" without the + // Worker failing to sign. return crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['sign']) } @@ -59,11 +65,20 @@ export function decodeJWTPayload(jwt: string): Record { return JSON.parse(new TextDecoder().decode(base64urlDecode(jwt.split('.')[1]))) } -// JWT alg → WebCrypto parameters +// JWT alg → WebCrypto parameters. +// +// Every entry is a fully-specified identifier. AAuth §Signature Algorithms +// says implementations MUST NOT accept `none`, the polymorphic `EdDSA` +// identifier, or any symmetric algorithm, and states no transition +// allowance — so `EdDSA` is deliberately absent, and a JWT presenting it +// fails with "unsupported alg" before any key is imported. +// +// This is a flag day with the issuers, not a one-sided tightening: Wallet's +// svr/issuer/sign.js signs AAuth token types with `EdDSA` today, and must +// ship `Ed25519` in the same window or whoami rejects the auth tokens +// person.hello.coop issues. const JWT_ALG_PARAMS: Record = { - EdDSA: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' }, - // RFC 9864 fully-specified identifier; peers moving to signature-key -08 - // conventions may mint JWTs with this alg instead of polymorphic EdDSA. + // RFC 9864 fully-specified identifier for Ed25519. Ed25519: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' }, RS256: { importAlgo: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, diff --git a/src/index.ts b/src/index.ts index a81b59c..d46ae4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,9 +12,20 @@ import { getPublicJWK, signJWT, generateJTI, - computeJwkThumbprint, verifyJWT, } from './crypto' +import { + TOKEN_TYP, + DWK, + SIGNING_ALG, + fetchIssuerJwks, + identityRecordKey, + isServerIdentifier, + verifyAgentToken, + verifyConfirmationKey, + verifyPersonToken, + type PersonIdentity, +} from './aauth' import { emit, emitVerifyFailed } from './events' import type { Env } from './types' @@ -46,6 +57,9 @@ app.onError((err, c) => { // AAuth-Requirement from the 401 response and the agent never sees // the resource_token it needs to exchange at the PS. app.use('*', cors({ + // '*' is the value hono has always defaulted to here; recent @types make + // `origin` required, so it is now stated rather than implied. + origin: '*', exposeHeaders: [ 'AAuth-Requirement', 'Accept-Signature', @@ -64,6 +78,9 @@ const PS_IDENTITY_SCOPES: Set = new Set([ 'tenant_sub', 'org', 'groups', 'roles', ]) +// Resource tokens SHOULD NOT have a lifetime exceeding 5 minutes. +const RESOURCE_TOKEN_LIFETIME = 300 + // ── Well-known endpoints ── app.get('/.well-known/aauth-resource.json', (c) => { @@ -75,6 +92,12 @@ app.get('/.well-known/aauth-resource.json', (c) => { description: 'Echoes back the identity claims a resource sees from your AAuth credentials — a minimal resource for testing identity-based access.', logo_uri: `${origin}/logo.png`, + // The lowest bar that gets a useful answer: an agent token alone returns + // the agent's own identity. A person token returns the person's. Asking + // for identity scopes with ?scope= escalates to auth-token. Declaring the + // minimum means no agent skips this resource for a setup it does have; + // the runtime AAuth-Requirement is authoritative in every case. + access_mode: 'agent-token', scope_descriptions: { whoami: 'Echo your provided identity claims', }, @@ -91,15 +114,21 @@ app.get('/.well-known/jwks.json', async (c) => { // ── Main endpoint ── // -// Three outcomes based on what the caller presents: +// Four outcomes based on what the caller presents: // // 1. No HTTP signature → 401 + Accept-Signature header // (tells the agent what signature scheme we expect) // -// 2. agent_token in Signature-Key → 401 + AAuth-Requirement header -// (resource token the agent takes to its PS for an auth_token) +// 2. agent_token in Signature-Key → 200 + the agent's own identity (agent +// identity access), or, when ?scope= asks about a person, 401 + +// AAuth-Requirement: requirement=person-token — a resource MUST have +// verified a person token before it issues a resource token // -// 3. auth_token in Signature-Key → 200 + identity claims as JSON +// 3. person_token in Signature-Key → 200 + the person's identity (iss, sub), +// or, when ?scope= asks for identity claims, 401 + AAuth-Requirement +// carrying a resource token the agent takes to its PS for an auth_token +// +// 4. auth_token in Signature-Key → 200 + identity claims as JSON app.get('/', async (c) => { const url = new URL(c.req.url) @@ -171,12 +200,20 @@ app.get('/', async (c) => { const jwtRaw = sigResult.jwt.raw // ── auth_token → verify and return identity claims ── - if (jwtHeader.typ === 'aa-auth+jwt') { + // + // Only `typ` distinguishes an auth token from a person token, so the branch + // is exact-match: an aa-person+jwt never reaches the auth-token path. + if (jwtHeader.typ === TOKEN_TYP.auth) { return handleAuthToken(c, jwtRaw, jwtPayload, sigResult.thumbprint) } - // ── agent_token → mint resource token, return 401 ── - if (jwtHeader.typ === 'aa-agent+jwt') { + // ── person_token → identity, or mint a resource token ── + if (jwtHeader.typ === TOKEN_TYP.person) { + return handlePersonToken(c, jwtRaw, jwtPayload, sigResult.thumbprint) + } + + // ── agent_token → agent identity, or 401 requirement=person-token ── + if (jwtHeader.typ === TOKEN_TYP.agent) { return handleAgentToken(c, jwtRaw, jwtPayload, sigResult.thumbprint) } @@ -192,27 +229,29 @@ async function handleAuthToken( payload: Record, callerJkt: string, ) { - // Verify JWT against issuer's JWKS (the Person Server) + // dwk names the metadata document the issuer's keys are discovered through: + // aauth-person.json from a PS asserting identity, aauth-access.json from an + // AS. Anything else is not an auth token issuer. const iss = payload.iss as string | undefined - const dwk = (payload.dwk as string) || 'aauth-person.json' + const dwk = payload.dwk if (!iss) return c.json({ error: 'auth_token missing iss' }, 401) + if (dwk !== DWK.person && dwk !== DWK.access) { + emitVerifyFailed(c, 'auth_token_bad_dwk', { iss, dwk }) + return c.json({ error: `auth_token dwk must be ${DWK.person} or ${DWK.access}` }, 401) + } + if (!isServerIdentifier(iss)) { + emitVerifyFailed(c, 'auth_token_bad_iss', { iss }) + return c.json({ error: 'auth_token iss is not a valid server identifier' }, 401) + } - let jwks: { keys: JsonWebKey[] } - try { - const metaRes = await fetch(`${iss}/.well-known/${dwk}`) - if (!metaRes.ok) return c.json({ error: `Failed to fetch issuer metadata: ${metaRes.status}` }, 502) - const meta = (await metaRes.json()) as Record - const jwksUri = meta.jwks_uri as string - if (!jwksUri) return c.json({ error: 'Issuer metadata missing jwks_uri' }, 502) - const jwksRes = await fetch(jwksUri) - if (!jwksRes.ok) return c.json({ error: `Failed to fetch issuer JWKS: ${jwksRes.status}` }, 502) - jwks = (await jwksRes.json()) as { keys: JsonWebKey[] } - } catch (err) { - return c.json({ error: `Cannot reach issuer: ${(err as Error).message}` }, 502) + const lookup = await fetchIssuerJwks(iss, dwk) + if (!lookup.ok) { + emitVerifyFailed(c, 'auth_token_key_discovery_failed', { iss, detail: lookup.error }) + return c.json({ error: lookup.error }, lookup.status) } try { - await verifyJWT(jwtRaw, jwks) + await verifyJWT(jwtRaw, lookup.jwks) } catch (err) { emitVerifyFailed(c, 'auth_token_jwt_verify_failed', { iss, @@ -237,6 +276,23 @@ async function handleAuthToken( return c.json({ error: 'auth_token expired' }, 401) } + // cnf.jwk is REQUIRED and must be the key that signed this request. + const cnfResult = await verifyConfirmationKey(payload.cnf, callerJkt) + if (!cnfResult.ok) { + emitVerifyFailed(c, 'auth_token_cnf_mismatch', { iss, detail: cnfResult.error }) + return c.json({ error: `auth_token ${cnfResult.error}` }, 401) + } + + // Verification step 7: `sub` is present, and (iss, sub) matches or + // establishes this resource's record for the person. The value is opaque — + // whoami never parses it, and never compares it to a sub from another iss. + const sub = payload.sub + if (typeof sub !== 'string' || sub === '') { + emitVerifyFailed(c, 'auth_token_missing_sub', { iss }) + return c.json({ error: 'auth_token missing sub' }, 401) + } + const identity: PersonIdentity = { iss, sub } + const scopeStr = typeof payload.scope === 'string' ? payload.scope : '' const scopes = scopeStr.split(/\s+/).filter(Boolean) if (!scopes.includes('whoami')) { @@ -248,9 +304,14 @@ async function handleAuthToken( return c.json({ error: 'insufficient_scope', required: 'whoami', granted: scopes }, 403) } - // Return identity claims — strip JWT infrastructure claims - const INFRA_CLAIMS = new Set(['iss', 'aud', 'exp', 'iat', 'jti', 'cnf', 'dwk', 'act', 'scope']) - const claims: Record = {} + // Return identity claims. `iss` and `sub` are released together and first: + // a directed sub on its own names nobody, so whoami never hands one out + // without the issuer whose namespace it belongs to. + const INFRA_CLAIMS = new Set([ + 'iss', 'sub', 'aud', 'exp', 'iat', 'jti', 'cnf', 'dwk', 'act', 'scope', + 'ps', 'mission_s256', + ]) + const claims: Record = { iss: identity.iss, sub: identity.sub } for (const [key, value] of Object.entries(payload)) { if (!INFRA_CLAIMS.has(key)) { claims[key] = value @@ -260,8 +321,11 @@ async function handleAuthToken( emit(c, { event: 'aauth.whoami.auth_verified', msg: 'auth_token verified', - iss, - agent_sub: payload.sub, + person_iss: identity.iss, + person_sub: identity.sub, + identity_key: identityRecordKey(identity), + ps: payload.ps, + mission_s256: payload.mission_s256, scope: scopeStr, jkt: callerJkt, }) @@ -270,6 +334,24 @@ async function handleAuthToken( } // ── Agent token handler ── +// +// Two outcomes, and which one applies is decided by what the caller asked +// for, not by what it presented. +// +// With no scopes requested, this is agent identity access +// (#overview-identity-access): the resource verifies the agent token and +// answers with the agent's own identity. No PS, no authorization flow — the +// API-key replacement. The agent token is the one token a resource reads that +// still carries an agent identifier; -11 removed `agent` from person, +// resource and auth tokens, not from here. +// +// With scopes requested, the caller is asking about a *person*, and an agent +// token cannot produce one: a resource MUST have verified a person token +// before it issues a resource token. So the response is the +// requirement=person-token challenge, which carries no parameters — and the +// agent token is neither fetched nor verified on that path, because nothing +// in it would change the answer and following its `iss` would let an attacker +// drive outbound requests from this Worker for free. async function handleAgentToken( c: import('hono').Context, @@ -277,72 +359,101 @@ async function handleAgentToken( payload: Record, callerJkt: string, ) { - // Verify agent_token against its issuer's JWKS (the agent server) - const agentIss = payload.iss as string | undefined - const agentDwk = (payload.dwk as string) || 'aauth-agent.json' - if (!agentIss) return c.json({ error: 'agent_token missing iss' }, 401) + const scopeParam = c.req.query('scope') || '' + const requestedScopes = scopeParam.trim().split(/\s+/).filter(Boolean) - let jwks: { keys: JsonWebKey[] } - try { - const metaRes = await fetch(`${agentIss}/.well-known/${agentDwk}`) - if (!metaRes.ok) return c.json({ error: `Failed to fetch agent server metadata: ${metaRes.status}` }, 502) - const meta = (await metaRes.json()) as Record - const jwksUri = meta.jwks_uri as string - if (!jwksUri) return c.json({ error: 'Agent server metadata missing jwks_uri' }, 502) - const jwksRes = await fetch(jwksUri) - if (!jwksRes.ok) return c.json({ error: `Failed to fetch agent server JWKS: ${jwksRes.status}` }, 502) - jwks = (await jwksRes.json()) as { keys: JsonWebKey[] } - } catch (err) { - return c.json({ error: `Cannot reach agent server: ${(err as Error).message}` }, 502) + if (requestedScopes.length > 0) { + emit(c, { + event: 'aauth.whoami.person_token_required', + msg: 'agent_token presented with a scope request; challenging for a person token', + requested_scope: requestedScopes.join(' '), + }) + return c.json( + { error: 'person_token_required' }, + { + status: 401, + headers: { + 'AAuth-Requirement': 'requirement=person-token', + }, + }, + ) } - try { - await verifyJWT(jwtRaw, jwks) - } catch (err) { - emitVerifyFailed(c, 'agent_token_jwt_verify_failed', { - iss: agentIss, - detail: (err as Error).message, - }) - return c.json({ error: `agent_token verification failed: ${(err as Error).message}` }, 401) + const now = Math.floor(Date.now() / 1000) + const result = await verifyAgentToken(jwtRaw, payload, { signingJkt: callerJkt, now }) + if (!result.ok) { + emitVerifyFailed(c, result.reason, { iss: payload.iss, detail: result.error }) + return c.json({ error: result.error }, result.status) } + const { identity } = result.token + + // An agent identifier is global and self-qualifying (`aauth:local@domain`), + // unlike a person's directed `sub` — but it is just as opaque. `iss` is + // released alongside it because it names the provider that vouched for it. + const body: Record = { iss: identity.iss, sub: identity.sub } + if (identity.ps) body.ps = identity.ps + if (identity.parent_agent) body.parent_agent = identity.parent_agent + + emit(c, { + event: 'aauth.whoami.agent_identity_returned', + msg: 'agent_token verified; returning agent identity', + agent_iss: identity.iss, + agent_sub: identity.sub, + agent_jkt: result.token.jkt, + ps: identity.ps, + }) + + return c.json(body) +} +// ── Person token handler ── +// +// The person token is what makes the identity this resource records +// PS-asserted rather than agent-asserted. With no scopes requested it is +// enough on its own — whoami serves the identity it just verified. With +// scopes requested it becomes the basis of a resource token. + +async function handlePersonToken( + c: import('hono').Context, + jwtRaw: string, + payload: Record, + callerJkt: string, +) { + const origin = c.env.ORIGIN const now = Math.floor(Date.now() / 1000) - if (!payload.exp || (payload.exp as number) < now) { - emitVerifyFailed(c, 'agent_token_expired', { iss: agentIss, exp: payload.exp }) - return c.json({ error: 'agent_token expired' }, 401) + + const result = await verifyPersonToken(jwtRaw, payload, { + resource: origin, + signingJkt: callerJkt, + now, + }) + if (!result.ok) { + emitVerifyFailed(c, result.reason, { iss: payload.iss, detail: result.error }) + return c.json({ error: result.error }, result.status) } + const person = result.token + const { identity } = person - // If no scope requested, return the agent's own identity directly const scopeParam = c.req.query('scope') || '' const requestedScopes = scopeParam.trim().split(/\s+/).filter(Boolean) + // No scopes requested — identity access. The person token already carries + // the person's identity at this resource, so no resource token is needed. if (requestedScopes.length === 0) { - const identity: Record = { sub: payload.sub } - if (payload.ps) identity.ps = payload.ps + const body: Record = { iss: identity.iss, sub: identity.sub } + // tenant is organizational context, not part of the identifier. + if (person.tenant) body.tenant = person.tenant emit(c, { - event: 'aauth.whoami.agent_identity_returned', - msg: 'agent_token verified; no scopes requested, returning identity', - agent_sub: payload.sub, - agent_jkt: callerJkt, - ps: payload.ps, + event: 'aauth.whoami.person_identity_returned', + msg: 'person_token verified; no scopes requested, returning identity', + person_iss: identity.iss, + person_sub: identity.sub, + identity_key: identityRecordKey(identity), + person_token_jti: person.jti, + mission_s256: person.mission_s256, + agent_jkt: person.jkt, }) - return c.json(identity) - } - - // PS URL from agent_token's ps claim - const psUrl = payload.ps as string | undefined - if (!psUrl) return c.json({ error: 'agent_token missing ps claim' }, 400) - - // Fetch PS metadata for resource_token aud - let psIssuer: string - try { - const psRes = await fetch(`${psUrl}/.well-known/aauth-person.json`) - if (!psRes.ok) return c.json({ error: `Failed to fetch PS metadata: ${psRes.status}` }, 502) - const psMeta = (await psRes.json()) as Record - if (!psMeta.issuer) return c.json({ error: 'PS metadata missing issuer' }, 502) - psIssuer = psMeta.issuer as string - } catch (err) { - return c.json({ error: `Cannot reach PS: ${(err as Error).message}` }, 502) + return c.json(body) } // Build scope: always "whoami" + requested identity scopes from ?scope= @@ -352,38 +463,59 @@ async function handleAgentToken( } const scopeString = ['whoami', ...requestedScopes].join(' ') - // Mint resource token - const origin = c.env.ORIGIN + // Mint resource token. `aud` is the PS that issued the person token — the + // same issuer whose namespace `sub` belongs to. const privateKey = await importSigningKey(c.env.SIGNING_KEY) const publicJwk = await getPublicJWK(c.env.SIGNING_KEY) - const cnf = payload.cnf as { jwk: JsonWebKey } | undefined - if (!cnf?.jwk) return c.json({ error: 'agent_token missing cnf.jwk' }, 400) - const agentJkt = await computeJwkThumbprint(cnf.jwk) - const rtHeader = { alg: 'Ed25519', typ: 'aa-resource+jwt', kid: publicJwk.kid } - const rtPayload = { + // A resource token derived from a person token must not outlive it. + // + // This is the most a resource can do about mission expiry, and it is not + // the mission clamp. -11 says a token carrying `mission_s256` MUST NOT + // expire after the mission's `expires_at` — but a resource only ever sees + // `mission_s256`, a hash, and has no endpoint that turns it back into an + // expiry. The real clamp lives at the PS, which holds the approved mission + // and re-checks it when it resolves `person_token_jti`. Do not "fix" this + // by trying to read an expiry the resource cannot have. + const exp = Math.min(now + RESOURCE_TOKEN_LIFETIME, person.exp) + + const rtHeader = { alg: SIGNING_ALG, typ: TOKEN_TYP.resource, kid: publicJwk.kid } + const rtPayload: Record = { iss: origin, - dwk: 'aauth-resource.json', - aud: psIssuer, + dwk: DWK.resource, + aud: identity.iss, jti: generateJTI(), - agent: payload.sub as string, - agent_jkt: agentJkt, + // ps, sub and person_token_jti are copied from the person token this + // resource verified. There is no agent claim in -11 — agent_jkt binds the + // token to the agent's key, and the PS learns the agent's identity from + // the agent token that signs the token request. + ps: identity.iss, + sub: identity.sub, + person_token_jti: person.jti, + agent_jkt: person.jkt, scope: scopeString, iat: now, - exp: now + 300, + exp, } + // REQUIRED when the person token carried one — a resource MUST NOT omit it. + if (person.mission_s256) rtPayload.mission_s256 = person.mission_s256 + if (person.tenant) rtPayload.tenant = person.tenant const resourceToken = await signJWT(rtHeader, rtPayload, privateKey) emit(c, { event: 'aauth.whoami.resource_token_minted', - msg: 'resource_token minted for agent', - agent_sub: payload.sub, - agent_jkt: agentJkt, + msg: 'resource_token minted from verified person_token', + person_iss: identity.iss, + person_sub: identity.sub, + identity_key: identityRecordKey(identity), + person_token_jti: person.jti, + mission_s256: person.mission_s256, + agent_jkt: person.jkt, caller_jkt: callerJkt, granted_scope: scopeString, requested_scope: requestedScopes.join(' '), - ps_issuer: psIssuer, + ps_issuer: identity.iss, }) return c.json( diff --git a/test/aauth.test.ts b/test/aauth.test.ts new file mode 100644 index 0000000..0990ad5 --- /dev/null +++ b/test/aauth.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { + DWK, + SIGNING_ALG, + TOKEN_TYP, + checkConfirmationJwk, + identityRecordKey, + isAgentIdentifier, + isForbiddenAlg, + isServerIdentifier, + sameIdentity, +} from '../src/aauth' + +describe('constants', () => { + it('names the -11 token types and metadata documents', () => { + expect(TOKEN_TYP).toEqual({ + agent: 'aa-agent+jwt', + person: 'aa-person+jwt', + resource: 'aa-resource+jwt', + auth: 'aa-auth+jwt', + }) + expect(DWK.person).toBe('aauth-person.json') + expect(DWK.resource).toBe('aauth-resource.json') + expect(DWK.access).toBe('aauth-access.json') + expect(SIGNING_ALG).toBe('Ed25519') + }) + + it('forbids polymorphic, symmetric and unsigned algorithms', () => { + expect(isForbiddenAlg('EdDSA')).toBe(true) + expect(isForbiddenAlg('none')).toBe(true) + expect(isForbiddenAlg('HS256')).toBe(true) + expect(isForbiddenAlg('Ed25519')).toBe(false) + }) +}) + +describe('isServerIdentifier', () => { + it.each([ + 'https://agent.example', + 'https://xn--nxasmq6b.example', + 'https://a.b.c.example', + ])('accepts %s', (v) => { + expect(isServerIdentifier(v)).toBe(true) + }) + + it.each([ + ['http://agent.example', 'not https'], + ['https://Agent.Example', 'not lowercase'], + ['https://agent.example:8443', 'has a port'], + ['https://agent.example/v1', 'has a path'], + ['https://agent.example/', 'trailing slash'], + ['https://agent.example?a=b', 'has a query'], + ['https://agent.example#f', 'has a fragment'], + ['https://user@agent.example', 'has userinfo'], + ['agent.example', 'not a URL'], + ['', 'empty'], + ])('rejects %s (%s)', (v) => { + expect(isServerIdentifier(v)).toBe(false) + }) +}) + +describe('isAgentIdentifier', () => { + it.each([ + 'aauth:assistant-v2@agent.example', + 'aauth:planner.7f3c@vendor.example', + 'aauth:planner.7f3c+search1@vendor.example', + 'aauth:a_b+c.d-e@xn--nxasmq6b.example', + ])('accepts %s', (v) => { + expect(isAgentIdentifier(v)).toBe(true) + }) + + it.each([ + ['My Agent@agent.example', 'no scheme, uppercase and space'], + ['aauth:My.Agent@agent.example', 'uppercase in local part'], + ['aauth:@agent.example', 'empty local part'], + ['aauth:agent@http://agent.example', 'domain includes scheme'], + ['aauth:agent@agent.example:8443', 'domain has a port'], + ['aauth:agent@', 'empty domain'], + ['https://agent.example/agents/1', 'a URL, not an agent identifier'], + ])('rejects %s (%s)', (v) => { + expect(isAgentIdentifier(v)).toBe(false) + }) + + it('rejects a local part over 255 characters', () => { + expect(isAgentIdentifier(`aauth:${'a'.repeat(256)}@agent.example`)).toBe(false) + expect(isAgentIdentifier(`aauth:${'a'.repeat(255)}@agent.example`)).toBe(true) + }) +}) + +describe('directed identifiers', () => { + const sub = '8f14e45fceea167a5a36dedd4bea2543' + + it('keys a person on (iss, sub)', () => { + expect(sameIdentity({ iss: 'https://ps.example', sub }, { iss: 'https://ps.example', sub })).toBe( + true, + ) + }) + + it('never matches the same sub under a different issuer', () => { + const a = { iss: 'https://ps.example', sub } + const b = { iss: 'https://other.example', sub } + expect(sameIdentity(a, b)).toBe(false) + expect(identityRecordKey(a)).not.toBe(identityRecordKey(b)) + }) + + it('cannot be made to collide by moving the iss/sub boundary', () => { + // Without the length prefix, ("https://a.example", "x|y") and + // ("https://a.example|x", "y") would produce the same key. + const a = identityRecordKey({ iss: 'https://a.example', sub: 'x|y' }) + const b = identityRecordKey({ iss: 'https://a.example|x', sub: 'y' }) + expect(a).not.toBe(b) + }) + + it('treats the sub as opaque', () => { + // Values that differ only by case, whitespace or encoding are different + // identifiers — nothing normalizes them. + const base = { iss: 'https://ps.example', sub: 'Alice' } + expect(identityRecordKey(base)).not.toBe( + identityRecordKey({ iss: 'https://ps.example', sub: 'alice' }), + ) + expect(identityRecordKey(base)).not.toBe( + identityRecordKey({ iss: 'https://ps.example', sub: 'Alice ' }), + ) + }) +}) + +describe('checkConfirmationJwk', () => { + const okp = { kty: 'OKP', crv: 'Ed25519', x: 'abc', alg: 'Ed25519' } + + it('accepts a fully-specified OKP key', () => { + expect(checkConfirmationJwk(okp).ok).toBe(true) + }) + + it('rejects a missing alg', () => { + const { alg: _alg, ...noAlg } = okp + expect(checkConfirmationJwk(noAlg).ok).toBe(false) + }) + + it('rejects a polymorphic alg', () => { + expect(checkConfirmationJwk({ ...okp, alg: 'EdDSA' }).ok).toBe(false) + }) + + it('rejects a symmetric key', () => { + expect(checkConfirmationJwk({ kty: 'oct', k: 'secret', alg: 'HS256' }).ok).toBe(false) + }) + + it('rejects a structurally incomplete key before decoding it', () => { + expect(checkConfirmationJwk({ kty: 'OKP', alg: 'Ed25519' }).ok).toBe(false) + expect(checkConfirmationJwk({ kty: 'EC', crv: 'P-256', x: 'a', alg: 'ES256' }).ok).toBe(false) + expect(checkConfirmationJwk({ kty: 'RSA', n: 'a', alg: 'RS256' }).ok).toBe(false) + }) + + it('rejects a key carrying private material', () => { + expect(checkConfirmationJwk({ ...okp, d: 'secret' }).ok).toBe(false) + }) +}) diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..c0ee78d --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,214 @@ +// Test fixtures: Ed25519 keys, AAuth token minting, and signed requests. + +import { fetch as httpSigFetch, calculateThumbprint } from '@hellocoop/httpsig' +import app from '../src/index' +import type { Env } from '../src/types' + +export const RESOURCE = 'https://whoami.aauth.dev' +export const PS = 'https://ps.example' +export const OTHER_PS = 'https://other-ps.example' +export const AS = 'https://as.example' +export const AGENT_PROVIDER = 'https://agent.example' +export const AGENT_SUB = 'aauth:assistant-v2@agent.example' + +export interface KeyPair { + privateJwk: JsonWebKey & { alg: string } + publicJwk: JsonWebKey & { alg: string; kid: string } + privateKey: CryptoKey +} + +export async function makeKeyPair(): Promise { + const pair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair + const rawPrivate = (await crypto.subtle.exportKey('jwk', pair.privateKey)) as JsonWebKey + const rawPublic = (await crypto.subtle.exportKey('jwk', pair.publicKey)) as JsonWebKey + // RFC 9864 fully-specified identifier — never the polymorphic "EdDSA". + const privateJwk = { ...rawPrivate, alg: 'Ed25519' } + const { d: _d, key_ops: _ops, ext: _ext, ...pub } = rawPublic as unknown as Record + const publicBase = { ...pub, alg: 'Ed25519' } as JsonWebKey & { alg: string } + const kid = await calculateThumbprint(publicBase) + return { + privateJwk: privateJwk as JsonWebKey & { alg: string }, + publicJwk: { ...publicBase, kid }, + privateKey: pair.privateKey, + } +} + +const enc = new TextEncoder() + +function b64url(bytes: Uint8Array): string { + let binary = '' + for (const b of bytes) binary += String.fromCharCode(b) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export async function mintJwt( + header: Record, + payload: Record, + key: CryptoKey, +): Promise { + const h = b64url(enc.encode(JSON.stringify(header))) + const p = b64url(enc.encode(JSON.stringify(payload))) + const sig = await crypto.subtle.sign('Ed25519', key, enc.encode(`${h}.${p}`)) + return `${h}.${p}.${b64url(new Uint8Array(sig))}` +} + +export function decodeJwt(jwt: string): { + header: Record + payload: Record +} { + const parts = jwt.split('.') + const dec = (s: string) => { + const padded = s + '='.repeat((4 - (s.length % 4)) % 4) + const bin = atob(padded.replace(/-/g, '+').replace(/_/g, '/')) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + return JSON.parse(new TextDecoder().decode(bytes)) + } + return { header: dec(parts[0]), payload: dec(parts[1]) } +} + +export const now = () => Math.floor(Date.now() / 1000) + +// ── Token builders ── + +export interface TokenOverrides { + header?: Record + payload?: Record + drop?: string[] +} + +function applyOverrides( + header: Record, + payload: Record, + o: TokenOverrides = {}, +) { + const h = { ...header, ...(o.header ?? {}) } + const p = { ...payload, ...(o.payload ?? {}) } + for (const k of o.drop ?? []) delete p[k] + return { h, p } +} + +export async function personToken( + psKey: KeyPair, + agent: KeyPair, + o: TokenOverrides = {}, +): Promise { + const t = now() + const { h, p } = applyOverrides( + { alg: 'Ed25519', typ: 'aa-person+jwt', kid: psKey.publicJwk.kid }, + { + iss: PS, + dwk: 'aauth-person.json', + aud: RESOURCE, + sub: 'directed-sub-for-whoami', + cnf: { jwk: agent.publicJwk }, + jti: 'pt-1', + iat: t, + exp: t + 3600, + }, + o, + ) + return mintJwt(h, p, psKey.privateKey) +} + +export async function agentToken( + agentServerKey: KeyPair, + agent: KeyPair, + o: TokenOverrides = {}, +): Promise { + const t = now() + const { h, p } = applyOverrides( + { alg: 'Ed25519', typ: 'aa-agent+jwt', kid: agentServerKey.publicJwk.kid }, + { + iss: AGENT_PROVIDER, + dwk: 'aauth-agent.json', + sub: 'aauth:assistant-v2@agent.example', + ps: PS, + cnf: { jwk: agent.publicJwk }, + jti: 'at-1', + iat: t, + exp: t + 3600, + }, + o, + ) + return mintJwt(h, p, agentServerKey.privateKey) +} + +export async function authToken( + psKey: KeyPair, + agent: KeyPair, + o: TokenOverrides = {}, +): Promise { + const t = now() + const { h, p } = applyOverrides( + { alg: 'Ed25519', typ: 'aa-auth+jwt', kid: psKey.publicJwk.kid }, + { + iss: PS, + dwk: 'aauth-person.json', + aud: RESOURCE, + jti: 'auth-1', + ps: PS, + sub: 'directed-sub-for-whoami', + cnf: { jwk: agent.publicJwk }, + scope: 'whoami email', + email: 'alice@example.com', + name: 'Alice Example', + iat: t, + exp: t + 3600, + }, + o, + ) + return mintJwt(h, p, psKey.privateKey) +} + +// ── Worker invocation ── + +export function makeEnv(signingKey: string): { env: Env; sent: unknown[] } { + const sent: unknown[] = [] + const env = { + ORIGIN: RESOURCE, + SIGNING_KEY: signingKey, + EVENTS_QUEUE: { + send: async (msg: unknown) => { + sent.push(msg) + }, + }, + } as unknown as Env + return { env, sent } +} + +const waited: Promise[] = [] +export const execCtx = { + waitUntil: (p: Promise) => { + waited.push(p) + }, + passThroughOnException: () => {}, +} as unknown as ExecutionContext + +/** Build the RFC 9421 signature headers for a GET, without sending anything. */ +export async function signHeaders( + url: string, + agent: KeyPair, + jwt: string, +): Promise { + const { headers } = await httpSigFetch(url, { + signingKey: agent.privateJwk, + signatureKey: { type: 'jwt', jwt }, + dryRun: true, + }) + return headers +} + +export async function callWhoami( + path: string, + env: Env, + agent?: KeyPair, + jwt?: string, +): Promise { + const url = `${RESOURCE}${path}` + const headers = agent && jwt ? await signHeaders(url, agent, jwt) : new Headers() + return app.request(url, { method: 'GET', headers }, env, execCtx) +} diff --git a/test/whoami.test.ts b/test/whoami.test.ts new file mode 100644 index 0000000..384db23 --- /dev/null +++ b/test/whoami.test.ts @@ -0,0 +1,462 @@ +import { beforeAll, beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_PROVIDER, + AGENT_SUB, + AS, + OTHER_PS, + PS, + RESOURCE, + agentToken, + authToken, + callWhoami, + decodeJwt, + makeEnv, + makeKeyPair, + now, + personToken, + type KeyPair, +} from './helpers' +import type { Env } from '../src/types' + +// A mission the agent is operating under. The hash is all a resource ever +// sees — the AAuth-Mission header was removed in -11. +const MISSION_S256 = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + +let resourceKey: KeyPair +let psKey: KeyPair +let otherPsKey: KeyPair +let asKey: KeyPair +let agentServerKey: KeyPair +let agent: KeyPair +let env: Env +let sent: unknown[] +let outbound: string[] + +function jwksResponse(key: KeyPair) { + return new Response(JSON.stringify({ keys: [key.publicJwk] }), { + headers: { 'content-type': 'application/json' }, + }) +} + +function metadataResponse(issuer: string) { + return new Response( + JSON.stringify({ issuer, jwks_uri: `${issuer}/.well-known/jwks.json` }), + { headers: { 'content-type': 'application/json' } }, + ) +} + +beforeAll(async () => { + ;[resourceKey, psKey, otherPsKey, asKey, agentServerKey, agent] = await Promise.all([ + makeKeyPair(), + makeKeyPair(), + makeKeyPair(), + makeKeyPair(), + makeKeyPair(), + makeKeyPair(), + ]) +}) + +beforeEach(() => { + const made = makeEnv(JSON.stringify(resourceKey.privateJwk)) + env = made.env + sent = made.sent + outbound = [] + + vi.stubGlobal('fetch', async (input: RequestInfo | URL) => { + const url = String(input) + outbound.push(url) + if (url === `${PS}/.well-known/aauth-person.json`) return metadataResponse(PS) + if (url === `${PS}/.well-known/jwks.json`) return jwksResponse(psKey) + if (url === `${OTHER_PS}/.well-known/aauth-person.json`) return metadataResponse(OTHER_PS) + if (url === `${OTHER_PS}/.well-known/jwks.json`) return jwksResponse(otherPsKey) + if (url === `${AS}/.well-known/aauth-access.json`) return metadataResponse(AS) + if (url === `${AS}/.well-known/jwks.json`) return jwksResponse(asKey) + if (url === `${AGENT_PROVIDER}/.well-known/aauth-agent.json`) { + return metadataResponse(AGENT_PROVIDER) + } + if (url === `${AGENT_PROVIDER}/.well-known/jwks.json`) return jwksResponse(agentServerKey) + return new Response('not found', { status: 404 }) + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('metadata', () => { + it('declares the lowest access mode that gets an answer', async () => { + const res = await callWhoami('/.well-known/aauth-resource.json', env) + const body = (await res.json()) as Record + expect(res.status).toBe(200) + expect(body.issuer).toBe(RESOURCE) + expect(body.access_mode).toBe('agent-token') + }) + + it('publishes a public key with a fully-specified alg', async () => { + const res = await callWhoami('/.well-known/jwks.json', env) + const body = (await res.json()) as { keys: Record[] } + expect(body.keys[0].alg).toBe('Ed25519') + expect(body.keys[0].d).toBeUndefined() + expect(body.keys[0].kid).toBeTruthy() + }) +}) + +describe('no signature', () => { + it('challenges with Accept-Signature', async () => { + const res = await callWhoami('/', env) + expect(res.status).toBe(401) + expect(res.headers.get('accept-signature')).toBeTruthy() + expect(res.headers.get('accept-signature-scheme')).toContain('jwt') + }) +}) + +describe('agent token — agent identity access', () => { + it('returns the agent identity with no PS involved', async () => { + const jwt = await agentToken(agentServerKey, agent) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + iss: AGENT_PROVIDER, + sub: AGENT_SUB, + ps: PS, + }) + expect(outbound).not.toContain(`${PS}/.well-known/aauth-person.json`) + }) + + it('omits ps when the agent token names none', async () => { + const jwt = await agentToken(agentServerKey, agent, { drop: ['ps'] }) + const res = await callWhoami('/', env, agent, jwt) + expect(await res.json()).toEqual({ iss: AGENT_PROVIDER, sub: AGENT_SUB }) + }) + + it('surfaces parent_agent on a sub-agent token', async () => { + const jwt = await agentToken(agentServerKey, agent, { + payload: { + sub: 'aauth:planner.7f3c+search1@agent.example', + parent_agent: 'aauth:planner.7f3c@agent.example', + }, + }) + const body = (await (await callWhoami('/', env, agent, jwt)).json()) as Record + expect(body.sub).toBe('aauth:planner.7f3c+search1@agent.example') + expect(body.parent_agent).toBe('aauth:planner.7f3c@agent.example') + }) + + it('rejects a sub that is not an agent identifier', async () => { + const jwt = await agentToken(agentServerKey, agent, { + payload: { sub: 'https://agent.example/agents/1' }, + }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + expect(outbound).toEqual([]) + }) + + it('rejects a wrong dwk', async () => { + const jwt = await agentToken(agentServerKey, agent, { payload: { dwk: 'aauth-person.json' } }) + expect((await callWhoami('/', env, agent, jwt)).status).toBe(401) + }) + + it('rejects a signature from a key the agent provider does not publish', async () => { + const jwt = await agentToken(psKey, agent, { header: { kid: agentServerKey.publicJwk.kid } }) + expect((await callWhoami('/', env, agent, jwt)).status).toBe(401) + }) + + it('rejects an expired token', async () => { + const t = now() + const jwt = await agentToken(agentServerKey, agent, { payload: { iat: t - 7200, exp: t - 60 } }) + expect((await callWhoami('/', env, agent, jwt)).status).toBe(401) + }) +}) + +describe('agent token — asking about a person', () => { + it('is challenged with requirement=person-token', async () => { + const jwt = await agentToken(agentServerKey, agent) + const res = await callWhoami('/?scope=email', env, agent, jwt) + expect(res.status).toBe(401) + expect(res.headers.get('aauth-requirement')).toBe('requirement=person-token') + expect(await res.json()).toEqual({ error: 'person_token_required' }) + }) + + it('gets no resource token', async () => { + const jwt = await agentToken(agentServerKey, agent) + const res = await callWhoami('/?scope=email', env, agent, jwt) + expect(res.headers.get('aauth-requirement')).not.toContain('resource-token') + }) + + it('makes no outbound request — nothing in the agent token changes the answer', async () => { + const jwt = await agentToken(agentServerKey, agent, { + payload: { iss: 'https://attacker.example' }, + }) + await callWhoami('/?scope=email', env, agent, jwt) + expect(outbound).toEqual([]) + }) +}) + +describe('person token — identity access', () => { + it('returns the directed identity as (iss, sub)', async () => { + const jwt = await personToken(psKey, agent) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + iss: PS, + sub: 'directed-sub-for-whoami', + }) + }) + + it('releases tenant alongside, but not as part of, the identifier', async () => { + const jwt = await personToken(psKey, agent, { payload: { tenant: 'acme' } }) + const res = await callWhoami('/', env, agent, jwt) + const body = (await res.json()) as Record + expect(body).toEqual({ iss: PS, sub: 'directed-sub-for-whoami', tenant: 'acme' }) + const event = sent.find( + (e) => (e as { event: string }).event === 'aauth.whoami.person_identity_returned', + ) as Record + expect(event.identity_key).not.toContain('acme') + }) + + it('keys the identity record on the issuer as well as the sub', async () => { + const same = 'directed-sub-for-whoami' + await callWhoami('/', env, agent, await personToken(psKey, agent)) + const first = sent.find( + (e) => (e as { event: string }).event === 'aauth.whoami.person_identity_returned', + ) as Record + + const made = makeEnv(JSON.stringify(resourceKey.privateJwk)) + const other = await personToken(otherPsKey, agent, { + header: { kid: otherPsKey.publicJwk.kid }, + payload: { iss: OTHER_PS, sub: same }, + }) + await callWhoami('/', made.env, agent, other) + const second = made.sent.find( + (e) => (e as { event: string }).event === 'aauth.whoami.person_identity_returned', + ) as Record + + // Identical `sub` strings, different issuers: two records, never one. + expect(first.person_sub).toBe(second.person_sub) + expect(first.identity_key).not.toBe(second.identity_key) + }) + + it('rejects a token audienced at another resource', async () => { + const jwt = await personToken(psKey, agent, { + payload: { aud: 'https://elsewhere.example' }, + }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + expect((await res.json()) as Record).toMatchObject({ + error: 'person_token aud mismatch', + }) + }) + + it('rejects a wrong dwk', async () => { + const jwt = await personToken(psKey, agent, { payload: { dwk: 'aauth-access.json' } }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + }) + + it('rejects an iss that is not a server identifier', async () => { + const jwt = await personToken(psKey, agent, { + payload: { iss: 'https://ps.example/tenant/1' }, + }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + expect(outbound).toEqual([]) + }) + + it('rejects a token carrying scope', async () => { + const jwt = await personToken(psKey, agent, { payload: { scope: 'whoami email' } }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + expect((await res.json()) as Record).toMatchObject({ + error: 'person_token MUST NOT carry scope or account', + }) + }) + + it('rejects an expired token', async () => { + const t = now() + const jwt = await personToken(psKey, agent, { + payload: { iat: t - 7200, exp: t - 60 }, + }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + }) + + it('rejects a signature from a key the PS does not publish', async () => { + const jwt = await personToken(otherPsKey, agent, { + header: { kid: psKey.publicJwk.kid }, + }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + }) + + it('rejects a polymorphic EdDSA header alg', async () => { + const jwt = await personToken(psKey, agent, { header: { alg: 'EdDSA' } }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + }) +}) + +describe('person token — resource token', () => { + async function mintResourceToken(overrides = {}) { + const jwt = await personToken(psKey, agent, overrides) + const res = await callWhoami('/?scope=email%20picture', env, agent, jwt) + expect(res.status).toBe(401) + const requirement = res.headers.get('aauth-requirement') as string + expect(requirement).toContain('requirement=auth-token') + const rt = /resource-token="([^"]+)"/.exec(requirement)?.[1] as string + return decodeJwt(rt) + } + + it('signs with the fully-specified Ed25519 identifier', async () => { + const { header } = await mintResourceToken() + expect(header.alg).toBe('Ed25519') + expect(header.typ).toBe('aa-resource+jwt') + expect(header.kid).toBeTruthy() + }) + + it('copies ps, sub and person_token_jti from the person token', async () => { + const { payload } = await mintResourceToken() + expect(payload.ps).toBe(PS) + expect(payload.sub).toBe('directed-sub-for-whoami') + expect(payload.person_token_jti).toBe('pt-1') + expect(payload.aud).toBe(PS) + expect(payload.iss).toBe(RESOURCE) + expect(payload.dwk).toBe('aauth-resource.json') + expect(payload.scope).toBe('whoami email picture') + }) + + it('carries no agent claim', async () => { + const { payload } = await mintResourceToken() + expect(payload.agent).toBeUndefined() + expect(payload.approver).toBeUndefined() + expect(payload.mission).toBeUndefined() + expect(payload.agent_jkt).toBeTruthy() + }) + + it('binds agent_jkt to the request-signing key', async () => { + const { payload } = await mintResourceToken() + const { calculateThumbprint } = await import('@hellocoop/httpsig') + expect(payload.agent_jkt).toBe(await calculateThumbprint(agent.publicJwk)) + }) + + it('copies mission_s256 when the person token carried one', async () => { + const { payload } = await mintResourceToken({ + payload: { mission_s256: MISSION_S256 }, + }) + expect(payload.mission_s256).toBe(MISSION_S256) + }) + + it('omits mission_s256 when the person token had none', async () => { + const { payload } = await mintResourceToken() + expect(payload.mission_s256).toBeUndefined() + }) + + it('copies tenant when the person token carried one', async () => { + const { payload } = await mintResourceToken({ payload: { tenant: 'acme' } }) + expect(payload.tenant).toBe('acme') + }) + + it('does not outlive the person token it came from', async () => { + const t = now() + const { payload } = await mintResourceToken({ payload: { iat: t, exp: t + 30 } }) + expect(payload.exp).toBeLessThanOrEqual(t + 30) + }) + + it('lives no longer than 5 minutes', async () => { + const { payload } = await mintResourceToken() + expect((payload.exp as number) - (payload.iat as number)).toBeLessThanOrEqual(300) + }) + + it('rejects an unknown scope before minting', async () => { + const jwt = await personToken(psKey, agent) + const res = await callWhoami('/?scope=nonsense', env, agent, jwt) + expect(res.status).toBe(400) + expect((await res.json()) as Record).toMatchObject({ + error: 'invalid_scope', + }) + }) +}) + +describe('auth token', () => { + it('releases iss and sub together with the claims', async () => { + const jwt = await authToken(psKey, agent) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + iss: PS, + sub: 'directed-sub-for-whoami', + email: 'alice@example.com', + name: 'Alice Example', + }) + }) + + it('strips ps and mission_s256 from the released claims', async () => { + const jwt = await authToken(psKey, agent, { + payload: { mission_s256: MISSION_S256 }, + }) + const res = await callWhoami('/', env, agent, jwt) + const body = (await res.json()) as Record + expect(body.ps).toBeUndefined() + expect(body.mission_s256).toBeUndefined() + expect(body.cnf).toBeUndefined() + }) + + it('accepts an AS-issued token discovered through aauth-access.json', async () => { + const jwt = await authToken(asKey, agent, { + header: { kid: asKey.publicJwk.kid }, + payload: { iss: AS, dwk: 'aauth-access.json', ps: PS }, + }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(200) + expect(((await res.json()) as Record).iss).toBe(AS) + }) + + it('rejects a dwk that names no AAuth issuer document', async () => { + const jwt = await authToken(psKey, agent, { payload: { dwk: 'aauth-agent.json' } }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + expect(outbound).toEqual([]) + }) + + it('rejects a polymorphic EdDSA header alg', async () => { + const jwt = await authToken(psKey, agent, { header: { alg: 'EdDSA' } }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + }) + + it('rejects an aud for another resource', async () => { + const jwt = await authToken(psKey, agent, { payload: { aud: AS } }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + }) + + it('rejects a token with no sub', async () => { + const jwt = await authToken(psKey, agent, { drop: ['sub'] }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + }) + + it('refuses without the whoami scope', async () => { + const jwt = await authToken(psKey, agent, { payload: { scope: 'email' } }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(403) + }) + + it('rejects a person token presented where an auth token is required', async () => { + // Only typ distinguishes the two: an aa-person+jwt with auth-token claims + // must never take the auth-token path. + const jwt = await personToken(psKey, agent, { + payload: { scope: 'whoami', email: 'alice@example.com' }, + }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(401) + const body = (await res.json()) as Record + expect(body.email).toBeUndefined() + }) +}) + +describe('unknown token type', () => { + it('is rejected', async () => { + const jwt = await personToken(psKey, agent, { header: { typ: 'aa-resource+jwt' } }) + const res = await callWhoami('/', env, agent, jwt) + expect(res.status).toBe(400) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 6c94792..22a6685 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,6 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "test/**/*.ts"], "exclude": ["node_modules"] }