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.
@@ -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"