Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions backend/auth_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import json
import logging
import os
import urllib.request
import urllib.parse
import base64
from http.cookies import SimpleCookie

logger = logging.getLogger(__name__)
logger.setLevel(os.environ.get('LOG_LEVEL', 'INFO'))


def handler(event, context):
"""Main Lambda handler - routes requests to appropriate function"""
path = event.get('path', '')
method = event.get('httpMethod', '')

if path == '/auth/token-exchange' and method == 'POST':
return token_exchange_handler(event)
elif path == '/auth/logout' and method == 'POST':
return logout_handler(event)
elif path == '/auth/userinfo' and method == 'GET':
return userinfo_handler(event)
else:
return error_response(404, 'Not Found', event)


def error_response(status_code, message, event=None):
"""Return error response with CORS headers"""
response = {
'statusCode': status_code,
'headers': get_cors_headers(event) if event else {'Content-Type': 'application/json'},
'body': json.dumps({'error': message}),
}
return response


def get_cors_headers(event):
"""Get CORS headers for response"""
cloudfront_url = os.environ.get('CLOUDFRONT_URL', '')
return {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': cloudfront_url,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}


def token_exchange_handler(event):
"""Exchange authorization code for tokens and set httpOnly cookies"""
try:
body = json.loads(event.get('body', '{}'))
code = body.get('code')
code_verifier = body.get('code_verifier')

if not code or not code_verifier:
return error_response(400, 'Missing code or code_verifier', event)

okta_url = os.environ.get('CUSTOM_AUTH_URL', '')
client_id = os.environ.get('CUSTOM_AUTH_CLIENT_ID', '')
redirect_uri = os.environ.get('CUSTOM_AUTH_REDIRECT_URL', '')

if not okta_url or not client_id:
return error_response(500, 'Missing Okta configuration', event)

# Call Okta token endpoint
token_url = f'{okta_url}/v1/token'
token_data = {
'grant_type': 'authorization_code',
'code': code,
'code_verifier': code_verifier,
'client_id': client_id,
'redirect_uri': redirect_uri,
}

data = urllib.parse.urlencode(token_data).encode('utf-8')
req = urllib.request.Request(
token_url,
data=data,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
)

try:
with urllib.request.urlopen(req, timeout=10) as response:
tokens = json.loads(response.read().decode('utf-8'))
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8')
logger.error(f'Token exchange failed: {error_body}')
return error_response(401, 'Authentication failed. Please try again.', event)

cookies = build_cookies(tokens)

return {
'statusCode': 200,
'headers': get_cors_headers(event),
'multiValueHeaders': {'Set-Cookie': cookies},
'body': json.dumps({'success': True}),
}

except Exception as e:
logger.error(f'Token exchange error: {str(e)}')
return error_response(500, 'Internal server error', event)


def build_cookies(tokens):
"""Build httpOnly cookies for tokens"""
cookies = []
secure = True
httponly = True
samesite = 'Lax'
max_age = 3600 # 1 hour

for token_name in ['access_token', 'id_token']:
if tokens.get(token_name):
cookie = SimpleCookie()
cookie[token_name] = tokens[token_name]
cookie[token_name]['path'] = '/'
cookie[token_name]['secure'] = secure
cookie[token_name]['httponly'] = httponly
cookie[token_name]['samesite'] = samesite
cookie[token_name]['max-age'] = max_age
cookies.append(cookie[token_name].OutputString())

return cookies


def logout_handler(event):
"""Clear all auth cookies"""
cookies = []
for cookie_name in ['access_token', 'id_token', 'refresh_token']:
cookie = SimpleCookie()
cookie[cookie_name] = ''
cookie[cookie_name]['path'] = '/'
cookie[cookie_name]['max-age'] = 0
cookies.append(cookie[cookie_name].OutputString())

return {
'statusCode': 200,
'headers': get_cors_headers(event),
'multiValueHeaders': {'Set-Cookie': cookies},
'body': json.dumps({'success': True}),
}


def userinfo_handler(event):
"""Return user info from id_token cookie"""
try:
cookie_header = event.get('headers', {}).get('Cookie') or event.get('headers', {}).get('cookie', '')
cookies = SimpleCookie()
cookies.load(cookie_header)

id_token_cookie = cookies.get('id_token')
if not id_token_cookie:
return error_response(401, 'Not authenticated', event)

id_token = id_token_cookie.value

# Decode JWT payload
parts = id_token.split('.')
if len(parts) != 3:
return error_response(401, 'Invalid token format', event)

payload = parts[1]
padding = 4 - len(payload) % 4
if padding != 4:
payload += '=' * padding

decoded = base64.urlsafe_b64decode(payload)
claims = json.loads(decoded)

email_claim = os.environ.get('CLAIMS_MAPPING_EMAIL', 'email')
user_id_claim = os.environ.get('CLAIMS_MAPPING_USER_ID', 'sub')

email = claims.get(email_claim, claims.get('email', claims.get('sub', '')))
user_id = claims.get(user_id_claim, claims.get('sub', ''))

return {
'statusCode': 200,
'headers': get_cors_headers(event),
'body': json.dumps(
{
'email': email,
'name': claims.get('name', email),
'sub': user_id,
}
),
}

except Exception as e:
logger.error(f'Userinfo error: {str(e)}')
return error_response(500, 'Internal server error', event)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
from http.cookies import SimpleCookie

from requests import HTTPError

Expand All @@ -23,10 +24,32 @@


def lambda_handler(incoming_event, context):
# Get the Token which is sent in the Authorization Header
# Get the Token - first try Cookie header, then Authorization header
logger.debug(incoming_event)
auth_token = incoming_event['headers']['Authorization']
headers = incoming_event.get('headers', {})

# Try to get access_token from Cookie header first (for cookie-based auth)
auth_token = None
cookie_header = headers.get('Cookie') or headers.get('cookie', '')

if cookie_header:
# Parse cookies to find access_token
cookies = SimpleCookie()
cookies.load(cookie_header)
access_token_cookie = cookies.get('access_token')
if access_token_cookie:
# Add Bearer prefix for consistency with existing validation
auth_token = f'Bearer {access_token_cookie.value}'
logger.debug('Using access_token from Cookie header')

# Fallback to Authorization header (for backward compatibility)
if not auth_token:
auth_token = headers.get('Authorization') or headers.get('authorization')
if auth_token:
logger.debug('Using token from Authorization header')

if not auth_token:
logger.warning('No authentication token found in Cookie or Authorization header')
return AuthServices.generate_deny_policy(incoming_event['methodArn'])

# Validate User is Active with Proper Access Token
Expand Down
61 changes: 54 additions & 7 deletions deploy/stacks/cloudfront.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Duration,
RemovalPolicy,
CfnOutput,
Fn,
)

