mythos-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.
Potentially problematic release.
This version of mythos-sdk might be problematic. Click here for more details.
- mythos_sdk-0.1.0/.gitignore +35 -0
- mythos_sdk-0.1.0/PKG-INFO +87 -0
- mythos_sdk-0.1.0/README.md +67 -0
- mythos_sdk-0.1.0/mythos_sdk/__init__.py +18 -0
- mythos_sdk-0.1.0/mythos_sdk/api_client.py +37 -0
- mythos_sdk-0.1.0/mythos_sdk/config.py +28 -0
- mythos_sdk-0.1.0/mythos_sdk/errors.py +14 -0
- mythos_sdk-0.1.0/mythos_sdk/handshake.py +54 -0
- mythos_sdk-0.1.0/mythos_sdk/jwks_cache.py +40 -0
- mythos_sdk-0.1.0/mythos_sdk/middleware.py +27 -0
- mythos_sdk-0.1.0/mythos_sdk/report_usage.py +5 -0
- mythos_sdk-0.1.0/mythos_sdk/types.py +10 -0
- mythos_sdk-0.1.0/mythos_sdk/verify.py +69 -0
- mythos_sdk-0.1.0/pyproject.toml +49 -0
- mythos_sdk-0.1.0/tests/conftest.py +41 -0
- mythos_sdk-0.1.0/tests/test_api_client.py +24 -0
- mythos_sdk-0.1.0/tests/test_handshake.py +113 -0
- mythos_sdk-0.1.0/tests/test_middleware.py +70 -0
- mythos_sdk-0.1.0/tests/test_report_usage.py +25 -0
- mythos_sdk-0.1.0/tests/test_verify.py +123 -0
- mythos_sdk-0.1.0/uv.lock +1424 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Node
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
*.tsbuildinfo
|
|
5
|
+
.npm
|
|
6
|
+
|
|
7
|
+
# Python
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
*.egg-info/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
|
|
19
|
+
# Env
|
|
20
|
+
.env
|
|
21
|
+
.env.local
|
|
22
|
+
|
|
23
|
+
# IDE
|
|
24
|
+
.vscode/
|
|
25
|
+
.idea/
|
|
26
|
+
*.swp
|
|
27
|
+
|
|
28
|
+
# OS
|
|
29
|
+
.DS_Store
|
|
30
|
+
Thumbs.db
|
|
31
|
+
|
|
32
|
+
# Coverage
|
|
33
|
+
coverage/
|
|
34
|
+
.coverage
|
|
35
|
+
htmlcov/
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mythos-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Mythos SDK for Python — launch token verification, usage reporting, and handshake
|
|
5
|
+
Project-URL: Homepage, https://github.com/Mythoswork/mythos-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/Mythoswork/mythos-sdk
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Requires-Dist: python-jose[cryptography]<4,>=3.3.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: cryptography>=42.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: fastapi[standard]>=0.100.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Provides-Extra: fastapi
|
|
18
|
+
Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# mythos-sdk
|
|
22
|
+
|
|
23
|
+
Official Mythos SDK for Python — launch token verification, usage reporting, and handshake.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install mythos-sdk[fastapi]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from fastapi import FastAPI, Depends
|
|
35
|
+
from mythos_sdk import require_launch_token, report_usage, create_handshake_router
|
|
36
|
+
|
|
37
|
+
app = FastAPI()
|
|
38
|
+
|
|
39
|
+
# Handshake endpoint — Mythos pings this before publishing your listing
|
|
40
|
+
app.include_router(create_handshake_router())
|
|
41
|
+
|
|
42
|
+
# Protected route — verifies and consumes the launch token automatically
|
|
43
|
+
@app.get("/dashboard")
|
|
44
|
+
async def dashboard(session = Depends(require_launch_token())):
|
|
45
|
+
# session = MythosSession(user_id, email, display_name, listing_id, session_jti)
|
|
46
|
+
await report_usage(session.session_jti, credits=1, reason="page-view")
|
|
47
|
+
return {"ok": True}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Environment variables
|
|
51
|
+
|
|
52
|
+
| Variable | Required | Default | Description |
|
|
53
|
+
|----------|----------|---------|-------------|
|
|
54
|
+
| `MYTHOS_LISTING_ID` | Yes* | — | Your listing ID |
|
|
55
|
+
| `MYTHOS_LISTING_IDS` | Yes* | — | Comma-separated listing IDs (overrides above) |
|
|
56
|
+
| `MYTHOS_API_URL` | No | `https://api.mythos.work` | API base URL override |
|
|
57
|
+
|
|
58
|
+
*One of `MYTHOS_LISTING_ID` or `MYTHOS_LISTING_IDS` is required.
|
|
59
|
+
|
|
60
|
+
## API
|
|
61
|
+
|
|
62
|
+
### `require_launch_token()`
|
|
63
|
+
|
|
64
|
+
FastAPI dependency. Verifies the RS256 launch token from `?lt=`, enforces single-use semantics, and returns a `MythosSession`. Returns `401` if the token is missing, invalid, or already consumed.
|
|
65
|
+
|
|
66
|
+
### `report_usage(session_jti, *, credits, reason=None)`
|
|
67
|
+
|
|
68
|
+
Reports credit consumption against a session. Call after delivering value to the user.
|
|
69
|
+
|
|
70
|
+
### `create_handshake_router()`
|
|
71
|
+
|
|
72
|
+
Returns a FastAPI `APIRouter` that mounts `GET /.well-known/mythos-handshake`. Mythos calls this endpoint during listing publish to confirm the SDK is installed and reachable.
|
|
73
|
+
|
|
74
|
+
### `verify_launch_token(token)`
|
|
75
|
+
|
|
76
|
+
Low-level token verifier. Returns the decoded payload. Use `require_launch_token()` dependency instead for most cases.
|
|
77
|
+
|
|
78
|
+
## Security
|
|
79
|
+
|
|
80
|
+
- Tokens verified via RS256 against the Mythos JWKS endpoint
|
|
81
|
+
- `alg: none` rejected as a hard block
|
|
82
|
+
- Single-use enforcement is non-skippable (ADR-0003)
|
|
83
|
+
- JWKS keys cached 10 minutes with automatic re-fetch on key rotation
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
Apache-2.0 — see [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# mythos-sdk
|
|
2
|
+
|
|
3
|
+
Official Mythos SDK for Python — launch token verification, usage reporting, and handshake.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install mythos-sdk[fastapi]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from fastapi import FastAPI, Depends
|
|
15
|
+
from mythos_sdk import require_launch_token, report_usage, create_handshake_router
|
|
16
|
+
|
|
17
|
+
app = FastAPI()
|
|
18
|
+
|
|
19
|
+
# Handshake endpoint — Mythos pings this before publishing your listing
|
|
20
|
+
app.include_router(create_handshake_router())
|
|
21
|
+
|
|
22
|
+
# Protected route — verifies and consumes the launch token automatically
|
|
23
|
+
@app.get("/dashboard")
|
|
24
|
+
async def dashboard(session = Depends(require_launch_token())):
|
|
25
|
+
# session = MythosSession(user_id, email, display_name, listing_id, session_jti)
|
|
26
|
+
await report_usage(session.session_jti, credits=1, reason="page-view")
|
|
27
|
+
return {"ok": True}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Environment variables
|
|
31
|
+
|
|
32
|
+
| Variable | Required | Default | Description |
|
|
33
|
+
|----------|----------|---------|-------------|
|
|
34
|
+
| `MYTHOS_LISTING_ID` | Yes* | — | Your listing ID |
|
|
35
|
+
| `MYTHOS_LISTING_IDS` | Yes* | — | Comma-separated listing IDs (overrides above) |
|
|
36
|
+
| `MYTHOS_API_URL` | No | `https://api.mythos.work` | API base URL override |
|
|
37
|
+
|
|
38
|
+
*One of `MYTHOS_LISTING_ID` or `MYTHOS_LISTING_IDS` is required.
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
### `require_launch_token()`
|
|
43
|
+
|
|
44
|
+
FastAPI dependency. Verifies the RS256 launch token from `?lt=`, enforces single-use semantics, and returns a `MythosSession`. Returns `401` if the token is missing, invalid, or already consumed.
|
|
45
|
+
|
|
46
|
+
### `report_usage(session_jti, *, credits, reason=None)`
|
|
47
|
+
|
|
48
|
+
Reports credit consumption against a session. Call after delivering value to the user.
|
|
49
|
+
|
|
50
|
+
### `create_handshake_router()`
|
|
51
|
+
|
|
52
|
+
Returns a FastAPI `APIRouter` that mounts `GET /.well-known/mythos-handshake`. Mythos calls this endpoint during listing publish to confirm the SDK is installed and reachable.
|
|
53
|
+
|
|
54
|
+
### `verify_launch_token(token)`
|
|
55
|
+
|
|
56
|
+
Low-level token verifier. Returns the decoded payload. Use `require_launch_token()` dependency instead for most cases.
|
|
57
|
+
|
|
58
|
+
## Security
|
|
59
|
+
|
|
60
|
+
- Tokens verified via RS256 against the Mythos JWKS endpoint
|
|
61
|
+
- `alg: none` rejected as a hard block
|
|
62
|
+
- Single-use enforcement is non-skippable (ADR-0003)
|
|
63
|
+
- JWKS keys cached 10 minutes with automatic re-fetch on key rotation
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
Apache-2.0 — see [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .errors import InsufficientFundsError, MythosError, SessionNotFoundError
|
|
2
|
+
from .handshake import create_handshake_router, handshake_router
|
|
3
|
+
from .middleware import require_launch_token
|
|
4
|
+
from .report_usage import report_usage
|
|
5
|
+
from .types import MythosSession
|
|
6
|
+
from .verify import verify_launch_token
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"verify_launch_token",
|
|
10
|
+
"require_launch_token",
|
|
11
|
+
"report_usage",
|
|
12
|
+
"handshake_router",
|
|
13
|
+
"create_handshake_router",
|
|
14
|
+
"MythosSession",
|
|
15
|
+
"MythosError",
|
|
16
|
+
"InsufficientFundsError",
|
|
17
|
+
"SessionNotFoundError",
|
|
18
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from .config import load_config
|
|
7
|
+
from .errors import InsufficientFundsError, SessionNotFoundError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def consume_session(jti: str) -> httpx.Response:
|
|
11
|
+
config = load_config()
|
|
12
|
+
async with httpx.AsyncClient() as client:
|
|
13
|
+
return await client.post(
|
|
14
|
+
f"{config.api_url}/api/apps/sessions/{jti}/consume",
|
|
15
|
+
json={},
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def meter_session(jti: str, credits: int, reason: str | None = None) -> None:
|
|
20
|
+
config = load_config()
|
|
21
|
+
# charge_id is a per-call idempotency key required by the backend's SQS metering
|
|
22
|
+
# job dedup (see backend docs/migrations) — generated here, not caller-supplied.
|
|
23
|
+
body: dict[str, Any] = {"credits": credits, "charge_id": str(uuid.uuid4())}
|
|
24
|
+
if reason is not None:
|
|
25
|
+
body["reason"] = reason
|
|
26
|
+
|
|
27
|
+
async with httpx.AsyncClient() as client:
|
|
28
|
+
resp = await client.post(
|
|
29
|
+
f"{config.api_url}/api/apps/sessions/{jti}/meter",
|
|
30
|
+
json=body,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if resp.status_code == 402:
|
|
34
|
+
raise InsufficientFundsError()
|
|
35
|
+
if resp.status_code == 404:
|
|
36
|
+
raise SessionNotFoundError(jti)
|
|
37
|
+
resp.raise_for_status()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
DEFAULT_API_URL = "https://api.mythos.work"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class MythosConfig:
|
|
9
|
+
listing_ids: list[str]
|
|
10
|
+
api_url: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_config() -> MythosConfig:
|
|
14
|
+
api_url = os.environ.get("MYTHOS_API_URL", DEFAULT_API_URL)
|
|
15
|
+
multi = os.environ.get("MYTHOS_LISTING_IDS")
|
|
16
|
+
single = os.environ.get("MYTHOS_LISTING_ID")
|
|
17
|
+
|
|
18
|
+
if multi:
|
|
19
|
+
listing_ids = [lid.strip() for lid in multi.split(",") if lid.strip()]
|
|
20
|
+
elif single:
|
|
21
|
+
listing_ids = [single]
|
|
22
|
+
else:
|
|
23
|
+
listing_ids = []
|
|
24
|
+
|
|
25
|
+
if not listing_ids:
|
|
26
|
+
raise RuntimeError("MYTHOS_LISTING_ID or MYTHOS_LISTING_IDS env var is required")
|
|
27
|
+
|
|
28
|
+
return MythosConfig(listing_ids=listing_ids, api_url=api_url)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class MythosError(Exception):
|
|
2
|
+
def __init__(self, message: str, code: str) -> None:
|
|
3
|
+
super().__init__(message)
|
|
4
|
+
self.code = code
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InsufficientFundsError(MythosError):
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
super().__init__("Insufficient funds in wallet", "INSUFFICIENT_FUNDS")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionNotFoundError(MythosError):
|
|
13
|
+
def __init__(self, jti: str) -> None:
|
|
14
|
+
super().__init__(f"Session not found: {jti}", "SESSION_NOT_FOUND")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Request
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
from jose import jwt
|
|
7
|
+
from jose.exceptions import ExpiredSignatureError, JOSEError, JWTError
|
|
8
|
+
|
|
9
|
+
from .jwks_cache import get_jwks, get_jwks_with_kid_fallback
|
|
10
|
+
|
|
11
|
+
_logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
SDK_VERSION = "0.1.0"
|
|
14
|
+
_DEFAULT_API_URL = "https://api.mythos.work"
|
|
15
|
+
# Tokens in this SDK carry no aud/iss claims.
|
|
16
|
+
_DECODE_OPTIONS = {"verify_aud": False, "verify_iss": False}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _validate_handshake_token(token: str) -> None:
|
|
20
|
+
api_url = os.environ.get("MYTHOS_API_URL", _DEFAULT_API_URL)
|
|
21
|
+
jwks = await get_jwks(api_url)
|
|
22
|
+
try:
|
|
23
|
+
payload = jwt.decode(token, jwks, algorithms=["RS256"], options=_DECODE_OPTIONS)
|
|
24
|
+
except ExpiredSignatureError:
|
|
25
|
+
raise
|
|
26
|
+
except JWTError:
|
|
27
|
+
jwks = await get_jwks_with_kid_fallback(api_url)
|
|
28
|
+
payload = jwt.decode(token, jwks, algorithms=["RS256"], options=_DECODE_OPTIONS)
|
|
29
|
+
|
|
30
|
+
if payload.get("purpose") != "handshake-check":
|
|
31
|
+
raise JWTError("Token purpose is not handshake-check")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_handshake_router() -> APIRouter:
|
|
35
|
+
router = APIRouter()
|
|
36
|
+
|
|
37
|
+
@router.get("/.well-known/mythos-handshake")
|
|
38
|
+
async def mythos_handshake(request: Request) -> JSONResponse:
|
|
39
|
+
token = request.query_params.get("lt")
|
|
40
|
+
if not token:
|
|
41
|
+
return JSONResponse({"error": "Missing launch token"}, status_code=401)
|
|
42
|
+
try:
|
|
43
|
+
await _validate_handshake_token(token)
|
|
44
|
+
except JOSEError:
|
|
45
|
+
return JSONResponse({"error": "Invalid launch token"}, status_code=401)
|
|
46
|
+
except Exception:
|
|
47
|
+
_logger.exception("Unexpected error in handshake endpoint")
|
|
48
|
+
return JSONResponse({"error": "Service unavailable"}, status_code=503)
|
|
49
|
+
return JSONResponse({"ok": True, "sdk_version": SDK_VERSION})
|
|
50
|
+
|
|
51
|
+
return router
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
handshake_router = create_handshake_router()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
CACHE_TTL = 600 # 10 minutes
|
|
7
|
+
|
|
8
|
+
_cache: dict[str, Any] | None = None
|
|
9
|
+
_fetched_at: float = 0.0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _is_stale() -> bool:
|
|
13
|
+
return _cache is None or time.monotonic() - _fetched_at > CACHE_TTL
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _fetch_jwks(api_url: str) -> dict[str, Any]:
|
|
17
|
+
global _cache, _fetched_at
|
|
18
|
+
async with httpx.AsyncClient() as client:
|
|
19
|
+
resp = await client.get(f"{api_url}/.well-known/jwks.json")
|
|
20
|
+
resp.raise_for_status()
|
|
21
|
+
data: dict[str, Any] = resp.json()
|
|
22
|
+
_cache = data
|
|
23
|
+
_fetched_at = time.monotonic()
|
|
24
|
+
return data
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def get_jwks(api_url: str, force_refresh: bool = False) -> dict[str, Any]:
|
|
28
|
+
if not force_refresh and not _is_stale() and _cache is not None:
|
|
29
|
+
return _cache
|
|
30
|
+
return await _fetch_jwks(api_url)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def get_jwks_with_kid_fallback(api_url: str) -> dict[str, Any]:
|
|
34
|
+
return await get_jwks(api_url, force_refresh=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clear_cache() -> None:
|
|
38
|
+
global _cache, _fetched_at
|
|
39
|
+
_cache = None
|
|
40
|
+
_fetched_at = 0.0
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from fastapi import HTTPException, Query
|
|
2
|
+
|
|
3
|
+
from .api_client import consume_session
|
|
4
|
+
from .types import MythosSession
|
|
5
|
+
from .verify import verify_launch_token
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def require_launch_token(lt: str = Query(..., alias="lt")) -> MythosSession:
|
|
9
|
+
try:
|
|
10
|
+
session = await verify_launch_token(lt)
|
|
11
|
+
except Exception:
|
|
12
|
+
raise HTTPException(status_code=401, detail="Invalid launch token")
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
resp = await consume_session(session.sessionJti)
|
|
16
|
+
except Exception:
|
|
17
|
+
# Network error / unreachable consume endpoint — fail closed, never grant
|
|
18
|
+
# single-use access without a confirmed consume.
|
|
19
|
+
raise HTTPException(status_code=503, detail="Could not verify session")
|
|
20
|
+
|
|
21
|
+
if resp.status_code == 409:
|
|
22
|
+
raise HTTPException(status_code=401, detail="Token already consumed")
|
|
23
|
+
if not (200 <= resp.status_code < 300):
|
|
24
|
+
# Any other non-2xx (500, 503, ...) is unconfirmed — fail closed.
|
|
25
|
+
raise HTTPException(status_code=503, detail="Could not verify session")
|
|
26
|
+
|
|
27
|
+
return session
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from jose import jwt, JWTError
|
|
4
|
+
from jose.exceptions import ExpiredSignatureError, JWTClaimsError
|
|
5
|
+
|
|
6
|
+
from .config import load_config
|
|
7
|
+
from .jwks_cache import get_jwks, get_jwks_with_kid_fallback
|
|
8
|
+
from .types import MythosSession
|
|
9
|
+
|
|
10
|
+
ALGORITHMS = ["RS256"]
|
|
11
|
+
_DECODE_OPTIONS = {"verify_aud": False}
|
|
12
|
+
|
|
13
|
+
# Matches backend's HANDSHAKE_ISS_CLAIM constant — the platform issuer is a
|
|
14
|
+
# fixed identifier, not the API URL (which varies by environment).
|
|
15
|
+
MYTHOS_ISSUER = "mythos"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _build_session(payload: dict[str, Any]) -> MythosSession:
|
|
19
|
+
return MythosSession(
|
|
20
|
+
userId=payload["sub"],
|
|
21
|
+
email=payload["email"],
|
|
22
|
+
displayName=payload["displayName"],
|
|
23
|
+
listingId=payload["listingId"],
|
|
24
|
+
sessionJti=payload["jti"],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_audience(payload: dict[str, Any], listing_ids: list[str]) -> None:
|
|
29
|
+
"""Check all aud elements for membership — avoids aud[0]-only ordering bug."""
|
|
30
|
+
aud = payload.get("aud")
|
|
31
|
+
if aud is None:
|
|
32
|
+
raise JWTClaimsError("Missing audience claim")
|
|
33
|
+
aud_values = aud if isinstance(aud, list) else [aud]
|
|
34
|
+
if not any(a in listing_ids for a in aud_values):
|
|
35
|
+
raise JWTClaimsError("Invalid audience")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def verify_launch_token(token: str) -> MythosSession:
|
|
39
|
+
config = load_config()
|
|
40
|
+
|
|
41
|
+
jwks = await get_jwks(config.api_url)
|
|
42
|
+
try:
|
|
43
|
+
payload = jwt.decode(
|
|
44
|
+
token,
|
|
45
|
+
jwks,
|
|
46
|
+
algorithms=ALGORITHMS,
|
|
47
|
+
issuer=MYTHOS_ISSUER,
|
|
48
|
+
options=_DECODE_OPTIONS,
|
|
49
|
+
)
|
|
50
|
+
except JWTError as e:
|
|
51
|
+
if isinstance(e, (ExpiredSignatureError, JWTClaimsError)):
|
|
52
|
+
raise
|
|
53
|
+
# python-jose 3.x raises a bare JWTError with this message for signature
|
|
54
|
+
# failures (version pinned <4 in pyproject.toml so this text stays stable).
|
|
55
|
+
# A bad signature is not a kid miss — fail immediately, don't re-fetch JWKS.
|
|
56
|
+
if "Signature verification failed" in str(e):
|
|
57
|
+
raise
|
|
58
|
+
# possible kid miss — re-fetch once
|
|
59
|
+
jwks = await get_jwks_with_kid_fallback(config.api_url)
|
|
60
|
+
payload = jwt.decode(
|
|
61
|
+
token,
|
|
62
|
+
jwks,
|
|
63
|
+
algorithms=ALGORITHMS,
|
|
64
|
+
issuer=MYTHOS_ISSUER,
|
|
65
|
+
options=_DECODE_OPTIONS,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_validate_audience(payload, config.listing_ids)
|
|
69
|
+
return _build_session(payload)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[dependency-groups]
|
|
6
|
+
dev = [
|
|
7
|
+
"pytest>=8.0",
|
|
8
|
+
"pytest-asyncio>=0.23",
|
|
9
|
+
"cryptography>=42.0",
|
|
10
|
+
"fastapi[standard]>=0.100.0",
|
|
11
|
+
"httpx>=0.27.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project]
|
|
15
|
+
name = "mythos-sdk"
|
|
16
|
+
version = "0.1.0"
|
|
17
|
+
description = "Official Mythos SDK for Python — launch token verification, usage reporting, and handshake"
|
|
18
|
+
readme = "README.md"
|
|
19
|
+
license = { text = "Apache-2.0" }
|
|
20
|
+
requires-python = ">=3.11"
|
|
21
|
+
dependencies = [
|
|
22
|
+
"python-jose[cryptography]>=3.3.0,<4",
|
|
23
|
+
"httpx>=0.27.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/Mythoswork/mythos-sdk"
|
|
28
|
+
Repository = "https://github.com/Mythoswork/mythos-sdk"
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
fastapi = ["fastapi>=0.100.0"]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.0",
|
|
34
|
+
"pytest-asyncio>=0.23",
|
|
35
|
+
"cryptography>=42.0",
|
|
36
|
+
"fastapi[standard]>=0.100.0",
|
|
37
|
+
"httpx>=0.27.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
asyncio_mode = "auto"
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
python_version = "3.11"
|
|
45
|
+
|
|
46
|
+
[[tool.mypy.overrides]]
|
|
47
|
+
# python-jose ships no type stubs and types-python-jose is incomplete/unmaintained.
|
|
48
|
+
module = ["jose.*"]
|
|
49
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import pytest
|
|
3
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
4
|
+
from cryptography.hazmat.primitives import serialization
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _int_to_base64url(n: int) -> str:
|
|
8
|
+
length = (n.bit_length() + 7) // 8
|
|
9
|
+
return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(autouse=True)
|
|
13
|
+
def set_env(monkeypatch):
|
|
14
|
+
monkeypatch.setenv("MYTHOS_LISTING_ID", "listing-abc")
|
|
15
|
+
monkeypatch.setenv("MYTHOS_API_URL", "https://api.mythos.work")
|
|
16
|
+
monkeypatch.delenv("MYTHOS_LISTING_IDS", raising=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(scope="session")
|
|
20
|
+
def rsa_key_pair():
|
|
21
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
22
|
+
private_pem = private_key.private_bytes(
|
|
23
|
+
serialization.Encoding.PEM,
|
|
24
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
25
|
+
serialization.NoEncryption(),
|
|
26
|
+
)
|
|
27
|
+
public_key = private_key.public_key()
|
|
28
|
+
public_pem = public_key.public_bytes(
|
|
29
|
+
serialization.Encoding.PEM,
|
|
30
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
31
|
+
)
|
|
32
|
+
pub_numbers = public_key.public_numbers()
|
|
33
|
+
jwk = {
|
|
34
|
+
"kty": "RSA",
|
|
35
|
+
"use": "sig",
|
|
36
|
+
"alg": "RS256",
|
|
37
|
+
"kid": "test-kid",
|
|
38
|
+
"n": _int_to_base64url(pub_numbers.n),
|
|
39
|
+
"e": _int_to_base64url(pub_numbers.e),
|
|
40
|
+
}
|
|
41
|
+
return {"private": private_pem, "public": public_pem, "private_key": private_key, "jwk": jwk}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from unittest.mock import AsyncMock, patch
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from mythos_sdk.api_client import meter_session
|
|
7
|
+
|
|
8
|
+
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def test_meter_session_sends_fresh_uuid_charge_id():
|
|
12
|
+
response = httpx.Response(200, json={}, request=httpx.Request("POST", "https://api.mythos.work/"))
|
|
13
|
+
mock_post = AsyncMock(return_value=response)
|
|
14
|
+
with patch("httpx.AsyncClient.post", mock_post):
|
|
15
|
+
await meter_session("jti-001", 5, "page-view")
|
|
16
|
+
await meter_session("jti-001", 5, "page-view")
|
|
17
|
+
|
|
18
|
+
bodies = [call.kwargs["json"] for call in mock_post.await_args_list]
|
|
19
|
+
|
|
20
|
+
assert UUID_RE.match(bodies[0]["charge_id"])
|
|
21
|
+
assert UUID_RE.match(bodies[1]["charge_id"])
|
|
22
|
+
assert bodies[0]["charge_id"] != bodies[1]["charge_id"]
|
|
23
|
+
assert bodies[0]["credits"] == 5
|
|
24
|
+
assert bodies[0]["reason"] == "page-view"
|