api-foundry-query-engine 0.8.34__tar.gz → 0.8.36__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 (39) hide show
  1. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/PKG-INFO +1 -1
  2. api_foundry_query_engine-0.8.36/api_foundry_query_engine/__init__.py +1 -0
  3. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/connectors/postgres_connection.py +21 -1
  4. api_foundry_query_engine-0.8.36/api_foundry_query_engine/utils/token_decoder.py +624 -0
  5. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/pyproject.toml +1 -1
  6. api_foundry_query_engine-0.8.34/api_foundry_query_engine/__init__.py +0 -0
  7. api_foundry_query_engine-0.8.34/api_foundry_query_engine/utils/token_decoder.py +0 -415
  8. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/.gitignore +0 -0
  9. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/LICENSE +0 -0
  10. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/.pre-commit-config.yaml +0 -0
  11. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/adapters/adapter.py +0 -0
  12. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/adapters/case_change_adapter.py +0 -0
  13. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/adapters/gateway_adapter.py +0 -0
  14. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/adapters/security_adapter.py +0 -0
  15. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/connectors/connection.py +0 -0
  16. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/connectors/connection_factory.py +0 -0
  17. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/connectors/oracle_connector.py +0 -0
  18. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/batch_operation_handler.py +0 -0
  19. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/dao.py +0 -0
  20. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/operation_dao.py +0 -0
  21. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/sql_custom_query_handler.py +0 -0
  22. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/sql_delete_query_handler.py +0 -0
  23. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/sql_insert_query_handler.py +0 -0
  24. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/sql_query_handler.py +0 -0
  25. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/sql_restore_query_handler.py +0 -0
  26. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/sql_select_query_handler.py +0 -0
  27. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/sql_subselect_query_handler.py +0 -0
  28. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/dao/sql_update_query_handler.py +0 -0
  29. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/lambda_handler.py +0 -0
  30. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/operation.py +0 -0
  31. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/services/service.py +0 -0
  32. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/services/transactional_service.py +0 -0
  33. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/utils/api_model.py +0 -0
  34. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/utils/app_exception.py +0 -0
  35. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/utils/claims_check.py +0 -0
  36. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/utils/dependency_resolver.py +0 -0
  37. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/utils/gateway_operation.py +0 -0
  38. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/utils/logger.py +0 -0
  39. {api_foundry_query_engine-0.8.34 → api_foundry_query_engine-0.8.36}/api_foundry_query_engine/utils/reference_resolver.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api-foundry-query-engine
3
- Version: 0.8.34
3
+ Version: 0.8.36
4
4
  Summary: The AWS lambda service handler use by the `api_foundry` project is a powerful tool designed to automate the deployment of REST APIs on AWS using Lambda services to access and interact with relational databases (RDBMS). This project leverages the OpenAPI specification to define and manage the APIs
5
5
  Project-URL: Documentation, https://github.com/DanRepik/api-foundry
6
6
  Project-URL: Source, https://github.com/DanRepik/api-foundry
@@ -0,0 +1 @@
1
+ __version__ = "0.8.35"
@@ -67,6 +67,20 @@ class PostgresCursor(Cursor):
67
67
 
68
68
 
69
69
  class PostgresConnection(Connection):
70
+ """
71
+ PostgreSQL database connection wrapper.
72
+
73
+ Supports two configuration formats:
74
+ 1. DSN-based (preferred for testing with fixture_foundry):
75
+ {"dsn": "postgresql://user:pass@host:port/dbname"}
76
+
77
+ 2. Individual parameters (for production AWS Secrets Manager):
78
+ {"host": "...", "port": 5432, "database": "...", "username": "...", "password": "..."}
79
+
80
+ The get_connection() method prioritizes DSN if present, otherwise builds connection
81
+ from individual parameters.
82
+ """
83
+
70
84
  def __init__(self, db_config: dict) -> None:
71
85
  super().__init__(db_config)
72
86
  self.__connection = self.get_connection()
