auth-gate 0.2.2__tar.gz → 0.2.3__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.
Files changed (26) hide show
  1. {auth_gate-0.2.2/src/auth_gate.egg-info → auth_gate-0.2.3}/PKG-INFO +39 -1
  2. {auth_gate-0.2.2 → auth_gate-0.2.3}/README.md +38 -0
  3. {auth_gate-0.2.2 → auth_gate-0.2.3}/pyproject.toml +1 -1
  4. {auth_gate-0.2.2 → auth_gate-0.2.3}/setup.cfg +1 -1
  5. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate/__init__.py +33 -6
  6. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate/middleware.py +192 -5
  7. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate/user_auth.py +1 -1
  8. {auth_gate-0.2.2 → auth_gate-0.2.3/src/auth_gate.egg-info}/PKG-INFO +39 -1
  9. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/tests/test_intergration.py +2 -2
  10. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/tests/test_middleware.py +344 -1
  11. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/tests/test_user_auth.py +1 -1
  12. {auth_gate-0.2.2 → auth_gate-0.2.3}/LICENSE +0 -0
  13. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate/config.py +0 -0
  14. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate/fastapi_utils.py +0 -0
  15. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate/s2s_auth.py +0 -0
  16. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate/schemas.py +0 -0
  17. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate.egg-info/SOURCES.txt +0 -0
  18. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate.egg-info/dependency_links.txt +0 -0
  19. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate.egg-info/requires.txt +0 -0
  20. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/auth_gate.egg-info/top_level.txt +0 -0
  21. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/tests/__init__.py +0 -0
  22. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/tests/conftest.py +0 -0
  23. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/tests/test_config.py +0 -0
  24. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/tests/test_fastapi_utils.py +0 -0
  25. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/tests/test_s2s_auth.py +0 -0
  26. {auth_gate-0.2.2 → auth_gate-0.2.3}/src/tests/test_schema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth-gate
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Enterprise-grade authentication for microservices with Kong and Keycloak integration
5
5
  Home-page: https://github.com/tradelink-org/auth-gate
6
6
  Author: Brian Mburu
@@ -290,6 +290,44 @@ app.add_middleware(
290
290
  )
291
291
  ```
292
292
 
293
+ ### Parameterized Paths with UUID Matching
294
+
295
+ You can exclude or make paths optional using UUID v4 parameters:
296
+
297
+ ```python
298
+ app.add_middleware(
299
+ AuthMiddleware,
300
+ excluded_paths={
301
+ "/api/v1/categories/{category_id:uuid}": {"GET"}, # Public read
302
+ "/api/v1/products/{product_id:uuid}": {"GET"},
303
+ },
304
+ excluded_prefixes={
305
+ "/api/{version:uuid}": {"GET"}, # Version-specific docs
306
+ },
307
+ optional_auth_paths={
308
+ "/api/v1/recommendations/{user_id:uuid}": {"GET"}, # Personalized if authenticated
309
+ }
310
+ )
311
+ ```
312
+
313
+ **Pattern Syntax:**
314
+ - `{param:uuid}` - Matches valid UUID v4 format (case-insensitive)
315
+ - Works with exact paths, prefixes, and optional auth paths
316
+ - Supports method-specific exclusions
317
+ - Exact matches take precedence over patterns
318
+
319
+ **Example Behavior:**
320
+ ```python
321
+ # Matches: /api/v1/categories/7b5bcc8f-2c99-43c0-9c7d-e27c10881bd2
322
+ # Does not match: /api/v1/categories/invalid-id
323
+ # Does not match: /api/v1/categories/all
324
+ ```
325
+
326
+ **UUID v4 Validation:**
327
+ - Must have version digit "4" in the correct position
328
+ - Must have variant bits (8, 9, a, or b) in the correct position
329
+ - Accepts uppercase, lowercase, or mixed case
330
+
293
331
  ### Direct Validator Usage
294
332
 
295
333
  ```python
@@ -249,6 +249,44 @@ app.add_middleware(
249
249
  )
