authoxi 0.1.0__tar.gz
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.
- authoxi-0.1.0/.gitignore +148 -0
- authoxi-0.1.0/LICENSE +21 -0
- authoxi-0.1.0/PKG-INFO +55 -0
- authoxi-0.1.0/README.md +26 -0
- authoxi-0.1.0/authoxi/__init__.py +49 -0
- authoxi-0.1.0/authoxi/agents.py +297 -0
- authoxi-0.1.0/authoxi/client.py +478 -0
- authoxi-0.1.0/authoxi/exceptions.py +69 -0
- authoxi-0.1.0/authoxi/fastapi.py +79 -0
- authoxi-0.1.0/authoxi/models.py +93 -0
- authoxi-0.1.0/pyproject.toml +51 -0
authoxi-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# authoxi — .gitignore
|
|
3
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
# Byte-compiled / optimized / DLL files
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*$py.class
|
|
9
|
+
|
|
10
|
+
# C extensions
|
|
11
|
+
*.so
|
|
12
|
+
|
|
13
|
+
# Distribution / packaging
|
|
14
|
+
.Python
|
|
15
|
+
build/
|
|
16
|
+
develop-eggs/
|
|
17
|
+
dist/
|
|
18
|
+
downloads/
|
|
19
|
+
eggs/
|
|
20
|
+
.eggs/
|
|
21
|
+
# ANCHORED to the repo root, deliberately. These come from the stock Python .gitignore, where
|
|
22
|
+
# `lib/` means "the Python build output". Unanchored, it matches ANY directory called lib at
|
|
23
|
+
# any depth — which silently swallowed the console's entire src/lib/ (the API client, the
|
|
24
|
+
# Ed25519 signer, the store). Everything still built locally, because Docker copies the working
|
|
25
|
+
# tree rather than what git tracks; only a clean clone revealed it. Keep these anchored.
|
|
26
|
+
/lib/
|
|
27
|
+
/lib64/
|
|
28
|
+
parts/
|
|
29
|
+
sdist/
|
|
30
|
+
var/
|
|
31
|
+
wheels/
|
|
32
|
+
share/python-wheels/
|
|
33
|
+
*.egg-info/
|
|
34
|
+
.installed.cfg
|
|
35
|
+
*.egg
|
|
36
|
+
MANIFEST
|
|
37
|
+
|
|
38
|
+
# Virtual environments
|
|
39
|
+
.venv*
|
|
40
|
+
venv/
|
|
41
|
+
ENV/
|
|
42
|
+
env/
|
|
43
|
+
|
|
44
|
+
# PyInstaller
|
|
45
|
+
*.manifest
|
|
46
|
+
*.spec
|
|
47
|
+
|
|
48
|
+
# Installer logs
|
|
49
|
+
pip-log.txt
|
|
50
|
+
pip-delete-this-directory.txt
|
|
51
|
+
|
|
52
|
+
# Unit test / coverage reports
|
|
53
|
+
htmlcov/
|
|
54
|
+
.tox/
|
|
55
|
+
.nox/
|
|
56
|
+
.coverage
|
|
57
|
+
.coverage.*
|
|
58
|
+
.cache
|
|
59
|
+
nosetests.xml
|
|
60
|
+
coverage.xml
|
|
61
|
+
*.cover
|
|
62
|
+
*.py,cover
|
|
63
|
+
.hypothesis/
|
|
64
|
+
.pytest_cache/
|
|
65
|
+
cover/
|
|
66
|
+
|
|
67
|
+
# Translations
|
|
68
|
+
*.mo
|
|
69
|
+
*.pot
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
.python-version
|
|
87
|
+
|
|
88
|
+
# Environments / secrets (never commit)
|
|
89
|
+
.env
|
|
90
|
+
.env.local
|
|
91
|
+
*.env
|
|
92
|
+
credentials.json
|
|
93
|
+
*.pem
|
|
94
|
+
*.key
|
|
95
|
+
secrets/
|
|
96
|
+
|
|
97
|
+
# Node
|
|
98
|
+
node_modules/
|
|
99
|
+
*node_modules*
|
|
100
|
+
.npm
|
|
101
|
+
.pnp.*
|
|
102
|
+
.yarn/
|
|
103
|
+
|
|
104
|
+
# IDE / editor
|
|
105
|
+
.idea/
|
|
106
|
+
.vscode/
|
|
107
|
+
*.swp
|
|
108
|
+
*.swo
|
|
109
|
+
*~
|
|
110
|
+
|
|
111
|
+
# macOS
|
|
112
|
+
.DS_Store
|
|
113
|
+
.AppleDouble
|
|
114
|
+
.LSOverride
|
|
115
|
+
|
|
116
|
+
# Logs & local data
|
|
117
|
+
*.log
|
|
118
|
+
*.sqlite
|
|
119
|
+
*.sqlite3
|
|
120
|
+
|
|
121
|
+
# Telemetry spool (runtime trace output)
|
|
122
|
+
*_spool*.jsonl
|
|
123
|
+
.telemetry_spool.jsonl
|
|
124
|
+
|
|
125
|
+
# Linter cache
|
|
126
|
+
.ruff*
|
|
127
|
+
.mypy*
|
|
128
|
+
|
|
129
|
+
# Local agent settings
|
|
130
|
+
.claude/settings.local.json
|
|
131
|
+
|
|
132
|
+
# Backup files (caveman-compress etc.)
|
|
133
|
+
*.original
|
|
134
|
+
|
|
135
|
+
# Temporary files
|
|
136
|
+
tmp/
|
|
137
|
+
temp/
|
|
138
|
+
*.tmp
|
|
139
|
+
|
|
140
|
+
# The loss-event pump / SDK spool signed evidence to disk when the sink is
|
|
141
|
+
# unreachable — real amounts, real vendors, real approver signatures. The rule
|
|
142
|
+
# above only catches `*_spool*` (underscore); the pump writes
|
|
143
|
+
# `authoxi-loss-events.spool.jsonl` (dot), which slipped straight through.
|
|
144
|
+
*.spool.jsonl
|
|
145
|
+
*.spool
|
|
146
|
+
|
|
147
|
+
# `*.sqlite` was covered; `*.db` was not — and a local memory-backend DB lands here.
|
|
148
|
+
*.db
|
authoxi-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aeternm
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
authoxi-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: authoxi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the authoxi authentication platform
|
|
5
|
+
Project-URL: Homepage, https://authoxi.com
|
|
6
|
+
Project-URL: Documentation, https://authoxi.com
|
|
7
|
+
Project-URL: Source, https://github.com/aeternm/authoxi/tree/main/sdk/python
|
|
8
|
+
Project-URL: Issues, https://github.com/aeternm/authoxi/issues
|
|
9
|
+
Author: aeternm
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent-identity,auth,authentication,authoxi,mcp
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: cryptography>=42.0.0
|
|
21
|
+
Requires-Dist: httpx>=0.27.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: fastapi>=0.110.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
26
|
+
Provides-Extra: fastapi
|
|
27
|
+
Requires-Dist: fastapi>=0.110.0; extra == 'fastapi'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# authoxi — Python SDK
|
|
31
|
+
|
|
32
|
+
The official Python client for the [authoxi](https://authoxi.com) authentication and agent-identity
|
|
33
|
+
platform: human auth (`/auth`), agent authority + mandates (`/agent`, `/admin`), and the MCP firewall
|
|
34
|
+
(`/mcp`). Agents generate their own Ed25519 keypair and sign proof-of-possession challenges
|
|
35
|
+
client-side.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install authoxi
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from authoxi import Authoxi
|
|
43
|
+
|
|
44
|
+
client = Authoxi(base_url="https://api.authoxi.com", api_key="sk_live_...")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **Docs:** https://authoxi.com
|
|
48
|
+
- **API:** https://api.authoxi.com
|
|
49
|
+
- **Source:** https://github.com/aeternm/authoxi/tree/main/sdk/python
|
|
50
|
+
- **JS SDK** (browser): [`@authoxi/js`](https://www.npmjs.com/package/@authoxi/js)
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT — see [LICENSE](./LICENSE). The SDK is MIT so you can embed it freely; the authoxi service itself
|
|
55
|
+
is proprietary.
|
authoxi-0.1.0/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# authoxi — Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python client for the [authoxi](https://authoxi.com) authentication and agent-identity
|
|
4
|
+
platform: human auth (`/auth`), agent authority + mandates (`/agent`, `/admin`), and the MCP firewall
|
|
5
|
+
(`/mcp`). Agents generate their own Ed25519 keypair and sign proof-of-possession challenges
|
|
6
|
+
client-side.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install authoxi
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from authoxi import Authoxi
|
|
14
|
+
|
|
15
|
+
client = Authoxi(base_url="https://api.authoxi.com", api_key="sk_live_...")
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **Docs:** https://authoxi.com
|
|
19
|
+
- **API:** https://api.authoxi.com
|
|
20
|
+
- **Source:** https://github.com/aeternm/authoxi/tree/main/sdk/python
|
|
21
|
+
- **JS SDK** (browser): [`@authoxi/js`](https://www.npmjs.com/package/@authoxi/js)
|
|
22
|
+
|
|
23
|
+
## License
|
|
24
|
+
|
|
25
|
+
MIT — see [LICENSE](./LICENSE). The SDK is MIT so you can embed it freely; the authoxi service itself
|
|
26
|
+
is proprietary.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""authoxi Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .agents import (
|
|
4
|
+
Agent,
|
|
5
|
+
AgentControl,
|
|
6
|
+
BudgetExceeded,
|
|
7
|
+
Denied,
|
|
8
|
+
Escalated,
|
|
9
|
+
keygen,
|
|
10
|
+
sign,
|
|
11
|
+
)
|
|
12
|
+
from .client import AsyncAuthoxiClient, AuthoxiClient
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
AuthoxiError,
|
|
15
|
+
EmailTaken,
|
|
16
|
+
InvalidCredentials,
|
|
17
|
+
InvalidToken,
|
|
18
|
+
NotFound,
|
|
19
|
+
OTPExpired,
|
|
20
|
+
OTPInvalidCode,
|
|
21
|
+
OTPLocked,
|
|
22
|
+
RateLimited,
|
|
23
|
+
)
|
|
24
|
+
from .models import OTPState, Tenant, Tokens, User
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Agent",
|
|
28
|
+
"AgentControl",
|
|
29
|
+
"BudgetExceeded",
|
|
30
|
+
"Denied",
|
|
31
|
+
"Escalated",
|
|
32
|
+
"keygen",
|
|
33
|
+
"sign",
|
|
34
|
+
"AsyncAuthoxiClient",
|
|
35
|
+
"AuthoxiClient",
|
|
36
|
+
"AuthoxiError",
|
|
37
|
+
"EmailTaken",
|
|
38
|
+
"InvalidCredentials",
|
|
39
|
+
"InvalidToken",
|
|
40
|
+
"NotFound",
|
|
41
|
+
"OTPExpired",
|
|
42
|
+
"OTPInvalidCode",
|
|
43
|
+
"OTPLocked",
|
|
44
|
+
"RateLimited",
|
|
45
|
+
"OTPState",
|
|
46
|
+
"Tenant",
|
|
47
|
+
"Tokens",
|
|
48
|
+
"User",
|
|
49
|
+
]
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Agent control-plane SDK surface.
|
|
2
|
+
|
|
3
|
+
Thin, typed wrappers over the agent endpoints, so a customer drives the whole control plane
|
|
4
|
+
(identity · keyring · meter · wallet) from code instead of raw HTTP. Plus a small runtime
|
|
5
|
+
``Agent`` helper that holds the keypair, manages its short-lived passport, and exposes a
|
|
6
|
+
**mocked** model call (``chat``) that is metered exactly like a real one — the governance is
|
|
7
|
+
real, the LLM is a stub.
|
|
8
|
+
|
|
9
|
+
The client is transport-agnostic: pass any object with ``.post`` / ``.get`` returning an
|
|
10
|
+
httpx-style response (an ``httpx.Client``, or a FastAPI ``TestClient`` for in-process demos).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
19
|
+
|
|
20
|
+
from .exceptions import AuthoxiError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Http(Protocol):
|
|
24
|
+
def post(self, url: str, **kwargs: Any) -> Any: ...
|
|
25
|
+
def get(self, url: str, **kwargs: Any) -> Any: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _b64url(raw: bytes) -> str:
|
|
29
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def keygen() -> tuple[Ed25519PrivateKey, str]:
|
|
33
|
+
"""A fresh Ed25519 keypair. Returns (private key object, public key as base64url) — the
|
|
34
|
+
private key never leaves the agent; only the public key is registered."""
|
|
35
|
+
priv = Ed25519PrivateKey.generate()
|
|
36
|
+
from cryptography.hazmat.primitives import serialization
|
|
37
|
+
|
|
38
|
+
pub_raw = priv.public_key().public_bytes(
|
|
39
|
+
encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
|
|
40
|
+
)
|
|
41
|
+
return priv, _b64url(pub_raw)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sign(priv: Ed25519PrivateKey, message: bytes) -> str:
|
|
45
|
+
return _b64url(priv.sign(message))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BudgetExceeded(AuthoxiError):
|
|
49
|
+
"""A metered call was blocked because the agent is over budget."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str) -> None:
|
|
52
|
+
super().__init__("budget_exceeded", message)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Denied(AuthoxiError):
|
|
56
|
+
"""An action was denied by policy. ``reason_code`` says why."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, reason_code: str) -> None:
|
|
59
|
+
super().__init__("denied", f"denied: {reason_code}")
|
|
60
|
+
self.reason_code = reason_code
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Escalated(AuthoxiError):
|
|
64
|
+
"""An action needs a human. ``pending_id`` is the decision awaiting a signature."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, pending_id: str) -> None:
|
|
67
|
+
super().__init__("escalate", f"escalate: {pending_id}")
|
|
68
|
+
self.pending_id = pending_id
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AgentControl:
|
|
72
|
+
"""Control-plane operations for a tenant (authenticated with its secret key)."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
http: _Http,
|
|
77
|
+
*,
|
|
78
|
+
secret_key: str | None = None,
|
|
79
|
+
master_key: str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._http = http
|
|
82
|
+
self._sk = {"Authorization": f"Bearer {secret_key}"} if secret_key else {}
|
|
83
|
+
self._mk = {"Authorization": f"Bearer {master_key}"} if master_key else {}
|
|
84
|
+
|
|
85
|
+
# -- low level --------------------------------------------------------
|
|
86
|
+
def _json(self, resp: Any) -> Any:
|
|
87
|
+
if resp.status_code >= 400:
|
|
88
|
+
raise AuthoxiError("http_error", f"{resp.status_code}: {resp.text}")
|
|
89
|
+
return resp.json()
|
|
90
|
+
|
|
91
|
+
def _post(
|
|
92
|
+
self, path: str, body: Any, *, master: bool = False, raw: bool = False
|
|
93
|
+
) -> Any:
|
|
94
|
+
resp = self._http.post(
|
|
95
|
+
path, json=body, headers=self._mk if master else self._sk
|
|
96
|
+
)
|
|
97
|
+
return resp if raw else self._json(resp)
|
|
98
|
+
|
|
99
|
+
def _get(self, path: str, **params: Any) -> Any:
|
|
100
|
+
return self._json(self._http.get(path, params=params, headers=self._sk))
|
|
101
|
+
|
|
102
|
+
# -- tenants (master key) --------------------------------------------
|
|
103
|
+
def create_tenant(self, name: str) -> dict[str, Any]:
|
|
104
|
+
return self._post("/admin/tenants", {"name": name}, master=True)
|
|
105
|
+
|
|
106
|
+
# -- identity ---------------------------------------------------------
|
|
107
|
+
def register_agent(
|
|
108
|
+
self, *, public_key: str, name: str, allowed_scopes: list[str] | None = None
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
return self._post(
|
|
111
|
+
"/agent/register",
|
|
112
|
+
{
|
|
113
|
+
"public_key": public_key,
|
|
114
|
+
"name": name,
|
|
115
|
+
"allowed_scopes": allowed_scopes or [],
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def passport(
|
|
120
|
+
self, *, agent_id: str, priv: Ed25519PrivateKey, scopes: list[str] | None = None
|
|
121
|
+
) -> str:
|
|
122
|
+
challenge = self._post("/agent/challenge", {"agent_id": agent_id})["challenge"]
|
|
123
|
+
sig = sign(priv, challenge.encode())
|
|
124
|
+
resp = self._http.post(
|
|
125
|
+
"/agent/passport",
|
|
126
|
+
json={
|
|
127
|
+
"agent_id": agent_id,
|
|
128
|
+
"challenge": challenge,
|
|
129
|
+
"signature": sig,
|
|
130
|
+
"scopes": scopes or [],
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
if resp.status_code == 402:
|
|
134
|
+
raise BudgetExceeded(resp.text)
|
|
135
|
+
return self._json(resp)["passport"]
|
|
136
|
+
|
|
137
|
+
# -- IAM (roles) + keyring -------------------------------------------
|
|
138
|
+
def create_type(
|
|
139
|
+
self, *, name: str, policy: dict[str, Any], description: str = ""
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
return self._post(
|
|
142
|
+
"/agent/types", {"name": name, "description": description, "policy": policy}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def assign_type(self, *, agent_id: str, type_id: str) -> Any:
|
|
146
|
+
return self._post(f"/agent/{agent_id}/type", {"type_id": type_id})
|
|
147
|
+
|
|
148
|
+
def grant(self, *, agent_id: str, system: str, scopes: list[str]) -> dict[str, Any]:
|
|
149
|
+
resp = self._http.post(
|
|
150
|
+
"/agent/grants",
|
|
151
|
+
json={"agent_id": agent_id, "system": system, "scopes": scopes},
|
|
152
|
+
headers=self._sk,
|
|
153
|
+
)
|
|
154
|
+
if resp.status_code == 403:
|
|
155
|
+
raise Denied("grant_exceeds_policy")
|
|
156
|
+
return self._json(resp)
|
|
157
|
+
|
|
158
|
+
# -- meter ------------------------------------------------------------
|
|
159
|
+
def set_budget(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
agent_id: str,
|
|
163
|
+
limit_units: int,
|
|
164
|
+
resource: str = "inference_usd",
|
|
165
|
+
period: str = "day",
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
return self._post(
|
|
168
|
+
"/agent/usage/policy",
|
|
169
|
+
{
|
|
170
|
+
"agent_id": agent_id,
|
|
171
|
+
"resource": resource,
|
|
172
|
+
"limit_units": limit_units,
|
|
173
|
+
"period": period,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def consume(
|
|
178
|
+
self, *, agent_id: str, units: int, resource: str = "inference_usd"
|
|
179
|
+
) -> dict[str, Any]:
|
|
180
|
+
return self._post(
|
|
181
|
+
"/agent/usage/consume",
|
|
182
|
+
{"agent_id": agent_id, "resource": resource, "units": units},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# -- wallet -----------------------------------------------------------
|
|
186
|
+
def issue_mandate(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
agent_id: str,
|
|
190
|
+
budget: str,
|
|
191
|
+
escalate_over: str,
|
|
192
|
+
actions: list[str],
|
|
193
|
+
subject_kind: str = "agent",
|
|
194
|
+
subject_did: str | None = None,
|
|
195
|
+
) -> dict[str, Any]:
|
|
196
|
+
body: dict[str, Any] = {
|
|
197
|
+
"subject_kind": subject_kind,
|
|
198
|
+
"subject_id": agent_id,
|
|
199
|
+
"budget": budget,
|
|
200
|
+
"currency": "USD",
|
|
201
|
+
"escalate_over": escalate_over,
|
|
202
|
+
"allowed_actions": actions,
|
|
203
|
+
}
|
|
204
|
+
if subject_did:
|
|
205
|
+
body["subject_did"] = subject_did
|
|
206
|
+
return self._post("/agent/mandate", body)
|
|
207
|
+
|
|
208
|
+
def authorize(
|
|
209
|
+
self, *, agent_id: str, mandate_id: str, action: str, amount: str
|
|
210
|
+
) -> dict[str, Any]:
|
|
211
|
+
return self._post(
|
|
212
|
+
"/agent/authorize",
|
|
213
|
+
{
|
|
214
|
+
"agent_id": agent_id,
|
|
215
|
+
"mandate_id": mandate_id,
|
|
216
|
+
"action": action,
|
|
217
|
+
"amount": amount,
|
|
218
|
+
"currency": "USD",
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def to_sign(
|
|
223
|
+
self,
|
|
224
|
+
*,
|
|
225
|
+
pending_id: str,
|
|
226
|
+
approver_mandate_id: str,
|
|
227
|
+
reason_class: str,
|
|
228
|
+
reason_code: str,
|
|
229
|
+
) -> Any:
|
|
230
|
+
return self._get(
|
|
231
|
+
f"/agent/pending/{pending_id}",
|
|
232
|
+
approver_mandate_id=approver_mandate_id,
|
|
233
|
+
reason_class=reason_class,
|
|
234
|
+
reason_code=reason_code,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def resolve(
|
|
238
|
+
self,
|
|
239
|
+
*,
|
|
240
|
+
pending_id: str,
|
|
241
|
+
resolution: str,
|
|
242
|
+
approver_mandate_id: str,
|
|
243
|
+
signature: str,
|
|
244
|
+
reason_class: str,
|
|
245
|
+
reason_code: str,
|
|
246
|
+
) -> dict[str, Any]:
|
|
247
|
+
return self._post(
|
|
248
|
+
"/agent/resolve",
|
|
249
|
+
{
|
|
250
|
+
"pending_id": pending_id,
|
|
251
|
+
"resolution": resolution,
|
|
252
|
+
"approver_mandate_id": approver_mandate_id,
|
|
253
|
+
"signature": signature,
|
|
254
|
+
"reason_class": reason_class,
|
|
255
|
+
"reason_code": reason_code,
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def events(self) -> list[dict[str, Any]]:
|
|
260
|
+
return self._get("/agent/events")["events"]
|
|
261
|
+
|
|
262
|
+
# -- connections (safe custody) --------------------------------------
|
|
263
|
+
def connect(self, *, provider: str, secret: str) -> dict[str, Any]:
|
|
264
|
+
"""HOSTED: the secret is sealed under the tenant's KMS at the control plane."""
|
|
265
|
+
return self._post("/connections", {"provider": provider, "secret": secret})
|
|
266
|
+
|
|
267
|
+
def declare_connection(self, *, provider: str) -> dict[str, Any]:
|
|
268
|
+
"""SELF-HOSTED: no secret sent — the credential lives in the customer's data plane."""
|
|
269
|
+
return self._post("/connections/declare", {"provider": provider})
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Agent:
|
|
273
|
+
"""A runtime agent: holds its keypair, refreshes its passport, and makes a **mocked**
|
|
274
|
+
model call that is metered exactly like a real one."""
|
|
275
|
+
|
|
276
|
+
def __init__(
|
|
277
|
+
self, control: AgentControl, *, agent_id: str, priv: Ed25519PrivateKey
|
|
278
|
+
) -> None:
|
|
279
|
+
self._c = control
|
|
280
|
+
self.agent_id = agent_id
|
|
281
|
+
self._priv = priv
|
|
282
|
+
|
|
283
|
+
def refresh_passport(self, scopes: list[str] | None = None) -> str:
|
|
284
|
+
"""Obtain a fresh passport. Raises ``BudgetExceeded`` if the agent is over its meter —
|
|
285
|
+
the enforcement lever for direct model calls (over budget → no passport → cut off)."""
|
|
286
|
+
return self._c.passport(agent_id=self.agent_id, priv=self._priv, scopes=scopes)
|
|
287
|
+
|
|
288
|
+
def chat(self, prompt: str, *, cost_units: int) -> str:
|
|
289
|
+
"""A MOCKED LLM call. The model is a stub; the metering is real: it consumes ``cost_units``
|
|
290
|
+
against the agent's inference budget and raises ``BudgetExceeded`` when over."""
|
|
291
|
+
out = self._c.consume(agent_id=self.agent_id, units=cost_units)
|
|
292
|
+
if not out["allowed"]:
|
|
293
|
+
raise BudgetExceeded(
|
|
294
|
+
f"inference budget exhausted (used {out['used']}/{out['limit']})"
|
|
295
|
+
)
|
|
296
|
+
# --- the real LLM call would go here; mocked for the demo ---
|
|
297
|
+
return f"[mocked completion for: {prompt[:40]}…]"
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""authoxi Python SDK — sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .exceptions import AuthoxiError, from_api_error
|
|
10
|
+
from .models import OTPState, Tenant, Tokens, User
|
|
11
|
+
|
|
12
|
+
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _build_signup_body(
|
|
16
|
+
email: str,
|
|
17
|
+
password: str,
|
|
18
|
+
name: str | None,
|
|
19
|
+
metadata: dict[str, Any] | None,
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
return {
|
|
22
|
+
"email": email,
|
|
23
|
+
"password": password,
|
|
24
|
+
"name": name,
|
|
25
|
+
"metadata": metadata or {},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_otp_body(
|
|
30
|
+
email: str | None,
|
|
31
|
+
phone: str | None,
|
|
32
|
+
purpose: str,
|
|
33
|
+
name: str | None,
|
|
34
|
+
first_name: str | None,
|
|
35
|
+
last_name: str | None,
|
|
36
|
+
metadata: dict[str, Any] | None,
|
|
37
|
+
) -> tuple[str, dict[str, Any]]:
|
|
38
|
+
if not email and not phone:
|
|
39
|
+
raise ValueError("request_otp requires email or phone")
|
|
40
|
+
body: dict[str, Any] = {}
|
|
41
|
+
if email is not None:
|
|
42
|
+
body["email"] = email
|
|
43
|
+
if phone is not None:
|
|
44
|
+
body["phone"] = phone
|
|
45
|
+
if purpose == "signup":
|
|
46
|
+
if name is not None:
|
|
47
|
+
body["name"] = name
|
|
48
|
+
if first_name is not None:
|
|
49
|
+
body["first_name"] = first_name
|
|
50
|
+
if last_name is not None:
|
|
51
|
+
body["last_name"] = last_name
|
|
52
|
+
if metadata is not None:
|
|
53
|
+
body["metadata"] = metadata
|
|
54
|
+
return "/auth/signup/otp", body
|
|
55
|
+
return "/auth/signin/otp", body
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _raise_for_response(resp: httpx.Response) -> dict[str, Any]:
|
|
59
|
+
if resp.status_code == 204 or not resp.content:
|
|
60
|
+
if resp.is_success:
|
|
61
|
+
return {}
|
|
62
|
+
data = resp.json()
|
|
63
|
+
if not resp.is_success:
|
|
64
|
+
err = data.get("error", {}) if isinstance(data, dict) else {}
|
|
65
|
+
raise from_api_error(
|
|
66
|
+
err.get("code", "error"),
|
|
67
|
+
err.get("message", str(data)),
|
|
68
|
+
resp.status_code,
|
|
69
|
+
)
|
|
70
|
+
return data # type: ignore[return-value]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Headers:
|
|
74
|
+
"""Mixin: assemble tenant/master headers from credentials."""
|
|
75
|
+
|
|
76
|
+
_publishable_key: str | None
|
|
77
|
+
_master_key: str | None
|
|
78
|
+
|
|
79
|
+
def _tenant_headers(self) -> dict[str, str]:
|
|
80
|
+
if not self._publishable_key:
|
|
81
|
+
raise AuthoxiError(
|
|
82
|
+
"missing_key", "publishable_key is required for /auth operations"
|
|
83
|
+
)
|
|
84
|
+
return {"X-Authoxi-Key": self._publishable_key}
|
|
85
|
+
|
|
86
|
+
def _master_headers(self) -> dict[str, str]:
|
|
87
|
+
if not self._master_key:
|
|
88
|
+
raise AuthoxiError(
|
|
89
|
+
"missing_key", "master_key is required for admin operations"
|
|
90
|
+
)
|
|
91
|
+
return {"Authorization": f"Bearer {self._master_key}"}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AsyncAuthoxiClient(_Headers):
|
|
95
|
+
"""
|
|
96
|
+
Async authoxi client.
|
|
97
|
+
|
|
98
|
+
Two credential modes:
|
|
99
|
+
* ``publishable_key`` — required for ``/auth/*`` flows. Sent as ``X-Authoxi-Key``.
|
|
100
|
+
* ``master_key`` — required for ``admin_*`` operations. Sent as
|
|
101
|
+
``Authorization: Bearer <master_key>``.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
base_url: str,
|
|
107
|
+
*,
|
|
108
|
+
publishable_key: str | None = None,
|
|
109
|
+
master_key: str | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
self._base_url = base_url.rstrip("/")
|
|
112
|
+
self._publishable_key = publishable_key
|
|
113
|
+
self._master_key = master_key
|
|
114
|
+
self._http = httpx.AsyncClient(base_url=self._base_url, timeout=_TIMEOUT)
|
|
115
|
+
|
|
116
|
+
async def aclose(self) -> None:
|
|
117
|
+
await self._http.aclose()
|
|
118
|
+
|
|
119
|
+
async def __aenter__(self) -> "AsyncAuthoxiClient":
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
123
|
+
await self.aclose()
|
|
124
|
+
|
|
125
|
+
async def _request(
|
|
126
|
+
self,
|
|
127
|
+
method: str,
|
|
128
|
+
path: str,
|
|
129
|
+
*,
|
|
130
|
+
json: Any = None,
|
|
131
|
+
headers: dict[str, str] | None = None,
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
resp = await self._http.request(method, path, json=json, headers=headers or {})
|
|
134
|
+
return _raise_for_response(resp)
|
|
135
|
+
|
|
136
|
+
# Admin -----------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
async def admin_create_tenant(
|
|
139
|
+
self,
|
|
140
|
+
name: str,
|
|
141
|
+
*,
|
|
142
|
+
metadata: dict[str, Any] | None = None,
|
|
143
|
+
) -> Tenant:
|
|
144
|
+
data = await self._request(
|
|
145
|
+
"POST",
|
|
146
|
+
"/admin/tenants",
|
|
147
|
+
json={"name": name, "metadata": metadata or {}},
|
|
148
|
+
headers=self._master_headers(),
|
|
149
|
+
)
|
|
150
|
+
return Tenant.from_dict(data)
|
|
151
|
+
|
|
152
|
+
async def admin_get_tenant(self, tenant_id: str) -> Tenant:
|
|
153
|
+
data = await self._request(
|
|
154
|
+
"GET",
|
|
155
|
+
f"/admin/tenants/{tenant_id}",
|
|
156
|
+
headers=self._master_headers(),
|
|
157
|
+
)
|
|
158
|
+
return Tenant.from_dict(data)
|
|
159
|
+
|
|
160
|
+
async def admin_rotate_secret(self, tenant_id: str) -> str:
|
|
161
|
+
data = await self._request(
|
|
162
|
+
"POST",
|
|
163
|
+
f"/admin/tenants/{tenant_id}/rotate-secret",
|
|
164
|
+
headers=self._master_headers(),
|
|
165
|
+
)
|
|
166
|
+
return data["secret_key"] # type: ignore[no-any-return]
|
|
167
|
+
|
|
168
|
+
# Auth ------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
async def signup(
|
|
171
|
+
self,
|
|
172
|
+
email: str,
|
|
173
|
+
password: str,
|
|
174
|
+
*,
|
|
175
|
+
name: str | None = None,
|
|
176
|
+
metadata: dict[str, Any] | None = None,
|
|
177
|
+
) -> tuple[User, Tokens]:
|
|
178
|
+
data = await self._request(
|
|
179
|
+
"POST",
|
|
180
|
+
"/auth/signup",
|
|
181
|
+
json=_build_signup_body(email, password, name, metadata),
|
|
182
|
+
headers=self._tenant_headers(),
|
|
183
|
+
)
|
|
184
|
+
return User.from_dict(data), Tokens.from_dict(data)
|
|
185
|
+
|
|
186
|
+
async def signin(self, email: str, password: str) -> Tokens:
|
|
187
|
+
data = await self._request(
|
|
188
|
+
"POST",
|
|
189
|
+
"/auth/signin",
|
|
190
|
+
json={"email": email, "password": password},
|
|
191
|
+
headers=self._tenant_headers(),
|
|
192
|
+
)
|
|
193
|
+
return Tokens.from_dict(data)
|
|
194
|
+
|
|
195
|
+
async def request_otp(
|
|
196
|
+
self,
|
|
197
|
+
email: str | None = None,
|
|
198
|
+
*,
|
|
199
|
+
phone: str | None = None,
|
|
200
|
+
purpose: str = "signin",
|
|
201
|
+
name: str | None = None,
|
|
202
|
+
first_name: str | None = None,
|
|
203
|
+
last_name: str | None = None,
|
|
204
|
+
metadata: dict[str, Any] | None = None,
|
|
205
|
+
) -> OTPState:
|
|
206
|
+
path, body = _build_otp_body(
|
|
207
|
+
email, phone, purpose, name, first_name, last_name, metadata
|
|
208
|
+
)
|
|
209
|
+
data = await self._request(
|
|
210
|
+
"POST", path, json=body, headers=self._tenant_headers()
|
|
211
|
+
)
|
|
212
|
+
return OTPState.from_dict(data)
|
|
213
|
+
|
|
214
|
+
async def verify_otp(self, otp_id: str, code: str) -> tuple[User, Tokens]:
|
|
215
|
+
data = await self._request(
|
|
216
|
+
"POST",
|
|
217
|
+
"/auth/otp/verify",
|
|
218
|
+
json={"otp_id": otp_id, "code": code},
|
|
219
|
+
headers=self._tenant_headers(),
|
|
220
|
+
)
|
|
221
|
+
return User.from_dict(data), Tokens.from_dict(data)
|
|
222
|
+
|
|
223
|
+
async def me(self, access_token: str) -> User:
|
|
224
|
+
data = await self._request(
|
|
225
|
+
"GET",
|
|
226
|
+
"/auth/me",
|
|
227
|
+
headers={
|
|
228
|
+
**self._tenant_headers(),
|
|
229
|
+
"Authorization": f"Bearer {access_token}",
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
return User.from_dict(data)
|
|
233
|
+
|
|
234
|
+
async def verify_token(self, access_token: str) -> User:
|
|
235
|
+
"""Validate a bearer token, return its ``User``. Raises ``InvalidToken`` on 401."""
|
|
236
|
+
return await self.me(access_token)
|
|
237
|
+
|
|
238
|
+
async def signout(self, refresh_token: str, *, access_token: str) -> None:
|
|
239
|
+
await self._request(
|
|
240
|
+
"POST",
|
|
241
|
+
"/auth/signout",
|
|
242
|
+
json={"refresh_token": refresh_token},
|
|
243
|
+
headers={
|
|
244
|
+
**self._tenant_headers(),
|
|
245
|
+
"Authorization": f"Bearer {access_token}",
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
async def refresh(self, refresh_token: str) -> Tokens:
|
|
250
|
+
data = await self._request(
|
|
251
|
+
"POST",
|
|
252
|
+
"/auth/refresh",
|
|
253
|
+
json={"refresh_token": refresh_token},
|
|
254
|
+
headers=self._tenant_headers(),
|
|
255
|
+
)
|
|
256
|
+
return Tokens.from_dict(data)
|
|
257
|
+
|
|
258
|
+
async def forgot_password(
|
|
259
|
+
self, email: str, *, redirect_url: str | None = None
|
|
260
|
+
) -> None:
|
|
261
|
+
body: dict[str, Any] = {"email": email}
|
|
262
|
+
if redirect_url is not None:
|
|
263
|
+
body["redirect_url"] = redirect_url
|
|
264
|
+
await self._request(
|
|
265
|
+
"POST",
|
|
266
|
+
"/auth/forgot-password",
|
|
267
|
+
json=body,
|
|
268
|
+
headers=self._tenant_headers(),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
async def reset_password(self, token: str, new_password: str) -> None:
|
|
272
|
+
await self._request(
|
|
273
|
+
"POST",
|
|
274
|
+
"/auth/reset-password",
|
|
275
|
+
json={"token": token, "new_password": new_password},
|
|
276
|
+
headers=self._tenant_headers(),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def update_password(self, access_token: str, new_password: str) -> None:
|
|
280
|
+
"""Set/update the current user's password (Bearer-authenticated)."""
|
|
281
|
+
await self._request(
|
|
282
|
+
"POST",
|
|
283
|
+
"/auth/password/update",
|
|
284
|
+
json={"new_password": new_password},
|
|
285
|
+
headers={
|
|
286
|
+
**self._tenant_headers(),
|
|
287
|
+
"Authorization": f"Bearer {access_token}",
|
|
288
|
+
},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class AuthoxiClient(_Headers):
|
|
293
|
+
"""
|
|
294
|
+
Sync authoxi client. Uses a persistent ``httpx.Client`` — safe to call repeatedly.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
base_url: str,
|
|
300
|
+
*,
|
|
301
|
+
publishable_key: str | None = None,
|
|
302
|
+
master_key: str | None = None,
|
|
303
|
+
) -> None:
|
|
304
|
+
self._base_url = base_url.rstrip("/")
|
|
305
|
+
self._publishable_key = publishable_key
|
|
306
|
+
self._master_key = master_key
|
|
307
|
+
self._http = httpx.Client(base_url=self._base_url, timeout=_TIMEOUT)
|
|
308
|
+
|
|
309
|
+
def close(self) -> None:
|
|
310
|
+
self._http.close()
|
|
311
|
+
|
|
312
|
+
def __enter__(self) -> "AuthoxiClient":
|
|
313
|
+
return self
|
|
314
|
+
|
|
315
|
+
def __exit__(self, *_: Any) -> None:
|
|
316
|
+
self.close()
|
|
317
|
+
|
|
318
|
+
def _request(
|
|
319
|
+
self,
|
|
320
|
+
method: str,
|
|
321
|
+
path: str,
|
|
322
|
+
*,
|
|
323
|
+
json: Any = None,
|
|
324
|
+
headers: dict[str, str] | None = None,
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
resp = self._http.request(method, path, json=json, headers=headers or {})
|
|
327
|
+
return _raise_for_response(resp)
|
|
328
|
+
|
|
329
|
+
# Admin -----------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def admin_create_tenant(
|
|
332
|
+
self,
|
|
333
|
+
name: str,
|
|
334
|
+
*,
|
|
335
|
+
metadata: dict[str, Any] | None = None,
|
|
336
|
+
) -> Tenant:
|
|
337
|
+
data = self._request(
|
|
338
|
+
"POST",
|
|
339
|
+
"/admin/tenants",
|
|
340
|
+
json={"name": name, "metadata": metadata or {}},
|
|
341
|
+
headers=self._master_headers(),
|
|
342
|
+
)
|
|
343
|
+
return Tenant.from_dict(data)
|
|
344
|
+
|
|
345
|
+
def admin_get_tenant(self, tenant_id: str) -> Tenant:
|
|
346
|
+
data = self._request(
|
|
347
|
+
"GET",
|
|
348
|
+
f"/admin/tenants/{tenant_id}",
|
|
349
|
+
headers=self._master_headers(),
|
|
350
|
+
)
|
|
351
|
+
return Tenant.from_dict(data)
|
|
352
|
+
|
|
353
|
+
def admin_rotate_secret(self, tenant_id: str) -> str:
|
|
354
|
+
data = self._request(
|
|
355
|
+
"POST",
|
|
356
|
+
f"/admin/tenants/{tenant_id}/rotate-secret",
|
|
357
|
+
headers=self._master_headers(),
|
|
358
|
+
)
|
|
359
|
+
return data["secret_key"] # type: ignore[no-any-return]
|
|
360
|
+
|
|
361
|
+
# Auth ------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def signup(
|
|
364
|
+
self,
|
|
365
|
+
email: str,
|
|
366
|
+
password: str,
|
|
367
|
+
*,
|
|
368
|
+
name: str | None = None,
|
|
369
|
+
metadata: dict[str, Any] | None = None,
|
|
370
|
+
) -> tuple[User, Tokens]:
|
|
371
|
+
data = self._request(
|
|
372
|
+
"POST",
|
|
373
|
+
"/auth/signup",
|
|
374
|
+
json=_build_signup_body(email, password, name, metadata),
|
|
375
|
+
headers=self._tenant_headers(),
|
|
376
|
+
)
|
|
377
|
+
return User.from_dict(data), Tokens.from_dict(data)
|
|
378
|
+
|
|
379
|
+
def signin(self, email: str, password: str) -> Tokens:
|
|
380
|
+
data = self._request(
|
|
381
|
+
"POST",
|
|
382
|
+
"/auth/signin",
|
|
383
|
+
json={"email": email, "password": password},
|
|
384
|
+
headers=self._tenant_headers(),
|
|
385
|
+
)
|
|
386
|
+
return Tokens.from_dict(data)
|
|
387
|
+
|
|
388
|
+
def request_otp(
|
|
389
|
+
self,
|
|
390
|
+
email: str | None = None,
|
|
391
|
+
*,
|
|
392
|
+
phone: str | None = None,
|
|
393
|
+
purpose: str = "signin",
|
|
394
|
+
name: str | None = None,
|
|
395
|
+
first_name: str | None = None,
|
|
396
|
+
last_name: str | None = None,
|
|
397
|
+
metadata: dict[str, Any] | None = None,
|
|
398
|
+
) -> OTPState:
|
|
399
|
+
path, body = _build_otp_body(
|
|
400
|
+
email, phone, purpose, name, first_name, last_name, metadata
|
|
401
|
+
)
|
|
402
|
+
data = self._request("POST", path, json=body, headers=self._tenant_headers())
|
|
403
|
+
return OTPState.from_dict(data)
|
|
404
|
+
|
|
405
|
+
def verify_otp(self, otp_id: str, code: str) -> tuple[User, Tokens]:
|
|
406
|
+
data = self._request(
|
|
407
|
+
"POST",
|
|
408
|
+
"/auth/otp/verify",
|
|
409
|
+
json={"otp_id": otp_id, "code": code},
|
|
410
|
+
headers=self._tenant_headers(),
|
|
411
|
+
)
|
|
412
|
+
return User.from_dict(data), Tokens.from_dict(data)
|
|
413
|
+
|
|
414
|
+
def me(self, access_token: str) -> User:
|
|
415
|
+
data = self._request(
|
|
416
|
+
"GET",
|
|
417
|
+
"/auth/me",
|
|
418
|
+
headers={
|
|
419
|
+
**self._tenant_headers(),
|
|
420
|
+
"Authorization": f"Bearer {access_token}",
|
|
421
|
+
},
|
|
422
|
+
)
|
|
423
|
+
return User.from_dict(data)
|
|
424
|
+
|
|
425
|
+
def verify_token(self, access_token: str) -> User:
|
|
426
|
+
"""Validate a bearer token, return its ``User``. Raises ``InvalidToken`` on 401."""
|
|
427
|
+
return self.me(access_token)
|
|
428
|
+
|
|
429
|
+
def signout(self, refresh_token: str, *, access_token: str) -> None:
|
|
430
|
+
self._request(
|
|
431
|
+
"POST",
|
|
432
|
+
"/auth/signout",
|
|
433
|
+
json={"refresh_token": refresh_token},
|
|
434
|
+
headers={
|
|
435
|
+
**self._tenant_headers(),
|
|
436
|
+
"Authorization": f"Bearer {access_token}",
|
|
437
|
+
},
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def refresh(self, refresh_token: str) -> Tokens:
|
|
441
|
+
data = self._request(
|
|
442
|
+
"POST",
|
|
443
|
+
"/auth/refresh",
|
|
444
|
+
json={"refresh_token": refresh_token},
|
|
445
|
+
headers=self._tenant_headers(),
|
|
446
|
+
)
|
|
447
|
+
return Tokens.from_dict(data)
|
|
448
|
+
|
|
449
|
+
def forgot_password(self, email: str, *, redirect_url: str | None = None) -> None:
|
|
450
|
+
body: dict[str, Any] = {"email": email}
|
|
451
|
+
if redirect_url is not None:
|
|
452
|
+
body["redirect_url"] = redirect_url
|
|
453
|
+
self._request(
|
|
454
|
+
"POST",
|
|
455
|
+
"/auth/forgot-password",
|
|
456
|
+
json=body,
|
|
457
|
+
headers=self._tenant_headers(),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def reset_password(self, token: str, new_password: str) -> None:
|
|
461
|
+
self._request(
|
|
462
|
+
"POST",
|
|
463
|
+
"/auth/reset-password",
|
|
464
|
+
json={"token": token, "new_password": new_password},
|
|
465
|
+
headers=self._tenant_headers(),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def update_password(self, access_token: str, new_password: str) -> None:
|
|
469
|
+
"""Set/update the current user's password (Bearer-authenticated)."""
|
|
470
|
+
self._request(
|
|
471
|
+
"POST",
|
|
472
|
+
"/auth/password/update",
|
|
473
|
+
json={"new_password": new_password},
|
|
474
|
+
headers={
|
|
475
|
+
**self._tenant_headers(),
|
|
476
|
+
"Authorization": f"Bearer {access_token}",
|
|
477
|
+
},
|
|
478
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Typed exceptions for the authoxi Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthoxiError(Exception):
|
|
7
|
+
"""Base exception for all authoxi errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, code: str, message: str, status_code: int = 0) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.code = code
|
|
12
|
+
self.message = message
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
|
|
15
|
+
def __repr__(self) -> str:
|
|
16
|
+
return (
|
|
17
|
+
f"{self.__class__.__name__}(code={self.code!r}, message={self.message!r})"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidCredentials(AuthoxiError):
|
|
22
|
+
"""Email or password is incorrect."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EmailTaken(AuthoxiError):
|
|
26
|
+
"""Email is already registered for this tenant."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OTPExpired(AuthoxiError):
|
|
30
|
+
"""OTP code has expired."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OTPInvalidCode(AuthoxiError):
|
|
34
|
+
"""OTP code is incorrect."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OTPLocked(AuthoxiError):
|
|
38
|
+
"""Too many incorrect OTP attempts — must request a new code."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RateLimited(AuthoxiError):
|
|
42
|
+
"""Too many requests. Slow down."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InvalidToken(AuthoxiError):
|
|
46
|
+
"""Access or refresh token is invalid or expired."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotFound(AuthoxiError):
|
|
50
|
+
"""Requested resource not found."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_CODE_MAP: dict[str, type[AuthoxiError]] = {
|
|
54
|
+
"invalid_credentials": InvalidCredentials,
|
|
55
|
+
"email_taken": EmailTaken,
|
|
56
|
+
"otp_expired": OTPExpired,
|
|
57
|
+
"invalid_otp_code": OTPInvalidCode,
|
|
58
|
+
"otp_locked": OTPLocked,
|
|
59
|
+
"rate_limited": RateLimited,
|
|
60
|
+
"invalid_token": InvalidToken,
|
|
61
|
+
"token_expired": InvalidToken,
|
|
62
|
+
"not_found": NotFound,
|
|
63
|
+
"user_not_found": NotFound,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def from_api_error(code: str, message: str, status_code: int) -> AuthoxiError:
|
|
68
|
+
cls = _CODE_MAP.get(code, AuthoxiError)
|
|
69
|
+
return cls(code=code, message=message, status_code=status_code)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""FastAPI integration for the authoxi SDK.
|
|
2
|
+
|
|
3
|
+
Drop-in dependency that validates the ``Authorization: Bearer <token>`` header
|
|
4
|
+
against authoxi and returns the authenticated :class:`User`.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, FastAPI
|
|
9
|
+
from authoxi import AuthoxiClient
|
|
10
|
+
from authoxi.fastapi import authed_user
|
|
11
|
+
|
|
12
|
+
client = AuthoxiClient(base_url="https://api.authoxi.com", publishable_key="pk_live_...")
|
|
13
|
+
app = FastAPI()
|
|
14
|
+
|
|
15
|
+
@app.get("/profile")
|
|
16
|
+
def profile(user = Depends(authed_user(client))):
|
|
17
|
+
return {"email": user.email, "tenant_id": user.tenant_id}
|
|
18
|
+
|
|
19
|
+
Works with both :class:`AuthoxiClient` (sync) and :class:`AsyncAuthoxiClient` (async).
|
|
20
|
+
``fastapi`` must be installed: ``pip install authoxi[fastapi]``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Annotated, Any, Callable
|
|
26
|
+
|
|
27
|
+
from fastapi import Header, HTTPException, status
|
|
28
|
+
|
|
29
|
+
from .client import AsyncAuthoxiClient, AuthoxiClient
|
|
30
|
+
from .exceptions import AuthoxiError, InvalidToken
|
|
31
|
+
from .models import User
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extract_bearer(authorization: str | None) -> str:
|
|
35
|
+
if not authorization or not authorization.lower().startswith("bearer "):
|
|
36
|
+
raise HTTPException(
|
|
37
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
38
|
+
detail={
|
|
39
|
+
"code": "missing_auth",
|
|
40
|
+
"message": "Authorization: Bearer <token> required",
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
return authorization.split(" ", 1)[1].strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _http_error_for(exc: AuthoxiError) -> HTTPException:
|
|
47
|
+
code = exc.status_code or status.HTTP_401_UNAUTHORIZED
|
|
48
|
+
return HTTPException(
|
|
49
|
+
status_code=code, detail={"code": exc.code, "message": exc.message}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def authed_user(client: AuthoxiClient | AsyncAuthoxiClient) -> Callable[..., Any]:
|
|
54
|
+
"""Build a FastAPI dependency that validates the bearer token and returns a ``User``."""
|
|
55
|
+
if isinstance(client, AsyncAuthoxiClient):
|
|
56
|
+
|
|
57
|
+
async def dep_async(
|
|
58
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
59
|
+
) -> User:
|
|
60
|
+
token = _extract_bearer(authorization)
|
|
61
|
+
try:
|
|
62
|
+
return await client.verify_token(token)
|
|
63
|
+
except InvalidToken as exc:
|
|
64
|
+
raise _http_error_for(exc) from exc
|
|
65
|
+
except AuthoxiError as exc:
|
|
66
|
+
raise _http_error_for(exc) from exc
|
|
67
|
+
|
|
68
|
+
return dep_async
|
|
69
|
+
|
|
70
|
+
def dep_sync(authorization: Annotated[str | None, Header()] = None) -> User:
|
|
71
|
+
token = _extract_bearer(authorization)
|
|
72
|
+
try:
|
|
73
|
+
return client.verify_token(token)
|
|
74
|
+
except InvalidToken as exc:
|
|
75
|
+
raise _http_error_for(exc) from exc
|
|
76
|
+
except AuthoxiError as exc:
|
|
77
|
+
raise _http_error_for(exc) from exc
|
|
78
|
+
|
|
79
|
+
return dep_sync
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Domain types returned by the authoxi SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class User:
|
|
12
|
+
user_id: str
|
|
13
|
+
tenant_id: str
|
|
14
|
+
email: str | None
|
|
15
|
+
phone: str | None
|
|
16
|
+
name: str | None
|
|
17
|
+
metadata: dict[str, Any]
|
|
18
|
+
status: str
|
|
19
|
+
created_at: datetime
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_dict(cls, d: dict[str, Any]) -> "User":
|
|
23
|
+
u = d.get("user", d)
|
|
24
|
+
return cls(
|
|
25
|
+
user_id=u["user_id"],
|
|
26
|
+
tenant_id=u["tenant_id"],
|
|
27
|
+
email=u.get("email"),
|
|
28
|
+
phone=u.get("phone"),
|
|
29
|
+
name=u.get("name"),
|
|
30
|
+
metadata=u.get("metadata", {}),
|
|
31
|
+
status=u.get("status", "active"),
|
|
32
|
+
created_at=datetime.fromisoformat(u["created_at"].replace("Z", "+00:00")),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Tokens:
|
|
38
|
+
access_token: str
|
|
39
|
+
refresh_token: str
|
|
40
|
+
token_type: str
|
|
41
|
+
expires_in: int
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, d: dict[str, Any]) -> "Tokens":
|
|
45
|
+
return cls(
|
|
46
|
+
access_token=d["access_token"],
|
|
47
|
+
refresh_token=d["refresh_token"],
|
|
48
|
+
token_type=d.get("token_type", "bearer"),
|
|
49
|
+
expires_in=d["expires_in"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class OTPState:
|
|
55
|
+
otp_id: str
|
|
56
|
+
expires_in: int
|
|
57
|
+
test_code: str | None = None
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, d: dict[str, Any]) -> "OTPState":
|
|
61
|
+
return cls(
|
|
62
|
+
otp_id=d["otp_id"],
|
|
63
|
+
expires_in=d["expires_in"],
|
|
64
|
+
test_code=d.get("test_code"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Tenant:
|
|
70
|
+
tenant_id: str
|
|
71
|
+
name: str
|
|
72
|
+
publishable_key: str
|
|
73
|
+
status: str
|
|
74
|
+
metadata: dict[str, Any]
|
|
75
|
+
created_at: datetime
|
|
76
|
+
secret_key: str | None = None
|
|
77
|
+
updated_at: datetime | None = None
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, d: dict[str, Any]) -> "Tenant":
|
|
81
|
+
updated_raw = d.get("updated_at")
|
|
82
|
+
return cls(
|
|
83
|
+
tenant_id=d["tenant_id"],
|
|
84
|
+
name=d["name"],
|
|
85
|
+
publishable_key=d["publishable_key"],
|
|
86
|
+
status=d.get("status", "active"),
|
|
87
|
+
metadata=d.get("metadata", {}),
|
|
88
|
+
created_at=datetime.fromisoformat(d["created_at"].replace("Z", "+00:00")),
|
|
89
|
+
secret_key=d.get("secret_key"),
|
|
90
|
+
updated_at=datetime.fromisoformat(updated_raw.replace("Z", "+00:00"))
|
|
91
|
+
if updated_raw
|
|
92
|
+
else None,
|
|
93
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "authoxi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for the authoxi authentication platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "aeternm" }]
|
|
14
|
+
keywords = ["authentication", "auth", "agent-identity", "mcp", "authoxi"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.27.0",
|
|
25
|
+
# Agents generate their own Ed25519 keypair and sign PoP challenges client-side.
|
|
26
|
+
"cryptography>=42.0.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://authoxi.com"
|
|
31
|
+
Documentation = "https://authoxi.com"
|
|
32
|
+
Source = "https://github.com/aeternm/authoxi/tree/main/sdk/python"
|
|
33
|
+
Issues = "https://github.com/aeternm/authoxi/issues"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
fastapi = [
|
|
37
|
+
"fastapi>=0.110.0",
|
|
38
|
+
]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8.0.0",
|
|
41
|
+
"pytest-asyncio>=0.23.0",
|
|
42
|
+
"fastapi>=0.110.0",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["authoxi"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
# The wheel carries the package; the sdist ships the license + readme too, so PyPI and
|
|
50
|
+
# downstream repackagers have them.
|
|
51
|
+
include = ["authoxi", "README.md", "LICENSE"]
|