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 +10 -0
- elav8-0.1.0/PKG-INFO +139 -0
- elav8-0.1.0/README.md +106 -0
- elav8-0.1.0/pyproject.toml +64 -0
- elav8-0.1.0/src/elav8/__init__.py +75 -0
- elav8-0.1.0/src/elav8/_types.py +99 -0
- elav8-0.1.0/src/elav8/client.py +178 -0
- elav8-0.1.0/src/elav8/errors.py +29 -0
- elav8-0.1.0/src/elav8/fastapi.py +70 -0
- elav8-0.1.0/src/elav8/oauth.py +234 -0
- elav8-0.1.0/src/elav8/pkce.py +56 -0
- elav8-0.1.0/src/elav8/py.typed +0 -0
- elav8-0.1.0/src/elav8/verify.py +95 -0
- elav8-0.1.0/src/elav8/webhook.py +107 -0
elav8-0.1.0/.gitignore
ADDED
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
|
+
]
|