250
250
  ```
251
251
 
252
+ ### Parameterized Paths with UUID Matching
253
+
254
+ You can exclude or make paths optional using UUID v4 parameters:
255
+
256
+ ```python
257
+ app.add_middleware(
258
+ AuthMiddleware,
259
+ excluded_paths={
260
+ "/api/v1/categories/{category_id:uuid}": {"GET"}, # Public read
261
+ "/api/v1/products/{product_id:uuid}": {"GET"},
262
+ },
263
+ excluded_prefixes={
264
+ "/api/{version:uuid}": {"GET"}, # Version-specific docs
265
+ },
266
+ optional_auth_paths={
267
+ "/api/v1/recommendations/{user_id:uuid}": {"GET"}, # Personalized if authenticated
268
+ }
269
+ )
270
+ ```
271
+
272
+ **Pattern Syntax:**
273
+ - `{param:uuid}` - Matches valid UUID v4 format (case-insensitive)
274
+ - Works with exact paths, prefixes, and optional auth paths
275
+ - Supports method-specific exclusions
276
+ - Exact matches take precedence over patterns
277
+
278
+ **Example Behavior:**
279
+ ```python
280
+ # Matches: /api/v1/categories/7b5bcc8f-2c99-43c0-9c7d-e27c10881bd2
281
+ # Does not match: /api/v1/categories/invalid-id
282
+ # Does not match: /api/v1/categories/all
283
+ ```
284
+
285
+ **UUID v4 Validation:**
286
+ - Must have version digit "4" in the correct position
287
+ - Must have variant bits (8, 9, a, or b) in the correct position
288
+ - Accepts uppercase, lowercase, or mixed case
289
+
252
290
  ### Direct Validator Usage
253
291
 
254
292
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "auth-gate"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Enterprise-grade authentication for microservices with Kong and Keycloak integration"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = auth-gate
3
- version = 0.2.2
3
+ version = 0.2.3
4
4
  author = Brian Mburu
5
5
  author_email = brian.mburu@students.jkuat.ac.ke
6
6
  description = Enterprise-grade authentication for microservices with Kong and Keycloak integration
@@ -4,46 +4,67 @@ Tradelink Authentication Client
4
4
  Enterprise authentication client for microservices with Kong/Keycloak integration.
5
5
  """
6
6
 
7
- from .config import AuthMode, AuthSettings
7
+ from .config import AuthMode, AuthSettings, get_settings, reset_settings
8
8
  from .fastapi_utils import (
9
9
  get_current_auth,
10
10
  get_current_service,
11
11
  get_current_user,
12
12
  get_optional_auth,
13
13
  get_optional_user,
14
+ is_bypass_mode,
15
+ is_using_keycloak,
16
+ is_using_kong,
14
17
  require_admin,
15
18
  require_customer,
16
19
  require_moderator,
17
20
  require_roles,
18
21
  require_scopes,
22
+ require_service_roles,
19
23
  require_supplier,
20
24
  require_supplier_or_admin,
21
25
  require_user_admin,
22
26
  require_user_customer,
23
27
  require_user_moderator,
28
+ require_user_roles,
24
29
  require_user_supplier,
25
30
  verify_hmac_signature,
26
31
  )
27
32
  from .middleware import AuthMiddleware
28
- from .s2s_auth import CircuitBreaker, CircuitBreakerOpenError, ServiceAuthClient
29
- from .schemas import ServiceContext, UserContext
30
- from .user_auth import UserValidator
33
+ from .s2s_auth import (
34
+ CircuitBreaker,
35
+ CircuitBreakerOpenError,
36
+ CircuitState,
37
+ ServiceAuthClient,
38
+ ServiceToken,
39
+ get_service_auth_client,
40
+ )
41
+ from .schemas import AuthContext, BaseAuthContext, ServiceContext, UserContext
42
+ from .user_auth import HMACVerifier, UserValidator, get_user_validator
31
43
 
32
- __version__ = "0.2.2"
44
+ __version__ = "0.2.3"
33
45
 
34
46
  __all__ = [
35
47
  # Configuration
36
48
  "AuthSettings",
37
49
  "AuthMode",
38
- # Schemas
50
+ "get_settings",
51
+ "reset_settings",
52
+ # Schemas & Type Aliases
39
53
  "UserContext",
40
54
  "ServiceContext",
55
+ "AuthContext",
56
+ "BaseAuthContext",
41
57
  # User Authentication
42
58
  "UserValidator",
59
+ "HMACVerifier",
60
+ "get_user_validator",
43
61
  # Service-to-Service
44
62
  "ServiceAuthClient",
45
63
  "CircuitBreaker",
46
64
  "CircuitBreakerOpenError",
65
+ "CircuitState",
66
+ "ServiceToken",
67
+ "get_service_auth_client",
47
68
  # Middleware
48
69
  "AuthMiddleware",
49
70
  # FastAPI Dependencies
@@ -53,6 +74,8 @@ __all__ = [
53
74
  "get_optional_auth",
54
75
  "get_optional_user",
55
76
  "require_roles",
77
+ "require_user_roles",
78
+ "require_service_roles",
56
79
  "require_scopes",
57
80
  "require_admin",
58
81
  "require_supplier",
@@ -64,4 +87,8 @@ __all__ = [
64
87
  "require_user_customer",
65
88
  "require_user_supplier",
66
89
  "require_user_moderator",
90
+ # Mode Utilities
91
+ "is_using_kong",
92
+ "is_using_keycloak",
93
+ "is_bypass_mode",
67
94
  ]
@@ -3,8 +3,9 @@ FastAPI middleware for authentication (with service-to-service support)
3
3
  """
4
4
 
5
5
  import logging
6
+ import re
6
7
  import time
7
- from typing import Dict, Optional, Set, Union
8
+ from typing import Dict, List, Optional, Pattern, Set, Tuple, Union
8
9
 
9
10
  from fastapi import HTTPException, Request, status
10
11
  from fastapi.responses import JSONResponse
@@ -16,6 +17,12 @@ from .user_auth import get_user_validator
16
17
 
17
18
  logger = logging.getLogger(__name__)
18
19
 
20
+ # UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
21
+ # where y is 8, 9, a, or b (variant bits)
22
+ TYPE_PATTERNS = {
23
+ "uuid": r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
24
+ }
25
+
19
26
 
20
27
  class AuthMiddleware(BaseHTTPMiddleware):
21
28
  """
@@ -73,6 +80,16 @@ class AuthMiddleware(BaseHTTPMiddleware):
73
80
  # Paths where authentication is optional
74
81
  self.optional_auth_dict = self._normalize_paths(optional_auth_paths or set())
75
82
 
83
+ # Compile parameterized patterns
84
+ self._excluded_patterns = self._compile_patterns(self.excluded_paths_dict)
85
+ self._excluded_prefix_patterns = self._compile_patterns(
86
+ self.excluded_prefixes_dict, is_prefix=True
87
+ )
88
+ self._optional_patterns = self._compile_patterns(self.optional_auth_dict)
89
+
90
+ # Remove parameterized paths from literal dicts (they're now in pattern lists)
91
+ self._remove_parameterized_from_dicts()
92
+
76
93
  self.validator = get_user_validator()
77
94
  self.settings = get_settings()
78
95
 
@@ -94,25 +111,195 @@ class AuthMiddleware(BaseHTTPMiddleware):
94
111
  return normalized
95
112
  raise ValueError("Invalid type for paths: must be set or dict")
96
113
 
114
+ @staticmethod
115
+ def _has_parameters(path: str) -> bool:
116
+ """Check if path contains parameter placeholders like {param:type}"""
117
+ return "{" in path and "}" in path
118
+
119
+ def _compile_path_pattern(self, path: str, is_prefix: bool = False) -> Pattern:
120
+ """
121
+ Compile a parameterized path pattern to regex.
122
+
123
+ Supports syntax: /path/{param:type}/more
124
+
125
+ Types:
126
+ - uuid: UUID v4 format (case-insensitive)
127
+
128
+ Args:
129
+ path: Path pattern with {param:type} placeholders
130
+ is_prefix: If True, don't anchor end of pattern (for prefix matching)
131
+
132
+ Returns:
133
+ Compiled regex pattern
134
+
135
+ Raises:
136
+ ValueError: If syntax is invalid or type is unsupported
137
+
138
+ Examples:
139
+ >>> _compile_path_pattern('/api/{id:uuid}')
140
+ re.compile(r'^/api/[0-9a-f]{8}-...$', re.IGNORECASE)
141
+
142
+ >>> _compile_path_pattern('/api/{version:uuid}', is_prefix=True)
143
+ re.compile(r'^/api/[0-9a-f]{8}-...', re.IGNORECASE)
144
+ """
145
+ # Pattern to find parameter placeholders: {param:type}
146
+ param_pattern = r"\{([^}:]+)(?::([^}]+))?\}"
147
+
148
+ def replace_param(match: re.Match) -> str:
149
+ param_name = match.group(1).strip()
150
+ param_type = match.group(2).strip() if match.group(2) else None
151
+
152
+ if param_type is None:
153
+ raise ValueError(
154
+ f"Parameter '{{{param_name}}}' missing type annotation. "
155
+ f"Use {{param:type}} syntax (e.g., {{id:uuid}})"
156
+ )
157
+
158
+ if param_type not in TYPE_PATTERNS:
159
+ supported = ", ".join(TYPE_PATTERNS.keys())
160
+ raise ValueError(
161
+ f"Unsupported type '{param_type}' for parameter '{{{param_name}}}'. "
162
+ f"Supported types: {supported}"
163
+ )
164
+
165
+ return f"({TYPE_PATTERNS[param_type]})"
166
+
167
+ # Escape special regex characters in path
168
+ escaped_path = re.escape(path)
169
+
170
+ # Unescape { and } for parameter replacement
171
+ escaped_path = escaped_path.replace(r"\{", "{").replace(r"\}", "}")
172
+
173
+ # Replace parameters with regex patterns
174
+ regex_pattern = re.sub(param_pattern, replace_param, escaped_path)
175
+
176
+ # Anchor pattern
177
+ if is_prefix:
178
+ regex_pattern = f"^{regex_pattern}"
179
+ else:
180
+ regex_pattern = f"^{regex_pattern}$"
181
+
182
+ # Compile with case-insensitive flag for UUIDs
183
+ try:
184
+ return re.compile(regex_pattern, re.IGNORECASE)
185
+ except re.error as e:
186
+ raise ValueError(f"Failed to compile pattern for path '{path}': {e}")
187
+
188
+ def _compile_patterns(
189
+ self, paths_dict: Dict[str, Optional[Set[str]]], is_prefix: bool = False
190
+ ) -> List[Tuple[Pattern, Optional[Set[str]]]]:
191
+ """
192
+ Extract parameterized paths and compile them to regex patterns.
193
+
194
+ Args:
195
+ paths_dict: Dictionary of paths with optional method restrictions
196
+ is_prefix: If True, compile as prefix patterns (don't anchor end)
197
+
198
+ Returns:
199
+ List of (compiled_pattern, methods) tuples
200
+
201
+ Raises:
202
+ ValueError: If pattern syntax is invalid or type is unsupported
203
+ """
204
+ patterns = []
205
+
206
+ for path, methods in paths_dict.items():
207
+ if self._has_parameters(path):
208
+ try:
209
+ compiled_pattern = self._compile_path_pattern(path, is_prefix)
210
+ patterns.append((compiled_pattern, methods))
211
+ except ValueError as e:
212
+ raise ValueError(f"Invalid pattern in path '{path}': {e}")
213
+
214
+ return patterns
215
+
216
+ def _remove_parameterized_from_dicts(self):
217
+ """
218
+ Remove parameterized paths from literal path dictionaries.
219
+ They are now stored in pattern lists.
220
+ """
221
+ for path_dict in [
222
+ self.excluded_paths_dict,
223
+ self.excluded_prefixes_dict,
224
+ self.optional_auth_dict,
225
+ ]:
226
+ parameterized_keys = [key for key in path_dict.keys() if self._has_parameters(key)]
227
+ for key in parameterized_keys:
228
+ del path_dict[key]
229
+
97
230
  def is_excluded(self, path: str, method: str) -> bool:
98
- """Check if path is excluded from authentication for the given method"""
99
- # Exact match
231
+ """
232
+ Check if path is excluded from authentication for the given method.
233
+
234
+ Matching order (highest to lowest priority):
235
+ 1. Exact literal match in excluded_paths
236
+ 2. Parameterized pattern match in excluded_paths
237
+ 3. Prefix literal match in excluded_prefixes
238
+ 4. Prefix pattern match in excluded_prefixes
239
+
240
+ Args:
241
+ path: Request path (e.g., '/api/v1/categories/7b5bcc8f-...')
242
+ method: HTTP method (e.g., 'GET', 'POST')
243
+
244
+ Returns:
245
+ True if path is excluded from authentication for this method
246
+ """
247
+ # 1. Exact literal match (O(1) dict lookup)
100
248
  for p, methods in self.excluded_paths_dict.items():
101
249
  if path == p and (methods is None or method in methods):
250
+ logger.debug(f"Exact match excluded: {path} [{method}]")
251
+ return True
252
+
253
+ # 2. Parameterized pattern match (O(n) pattern checks)
254
+ for pattern, methods in self._excluded_patterns:
255
+ if pattern.match(path) and (methods is None or method in methods):
256
+ logger.debug(f"Pattern match excluded: {path} [{method}] via {pattern.pattern}")
102
257
  return True
103
258
 
104
- # Prefix match
259
+ # 3. Prefix literal match (O(n) prefix checks)
105
260
  for p, methods in self.excluded_prefixes_dict.items():
106
261
  if path.startswith(p) and (methods is None or method in methods):
262
+ logger.debug(f"Prefix match excluded: {path} [{method}]")
263
+ return True
264
+
265
+ # 4. Prefix pattern match (O(n) pattern checks)
266
+ for pattern, methods in self._excluded_prefix_patterns:
267
+ # For prefix patterns, check if path starts with pattern match
268
+ match = pattern.match(path)
269
+ if match and (methods is None or method in methods):
270
+ logger.debug(
271
+ f"Prefix pattern match excluded: {path} [{method}] via {pattern.pattern}"
272
+ )
107
273
  return True
108
274
 
109
275
  return False
110
276
 
111
277
  def is_optional_auth(self, path: str, method: str) -> bool:
112
- """Check if path has optional authentication for the given method"""
278
+ """
279
+ Check if path has optional authentication for the given method.
280
+
281
+ Checks both literal paths and parameterized patterns.
282
+
283
+ Args:
284
+ path: Request path
285
+ method: HTTP method
286
+
287
+ Returns:
288
+ True if authentication is optional for this path and method
289
+ """
290
+ # Exact literal match
113
291
  for p, methods in self.optional_auth_dict.items():
114
292
  if (path == p) and (methods is None or method in methods):
115
293
  return True
294
+
295
+ # Parameterized pattern match
296
+ for pattern, methods in self._optional_patterns:
297
+ if pattern.match(path) and (methods is None or method in methods):
298
+ logger.debug(
299
+ f"Pattern match optional auth: {path} [{method}] via {pattern.pattern}"
300
+ )
301
+ return True
302
+
116
303
  return False
117
304
 
118
305
  async def dispatch(self, request: Request, call_next):
@@ -347,7 +347,7 @@ class UserValidator:
347
347
  # Testing mode - return mock user
348
348
  logger.warning("SECURITY BYPASS MODE - FOR TESTING ONLY")
349
349
  return UserContext(
350
- user_id="test_user",
350
+ user_id="00000000-0000-0000-0000-000000000000",
351
351
  username="testuser",
352
352
  roles=["admin"],
353
353
  auth_source="bypass",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth-gate
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Enterprise-grade authentication for microservices with Kong and Keycloak integration
5
5
  Home-page: https://github.com/tradelink-org/auth-gate
6
6
  Author: Brian Mburu
@@ -290,6 +290,44 @@ app.add_middleware(
290
290
  )
291
291
  ```
