Metadata-Version: 2.4
Name: axowl-sdk
Version: 0.1.0
Summary: Axowl backend SDK for Python — verify Axowl end-user JWTs against the org JWKS, wildcard permission checks, server-authoritative introspection.
Author: Axowl Inc.
License: MIT
Project-URL: Homepage, https://axowl.com
Project-URL: Documentation, https://docs.axowl.com/sdk/python/
Project-URL: Source, https://github.com/Axowl-inc/axowl-sdk
Keywords: axowl,authentication,jwt,jwks,permissions
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Topic :: Security
Classifier: Framework :: FastAPI
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: PyJWT[crypto]<3,>=2.8
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100; extra == "fastapi"
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Requires-Dist: cryptography>=41; extra == "test"

# axowl-sdk (Python)

Backend SDK for [Axowl](https://axowl.com): verify Axowl end-user access tokens on your server,
read the caller's permissions, and — when a decision must see revocations made after the token was
issued — ask Axowl directly. Mirrors `@axowl/sdk-backend` (Node) and `Axowl.Sdk.Identity.Client` (.NET).

```bash
pip install axowl-sdk            # + "axowl-sdk[fastapi]" for the FastAPI dependency
```

## Verify a token (no server round trip)

```python
from axowl import AxowlConfig, verify_token, has_permission, AxowlAuthError

config = AxowlConfig(
    org_slug="my-org",                 # your Axowl org
    audience="app_my_main",            # optional: your application key — rejects tokens minted for another app
    base_url="https://api.axowl.com",  # default
)

try:
    ctx = verify_token(bearer_token, config)
except AxowlAuthError as e:
    ...  # e.reason ∈ missing_token | invalid_token | expired | signature | issuer | audience | jwks

ctx.user_id, ctx.email, ctx.org_slug, ctx.connected_id, ctx.is_employee
ctx.permissions                            # ["wallet.read", "report.*"] — decoded from the token
has_permission(ctx.permissions, "report.monthly")   # True (wildcards honoured)
```

- Signature: RS256 against `{base_url}/api/public/orgs/{org_slug}/.well-known/jwks.json`.
  Keys are cached for 10 minutes; an unknown `kid` (rotation) triggers a re-fetch.
- Issuer must equal `{base_url}/api/public/orgs/{org_slug}` — what Axowl writes into the token.
- `exp`/`nbf` enforced (`leeway_seconds` on the config if your clock drifts).

## FastAPI

```python
from fastapi import Depends, FastAPI
from axowl import AxowlConfig, AxowlContext
from axowl.fastapi import AxowlAuth

auth = AxowlAuth(AxowlConfig(org_slug="my-org", audience="app_my_main"))
app = FastAPI()

@app.get("/wallet")
def wallet(ctx: AxowlContext = Depends(auth)):
    return {"user": ctx.email}

@app.post("/wallet/withdraw")
def withdraw(ctx: AxowlContext = Depends(auth.require("wallet.withdraw"))):
    ...
```

401 `{"error": ..., "reason": ...}` for a missing/invalid token, 403 `{"error": ..., "required": [...]}`
for a missing scope — the same shapes as the Express middleware.

Any other framework: call `extract_bearer_token(request.headers["Authorization"])` then `verify_token`.

## Server-authoritative checks

The JWT fast path cannot see a permission revoked *after* the token was issued. For those decisions
ask Axowl with your **org API key** (`ah_live_…`):

```python
from axowl import AxowlIdentityClient

identity = AxowlIdentityClient(api_key="ah_live_...", base_url="https://api.axowl.com")

res = identity.introspect(bearer_token)          # → IntrospectResult(active, principal, expires_at, issued_at)
res = identity.check_permission(bearer_token, "wallet.withdraw")   # → PermissionCheckResult(granted, matched_scopes, reason)
```

Both are synchronous and stdlib-only (`urllib`); run them in a thread from async code.

## Permission matching

Same rules as every other Axowl SDK and the server:

| pattern | scope | match |
|---|---|---|
| `sap.fi.document.post` | `sap.fi.document.post` | yes |
| `sap.fi.*` | `sap.fi.document.post` | yes |
| `*` | anything | yes |
| `sap.fi` | `sap.fi.document.post` | **no** (a prefix without `*` is not a wildcard) |

## Tests

```bash
pip install -e ".[test]" && pytest
```
