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.
- package/lib/bicep/azure-functions/custom-claims-provider/function_app.py +164 -0
- package/lib/bicep/azure-functions/custom-claims-provider/host.json +10 -0
- package/lib/bicep/azure-functions/custom-claims-provider/requirements.txt +8 -0
- package/lib/bicep/deploy/lambda/Dockerfile +10 -0
- package/lib/bicep/deploy/lambda/index.py +341 -0
- package/lib/bicep/patterns/scripts/LOCALDEBUGREADME.md +86 -0
- package/lib/bicep/patterns/scripts/wire-custom-claims-extension.sh +122 -0
- package/lib/bicep/patterns/scripts/wire-custom-claims-extension.sh.bak +118 -0
- package/lib/cloudfront/cloudfront-functions/auth-check.js +220 -0
- package/lib/cloudfront/cloudfront-functions/modules/auth-check.js +263 -0
- package/lib/cloudfront/cloudfront-functions/modules/cognito-auth-check.js +223 -0
- package/lib/cloudfront/cloudfront-functions/modules/shared-utils.js +47 -0
- package/lib/cloudfront/cloudfront-functions/modules/tls-check.js +39 -0
- package/lib/cloudfront/cloudfront-functions/modules/url-rewrite.js +6 -0
- package/lib/cloudfront/cloudfront-functions/role-enforcer.js +93 -0
- package/lib/cloudfront/cloudfront-functions/tls-enforcer.js +55 -0
- package/lib/cloudfront/cloudfront-functions/userinfo-endpoint.js +208 -0
- package/lib/cloudfront/deployment/viteFrontendDeployment.d.ts +1 -0
- package/lib/cloudfront/deployment/viteFrontendDeployment.js +2 -2
- package/lib/cloudfront/lambda/certificate/index.py +219 -0
- package/lib/cloudfront/lambda/cognito-auth/oauth-callback.py +215 -0
- package/lib/cloudfront/lambda/cognito-auth/requirements.txt +3 -0
- package/lib/cloudfront/lambda/edge-auth/config.py +6 -0
- package/lib/cloudfront/lambda/edge-auth/config_generated.py +12 -0
- package/lib/cloudfront/lambda/edge-auth/oauth-callback.py +538 -0
- package/lib/cloudfront/lambda/edge-auth/requirements.txt +3 -0
- package/lib/cloudfront/lambda/hmacSecret/index.py +129 -0
- package/lib/cloudfront/lambda/hmacSecret/requirements.txt +1 -0
- package/lib/cloudfront/lambda/jwt-decoder/index.py +88 -0
- package/lib/cloudfront/lambda/pre-token/index.py +11 -0
- package/lib/cloudfront/lambda/rotateSecret/index.py +86 -0
- package/lib/cloudfront/lambda/session-revocation/index.py +80 -0
- package/lib/cloudfront/lambda/ssm-writer/index.py +44 -0
- package/lib/cloudfront/lambda/stream-processor/index.py +53 -0
- package/lib/cloudfront/lambda/webacl/index.py +356 -0
- package/lib/cloudfront/logging/README.md +144 -0
- package/lib/cloudfront/patterns/SPLIT_STACK_USAGE.md +138 -0
- 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,
|
|
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")
|