Skip to content

Commit 464d96d

Browse files
authored
feat(auth): Implement secure httpOnly cookie authentication for Okta (#1920)
## Summary Replace localStorage token storage with httpOnly cookies to prevent XSS attacks for Okta authentication. This implements a custom PKCE flow while maintaining existing Cognito/Amplify behavior unchanged. ## Security Improvements - Tokens stored in httpOnly cookies (not accessible via JavaScript - prevents XSS token theft) - SameSite=Lax prevents CSRF while allowing OAuth redirects from Okta - Secure flag ensures HTTPS-only transmission ## Changes ### Frontend - `frontend/src/utils/pkce.js` - PKCE utility for secure OAuth code exchange - `frontend/src/authentication/views/Callback.js` - OAuth callback handler - `frontend/src/authentication/contexts/GenericAuthContext.js` - Cookie-based auth for Okta - `frontend/src/services/hooks/useClient.js` - Relative URLs + credentials for cookies - `frontend/src/routes.js` - Added /callback route ### Backend - `backend/auth_handler.py` - Token exchange, userinfo, logout endpoints - `deploy/stacks/lambda_api.py` - Auth handler Lambda + API routes - `deploy/stacks/cloudfront.py` - Proxy /auth/*, /graphql/*, /search/* to API Gateway - `deploy/custom_resources/custom_authorizer/custom_authorizer_lambda.py` - Read tokens from Cookie header ## How It Works 1. User clicks login → redirected to Okta with PKCE challenge 2. Okta redirects back to /callback with authorization code 3. Frontend calls /auth/token-exchange with code + PKCE verifier 4. Backend exchanges code for tokens, sets httpOnly cookies 5. All subsequent API calls include cookies automatically (same-origin via CloudFront proxy) 6. Custom authorizer reads access_token from Cookie header ## Backward Compatibility - Cognito users: No changes - continues using Amplify with Authorization header
1 parent 952a42a commit 464d96d

17 files changed

Lines changed: 4510 additions & 3757 deletions

File tree

.checkov.baseline

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,43 @@
192192
"CKV_AWS_120"
193193
]
194194
},
195+
{
196+
"resource": "AWS::ApiGateway::Method.dataalldevapiauthtokenexchangePOSTD92E005E",
197+
"check_ids": [
198+
"CKV_AWS_59"
199+
]
200+
},
201+
{
202+
"resource": "AWS::ApiGateway::Method.dataalldevapiauthlogoutPOST5A8B3C2D",
203+
"check_ids": [
204+
"CKV_AWS_59"
205+
]
206+
},
207+
{
208+
"resource": "AWS::ApiGateway::Method.dataalldevapiauthlogoutPOST89141B56",
209+
"check_ids": [
210+
"CKV_AWS_59"
211+
]
212+
},
213+
{
214+
"resource": "AWS::ApiGateway::Method.dataalldevapiauthuserinfoGET9388EE8D",
215+
"check_ids": [
216+
"CKV_AWS_59"
217+
]
218+
},
219+
{
220+
"resource": "AWS::Lambda::Function.AuthHandler9DC767B7",
221+
"check_ids": [
222+
"CKV_AWS_115",
223+
"CKV_AWS_116"
224+
]
225+
},
226+
{
227+
"resource": "AWS::Logs::LogGroup.authhandlerloggroup",
228+
"check_ids": [
229+
"CKV_AWS_158"
230+
]
231+
},
195232
{
196233
"resource": "AWS::Lambda::Function.AWSWorkerAA1523CA",
197234
"check_ids": [

backend/auth_handler.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import json
2+
import logging
3+
import os
4+
import urllib.request
5+
import urllib.parse
6+
import base64
7+
import binascii
8+
from http.cookies import SimpleCookie
9+
10+
logger = logging.getLogger(__name__)
11+
logger.setLevel(os.environ.get('LOG_LEVEL', 'INFO'))
12+
13+
14+
def handler(event, context):
15+
"""Main Lambda handler - routes requests to appropriate function"""
16+
path = event.get('path', '')
17+
method = event.get('httpMethod', '')
18+
19+
if path == '/auth/token-exchange' and method == 'POST':
20+
return token_exchange_handler(event)
21+
elif path == '/auth/logout' and method == 'POST':
22+
return logout_handler(event)
23+
elif path == '/auth/userinfo' and method == 'GET':
24+
return userinfo_handler(event)
25+
else:
26+
return error_response(
27+
404, 'Auth endpoint not found. Valid routes: /auth/token-exchange, /auth/logout, /auth/userinfo', event
28+
)
29+
30+
31+
def error_response(status_code, message, event=None):
32+
"""Return error response with CORS headers"""
33+
response = {
34+
'statusCode': status_code,
35+
'headers': get_cors_headers(event) if event else {'Content-Type': 'application/json'},
36+
'body': json.dumps({'error': message}),
37+
}
38+
return response
39+
40+
41+
def get_cors_headers(event):
42+
"""Get CORS headers for response"""
43+
cloudfront_url = os.environ.get('CLOUDFRONT_URL', '')
44+
if not cloudfront_url:
45+
logger.debug('CLOUDFRONT_URL not set - authentication endpoints will reject cross-origin requests')
46+
47+
return {
48+
'Content-Type': 'application/json',
49+
'Access-Control-Allow-Origin': cloudfront_url,
50+
'Access-Control-Allow-Credentials': 'true',
51+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
52+
'Access-Control-Allow-Headers': 'Content-Type',
53+
}
54+
55+
56+
def token_exchange_handler(event):
57+
"""Exchange authorization code for tokens and set httpOnly cookies"""
58+
try:
59+
body = json.loads(event.get('body', '{}'))
60+
code = body.get('code')
61+
code_verifier = body.get('code_verifier')
62+
63+
if not code or not code_verifier:
64+
return error_response(400, 'Missing code or code_verifier', event)
65+
66+
okta_url = os.environ.get('CUSTOM_AUTH_URL', '')
67+
client_id = os.environ.get('CUSTOM_AUTH_CLIENT_ID', '')
68+
redirect_uri = os.environ.get('CUSTOM_AUTH_REDIRECT_URL', '')
69+
70+
if not okta_url or not client_id:
71+
return error_response(500, 'Missing Okta configuration', event)
72+
73+
# Validate URL scheme to prevent file:// or other dangerous schemes
74+
if not okta_url.startswith('https://'):
75+
logger.error(f'Invalid CUSTOM_AUTH_URL scheme: {okta_url}')
76+
return error_response(500, 'Invalid authentication configuration', event)
77+
78+
# Call Okta token endpoint
79+
token_url = f'{okta_url}/v1/token'
80+
token_data = {
81+
'grant_type': 'authorization_code',
82+
'code': code,
83+
'code_verifier': code_verifier,
84+
'client_id': client_id,
85+
'redirect_uri': redirect_uri,
86+
}
87+
88+
data = urllib.parse.urlencode(token_data).encode('utf-8')
89+
req = urllib.request.Request(
90+
token_url,
91+
data=data,
92+
headers={'Content-Type': 'application/x-www-form-urlencoded'},
93+
)
94+
95+
try:
96+
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
97+
with urllib.request.urlopen(req, timeout=10) as response:
98+
tokens = json.loads(response.read().decode('utf-8'))
99+
except urllib.error.HTTPError as e:
100+
error_body = e.read().decode('utf-8')
101+
logger.error(f'Token exchange failed: {error_body}')
102+
return error_response(401, 'Authentication failed. Please try again.', event)
103+
104+
cookies = build_cookies(tokens)
105+
106+
return {
107+
'statusCode': 200,
108+
'headers': get_cors_headers(event),
109+
'multiValueHeaders': {'Set-Cookie': cookies},
110+
'body': json.dumps({'success': True}),
111+
}
112+
113+
except Exception as e:
114+
logger.error(f'Token exchange error: {str(e)}')
115+
return error_response(500, 'Internal server error', event)
116+
117+
118+
def get_token_expiry(token):
119+
"""Extract exp claim from JWT token"""
120+
import time
121+
122+
try:
123+
parts = token.split('.')
124+
if len(parts) != 3:
125+
return None
126+
payload = parts[1]
127+
padding = 4 - len(payload) % 4
128+
if padding != 4:
129+
payload += '=' * padding
130+
decoded = base64.urlsafe_b64decode(payload)
131+
claims = json.loads(decoded)
132+
exp = claims.get('exp')
133+
if exp:
134+
# Return seconds until expiration
135+
return max(0, int(exp) - int(time.time()))
136+
except Exception:
137+
pass
138+
return None
139+
140+
141+
def build_cookies(tokens):
142+
"""Build httpOnly cookies for tokens"""
143+
cookies = []
144+
secure = True
145+
httponly = True
146+
samesite = 'Lax'
147+
148+
# Get max_age from token's exp claim, fallback to 1 hour
149+
max_age = 3600
150+
id_token = tokens.get('id_token')
151+
if id_token:
152+
token_ttl = get_token_expiry(id_token)
153+
if token_ttl:
154+
max_age = token_ttl
155+
156+
for token_name in ['access_token', 'id_token']:
157+
if tokens.get(token_name):
158+
cookie = SimpleCookie()
159+
cookie[token_name] = tokens[token_name]
160+
cookie[token_name]['path'] = '/'
161+
cookie[token_name]['secure'] = secure
162+
cookie[token_name]['httponly'] = httponly
163+
cookie[token_name]['samesite'] = samesite
164+
cookie[token_name]['max-age'] = max_age
165+
cookies.append(cookie[token_name].OutputString())
166+
167+
return cookies
168+
169+
170+
def logout_handler(event):
171+
"""Clear all auth cookies (silent logout - does not end Okta SSO session)"""
172+
# Clear all auth cookies
173+
cookies = []
174+
for cookie_name in ['access_token', 'id_token', 'refresh_token']:
175+
cookie = SimpleCookie()
176+
cookie[cookie_name] = ''
177+
cookie[cookie_name]['path'] = '/'
178+
cookie[cookie_name]['max-age'] = 0
179+
cookies.append(cookie[cookie_name].OutputString())
180+
181+
# Note: We intentionally do NOT redirect to Okta's logout endpoint.
182+
# This matches the previous behavior using react-oidc-context's signoutSilent(),
183+
# which clears local tokens but keeps the Okta SSO session active.
184+
# This allows users to re-login seamlessly without re-entering credentials
185+
# if their Okta session is still valid.
186+
187+
return {
188+
'statusCode': 200,
189+
'headers': get_cors_headers(event),
190+
'multiValueHeaders': {'Set-Cookie': cookies},
191+
'body': json.dumps({'success': True}),
192+
}
193+
194+
195+
def userinfo_handler(event):
196+
"""Return user info from id_token cookie"""
197+
try:
198+
# Check both 'Cookie' and 'cookie' - API Gateway may normalize header casing
199+
cookie_header = event.get('headers', {}).get('Cookie') or event.get('headers', {}).get('cookie', '')
200+
201+
cookies = SimpleCookie()
202+
cookies.load(cookie_header)
203+
204+
id_token_cookie = cookies.get('id_token')
205+
if not id_token_cookie:
206+
return error_response(401, 'Not authenticated', event)
207+
208+
id_token = id_token_cookie.value
209+
210+
# Decode JWT payload (middle part of token)
211+
# JWT format: header.payload.signature (base64url encoded)
212+
parts = id_token.split('.')
213+
if len(parts) != 3:
214+
return error_response(401, 'Invalid token format', event)
215+
216+
payload = parts[1]
217+
218+
# Base64 requires padding to be multiple of 4 characters
219+
# URL-safe base64 in JWTs often omits padding, so we add it back
220+
padding = 4 - len(payload) % 4
221+
if padding != 4:
222+
payload += '=' * padding
223+
224+
decoded = base64.urlsafe_b64decode(payload)
225+
claims = json.loads(decoded)
226+
227+
# Check if token is expired
228+
import time
229+
230+
exp = claims.get('exp')
231+
if exp and int(exp) < int(time.time()):
232+
return error_response(401, 'Token expired', event)
233+
234+
email_claim = os.environ.get('CLAIMS_MAPPING_EMAIL', 'email')
235+
user_id_claim = os.environ.get('CLAIMS_MAPPING_USER_ID', 'sub')
236+
237+
email = claims.get(email_claim, claims.get('email', claims.get('sub', '')))
238+
user_id = claims.get(user_id_claim, claims.get('sub', ''))
239+
240+
return {
241+
'statusCode': 200,
242+
'headers': get_cors_headers(event),
243+
'body': json.dumps(
244+
{
245+
'email': email,
246+
'name': claims.get('name', email),
247+
'sub': user_id,
248+
'exp': exp, # Include expiration time for frontend to set up timer
249+
'auth_time': claims.get('auth_time'), # Include auth_time for reauth detection
250+
}
251+
),
252+
}
253+
254+
except (binascii.Error, ValueError) as e:
255+
logger.error(f'Failed to decode JWT payload: {str(e)}')
256+
return error_response(401, 'Invalid token', event)
257+
except json.JSONDecodeError as e:
258+
logger.error(f'Failed to parse JWT claims: {str(e)}')
259+
return error_response(401, 'Invalid token', event)
260+
except Exception as e:
261+
logger.error(f'Userinfo error: {str(e)}')
262+
return error_response(500, 'Internal server error', event)

deploy/custom_resources/custom_authorizer/custom_authorizer_lambda.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import os
3+
from http.cookies import SimpleCookie
34

45
from requests import HTTPError
56

@@ -23,10 +24,32 @@
2324

2425

2526
def lambda_handler(incoming_event, context):
26-
# Get the Token which is sent in the Authorization Header
27+
# Get the Token - first try Cookie header, then Authorization header
2728
logger.debug(incoming_event)
28-
auth_token = incoming_event['headers']['Authorization']
29+
headers = incoming_event.get('headers', {})
30+
31+
# Try to get access_token from Cookie header first (for cookie-based auth)
32+
auth_token = None
33+
cookie_header = headers.get('Cookie') or headers.get('cookie', '')
34+
35+
if cookie_header:
36+
# Parse cookies to find access_token
37+
cookies = SimpleCookie()
38+
cookies.load(cookie_header)
39+
access_token_cookie = cookies.get('access_token')
40+
if access_token_cookie:
41+
# Add Bearer prefix for consistency with existing validation
42+
auth_token = f'Bearer {access_token_cookie.value}'
43+
logger.debug('Using access_token from Cookie header')
44+
45+
# Fallback to Authorization header (for backward compatibility)
46+
if not auth_token:
47+
auth_token = headers.get('Authorization') or headers.get('authorization')
48+
if auth_token:
49+
logger.debug('Using token from Authorization header')
50+
2951
if not auth_token:
52+
logger.warning('No authentication token found in Cookie or Authorization header')
3053
return AuthServices.generate_deny_policy(incoming_event['methodArn'])
3154

3255
# Validate User is Active with Proper Access Token

0 commit comments

Comments
 (0)