raindancers-cloudfront 0.0.0 → 0.0.2

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 (38) 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/deployment/viteFrontendDeployment.d.ts +1 -0
  19. package/lib/cloudfront/deployment/viteFrontendDeployment.js +2 -2
  20. package/lib/cloudfront/lambda/certificate/index.py +219 -0
  21. package/lib/cloudfront/lambda/cognito-auth/oauth-callback.py +215 -0
  22. package/lib/cloudfront/lambda/cognito-auth/requirements.txt +3 -0
  23. package/lib/cloudfront/lambda/edge-auth/config.py +6 -0
  24. package/lib/cloudfront/lambda/edge-auth/config_generated.py +12 -0
  25. package/lib/cloudfront/lambda/edge-auth/oauth-callback.py +538 -0
  26. package/lib/cloudfront/lambda/edge-auth/requirements.txt +3 -0
  27. package/lib/cloudfront/lambda/hmacSecret/index.py +129 -0
  28. package/lib/cloudfront/lambda/hmacSecret/requirements.txt +1 -0
  29. package/lib/cloudfront/lambda/jwt-decoder/index.py +88 -0
  30. package/lib/cloudfront/lambda/pre-token/index.py +11 -0
  31. package/lib/cloudfront/lambda/rotateSecret/index.py +86 -0
  32. package/lib/cloudfront/lambda/session-revocation/index.py +80 -0
  33. package/lib/cloudfront/lambda/ssm-writer/index.py +44 -0
  34. package/lib/cloudfront/lambda/stream-processor/index.py +53 -0
  35. package/lib/cloudfront/lambda/webacl/index.py +356 -0
  36. package/lib/cloudfront/logging/README.md +144 -0
  37. package/lib/cloudfront/patterns/SPLIT_STACK_USAGE.md +138 -0
  38. 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
+ }
@@ -5,6 +5,7 @@ export interface ViteFrontendDeploymentProps {
5
5
  readonly sourcePath: string;
6
6
  readonly destinationBucket: s3.IBucket;
7
7
  readonly distribution: cloudfront.IDistribution;
8
+ readonly distributionPaths?: string[];
8
9
  }
9
10
  export declare class ViteFrontendDeployment extends Construct {
10
11
  readonly deployment: s3deploy.BucketDeployment;
@@ -55,9 +55,9 @@ class ViteFrontendDeployment extends constructs_1.Construct {
55
55
  destinationBucket: props.destinationBucket,
56
56
  destinationKeyPrefix: `${props.appName}/`,
57
57
  distribution: props.distribution,
58
- distributionPaths: ['/*'],
58
+ distributionPaths: props.distributionPaths ?? [`/${props.appName}/*`],
59
59
  });
60
60
  }
61
61
  }
62
62
  exports.ViteFrontendDeployment = ViteFrontendDeployment;
