Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml

- name: Install
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"

- name: Test
run: python -m pytest -q

# The SDK must keep working for callers on plain httpx (no httpx2 installed).
test-httpx-only:
name: Python 3.12 (httpx only)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml

- name: Install without httpx2
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[server]" pytest

- name: Test
run: python -m pytest -q
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# AgentAuth

[![CI](https://github.com/invarislabs/agent-auth/actions/workflows/ci.yml/badge.svg)](https://github.com/invarislabs/agent-auth/actions/workflows/ci.yml)

Cryptographic identities and delegated authority for autonomous AI agents.

Each agent gets a **self-certifying DID** backed by an Ed25519 keypair. It proves who it is by **signing every request**, so no API key or bearer token ever crosses the wire. Keys can be **rotated** safely, and an agent can be **killed** by itself or by the human or org that owns it. Anyone can check all of this without trusting the registry.
Expand Down Expand Up @@ -130,7 +132,7 @@ Amounts are integers in the smallest unit (such as cents); floats are never sign
```bash
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytest # 74 tests
python -m pytest # 76 tests (use `python -m` so the venv's pytest runs)

agentauth serve --db agentauth.db & # registry on 127.0.0.1:8000

Expand Down
19 changes: 16 additions & 3 deletions agentauth/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,21 +184,34 @@ def load(cls, directory: Path, name: str, passphrase: Optional[str] = None, reco


class AgentSigAuth(httpx.Auth):
"""httpx auth hook: signs every outgoing request with the agent's key."""
"""Signs every outgoing request with the agent's key (and presents a grant chain, if any).

Works with both `httpx` and `httpx2` clients: httpx uses it as an
`httpx.Auth` (via `auth_flow`), while httpx2 — which doesn't recognise
httpx's Auth class — accepts any callable that takes and returns a request
(via `__call__`).
"""

requires_request_body = True

def __init__(self, identity: AgentIdentity, grants: Optional[GrantChain] = None):
self.identity = identity
self.grants = grants.encode() if grants is not None else ""

def auth_flow(self, request: httpx.Request):
def sign(self, request):
"""Add the AgentSig (and AgentGrant) headers to an httpx or httpx2 request, in place."""
if self.grants:
request.headers[GRANT_HEADER] = self.grants
request.headers["Authorization"] = self.identity.authorization(
request.method, str(request.url), request.content, self.grants
)
yield request
return request

def auth_flow(self, request: httpx.Request):
yield self.sign(request)

def __call__(self, request):
return self.sign(request)


class RegistryError(Exception):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ dependencies = ["cryptography>=42", "httpx>=0.27"]

[project.optional-dependencies]
server = ["fastapi>=0.110", "uvicorn>=0.29"]
dev = ["agentauth[server]", "pytest>=8"]
dev = ["agentauth[server]", "pytest>=8", "httpx2>=2.13"] # httpx2: used by starlette.testclient

[project.scripts]
agentauth = "agentauth.cli:main"
Expand Down
35 changes: 35 additions & 0 deletions tests/test_agentauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,38 @@ def test_save_load_encrypted_and_split(tmp_path, registry):
admin = AgentIdentity.load(keys, "persist", passphrase="hunter2", recovery_dir=vault)
reg.rotate(admin)
assert reg.whoami(admin)["kid"].endswith("#key-1")


# --------------------------------------------------------------------------- #
# client compatibility
# --------------------------------------------------------------------------- #


def test_auth_works_with_plain_httpx_client():
"""TestClient uses httpx2 when installed; make sure plain httpx clients are still supported."""
import httpx

ident, _ = AgentIdentity.create(name="plain")
verifier = RequestVerifier(lambda d: (ident.current_key.public_multikey, ident.kid), audiences={"svc.example"})

def handler(request: httpx.Request) -> httpx.Response:
agent = verifier.verify(
authorization=request.headers["authorization"],
method=request.method,
path=request.url.raw_path.decode(),
body=request.content,
)
return httpx.Response(200, json={"did": agent.did})

with httpx.Client(transport=httpx.MockTransport(handler)) as client:
r = client.post("http://svc.example/do?x=1", json={"a": 1}, auth=ident.httpx_auth())
assert r.json() == {"did": ident.did}


def test_auth_is_callable_for_httpx2_style_clients():
ident, _ = AgentIdentity.create(name="callable")
import httpx

req = httpx.Request("GET", "http://svc.example/x")
signed = ident.httpx_auth()(req)
assert signed is req and req.headers["authorization"].startswith("AgentSig ")
Loading