292
292
 
293
+ ### Parameterized Paths with UUID Matching
294
+
295
+ You can exclude or make paths optional using UUID v4 parameters:
296
+
297
+ ```python
298
+ app.add_middleware(
299
+ AuthMiddleware,
300
+ excluded_paths={
301
+ "/api/v1/categories/{category_id:uuid}": {"GET"}, # Public read
302
+ "/api/v1/products/{product_id:uuid}": {"GET"},
303
+ },
304
+ excluded_prefixes={
305
+ "/api/{version:uuid}": {"GET"}, # Version-specific docs
306
+ },
307
+ optional_auth_paths={
308
+ "/api/v1/recommendations/{user_id:uuid}": {"GET"}, # Personalized if authenticated
309
+ }
310
+ )
311
+ ```
312
+
313
+ **Pattern Syntax:**
314
+ - `{param:uuid}` - Matches valid UUID v4 format (case-insensitive)
315
+ - Works with exact paths, prefixes, and optional auth paths
316
+ - Supports method-specific exclusions
317
+ - Exact matches take precedence over patterns
318
+
319
+ **Example Behavior:**
320
+ ```python
321
+ # Matches: /api/v1/categories/7b5bcc8f-2c99-43c0-9c7d-e27c10881bd2
322
+ # Does not match: /api/v1/categories/invalid-id
323
+ # Does not match: /api/v1/categories/all
324
+ ```
325
+
326
+ **UUID v4 Validation:**
327
+ - Must have version digit "4" in the correct position
328
+ - Must have variant bits (8, 9, a, or b) in the correct position
329
+ - Accepts uppercase, lowercase, or mixed case
330
+
293
331
  ### Direct Validator Usage
