From d80dc9557ddf36d0df618927eb545e39b653fad9 Mon Sep 17 00:00:00 2001 From: derrickfink Date: Tue, 28 Jul 2026 15:56:40 -0600 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20default=20inbound=20auth=20to?= =?UTF-8?q?=20iam=20=E2=80=94=20the=20authorizer=20is=20immutable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlatformStack failed deploying #778 to dev: Authorizer type cannot be updated for an existing gateway (Service: BedrockAgentCoreControl, Status Code: 400) AgentCore will not change a Gateway's authorizerType after creation. The stack rolled back cleanly and the Gateway kept AWS_IAM, READY, and both targets — but the migration as designed cannot work in place. Neither pre-deploy check caught this. The CloudFormation resource reference documents AuthorizerType as "Update requires: No interruption", and `cdk diff` via a real change set reported an in-place [~] modify. Both describe CFN's plan, not the AgentCore service's validation. A change set is not a deploy test. Two failures to fix, not one: 1. The backend half shipped while the infra half rolled back, so the runtime had no AGENTCORE_GATEWAY_INBOUND_AUTH — and the agent's default was 'jwt'. That pointed the new agent at bearer auth against an AWS_IAM Gateway, 401ing every Gateway tool call. Both defaults are now 'iam': an absent value means "behave like the Gateway that is actually deployed", which is the only safe direction when the two halves can land independently. 2. CDK_GATEWAY_INBOUND_AUTH existed only in config.ts — step 1 of the repo's 7-step config pattern. The documented escape hatch was unreachable from CI. Now exported and validated in load-env.sh, passed as context (synth.sh and deploy.sh already use build_cdk_context_params), and carried in platform.yml's job env. backend.yml runs no CDK, so it needs nothing. Also corrects every place that asserted the opposite: the GatewayConfig doc, the construct's inline comment and class doc, the plan's Phase 0/1A, and the public environments.md page (now a :::danger: covering the real error and why the pre-deploy signals mislead). The single-Gateway endstate still holds. What changes is the mechanism: reaching CUSTOM_JWT needs a new Gateway plus target re-registration and a cutover, not a config flip. Targets are managed out-of-band by app-api's GatewayTargetService and the mcp-servers repo, so that needs its own design — tracked in the plan. Verified: synth against dev now emits AuthorizerType AWS_IAM matching the live Gateway, and `cdk diff` shows no authorizer change, so the deploy proceeds. tsc clean; infra 481 jest; 58 backend gateway + supply-chain tests. --- .github/workflows/platform.yml | 6 ++ .../src/agents/main_agent/config/constants.py | 22 ++++-- .../integrations/gateway_mcp_client.py | 10 ++- .../integrations/test_gateway_mcp_client.py | 23 +++++-- .../content/docs/deployment/environments.md | 68 +++++++++++-------- .../AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md | 54 ++++++++++++++- infrastructure/lib/config.ts | 43 ++++++++---- .../gateway/agentcore-gateway-construct.ts | 28 +++++--- infrastructure/test/helpers/mock-config.ts | 4 +- .../test/network-and-scripts.test.ts | 59 ++++++++++------ scripts/common/load-env.sh | 19 ++++++ 11 files changed, 245 insertions(+), 91 deletions(-) diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index c16325cbb..aac3140c5 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -71,6 +71,12 @@ jobs: # a different AWS account (records are then created manually from the # deploy's CfnOutputs). Defaults to true. CDK_MANAGE_DNS_RECORDS: ${{ vars.CDK_MANAGE_DNS_RECORDS }} + # AgentCore Gateway inbound authorizer: "iam" (default) or "jwt". + # The authorizer is IMMUTABLE after Gateway creation — the AgentCore + # control plane rejects a change on an existing Gateway. Leave unset (or + # "iam") for any environment whose Gateway already exists; "jwt" only + # applies to a newly created Gateway. + CDK_GATEWAY_INBOUND_AUTH: ${{ vars.CDK_GATEWAY_INBOUND_AUTH }} CDK_HOSTED_ZONE_DOMAIN: ${{ vars.CDK_HOSTED_ZONE_DOMAIN }} CDK_COGNITO_DOMAIN_PREFIX: ${{ vars.CDK_COGNITO_DOMAIN_PREFIX }} CDK_COGNITO_CALLBACK_URLS: ${{ vars.CDK_COGNITO_CALLBACK_URLS }} diff --git a/backend/src/agents/main_agent/config/constants.py b/backend/src/agents/main_agent/config/constants.py index 51f976126..333f07b00 100644 --- a/backend/src/agents/main_agent/config/constants.py +++ b/backend/src/agents/main_agent/config/constants.py @@ -55,11 +55,12 @@ class EnvVars: # --- Gateway --- GATEWAY_MCP_ENABLED = "AGENTCORE_GATEWAY_MCP_ENABLED" - # Gateway inbound authorizer mode: "jwt" (default) or "iam". - # MUST match the deployed Gateway's authorizerType — CDK sets this from - # `config.gateway.inboundAuth` so the two cannot drift. With "jwt" the agent - # sends the signed-in user's Cognito access token as a Bearer token; with - # "iam" it falls back to SigV4 signing. + # Gateway inbound authorizer mode: "iam" (default) or "jwt". + # MUST match the deployed Gateway's authorizerType. CDK sets this from + # `config.gateway.inboundAuth` so the two cannot drift; the default is + # deliberately the conservative one (see Defaults.GATEWAY_INBOUND_AUTH). + # With "iam" the agent SigV4-signs Gateway calls; with "jwt" it sends the + # signed-in user's Cognito access token as a Bearer token. GATEWAY_INBOUND_AUTH = "AGENTCORE_GATEWAY_INBOUND_AUTH" # --- MCP Apps (host renderer initiative) --- @@ -126,8 +127,15 @@ class Defaults: # --- Gateway --- GATEWAY_MCP_ENABLED = True - # Matches the CDK default (`config.gateway.inboundAuth`). - GATEWAY_INBOUND_AUTH = "jwt" + # Fail-safe default: SigV4. An absent env var must mean "behave like the + # Gateway that is actually deployed today", and AgentCore refuses to change + # a Gateway's authorizerType after creation ("Authorizer type cannot be + # updated for an existing gateway"), so every existing Gateway is AWS_IAM. + # Defaulting to 'jwt' made a partial deploy (infra rolled back, backend + # shipped) send bearer tokens to an IAM Gateway and 401 every tool call. + # CDK sets this explicitly from `config.gateway.inboundAuth`, so a JWT + # Gateway still gets 'jwt' — but only once the infra says so. + GATEWAY_INBOUND_AUTH = "iam" # --- MCP Apps (host renderer initiative) --- # Gates the entire MCP Apps host surface. Flipped on in PR #7 of diff --git a/backend/src/agents/main_agent/integrations/gateway_mcp_client.py b/backend/src/agents/main_agent/integrations/gateway_mcp_client.py index 909f82401..440aadac6 100644 --- a/backend/src/agents/main_agent/integrations/gateway_mcp_client.py +++ b/backend/src/agents/main_agent/integrations/gateway_mcp_client.py @@ -32,11 +32,15 @@ class GatewayAuthError(Exception): def _gateway_inbound_auth_mode() -> str: - """Return the Gateway's inbound auth mode: 'jwt' (default) or 'iam'. + """Return the Gateway's inbound auth mode: 'iam' (default) or 'jwt'. Set by CDK from `config.gateway.inboundAuth`, so the agent and the deployed - Gateway authorizer cannot drift. Unrecognized values fall back to 'jwt' - (the deployed default) with a warning. + Gateway authorizer cannot drift. The default is 'iam' on purpose: AgentCore + refuses to change a Gateway's authorizerType after creation, so an + already-deployed Gateway is AWS_IAM, and an absent env var must mean "match + what is deployed" rather than "assume the migration happened". + + Unrecognized values fall back to the same conservative default. """ mode = os.environ.get( EnvVars.GATEWAY_INBOUND_AUTH, Defaults.GATEWAY_INBOUND_AUTH diff --git a/backend/tests/agents/main_agent/integrations/test_gateway_mcp_client.py b/backend/tests/agents/main_agent/integrations/test_gateway_mcp_client.py index 08ef8b694..1914a339c 100644 --- a/backend/tests/agents/main_agent/integrations/test_gateway_mcp_client.py +++ b/backend/tests/agents/main_agent/integrations/test_gateway_mcp_client.py @@ -92,8 +92,19 @@ def test_returns_none_with_custom_prefix_no_match(self): class TestGatewayInboundAuthMode: """Auth-mode resolution from the CDK-managed env var.""" - def test_defaults_to_jwt(self, monkeypatch): + def test_defaults_to_iam(self, monkeypatch): + """Absent env var must mean SigV4 — the deployed reality. + + AgentCore refuses to change a Gateway's authorizerType after creation, + so any Gateway that already exists is AWS_IAM. Defaulting to 'jwt' made + a partial deploy (infra rolled back, backend shipped) send bearer + tokens to an IAM Gateway and 401 every tool call. + """ monkeypatch.delenv("AGENTCORE_GATEWAY_INBOUND_AUTH", raising=False) + assert _gateway_inbound_auth_mode() == "iam" + + def test_reads_jwt(self, monkeypatch): + monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "jwt") assert _gateway_inbound_auth_mode() == "jwt" def test_reads_iam(self, monkeypatch): @@ -101,13 +112,13 @@ def test_reads_iam(self, monkeypatch): assert _gateway_inbound_auth_mode() == "iam" def test_is_case_insensitive_and_trims(self, monkeypatch): - monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", " IAM ") - assert _gateway_inbound_auth_mode() == "iam" + monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", " JWT ") + assert _gateway_inbound_auth_mode() == "jwt" - def test_unknown_value_falls_back_to_jwt(self, monkeypatch): - """An unrecognized value must not silently disable user auth.""" + def test_unknown_value_falls_back_to_iam(self, monkeypatch): + """An unrecognized value must land on the conservative default.""" monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "banana") - assert _gateway_inbound_auth_mode() == "jwt" + assert _gateway_inbound_auth_mode() == "iam" class TestBuildGatewayAuth: diff --git a/docs-site/src/content/docs/deployment/environments.md b/docs-site/src/content/docs/deployment/environments.md index e9237423f..7307989bc 100644 --- a/docs-site/src/content/docs/deployment/environments.md +++ b/docs-site/src/content/docs/deployment/environments.md @@ -101,7 +101,7 @@ differ between a dev and a prod stack: | **Frame ancestors** | `CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS`, `CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS` — leave unset in production | | **Networking** | `CDK_VPC_CIDR` | | **Retention** | `CDK_RETAIN_DATA_ON_DELETE`, `CDK_ARTIFACTS_RETENTION_DAYS` | -| **Gateway inbound auth** | `CDK_GATEWAY_INBOUND_AUTH` (`jwt` default, or `iam` to keep SigV4) | +| **Gateway inbound auth** | `CDK_GATEWAY_INBOUND_AUTH` (`iam` default; `jwt` only on a newly created Gateway — see below) | :::caution[Extra frame ancestors are a real loosening] `CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS` and `CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS` @@ -112,36 +112,48 @@ that content — leave them unset on production. ## Gateway inbound authentication -The AgentCore Gateway accepts **one** inbound authorizer, and it defaults to -`jwt` — the Gateway validates a Cognito access token presented as a bearer -token, and the agent sends the signed-in user's token on every call. +The AgentCore Gateway accepts **one** inbound authorizer. It defaults to `iam` — +the Gateway is invoked with SigV4, which is what every already-deployed Gateway +uses. -That default is deliberate: on-behalf-of token exchange can only exchange a -*user* subject token, so an IAM-authorized Gateway has nothing to exchange. Set -`CDK_GATEWAY_INBOUND_AUTH=iam` to keep the older SigV4 posture. +Setting `CDK_GATEWAY_INBOUND_AUTH=jwt` makes the Gateway validate a Cognito +access token presented as a bearer token, and the agent then sends the signed-in +user's token on every call. That mode is required for on-behalf-of token +exchange, which can only exchange a *user* subject token. Outbound credentials are unaffected either way. They're configured **per target**, -so one Gateway still fronts IAM-invoked Lambda targets and OAuth/token-exchange -targets side by side. - -:::caution[Upgrading a fork: check for SigV4 Gateway callers first] -Switching to `jwt` means the Gateway **stops accepting SigV4**. In this -repository the agent is the only thing that calls the Gateway data plane, so the -migration is self-contained. If your fork added anything else that invokes the -Gateway with SigV4 — a Lambda, a scheduled job, another service — it will start -getting `401`s. - -Before upgrading, either move those callers onto a user bearer token or set -`CDK_GATEWAY_INBOUND_AUTH=iam` to stay on SigV4. - -Two things that are *not* affected: registered Gateway targets keep working -(their outbound credentials are per-target), and flipping this value never -replaces the Gateway — both `AuthorizerType` and `AuthorizerConfiguration` are -CloudFormation "no interruption" updates, so the Gateway keeps its ID, URL, and -targets. - -The change spans infrastructure and backend code, so **deploy both together**. -Whichever lands second leaves a short window where Gateway tool calls fail. +so one Gateway fronts IAM-invoked Lambda targets and OAuth/token-exchange targets +side by side. + +:::danger[The authorizer cannot be changed after the Gateway is created] +`jwt` only takes effect on a **newly created** Gateway. Switching an existing one +fails mid-deploy: + +``` +Authorizer type cannot be updated for an existing gateway +(Service: BedrockAgentCoreControl, Status Code: 400) +``` + +Neither pre-deploy check catches this. The CloudFormation resource reference +documents `AuthorizerType` as *"Update requires: No interruption"*, and `cdk diff` +— even using a real change set — reports an in-place `[~]` modify. Both describe +CloudFormation's plan, not the AgentCore service's validation. + +The stack rolls back cleanly and the Gateway is unharmed, but the deploy fails. +**Leave this unset (or `iam`) for any environment whose Gateway already exists.** +Moving an existing deployment to JWT means creating a new Gateway, re-registering +its targets, and cutting over. +::: + +:::caution[If you do run a JWT Gateway: check for SigV4 callers] +A `jwt` Gateway **stops accepting SigV4**. In this repository the agent is the +only thing that calls the Gateway data plane, so nothing else is affected. If +your fork added something else that invokes the Gateway with SigV4 — a Lambda, a +scheduled job, another service — it will get `401`s. Move those callers onto a +user bearer token first. + +The infrastructure and backend halves must **deploy together**: whichever lands +second leaves a window where Gateway tool calls fail. ::: ## Data retention on teardown diff --git a/docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md b/docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md index 37758fd1f..6e26dac8b 100644 --- a/docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md +++ b/docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md @@ -197,6 +197,56 @@ The only Gateway *data-plane* caller is the agent's `gateway_mcp_client.py`. `bedrock-agentcore-control` and is unaffected by inbound auth. `external_mcp_client.py` is the separate direct-forwarding path. +#### The authorizer is immutable — confirmed the hard way + +**An existing Gateway's `authorizerType` cannot be changed.** The first attempt +to deploy this migration failed mid-update: + +``` +Resource handler returned message: "Authorizer type cannot be updated for an +existing gateway (Service: BedrockAgentCoreControl, Status Code: 400)" +HandlerErrorCode: InvalidRequest +``` + +The stack rolled back cleanly (`UPDATE_ROLLBACK_COMPLETE`); the Gateway kept +`AWS_IAM`, `READY` status, and both registered targets. + +**Why pre-deploy checks did not catch it.** Two sources said the change was +safe, and both were describing CloudFormation's model rather than the service's +validation: + +| Signal | What it said | Why it was wrong | +|---|---|---| +| CFN resource reference | `AuthorizerType` → *"Update requires: No interruption"* | Describes CFN's update *mode*, not whether the service accepts the call | +| `cdk diff` (real change set) | `[~]` modify-in-place, no replacement | A change set predicts CFN's plan; it does not invoke service-side validation | + +Treat this as the general lesson for AgentCore resources: **a change set is not a +deploy test.** For a young service, verify a mutation against a throwaway +resource before trusting either signal. + +#### What this means for the migration + +`gateway.inboundAuth` effectively sets the authorizer **at Gateway creation +time**. Moving an existing deployment from `AWS_IAM` to `CUSTOM_JWT` requires +creating a **new Gateway** and cutting over: + +1. Create a second Gateway with `CUSTOM_JWT` (the existing one keeps serving). +2. Re-register every target on it. Targets are managed out-of-band — by app-api's + `GatewayTargetService` (admin-registered) and by the `mcp-servers` repo — so + this is not a CDK-only step and needs its own design. +3. Point the agent at the new Gateway (`/{prefix}/gateway/id`). +4. Verify, then delete the old Gateway. + +So the plan's original "option B" (a second Gateway) is not a preference — it is +the **only available mechanism**. The single-Gateway *endstate* still holds: you +end with one Gateway, because the old one is deleted after cutover. What changes +is that getting there is a create-and-cutover, not a config flip. + +Defaults were set accordingly: `gateway.inboundAuth` defaults to `iam` (matching +every deployed Gateway) and the agent's `AGENTCORE_GATEWAY_INBOUND_AUTH` defaults +to `iam` too, so a partial deploy cannot leave the agent presenting a credential +its Gateway rejects. + #### Critical authorizer-config gotcha **Cognito *access* tokens carry `client_id`, not `aud`.** The @@ -241,7 +291,7 @@ Required config values (both already published to SSM): - `/{prefix}/auth/cognito/user-pool-id` - `/{prefix}/auth/cognito/bff-app-client-id` -Treat an authorizer change as a migration with a rollback plan; verify whether CloudFormation updates the Gateway in place or replaces it. **Check this before deploying** — a Gateway replacement would issue a new Gateway ID/URL and orphan every registered target, which would need re-registration. +This applies to a **newly created** Gateway only. An existing Gateway's authorizer cannot be changed — see "The authorizer is immutable" in Phase 0. For an environment whose Gateway already exists, leave `inboundAuth` at `iam` and follow the create-and-cutover path instead. ### 1B. Agent Gateway client — bearer token @@ -751,7 +801,7 @@ The current admin UI stores per-tool approval flags but does not manage Cedar po ## Open decisions -1. ~~Migrate the existing IAM Gateway or add a second delegated JWT Gateway?~~ **Decided: one Gateway, migrate inbound to `CUSTOM_JWT` (option A).** Splitting by backend type would split along the wrong seam — backend differences are handled entirely by per-target *outbound* credentials. See "Gateway decision" in Phase 0. +1. ~~Migrate the existing IAM Gateway or add a second delegated JWT Gateway?~~ **Resolved by constraint: a second (new) Gateway, because option A is impossible.** The authorizer is immutable after creation, so an existing Gateway cannot be flipped — the first deploy attempt failed with "Authorizer type cannot be updated for an existing gateway". The single-Gateway *endstate* still holds (the old Gateway is deleted after cutover); the mechanism is create-and-cutover, not a config flip. See "The authorizer is immutable" in Phase 0. 2. Use OAuth discovery from token-service or explicit AgentCore authorization-server metadata? 3. Exact confidential-client authentication and secret-rotation process? 4. ~~Pilot MCP server and legacy audience?~~ **Decided: Campus Directory API.** Audience = the Directory application's registered token-service audience. See Phase 0 for rationale. diff --git a/infrastructure/lib/config.ts b/infrastructure/lib/config.ts index 1d74387fa..9ab0a4242 100644 --- a/infrastructure/lib/config.ts +++ b/infrastructure/lib/config.ts @@ -265,15 +265,31 @@ export interface McpIdentityConfig { * Gateway still serves both IAM-invoked Lambda targets and OAuth/token-exchange * targets. * - * Defaults to `jwt` (Cognito access tokens), which is required for outbound - * on-behalf-of token exchange: AgentCore can only exchange a *user* subject - * token, and an IAM-authorized Gateway has no user token to exchange. - * See docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md. + * ## The authorizer is immutable after creation * - * Set to `iam` to roll back to SigV4 inbound auth without a code change. Both - * `AuthorizerType` and `AuthorizerConfiguration` are CloudFormation - * "no interruption" updates, so flipping this does not replace the Gateway and - * does not orphan registered targets. + * Changing this value on an **existing** Gateway does not work. The AgentCore + * control plane rejects it: + * + * ``` + * Authorizer type cannot be updated for an existing gateway + * (Service: BedrockAgentCoreControl, Status Code: 400) + * ``` + * + * This is *not* visible from CloudFormation: the resource schema documents both + * `AuthorizerType` and `AuthorizerConfiguration` as "Update requires: No + * interruption", and `cdk diff` — even via a real change set — reports an + * in-place `[~]` modify. Both reflect CFN's model, not the service's validation. + * The failure only surfaces at deploy time, mid-update. + * + * So `inboundAuth` effectively sets the authorizer **at Gateway creation**. + * Moving an existing deployment from AWS_IAM to CUSTOM_JWT requires a *new* + * Gateway plus target re-registration and a cutover — not a config flip. See + * docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md. + * + * Defaults to `iam`, matching every Gateway already deployed. Do not change the + * default to `jwt`: it would make `PlatformStack` fail on every existing + * deployment (this repo's included) and — because the agent reads the same + * value — point the agent at an auth mode its Gateway does not accept. */ export interface GatewayConfig { inboundAuth: 'jwt' | 'iam'; @@ -507,14 +523,15 @@ export function loadConfig(scope: cdk.App): AppConfig { }, }, gateway: { - // Inbound authorizer selection. Defaults to 'jwt' (Cognito access - // tokens) — required for outbound OBO token exchange. Set to 'iam' to - // roll back to SigV4 without a code change; the authorizer swap is a - // CFN "no interruption" update either way, so registered targets survive. + // Inbound authorizer selection. Defaults to 'iam' — the authorizer is + // immutable after Gateway creation (the AgentCore control plane rejects + // an authorizerType change), so every already-deployed Gateway is + // AWS_IAM and the default must match that. 'jwt' applies to a *newly + // created* Gateway. See GatewayConfig. inboundAuth: (process.env.CDK_GATEWAY_INBOUND_AUTH as 'jwt' | 'iam' | undefined) || scope.node.tryGetContext('gateway')?.inboundAuth - || 'jwt', + || 'iam', }, tags: { ...(scope.node.tryGetContext('tags') || {}), diff --git a/infrastructure/lib/constructs/gateway/agentcore-gateway-construct.ts b/infrastructure/lib/constructs/gateway/agentcore-gateway-construct.ts index 5a2ec46ee..a5d5671a4 100644 --- a/infrastructure/lib/constructs/gateway/agentcore-gateway-construct.ts +++ b/infrastructure/lib/constructs/gateway/agentcore-gateway-construct.ts @@ -36,14 +36,18 @@ export interface AgentCoreGatewayConstructProps { * AgentCoreGatewayConstruct — AWS Bedrock AgentCore Gateway with MCP * protocol and configurable inbound authorization. * - * Inbound auth (`config.gateway.inboundAuth`, default `jwt`): + * Inbound auth (`config.gateway.inboundAuth`, default `iam`): + * - `iam` — AWS_IAM (SigV4). The default, and what every already-deployed + * Gateway uses, because the authorizer cannot be changed after creation. * - `jwt` — CUSTOM_JWT trusting the platform Cognito user pool. Required for * outbound on-behalf-of (OBO) token exchange, because AgentCore can only * exchange a *user* subject token and an IAM-authorized Gateway has none. * The agent calls the Gateway with the signed-in user's Cognito access - * token as a bearer token. - * - `iam` — the legacy AWS_IAM (SigV4) posture, retained as a code-free - * rollback path. + * token as a bearer token. Only takes effect on a *newly created* Gateway. + * + * The authorizer is immutable: changing it on an existing Gateway fails with + * "Authorizer type cannot be updated for an existing gateway" — see the inline + * WARNING below and docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md. * * AgentCore permits exactly one inbound authorizer per Gateway * (`authorizerType` is a scalar), but outbound credentials are *per target* — @@ -134,11 +138,17 @@ export class AgentCoreGatewayConstruct extends Construct { ); // Inbound authorizer. AgentCore takes exactly one type per Gateway, so this - // is an either/or — not additive. Both `AuthorizerType` and - // `AuthorizerConfiguration` are CloudFormation "no interruption" updates, - // so flipping between them updates the Gateway in place and does NOT - // replace it (a replacement would mint a new Gateway id/URL and orphan - // every registered target). + // is an either/or — not additive. + // + // WARNING: the authorizer is immutable after creation. The AgentCore + // control plane rejects a change on an existing Gateway with + // "Authorizer type cannot be updated for an existing gateway" (400), even + // though CloudFormation models both AuthorizerType and + // AuthorizerConfiguration as "no interruption" updates and `cdk diff` + // reports an in-place `[~]` modify. The CFN schema and the change set both + // describe CFN's intent, not the service's validation — the failure only + // appears at deploy time. Switching an existing deployment therefore needs + // a new Gateway + target re-registration, not a config flip. const useJwtInboundAuth = config.gateway.inboundAuth === 'jwt'; if (useJwtInboundAuth && (!props.userPool || !props.bffAppClient)) { diff --git a/infrastructure/test/helpers/mock-config.ts b/infrastructure/test/helpers/mock-config.ts index 7506da858..c850fdc9f 100644 --- a/infrastructure/test/helpers/mock-config.ts +++ b/infrastructure/test/helpers/mock-config.ts @@ -78,7 +78,9 @@ export function createMockConfig(overrides: Partial = {}): AppConfig }, }, gateway: { - inboundAuth: 'jwt', + // Mirror the production default. JWT tests opt in explicitly via + // createMockConfig({ gateway: { inboundAuth: 'jwt' } }). + inboundAuth: 'iam', }, cognito: { domainPrefix: MOCK_PREFIX, diff --git a/infrastructure/test/network-and-scripts.test.ts b/infrastructure/test/network-and-scripts.test.ts index 66cd91648..24a0f95b8 100644 --- a/infrastructure/test/network-and-scripts.test.ts +++ b/infrastructure/test/network-and-scripts.test.ts @@ -248,6 +248,9 @@ describe('McpSandboxBucketConstruct — detailed', () => { describe('AgentCoreGatewayConstruct — detailed', () => { let t: Template; + /** Template built with inboundAuth explicitly set to 'jwt'. */ + let jwtTemplate: Template; + beforeAll(() => { const stack = testStack(); new AgentCoreGatewayConstruct(stack, 'GW', { @@ -255,6 +258,13 @@ describe('AgentCoreGatewayConstruct — detailed', () => { ...mockCognitoRefs(stack), }); t = Template.fromStack(stack); + + const jwtStack = testStack(); + new AgentCoreGatewayConstruct(jwtStack, 'GW', { + config: createMockConfig({ gateway: { inboundAuth: 'jwt' } }), + ...mockCognitoRefs(jwtStack), + }); + jwtTemplate = Template.fromStack(jwtStack); }); it('gateway uses MCP protocol', () => { @@ -263,10 +273,22 @@ describe('AgentCoreGatewayConstruct — detailed', () => { }); }); - it('gateway uses CUSTOM_JWT authorizer by default', () => { - // Default inbound auth is JWT — required for outbound OBO token exchange, - // since AgentCore can only exchange a *user* subject token. + it('gateway uses AWS_IAM authorizer by default', () => { + // The authorizer is immutable after Gateway creation (AgentCore rejects an + // authorizerType change on an existing Gateway), so the default must match + // what is already deployed everywhere: AWS_IAM. Defaulting to CUSTOM_JWT + // would break PlatformStack on every existing deployment. t.hasResourceProperties('AWS::BedrockAgentCore::Gateway', { + AuthorizerType: 'AWS_IAM', + }); + const gw = Object.values( + t.findResources('AWS::BedrockAgentCore::Gateway'), + )[0] as any; + expect(gw.Properties.AuthorizerConfiguration).toBeUndefined(); + }); + + it('gateway uses CUSTOM_JWT when inboundAuth is jwt', () => { + jwtTemplate.hasResourceProperties('AWS::BedrockAgentCore::Gateway', { AuthorizerType: 'CUSTOM_JWT', }); }); @@ -274,7 +296,7 @@ describe('AgentCoreGatewayConstruct — detailed', () => { it('JWT authorizer validates client_id, not audience', () => { // Cognito *access* tokens carry `client_id` and no `aud` claim, so // AllowedAudience could never match and would 401 every call. - t.hasResourceProperties('AWS::BedrockAgentCore::Gateway', { + jwtTemplate.hasResourceProperties('AWS::BedrockAgentCore::Gateway', { AuthorizerConfiguration: { CustomJWTAuthorizer: { AllowedClients: [MOCK_BFF_CLIENT_ID], @@ -282,7 +304,7 @@ describe('AgentCoreGatewayConstruct — detailed', () => { }, }); const gw = Object.values( - t.findResources('AWS::BedrockAgentCore::Gateway'), + jwtTemplate.findResources('AWS::BedrockAgentCore::Gateway'), )[0] as any; expect( gw.Properties.AuthorizerConfiguration.CustomJWTAuthorizer.AllowedAudience, @@ -291,36 +313,29 @@ describe('AgentCoreGatewayConstruct — detailed', () => { it('JWT authorizer points at the Cognito OIDC discovery document', () => { const gw = Object.values( - t.findResources('AWS::BedrockAgentCore::Gateway'), + jwtTemplate.findResources('AWS::BedrockAgentCore::Gateway'), )[0] as any; expect( gw.Properties.AuthorizerConfiguration.CustomJWTAuthorizer.DiscoveryUrl, ).toContain('/.well-known/openid-configuration'); }); - it('gateway falls back to AWS_IAM when inboundAuth is iam (rollback path)', () => { - // Code-free rollback: flipping the flag restores SigV4 inbound auth. Both - // AuthorizerType and AuthorizerConfiguration are CFN "no interruption" - // updates, so this does not replace the Gateway or orphan its targets. + it('does not require Cognito refs in the default iam mode', () => { + // A fork that never opts into JWT must not be forced to wire Cognito. const stack = testStack(); - new AgentCoreGatewayConstruct(stack, 'GW', { - config: createMockConfig({ gateway: { inboundAuth: 'iam' } }), - }); - const iamTemplate = Template.fromStack(stack); - iamTemplate.hasResourceProperties('AWS::BedrockAgentCore::Gateway', { - AuthorizerType: 'AWS_IAM', - }); - const gw = Object.values( - iamTemplate.findResources('AWS::BedrockAgentCore::Gateway'), - )[0] as any; - expect(gw.Properties.AuthorizerConfiguration).toBeUndefined(); + expect( + () => new AgentCoreGatewayConstruct(stack, 'GW', { config }), + ).not.toThrow(); }); it('throws when JWT auth is selected without Cognito refs', () => { // Fail fast at synth rather than deploying a Gateway that 401s everything. const stack = testStack(); expect( - () => new AgentCoreGatewayConstruct(stack, 'GW', { config }), + () => + new AgentCoreGatewayConstruct(stack, 'GW', { + config: createMockConfig({ gateway: { inboundAuth: 'jwt' } }), + }), ).toThrow(/inboundAuth is 'jwt' but/); }); diff --git a/scripts/common/load-env.sh b/scripts/common/load-env.sh index db6efb65d..d95980ebf 100644 --- a/scripts/common/load-env.sh +++ b/scripts/common/load-env.sh @@ -145,6 +145,10 @@ build_cdk_context_params() { if [ -n "${CDK_DOMAIN_NAME:-}" ]; then context_params="${context_params} --context domainName=\"${CDK_DOMAIN_NAME}\"" fi + # AgentCore Gateway inbound authorizer ('iam' | 'jwt') + if [ -n "${CDK_GATEWAY_INBOUND_AUTH:-}" ]; then + context_params="${context_params} --context gateway.inboundAuth=\"${CDK_GATEWAY_INBOUND_AUTH}\"" + fi if [ -n "${CDK_FRONTEND_CERTIFICATE_ARN:-}" ]; then context_params="${context_params} --context frontend.certificateArn=\"${CDK_FRONTEND_CERTIFICATE_ARN}\"" fi @@ -233,6 +237,13 @@ export CDK_CERTIFICATE_ARN="${CDK_CERTIFICATE_ARN:-$(get_json_value "certificate export CDK_RETAIN_DATA_ON_DELETE="${CDK_RETAIN_DATA_ON_DELETE:-$(get_json_value "retainDataOnDelete" "${CONTEXT_FILE}")}" export CDK_MANAGE_DNS_RECORDS="${CDK_MANAGE_DNS_RECORDS:-$(get_json_value "manageDnsRecords" "${CONTEXT_FILE}")}" +# AgentCore Gateway inbound authorizer: "iam" (default) or "jwt". +# Empty is safe — config.ts falls back to 'iam'. NOTE: the authorizer is +# immutable after Gateway creation, so setting this to 'jwt' against an +# existing AWS_IAM Gateway makes the deploy fail. It applies to a newly +# created Gateway. +export CDK_GATEWAY_INBOUND_AUTH="${CDK_GATEWAY_INBOUND_AUTH:-$(get_json_value "gateway.inboundAuth" "${CONTEXT_FILE}")}" + # Shared CORS origins — env var > context file (no hardcoded defaults) export CDK_CORS_ORIGINS="${CDK_CORS_ORIGINS:-$(get_json_value "corsOrigins" "${CONTEXT_FILE}")}" @@ -291,6 +302,14 @@ validate_config() { log_error " Expected 'true', 'false', '1', or '0'" errors=$((errors + 1)) fi + + # Validate the Gateway inbound authorizer selection. A typo would otherwise + # fall through to the 'iam' default and silently not apply. + if [ -n "${CDK_GATEWAY_INBOUND_AUTH:-}" ] && ! [[ "${CDK_GATEWAY_INBOUND_AUTH}" =~ ^(iam|jwt)$ ]]; then + log_error "Invalid CDK_GATEWAY_INBOUND_AUTH value: '${CDK_GATEWAY_INBOUND_AUTH}'" + log_error " Expected 'iam' or 'jwt'" + errors=$((errors + 1)) + fi if [ $errors -gt 0 ]; then log_error "Configuration validation failed with ${errors} error(s)"