Skip to content

Gateway inbound auth: migrate to Cognito JWT (token-exchange Phase 1) - #778

Merged
DerrickF merged 3 commits into
developfrom
feature/gateway-jwt-inbound-auth
Jul 28, 2026
Merged

Gateway inbound auth: migrate to Cognito JWT (token-exchange Phase 1)#778
DerrickF merged 3 commits into
developfrom
feature/gateway-jwt-inbound-auth

Conversation

@DerrickF

Copy link
Copy Markdown
Contributor

Phase 1 of the AgentCore Gateway token-exchange work (plan). Moves the Gateway's inbound authorizer from IAM/SigV4 to Cognito JWT, and switches the agent to presenting the signed-in user's access token.

This is the prerequisite for on-behalf-of (OBO) token exchange: AgentCore can only exchange a user subject token, and an IAM-authorized Gateway never carried one.

Why one Gateway, not two

AgentCore permits exactly one inbound authorizer per Gateway (authorizerType is a scalar — no "accept either SigV4 or JWT" mode), but outbound credentials are per-target. So a single Gateway still fronts IAM-invoked Lambda targets (arxiv, policy-search) and future OAuth/token-exchange targets for legacy token-service APIs. Splitting Gateways by backend type would split along the wrong seam.

Changes

Infrastructure (d56588bd)

  • config.gateway.inboundAuth'jwt' (default) or 'iam', with env/context override and synth-time enum validation
  • Gateway authorizer becomes CUSTOM_JWT against the platform Cognito pool
  • Cognito refs passed as construct refs (not SSM — same-stack reads are unsatisfiable on first deploy)
  • AGENTCORE_GATEWAY_INBOUND_AUTH threaded to the inference runtime

Agent (9c3c11f2)

  • Gateway calls carry Authorization: Bearer <cognito-access-token> instead of SigV4
  • Reuses the existing OAuthBearerAuth; tokens bound per client instance
  • Missing token → GatewayAuthError, caught to degrade to no-Gateway-tools rather than failing the turn

Two details worth reviewer attention

  1. allowedClients, not allowedAudience. Cognito access tokens carry client_id and no aud claim, so an audience check can never match and would 401 every call. There is a test asserting AllowedAudience stays unset.

  2. No Gateway replacement. Both AuthorizerType and AuthorizerConfiguration are CloudFormation "no interruption" updates. Confirmed against the live dev stack with a real change set — the Gateway shows as [~] modify-in-place, so the physical ID and both registered targets survive:

    [~] AWS::BedrockAgentCore::Gateway AgentCoreGateway/MCPGateway
     ├─ [+] AuthorizerConfiguration
     ├─ [~] AuthorizerType
     │   ├─ [-] AWS_IAM
     │   └─ [+] CUSTOM_JWT
    

Deploy note

