hub02-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.
- hub02_sdk-0.1.0/.gitignore +27 -0
- hub02_sdk-0.1.0/PKG-INFO +127 -0
- hub02_sdk-0.1.0/README.md +94 -0
- hub02_sdk-0.1.0/pyproject.toml +62 -0
- hub02_sdk-0.1.0/src/hub02_sdk/__init__.py +36 -0
- hub02_sdk-0.1.0/src/hub02_sdk/_shared.py +77 -0
- hub02_sdk-0.1.0/src/hub02_sdk/client.py +69 -0
- hub02_sdk-0.1.0/src/hub02_sdk/server.py +275 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Node
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
*.tsbuildinfo
|
|
5
|
+
npm-debug.log*
|
|
6
|
+
.eslintcache
|
|
7
|
+
|
|
8
|
+
# Python
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.py[cod]
|
|
13
|
+
*.egg-info/
|
|
14
|
+
build/
|
|
15
|
+
*.whl
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
|
|
20
|
+
# Editors / OS
|
|
21
|
+
.DS_Store
|
|
22
|
+
.idea/
|
|
23
|
+
.vscode/
|
|
24
|
+
|
|
25
|
+
# Env / secrets
|
|
26
|
+
.env
|
|
27
|
+
.env.*
|
hub02_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hub02-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hub02 tool-identity SDK — verify Hub02 identity tokens (Ed25519) on your Python backend.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Irshai02/hub02-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/Irshai02/hub02-sdk
|
|
7
|
+
Author: Hub02
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: auth,ed25519,hub02,identity,jwt,sso
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: cryptography>=42.0.0
|
|
20
|
+
Requires-Dist: pyjwt[crypto]>=2.8.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
24
|
+
Requires-Dist: flask>=2.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
27
|
+
Requires-Dist: starlette>=0.37; extra == 'dev'
|
|
28
|
+
Provides-Extra: fastapi
|
|
29
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
30
|
+
Provides-Extra: flask
|
|
31
|
+
Requires-Dist: flask>=2.0; extra == 'flask'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# `hub02-sdk` (Python)
|
|
35
|
+
|
|
36
|
+
Verify Hub02 tool-identity tokens (Ed25519) on your Python backend, and read
|
|
37
|
+
the signed-in user — no second login.
|
|
38
|
+
|
|
39
|
+
> Token algorithm is **EdDSA / Ed25519**, `iss="hub02"`, `aud="tool-identity"`.
|
|
40
|
+
> JWKS: `https://ddeubhasvmeqwtzgkunt.supabase.co/functions/v1/jwks`.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install hub02-sdk # not yet published — install from the repo for now
|
|
46
|
+
# pip install "git+https://github.com/Irshai02/hub02-sdk.git#subdirectory=python"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Server — verify on your backend
|
|
50
|
+
|
|
51
|
+
The Hub02 proxy injects the identity JWT as the `X-Hub02-Auth` header (also
|
|
52
|
+
accepts `Authorization: Bearer <jwt>`). Always trust `user.id` from the
|
|
53
|
+
verified token, never a client-supplied field.
|
|
54
|
+
|
|
55
|
+
### Framework-agnostic
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from hub02_sdk.server import authenticate_hub02, Hub02AuthError
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
user = authenticate_hub02(request) # request: FastAPI/Starlette, Flask, Django, or header dict
|
|
62
|
+
plan = get_plan(user.id) # key your data on user.id (durable UUID)
|
|
63
|
+
except Hub02AuthError:
|
|
64
|
+
... # 401
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### FastAPI
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from fastapi import FastAPI, Depends
|
|
71
|
+
from hub02_sdk.server import fastapi_dependency, Hub02User
|
|
72
|
+
|
|
73
|
+
require_user = fastapi_dependency() # optionally fastapi_dependency(tool_id="my-tool")
|
|
74
|
+
app = FastAPI()
|
|
75
|
+
|
|
76
|
+
@app.get("/my-plan")
|
|
77
|
+
def my_plan(user: Hub02User = Depends(require_user)):
|
|
78
|
+
return get_plan(user.id)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Flask
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from flask import Flask, jsonify
|
|
85
|
+
from hub02_sdk.server import flask_authenticate_hub02, Hub02AuthError
|
|
86
|
+
|
|
87
|
+
app = Flask(__name__)
|
|
88
|
+
|
|
89
|
+
@app.get("/my-plan")
|
|
90
|
+
def my_plan():
|
|
91
|
+
try:
|
|
92
|
+
user = flask_authenticate_hub02()
|
|
93
|
+
except Hub02AuthError as e:
|
|
94
|
+
return jsonify(authenticated=False, error=str(e)), 401
|
|
95
|
+
return jsonify(get_plan(user.id))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Public API
|
|
99
|
+
|
|
100
|
+
| Name | Signature | Purpose |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `verify_hub02_token` | `(token, *, tool_id=None, jwks_url=…, leeway=5) -> Hub02Claims` | Verify Ed25519 token vs JWKS; checks `iss`/`aud`/`exp`/optional `tool_id`. Raises `Hub02AuthError`. |
|
|
103
|
+
| `authenticate_hub02` | `(request, *, tool_id=None, …) -> Hub02User` | Extract + verify token from a request; raises `Hub02AuthError` (status 401). |
|
|
104
|
+
| `extract_token` | `(request) -> str | None` | Pull token from `X-Hub02-Auth` / `Authorization: Bearer`. |
|
|
105
|
+
| `fastapi_dependency` | `(*, tool_id=None, …) -> Depends-able` | FastAPI dependency returning `Hub02User`; raises `HTTPException(401)`. |
|
|
106
|
+
| `flask_authenticate_hub02` | `(*, tool_id=None, …) -> Hub02User` | Flask helper using `flask.request`. |
|
|
107
|
+
| `Hub02User` | dataclass `{ id, hub_id, tool_id, email, name }` | Trusted identity. Key data on `id`. |
|
|
108
|
+
| `Hub02Claims` | dict subclass | Raw verified claims. |
|
|
109
|
+
| `Hub02AuthError` | exception (`status = 401`) | Raised on any verification failure. |
|
|
110
|
+
|
|
111
|
+
Client helpers (SSR / forwarded identity, in `hub02_sdk`):
|
|
112
|
+
`user_from_window_identity(data)`, `user_from_me_response(data)`.
|
|
113
|
+
|
|
114
|
+
## Develop / test (offline, no secrets)
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
python -m venv .venv && . .venv/bin/activate
|
|
118
|
+
pip install -e ".[dev]"
|
|
119
|
+
pytest
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Tests generate a local Ed25519 keypair and a mock JWKS — no network, no Hub02
|
|
123
|
+
secrets.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# `hub02-sdk` (Python)
|
|
2
|
+
|
|
3
|
+
Verify Hub02 tool-identity tokens (Ed25519) on your Python backend, and read
|
|
4
|
+
the signed-in user — no second login.
|
|
5
|
+
|
|
6
|
+
> Token algorithm is **EdDSA / Ed25519**, `iss="hub02"`, `aud="tool-identity"`.
|
|
7
|
+
> JWKS: `https://ddeubhasvmeqwtzgkunt.supabase.co/functions/v1/jwks`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install hub02-sdk # not yet published — install from the repo for now
|
|
13
|
+
# pip install "git+https://github.com/Irshai02/hub02-sdk.git#subdirectory=python"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Server — verify on your backend
|
|
17
|
+
|
|
18
|
+
The Hub02 proxy injects the identity JWT as the `X-Hub02-Auth` header (also
|
|
19
|
+
accepts `Authorization: Bearer <jwt>`). Always trust `user.id` from the
|
|
20
|
+
verified token, never a client-supplied field.
|
|
21
|
+
|
|
22
|
+
### Framework-agnostic
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from hub02_sdk.server import authenticate_hub02, Hub02AuthError
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
user = authenticate_hub02(request) # request: FastAPI/Starlette, Flask, Django, or header dict
|
|
29
|
+
plan = get_plan(user.id) # key your data on user.id (durable UUID)
|
|
30
|
+
except Hub02AuthError:
|
|
31
|
+
... # 401
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### FastAPI
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from fastapi import FastAPI, Depends
|
|
38
|
+
from hub02_sdk.server import fastapi_dependency, Hub02User
|
|
39
|
+
|
|
40
|
+
require_user = fastapi_dependency() # optionally fastapi_dependency(tool_id="my-tool")
|
|
41
|
+
app = FastAPI()
|
|
42
|
+
|
|
43
|
+
@app.get("/my-plan")
|
|
44
|
+
def my_plan(user: Hub02User = Depends(require_user)):
|
|
45
|
+
return get_plan(user.id)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Flask
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from flask import Flask, jsonify
|
|
52
|
+
from hub02_sdk.server import flask_authenticate_hub02, Hub02AuthError
|
|
53
|
+
|
|
54
|
+
app = Flask(__name__)
|
|
55
|
+
|
|
56
|
+
@app.get("/my-plan")
|
|
57
|
+
def my_plan():
|
|
58
|
+
try:
|
|
59
|
+
user = flask_authenticate_hub02()
|
|
60
|
+
except Hub02AuthError as e:
|
|
61
|
+
return jsonify(authenticated=False, error=str(e)), 401
|
|
62
|
+
return jsonify(get_plan(user.id))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Public API
|
|
66
|
+
|
|
67
|
+
| Name | Signature | Purpose |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `verify_hub02_token` | `(token, *, tool_id=None, jwks_url=…, leeway=5) -> Hub02Claims` | Verify Ed25519 token vs JWKS; checks `iss`/`aud`/`exp`/optional `tool_id`. Raises `Hub02AuthError`. |
|
|
70
|
+
| `authenticate_hub02` | `(request, *, tool_id=None, …) -> Hub02User` | Extract + verify token from a request; raises `Hub02AuthError` (status 401). |
|
|
71
|
+
| `extract_token` | `(request) -> str | None` | Pull token from `X-Hub02-Auth` / `Authorization: Bearer`. |
|
|
72
|
+
| `fastapi_dependency` | `(*, tool_id=None, …) -> Depends-able` | FastAPI dependency returning `Hub02User`; raises `HTTPException(401)`. |
|
|
73
|
+
| `flask_authenticate_hub02` | `(*, tool_id=None, …) -> Hub02User` | Flask helper using `flask.request`. |
|
|
74
|
+
| `Hub02User` | dataclass `{ id, hub_id, tool_id, email, name }` | Trusted identity. Key data on `id`. |
|
|
75
|
+
| `Hub02Claims` | dict subclass | Raw verified claims. |
|
|
76
|
+
| `Hub02AuthError` | exception (`status = 401`) | Raised on any verification failure. |
|
|
77
|
+
|
|
78
|
+
Client helpers (SSR / forwarded identity, in `hub02_sdk`):
|
|
79
|
+
`user_from_window_identity(data)`, `user_from_me_response(data)`.
|
|
80
|
+
|
|
81
|
+
## Develop / test (offline, no secrets)
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python -m venv .venv && . .venv/bin/activate
|
|
85
|
+
pip install -e ".[dev]"
|
|
86
|
+
pytest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Tests generate a local Ed25519 keypair and a mock JWKS — no network, no Hub02
|
|
90
|
+
secrets.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hub02-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Hub02 tool-identity SDK — verify Hub02 identity tokens (Ed25519) on your Python backend."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Hub02" }]
|
|
13
|
+
keywords = ["hub02", "sso", "identity", "jwt", "ed25519", "auth"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Topic :: Security",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"PyJWT[crypto]>=2.8.0",
|
|
26
|
+
"cryptography>=42.0.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/Irshai02/hub02-sdk"
|
|
31
|
+
Repository = "https://github.com/Irshai02/hub02-sdk"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
fastapi = ["fastapi>=0.100"]
|
|
35
|
+
flask = ["flask>=2.0"]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.0",
|
|
38
|
+
"ruff>=0.6",
|
|
39
|
+
"build>=1.2",
|
|
40
|
+
"fastapi>=0.100",
|
|
41
|
+
"flask>=2.0",
|
|
42
|
+
"starlette>=0.37",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/hub02_sdk"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
include = ["src/hub02_sdk", "README.md", "LICENSE"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
pythonpath = ["src", "."]
|
|
54
|
+
addopts = "--import-mode=importlib"
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 100
|
|
58
|
+
src = ["src", "tests"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "I", "W"]
|
|
62
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Hub02 SDK — read the signed-in Hub02 user and verify identity tokens.
|
|
2
|
+
|
|
3
|
+
Public surface::
|
|
4
|
+
|
|
5
|
+
from hub02_sdk import Hub02User
|
|
6
|
+
from hub02_sdk.server import verify_hub02_token, authenticate_hub02
|
|
7
|
+
|
|
8
|
+
Token algorithm is EdDSA / Ed25519, ``iss="hub02"``, ``aud="tool-identity"``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._shared import (
|
|
12
|
+
HUB02_ALG,
|
|
13
|
+
HUB02_AUD,
|
|
14
|
+
HUB02_ISS,
|
|
15
|
+
HUB02_JWKS_URL,
|
|
16
|
+
HUB02_ME_PATH,
|
|
17
|
+
Hub02AuthError,
|
|
18
|
+
Hub02Claims,
|
|
19
|
+
Hub02User,
|
|
20
|
+
)
|
|
21
|
+
from .client import user_from_me_response, user_from_window_identity
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Hub02User",
|
|
25
|
+
"Hub02Claims",
|
|
26
|
+
"Hub02AuthError",
|
|
27
|
+
"HUB02_JWKS_URL",
|
|
28
|
+
"HUB02_ISS",
|
|
29
|
+
"HUB02_AUD",
|
|
30
|
+
"HUB02_ALG",
|
|
31
|
+
"HUB02_ME_PATH",
|
|
32
|
+
"user_from_window_identity",
|
|
33
|
+
"user_from_me_response",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared constants and types for the Hub02 SDK.
|
|
2
|
+
|
|
3
|
+
These pin the contract so client and server halves cannot drift:
|
|
4
|
+
- JWKS endpoint (public Ed25519 keys)
|
|
5
|
+
- token issuer (``iss``)
|
|
6
|
+
- token audience (``aud``)
|
|
7
|
+
|
|
8
|
+
Token algorithm is EdDSA / Ed25519 (NOT ES256).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
#: Public JWKS endpoint exposing Hub02's Ed25519 verification keys.
|
|
17
|
+
HUB02_JWKS_URL = "https://ddeubhasvmeqwtzgkunt.supabase.co/functions/v1/jwks"
|
|
18
|
+
|
|
19
|
+
#: Expected ``iss`` claim on a Hub02 identity token.
|
|
20
|
+
HUB02_ISS = "hub02"
|
|
21
|
+
|
|
22
|
+
#: Expected ``aud`` claim on a Hub02 identity token.
|
|
23
|
+
HUB02_AUD = "tool-identity"
|
|
24
|
+
|
|
25
|
+
#: Signing / verification algorithm. EdDSA over the Ed25519 curve.
|
|
26
|
+
HUB02_ALG = "EdDSA"
|
|
27
|
+
|
|
28
|
+
#: Same-origin pull endpoint the proxy exposes for client identity.
|
|
29
|
+
HUB02_ME_PATH = "/__hub02/me"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Hub02User:
|
|
34
|
+
"""Identity returned to application code.
|
|
35
|
+
|
|
36
|
+
``id`` is the durable Hub02 user UUID (the ``sub`` claim) — builders MUST
|
|
37
|
+
key their data on this. ``email`` / ``name`` are display-only and may
|
|
38
|
+
change.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
id: str
|
|
42
|
+
hub_id: Optional[str] = None
|
|
43
|
+
tool_id: Optional[str] = None
|
|
44
|
+
email: Optional[str] = None
|
|
45
|
+
name: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Hub02Claims(dict):
|
|
49
|
+
"""Raw verified claims from a Hub02 identity JWT (a plain dict).
|
|
50
|
+
|
|
51
|
+
Common keys: ``sub``, ``iss``, ``aud``, ``hub_id``, ``tool_id``,
|
|
52
|
+
``email``, ``name``, ``iat``, ``exp``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def sub(self) -> str:
|
|
57
|
+
return self["sub"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Hub02AuthError(Exception):
|
|
61
|
+
"""Raised when token verification or authorization fails.
|
|
62
|
+
|
|
63
|
+
Carries ``status = 401`` so framework handlers can map it directly.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
status = 401
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def claims_to_user(claims: dict[str, Any]) -> Hub02User:
|
|
70
|
+
"""Map verified claims to the public :class:`Hub02User`."""
|
|
71
|
+
return Hub02User(
|
|
72
|
+
id=claims["sub"],
|
|
73
|
+
hub_id=claims.get("hub_id"),
|
|
74
|
+
tool_id=claims.get("tool_id"),
|
|
75
|
+
email=claims.get("email"),
|
|
76
|
+
name=claims.get("name"),
|
|
77
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Hub02 SDK — client-side helpers (Python).
|
|
2
|
+
|
|
3
|
+
Client-side Python is minimal — most Python use is server-side. This module
|
|
4
|
+
provides a helper to read an already-established Hub02 identity from a
|
|
5
|
+
forwarded header (e.g. SSR frameworks where the proxy injected
|
|
6
|
+
``X-Hub02-*`` / ``window.__HUB02__`` upstream), plus the ``Hub02User`` type.
|
|
7
|
+
|
|
8
|
+
For real authorization, verify the token on the server with
|
|
9
|
+
:func:`hub02_sdk.server.authenticate_hub02` — never trust a forwarded
|
|
10
|
+
identity field without verifying the signed token.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from ._shared import Hub02User
|
|
19
|
+
|
|
20
|
+
__all__ = ["Hub02User", "user_from_window_identity", "user_from_me_response"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def user_from_window_identity(data: Any) -> Optional[Hub02User]:
|
|
24
|
+
"""Build a :class:`Hub02User` from a ``window.__HUB02__`` JSON blob.
|
|
25
|
+
|
|
26
|
+
Accepts a dict or a JSON string. Returns ``None`` if no ``user_id``.
|
|
27
|
+
Display-only: do not use as an authorization decision on its own.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(data, str):
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(data)
|
|
32
|
+
except (ValueError, TypeError):
|
|
33
|
+
return None
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
return None
|
|
36
|
+
user_id = data.get("user_id") or data.get("id")
|
|
37
|
+
if not user_id:
|
|
38
|
+
return None
|
|
39
|
+
return Hub02User(
|
|
40
|
+
id=user_id,
|
|
41
|
+
hub_id=data.get("hub_id"),
|
|
42
|
+
tool_id=data.get("tool_id"),
|
|
43
|
+
email=data.get("email"),
|
|
44
|
+
name=data.get("name"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def user_from_me_response(data: Any) -> Optional[Hub02User]:
|
|
49
|
+
"""Build a :class:`Hub02User` from a ``GET /__hub02/me`` JSON body.
|
|
50
|
+
|
|
51
|
+
Returns ``None`` when ``authenticated`` is false or no ``user_id``.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(data, str):
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(data)
|
|
56
|
+
except (ValueError, TypeError):
|
|
57
|
+
return None
|
|
58
|
+
if not isinstance(data, dict) or not data.get("authenticated"):
|
|
59
|
+
return None
|
|
60
|
+
user_id = data.get("user_id")
|
|
61
|
+
if not user_id:
|
|
62
|
+
return None
|
|
63
|
+
return Hub02User(
|
|
64
|
+
id=user_id,
|
|
65
|
+
hub_id=data.get("hub_id"),
|
|
66
|
+
tool_id=data.get("tool_id"),
|
|
67
|
+
email=data.get("email"),
|
|
68
|
+
name=data.get("name"),
|
|
69
|
+
)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Hub02 SDK — server (framework-agnostic + FastAPI + Flask helpers).
|
|
2
|
+
|
|
3
|
+
Verifies Hub02 identity JWTs against the public JWKS using Ed25519, and turns
|
|
4
|
+
a request into a trusted :class:`Hub02User`.
|
|
5
|
+
|
|
6
|
+
SECURITY: always read ``user.id`` from the verified token — never from a
|
|
7
|
+
client-supplied field.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from hub02_sdk.server import authenticate_hub02
|
|
12
|
+
user = authenticate_hub02(request) # raises Hub02AuthError on invalid
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
import jwt
|
|
22
|
+
from jwt import PyJWK, PyJWKClient
|
|
23
|
+
|
|
24
|
+
from ._shared import (
|
|
25
|
+
HUB02_ALG,
|
|
26
|
+
HUB02_AUD,
|
|
27
|
+
HUB02_ISS,
|
|
28
|
+
HUB02_JWKS_URL,
|
|
29
|
+
Hub02AuthError,
|
|
30
|
+
Hub02Claims,
|
|
31
|
+
Hub02User,
|
|
32
|
+
claims_to_user,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"verify_hub02_token",
|
|
37
|
+
"authenticate_hub02",
|
|
38
|
+
"extract_token",
|
|
39
|
+
"fastapi_dependency",
|
|
40
|
+
"flask_authenticate_hub02",
|
|
41
|
+
"Hub02User",
|
|
42
|
+
"Hub02Claims",
|
|
43
|
+
"Hub02AuthError",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------------------------
|
|
48
|
+
# JWKS cache (by kid, TTL ~10m, refetch on unknown kid).
|
|
49
|
+
# --------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
_JWKS_TTL_SEC = 10 * 60
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _JwksCache:
|
|
55
|
+
"""Thread-safe JWKS resolver with a TTL and unknown-kid refetch."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, url: str) -> None:
|
|
58
|
+
self._url = url
|
|
59
|
+
self._client: Optional[PyJWKClient] = None
|
|
60
|
+
self._fetched_at = 0.0
|
|
61
|
+
self._lock = threading.Lock()
|
|
62
|
+
|
|
63
|
+
def _client_fresh(self) -> PyJWKClient:
|
|
64
|
+
now = time.time()
|
|
65
|
+
with self._lock:
|
|
66
|
+
if self._client is None or (now - self._fetched_at) > _JWKS_TTL_SEC:
|
|
67
|
+
self._client = PyJWKClient(self._url, lifespan=_JWKS_TTL_SEC)
|
|
68
|
+
self._fetched_at = now
|
|
69
|
+
return self._client
|
|
70
|
+
|
|
71
|
+
def get_key(self, token: str) -> PyJWK:
|
|
72
|
+
client = self._client_fresh()
|
|
73
|
+
try:
|
|
74
|
+
return client.get_signing_key_from_jwt(token)
|
|
75
|
+
except Exception:
|
|
76
|
+
# Unknown kid → force a refetch once.
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._client = PyJWKClient(self._url, lifespan=_JWKS_TTL_SEC)
|
|
79
|
+
self._fetched_at = time.time()
|
|
80
|
+
client = self._client
|
|
81
|
+
return client.get_signing_key_from_jwt(token)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_caches: dict[str, _JwksCache] = {}
|
|
85
|
+
_caches_lock = threading.Lock()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve_key(token: str, jwks_url: str, jwks_client: Any) -> Any:
|
|
89
|
+
if jwks_client is not None:
|
|
90
|
+
# Caller-provided resolver (tests). Either a PyJWKClient-like with
|
|
91
|
+
# get_signing_key_from_jwt, or a mapping kid->key.
|
|
92
|
+
if hasattr(jwks_client, "get_signing_key_from_jwt"):
|
|
93
|
+
return jwks_client.get_signing_key_from_jwt(token).key
|
|
94
|
+
# mapping by kid
|
|
95
|
+
header = jwt.get_unverified_header(token)
|
|
96
|
+
kid = header.get("kid")
|
|
97
|
+
key = jwks_client.get(kid) if hasattr(jwks_client, "get") else None
|
|
98
|
+
if key is None:
|
|
99
|
+
raise Hub02AuthError(f"No key for kid {kid}")
|
|
100
|
+
return key
|
|
101
|
+
with _caches_lock:
|
|
102
|
+
cache = _caches.get(jwks_url)
|
|
103
|
+
if cache is None:
|
|
104
|
+
cache = _JwksCache(jwks_url)
|
|
105
|
+
_caches[jwks_url] = cache
|
|
106
|
+
return cache.get_key(token).key
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def verify_hub02_token(
|
|
110
|
+
token: str,
|
|
111
|
+
*,
|
|
112
|
+
tool_id: Optional[str] = None,
|
|
113
|
+
jwks_url: str = HUB02_JWKS_URL,
|
|
114
|
+
jwks_client: Any = None,
|
|
115
|
+
leeway: int = 5,
|
|
116
|
+
) -> Hub02Claims:
|
|
117
|
+
"""Verify a Hub02 identity JWT.
|
|
118
|
+
|
|
119
|
+
Checks: Ed25519 signature vs JWKS, ``iss == "hub02"``,
|
|
120
|
+
``aud == "tool-identity"``, ``exp`` not passed (small leeway), and — if
|
|
121
|
+
``tool_id`` is supplied — that the ``tool_id`` claim matches.
|
|
122
|
+
|
|
123
|
+
Raises :class:`Hub02AuthError` on any failure.
|
|
124
|
+
"""
|
|
125
|
+
if not token or not isinstance(token, str):
|
|
126
|
+
raise Hub02AuthError("Missing token")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
signing_key = _resolve_key(token, jwks_url, jwks_client)
|
|
130
|
+
except Hub02AuthError:
|
|
131
|
+
raise
|
|
132
|
+
except Exception as exc: # noqa: BLE001
|
|
133
|
+
raise Hub02AuthError(f"Could not resolve signing key: {exc}") from exc
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
payload = jwt.decode(
|
|
137
|
+
token,
|
|
138
|
+
signing_key,
|
|
139
|
+
algorithms=[HUB02_ALG],
|
|
140
|
+
issuer=HUB02_ISS,
|
|
141
|
+
audience=HUB02_AUD,
|
|
142
|
+
leeway=leeway,
|
|
143
|
+
options={"require": ["exp", "sub"]},
|
|
144
|
+
)
|
|
145
|
+
except Exception as exc: # noqa: BLE001 (PyJWT raises many subclasses)
|
|
146
|
+
raise Hub02AuthError(f"Token verification failed: {exc}") from exc
|
|
147
|
+
|
|
148
|
+
if not payload.get("sub"):
|
|
149
|
+
raise Hub02AuthError("Token missing sub claim")
|
|
150
|
+
|
|
151
|
+
if tool_id is not None and payload.get("tool_id") != tool_id:
|
|
152
|
+
raise Hub02AuthError(f"Token tool_id mismatch (expected {tool_id})")
|
|
153
|
+
|
|
154
|
+
return Hub02Claims(payload)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --------------------------------------------------------------------------
|
|
158
|
+
# Request helpers
|
|
159
|
+
# --------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _get_header(request: Any, name: str) -> Optional[str]:
|
|
163
|
+
"""Read a header from common request objects (Starlette/FastAPI, Flask,
|
|
164
|
+
Django, or a plain dict)."""
|
|
165
|
+
lname = name.lower()
|
|
166
|
+
headers = getattr(request, "headers", None)
|
|
167
|
+
if headers is not None:
|
|
168
|
+
# Starlette/Werkzeug headers are case-insensitive mappings.
|
|
169
|
+
try:
|
|
170
|
+
val = headers.get(name) or headers.get(lname)
|
|
171
|
+
if val:
|
|
172
|
+
return val
|
|
173
|
+
except Exception: # noqa: BLE001
|
|
174
|
+
pass
|
|
175
|
+
# Django style: request.META["HTTP_X_HUB02_AUTH"]
|
|
176
|
+
meta = getattr(request, "META", None)
|
|
177
|
+
if isinstance(meta, dict):
|
|
178
|
+
key = "HTTP_" + name.upper().replace("-", "_")
|
|
179
|
+
if meta.get(key):
|
|
180
|
+
return meta[key]
|
|
181
|
+
# Plain dict of headers.
|
|
182
|
+
if isinstance(request, dict):
|
|
183
|
+
return request.get(name) or request.get(lname)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def extract_token(request: Any) -> Optional[str]:
|
|
188
|
+
"""Extract the identity token: ``X-Hub02-Auth`` then ``Authorization:
|
|
189
|
+
Bearer``."""
|
|
190
|
+
direct = _get_header(request, "X-Hub02-Auth")
|
|
191
|
+
if direct:
|
|
192
|
+
return direct[7:].strip() if direct.lower().startswith("bearer ") else direct.strip()
|
|
193
|
+
auth = _get_header(request, "Authorization")
|
|
194
|
+
if auth and auth.lower().startswith("bearer "):
|
|
195
|
+
return auth[7:].strip()
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def authenticate_hub02(
|
|
200
|
+
request: Any,
|
|
201
|
+
*,
|
|
202
|
+
tool_id: Optional[str] = None,
|
|
203
|
+
jwks_url: str = HUB02_JWKS_URL,
|
|
204
|
+
jwks_client: Any = None,
|
|
205
|
+
leeway: int = 5,
|
|
206
|
+
) -> Hub02User:
|
|
207
|
+
"""Verify the request's identity token and return the trusted user.
|
|
208
|
+
|
|
209
|
+
Framework-agnostic: accepts Starlette/FastAPI, Flask, Django, or a plain
|
|
210
|
+
header dict. Raises :class:`Hub02AuthError` (status 401) when no valid
|
|
211
|
+
token is present.
|
|
212
|
+
"""
|
|
213
|
+
token = extract_token(request)
|
|
214
|
+
if not token:
|
|
215
|
+
raise Hub02AuthError("No Hub02 identity token on request")
|
|
216
|
+
claims = verify_hub02_token(
|
|
217
|
+
token,
|
|
218
|
+
tool_id=tool_id,
|
|
219
|
+
jwks_url=jwks_url,
|
|
220
|
+
jwks_client=jwks_client,
|
|
221
|
+
leeway=leeway,
|
|
222
|
+
)
|
|
223
|
+
return claims_to_user(claims)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --------------------------------------------------------------------------
|
|
227
|
+
# FastAPI dependency
|
|
228
|
+
# --------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def fastapi_dependency(*, tool_id: Optional[str] = None, **kwargs: Any):
|
|
232
|
+
"""Build a FastAPI dependency that returns a :class:`Hub02User`.
|
|
233
|
+
|
|
234
|
+
Usage::
|
|
235
|
+
|
|
236
|
+
from fastapi import Depends
|
|
237
|
+
from hub02_sdk.server import fastapi_dependency
|
|
238
|
+
require_user = fastapi_dependency()
|
|
239
|
+
|
|
240
|
+
@app.get("/my-plan")
|
|
241
|
+
def my_plan(user = Depends(require_user)):
|
|
242
|
+
return get_plan(user.id)
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def _dep(request: Any) -> Hub02User:
|
|
246
|
+
try:
|
|
247
|
+
return authenticate_hub02(request, tool_id=tool_id, **kwargs)
|
|
248
|
+
except Hub02AuthError as exc:
|
|
249
|
+
try:
|
|
250
|
+
from fastapi import HTTPException
|
|
251
|
+
except ImportError as ie: # pragma: no cover
|
|
252
|
+
raise exc from ie
|
|
253
|
+
raise HTTPException(
|
|
254
|
+
status_code=401,
|
|
255
|
+
detail={"authenticated": False, "error": str(exc)},
|
|
256
|
+
) from exc
|
|
257
|
+
|
|
258
|
+
return _dep
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# --------------------------------------------------------------------------
|
|
262
|
+
# Flask helper
|
|
263
|
+
# --------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def flask_authenticate_hub02(*, tool_id: Optional[str] = None, **kwargs: Any) -> Hub02User:
|
|
267
|
+
"""Flask helper: read the current request and return a trusted user.
|
|
268
|
+
|
|
269
|
+
Call inside a view (uses ``flask.request``). Raises
|
|
270
|
+
:class:`Hub02AuthError` (status 401) on failure — register an error
|
|
271
|
+
handler, or wrap in try/except and return a 401 JSON body.
|
|
272
|
+
"""
|
|
273
|
+
from flask import request as flask_request
|
|
274
|
+
|
|
275
|
+
return authenticate_hub02(flask_request, tool_id=tool_id, **kwargs)
|