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,55 @@
1
+ // tls-enforcer.js - CloudFront Function (viewer-request)
2
+ // Enforces TLS 1.3 with strongest ciphers only
3
+ // Requires: CloudFront-Viewer-TLS header in Origin Request Policy
4
+
5
+ function handler(event) {
6
+ var request = event.request;
7
+ var headers = request.headers;
8
+
9
+ // Get TLS info from CloudFront-Viewer-TLS header
10
+ // Format: TLSv1.3:TLS_AES_128_GCM_SHA256:sessionResumed
11
+ var tlsInfo = headers['cloudfront-viewer-tls']
12
+ ? headers['cloudfront-viewer-tls'].value
13
+ : '';
14
+
15
+ // Parse TLS version and cipher
16
+ var parts = tlsInfo.split(':');
17
+ var tlsVersion = parts[0] || 'unknown';
18
+ var cipher = parts[1] || 'unknown';
19
+
20
+ // Check if using TLS 1.3
21
+ if (!tlsVersion.startsWith('TLSv1.3')) {
22
+ // Redirect to upgrade page with Request ID for incident tracking
23
+ var requestId = event.context.requestId;
24
+ return {
25
+ statusCode: 302,
26
+ statusDescription: 'Found',
27
+ headers: {
28
+ 'location': { value: '/upgrade.html?ref=' + requestId },
29
+ 'cache-control': { value: 'no-store' },
30
+ },
31
+ };
32
+ }
33
+
34
+ // Verify strong cipher (TLS 1.3 GCM only)
35
+ var allowedCiphers = [
36
+ 'TLS_AES_128_GCM_SHA256',
37
+ 'TLS_AES_256_GCM_SHA384'
38
+ ];
39
+
40
+ if (allowedCiphers.indexOf(cipher) === -1) {
41
+ // Redirect to upgrade page with Request ID for incident tracking
42
+ var requestId = event.context.requestId;
43
+ return {
44
+ statusCode: 302,
45
+ statusDescription: 'Found',
46
+ headers: {
47
+ 'location': { value: '/upgrade.html?ref=' + requestId },
48
+ 'cache-control': { value: 'no-store' },
49
+ },
50
+ };
51
+ }
52
+
53
+ // Allow request to proceed
54
+ return request;
55
+ }
@@ -0,0 +1,208 @@
1
+ // CloudFront Function: User Info Endpoint (viewer-request)
2
+ // Purpose: Validate JWT and return user info JSON without exposing full JWT
3
+ // Security: Validates JWT signature, returns only name and roles, calculates cache expiration
4
+
5
+ import cf from 'cloudfront';
6
+ var crypto = require('crypto');
7
+
8
+ const kvsHandle = cf.kvs();
9
+
10
+ function base64urlDecode(str) {
11
+ var base64 = str.replace(/-/g, '+').replace(/_/g, '/');
12
+ while (base64.length % 4) {
13
+ base64 += '=';
14
+ }
15
+ return atob(base64);
16
+ }
17
+
18
+ function constantTimeCompare(a, b) {
19
+ if (a.length !== b.length) {
20
+ return false;
21
+ }
22
+ var result = 0;
23
+ for (var i = 0; i < a.length; i++) {
24
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
25
+ }
26
+ return result === 0;
27
+ }
28
+
29
+ async function validateHmacSignature(token) {
30
+ var parts = token.split('.');
31
+ if (parts.length !== 3) {
32
+ return false;
33
+ }
34
+
35
+ var signingInput = parts[0] + '.' + parts[1];
36
+ var providedSignature = parts[2];
37
+
38
+ try {
39
+ var secret = await kvsHandle.get('jwt.secret');
40
+ if (!secret) {
41
+ return false;
42
+ }
43
+
44
+ var hmac = crypto.createHmac('sha256', secret);
45
+ hmac.update(signingInput);
46
+ var computedSignature = hmac.digest('base64url');
47
+
48
+ if (constantTimeCompare(computedSignature, providedSignature)) {
49
+ return true;
50
+ }
51
+
52
+ try {
53
+ var oldSecret = await kvsHandle.get('jwt.secret.old');
54
+ if (oldSecret) {
55
+ var oldHmac = crypto.createHmac('sha256', oldSecret);
56
+ oldHmac.update(signingInput);
57
+ var oldComputedSignature = oldHmac.digest('base64url');
58
+
59
+ if (constantTimeCompare(oldComputedSignature, oldComputedSignature)) {
60
+ return true;
61
+ }
62
+ }
63
+ } catch (e) {
64
+ // Old secret doesn't exist
65
+ }
66
+
67
+ return false;
68
+ } catch (e) {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function extractUserInfo(token, nameFields) {
74
+ try {
75
+ var parts = token.split('.');
76
+ if (parts.length !== 3) {
77
+ return null;
78
+ }
79
+
80
+ var payload = JSON.parse(base64urlDecode(parts[1]));
81
+
82
+ // Extract name using ordered fallback list
83
+ var username = 'User';
84
+ for (var i = 0; i < nameFields.length; i++) {
85
+ if (payload[nameFields[i]]) {
86
+ username = payload[nameFields[i]];
87
+ break;
88
+ }
89
+ }
90
+
91
+ // Extract roles (default to empty array)
92
+ var roles = payload.roles || [];
93
+
94
+ // Extract expiration for cache control
95
+ var exp = payload.exp || 0;
96
+
97
+ return {
98
+ name: username,
99
+ roles: roles,
100
+ exp: exp
101
+ };
102
+ } catch (e) {
103
+ console.log('Failed to extract user info from JWT: ' + e);
104
+ return null;
105
+ }
106
+ }
107
+
108
+ async function handler(event) {
109
+ var request = event.request;
110
+
111
+ // Extract JWT from request cookies
112
+ var cookies = request.cookies;
113
+ if (!cookies['__Host-auth_session']) {
114
+ return {
115
+ statusCode: 401,
116
+ statusDescription: 'Unauthorized',
117
+ headers: {
118
+ 'content-type': { value: 'application/json' },
119
+ 'cache-control': { value: 'no-store' }
120
+ },
121
+ body: {
122
+ encoding: 'text',
123
+ data: JSON.stringify({ error: 'No session found' })
124
+ }
125
+ };
126
+ }
127
+
128
+ var token = cookies['__Host-auth_session'].value;
129
+ if (!token) {
130
+ return {
131
+ statusCode: 401,
132
+ statusDescription: 'Unauthorized',
133
+ headers: {
134
+ 'content-type': { value: 'application/json' },
135
+ 'cache-control': { value: 'no-store' }
136
+ },
137
+ body: {
138
+ encoding: 'text',
139
+ data: JSON.stringify({ error: 'Invalid session' })
140
+ }
141
+ };
142
+ }
143
+
144
+ // Validate JWT signature
145
+ var isValid = await validateHmacSignature(token);
146
+ if (!isValid) {
147
+ return {
148
+ statusCode: 401,
149
+ statusDescription: 'Unauthorized',
150
+ headers: {
151
+ 'content-type': { value: 'application/json' },
152
+ 'cache-control': { value: 'no-store' }
153
+ },
154
+ body: {
155
+ encoding: 'text',
156
+ data: JSON.stringify({ error: 'Invalid JWT signature' })
157
+ }
158
+ };
159
+ }
160
+
161
+ // NOTE: This array is automatically replaced by CDK with configured JWT claim fields
162
+ var nameFields = ['key1', 'key2', 'key3'];
163
+
164
+ // Extract user info from JWT
165
+ var userInfo = extractUserInfo(token, nameFields);
166
+ if (!userInfo) {
167
+ return {
168
+ statusCode: 500,
169
+ statusDescription: 'Internal Server Error',
170
+ headers: {
171
+ 'content-type': { value: 'application/json' },
172
+ 'cache-control': { value: 'no-store' }
173
+ },
174
+ body: {
175
+ encoding: 'text',
176
+ data: JSON.stringify({ error: 'Failed to parse JWT' })
177
+ }
178
+ };
179
+ }
180
+
181
+ // Calculate cache duration from JWT expiration
182
+ var now = Math.floor(Date.now() / 1000);
183
+ var maxAge = userInfo.exp - now;
184
+
185
+ // Ensure maxAge is positive and reasonable
186
+ if (maxAge <= 0) {
187
+ maxAge = 0;
188
+ } else if (maxAge > 3600) {
189
+ maxAge = 3600; // Cap at 1 hour for safety
190
+ }
191
+
192
+ // Return user info as JSON with dynamic cache control
193
+ return {
194
+ statusCode: 200,
195
+ statusDescription: 'OK',
196
+ headers: {
197
+ 'content-type': { value: 'application/json' },
198
+ 'cache-control': { value: 'private, max-age=' + maxAge }
199
+ },
200
+ body: {
201
+ encoding: 'text',
202
+ data: JSON.stringify({
203
+ name: userInfo.name,
204
+ roles: userInfo.roles
205
+ })
206
+ }
207
+ };
208
+ }
@@ -0,0 +1,219 @@
1
+ import json
2
+ import boto3
3
+ import time
4
+ from typing import Any, Dict
5
+
6
+ acm = boto3.client('acm', region_name='us-east-1')
7
+ route53 = boto3.client('route53')
8
+
9
+ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
10
+ """
11
+ Custom resource handler for creating ACM certificates in us-east-1.
12
+ """
13
+ print(f"Event: {json.dumps(event)}")
14
+
15
+ try:
16
+ request_type = event['RequestType']
17
+ props = event['ResourceProperties']
18
+
19
+ domain_name = props['DomainName']
20
+ sans = props.get('SubjectAlternativeNames', [])
21
+ hosted_zone_id = props['HostedZoneId']
22
+
23
+ print(f"Processing {request_type} for domain: {domain_name}")
24
+ print(f"Hosted Zone ID: {hosted_zone_id}")
25
+
26
+ if request_type == 'Create':
27
+ return create_certificate(domain_name, sans, hosted_zone_id)
28
+ elif request_type == 'Update':
29
+ # For updates, delete old and create new
30
+ old_props = event['OldResourceProperties']
31
+ old_cert_arn = event['PhysicalResourceId']
32
+
33
+ # Create new certificate first
34
+ result = create_certificate(domain_name, sans, hosted_zone_id)
35
+
36
+ # Delete old certificate (best effort)
37
+ try:
38
+ acm.delete_certificate(CertificateArn=old_cert_arn)
39
+ except Exception as e:
40
+ print(f"Failed to delete old certificate: {e}")
41
+
42
+ return result
43
+ elif request_type == 'Delete':
44
+ cert_arn = event['PhysicalResourceId']
45
+ return delete_certificate(cert_arn, hosted_zone_id)
46
+
47
+ return {
48
+ 'PhysicalResourceId': event.get('PhysicalResourceId', 'unknown'),
49
+ 'Data': {}
50
+ }
51
+ except Exception as e:
52
+ print(f"ERROR: {str(e)}")
53
+ import traceback
54
+ traceback.print_exc()
55
+ raise
56
+
57
+ def create_certificate(domain_name: str, sans: list, hosted_zone_id: str) -> Dict[str, Any]:
58
+ """Create and validate an ACM certificate."""
59
+
60
+ # Request certificate
61
+ request_params = {
62
+ 'DomainName': domain_name,
63
+ 'ValidationMethod': 'DNS',
64
+ }
65
+
66
+ if sans:
67
+ request_params['SubjectAlternativeNames'] = sans
68
+
69
+ response = acm.request_certificate(**request_params)
70
+ cert_arn = response['CertificateArn']
71
+
72
+ print(f"Certificate requested: {cert_arn}")
73
+
74
+ # Wait for validation records to be available
75
+ validation_records = wait_for_validation_records(cert_arn)
76
+
77
+ # Create DNS validation records
78
+ create_validation_records(validation_records, hosted_zone_id)
79
+
80
+ # Wait for certificate to be issued
81
+ wait_for_certificate_validation(cert_arn)
82
+
83
+ return {
84
+ 'PhysicalResourceId': cert_arn,
85
+ 'Data': {
86
+ 'CertificateArn': cert_arn
87
+ }
88
+ }
89
+
90
+ def delete_certificate(cert_arn: str, hosted_zone_id: str) -> Dict[str, Any]:
91
+ """Delete certificate and cleanup validation records."""
92
+
93
+ try:
94
+ # Get validation records before deleting
95
+ cert = acm.describe_certificate(CertificateArn=cert_arn)
96
+ validation_records = cert['Certificate'].get('DomainValidationOptions', [])
97
+
98
+ # Delete validation records
99
+ delete_validation_records(validation_records, hosted_zone_id)
100
+
101
+ # Delete certificate
102
+ acm.delete_certificate(CertificateArn=cert_arn)
103
+ print(f"Certificate deleted: {cert_arn}")
104
+ except acm.exceptions.ResourceNotFoundException:
105
+ print(f"Certificate not found: {cert_arn}")
106
+ except Exception as e:
107
+ print(f"Error deleting certificate: {e}")
108
+
109
+ return {
110
+ 'PhysicalResourceId': cert_arn,
111
+ 'Data': {}
112
+ }
113
+
114
+ def wait_for_validation_records(cert_arn: str, max_attempts: int = 60) -> list:
115
+ """Wait for validation records to be available."""
116
+
117
+ for attempt in range(max_attempts):
118
+ cert = acm.describe_certificate(CertificateArn=cert_arn)
119
+ validation_options = cert['Certificate'].get('DomainValidationOptions', [])
120
+
121
+ # Check if all domains have validation records
122
+ all_ready = all(
123
+ 'ResourceRecord' in option
124
+ for option in validation_options
125
+ )
126
+
127
+ if all_ready:
128
+ return validation_options
129
+
130
+ time.sleep(5)
131
+
132
+ raise Exception("Timeout waiting for validation records")
133
+
134
+ def create_validation_records(validation_options: list, hosted_zone_id: str) -> None:
135
+ """Create DNS validation records in Route53."""
136
+
137
+ changes = []
138
+ for option in validation_options:
139
+ if 'ResourceRecord' in option:
140
+ record = option['ResourceRecord']
141
+ print(f"Creating validation record: {record['Name']} -> {record['Value']}")
142
+ changes.append({
143
+ 'Action': 'UPSERT',
144
+ 'ResourceRecordSet': {
145
+ 'Name': record['Name'],
146
+ 'Type': record['Type'],
147
+ 'TTL': 300,
148
+ 'ResourceRecords': [{'Value': record['Value']}]
149
+ }
150
+ })
151
+
152
+ if changes:
153
+ print(f"Applying {len(changes)} DNS changes to hosted zone {hosted_zone_id}")
154
+ response = route53.change_resource_record_sets(
155
+ HostedZoneId=hosted_zone_id,
156
+ ChangeBatch={'Changes': changes}
157
+ )
158
+
159
+ # Wait for change to propagate
160
+ change_id = response['ChangeInfo']['Id']
161
+ print(f"Waiting for Route53 change {change_id} to complete")
162
+ wait_for_route53_change(change_id)
163
+ print(f"Route53 change completed")
164
+ else:
165
+ print("No validation records to create")
166
+
167
+ def delete_validation_records(validation_options: list, hosted_zone_id: str) -> None:
168
+ """Delete DNS validation records from Route53."""
169
+
170
+ changes = []
171
+ for option in validation_options:
172
+ if 'ResourceRecord' in option:
173
+ record = option['ResourceRecord']
174
+ changes.append({
175
+ 'Action': 'DELETE',
176
+ 'ResourceRecordSet': {
177
+ 'Name': record['Name'],
178
+ 'Type': record['Type'],
179
+ 'TTL': 300,
180
+ 'ResourceRecords': [{'Value': record['Value']}]
181
+ }
182
+ })
183
+
184
+ if changes:
185
+ try:
186
+ route53.change_resource_record_sets(
187
+ HostedZoneId=hosted_zone_id,
188
+ ChangeBatch={'Changes': changes}
189
+ )
190
+ except Exception as e:
191
+ print(f"Error deleting validation records: {e}")
192
+
193
+ def wait_for_route53_change(change_id: str, max_attempts: int = 60) -> None:
194
+ """Wait for Route53 change to complete."""
195
+
196
+ for attempt in range(max_attempts):
197
+ response = route53.get_change(Id=change_id)
198
+ if response['ChangeInfo']['Status'] == 'INSYNC':
199
+ return
200
+ time.sleep(5)
201
+
202
+ raise Exception("Timeout waiting for Route53 change")
203
+
204
+ def wait_for_certificate_validation(cert_arn: str, max_attempts: int = 120) -> None:
205
+ """Wait for certificate to be validated and issued."""
206
+
207
+ for attempt in range(max_attempts):
208
+ cert = acm.describe_certificate(CertificateArn=cert_arn)
209
+ status = cert['Certificate']['Status']
210
+
211
+ if status == 'ISSUED':
212
+ print(f"Certificate issued: {cert_arn}")
213
+ return
214
+ elif status == 'FAILED':
215
+ raise Exception(f"Certificate validation failed: {cert_arn}")
216
+
217
+ time.sleep(10)
218
+
219
+ raise Exception("Timeout waiting for certificate validation")
@@ -0,0 +1,215 @@
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
14
+ from config_generated import get_config
15
+
16
+ logger = logging.getLogger()
17
+ logger.setLevel(logging.INFO)
18
+
19
+ dynamodb = None
20
+ CONFIG = None
21
+ HMAC_SECRET = None
22
+
23
+
24
+ def get_config_cached():
25
+ global CONFIG, dynamodb
26
+ if CONFIG is None:
27
+ CONFIG = get_config()
28
+ dynamodb_region = CONFIG.get('dynamodb_region', 'us-east-1')
29
+ dynamodb = boto3.client('dynamodb', region_name=dynamodb_region)
30
+ return CONFIG
31
+
32
+
33
+ def get_hmac_secret():
34
+ global HMAC_SECRET
35
+ if HMAC_SECRET is not None:
36
+ return HMAC_SECRET
37
+ config = get_config_cached()
38
+ HMAC_SECRET = config.get('hmac_key')
39
+ if not HMAC_SECRET:
40
+ raise ValueError('hmac_key not found in config')
41
+ return HMAC_SECRET
42
+
43
+
44
+ def exchange_code_for_token(code, cognito_domain, client_id, redirect_uri, code_verifier):
45
+ token_url = f'https://{cognito_domain}/oauth2/token'
46
+ data = {
47
+ 'grant_type': 'authorization_code',
48
+ 'client_id': client_id,
49
+ 'code': code,
50
+ 'redirect_uri': redirect_uri,
51
+ 'code_verifier': code_verifier,
52
+ }
53
+ req = urllib.request.Request(
54
+ token_url,
55
+ data=urllib.parse.urlencode(data).encode('utf-8'),
56
+ headers={'Content-Type': 'application/x-www-form-urlencoded'},
57
+ )
58
+ try:
59
+ with urllib.request.urlopen(req) as response:
60
+ return json.loads(response.read().decode('utf-8'))
61
+ except urllib.error.HTTPError as e:
62
+ logger.error(f'Token exchange HTTP error: {e.code} {e.read().decode()}')
63
+ raise
64
+
65
+
66
+ def validate_jwt_token(id_token, client_id, user_pool_id, cognito_region):
67
+ jwks_url = f'https://cognito-idp.{cognito_region}.amazonaws.com/{user_pool_id}/.well-known/jwks.json'
68
+ with urllib.request.urlopen(jwks_url) as response:
69
+ jwks = json.loads(response.read().decode('utf-8'))
70
+
71
+ unverified_header = jwt.get_unverified_header(id_token)
72
+ rsa_key = next((k for k in jwks['keys'] if k['kid'] == unverified_header['kid']), None)
73
+ if not rsa_key:
74
+ raise ValueError('Unable to find matching key')
75
+
76
+ jwk = PyJWK.from_dict(rsa_key)
77
+ issuer = f'https://cognito-idp.{cognito_region}.amazonaws.com/{user_pool_id}'
78
+ return jwt.decode(id_token, jwk.key, algorithms=['RS256'], audience=client_id, issuer=issuer)
79
+
80
+
81
+ def lambda_handler(event, context):
82
+ request = event['Records'][0]['cf']['request']
83
+
84
+ try:
85
+ config = get_config_cached()
86
+ cognito_domain = config['cognito_domain']
87
+ client_id = config['cognito_client_id']
88
+ user_pool_id = config['cognito_user_pool_id']
89
+ cognito_region = config['cognito_region']
90
+ redirect_uri = config['redirect_uri']
91
+ table_name = config.get('dynamodb_table_name')
92
+ auto_revoke_on_reuse = config.get('auto_revoke_on_reuse', 'false').lower() == 'true'
93
+ jwt_claims_whitelist_str = config.get('jwt_claims_whitelist', '')
94
+ except Exception as e:
95
+ logger.error(f'Config load failed: {e}')
96
+ return {'status': '500', 'statusDescription': 'Internal Server Error', 'body': 'Configuration error'}
97
+
98
+ params = urllib.parse.parse_qs(request.get('querystring', ''))
99
+ code = params.get('code', [None])[0]
100
+ state = params.get('state', [None])[0]
101
+
102
+ if not code:
103
+ return {'status': '400', 'statusDescription': 'Bad Request', 'body': 'Missing authorization code'}
104
+
105
+ cookies = {}
106
+ for cookie in (request.get('headers', {}).get('cookie') or [{}])[0].get('value', '').split('; '):
107
+ if '=' in cookie:
108
+ name, value = cookie.split('=', 1)
109
+ cookies[name] = value
110
+
111
+ code_verifier = cookies.get('code_verifier')
112
+ if not code_verifier:
113
+ return {'status': '400', 'statusDescription': 'Bad Request', 'body': 'Authentication failed'}
114
+
115
+ stored_state = cookies.get('oauth_state')
116
+ if not stored_state or stored_state != state:
117
+ return {'status': '400', 'statusDescription': 'Bad Request', 'body': 'Invalid state parameter'}
118
+
119
+ if table_name and state:
120
+ try:
121
+ resp = dynamodb.get_item(
122
+ TableName=table_name,
123
+ Key={'pk': {'S': f'STATE#{state}'}, 'sk': {'S': f'STATE#{state}'}},
124
+ )
125
+ if not resp.get('Item'):
126
+ dynamodb.put_item(TableName=table_name, Item={
127
+ 'pk': {'S': f'STATE#{state}'}, 'sk': {'S': f'STATE#{state}'},
128
+ 'used': {'BOOL': True},
129
+ 'createdAt': {'N': str(int(time.time()))},
130
+ 'expiresAt': {'N': str(int(time.time()) + 600)},
131
+ })
132
+ elif resp['Item'].get('used', {}).get('BOOL') and auto_revoke_on_reuse:
133
+ return {'status': '400', 'statusDescription': 'Bad Request', 'body': 'Invalid or reused state token'}
134
+ except Exception as e:
135
+ logger.error(f'DynamoDB state check error: {e}')
136
+
137
+ redirect_path = '/'
138
+ try:
139
+ padded = state + '=' * (4 - len(state) % 4)
140
+ state_obj = json.loads(base64.urlsafe_b64decode(padded.replace('-', '+').replace('_', '/')))
141
+ redirect_path = state_obj.get('p', '/')
142
+ except Exception:
143
+ pass
144
+
145
+ try:
146
+ token_response = exchange_code_for_token(code, cognito_domain, client_id, redirect_uri, code_verifier)
147
+ id_token = token_response['id_token']
148
+
149
+ payload = validate_jwt_token(id_token, client_id, user_pool_id, cognito_region)
150
+ logger.info(f'JWT validated for: {payload.get("email", payload.get("cognito:username", "unknown"))}')
151
+
152
+ jti = f'sess_{uuid.uuid4().hex}'
153
+ user_id = payload.get('sub') or payload.get('email') or payload.get('cognito:username')
154
+
155
+ if jwt_claims_whitelist_str:
156
+ whitelist = json.loads(jwt_claims_whitelist_str)
157
+ filtered_payload = {k: v for k, v in payload.items() if k in whitelist}
158
+ else:
159
+ filtered_payload = {k: v for k, v in payload.items() if not k.startswith('cognito:')}
160
+
161
+ session_payload = {
162
+ **filtered_payload,
163
+ 'jti': jti,
164
+ 'exp': payload.get('exp', int(time.time()) + 3600),
165
+ 'iat': int(time.time()),
166
+ 'iss': redirect_uri,
167
+ 'idp': payload.get('iss'),
168
+ }
169
+
170
+ if table_name and user_id:
171
+ try:
172
+ dynamodb.put_item(TableName=table_name, Item={
173
+ 'pk': {'S': f'SESSION#{user_id}'}, 'sk': {'S': f'SESSION#{jti}'},
174
+ 'gsi1pk': {'S': f'USER#{user_id}'}, 'gsi1sk': {'S': f'SESSION#{int(time.time())}'},
175
+ 'jti': {'S': jti}, 'userId': {'S': user_id},
176
+ 'email': {'S': payload.get('email', '')},
177
+ 'createdAt': {'N': str(int(time.time()))},
178
+ 'revoked': {'BOOL': False},
179
+ 'expiresAt': {'N': str(int(time.time()) + 3600)},
180
+ })
181
+ except Exception as e:
182
+ logger.error(f'Failed to store session: {e}')
183
+
184
+ header_b64 = base64.urlsafe_b64encode(
185
+ json.dumps({'alg': 'HS256', 'typ': 'JWT'}, separators=(',', ':')).encode()
186
+ ).decode().rstrip('=')
187
+ payload_b64 = base64.urlsafe_b64encode(
188
+ json.dumps(session_payload, separators=(',', ':')).encode()
189
+ ).decode().rstrip('=')
190
+ signing_input = f'{header_b64}.{payload_b64}'
191
+ hmac_secret = get_hmac_secret()
192
+ sig = hmac.new(hmac_secret.encode(), signing_input.encode(), hashlib.sha256).digest()
193
+ cookie_value = f'{signing_input}.{base64.urlsafe_b64encode(sig).decode().rstrip("=")}'
194
+
195
+ exp_ts = payload.get('exp', int(time.time()) + 3600)
196
+ expires_str = datetime.utcfromtimestamp(exp_ts).strftime('%a, %d %b %Y %H:%M:%S GMT')
197
+
198
+ return {
199
+ 'status': '302',
200
+ 'statusDescription': 'Found',
201
+ 'headers': {
202
+ 'location': [{'key': 'Location', 'value': redirect_path}],
203
+ 'set-cookie': [
204
+ {'key': 'Set-Cookie', 'value': f'__Host-auth_session={cookie_value}; HttpOnly; Secure; SameSite=Lax; Path=/; Expires={expires_str}'},
205
+ {'key': 'Set-Cookie', 'value': 'oauth_state=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0'},
206
+ {'key': 'Set-Cookie', 'value': 'code_verifier=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0'},
207
+ ],
208
+ 'cache-control': [{'key': 'Cache-Control', 'value': 'no-store'}],
209
+ },
210
+ }
211
+
212
+ except Exception as e:
213
+ import traceback
214
+ logger.error(f'OAuth callback error: {traceback.format_exc()}')
215
+ return {'status': '500', 'statusDescription': 'Internal Server Error', 'body': 'Authentication failed'}