cntm-nucleus 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.
- cntm_nucleus-0.1.0/.gitignore +61 -0
- cntm_nucleus-0.1.0/LICENSE +21 -0
- cntm_nucleus-0.1.0/PKG-INFO +62 -0
- cntm_nucleus-0.1.0/README.md +26 -0
- cntm_nucleus-0.1.0/pyproject.toml +40 -0
- cntm_nucleus-0.1.0/src/nucleus/__init__.py +14 -0
- cntm_nucleus-0.1.0/src/nucleus/claims.py +17 -0
- cntm_nucleus-0.1.0/src/nucleus/client.py +34 -0
- cntm_nucleus-0.1.0/src/nucleus/django.py +21 -0
- cntm_nucleus-0.1.0/src/nucleus/fastapi.py +27 -0
- cntm_nucleus-0.1.0/src/nucleus/flask.py +30 -0
- cntm_nucleus-0.1.0/src/nucleus/sync.py +19 -0
- cntm_nucleus-0.1.0/src/nucleus/verify.py +36 -0
- cntm_nucleus-0.1.0/tests/__init__.py +0 -0
- cntm_nucleus-0.1.0/tests/conftest.py +72 -0
- cntm_nucleus-0.1.0/tests/test_client.py +82 -0
- cntm_nucleus-0.1.0/tests/test_verify.py +101 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/target
|
|
2
|
+
dashboard/node_modules
|
|
3
|
+
node_modules/
|
|
4
|
+
|
|
5
|
+
# Secrets & credentials — NEVER commit these
|
|
6
|
+
.env
|
|
7
|
+
.env.*
|
|
8
|
+
!.env.example
|
|
9
|
+
*.pem
|
|
10
|
+
*.key
|
|
11
|
+
*.p12
|
|
12
|
+
*.pfx
|
|
13
|
+
*.jks
|
|
14
|
+
*.keystore
|
|
15
|
+
*.cert
|
|
16
|
+
*.crt
|
|
17
|
+
*.der
|
|
18
|
+
credentials.json
|
|
19
|
+
service-account.json
|
|
20
|
+
*.credential
|
|
21
|
+
|
|
22
|
+
# IDE
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
*.swp
|
|
26
|
+
*.swo
|
|
27
|
+
*~
|
|
28
|
+
.DS_Store
|
|
29
|
+
|
|
30
|
+
# Flutter
|
|
31
|
+
.dart_tool/
|
|
32
|
+
.flutter-plugins
|
|
33
|
+
.flutter-plugins-dependencies
|
|
34
|
+
.packages
|
|
35
|
+
build/
|
|
36
|
+
|
|
37
|
+
# .NET
|
|
38
|
+
**/obj/
|
|
39
|
+
**/bin/
|
|
40
|
+
|
|
41
|
+
# Python
|
|
42
|
+
__pycache__/
|
|
43
|
+
*.pyc
|
|
44
|
+
*.egg-info/
|
|
45
|
+
dist/
|
|
46
|
+
.venv/
|
|
47
|
+
venv/
|
|
48
|
+
|
|
49
|
+
# Go
|
|
50
|
+
vendor/
|
|
51
|
+
|
|
52
|
+
# Rust
|
|
53
|
+
*.profraw
|
|
54
|
+
lcov.info
|
|
55
|
+
tarpaulin-report.html
|
|
56
|
+
|
|
57
|
+
# Worktrees
|
|
58
|
+
.worktrees/
|
|
59
|
+
|
|
60
|
+
# OS
|
|
61
|
+
Thumbs.db
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Continuum
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cntm-nucleus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Nucleus authentication SDK for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cntm-labs/nucleus
|
|
6
|
+
Project-URL: Repository, https://github.com/cntm-labs/nucleus
|
|
7
|
+
Project-URL: Issues, https://github.com/cntm-labs/nucleus/issues
|
|
8
|
+
Author-email: cntm-labs <dev@cntm-labs.dev>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: auth,authentication,jwt,nucleus,oauth,sdk
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Framework :: Flask
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: cryptography>=43.0
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: pyjwt[crypto]>=2.9
|
|
26
|
+
Provides-Extra: django
|
|
27
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
28
|
+
Provides-Extra: fastapi
|
|
29
|
+
Requires-Dist: fastapi>=0.115; extra == 'fastapi'
|
|
30
|
+
Provides-Extra: flask
|
|
31
|
+
Requires-Dist: flask>=3.0; extra == 'flask'
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
34
|
+
Requires-Dist: respx>=0.22; extra == 'test'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# cntm-labs-nucleus
|
|
38
|
+
|
|
39
|
+
> **Warning: DEV PREVIEW** — This package is under active development
|
|
40
|
+
> and is NOT ready for production use. APIs may change without notice.
|
|
41
|
+
> For updates, watch the [Nucleus repo](https://github.com/cntm-labs/nucleus).
|
|
42
|
+
|
|
43
|
+
Nucleus authentication SDK for Python.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install cntm-labs-nucleus==0.1.0.dev1
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from nucleus import NucleusClient
|
|
55
|
+
|
|
56
|
+
client = NucleusClient(secret_key="sk_...")
|
|
57
|
+
session = client.verify_session(token)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# cntm-labs-nucleus
|
|
2
|
+
|
|
3
|
+
> **Warning: DEV PREVIEW** — This package is under active development
|
|
4
|
+
> and is NOT ready for production use. APIs may change without notice.
|
|
5
|
+
> For updates, watch the [Nucleus repo](https://github.com/cntm-labs/nucleus).
|
|
6
|
+
|
|
7
|
+
Nucleus authentication SDK for Python.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install cntm-labs-nucleus==0.1.0.dev1
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from nucleus import NucleusClient
|
|
19
|
+
|
|
20
|
+
client = NucleusClient(secret_key="sk_...")
|
|
21
|
+
session = client.verify_session(token)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cntm-nucleus"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Nucleus authentication SDK for Python."
|
|
9
|
+
license = {text = "MIT"}
|
|
10
|
+
authors = [{name = "cntm-labs", email = "dev@cntm-labs.dev"}]
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
keywords = ["authentication", "auth", "nucleus", "sdk", "jwt", "oauth"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Framework :: FastAPI",
|
|
22
|
+
"Framework :: Django",
|
|
23
|
+
"Framework :: Flask",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["httpx>=0.27", "PyJWT[crypto]>=2.9", "cryptography>=43.0"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/cntm-labs/nucleus"
|
|
30
|
+
Repository = "https://github.com/cntm-labs/nucleus"
|
|
31
|
+
Issues = "https://github.com/cntm-labs/nucleus/issues"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/nucleus"]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
fastapi = ["fastapi>=0.115"]
|
|
38
|
+
django = ["django>=4.2"]
|
|
39
|
+
flask = ["flask>=3.0"]
|
|
40
|
+
test = ["pytest>=8.0", "respx>=0.22"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0.dev1"
|
|
4
|
+
if "dev" in __version__:
|
|
5
|
+
warnings.warn(
|
|
6
|
+
f"[Nucleus] You are using a dev preview ({__version__}). Do not use in production.",
|
|
7
|
+
stacklevel=2,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from .client import NucleusClient
|
|
11
|
+
from .verify import verify_token
|
|
12
|
+
from .claims import NucleusClaims
|
|
13
|
+
|
|
14
|
+
__all__ = ["NucleusClient", "verify_token", "NucleusClaims"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class NucleusClaims:
|
|
6
|
+
user_id: str
|
|
7
|
+
project_id: str
|
|
8
|
+
email: str | None = None
|
|
9
|
+
first_name: str | None = None
|
|
10
|
+
last_name: str | None = None
|
|
11
|
+
avatar_url: str | None = None
|
|
12
|
+
email_verified: bool | None = None
|
|
13
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
14
|
+
org_id: str | None = None
|
|
15
|
+
org_slug: str | None = None
|
|
16
|
+
org_role: str | None = None
|
|
17
|
+
permissions: list[str] = field(default_factory=list)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from .claims import NucleusClaims
|
|
3
|
+
from .verify import verify_token
|
|
4
|
+
|
|
5
|
+
class NucleusClient:
|
|
6
|
+
def __init__(self, secret_key: str, base_url: str = "https://api.nucleus.dev"):
|
|
7
|
+
self.secret_key = secret_key
|
|
8
|
+
self.base_url = base_url
|
|
9
|
+
self._client = httpx.AsyncClient(base_url=f"{base_url}/api/v1/admin",
|
|
10
|
+
headers={"Authorization": f"Bearer {secret_key}", "Content-Type": "application/json"})
|
|
11
|
+
self.users = UsersApi(self._client)
|
|
12
|
+
self.orgs = OrgsApi(self._client)
|
|
13
|
+
|
|
14
|
+
def verify_token(self, token: str, audience: str | None = None) -> NucleusClaims:
|
|
15
|
+
return verify_token(token, self.base_url, audience=audience)
|
|
16
|
+
|
|
17
|
+
async def close(self):
|
|
18
|
+
await self._client.aclose()
|
|
19
|
+
|
|
20
|
+
class UsersApi:
|
|
21
|
+
def __init__(self, client: httpx.AsyncClient): self._client = client
|
|
22
|
+
async def get(self, user_id: str): return (await self._client.get(f"/users/{user_id}")).json()
|
|
23
|
+
async def list(self, limit: int = 20, cursor: str | None = None, email_contains: str | None = None):
|
|
24
|
+
params = {"limit": limit}
|
|
25
|
+
if cursor: params["cursor"] = cursor
|
|
26
|
+
if email_contains: params["email_contains"] = email_contains
|
|
27
|
+
return (await self._client.get("/users", params=params)).json()
|
|
28
|
+
async def ban(self, user_id: str): await self._client.post(f"/users/{user_id}/ban")
|
|
29
|
+
async def unban(self, user_id: str): await self._client.post(f"/users/{user_id}/unban")
|
|
30
|
+
|
|
31
|
+
class OrgsApi:
|
|
32
|
+
def __init__(self, client: httpx.AsyncClient): self._client = client
|
|
33
|
+
async def get(self, org_id: str): return (await self._client.get(f"/orgs/{org_id}")).json()
|
|
34
|
+
async def list(self, limit: int = 20): return (await self._client.get("/orgs", params={"limit": limit})).json()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from .verify import verify_token
|
|
3
|
+
|
|
4
|
+
class NucleusMiddleware:
|
|
5
|
+
def __init__(self, get_response):
|
|
6
|
+
self.get_response = get_response
|
|
7
|
+
self.base_url = getattr(settings, 'NUCLEUS_BASE_URL', 'https://api.nucleus.dev')
|
|
8
|
+
|
|
9
|
+
def __call__(self, request):
|
|
10
|
+
auth = request.META.get('HTTP_AUTHORIZATION', '')
|
|
11
|
+
if auth.startswith('Bearer '):
|
|
12
|
+
try:
|
|
13
|
+
request.nucleus_claims = verify_token(auth[7:], self.base_url)
|
|
14
|
+
except Exception:
|
|
15
|
+
request.nucleus_claims = None
|
|
16
|
+
else:
|
|
17
|
+
request.nucleus_claims = None
|
|
18
|
+
return self.get_response(request)
|
|
19
|
+
|
|
20
|
+
def nucleus_claims(request):
|
|
21
|
+
return getattr(request, 'nucleus_claims', None)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException, Request
|
|
2
|
+
from .claims import NucleusClaims
|
|
3
|
+
from .verify import verify_token
|
|
4
|
+
|
|
5
|
+
class NucleusAuth:
|
|
6
|
+
def __init__(self, secret_key: str | None = None, base_url: str = "https://api.nucleus.dev"):
|
|
7
|
+
self.base_url = base_url
|
|
8
|
+
|
|
9
|
+
async def __call__(self, request: Request) -> NucleusClaims:
|
|
10
|
+
auth = request.headers.get("authorization", "")
|
|
11
|
+
if not auth.startswith("Bearer "):
|
|
12
|
+
raise HTTPException(401, detail="Missing authorization header")
|
|
13
|
+
try:
|
|
14
|
+
return verify_token(auth[7:], self.base_url)
|
|
15
|
+
except Exception:
|
|
16
|
+
raise HTTPException(401, detail="Invalid or expired token")
|
|
17
|
+
|
|
18
|
+
def require_permission(permission: str):
|
|
19
|
+
def decorator(func):
|
|
20
|
+
from functools import wraps
|
|
21
|
+
@wraps(func)
|
|
22
|
+
async def wrapper(*args, claims: NucleusClaims = Depends(), **kwargs):
|
|
23
|
+
if permission not in (claims.permissions or []) and claims.org_role != "owner":
|
|
24
|
+
raise HTTPException(403, detail="Insufficient permissions")
|
|
25
|
+
return await func(*args, claims=claims, **kwargs)
|
|
26
|
+
return wrapper
|
|
27
|
+
return decorator
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from flask import request, g, jsonify
|
|
3
|
+
from .verify import verify_token
|
|
4
|
+
from .claims import NucleusClaims
|
|
5
|
+
|
|
6
|
+
class NucleusAuth:
|
|
7
|
+
def __init__(self, app=None, secret_key=None, base_url="https://api.nucleus.dev"):
|
|
8
|
+
self.base_url = base_url
|
|
9
|
+
if app: self.init_app(app)
|
|
10
|
+
|
|
11
|
+
def init_app(self, app):
|
|
12
|
+
app.before_request(self._before_request)
|
|
13
|
+
|
|
14
|
+
def _before_request(self):
|
|
15
|
+
auth = request.headers.get('Authorization', '')
|
|
16
|
+
if auth.startswith('Bearer '):
|
|
17
|
+
try: g.nucleus_claims = verify_token(auth[7:], self.base_url)
|
|
18
|
+
except: g.nucleus_claims = None
|
|
19
|
+
else: g.nucleus_claims = None
|
|
20
|
+
|
|
21
|
+
def required(self, f):
|
|
22
|
+
@wraps(f)
|
|
23
|
+
def decorated(*args, **kwargs):
|
|
24
|
+
if not getattr(g, 'nucleus_claims', None):
|
|
25
|
+
return jsonify({"error": "Unauthorized"}), 401
|
|
26
|
+
return f(*args, **kwargs)
|
|
27
|
+
return decorated
|
|
28
|
+
|
|
29
|
+
def current_claims() -> NucleusClaims | None:
|
|
30
|
+
return getattr(g, 'nucleus_claims', None)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from .claims import NucleusClaims
|
|
3
|
+
from .verify import verify_token
|
|
4
|
+
|
|
5
|
+
class SyncNucleusClient:
|
|
6
|
+
def __init__(self, secret_key: str, base_url: str = "https://api.nucleus.dev"):
|
|
7
|
+
self.secret_key = secret_key
|
|
8
|
+
self.base_url = base_url
|
|
9
|
+
self._client = httpx.Client(base_url=f"{base_url}/api/v1/admin",
|
|
10
|
+
headers={"Authorization": f"Bearer {secret_key}", "Content-Type": "application/json"})
|
|
11
|
+
self.users = SyncUsersApi(self._client)
|
|
12
|
+
|
|
13
|
+
def verify_token(self, token: str) -> NucleusClaims:
|
|
14
|
+
return verify_token(token, self.base_url)
|
|
15
|
+
|
|
16
|
+
class SyncUsersApi:
|
|
17
|
+
def __init__(self, client: httpx.Client): self._client = client
|
|
18
|
+
def get(self, user_id: str): return self._client.get(f"/users/{user_id}").json()
|
|
19
|
+
def list(self, limit: int = 20): return self._client.get("/users", params={"limit": limit}).json()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import jwt
|
|
2
|
+
import httpx
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from .claims import NucleusClaims
|
|
5
|
+
|
|
6
|
+
@lru_cache(maxsize=1)
|
|
7
|
+
def _get_jwks(base_url: str) -> dict:
|
|
8
|
+
res = httpx.get(f"{base_url}/.well-known/jwks.json")
|
|
9
|
+
res.raise_for_status()
|
|
10
|
+
return res.json()
|
|
11
|
+
|
|
12
|
+
def verify_token(
|
|
13
|
+
token: str,
|
|
14
|
+
base_url: str = "https://api.nucleus.dev",
|
|
15
|
+
audience: str | None = None,
|
|
16
|
+
) -> NucleusClaims:
|
|
17
|
+
jwks = _get_jwks(base_url)
|
|
18
|
+
header = jwt.get_unverified_header(token)
|
|
19
|
+
key = next((k for k in jwks["keys"] if k["kid"] == header.get("kid")), None)
|
|
20
|
+
if not key:
|
|
21
|
+
raise ValueError("No matching key found in JWKS")
|
|
22
|
+
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
|
|
23
|
+
decode_opts: dict = {}
|
|
24
|
+
decode_kwargs: dict = {"algorithms": ["RS256"]}
|
|
25
|
+
if audience:
|
|
26
|
+
decode_kwargs["audience"] = audience
|
|
27
|
+
else:
|
|
28
|
+
decode_opts["verify_aud"] = False
|
|
29
|
+
payload = jwt.decode(token, public_key, options=decode_opts, **decode_kwargs)
|
|
30
|
+
return NucleusClaims(
|
|
31
|
+
user_id=payload["sub"], project_id=payload.get("aud", ""),
|
|
32
|
+
email=payload.get("email"), first_name=payload.get("first_name"),
|
|
33
|
+
last_name=payload.get("last_name"), avatar_url=payload.get("avatar_url"),
|
|
34
|
+
email_verified=payload.get("email_verified"), metadata=payload.get("metadata", {}),
|
|
35
|
+
org_id=payload.get("org_id"), org_slug=payload.get("org_slug"),
|
|
36
|
+
org_role=payload.get("org_role"), permissions=payload.get("org_permissions", []))
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Shared test fixtures for Nucleus Python SDK tests."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import pytest
|
|
5
|
+
from datetime import datetime, timezone, timedelta
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
7
|
+
from cryptography.hazmat.primitives import serialization
|
|
8
|
+
import jwt as pyjwt
|
|
9
|
+
|
|
10
|
+
# Generate a test RSA key pair (reused across all tests in the session)
|
|
11
|
+
_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
12
|
+
_public_key = _private_key.public_key()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def private_key():
|
|
17
|
+
return _private_key
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def public_key():
|
|
22
|
+
return _public_key
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def jwks_response():
|
|
27
|
+
"""Build a JWKS JSON response from the test public key."""
|
|
28
|
+
pub_numbers = _public_key.public_numbers()
|
|
29
|
+
# Build JWK from public key components
|
|
30
|
+
import base64
|
|
31
|
+
|
|
32
|
+
def _int_to_base64url(n: int, length: int) -> str:
|
|
33
|
+
return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
|
|
34
|
+
|
|
35
|
+
n = _int_to_base64url(pub_numbers.n, 256) # 2048 bits = 256 bytes
|
|
36
|
+
e = _int_to_base64url(pub_numbers.e, 3)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"keys": [
|
|
40
|
+
{
|
|
41
|
+
"kty": "RSA",
|
|
42
|
+
"kid": "test-key-1",
|
|
43
|
+
"alg": "RS256",
|
|
44
|
+
"use": "sig",
|
|
45
|
+
"n": n,
|
|
46
|
+
"e": e,
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_token(claims: dict, kid: str = "test-key-1") -> str:
|
|
53
|
+
"""Sign a JWT with the test private key."""
|
|
54
|
+
return pyjwt.encode(claims, _private_key, algorithm="RS256", headers={"kid": kid})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def valid_claims(**overrides) -> dict:
|
|
58
|
+
"""Build a valid set of JWT claims."""
|
|
59
|
+
now = datetime.now(timezone.utc)
|
|
60
|
+
claims = {
|
|
61
|
+
"sub": "user_123",
|
|
62
|
+
"iss": "https://api.test.com",
|
|
63
|
+
"aud": "project_456",
|
|
64
|
+
"exp": int((now + timedelta(hours=1)).timestamp()),
|
|
65
|
+
"iat": int(now.timestamp()),
|
|
66
|
+
"jti": "jwt_abc",
|
|
67
|
+
"email": "test@example.com",
|
|
68
|
+
"first_name": "Test",
|
|
69
|
+
"last_name": "User",
|
|
70
|
+
}
|
|
71
|
+
claims.update(overrides)
|
|
72
|
+
return claims
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Tests for nucleus.client — NucleusClient initialization and delegation."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import patch, AsyncMock
|
|
5
|
+
from nucleus.client import NucleusClient
|
|
6
|
+
from nucleus.claims import NucleusClaims
|
|
7
|
+
from .conftest import make_token, valid_claims
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestNucleusClientInit:
|
|
11
|
+
def test_default_base_url(self):
|
|
12
|
+
client = NucleusClient(secret_key="sk_test")
|
|
13
|
+
assert client.base_url == "https://api.nucleus.dev"
|
|
14
|
+
|
|
15
|
+
def test_custom_base_url(self):
|
|
16
|
+
client = NucleusClient(secret_key="sk_test", base_url="https://custom.api.dev")
|
|
17
|
+
assert client.base_url == "https://custom.api.dev"
|
|
18
|
+
|
|
19
|
+
def test_has_users_api(self):
|
|
20
|
+
client = NucleusClient(secret_key="sk_test")
|
|
21
|
+
assert client.users is not None
|
|
22
|
+
|
|
23
|
+
def test_has_orgs_api(self):
|
|
24
|
+
client = NucleusClient(secret_key="sk_test")
|
|
25
|
+
assert client.orgs is not None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestNucleusClientVerifyToken:
|
|
29
|
+
def test_delegates_to_verify_token(self, jwks_response):
|
|
30
|
+
from nucleus.verify import _get_jwks
|
|
31
|
+
_get_jwks.cache_clear()
|
|
32
|
+
client = NucleusClient(secret_key="sk_test", base_url="https://test.local")
|
|
33
|
+
token = make_token(valid_claims())
|
|
34
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
35
|
+
claims = client.verify_token(token)
|
|
36
|
+
|
|
37
|
+
assert isinstance(claims, NucleusClaims)
|
|
38
|
+
assert claims.user_id == "user_123"
|
|
39
|
+
|
|
40
|
+
def test_forwards_audience_parameter(self, jwks_response):
|
|
41
|
+
from nucleus.verify import _get_jwks
|
|
42
|
+
_get_jwks.cache_clear()
|
|
43
|
+
client = NucleusClient(secret_key="sk_test", base_url="https://test.local")
|
|
44
|
+
token = make_token(valid_claims(aud="project_456"))
|
|
45
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
46
|
+
claims = client.verify_token(token, audience="project_456")
|
|
47
|
+
|
|
48
|
+
assert claims.project_id == "project_456"
|
|
49
|
+
|
|
50
|
+
def test_wrong_audience_via_client_raises(self, jwks_response):
|
|
51
|
+
import jwt as pyjwt
|
|
52
|
+
from nucleus.verify import _get_jwks
|
|
53
|
+
_get_jwks.cache_clear()
|
|
54
|
+
client = NucleusClient(secret_key="sk_test", base_url="https://test.local")
|
|
55
|
+
token = make_token(valid_claims(aud="project_456"))
|
|
56
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
57
|
+
with pytest.raises(pyjwt.InvalidAudienceError):
|
|
58
|
+
client.verify_token(token, audience="wrong_project")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestNucleusClaims:
|
|
62
|
+
def test_default_values(self):
|
|
63
|
+
claims = NucleusClaims(user_id="u1", project_id="p1")
|
|
64
|
+
assert claims.user_id == "u1"
|
|
65
|
+
assert claims.project_id == "p1"
|
|
66
|
+
assert claims.email is None
|
|
67
|
+
assert claims.metadata == {}
|
|
68
|
+
assert claims.permissions == []
|
|
69
|
+
|
|
70
|
+
def test_all_fields(self):
|
|
71
|
+
claims = NucleusClaims(
|
|
72
|
+
user_id="u1", project_id="p1",
|
|
73
|
+
email="test@example.com", first_name="Test", last_name="User",
|
|
74
|
+
avatar_url="https://img.test/a.png", email_verified=True,
|
|
75
|
+
metadata={"role": "admin"}, org_id="org_1", org_slug="my-org",
|
|
76
|
+
org_role="admin", permissions=["read", "write"],
|
|
77
|
+
)
|
|
78
|
+
assert claims.email == "test@example.com"
|
|
79
|
+
assert claims.email_verified is True
|
|
80
|
+
assert claims.metadata == {"role": "admin"}
|
|
81
|
+
assert claims.org_role == "admin"
|
|
82
|
+
assert claims.permissions == ["read", "write"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Tests for nucleus.verify — token verification with JWKS."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
from datetime import datetime, timezone, timedelta
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
7
|
+
|
|
8
|
+
import jwt as pyjwt
|
|
9
|
+
|
|
10
|
+
from nucleus.verify import verify_token, _get_jwks
|
|
11
|
+
from nucleus.claims import NucleusClaims
|
|
12
|
+
from .conftest import make_token, valid_claims
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestVerifyTokenSuccess:
|
|
16
|
+
def test_returns_nucleus_claims(self, jwks_response):
|
|
17
|
+
token = make_token(valid_claims())
|
|
18
|
+
_get_jwks.cache_clear()
|
|
19
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
20
|
+
claims = verify_token(token, base_url="https://test.local")
|
|
21
|
+
|
|
22
|
+
assert isinstance(claims, NucleusClaims)
|
|
23
|
+
assert claims.user_id == "user_123"
|
|
24
|
+
assert claims.project_id == "project_456"
|
|
25
|
+
assert claims.email == "test@example.com"
|
|
26
|
+
assert claims.first_name == "Test"
|
|
27
|
+
assert claims.last_name == "User"
|
|
28
|
+
|
|
29
|
+
def test_maps_org_claims(self, jwks_response):
|
|
30
|
+
token = make_token(valid_claims(
|
|
31
|
+
org_id="org_1",
|
|
32
|
+
org_slug="my-org",
|
|
33
|
+
org_role="admin",
|
|
34
|
+
org_permissions=["read", "write"],
|
|
35
|
+
))
|
|
36
|
+
_get_jwks.cache_clear()
|
|
37
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
38
|
+
claims = verify_token(token, base_url="https://test.local")
|
|
39
|
+
|
|
40
|
+
assert claims.org_id == "org_1"
|
|
41
|
+
assert claims.org_slug == "my-org"
|
|
42
|
+
assert claims.org_role == "admin"
|
|
43
|
+
assert claims.permissions == ["read", "write"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestVerifyTokenFailures:
|
|
47
|
+
def test_expired_token_raises(self, jwks_response):
|
|
48
|
+
expired = valid_claims(
|
|
49
|
+
exp=int((datetime.now(timezone.utc) - timedelta(hours=1)).timestamp())
|
|
50
|
+
)
|
|
51
|
+
token = make_token(expired)
|
|
52
|
+
_get_jwks.cache_clear()
|
|
53
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
54
|
+
with pytest.raises(pyjwt.ExpiredSignatureError):
|
|
55
|
+
verify_token(token, base_url="https://test.local")
|
|
56
|
+
|
|
57
|
+
def test_wrong_key_raises(self, jwks_response):
|
|
58
|
+
wrong_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
59
|
+
token = pyjwt.encode(
|
|
60
|
+
valid_claims(), wrong_key, algorithm="RS256", headers={"kid": "test-key-1"}
|
|
61
|
+
)
|
|
62
|
+
_get_jwks.cache_clear()
|
|
63
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
64
|
+
with pytest.raises(pyjwt.InvalidSignatureError):
|
|
65
|
+
verify_token(token, base_url="https://test.local")
|
|
66
|
+
|
|
67
|
+
def test_missing_kid_raises(self, jwks_response):
|
|
68
|
+
token = make_token(valid_claims(), kid="nonexistent-kid")
|
|
69
|
+
_get_jwks.cache_clear()
|
|
70
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
71
|
+
with pytest.raises(ValueError, match="No matching key"):
|
|
72
|
+
verify_token(token, base_url="https://test.local")
|
|
73
|
+
|
|
74
|
+
def test_invalid_token_string_raises(self, jwks_response):
|
|
75
|
+
_get_jwks.cache_clear()
|
|
76
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
77
|
+
with pytest.raises(Exception):
|
|
78
|
+
verify_token("not.a.valid.token", base_url="https://test.local")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestAudienceValidation:
|
|
82
|
+
def test_valid_audience_passes(self, jwks_response):
|
|
83
|
+
token = make_token(valid_claims(aud="project_456"))
|
|
84
|
+
_get_jwks.cache_clear()
|
|
85
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
86
|
+
claims = verify_token(token, base_url="https://test.local", audience="project_456")
|
|
87
|
+
assert claims.project_id == "project_456"
|
|
88
|
+
|
|
89
|
+
def test_wrong_audience_raises(self, jwks_response):
|
|
90
|
+
token = make_token(valid_claims(aud="project_456"))
|
|
91
|
+
_get_jwks.cache_clear()
|
|
92
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
93
|
+
with pytest.raises(pyjwt.InvalidAudienceError):
|
|
94
|
+
verify_token(token, base_url="https://test.local", audience="wrong_project")
|
|
95
|
+
|
|
96
|
+
def test_no_audience_skips_validation(self, jwks_response):
|
|
97
|
+
token = make_token(valid_claims(aud="project_456"))
|
|
98
|
+
_get_jwks.cache_clear()
|
|
99
|
+
with patch("nucleus.verify._get_jwks", return_value=jwks_response):
|
|
100
|
+
claims = verify_token(token, base_url="https://test.local")
|
|
101
|
+
assert claims.project_id == "project_456"
|