294
332
 
295
333
  ```python
@@ -299,7 +299,7 @@ class TestEndToEndIntegration:
299
299
  # Mock the validator's get_current_user to return bypass user
300
300
  mock_validator.return_value.get_current_user = AsyncMock(
301
301
  return_value=UserContext(
302
- user_id="test_user",
302
+ user_id="00000000-0000-0000-0000-000000000000",
303
303
  username="testuser",
304
304
  roles=["admin"],
305
305
  auth_source="bypass",
@@ -314,7 +314,7 @@ class TestEndToEndIntegration:
314
314
  # Should work without any headers
315
315
  response = client.get("/api/profile")
316
316
  assert response.status_code == 200
317
- assert response.json()["user_id"] == "test_user"
317
+ assert response.json()["user_id"] == "00000000-0000-0000-0000-000000000000"
318
318
 
319
319
  # Admin endpoint should work (due to "admin" role)
320
320
  response = client.get("/api/admin/users")
@@ -162,7 +162,7 @@ class TestAuthMiddleware:
162
162
  # Should work without any auth headers in bypass mode
163
163
  response = client.get("/protected")
164
164
  assert response.status_code == 200
165
- assert response.json()["user_id"] == "test_user"
165
+ assert response.json()["user_id"] == "00000000-0000-0000-0000-000000000000"
166
166
 
167
167
 
168
168
  class TestMethodSpecificExclusions:
@@ -416,3 +416,346 @@ class TestMethodSpecificExclusions:
416
416
 
417
417
  response = client.get("/protected", headers=kong_headers)
418
418
  assert response.status_code == 200
419
+
420
+
421
+ class TestParameterizedPathMatching:
422
+ """Test parameterized path matching with UUID v4 patterns"""
423
+
424
+ def test_uuid_path_parameter_basic(self, mock_settings, kong_headers):
425
+ """Test basic UUID parameter matching"""
426
+ mock_settings.AUTH_MODE = "kong_headers"
427
+
428
+ with patch("auth_gate.middleware.get_settings", return_value=mock_settings):
429
+ app = FastAPI()
430
+
431
+ valid_uuid = "7b5bcc8f-2c99-43c0-9c7d-e27c10881bd2"
432
+
433
+ @app.get(f"/api/v1/categories/{valid_uuid}")
434
+ async def get_category():
435
+ return {"id": valid_uuid}
436
+
437
+ app.add_middleware(
438
+ AuthMiddleware,
439
+ excluded_paths={
440
+ "/api/v1/categories/{category_id:uuid}": None,
441
+ },
442
+ )
443
+
444
+ client = TestClient(app)
445
+
446
+ # Valid UUID should be excluded (no auth needed)
447
+ response = client.get(f"/api/v1/categories/{valid_uuid}")
448
+ assert response.status_code == 200
449
+ assert response.json()["id"] == valid_uuid
450
+
451
+ def test_uuid_path_parameter_with_methods(self, mock_settings, kong_headers):
452
+ """Test UUID parameters with method-specific exclusions"""
453
+ mock_settings.AUTH_MODE = "kong_headers"
454
+
455
+ with patch("auth_gate.middleware.get_settings", return_value=mock_settings):
456
+ app = FastAPI()
457
+
458
+ valid_uuid = "a1b2c3d4-e5f6-4789-abcd-ef0123456789"
459
+
460
+ @app.get(f"/api/v1/products/{valid_uuid}")
461
+ async def get_product():
462
+ return {"id": valid_uuid, "method": "GET"}
463
+
464
+ @app.post(f"/api/v1/products/{valid_uuid}")
465
+ async def update_product(request: Request):
466
+ user = request.state.user
467
+ return {"id": valid_uuid, "method": "POST", "user": user.user_id}
468
+
469
+ app.add_middleware(
470
+ AuthMiddleware,
471
+ excluded_paths={
472
+ "/api/v1/products/{product_id:uuid}": {"GET"}, # Only GET excluded
473
+ },
474
+ )
475
+
476
+ client = TestClient(app)
477
+
478
+ # GET should work without auth (excluded)
479
+ response = client.get(f"/api/v1/products/{valid_uuid}")
480
+ assert response.status_code == 200
481
+ assert response.json()["method"] == "GET"
482
+
483
+ # POST should require auth (not excluded)
484
+ response = client.post(f"/api/v1/products/{valid_uuid}")
485
+ assert response.status_code == 401
486
+
487
+ # POST with auth should work
488
+ response = client.post(f"/api/v1/products/{valid_uuid}", headers=kong_headers)
489
+ assert response.status_code == 200
490
+ assert response.json()["user"] == "test-user-123"
491
+
492
+ def test_uuid_invalid_format_requires_auth(self, mock_settings):
493
+ """Test that invalid UUID formats don't match pattern"""
494
+ mock_settings.AUTH_MODE = "kong_headers"
495
+
496
+ with patch("auth_gate.middleware.get_settings", return_value=mock_settings):
497
+ app = FastAPI()
498
+
499
+ @app.get("/api/v1/categories/{category_id}")
500
+ async def get_category(category_id: str):
501
+ return {"id": category_id}
502
+
503
+ app.add_middleware(
504
+ AuthMiddleware,
505
+ excluded_paths={
506
+ "/api/v1/categories/{category_id:uuid}": None,
507
+ },
508
+ )
509
+
510
+ client = TestClient(app)
511
+
512
+ # Invalid UUIDs should require auth
513
+ invalid_ids = [
514
+ "invalid-id",
515
+ "123",
516
+ "all",
517
+ "not-a-uuid",
518
+ "7b5bcc8f", # Too short
519
+ "7b5bcc8f-2c99-43c0-9c7d-e27c10881bd2-extra", # Too long
520
+ "7b5bcc8f-2c99-53c0-9c7d-e27c10881bd2", # Wrong version (5 instead of 4)
521
+ ]
522
+
523
+ for invalid_id in invalid_ids:
524
+ response = client.get(f"/api/v1/categories/{invalid_id}")
525
+ assert response.status_code == 401, f"Expected 401 for {invalid_id}"
526
+
527
+ def test_exact_match_takes_precedence_over_pattern(self, mock_settings, kong_headers):
528
+ """Test that exact paths have higher priority than patterns"""
529
+ mock_settings.AUTH_MODE = "kong_headers"
530
+
531
+ with patch("auth_gate.middleware.get_settings", return_value=mock_settings):
532
+ app = FastAPI()
533
+
534
+ @app.get("/api/v1/categories/featured")
535
+ async def get_featured():
536
+ return {"type": "featured"}
537
+
538
+ @app.post("/api/v1/categories/featured")
539
+ async def update_featured():
540
+ return {"type": "updated"}
541
+
542
+ app.add_middleware(
543
+ AuthMiddleware,
544
+ excluded_paths={
545
+ "/api/v1/categories/featured": None, # Exact match - all methods
546
+ "/api/v1/categories/{category_id:uuid}": {"GET"}, # Pattern - GET only
547
+ },
548
+ )
549
+
550
+ client = TestClient(app)
551
+
552
+ # Exact match should work for all methods (None = all methods)
553
+ response = client.get("/api/v1/categories/featured")
554
+ assert response.status_code == 200
555
+
556
+ response = client.post("/api/v1/categories/featured")
557
+ assert response.status_code == 200 # Exact match wins, POST excluded
558
+
559
+ def test_multiple_uuid_parameters(self, mock_settings):
560
+ """Test paths with multiple UUID parameters"""
561
+ mock_settings.AUTH_MODE = "kong_headers"
562
+
563
+ with patch("auth_gate.middleware.get_settings", return_value=mock_settings):
564
+ app = FastAPI()
565
+
566
+ store_id = "550e8400-e29b-41d4-a716-446655440000"
567
+ product_id = "7b5bcc8f-2c99-43c0-9c7d-e27c10881bd2"
568
+
569
+ @app.get(f"/api/v1/stores/{store_id}/products/{product_id}")
570
+ async def get_product():
571
+ return {"store_id": store_id, "product_id": product_id}
572
+
573
+ app.add_middleware(
574
+ AuthMiddleware,
575
+ excluded_paths={
576
+ "/api/v1/stores/{store_id:uuid}/products/{product_id:uuid}": {"GET"},
577
+ },
578
+ )
579
+
580
+ client = TestClient(app)
581
+
582
+ # Valid UUIDs should match
583
+ response = client.get(f"/api/v1/stores/{store_id}/products/{product_id}")
584
+ assert response.status_code == 200
585
+ assert response.json()["store_id"] == store_id
586
+ assert response.json()["product_id"] == product_id
587
+
588
+ # Invalid UUID in either position should require auth
589
+ response = client.get(f"/api/v1/stores/invalid/products/{product_id}")
590
+ assert response.status_code == 401
591
+
592
+ response = client.get(f"/api/v1/stores/{store_id}/products/invalid")
593
+ assert response.status_code == 401
594
+
595
+ def test_pattern_match_with_optional_auth(self, mock_settings, kong_headers):
596
+ """Test parameterized paths with optional authentication"""
597
+ mock_settings.AUTH_MODE = "kong_headers"
598
+
599
+ with patch("auth_gate.middleware.get_settings", return_value=mock_settings):
600
+ app = FastAPI()
601
+
602
+ user_id = "123e4567-e89b-42d3-a456-426614174000"
603
+
604
+ @app.get(f"/api/v1/recommendations/{user_id}")
605
+ async def get_recommendations(request: Request):
606
+ user = getattr(request.state, "user", None)
607
+ if user:
608
+ return {"personalized": True, "user_id": user.user_id}
609
+ return {"personalized": False}
610
+
611
+ app.add_middleware(
612
+ AuthMiddleware,
613
+ optional_auth_paths={
614
+ "/api/v1/recommendations/{user_id:uuid}": {"GET"},
615
+ },
616
+ )
617
+
618
+ client = TestClient(app)
619
+
620
+ # Without auth - should work but not personalized
621
+ response = client.get(f"/api/v1/recommendations/{user_id}")
622
+ assert response.status_code == 200
623
+ assert response.json()["personalized"] is False
624
+
625
+ # With auth - should work and be personalized
626
+ response = client.get(f"/api/v1/recommendations/{user_id}", headers=kong_headers)
627
+ assert response.status_code == 200
628
+ assert response.json()["personalized"] is True
629
+ assert response.json()["user_id"] == "test-user-123"
630
+
631
+ def test_prefix_pattern_matching(self, mock_settings):
632
+ """Test parameterized prefix matching"""
633
+ mock_settings.AUTH_MODE = "kong_headers"
634
+
635
+ with patch("auth_gate.middleware.get_settings", return_value=mock_settings):
636
+ app = FastAPI()
637
+
638
+ version_id = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
639
+
640
+ @app.get(f"/api/{version_id}/docs")
641
+ async def get_docs():
642
+ return {"version": version_id, "type": "docs"}
643
+
644
+ @app.get(f"/api/{version_id}/swagger")
645
+ async def get_swagger():
646
+ return {"version": version_id, "type": "swagger"}
647
+
648
+ app.add_middleware(
649
+ AuthMiddleware,
650
+ excluded_prefixes={
651
+ "/api/{version:uuid}": {"GET"},
652
+ },
653
+ )
654
+
655
+ client = TestClient(app)
656
+
657
+ # Both should be excluded (prefix match)
658
+ response = client.get(f"/api/{version_id}/docs")
659
+ assert response.status_code == 200
660
+
661
+ response = client.get(f"/api/{version_id}/swagger")
662
+ assert response.status_code == 200
663
+
664
+ def test_backward_compatibility_no_regression(self, mock_settings, kong_headers):
665
+ """Test that existing configurations still work"""
666
+ mock_settings.AUTH_MODE = "kong_headers"
667
+
668
+ with patch("auth_gate.middleware.get_settings", return_value=mock_settings):
669
+ app = FastAPI()
670
+
671
+ @app.get("/health")
672
+ async def health():
673
+ return {"status": "ok"}
674
+
675
+ @app.get("/api/docs/swagger")
676
+ async def swagger():
677
+ return {"docs": "swagger"}
678
+
679
+ @app.get("/protected")
680
+ async def protected(request: Request):
681
+ user = request.state.user
682
+ return {"user_id": user.user_id}
683
+
684
+ app.add_middleware(
685
+ AuthMiddleware,
686
+ excluded_paths={"/health"}, # Set format (legacy)
687
+ excluded_prefixes={"/api/docs": {"GET"}}, # Dict with methods
688
+ )
689
+
690
+ client = TestClient(app)
691
+
692
+ # Exact path exclusion still works
693
+ response = client.get("/health")
694
+ assert response.status_code == 200
695
+
696
+ # Prefix exclusion still works
697
+ response = client.get("/api/docs/swagger")
698
+ assert response.status_code == 200
699
+
700
+ # Protected endpoint still requires auth
701
+ response = client.get("/protected")
702
+ assert response.status_code == 401
703
+
704
+ response = client.get("/protected", headers=kong_headers)
705
+ assert response.status_code == 200
706
+
707
+ def test_invalid_pattern_syntax_raises_error(self, mock_settings):
708
+ """Test that invalid patterns raise clear errors"""
709
+ mock_settings.AUTH_MODE = "kong_headers"
710
+
711
+ with patch("auth_gate.middleware.get_settings", return_value=mock_settings):
712
+ with patch("auth_gate.middleware.get_user_validator"):
713
+ # Missing type annotation
714
+ with pytest.raises(ValueError, match="missing type annotation"):
715
+ AuthMiddleware(
716
+ app=None,
717
+ excluded_paths={
718
+ "/api/{id}": None, # No :type specified
719
+ },
720
+ )
721
+
722
+ # Unsupported type
723
+ with pytest.raises(ValueError, match="Unsupported type"):
724
+ AuthMiddleware(
725
+ app=None,
726
+ excluded_paths={
727
+ "/api/{id:invalid}": None,
728
+ },
729
+ )
730
+
731
+ def test_case_insensitive_uuid_matching(self, mock_settings):
732
+ """Test that UUID matching is case-insensitive"""
733
+ mock_settings.AUTH_MODE = "kong_headers"
734
+
735
+ with patch("auth_gate.middleware.get_settings", return_value=mock_settings):
736
+ app = FastAPI()
737
+
738
+ @app.get("/api/v1/items/{item_id}")
739
+ async def get_item(item_id: str):
740
+ return {"id": item_id}
741
+
742
+ app.add_middleware(
743
+ AuthMiddleware,
744
+ excluded_paths={
745
+ "/api/v1/items/{item_id:uuid}": None,
746
+ },
747
+ )
748
+
749
+ client = TestClient(app)
750
+
751
+ # Test different case variations (all valid UUID v4)
752
+ uuids = [
753
+ "7b5bcc8f-2c99-43c0-9c7d-e27c10881bd2", # lowercase
754
+ "7B5BCC8F-2C99-43C0-9C7D-E27C10881BD2", # uppercase
755
+ "7b5bCc8f-2C99-43c0-9C7d-E27c10881bD2", # mixed case
756
+ ]
757
+
758
+ for uuid_val in uuids:
759
+ response = client.get(f"/api/v1/items/{uuid_val}")
760
+ assert response.status_code == 200, f"Failed for UUID: {uuid_val}"
761
+ assert response.json()["id"] == uuid_val
@@ -217,7 +217,7 @@ class TestUserValidator:
217
217
  user = await validator.get_current_user(request)
218
218
 
219
219
  assert isinstance(user, UserContext)
220
- assert user.user_id == "test_user"
220
+ assert user.user_id == "00000000-0000-0000-0000-000000000000"
221
221
  assert user.username == "testuser"
222
222
  assert user.roles == ["admin"]
223
223
  assert user.auth_source == "bypass"
File without changes