axowl-sdk 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.
- axowl_sdk-0.1.0/PKG-INFO +115 -0
- axowl_sdk-0.1.0/README.md +92 -0
- axowl_sdk-0.1.0/axowl/__init__.py +52 -0
- axowl_sdk-0.1.0/axowl/errors.py +19 -0
- axowl_sdk-0.1.0/axowl/fastapi.py +65 -0
- axowl_sdk-0.1.0/axowl/identity.py +151 -0
- axowl_sdk-0.1.0/axowl/permissions.py +46 -0
- axowl_sdk-0.1.0/axowl/token.py +40 -0
- axowl_sdk-0.1.0/axowl/verify.py +195 -0
- axowl_sdk-0.1.0/axowl_sdk.egg-info/PKG-INFO +115 -0
- axowl_sdk-0.1.0/axowl_sdk.egg-info/SOURCES.txt +17 -0
- axowl_sdk-0.1.0/axowl_sdk.egg-info/dependency_links.txt +1 -0
- axowl_sdk-0.1.0/axowl_sdk.egg-info/requires.txt +8 -0
- axowl_sdk-0.1.0/axowl_sdk.egg-info/top_level.txt +1 -0
- axowl_sdk-0.1.0/pyproject.toml +38 -0
- axowl_sdk-0.1.0/setup.cfg +4 -0
- axowl_sdk-0.1.0/tests/test_identity.py +77 -0
- axowl_sdk-0.1.0/tests/test_permissions.py +33 -0
- axowl_sdk-0.1.0/tests/test_verify.py +125 -0
axowl_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axowl-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Axowl backend SDK for Python — verify Axowl end-user JWTs against the org JWKS, wildcard permission checks, server-authoritative introspection.
|
|
5
|
+
Author: Axowl Inc.
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://axowl.com
|
|
8
|
+
Project-URL: Documentation, https://docs.axowl.com/sdk/python/
|
|
9
|
+
Project-URL: Source, https://github.com/Axowl-inc/axowl-sdk
|
|
10
|
+
Keywords: axowl,authentication,jwt,jwks,permissions
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: PyJWT[crypto]<3,>=2.8
|
|
18
|
+
Provides-Extra: fastapi
|
|
19
|
+
Requires-Dist: fastapi>=0.100; extra == "fastapi"
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
22
|
+
Requires-Dist: cryptography>=41; extra == "test"
|
|
23
|
+
|
|
24
|
+
# axowl-sdk (Python)
|
|
25
|
+
|
|
26
|
+
Backend SDK for [Axowl](https://axowl.com): verify Axowl end-user access tokens on your server,
|
|
27
|
+
read the caller's permissions, and — when a decision must see revocations made after the token was
|
|
28
|
+
issued — ask Axowl directly. Mirrors `@axowl/sdk-backend` (Node) and `Axowl.Sdk.Identity.Client` (.NET).
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install axowl-sdk # + "axowl-sdk[fastapi]" for the FastAPI dependency
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Verify a token (no server round trip)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from axowl import AxowlConfig, verify_token, has_permission, AxowlAuthError
|
|
38
|
+
|
|
39
|
+
config = AxowlConfig(
|
|
40
|
+
org_slug="my-org", # your Axowl org
|
|
41
|
+
audience="app_my_main", # optional: your application key — rejects tokens minted for another app
|
|
42
|
+
base_url="https://api.axowl.com", # default
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
ctx = verify_token(bearer_token, config)
|
|
47
|
+
except AxowlAuthError as e:
|
|
48
|
+
... # e.reason ∈ missing_token | invalid_token | expired | signature | issuer | audience | jwks
|
|
49
|
+
|
|
50
|
+
ctx.user_id, ctx.email, ctx.org_slug, ctx.connected_id, ctx.is_employee
|
|
51
|
+
ctx.permissions # ["wallet.read", "report.*"] — decoded from the token
|
|
52
|
+
has_permission(ctx.permissions, "report.monthly") # True (wildcards honoured)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- Signature: RS256 against `{base_url}/api/public/orgs/{org_slug}/.well-known/jwks.json`.
|
|
56
|
+
Keys are cached for 10 minutes; an unknown `kid` (rotation) triggers a re-fetch.
|
|
57
|
+
- Issuer must equal `{base_url}/api/public/orgs/{org_slug}` — what Axowl writes into the token.
|
|
58
|
+
- `exp`/`nbf` enforced (`leeway_seconds` on the config if your clock drifts).
|
|
59
|
+
|
|
60
|
+
## FastAPI
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from fastapi import Depends, FastAPI
|
|
64
|
+
from axowl import AxowlConfig, AxowlContext
|
|
65
|
+
from axowl.fastapi import AxowlAuth
|
|
66
|
+
|
|
67
|
+
auth = AxowlAuth(AxowlConfig(org_slug="my-org", audience="app_my_main"))
|
|
68
|
+
app = FastAPI()
|
|
69
|
+
|
|
70
|
+
@app.get("/wallet")
|
|
71
|
+
def wallet(ctx: AxowlContext = Depends(auth)):
|
|
72
|
+
return {"user": ctx.email}
|
|
73
|
+
|
|
74
|
+
@app.post("/wallet/withdraw")
|
|
75
|
+
def withdraw(ctx: AxowlContext = Depends(auth.require("wallet.withdraw"))):
|
|
76
|
+
...
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
401 `{"error": ..., "reason": ...}` for a missing/invalid token, 403 `{"error": ..., "required": [...]}`
|
|
80
|
+
for a missing scope — the same shapes as the Express middleware.
|
|
81
|
+
|
|
82
|
+
Any other framework: call `extract_bearer_token(request.headers["Authorization"])` then `verify_token`.
|
|
83
|
+
|
|
84
|
+
## Server-authoritative checks
|
|
85
|
+
|
|
86
|
+
The JWT fast path cannot see a permission revoked *after* the token was issued. For those decisions
|
|
87
|
+
ask Axowl with your **org API key** (`ah_live_…`):
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from axowl import AxowlIdentityClient
|
|
91
|
+
|
|
92
|
+
identity = AxowlIdentityClient(api_key="ah_live_...", base_url="https://api.axowl.com")
|
|
93
|
+
|
|
94
|
+
res = identity.introspect(bearer_token) # → IntrospectResult(active, principal, expires_at, issued_at)
|
|
95
|
+
res = identity.check_permission(bearer_token, "wallet.withdraw") # → PermissionCheckResult(granted, matched_scopes, reason)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Both are synchronous and stdlib-only (`urllib`); run them in a thread from async code.
|
|
99
|
+
|
|
100
|
+
## Permission matching
|
|
101
|
+
|
|
102
|
+
Same rules as every other Axowl SDK and the server:
|
|
103
|
+
|
|
104
|
+
| pattern | scope | match |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `sap.fi.document.post` | `sap.fi.document.post` | yes |
|
|
107
|
+
| `sap.fi.*` | `sap.fi.document.post` | yes |
|
|
108
|
+
| `*` | anything | yes |
|
|
109
|
+
| `sap.fi` | `sap.fi.document.post` | **no** (a prefix without `*` is not a wildcard) |
|
|
110
|
+
|
|
111
|
+
## Tests
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pip install -e ".[test]" && pytest
|
|
115
|
+
```
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# axowl-sdk (Python)
|
|
2
|
+
|
|
3
|
+
Backend SDK for [Axowl](https://axowl.com): verify Axowl end-user access tokens on your server,
|
|
4
|
+
read the caller's permissions, and — when a decision must see revocations made after the token was
|
|
5
|
+
issued — ask Axowl directly. Mirrors `@axowl/sdk-backend` (Node) and `Axowl.Sdk.Identity.Client` (.NET).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install axowl-sdk # + "axowl-sdk[fastapi]" for the FastAPI dependency
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Verify a token (no server round trip)
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from axowl import AxowlConfig, verify_token, has_permission, AxowlAuthError
|
|
15
|
+
|
|
16
|
+
config = AxowlConfig(
|
|
17
|
+
org_slug="my-org", # your Axowl org
|
|
18
|
+
audience="app_my_main", # optional: your application key — rejects tokens minted for another app
|
|
19
|
+
base_url="https://api.axowl.com", # default
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
ctx = verify_token(bearer_token, config)
|
|
24
|
+
except AxowlAuthError as e:
|
|
25
|
+
... # e.reason ∈ missing_token | invalid_token | expired | signature | issuer | audience | jwks
|
|
26
|
+
|
|
27
|
+
ctx.user_id, ctx.email, ctx.org_slug, ctx.connected_id, ctx.is_employee
|
|
28
|
+
ctx.permissions # ["wallet.read", "report.*"] — decoded from the token
|
|
29
|
+
has_permission(ctx.permissions, "report.monthly") # True (wildcards honoured)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- Signature: RS256 against `{base_url}/api/public/orgs/{org_slug}/.well-known/jwks.json`.
|
|
33
|
+
Keys are cached for 10 minutes; an unknown `kid` (rotation) triggers a re-fetch.
|
|
34
|
+
- Issuer must equal `{base_url}/api/public/orgs/{org_slug}` — what Axowl writes into the token.
|
|
35
|
+
- `exp`/`nbf` enforced (`leeway_seconds` on the config if your clock drifts).
|
|
36
|
+
|
|
37
|
+
## FastAPI
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from fastapi import Depends, FastAPI
|
|
41
|
+
from axowl import AxowlConfig, AxowlContext
|
|
42
|
+
from axowl.fastapi import AxowlAuth
|
|
43
|
+
|
|
44
|
+
auth = AxowlAuth(AxowlConfig(org_slug="my-org", audience="app_my_main"))
|
|
45
|
+
app = FastAPI()
|
|
46
|
+
|
|
47
|
+
@app.get("/wallet")
|
|
48
|
+
def wallet(ctx: AxowlContext = Depends(auth)):
|
|
49
|
+
return {"user": ctx.email}
|
|
50
|
+
|
|
51
|
+
@app.post("/wallet/withdraw")
|
|
52
|
+
def withdraw(ctx: AxowlContext = Depends(auth.require("wallet.withdraw"))):
|
|
53
|
+
...
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
401 `{"error": ..., "reason": ...}` for a missing/invalid token, 403 `{"error": ..., "required": [...]}`
|
|
57
|
+
for a missing scope — the same shapes as the Express middleware.
|
|
58
|
+
|
|
59
|
+
Any other framework: call `extract_bearer_token(request.headers["Authorization"])` then `verify_token`.
|
|
60
|
+
|
|
61
|
+
## Server-authoritative checks
|
|
62
|
+
|
|
63
|
+
The JWT fast path cannot see a permission revoked *after* the token was issued. For those decisions
|
|
64
|
+
ask Axowl with your **org API key** (`ah_live_…`):
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from axowl import AxowlIdentityClient
|
|
68
|
+
|
|
69
|
+
identity = AxowlIdentityClient(api_key="ah_live_...", base_url="https://api.axowl.com")
|
|
70
|
+
|
|
71
|
+
res = identity.introspect(bearer_token) # → IntrospectResult(active, principal, expires_at, issued_at)
|
|
72
|
+
res = identity.check_permission(bearer_token, "wallet.withdraw") # → PermissionCheckResult(granted, matched_scopes, reason)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Both are synchronous and stdlib-only (`urllib`); run them in a thread from async code.
|
|
76
|
+
|
|
77
|
+
## Permission matching
|
|
78
|
+
|
|
79
|
+
Same rules as every other Axowl SDK and the server:
|
|
80
|
+
|
|
81
|
+
| pattern | scope | match |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `sap.fi.document.post` | `sap.fi.document.post` | yes |
|
|
84
|
+
| `sap.fi.*` | `sap.fi.document.post` | yes |
|
|
85
|
+
| `*` | anything | yes |
|
|
86
|
+
| `sap.fi` | `sap.fi.document.post` | **no** (a prefix without `*` is not a wildcard) |
|
|
87
|
+
|
|
88
|
+
## Tests
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install -e ".[test]" && pytest
|
|
92
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Axowl backend SDK for Python.
|
|
2
|
+
|
|
3
|
+
Verify Axowl end-user access tokens on your server (RS256 against the org JWKS),
|
|
4
|
+
read the caller's permissions from the token, and — when a decision must reflect
|
|
5
|
+
revocations made *after* the token was issued — ask Axowl directly (introspect /
|
|
6
|
+
check-permission with your org API key).
|
|
7
|
+
|
|
8
|
+
from axowl import AxowlConfig, verify_token, has_permission
|
|
9
|
+
|
|
10
|
+
config = AxowlConfig(org_slug="my-org", base_url="https://api.axowl.com")
|
|
11
|
+
ctx = verify_token(bearer_token, config)
|
|
12
|
+
if not has_permission(ctx.permissions, "report.view"):
|
|
13
|
+
raise PermissionError
|
|
14
|
+
|
|
15
|
+
The behaviour mirrors `@axowl/sdk-backend` (JS) and `Axowl.Sdk.Identity.Client` (.NET).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .errors import AxowlAuthError, AxowlPermissionError
|
|
19
|
+
from .permissions import has_all_permissions, has_any_permission, has_permission, match_scope
|
|
20
|
+
from .token import decode_jwt, is_token_expired
|
|
21
|
+
from .verify import (
|
|
22
|
+
AxowlConfig,
|
|
23
|
+
AxowlContext,
|
|
24
|
+
extract_bearer_token,
|
|
25
|
+
resolve_issuer,
|
|
26
|
+
resolve_jwks_url,
|
|
27
|
+
verify_token,
|
|
28
|
+
)
|
|
29
|
+
from .identity import AxowlIdentityClient, AxowlPrincipal, IntrospectResult, PermissionCheckResult
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AxowlAuthError",
|
|
33
|
+
"AxowlPermissionError",
|
|
34
|
+
"AxowlConfig",
|
|
35
|
+
"AxowlContext",
|
|
36
|
+
"AxowlIdentityClient",
|
|
37
|
+
"AxowlPrincipal",
|
|
38
|
+
"IntrospectResult",
|
|
39
|
+
"PermissionCheckResult",
|
|
40
|
+
"decode_jwt",
|
|
41
|
+
"extract_bearer_token",
|
|
42
|
+
"has_all_permissions",
|
|
43
|
+
"has_any_permission",
|
|
44
|
+
"has_permission",
|
|
45
|
+
"is_token_expired",
|
|
46
|
+
"match_scope",
|
|
47
|
+
"resolve_issuer",
|
|
48
|
+
"resolve_jwks_url",
|
|
49
|
+
"verify_token",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class AxowlAuthError(Exception):
|
|
2
|
+
"""The token could not be accepted (missing, malformed, expired, bad signature, wrong issuer/audience).
|
|
3
|
+
|
|
4
|
+
`reason` is a short machine-readable code so a framework adapter can map it to a response
|
|
5
|
+
without parsing the message: ``missing_token``, ``invalid_token``, ``expired``,
|
|
6
|
+
``signature``, ``issuer``, ``audience``, ``jwks``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, reason: str = "invalid_token"):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.reason = reason
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AxowlPermissionError(Exception):
|
|
15
|
+
"""The caller is authenticated but lacks one of the required scopes."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, required: list[str]):
|
|
18
|
+
super().__init__(f"Insufficient permissions: {', '.join(required)}")
|
|
19
|
+
self.required = list(required)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""FastAPI adapter — optional (``pip install axowl-sdk[fastapi]``).
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, FastAPI
|
|
4
|
+
from axowl import AxowlConfig, AxowlContext
|
|
5
|
+
from axowl.fastapi import AxowlAuth
|
|
6
|
+
|
|
7
|
+
auth = AxowlAuth(AxowlConfig(org_slug="my-org", audience="app_my_main"))
|
|
8
|
+
app = FastAPI()
|
|
9
|
+
|
|
10
|
+
@app.get("/wallet")
|
|
11
|
+
def wallet(ctx: AxowlContext = Depends(auth)):
|
|
12
|
+
return {"user": ctx.email}
|
|
13
|
+
|
|
14
|
+
@app.post("/wallet/withdraw")
|
|
15
|
+
def withdraw(ctx: AxowlContext = Depends(auth.require("wallet.withdraw"))):
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
401 for a missing/invalid token, 403 for a missing scope — the same shape as the Express middleware
|
|
19
|
+
(``{"error": ..., "required": [...]}``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Callable, Optional
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from fastapi import HTTPException, Request
|
|
28
|
+
except ImportError as exc: # pragma: no cover
|
|
29
|
+
raise ImportError("axowl.fastapi needs FastAPI: pip install 'axowl-sdk[fastapi]'") from exc
|
|
30
|
+
|
|
31
|
+
from .errors import AxowlAuthError
|
|
32
|
+
from .permissions import has_permission
|
|
33
|
+
from .verify import AxowlConfig, AxowlContext, extract_bearer_token, verify_token
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AxowlAuth:
|
|
37
|
+
def __init__(self, config: AxowlConfig):
|
|
38
|
+
self.config = config
|
|
39
|
+
|
|
40
|
+
def __call__(self, request: Request) -> AxowlContext:
|
|
41
|
+
token = extract_bearer_token(request.headers.get("authorization"))
|
|
42
|
+
if not token:
|
|
43
|
+
raise HTTPException(status_code=401, detail={"error": "Missing authorization token"})
|
|
44
|
+
try:
|
|
45
|
+
ctx = verify_token(token, self.config)
|
|
46
|
+
except AxowlAuthError as exc:
|
|
47
|
+
raise HTTPException(status_code=401, detail={"error": str(exc), "reason": exc.reason}) from exc
|
|
48
|
+
request.state.axowl = ctx
|
|
49
|
+
return ctx
|
|
50
|
+
|
|
51
|
+
def require(self, *scopes: str) -> Callable[[Request], AxowlContext]:
|
|
52
|
+
"""Dependency that also demands every listed scope (wildcards in the token honoured)."""
|
|
53
|
+
required = list(scopes)
|
|
54
|
+
|
|
55
|
+
def dependency(request: Request) -> AxowlContext:
|
|
56
|
+
ctx = self(request)
|
|
57
|
+
missing: Optional[str] = next((s for s in required if not has_permission(ctx.permissions, s)), None)
|
|
58
|
+
if missing is not None:
|
|
59
|
+
raise HTTPException(
|
|
60
|
+
status_code=403,
|
|
61
|
+
detail={"error": "Insufficient permissions", "required": required},
|
|
62
|
+
)
|
|
63
|
+
return ctx
|
|
64
|
+
|
|
65
|
+
return dependency
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Server-authoritative checks — ask Axowl instead of trusting the token's claims.
|
|
2
|
+
|
|
3
|
+
Use this when a decision must see revocations made *after* the token was issued
|
|
4
|
+
(the JWT fast path cannot). Both calls authenticate with your **org API key**
|
|
5
|
+
(``ah_live_…``) and hit the same handler the gRPC ``IdentityService`` uses:
|
|
6
|
+
|
|
7
|
+
POST {base_url}/v1/identity/introspect { "token": "<end-user jwt>" }
|
|
8
|
+
POST {base_url}/v1/identity/check-permission { "token": "<end-user jwt>", "requiredScope": "wallet.withdraw" }
|
|
9
|
+
|
|
10
|
+
Sync, stdlib-only (``urllib``). Wrap in a thread if you call it from an async handler.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any, Callable, Optional
|
|
21
|
+
|
|
22
|
+
from .verify import DEFAULT_BASE_URL
|
|
23
|
+
|
|
24
|
+
INTROSPECT_PATH = "/v1/identity/introspect"
|
|
25
|
+
CHECK_PERMISSION_PATH = "/v1/identity/check-permission"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class AxowlPrincipal:
|
|
30
|
+
end_user_id: str
|
|
31
|
+
organization_id: str
|
|
32
|
+
connected_id: Optional[str]
|
|
33
|
+
is_employee: bool
|
|
34
|
+
email: str
|
|
35
|
+
display_name: Optional[str]
|
|
36
|
+
application_key: Optional[str]
|
|
37
|
+
permissions: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class IntrospectResult:
|
|
42
|
+
active: bool
|
|
43
|
+
principal: Optional[AxowlPrincipal] = None
|
|
44
|
+
expires_at: Optional[datetime] = None
|
|
45
|
+
issued_at: Optional[datetime] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class PermissionCheckResult:
|
|
50
|
+
granted: bool
|
|
51
|
+
matched_scopes: list[str] = field(default_factory=list)
|
|
52
|
+
reason: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AxowlIdentityError(Exception):
|
|
56
|
+
def __init__(self, status: int, body: str):
|
|
57
|
+
super().__init__(f"Axowl identity call failed ({status}): {body}")
|
|
58
|
+
self.status = status
|
|
59
|
+
self.body = body
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Transport = Callable[[str, dict[str, Any], dict[str, str]], tuple[int, str]]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _urllib_transport(url: str, body: dict[str, Any], headers: dict[str, str]) -> tuple[int, str]:
|
|
66
|
+
data = json.dumps(body).encode("utf-8")
|
|
67
|
+
req = urllib.request.Request(url, data=data, method="POST", headers=headers)
|
|
68
|
+
try:
|
|
69
|
+
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 — URL comes from config, not user input
|
|
70
|
+
return resp.status, resp.read().decode("utf-8")
|
|
71
|
+
except urllib.error.HTTPError as exc:
|
|
72
|
+
return exc.code, exc.read().decode("utf-8", errors="replace")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_dt(value: Any) -> Optional[datetime]:
|
|
76
|
+
if not value or not isinstance(value, str):
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AxowlIdentityClient:
|
|
86
|
+
"""``introspect(token)`` and ``check_permission(token, scope)`` with your org API key."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
api_key: str,
|
|
91
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
92
|
+
*,
|
|
93
|
+
transport: Optional[Transport] = None,
|
|
94
|
+
):
|
|
95
|
+
if not api_key:
|
|
96
|
+
raise ValueError("api_key is required (org API key, ah_live_…)")
|
|
97
|
+
self._api_key = api_key
|
|
98
|
+
self._base_url = base_url.rstrip("/")
|
|
99
|
+
self._transport = transport or _urllib_transport
|
|
100
|
+
|
|
101
|
+
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
102
|
+
status, text = self._transport(
|
|
103
|
+
f"{self._base_url}{path}",
|
|
104
|
+
body,
|
|
105
|
+
{
|
|
106
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"Accept": "application/json",
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
if status < 200 or status >= 300:
|
|
112
|
+
raise AxowlIdentityError(status, text)
|
|
113
|
+
parsed = json.loads(text) if text else {}
|
|
114
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
115
|
+
|
|
116
|
+
def introspect(self, token: str) -> IntrospectResult:
|
|
117
|
+
if not token:
|
|
118
|
+
raise ValueError("token is required")
|
|
119
|
+
root = self._post(INTROSPECT_PATH, {"token": token})
|
|
120
|
+
if not root.get("active"):
|
|
121
|
+
return IntrospectResult(active=False)
|
|
122
|
+
perms = root.get("permissions")
|
|
123
|
+
principal = AxowlPrincipal(
|
|
124
|
+
end_user_id=str(root.get("endUserId", "")),
|
|
125
|
+
organization_id=str(root.get("organizationId", "")),
|
|
126
|
+
connected_id=root.get("connectedId") or None,
|
|
127
|
+
is_employee=bool(root.get("isEmployee", False)),
|
|
128
|
+
email=str(root.get("email", "")),
|
|
129
|
+
display_name=root.get("displayName") or None,
|
|
130
|
+
application_key=root.get("applicationKey") or None,
|
|
131
|
+
permissions=[str(p) for p in perms if p] if isinstance(perms, list) else [],
|
|
132
|
+
)
|
|
133
|
+
return IntrospectResult(
|
|
134
|
+
active=True,
|
|
135
|
+
principal=principal,
|
|
136
|
+
expires_at=_parse_dt(root.get("expiresAt")),
|
|
137
|
+
issued_at=_parse_dt(root.get("issuedAt")),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def check_permission(self, token: str, required_scope: str) -> PermissionCheckResult:
|
|
141
|
+
if not token:
|
|
142
|
+
raise ValueError("token is required")
|
|
143
|
+
if not required_scope:
|
|
144
|
+
raise ValueError("required_scope is required")
|
|
145
|
+
root = self._post(CHECK_PERMISSION_PATH, {"token": token, "requiredScope": required_scope})
|
|
146
|
+
matched = root.get("matchedScopes")
|
|
147
|
+
return PermissionCheckResult(
|
|
148
|
+
granted=bool(root.get("granted", False)),
|
|
149
|
+
matched_scopes=[str(s) for s in matched if s] if isinstance(matched, list) else [],
|
|
150
|
+
reason=root.get("reason") or None,
|
|
151
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Wildcard permission matching — byte-for-byte the same rules as `@axowl/sdk-core`
|
|
2
|
+
(`matchScope`) and the server's `IdentityServiceImpl.MatchScope`.
|
|
3
|
+
|
|
4
|
+
match_scope("sap.fi.document.post", "sap.fi.document.post") -> True
|
|
5
|
+
match_scope("sap.fi.*", "sap.fi.document.post") -> True
|
|
6
|
+
match_scope("sap.*", "sap.fi.document.post") -> True
|
|
7
|
+
match_scope("*", "anything.at.all") -> True
|
|
8
|
+
match_scope("aws.ec2.start", "aws.ec2.stop") -> False
|
|
9
|
+
match_scope("sap.fi", "sap.fi.document.post") -> False (prefix without `*` is not a match)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def match_scope(pattern: str, scope: str) -> bool:
|
|
18
|
+
if pattern == scope:
|
|
19
|
+
return True
|
|
20
|
+
if pattern == "*":
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
pattern_parts = pattern.split(".")
|
|
24
|
+
scope_parts = scope.split(".")
|
|
25
|
+
|
|
26
|
+
for i, part in enumerate(pattern_parts):
|
|
27
|
+
if part == "*":
|
|
28
|
+
return True
|
|
29
|
+
if i >= len(scope_parts) or part != scope_parts[i]:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
return len(pattern_parts) == len(scope_parts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def has_permission(permissions: Iterable[str], scope: str) -> bool:
|
|
36
|
+
return any(match_scope(p, scope) for p in permissions)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def has_all_permissions(permissions: Iterable[str], scopes: Iterable[str]) -> bool:
|
|
40
|
+
perms = list(permissions)
|
|
41
|
+
return all(has_permission(perms, s) for s in scopes)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def has_any_permission(permissions: Iterable[str], scopes: Iterable[str]) -> bool:
|
|
45
|
+
perms = list(permissions)
|
|
46
|
+
return any(has_permission(perms, s) for s in scopes)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Decode-only helpers. Nothing here checks a signature — use `axowl.verify.verify_token` for that."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _b64url_decode(segment: str) -> bytes:
|
|
12
|
+
padding = "=" * (-len(segment) % 4)
|
|
13
|
+
return base64.urlsafe_b64decode(segment + padding)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def decode_jwt(token: str) -> dict[str, Any]:
|
|
17
|
+
"""Return the JWT payload as a dict **without** verifying the signature.
|
|
18
|
+
|
|
19
|
+
Raises ``ValueError`` when the token is not three base64url segments of JSON.
|
|
20
|
+
"""
|
|
21
|
+
parts = token.split(".")
|
|
22
|
+
if len(parts) != 3:
|
|
23
|
+
raise ValueError("Invalid JWT format")
|
|
24
|
+
try:
|
|
25
|
+
payload = json.loads(_b64url_decode(parts[1]))
|
|
26
|
+
except (ValueError, json.JSONDecodeError) as exc: # binascii.Error is a ValueError
|
|
27
|
+
raise ValueError("Invalid JWT payload") from exc
|
|
28
|
+
if not isinstance(payload, dict):
|
|
29
|
+
raise ValueError("Invalid JWT payload")
|
|
30
|
+
return payload
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_token_expired(token: str, buffer_seconds: int = 0) -> bool:
|
|
34
|
+
"""True when ``exp`` is at or before now (+ buffer). A malformed token counts as expired."""
|
|
35
|
+
try:
|
|
36
|
+
payload = decode_jwt(token)
|
|
37
|
+
exp = int(payload.get("exp", 0))
|
|
38
|
+
except (ValueError, TypeError):
|
|
39
|
+
return True
|
|
40
|
+
return exp <= int(time.time()) + buffer_seconds
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""RS256 verification of Axowl end-user access tokens against the org JWKS.
|
|
2
|
+
|
|
3
|
+
What Axowl puts in an end-user token (server: ``PublicEndpoints.GenerateEndUserJwt``):
|
|
4
|
+
|
|
5
|
+
iss {base_url}/api/public/orgs/{org_slug}
|
|
6
|
+
aud the application key the user signed in to (``app_...``)
|
|
7
|
+
sub end-user id
|
|
8
|
+
email end-user e-mail
|
|
9
|
+
org organization id org_slug organization slug
|
|
10
|
+
app application key app_group application group id
|
|
11
|
+
type "enduser" is_employee "true" / "false"
|
|
12
|
+
cid ConnectedId (the user's badge in this org) — absent for a plain end-user
|
|
13
|
+
permissions JSON-encoded string array, e.g. '["wallet.read","report.*"]' — absent when empty
|
|
14
|
+
|
|
15
|
+
Keys come from ``{base_url}/api/public/orgs/{org_slug}/.well-known/jwks.json``. They are cached
|
|
16
|
+
in-process for 10 minutes and re-fetched when a token carries an unknown ``kid`` (key rotation),
|
|
17
|
+
with a 30-second cooldown so a flood of bad tokens cannot hammer the endpoint.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import threading
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
|
|
27
|
+
import jwt
|
|
28
|
+
from jwt import PyJWKClient
|
|
29
|
+
|
|
30
|
+
from .errors import AxowlAuthError
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "https://api.axowl.com"
|
|
33
|
+
JWKS_CACHE_SECONDS = 600
|
|
34
|
+
JWKS_COOLDOWN_SECONDS = 30
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class AxowlConfig:
|
|
39
|
+
"""Where your org lives and how to reach Axowl.
|
|
40
|
+
|
|
41
|
+
``org_slug`` is required unless both ``jwks_url`` and ``issuer`` are given explicitly.
|
|
42
|
+
``audience`` — pass your application key to reject tokens minted for another app.
|
|
43
|
+
``api_key`` — your org API key (``ah_live_…``); only needed for `AxowlIdentityClient`.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
org_slug: Optional[str] = None
|
|
47
|
+
base_url: str = DEFAULT_BASE_URL
|
|
48
|
+
jwks_url: Optional[str] = None
|
|
49
|
+
issuer: Optional[str] = None
|
|
50
|
+
audience: Optional[str] = None
|
|
51
|
+
api_key: Optional[str] = None
|
|
52
|
+
leeway_seconds: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class AxowlContext:
|
|
57
|
+
"""The verified caller. ``permissions`` is already a list even though the token stores a JSON string."""
|
|
58
|
+
|
|
59
|
+
token: dict[str, Any]
|
|
60
|
+
raw: str
|
|
61
|
+
user_id: str
|
|
62
|
+
email: Optional[str] = None
|
|
63
|
+
org_id: Optional[str] = None
|
|
64
|
+
org_slug: Optional[str] = None
|
|
65
|
+
app_key: Optional[str] = None
|
|
66
|
+
app_group_id: Optional[str] = None
|
|
67
|
+
connected_id: Optional[str] = None
|
|
68
|
+
type: Optional[str] = None
|
|
69
|
+
is_employee: bool = False
|
|
70
|
+
permissions: list[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_jwks_url(config: AxowlConfig) -> str:
|
|
74
|
+
if config.jwks_url:
|
|
75
|
+
return config.jwks_url
|
|
76
|
+
if not config.org_slug:
|
|
77
|
+
raise ValueError("AxowlConfig needs org_slug (or an explicit jwks_url)")
|
|
78
|
+
return f"{config.base_url.rstrip('/')}/api/public/orgs/{config.org_slug}/.well-known/jwks.json"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_issuer(config: AxowlConfig) -> str:
|
|
82
|
+
"""The ``iss`` Axowl writes into end-user tokens: ``{base_url}/api/public/orgs/{org_slug}``."""
|
|
83
|
+
if config.issuer:
|
|
84
|
+
return config.issuer
|
|
85
|
+
if not config.org_slug:
|
|
86
|
+
raise ValueError("AxowlConfig needs org_slug (or an explicit issuer)")
|
|
87
|
+
return f"{config.base_url.rstrip('/')}/api/public/orgs/{config.org_slug}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_jwks_clients: dict[str, PyJWKClient] = {}
|
|
91
|
+
_jwks_lock = threading.Lock()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _jwks_client(url: str) -> PyJWKClient:
|
|
95
|
+
with _jwks_lock:
|
|
96
|
+
client = _jwks_clients.get(url)
|
|
97
|
+
if client is None:
|
|
98
|
+
client = PyJWKClient(
|
|
99
|
+
url,
|
|
100
|
+
cache_keys=True,
|
|
101
|
+
lifespan=JWKS_CACHE_SECONDS,
|
|
102
|
+
max_cached_keys=16,
|
|
103
|
+
)
|
|
104
|
+
_jwks_clients[url] = client
|
|
105
|
+
return client
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_permissions(value: Any) -> list[str]:
|
|
109
|
+
if value is None:
|
|
110
|
+
return []
|
|
111
|
+
if isinstance(value, str):
|
|
112
|
+
try:
|
|
113
|
+
value = json.loads(value)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return []
|
|
116
|
+
if isinstance(value, list):
|
|
117
|
+
return [str(p) for p in value if p]
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_bool(value: Any) -> bool:
|
|
122
|
+
if isinstance(value, bool):
|
|
123
|
+
return value
|
|
124
|
+
if isinstance(value, str):
|
|
125
|
+
return value.strip().lower() == "true"
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_context(payload: dict[str, Any], raw: str) -> AxowlContext:
|
|
130
|
+
return AxowlContext(
|
|
131
|
+
token=payload,
|
|
132
|
+
raw=raw,
|
|
133
|
+
user_id=str(payload.get("sub", "")),
|
|
134
|
+
email=payload.get("email"),
|
|
135
|
+
org_id=payload.get("org"),
|
|
136
|
+
org_slug=payload.get("org_slug") or None,
|
|
137
|
+
app_key=payload.get("app"),
|
|
138
|
+
app_group_id=payload.get("app_group") or None,
|
|
139
|
+
connected_id=payload.get("cid"),
|
|
140
|
+
type=payload.get("type"),
|
|
141
|
+
is_employee=_parse_bool(payload.get("is_employee")),
|
|
142
|
+
permissions=_parse_permissions(payload.get("permissions")),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def verify_token(token: str, config: AxowlConfig, *, jwks_client: Optional[PyJWKClient] = None) -> AxowlContext:
|
|
147
|
+
"""Verify signature (RS256, org JWKS), ``exp``/``nbf``, ``iss`` and — when configured — ``aud``.
|
|
148
|
+
|
|
149
|
+
Raises `AxowlAuthError` with a ``reason`` of ``expired``, ``signature``, ``issuer``,
|
|
150
|
+
``audience``, ``jwks`` or ``invalid_token``. ``jwks_client`` is an injection point for tests.
|
|
151
|
+
"""
|
|
152
|
+
if not token:
|
|
153
|
+
raise AxowlAuthError("Missing token", "missing_token")
|
|
154
|
+
|
|
155
|
+
client = jwks_client or _jwks_client(resolve_jwks_url(config))
|
|
156
|
+
try:
|
|
157
|
+
signing_key = client.get_signing_key_from_jwt(token)
|
|
158
|
+
except jwt.exceptions.PyJWKClientError as exc:
|
|
159
|
+
raise AxowlAuthError(f"Could not resolve signing key: {exc}", "jwks") from exc
|
|
160
|
+
except jwt.exceptions.DecodeError as exc:
|
|
161
|
+
raise AxowlAuthError(f"Invalid token: {exc}", "invalid_token") from exc
|
|
162
|
+
|
|
163
|
+
options = {"require": ["exp", "sub"], "verify_aud": config.audience is not None}
|
|
164
|
+
try:
|
|
165
|
+
payload = jwt.decode(
|
|
166
|
+
token,
|
|
167
|
+
signing_key.key,
|
|
168
|
+
algorithms=["RS256"],
|
|
169
|
+
issuer=resolve_issuer(config),
|
|
170
|
+
audience=config.audience,
|
|
171
|
+
leeway=config.leeway_seconds,
|
|
172
|
+
options=options,
|
|
173
|
+
)
|
|
174
|
+
except jwt.ExpiredSignatureError as exc:
|
|
175
|
+
raise AxowlAuthError("Token expired", "expired") from exc
|
|
176
|
+
except jwt.InvalidIssuerError as exc:
|
|
177
|
+
raise AxowlAuthError("Token issuer mismatch", "issuer") from exc
|
|
178
|
+
except jwt.InvalidAudienceError as exc:
|
|
179
|
+
raise AxowlAuthError("Token audience mismatch", "audience") from exc
|
|
180
|
+
except jwt.InvalidSignatureError as exc:
|
|
181
|
+
raise AxowlAuthError("Token signature invalid", "signature") from exc
|
|
182
|
+
except jwt.InvalidTokenError as exc:
|
|
183
|
+
raise AxowlAuthError(f"Invalid token: {exc}", "invalid_token") from exc
|
|
184
|
+
|
|
185
|
+
return build_context(payload, token)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def extract_bearer_token(authorization_header: Optional[str]) -> Optional[str]:
|
|
189
|
+
"""``"Bearer eyJ…"`` → ``"eyJ…"``; anything else → ``None``."""
|
|
190
|
+
if not authorization_header:
|
|
191
|
+
return None
|
|
192
|
+
scheme, _, value = authorization_header.partition(" ")
|
|
193
|
+
if scheme.lower() != "bearer" or not value.strip():
|
|
194
|
+
return None
|
|
195
|
+
return value.strip()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axowl-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Axowl backend SDK for Python — verify Axowl end-user JWTs against the org JWKS, wildcard permission checks, server-authoritative introspection.
|
|
5
|
+
Author: Axowl Inc.
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://axowl.com
|
|
8
|
+
Project-URL: Documentation, https://docs.axowl.com/sdk/python/
|
|
9
|
+
Project-URL: Source, https://github.com/Axowl-inc/axowl-sdk
|
|
10
|
+
Keywords: axowl,authentication,jwt,jwks,permissions
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: PyJWT[crypto]<3,>=2.8
|
|
18
|
+
Provides-Extra: fastapi
|
|
19
|
+
Requires-Dist: fastapi>=0.100; extra == "fastapi"
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
22
|
+
Requires-Dist: cryptography>=41; extra == "test"
|
|
23
|
+
|
|
24
|
+
# axowl-sdk (Python)
|
|
25
|
+
|
|
26
|
+
Backend SDK for [Axowl](https://axowl.com): verify Axowl end-user access tokens on your server,
|
|
27
|
+
read the caller's permissions, and — when a decision must see revocations made after the token was
|
|
28
|
+
issued — ask Axowl directly. Mirrors `@axowl/sdk-backend` (Node) and `Axowl.Sdk.Identity.Client` (.NET).
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install axowl-sdk # + "axowl-sdk[fastapi]" for the FastAPI dependency
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Verify a token (no server round trip)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from axowl import AxowlConfig, verify_token, has_permission, AxowlAuthError
|
|
38
|
+
|
|
39
|
+
config = AxowlConfig(
|
|
40
|
+
org_slug="my-org", # your Axowl org
|
|
41
|
+
audience="app_my_main", # optional: your application key — rejects tokens minted for another app
|
|
42
|
+
base_url="https://api.axowl.com", # default
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
ctx = verify_token(bearer_token, config)
|
|
47
|
+
except AxowlAuthError as e:
|
|
48
|
+
... # e.reason ∈ missing_token | invalid_token | expired | signature | issuer | audience | jwks
|
|
49
|
+
|
|
50
|
+
ctx.user_id, ctx.email, ctx.org_slug, ctx.connected_id, ctx.is_employee
|
|
51
|
+
ctx.permissions # ["wallet.read", "report.*"] — decoded from the token
|
|
52
|
+
has_permission(ctx.permissions, "report.monthly") # True (wildcards honoured)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- Signature: RS256 against `{base_url}/api/public/orgs/{org_slug}/.well-known/jwks.json`.
|
|
56
|
+
Keys are cached for 10 minutes; an unknown `kid` (rotation) triggers a re-fetch.
|
|
57
|
+
- Issuer must equal `{base_url}/api/public/orgs/{org_slug}` — what Axowl writes into the token.
|
|
58
|
+
- `exp`/`nbf` enforced (`leeway_seconds` on the config if your clock drifts).
|
|
59
|
+
|
|
60
|
+
## FastAPI
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from fastapi import Depends, FastAPI
|
|
64
|
+
from axowl import AxowlConfig, AxowlContext
|
|
65
|
+
from axowl.fastapi import AxowlAuth
|
|
66
|
+
|
|
67
|
+
auth = AxowlAuth(AxowlConfig(org_slug="my-org", audience="app_my_main"))
|
|
68
|
+
app = FastAPI()
|
|
69
|
+
|
|
70
|
+
@app.get("/wallet")
|
|
71
|
+
def wallet(ctx: AxowlContext = Depends(auth)):
|
|
72
|
+
return {"user": ctx.email}
|
|
73
|
+
|
|
74
|
+
@app.post("/wallet/withdraw")
|
|
75
|
+
def withdraw(ctx: AxowlContext = Depends(auth.require("wallet.withdraw"))):
|
|
76
|
+
...
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
401 `{"error": ..., "reason": ...}` for a missing/invalid token, 403 `{"error": ..., "required": [...]}`
|
|
80
|
+
for a missing scope — the same shapes as the Express middleware.
|
|
81
|
+
|
|
82
|
+
Any other framework: call `extract_bearer_token(request.headers["Authorization"])` then `verify_token`.
|
|
83
|
+
|
|
84
|
+
## Server-authoritative checks
|
|
85
|
+
|
|
86
|
+
The JWT fast path cannot see a permission revoked *after* the token was issued. For those decisions
|
|
87
|
+
ask Axowl with your **org API key** (`ah_live_…`):
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from axowl import AxowlIdentityClient
|
|
91
|
+
|
|
92
|
+
identity = AxowlIdentityClient(api_key="ah_live_...", base_url="https://api.axowl.com")
|
|
93
|
+
|
|
94
|
+
res = identity.introspect(bearer_token) # → IntrospectResult(active, principal, expires_at, issued_at)
|
|
95
|
+
res = identity.check_permission(bearer_token, "wallet.withdraw") # → PermissionCheckResult(granted, matched_scopes, reason)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Both are synchronous and stdlib-only (`urllib`); run them in a thread from async code.
|
|
99
|
+
|
|
100
|
+
## Permission matching
|
|
101
|
+
|
|
102
|
+
Same rules as every other Axowl SDK and the server:
|
|
103
|
+
|
|
104
|
+
| pattern | scope | match |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `sap.fi.document.post` | `sap.fi.document.post` | yes |
|
|
107
|
+
| `sap.fi.*` | `sap.fi.document.post` | yes |
|
|
108
|
+
| `*` | anything | yes |
|
|
109
|
+
| `sap.fi` | `sap.fi.document.post` | **no** (a prefix without `*` is not a wildcard) |
|
|
110
|
+
|
|
111
|
+
## Tests
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pip install -e ".[test]" && pytest
|
|
115
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
axowl/__init__.py
|
|
4
|
+
axowl/errors.py
|
|
5
|
+
axowl/fastapi.py
|
|
6
|
+
axowl/identity.py
|
|
7
|
+
axowl/permissions.py
|
|
8
|
+
axowl/token.py
|
|
9
|
+
axowl/verify.py
|
|
10
|
+
axowl_sdk.egg-info/PKG-INFO
|
|
11
|
+
axowl_sdk.egg-info/SOURCES.txt
|
|
12
|
+
axowl_sdk.egg-info/dependency_links.txt
|
|
13
|
+
axowl_sdk.egg-info/requires.txt
|
|
14
|
+
axowl_sdk.egg-info/top_level.txt
|
|
15
|
+
tests/test_identity.py
|
|
16
|
+
tests/test_permissions.py
|
|
17
|
+
tests/test_verify.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
axowl
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "axowl-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Axowl backend SDK for Python — verify Axowl end-user JWTs against the org JWKS, wildcard permission checks, server-authoritative introspection."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Axowl Inc." }]
|
|
13
|
+
keywords = ["axowl", "authentication", "jwt", "jwks", "permissions"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Topic :: Security",
|
|
18
|
+
"Framework :: FastAPI",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"PyJWT[crypto]>=2.8,<3",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
fastapi = ["fastapi>=0.100"]
|
|
26
|
+
test = ["pytest>=8", "cryptography>=41"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://axowl.com"
|
|
30
|
+
Documentation = "https://docs.axowl.com/sdk/python/"
|
|
31
|
+
Source = "https://github.com/Axowl-inc/axowl-sdk"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["."]
|
|
35
|
+
include = ["axowl*"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from axowl import AxowlIdentityClient
|
|
6
|
+
from axowl.identity import AxowlIdentityError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeTransport:
|
|
10
|
+
def __init__(self, status, body):
|
|
11
|
+
self.status = status
|
|
12
|
+
self.body = body
|
|
13
|
+
self.calls = []
|
|
14
|
+
|
|
15
|
+
def __call__(self, url, body, headers):
|
|
16
|
+
self.calls.append((url, body, headers))
|
|
17
|
+
return self.status, json.dumps(self.body) if isinstance(self.body, dict) else self.body
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_introspect_active_maps_principal():
|
|
21
|
+
t = FakeTransport(
|
|
22
|
+
200,
|
|
23
|
+
{
|
|
24
|
+
"active": True,
|
|
25
|
+
"endUserId": "3f2c9f0e2a1b4c5d8e9f0a1b2c3d4e5f",
|
|
26
|
+
"organizationId": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
|
|
27
|
+
"connectedId": "99999999888877776666555555555555",
|
|
28
|
+
"isEmployee": True,
|
|
29
|
+
"email": "user@example.com",
|
|
30
|
+
"displayName": "User",
|
|
31
|
+
"applicationKey": "app_acme_main",
|
|
32
|
+
"permissions": ["wallet.read", "", "report.*"],
|
|
33
|
+
"expiresAt": "2026-09-22T10:00:00Z",
|
|
34
|
+
"issuedAt": "2026-09-22T09:00:00+00:00",
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
client = AxowlIdentityClient("ah_live_x", "https://api.example.test/", transport=t)
|
|
38
|
+
res = client.introspect("tok")
|
|
39
|
+
assert res.active and res.principal is not None
|
|
40
|
+
assert res.principal.email == "user@example.com"
|
|
41
|
+
assert res.principal.permissions == ["wallet.read", "report.*"]
|
|
42
|
+
assert res.principal.is_employee is True
|
|
43
|
+
assert res.expires_at.isoformat() == "2026-09-22T10:00:00+00:00"
|
|
44
|
+
url, body, headers = t.calls[0]
|
|
45
|
+
assert url == "https://api.example.test/v1/identity/introspect"
|
|
46
|
+
assert body == {"token": "tok"}
|
|
47
|
+
assert headers["Authorization"] == "Bearer ah_live_x"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_introspect_inactive():
|
|
51
|
+
client = AxowlIdentityClient("ah_live_x", transport=FakeTransport(200, {"active": False}))
|
|
52
|
+
res = client.introspect("tok")
|
|
53
|
+
assert res.active is False and res.principal is None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_check_permission():
|
|
57
|
+
t = FakeTransport(200, {"granted": True, "matchedScopes": ["report.*"], "reason": None})
|
|
58
|
+
client = AxowlIdentityClient("ah_live_x", transport=t)
|
|
59
|
+
res = client.check_permission("tok", "report.view")
|
|
60
|
+
assert res.granted and res.matched_scopes == ["report.*"] and res.reason is None
|
|
61
|
+
assert t.calls[0][1] == {"token": "tok", "requiredScope": "report.view"}
|
|
62
|
+
assert t.calls[0][0] == "https://api.axowl.com/v1/identity/check-permission"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_http_error_raises():
|
|
66
|
+
client = AxowlIdentityClient("ah_live_x", transport=FakeTransport(401, '{"error":"bad key"}'))
|
|
67
|
+
with pytest.raises(AxowlIdentityError) as ei:
|
|
68
|
+
client.introspect("tok")
|
|
69
|
+
assert ei.value.status == 401
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_argument_validation():
|
|
73
|
+
with pytest.raises(ValueError):
|
|
74
|
+
AxowlIdentityClient("")
|
|
75
|
+
client = AxowlIdentityClient("ah_live_x", transport=FakeTransport(200, {}))
|
|
76
|
+
with pytest.raises(ValueError):
|
|
77
|
+
client.check_permission("tok", "")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from axowl import has_all_permissions, has_any_permission, has_permission, match_scope
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.parametrize(
|
|
7
|
+
"pattern,scope,expected",
|
|
8
|
+
[
|
|
9
|
+
("sap.fi.document.post", "sap.fi.document.post", True),
|
|
10
|
+
("sap.fi.*", "sap.fi.document.post", True),
|
|
11
|
+
("sap.*", "sap.fi.document.post", True),
|
|
12
|
+
("*", "anything.at.all", True),
|
|
13
|
+
("aws.ec2.start", "aws.ec2.stop", False),
|
|
14
|
+
("sap.fi", "sap.fi.document.post", False), # prefix without `*` is not a match
|
|
15
|
+
("sap.fi.document.post", "sap.fi", False), # pattern longer than scope
|
|
16
|
+
("sap.fi.*", "sap", False),
|
|
17
|
+
("", "", True),
|
|
18
|
+
],
|
|
19
|
+
)
|
|
20
|
+
def test_match_scope(pattern, scope, expected):
|
|
21
|
+
assert match_scope(pattern, scope) is expected
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_has_permission_family():
|
|
25
|
+
perms = ["wallet.read", "report.*"]
|
|
26
|
+
assert has_permission(perms, "wallet.read")
|
|
27
|
+
assert has_permission(perms, "report.monthly.export")
|
|
28
|
+
assert not has_permission(perms, "wallet.write")
|
|
29
|
+
assert has_all_permissions(perms, ["wallet.read", "report.view"])
|
|
30
|
+
assert not has_all_permissions(perms, ["wallet.read", "wallet.write"])
|
|
31
|
+
assert has_any_permission(perms, ["wallet.write", "report.view"])
|
|
32
|
+
assert not has_any_permission(perms, ["wallet.write", "billing.admin"])
|
|
33
|
+
assert not has_permission([], "wallet.read")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from axowl import (
|
|
7
|
+
AxowlAuthError,
|
|
8
|
+
AxowlConfig,
|
|
9
|
+
decode_jwt,
|
|
10
|
+
extract_bearer_token,
|
|
11
|
+
is_token_expired,
|
|
12
|
+
resolve_issuer,
|
|
13
|
+
resolve_jwks_url,
|
|
14
|
+
verify_token,
|
|
15
|
+
)
|
|
16
|
+
from tests.conftest import APP_KEY, BASE, ORG
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_resolvers_default_to_axowl_layout():
|
|
20
|
+
cfg = AxowlConfig(org_slug="my-org")
|
|
21
|
+
assert resolve_jwks_url(cfg) == "https://api.axowl.com/api/public/orgs/my-org/.well-known/jwks.json"
|
|
22
|
+
assert resolve_issuer(cfg) == "https://api.axowl.com/api/public/orgs/my-org"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_resolvers_honour_overrides():
|
|
26
|
+
cfg = AxowlConfig(jwks_url="https://x/jwks", issuer="https://x")
|
|
27
|
+
assert resolve_jwks_url(cfg) == "https://x/jwks"
|
|
28
|
+
assert resolve_issuer(cfg) == "https://x"
|
|
29
|
+
with pytest.raises(ValueError):
|
|
30
|
+
resolve_jwks_url(AxowlConfig())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_verify_happy_path_maps_every_claim(make_token, config, jwks_client):
|
|
34
|
+
ctx = verify_token(make_token(), config, jwks_client=jwks_client)
|
|
35
|
+
assert ctx.user_id == "3f2c9f0e2a1b4c5d8e9f0a1b2c3d4e5f"
|
|
36
|
+
assert ctx.email == "user@example.com"
|
|
37
|
+
assert ctx.org_id == "0a1b2c3d4e5f60718293a4b5c6d7e8f9"
|
|
38
|
+
assert ctx.org_slug == ORG
|
|
39
|
+
assert ctx.app_key == APP_KEY
|
|
40
|
+
assert ctx.app_group_id == "11111111222233334444555555555555"
|
|
41
|
+
assert ctx.connected_id == "99999999888877776666555555555555"
|
|
42
|
+
assert ctx.type == "enduser"
|
|
43
|
+
assert ctx.is_employee is False
|
|
44
|
+
assert ctx.permissions == ["wallet.read", "report.*"] # JSON string in the token → list
|
|
45
|
+
assert ctx.token["aud"] == APP_KEY
|
|
46
|
+
assert ctx.raw.count(".") == 2
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_permissions_absent_or_array_or_garbage(make_token, config, jwks_client):
|
|
50
|
+
assert verify_token(make_token(permissions=None), config, jwks_client=jwks_client).permissions == []
|
|
51
|
+
assert verify_token(make_token(permissions=["a.b"]), config, jwks_client=jwks_client).permissions == ["a.b"]
|
|
52
|
+
assert verify_token(make_token(permissions="not json"), config, jwks_client=jwks_client).permissions == []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_is_employee_string_true(make_token, config, jwks_client):
|
|
56
|
+
assert verify_token(make_token(is_employee="true"), config, jwks_client=jwks_client).is_employee is True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_expired(make_token, config, jwks_client):
|
|
60
|
+
with pytest.raises(AxowlAuthError) as ei:
|
|
61
|
+
verify_token(make_token(exp=int(time.time()) - 10), config, jwks_client=jwks_client)
|
|
62
|
+
assert ei.value.reason == "expired"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_wrong_issuer(make_token, config, jwks_client):
|
|
66
|
+
with pytest.raises(AxowlAuthError) as ei:
|
|
67
|
+
verify_token(make_token(iss="https://evil.example"), config, jwks_client=jwks_client)
|
|
68
|
+
assert ei.value.reason == "issuer"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_wrong_audience_only_when_configured(make_token, config, jwks_client):
|
|
72
|
+
with pytest.raises(AxowlAuthError) as ei:
|
|
73
|
+
verify_token(make_token(aud="app_other"), config, jwks_client=jwks_client)
|
|
74
|
+
assert ei.value.reason == "audience"
|
|
75
|
+
# no audience configured → any aud accepted
|
|
76
|
+
relaxed = AxowlConfig(org_slug=ORG, base_url=BASE)
|
|
77
|
+
assert verify_token(make_token(aud="app_other"), relaxed, jwks_client=jwks_client).app_key == APP_KEY
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_unknown_kid(make_token, config, jwks_client):
|
|
81
|
+
with pytest.raises(AxowlAuthError) as ei:
|
|
82
|
+
verify_token(make_token(_kid="rotated"), config, jwks_client=jwks_client)
|
|
83
|
+
assert ei.value.reason == "jwks"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_tampered_signature(make_token, config, jwks_client):
|
|
87
|
+
token = make_token()
|
|
88
|
+
head, body, sig = token.split(".")
|
|
89
|
+
forged = json.loads(decode_jwt(token) and "{}") # keep decode_jwt exercised
|
|
90
|
+
assert forged == {}
|
|
91
|
+
with pytest.raises(AxowlAuthError) as ei:
|
|
92
|
+
verify_token(f"{head}.{body}.{sig[:-2]}AA", config, jwks_client=jwks_client)
|
|
93
|
+
assert ei.value.reason in ("signature", "invalid_token")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_missing_and_garbage_tokens(config, jwks_client):
|
|
97
|
+
with pytest.raises(AxowlAuthError) as ei:
|
|
98
|
+
verify_token("", config, jwks_client=jwks_client)
|
|
99
|
+
assert ei.value.reason == "missing_token"
|
|
100
|
+
with pytest.raises(AxowlAuthError):
|
|
101
|
+
verify_token("not.a.jwt", config, jwks_client=jwks_client)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_jwks_is_cached_across_calls(make_token, config, jwks_client):
|
|
105
|
+
verify_token(make_token(), config, jwks_client=jwks_client)
|
|
106
|
+
verify_token(make_token(), config, jwks_client=jwks_client)
|
|
107
|
+
assert jwks_client.fetch_count == 1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_decode_only_helpers(make_token):
|
|
111
|
+
t = make_token()
|
|
112
|
+
assert decode_jwt(t)["type"] == "enduser"
|
|
113
|
+
assert is_token_expired(t) is False
|
|
114
|
+
assert is_token_expired(make_token(exp=int(time.time()) - 1)) is True
|
|
115
|
+
assert is_token_expired("garbage") is True
|
|
116
|
+
with pytest.raises(ValueError):
|
|
117
|
+
decode_jwt("a.b")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_extract_bearer_token():
|
|
121
|
+
assert extract_bearer_token("Bearer abc") == "abc"
|
|
122
|
+
assert extract_bearer_token("bearer abc") == "abc"
|
|
123
|
+
assert extract_bearer_token("Basic abc") is None
|
|
124
|
+
assert extract_bearer_token("Bearer ") is None
|
|
125
|
+
assert extract_bearer_token(None) is None
|