diff --git a/agentic-atx-platform/ARCHITECTURE.md b/agentic-atx-platform/ARCHITECTURE.md index 35ceea0..907a349 100644 --- a/agentic-atx-platform/ARCHITECTURE.md +++ b/agentic-atx-platform/ARCHITECTURE.md @@ -151,6 +151,28 @@ Knowledge items are generated DISABLED by ATX after a run; the UI lists them cache-first and only triggers the Batch refresh on an explicit "Pull from registry". ``` +### Authentication +``` +Secure by default (EnableAuth=true). + +UI (Cognito Hosted UI, OAuth2 auth-code + PKCE) + → user signs in → app exchanges ?code= for an access token (sessionStorage) + → authedFetch attaches Authorization: Bearer to every /orchestrate call + +API Gateway (raw ApiGatewayV2 + Cognito JWT authorizer) + → EnableAuth=true: /orchestrate route requires a valid Cognito JWT; rejects + unauthenticated/invalid tokens with 401 at the edge (Lambda not invoked) + → EnableAuth=false: route is open (AuthorizationType NONE) for blog/demo mode + +async_invoke_agent Lambda (auth.py) — defense-in-depth + → trusts gateway-validated claims when present; otherwise re-verifies the JWT + (JWKS signature, issuer, audience, expiry, token_use, client_id) + → internal async self-invokes and CORS preflight bypass the gate + +Note: raw ApiGatewayV2 resources are used (not SAM's HttpApi `Auth` shorthand) so +the JWT authorizer can be attached conditionally via !If on EnableAuth. +``` + ## Components | Component | Path | Purpose | @@ -163,6 +185,7 @@ cache-first and only triggers the Batch refresh on an explicit "Pull from regist | Async Lambda | `api/lambda/async_invoke_agent.py` | Submit/poll/direct bridge | | Metrics | `api/lambda/metrics.py` | CloudWatch AWS/TransformCustom metrics (direct op) | | Knowledge Items | `api/lambda/knowledge_items.py` | List/enable/disable/delete/export KIs (direct op) | +| Auth | `api/lambda/auth.py` | Cognito JWT verification (secure-by-default, fails closed) | | UI | `ui/src/` | React app (8 tabs) | | Infrastructure | `cdk/` | Batch, S3, VPC, CloudFront, AgentCore | | SAM Layer | `sam/` | AgentCore deploy Lambda + API (Option A) | @@ -181,6 +204,7 @@ cache-first and only triggers the Batch refresh on an explicit "Pull from regist | API Gateway v2 (HTTP) | Single /orchestrate endpoint | | Lambda | Async bridge (submit/poll/direct) | | DynamoDB | Job tracking (persisted across sessions) | +| Cognito (User Pool) | UI authentication — JWT verified in Lambda (when EnableAuth=true) | ## Project Structure @@ -192,8 +216,10 @@ cache-first and only triggers the Batch refresh on an explicit "Pull from regist │ └── requirements.txt ├── api/lambda/ # Async bridge Lambda │ ├── async_invoke_agent.py +│ ├── auth.py # Cognito JWT verification (secure by default) │ ├── metrics.py # CloudWatch metrics (op: metrics) -│ └── knowledge_items.py # Knowledge items (op: knowledge_items) +│ ├── knowledge_items.py # Knowledge items (op: knowledge_items) +│ └── tests/ # unittest suite (auth enforcement, no open endpoints) ├── ui/ # React frontend (8 tabs) │ └── src/components/ # TransformationList, Form, CreateCustom, CsvUpload, JobTracker, Metrics, KnowledgeItems, Chat ├── cdk/ # CDK stacks (Container, Infrastructure, AgentCore, UI) diff --git a/agentic-atx-platform/README.md b/agentic-atx-platform/README.md index 9214aa8..03d5d23 100644 --- a/agentic-atx-platform/README.md +++ b/agentic-atx-platform/README.md @@ -58,6 +58,7 @@ Key settings: | `BEDROCK_MODEL_ID` | `us.anthropic.claude-sonnet-4-5-20250929-v1:0` | AI model for orchestrator | | `FARGATE_VCPU` | `2` | vCPU for Batch jobs | | `FARGATE_MEMORY` | `4096` | Memory (MB) for Batch jobs | +| `ENABLE_AUTH` | `true` | Secure by default. `true` requires Cognito JWT auth; set `false` for the open blog/demo walkthrough | | `JOB_TIMEOUT` | `43200` | Max job duration (seconds) | See `deployment/config.env.template` for all options. @@ -295,6 +296,75 @@ Create via the "Create Custom" tab. Published to the ATX registry via `atx custo --- +## Authentication + +The HTTP API is **secure by default** (`EnableAuth=true`). It requires a Cognito +JWT access token; the `atx-async-invoke-agent` Lambda verifies the token +(signature via the user pool JWKS, plus issuer/audience/expiry) and rejects +unauthenticated calls with `401`. The React UI signs in through the Cognito +Hosted UI (OAuth2 authorization-code + PKCE) and attaches the token to every +API call. + +> **Open demo mode:** for the blog/demo walkthrough where no login is desired, +> deploy with `ENABLE_AUTH=false` and build the UI without `VITE_AUTH_ENABLED`. + +### Enabling auth (default) + +1. **Deploy the stack** (creates the Cognito User Pool, app client, and hosted UI domain): + ```bash + cd sam && ./deploy.sh # ENABLE_AUTH defaults to true + ``` + Note the stack outputs: `UserPoolId`, `UserPoolClientId`, `CognitoHostedUiDomain`. + +2. **Create a user** (self-signup is disabled — admin-create only): + ```bash + aws cognito-idp admin-create-user \ + --user-pool-id \ + --username you@example.com \ + --user-attributes Name=email,Value=you@example.com Name=email_verified,Value=true + # then set a permanent password: + aws cognito-idp admin-set-user-password \ + --user-pool-id --username you@example.com \ + --password '' --permanent + ``` + +3. **Build + deploy the UI** with auth config from the stack outputs: + ```bash + cd ui && npm install + VITE_API_ENDPOINT=$API_URL \ + VITE_AUTH_ENABLED=true \ + VITE_COGNITO_DOMAIN= \ + VITE_COGNITO_CLIENT_ID= \ + VITE_AUTH_REDIRECT_URI= \ + npx vite build + ./deploy-aws.sh + ``` + +4. **Verify:** an unauthenticated call returns 401; the UI redirects to the + Cognito Hosted UI for login. + ```bash + curl -s -o /dev/null -w "%{http_code}\n" -X POST $API_URL/orchestrate \ + -H 'Content-Type: application/json' -d '{"action":"direct","op":"list_jobs"}' # -> 401 + ``` + +### UI auth build flags + +| Flag | Description | +|------|-------------| +| `VITE_AUTH_ENABLED` | `true` to enable the Hosted UI login flow | +| `VITE_COGNITO_DOMAIN` | Cognito hosted UI domain (stack output `CognitoHostedUiDomain`) | +| `VITE_COGNITO_CLIENT_ID` | App client id (stack output `UserPoolClientId`) | +| `VITE_AUTH_REDIRECT_URI` | OAuth redirect URI (defaults to `window.location.origin`) | + +> **Design note:** auth is enforced at the **API Gateway JWT authorizer** — the +> `/orchestrate` route rejects unauthenticated/invalid tokens with `401` at the edge, +> before the Lambda is invoked. The Lambda (`auth.py`) additionally verifies the JWT +> as defense-in-depth (and trusts gateway-validated claims when present). Raw +> `AWS::ApiGatewayV2` resources are used (instead of SAM's HttpApi `Auth` shorthand) +> so the authorizer can be attached conditionally on `EnableAuth`. + +--- + ## Project Structure ``` 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..5dd14fc --- /dev/null +++ b/agentic-atx-platform/api/lambda/auth.py @@ -0,0 +1,150 @@ +""" +Defense-in-depth auth verification for the async invoke Lambda. + +Primary enforcement is the API Gateway JWT authorizer on the /orchestrate route +(raw ApiGatewayV2, attached conditionally on EnableAuth) — it rejects +unauthenticated/invalid requests with 401 at the edge before this function runs. +This module is the second layer: when API Gateway has validated the token it +attaches the claims to the request, which we trust; otherwise (or as a safety net) +we cryptographically verify the Cognito JWT ourselves (signature via the user pool +JWKS, plus issuer/audience/expiry/token_use/client_id). When ENABLE_AUTH!=true the +API is open (blog/demo mode). Fails closed if enabled but misconfigured. + +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 _gateway_claims(event) -> dict: + """Claims attached by the API Gateway JWT authorizer (HTTP API payload v2).""" + authorizer = event.get('requestContext', {}).get('authorizer', {}) + jwt_claims = authorizer.get('jwt', {}) + claims = jwt_claims.get('claims') if isinstance(jwt_claims, dict) else None + if claims: + return claims + if isinstance(authorizer.get('claims'), dict): + return authorizer['claims'] + return {} + + +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, {}) + + When the API Gateway JWT authorizer is attached it has already validated the + token and populated requestContext.authorizer.jwt.claims; we trust those. + Otherwise (or as defense-in-depth) we verify the bearer token in-process. + """ + if not auth_enabled(): + return True, None, {} + + # 1. Trust gateway-validated claims if present (authorizer already verified sig/exp). + gw = _gateway_claims(event) + if gw: + return True, None, gw + + # 2. Fallback: verify the bearer token ourselves. + 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/metrics.py b/agentic-atx-platform/api/lambda/metrics.py index 91eb995..b919d98 100644 --- a/agentic-atx-platform/api/lambda/metrics.py +++ b/agentic-atx-platform/api/lambda/metrics.py @@ -282,8 +282,8 @@ def _get_job_counts(): token = resp.get('nextToken') if not token: break - except Exception: - pass + except Exception as e: + print(f"Error counting {status} jobs: {e}") counts[status] = total return counts 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..d1a4e76 --- /dev/null +++ b/agentic-atx-platform/api/lambda/tests/test_auth.py @@ -0,0 +1,151 @@ +""" +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 TestGatewayClaims(unittest.TestCase): + def test_gateway_validated_claims_trusted(self): + # When the API Gateway JWT authorizer has validated and attached claims, + # authorize() trusts them without re-verifying the token. + with mock.patch.dict(os.environ, POOL_ENV, clear=False): + ev = { + 'requestContext': {'http': {'method': 'POST'}, + 'authorizer': {'jwt': {'claims': {'sub': 'gw-user', 'token_use': 'access'}}}}, + 'headers': {}, + 'body': '{}', + } + ok, err, claims = auth.authorize(ev) + self.assertTrue(ok, err) + self.assertEqual(claims['sub'], 'gw-user') + + +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..63ad29a --- /dev/null +++ b/agentic-atx-platform/api/lambda/tests/test_template_no_open_endpoints.py @@ -0,0 +1,100 @@ +""" +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_jwt_authorizer_conditional(self): + # A JWT authorizer is declared and gated on AuthEnabled. + self.assertRegex(self.text, r'HttpApiJwtAuthorizer:\s*\n\s*Type:\s*AWS::ApiGatewayV2::Authorizer\s*\n\s*Condition:\s*AuthEnabled') + self.assertIn('AuthorizerType: JWT', self.text) + self.assertIn('cognito-idp.', self.text) + + def test_route_auth_is_conditional(self): + # The route's AuthorizationType flips JWT/NONE and AuthorizerId is conditional. + self.assertRegex(self.text, r'AuthorizationType:\s*!If\s*\[\s*AuthEnabled,\s*"JWT",\s*"NONE"\s*\]') + self.assertRegex(self.text, r'AuthorizerId:\s*!If\s*\[\s*AuthEnabled,\s*!Ref HttpApiJwtAuthorizer,\s*!Ref "AWS::NoValue"\s*\]') + + def test_single_route(self): + # Exactly one route, and it's POST /orchestrate. + routes = re.findall(r'RouteKey:\s*"POST /orchestrate"', self.text) + self.assertEqual(len(routes), 1, f"expected exactly 1 /orchestrate route, found {len(routes)}") + + 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/docs/SECURITY.md b/agentic-atx-platform/docs/SECURITY.md index 2d65f2a..bb07ced 100644 --- a/agentic-atx-platform/docs/SECURITY.md +++ b/agentic-atx-platform/docs/SECURITY.md @@ -64,38 +64,62 @@ Security considerations and best practices for the AWS Transform CLI container. - Enable VPC Flow Logs for audit - Monitor network traffic patterns with CloudWatch -## REST API Security +## API Security -### IAM Authentication +### Agentic Platform HTTP API (Cognito JWT) -✅ **Implemented:** -- AWS IAM authentication (AWS Signature V4) -- No API keys or shared secrets -- IAM user/role permissions with `execute-api:Invoke` -- Full CloudTrail audit trail - -⚠️ **Recommendations:** -- Grant users `execute-api:Invoke` permission on the API -- Use temporary credentials (AWS SSO or STS) -- Monitor API access via CloudTrail -- Set up CloudWatch Alarms for unusual activity +The agentic platform exposes a single HTTP API (`POST /orchestrate`). It is +**secure by default** (`EnableAuth=true`). -**Grant API access:** -```bash -aws iam put-user-policy \ - --user-name YOUR_USERNAME \ - --policy-name InvokeATXApi \ - --policy-document '{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Action": "execute-api:Invoke", - "Resource": "arn:aws:execute-api:*:*:*/prod/*" - }] - }' -``` - -**See:** The orchestrator handles API authentication via AgentCore IAM roles. +✅ **Implemented:** +- Cognito User Pool authentication; UI signs in via the Hosted UI (OAuth2 + authorization-code + PKCE) +- **API Gateway JWT authorizer** on the `/orchestrate` route: unauthenticated or + invalid tokens are rejected with `401` at the edge, before the Lambda is invoked +- Defense-in-depth: the `atx-async-invoke-agent` Lambda (`auth.py`) also verifies + the Cognito JWT (JWKS signature, issuer, audience, expiry, token_use, client_id), + trusting gateway-validated claims when present +- Fails closed: if auth is enabled but misconfigured, requests are denied +- CORS restricted to the configured UI origin (`AllowedOrigin`) +- Internal async self-invokes (Lambda→Lambda) and CORS preflight bypass the gate + +⚠️ **Notes / recommendations:** +- The authorizer is implemented with raw `AWS::ApiGatewayV2` resources (not SAM's + HttpApi `Auth` shorthand) so it can be attached conditionally on `EnableAuth`. +- `ENABLE_AUTH=false` deploys an **open** API for the blog/demo walkthrough only — + do not use open mode for shared or internet-reachable environments. +- Self-signup is disabled; create users via `admin-create-user`. +- Restrict `AllowedOrigin` to the exact UI URL (avoid `*`) when auth is enabled. + +### Private API endpoint (network isolation) + +The `/orchestrate` API is a **public** regional endpoint protected by the Cognito +JWT authorizer. If you have private network access to AWS (VPN, Direct Connect, or +in-VPC clients) and want to remove public internet exposure entirely, consider a +private API. Important trade-offs: + +- **HTTP API (API Gateway v2) does not support private endpoints.** Private API is + a REST API (v1) feature — it uses an interface VPC endpoint (`execute-api`) plus a + resource policy that restricts access to your VPC/VPCe. Making this API private is + therefore a migration from HTTP API → REST API, not a config toggle. +- **A browser UI cannot reach a private endpoint** over the public internet. Going + private only makes sense if the UI is also internal (VPN, WorkSpaces, or an + in-VPC/internal ALB front end). For a public browser SPA, keep the public endpoint + and rely on the JWT authorizer (and optionally WAF — see below). +- **WAF caveat:** AWS WAF cannot be associated with an HTTP API. To add WAF + (e.g. rate-based rules, IP allowlists) you would either route the API through + CloudFront as an origin and attach WAF to the distribution, or migrate to REST API + and attach WAF directly. WAF can also be attached to the Cognito user pool to + protect the login/token endpoints. + +**Recommendation:** for the public-UI deployment, keep the public HTTP API + Cognito +JWT auth; add CloudFront-fronted WAF for rate limiting/IP restriction if needed. Use +a private REST API only when the entire access path (including the UI) is internal. + +### Container REST API (scaled-execution-containers) + +The separate `scaled-execution-containers` REST API uses **IAM authentication** +(AWS Signature V4); callers need `execute-api:Invoke`. See that project's docs. ## Secrets Management @@ -283,6 +307,9 @@ aws ecr start-image-scan \ ### Before Deployment - [ ] Review and customize IAM policies +- [ ] Keep `EnableAuth=true` (default) for any shared/reachable environment +- [ ] Set `AllowedOrigin` to the exact UI URL (not `*`) when auth is enabled +- [ ] Create Cognito users via `admin-create-user` (self-signup is disabled) - [ ] Configure VPC and subnets - [ ] Set up security groups - [ ] Enable ECR image scanning 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 97af866..46b409c 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: @@ -115,10 +139,16 @@ Resources: - bedrock:InvokeModel - bedrock:InvokeModelWithResponseStream Resource: "*" + # SubmitJob scoped to the ATX job queue + definition; DescribeJobs + # has no resource-level support in AWS Batch, so it requires "*". - Effect: Allow - Action: - - batch:SubmitJob - - batch:DescribeJobs + Action: batch:SubmitJob + Resource: + - !Sub "arn:aws:batch:${AwsRegion}:${AWS::AccountId}:job-queue/atx-job-queue" + - !Sub "arn:aws:batch:${AwsRegion}:${AWS::AccountId}:job-definition/atx-transform-job" + - !Sub "arn:aws:batch:${AwsRegion}:${AWS::AccountId}:job-definition/atx-transform-job:*" + - Effect: Allow + Action: batch:DescribeJobs Resource: "*" - Effect: Allow Action: @@ -189,14 +219,15 @@ Resources: - !Sub "arn:aws:s3:::${OutputBucketName}/*" - !Sub "arn:aws:s3:::${SourceBucketName}" - !Sub "arn:aws:s3:::${SourceBucketName}/*" - # DescribeJobs has no resource-level ARN support in AWS Batch - # (jobs are described by dynamic job id), so it requires "*". + # ListJobs and DescribeJobs do NOT support resource-level permissions + # in AWS Batch — scoping them to an ARN silently denies the call, so + # they must use "*". Only SubmitJob supports queue/definition ARNs. - Effect: Allow Action: batch:DescribeJobs Resource: "*" - Effect: Allow Action: batch:ListJobs - Resource: !Sub "arn:aws:batch:${AwsRegion}:${AWS::AccountId}:job-queue/atx-job-queue" + Resource: "*" - Effect: Allow Action: batch:SubmitJob Resource: @@ -249,30 +280,145 @@ Resources: RESULT_BUCKET: !Ref OutputBucketName JOB_QUEUE_NAME: atx-job-queue JOB_DEFINITION_NAME: atx-transform-job - Events: - Orchestrate: - Type: HttpApi - Properties: - Path: /orchestrate - Method: POST - ApiId: !Ref HttpApi + 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, ""] + + # Allow API Gateway to invoke the async function (explicit, since the HttpApi + # event is replaced by raw ApiGatewayV2 resources below). + AsyncInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref AsyncInvokeFunction + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub "arn:aws:execute-api:${AwsRegion}:${AWS::AccountId}:${HttpApi}/*/*/orchestrate" # ======================================== - # 3. HTTP API Gateway + # 3. HTTP API Gateway (raw ApiGatewayV2 so the JWT authorizer can be conditional) # ======================================== + # When EnableAuth=true, a JWT authorizer (Cognito) is created and attached to the + # /orchestrate route (AuthorizationType: JWT). When false, the route is open + # (AuthorizationType: NONE). Using raw ApiGatewayV2 resources lets these properties + # be conditional via !If — which SAM's HttpApi `Auth` shorthand does not allow. + # The Lambda (auth.py) still verifies the JWT as defense-in-depth. HttpApi: - Type: AWS::Serverless::HttpApi + Type: AWS::ApiGatewayV2::Api Properties: - StageName: prod + Name: AtxAgentCoreSAM + ProtocolType: HTTP CorsConfiguration: AllowOrigins: - - "*" + - !Ref AllowedOrigin AllowMethods: - POST + - OPTIONS AllowHeaders: - content-type + - authorization MaxAge: 86400 + HttpApiIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref HttpApi + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt AsyncInvokeFunction.Arn + PayloadFormatVersion: "2.0" + + # Cognito JWT authorizer — only created when auth is enabled. + HttpApiJwtAuthorizer: + Type: AWS::ApiGatewayV2::Authorizer + Condition: AuthEnabled + Properties: + ApiId: !Ref HttpApi + AuthorizerType: JWT + Name: CognitoJwtAuthorizer + IdentitySource: + - "$request.header.Authorization" + JwtConfiguration: + Issuer: !Sub "https://cognito-idp.${AwsRegion}.amazonaws.com/${UserPool}" + Audience: + - !Ref UserPoolClient + + HttpApiRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref HttpApi + RouteKey: "POST /orchestrate" + Target: !Sub "integrations/${HttpApiIntegration}" + AuthorizationType: !If [AuthEnabled, "JWT", "NONE"] + AuthorizerId: !If [AuthEnabled, !Ref HttpApiJwtAuthorizer, !Ref "AWS::NoValue"] + + HttpApiStage: + Type: AWS::ApiGatewayV2::Stage + Properties: + ApiId: !Ref HttpApi + StageName: prod + AutoDeploy: true + + # ======================================== + # 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 @@ -286,3 +432,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" 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() && ( + + )} +