auth-gate 0.1.0__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.
auth_gate/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """
2
+ Tradelink Authentication Client
3
+
4
+ Enterprise authentication client for microservices with Kong/Keycloak integration.
5
+ """
6
+
7
+ from .config import AuthMode, AuthSettings
8
+ from .fastapi_utils import (
9
+ get_current_user,
10
+ get_optional_user,
11
+ require_admin,
12
+ require_customer,
13
+ require_moderator,
14
+ require_roles,
15
+ require_scopes,
16
+ require_supplier,
17
+ require_supplier_or_admin,
18
+ verify_hmac_signature,
19
+ )
20
+ from .middleware import AuthMiddleware
21
+ from .s2s_auth import CircuitBreaker, CircuitBreakerOpenError, ServiceAuthClient
22
+ from .schemas import UserContext
23
+ from .user_auth import UserValidator
24
+
25
+ __version__ = "1.0.0"
26
+
27
+ __all__ = [
28
+ # Configuration
29
+ "AuthSettings",
30
+ "AuthMode",
31
+ # Schemas
32
+ "UserContext",
33
+ # User Authentication
34
+ "UserValidator",
35
+ # Service-to-Service
36
+ "ServiceAuthClient",
37
+ "CircuitBreaker",
38
+ "CircuitBreakerOpenError",
39
+ # Middleware
40
+ "AuthMiddleware",
41
+ # FastAPI Dependencies
42
+ "get_current_user",
43
+ "get_optional_user",
44
+ "require_roles",
45
+ "require_scopes",
46
+ "require_admin",
47
+ "require_supplier",
48
+ "require_customer",
49
+ "require_moderator",
50
+ "require_supplier_or_admin",
51
+ "verify_hmac_signature",
52
+ ]
auth_gate/config.py ADDED
@@ -0,0 +1,99 @@
1
+ """
2
+ Configuration management for Tradelink Auth Client
3
+ """
4
+
5
+ from enum import Enum
6
+ from typing import Optional
7
+
8
+ from pydantic_settings import BaseSettings
9
+
10
+
11
+ class AuthMode(Enum):
12
+ """Authentication modes for different environments"""
13
+
14
+ KONG_HEADERS = "kong_headers" # Production: Kong validates tokens
15
+ DIRECT_KEYCLOAK = "direct_keycloak" # Development: Direct Keycloak validation
16
+ BYPASS = "bypass" # Testing only - NEVER use in production
17
+
18
+
19
+ class AuthSettings(BaseSettings):
20
+ """
21
+ Centralized configuration for authentication client.
22
+ All settings can be overridden via environment variables.
23
+ """
24
+
25
+ model_config = {
26
+ "env_file": (".env", ".env.test", ".env.prod"),
27
+ "extra": "ignore",
28
+ "env_file_encoding": "utf-8",
29
+ "case_sensitive": True,
30
+ }
31
+
32
+ # Authentication Mode
33
+ AUTH_MODE: str = "kong_headers"
34
+
35
+ # Keycloak Configuration
36
+ KEYCLOAK_REALM_URL: str = "http://localhost:8080/realms/master"
37
+ KEYCLOAK_CLIENT_ID: str = "backend-service"
38
+ KEYCLOAK_CLIENT_SECRET: str = ""
39
+
40
+ # Service Account Configuration (for S2S)
41
+ SERVICE_CLIENT_ID: str = "service-account"
42
+ SERVICE_CLIENT_SECRET: str = ""
43
+
44
+ # HMAC Verification (optional)
45
+ VERIFY_HMAC: bool = False
46
+ INTERNAL_HMAC_KEY: str = ""
47
+
48
+ # Circuit Breaker Settings
49
+ CIRCUIT_BREAKER_FAILURE_THRESHOLD: int = 5
50
+ CIRCUIT_BREAKER_RECOVERY_TIMEOUT: int = 60
51
+
52
+ # Token Settings
53
+ TOKEN_CACHE_TTL: int = 300 # seconds
54
+ TOKEN_REFRESH_BUFFER: int = 30 # seconds before expiry
55
+
56
+ # HTTP Client Settings
57
+ HTTP_TIMEOUT: float = 10.0
58
+ HTTP_MAX_KEEPALIVE_CONNECTIONS: int = 10
59
+
60
+ @property
61
+ def auth_mode_enum(self) -> AuthMode:
62
+ """Get AUTH_MODE as enum"""
63
+ try:
64
+ return AuthMode(self.AUTH_MODE.lower())
65
+ except ValueError:
66
+ raise ValueError(f"Invalid AUTH_MODE: {self.AUTH_MODE}")
67
+
68
+ @property
69
+ def is_production(self) -> bool:
70
+ """Check if running in production mode"""
71
+ return self.auth_mode_enum == AuthMode.KONG_HEADERS
72
+
73
+ @property
74
+ def is_development(self) -> bool:
75
+ """Check if running in development mode"""
76
+ return self.auth_mode_enum == AuthMode.DIRECT_KEYCLOAK
77
+
78
+ @property
79
+ def is_testing(self) -> bool:
80
+ """Check if running in testing/bypass mode"""
81
+ return self.auth_mode_enum == AuthMode.BYPASS
82
+
83
+
84
+ # Global settings instance
85
+ _settings: Optional[AuthSettings] = None
86
+
87
+
88
+ def get_settings() -> AuthSettings:
89
+ """Get or create settings instance"""
90
+ global _settings
91
+ if _settings is None:
92
+ _settings = AuthSettings()
93
+ return _settings
94
+
95
+
96
+ def reset_settings() -> None:
97
+ """Reset settings (useful for testing)"""
98
+ global _settings
99
+ _settings = None
@@ -0,0 +1,275 @@
1
+ """
2
+ FastAPI dependency injection utilities for authentication
3
+ """
4
+
5
+ import logging
6
+ from typing import Optional
7
+
8
+ from fastapi import Depends, Header, HTTPException, Request, status
9
+
10
+ from .config import get_settings
11
+ from .schemas import UserContext
12
+ from .user_auth import HMACVerifier, get_user_validator
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # Core authentication dependencies
18
+ async def get_current_user(
19
+ request: Request,
20
+ authorization: Optional[str] = Header(None),
21
+ x_token_verified: Optional[str] = Header(None, alias="X-Token-Verified"),
22
+ x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
23
+ x_username: Optional[str] = Header(None, alias="X-Username"),
24
+ x_user_email: Optional[str] = Header(None, alias="X-User-Email"),
25
+ x_user_roles: Optional[str] = Header(None, alias="X-User-Roles"),
26
+ x_user_scopes: Optional[str] = Header(None, alias="X-User-Scopes"),
27
+ x_session_id: Optional[str] = Header(None, alias="X-Session-ID"),
28
+ x_client_id: Optional[str] = Header(None, alias="X-Client-ID"),
29
+ x_auth_source: Optional[str] = Header(None, alias="X-Auth-Source"),
30
+ ) -> UserContext:
31
+ """
32
+ Main dependency for getting authenticated user.
33
+
34
+ This is the primary dependency to use in your FastAPI endpoints.
35
+
36
+ Example:
37
+ @app.get("/api/profile")
38
+ async def get_profile(user: UserContext = Depends(get_current_user)):
39
+ return {"user_id": user.user_id}
40
+
41
+ Args:
42
+ Various authentication headers
43
+
44
+ Returns:
45
+ UserContext with authenticated user information
46
+
47
+ Raises:
48
+ HTTPException: If authentication fails
49
+ """
50
+ validator = get_user_validator()
51
+ return await validator.get_current_user(
52
+ request,
53
+ authorization,
54
+ x_token_verified,
55
+ x_user_id,
56
+ x_username,
57
+ x_user_email,
58
+ x_user_roles,
59
+ x_user_scopes,
60
+ x_session_id,
61
+ x_client_id,
62
+ x_auth_source,
63
+ )
64
+
65
+
66
+ async def get_optional_user(
67
+ request: Request,
68
+ authorization: Optional[str] = Header(None),
69
+ x_token_verified: Optional[str] = Header(None, alias="X-Token-Verified"),
70
+ x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
71
+ x_username: Optional[str] = Header(None, alias="X-Username"),
72
+ x_user_email: Optional[str] = Header(None, alias="X-User-Email"),
73
+ x_user_roles: Optional[str] = Header(None, alias="X-User-Roles"),
74
+ x_user_scopes: Optional[str] = Header(None, alias="X-User-Scopes"),
75
+ x_session_id: Optional[str] = Header(None, alias="X-Session-ID"),
76
+ x_client_id: Optional[str] = Header(None, alias="X-Client-ID"),
77
+ x_auth_source: Optional[str] = Header(None, alias="X-Auth-Source"),
78
+ ) -> Optional[UserContext]:
79
+ """
80
+ Optional authentication - returns None if not authenticated.
81
+
82
+ Use this for endpoints where authentication is optional.
83
+
84
+ Example:
85
+ @app.get("/api/products")
86
+ async def list_products(user: Optional[UserContext] = Depends(get_optional_user)):
87
+ if user:
88
+ # Show personalized products
89
+ pass
90
+ else:
91
+ # Show public products
92
+ pass
93
+
94
+ Args:
95
+ Various authentication headers
96
+
97
+ Returns:
98
+ UserContext if authenticated, None otherwise
99
+ """
100
+ try:
101
+ return await get_current_user(
102
+ request,
103
+ authorization,
104
+ x_token_verified,
105
+ x_user_id,
106
+ x_username,
107
+ x_user_email,
108
+ x_user_roles,
109
+ x_user_scopes,
110
+ x_session_id,
111
+ x_client_id,
112
+ x_auth_source,
113
+ )
114
+ except HTTPException:
115
+ return None
116
+
117
+
118
+ # Role-based access control factories
119
+ def require_roles(*required_roles: str):
120
+ """
121
+ Factory for role-checking dependencies.
122
+
123
+ Creates a dependency that ensures the user has at least one of the specified roles.
124
+
125
+ Example:
126
+ require_editor = require_roles("editor", "admin")
127
+
128
+ @app.post("/api/articles")
129
+ async def create_article(user: UserContext = Depends(require_editor)):
130
+ return {"created_by": user.user_id}
131
+
132
+ Args:
133
+ *required_roles: Variable number of role names
134
+
135
+ Returns:
136
+ Dependency function that validates roles
137
+ """
138
+
139
+ async def role_checker(user: UserContext = Depends(get_current_user)) -> UserContext:
140
+ if not user.has_any_role(list(required_roles)):
141
+ logger.warning(f"User {user.user_id} lacks required roles: {required_roles}")
142
+ raise HTTPException(
143
+ status_code=status.HTTP_403_FORBIDDEN,
144
+ detail=f"Requires one of roles: {', '.join(required_roles)}",
145
+ )
146
+ return user
147
+
148
+ return role_checker
149
+
150
+
151
+ def require_scopes(*required_scopes: str):
152
+ """
153
+ Factory for scope-checking dependencies.
154
+
155
+ Creates a dependency that ensures the user has all specified scopes.
156
+
157
+ Example:
158
+ require_write = require_scopes("write", "publish")
159
+
160
+ @app.post("/api/publish")
161
+ async def publish(user: UserContext = Depends(require_write)):
162
+ return {"published_by": user.user_id}
163
+
164
+ Args:
165
+ *required_scopes: Variable number of scope names
166
+
167
+ Returns:
168
+ Dependency function that validates scopes
169
+ """
170
+
171
+ async def scope_checker(user: UserContext = Depends(get_current_user)) -> UserContext:
172
+ missing_scopes = [s for s in required_scopes if not user.has_scope(s)]
173
+ if missing_scopes:
174
+ logger.warning(f"User {user.user_id} lacks required scopes: {missing_scopes}")
175
+ raise HTTPException(
176
+ status_code=status.HTTP_403_FORBIDDEN,
177
+ detail=f"Requires scopes: {', '.join(missing_scopes)}",
178
+ )
179
+ return user
180
+
181
+ return scope_checker
182
+
183
+
184
+ # Pre-configured role dependencies for common use cases
185
+ require_admin = require_roles("admin")
186
+ """Dependency that requires admin role"""
187
+
188
+ require_supplier = require_roles("supplier")
189
+ """Dependency that requires supplier role"""
190
+
191
+ require_customer = require_roles("customer")
192
+ """Dependency that requires customer role"""
193
+
194
+ require_moderator = require_roles("moderator")
195
+ """Dependency that requires moderator role"""
196
+
197
+ require_supplier_or_admin = require_roles("supplier", "admin")
198
+ """Dependency that requires either supplier or admin role"""
199
+
200
+
201
+ # HMAC verification for service-to-service communication
202
+ async def verify_hmac_signature(
203
+ request: Request,
204
+ x_authz_signature: Optional[str] = Header(None, alias="X-Authz-Signature"),
205
+ x_authz_ts: Optional[str] = Header(None, alias="X-Authz-Ts"),
206
+ user: UserContext = Depends(get_current_user),
207
+ ) -> UserContext:
208
+ """
209
+ Dependency to verify HMAC signatures from Kong.
210
+
211
+ Use this when Kong is configured with HMAC plugin for additional security.
212
+
213
+ Example:
214
+ @app.post("/api/sensitive")
215
+ async def sensitive_operation(user: UserContext = Depends(verify_hmac_signature)):
216
+ return {"verified_user": user.user_id}
217
+
218
+ Args:
219
+ request: FastAPI request
220
+ x_authz_signature: HMAC signature header
221
+ x_authz_ts: Timestamp header
222
+ user: Authenticated user context
223
+
224
+ Returns:
225
+ UserContext if HMAC is valid
226
+
227
+ Raises:
228
+ HTTPException: If HMAC verification fails
229
+ """
230
+ settings = get_settings()
231
+
232
+ if not settings.VERIFY_HMAC:
233
+ return user
234
+
235
+ if not x_authz_signature or not x_authz_ts:
236
+ raise HTTPException(
237
+ status_code=status.HTTP_401_UNAUTHORIZED,
238
+ detail="Missing HMAC headers",
239
+ )
240
+
241
+ verifier = HMACVerifier(settings.INTERNAL_HMAC_KEY)
242
+
243
+ if not verifier.verify_signature(
244
+ x_authz_signature,
245
+ x_authz_ts,
246
+ user.user_id,
247
+ user.session_id or "",
248
+ request.method,
249
+ request.url.path,
250
+ ):
251
+ raise HTTPException(
252
+ status_code=status.HTTP_401_UNAUTHORIZED,
253
+ detail="Invalid HMAC signature",
254
+ )
255
+
256
+ return user
257
+
258
+
259
+ # Utility functions for checking authentication mode
260
+ def is_using_kong() -> bool:
261
+ """Check if using Kong authentication"""
262
+ settings = get_settings()
263
+ return settings.is_production
264
+
265
+
266
+ def is_using_keycloak() -> bool:
267
+ """Check if using direct Keycloak authentication"""
268
+ settings = get_settings()
269
+ return settings.is_development
270
+
271
+
272
+ def is_bypass_mode() -> bool:
273
+ """Check if in bypass/testing mode"""
274
+ settings = get_settings()
275
+ return settings.is_testing
@@ -0,0 +1,205 @@
1
+ """
2
+ FastAPI middleware for authentication
3
+ """
4
+
5
+ import logging
6
+ import time
7
+ from typing import Optional, Set
8
+
9
+ from fastapi import HTTPException, Request, status
10
+ from fastapi.responses import JSONResponse
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+ from starlette.types import ASGIApp
13
+
14
+ from .config import get_settings
15
+ from .user_auth import get_user_validator
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class AuthMiddleware(BaseHTTPMiddleware):
21
+ """
22
+ Middleware for handling authentication in Kong/Keycloak environment.
23
+
24
+ Features:
25
+ - Automatic authentication based on configured mode
26
+ - Configurable path exclusions
27
+ - Optional authentication paths
28
+ - Request enrichment with user context
29
+ - Security headers injection
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ app: ASGIApp,
35
+ excluded_paths: Optional[Set[str]] = None,
36
+ excluded_prefixes: Optional[Set[str]] = None,
37
+ optional_auth_paths: Optional[Set[str]] = None,
38
+ ):
39
+ """
40
+ Initialize authentication middleware.
41
+
42
+ Args:
43
+ app: ASGI application
44
+ excluded_paths: Exact paths that don't require authentication
45
+ excluded_prefixes: Path prefixes that don't require authentication
46
+ optional_auth_paths: Paths where authentication is optional
47
+ """
48
+ super().__init__(app)
49
+
50
+ # Paths that don't require authentication
51
+ self.excluded_paths = excluded_paths or {
52
+ "/health",
53
+ "/metrics",
54
+ "/openapi.json",
55
+ "/favicon.ico",
56
+ }
57
+
58
+ # Path prefixes that don't require authentication
59
+ self.excluded_prefixes = excluded_prefixes or {
60
+ "/api/docs",
61
+ "/api/redoc",
62
+ "/static",
63
+ "/_health",
64
+ }
65
+
66
+ # Paths where authentication is optional
67
+ self.optional_auth_paths = optional_auth_paths or set()
68
+
69
+ self.validator = get_user_validator()
70
+ self.settings = get_settings()
71
+
72
+ def is_excluded(self, path: str) -> bool:
73
+ """Check if path is excluded from authentication"""
74
+ # Exact match
75
+ if path in self.excluded_paths:
76
+ return True
77
+
78
+ # Prefix match
79
+ for prefix in self.excluded_prefixes:
80
+ if path.startswith(prefix):
81
+ return True
82
+
83
+ return False
84
+
85
+ def is_optional_auth(self, path: str) -> bool:
86
+ """Check if path has optional authentication"""
87
+ return path in self.optional_auth_paths
88
+
89
+ async def dispatch(self, request: Request, call_next):
90
+ """Process request with authentication"""
91
+ start_time = time.time()
92
+
93
+ # Add request ID for tracing
94
+ request_id = request.headers.get("X-Request-ID", str(time.time()))
95
+
96
+ # Skip authentication for excluded paths
97
+ if self.is_excluded(request.url.path):
98
+ response = await call_next(request)
99
+ self._add_security_headers(response, request_id, start_time)
100
+ return response
101
+
102
+ try:
103
+ # Extract authentication headers
104
+ auth_headers = self._extract_auth_headers(request)
105
+
106
+ # Check if we have any authentication
107
+ has_auth = self._has_authentication(auth_headers)
108
+
109
+ if not has_auth:
110
+ # No authentication present
111
+ if self.is_optional_auth(request.url.path):
112
+ request.state.user = None
113
+ else:
114
+ logger.warning(f"Missing authentication for {request.url.path}")
115
+ return JSONResponse(
116
+ status_code=status.HTTP_401_UNAUTHORIZED,
117
+ content={
118
+ "error": "unauthorized",
119
+ "message": "Authentication required",
120
+ "request_id": request_id,
121
+ },
122
+ )
123
+ else:
124
+ # Validate authentication
125
+ user_context = await self.validator.get_current_user(request, **auth_headers)
126
+ request.state.user = user_context
127
+
128
+ # Log authentication details
129
+ logger.info(
130
+ f"Authenticated request: user={user_context.user_id}, "
131
+ f"roles={user_context.roles}, path={request.url.path}, "
132
+ f"method={request.method}, source={user_context.auth_source}"
133
+ )
134
+
135
+ # Process request
136
+ response = await call_next(request)
137
+
138
+ # Add security headers
139
+ self._add_security_headers(response, request_id, start_time)
140
+
141
+ return response
142
+
143
+ except HTTPException as e:
144
+ # Handle authentication exceptions
145
+ logger.warning(f"Auth failed: {e.detail}, path={request.url.path}")
146
+ return JSONResponse(
147
+ status_code=e.status_code,
148
+ content={
149
+ "error": "authentication_failed",
150
+ "message": e.detail,
151
+ "request_id": request_id,
152
+ },
153
+ )
154
+ except Exception as e:
155
+ # Handle unexpected errors
156
+ logger.error(f"Middleware error: {e}", exc_info=True)
157
+ return JSONResponse(
158
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
159
+ content={
160
+ "error": "internal_error",
161
+ "message": "An internal error occurred",
162
+ "request_id": request_id,
163
+ },
164
+ )
165
+
166
+ def _extract_auth_headers(self, request: Request) -> dict:
167
+ """Extract all authentication-related headers"""
168
+ return {
169
+ "authorization": request.headers.get("Authorization"),
170
+ "x_token_verified": request.headers.get("X-Token-Verified"),
171
+ "x_user_id": request.headers.get("X-User-ID"),
172
+ "x_username": request.headers.get("X-Username"),
173
+ "x_user_email": request.headers.get("X-User-Email"),
174
+ "x_user_roles": request.headers.get("X-User-Roles"),
175
+ "x_user_scopes": request.headers.get("X-User-Scopes"),
176
+ "x_session_id": request.headers.get("X-Session-ID"),
177
+ "x_client_id": request.headers.get("X-Client-ID"),
178
+ "x_auth_source": request.headers.get("X-Auth-Source"),
179
+ }
180
+
181
+ def _has_authentication(self, auth_headers: dict) -> bool:
182
+ """Check if request has any authentication"""
183
+ if self.settings.is_production:
184
+ # Kong mode - check for verified token header
185
+ return auth_headers.get("x_token_verified") == "true"
186
+ elif self.settings.is_development:
187
+ # Direct Keycloak mode - check for Bearer token
188
+ auth: str = auth_headers.get("authorization", "")
189
+ return auth.startswith("Bearer ")
190
+ elif self.settings.is_testing:
191
+ # Bypass mode - always authenticated
192
+ return True
193
+ return False
194
+
195
+ def _add_security_headers(self, response, request_id: str, start_time: float):
196
+ """Add security and performance headers to response"""
197
+ response.headers["X-Request-ID"] = request_id
198
+ response.headers["X-Content-Type-Options"] = "nosniff"
199
+ response.headers["X-Frame-Options"] = "DENY"
200
+ response.headers["X-XSS-Protection"] = "1; mode=block"
201
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
202
+
203
+ # Add performance metrics
204
+ process_time = time.time() - start_time
205
+ response.headers["X-Process-Time"] = str(process_time)