63
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidml0ZUZyb250ZW5kRGVwbG95bWVudC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jbG91ZGZyb250L2RlcGxveW1lbnQvdml0ZUZyb250ZW5kRGVwbG95bWVudC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7QUFBQSxrREFBb0M7QUFDcEMsNkNBQXdHO0FBQ3hHLDJDQUF1QztBQVN2QyxNQUFhLHNCQUF1QixTQUFRLHNCQUFTO0lBR25ELFlBQVksS0FBZ0IsRUFBRSxFQUFVLEVBQUUsS0FBa0M7UUFDMUUsS0FBSyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsQ0FBQztRQUVqQixJQUFJLENBQUMsVUFBVSxHQUFHLElBQUksK0JBQVEsQ0FBQyxnQkFBZ0IsQ0FBQyxJQUFJLEVBQUUsWUFBWSxFQUFFO1lBQ2xFLE9BQU8sRUFBRTtnQkFDUCwrQkFBUSxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDLFVBQVUsRUFBRTtvQkFDdEMsUUFBUSxFQUFFO3dCQUNSLEtBQUssRUFBRSxJQUFJLENBQUMsV0FBVyxDQUFDLFlBQVksQ0FBQyxnQkFBZ0IsQ0FBQzt3QkFDdEQsT0FBTyxFQUFFOzRCQUNQLElBQUksRUFBRSxJQUFJOzRCQUNWLHNFQUFzRTt5QkFDdkU7cUJBQ0Y7aUJBQ0YsQ0FBQzthQUNIO1lBQ0QsaUJBQWlCLEVBQUUsS0FBSyxDQUFDLGlCQUFpQjtZQUMxQyxvQkFBb0IsRUFBRSxHQUFHLEtBQUssQ0FBQyxPQUFPLEdBQUc7WUFDekMsWUFBWSxFQUFFLEtBQUssQ0FBQyxZQUFZO1lBQ2hDLGlCQUFpQixFQUFFLENBQUMsSUFBSSxDQUFDO1NBQzFCLENBQUMsQ0FBQztJQUNMLENBQUM7Q0FDRjtBQXhCRCx3REF3QkMiLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBjb3JlIGZyb20gJ2F3cy1jZGstbGliJztcbmltcG9ydCB7IGF3c19zMyBhcyBzMywgYXdzX3MzX2RlcGxveW1lbnQgYXMgczNkZXBsb3ksIGF3c19jbG91ZGZyb250IGFzIGNsb3VkZnJvbnQgfSBmcm9tICdhd3MtY2RrLWxpYic7XG5pbXBvcnQgeyBDb25zdHJ1Y3QgfSBmcm9tICdjb25zdHJ1Y3RzJztcblxuZXhwb3J0IGludGVyZmFjZSBWaXRlRnJvbnRlbmREZXBsb3ltZW50UHJvcHMge1xuICByZWFkb25seSBhcHBOYW1lOiBzdHJpbmc7XG4gIHJlYWRvbmx5IHNvdXJjZVBhdGg6IHN0cmluZztcbiAgcmVhZG9ubHkgZGVzdGluYXRpb25CdWNrZXQ6IHMzLklCdWNrZXQ7XG4gIHJlYWRvbmx5IGRpc3RyaWJ1dGlvbjogY2xvdWRmcm9udC5JRGlzdHJpYnV0aW9uO1xufVxuXG5leHBvcnQgY2xhc3MgVml0ZUZyb250ZW5kRGVwbG95bWVudCBleHRlbmRzIENvbnN0cnVjdCB7XG4gIHB1YmxpYyByZWFkb25seSBkZXBsb3ltZW50OiBzM2RlcGxveS5CdWNrZXREZXBsb3ltZW50O1xuXG4gIGNvbnN0cnVjdG9yKHNjb3BlOiBDb25zdHJ1Y3QsIGlkOiBzdHJpbmcsIHByb3BzOiBWaXRlRnJvbnRlbmREZXBsb3ltZW50UHJvcHMpIHtcbiAgICBzdXBlcihzY29wZSwgaWQpO1xuXG4gICAgdGhpcy5kZXBsb3ltZW50ID0gbmV3IHMzZGVwbG95LkJ1Y2tldERlcGxveW1lbnQodGhpcywgJ0RlcGxveW1lbnQnLCB7XG4gICAgICBzb3VyY2VzOiBbXG4gICAgICAgIHMzZGVwbG95LlNvdXJjZS5hc3NldChwcm9wcy5zb3VyY2VQYXRoLCB7XG4gICAgICAgICAgYnVuZGxpbmc6IHtcbiAgICAgICAgICAgIGltYWdlOiBjb3JlLkRvY2tlckltYWdlLmZyb21SZWdpc3RyeSgnbm9kZToyMC1hbHBpbmUnKSxcbiAgICAgICAgICAgIGNvbW1hbmQ6IFtcbiAgICAgICAgICAgICAgJ3NoJywgJy1jJyxcbiAgICAgICAgICAgICAgJ25wbSBjaSAtLWluY2x1ZGU9ZGV2ICYmIG5wbSBydW4gYnVpbGQgJiYgY3AgLXIgZGlzdC8uIC9hc3NldC1vdXRwdXQvJyxcbiAgICAgICAgICAgIF0sXG4gICAgICAgICAgfSxcbiAgICAgICAgfSksXG4gICAgICBdLFxuICAgICAgZGVzdGluYXRpb25CdWNrZXQ6IHByb3BzLmRlc3RpbmF0aW9uQnVja2V0LFxuICAgICAgZGVzdGluYXRpb25LZXlQcmVmaXg6IGAke3Byb3BzLmFwcE5hbWV9L2AsXG4gICAgICBkaXN0cmlidXRpb246IHByb3BzLmRpc3RyaWJ1dGlvbixcbiAgICAgIGRpc3RyaWJ1dGlvblBhdGhzOiBbJy8qJ10sXG4gICAgfSk7XG4gIH1cbn1cbiJdfQ==
63
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidml0ZUZyb250ZW5kRGVwbG95bWVudC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jbG91ZGZyb250L2RlcGxveW1lbnQvdml0ZUZyb250ZW5kRGVwbG95bWVudC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7QUFBQSxrREFBb0M7QUFDcEMsNkNBQXdHO0FBQ3hHLDJDQUF1QztBQVV2QyxNQUFhLHNCQUF1QixTQUFRLHNCQUFTO0lBR25ELFlBQVksS0FBZ0IsRUFBRSxFQUFVLEVBQUUsS0FBa0M7UUFDMUUsS0FBSyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsQ0FBQztRQUVqQixJQUFJLENBQUMsVUFBVSxHQUFHLElBQUksK0JBQVEsQ0FBQyxnQkFBZ0IsQ0FBQyxJQUFJLEVBQUUsWUFBWSxFQUFFO1lBQ2xFLE9BQU8sRUFBRTtnQkFDUCwrQkFBUSxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDLFVBQVUsRUFBRTtvQkFDdEMsUUFBUSxFQUFFO3dCQUNSLEtBQUssRUFBRSxJQUFJLENBQUMsV0FBVyxDQUFDLFlBQVksQ0FBQyxnQkFBZ0IsQ0FBQzt3QkFDdEQsT0FBTyxFQUFFOzRCQUNQLElBQUksRUFBRSxJQUFJOzRCQUNWLHNFQUFzRTt5QkFDdkU7cUJBQ0Y7aUJBQ0YsQ0FBQzthQUNIO1lBQ0QsaUJBQWlCLEVBQUUsS0FBSyxDQUFDLGlCQUFpQjtZQUMxQyxvQkFBb0IsRUFBRSxHQUFHLEtBQUssQ0FBQyxPQUFPLEdBQUc7WUFDekMsWUFBWSxFQUFFLEtBQUssQ0FBQyxZQUFZO1lBQ2hDLGlCQUFpQixFQUFFLEtBQUssQ0FBQyxpQkFBaUIsSUFBSSxDQUFDLElBQUksS0FBSyxDQUFDLE9BQU8sSUFBSSxDQUFDO1NBQ3RFLENBQUMsQ0FBQztJQUNMLENBQUM7Q0FDRjtBQXhCRCx3REF3QkMiLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBjb3JlIGZyb20gJ2F3cy1jZGstbGliJztcbmltcG9ydCB7IGF3c19zMyBhcyBzMywgYXdzX3MzX2RlcGxveW1lbnQgYXMgczNkZXBsb3ksIGF3c19jbG91ZGZyb250IGFzIGNsb3VkZnJvbnQgfSBmcm9tICdhd3MtY2RrLWxpYic7XG5pbXBvcnQgeyBDb25zdHJ1Y3QgfSBmcm9tICdjb25zdHJ1Y3RzJztcblxuZXhwb3J0IGludGVyZmFjZSBWaXRlRnJvbnRlbmREZXBsb3ltZW50UHJvcHMge1xuICByZWFkb25seSBhcHBOYW1lOiBzdHJpbmc7XG4gIHJlYWRvbmx5IHNvdXJjZVBhdGg6IHN0cmluZztcbiAgcmVhZG9ubHkgZGVzdGluYXRpb25CdWNrZXQ6IHMzLklCdWNrZXQ7XG4gIHJlYWRvbmx5IGRpc3RyaWJ1dGlvbjogY2xvdWRmcm9udC5JRGlzdHJpYnV0aW9uO1xuICByZWFkb25seSBkaXN0cmlidXRpb25QYXRocz86IHN0cmluZ1tdO1xufVxuXG5leHBvcnQgY2xhc3MgVml0ZUZyb250ZW5kRGVwbG95bWVudCBleHRlbmRzIENvbnN0cnVjdCB7XG4gIHB1YmxpYyByZWFkb25seSBkZXBsb3ltZW50OiBzM2RlcGxveS5CdWNrZXREZXBsb3ltZW50O1xuXG4gIGNvbnN0cnVjdG9yKHNjb3BlOiBDb25zdHJ1Y3QsIGlkOiBzdHJpbmcsIHByb3BzOiBWaXRlRnJvbnRlbmREZXBsb3ltZW50UHJvcHMpIHtcbiAgICBzdXBlcihzY29wZSwgaWQpO1xuXG4gICAgdGhpcy5kZXBsb3ltZW50ID0gbmV3IHMzZGVwbG95LkJ1Y2tldERlcGxveW1lbnQodGhpcywgJ0RlcGxveW1lbnQnLCB7XG4gICAgICBzb3VyY2VzOiBbXG4gICAgICAgIHMzZGVwbG95LlNvdXJjZS5hc3NldChwcm9wcy5zb3VyY2VQYXRoLCB7XG4gICAgICAgICAgYnVuZGxpbmc6IHtcbiAgICAgICAgICAgIGltYWdlOiBjb3JlLkRvY2tlckltYWdlLmZyb21SZWdpc3RyeSgnbm9kZToyMC1hbHBpbmUnKSxcbiAgICAgICAgICAgIGNvbW1hbmQ6IFtcbiAgICAgICAgICAgICAgJ3NoJywgJy1jJyxcbiAgICAgICAgICAgICAgJ25wbSBjaSAtLWluY2x1ZGU9ZGV2ICYmIG5wbSBydW4gYnVpbGQgJiYgY3AgLXIgZGlzdC8uIC9hc3NldC1vdXRwdXQvJyxcbiAgICAgICAgICAgIF0sXG4gICAgICAgICAgfSxcbiAgICAgICAgfSksXG4gICAgICBdLFxuICAgICAgZGVzdGluYXRpb25CdWNrZXQ6IHByb3BzLmRlc3RpbmF0aW9uQnVja2V0LFxuICAgICAgZGVzdGluYXRpb25LZXlQcmVmaXg6IGAke3Byb3BzLmFwcE5hbWV9L2AsXG4gICAgICBkaXN0cmlidXRpb246IHByb3BzLmRpc3RyaWJ1dGlvbixcbiAgICAgIGRpc3RyaWJ1dGlvblBhdGhzOiBwcm9wcy5kaXN0cmlidXRpb25QYXRocyA/PyBbYC8ke3Byb3BzLmFwcE5hbWV9LypgXSxcbiAgICB9KTtcbiAgfVxufVxuIl19
@@ -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")