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.
@@ -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.*
@@ -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)