raindancers-cloudfront 0.0.0 → 0.0.1

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 (36) hide show
  1. package/lib/bicep/azure-functions/custom-claims-provider/function_app.py +164 -0
  2. package/lib/bicep/azure-functions/custom-claims-provider/host.json +10 -0
  3. package/lib/bicep/azure-functions/custom-claims-provider/requirements.txt +8 -0
  4. package/lib/bicep/deploy/lambda/Dockerfile +10 -0
  5. package/lib/bicep/deploy/lambda/index.py +341 -0
  6. package/lib/bicep/patterns/scripts/LOCALDEBUGREADME.md +86 -0
  7. package/lib/bicep/patterns/scripts/wire-custom-claims-extension.sh +122 -0
  8. package/lib/bicep/patterns/scripts/wire-custom-claims-extension.sh.bak +118 -0
  9. package/lib/cloudfront/cloudfront-functions/auth-check.js +220 -0
  10. package/lib/cloudfront/cloudfront-functions/modules/auth-check.js +263 -0
  11. package/lib/cloudfront/cloudfront-functions/modules/cognito-auth-check.js +223 -0
  12. package/lib/cloudfront/cloudfront-functions/modules/shared-utils.js +47 -0
  13. package/lib/cloudfront/cloudfront-functions/modules/tls-check.js +39 -0
  14. package/lib/cloudfront/cloudfront-functions/modules/url-rewrite.js +6 -0
  15. package/lib/cloudfront/cloudfront-functions/role-enforcer.js +93 -0
  16. package/lib/cloudfront/cloudfront-functions/tls-enforcer.js +55 -0
  17. package/lib/cloudfront/cloudfront-functions/userinfo-endpoint.js +208 -0
  18. package/lib/cloudfront/lambda/certificate/index.py +219 -0
  19. package/lib/cloudfront/lambda/cognito-auth/oauth-callback.py +215 -0
  20. package/lib/cloudfront/lambda/cognito-auth/requirements.txt +3 -0
  21. package/lib/cloudfront/lambda/edge-auth/config.py +6 -0
  22. package/lib/cloudfront/lambda/edge-auth/config_generated.py +12 -0
  23. package/lib/cloudfront/lambda/edge-auth/oauth-callback.py +538 -0
  24. package/lib/cloudfront/lambda/edge-auth/requirements.txt +3 -0
  25. package/lib/cloudfront/lambda/hmacSecret/index.py +129 -0
  26. package/lib/cloudfront/lambda/hmacSecret/requirements.txt +1 -0
  27. package/lib/cloudfront/lambda/jwt-decoder/index.py +88 -0
  28. package/lib/cloudfront/lambda/pre-token/index.py +11 -0
  29. package/lib/cloudfront/lambda/rotateSecret/index.py +86 -0
  30. package/lib/cloudfront/lambda/session-revocation/index.py +80 -0
  31. package/lib/cloudfront/lambda/ssm-writer/index.py +44 -0
  32. package/lib/cloudfront/lambda/stream-processor/index.py +53 -0
  33. package/lib/cloudfront/lambda/webacl/index.py +356 -0
  34. package/lib/cloudfront/logging/README.md +144 -0
  35. package/lib/cloudfront/patterns/SPLIT_STACK_USAGE.md +138 -0
  36. package/package.json +1 -1
