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,129 @@
1
+ import boto3
2
+ import json
3
+ import logging
4
+ import urllib3
5
+
6
+ logger = logging.getLogger()
7
+ logger.setLevel(logging.INFO)
8
+ http = urllib3.PoolManager()
9
+
10
+ def send_response(event, context, status, data=None):
11
+ """Send response to CloudFormation"""
12
+ response_body = {
13
+ 'Status': status,
14
+ 'Reason': f'See CloudWatch Log Stream: {context.log_stream_name}',
15
+ 'PhysicalResourceId': context.log_stream_name,
16
+ 'StackId': event['StackId'],
17
+ 'RequestId': event['RequestId'],
18
+ 'LogicalResourceId': event['LogicalResourceId'],
19
+ 'Data': data or {}
20
+ }
21
+
22
+ json_response = json.dumps(response_body)
23
+ headers = {'content-type': '', 'content-length': str(len(json_response))}
24
+
25
+ try:
26
+ http.request('PUT', event['ResponseURL'], body=json_response, headers=headers)
27
+ except Exception as e:
28
+ logger.error(f'Failed to send response: {e}')
29
+
30
+ def copy_secret_to_kvs(secret_arn, kvs_arn):
31
+ """
32
+ Core logic to copy HMAC secret from Secrets Manager to CloudFront KVS.
33
+ Implements dual-secret rotation: stores both current and old secret for zero-downtime rotation.
34
+ """
35
+ logger.info(f'Copying secret from {secret_arn} to KVS {kvs_arn}')
36
+
37
+ secrets_client = boto3.client('secretsmanager')
38
+ secret_response = secrets_client.get_secret_value(SecretId=secret_arn)
39
+ secret_data = json.loads(secret_response['SecretString'])
40
+ new_secret = secret_data['hmac_key']
41
+
42
+ logger.info('New secret retrieved from Secrets Manager')
43
+
44
+ cf_client = boto3.client('cloudfront-keyvaluestore')
45
+
46
+ kvs_response = cf_client.describe_key_value_store(KvsARN=kvs_arn)
47
+ etag = kvs_response['ETag']
48
+ logger.info(f'KVS ETag: {etag}')
49
+
50
+ try:
51
+ current_response = cf_client.get_key(
52
+ KvsARN=kvs_arn,
53
+ # amazonq-ignore-next-line
54
+ Key='jwt.secret'
55
+ )
56
+ old_secret = current_response['Value']
57
+
58
+ cf_client.put_key(
59
+ KvsARN=kvs_arn,
60
+ # amazonq-ignore-next-line
61
+ Key='jwt.secret.old',
62
+ Value=old_secret,
63
+ IfMatch=etag
64
+ )
65
+ logger.info('Current secret preserved as old secret')
66
+
67
+ kvs_response = cf_client.describe_key_value_store(KvsARN=kvs_arn)
68
+ etag = kvs_response['ETag']
69
+ except cf_client.exceptions.ResourceNotFoundException:
70
+ logger.info('No existing secret found (first deployment)')
71
+ except Exception as e:
72
+ logger.warning(f'Could not preserve old secret: {e}')
73
+
74
+ cf_client.put_key(
75
+ KvsARN=kvs_arn,
76
+ # amazonq-ignore-next-line
77
+ Key='jwt.secret',
78
+ Value=new_secret,
79
+ IfMatch=etag
80
+ )
81
+
82
+ logger.info('New secret copied to CloudFront KVS successfully')
83
+ logger.info('Dual-secret rotation complete: jwt.secret (new) + jwt.secret.old (previous)')
84
+
85
+ def handler(event, context):
86
+ """
87
+ Handler supporting both CloudFormation Custom Resource and direct Lambda invocations.
88
+ """
89
+ try:
90
+ is_cfn_event = 'RequestType' in event and 'ResponseURL' in event
91
+
92
+ if is_cfn_event:
93
+ logger.info(f'CloudFormation Request Type: {event["RequestType"]}')
94
+ request_type = event['RequestType']
95
+
96
+ if request_type in ['Create', 'Update']:
97
+ secret_arn = event['ResourceProperties']['SecretArn']
98
+ kvs_arn = event['ResourceProperties']['KvsArn']
99
+
100
+ copy_secret_to_kvs(secret_arn, kvs_arn)
101
+ send_response(event, context, 'SUCCESS', {
102
+ 'Message': 'Secret rotation complete with dual-secret support'
103
+ })
104
+
105
+ elif request_type == 'Delete':
106
+ logger.info('Delete request - no cleanup needed')
107
+ send_response(event, context, 'SUCCESS')
108
+
109
+ else:
110
+ logger.warning(f'Unknown request type: {request_type}')
111
+ send_response(event, context, 'SUCCESS')
112
+ else:
113
+ logger.info('Direct invocation (non-CloudFormation)')
114
+ secret_arn = event['ResourceProperties']['SecretArn']
115
+ kvs_arn = event['ResourceProperties']['KvsArn']
116
+
117
+ copy_secret_to_kvs(secret_arn, kvs_arn)
118
+
119
+ return {
120
+ 'statusCode': 200,
121
+ 'body': json.dumps({'message': 'Secret copied successfully'})
122
+ }
123
+
124
+ except Exception as e:
125
+ logger.error(f'Error: {str(e)}', exc_info=True)
126
+ if 'ResponseURL' in event:
127
+ send_response(event, context, 'FAILED')
128
+ else:
129
+ raise
@@ -0,0 +1 @@
1
+ botocore[crt]>=1.31.0
@@ -0,0 +1,88 @@
1
+ import json
2
+ import base64
3
+ from datetime import datetime
4
+
5
+ def handler(event, context):
6
+ cookies = event.get('cookies', [])
7
+
8
+ auth_token = None
9
+ for cookie in cookies:
10
+ if cookie.startswith('__Host-auth_session='):
11
+ auth_token = cookie.split('=', 1)[1]
12
+ break
13
+
14
+ if not auth_token:
15
+ return {
16
+ 'statusCode': 200,
17
+ 'headers': {
18
+ 'Content-Type': 'text/html',
19
+ 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
20
+ },
21
+ 'body': '<html><body><h1>No JWT Found</h1><p>No auth_session cookie present.</p></body></html>'
22
+ }
23
+
24
+ try:
25
+ parts = auth_token.split('.')
26
+ if len(parts) != 3:
27
+ raise ValueError('Invalid JWT format')
28
+
29
+ header = json.loads(base64.urlsafe_b64decode(parts[0] + '=='))
30
+ payload = json.loads(base64.urlsafe_b64decode(parts[1] + '=='))
31
+
32
+ # Format timestamps
33
+ nbf_time = datetime.fromtimestamp(payload.get('nbf', 0)).strftime('%Y-%m-%d %H:%M:%S') if 'nbf' in payload else 'N/A'
34
+ exp_time = datetime.fromtimestamp(payload.get('exp', 0)).strftime('%Y-%m-%d %H:%M:%S') if 'exp' in payload else 'N/A'
35
+
36
+ html = f'''
37
+ <html>
38
+ <head><title>JWT Decoder</title></head>
39
+ <body style="font-family: monospace; padding: 20px;">
40
+ <h1>JWT Token Details</h1>
41
+ <p><strong>Not Before:</strong> <span id="nbf">{nbf_time} UTC</span></p>
42
+ <p><strong>Expires:</strong> <span id="exp">{exp_time} UTC</span></p>
43
+ <h2>Header</h2>
44
+ <pre>{json.dumps(header, indent=2)}</pre>
45
+ <h2>Payload</h2>
46
+ <pre>{json.dumps(payload, indent=2)}</pre>
47
+ <h2>Signature</h2>
48
+ <pre>{parts[2]}</pre>
49
+ <script>
50
+ const nbf = {payload.get('nbf', 0)};
51
+ const exp = {payload.get('exp', 0)};
52
+
53
+ const offset = -new Date().getTimezoneOffset();
54
+ const offsetHours = Math.floor(Math.abs(offset) / 60);
55
+ const offsetMinutes = Math.abs(offset) % 60;
56
+ const offsetSign = offset >= 0 ? '+' : '-';
57
+ const offsetStr = `${{offsetSign}}${{String(offsetHours).padStart(2, '0')}}:${{String(offsetMinutes).padStart(2, '0')}}`;
58
+
59
+ const options = {{ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }};
60
+
61
+ if (nbf) {{
62
+ document.getElementById('nbf').textContent = `${{new Date(nbf * 1000).toLocaleString('en-NZ', options)}} (UTC${{offsetStr}})`;
63
+ }}
64
+ if (exp) {{
65
+ document.getElementById('exp').textContent = `${{new Date(exp * 1000).toLocaleString('en-NZ', options)}} (UTC${{offsetStr}})`;
66
+ }}
67
+ </script>
68
+ </body>
69
+ </html>
70
+ '''
71
+
72
+ return {
73
+ 'statusCode': 200,
74
+ 'headers': {
75
+ 'Content-Type': 'text/html',
76
+ 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
77
+ },
78
+ 'body': html
79
+ }
80
+ except Exception as e:
81
+ return {
82
+ 'statusCode': 200,
83
+ 'headers': {
84
+ 'Content-Type': 'text/html',
85
+ 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
86
+ },
87
+ 'body': f'<html><body><h1>Error Decoding JWT</h1><p>{str(e)}</p></body></html>'
88
+ }
@@ -0,0 +1,11 @@
1
+ def handler(event, context):
2
+ groups = event['request']['groupConfiguration']['groupsToOverride']
3
+ event['response']['claimsAndScopeOverrideDetails'] = {
4
+ 'idTokenGeneration': {
5
+ 'claimsToAddOrOverride': {
6
+ 'roles': groups,
7
+ 'https://aws.amazon.com/tags/principal_tags/Claims': ':' + ':'.join(groups) + ':',
8
+ }
9
+ }
10
+ }
11
+ return event
@@ -0,0 +1,86 @@
1
+ import json
2
+ import boto3
3
+ import secrets
4
+ import string
5
+ import os
6
+ import time
7
+
8
+ secretsmanager = boto3.client('secretsmanager')
9
+ lambda_client = boto3.client('lambda')
10
+
11
+ SECRET_ARN = os.environ['SECRET_ARN']
12
+ COPY_LAMBDA_ARN = os.environ['COPY_LAMBDA_ARN']
13
+ KVS_ARN = os.environ['KVS_ARN']
14
+
15
+ MAX_RETRIES = 3
16
+ RETRY_DELAY = 2
17
+
18
+ def generate_hmac_key(length=64):
19
+ alphabet = string.ascii_letters + string.digits
20
+ return ''.join(secrets.choice(alphabet) for _ in range(length))
21
+
22
+ def invoke_copy_lambda_with_retry(payload):
23
+ """Invoke copy lambda synchronously with retry logic"""
24
+ for attempt in range(MAX_RETRIES):
25
+ try:
26
+ print(f'Invoking copy lambda (attempt {attempt + 1}/{MAX_RETRIES})')
27
+ response = lambda_client.invoke(
28
+ FunctionName=COPY_LAMBDA_ARN,
29
+ InvocationType='RequestResponse',
30
+ Payload=json.dumps(payload)
31
+ )
32
+
33
+ response_payload = json.loads(response['Payload'].read())
34
+
35
+ if response['StatusCode'] == 200:
36
+ if 'statusCode' in response_payload and response_payload['statusCode'] == 200:
37
+ print('Copy lambda invoked successfully')
38
+ return response_payload
39
+ elif 'FunctionError' in response:
40
+ raise Exception(f"Copy lambda error: {response_payload}")
41
+ else:
42
+ print('Copy lambda completed successfully')
43
+ return response_payload
44
+ else:
45
+ raise Exception(f"Lambda invocation failed with status {response['StatusCode']}")
46
+
47
+ except Exception as e:
48
+ print(f'Attempt {attempt + 1} failed: {str(e)}')
49
+ if attempt < MAX_RETRIES - 1:
50
+ print(f'Retrying in {RETRY_DELAY} seconds...')
51
+ time.sleep(RETRY_DELAY)
52
+ else:
53
+ raise Exception(f'Failed to invoke copy lambda after {MAX_RETRIES} attempts: {str(e)}')
54
+
55
+ def handler(event, context):
56
+ print(f"Rotating secret: {SECRET_ARN}")
57
+
58
+ response = secretsmanager.get_secret_value(SecretId=SECRET_ARN)
59
+ current_secret = json.loads(response['SecretString'])
60
+
61
+ new_hmac_key = generate_hmac_key()
62
+
63
+ current_secret['hmac_key'] = new_hmac_key
64
+
65
+ secretsmanager.put_secret_value(
66
+ SecretId=SECRET_ARN,
67
+ SecretString=json.dumps(current_secret)
68
+ )
69
+
70
+ print(f"Secret rotated in Secrets Manager")
71
+
72
+ payload = {
73
+ 'ResourceProperties': {
74
+ 'SecretArn': SECRET_ARN,
75
+ 'KvsArn': KVS_ARN
76
+ }
77
+ }
78
+
79
+ invoke_copy_lambda_with_retry(payload)
80
+
81
+ print(f"KVS updated successfully")
82
+
83
+ return {
84
+ 'statusCode': 200,
85
+ 'body': json.dumps('Secret rotated and synchronized successfully')
86
+ }
@@ -0,0 +1,80 @@
1
+ import json
2
+ import boto3
3
+ import logging
4
+ import os
5
+ import time
6
+
7
+ logger = logging.getLogger()
8
+ logger.setLevel(logging.INFO)
9
+
10
+ dynamodb = boto3.client('dynamodb')
11
+ kvs_client = boto3.client('cloudfront-keyvaluestore')
12
+
13
+ TABLE_NAME = os.environ['TABLE_NAME']
14
+ KVS_ARN = os.environ['KVS_ARN']
15
+
16
+ def lambda_handler(event, context):
17
+ """Triggered by SNS message to revoke user sessions
18
+
19
+ Message format: {"action": "revoke", "userId": "user@example.com"}
20
+ """
21
+
22
+ for record in event['Records']:
23
+ try:
24
+ message = json.loads(record['Sns']['Message'])
25
+
26
+ if message.get('action') != 'revoke':
27
+ logger.warning(f'Unknown action: {message.get("action")}')
28
+ continue
29
+
30
+ user_id = message.get('userId')
31
+ if not user_id:
32
+ logger.error('Missing userId in revocation message')
33
+ continue
34
+
35
+ logger.info(f'Processing revocation for user: {user_id}')
36
+
37
+ response = dynamodb.query(
38
+ TableName=TABLE_NAME,
39
+ IndexName='GSI1',
40
+ KeyConditionExpression='gsi1pk = :pk',
41
+ FilterExpression='revoked = :false',
42
+ ExpressionAttributeValues={
43
+ ':pk': {'S': f'USER#{user_id}'},
44
+ ':false': {'BOOL': False}
45
+ }
46
+ )
47
+
48
+ revoked_count = 0
49
+ for item in response.get('Items', []):
50
+ jti = item['jti']['S']
51
+
52
+ dynamodb.update_item(
53
+ TableName=TABLE_NAME,
54
+ Key={
55
+ 'pk': {'S': f'SESSION#{user_id}'},
56
+ 'sk': {'S': f'SESSION#{jti}'}
57
+ },
58
+ UpdateExpression='SET revoked = :true, revokedAt = :now',
59
+ ExpressionAttributeValues={
60
+ ':true': {'BOOL': True},
61
+ ':now': {'N': str(int(time.time()))}
62
+ }
63
+ )
64
+
65
+ kvs_client.put_key(
66
+ KvsARN=KVS_ARN,
67
+ Key=f'revoked:{jti}',
68
+ Value=str(int(time.time())),
69
+ IfMatch='*'
70
+ )
71
+
72
+ revoked_count += 1
73
+
74
+ logger.info(f'Revoked {revoked_count} sessions for user: {user_id}')
75
+
76
+ except Exception as e:
77
+ logger.error(f'Error processing revocation: {str(e)}')
78
+ raise
79
+
80
+ return {'statusCode': 200, 'body': 'Revocation processed'}
@@ -0,0 +1,44 @@
1
+ import boto3
2
+ import json
3
+ import urllib3
4
+
5
+ http = urllib3.PoolManager()
6
+
7
+ def send_response(event, context, status, reason, physical_id):
8
+ body = json.dumps({
9
+ 'Status': status,
10
+ 'Reason': reason,
11
+ 'PhysicalResourceId': physical_id,
12
+ 'StackId': event['StackId'],
13
+ 'RequestId': event['RequestId'],
14
+ 'LogicalResourceId': event['LogicalResourceId'],
15
+ 'Data': {},
16
+ })
17
+ try:
18
+ http.request('PUT', event['ResponseURL'], body=body.encode('utf-8'), headers={'Content-Type': ''})
19
+ except Exception as e:
20
+ print(f'Failed to send response to CloudFormation: {str(e)}')
21
+
22
+ def handler(event, context):
23
+ physical_id = event.get('PhysicalResourceId', context.log_stream_name)
24
+ try:
25
+ props = event['ResourceProperties']
26
+ prefix = props['Prefix']
27
+ params = json.loads(props['Params'])
28
+ region = props.get('Region', 'us-east-1')
29
+ ssm = boto3.client('ssm', region_name=region)
30
+
31
+ if event['RequestType'] in ('Create', 'Update'):
32
+ for key, value in params.items():
33
+ ssm.put_parameter(Name=f'{prefix}/{key}', Value=value, Type='String', Overwrite=True)
34
+ physical_id = prefix
35
+
36
+ elif event['RequestType'] == 'Delete':
37
+ names = [f'{prefix}/{key}' for key in params]
38
+ for i in range(0, len(names), 10):
39
+ ssm.delete_parameters(Names=names[i:i+10])
40
+
41
+ send_response(event, context, 'SUCCESS', 'OK', physical_id)
42
+ except Exception as e:
43
+ print(f'Error: {str(e)}')
44
+ send_response(event, context, 'FAILED', str(e), physical_id)
@@ -0,0 +1,53 @@
1
+ import boto3
2
+ import logging
3
+ import os
4
+
5
+ logger = logging.getLogger()
6
+ logger.setLevel(logging.INFO)
7
+
8
+ kvs_client = boto3.client('cloudfront-keyvaluestore')
9
+ KVS_ARN = os.environ['KVS_ARN']
10
+
11
+ def lambda_handler(event, context):
12
+ """Triggered by DynamoDB Stream when TTL deletes expired sessions
13
+
14
+ Automatically removes corresponding revocations from KVS.
15
+ Only processes revoked sessions (not all TTL deletions).
16
+ """
17
+
18
+ deleted_count = 0
19
+
20
+ for record in event['Records']:
21
+ if record['eventName'] != 'REMOVE':
22
+ continue
23
+
24
+ if 'userIdentity' not in record or record['userIdentity'].get('type') != 'Service':
25
+ continue
26
+
27
+ old_image = record['dynamodb'].get('OldImage', {})
28
+
29
+ pk = old_image.get('pk', {}).get('S', '')
30
+ if not pk.startswith('SESSION#'):
31
+ continue
32
+
33
+ revoked = old_image.get('revoked', {}).get('BOOL', False)
34
+ if not revoked:
35
+ continue
36
+
37
+ jti = old_image.get('jti', {}).get('S')
38
+ if not jti:
39
+ continue
40
+
41
+ try:
42
+ kvs_client.delete_key(
43
+ KvsARN=KVS_ARN,
44
+ Key=f'revoked:{jti}',
45
+ IfMatch='*'
46
+ )
47
+ deleted_count += 1
48
+ logger.info(f'Cleaned up expired revocation from KVS: {jti}')
49
+ except Exception as e:
50
+ logger.warning(f'Failed to delete revocation from KVS: {jti}, error: {e}')
51
+
52
+ logger.info(f'Processed {len(event["Records"])} stream records, cleaned {deleted_count} revocations')
53
+ return {'statusCode': 200, 'deletedCount': deleted_count}