@@ -96,10 +110,16 @@ class PostgresConnection(Connection):
96
110
  """
97
111
  from psycopg2 import connect
98
112
 
113
+ # If DSN is provided, use it directly (simplifies fixture_foundry integration)
114
+ if "dsn" in self.db_config:
115
+ log.info("Connecting using DSN: %s", self.db_config["dsn"])
116
+ return connect(self.db_config["dsn"])
117
+
118
+ # Otherwise, build connection from individual parameters
99
119
  dbname = self.db_config["database"]
100
120
  user = self.db_config["username"]
101
121
  password = self.db_config["password"]
102
- host = self.db_config["host"]
122
+ host = self.db_config.get("host", "localhost")
103
123
  port = self.db_config.get("port", 5432)
104
124
  additional_config = self.db_config.get("configuration", {})
105
125
 
@@ -0,0 +1,624 @@
1
+ """JWT Decoder Filter for AWS Lambda Functions.
2
+
3
+ This module provides a decorator-based JWT token decoder that extracts JWT
4
+ tokens from Authorization headers and exposes the claims through
5
+ requestContext.authorizer to maintain compatibility with API Gateway TOKEN
6
+ authorizers.
7
+
8
+ The filter acts like a Java servlet filter but for Lambda functions, providing
9
+ a clean separation of concerns between authentication and business logic.
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import functools
15
+ import os
16
+ import base64
17
+ from typing import Callable, Any, Dict, Optional
18
+
19
+ __all__ = ["token_decoder"]
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ def _log_jwt_configuration():
25
+ """Log current JWT configuration for debugging."""
26
+ log.debug("=== JWT Configuration Debug ===")
27
+ log.debug("JWKS_HOST: %s", os.getenv("JWKS_HOST", "NOT_SET"))
28
+ log.debug("JWT_ISSUER: %s", os.getenv("JWT_ISSUER", "NOT_SET"))
29
+ log.debug(
30
+ "JWT_ALLOWED_AUDIENCES: %s", os.getenv("JWT_ALLOWED_AUDIENCES", "NOT_SET")
31
+ )
32
+ log.debug("ANONYMOUS_ROLE: %s", os.getenv("ANONYMOUS_ROLE", "NOT_SET"))
33
+ log.debug(
34
+ "TOKEN_VALIDATOR_LAMBDA_ARN: %s",
35
+ os.getenv("TOKEN_VALIDATOR_LAMBDA_ARN", "NOT_SET"),
36
+ )
37
+ log.debug("Logging level: %s", logging.getLogger().getEffectiveLevel())
38
+ log.debug("===============================")
39
+
40
+
41
+ class LambdaTokenValidator:
42
+ """Validates tokens by invoking an AWS Lambda TOKEN authorizer."""
43
+
44
+ def __init__(self, lambda_arn: str):
45
+ """
46
+ Initialize Lambda token validator.
47
+
48
+ Args:
49
+ lambda_arn: ARN or name of the Lambda authorizer function
50
+ """
51
+ self.lambda_arn = lambda_arn
52
+ try:
53
+ import boto3
54
+
55
+ self.lambda_client = boto3.client("lambda")
56
+ log.debug(
57
+ "Lambda client initialized for validator: %s", lambda_arn
58
+ )
59
+ except ImportError:
60
+ log.error("boto3 not available for Lambda token validation")
61
+ raise ImportError(
62
+ "boto3 is required for Lambda token validation"
63
+ )
64
+
65
+ def validate(
66
+ self, token: str, method_arn: str = "arn:aws:execute-api:*:*:*"
67
+ ) -> Dict[str, Any]:
68
+ """
69
+ Invoke AWS Lambda TOKEN authorizer.
70
+
71
+ Args:
72
+ token: JWT token (without "Bearer " prefix)
73
+ method_arn: Method ARN for resource-based policies
74
+
75
+ Returns:
76
+ Claims dict (extracted from context field)
77
+
78
+ Raises:
79
+ ValueError: If validation fails
80
+ """
81
+ log.debug("Invoking Lambda authorizer: %s", self.lambda_arn)
82
+
83
+ payload = {
84
+ "type": "TOKEN",
85
+ "authorizationToken": f"Bearer {token}",
86
+ "methodArn": method_arn,
87
+ }
88
+
89
+ try:
90
+ response = self.lambda_client.invoke(
91
+ FunctionName=self.lambda_arn,
92
+ InvocationType="RequestResponse",
93
+ Payload=json.dumps(payload),
94
+ )
95
+
96
+ result = json.loads(response["Payload"].read())
97
+ log.debug("Lambda authorizer response received")
98
+
99
+ # Check for Lambda errors
100
+ if "FunctionError" in response:
101
+ error_msg = result.get("errorMessage", "Unknown error")
102
+ log.error("Lambda authorizer error: %s", error_msg)
103
+ raise ValueError(f"Token validation failed: {error_msg}")
104
+
105
+ # Extract context (claims) from authorizer response
106
+ # AWS API Gateway places the 'context' object into
107
+ # requestContext.authorizer
108
+ context = result.get("context", {})
109
+ if not context:
110
+ log.warning("Authorizer returned no context/claims")
111
+ raise ValueError("Authorizer returned no context/claims")
112
+
113
+ log.debug(
114
+ "Token validated successfully, claims extracted from context"
115
+ )
116
+ return context
117
+
118
+ except Exception as e:
119
+ log.error("Lambda authorizer invocation failed: %s", e)
120
+ raise ValueError(f"Token validation failed: {str(e)}")
121
+
122
+
123
+ def token_decoder(
124
+ jwks_url: Optional[str] = None,
125
+ audience: Optional[str] = None,
126
+ issuer: Optional[str] = None,
127
+ algorithms: Optional[list[str]] = None,
128
+ anonymous_role: Optional[str] = None,
129
+ lambda_validator_arn: Optional[str] = None,
130
+ ):
131
+ """
132
+ JWT token decoder decorator for AWS Lambda handlers.
133
+
134
+ This filter extracts JWT tokens from the Authorization header, validates
135
+ them using JWKS, Lambda authorizer, or falls back to anonymous access.
136
+
137
+ Validation priority:
138
+ 1. Check if requestContext.authorizer exists (gateway validated)
139
+ 2. Try Lambda validation if TOKEN_VALIDATOR_LAMBDA_ARN is set
140
+ 3. Try JWKS validation if JWKS_HOST is set
141
+ 4. Fall back to anonymous role if ANONYMOUS_ROLE is set
142
+ 5. Reject with error
143
+
144
+ Args:
145
+ jwks_url: URL to fetch JWKS from
146
+ audience: Expected audience claim (or list of audiences)
147
+ issuer: Expected issuer claim
148
+ algorithms: List of allowed algorithms (defaults to ["RS256"])
149
+ anonymous_role: Role to assign to anonymous requests.
150
+ Set via ANONYMOUS_ROLE environment variable.
151
+ lambda_validator_arn: ARN of Lambda TOKEN authorizer function.
152
+ Set via TOKEN_VALIDATOR_LAMBDA_ARN env var.
153
+
154
+ Returns:
155
+ Decorated handler function that processes JWT tokens
156
+
157
+ Example:
158
+ # JWKS validation
159
+ @token_decoder(
160
+ jwks_url="https://oauth.local/.well-known/jwks.json",
161
+ audience="test-api",
162
+ issuer="https://oauth.local/",
163
+ )
164
+ def handler(event, context):
165
+ return {'statusCode': 200, 'body': 'Secured'}
166
+
167
+ # Lambda authorizer validation
168
+ @token_decoder() # Uses TOKEN_VALIDATOR_LAMBDA_ARN env var
169
+ def handler(event, context):
170
+ return {'statusCode': 200, 'body': 'Secured'}
171
+
172
+ # Public endpoint
173
+ @token_decoder(anonymous_role="public")
174
+ def handler(event, context):
175
+ return {'statusCode': 200, 'body': 'Public'}
176
+ """
177
+
178
+ # Validate configuration at decorator time
179
+ config_anonymous_role = anonymous_role or os.getenv("ANONYMOUS_ROLE")
180
+ config_lambda_arn = lambda_validator_arn or os.getenv(
181
+ "TOKEN_VALIDATOR_LAMBDA_ARN"
182
+ )
183
+
184
+ # If no config provided, allow for runtime configuration
185
+ if (
186
+ not config_anonymous_role
187
+ and not jwks_url
188
+ and not os.getenv("JWKS_HOST")
189
+ and not config_lambda_arn
190
+ ):
191
+ pass
192
+
193
+ def decorator(handler: Callable) -> Callable:
194
+ @functools.wraps(handler)
195
+ def wrapper(event: dict, context: Any) -> dict:
196
+ log.debug(
197
+ "JWT token decoder starting for function: %s",
198
+ handler.__name__,
199
+ )
200
+
201
+ # Log configuration for debugging
202
+ if log.isEnabledFor(logging.DEBUG):
203
+ _log_jwt_configuration()
204
+
205
+ log.debug("Event structure: %s", json.dumps(event, default=str))
206
+
207
+ # Check if authorizer already exists (gateway validated)
208
+ if event.get("requestContext", {}).get("authorizer"):
209
+ log.debug(
210
+ "Authorizer already exists, skipping JWT processing"
211
+ )
212
+ return handler(event, context)
213
+
214
+ # Determine configuration sources
215
+ config_lambda_arn = lambda_validator_arn or os.getenv(
216
+ "TOKEN_VALIDATOR_LAMBDA_ARN"
217
+ )
218
+ config_jwks_url = (
219
+ jwks_url
220
+ or f"{os.getenv('JWKS_HOST')}/.well-known/jwks.json"
221
+ if os.getenv("JWKS_HOST")
222
+ else None
223
+ )
224
+ config_issuer = issuer or os.getenv("JWT_ISSUER")
225
+ config_audience = (
226
+ audience
227
+ or os.getenv("JWT_ALLOWED_AUDIENCES", "").split(",")
228
+ if os.getenv("JWT_ALLOWED_AUDIENCES")
229
+ else []
230
+ )
231
+ config_algorithms = algorithms or ["RS256"]
232
+ config_anonymous_role = anonymous_role or os.getenv(
233
+ "ANONYMOUS_ROLE"
234
+ )
235
+ log.debug("anonymous_role parameter: %s", anonymous_role)
236
+ log.debug(
237
+ "ANONYMOUS_ROLE env: %s", os.getenv("ANONYMOUS_ROLE", "")
238
+ )
239
+ log.debug("config_anonymous_role: %s", config_anonymous_role)
240
+ log.debug("config_lambda_arn: %s", config_lambda_arn)
241
+
242
+ # Skip all validation if no method configured
243
+ if not config_lambda_arn and not config_jwks_url:
244
+ log.debug("No validation method configured")
245
+ if config_anonymous_role:
246
+ log.debug(
247
+ "Using anonymous role: %s", config_anonymous_role
248
+ )
249
+ if "requestContext" not in event:
250
+ event["requestContext"] = {}
251
+ event["requestContext"]["authorizer"] = {
252
+ "roles": [config_anonymous_role]
253
+ }
254
+ return handler(event, context)
255
+
256
+ try:
257
+ log.debug("Processing JWT token extraction and validation")
258
+
259
+ # Set up validation instances
260
+ if not hasattr(wrapper, "_jwt_decoder"):
261
+ log.debug("Creating validator instances")
262
+
263
+ # Lambda validator takes priority
264
+ if config_lambda_arn:
265
+ log.debug(
266
+ "Configuring Lambda validator: %s",
267
+ config_lambda_arn,
268
+ )
269
+ wrapper._lambda_validator = LambdaTokenValidator(
270
+ config_lambda_arn
271
+ )
272
+ else:
273
+ wrapper._lambda_validator = None
274
+
275
+ # JWKS validator as fallback
276
+ if config_jwks_url:
277
+ log.debug(
278
+ "Configuring JWKS validator: %s",
279
+ config_jwks_url,
280
+ )
281
+ wrapper._jwt_decoder = JWTDecoder(
282
+ jwks_url=config_jwks_url,
283
+ issuer=config_issuer,
284
+ allowed_audiences=set(config_audience)
285
+ if config_audience
286
+ else None,
287
+ algorithms=config_algorithms,
288
+ anonymous_role=config_anonymous_role,
289
+ )
290
+ else:
291
+ wrapper._jwt_decoder = None
292
+
293
+ wrapper._anonymous_role = config_anonymous_role
294
+
295
+ log.debug("Parsing token from event")
296
+ token = None
297
+ try:
298
+ # Extract token
299
+ auth_header = (
300
+ event.get("authorizationToken")
301
+ or event.get("headers", {}).get("Authorization")
302
+ or event.get("headers", {}).get("authorization")
303
+ )
304
+
305
+ if auth_header:
306
+ auth_parts = auth_header.split(" ")
307
+ if (
308
+ len(auth_parts) == 2
309
+ and auth_parts[0].lower() == "bearer"
310
+ ):
311
+ token = auth_parts[1]
312
+ log.debug("Token extracted, length: %d", len(token))
313
+ except Exception:
314
+ log.debug("Token extraction failed")
315
+
316
+ decoded_token = None
317
+
318
+ # Try Lambda validator first
319
+ if token and wrapper._lambda_validator:
320
+ try:
321
+ log.debug("Attempting Lambda validation")
322
+ decoded_token = wrapper._lambda_validator.validate(
323
+ token
324
+ )
325
+ log.debug("Lambda validation successful")
326
+ except Exception as e:
327
+ log.warning("Lambda validation failed: %s", e)
328
+ # Fall through to JWKS
329
+
330
+ # Try JWKS validator if Lambda failed or unavailable
331
+ if token and not decoded_token and wrapper._jwt_decoder:
332
+ try:
333
+ log.debug("Attempting JWKS validation")
334
+ decoded_token = wrapper._jwt_decoder.decode_token(
335
+ token
336
+ )
337
+ log.debug("JWKS validation successful")
338
+ except Exception as e:
339
+ log.warning("JWKS validation failed: %s", e)
340
+
341
+ # Fall back to anonymous if configured
342
+ if not decoded_token:
343
+ if wrapper._anonymous_role:
344
+ log.debug(
345
+ "Using anonymous role: %s",
346
+ wrapper._anonymous_role,
347
+ )
348
+ decoded_token = {
349
+ "roles": [wrapper._anonymous_role]
350
+ }
351
+ else:
352
+ log.error("No valid token and no anonymous access")
353
+ return {
354
+ "statusCode": 401,
355
+ "headers": {"Content-Type": "application/json"},
356
+ "body": json.dumps(
357
+ {"error": "Unauthorized"}
358
+ ),
359
+ }
360
+
361
+ log.debug(
362
+ "JWT token result: %s",
363
+ json.dumps(decoded_token, default=str)
364
+ if decoded_token
365
+ else "None",
366
+ )
367
+
368
+ # Populate requestContext
369
+ if "requestContext" not in event:
370
+ event["requestContext"] = {}
371
+ if "authorizer" not in event["requestContext"]:
372
+ event["requestContext"]["authorizer"] = {}
373
+ event["requestContext"]["authorizer"] = decoded_token
374
+
375
+ return handler(event, context)
376
+
377
+ except Exception as e:
378
+ log.error("JWT filter critical error: %s", str(e))
379
+ return {
380
+ "statusCode": 500,
381
+ "headers": {"Content-Type": "application/json"},
382
+ "body": json.dumps({"error": "Internal server error"}),
383
+ }
384
+
385
+ return wrapper
386
+
387
+ return decorator
388
+
389
+
390
+ class JWTDecoder:
391
+ def __init__(
392
+ self,
393
+ jwks_url: Optional[str] = None,
394
+ issuer: Optional[str] = None,
395
+ allowed_audiences: Optional[set] = None,
396
+ algorithms: Optional[list] = None,
397
+ anonymous_role: Optional[str] = None,
398
+ ):
399
+ log.debug(
400
+ "Initializing JWTDecoder with jwks_url: %s, issuer: %s, anonymous_role: %s",
401
+ jwks_url,
402
+ issuer,
403
+ anonymous_role,
404
+ )
405
+ self.jwks_url = jwks_url
406
+ self.issuer = issuer
407
+ self.allowed_audiences = allowed_audiences or set()
408
+ self.algorithms = algorithms or ["RS256"]
409
+ self.anonymous_role = anonymous_role
410
+
411
+ # Fetch public key only if JWKS URL is provided
412
+ if jwks_url:
413
+ self.public_key = self.fetch_public_key_from_jwks(jwks_url)
414
+ if not self.public_key:
415
+ raise ValueError(
416
+ f"Failed to fetch public key from JWKS URL: {jwks_url}"
417
+ )
418
+ else:
419
+ self.public_key = None
420
+
421
+ log.debug(
422
+ "JWTDecoder initialized with %d allowed audiences, algorithms: %s",
423
+ len(self.allowed_audiences),
424
+ self.algorithms,
425
+ )
426
+
427
+ def fetch_public_key_from_jwks(
428
+ self, jwks_url: str, kid: Optional[str] = None
429
+ ) -> Optional[str]:
430
+ """
431
+ Fetch the public key from a JWKS endpoint.
432
+
433
+ Args:
434
+ jwks_url: The full JWKS endpoint URL
435
+ kid: Optional key ID to select a specific key
436
+
437
+ Returns:
438
+ PEM-formatted public key string, or None if not found
439
+ """
440
+ import requests
441
+ from cryptography.hazmat.primitives import serialization
442
+ from cryptography.hazmat.primitives.asymmetric import rsa
443
+ from cryptography.hazmat.backends import default_backend
444
+
445
+ try:
446
+ log.debug("Fetching JWKS from URL: %s", jwks_url)
447
+ resp = requests.get(jwks_url, timeout=5)
448
+ resp.raise_for_status()
449
+ log.debug("JWKS request successful, status: %d", resp.status_code)
450
+
451
+ jwks = resp.json()
452
+ keys = jwks.get("keys", [])
453
+ log.debug("Found %d keys in JWKS response", len(keys))
454
+
455
+ if not keys:
456
+ log.warning("No keys found in JWKS response")
457
+ return None
458
+
459
+ # Select key by kid if provided, else use first key
460
+ key = None
461
+ if kid:
462
+ log.debug("Looking for specific key ID: %s", kid)
463
+ for k in keys:
464
+ if k.get("kid") == kid:
465
+ key = k
466
+ log.debug("Found matching key for kid: %s", kid)
467
+ break
468
+ if not key:
469
+ log.warning("Key ID %s not found, using first key", kid)
470
+ if not key:
471
+ key = keys[0]
472
+ log.debug(
473
+ "Using first available key with kid: %s", key.get("kid", "unknown")
474
+ )
475
+ # Convert JWK to PEM (requires cryptography)
476
+
477
+ def b64url_decode(val):
478
+ val += "=" * (-len(val) % 4)
479
+ return base64.urlsafe_b64decode(val)
480
+
481
+ n = int.from_bytes(b64url_decode(key["n"]), "big")
482
+ e = int.from_bytes(b64url_decode(key["e"]), "big")
483
+ pubkey = rsa.RSAPublicNumbers(e, n).public_key(default_backend())
484
+ pem = pubkey.public_bytes(
485
+ encoding=serialization.Encoding.PEM,
486
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
487
+ )
488
+ return pem.decode("utf-8")
489
+ except Exception as e:
490
+ log.error("Failed to fetch public key from JWKS: %s", e)
491
+ return None
492
+
493
+ def decode(self, event: Dict[str, Any]) -> Dict[str, Any]:
494
+ """Decode the JWT token and return the claims, or anonymous claims if no token."""
495
+ try:
496
+ token = self.parse_token_from_event(event)
497
+ if token:
498
+ return self.decode_token(token)
499
+ elif self.anonymous_role:
500
+ log.debug(
501
+ "No token found, using anonymous role: %s", self.anonymous_role
502
+ )
503
+ return {"roles": [self.anonymous_role]}
504
+ else:
505
+ log.error("No token found and no anonymous role configured")
506
+ raise ValueError(
507
+ "Authentication required: no token provided and no anonymous access configured"
508
+ )
509
+ except ValueError as e:
510
+ if self.anonymous_role and "No authorization header" in str(e):
511
+ log.debug("Token parsing failed, using anonymous role: %s", str(e))
512
+ return {"roles": [self.anonymous_role]}
513
+ else:
514
+ raise
515
+
516
+ def parse_token_from_event(self, event: Dict[str, Any]) -> Optional[str]:
517
+ """Extract the Bearer token from the authorization header."""
518
+ log.debug("Parsing JWT token from event")
519
+
520
+ auth_header = (
521
+ event.get("authorizationToken")
522
+ or event.get("headers", {}).get("Authorization")
523
+ or event.get("headers", {}).get("authorization")
524
+ )
525
+
526
+ log.debug("Authorization header found: %s", "Yes" if auth_header else "No")
527
+
528
+ if not auth_header:
529
+ log.debug("No authorization header found")
530
+ raise ValueError("No authorization header found")
531
+
532
+ auth_token_parts = auth_header.split(" ")
533
+ log.debug("Authorization header parts: %d", len(auth_token_parts))
534
+
535
+ if (
536
+ len(auth_token_parts) != 2
537
+ or auth_token_parts[0].lower() != "bearer"
538
+ or not auth_token_parts[1]
539
+ ):
540
+ log.error("Invalid authorization header format")
541
+ raise ValueError("Invalid AuthorizationToken.")
542
+
543
+ token = auth_token_parts[1]
544
+ log.debug("JWT token extracted successfully, length: %d", len(token))
545
+ return token
546
+
547
+ def decode_token(self, token: str) -> Dict[str, Any]:
548
+ """Validate and decode the JWT using the PEM public key."""
549
+ log.debug("Starting JWT token validation and decoding")
550
+
551
+ if not self.public_key:
552
+ raise ValueError("No public key available for token validation")
553
+
554
+ import jwt
555
+ from jwt import (
556
+ InvalidTokenError,
557
+ ExpiredSignatureError,
558
+ InvalidAudienceError, # type: ignore
559
+ )
560
+
561
+ try:
562
+ # First decode without audience enforcement; we'll validate
563
+ # audience against the configured allowed set derived from
564
+ # config.yaml. This supports multi-audience tokens.
565
+ log.debug("Configuring JWT decode options")
566
+ decode_options = {"verify_aud": False}
567
+ decode_args = {
568
+ "algorithms": self.algorithms,
569
+ "options": decode_options,
570
+ "key": self.public_key,
571
+ "token": token,
572
+ }
573
+
574
+ if self.issuer:
575
+ log.debug("Using issuer validation: %s", self.issuer)
576
+ decode_args["issuer"] = self.issuer
577
+ else:
578
+ log.debug("Skipping issuer validation")
579
+ decode_options["verify_iss"] = False
580
+
581
+ if self.allowed_audiences:
582
+ log.debug("Allowed audiences configured: %s", self.allowed_audiences)
583
+ # We'll validate audience manually after decoding
584
+ decode_options["verify_aud"] = False
585
+ else:
586
+ log.debug("No audience validation configured")
587
+ decode_options["verify_aud"] = False
588
+
589
+ log.debug("Decoding JWT token with PyJWT")
590
+ # Extract token from decode_args as it needs to be the first positional argument
591
+ token_arg = decode_args.pop("token")
592
+ decoded_token = jwt.decode(token_arg, **decode_args)
593
+ log.debug("JWT token decoded successfully")
594
+
595
+ token_aud = decoded_token.get("aud")
596
+ log.debug("Token audience claim: %s", token_aud)
597
+
598
+ # Normalize token audience to a list for comparison
599
+ token_auds = (
600
+ [token_aud] if isinstance(token_aud, str) else list(token_aud or [])
601
+ )
602
+ token_auds = [str(a).strip() for a in token_auds if str(a).strip()]
603
+ log.debug("Normalized token audiences: %s", token_auds)
604
+
605
+ # Validate audience: token must contain at least one allowed aud
606
+ if self.allowed_audiences:
607
+ if not any(a in self.allowed_audiences for a in token_auds):
608
+ log.error(
609
+ "Audience validation failed. Required: %s, Found: %s",
610
+ self.allowed_audiences,
611
+ token_auds,
612
+ )
613
+ raise InvalidAudienceError("Audience not allowed")
614
+
615
+ log.debug("All JWT validations passed successfully")
616
+ log.debug("Decoded token claims: %s", list(decoded_token.keys()))
617
+ return decoded_token
618
+
619
+ except ExpiredSignatureError:
620
+ log.error("Token has expired")
621
+ raise
622
+ except InvalidTokenError as e:
623
+ log.error("Token validation failed: %s", e)
624
+ raise
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "api-foundry-query-engine"
7
- version = "0.8.34"
7
+ version = "0.8.36"
8
8
  authors = [
9
9
  { name="Daniel Repik", email="danrepik@icloud.com" },
10
10
  ]
