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.
Files changed (75) hide show
  1. neuronos_capabilities/AuthManager/__init__.py +42 -0
  2. neuronos_capabilities/AuthManager/adapters/__init__.py +3 -0
  3. neuronos_capabilities/AuthManager/adapters/clerk_adapter.py +87 -0
  4. neuronos_capabilities/AuthManager/auth_manager.py +237 -0
  5. neuronos_capabilities/AuthManager/config/__init__.py +3 -0
  6. neuronos_capabilities/AuthManager/config/auth_config.py +106 -0
  7. neuronos_capabilities/AuthManager/examples/usage.py +56 -0
  8. neuronos_capabilities/AuthManager/exceptions/__init__.py +13 -0
  9. neuronos_capabilities/AuthManager/exceptions/auth_exceptions.py +23 -0
  10. neuronos_capabilities/AuthManager/ports/__init__.py +3 -0
  11. neuronos_capabilities/AuthManager/ports/auth_port.py +92 -0
  12. neuronos_capabilities/AuthManager/tests/__init__.py +1 -0
  13. neuronos_capabilities/AuthManager/tests/test_clock_skew.py +178 -0
  14. neuronos_capabilities/NotificationsManager/README.md +75 -0
  15. neuronos_capabilities/NotificationsManager/__init__.py +37 -0
  16. neuronos_capabilities/NotificationsManager/adapters/__init__.py +7 -0
  17. neuronos_capabilities/NotificationsManager/adapters/aws.py +146 -0
  18. neuronos_capabilities/NotificationsManager/adapters/console.py +30 -0
  19. neuronos_capabilities/NotificationsManager/adapters/memory.py +143 -0
  20. neuronos_capabilities/NotificationsManager/adapters/postgres.py +214 -0
  21. neuronos_capabilities/NotificationsManager/adapters/templates.py +73 -0
  22. neuronos_capabilities/NotificationsManager/artifact.yaml +64 -0
  23. neuronos_capabilities/NotificationsManager/config/__init__.py +3 -0
  24. neuronos_capabilities/NotificationsManager/config/notifications_config.py +123 -0
  25. neuronos_capabilities/NotificationsManager/entrypoints/__init__.py +1 -0
  26. neuronos_capabilities/NotificationsManager/entrypoints/events.py +36 -0
  27. neuronos_capabilities/NotificationsManager/entrypoints/sender.py +22 -0
  28. neuronos_capabilities/NotificationsManager/exceptions/__init__.py +19 -0
  29. neuronos_capabilities/NotificationsManager/exceptions/notifications_exceptions.py +41 -0
  30. neuronos_capabilities/NotificationsManager/models.py +88 -0
  31. neuronos_capabilities/NotificationsManager/notifications_manager.py +605 -0
  32. neuronos_capabilities/NotificationsManager/ports/__init__.py +110 -0
  33. neuronos_capabilities/NotificationsManager/tests/__init__.py +0 -0
  34. neuronos_capabilities/NotificationsManager/tests/test_aws_adapters.py +93 -0
  35. neuronos_capabilities/NotificationsManager/tests/test_notifications_manager.py +198 -0
  36. neuronos_capabilities/S3Manager/__init__.py +55 -0
  37. neuronos_capabilities/S3Manager/adapters/__init__.py +3 -0
  38. neuronos_capabilities/S3Manager/adapters/aws_adapter.py +400 -0
  39. neuronos_capabilities/S3Manager/artifact.yaml +52 -0
  40. neuronos_capabilities/S3Manager/config/__init__.py +3 -0
  41. neuronos_capabilities/S3Manager/config/s3_config.py +91 -0
  42. neuronos_capabilities/S3Manager/examples/usage.py +86 -0
  43. neuronos_capabilities/S3Manager/exceptions/__init__.py +21 -0
  44. neuronos_capabilities/S3Manager/exceptions/s3_exceptions.py +43 -0
  45. neuronos_capabilities/S3Manager/ports/__init__.py +3 -0
  46. neuronos_capabilities/S3Manager/ports/s3_port.py +499 -0
  47. neuronos_capabilities/S3Manager/s3_manager.py +332 -0
  48. neuronos_capabilities/S3Manager/tests/__init__.py +1 -0
  49. neuronos_capabilities/SQLManager/__init__.py +53 -0
  50. neuronos_capabilities/SQLManager/adapters/__init__.py +0 -0
  51. neuronos_capabilities/SQLManager/adapters/postgres_adapter.py +893 -0
  52. neuronos_capabilities/SQLManager/artifact.yaml +52 -0
  53. neuronos_capabilities/SQLManager/config/__init__.py +0 -0
  54. neuronos_capabilities/SQLManager/config/sql_config.py +175 -0
  55. neuronos_capabilities/SQLManager/examples/testing_library_execution.py +167 -0
  56. neuronos_capabilities/SQLManager/exceptions/__init__.py +0 -0
  57. neuronos_capabilities/SQLManager/exceptions/sql_exceptions.py +117 -0
  58. neuronos_capabilities/SQLManager/migrations/__init__.py +0 -0
  59. neuronos_capabilities/SQLManager/migrations/alembic_manager.py +617 -0
  60. neuronos_capabilities/SQLManager/models/__init__.py +9 -0
  61. neuronos_capabilities/SQLManager/models/base.py +118 -0
  62. neuronos_capabilities/SQLManager/ports/__init__.py +0 -0
  63. neuronos_capabilities/SQLManager/ports/sql_port.py +940 -0
  64. neuronos_capabilities/SQLManager/sql_manager.py +495 -0
  65. neuronos_capabilities/SQLManager/tests/__init__.py +0 -0
  66. neuronos_capabilities/SQLManager/utils/__init__.py +0 -0
  67. neuronos_capabilities/SQLManager/utils/query_builder.py +238 -0
  68. neuronos_capabilities/SQLManager/utils/singleton.py +120 -0
  69. neuronos_capabilities/__init__.py +58 -0
  70. neuronos_capabilities/__version__.py +2 -0
  71. neuronos_capabilities_lib-0.0.2.dist-info/LICENSE +21 -0
  72. neuronos_capabilities_lib-0.0.2.dist-info/METADATA +152 -0
  73. neuronos_capabilities_lib-0.0.2.dist-info/RECORD +75 -0
  74. neuronos_capabilities_lib-0.0.2.dist-info/WHEEL +5 -0
  75. 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,3 @@
1
+ from .clerk_adapter import ClerkAdapter
2
+
3
+ __all__ = ["ClerkAdapter"]
@@ -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,3 @@
1
+ from .auth_config import AuthConfig
2
+
3
+ __all__ = ["AuthConfig"]
@@ -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,13 @@
1
+ from .auth_exceptions import (
2
+ AuthManagerError,
3
+ ConfigurationError,
4
+ TokenError,
5
+ AuthorizationError,
6
+ )
7
+
8
+ __all__ = [
9
+ "AuthManagerError",
10
+ "ConfigurationError",
11
+ "TokenError",
12
+ "AuthorizationError",
13
+ ]
@@ -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,3 @@
1
+ from .auth_port import AuthPort
2
+
3
+ __all__ = ["AuthPort"]
@@ -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