@@ -0,0 +1,3 @@
1
+ boto3>=1.35.36
2
+ PyJWT==2.8.0
3
+ cryptography==41.0.7
@@ -0,0 +1,6 @@
1
+ # This file will be generated by CDK at synth time with actual configuration values
2
+ AZURE_TENANT_ID = 'TENANT_ID_PLACEHOLDER'
3
+ # amazonq-ignore-next-line
4
+ AZURE_CLIENT_ID = 'CLIENT_ID_PLACEHOLDER'
5
+ REDIRECT_URI = 'REDIRECT_URI_PLACEHOLDER'
6
+ HMAC_SECRET_ARN = 'SECRET_ARN_PLACEHOLDER'
@@ -0,0 +1,12 @@
1
+ # Generated configuration
2
+ import json
3
+ import boto3
4
+ import os
5
+
6
+ # Well-known secret name pattern
7
+ CONFIG_SECRET_NAME = 'cloudfront-auth-config-cdk-api-dev'
8
+
9
+ def get_config():
10
+ client = boto3.client('secretsmanager', region_name='us-east-1')
11
+ response = client.get_secret_value(SecretId=CONFIG_SECRET_NAME)
12
+ return json.loads(response['SecretString'])
@@ -0,0 +1,538 @@
1
+ import json
2
+ import urllib.parse
3
+ import urllib.request
4
+ from jwt import PyJWK
5
+ import jwt
6
+ import boto3
7
+ import logging
8
+ import hmac
9
+ import hashlib
10
+ import base64
11
+ import time
12
+ import uuid
13
+ from datetime import datetime, timedelta
14
+ from config_generated import get_config
15
+
16
+ logger = logging.getLogger()
17
+ logger.setLevel(logging.INFO)
18
+
19
+ # Log boto3 version for diagnostics
20
+ logger.info(f'boto3 version: {boto3.__version__}')
21
+
22
+ # Initialize AWS clients
23
+ sts_client = boto3.client('sts', region_name='us-east-1')
24
+ # DynamoDB and SNS clients will be initialized lazily with region from config.
25
+ # Set to None here for lazy initialization - they'll be created on first use with the
26
+ # correct region from Secrets Manager, enabling cross-region Lambda@Edge deployments.
27
+ dynamodb = None
28
+ sns_client = None
29
+
30
+ # Cache HMAC secret and config (Lambda@Edge reuses execution context)
31
+ HMAC_SECRET = None
32
+ CONFIG = None
33
+
34
+ def get_config_cached():
35
+ """Get cached config"""
36
+ global CONFIG, dynamodb, sns_client
37
+ if CONFIG is None:
38
+ CONFIG = get_config()
39
+ # Initialize DynamoDB and SNS clients with region from config
40
+ dynamodb_region = CONFIG.get('dynamodb_region', 'us-east-1')
41
+ dynamodb = boto3.client('dynamodb', region_name=dynamodb_region)
42
+ sns_client = boto3.client('sns', region_name=dynamodb_region)
43
+ logger.info(f'DynamoDB and SNS clients initialized for region: {dynamodb_region}')
44
+ return CONFIG
45
+
46
+ def get_hmac_secret():
47
+ """Get HMAC secret from config"""
48
+ global HMAC_SECRET
49
+ if HMAC_SECRET is not None:
50
+ return HMAC_SECRET
51
+
52
+ config = get_config_cached()
53
+ HMAC_SECRET = config.get('hmac_key')
54
+ if not HMAC_SECRET:
55
+ raise ValueError('hmac_key not found in config')
56
+
57
+ logger.info('HMAC secret loaded from config')
58
+ return HMAC_SECRET
59
+
60
+ def get_federated_token(sts_audience):
61
+ """Generate JWT from AWS STS for Azure AD federated authentication"""
62
+ logger.info(f'Getting federated token for audience: {sts_audience}')
63
+ try:
64
+ response = sts_client._make_api_call(
65
+ 'GetWebIdentityToken',
66
+ {
67
+ 'Audience': [sts_audience],
68
+ 'DurationSeconds': 900,
69
+ 'SigningAlgorithm': 'RS256'
70
+ }
71
+ )
72
+ logger.info('Successfully obtained federated token from STS')
73
+ return response['WebIdentityToken']
74
+ except Exception as e:
75
+ import traceback
76
+ error_details = traceback.format_exc()
77
+ logger.error(f'Failed to get web identity token')
78
+ logger.error(f'Error type: {type(e).__name__}')
79
+ logger.error(f'Error message: {str(e)}')
80
+ logger.error(f'Full traceback: {error_details}')
81
+ logger.error(f'boto3 version: {boto3.__version__}')
82
+ logger.error(f'Requirements: boto3 >= 1.35.36, Outbound Identity Federation enabled')
83
+ raise
84
+
85
+ def exchange_code_for_token(code, azure_tenant_id, azure_client_id, redirect_uri, sts_audience, code_verifier):
86
+ """Exchange authorization code for JWT token using federated identity with PKCE"""
87
+ logger.info(f'Starting token exchange with Azure AD (PKCE enabled)')
88
+ logger.info(f'Tenant ID: {azure_tenant_id}, Client ID: {azure_client_id}')
89
+ token_url = f'https://login.microsoftonline.com/{azure_tenant_id}/oauth2/v2.0/token'
90
+
91
+ # Use federated identity - no client secret needed
92
+ # Lambda role has federated credential configured in Azure AD
93
+ logger.info('Getting federated token for client assertion')
94
+ client_assertion = get_federated_token(sts_audience)
95
+ logger.info(f'Client assertion obtained, length: {len(client_assertion)}')
96
+
97
+ # DEBUG: decode and log JWT header+payload (no signature)
98
+ try:
99
+ import base64 as _b64, json as _json
100
+ _parts = client_assertion.split('.')
101
+ _header = _json.loads(_b64.urlsafe_b64decode(_parts[0] + '=='))
102
+ _payload_raw = _parts[1] + '=='
103
+ _payload = _json.loads(_b64.urlsafe_b64decode(_payload_raw))
104
+ logger.info(f'DEBUG client_assertion header: {_json.dumps(_header)}')
105
+ logger.info(f'DEBUG client_assertion payload: {_json.dumps(_payload)}')
106
+ except Exception as _e:
107
+ logger.warning(f'DEBUG decode failed: {_e}')
108
+
109
+ data = {
110
+ 'grant_type': 'authorization_code',
111
+ 'client_id': azure_client_id,
112
+ 'code': code,
113
+ 'redirect_uri': redirect_uri,
114
+ 'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
115
+ 'client_assertion': client_assertion,
116
+ 'code_verifier': code_verifier
117
+ }
118
+
119
+ logger.info(f'Making token request to: {token_url}')
120
+ req = urllib.request.Request(
121
+ token_url,
122
+ data=urllib.parse.urlencode(data).encode('utf-8'),
123
+ headers={'Content-Type': 'application/x-www-form-urlencoded'}
124
+ )
125
+
126
+ try:
127
+ with urllib.request.urlopen(req) as response:
128
+ logger.info(f'Token exchange response status: {response.status}')
129
+ token_response = json.loads(response.read().decode('utf-8'))
130
+ logger.info(f'Token response keys: {list(token_response.keys())}')
131
+ return token_response
132
+ except urllib.error.HTTPError as e:
133
+ logger.error(f'HTTP error during token exchange: {e.code} {e.reason}')
134
+ error_body = e.read().decode('utf-8')
135
+ logger.error(f'Error response body: {error_body}')
136
+ raise
137
+ except Exception as e:
138
+ logger.error(f'Unexpected error during token exchange: {type(e).__name__}: {str(e)}')
139
+ raise
140
+
141
+ def validate_jwt_token(id_token, azure_tenant_id, azure_client_id):
142
+ """Validate JWT token from Azure AD"""
143
+ logger.info('Starting JWT token validation')
144
+ # Get Azure AD public keys
145
+ jwks_url = f'https://login.microsoftonline.com/{azure_tenant_id}/discovery/v2.0/keys'
146
+ logger.info(f'Fetching JWKS from: {jwks_url}')
147
+
148
+ with urllib.request.urlopen(jwks_url) as response:
149
+ jwks = json.loads(response.read().decode('utf-8'))
150
+ logger.info(f'Retrieved {len(jwks.get("keys", []))} keys from JWKS')
151
+
152
+ # Decode and validate
153
+ unverified_header = jwt.get_unverified_header(id_token)
154
+ logger.info(f'Token header kid: {unverified_header.get("kid")}')
155
+ rsa_key = None
156
+
157
+ for key in jwks['keys']:
158
+ if key['kid'] == unverified_header['kid']:
159
+ rsa_key = key
160
+ logger.info(f'Found matching key for kid: {key["kid"]}')
161
+ break
162
+
163
+ if not rsa_key:
164
+ logger.error(f'Unable to find key for kid: {unverified_header.get("kid")}')
165
+ raise ValueError('Unable to find appropriate key')
166
+
167
+ # Convert JWK to key object
168
+ logger.info('Converting JWK to key object')
169
+ jwk = PyJWK.from_dict(rsa_key)
170
+
171
+ # Validate token
172
+ logger.info('Validating JWT signature and claims')
173
+ payload = jwt.decode(
174
+ id_token,
175
+ jwk.key,
176
+ algorithms=['RS256'],
177
+ audience=azure_client_id,
178
+ issuer=f'https://login.microsoftonline.com/{azure_tenant_id}/v2.0'
179
+ )
180
+ logger.info(f'JWT validation successful, user: {payload.get("email", payload.get("preferred_username", "unknown"))}')
181
+
182
+ return payload
183
+
184
+
185
+
186
+ def lambda_handler(event, context):
187
+ """Handle OAuth callback from Azure AD"""
188
+ request = event['Records'][0]['cf']['request']
189
+
190
+ # Debug logging for cookies
191
+ logger.info(f'Request headers: {json.dumps(request.get("headers", {}))}')
192
+
193
+ # Load configuration
194
+ try:
195
+ config = get_config_cached()
196
+ azure_tenant_id = config['azure_tenant_id']
197
+ azure_client_id = config['azure_client_id']
198
+ redirect_uri = config['redirect_uri']
199
+ sts_audience = config.get('sts_audience', 'sts.amazonaws.com')
200
+ table_name = config.get('dynamodb_table_name')
201
+ security_alerts_topic_arn = config.get('security_alerts_topic_arn')
202
+ auto_revoke_on_reuse = config.get('auto_revoke_on_reuse', 'false').lower() == 'true'
203
+ allowed_domains_str = config.get('allowed_domains', '[]')
204
+ allowed_domains = json.loads(allowed_domains_str)
205
+ except Exception as e:
206
+ logger.error(f'Failed to load configuration: {str(e)}')
207
+ return {
208
+ 'status': '500',
209
+ 'statusDescription': 'Internal Server Error',
210
+ 'body': 'Configuration error'
211
+ }
212
+
213
+ # Validate Host header
214
+ host_header = None
215
+ if 'host' in request.get('headers', {}):
216
+ host_header = request['headers']['host'][0]['value']
217
+
218
+ if host_header and allowed_domains:
219
+ if host_header not in allowed_domains:
220
+ logger.error(f'Host header validation failed: {host_header} not in allowed domains')
221
+ return {
222
+ 'status': '400',
223
+ 'statusDescription': 'Bad Request',
224
+ 'body': 'Invalid request'
225
+ }
226
+
227
+ # Parse query string
228
+ query_string = request.get('querystring', '')
229
+ params = urllib.parse.parse_qs(query_string)
230
+
231
+ # Extract code and state
232
+ code = params.get('code', [None])[0]
233
+ state = params.get('state', [None])[0]
234
+
235
+ logger.info(f'OAuth callback - code present: {code is not None}, state: {state}')
236
+
237
+ if not code:
238
+ return {
239
+ 'status': '400',
240
+ 'statusDescription': 'Bad Request',
241
+ 'body': 'Missing authorization code'
242
+ }
243
+
244
+ # Validate CSRF state
245
+ cookies = {}
246
+ if 'cookie' in request.get('headers', {}):
247
+ cookie_header = request['headers']['cookie'][0]['value']
248
+ logger.info(f'Cookie header: {cookie_header}')
249
+ for cookie in cookie_header.split('; '):
250
+ if '=' in cookie:
251
+ name, value = cookie.split('=', 1)
252
+ cookies[name] = value
253
+ else:
254
+ logger.warning('No cookie header in request')
255
+
256
+ logger.info(f'Parsed cookies: {list(cookies.keys())}')
257
+
258
+ # Get code_verifier from cookie for PKCE
259
+ code_verifier = cookies.get('code_verifier')
260
+ if not code_verifier:
261
+ logger.error('PKCE code_verifier missing from cookies')
262
+ return {
263
+ 'status': '400',
264
+ 'statusDescription': 'Bad Request',
265
+ 'body': 'Authentication failed'
266
+ }
267
+
268
+ logger.info('PKCE code_verifier retrieved from cookie')
269
+
270
+ stored_state = cookies.get('oauth_state')
271
+ if not stored_state or stored_state != state:
272
+ logger.error(f'CSRF validation failed: stored={stored_state}, received={state}')
273
+ return {
274
+ 'status': '400',
275
+ 'statusDescription': 'Bad Request',
276
+ 'body': 'Invalid state parameter'
277
+ }
278
+
279
+ # Validate state token in DynamoDB (one-time-use enforcement)
280
+ if table_name:
281
+ try:
282
+ response = dynamodb.get_item(
283
+ TableName=table_name,
284
+ Key={'pk': {'S': f'STATE#{state}'}, 'sk': {'S': f'STATE#{state}'}}
285
+ )
286
+
287
+ item = response.get('Item')
288
+
289
+ if not item:
290
+ # First use - store in DynamoDB
291
+ dynamodb.put_item(
292
+ TableName=table_name,
293
+ Item={
294
+ 'pk': {'S': f'STATE#{state}'},
295
+ 'sk': {'S': f'STATE#{state}'},
296
+ 'used': {'BOOL': True},
297
+ 'createdAt': {'N': str(int(time.time()))},
298
+ 'expiresAt': {'N': str(int(time.time()) + 600)}
299
+ }
300
+ )
301
+ elif item.get('used', {}).get('BOOL'):
302
+ # Token reuse detected - security incident
303
+ logger.error(f'State token reuse detected: {state}')
304
+
305
+ if security_alerts_topic_arn:
306
+ try:
307
+ sns_client.publish(
308
+ TopicArn=security_alerts_topic_arn,
309
+ Subject='Security Alert: State Token Reuse Detected',
310
+ Message=json.dumps({
311
+ 'event': 'STATE_TOKEN_REUSE',
312
+ 'state': state,
313
+ 'timestamp': int(time.time()),
314
+ 'ip': request.get('clientIp', 'unknown')
315
+ })
316
+ )
317
+ except Exception as e:
318
+ logger.error(f'Failed to publish security alert: {e}')
319
+
320
+ return {
321
+ 'status': '400',
322
+ 'statusDescription': 'Bad Request',
323
+ 'body': 'Invalid or reused state token'
324
+ }
325
+ except Exception as e:
326
+ logger.error(f'DynamoDB state validation error: {e}')
327
+
328
+ logger.info(f'CSRF validation passed, proceeding with token exchange')
329
+
330
+ # Decode state to get original path
331
+ redirect_path = '/'
332
+ try:
333
+ # Decode base64url state
334
+ state_padded = state + '=' * (4 - len(state) % 4)
335
+ state_decoded = base64.urlsafe_b64decode(state_padded.replace('-', '+').replace('_', '/')).decode('utf-8')
336
+ state_obj = json.loads(state_decoded)
337
+ redirect_path = state_obj.get('p', '/')
338
+ logger.info(f'Decoded original path from state: {redirect_path}')
339
+ except Exception as e:
340
+ logger.warning(f'Could not decode state, using default redirect: {str(e)}')
341
+ redirect_path = '/'
342
+
343
+ try:
344
+ # Exchange code for tokens
345
+ logger.info('Step 1: Exchanging authorization code for tokens (with PKCE)')
346
+ token_response = exchange_code_for_token(code, azure_tenant_id, azure_client_id, redirect_uri, sts_audience, code_verifier)
347
+ id_token = token_response['id_token']
348
+ logger.info('Step 1 complete: Received id_token from Azure')
349
+
350
+ # Validate JWT
351
+ logger.info('Step 2: Validating JWT token')
352
+ payload = validate_jwt_token(id_token, azure_tenant_id, azure_client_id)
353
+ logger.info('Step 2 complete: JWT token validated successfully')
354
+
355
+ # Store Azure AD JWT reference for IAM authorization
356
+ azure_id_token = id_token
357
+ logger.info('Azure AD JWT will be stored for IAM authorization')
358
+
359
+ # Validate nonce in DynamoDB (one-time-use enforcement)
360
+ token_nonce = payload.get('nonce')
361
+ if table_name and token_nonce:
362
+ try:
363
+ response = dynamodb.get_item(
364
+ TableName=table_name,
365
+ Key={'pk': {'S': f'NONCE#{token_nonce}'}, 'sk': {'S': f'NONCE#{token_nonce}'}}
366
+ )
367
+
368
+ item = response.get('Item')
369
+
370
+ if not item:
371
+ # First use - store in DynamoDB
372
+ dynamodb.put_item(
373
+ TableName=table_name,
374
+ Item={
375
+ 'pk': {'S': f'NONCE#{token_nonce}'},
376
+ 'sk': {'S': f'NONCE#{token_nonce}'},
377
+ 'used': {'BOOL': True},
378
+ 'createdAt': {'N': str(int(time.time()))},
379
+ 'expiresAt': {'N': str(int(time.time()) + 600)}
380
+ }
381
+ )
382
+ elif item.get('used', {}).get('BOOL'):
383
+ # Nonce reuse detected - security incident
384
+ logger.error(f'Nonce reuse detected: {token_nonce}')
385
+
386
+ if security_alerts_topic_arn:
387
+ try:
388
+ sns_client.publish(
389
+ TopicArn=security_alerts_topic_arn,
390
+ Subject='Security Alert: Nonce Reuse Detected',
391
+ Message=json.dumps({
392
+ 'event': 'NONCE_REUSE',
393
+ 'nonce': token_nonce,
394
+ 'timestamp': int(time.time()),
395
+ 'ip': request.get('clientIp', 'unknown')
396
+ })
397
+ )
398
+ except Exception as e:
399
+ logger.error(f'Failed to publish security alert: {e}')
400
+
401
+ return {
402
+ 'status': '400',
403
+ 'statusDescription': 'Bad Request',
404
+ 'body': 'Invalid or reused nonce'
405
+ }
406
+ except Exception as e:
407
+ logger.error(f'DynamoDB nonce validation error: {e}')
408
+
409
+ # Create HMAC-signed session JWT
410
+ logger.info('Step 3: Creating HMAC-signed session JWT')
411
+
412
+ # Generate unique session ID (jti)
413
+ jti = f"sess_{uuid.uuid4().hex}"
414
+ user_id = payload.get('sub') or payload.get('email') or payload.get('preferred_username')
415
+
416
+ header = {"alg": "HS256", "typ": "JWT"}
417
+
418
+ # Filter claims based on whitelist
419
+ jwt_claims_whitelist_str = config.get('jwt_claims_whitelist', '')
420
+ if jwt_claims_whitelist_str:
421
+ whitelist = json.loads(jwt_claims_whitelist_str)
422
+ filtered_payload = {k: v for k, v in payload.items() if k in whitelist}
423
+ logger.info(f'Applied claims whitelist: {whitelist}')
424
+ else:
425
+ # Fallback: filter out Microsoft internal claims only (should not reach here with proper config)
426
+ logger.warning('No JWT claims whitelist configured, using fallback filter')
427
+ filtered_payload = {k: v for k, v in payload.items() if k not in ['aio', 'rh', 'uti']}
428
+
429
+ # Include filtered Azure AD claims in session JWT
430
+ azure_exp = payload.get('exp', int(time.time()) + 3600)
431
+ session_payload = {
432
+ **filtered_payload,
433
+ "jti": jti,
434
+ "exp": azure_exp, # Match Azure AD expiration
435
+ "iat": int(time.time()),
436
+ "iss": redirect_uri,
437
+ "idp": payload.get("iss")
438
+ }
439
+
440
+ # Store session in DynamoDB
441
+ if table_name and user_id:
442
+ try:
443
+ dynamodb.put_item(
444
+ TableName=table_name,
445
+ Item={
446
+ 'pk': {'S': f'SESSION#{user_id}'},
447
+ 'sk': {'S': f'SESSION#{jti}'},
448
+ 'gsi1pk': {'S': f'USER#{user_id}'},
449
+ 'gsi1sk': {'S': f'SESSION#{int(time.time())}'},
450
+ 'jti': {'S': jti},
451
+ 'userId': {'S': user_id},
452
+ 'email': {'S': payload.get('email', '')},
453
+ 'createdAt': {'N': str(int(time.time()))},
454
+ 'revoked': {'BOOL': False},
455
+ 'expiresAt': {'N': str(int(time.time()) + 3600)}
456
+ }
457
+ )
458
+ logger.info(f'Session stored in DynamoDB: {jti}')
459
+ except Exception as e:
460
+ logger.error(f'Failed to store session in DynamoDB: {e}')
461
+
462
+ header_b64 = base64.urlsafe_b64encode(
463
+ json.dumps(header, separators=(',', ':')).encode()
464
+ ).decode().rstrip('=')
465
+
466
+ payload_b64 = base64.urlsafe_b64encode(
467
+ json.dumps(session_payload, separators=(',', ':')).encode()
468
+ ).decode().rstrip('=')
469
+
470
+ signing_input = f"{header_b64}.{payload_b64}"
471
+ hmac_secret = get_hmac_secret()
472
+ signature = hmac.new(
473
+ hmac_secret.encode('utf-8'),
474
+ signing_input.encode('utf-8'),
475
+ hashlib.sha256
476
+ ).digest()
477
+
478
+ signature_b64 = base64.urlsafe_b64encode(signature).decode().rstrip('=')
479
+ cookie_value = f"{header_b64}.{payload_b64}.{signature_b64}"
480
+
481
+ logger.info('Step 3 complete: HMAC-signed JWT created')
482
+
483
+ # Calculate expiry from Azure AD token
484
+ azure_exp_timestamp = payload.get('exp', int(time.time()) + 3600)
485
+ expires = datetime.utcfromtimestamp(azure_exp_timestamp)
486
+ expires_str = expires.strftime('%a, %d %b %Y %H:%M:%S GMT')
487
+ logger.info(f'Cookie expiry set to: {expires_str}')
488
+
489
+ # Build Set-Cookie headers
490
+ auth_cookie = (
491
+ f'__Host-auth_session={cookie_value}; '
492
+ f'HttpOnly; Secure; SameSite=Lax; '
493
+ f'Path=/; '
494
+ f'Expires={expires_str}'
495
+ )
496
+
497
+ # Create Azure AD JWT cookie for IAM authorization
498
+ azure_cookie = (
499
+ f'__Host-azure_token={azure_id_token}; '
500
+ f'HttpOnly; Secure; SameSite=Lax; '
501
+ f'Path=/; '
502
+ f'Expires={expires_str}'
503
+ )
504
+
505
+ # Clear oauth_state cookie
506
+ state_cookie = 'oauth_state=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0'
507
+
508
+ # Clear code_verifier cookie
509
+ verifier_cookie = 'code_verifier=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0'
510
+
511
+ logger.info('Step 4: Building redirect response')
512
+ response = {
513
+ 'status': '302',
514
+ 'statusDescription': 'Found',
515
+ 'headers': {
516
+ 'location': [{'key': 'Location', 'value': redirect_path}],
517
+ 'set-cookie': [
518
+ {'key': 'Set-Cookie', 'value': auth_cookie},
519
+ {'key': 'Set-Cookie', 'value': azure_cookie},
520
+ {'key': 'Set-Cookie', 'value': state_cookie},
521
+ {'key': 'Set-Cookie', 'value': verifier_cookie}
522
+ ],
523
+ 'cache-control': [{'key': 'Cache-Control', 'value': 'no-store'}]
524
+ }
525
+ }
526
+ logger.info(f'OAuth callback completed successfully, redirecting to {redirect_path}')
527
+ return response
528
+
529
+ except Exception as e:
530
+ import traceback
531
+ error_details = traceback.format_exc()
532
+ logger.error(f'Error during OAuth callback: {type(e).__name__}: {str(e)}')
533
+ logger.error(f'Full traceback: {error_details}')
534
+ return {
535
+ 'status': '500',
536
+ 'statusDescription': 'Internal Server Error',
537
+ 'body': 'Authentication failed'
538
+ }
@@ -0,0 +1,3 @@
1
+ boto3>=1.35.36
2
+ PyJWT==2.8.0
3
+ cryptography==41.0.7