@@ -1,415 +0,0 @@
1
- """JWT Decoder Filter for AWS Lambda Functions.
2
-
3
- This module provides a decorator-based JWT token decoder that extracts JWT
4
- tokens from Authorization headers and exposes the claims through
5
- requestContext.authorizer to maintain compatibility with API Gateway TOKEN
6
- authorizers.
7
-
8
- The filter acts like a Java servlet filter but for Lambda functions, providing
9
- a clean separation of concerns between authentication and business logic.
10
- """
11
-
12
- import json
13
- import logging
14
- import functools
15
- import os
16
- import base64
17
- from typing import Callable, Any, Dict, Optional
18
-
19
- __all__ = ["token_decoder"]
20
-
21
- log = logging.getLogger(__name__)
22
-
23
-
24
- def _log_jwt_configuration():
25
- """Log current JWT configuration for debugging."""
26
- log.debug("=== JWT Configuration Debug ===")
27
- log.debug("JWKS_HOST: %s", os.getenv("JWKS_HOST", "NOT_SET"))
28
- log.debug("JWT_ISSUER: %s", os.getenv("JWT_ISSUER", "NOT_SET"))
29
- log.debug(
30
- "JWT_ALLOWED_AUDIENCES: %s", os.getenv("JWT_ALLOWED_AUDIENCES", "NOT_SET")
31
- )
32
- log.debug("REQUIRE_TOKEN: %s", os.getenv("REQUIRE_AUTHENTICATION"))
33
- log.debug("Logging level: %s", logging.getLogger().getEffectiveLevel())
34
- log.debug("===============================")
35
-
36
-
37
- def token_decoder(
38
- jwks_url: Optional[str] = None,
39
- audience: Optional[str] = None,
40
- issuer: Optional[str] = None,
41
- algorithms: Optional[list[str]] = None,
42
- require_authentication: Optional[bool] = None,
43
- ):
44
- """
45
- JWT token decoder decorator for AWS Lambda handlers.
46
-
47
- This filter extracts JWT tokens from the Authorization header, validates
48
- them, and passes the decoded token as a keyword argument to the handler
49
- function.
50
-
51
- Args:
52
- jwks_url: URL to fetch JWKS from (e.g., "https://oauth.local/.well-known/jwks.json")
53
- audience: Expected audience claim (or list of audiences)
54
- issuer: Expected issuer claim
55
- algorithms: List of allowed algorithms (defaults to ["RS256"])
56
- require_authentication: Whether to require a token (default: True).
57
- If False, missing tokens are allowed for unsecured
58
- endpoints Returns:
59
- Decorated handler function that processes JWT tokens before execution
60
-
61
- Example:
62
- # Secured endpoint - requires valid JWT token
63
- @token_decoder(
64
- jwks_url="https://your-oauth-server/.well-known/jwks.json",
65
- audience="test-api",
66
- issuer="https://oauth.local/",
67
- algorithms=["RS256"]
68
- )
69
- def secured_handler(event, context):
70
- # Claims available in event['requestContext']['authorizer']
71
- return {'statusCode': 200, 'body': 'Secured endpoint'}
72
-
73
- # Public endpoint - no token required
74
- @token_decoder(require_authentication=False)
75
- def public_handler(event, context):
76
- # Works without Authorization header
77
- return {'statusCode': 200, 'body': 'Public endpoint'}
78
- """
79
-
80
- # Validate configuration at decorator time
81
- config_require_token = require_authentication
82
- if os.getenv("REQUIRE_AUTHENTICATION") is not None:
83
- config_require_token = os.getenv("REQUIRE_AUTHENTICATION", "").lower() in (
84
- "true",
85
- "1",
86
- "yes",
87
- )
88
-
89
- if config_require_token and not jwks_url:
90
- raise ValueError(
91
- "JWT token validation is required but no jwks_url was provided. "
92
- "Either provide a jwks_url or set require_authentication=False."
93
- )
94
-
95
- def decorator(handler: Callable) -> Callable:
96
- @functools.wraps(handler)
97
- def wrapper(event: dict, context: Any) -> dict:
98
- log.debug("JWT token decoder starting for function: %s", handler.__name__)
99
-
100
- # Log configuration for debugging
101
- if log.isEnabledFor(logging.DEBUG):
102
- _log_jwt_configuration()
103
-
104
- log.debug("Event structure: %s", json.dumps(event, default=str))
105
-
106
- # Determine configuration source
107
- config_jwks_url = (
108
- jwks_url or f"{os.getenv('JWKS_HOST')}/.well-known/jwks.json"
109
- if os.getenv("JWKS_HOST")
110
- else None
111
- )
112
- config_issuer = issuer or os.getenv("JWT_ISSUER")
113
- config_audience = (
114
- audience or os.getenv("JWT_ALLOWED_AUDIENCES", "").split(",")
115
- if os.getenv("JWT_ALLOWED_AUDIENCES")
116
- else []
117
- )
118
- config_algorithms = algorithms or ["RS256"]
119
- config_require_authentication = require_authentication or os.getenv(
120
- "REQUIRE_AUTHENTICATION", ""
121
- ).lower() in ("true", "1", "yes")
122
- log.debug("require_authentication %s", require_authentication)
123
- log.debug("REQUIRE_TOKEN: %s", os.getenv("REQUIRE_AUTHENTICATION", ""))
124
- log.debug(
125
- "config_require_authentication: %s", config_require_authentication
126
- )
127
-
128
- # Skip JWT decoding if no JWKS URL is configured
129
- if not config_jwks_url:
130
- log.debug("No JWKS URL configured, skipping JWT decoding")
131
- return handler(event, context)
132
-
133
- try:
134
- log.debug("Processing JWT token extraction and validation")
135
-
136
- # Check if authorizer already exists (preserve context)
137
- if event.get("requestContext", {}).get("authorizer"):
138
- log.debug("Authorizer already exists, skipping JWT processing")
139
- return handler(event, context)
140
-
141
- # Set up a singleton JWTDecoder instance
142
- if not hasattr(wrapper, "_jwt_decoder"):
143
- log.debug("Creating new JWTDecoder instance")
144
- log.debug(
145
- "JWT config - URL: %s, issuer: %s, aud: %s, alg: %s",
146
- config_jwks_url,
147
- config_issuer,
148
- config_audience,
149
- config_algorithms,
150
- )
151
- wrapper._jwt_decoder = JWTDecoder(
152
- jwks_url=config_jwks_url,
153
- issuer=config_issuer,
154
- allowed_audiences=set(config_audience)
155
- if config_audience
156
- else None,
157
- algorithms=config_algorithms,
158
- require_authentication=config_require_token,
159
- )
160
- else:
161
- log.debug("Using existing JWTDecoder instance")
162
- jwt_decoder = wrapper._jwt_decoder
163
-
164
- log.debug("Decoding JWT token")
165
- decoded_token = jwt_decoder.decode(
166
- event, require_token=config_require_token
167
- )
168
-
169
- if decoded_token is not None:
170
- log.debug(
171
- "JWT token successfully decoded: %s",
172
- json.dumps(decoded_token, default=str),
173
- )
174
- # Populate requestContext for Lambda handler compatibility
175
- if "requestContext" not in event:
176
- event["requestContext"] = {}
177
- if "authorizer" not in event["requestContext"]:
178
- event["requestContext"]["authorizer"] = {}
179
- event["requestContext"]["authorizer"] = decoded_token
180
- else:
181
- log.debug("No token found, proceeding without authentication")
182
-
183
- return handler(event, context)
184
-
185
- except Exception as e:
186
- log.error("JWT filter critical error: %s", str(e))
187
- return {
188
- "statusCode": 500,
189
- "headers": {"Content-Type": "application/json"},
190
- "body": json.dumps({"error": "Internal server error"}),
191
- }
192
-
193
- return wrapper
194
-
195
- return decorator
196
-
197
-
198
- class JWTDecoder:
199
- def __init__(
200
- self,
201
- jwks_url: str,
202
- issuer: Optional[str] = None,
203
- allowed_audiences: Optional[set] = None,
204
- algorithms: Optional[list] = None,
205
- require_authentication: Optional[bool] = None,
206
- ):
207
- log.debug(
208
- "Initializing JWTDecoder with jwks_url: %s, issuer: %s", jwks_url, issuer
209
- )
210
- self.jwks_url = jwks_url
211
- self.public_key = self.fetch_public_key_from_jwks(jwks_url)
212
- self.issuer = issuer
213
- self.allowed_audiences = allowed_audiences or set()
214
- self.algorithms = algorithms or ["RS256"]
215
- self.require_authentication = require_authentication or True
216
- log.debug(
217
- "JWTDecoder initialized with %d allowed audiences, algorithms: %s",
218
- len(self.allowed_audiences),
219
- self.algorithms,
220
- )
221
-
222
- # Ensure we have a valid public key
223
- if not self.public_key:
224
- raise ValueError(f"Failed to fetch public key from JWKS URL: {jwks_url}")
225
-
226
- def fetch_public_key_from_jwks(
227
- self, jwks_url: str, kid: Optional[str] = None
228
- ) -> Optional[str]:
229
- """
230
- Fetch the public key from a JWKS endpoint.
231
-
232
- Args:
233
- jwks_url: The full JWKS endpoint URL
234
- kid: Optional key ID to select a specific key
235
-
236
- Returns:
237
- PEM-formatted public key string, or None if not found
238
- """
239
- import requests
240
- from cryptography.hazmat.primitives import serialization
241
- from cryptography.hazmat.primitives.asymmetric import rsa
242
- from cryptography.hazmat.backends import default_backend
243
-
244
- try:
245
- log.debug("Fetching JWKS from URL: %s", jwks_url)
246
- resp = requests.get(jwks_url, timeout=5)
247
- resp.raise_for_status()
248
- log.debug("JWKS request successful, status: %d", resp.status_code)
249
-
250
- jwks = resp.json()
251
- keys = jwks.get("keys", [])
252
- log.debug("Found %d keys in JWKS response", len(keys))
253
-
254
- if not keys:
255
- log.warning("No keys found in JWKS response")
256
- return None
257
-
258
- # Select key by kid if provided, else use first key
259
- key = None
260
- if kid:
261
- log.debug("Looking for specific key ID: %s", kid)
262
- for k in keys:
263
- if k.get("kid") == kid:
264
- key = k
265
- log.debug("Found matching key for kid: %s", kid)
266
- break
267
- if not key:
268
- log.warning("Key ID %s not found, using first key", kid)
269
- if not key:
270
- key = keys[0]
271
- log.debug(
272
- "Using first available key with kid: %s", key.get("kid", "unknown")
273
- )
274
- # Convert JWK to PEM (requires cryptography)
275
-
276
- def b64url_decode(val):
277
- val += "=" * (-len(val) % 4)
278
- return base64.urlsafe_b64decode(val)
279
-
280
- n = int.from_bytes(b64url_decode(key["n"]), "big")
281
- e = int.from_bytes(b64url_decode(key["e"]), "big")
282
- pubkey = rsa.RSAPublicNumbers(e, n).public_key(default_backend())
283
- pem = pubkey.public_bytes(
284
- encoding=serialization.Encoding.PEM,
285
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
286
- )
287
- return pem.decode("utf-8")
288
- except Exception as e:
289
- log.error("Failed to fetch public key from JWKS: %s", e)
290
- return None
291
-
292
- def decode(
293
- self, event: Dict[str, Any], require_token: bool = True
294
- ) -> Optional[Dict[str, Any]]:
295
- """Decode the JWT token and return the claims."""
296
- try:
297
- token = self.parse_token_from_event(event)
298
- return self.decode_token(token)
299
- except ValueError as e:
300
- if require_token:
301
- raise
302
- else:
303
- log.debug("No token found but not required: %s", str(e))
304
- return None
305
-
306
- def parse_token_from_event(self, event: Dict[str, Any]) -> str:
307
- """Extract the Bearer token from the authorization header."""
308
- log.debug("Parsing JWT token from event")
309
-
310
- auth_header = (
311
- event.get("authorizationToken")
312
- or event.get("headers", {}).get("Authorization")
313
- or event.get("headers", {}).get("authorization")
314
- )
315
-
316
- log.debug("Authorization header found: %s", "Yes" if auth_header else "No")
317
-
318
- log.debug("require_authentication: %s", self.require_authentication)
319
- if not auth_header:
320
- if self.require_authentication:
321
- log.error("No authorization header found in event")
322
- raise ValueError("No authorization header found")
323
- else:
324
- return ""
325
-
326
- auth_token_parts = auth_header.split(" ")
327
- log.debug("Authorization header parts: %d", len(auth_token_parts))
328
-
329
- if (
330
- len(auth_token_parts) != 2
331
- or auth_token_parts[0].lower() != "bearer"
332
- or not auth_token_parts[1]
333
- ):
334
- log.error("Invalid authorization header format")
335
- raise ValueError("Invalid AuthorizationToken.")
336
-
337
- token = auth_token_parts[1]
338
- log.debug("JWT token extracted successfully, length: %d", len(token))
339
- return token
340
-
341
- def decode_token(self, token: str) -> Dict[str, Any]:
342
- """Validate and decode the JWT using the PEM public key."""
343
- log.debug("Starting JWT token validation and decoding")
344
-
345
- import jwt
346
- from jwt import (
347
- InvalidTokenError,
348
- ExpiredSignatureError,
349
- InvalidAudienceError, # type: ignore
350
- )
351
-
352
- try:
353
- # First decode without audience enforcement; we'll validate
354
- # audience against the configured allowed set derived from
355
- # config.yaml. This supports multi-audience tokens.
356
- log.debug("Configuring JWT decode options")
357
- decode_options = {"verify_aud": False}
358
- decode_args = {
359
- "algorithms": self.algorithms,
360
- "options": decode_options,
361
- "key": self.public_key,
362
- "token": token,
363
- }
364
-
365
- if self.issuer:
366
- log.debug("Using issuer validation: %s", self.issuer)
367
- decode_args["issuer"] = self.issuer
368
- else:
369
- log.debug("Skipping issuer validation")
370
- decode_options["verify_iss"] = False
371
-
372
- if self.allowed_audiences:
373
- log.debug("Allowed audiences configured: %s", self.allowed_audiences)
374
- # We'll validate audience manually after decoding
375
- decode_options["verify_aud"] = False
376
- else:
377
- log.debug("No audience validation configured")
378
- decode_options["verify_aud"] = False
379
-
380
- log.debug("Decoding JWT token with PyJWT")
381
- # Extract token from decode_args as it needs to be the first positional argument
382
- token_arg = decode_args.pop("token")
383
- decoded_token = jwt.decode(token_arg, **decode_args)
384
- log.debug("JWT token decoded successfully")
385
-
386
- token_aud = decoded_token.get("aud")
387
- log.debug("Token audience claim: %s", token_aud)
388
-
389
- # Normalize token audience to a list for comparison
390
- token_auds = (
391
- [token_aud] if isinstance(token_aud, str) else list(token_aud or [])
392
- )
393
- token_auds = [str(a).strip() for a in token_auds if str(a).strip()]
394
- log.debug("Normalized token audiences: %s", token_auds)
395
-
396
- # Validate audience: token must contain at least one allowed aud
397
- if self.allowed_audiences:
398
- if not any(a in self.allowed_audiences for a in token_auds):
399
- log.error(
400
- "Audience validation failed. Required: %s, Found: %s",
401
- self.allowed_audiences,
402
- token_auds,
403
- )
404
- raise InvalidAudienceError("Audience not allowed")
405
-
406
- log.debug("All JWT validations passed successfully")
407
- log.debug("Decoded token claims: %s", list(decoded_token.keys()))
408
- return decoded_token
409
-
410
- except ExpiredSignatureError:
411
- log.error("Token has expired")
412
- raise
413
- except InvalidTokenError as e:
414
- log.error("Token validation failed: %s", e)
415
- raise