mythos-sdk 0.0.4__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.
- mythos_sdk-0.0.4/.gitignore +35 -0
- mythos_sdk-0.0.4/PKG-INFO +87 -0
- mythos_sdk-0.0.4/README.md +67 -0
- mythos_sdk-0.0.4/mythos_sdk/__init__.py +20 -0
- mythos_sdk-0.0.4/mythos_sdk/api_client.py +37 -0
- mythos_sdk-0.0.4/mythos_sdk/config.py +25 -0
- mythos_sdk-0.0.4/mythos_sdk/errors.py +14 -0
- mythos_sdk-0.0.4/mythos_sdk/handshake.py +54 -0
- mythos_sdk-0.0.4/mythos_sdk/jwks_cache.py +40 -0
- mythos_sdk-0.0.4/mythos_sdk/listing_callback.py +57 -0
- mythos_sdk-0.0.4/mythos_sdk/middleware.py +34 -0
- mythos_sdk-0.0.4/mythos_sdk/report_usage.py +5 -0
- mythos_sdk-0.0.4/mythos_sdk/types.py +10 -0
- mythos_sdk-0.0.4/mythos_sdk/verify.py +78 -0
- mythos_sdk-0.0.4/pyproject.toml +49 -0
- mythos_sdk-0.0.4/tests/conftest.py +41 -0
- mythos_sdk-0.0.4/tests/test_api_client.py +24 -0
- mythos_sdk-0.0.4/tests/test_handshake.py +113 -0
- mythos_sdk-0.0.4/tests/test_listing_callback.py +171 -0
- mythos_sdk-0.0.4/tests/test_middleware.py +85 -0
- mythos_sdk-0.0.4/tests/test_report_usage.py +25 -0
- mythos_sdk-0.0.4/tests/test_verify.py +146 -0
- mythos_sdk-0.0.4/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.0.4
|
|
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,20 @@
|
|
|
1
|
+
from .errors import InsufficientFundsError, MythosError, SessionNotFoundError
|
|
2
|
+
from .handshake import create_handshake_router, handshake_router
|
|
3
|
+
from .listing_callback import create_listing_callback_handler
|
|
4
|
+
from .middleware import require_launch_token
|
|
5
|
+
from .report_usage import report_usage
|
|
6
|
+
from .types import MythosSession
|
|
7
|
+
from .verify import verify_launch_token
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"verify_launch_token",
|
|
11
|
+
"require_launch_token",
|
|
12
|
+
"report_usage",
|
|
13
|
+
"handshake_router",
|
|
14
|
+
"create_handshake_router",
|
|
15
|
+
"create_listing_callback_handler",
|
|
16
|
+
"MythosSession",
|
|
17
|
+
"MythosError",
|
|
18
|
+
"InsufficientFundsError",
|
|
19
|
+
"SessionNotFoundError",
|
|
20
|
+
]
|
|
@@ -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,25 @@
|
|
|
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
|
+
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,57 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from jose import jwt
|
|
8
|
+
from jose.exceptions import ExpiredSignatureError, JOSEError, JWTClaimsError, JWTError
|
|
9
|
+
|
|
10
|
+
from .jwks_cache import get_jwks, get_jwks_with_kid_fallback
|
|
11
|
+
|
|
12
|
+
_logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_DEFAULT_API_URL = "https://api.mythos.work"
|
|
15
|
+
_MYTHOS_ISSUER = "mythos"
|
|
16
|
+
_DECODE_OPTIONS = {"verify_aud": False}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _validate_listing_callback_token(token: str) -> str:
|
|
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(
|
|
24
|
+
token, jwks, algorithms=["RS256"], issuer=_MYTHOS_ISSUER, options=_DECODE_OPTIONS
|
|
25
|
+
)
|
|
26
|
+
except ExpiredSignatureError:
|
|
27
|
+
raise
|
|
28
|
+
except JWTError:
|
|
29
|
+
jwks = await get_jwks_with_kid_fallback(api_url)
|
|
30
|
+
payload = jwt.decode(
|
|
31
|
+
token, jwks, algorithms=["RS256"], issuer=_MYTHOS_ISSUER, options=_DECODE_OPTIONS
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if payload.get("purpose") != "listing_registered":
|
|
35
|
+
raise JWTClaimsError("Token purpose is not listing_registered")
|
|
36
|
+
|
|
37
|
+
return payload["listingId"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_listing_callback_handler(
|
|
41
|
+
on_registered: Callable[[str], Awaitable[None]],
|
|
42
|
+
) -> Callable[[Request], Awaitable[JSONResponse]]:
|
|
43
|
+
async def mythos_listing_registered(request: Request) -> JSONResponse:
|
|
44
|
+
token = request.query_params.get("lt")
|
|
45
|
+
if not token:
|
|
46
|
+
return JSONResponse({"error": "Missing listing callback token"}, status_code=401)
|
|
47
|
+
try:
|
|
48
|
+
listing_id = await _validate_listing_callback_token(token)
|
|
49
|
+
await on_registered(listing_id)
|
|
50
|
+
except JOSEError:
|
|
51
|
+
return JSONResponse({"error": "Invalid listing callback token"}, status_code=401)
|
|
52
|
+
except Exception:
|
|
53
|
+
_logger.exception("listing-callback: unexpected error")
|
|
54
|
+
return JSONResponse({"error": "Service unavailable"}, status_code=503)
|
|
55
|
+
return JSONResponse({"ok": True})
|
|
56
|
+
|
|
57
|
+
return mythos_listing_registered
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException, Query
|
|
4
|
+
|
|
5
|
+
from .api_client import consume_session
|
|
6
|
+
from .types import MythosSession
|
|
7
|
+
from .verify import verify_launch_token
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def require_launch_token(
|
|
11
|
+
resolve_listing_ids: Callable[[], Awaitable[list[str]]] | None = None,
|
|
12
|
+
) -> Callable[..., Awaitable[MythosSession]]:
|
|
13
|
+
async def dependency(lt: str = Query(..., alias="lt")) -> MythosSession:
|
|
14
|
+
try:
|
|
15
|
+
session = await verify_launch_token(lt, resolve_listing_ids)
|
|
16
|
+
except Exception:
|
|
17
|
+
raise HTTPException(status_code=401, detail="Invalid launch token")
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
resp = await consume_session(session.sessionJti)
|
|
21
|
+
except Exception:
|
|
22
|
+
# Network error / unreachable consume endpoint — fail closed, never grant
|
|
23
|
+
# single-use access without a confirmed consume.
|
|
24
|
+
raise HTTPException(status_code=503, detail="Could not verify session")
|
|
25
|
+
|
|
26
|
+
if resp.status_code == 409:
|
|
27
|
+
raise HTTPException(status_code=401, detail="Token already consumed")
|
|
28
|
+
if not (200 <= resp.status_code < 300):
|
|
29
|
+
# Any other non-2xx (500, 503, ...) is unconfirmed — fail closed.
|
|
30
|
+
raise HTTPException(status_code=503, detail="Could not verify session")
|
|
31
|
+
|
|
32
|
+
return session
|
|
33
|
+
|
|
34
|
+
return dependency
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from jose import jwt, JWTError
|
|
5
|
+
from jose.exceptions import ExpiredSignatureError, JWTClaimsError
|
|
6
|
+
|
|
7
|
+
from .config import load_config
|
|
8
|
+
from .jwks_cache import get_jwks, get_jwks_with_kid_fallback
|
|
9
|
+
from .types import MythosSession
|
|
10
|
+
|
|
11
|
+
ALGORITHMS = ["RS256"]
|
|
12
|
+
_DECODE_OPTIONS = {"verify_aud": False}
|
|
13
|
+
|
|
14
|
+
# Matches backend's HANDSHAKE_ISS_CLAIM constant — the platform issuer is a
|
|
15
|
+
# fixed identifier, not the API URL (which varies by environment).
|
|
16
|
+
MYTHOS_ISSUER = "mythos"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_session(payload: dict[str, Any]) -> MythosSession:
|
|
20
|
+
return MythosSession(
|
|
21
|
+
userId=payload["sub"],
|
|
22
|
+
email=payload["email"],
|
|
23
|
+
displayName=payload["displayName"],
|
|
24
|
+
listingId=payload["listingId"],
|
|
25
|
+
sessionJti=payload["jti"],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_audience(payload: dict[str, Any], listing_ids: list[str]) -> None:
|
|
30
|
+
"""Check all aud elements for membership — avoids aud[0]-only ordering bug."""
|
|
31
|
+
aud = payload.get("aud")
|
|
32
|
+
if aud is None:
|
|
33
|
+
raise JWTClaimsError("Missing audience claim")
|
|
34
|
+
aud_values = aud if isinstance(aud, list) else [aud]
|
|
35
|
+
if not any(a in listing_ids for a in aud_values):
|
|
36
|
+
raise JWTClaimsError("Invalid audience")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def verify_launch_token(
|
|
40
|
+
token: str,
|
|
41
|
+
resolve_listing_ids: Callable[[], Awaitable[list[str]]] | None = None,
|
|
42
|
+
) -> MythosSession:
|
|
43
|
+
config = load_config()
|
|
44
|
+
|
|
45
|
+
jwks = await get_jwks(config.api_url)
|
|
46
|
+
try:
|
|
47
|
+
payload = jwt.decode(
|
|
48
|
+
token,
|
|
49
|
+
jwks,
|
|
50
|
+
algorithms=ALGORITHMS,
|
|
51
|
+
issuer=MYTHOS_ISSUER,
|
|
52
|
+
options=_DECODE_OPTIONS,
|
|
53
|
+
)
|
|
54
|
+
except JWTError as e:
|
|
55
|
+
if isinstance(e, (ExpiredSignatureError, JWTClaimsError)):
|
|
56
|
+
raise
|
|
57
|
+
# python-jose 3.x raises a bare JWTError with this message for signature
|
|
58
|
+
# failures (version pinned <4 in pyproject.toml so this text stays stable).
|
|
59
|
+
# A bad signature is not a kid miss — fail immediately, don't re-fetch JWKS.
|
|
60
|
+
if "Signature verification failed" in str(e):
|
|
61
|
+
raise
|
|
62
|
+
# possible kid miss — re-fetch once
|
|
63
|
+
jwks = await get_jwks_with_kid_fallback(config.api_url)
|
|
64
|
+
payload = jwt.decode(
|
|
65
|
+
token,
|
|
66
|
+
jwks,
|
|
67
|
+
algorithms=ALGORITHMS,
|
|
68
|
+
issuer=MYTHOS_ISSUER,
|
|
69
|
+
options=_DECODE_OPTIONS,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
dynamic_ids = await resolve_listing_ids() if resolve_listing_ids else []
|
|
73
|
+
if not config.listing_ids and not dynamic_ids:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"MYTHOS_LISTING_ID or MYTHOS_LISTING_IDS env var is required, or pass resolve_listing_ids"
|
|
76
|
+
)
|
|
77
|
+
_validate_audience(payload, config.listing_ids + dynamic_ids)
|
|
78
|
+
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.0.4"
|
|
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}
|