neuronos-capabilities-lib 0.0.2__py3-none-any.whl
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.
- neuronos_capabilities/AuthManager/__init__.py +42 -0
- neuronos_capabilities/AuthManager/adapters/__init__.py +3 -0
- neuronos_capabilities/AuthManager/adapters/clerk_adapter.py +87 -0
- neuronos_capabilities/AuthManager/auth_manager.py +237 -0
- neuronos_capabilities/AuthManager/config/__init__.py +3 -0
- neuronos_capabilities/AuthManager/config/auth_config.py +106 -0
- neuronos_capabilities/AuthManager/examples/usage.py +56 -0
- neuronos_capabilities/AuthManager/exceptions/__init__.py +13 -0
- neuronos_capabilities/AuthManager/exceptions/auth_exceptions.py +23 -0
- neuronos_capabilities/AuthManager/ports/__init__.py +3 -0
- neuronos_capabilities/AuthManager/ports/auth_port.py +92 -0
- neuronos_capabilities/AuthManager/tests/__init__.py +1 -0
- neuronos_capabilities/AuthManager/tests/test_clock_skew.py +178 -0
- neuronos_capabilities/NotificationsManager/README.md +75 -0
- neuronos_capabilities/NotificationsManager/__init__.py +37 -0
- neuronos_capabilities/NotificationsManager/adapters/__init__.py +7 -0
- neuronos_capabilities/NotificationsManager/adapters/aws.py +146 -0
- neuronos_capabilities/NotificationsManager/adapters/console.py +30 -0
- neuronos_capabilities/NotificationsManager/adapters/memory.py +143 -0
- neuronos_capabilities/NotificationsManager/adapters/postgres.py +214 -0
- neuronos_capabilities/NotificationsManager/adapters/templates.py +73 -0
- neuronos_capabilities/NotificationsManager/artifact.yaml +64 -0
- neuronos_capabilities/NotificationsManager/config/__init__.py +3 -0
- neuronos_capabilities/NotificationsManager/config/notifications_config.py +123 -0
- neuronos_capabilities/NotificationsManager/entrypoints/__init__.py +1 -0
- neuronos_capabilities/NotificationsManager/entrypoints/events.py +36 -0
- neuronos_capabilities/NotificationsManager/entrypoints/sender.py +22 -0
- neuronos_capabilities/NotificationsManager/exceptions/__init__.py +19 -0
- neuronos_capabilities/NotificationsManager/exceptions/notifications_exceptions.py +41 -0
- neuronos_capabilities/NotificationsManager/models.py +88 -0
- neuronos_capabilities/NotificationsManager/notifications_manager.py +605 -0
- neuronos_capabilities/NotificationsManager/ports/__init__.py +110 -0
- neuronos_capabilities/NotificationsManager/tests/__init__.py +0 -0
- neuronos_capabilities/NotificationsManager/tests/test_aws_adapters.py +93 -0
- neuronos_capabilities/NotificationsManager/tests/test_notifications_manager.py +198 -0
- neuronos_capabilities/S3Manager/__init__.py +55 -0
- neuronos_capabilities/S3Manager/adapters/__init__.py +3 -0
- neuronos_capabilities/S3Manager/adapters/aws_adapter.py +400 -0
- neuronos_capabilities/S3Manager/artifact.yaml +52 -0
- neuronos_capabilities/S3Manager/config/__init__.py +3 -0
- neuronos_capabilities/S3Manager/config/s3_config.py +91 -0
- neuronos_capabilities/S3Manager/examples/usage.py +86 -0
- neuronos_capabilities/S3Manager/exceptions/__init__.py +21 -0
- neuronos_capabilities/S3Manager/exceptions/s3_exceptions.py +43 -0
- neuronos_capabilities/S3Manager/ports/__init__.py +3 -0
- neuronos_capabilities/S3Manager/ports/s3_port.py +499 -0
- neuronos_capabilities/S3Manager/s3_manager.py +332 -0
- neuronos_capabilities/S3Manager/tests/__init__.py +1 -0
- neuronos_capabilities/SQLManager/__init__.py +53 -0
- neuronos_capabilities/SQLManager/adapters/__init__.py +0 -0
- neuronos_capabilities/SQLManager/adapters/postgres_adapter.py +893 -0
- neuronos_capabilities/SQLManager/artifact.yaml +52 -0
- neuronos_capabilities/SQLManager/config/__init__.py +0 -0
- neuronos_capabilities/SQLManager/config/sql_config.py +175 -0
- neuronos_capabilities/SQLManager/examples/testing_library_execution.py +167 -0
- neuronos_capabilities/SQLManager/exceptions/__init__.py +0 -0
- neuronos_capabilities/SQLManager/exceptions/sql_exceptions.py +117 -0
- neuronos_capabilities/SQLManager/migrations/__init__.py +0 -0
- neuronos_capabilities/SQLManager/migrations/alembic_manager.py +617 -0
- neuronos_capabilities/SQLManager/models/__init__.py +9 -0
- neuronos_capabilities/SQLManager/models/base.py +118 -0
- neuronos_capabilities/SQLManager/ports/__init__.py +0 -0
- neuronos_capabilities/SQLManager/ports/sql_port.py +940 -0
- neuronos_capabilities/SQLManager/sql_manager.py +495 -0
- neuronos_capabilities/SQLManager/tests/__init__.py +0 -0
- neuronos_capabilities/SQLManager/utils/__init__.py +0 -0
- neuronos_capabilities/SQLManager/utils/query_builder.py +238 -0
- neuronos_capabilities/SQLManager/utils/singleton.py +120 -0
- neuronos_capabilities/__init__.py +58 -0
- neuronos_capabilities/__version__.py +2 -0
- neuronos_capabilities_lib-0.0.2.dist-info/LICENSE +21 -0
- neuronos_capabilities_lib-0.0.2.dist-info/METADATA +152 -0
- neuronos_capabilities_lib-0.0.2.dist-info/RECORD +75 -0
- neuronos_capabilities_lib-0.0.2.dist-info/WHEEL +5 -0
- neuronos_capabilities_lib-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AuthManager - Authentication & Authorization Capability for NeuronOS.
|
|
3
|
+
|
|
4
|
+
A provider-agnostic auth capability following hexagonal architecture principles.
|
|
5
|
+
Provides FastAPI decorators for JWT-based authentication and role-based access control.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from neuronos_capabilities import AuthManager
|
|
9
|
+
|
|
10
|
+
auth = AuthManager({
|
|
11
|
+
"provider": "clerk",
|
|
12
|
+
"jwks_url": "https://your-app.clerk.accounts.dev/.well-known/jwks.json",
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
@app.get("/api/protected")
|
|
16
|
+
def protected(user: dict = Depends(auth.require_auth())):
|
|
17
|
+
return {"user_id": user["sub"]}
|
|
18
|
+
|
|
19
|
+
@app.get("/api/admin")
|
|
20
|
+
def admin(user: dict = Depends(auth.require_roles(["org:admin"]))):
|
|
21
|
+
return {"message": "Admin access granted"}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .auth_manager import AuthManager
|
|
25
|
+
from .config.auth_config import AuthConfig
|
|
26
|
+
from .exceptions.auth_exceptions import (
|
|
27
|
+
AuthManagerError,
|
|
28
|
+
ConfigurationError,
|
|
29
|
+
TokenError,
|
|
30
|
+
AuthorizationError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"AuthManager",
|
|
35
|
+
"AuthConfig",
|
|
36
|
+
"AuthManagerError",
|
|
37
|
+
"ConfigurationError",
|
|
38
|
+
"TokenError",
|
|
39
|
+
"AuthorizationError",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clerk authentication adapter.
|
|
3
|
+
|
|
4
|
+
Implements AuthPort using Clerk's JWKS endpoint for JWT verification.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import jwt
|
|
10
|
+
from jwt import PyJWKClient
|
|
11
|
+
|
|
12
|
+
from ..config.auth_config import AuthConfig
|
|
13
|
+
from ..ports.auth_port import AuthPort
|
|
14
|
+
from ..exceptions.auth_exceptions import TokenError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClerkAdapter(AuthPort):
|
|
18
|
+
"""
|
|
19
|
+
Clerk-based authentication adapter.
|
|
20
|
+
|
|
21
|
+
Verifies JWTs against Clerk's JWKS endpoint and extracts
|
|
22
|
+
role/organization claims from the token payload.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: AuthConfig):
|
|
26
|
+
self._config = config
|
|
27
|
+
self._jwks_client = PyJWKClient(config.jwks_url)
|
|
28
|
+
|
|
29
|
+
def _decode_token(self, token: str) -> Dict[str, Any]:
|
|
30
|
+
"""Shared decode logic for sync and async paths."""
|
|
31
|
+
try:
|
|
32
|
+
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
|
33
|
+
|
|
34
|
+
decode_options = {"verify_audience": False}
|
|
35
|
+
kwargs: Dict[str, Any] = {
|
|
36
|
+
"jwt": token,
|
|
37
|
+
"key": signing_key.key,
|
|
38
|
+
"algorithms": self._config.algorithms,
|
|
39
|
+
"options": decode_options,
|
|
40
|
+
# Clock tolerance on exp/nbf/iat. Without it, PyJWT rejects a
|
|
41
|
+
# token whose `iat` is a single second ahead of this host
|
|
42
|
+
# ("The token is not yet valid (iat)", api_jwt.py: `iat > now +
|
|
43
|
+
# leeway`). Clerk stamps `iat` on its own servers, so a host
|
|
44
|
+
# running slightly behind fails EVERY request, and the client
|
|
45
|
+
# cannot compensate — re-minting yields a later `iat` still.
|
|
46
|
+
"leeway": self._config.leeway_seconds,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if self._config.issuer:
|
|
50
|
+
kwargs["issuer"] = self._config.issuer
|
|
51
|
+
if self._config.audience:
|
|
52
|
+
kwargs["audience"] = self._config.audience
|
|
53
|
+
decode_options["verify_audience"] = True
|
|
54
|
+
|
|
55
|
+
return jwt.decode(**kwargs)
|
|
56
|
+
except jwt.ExpiredSignatureError:
|
|
57
|
+
raise TokenError("Token has expired")
|
|
58
|
+
except jwt.InvalidTokenError as e:
|
|
59
|
+
raise TokenError(f"Invalid token: {str(e)}")
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise TokenError(f"Token verification failed: {str(e)}")
|
|
62
|
+
|
|
63
|
+
def verify_token(self, token: str) -> Dict[str, Any]:
|
|
64
|
+
return self._decode_token(token)
|
|
65
|
+
|
|
66
|
+
async def verify_token_async(self, token: str) -> Dict[str, Any]:
|
|
67
|
+
# PyJWKClient doesn't have native async; delegate to sync.
|
|
68
|
+
# For high-throughput, consider caching the JWKS keys.
|
|
69
|
+
return self._decode_token(token)
|
|
70
|
+
|
|
71
|
+
def has_role(self, payload: Dict[str, Any], roles: List[str]) -> bool:
|
|
72
|
+
# Clerk puts custom roles in public_metadata.rol when no org is configured,
|
|
73
|
+
# and in org_role when the user is scoped to an organization.
|
|
74
|
+
# Check both locations so the same decorator works either way.
|
|
75
|
+
org_role = payload.get("org_role")
|
|
76
|
+
if org_role and org_role in roles:
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
metadata = payload.get("public_metadata") or payload.get("metadata") or {}
|
|
80
|
+
user_rol = metadata.get("rol", "")
|
|
81
|
+
return user_rol in roles
|
|
82
|
+
|
|
83
|
+
def get_org_id(self, payload: Dict[str, Any]) -> Optional[str]:
|
|
84
|
+
return payload.get("org_id")
|
|
85
|
+
|
|
86
|
+
def get_user_id(self, payload: Dict[str, Any]) -> Optional[str]:
|
|
87
|
+
return payload.get("sub")
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AuthManager - Public API for authentication and authorization.
|
|
3
|
+
|
|
4
|
+
Provides FastAPI-compatible dependency decorators for protecting endpoints
|
|
5
|
+
with JWT verification and role-based access control.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
from fastapi import Depends, HTTPException
|
|
11
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
12
|
+
|
|
13
|
+
from .config.auth_config import AuthConfig
|
|
14
|
+
from .ports.auth_port import AuthPort
|
|
15
|
+
from .adapters.clerk_adapter import ClerkAdapter
|
|
16
|
+
from .exceptions.auth_exceptions import (
|
|
17
|
+
AuthManagerError,
|
|
18
|
+
ConfigurationError,
|
|
19
|
+
TokenError,
|
|
20
|
+
AuthorizationError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_security = HTTPBearer()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthManager:
|
|
28
|
+
"""
|
|
29
|
+
Public API for authentication and authorization.
|
|
30
|
+
|
|
31
|
+
Provides FastAPI dependency functions that can be used with Depends()
|
|
32
|
+
to protect endpoints with JWT verification and role-based access control.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
auth = AuthManager({"jwks_url": os.getenv("NEURONOS_AUTH_JWKS_URL")})
|
|
36
|
+
|
|
37
|
+
@app.get("/api/protected")
|
|
38
|
+
def protected(user: dict = Depends(auth.require_auth())):
|
|
39
|
+
return {"user_id": user["sub"]}
|
|
40
|
+
|
|
41
|
+
@app.get("/api/admin")
|
|
42
|
+
def admin_only(user: dict = Depends(auth.require_roles(["org:admin"]))):
|
|
43
|
+
return {"message": "Welcome admin"}
|
|
44
|
+
|
|
45
|
+
@app.get("/api/tenant")
|
|
46
|
+
def tenant_only(user: dict = Depends(auth.require_org("org_2abc123"))):
|
|
47
|
+
return {"message": "Org access granted"}
|
|
48
|
+
|
|
49
|
+
@app.get("/api/coordinator")
|
|
50
|
+
def coordinator(user: dict = Depends(auth.require_org_role("org_2abc123", ["org:coordinator"]))):
|
|
51
|
+
return {"message": "Org coordinator access"}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_ADAPTERS = {
|
|
55
|
+
"clerk": ClerkAdapter,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def __init__(self, config: Optional[Union[AuthConfig, Dict[str, Any]]] = None):
|
|
59
|
+
"""
|
|
60
|
+
Initialize AuthManager with configuration.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
config: AuthConfig object or dictionary with auth parameters.
|
|
64
|
+
Sensitive values fall back to environment variables.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ConfigurationError: If configuration is invalid or missing
|
|
68
|
+
"""
|
|
69
|
+
if config is None:
|
|
70
|
+
# Attempt to build config entirely from env variables
|
|
71
|
+
config = AuthConfig()
|
|
72
|
+
elif isinstance(config, dict):
|
|
73
|
+
try:
|
|
74
|
+
config = AuthConfig(**config)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
raise ConfigurationError(f"Invalid configuration: {str(e)}")
|
|
77
|
+
|
|
78
|
+
self.config = config
|
|
79
|
+
self._adapter: AuthPort = self._create_adapter(config)
|
|
80
|
+
|
|
81
|
+
def _create_adapter(self, config: AuthConfig) -> AuthPort:
|
|
82
|
+
adapter_cls = self._ADAPTERS.get(config.provider)
|
|
83
|
+
if not adapter_cls:
|
|
84
|
+
raise ConfigurationError(
|
|
85
|
+
f"Unsupported provider: {config.provider}. "
|
|
86
|
+
f"Supported: {', '.join(self._ADAPTERS.keys())}"
|
|
87
|
+
)
|
|
88
|
+
return adapter_cls(config)
|
|
89
|
+
|
|
90
|
+
# ===== FastAPI Dependency Factories =====
|
|
91
|
+
|
|
92
|
+
def require_auth(self):
|
|
93
|
+
"""
|
|
94
|
+
FastAPI dependency that verifies the JWT and returns the decoded payload.
|
|
95
|
+
|
|
96
|
+
Returns 401 if token is missing or invalid.
|
|
97
|
+
|
|
98
|
+
Usage:
|
|
99
|
+
@app.get("/protected")
|
|
100
|
+
def endpoint(user: dict = Depends(auth.require_auth())):
|
|
101
|
+
...
|
|
102
|
+
"""
|
|
103
|
+
adapter = self._adapter
|
|
104
|
+
|
|
105
|
+
async def _dependency(
|
|
106
|
+
credentials: HTTPAuthorizationCredentials = Depends(_security),
|
|
107
|
+
) -> Dict[str, Any]:
|
|
108
|
+
try:
|
|
109
|
+
return await adapter.verify_token_async(credentials.credentials)
|
|
110
|
+
except TokenError as e:
|
|
111
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
112
|
+
|
|
113
|
+
return _dependency
|
|
114
|
+
|
|
115
|
+
def require_roles(self, roles: List[str]):
|
|
116
|
+
"""
|
|
117
|
+
FastAPI dependency that verifies JWT and checks the user has one of the given roles.
|
|
118
|
+
|
|
119
|
+
Returns 401 if token is invalid, 403 if role is not matched.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
roles: List of acceptable roles (e.g., ["coordinador", "admin"])
|
|
123
|
+
|
|
124
|
+
Usage:
|
|
125
|
+
@app.get("/admin")
|
|
126
|
+
def endpoint(user: dict = Depends(auth.require_roles(["admin"]))):
|
|
127
|
+
...
|
|
128
|
+
"""
|
|
129
|
+
adapter = self._adapter
|
|
130
|
+
|
|
131
|
+
async def _dependency(
|
|
132
|
+
credentials: HTTPAuthorizationCredentials = Depends(_security),
|
|
133
|
+
) -> Dict[str, Any]:
|
|
134
|
+
try:
|
|
135
|
+
payload = await adapter.verify_token_async(credentials.credentials)
|
|
136
|
+
except TokenError as e:
|
|
137
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
138
|
+
|
|
139
|
+
if not adapter.has_role(payload, roles):
|
|
140
|
+
raise HTTPException(
|
|
141
|
+
status_code=403,
|
|
142
|
+
detail=f"Requires one of roles: {', '.join(roles)}",
|
|
143
|
+
)
|
|
144
|
+
return payload
|
|
145
|
+
|
|
146
|
+
return _dependency
|
|
147
|
+
|
|
148
|
+
def require_org(self, org_id: str):
|
|
149
|
+
"""
|
|
150
|
+
FastAPI dependency that verifies JWT and checks the user belongs to a specific org.
|
|
151
|
+
|
|
152
|
+
Returns 401 if token is invalid, 403 if org doesn't match.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
org_id: Required organization ID (e.g., "org_2abc123")
|
|
156
|
+
|
|
157
|
+
Usage:
|
|
158
|
+
@app.get("/tenant")
|
|
159
|
+
def endpoint(user: dict = Depends(auth.require_org("org_2abc123"))):
|
|
160
|
+
...
|
|
161
|
+
"""
|
|
162
|
+
adapter = self._adapter
|
|
163
|
+
|
|
164
|
+
async def _dependency(
|
|
165
|
+
credentials: HTTPAuthorizationCredentials = Depends(_security),
|
|
166
|
+
) -> Dict[str, Any]:
|
|
167
|
+
try:
|
|
168
|
+
payload = await adapter.verify_token_async(credentials.credentials)
|
|
169
|
+
except TokenError as e:
|
|
170
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
171
|
+
|
|
172
|
+
if adapter.get_org_id(payload) != org_id:
|
|
173
|
+
raise HTTPException(
|
|
174
|
+
status_code=403,
|
|
175
|
+
detail=f"Access restricted to organization '{org_id}'.",
|
|
176
|
+
)
|
|
177
|
+
return payload
|
|
178
|
+
|
|
179
|
+
return _dependency
|
|
180
|
+
|
|
181
|
+
def require_org_role(self, org_id: str, roles: List[str]):
|
|
182
|
+
"""
|
|
183
|
+
FastAPI dependency that verifies JWT, org membership, AND role in one step.
|
|
184
|
+
|
|
185
|
+
Returns 401 if token is invalid, 403 if org or role doesn't match.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
org_id: Required organization ID
|
|
189
|
+
roles: List of acceptable roles within that org
|
|
190
|
+
|
|
191
|
+
Usage:
|
|
192
|
+
@app.get("/org-coordinator")
|
|
193
|
+
def endpoint(user: dict = Depends(auth.require_org_role("org_2abc123", ["org:coordinator"]))):
|
|
194
|
+
...
|
|
195
|
+
"""
|
|
196
|
+
adapter = self._adapter
|
|
197
|
+
|
|
198
|
+
async def _dependency(
|
|
199
|
+
credentials: HTTPAuthorizationCredentials = Depends(_security),
|
|
200
|
+
) -> Dict[str, Any]:
|
|
201
|
+
try:
|
|
202
|
+
payload = await adapter.verify_token_async(credentials.credentials)
|
|
203
|
+
except TokenError as e:
|
|
204
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
205
|
+
|
|
206
|
+
if adapter.get_org_id(payload) != org_id:
|
|
207
|
+
raise HTTPException(
|
|
208
|
+
status_code=403,
|
|
209
|
+
detail=f"Access restricted to organization '{org_id}'.",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if not adapter.has_role(payload, roles):
|
|
213
|
+
raise HTTPException(
|
|
214
|
+
status_code=403,
|
|
215
|
+
detail=f"Requires one of roles: {', '.join(roles)}",
|
|
216
|
+
)
|
|
217
|
+
return payload
|
|
218
|
+
|
|
219
|
+
return _dependency
|
|
220
|
+
|
|
221
|
+
# ===== Direct API (non-decorator usage) =====
|
|
222
|
+
|
|
223
|
+
def verify_token(self, token: str) -> Dict[str, Any]:
|
|
224
|
+
"""Verify and decode a JWT token (synchronous)."""
|
|
225
|
+
return self._adapter.verify_token(token)
|
|
226
|
+
|
|
227
|
+
async def verify_token_async(self, token: str) -> Dict[str, Any]:
|
|
228
|
+
"""Verify and decode a JWT token (asynchronous)."""
|
|
229
|
+
return await self._adapter.verify_token_async(token)
|
|
230
|
+
|
|
231
|
+
def get_user_id(self, payload: Dict[str, Any]) -> Optional[str]:
|
|
232
|
+
"""Extract user ID from decoded payload."""
|
|
233
|
+
return self._adapter.get_user_id(payload)
|
|
234
|
+
|
|
235
|
+
def get_org_id(self, payload: Dict[str, Any]) -> Optional[str]:
|
|
236
|
+
"""Extract organization ID from decoded payload."""
|
|
237
|
+
return self._adapter.get_org_id(payload)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration module for AuthManager capability.
|
|
3
|
+
|
|
4
|
+
Supports loading sensitive values from environment variables.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from ..exceptions.auth_exceptions import ConfigurationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AuthConfig:
|
|
16
|
+
"""
|
|
17
|
+
Configuration for authentication provider.
|
|
18
|
+
|
|
19
|
+
Sensitive values (jwks_url, issuer) can be set via environment variables:
|
|
20
|
+
- NEURONOS_AUTH_JWKS_URL
|
|
21
|
+
- NEURONOS_AUTH_ISSUER
|
|
22
|
+
- NEURONOS_AUTH_PROVIDER
|
|
23
|
+
- NEURONOS_AUTH_LEEWAY_SECONDS
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
provider: Auth provider name (e.g., "clerk")
|
|
27
|
+
jwks_url: URL to the JWKS endpoint for token verification
|
|
28
|
+
issuer: Expected token issuer (optional, for extra validation)
|
|
29
|
+
algorithms: Allowed JWT signing algorithms
|
|
30
|
+
audience: Expected audience claim (optional)
|
|
31
|
+
org_id: Lock to a specific organization ID (optional)
|
|
32
|
+
auto_error: If True, raise HTTPException on auth failure (default True)
|
|
33
|
+
leeway_seconds: Clock difference tolerated on the JWT time claims
|
|
34
|
+
(exp/nbf/iat). Defaults to 30.
|
|
35
|
+
|
|
36
|
+
Not merely a nicety: the provider stamps `iat` with ITS clock, so a
|
|
37
|
+
host running even ONE SECOND behind rejects every token with
|
|
38
|
+
"The token is not yet valid (iat)" — and no client can work around
|
|
39
|
+
it, because a freshly minted token carries a later `iat` still.
|
|
40
|
+
30s absorbs ordinary NTP drift without meaningfully extending a
|
|
41
|
+
token's life. Set to 0 to demand perfectly synchronized clocks.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
DEFAULT_LEEWAY_SECONDS = 30
|
|
45
|
+
|
|
46
|
+
provider: str = ""
|
|
47
|
+
jwks_url: str = ""
|
|
48
|
+
issuer: Optional[str] = None
|
|
49
|
+
algorithms: List[str] = field(default_factory=lambda: ["RS256"])
|
|
50
|
+
audience: Optional[str] = None
|
|
51
|
+
org_id: Optional[str] = None
|
|
52
|
+
auto_error: bool = True
|
|
53
|
+
leeway_seconds: int = DEFAULT_LEEWAY_SECONDS
|
|
54
|
+
|
|
55
|
+
def __post_init__(self):
|
|
56
|
+
# Resolve from env variables if not provided directly
|
|
57
|
+
self.provider = self.provider or os.getenv("NEURONOS_AUTH_PROVIDER", "clerk")
|
|
58
|
+
self.jwks_url = self.jwks_url or os.getenv("NEURONOS_AUTH_JWKS_URL", "")
|
|
59
|
+
self.issuer = self.issuer or os.getenv("NEURONOS_AUTH_ISSUER") or None
|
|
60
|
+
|
|
61
|
+
# An explicit 0 is a real choice ("require synced clocks"), so the env
|
|
62
|
+
# var is only consulted when the caller left the default untouched.
|
|
63
|
+
if self.leeway_seconds == self.DEFAULT_LEEWAY_SECONDS:
|
|
64
|
+
env_leeway = os.getenv("NEURONOS_AUTH_LEEWAY_SECONDS")
|
|
65
|
+
if env_leeway:
|
|
66
|
+
try:
|
|
67
|
+
self.leeway_seconds = int(env_leeway)
|
|
68
|
+
except ValueError:
|
|
69
|
+
raise ConfigurationError(
|
|
70
|
+
"NEURONOS_AUTH_LEEWAY_SECONDS must be an integer number of "
|
|
71
|
+
f"seconds, got '{env_leeway}'."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if self.leeway_seconds < 0:
|
|
75
|
+
raise ConfigurationError(
|
|
76
|
+
f"leeway_seconds must be >= 0, got {self.leeway_seconds}."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if not self.jwks_url:
|
|
80
|
+
raise ConfigurationError(
|
|
81
|
+
"jwks_url is required. Set it directly or via NEURONOS_AUTH_JWKS_URL env variable."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
valid_providers = ["clerk"]
|
|
85
|
+
if self.provider.lower() not in valid_providers:
|
|
86
|
+
raise ConfigurationError(
|
|
87
|
+
f"Invalid provider '{self.provider}'. Supported: {', '.join(valid_providers)}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
self.provider = self.provider.lower()
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict:
|
|
93
|
+
return {
|
|
94
|
+
"provider": self.provider,
|
|
95
|
+
"jwks_url": self.jwks_url,
|
|
96
|
+
"issuer": self.issuer,
|
|
97
|
+
"algorithms": self.algorithms,
|
|
98
|
+
"audience": self.audience,
|
|
99
|
+
"org_id": self.org_id,
|
|
100
|
+
"auto_error": self.auto_error,
|
|
101
|
+
"leeway_seconds": self.leeway_seconds,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_dict(cls, config_dict: dict) -> "AuthConfig":
|
|
106
|
+
return cls(**config_dict)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example: Using AuthManager with FastAPI.
|
|
3
|
+
|
|
4
|
+
Before running, set the environment variable:
|
|
5
|
+
export NEURONOS_AUTH_JWKS_URL="https://your-app.clerk.accounts.dev/.well-known/jwks.json"
|
|
6
|
+
|
|
7
|
+
Three levels of protection:
|
|
8
|
+
1. auth.require_auth() → just verify the token
|
|
9
|
+
2. auth.require_roles(["coordinador"]) → verify token + check role
|
|
10
|
+
3. auth.require_org_role("org_2x", ["coordinador"]) → verify token + org + role
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Depends
|
|
14
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
15
|
+
|
|
16
|
+
from neuronos_capabilities import AuthManager
|
|
17
|
+
|
|
18
|
+
app = FastAPI()
|
|
19
|
+
app.add_middleware(
|
|
20
|
+
CORSMiddleware,
|
|
21
|
+
allow_origins=["http://localhost:3000"],
|
|
22
|
+
allow_methods=["*"],
|
|
23
|
+
allow_headers=["*"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
auth = AuthManager()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- Level 1: Auth only ---
|
|
30
|
+
@app.get("/api/test-auth")
|
|
31
|
+
async def test_auth(user: dict = Depends(auth.require_auth())):
|
|
32
|
+
"""Any authenticated user."""
|
|
33
|
+
return {"status": "Token is valid", "user_id": user.get("sub")}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- Level 2: Auth + Role ---
|
|
37
|
+
@app.get("/api/coordinator-action")
|
|
38
|
+
async def coordinator_action(
|
|
39
|
+
user: dict = Depends(auth.require_roles(["coordinador"])),
|
|
40
|
+
):
|
|
41
|
+
"""Requires 'coordinador' role from public_metadata.rol."""
|
|
42
|
+
return {"message": "Coordinator access granted", "user_id": user.get("sub")}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --- Level 3: Auth + Org + Role ---
|
|
46
|
+
@app.get("/api/org-coordinator")
|
|
47
|
+
async def org_coordinator(
|
|
48
|
+
user: dict = Depends(
|
|
49
|
+
auth.require_org_role("org_2abc123", ["coordinador"])
|
|
50
|
+
),
|
|
51
|
+
):
|
|
52
|
+
"""Requires org membership AND 'coordinador' role."""
|
|
53
|
+
return {
|
|
54
|
+
"message": "Org Coordinator access granted",
|
|
55
|
+
"user_id": user.get("sub"),
|
|
56
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for AuthManager capability.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthManagerError(Exception):
|
|
7
|
+
"""Base exception for all AuthManager errors."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigurationError(AuthManagerError):
|
|
12
|
+
"""Raised when configuration is invalid or incomplete."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenError(AuthManagerError):
|
|
17
|
+
"""Raised when token verification fails (expired, malformed, invalid signature)."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthorizationError(AuthManagerError):
|
|
22
|
+
"""Raised when the user lacks required roles or permissions."""
|
|
23
|
+
pass
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Abstract port interface for authentication operations.
|
|
3
|
+
|
|
4
|
+
Defines the provider-agnostic contract that all auth adapters must implement.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthPort(ABC):
|
|
12
|
+
"""
|
|
13
|
+
Abstract interface for authentication providers.
|
|
14
|
+
|
|
15
|
+
All auth adapters must implement this interface.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def verify_token(self, token: str) -> Dict[str, Any]:
|
|
20
|
+
"""
|
|
21
|
+
Verify and decode a JWT token (synchronous).
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
token: Raw JWT string
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Decoded token payload as dictionary
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
TokenError: If token is invalid, expired, or malformed
|
|
31
|
+
"""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def verify_token_async(self, token: str) -> Dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Verify and decode a JWT token (asynchronous).
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
token: Raw JWT string
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Decoded token payload as dictionary
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
TokenError: If token is invalid, expired, or malformed
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def has_role(self, payload: Dict[str, Any], roles: List[str]) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Check if the token payload contains any of the required roles.
|
|
54
|
+
|
|
55
|
+
The role can come from different locations depending on the provider
|
|
56
|
+
configuration (e.g., org_role for org-scoped tokens, or
|
|
57
|
+
public_metadata.rol for custom roles without orgs).
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
payload: Decoded JWT payload
|
|
61
|
+
roles: List of acceptable roles (e.g., ["coordinador", "admin"])
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
True if user has at least one of the required roles
|
|
65
|
+
"""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def get_org_id(self, payload: Dict[str, Any]) -> Optional[str]:
|
|
70
|
+
"""
|
|
71
|
+
Extract organization ID from token payload.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
payload: Decoded JWT payload
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Organization ID string or None
|
|
78
|
+
"""
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def get_user_id(self, payload: Dict[str, Any]) -> Optional[str]:
|
|
83
|
+
"""
|
|
84
|
+
Extract user ID from token payload.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
payload: Decoded JWT payload
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
User ID string or None
|
|
91
|
+
"""
|
|
92
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# AuthManager tests
|