elav8 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.
elav8-0.1.0/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ # Python
2
+ .venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ dist/
10
+ build/
elav8-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: elav8
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Elav8 / Paga — Sign in with Elav8 (OAuth 2.1 + PKCE), offline token verification, entitlements, checkout/portal, and webhook verification.
5
+ Project-URL: Homepage, https://elav8.dev
6
+ Project-URL: Documentation, https://elav8.dev/docs
7
+ Author: Elav8
8
+ License-Expression: LicenseRef-Proprietary
9
+ Keywords: billing,elav8,entitlements,jwt,oauth,oidc,paga
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
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: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: cryptography>=42.0
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: pyjwt[crypto]>=2.8
23
+ Provides-Extra: dev
24
+ Requires-Dist: fastapi>=0.110; extra == 'dev'
25
+ Requires-Dist: mypy>=1.8; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Requires-Dist: respx>=0.21; extra == 'dev'
29
+ Requires-Dist: ruff>=0.5; extra == 'dev'
30
+ Provides-Extra: fastapi
31
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # elav8 (Python SDK)
35
+
36
+ Python SDK for **Elav8 / Paga** — "Sign in with Elav8" (OAuth 2.1 + PKCE),
37
+ offline access-token verification, entitlements, hosted checkout / customer
38
+ portal, and webhook signature verification. Mirrors the JavaScript
39
+ [`@elav8/sdk`](https://www.npmjs.com/package/@elav8/sdk).
40
+
41
+ - Offline JWT verification against Elav8's JWKS (no per-request network call).
42
+ - One-line FastAPI route protection.
43
+ - Small dependency surface: `PyJWT`, `cryptography`, `httpx`.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install elav8
49
+ # with the FastAPI helper:
50
+ pip install "elav8[fastapi]"
51
+ ```
52
+
53
+ ## Verify an access token (offline)
54
+
55
+ ```python
56
+ from elav8 import verify_access_token, Elav8Error
57
+
58
+ try:
59
+ claims = verify_access_token(token, issuer="https://billing.example.com/api/auth")
60
+ user_id = claims["sub"]
61
+ except Elav8Error:
62
+ ... # 401
63
+ ```
64
+
65
+ > Trade-off: verification is **offline**, so a token revoked before its `exp`
66
+ > (~10 min) stays accepted until it expires. For sensitive operations, re-check
67
+ > server state (e.g. entitlements) rather than trusting the token alone.
68
+
69
+ ## Protect a FastAPI route (one line)
70
+
71
+ ```python
72
+ from fastapi import Depends, FastAPI
73
+ from elav8.fastapi import require_user
74
+
75
+ app = FastAPI()
76
+ authed = require_user(issuer="https://billing.example.com/api/auth")
77
+
78
+ @app.get("/me")
79
+ def me(user: dict = Depends(authed)):
80
+ return {"sub": user["sub"]}
81
+ ```
82
+
83
+ ## Sign in with Elav8 (OAuth 2.1 + PKCE)
84
+
85
+ ```python
86
+ from elav8 import Elav8OAuth
87
+
88
+ oauth = Elav8OAuth(
89
+ issuer="https://billing.example.com/api/auth",
90
+ client_id="client_...",
91
+ redirect_uri="https://app.example.com/callback",
92
+ )
93
+
94
+ # 1. Begin sign-in — persist state + code_verifier in the session, redirect to .url
95
+ flow = oauth.start()
96
+
97
+ # 2. On your callback route:
98
+ tokens = oauth.exchange_code(code, code_verifier)
99
+ # tokens.access_token is a JWT verifiable with verify_access_token(...)
100
+ ```
101
+
102
+ ## Server API (secret key)
103
+
104
+ ```python
105
+ import os
106
+ from elav8 import Elav8ServerClient, CustomerInput
107
+
108
+ elav8 = Elav8ServerClient(
109
+ secret_key=os.environ["ELAV8_SECRET_KEY"],
110
+ base_url="https://billing.example.com",
111
+ webhook_secret=os.environ.get("ELAV8_WEBHOOK_SECRET"),
112
+ )
113
+
114
+ snapshot = elav8.get_entitlements("user_1")
115
+ if snapshot.entitlements.get("pro"):
116
+ ...
117
+
118
+ checkout = elav8.create_checkout(
119
+ plan="pro",
120
+ customer=CustomerInput(email="a@b.com", external_user_id="user_1"),
121
+ success_url="https://app.example.com/welcome",
122
+ cancel_url="https://app.example.com/pricing",
123
+ )
124
+ # redirect the customer to checkout.url
125
+ ```
126
+
127
+ ## Verify a webhook
128
+
129
+ ```python
130
+ event = elav8.construct_webhook_event(raw_body, signature_header)
131
+ ```
132
+
133
+ Elav8 signs each webhook with `x-elav8-signature: t=<unix>,v1=<hmac-sha256-hex>`
134
+ over `"{t}.{raw_body}"`; the timestamp blocks replays. Always pass the **raw**
135
+ request body.
136
+
137
+ ## License
138
+
139
+ Proprietary — internal to Elav8 until open-sourced.
elav8-0.1.0/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # elav8 (Python SDK)
2
+
3
+ Python SDK for **Elav8 / Paga** — "Sign in with Elav8" (OAuth 2.1 + PKCE),
4
+ offline access-token verification, entitlements, hosted checkout / customer
5
+ portal, and webhook signature verification. Mirrors the JavaScript
6
+ [`@elav8/sdk`](https://www.npmjs.com/package/@elav8/sdk).
7
+
8
+ - Offline JWT verification against Elav8's JWKS (no per-request network call).
9
+ - One-line FastAPI route protection.
10
+ - Small dependency surface: `PyJWT`, `cryptography`, `httpx`.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install elav8
16
+ # with the FastAPI helper:
17
+ pip install "elav8[fastapi]"
18
+ ```
19
+
20
+ ## Verify an access token (offline)
21
+
22
+ ```python
23
+ from elav8 import verify_access_token, Elav8Error
24
+
25
+ try:
26
+ claims = verify_access_token(token, issuer="https://billing.example.com/api/auth")
27
+ user_id = claims["sub"]
28
+ except Elav8Error:
29
+ ... # 401
30
+ ```
31
+
32
+ > Trade-off: verification is **offline**, so a token revoked before its `exp`
33
+ > (~10 min) stays accepted until it expires. For sensitive operations, re-check
34
+ > server state (e.g. entitlements) rather than trusting the token alone.
35
+
36
+ ## Protect a FastAPI route (one line)
37
+
38
+ ```python
39
+ from fastapi import Depends, FastAPI
40
+ from elav8.fastapi import require_user
41
+
42
+ app = FastAPI()
43
+ authed = require_user(issuer="https://billing.example.com/api/auth")
44
+
45
+ @app.get("/me")
46
+ def me(user: dict = Depends(authed)):
47
+ return {"sub": user["sub"]}
48
+ ```
49
+
50
+ ## Sign in with Elav8 (OAuth 2.1 + PKCE)
51
+
52
+ ```python
53
+ from elav8 import Elav8OAuth
54
+
55
+ oauth = Elav8OAuth(
56
+ issuer="https://billing.example.com/api/auth",
57
+ client_id="client_...",
58
+ redirect_uri="https://app.example.com/callback",
59
+ )
60
+
61
+ # 1. Begin sign-in — persist state + code_verifier in the session, redirect to .url
62
+ flow = oauth.start()
63
+
64
+ # 2. On your callback route:
65
+ tokens = oauth.exchange_code(code, code_verifier)
66
+ # tokens.access_token is a JWT verifiable with verify_access_token(...)
67
+ ```
68
+
69
+ ## Server API (secret key)
70
+
71
+ ```python
72
+ import os
73
+ from elav8 import Elav8ServerClient, CustomerInput
74
+
75
+ elav8 = Elav8ServerClient(
76
+ secret_key=os.environ["ELAV8_SECRET_KEY"],
77
+ base_url="https://billing.example.com",
78
+ webhook_secret=os.environ.get("ELAV8_WEBHOOK_SECRET"),
79
+ )
80
+
81
+ snapshot = elav8.get_entitlements("user_1")
82
+ if snapshot.entitlements.get("pro"):
83
+ ...
84
+
85
+ checkout = elav8.create_checkout(
86
+ plan="pro",
87
+ customer=CustomerInput(email="a@b.com", external_user_id="user_1"),
88
+ success_url="https://app.example.com/welcome",
89
+ cancel_url="https://app.example.com/pricing",
90
+ )
91
+ # redirect the customer to checkout.url
92
+ ```
93
+
94
+ ## Verify a webhook
95
+
96
+ ```python
97
+ event = elav8.construct_webhook_event(raw_body, signature_header)
98
+ ```
99
+
100
+ Elav8 signs each webhook with `x-elav8-signature: t=<unix>,v1=<hmac-sha256-hex>`
101
+ over `"{t}.{raw_body}"`; the timestamp blocks replays. Always pass the **raw**
102
+ request body.
103
+
104
+ ## License
105
+
106
+ Proprietary — internal to Elav8 until open-sourced.
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "elav8"
7
+ version = "0.1.0"
8
+ description = "Python SDK for Elav8 / Paga — Sign in with Elav8 (OAuth 2.1 + PKCE), offline token verification, entitlements, checkout/portal, and webhook verification."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "LicenseRef-Proprietary"
12
+ authors = [{ name = "Elav8" }]
13
+ keywords = ["elav8", "paga", "oauth", "oidc", "billing", "entitlements", "jwt"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "pyjwt[crypto]>=2.8",
27
+ "cryptography>=42.0",
28
+ "httpx>=0.27",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ fastapi = ["fastapi>=0.110"]
33
+ dev = [
34
+ "pytest>=8",
35
+ "pytest-asyncio>=0.23",
36
+ "respx>=0.21",
37
+ "mypy>=1.8",
38
+ "ruff>=0.5",
39
+ "fastapi>=0.110",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://elav8.dev"
44
+ Documentation = "https://elav8.dev/docs"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/elav8"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ include = ["src/elav8", "README.md"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ asyncio_mode = "auto"
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ target-version = "py39"
59
+
60
+ # Type-check against 3.10 (the lowest version this mypy supports); the package
61
+ # still runs on 3.9+ at runtime (annotations are deferred via __future__).
62
+ [tool.mypy]
63
+ python_version = "3.10"
64
+ strict = true
@@ -0,0 +1,75 @@
1
+ """Elav8 / Paga Python SDK.
2
+
3
+ Mirrors ``@elav8/sdk`` (JavaScript): "Sign in with Elav8" (OAuth 2.1 + PKCE),
4
+ offline access-token verification, entitlements, hosted checkout / portal, and
5
+ webhook signature verification.
6
+
7
+ ``elav8.fastapi`` (extra ``elav8[fastapi]``) adds a one-line ``require_user``
8
+ route dependency.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from ._types import (
14
+ AccessTokenClaims,
15
+ CheckoutResult,
16
+ CustomerInput,
17
+ Elav8User,
18
+ EntitlementSnapshot,
19
+ Plan,
20
+ )
21
+ from .client import Elav8ServerClient
22
+ from .errors import Elav8Error
23
+ from .oauth import (
24
+ Elav8OAuth,
25
+ SignInRedirect,
26
+ TokenResponse,
27
+ build_authorize_url,
28
+ build_refresh_form,
29
+ build_token_exchange_form,
30
+ )
31
+ from .pkce import (
32
+ PkcePair,
33
+ create_code_challenge,
34
+ create_pkce_pair,
35
+ generate_code_verifier,
36
+ generate_state,
37
+ )
38
+ from .verify import verify_access_token
39
+ from .webhook import construct_webhook_event, sign_payload, verify_webhook_signature
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ __all__ = [
44
+ "__version__",
45
+ # Errors
46
+ "Elav8Error",
47
+ # Types
48
+ "AccessTokenClaims",
49
+ "CheckoutResult",
50
+ "CustomerInput",
51
+ "Elav8User",
52
+ "EntitlementSnapshot",
53
+ "Plan",
54
+ # Server client
55
+ "Elav8ServerClient",
56
+ # OAuth
57
+ "Elav8OAuth",
58
+ "SignInRedirect",
59
+ "TokenResponse",
60
+ "build_authorize_url",
61
+ "build_refresh_form",
62
+ "build_token_exchange_form",
63
+ # PKCE
64
+ "PkcePair",
65
+ "create_code_challenge",
66
+ "create_pkce_pair",
67
+ "generate_code_verifier",
68
+ "generate_state",
69
+ # Verify
70
+ "verify_access_token",
71
+ # Webhooks
72
+ "construct_webhook_event",
73
+ "sign_payload",
74
+ "verify_webhook_signature",
75
+ ]
@@ -0,0 +1,99 @@
1
+ """Typed data shapes mirroring ``packages/sdk/src/shared.ts``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Optional, Union
7
+
8
+ EntitlementValue = Union[bool, int, float, str, None]
9
+
10
+
11
+ @dataclass
12
+ class Plan:
13
+ id: str
14
+ slug: str
15
+ name: str
16
+
17
+
18
+ @dataclass
19
+ class EntitlementSnapshot:
20
+ """A resolved entitlements snapshot for a customer of an app."""
21
+
22
+ active: bool
23
+ plan: Optional[Plan]
24
+ subscription_status: Optional[str]
25
+ current_period_end: Optional[str]
26
+ cancel_at_period_end: bool
27
+ entitlements: Dict[str, EntitlementValue] = field(default_factory=dict)
28
+
29
+ @classmethod
30
+ def from_api(cls, data: Dict[str, Any]) -> "EntitlementSnapshot":
31
+ plan_data = data.get("plan")
32
+ return cls(
33
+ active=bool(data.get("active", False)),
34
+ plan=Plan(**plan_data) if plan_data else None,
35
+ subscription_status=data.get("subscriptionStatus"),
36
+ current_period_end=data.get("currentPeriodEnd"),
37
+ cancel_at_period_end=bool(data.get("cancelAtPeriodEnd", False)),
38
+ entitlements=data.get("entitlements") or {},
39
+ )
40
+
41
+
42
+ @dataclass
43
+ class Elav8User:
44
+ """The minimal user shape returned by the OIDC UserInfo endpoint."""
45
+
46
+ sub: str
47
+ email: Optional[str] = None
48
+ email_verified: Optional[bool] = None
49
+ name: Optional[str] = None
50
+ picture: Optional[str] = None
51
+ claims: Dict[str, Any] = field(default_factory=dict)
52
+
53
+ @classmethod
54
+ def from_api(cls, data: Dict[str, Any]) -> "Elav8User":
55
+ known = {"sub", "email", "email_verified", "name", "picture"}
56
+ return cls(
57
+ sub=str(data.get("sub", "")),
58
+ email=data.get("email"),
59
+ email_verified=data.get("email_verified"),
60
+ name=data.get("name"),
61
+ picture=data.get("picture"),
62
+ claims={k: v for k, v in data.items() if k not in known},
63
+ )
64
+
65
+
66
+ # Verified access-token claims (parity with AccessTokenClaims in verify.ts).
67
+ AccessTokenClaims = Dict[str, Any]
68
+
69
+
70
+ @dataclass
71
+ class CheckoutResult:
72
+ url: str
73
+ session_id: str
74
+
75
+
76
+ @dataclass
77
+ class CustomerInput:
78
+ email: str
79
+ external_user_id: Optional[str] = None
80
+ name: Optional[str] = None
81
+
82
+ def to_api(self) -> Dict[str, Any]:
83
+ out: Dict[str, Any] = {"email": self.email}
84
+ if self.external_user_id is not None:
85
+ out["externalUserId"] = self.external_user_id
86
+ if self.name is not None:
87
+ out["name"] = self.name
88
+ return out
89
+
90
+
91
+ __all__ = [
92
+ "Plan",
93
+ "EntitlementSnapshot",
94
+ "Elav8User",
95
+ "AccessTokenClaims",
96
+ "CheckoutResult",
97
+ "CustomerInput",
98
+ "EntitlementValue",
99
+ ]
@@ -0,0 +1,178 @@
1
+ """Server-side Elav8 client — parity with ``packages/sdk/src/server.ts``.
2
+
3
+ Uses your **secret** key (``elav8_sk_...``) as a Bearer token to call the Elav8
4
+ API: create checkout sessions, create customer-portal links, read entitlements,
5
+ verify access tokens offline, and verify forwarded webhooks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, List, Optional, Union
11
+
12
+ import httpx
13
+
14
+ from ._types import CheckoutResult, CustomerInput, EntitlementSnapshot
15
+ from .errors import Elav8Error
16
+ from .verify import verify_access_token
17
+ from .webhook import construct_webhook_event, verify_webhook_signature
18
+
19
+
20
+ def _trim_trailing_slash(value: str) -> str:
21
+ return value[:-1] if value.endswith("/") else value
22
+
23
+
24
+ class Elav8ServerClient:
25
+ """Server-only Elav8 client authenticated with your secret key.
26
+
27
+ Example::
28
+
29
+ elav8 = Elav8ServerClient(
30
+ secret_key=os.environ["ELAV8_SECRET_KEY"],
31
+ base_url="https://billing.example.com",
32
+ )
33
+ snapshot = elav8.get_entitlements("user_1")
34
+ if snapshot.entitlements.get("pro"):
35
+ ...
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ secret_key: str,
42
+ base_url: str,
43
+ webhook_secret: Optional[str] = None,
44
+ issuer: Optional[str] = None,
45
+ http_client: Optional[httpx.Client] = None,
46
+ timeout: float = 10.0,
47
+ ) -> None:
48
+ self._secret_key = secret_key
49
+ self._base = _trim_trailing_slash(base_url)
50
+ self._webhook_secret = webhook_secret
51
+ self._issuer = _trim_trailing_slash(issuer or f"{self._base}/api/auth")
52
+ self._client = http_client
53
+ self._timeout = timeout
54
+
55
+ # -- HTTP ---------------------------------------------------------------
56
+
57
+ def _request(
58
+ self,
59
+ method: str,
60
+ path: str,
61
+ *,
62
+ json: Optional[Dict[str, Any]] = None,
63
+ params: Optional[Dict[str, str]] = None,
64
+ ) -> Dict[str, Any]:
65
+ owns_client = self._client is None
66
+ client = self._client or httpx.Client(timeout=self._timeout)
67
+ try:
68
+ res = client.request(
69
+ method,
70
+ f"{self._base}{path}",
71
+ json=json,
72
+ params=params,
73
+ headers={
74
+ "authorization": f"Bearer {self._secret_key}",
75
+ "accept": "application/json",
76
+ },
77
+ )
78
+ finally:
79
+ if owns_client:
80
+ client.close()
81
+ if res.status_code >= 400:
82
+ raise _error_from_response(res)
83
+ return res.json() if res.content else {}
84
+
85
+ # -- Billing ------------------------------------------------------------
86
+
87
+ def create_checkout(
88
+ self,
89
+ *,
90
+ plan: str,
91
+ customer: Union[CustomerInput, Dict[str, Any]],
92
+ success_url: str,
93
+ cancel_url: str,
94
+ price_id: Optional[str] = None,
95
+ trial_days: Optional[int] = None,
96
+ ) -> CheckoutResult:
97
+ body: Dict[str, Any] = {
98
+ "plan": plan,
99
+ "customer": customer.to_api() if isinstance(customer, CustomerInput) else customer,
100
+ "successUrl": success_url,
101
+ "cancelUrl": cancel_url,
102
+ }
103
+ if price_id is not None:
104
+ body["priceId"] = price_id
105
+ if trial_days is not None:
106
+ body["trialDays"] = trial_days
107
+ data = self._request("POST", "/api/v1/checkout", json=body)
108
+ return CheckoutResult(url=data["checkoutUrl"], session_id=data["sessionId"])
109
+
110
+ def create_portal_link(
111
+ self, *, customer: Union[CustomerInput, Dict[str, Any]], return_url: str
112
+ ) -> str:
113
+ cust = customer.to_api() if isinstance(customer, CustomerInput) else customer
114
+ data = self._request(
115
+ "POST", "/api/v1/portal", json={"customer": cust, "returnUrl": return_url}
116
+ )
117
+ return str(data["portalUrl"])
118
+
119
+ def get_entitlements(
120
+ self, external_user_id: str, *, by_email: bool = False
121
+ ) -> EntitlementSnapshot:
122
+ params = {"email": external_user_id} if by_email else {"externalUserId": external_user_id}
123
+ data = self._request("GET", "/api/v1/entitlements", params=params)
124
+ return EntitlementSnapshot.from_api(data)
125
+
126
+ # -- Auth ---------------------------------------------------------------
127
+
128
+ def verify_access_token(
129
+ self,
130
+ token: str,
131
+ *,
132
+ audience: Optional[Union[str, List[str]]] = None,
133
+ leeway: int = 60,
134
+ ) -> Dict[str, Any]:
135
+ """Verifies a JWT access token offline against Elav8's JWKS.
136
+
137
+ Note: a token revoked before ``exp`` (~10 min) stays valid until then;
138
+ re-check server state for sensitive operations.
139
+ """
140
+ return verify_access_token(
141
+ token, issuer=self._issuer, audience=audience, leeway=leeway
142
+ )
143
+
144
+ # -- Webhooks -----------------------------------------------------------
145
+
146
+ def _require_webhook_secret(self) -> str:
147
+ if not self._webhook_secret:
148
+ raise Elav8Error(
149
+ "webhook_secret is required to verify webhooks.", 0, "missing_webhook_secret"
150
+ )
151
+ return self._webhook_secret
152
+
153
+ def verify_webhook(self, payload: Union[str, bytes], signature: str) -> bool:
154
+ return verify_webhook_signature(payload, signature, self._require_webhook_secret())
155
+
156
+ def construct_webhook_event(
157
+ self, payload: Union[str, bytes], signature: str
158
+ ) -> Dict[str, Any]:
159
+ return construct_webhook_event(payload, signature, self._require_webhook_secret())
160
+
161
+
162
+ def _error_from_response(res: httpx.Response) -> Elav8Error:
163
+ try:
164
+ body = res.json()
165
+ except ValueError:
166
+ body = {}
167
+ message = (
168
+ body.get("error_description")
169
+ or body.get("title")
170
+ or body.get("detail")
171
+ or body.get("error")
172
+ or f"Request failed with status {res.status_code}"
173
+ )
174
+ code = body.get("code") or body.get("error") or "elav8_error"
175
+ return Elav8Error(message, res.status_code, code, body)
176
+
177
+
178
+ __all__ = ["Elav8ServerClient"]
@@ -0,0 +1,29 @@
1
+ """Structured error type, mirroring the JS SDK's ``Elav8Error``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+
8
+ class Elav8Error(Exception):
9
+ """Raised on any SDK failure (HTTP non-2xx, invalid token, bad signature).
10
+
11
+ Mirrors ``Elav8Error`` in ``@elav8/sdk`` so error handling looks the same
12
+ across languages.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ status: int = 0,
19
+ code: str = "elav8_error",
20
+ details: Optional[Any] = None,
21
+ ) -> None:
22
+ super().__init__(message)
23
+ self.message = message
24
+ self.status = status
25
+ self.code = code
26
+ self.details = details
27
+
28
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
29
+ return f"Elav8Error(message={self.message!r}, status={self.status}, code={self.code!r})"
@@ -0,0 +1,70 @@
1
+ """FastAPI integration — protect a route with one line.
2
+
3
+ Example::
4
+
5
+ from fastapi import Depends, FastAPI
6
+ from elav8.fastapi import require_user
7
+
8
+ app = FastAPI()
9
+ authed = require_user(issuer="https://billing.example.com/api/auth")
10
+
11
+ @app.get("/me")
12
+ def me(user: dict = Depends(authed)):
13
+ return {"sub": user["sub"]}
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Callable, Dict, List, Optional, Union
19
+
20
+ from .errors import Elav8Error
21
+ from .verify import verify_access_token
22
+
23
+ try:
24
+ from fastapi import Depends, HTTPException
25
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
26
+ except ImportError as exc: # pragma: no cover - import-time guard
27
+ raise ImportError(
28
+ "elav8.fastapi requires FastAPI. Install it with: pip install 'elav8[fastapi]'"
29
+ ) from exc
30
+
31
+
32
+ def require_user(
33
+ *,
34
+ issuer: str,
35
+ audience: Optional[Union[str, List[str]]] = None,
36
+ leeway: int = 60,
37
+ ) -> Callable[..., Dict[str, Any]]:
38
+ """Builds a FastAPI dependency that authenticates the request via a Bearer
39
+ JWT access token and returns its verified claims.
40
+
41
+ Verification is offline (against ``{issuer}/jwks``), so it adds no network
42
+ latency per request. Raises ``HTTPException(401)`` when the token is missing,
43
+ malformed, expired, or otherwise invalid.
44
+ """
45
+ bearer_scheme = HTTPBearer(auto_error=False)
46
+
47
+ def dependency(
48
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
49
+ ) -> Dict[str, Any]:
50
+ if credentials is None or not credentials.credentials:
51
+ raise HTTPException(
52
+ status_code=401,
53
+ detail="Missing bearer token.",
54
+ headers={"WWW-Authenticate": "Bearer"},
55
+ )
56
+ try:
57
+ return verify_access_token(
58
+ credentials.credentials, issuer=issuer, audience=audience, leeway=leeway
59
+ )
60
+ except Elav8Error as exc:
61
+ raise HTTPException(
62
+ status_code=401,
63
+ detail=exc.message,
64
+ headers={"WWW-Authenticate": "Bearer"},
65
+ ) from exc
66
+
67
+ return dependency
68
+
69
+
70
+ __all__ = ["require_user"]
@@ -0,0 +1,234 @@
1
+ """OAuth 2.1 + PKCE sign-in helpers — parity with ``packages/sdk/src/url.ts``
2
+ and the token-exchange flow in ``index.ts``.
3
+
4
+ Pure URL/form builders are kept separate from HTTP so the exact request shape is
5
+ testable, then wrapped by :class:`Elav8OAuth` which performs the exchange.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional
12
+ from urllib.parse import urlencode
13
+
14
+ import httpx
15
+
16
+ from .errors import Elav8Error
17
+ from .pkce import PkcePair, create_pkce_pair, generate_state
18
+
19
+ DEFAULT_SCOPES = ["openid", "profile", "email"]
20
+
21
+
22
+ def _trim_trailing_slash(value: str) -> str:
23
+ return value[:-1] if value.endswith("/") else value
24
+
25
+
26
+ def build_authorize_url(
27
+ *,
28
+ issuer: str,
29
+ client_id: str,
30
+ redirect_uri: str,
31
+ code_challenge: str,
32
+ state: str,
33
+ scopes: Optional[List[str]] = None,
34
+ prompt: Optional[str] = None,
35
+ ) -> str:
36
+ """Builds the ``/oauth2/authorize`` URL with PKCE (S256)."""
37
+ params: Dict[str, str] = {
38
+ "response_type": "code",
39
+ "client_id": client_id,
40
+ "redirect_uri": redirect_uri,
41
+ "scope": " ".join(scopes or DEFAULT_SCOPES),
42
+ "state": state,
43
+ "code_challenge": code_challenge,
44
+ "code_challenge_method": "S256",
45
+ }
46
+ if prompt:
47
+ params["prompt"] = prompt
48
+ return f"{_trim_trailing_slash(issuer)}/oauth2/authorize?{urlencode(params)}"
49
+
50
+
51
+ def build_token_exchange_form(
52
+ *,
53
+ client_id: str,
54
+ code: str,
55
+ redirect_uri: str,
56
+ code_verifier: str,
57
+ resource: Optional[str] = None,
58
+ ) -> Dict[str, str]:
59
+ """Builds the authorization-code grant body (public client; no secret).
60
+
61
+ When ``resource`` is set (to the issuer), the provider returns a signed JWT
62
+ access token verifiable offline via ``/jwks`` instead of an opaque token.
63
+ """
64
+ form = {
65
+ "grant_type": "authorization_code",
66
+ "client_id": client_id,
67
+ "code": code,
68
+ "redirect_uri": redirect_uri,
69
+ "code_verifier": code_verifier,
70
+ }
71
+ if resource:
72
+ form["resource"] = resource
73
+ return form
74
+
75
+
76
+ def build_refresh_form(
77
+ client_id: str, refresh_token: str, resource: Optional[str] = None
78
+ ) -> Dict[str, str]:
79
+ """Builds the refresh-token grant body (public client)."""
80
+ form = {
81
+ "grant_type": "refresh_token",
82
+ "client_id": client_id,
83
+ "refresh_token": refresh_token,
84
+ }
85
+ if resource:
86
+ form["resource"] = resource
87
+ return form
88
+
89
+
90
+ @dataclass
91
+ class TokenResponse:
92
+ access_token: str
93
+ token_type: str = "Bearer"
94
+ expires_in: Optional[int] = None
95
+ refresh_token: Optional[str] = None
96
+ id_token: Optional[str] = None
97
+ scope: Optional[str] = None
98
+ raw: Dict[str, Any] = field(default_factory=dict)
99
+
100
+ @classmethod
101
+ def from_api(cls, data: Dict[str, Any]) -> "TokenResponse":
102
+ return cls(
103
+ access_token=str(data["access_token"]),
104
+ token_type=data.get("token_type", "Bearer"),
105
+ expires_in=data.get("expires_in"),
106
+ refresh_token=data.get("refresh_token"),
107
+ id_token=data.get("id_token"),
108
+ scope=data.get("scope"),
109
+ raw=data,
110
+ )
111
+
112
+
113
+ @dataclass
114
+ class SignInRedirect:
115
+ """What you need to begin sign-in: the URL to redirect to, plus the PKCE
116
+ verifier and state to stash in the user's session for the callback."""
117
+
118
+ url: str
119
+ state: str
120
+ code_verifier: str
121
+
122
+
123
+ class Elav8OAuth:
124
+ """Server-side helper for "Sign in with Elav8" (OAuth 2.1 + PKCE).
125
+
126
+ Example (FastAPI)::
127
+
128
+ oauth = Elav8OAuth(
129
+ issuer="https://billing.example.com/api/auth",
130
+ client_id="client_...",
131
+ redirect_uri="https://app.example.com/callback",
132
+ )
133
+ # 1. Begin sign-in:
134
+ flow = oauth.start()
135
+ # persist flow.state + flow.code_verifier, then redirect to flow.url
136
+ # 2. On the callback:
137
+ tokens = oauth.exchange_code(code, code_verifier)
138
+ """
139
+
140
+ def __init__(
141
+ self,
142
+ *,
143
+ issuer: str,
144
+ client_id: str,
145
+ redirect_uri: str,
146
+ scopes: Optional[List[str]] = None,
147
+ request_jwt_access_token: bool = True,
148
+ http_client: Optional[httpx.Client] = None,
149
+ timeout: float = 10.0,
150
+ ) -> None:
151
+ self.issuer = _trim_trailing_slash(issuer)
152
+ self.client_id = client_id
153
+ self.redirect_uri = redirect_uri
154
+ self.scopes = scopes or DEFAULT_SCOPES
155
+ # Request the issuer as the audience so access tokens are JWTs.
156
+ self._resource = self.issuer if request_jwt_access_token else None
157
+ self._client = http_client
158
+ self._timeout = timeout
159
+
160
+ def _http(self) -> httpx.Client:
161
+ return self._client or httpx.Client(timeout=self._timeout)
162
+
163
+ def start(
164
+ self, *, state: Optional[str] = None, prompt: Optional[str] = None
165
+ ) -> SignInRedirect:
166
+ pair: PkcePair = create_pkce_pair()
167
+ st = state or generate_state()
168
+ url = build_authorize_url(
169
+ issuer=self.issuer,
170
+ client_id=self.client_id,
171
+ redirect_uri=self.redirect_uri,
172
+ code_challenge=pair.challenge,
173
+ state=st,
174
+ scopes=self.scopes,
175
+ prompt=prompt,
176
+ )
177
+ return SignInRedirect(url=url, state=st, code_verifier=pair.verifier)
178
+
179
+ def exchange_code(self, code: str, code_verifier: str) -> TokenResponse:
180
+ form = build_token_exchange_form(
181
+ client_id=self.client_id,
182
+ code=code,
183
+ redirect_uri=self.redirect_uri,
184
+ code_verifier=code_verifier,
185
+ resource=self._resource,
186
+ )
187
+ return self._post_token(form)
188
+
189
+ def refresh(self, refresh_token: str) -> TokenResponse:
190
+ form = build_refresh_form(self.client_id, refresh_token, self._resource)
191
+ return self._post_token(form)
192
+
193
+ def _post_token(self, form: Dict[str, str]) -> TokenResponse:
194
+ owns_client = self._client is None
195
+ client = self._http()
196
+ try:
197
+ res = client.post(
198
+ f"{self.issuer}/oauth2/token",
199
+ data=form,
200
+ headers={"accept": "application/json"},
201
+ )
202
+ finally:
203
+ if owns_client:
204
+ client.close()
205
+ if res.status_code >= 400:
206
+ raise _error_from_response(res)
207
+ return TokenResponse.from_api(res.json())
208
+
209
+
210
+ def _error_from_response(res: httpx.Response) -> Elav8Error:
211
+ try:
212
+ body = res.json()
213
+ except ValueError:
214
+ body = {}
215
+ message = (
216
+ body.get("error_description")
217
+ or body.get("title")
218
+ or body.get("detail")
219
+ or body.get("error")
220
+ or f"Request failed with status {res.status_code}"
221
+ )
222
+ code = body.get("code") or body.get("error") or "elav8_error"
223
+ return Elav8Error(message, res.status_code, code, body)
224
+
225
+
226
+ __all__ = [
227
+ "DEFAULT_SCOPES",
228
+ "build_authorize_url",
229
+ "build_token_exchange_form",
230
+ "build_refresh_form",
231
+ "TokenResponse",
232
+ "SignInRedirect",
233
+ "Elav8OAuth",
234
+ ]
@@ -0,0 +1,56 @@
1
+ """PKCE (RFC 7636) helpers — parity with ``packages/sdk/src/pkce.ts``.
2
+
3
+ The code challenge method is always ``S256`` (the insecure ``plain`` method is
4
+ never used), matching OAuth 2.1.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import os
12
+ from dataclasses import dataclass
13
+
14
+
15
+ def base64url_encode(data: bytes) -> str:
16
+ """Base64url-encodes bytes without padding (RFC 7636 Appendix A)."""
17
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
18
+
19
+
20
+ def generate_code_verifier(byte_length: int = 32) -> str:
21
+ """Generates a high-entropy code verifier (43-128 chars per the spec)."""
22
+ return base64url_encode(os.urandom(byte_length))
23
+
24
+
25
+ def create_code_challenge(verifier: str) -> str:
26
+ """Derives the S256 code challenge from a verifier."""
27
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
28
+ return base64url_encode(digest)
29
+
30
+
31
+ def generate_state() -> str:
32
+ """Generates an opaque random value for the OAuth ``state`` parameter."""
33
+ return generate_code_verifier(16)
34
+
35
+
36
+ @dataclass
37
+ class PkcePair:
38
+ verifier: str
39
+ challenge: str
40
+ method: str = "S256"
41
+
42
+
43
+ def create_pkce_pair() -> PkcePair:
44
+ """Creates a verifier + S256 challenge pair ready for the authorize request."""
45
+ verifier = generate_code_verifier()
46
+ return PkcePair(verifier=verifier, challenge=create_code_challenge(verifier))
47
+
48
+
49
+ __all__ = [
50
+ "base64url_encode",
51
+ "generate_code_verifier",
52
+ "create_code_challenge",
53
+ "generate_state",
54
+ "PkcePair",
55
+ "create_pkce_pair",
56
+ ]
File without changes
@@ -0,0 +1,95 @@
1
+ """Offline access-token verification — parity with ``packages/sdk/src/verify.ts``.
2
+
3
+ Elav8 issues EdDSA-signed JWT access tokens (when the token request includes the
4
+ issuer as its ``resource``/audience). This verifies the signature against the
5
+ published JWKS (``/jwks``) plus issuer, audience, and expiry, using PyJWT's
6
+ ``PyJWKClient`` for key fetching, caching, and rotation.
7
+
8
+ Trade-off: verification is offline, so a token revoked before its ``exp``
9
+ (~10 minutes) stays accepted until it expires. For sensitive operations,
10
+ re-check server state (e.g. entitlements) rather than trusting the token alone.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from functools import lru_cache
16
+ from typing import List, Optional, Union
17
+
18
+ import jwt
19
+ from jwt import PyJWKClient
20
+
21
+ from ._types import AccessTokenClaims
22
+ from .errors import Elav8Error
23
+
24
+ # JWKS cache lifetime; after this the client refetches keys (handles rotation).
25
+ _JWKS_LIFESPAN_SECONDS = 300
26
+
27
+
28
+ def _trim_trailing_slash(value: str) -> str:
29
+ return value[:-1] if value.endswith("/") else value
30
+
31
+
32
+ @lru_cache(maxsize=32)
33
+ def _shared_jwk_client(jwks_url: str) -> PyJWKClient:
34
+ return PyJWKClient(jwks_url, cache_keys=True, lifespan=_JWKS_LIFESPAN_SECONDS)
35
+
36
+
37
+ def verify_access_token(
38
+ token: str,
39
+ *,
40
+ issuer: str,
41
+ audience: Optional[Union[str, List[str]]] = None,
42
+ jwks_url: Optional[str] = None,
43
+ leeway: int = 60,
44
+ jwk_client: Optional[PyJWKClient] = None,
45
+ ) -> AccessTokenClaims:
46
+ """Verifies an EdDSA JWT access token and returns its claims.
47
+
48
+ Raises :class:`~elav8.errors.Elav8Error` (status 401) on any failure.
49
+
50
+ :param issuer: Expected issuer, e.g. ``https://billing.example.com/api/auth``.
51
+ Also used to derive the JWKS URL and the default expected audience.
52
+ :param audience: Expected audience(s); defaults to ``issuer``.
53
+ :param jwks_url: Override the JWKS URL (defaults to ``{issuer}/jwks``).
54
+ :param leeway: Allowable clock skew in seconds (default 60).
55
+ :param jwk_client: Reuse an existing ``PyJWKClient`` (keeps the cache warm).
56
+ """
57
+ issuer = _trim_trailing_slash(issuer)
58
+ client = jwk_client or _shared_jwk_client(jwks_url or f"{issuer}/jwks")
59
+
60
+ try:
61
+ signing_key = client.get_signing_key_from_jwt(token)
62
+ except jwt.PyJWTError as exc:
63
+ raise Elav8Error(
64
+ "No matching JWKS key for the token.", 401, "jwks_key_not_found", str(exc)
65
+ ) from exc
66
+ except Exception as exc: # network / fetch failures from urllib
67
+ raise Elav8Error("Failed to fetch JWKS.", 401, "jwks_fetch_failed", str(exc)) from exc
68
+
69
+ expected_audience: Union[str, List[str]] = audience if audience is not None else issuer
70
+
71
+ try:
72
+ claims = jwt.decode(
73
+ token,
74
+ signing_key.key,
75
+ algorithms=["EdDSA"],
76
+ issuer=issuer,
77
+ audience=expected_audience,
78
+ leeway=leeway,
79
+ options={"require": ["exp", "iss", "sub"]},
80
+ )
81
+ except jwt.ExpiredSignatureError as exc:
82
+ raise Elav8Error("Access token has expired.", 401, "token_expired", str(exc)) from exc
83
+ except jwt.InvalidAudienceError as exc:
84
+ raise Elav8Error("Token audience mismatch.", 401, "invalid_audience", str(exc)) from exc
85
+ except jwt.InvalidIssuerError as exc:
86
+ raise Elav8Error("Token issuer mismatch.", 401, "invalid_issuer", str(exc)) from exc
87
+ except jwt.InvalidSignatureError as exc:
88
+ raise Elav8Error("Invalid access token signature.", 401, "invalid_signature", str(exc)) from exc
89
+ except jwt.PyJWTError as exc:
90
+ raise Elav8Error("Invalid access token.", 401, "invalid_token", str(exc)) from exc
91
+
92
+ return claims
93
+
94
+
95
+ __all__ = ["verify_access_token"]
@@ -0,0 +1,107 @@
1
+ """Webhook signature verification — parity with ``packages/sdk/src/webhook.ts``.
2
+
3
+ Elav8 signs each forwarded webhook with your app's webhook secret and sends a
4
+ timestamped HMAC-SHA256 signature in the ``x-elav8-signature`` header:
5
+
6
+ x-elav8-signature: t=<unix_seconds>,v1=<hex_hmac>
7
+
8
+ The signed content is ``"{t}.{raw_body}"``. Including the timestamp (and
9
+ rejecting old ones) blocks replay attacks. Always verify before trusting an
10
+ event, and pass the **raw** request body (not a re-serialized object).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac
17
+ import json
18
+ import time
19
+ from typing import Any, Dict, Optional, Tuple, Union
20
+
21
+ from .errors import Elav8Error
22
+
23
+ DEFAULT_TOLERANCE_SECONDS = 300
24
+
25
+
26
+ def _to_bytes(value: Union[str, bytes]) -> bytes:
27
+ return value if isinstance(value, bytes) else value.encode("utf-8")
28
+
29
+
30
+ def _compute(timestamp: int, payload: Union[str, bytes], secret: str) -> str:
31
+ signed = b"%d." % timestamp + _to_bytes(payload)
32
+ return hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
33
+
34
+
35
+ def sign_payload(
36
+ payload: Union[str, bytes], secret: str, *, timestamp: Optional[int] = None
37
+ ) -> str:
38
+ """Produces the ``t=<ts>,v1=<hex>`` header value for ``payload``.
39
+
40
+ Primarily for tests / parity; senders are in the Elav8 backend.
41
+ """
42
+ ts = int(timestamp if timestamp is not None else time.time())
43
+ return f"t={ts},v1={_compute(ts, payload, secret)}"
44
+
45
+
46
+ def _parse_header(header: str) -> Tuple[Optional[int], Optional[str]]:
47
+ timestamp: Optional[int] = None
48
+ signature: Optional[str] = None
49
+ for part in header.split(","):
50
+ key, _, value = part.strip().partition("=")
51
+ if key == "t":
52
+ try:
53
+ timestamp = int(value)
54
+ except ValueError:
55
+ timestamp = None
56
+ elif key == "v1":
57
+ signature = value.strip().lower()
58
+ return timestamp, signature
59
+
60
+
61
+ def verify_webhook_signature(
62
+ payload: Union[str, bytes],
63
+ signature_header: str,
64
+ secret: str,
65
+ *,
66
+ tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
67
+ ) -> bool:
68
+ """Returns ``True`` when the signature is valid and within the time window."""
69
+ timestamp, signature = _parse_header(signature_header)
70
+ if timestamp is None or not signature:
71
+ return False
72
+ if tolerance_seconds > 0 and abs(int(time.time()) - timestamp) > tolerance_seconds:
73
+ return False
74
+ expected = _compute(timestamp, payload, secret)
75
+ return hmac.compare_digest(expected, signature)
76
+
77
+
78
+ def construct_webhook_event(
79
+ payload: Union[str, bytes],
80
+ signature_header: str,
81
+ secret: str,
82
+ *,
83
+ tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
84
+ ) -> Dict[str, Any]:
85
+ """Verifies the signature and returns the parsed event.
86
+
87
+ Raises :class:`~elav8.errors.Elav8Error` if the signature is invalid/expired
88
+ or the body is not JSON.
89
+ """
90
+ if not verify_webhook_signature(
91
+ payload, signature_header, secret, tolerance_seconds=tolerance_seconds
92
+ ):
93
+ raise Elav8Error("Invalid webhook signature.", 400, "invalid_signature")
94
+ try:
95
+ text = payload.decode("utf-8") if isinstance(payload, bytes) else payload
96
+ event: Dict[str, Any] = json.loads(text)
97
+ return event
98
+ except (ValueError, UnicodeDecodeError) as exc:
99
+ raise Elav8Error("Webhook payload is not valid JSON.", 400, "invalid_payload") from exc
100
+
101
+
102
+ __all__ = [
103
+ "DEFAULT_TOLERANCE_SECONDS",
104
+ "sign_payload",
105
+ "verify_webhook_signature",
106
+ "construct_webhook_event",
107
+ ]