From c5efb2d0f96237bbbdce3c77689abf0223b6f6da Mon Sep 17 00:00:00 2001 From: Sandeep Batchu Date: Tue, 30 Jun 2026 12:20:45 -0500 Subject: [PATCH 1/8] feat(auth): secure-by-default Cognito JWT auth for agentic platform API - EnableAuth parameter defaults to true (secure by default); set false only for the open blog/demo walkthrough - Cognito User Pool + app client + hosted-UI domain (conditional on auth) - auth.py: verify Cognito JWT in the Lambda (JWKS signature, issuer, audience, expiry, token_use, client_id); fails closed when enabled-but-unconfigured - Handler enforces the auth gate before any action routing; 401 on failure; internal self-invokes and CORS preflight bypass correctly - CORS: configurable AllowedOrigin + authorization header - deploy.sh: ENABLE_AUTH env override (defaults true) - 25 unittest cases (no pytest dependency) covering auth on/off, token validation failures, fail-closed, and a static check that there are no unauthenticated endpoints / public function URLs / open default --- .../api/lambda/async_invoke_agent.py | 11 +- agentic-atx-platform/api/lambda/auth.py | 126 ++++++++++++++++ .../api/lambda/requirements.txt | 1 + .../api/lambda/tests/test_auth.py | 135 ++++++++++++++++++ .../api/lambda/tests/test_handler_auth.py | 102 +++++++++++++ .../tests/test_template_no_open_endpoints.py | 89 ++++++++++++ agentic-atx-platform/sam/deploy.sh | 7 +- agentic-atx-platform/sam/template.yaml | 111 +++++++++++++- 8 files changed, 578 insertions(+), 4 deletions(-) create mode 100644 agentic-atx-platform/api/lambda/auth.py create mode 100644 agentic-atx-platform/api/lambda/requirements.txt create mode 100644 agentic-atx-platform/api/lambda/tests/test_auth.py create mode 100644 agentic-atx-platform/api/lambda/tests/test_handler_auth.py create mode 100644 agentic-atx-platform/api/lambda/tests/test_template_no_open_endpoints.py diff --git a/agentic-atx-platform/api/lambda/async_invoke_agent.py b/agentic-atx-platform/api/lambda/async_invoke_agent.py index 64c27d3..be27931 100644 --- a/agentic-atx-platform/api/lambda/async_invoke_agent.py +++ b/agentic-atx-platform/api/lambda/async_invoke_agent.py @@ -46,6 +46,13 @@ def lambda_handler(event, context): if event.get('requestContext', {}).get('http', {}).get('method') == 'OPTIONS': return cors_response(200, '') + # Enforce auth at the function boundary (defense-in-depth). No-op when + # ENABLE_AUTH != "true"; otherwise requires API Gateway-validated JWT claims. + from auth import authorize + ok, auth_error, _claims = authorize(event) + if not ok: + return cors_response(401, json.dumps({'error': auth_error})) + try: body = json.loads(event.get('body', '{}')) action = body.get('action', 'submit') @@ -583,9 +590,9 @@ def cors_response(status_code, body): 'statusCode': status_code, 'headers': { 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Origin': os.environ.get('ALLOWED_ORIGIN', '*'), 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }, 'body': body } diff --git a/agentic-atx-platform/api/lambda/auth.py b/agentic-atx-platform/api/lambda/auth.py new file mode 100644 index 0000000..f0f95af --- /dev/null +++ b/agentic-atx-platform/api/lambda/auth.py @@ -0,0 +1,126 @@ +""" +Auth enforcement for the async invoke Lambda. + +Authentication is enforced here (not at the API Gateway) so a single EnableAuth +toggle controls it cleanly — SAM cannot conditionally attach/detach a default JWT +authorizer on one HTTP API. When ENABLE_AUTH=true, every HTTP request must carry a +valid Cognito JWT in the Authorization header; the token is cryptographically +verified (signature via the user pool JWKS, plus issuer/audience/expiry) before any +action runs. When ENABLE_AUTH!=true the API is open (blog/demo mode). + +Verification uses PyJWT. The JWKS is fetched once per cold start and cached. +""" + +import os +import json +import time +import urllib.request + +try: + import jwt + from jwt import PyJWKClient + _JWT_AVAILABLE = True +except Exception: # pragma: no cover - import guard + _JWT_AVAILABLE = False + +REGION = os.environ.get('AWS_REGION', os.environ.get('AWS_DEFAULT_REGION', 'us-east-1')) + +_jwk_client = None +_jwk_client_url = None + + +def auth_enabled() -> bool: + return os.environ.get('ENABLE_AUTH', 'false').strip().lower() == 'true' + + +def is_internal_invoke(event) -> bool: + """Internal async self-invokes (InvocationType=Event) bypass HTTP auth.""" + return bool(event.get('_async_execute') or event.get('_async_download')) + + +def _issuer() -> str: + pool_id = os.environ.get('COGNITO_USER_POOL_ID', '') + return f"https://cognito-idp.{REGION}.amazonaws.com/{pool_id}" + + +def _jwks_url() -> str: + return f"{_issuer()}/.well-known/jwks.json" + + +def _get_jwk_client(): + global _jwk_client, _jwk_client_url + url = _jwks_url() + if _jwk_client is None or _jwk_client_url != url: + _jwk_client = PyJWKClient(url) + _jwk_client_url = url + return _jwk_client + + +def _bearer_token(event) -> str: + """Extract the bearer token from the Authorization header (case-insensitive).""" + headers = event.get('headers') or {} + auth_header = '' + for k, v in headers.items(): + if k and k.lower() == 'authorization': + auth_header = v or '' + break + if not auth_header: + return '' + parts = auth_header.split() + if len(parts) == 2 and parts[0].lower() == 'bearer': + return parts[1] + # Some clients send the raw token without the Bearer prefix. + return auth_header.strip() + + +def authorize(event): + """ + Returns (ok: bool, error: str|None, claims: dict). + + - Auth disabled -> (True, None, {}) [open mode] + - Auth enabled + valid token -> (True, None, ) + - Auth enabled + bad/missing -> (False, reason, {}) + """ + if not auth_enabled(): + return True, None, {} + + if not _JWT_AVAILABLE: + # Fail closed: if the crypto library is missing while auth is on, do not serve. + return False, 'Unauthorized: auth library unavailable', {} + + pool_id = os.environ.get('COGNITO_USER_POOL_ID', '') + app_client_id = os.environ.get('COGNITO_APP_CLIENT_ID', '') + if not pool_id or not app_client_id: + return False, 'Unauthorized: auth not configured', {} + + token = _bearer_token(event) + if not token: + return False, 'Unauthorized: missing bearer token', {} + + try: + signing_key = _get_jwk_client().get_signing_key_from_jwt(token) + expected_use = os.environ.get('EXPECTED_TOKEN_USE', 'access').strip().lower() + # Access tokens do not carry an `aud` claim (they use client_id); id tokens do. + # Verify audience only for id tokens; always verify issuer + signature + expiry. + decode_kwargs = { + 'algorithms': ['RS256'], + 'issuer': _issuer(), + 'options': {'require': ['exp', 'iat']}, + } + if expected_use == 'id': + decode_kwargs['audience'] = app_client_id + claims = jwt.decode(token, signing_key.key, **decode_kwargs) + + token_use = str(claims.get('token_use', '')).lower() + if expected_use and token_use and token_use != expected_use: + return False, f'Unauthorized: unexpected token_use "{token_use}"', {} + + # For access tokens, validate the client_id claim matches our app client. + if token_use == 'access': + if claims.get('client_id') and claims['client_id'] != app_client_id: + return False, 'Unauthorized: token client_id mismatch', {} + + return True, None, claims + + except Exception as e: # invalid signature, expired, wrong issuer, etc. + return False, f'Unauthorized: {type(e).__name__}', {} diff --git a/agentic-atx-platform/api/lambda/requirements.txt b/agentic-atx-platform/api/lambda/requirements.txt new file mode 100644 index 0000000..76e1b55 --- /dev/null +++ b/agentic-atx-platform/api/lambda/requirements.txt @@ -0,0 +1 @@ +PyJWT[crypto]>=2.8.0 diff --git a/agentic-atx-platform/api/lambda/tests/test_auth.py b/agentic-atx-platform/api/lambda/tests/test_auth.py new file mode 100644 index 0000000..5b518a2 --- /dev/null +++ b/agentic-atx-platform/api/lambda/tests/test_auth.py @@ -0,0 +1,135 @@ +""" +Tests for auth enforcement on the async invoke Lambda. + +Run from agentic-atx-platform/api/lambda: + python3 -m unittest discover -s tests -v + +Covers: + - auth disabled (open/demo mode) lets requests through + - auth enabled rejects requests with no bearer token (401) + - auth enabled rejects when JWT verification fails (bad signature/expired) + - auth enabled accepts a valid token and returns its claims + - token_use / client_id mismatches are rejected + - misconfiguration (no pool/client) fails closed + - internal async self-invokes bypass HTTP auth +""" + +import os +import sys +import json +import unittest +from unittest import mock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +import auth # noqa: E402 + +POOL_ENV = { + 'ENABLE_AUTH': 'true', + 'COGNITO_USER_POOL_ID': 'us-east-1_pool', + 'COGNITO_APP_CLIENT_ID': 'client123', + 'EXPECTED_TOKEN_USE': 'access', + 'AWS_REGION': 'us-east-1', +} + + +def http_event(token=None, body=None, method='POST'): + headers = {} + if token is not None: + headers['Authorization'] = f'Bearer {token}' + return { + 'requestContext': {'http': {'method': method}}, + 'headers': headers, + 'body': json.dumps(body or {'action': 'direct', 'op': 'list_jobs'}), + } + + +class TestAuthDisabled(unittest.TestCase): + def test_disabled_is_open(self): + with mock.patch.dict(os.environ, {'ENABLE_AUTH': 'false'}, clear=False): + ok, err, claims = auth.authorize(http_event()) + self.assertTrue(ok) + self.assertIsNone(err) + + def test_disabled_variants(self): + for val in ('', 'False', 'no', '0', 'FALSE'): + with mock.patch.dict(os.environ, {'ENABLE_AUTH': val}, clear=False): + self.assertFalse(auth.auth_enabled(), f"{val!r} should be disabled") + + +class TestAuthEnabled(unittest.TestCase): + def test_missing_token_rejected(self): + with mock.patch.dict(os.environ, POOL_ENV, clear=False): + ok, err, _ = auth.authorize(http_event(token=None)) + self.assertFalse(ok) + self.assertIn('missing bearer token', err) + + def test_unconfigured_fails_closed(self): + env = dict(POOL_ENV) + env['COGNITO_USER_POOL_ID'] = '' + with mock.patch.dict(os.environ, env, clear=False): + ok, err, _ = auth.authorize(http_event(token='x')) + self.assertFalse(ok) + self.assertIn('not configured', err) + + def test_invalid_token_rejected(self): + with mock.patch.dict(os.environ, POOL_ENV, clear=False): + with mock.patch.object(auth, '_get_jwk_client') as gk: + gk.return_value.get_signing_key_from_jwt.side_effect = Exception('bad key') + ok, err, _ = auth.authorize(http_event(token='tampered')) + self.assertFalse(ok) + self.assertIn('Unauthorized', err) + + def test_valid_access_token_accepted(self): + with mock.patch.dict(os.environ, POOL_ENV, clear=False): + with mock.patch.object(auth, '_get_jwk_client') as gk, \ + mock.patch.object(auth, 'jwt') as jwtmod: + gk.return_value.get_signing_key_from_jwt.return_value = mock.Mock(key='K') + jwtmod.decode.return_value = {'sub': 'u1', 'token_use': 'access', 'client_id': 'client123'} + ok, err, claims = auth.authorize(http_event(token='good')) + self.assertTrue(ok, err) + self.assertEqual(claims['sub'], 'u1') + + def test_wrong_token_use_rejected(self): + with mock.patch.dict(os.environ, POOL_ENV, clear=False): + with mock.patch.object(auth, '_get_jwk_client') as gk, \ + mock.patch.object(auth, 'jwt') as jwtmod: + gk.return_value.get_signing_key_from_jwt.return_value = mock.Mock(key='K') + jwtmod.decode.return_value = {'sub': 'u1', 'token_use': 'id'} + ok, err, _ = auth.authorize(http_event(token='good')) + self.assertFalse(ok) + self.assertIn('token_use', err) + + def test_client_id_mismatch_rejected(self): + with mock.patch.dict(os.environ, POOL_ENV, clear=False): + with mock.patch.object(auth, '_get_jwk_client') as gk, \ + mock.patch.object(auth, 'jwt') as jwtmod: + gk.return_value.get_signing_key_from_jwt.return_value = mock.Mock(key='K') + jwtmod.decode.return_value = {'sub': 'u1', 'token_use': 'access', 'client_id': 'someoneelse'} + ok, err, _ = auth.authorize(http_event(token='good')) + self.assertFalse(ok) + self.assertIn('client_id', err) + + +class TestBearerExtraction(unittest.TestCase): + def test_case_insensitive_header(self): + ev = {'headers': {'authorization': 'Bearer abc'}} + self.assertEqual(auth._bearer_token(ev), 'abc') + + def test_raw_token_without_prefix(self): + ev = {'headers': {'Authorization': 'abc'}} + self.assertEqual(auth._bearer_token(ev), 'abc') + + def test_no_header(self): + self.assertEqual(auth._bearer_token({'headers': {}}), '') + + +class TestInternalInvoke(unittest.TestCase): + def test_internal_invoke_detection(self): + self.assertTrue(auth.is_internal_invoke({'_async_execute': True})) + self.assertTrue(auth.is_internal_invoke({'_async_download': True})) + self.assertFalse(auth.is_internal_invoke(http_event())) + + +if __name__ == '__main__': + unittest.main() diff --git a/agentic-atx-platform/api/lambda/tests/test_handler_auth.py b/agentic-atx-platform/api/lambda/tests/test_handler_auth.py new file mode 100644 index 0000000..455baf8 --- /dev/null +++ b/agentic-atx-platform/api/lambda/tests/test_handler_auth.py @@ -0,0 +1,102 @@ +""" +Handler-level auth enforcement tests for async_invoke_agent.lambda_handler. + +Ensures that when ENABLE_AUTH=true, EVERY HTTP entry path (submit, poll, direct, +and unknown actions) is rejected with 401 unless API Gateway-validated JWT claims +are present — i.e. there are no unauthenticated endpoints. Also verifies that +internal async self-invokes and CORS preflight are handled correctly. + +boto3 is mocked so the handler imports without AWS access. + +Run from agentic-atx-platform/api/lambda: + python3 -m unittest discover -s tests -v +""" + +import os +import sys +import json +import unittest +from unittest import mock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + + +def _import_handler(): + """Import the handler with boto3 fully mocked.""" + boto3_mock = mock.MagicMock() + with mock.patch.dict(sys.modules, {'boto3': boto3_mock}): + # Fresh import each time so module-level boto3 clients are the mocks + for mod in ('async_invoke_agent',): + sys.modules.pop(mod, None) + import async_invoke_agent + return async_invoke_agent + + +def http_event(action='submit', extra=None, token=None, method='POST'): + body = {'action': action} + if action == 'submit': + body['prompt'] = 'do something' + if extra: + body.update(extra) + headers = {} + if token is not None: + headers['Authorization'] = f'Bearer {token}' + return {'requestContext': {'http': {'method': method}}, 'headers': headers, 'body': json.dumps(body)} + + +HTTP_ACTIONS = ['submit', 'poll', 'direct', 'bogus-action'] + + +class TestHandlerAuthEnabled(unittest.TestCase): + def setUp(self): + self.handler = _import_handler() + + def test_all_http_actions_rejected_without_token(self): + # Auth on + no token => authorize() returns False => 401 for every action. + with mock.patch.dict(os.environ, {'ENABLE_AUTH': 'true', 'COGNITO_USER_POOL_ID': 'p', 'COGNITO_APP_CLIENT_ID': 'c'}, clear=False): + for action in HTTP_ACTIONS: + ev = http_event(action=action, token=None) + resp = self.handler.lambda_handler(ev, None) + self.assertEqual(resp['statusCode'], 401, + f"action={action} should be 401 without token, got {resp['statusCode']}") + self.assertIn('Unauthorized', json.loads(resp['body'])['error']) + + def test_options_preflight_allowed_without_auth(self): + with mock.patch.dict(os.environ, {'ENABLE_AUTH': 'true'}, clear=False): + resp = self.handler.lambda_handler(http_event(method='OPTIONS', token=None), None) + self.assertEqual(resp['statusCode'], 200) + + def test_internal_async_execute_bypasses_http_auth(self): + with mock.patch.dict(os.environ, {'ENABLE_AUTH': 'true'}, clear=False): + with mock.patch.object(self.handler, '_execute_agentcore', return_value={'ok': True}) as m: + resp = self.handler.lambda_handler( + {'_async_execute': True, 'request_id': 'r1', 'prompt': 'p'}, None) + m.assert_called_once() + self.assertEqual(resp, {'ok': True}) + + def test_valid_token_passes_auth_gate(self): + # Mock the auth module the handler imports so a valid token passes the gate. + with mock.patch.dict(os.environ, {'ENABLE_AUTH': 'true'}, clear=False): + import auth as auth_mod + with mock.patch.object(auth_mod, 'authorize', return_value=(True, None, {'sub': 'u'})), \ + mock.patch.object(self.handler, '_handle_submit', return_value=self.handler.cors_response(200, '{"status":"SUBMITTED"}')) as m: + ev = http_event(action='submit', token='good') + resp = self.handler.lambda_handler(ev, None) + m.assert_called_once() + self.assertEqual(resp['statusCode'], 200) + + +class TestHandlerAuthDisabled(unittest.TestCase): + def setUp(self): + self.handler = _import_handler() + + def test_open_mode_does_not_require_token(self): + with mock.patch.dict(os.environ, {'ENABLE_AUTH': 'false'}, clear=False): + with mock.patch.object(self.handler, '_handle_submit', return_value=self.handler.cors_response(200, '{}')) as m: + resp = self.handler.lambda_handler(http_event(action='submit', token=None), None) + m.assert_called_once() + self.assertEqual(resp['statusCode'], 200) + + +if __name__ == '__main__': + unittest.main() diff --git a/agentic-atx-platform/api/lambda/tests/test_template_no_open_endpoints.py b/agentic-atx-platform/api/lambda/tests/test_template_no_open_endpoints.py new file mode 100644 index 0000000..9a99f7a --- /dev/null +++ b/agentic-atx-platform/api/lambda/tests/test_template_no_open_endpoints.py @@ -0,0 +1,89 @@ +""" +Static checks guaranteeing there are no unauthenticated endpoints once auth is on. + +Auth is enforced in the Lambda (auth.py) rather than a gateway authorizer (SAM can't +toggle a default JWT authorizer on one HttpApi). These tests assert the security +invariants that keep the surface closed: + + 1. EnableAuth parameter + AuthEnabled condition exist; Cognito resources are gated. + 2. There is exactly one HTTP API route (/orchestrate) — no stray public routes. + 3. No AWS::Lambda::Url / FunctionUrlConfig (those bypass the handler's auth gate). + 4. The handler calls auth.authorize() before routing any HTTP action, and the + OPTIONS/preflight short-circuit happens before action routing. + 5. auth.py fails closed (rejects) when enabled but unconfigured or token invalid. + +Run from agentic-atx-platform/api/lambda: + python3 -m unittest discover -s tests -v +""" + +import os +import re +import unittest + +HERE = os.path.dirname(__file__) +TEMPLATE = os.path.abspath(os.path.join(HERE, '..', '..', '..', 'sam', 'template.yaml')) +HANDLER = os.path.abspath(os.path.join(HERE, '..', 'async_invoke_agent.py')) + + +class TestTemplateSecurity(unittest.TestCase): + @classmethod + def setUpClass(cls): + with open(TEMPLATE) as f: + cls.text = f.read() + with open(HANDLER) as f: + cls.handler = f.read() + + def test_enable_auth_parameter_present(self): + self.assertIn('EnableAuth:', self.text) + self.assertRegex(self.text, r'AuthEnabled:\s*!Equals\s*\[\s*!Ref EnableAuth,\s*"true"\s*\]') + + def test_auth_enabled_by_default(self): + # Secure by default: the EnableAuth parameter must default to "true". + m = re.search(r'EnableAuth:\s*\n\s*Type:\s*String\s*\n\s*Default:\s*"(\w+)"', self.text) + self.assertIsNotNone(m, "EnableAuth parameter/Default not found") + self.assertEqual(m.group(1), 'true', "EnableAuth must default to 'true' (secure by default)") + + def test_cognito_resources_conditional(self): + for res in ('UserPool:', 'UserPoolClient:'): + self.assertIn(res, self.text) + self.assertGreaterEqual(self.text.count('Condition: AuthEnabled'), 2) + + def test_single_httpapi_route(self): + events = re.findall(r'Type:\s*HttpApi\b', self.text) + self.assertEqual(len(events), 1, f"expected exactly 1 HttpApi event, found {len(events)}") + self.assertIn('Path: /orchestrate', self.text) + + def test_no_public_function_urls(self): + self.assertNotIn('AWS::Lambda::Url', self.text) + self.assertNotIn('FunctionUrlConfig', self.text) + + def test_lambda_receives_auth_config(self): + for env in ('ENABLE_AUTH:', 'COGNITO_USER_POOL_ID:', 'COGNITO_APP_CLIENT_ID:'): + self.assertIn(env, self.text) + + def test_handler_enforces_auth_before_routing(self): + # authorize() must be invoked, and its failure must return before action routing. + self.assertIn('from auth import authorize', self.handler) + self.assertIn('authorize(event)', self.handler) + idx_auth = self.handler.index('authorize(event)') + idx_submit = self.handler.index("action == 'submit'") + self.assertLess(idx_auth, idx_submit, "auth gate must run before action routing") + # A 401 must be returned on failure. + self.assertRegex(self.handler, r'return cors_response\(401') + + +class TestAuthFailsClosed(unittest.TestCase): + def test_unconfigured_when_enabled_rejects(self): + import sys + sys.path.insert(0, os.path.abspath(os.path.join(HERE, '..'))) + import auth + from unittest import mock + env = {'ENABLE_AUTH': 'true', 'COGNITO_USER_POOL_ID': '', 'COGNITO_APP_CLIENT_ID': ''} + with mock.patch.dict(os.environ, env, clear=False): + ev = {'headers': {'Authorization': 'Bearer x'}, 'requestContext': {'http': {'method': 'POST'}}} + ok, err, _ = auth.authorize(ev) + self.assertFalse(ok) + + +if __name__ == '__main__': + unittest.main() diff --git a/agentic-atx-platform/sam/deploy.sh b/agentic-atx-platform/sam/deploy.sh index d7560c1..28ea55e 100755 --- a/agentic-atx-platform/sam/deploy.sh +++ b/agentic-atx-platform/sam/deploy.sh @@ -73,7 +73,11 @@ echo "4. Building SAM application..." SAM_CLI_CONTAINER_TOOL=docker sam build 2>&1 echo "" -echo "5. Deploying SAM stack..." +# Auth is secure-by-default (EnableAuth=true). For the open blog/demo walkthrough, +# export ENABLE_AUTH=false before running this script. +AUTH_OVERRIDE="EnableAuth=${ENABLE_AUTH:-true}" + +echo "5. Deploying SAM stack... (auth: ${ENABLE_AUTH:-true})" sam deploy \ --stack-name AtxAgentCoreSAM \ --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM \ @@ -82,6 +86,7 @@ sam deploy \ SourceBucketName="${SOURCE_BUCKET}" \ AwsRegion="${REGION}" \ ${MODEL_OVERRIDE} \ + ${AUTH_OVERRIDE} \ OrchestratorContainerUri="${ORCH_ECR_URI}:latest" \ --no-confirm-changeset \ --no-fail-on-empty-changeset \ diff --git a/agentic-atx-platform/sam/template.yaml b/agentic-atx-platform/sam/template.yaml index 4177da5..8ccae05 100644 --- a/agentic-atx-platform/sam/template.yaml +++ b/agentic-atx-platform/sam/template.yaml @@ -27,6 +27,30 @@ Parameters: Type: String Default: "" Description: AgentCore runtime ARN (set after the orchestrator is deployed). Preserved across redeploys. + EnableAuth: + Type: String + Default: "true" + AllowedValues: ["true", "false"] + Description: > + Secure by default. When "true" (default), the API requires a valid Cognito JWT + (verified in the Lambda) and provisions a Cognito User Pool. Set to "false" only + for the open blog/demo walkthrough where no authentication is desired. + AllowedOrigin: + Type: String + Default: "*" + Description: > + CORS allowed origin. Use "*" for demo; set to the CloudFront/UI URL when auth is enabled. + CognitoCallbackUrl: + Type: String + Default: "https://localhost:3000" + Description: OAuth callback URL for the Cognito hosted UI (the deployed UI URL). + CognitoLogoutUrl: + Type: String + Default: "https://localhost:3000" + Description: OAuth sign-out redirect URL for the Cognito hosted UI. + +Conditions: + AuthEnabled: !Equals [!Ref EnableAuth, "true"] Globals: Function: @@ -244,6 +268,11 @@ Resources: RESULT_BUCKET: !Ref OutputBucketName JOB_QUEUE_NAME: atx-job-queue JOB_DEFINITION_NAME: atx-transform-job + ENABLE_AUTH: !Ref EnableAuth + EXPECTED_TOKEN_USE: "access" + ALLOWED_ORIGIN: !Ref AllowedOrigin + COGNITO_USER_POOL_ID: !If [AuthEnabled, !Ref UserPool, ""] + COGNITO_APP_CLIENT_ID: !If [AuthEnabled, !Ref UserPoolClient, ""] Events: Orchestrate: Type: HttpApi @@ -255,19 +284,87 @@ Resources: # ======================================== # 3. HTTP API Gateway # ======================================== + # A single HTTP API. Authentication is enforced inside the Lambda (auth.py) based + # on the EnableAuth parameter, rather than via a gateway JWT authorizer — SAM does + # not support toggling a default authorizer on/off on one HttpApi, and Lambda-side + # verification keeps a single clean toggle. When EnableAuth=true the function + # cryptographically validates the Cognito JWT (issuer/audience/signature/expiry) + # and rejects anything unauthenticated with 401. HttpApi: Type: AWS::Serverless::HttpApi Properties: StageName: prod CorsConfiguration: AllowOrigins: - - "*" + - !Ref AllowedOrigin AllowMethods: - POST + - OPTIONS AllowHeaders: - content-type + - authorization MaxAge: 86400 + # ======================================== + # 4. Cognito (created only when EnableAuth=true) + # ======================================== + UserPool: + Type: AWS::Cognito::UserPool + Condition: AuthEnabled + Properties: + UserPoolName: atx-transform-users + AutoVerifiedAttributes: + - email + UsernameAttributes: + - email + AdminCreateUserConfig: + AllowAdminCreateUserOnly: true + Policies: + PasswordPolicy: + MinimumLength: 12 + RequireUppercase: true + RequireLowercase: true + RequireNumbers: true + RequireSymbols: true + + UserPoolClient: + Type: AWS::Cognito::UserPoolClient + Condition: AuthEnabled + Properties: + ClientName: atx-transform-ui + UserPoolId: !Ref UserPool + GenerateSecret: false + AllowedOAuthFlowsUserPoolClient: true + AllowedOAuthFlows: + - code + AllowedOAuthScopes: + - email + - openid + - profile + SupportedIdentityProviders: + - COGNITO + CallbackURLs: + - !Ref CognitoCallbackUrl + LogoutURLs: + - !Ref CognitoLogoutUrl + ExplicitAuthFlows: + - ALLOW_USER_SRP_AUTH + - ALLOW_REFRESH_TOKEN_AUTH + AccessTokenValidity: 60 + IdTokenValidity: 60 + RefreshTokenValidity: 30 + TokenValidityUnits: + AccessToken: minutes + IdToken: minutes + RefreshToken: days + + UserPoolDomain: + Type: AWS::Cognito::UserPoolDomain + Condition: AuthEnabled + Properties: + Domain: !Sub "atx-transform-${AWS::AccountId}" + UserPoolId: !Ref UserPool + Outputs: ApiEndpoint: Description: HTTP API endpoint for UI @@ -281,3 +378,15 @@ Outputs: AsyncLambdaName: Description: Async invoke Lambda function name Value: !Ref AsyncInvokeFunction + UserPoolId: + Description: Cognito User Pool ID (only when auth enabled) + Condition: AuthEnabled + Value: !Ref UserPool + UserPoolClientId: + Description: Cognito User Pool App Client ID (only when auth enabled) + Condition: AuthEnabled + Value: !Ref UserPoolClient + CognitoHostedUiDomain: + Description: Cognito hosted UI domain (only when auth enabled) + Condition: AuthEnabled + Value: !Sub "https://atx-transform-${AWS::AccountId}.auth.${AwsRegion}.amazoncognito.com" From bd5aaa01b1e9154294f781b558900486ad50ca04 Mon Sep 17 00:00:00 2001 From: Sandeep Batchu Date: Tue, 30 Jun 2026 12:24:45 -0500 Subject: [PATCH 2/8] feat(ui): Cognito Hosted UI auth + authedFetch wrapper - auth.js: OAuth2 authorization-code + PKCE login against the Cognito Hosted UI, sessionStorage token handling, authedFetch() that attaches the bearer token and redirects to login on 401/missing token. No-op when VITE_AUTH_ENABLED!=true so the blog/demo build is unchanged. - Route all ~25 /orchestrate calls through authedFetch across App + components. - App gates render on auth when enabled (handles ?code= redirect, shows Sign out). Build-time flags: VITE_AUTH_ENABLED, VITE_COGNITO_DOMAIN, VITE_COGNITO_CLIENT_ID, VITE_AUTH_REDIRECT_URI. Verified both auth-off and auth-on builds compile. --- agentic-atx-platform/ui/src/App.jsx | 61 +++++-- agentic-atx-platform/ui/src/auth.js | 149 ++++++++++++++++++ .../ui/src/components/Chat.jsx | 3 +- .../ui/src/components/CreateCustom.jsx | 3 +- .../ui/src/components/JobTracker.jsx | 27 ++-- .../ui/src/components/KnowledgeItems.jsx | 3 +- .../ui/src/components/TransformationForm.jsx | 3 +- .../ui/src/components/TransformationList.jsx | 5 +- agentic-atx-platform/ui/src/knowledgeApi.js | 3 +- agentic-atx-platform/ui/src/metricsApi.js | 5 +- 10 files changed, 229 insertions(+), 33 deletions(-) create mode 100644 agentic-atx-platform/ui/src/auth.js diff --git a/agentic-atx-platform/ui/src/App.jsx b/agentic-atx-platform/ui/src/App.jsx index fe07e12..5a19f78 100644 --- a/agentic-atx-platform/ui/src/App.jsx +++ b/agentic-atx-platform/ui/src/App.jsx @@ -1,3 +1,4 @@ +import { authedFetch, authEnabled, handleAuthRedirect, isAuthenticated, login, logout } from "./auth" import React, { useState, useEffect } from 'react' import TransformationList from './components/TransformationList' import TransformationForm from './components/TransformationForm' @@ -13,7 +14,7 @@ const API_BASE = import.meta.env.VITE_API_ENDPOINT || '/api' // Async orchestrator for AI operations async function orchestrate(prompt, { onStep, pollIntervalMs = 5000, maxPollMs = 300000 } = {}) { - const submitRes = await fetch(`${API_BASE}/orchestrate`, { + const submitRes = await authedFetch(`${API_BASE}/orchestrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'submit', prompt }) @@ -24,7 +25,7 @@ async function orchestrate(prompt, { onStep, pollIntervalMs = 5000, maxPollMs = const deadline = Date.now() + maxPollMs while (Date.now() < deadline) { await new Promise(r => setTimeout(r, pollIntervalMs)) - const pollRes = await fetch(`${API_BASE}/orchestrate`, { + const pollRes = await authedFetch(`${API_BASE}/orchestrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'poll', request_id }) @@ -46,7 +47,7 @@ async function orchestrate(prompt, { onStep, pollIntervalMs = 5000, maxPollMs = // Direct calls for fast operations (status, results) - no AI overhead async function directCall(op, job_id) { - const res = await fetch(`${API_BASE}/orchestrate`, { + const res = await authedFetch(`${API_BASE}/orchestrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'direct', op, job_id }) @@ -56,7 +57,7 @@ async function directCall(op, job_id) { // Fire-and-forget: submit to orchestrator without waiting for result async function submitAsync(prompt) { - const res = await fetch(`${API_BASE}/orchestrate`, { + const res = await authedFetch(`${API_BASE}/orchestrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'submit', prompt }) @@ -68,12 +69,33 @@ export default function App() { const [tab, setTab] = useState('Transformations') const [jobs, setJobs] = useState([]) const [jobsLoaded, setJobsLoaded] = useState(false) + const [authReady, setAuthReady] = useState(!authEnabled()) + const [authed, setAuthed] = useState(isAuthenticated()) - // Load jobs from DynamoDB on mount + // Handle the Cognito redirect (?code=...) and gate the app on auth when enabled. useEffect(() => { + if (!authEnabled()) { setAuthReady(true); setAuthed(true); return } + let cancelled = false + ;(async () => { + await handleAuthRedirect() + if (cancelled) return + if (isAuthenticated()) { + setAuthed(true) + setAuthReady(true) + } else { + // Not signed in — redirect to the Cognito Hosted UI. + login() + } + })() + return () => { cancelled = true } + }, []) + + // Load jobs from DynamoDB on mount (only once authenticated) + useEffect(() => { + if (!authed) return async function loadJobs() { try { - const res = await fetch(`${API_BASE}/orchestrate`, { + const res = await authedFetch(`${API_BASE}/orchestrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'direct', op: 'list_jobs' }) @@ -84,7 +106,7 @@ export default function App() { setJobsLoaded(true) } loadJobs() - }, []) + }, [authed]) function updateJobs(updater) { setJobs(prev => { @@ -96,7 +118,7 @@ export default function App() { const addJob = (job) => { updateJobs(prev => [job, ...prev]) // Persist to DynamoDB - fetch(`${API_BASE}/orchestrate`, { + authedFetch(`${API_BASE}/orchestrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'direct', op: 'save_job', job }) @@ -107,7 +129,7 @@ export default function App() { updateJobs(prev => [...newJobs, ...prev]) // Persist all to DynamoDB newJobs.forEach(job => { - fetch(`${API_BASE}/orchestrate`, { + authedFetch(`${API_BASE}/orchestrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'direct', op: 'save_job', job }) @@ -115,11 +137,28 @@ export default function App() { }) } + // While auth is initializing/redirecting, don't render the app shell. + if (!authReady) { + return ( +
+

ATX Transform

+
Signing in...
+
+ ) + } + return (
-

ATX Transform

-

AI-Powered Code Transformation Platform

+
+
+

ATX Transform

+

AI-Powered Code Transformation Platform

+
+ {authEnabled() && ( + + )} +