diff --git a/.checkov.baseline b/.checkov.baseline
index bb4c58346..796f5f1ea 100644
--- a/.checkov.baseline
+++ b/.checkov.baseline
@@ -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": [
diff --git a/backend/auth_handler.py b/backend/auth_handler.py
new file mode 100644
index 000000000..4d2fe09ff
--- /dev/null
+++ b/backend/auth_handler.py
@@ -0,0 +1,262 @@
+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']:
+ 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
+ 'auth_time': claims.get('auth_time'), # Include auth_time for reauth detection
+ }
+ ),
+ }
+
+ 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)
diff --git a/deploy/custom_resources/custom_authorizer/custom_authorizer_lambda.py b/deploy/custom_resources/custom_authorizer/custom_authorizer_lambda.py
index 153191946..20593fffd 100644
--- a/deploy/custom_resources/custom_authorizer/custom_authorizer_lambda.py
+++ b/deploy/custom_resources/custom_authorizer/custom_authorizer_lambda.py
@@ -1,5 +1,6 @@
import logging
import os
+from http.cookies import SimpleCookie
from requests import HTTPError
@@ -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
diff --git a/deploy/stacks/cloudfront.py b/deploy/stacks/cloudfront.py
index d61f3208c..0aa9d3e5e 100644
--- a/deploy/stacks/cloudfront.py
+++ b/deploy/stacks/cloudfront.py
@@ -11,6 +11,7 @@
Duration,
RemovalPolicy,
CfnOutput,
+ Fn,
)
from .cdk_asset_trail import setup_cdk_asset_trail
@@ -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)
@@ -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:
+ # 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}',
@@ -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',
),
diff --git a/deploy/stacks/cloudfront_stack.py b/deploy/stacks/cloudfront_stack.py
index ea59defbc..a241b8866 100644
--- a/deploy/stacks/cloudfront_stack.py
+++ b/deploy/stacks/cloudfront_stack.py
@@ -29,6 +29,7 @@ def __init__(
tooling_account_id=tooling_account_id,
custom_domain=custom_domain,
custom_waf_rules=custom_waf_rules,
+ custom_auth=custom_auth,
backend_region=backend_region,
**kwargs,
)
diff --git a/deploy/stacks/lambda_api.py b/deploy/stacks/lambda_api.py
index a73f18726..e9e2e9a0d 100644
--- a/deploy/stacks/lambda_api.py
+++ b/deploy/stacks/lambda_api.py
@@ -1,5 +1,6 @@
import json
import os
+from .runtime_options import PYTHON_LAMBDA_RUNTIME
from aws_cdk import (
aws_iam as iam,
@@ -23,7 +24,7 @@
BundlingOptions,
)
from cdk_klayers import Klayers
-from aws_cdk.aws_apigateway import EndpointType, SecurityPolicy
+from aws_cdk.aws_apigateway import DomainNameOptions, EndpointType, SecurityPolicy
from aws_cdk.aws_certificatemanager import Certificate
from aws_cdk.aws_ec2 import (
InterfaceVpcEndpoint,
@@ -35,7 +36,6 @@
from .pyNestedStack import pyNestedClass
from .solution_bundling import SolutionBundling
from .waf_rules import get_waf_rules
-from .runtime_options import PYTHON_LAMBDA_RUNTIME
DEFAULT_API_RATE_LIMIT = 10000
DEFAULT_API_BURST_LIMIT = 5000
@@ -83,6 +83,43 @@ def __init__(
image_tag = f'lambdas-{image_tag}'
+ # Create KMS key for CloudWatch Logs encryption
+ logs_kms_key = kms.Key(
+ self,
+ f'{resource_prefix}-{envname}-logs-key',
+ removal_policy=RemovalPolicy.DESTROY,
+ alias=f'{resource_prefix}-{envname}-logs-key',
+ enable_key_rotation=True,
+ policy=iam.PolicyDocument(
+ statements=[
+ iam.PolicyStatement(
+ resources=['*'],
+ effect=iam.Effect.ALLOW,
+ principals=[iam.AccountPrincipal(account_id=self.account)],
+ actions=['kms:*'],
+ ),
+ iam.PolicyStatement(
+ resources=['*'],
+ effect=iam.Effect.ALLOW,
+ principals=[iam.ServicePrincipal(f'logs.{self.region}.amazonaws.com')],
+ actions=[
+ 'kms:Encrypt',
+ 'kms:Decrypt',
+ 'kms:ReEncrypt*',
+ 'kms:GenerateDataKey*',
+ 'kms:CreateGrant',
+ 'kms:DescribeKey',
+ ],
+ conditions={
+ 'ArnLike': {
+ 'kms:EncryptionContext:aws:logs:arn': f'arn:aws:logs:{self.region}:{self.account}:log-group:*'
+ }
+ },
+ ),
+ ],
+ ),
+ )
+
lambda_env_key = kms.Key(
self,
f'{resource_prefix}-lambda-env-var-key',
@@ -159,6 +196,8 @@ def __init__(
api_handler_env['frontend_domain_url'] = f'https://{custom_domain.get("hosted_zone_name", None)}'
if custom_auth:
api_handler_env['custom_auth'] = custom_auth.get('provider', None)
+ api_handler_env['custom_auth_url'] = custom_auth.get('url', None)
+ api_handler_env['custom_auth_client'] = custom_auth.get('client_id', None)
self.api_handler = _lambda.DockerImageFunction(
self,
'LambdaGraphQL',
@@ -242,6 +281,70 @@ def __init__(
)
)
+ # Auth handler Lambda for cookie-based authentication
+ self.auth_handler_dlq = self.set_dlq(f'{resource_prefix}-{envname}-authhandler-dlq')
+ auth_handler_sg = self.create_lambda_sgs(envname, 'authhandler', resource_prefix, vpc)
+
+ # Get CloudFront URL - priority: custom_domain > custom_auth.cloudfront_url
+ if custom_domain and custom_domain.get('hosted_zone_name'):
+ cloudfront_url = f'https://{custom_domain.get("hosted_zone_name")}'
+ elif custom_auth and custom_auth.get('cloudfront_url'):
+ cloudfront_url = custom_auth.get('cloudfront_url')
+ else:
+ cloudfront_url = '' # Must be configured via custom_domain or custom_auth.cloudfront_url
+
+ auth_handler_env = {
+ 'envname': envname,
+ 'LOG_LEVEL': log_level,
+ 'CLOUDFRONT_URL': cloudfront_url,
+ }
+
+ # Add custom auth config for token exchange with Okta
+ if custom_auth:
+ auth_handler_env['CUSTOM_AUTH_URL'] = custom_auth.get('url', '')
+ auth_handler_env['CUSTOM_AUTH_CLIENT_ID'] = custom_auth.get('client_id', '')
+ auth_handler_env['CUSTOM_AUTH_REDIRECT_URL'] = custom_auth.get('redirect_url', cloudfront_url + '/callback')
+ # Pass claims mapping for user info extraction
+ claims_mapping = custom_auth.get('claims_mapping', {})
+ auth_handler_env['CLAIMS_MAPPING_EMAIL'] = claims_mapping.get('email', 'email')
+ auth_handler_env['CLAIMS_MAPPING_USER_ID'] = claims_mapping.get('user_id', 'sub')
+
+ self.auth_handler = _lambda.DockerImageFunction(
+ self,
+ 'AuthHandler',
+ function_name=f'{resource_prefix}-{envname}-authhandler',
+ log_group=logs.LogGroup(
+ self,
+ 'authhandlerloggroup',
+ log_group_name=f'/aws/lambda/{resource_prefix}-{envname}-backend-authhandler',
+ retention=getattr(logs.RetentionDays, self.log_retention_duration),
+ encryption_key=logs_kms_key,
+ ),
+ description='dataall auth handler for cookie-based authentication',
+ role=self.create_function_role(envname, resource_prefix, 'authhandler', pivot_role_name, vpc),
+ code=_lambda.DockerImageCode.from_ecr(
+ repository=ecr_repository, tag=image_tag, cmd=['auth_handler.handler']
+ ),
+ vpc=vpc,
+ security_groups=[auth_handler_sg],
+ memory_size=512 if prod_sizing else 256,
+ timeout=Duration.seconds(30),
+ reserved_concurrent_executions=100, # Limit concurrent executions for cost control
+ environment=auth_handler_env,
+ environment_encryption=lambda_env_key,
+ dead_letter_queue_enabled=True,
+ dead_letter_queue=self.auth_handler_dlq,
+ on_failure=lambda_destination.SqsDestination(self.auth_handler_dlq),
+ tracing=_lambda.Tracing.ACTIVE,
+ logging_format=_lambda.LoggingFormat.JSON,
+ application_log_level_v2=getattr(_lambda.ApplicationLogLevel, log_level),
+ )
+
+ # Allow auth handler to access internet (for Okta API calls)
+ self.auth_handler.connections.allow_to(
+ ec2.Peer.any_ipv4(), ec2.Port.tcp(443), 'Allow NAT Internet Access for Okta'
+ )
+
# Create the custom authorizer lambda
custom_authorizer_assets = os.path.realpath(
os.path.join(
@@ -283,7 +386,8 @@ def __init__(
)
# Initialize Klayers
- klayers = Klayers(self, python_version=PYTHON_LAMBDA_RUNTIME, region=self.region)
+ runtime = PYTHON_LAMBDA_RUNTIME
+ klayers = Klayers(self, python_version=runtime, region=self.region)
# get the latest layer version for the cryptography package
cryptography_layer = klayers.layer_version(self, 'cryptography')
@@ -314,7 +418,7 @@ def __init__(
environment_encryption=lambda_env_key,
vpc=vpc,
security_groups=[authorizer_fn_sg],
- runtime=PYTHON_LAMBDA_RUNTIME,
+ runtime=runtime,
layers=[cryptography_layer],
logging_format=_lambda.LoggingFormat.JSON,
application_log_level_v2=getattr(_lambda.ApplicationLogLevel, log_level),
@@ -368,6 +472,7 @@ def __init__(
user_pool,
custom_auth,
throttling_config,
+ custom_domain,
)
self.create_sns_topic(
@@ -540,6 +645,7 @@ def create_api_gateway(
user_pool,
custom_auth,
throttling_config,
+ custom_domain,
):
api_deploy_options = apigw.StageOptions(
throttling_rate_limit=throttling_config.get('global_rate_limit', DEFAULT_API_RATE_LIMIT),
@@ -563,6 +669,7 @@ def create_api_gateway(
resource_prefix,
user_pool,
custom_auth,
+ custom_domain,
)
# Create IP set if IP filtering enabled in CDK.json
@@ -623,6 +730,7 @@ def set_up_graphql_api_gateway(
resource_prefix,
user_pool,
custom_auth,
+ custom_domain,
):
# Create a custom Authorizer
custom_authorizer_role = iam.Role(
@@ -644,10 +752,14 @@ def set_up_graphql_api_gateway(
self,
'CustomAuthorizer',
handler=self.authorizer_fn,
- identity_sources=[apigw.IdentitySource.header('Authorization')],
+ # Empty identity_sources allows Lambda to be invoked without specific headers
+ # This enables cookie-based auth where tokens come from Cookie header
+ # and also auth with Authorization header (for Cognito users)
+ identity_sources=[],
authorizer_name=f'{resource_prefix}-{envname}-custom-authorizer',
assume_role=custom_authorizer_role,
- results_cache_ttl=Duration.minutes(1),
+ # Disable caching to ensure cookies are read on every request
+ results_cache_ttl=Duration.seconds(0),
)
if not internet_facing:
if apig_vpce:
@@ -829,6 +941,69 @@ def set_up_graphql_api_gateway(
request_models={'application/json': search_validation_model},
)
+ # Auth routes for cookie-based authentication
+ auth_integration = apigw.LambdaIntegration(self.auth_handler)
+ auth = gw.root.add_resource(path_part='auth')
+
+ # Get CloudFront URL for CORS - priority: custom_domain > custom_auth.cloudfront_url
+ if custom_domain and custom_domain.get('hosted_zone_name'):
+ cors_origin = f'https://{custom_domain.get("hosted_zone_name")}'
+ elif custom_auth and custom_auth.get('cloudfront_url'):
+ cors_origin = custom_auth.get('cloudfront_url')
+ else:
+ cors_origin = '' # Must be configured via custom_domain or custom_auth.cloudfront_url
+
+ # Token exchange route - NO authorization (public endpoint for OAuth callback)
+ # checkov:skip=CKV_AWS_59: Auth endpoints intentionally public - protected by WAF, CORS, and Lambda validation
+ token_exchange = auth.add_resource(
+ path_part='token-exchange',
+ default_cors_preflight_options=apigw.CorsOptions(
+ allow_methods=['POST', 'OPTIONS'],
+ allow_origins=[cors_origin],
+ allow_credentials=True,
+ allow_headers=['Content-Type'],
+ ),
+ )
+ token_exchange.add_method(
+ 'POST',
+ auth_integration,
+ authorization_type=apigw.AuthorizationType.NONE,
+ )
+
+ # Logout route - NO authorization (needs to work even with expired tokens)
+ # checkov:skip=CKV_AWS_59: Auth endpoints intentionally public - protected by WAF, CORS, and Lambda validation
+ logout = auth.add_resource(
+ path_part='logout',
+ default_cors_preflight_options=apigw.CorsOptions(
+ allow_methods=['POST', 'OPTIONS'],
+ allow_origins=[cors_origin],
+ allow_credentials=True,
+ allow_headers=['Content-Type'],
+ ),
+ )
+ logout.add_method(
+ 'POST',
+ auth_integration,
+ authorization_type=apigw.AuthorizationType.NONE,
+ )
+
+ # Userinfo route - NO authorization (Lambda reads cookies and validates)
+ # checkov:skip=CKV_AWS_59: Auth endpoints intentionally public - protected by WAF, CORS, and Lambda validation
+ userinfo = auth.add_resource(
+ path_part='userinfo',
+ default_cors_preflight_options=apigw.CorsOptions(
+ allow_methods=['GET', 'OPTIONS'],
+ allow_origins=[cors_origin],
+ allow_credentials=True,
+ allow_headers=['Content-Type'],
+ ),
+ )
+ userinfo.add_method(
+ 'GET',
+ auth_integration,
+ authorization_type=apigw.AuthorizationType.NONE,
+ )
+
apigateway_log_group = logs.LogGroup(
self,
f'{resource_prefix}/{envname}/apigateway',
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5b1f4f1e4..e94315911 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -19263,15 +19263,15 @@
}
},
"node_modules/lodash": {
- "version": "4.17.23",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
- "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
- "version": "4.17.23",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
- "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
@@ -19668,9 +19668,9 @@
}
},
"node_modules/node-forge": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz",
- "integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==",
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
+ "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
@@ -20244,9 +20244,9 @@
}
},
"node_modules/path-to-regexp": {
- "version": "0.1.12",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
- "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/path-type": {
@@ -20269,11 +20269,12 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "license": "MIT",
"engines": {
- "node": ">=8.6"
+ "node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
@@ -24376,18 +24377,6 @@
}
}
},
- "node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 692a6a552..e127e8227 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -100,18 +100,21 @@
"fast-xml-parser": "5.5.6",
"serialize-javascript": "7.0.4",
"svgo": "2.8.2",
- "path-to-regexp": "0.1.12",
+ "path-to-regexp": "0.1.13",
"body-parser": "^1.20.3",
"send": "0.19.0",
"rollup": "4.59.0",
"http-proxy-middleware": "2.0.9",
"cross-spawn": "7.0.5",
- "node-forge": "1.3.2",
+ "node-forge": "1.4.0",
"cookie": "0.7.2",
"glob": "10.5.0",
"minimatch": "10.2.4",
"qs": "6.15.0",
- "bfj": "9.1.3"
+ "bfj": "9.1.3",
+ "lodash": "4.18.1",
+ "lodash-es": "4.18.1",
+ "picomatch": "4.0.4"
},
"resolutions": {
"react-redux": "^7.2.6",
@@ -130,17 +133,20 @@
"fast-xml-parser": "5.5.6",
"serialize-javascript": "7.0.4",
"svgo": "2.8.2",
- "path-to-regexp": "0.1.12",
+ "path-to-regexp": "0.1.13",
"body-parser": "^1.20.3",
"send": "0.19.0",
"rollup": "4.59.0",
"http-proxy-middleware": "2.0.9",
"cross-spawn": "7.0.5",
- "node-forge": "1.3.2",
+ "node-forge": "1.4.0",
"cookie": "0.7.2",
"glob": "10.5.0",
"minimatch": "10.2.4",
- "qs": "6.15.0"
+ "qs": "6.15.0",
+ "lodash": "4.18.1",
+ "lodash-es": "4.18.1",
+ "picomatch": "4.0.4"
},
"devDependencies": {
"env-cmd": "^10.1.0",
diff --git a/frontend/src/authentication/contexts/GenericAuthContext.js b/frontend/src/authentication/contexts/GenericAuthContext.js
index 8dee6a86c..5544c07af 100644
--- a/frontend/src/authentication/contexts/GenericAuthContext.js
+++ b/frontend/src/authentication/contexts/GenericAuthContext.js
@@ -1,13 +1,13 @@
-import { createContext, useEffect, useReducer } from 'react';
+import { createContext, useEffect, useReducer, useRef } from 'react';
import { SET_ERROR } from 'globalErrors';
import PropTypes from 'prop-types';
-import { useAuth } from 'react-oidc-context';
import {
fetchAuthSession,
fetchUserAttributes,
signInWithRedirect,
signOut
} from 'aws-amplify/auth';
+import { generatePKCE, generateState } from '../../utils';
const CUSTOM_AUTH = process.env.REACT_APP_CUSTOM_AUTH;
@@ -70,10 +70,7 @@ export const GenericAuthContext = createContext({
export const GenericAuthProvider = (props) => {
const { children } = props;
const [state, dispatch] = useReducer(reducer, initialState);
- const auth = useAuth();
- const isLoading = auth ? auth.isLoading : false;
- const userProfile = auth ? auth.user : null;
- const authEvents = auth ? auth.events : null;
+ const expirationTimerRef = useRef(null);
useEffect(() => {
const initialize = async () => {
@@ -95,160 +92,172 @@ export const GenericAuthProvider = (props) => {
}
});
} catch (error) {
- if (CUSTOM_AUTH) {
- processLoadingStateChange();
- } else {
- dispatch({
- type: 'INITIALIZE',
- payload: {
- isAuthenticated: false,
- isInitialized: true,
- user: null
- }
- });
- }
- }
- };
-
- initialize().catch((e) => dispatch({ type: SET_ERROR, error: e.message }));
- }, []);
-
- // useEffect needed for React OIDC context
- // Process OIDC state when isLoading state changes
- useEffect(() => {
- if (CUSTOM_AUTH) {
- processLoadingStateChange();
- }
- }, [isLoading]);
-
- // useEffect to process when a user is loaded by react OIDC
- // This is triggered when the userProfile ( i.e. auth.user ) is loaded by react OIDC
- useEffect(() => {
- const processStateChange = async () => {
- try {
- const user = await getAuthenticatedUser();
dispatch({
- type: 'LOGIN',
- payload: {
- user: {
- id: user.email,
- email: user.email,
- name: user.email,
- id_token: user.id_token,
- short_id: user.short_id,
- access_token: user.access_token
- }
- }
- });
- } catch (error) {
- dispatch({
- type: 'LOGOUT',
+ type: 'INITIALIZE',
payload: {
isAuthenticated: false,
+ isInitialized: true,
user: null
}
});
}
};
- if (CUSTOM_AUTH) {
- processStateChange().catch((e) =>
- dispatch({ type: SET_ERROR, error: e.message })
- );
+ initialize().catch((e) => dispatch({ type: SET_ERROR, error: e.message }));
+
+ // Cleanup: clear expiration timer on unmount
+ return () => {
+ if (expirationTimerRef.current) {
+ clearTimeout(expirationTimerRef.current);
+ }
+ };
+ }, []);
+
+ const setupExpirationTimer = (exp) => {
+ // Clear any existing timer
+ if (expirationTimerRef.current) {
+ clearTimeout(expirationTimerRef.current);
+ expirationTimerRef.current = null;
}
- }, [userProfile]);
- // useEffect to process auth events generated by react OIDC
- // This is used to logout user when the token expires
- useEffect(() => {
- if (CUSTOM_AUTH) {
- return auth.events.addAccessTokenExpired(() => {
- auth.signoutSilent().then((r) => {
- dispatch({
- type: 'LOGOUT',
- payload: {
- isAuthenticated: false,
- user: null
- }
- });
- });
+ if (!exp) return;
+
+ // Calculate time until expiration (exp is in seconds, Date.now() is in ms)
+ const expiresAt = exp * 1000;
+ const now = Date.now();
+ const timeUntilExpiry = expiresAt - now;
+
+ // If already expired, redirect to login immediately
+ if (timeUntilExpiry <= 0) {
+ handleSessionExpired();
+ return;
+ }
+
+ // Set timer to handle expiration (with 30s buffer for network latency)
+ const bufferMs = 30 * 1000;
+ const timerMs = Math.max(timeUntilExpiry - bufferMs, 0);
+
+ expirationTimerRef.current = setTimeout(() => {
+ handleSessionExpired();
+ }, timerMs);
+ };
+
+ const handleSessionExpired = async () => {
+ // Clear expiration timer
+ if (expirationTimerRef.current) {
+ clearTimeout(expirationTimerRef.current);
+ expirationTimerRef.current = null;
+ }
+
+ // Try to clear cookies (ignore errors if already expired)
+ try {
+ await fetch('/auth/logout', {
+ method: 'POST',
+ credentials: 'include'
});
+ } catch (error) {
+ // Ignore - cookies may already be expired
}
- }, [authEvents]);
+
+ dispatch({
+ type: 'LOGOUT',
+ payload: {
+ isAuthenticated: false,
+ user: null
+ }
+ });
+ sessionStorage.clear();
+
+ // Redirect to homepage which will show login page
+ window.location.href = window.location.origin;
+ };
const getAuthenticatedUser = async () => {
if (CUSTOM_AUTH) {
- if (!auth.user) throw Error('User not initialized');
+ // Use relative URL - CloudFront proxies to API Gateway (same-origin)
+ const response = await fetch('/auth/userinfo', {
+ credentials: 'include'
+ });
+ if (!response.ok) throw Error('User not authenticated');
+ const user = await response.json();
+
+ // Set up expiration timer if exp claim is present
+ if (user.exp) {
+ setupExpirationTimer(user.exp);
+ }
+
+ // Use auth_time as token identifier for reauth detection
+ // auth_time changes after each authentication, enabling retry detection
+ const tokenId = user.auth_time ? `cookie_${user.auth_time}` : 'cookie';
+
return {
- email:
- auth.user.profile[
- process.env.REACT_APP_CUSTOM_AUTH_EMAIL_CLAIM_MAPPING
- ],
- id_token: auth.user.id_token,
- access_token: auth.user.access_token,
- short_id:
- auth.user.profile[
- process.env.REACT_APP_CUSTOM_AUTH_USERID_CLAIM_MAPPING
- ]
+ email: user.email,
+ id_token: tokenId,
+ access_token: tokenId,
+ short_id: user.sub
};
} else {
- const [session, attrs] = await Promise.all([
- fetchAuthSession(),
- fetchUserAttributes()
- ]);
-
+ const session = await fetchAuthSession();
+ const userAttributes = await fetchUserAttributes();
return {
- email: attrs.email,
- id_token: session.tokens.idToken.toString(),
- access_token: session.tokens.accessToken.toString(),
+ email: userAttributes.email,
+ id_token: session.tokens?.idToken?.toString() || '',
+ access_token: session.tokens?.accessToken?.toString() || '',
short_id: 'none'
};
}
};
- // Function to process OIDC State when it transitions from false to true
- function processLoadingStateChange() {
- if (isLoading) {
- dispatch({
- type: 'INITIALIZE',
- payload: {
- isAuthenticated: false,
- isInitialized: false, // setting to false when the OIDC State is loading
- user: null
- }
- });
- } else {
- dispatch({
- type: 'INITIALIZE',
- payload: {
- isAuthenticated: false,
- isInitialized: true, // setting to true when the OIDC state is completely loaded
- user: null
- }
- });
- }
- }
-
- const login = async () => {
+ const login = async (forceReauth = false) => {
try {
if (CUSTOM_AUTH) {
- await auth.signinRedirect();
+ const { verifier, challenge } = await generatePKCE();
+ const state = generateState();
+
+ sessionStorage.setItem('pkce_verifier', verifier);
+ sessionStorage.setItem('pkce_state', state);
+
+ const params = new URLSearchParams({
+ client_id: process.env.REACT_APP_CUSTOM_AUTH_CLIENT_ID,
+ redirect_uri: window.location.origin + '/callback',
+ response_type: 'code',
+ scope: process.env.REACT_APP_CUSTOM_AUTH_SCOPES,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ state
+ });
+
+ // Force re-authentication if requested (for reauth flow)
+ // This ensures user must enter credentials again, getting a new auth_time
+ if (forceReauth) {
+ params.append('prompt', 'login');
+ }
+
+ window.location.href = `${process.env.REACT_APP_CUSTOM_AUTH_URL}/v1/authorize?${params}`;
} else {
await signInWithRedirect();
}
} catch (error) {
- if (error.name === 'UserAlreadyAuthenticatedException') {
- // User is already authenticated, ignore this error
- return;
- }
console.error('Failed to authenticate user', error);
}
};
const logout = async () => {
try {
+ // Clear expiration timer
+ if (expirationTimerRef.current) {
+ clearTimeout(expirationTimerRef.current);
+ expirationTimerRef.current = null;
+ }
+
if (CUSTOM_AUTH) {
- await auth.signoutSilent();
+ // Silent logout - clears cookies but keeps Okta SSO session active
+ // This matches the previous behavior using react-oidc-context's signoutSilent()
+ await fetch('/auth/logout', {
+ method: 'POST',
+ credentials: 'include'
+ });
+
dispatch({
type: 'LOGOUT',
payload: {
@@ -256,6 +265,10 @@ export const GenericAuthProvider = (props) => {
user: null
}
});
+ sessionStorage.clear();
+
+ // Redirect to homepage (login page)
+ window.location.href = window.location.origin;
} else {
await signOut({ global: true });
dispatch({
@@ -265,8 +278,8 @@ export const GenericAuthProvider = (props) => {
user: null
}
});
+ sessionStorage.removeItem('window-location');
}
- sessionStorage.removeItem('window-location');
} catch (error) {
console.error('Failed to signout', error);
}
@@ -275,15 +288,31 @@ export const GenericAuthProvider = (props) => {
const reauth = async () => {
if (CUSTOM_AUTH) {
try {
- auth.signoutSilent().then((r) => {
- dispatch({
- type: 'REAUTH',
- payload: {
- reAuthStatus: false,
- requestInfo: null
- }
- });
+ // Clear expiration timer
+ if (expirationTimerRef.current) {
+ clearTimeout(expirationTimerRef.current);
+ expirationTimerRef.current = null;
+ }
+
+ // Clear cookies via backend (but don't redirect to Okta logout)
+ await fetch('/auth/logout', {
+ method: 'POST',
+ credentials: 'include'
+ });
+
+ dispatch({
+ type: 'REAUTH',
+ payload: {
+ reAuthStatus: false,
+ requestInfo: null
+ }
});
+ sessionStorage.clear();
+
+ // Trigger new login flow with forceReauth=true
+ // This adds prompt=login to force Okta to re-authenticate,
+ // generating a new auth_time which enables retry detection
+ await login(true);
} catch (error) {
console.error('Failed to ReAuth', error);
}
@@ -296,8 +325,8 @@ export const GenericAuthProvider = (props) => {
requestInfo: null
}
});
+ sessionStorage.removeItem('window-location');
}
- sessionStorage.removeItem('window-location');
};
return (
@@ -309,7 +338,7 @@ export const GenericAuthProvider = (props) => {
login,
logout,
reauth,
- isLoading
+ isLoading: !state.isInitialized
}}
>
{children}
@@ -317,6 +346,6 @@ export const GenericAuthProvider = (props) => {
);
};
-GenericAuthContext.propTypes = {
+GenericAuthProvider.propTypes = {
children: PropTypes.node.isRequired
};
diff --git a/frontend/src/authentication/views/Callback.js b/frontend/src/authentication/views/Callback.js
new file mode 100644
index 000000000..013a7e982
--- /dev/null
+++ b/frontend/src/authentication/views/Callback.js
@@ -0,0 +1,144 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Box, CircularProgress, Typography } from '@mui/material';
+
+const Callback = () => {
+ const navigate = useNavigate();
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const exchangeCode = async () => {
+ try {
+ const params = new URLSearchParams(window.location.search);
+ const code = params.get('code');
+ const state = params.get('state');
+ const errorParam = params.get('error');
+
+ if (errorParam) {
+ throw new Error(params.get('error_description') || errorParam);
+ }
+
+ if (!code) {
+ throw new Error('No authorization code received');
+ }
+
+ // Verify state matches
+ const savedState = sessionStorage.getItem('pkce_state');
+ if (state !== savedState) {
+ throw new Error('State mismatch - possible CSRF attack');
+ }
+
+ // Get code verifier
+ const codeVerifier = sessionStorage.getItem('pkce_verifier');
+ if (!codeVerifier) {
+ throw new Error('No code verifier found');
+ }
+
+ // Exchange code for tokens via backend
+ // Add AbortController for timeout
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
+
+ try {
+ const response = await fetch('/auth/token-exchange', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ code,
+ code_verifier: codeVerifier
+ }),
+ signal: controller.signal
+ });
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Token exchange failed');
+ }
+ } catch (fetchErr) {
+ clearTimeout(timeoutId);
+ if (fetchErr.name === 'AbortError') {
+ throw new Error('Request timed out. Please try again.');
+ }
+ throw fetchErr;
+ }
+
+ // Clear PKCE values
+ sessionStorage.removeItem('pkce_verifier');
+ sessionStorage.removeItem('pkce_state');
+
+ // Fetch user info to verify cookies are set correctly
+ const userInfoResponse = await fetch('/auth/userinfo', {
+ credentials: 'include'
+ });
+
+ if (!userInfoResponse.ok) {
+ throw new Error('Failed to fetch user info after login');
+ }
+
+ // Check if there's a pending reauth request to redirect back to
+ let redirectPath = '/console/environments';
+ try {
+ const storedRequestInfo = localStorage.getItem('requestInfo');
+ if (storedRequestInfo) {
+ const requestInfo = JSON.parse(storedRequestInfo);
+ const operationName =
+ requestInfo.requestInfo?.operationName?.toLowerCase() || '';
+ // For delete operations, don't redirect to the deleted resource
+ // The RequestContext will handle the retry and show success message
+ if (operationName.includes('delete')) {
+ redirectPath = '/console/environments';
+ } else if (requestInfo.pathname) {
+ redirectPath = requestInfo.pathname;
+ }
+ }
+ } catch (e) {
+ // Ignore parsing errors, use default redirect
+ }
+
+ // Full page reload to re-initialize auth context with new cookies
+ window.location.href = redirectPath;
+ } catch (err) {
+ console.error('Callback error:', err);
+ setError(err.message);
+ }
+ };
+
+ exchangeCode();
+ }, [navigate]);
+
+ if (error) {
+ return (
+
+
+ Authentication Error
+
+ {error}
+
+ );
+ }
+
+ return (
+
+
+ Completing sign in...
+
+ );
+};
+
+export default Callback;
diff --git a/frontend/src/reauthentication/components/ReAuthModal.js b/frontend/src/reauthentication/components/ReAuthModal.js
index e93e45c0e..c7cd03f5e 100644
--- a/frontend/src/reauthentication/components/ReAuthModal.js
+++ b/frontend/src/reauthentication/components/ReAuthModal.js
@@ -37,7 +37,7 @@ export const ReAuthModal = () => {
}, [reAuthStatus, requestInfo]);
return (
-