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.
@@ -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.
@@ -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"]