Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
22 changes: 15 additions & 7 deletions backend/src/agents/main_agent/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions backend/src/agents/main_agent/integrations/gateway_mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,22 +92,33 @@ 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):
monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "iam")
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:
Expand Down
68 changes: 40 additions & 28 deletions docs-site/src/content/docs/deployment/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down
54 changes: 52 additions & 2 deletions docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
43 changes: 30 additions & 13 deletions infrastructure/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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') || {}),
Expand Down
Loading