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