Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
aa8c01d
feat(auth): Implement secure httpOnly cookie authentication for Okta
arjunp99 Mar 3, 2026
5a04776
feat: implement secure cookie-based authentication with PKCE
arjunp99 Mar 3, 2026
25982d2
Merge branch 'main' into feature/secure-httponly-cookie-auth
arjunp99 Mar 3, 2026
b02e1d7
fix: improve error handling and code organization for auth endpoints
arjunp99 Mar 9, 2026
8f4308f
feat(auth): Add Okta logout integration
arjunp99 Mar 9, 2026
a1c9d4a
fix: resolve all Checkov security findings for auth infrastructure
arjunp99 Mar 9, 2026
e70e6a6
fix(cloudfront): Pass custom_auth to CloudfrontDistro
arjunp99 Mar 9, 2026
9856fd8
fix: Resolve linting and formatting issues for PR checks
arjunp99 Mar 9, 2026
1f297fd
fix(security): Upgrade serialize-javascript to 7.0.3+ to fix RCE vuln…
arjunp99 Mar 9, 2026
c80b545
chore(security): Baseline checkov CKV_AWS_59 for auth endpoints
arjunp99 Mar 9, 2026
8bb9811
fix(deps): Pin react-apexcharts to 1.3.9 for apexcharts 3.x compatibi…
arjunp99 Mar 9, 2026
43d0772
feat(auth): Add automatic token expiration handling for Okta auth
arjunp99 Mar 10, 2026
60a65bf
fix: Resolve CI failures for checkov, semgrep, and npm-audit
arjunp99 Mar 10, 2026
f49e17c
feat: Derive cookie max_age from token expiration
arjunp99 Mar 12, 2026
8421dd5
fix(security): Add HTTPS scheme validation for auth URL
arjunp99 Mar 12, 2026
9761091
fix: Update to Amplify v6 API and fix double-login issue
arjunp99 Mar 16, 2026
bd645c3
fix: Fix reauth and auto-logout redirect issues
arjunp99 Mar 16, 2026
6b9821d
fix(frontend): resolve npm audit high vulnerabilities
arjunp99 Mar 17, 2026
8ab8e8f
fix: silent logout and ReAuth modal z-index
arjunp99 Mar 18, 2026
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
37 changes: 37 additions & 0 deletions .checkov.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,43 @@
"CKV_AWS_120"
]
},
{
"resource": "AWS::ApiGateway::Method.dataalldevapiauthtokenexchangePOSTD92E005E",
"check_ids": [
"CKV_AWS_59"
]
},
{
"resource": "AWS::ApiGateway::Method.dataalldevapiauthlogoutPOST5A8B3C2D",
"check_ids": [
"CKV_AWS_59"
]
},
{
"resource": "AWS::ApiGateway::Method.dataalldevapiauthlogoutPOST89141B56",
"check_ids": [
"CKV_AWS_59"
]
},
{
"resource": "AWS::ApiGateway::Method.dataalldevapiauthuserinfoGET9388EE8D",
"check_ids": [
"CKV_AWS_59"
]
},
{
"resource": "AWS::Lambda::Function.AuthHandler9DC767B7",
"check_ids": [
"CKV_AWS_115",
"CKV_AWS_116"
]
},
{
"resource": "AWS::Logs::LogGroup.authhandlerloggroup",
"check_ids": [
"CKV_AWS_158"
]
},
{
"resource": "AWS::Lambda::Function.AWSWorkerAA1523CA",
"check_ids": [
Expand Down
261 changes: 261 additions & 0 deletions backend/auth_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import json
import logging
import os
import urllib.request
import urllib.parse
import base64
import binascii
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, 'Auth endpoint not found. Valid routes: /auth/token-exchange, /auth/logout, /auth/userinfo', 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', '')
if not cloudfront_url:
logger.debug('CLOUDFRONT_URL not set - authentication endpoints will reject cross-origin requests')

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)

# Validate URL scheme to prevent file:// or other dangerous schemes
if not okta_url.startswith('https://'):
logger.error(f'Invalid CUSTOM_AUTH_URL scheme: {okta_url}')
return error_response(500, 'Invalid authentication 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:
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
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 get_token_expiry(token):
"""Extract exp claim from JWT token"""
import time

try:
parts = token.split('.')
if len(parts) != 3:
return None
payload = parts[1]
padding = 4 - len(payload) % 4
if padding != 4:
payload += '=' * padding
decoded = base64.urlsafe_b64decode(payload)
claims = json.loads(decoded)
exp = claims.get('exp')
if exp:
# Return seconds until expiration
return max(0, int(exp) - int(time.time()))
except Exception:
pass
return None


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

# Get max_age from token's exp claim, fallback to 1 hour
max_age = 3600
id_token = tokens.get('id_token')
if id_token:
token_ttl = get_token_expiry(id_token)
if token_ttl:
max_age = token_ttl

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 (silent logout - does not end Okta SSO session)"""
# Clear all auth cookies
cookies = []
for cookie_name in ['access_token', 'id_token', 'refresh_token']:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also logout from okta. Here we are deleting those cookies but that doesn't mean we will be logging out of Okta. Should we make a call to the okta endpoint to let Okta know that we want to logout ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good point! I'll implement Okta logout by redirecting to Okta's /v1/logout endpoint from the frontend after clearing cookies. This fully ends the Okta session so the user must re-authenticate on next login.

The flow will be:

Frontend calls /auth/logout to clear cookies
Frontend redirects to {okta_url}/v1/logout?id_token_hint=...&post_logout_redirect_uri=...
This is handled on the frontend side since it requires a browser redirect.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super nit: can you comment this flow as a comment here so that anyone looking at this will have clear reference

cookie = SimpleCookie()
cookie[cookie_name] = ''
cookie[cookie_name]['path'] = '/'
cookie[cookie_name]['max-age'] = 0
cookies.append(cookie[cookie_name].OutputString())

# Note: We intentionally do NOT redirect to Okta's logout endpoint.
# This matches the previous behavior using react-oidc-context's signoutSilent(),
# which clears local tokens but keeps the Okta SSO session active.
# This allows users to re-login seamlessly without re-entering credentials
# if their Okta session is still valid.

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:
# Check both 'Cookie' and 'cookie' - API Gateway may normalize header casing
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 (middle part of token)
# JWT format: header.payload.signature (base64url encoded)
parts = id_token.split('.')
if len(parts) != 3:
return error_response(401, 'Invalid token format', event)

payload = parts[1]

# Base64 requires padding to be multiple of 4 characters
# URL-safe base64 in JWTs often omits padding, so we add it back
padding = 4 - len(payload) % 4
if padding != 4:
payload += '=' * padding

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

# Check if token is expired
import time

exp = claims.get('exp')
if exp and int(exp) < int(time.time()):
return error_response(401, 'Token expired', event)

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,
'exp': exp, # Include expiration time for frontend to set up timer
}
),
}

except (binascii.Error, ValueError) as e:
logger.error(f'Failed to decode JWT payload: {str(e)}')
return error_response(401, 'Invalid token', event)
except json.JSONDecodeError as e:
logger.error(f'Failed to parse JWT claims: {str(e)}')
return error_response(401, 'Invalid token', event)
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
Loading