The two commits must deploy together. Once the authorizer flips, SigV4 is rejected; until it flips, a bearer token is. This touches both infrastructure/** and backend/**, so merging fires platform.yml and backend.yml. They share the deploy-<ref> concurrency group so they queue, but order is nondeterministic — expect a window where Gateway tools 401 until both finish. Self-healing.

Rollback without a code change: set CDK_GATEWAY_INBOUND_AUTH=iam and redeploy.

Verification

  • Infra: tsc clean, 480/480 jest (+5 new)
  • Backend: 5419 passed, 0 failed (+11 new)
  • cdk diff against live dev confirms in-place Gateway update

Non-user callers verified safe

Caller Has a Cognito access token?
Browser / BFF Yes, by construction
Scheduled / headless runs Yes — CognitoRefreshBearerAuth mints one from the stored grant, and run_agent_headless already requires it today
API key (/chat/api-converse) N/A — calls Bedrock Converse directly, never touches the Gateway

Not in this PR

Phases 2–4 (token-service /v2/oauth/token RFC 8693 endpoint, AgentCore OBO connector provisioning, and the Directory pilot target) land separately. The Directory MCP server itself lives in the mcp-servers repo.

DerrickF added 3 commits July 28, 2026 14:51
AgentCore outbound on-behalf-of token exchange can only exchange a *user*
subject token, and the Gateway's IAM/SigV4 inbound auth never carried one.
Switch the Gateway's inbound authorizer to CUSTOM_JWT trusting the platform
Cognito user pool, so the agent's per-user access token reaches the Gateway
and becomes exchangeable for a target-specific token downstream.

One Gateway remains the endstate. AgentCore permits exactly one inbound
authorizer per Gateway (`authorizerType` is a scalar), but outbound
credentials are per-target — so this single Gateway still fronts both
IAM-invoked Lambda targets (arxiv, policy-search) and future
OAuth/token-exchange targets. Splitting Gateways by backend type would
split along the wrong seam.

- `config.gateway.inboundAuth` ('jwt' default | 'iam') with env/context
  override and synth-time enum validation. 'iam' is a code-free rollback:
  both AuthorizerType and AuthorizerConfiguration are CloudFormation
  "no interruption" updates, so flipping does not replace the Gateway or
  orphan its registered targets (confirmed via cdk diff change set).
- Cognito user pool + BFF app client passed as construct refs, not SSM:
  the pool is a sibling in this stack and CFN resolves SSM template
  parameters before any stack resource exists.
- Authorizer validates `allowedClients`, NOT `allowedAudience` — Cognito
  *access* tokens carry `client_id` and no `aud` claim, so an audience
  check can never match and would 401 every call.
- Construct throws at synth when 'jwt' is selected without Cognito refs,
  rather than deploying a Gateway that rejects everything.
- AGENTCORE_GATEWAY_INBOUND_AUTH threaded to the inference runtime from the
  same config value, so agent data-plane auth and the deployed authorizer
  cannot drift.

Tests: 5 new Gateway construct tests (JWT default, client_id-not-audience,
discovery URL, iam rollback, throw-without-refs). New `mockCognitoRefs`
helper uses `from*` imports so it adds zero resources and existing
resourceCountIs assertions stay meaningful. 480/480 jest, tsc clean.

Refs docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md (Phase 1A)
Pairs with the CUSTOM_JWT inbound authorizer: the agent now presents the
signed-in user's Cognito access token as a Bearer credential instead of
SigV4-signing Gateway calls with the task's IAM identity. `self.auth_token`
already held that token, so this threads it through rather than adding a
new credential source.

Both halves must deploy together — once the authorizer flips, SigV4 is
rejected, and until it flips a bearer token is.

- `_build_gateway_auth()` selects bearer vs SigV4 from
  AGENTCORE_GATEWAY_INBOUND_AUTH, which CDK sets from the same
  `config.gateway.inboundAuth` that builds the authorizer — so the two
  cannot drift. Unrecognized values fall back to 'jwt' with a warning
  rather than silently dropping user auth.
- Reuses the existing OAuthBearerAuth from integrations/oauth_auth.py
  instead of adding a second bearer implementation.
- Tokens are bound per client instance, never module-level, so one user's
  credential cannot leak into another user's Gateway client.
- Missing token in jwt mode raises GatewayAuthError; GatewayIntegration
  catches it and degrades to no Gateway tools rather than failing the turn
  (every call would 401 anyway). Error text points at the headless-grant
  path, the likely cause for a scheduled run.

Verified no non-user caller loses access: scheduled/headless runs already
mint a real Cognito access token via CognitoRefreshBearerAuth (and
run_agent_headless requires it to start today), and the API-key path calls
Bedrock Converse directly without touching the Gateway.

Tests: 11 new — auth-mode resolution, the actual Authorization header
value, missing/empty token rejection, iam rollback ignoring the token,
cross-user token isolation, and graceful degradation. Full backend suite
5419 passed.

Refs docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md (Phase 1B)
The Gateway inbound-auth default flips to `jwt`, which means the Gateway
stops accepting SigV4. In this repo the agent is the only data-plane caller,
so the migration is self-contained — but a fork that added its own Lambda,
scheduled job, or service calling the Gateway with SigV4 would start getting
401s with nothing in the code to warn them.

Documents `CDK_GATEWAY_INBOUND_AUTH` in the per-environment overrides table
and adds a "Gateway inbound authentication" section covering the upgrade
check, the `iam` escape hatch, and the two things that are *not* affected
(registered targets keep working; the authorizer swap never replaces the
Gateway). Also notes the infra+backend deploy-together requirement.

Docs-only. Astro build clean (51 pages).
@DerrickF
DerrickF merged commit b0c68b9 into develop Jul 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant