in reviewhumanPR #132Auth
auth: macaroon-style delegatable capability tokens with cascading revocation
The default auth plugin (nest_plugins_reference/auth/jwt_auth.py) is 106 lines of HMAC-signed token issuance. It has no notion of delegation: JwtAuth._revoked: set[str] tracks revocation by exact token string, with no parent-child…
Author
@TanishDevs11
github profile →- Status
- In review
- Opened on
- Jul 9
- Branch
- hackathon/Tanish-capability-delegation
Description
The pitch.
## Motivation The default auth plugin (`nest_plugins_reference/auth/jwt_auth.py`) is 106 lines of HMAC-signed token issuance. It has no notion of delegation: `JwtAuth._revoked: set[str]` tracks revocation by exact token string, with no parent-child relationship between tokens at all. That makes the auth layer unable to model the most common real-world capability pattern — "agent A holds a long-lived root token, mints a 10-minute sub-token for agent B without going back to the issuer, and revoking A's token invalidates B's at the next verify, automatically." Macaroons (Google, 2014), biscuits (CleverCloud), and SPIFFE delegation all do this; `docs/layers/auth.md` explicitly lists "capability delegation" and "revocation propagation" as wanted. Solves `docs/hackathon/problems/04-auth-capability-delegation.md`. Anyone building a multi-agent workflow where an orchestrator hands out narrowly-scoped, time-bounded sub-capabilities needs this — e.g. an LLM agent sub-renting tool access to a worker, and being able to withdraw it cleanly without hunting down every descendant token. ## Design `DelegatableAuth` (`packages/nest-plugins-reference/nest_plugins_reference/auth/delegatable.py`) borrows the caveat-chaining trick from macaroons (Birgisson et al., 2014): a token is not one HMAC over one flat payload, it's an ordered chain of caveats, where each caveat's signature is keyed off the **previous caveat's own signature**, not the root secret: ``` sig_0 = HMAC(root_secret, caveat_0) # issue() sig_1 = HMAC(sig_0, caveat_1) # delegate() sig_2 = HMAC(sig_1, caveat_2) # delegate() again ``` Two properties fall out of that for free: 1. **Delegation needs no issuer round-trip.** `delegate()` never reads `self._secret`. It only needs the parent token's own trailing signature (which travels with the token in plain sight) to key the next caveat, plus a local, secret-free check that no ancestor in the chain is in the revocation table. This is the anti-pattern the problem doc calls out explicitly: "don't ship delegation that's actually just re-issuance by the central authority." A forged/tampered parent isn't rejected at delegate time — it's still caught the next time anyone calls `verify()`, when the real root secret replays the whole chain. 2. **Revocation is transitive by construction.** Every token is self-contained — it carries its *entire* ancestor chain, not a pointer to one. `verify()` replays the whole chain from `self._secret` and, at every level, recomputes that ancestor's *exact* original token string and checks its digest against `self._revoked: dict[str, bool]`. That dict only ever gains an entry for a token *directly* passed to `revoke()` — a child's entry is never written. Revoking a parent invalidates every descendant the next time any of them is verified, with zero bookkeeping proportional to subtree size. On top of the base `Auth` protocol (`issue`/`verify`/`revoke`, unchanged signatures), it adds: - `delegate(parent_token, audience, scopes_subset, ttl) -> Token` — `scopes_subset` must be a **strict** (proper) subset of the parent's scopes (`ScopeEscalationError` otherwise — every hop must shed at least one capability, not just avoid escalating), and `ttl` must land the child's expiry at or before the parent's own (`DelegationTtlError` otherwise, which also naturally rejects delegating from an already-expired parent). - `verify_presented_by(token, presenter) -> AuthContext` — the audience-aware sibling of `verify()`. The base `Auth.verify(token)` signature can't check *who* presents a token since it only receives the token; this adds that check (`AudienceMismatchError`) on top without breaking the base contract. ## Adversarial validator `packages/nest-plugins-reference/nest_plugins_reference/validators/auth_delegation_validators.py` ships three checks, each deliberately **plugin-agnostic** — they take async callables the caller builds however that plugin exposes the relevant operation, and only inspect …
Try it
Open PR on GitHubView diffCheckout locally
git fetch origin pull/132/head:pr-132
git checkout pr-132