authyouragent 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ """Auth Your Agent — Python SDK.
2
+
3
+ Two halves:
4
+
5
+ * :mod:`authyouragent.agent` — for AI agents. Holds the agent's private key,
6
+ asks the user's phone for approval, and signs every request (DPoP).
7
+ * :mod:`authyouragent.site` — for websites. Verifies that a request really
8
+ comes from an agent the user approved, and whether a sensitive action was
9
+ confirmed (step-up).
10
+
11
+ Quick start (agent)::
12
+
13
+ from authyouragent import AgentClient
14
+ agent = AgentClient(base_url="https://authyouragent.com",
15
+ agent_id="ag_…", privkey_pem=open("agent.pem").read())
16
+ agent.ensure_grant("jobs.example.com", scopes=["list"])
17
+ r = agent.request("GET", "https://jobs.example.com/api/jobs")
18
+
19
+ Quick start (site)::
20
+
21
+ from authyouragent import SiteVerifier, AuthError
22
+ verifier = SiteVerifier("https://authyouragent.com")
23
+ auth = verifier.verify(request) # raises AuthError
24
+
25
+ Documentation: https://authyouragent.com/docs
26
+ """
27
+
28
+ from .agent import AgentClient, AgentError, keygen, agent_jwk
29
+ from .site import SiteVerifier, AuthError, Auth
30
+
31
+ __all__ = ["AgentClient", "AgentError", "keygen", "agent_jwk",
32
+ "SiteVerifier", "AuthError", "Auth"]
33
+ __version__ = "0.3.0"
34
+ DEFAULT_CLOUD = "https://authyouragent.com"
@@ -0,0 +1,6 @@
1
+ """`python -m authyouragent keygen --name "My agent"` — generate an agent key
2
+ locally (the private key never leaves this machine)."""
3
+ from .agent import _cli_main
4
+
5
+ if __name__ == "__main__":
6
+ _cli_main()
authyouragent/agent.py ADDED
@@ -0,0 +1,476 @@
1
+ """authyouragent.agent — agent-side SDK for the Auth Your Agent cloud (vertical slice).
2
+
3
+ An agent that wants to act on a user's behalf on a site:
4
+
5
+ s = AgentClient(base_url="https://authyouragent.com",
6
+ agent_id="ag_xxx", privkey_pem="-----BEGIN PRIVATE KEY-----...")
7
+ s.ensure_grant("jobboard.example", scopes=["list", "apply"])
8
+ # → pushes a biometric approval to the user's phone; blocks until answered
9
+ s.call("jobboard.example", "GET", "https://jobs.example.com/list",
10
+ token=..., dpop=...)
11
+
12
+ In practice the agent embeds this around its HTTP calls:
13
+
14
+ req = s.request("GET", "https://jobs.example.com/jobs")
15
+ # sends Authorization: Bearer <AT> + DPoP header; auto-refreshes tokens;
16
+ # auto-requests step-up when the site returns 403 stepup_required
17
+
18
+ Key material: the agent holds its Ed25519/EC P-256 private key; the cloud
19
+ never sees it. The DPoP key IS the agent key (single key, per RFC 9449).
20
+ """
21
+
22
+ import sys
23
+ import json
24
+ import threading
25
+ import time
26
+
27
+ import httpx
28
+ import jwt as pyjwt
29
+ from cryptography.hazmat.primitives import hashes, serialization
30
+ from cryptography.hazmat.primitives.asymmetric import ec
31
+ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
32
+
33
+
34
+ class AgentError(Exception):
35
+ pass
36
+
37
+
38
+ class AgentClient:
39
+ def __init__(self, base_url, agent_id, privkey_pem, poll_interval=1.5,
40
+ timeout=60, verify=True):
41
+ self.base = base_url.rstrip("/")
42
+ self.agent_id = agent_id
43
+ self.key = serialization.load_pem_private_key(
44
+ privkey_pem.encode() if isinstance(privkey_pem, str) else privkey_pem,
45
+ password=None)
46
+ self.pub = self.key.public_key()
47
+ self.poll_interval = poll_interval
48
+ self.client = httpx.Client(timeout=timeout, verify=verify)
49
+ # per-site token cache: an access token is audience-bound (aud=site),
50
+ # so a token minted for site A must never be sent to site B.
51
+ self._access_by_site = {} # site -> (token, exp_float)
52
+ self._refresh_by_site = {} # site -> refresh token
53
+ self._last_site = None
54
+ self._lock = threading.RLock()
55
+ self._jwk = {
56
+ "kty": "EC", "crv": "P-256",
57
+ "x": b64(self.pub.public_numbers().x.to_bytes(32, "big")),
58
+ "y": b64(self.pub.public_numbers().y.to_bytes(32, "big")),
59
+ }
60
+
61
+ # ------------------------------------------------ legacy cache accessors
62
+ # Back-compat for callers/tests that poke `_access` / `_refresh` directly:
63
+ # reading returns the most-recently-used site's entry; assigning None
64
+ # clears the cache for ALL sites.
65
+
66
+ @property
67
+ def _access(self):
68
+ return self._access_by_site.get(self._last_site)
69
+
70
+ @_access.setter
71
+ def _access(self, v):
72
+ with self._lock:
73
+ if v is None:
74
+ self._access_by_site.clear()
75
+ elif self._last_site is not None:
76
+ self._access_by_site[self._last_site] = v
77
+
78
+ @property
79
+ def _refresh(self):
80
+ return self._refresh_by_site.get(self._last_site)
81
+
82
+ @_refresh.setter
83
+ def _refresh(self, v):
84
+ with self._lock:
85
+ if v is None:
86
+ self._refresh_by_site.clear()
87
+ elif self._last_site is not None:
88
+ self._refresh_by_site[self._last_site] = v
89
+
90
+ def _poll_headers(self):
91
+ # the cloud only reveals a txn's stepup_token to its own agent: every
92
+ # poll carries a FRESH short-lived agent JWT (they expire)
93
+ return {"X-Agent-JWT": self._agent_jwt()}
94
+
95
+ # ------------------------------------------------------------ crypto
96
+
97
+ def _agent_jwt(self, ttl=300):
98
+ """Signed statement of agency, verified by the cloud against our pubkey."""
99
+ now = int(time.time())
100
+ return pyjwt.encode(
101
+ {"iss": self.agent_id, "sub": self.agent_id,
102
+ "iat": now, "exp": now + ttl, "type": "agent"},
103
+ self.key, algorithm="ES256")
104
+
105
+ def _dpop_proof(self, method, url, access_token=None):
106
+ ath = None
107
+ if access_token:
108
+ import hashlib, base64
109
+ ath = base64.urlsafe_b64encode(
110
+ hashlib.sha256(access_token.encode()).digest()
111
+ ).rstrip(b"=").decode()
112
+ payload = {"htm": method.upper(),
113
+ # 'url' for the current cloud, 'htu' (RFC 9449 name)
114
+ # for clouds that accept the standard claim
115
+ "url": url, "htu": url, "iat": int(time.time()),
116
+ "jti": "j_" + _rand()[:12]}
117
+ if ath:
118
+ payload["ath"] = ath
119
+ # RFC 9449: the key (jwk) lives in the DPoP proof's JOSE header.
120
+ header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": self._jwk}
121
+ h = b64(json.dumps(header, separators=(",", ":")).encode())
122
+ p = b64(json.dumps(payload, separators=(",", ":")).encode())
123
+ signing = (h + "." + p).encode()
124
+ der = self.key.sign(signing, ec.ECDSA(hashes.SHA256()))
125
+ # JWS ES256 signatures are raw r||s, 64 bytes (RFC 7518 §3.4), not DER
126
+ r, s_ = decode_dss_signature(der)
127
+ return h + "." + p + "." + b64(r.to_bytes(32, "big") + s_.to_bytes(32, "big"))
128
+
129
+ # ------------------------------------------------------------ grants
130
+
131
+ def ensure_grant(self, site, scopes, wait=True):
132
+ """Ask the cloud for access to `site`. Pushes approval to the user's
133
+ phone; if `wait`, blocks until the user approves/denies (max ~5 min).
134
+ Returns True if the grant is usable."""
135
+ try:
136
+ r = self.client.post(f"{self.base}/api/v1/authz-requests", json={
137
+ "agent_id": self.agent_id, "site": site,
138
+ "scopes": scopes, "agent_jwt": self._agent_jwt()})
139
+ except httpx.HTTPError as e:
140
+ raise AgentError(f"cloud unreachable: {e}")
141
+ if r.status_code == 403:
142
+ # The only 403 this endpoint emits is check_agent's
143
+ # "agent revoked by owner" (409 means "grant already active").
144
+ # Surface revocation loudly; anything else -> try the token path.
145
+ detail = ""
146
+ try:
147
+ b = r.json()
148
+ detail = (b.get("detail") if isinstance(b.get("detail"), str)
149
+ else b.get("error", "")) or ""
150
+ except Exception:
151
+ pass
152
+ if "revoked" in detail.lower():
153
+ raise AgentError(f"agent revoked by owner: {detail}")
154
+ return self._try_token(site)
155
+ if r.status_code == 409:
156
+ return True
157
+ if r.status_code >= 400:
158
+ raise AgentError(f"authz request failed: {r.status_code} {r.text[:200]}")
159
+ txn = _txn_id(r)
160
+ print(f"[authyouragent] approval requested ({txn}) — waiting for phone…")
161
+ if not wait:
162
+ return None
163
+ deadline = time.time() + 330
164
+ while time.time() < deadline:
165
+ try:
166
+ p = self.client.get(f"{self.base}/api/v1/authz-requests/{txn}",
167
+ headers=self._poll_headers())
168
+ except httpx.HTTPError:
169
+ time.sleep(self.poll_interval)
170
+ continue
171
+ if p.status_code >= 500:
172
+ # transient server error — keep polling (the phone may still
173
+ # respond), do not kill the whole flow over one bad poll
174
+ time.sleep(self.poll_interval)
175
+ continue
176
+ if p.status_code == 404:
177
+ raise AgentError("approval txn vanished from the cloud")
178
+ try:
179
+ b = p.json()
180
+ st = b.get("status", "pending") if isinstance(b, dict) else "pending"
181
+ except Exception:
182
+ st = "pending"
183
+ if st == "approved":
184
+ print("[authyouragent] approved ✓")
185
+ return self._try_token(site)
186
+ if st in ("denied", "expired"):
187
+ print(f"[authyouragent] {st}")
188
+ raise AgentError(f"approval {st} by user")
189
+ time.sleep(self.poll_interval)
190
+ raise AgentError("approval timed out")
191
+
192
+ def _try_token(self, site):
193
+ try:
194
+ self._get_access(site)
195
+ return True
196
+ except AgentError:
197
+ return False
198
+
199
+ # ------------------------------------------------------------ tokens
200
+
201
+ def _get_access(self, site, rotate=True):
202
+ with self._lock:
203
+ return self._get_access_locked(site, rotate)
204
+
205
+ def _get_access_locked(self, site, rotate=True):
206
+ # cache entry: self._access_by_site[site] = (access_token, exp_float)
207
+ self._last_site = site
208
+ cur = self._access_by_site.get(site)
209
+ if cur and float(cur[1]) > time.time() + 30:
210
+ return cur[0]
211
+ refresh = self._refresh_by_site.get(site)
212
+ try:
213
+ if refresh:
214
+ body = {"agent_id": self.agent_id, "site": site,
215
+ "agent_jwt": self._agent_jwt(),
216
+ "refresh_token": refresh}
217
+ r = self.client.post(f"{self.base}/api/v1/token", json=body)
218
+ # the cloud ROTATES the refresh token on every issuance; a
219
+ # stale/rotated-away token is a 401 — drop it and retry once
220
+ # via the initial-approval path (which re-mints the grant's
221
+ # refresh). A misbehaving agent must not crash on rotation.
222
+ if r.status_code == 401:
223
+ self._refresh_by_site.pop(site, None)
224
+ body = {"agent_id": self.agent_id, "site": site,
225
+ "agent_jwt": self._agent_jwt()}
226
+ r = self.client.post(f"{self.base}/api/v1/token", json=body)
227
+ else:
228
+ body = {"agent_id": self.agent_id, "site": site,
229
+ "agent_jwt": self._agent_jwt()}
230
+ r = self.client.post(f"{self.base}/api/v1/token", json=body)
231
+ except httpx.HTTPError as e:
232
+ raise AgentError(f"cloud unreachable: {e}")
233
+ if r.status_code == 403:
234
+ raise AgentError("no grant — call ensure_grant() first")
235
+ if r.status_code >= 400:
236
+ raise AgentError(f"token fetch failed: {r.status_code} {r.text[:200]}")
237
+ try:
238
+ d = r.json()
239
+ at = d["access_token"]
240
+ except Exception:
241
+ raise AgentError(f"token response malformed: {r.text[:200]}")
242
+ exp = None
243
+ # the token endpoint returns expires_in (seconds); prefer it
244
+ try:
245
+ if d.get("expires_in") is not None:
246
+ exp = time.time() + float(d["expires_in"])
247
+ except (TypeError, ValueError):
248
+ exp = None
249
+ if exp is None:
250
+ try:
251
+ claims = pyjwt.decode(at, options={"verify_signature": False})
252
+ exp = float(claims.get("exp"))
253
+ except Exception:
254
+ exp = time.time()
255
+ self._access_by_site[site] = (at, exp)
256
+ if d.get("refresh_token"):
257
+ self._refresh_by_site[site] = d["refresh_token"]
258
+ else:
259
+ self._refresh_by_site.pop(site, None)
260
+ return at
261
+
262
+ # ------------------------------------------------------------ calls
263
+
264
+ def request(self, method, url, stepup_action=None, site=None, **kwargs):
265
+ """Perform an HTTP request through Auth Your Agent: attaches Bearer + DPoP,
266
+ auto-refreshes tokens, and on a 403 'stepup_required' from the site,
267
+ requests a one-time step-up grant (phone prompt) and retries ONCE.
268
+ Each attempt mints a FRESH DPoP proof (and a fresh access token if
269
+ the old one is within 30s of expiry) — reusing a proof from the
270
+ first attempt 401s after ~2 min (DPoP iat window) or once the
271
+ access token has rolled.
272
+
273
+ `site` is the site id you were granted; it defaults to the URL's
274
+ host. Pass it when the site is reached at another address (a
275
+ local test server, a regional API host)."""
276
+ site = site or url.split("/")[2].split(":")[0]
277
+ at = self._get_access(site)
278
+ headers = dict(kwargs.pop("headers", {}))
279
+ resp = self._do_request(method, url, at, headers, kwargs)
280
+ if resp.status_code == 403 and stepup_action:
281
+ try:
282
+ body = resp.json()
283
+ except Exception:
284
+ return resp
285
+ # tolerate both the flat {"error": ...} and FastAPI's nested
286
+ # {"detail": {"error": ...}} shapes
287
+ if not isinstance(body, dict):
288
+ return resp # not a step-up signal; surface as-is
289
+ err_obj = body.get("detail") if isinstance(body.get("detail"), dict) else body
290
+ if err_obj.get("error") == "stepup_required":
291
+ st = self._stepup(site, stepup_action)
292
+ if st:
293
+ # retry: fresh token (the old one may have expired
294
+ # during the phone wait) + fresh DPoP proof bound to it
295
+ headers["X-AuthYourAgent-Stepup"] = st
296
+ at2 = self._get_access(site, rotate=False)
297
+ return self._do_request(method, url, at2, headers, kwargs)
298
+ return resp
299
+
300
+ def _do_request(self, method, url, at, headers, kwargs):
301
+ # DPoP: one fresh proof PER request (htm/url/iat/ath all bind to
302
+ # this exact call — RFC 9449).
303
+ h = dict(headers)
304
+ h["Authorization"] = f"Bearer {at}"
305
+ h["DPoP"] = self._dpop_proof(method, url, at)
306
+ try:
307
+ return self.client.request(method, url, headers=h, **kwargs)
308
+ except httpx.HTTPError as e:
309
+ raise AgentError(f"request failed: {e}")
310
+
311
+ def check_status(self):
312
+ """Check whether this agent is still authorized by the owner.
313
+ Returns a dict with 'status' (active/revoked), 'global_flag',
314
+ 'agent_name', and 'revoked_sites'.
315
+ Raises AgentError if the server cannot be reached or the JWT is invalid."""
316
+ try:
317
+ r = self.client.get(
318
+ f"{self.base}/api/v1/agent-status",
319
+ headers={"X-Agent-JWT": self._agent_jwt()},
320
+ params={"agent_id": self.agent_id},
321
+ timeout=10)
322
+ except httpx.HTTPError as e:
323
+ raise AgentError(f"status check failed: {e}")
324
+ if r.status_code == 401:
325
+ raise AgentError("agent JWT rejected — the key may be invalid or the agent deleted")
326
+ if r.status_code == 403:
327
+ return {"status": "revoked", "global_flag": "revoked", "revoked_sites": []}
328
+ if r.status_code != 200:
329
+ raise AgentError(f"status check returned {r.status_code}: {r.text[:200]}")
330
+ return r.json()
331
+
332
+ def report_status(self, status, site="", detail=""):
333
+ """Report this agent's current status to the server so the owner's
334
+ dashboard shows what the agent is doing. Best-effort: errors are
335
+ printed but not raised, so a reporting failure never blocks the agent."""
336
+ try:
337
+ r = self.client.post(
338
+ f"{self.base}/api/v1/agent-status",
339
+ json={"agent_id": self.agent_id, "agent_jwt": self._agent_jwt(),
340
+ "status": status, "site": site, "detail": detail},
341
+ timeout=10)
342
+ if r.status_code >= 400:
343
+ print(f"[authyouragent] status report failed: {r.status_code}")
344
+ except Exception as e:
345
+ print(f"[authyouragent] status report failed: {e}")
346
+
347
+ def _stepup(self, site, action):
348
+ try:
349
+ r = self.client.post(f"{self.base}/api/v1/stepup", json={
350
+ "agent_id": self.agent_id, "site": site, "action": action,
351
+ "agent_jwt": self._agent_jwt()})
352
+ except httpx.HTTPError as e:
353
+ print(f"[authyouragent] stepup request failed: {e}")
354
+ return None
355
+ if r.status_code >= 400:
356
+ print(f"[authyouragent] stepup request failed: {r.text}")
357
+ return None
358
+ txn = _txn_id(r)
359
+ print(f"[authyouragent] step-up requested ({txn}) — waiting for phone…")
360
+ deadline = time.time() + 330
361
+ while time.time() < deadline:
362
+ try:
363
+ p = self.client.get(f"{self.base}/api/v1/authz-requests/{txn}",
364
+ headers=self._poll_headers())
365
+ except httpx.HTTPError:
366
+ time.sleep(self.poll_interval)
367
+ continue
368
+ if p.status_code >= 500:
369
+ # transient server error — keep polling (the phone may still
370
+ # respond), do not kill the whole flow over one bad poll
371
+ time.sleep(self.poll_interval)
372
+ continue
373
+ if p.status_code == 404:
374
+ return None
375
+ try:
376
+ st = p.json()
377
+ except Exception:
378
+ st = None
379
+ if not isinstance(st, dict):
380
+ time.sleep(self.poll_interval)
381
+ continue
382
+ if st.get("status") == "approved" and st.get("stepup_token"):
383
+ print("[authyouragent] step-up approved ✓")
384
+ return st["stepup_token"]
385
+ if st.get("status") in ("denied", "expired"):
386
+ print(f"[authyouragent] step-up {st.get('status')}")
387
+ return None
388
+ time.sleep(self.poll_interval)
389
+ return None
390
+
391
+
392
+ def _txn_id(r):
393
+ try:
394
+ b = r.json()
395
+ except Exception:
396
+ raise AgentError(f"cloud returned non-JSON: {r.text[:200]}")
397
+ if not isinstance(b, dict) or not b.get("txn_id"):
398
+ raise AgentError(f"cloud response missing txn_id: {r.text[:200]}")
399
+ return b["txn_id"]
400
+
401
+
402
+ def _rand(n=16):
403
+ import secrets
404
+ return secrets.token_hex(n)
405
+
406
+
407
+ def b64(x: bytes) -> str:
408
+ import base64
409
+ return base64.urlsafe_b64encode(x).rstrip(b"=").decode()
410
+
411
+
412
+ def b64dec(s: str) -> bytes:
413
+ import base64
414
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
415
+
416
+
417
+ def keygen():
418
+ """§10.1: generate an agent keypair LOCALLY, in the agent runtime.
419
+ The private key never touches the network. Returns (privkey_pem, pubkey_pem)."""
420
+ key = ec.generate_private_key(ec.SECP256R1())
421
+ priv = key.private_bytes(
422
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
423
+ serialization.NoEncryption()).decode()
424
+ pub = key.public_key().public_bytes(
425
+ serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
426
+ ).decode()
427
+ return priv, pub
428
+
429
+
430
+ def agent_jwk(pubkey_pem: str) -> dict:
431
+ """Emit the RFC 7515 JWK the PWA posts when registering an agent whose
432
+ key was generated locally by the agent runtime."""
433
+ pub = serialization.load_pem_public_key(
434
+ pubkey_pem.encode() if isinstance(pubkey_pem, str) else pubkey_pem)
435
+ n = pub.public_numbers()
436
+ return {"kty": "EC", "crv": "P-256",
437
+ "x": b64(n.x.to_bytes(32, "big")),
438
+ "y": b64(n.y.to_bytes(32, "big"))}
439
+
440
+ # ------------------------------------------------------------ CLI
441
+ # python -m authyouragent keygen --name "Hermes JARVIS"
442
+ # -> prints {"agent_key": {...}} ; the agent keeps the privkey, and the
443
+ # user's phone registers the pubkey with the cloud (cloud never sees
444
+ # the private key — §10.1).
445
+
446
+ def _cli_keygen(name):
447
+ priv, pub = keygen()
448
+ print(json.dumps({
449
+ "agent_key": {
450
+ "name": name,
451
+ "jwk": agent_jwk(pub),
452
+ "pubkey_pem": pub,
453
+ "privkey_pem": priv,
454
+ "note": ("agent runtime: keep privkey_pem local. "
455
+ "Phone/PWA: register jwk (+name) with the cloud; "
456
+ "the cloud will return agent_id, which the runtime "
457
+ "uses to build its AgentClient."),
458
+ }
459
+ }, indent=2))
460
+
461
+ def _cli_main(argv=None):
462
+ argv = sys.argv[1:] if argv is None else argv
463
+ if argv[:1] == ["vault"]:
464
+ from .vault_cli import main as vault_main
465
+ return vault_main(argv[1:])
466
+ import argparse
467
+ ap = argparse.ArgumentParser(prog="authyouragent", epilog="also: authyouragent vault up|down|status|env")
468
+ ap.add_argument("cmd", choices=["keygen"])
469
+ ap.add_argument("--name", default="agent")
470
+ args = ap.parse_args(argv)
471
+ if args.cmd == "keygen":
472
+ _cli_keygen(args.name)
473
+
474
+
475
+ if __name__ == "__main__":
476
+ _cli_main()