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.
@@ -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,8 @@
1
+ PyJWT[crypto]<3,>=2.8
2
+
3
+ [fastapi]
4
+ fastapi>=0.100
5
+
6
+ [test]
7
+ pytest>=8
8
+ cryptography>=41
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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