from .cdk_asset_trail import setup_cdk_asset_trail
Expand All @@ -30,6 +31,7 @@ def __init__(
custom_waf_rules=None,
tooling_account_id=None,
backend_region=None,
custom_auth=None,
**kwargs,
):
super().__init__(scope, id, **kwargs)
Expand Down Expand Up @@ -166,6 +168,55 @@ def __init__(
log_file_prefix='cloudfront-logs/frontend',
)

# Add API Gateway behaviors for cookie-based authentication (when using custom_auth)
if custom_auth and backend_region:
# Get API Gateway URL from SSM parameter (set by backend stack)
api_gateway_url_param = ssm.StringParameter.from_string_parameter_name(
self,
'ApiGatewayUrlParam',
string_parameter_name=f'/dataall/{envname}/apiGateway/backendUrl',
)

# Extract API Gateway domain from URL using CloudFormation intrinsic functions
# Input: https://xyz123.execute-api.us-east-1.amazonaws.com/prod/
# Split by '/': ['https:', '', 'xyz123.execute-api.us-east-1.amazonaws.com', 'prod', '']
# Select index 2: 'xyz123.execute-api.us-east-1.amazonaws.com'
api_gateway_origin = origins.HttpOrigin(
domain_name=Fn.select(2, Fn.split('/', api_gateway_url_param.string_value)),
origin_path='/prod',
protocol_policy=cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
)

# Add behavior for /auth/* routes (token exchange, userinfo, logout)
cloudfront_distribution.add_behavior(
path_pattern='/auth/*',
origin=api_gateway_origin,
cache_policy=cloudfront.CachePolicy.CACHING_DISABLED,
origin_request_policy=cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
allowed_methods=cloudfront.AllowedMethods.ALLOW_ALL,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
)

# Add behavior for /graphql/* routes
cloudfront_distribution.add_behavior(
path_pattern='/graphql/*',
origin=api_gateway_origin,
cache_policy=cloudfront.CachePolicy.CACHING_DISABLED,
origin_request_policy=cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
allowed_methods=cloudfront.AllowedMethods.ALLOW_ALL,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
)

# Add behavior for /search/* routes
cloudfront_distribution.add_behavior(
path_pattern='/search/*',
origin=api_gateway_origin,
cache_policy=cloudfront.CachePolicy.CACHING_DISABLED,
origin_request_policy=cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
allowed_methods=cloudfront.AllowedMethods.ALLOW_ALL,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
)

ssm_distribution_id = ssm.StringParameter(
self,
f'SSMDistribution{envname}',
Expand Down Expand Up @@ -276,16 +327,12 @@ def __init__(

@staticmethod
def error_responses():
# Only intercept 404 for SPA routing (redirect to index.html)
# Do NOT intercept 403 - let API Gateway errors pass through
return [
cloudfront.ErrorResponse(
http_status=404,
response_http_status=404,
ttl=Duration.seconds(0),
response_page_path='/index.html',
),
cloudfront.ErrorResponse(
http_status=403,
response_http_status=403,
response_http_status=200,
ttl=Duration.seconds(0),
response_page_path='/index.html',
),
Expand Down
Loading