From d56588bd6a4d09db6f02793af50fa28cd62f44a3 Mon Sep 17 00:00:00 2001 From: derrickfink Date: Tue, 28 Jul 2026 14:51:57 -0600 Subject: [PATCH 1/3] feat(gateway): migrate inbound auth to Cognito JWT (CUSTOM_JWT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md | 763 ++++++++++++++++++ infrastructure/lib/config.ts | 44 + .../gateway/agentcore-gateway-construct.ts | 108 ++- .../inference-agentcore-construct.ts | 6 + infrastructure/lib/platform-stack.ts | 8 +- infrastructure/test/constructs.test.ts | 11 +- infrastructure/test/helpers/mock-cognito.ts | 29 + infrastructure/test/helpers/mock-config.ts | 3 + .../test/network-and-scripts.test.ts | 63 +- 9 files changed, 1023 insertions(+), 12 deletions(-) create mode 100644 docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md create mode 100644 infrastructure/test/helpers/mock-cognito.ts diff --git a/docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md b/docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md new file mode 100644 index 000000000..37758fd1f --- /dev/null +++ b/docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md @@ -0,0 +1,763 @@ +# AgentCore Gateway Token Exchange — Implementation Plan + +**Status:** Proposed +**Audience:** Boise State auth owners, token-service maintainers, AgentCore platform maintainers, MCP server owners +**Goal:** Let BoiseState.ai call existing campus MCP servers and APIs as the signed-in user without teaching every service to validate Cognito tokens or changing legacy APIs that already trust token-service JWTs. + +## Decision summary + +Add a new, isolated OAuth token-exchange flow to token-service and use Amazon Bedrock AgentCore Gateway to obtain a short-lived, target-specific token-service JWT when an agent calls a campus MCP tool. + +The first implementation will: + +1. Keep the existing token-service login and refresh routes unchanged. +2. Add a feature-flagged RFC 8693 endpoint such as `POST /v2/oauth/token`. +3. Accept the user's enriched Cognito **access token** as the subject token. +4. Authenticate AgentCore/BoiseState.ai as an approved confidential client. +5. Issue the same token-service JWT format that existing campus APIs already accept. +6. Register token-service as an AgentCore Identity custom OAuth provider with OBO enabled. +7. Register campus MCP servers through the existing `mcp` Gateway plugin type using OAuth + Token Exchange. +8. Keep current application-role filtering for initial tool access. Add finer Gateway Policy controls in a later phase. + +**OBO** means **On Behalf Of**: BoiseState.ai proves both which trusted application is calling and which authenticated employee it is acting for. + +## Why this approach + +Today, different parts of the environment trust different credentials: + +- BoiseState.ai uses Cognito access tokens behind a proper BFF session. +- Existing campus applications and APIs trust token-service JWTs. +- AWS-hosted tools commonly use IAM/SigV4. +- Third-party connectors use AgentCore Identity's OAuth token vault. + +Without a central exchange, each MCP server must learn Cognito validation or each legacy API must be changed. Token exchange moves that translation into one controlled place. + +The result is: + +- The browser never receives or handles the downstream token. +- MCP servers do not need to validate Cognito. +- Legacy APIs continue receiving the token format they already trust. +- Token-service remains the source of application-specific roles and audiences. +- New campus integrations follow one repeatable registration process. + +## Related existing design + +[`MCP_USER_IDENTITY_FORWARDING_SPEC.md`](./MCP_USER_IDENTITY_FORWARDING_SPEC.md) documents direct forwarding of an enriched Cognito access token to a controlled `mcp_external` server. That remains useful when a server is designed to trust Cognito directly. + +This plan covers a different case: a server or legacy API that already trusts token-service and should sit behind AgentCore Gateway. It does not replace the direct-forwarding path. + +## Current platform state + +### Already implemented + +- The SPA uses an httpOnly BFF session cookie; Cognito access, refresh, and ID tokens remain server-side in `BFFSessionsTable`. +- The BFF refreshes the Cognito access token and forwards it to inference-api. +- A Cognito Pre-Token-Generation v2 hook enriches the access token with the stable Boise State employee identifier. +- Inference-api has the current Cognito access token as `current_user.raw_token`. +- The MCP Gateway plugin model already supports: + - `credential_type = oauth` + - a credential-provider ARN + - OAuth scopes + - `grant_type = token_exchange` +- `GatewayTargetService` already maps that configuration to AgentCore's `OAUTH` + `TOKEN_EXCHANGE` target configuration. +- AgentCore Identity connector provisioning already handles custom OAuth providers, client credentials, and discovery metadata. + +### Missing today + +- token-service does not accept a Cognito access token. Its existing handler accepts an Entra authorization code or refresh token and then issues a token-service JWT. +- `AgentCoreRegistrar` does not provision `onBehalfOfTokenExchangeConfig` on a credential provider. +- The centralized Gateway uses `AWS_IAM` inbound authorization. +- The agent's Gateway MCP client signs requests with SigV4. +- Because the current Gateway receives IAM identity rather than a user JWT, it has no user subject token to exchange and no user claims for Cedar policy evaluation. +- There is no admin policy-management layer for AgentCore Gateway Policy. + +The existing “Token Exchange” target option is therefore necessary but not sufficient by itself. + +## Proposed request flow + +```text +Employee + | + | Entra sign-in federated through Cognito + v +BoiseState.ai BFF + | Browser holds only __Host-bff_session + | Server holds and refreshes Cognito tokens + v +Inference API / Agent + | Cognito access token contains stable employee/sample ID + | Calls Gateway with that user access token + v +AgentCore Gateway + | 1. Validates the inbound Cognito JWT + | 2. Identifies the selected MCP tool/target + | 3. Sends an RFC 8693 OBO request to token-service + v +Token-service POST /v2/oauth/token + | 1. Authenticates the AgentCore client + | 2. Validates the Cognito subject token + | 3. Reads the stable employee/sample ID + | 4. Resolves target application roles + | 5. Issues a short-lived token-service JWT for that target + v +AgentCore Gateway + | Adds Authorization: Bearer + v +Campus MCP server / legacy API + | Validates the token it already understands + | Performs final business authorization +``` + +## Trust and responsibility boundaries + +| Component | Responsibility | +|---|---| +| Cognito | Authenticate the user and issue the enriched access token used as proof of identity. | +| BoiseState.ai BFF | Keep Cognito tokens server-side, refresh them, and provide the current access token to the agent request. | +| AgentCore Gateway | Validate the inbound user token, select the MCP target, obtain the outbound target token, and enforce future tool policies. | +| token-service | Validate subject and client identity, resolve application roles, and issue audience-specific token-service JWTs. | +| MCP server | Validate the token-service JWT and avoid accepting unauthenticated direct calls. | +| Legacy API | Remain the final authority for business operations such as approving directory entry changes. | + +Authentication and authorization remain separate: + +- Token exchange proves **who is acting and for which target**. +- Tool/API authorization decides **what that person may do**. + +## Phase 0 — Confirm contracts and choose the Gateway migration strategy + +Before implementation, document these values: + +- Cognito issuer URL and JWKS URL. +- BFF Cognito app client ID expected in access tokens. +- Exact enriched employee-ID claim name. +- token-service issuer, signing algorithm, and legacy audience naming convention. +- Pilot MCP target and corresponding token-service application ID/audience. +- AgentCore confidential client ID and secret ownership/rotation process. +- Pilot scopes and maximum token lifetime. + +### Pilot target: Campus Directory API + +The first integration uses the Campus Directory API — a .NET Core Lambda-backed service fronting a DynamoDB table of employee contact information. It already validates token-service JWTs via RSA public key and requires no code changes to accept an exchanged token. + +| Contract Item | Directory API Value | +|---|---| +| Pilot MCP target | Campus Directory API (`/directory/*` endpoints) | +| token-service audience | The registered Directory application audience in token-service | +| Pilot scopes | Default (Directory uses role claims, not OAuth scopes) | +| Maximum token lifetime | 5–10 minutes | +| First pilot endpoint | `GET /directory/me` — proves employee identity flows correctly | +| Role-gated test endpoint | `POST /directory/pending` — requires IamStaff or DotNetDevelopers role | +| Auth mechanism | token-service JWT validated with RSA public key (no JWKS endpoint — keys are configured directly) | +| Dev base URL | `https://directory-api.dev.boisestate.edu` (confirmed) | + +**Why Directory is the right first target:** + +- Already trusts token-service JWTs — zero code changes on the target side. +- No VPC dependency — Lambda + DynamoDB, publicly reachable via API Gateway. +- Read-heavy and low-risk — worst case is a directory entry update, not corrupting business-critical state. +- Tiered authorization — anonymous search, authenticated self-lookup, and role-gated admin endpoints provide three levels of proof. +- Simple data model — employee contact info (name, email, phone, department, building/room, title). + +### Gateway decision — one Gateway, migrate inbound to JWT (DECIDED) + +**Decision: keep a single centralized Gateway and migrate its inbound authorizer from `AWS_IAM` to `CUSTOM_JWT` (Cognito).** Do not split Gateways by backend type. + +#### Why one Gateway is the right endstate + +The two halves of a Gateway behave differently, and only one of them is a single global setting: + +| | Scope | Implication | +|---|---|---| +| **Outbound** (per-target credentials) | Per target | One Gateway can mix `GATEWAY_IAM_ROLE` (Wikipedia/ArXiv Lambdas), OAuth token-exchange (token-service .NET APIs), and OAuth token-vault (Google/Canvas) simultaneously. | +| **Inbound** (`authorizerType`) | **One per Gateway** | A single scalar — `CUSTOM_JWT`, `AWS_IAM`, `NONE`, or `AUTHENTICATE_ONLY`. There is no "accept either SigV4 or JWT" mode. | + +The Gateway is the front desk; each target is a door with its own key. Backend +differences (MCP-native vs. legacy token-service API) are entirely an *outbound* +concern, so splitting Gateways along that seam would solve nothing while +doubling the catalog, target-service, SSM, and agent-client surface. + +A second Gateway is justified only if some caller genuinely cannot present a +user JWT at the front desk. On this platform, none can't — see below. + +#### Why the migration is safe here + +Because Cognito is the platform's single token authority, every inbound path +already collapses to a Cognito access token regardless of how the user +originally authenticated. Verified against the code: + +| Caller | Has a Cognito access token? | Notes | +|---|---|---| +| Browser / BFF session | Yes | By construction — the BFF holds and refreshes it server-side. | +| Scheduled & headless runs | Yes | `CognitoRefreshBearerAuth.mint_bearer_for_user()` (`apis/shared/harness/auth.py`) mints one from the user's stored headless grant. `run_agent_headless` requires it to start, so such a run cannot execute without a token *today* either — no regression. | +| API-key (`/chat/api-converse`) | N/A | Calls Bedrock Converse directly (`chat/converse_routes.py`); never touches the Gateway or tools. Out of scope. | + +The only Gateway *data-plane* caller is the agent's `gateway_mcp_client.py`. +`gateway_identity.py` is control-plane target management via +`bedrock-agentcore-control` and is unaffected by inbound auth. +`external_mcp_client.py` is the separate direct-forwarding path. + +#### Critical authorizer-config gotcha + +**Cognito *access* tokens carry `client_id`, not `aud`.** The +`customJWTAuthorizer` must therefore validate with **`allowedClients`** (the BFF +app client ID) — **not** `allowedAudience`. Setting `allowedAudience` against a +Cognito access token makes every Gateway call fail authorization. (The +`travel-auth` MCP server documents this same trap for its PyJWT check, where +`JWT_AUDIENCE` is deliberately left unset.) + +## Phase 1 — Gateway spike and anonymous Directory tool + +**Goal:** Prove the Gateway JWT-auth path works end-to-end and deploy a working `directory_search` tool *before* investing in token-service changes. This front-loads the riskiest unknowns (Gateway authorizer behavior, agent bearer-token wiring, MCP target connectivity) and gives you a working tool to show for it. + +### 1A. Gateway inbound JWT authentication + +Migrate the existing centralized Gateway's inbound authorizer from `AWS_IAM` to `CUSTOM_JWT` so it accepts Cognito access tokens instead of SigV4 (decided in Phase 0). + +Update: + +- `infrastructure/lib/constructs/gateway/agentcore-gateway-construct.ts` +- Infrastructure configuration and deployment inputs +- Gateway construct tests + +Configure a custom JWT authorizer that trusts the Cognito user pool. Update outputs and documentation that currently state the Gateway requires SigV4. + +Target shape: + +```ts +authorizerType: 'CUSTOM_JWT', +authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl: `https://cognito-idp.${region}.amazonaws.com/${userPoolId}/.well-known/openid-configuration`, + // MUST be allowedClients, NOT allowedAudience — Cognito access tokens + // carry `client_id`, not `aud`. See the Phase 0 gotcha. + allowedClients: [bffAppClientId], + }, +}, +``` + +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. + +### 1B. Agent Gateway client — bearer token + +Update: + +- `backend/src/agents/main_agent/integrations/gateway_mcp_client.py` +- `backend/src/agents/main_agent/tools/gateway_integration.py` +- The agent construction path that already has `current_user.raw_token` +- Gateway integration tests + +Replace SigV4 data-plane authentication for the JWT Gateway with bearer authentication using the current user's enriched Cognito access token. + +Requirements: + +- The token must be supplied per user and per agent invocation. +- Never cache one user's bearer token in a global/shared Gateway client. +- Keep Gateway clients scoped to the agent lifecycle. +- Preserve existing tool filtering and MCP Apps behavior. +- Return a clear authentication error when no user token is available. + +Because there is a single Gateway (Phase 0 decision), SigV4 data-plane signing for the Gateway is removed rather than kept alongside a second client. Keep `gateway_identity.py` untouched — it is control-plane (`bedrock-agentcore-control`) target management and still uses IAM. + +Note the behavioral change: today the agent signs Gateway calls with a machine identity, so any code path could reach Gateway tools. After the flip, a Gateway call requires a real user access token. Verified safe — scheduled/headless runs already mint one via `CognitoRefreshBearerAuth`, and the API-key path never touches the Gateway (see Phase 0). + +### 1C. Deploy the anonymous `directory_search` tool + +Register a Directory MCP target that uses **no outbound credential** (or Gateway IAM role only) and exposes only the anonymous `directory_search` endpoint: + +```text +Protocol: MCP Gateway (AgentCore) +Target name: campus-directory +Endpoint URL: +Listing mode: Default +Outbound credential: GATEWAY_IAM_ROLE (no user token needed for anonymous endpoint) +``` + +Tool schema for this phase (single tool): + +```json +[ + { + "name": "directory_search", + "description": "Search the Boise State employee directory by name, email, department, or title. Returns public contact information.", + "inputSchema": { + "type": "object", + "required": ["search_terms"], + "properties": { + "search_terms": { + "type": "string", + "description": "Space-separated search terms (name, email, department, title)" + }, + "page_size": { + "type": "integer", + "description": "Results per page (default 25, max 100)", + "default": 25 + }, + "page": { + "type": "integer", + "description": "Page number (1-based)", + "default": 1 + } + } + } + } +] +``` + +#### MCP Lambda wrapper + +Build a simple Lambda that receives the MCP tool invocation from Gateway, translates it to an HTTP request against the Directory API's `GET /directory/search?searchTerms=...` endpoint, and returns the response as MCP tool output. This follows the same pattern as existing Gateway Lambda tools (Wikipedia, ArXiv, etc.). + +Because `directory_search` is `[AllowAnonymous]` on the Directory API, no Authorization header is needed. The wrapper is trivial. + +### 1D. Observe the OBO request format (optional but recommended) + +If Gateway is configured with a dummy OAuth OBO provider (pointing at a request-logging endpoint like a RequestBin or a Lambda that logs and returns 400), you can observe exactly what AgentCore sends as the token-exchange request. This tells you: + +- The exact `grant_type` value AgentCore uses. +- Whether it sends `subject_token_type` and `audience`. +- How it formats `client_id`/`client_secret` (Basic header vs. POST body). +- Any AgentCore-specific parameters you didn't expect. + +This intel directly informs the token-service v2 endpoint implementation in Phase 2. + +### Phase 1 acceptance criteria + +- A signed-in user's agent can invoke `directory_search` through the JWT-authenticated Gateway. +- The Gateway accepts the Cognito access token as a bearer token. +- `directory_search` returns employee contact results. +- Existing Gateway tools (Wikipedia, ArXiv, and every other registered target) still work after the inbound-auth migration — their outbound credentials are unchanged. +- The agent correctly passes per-user bearer tokens (no cross-user leakage). +- (Optional) The OBO request format has been captured and documented for Phase 2. + +**At this point you have a working Directory search tool in the agent, the Gateway JWT path is proven, and you know exactly what token-service needs to accept.** + +## Phase 2 — Add the isolated token-service v2 exchange endpoint + +Implement a new feature module and leave the existing Entra authorization-code and refresh handlers unchanged. + +Suggested structure: + +```text +Features/ + TokenExchangeV2/ + ExchangeToken.cs + CognitoTokenValidator.cs + ClientAuthenticator.cs + ApplicationTokenIssuer.cs + Models.cs + TokenExchangeV2Endpoints.cs +``` + +Suggested endpoint: + +```http +POST /v2/oauth/token +Content-Type: application/x-www-form-urlencoded +Authorization: Basic + +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +subject_token= +subject_token_type=urn:ietf:params:oauth:token-type:access_token +audience= +scope= +``` + +Expected response: + +```json +{ + "access_token": "", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 600 +} +``` + +### Validation requirements + +The endpoint must: + +- Be disabled by default behind a feature flag. +- Authenticate AgentCore/BoiseState.ai as a confidential client. +- Validate the Cognito token signature using Cognito JWKS. +- Require the exact Cognito issuer. +- Require the expected BFF app client. +- Require `token_use = access`. +- Validate expiration and not-before values. +- Require the configured employee-ID claim and validate its format. +- Permit only allowlisted target audiences for the calling client. +- Reject unsupported grant types and token types. +- Never log subject tokens, client secrets, or issued tokens. + +### Issued token requirements + +The new path should preserve the existing token-service contract: + +- Same token-service issuer. +- Same audience format expected by the selected legacy application. +- Same RSA signing behavior and KMS-protected key source. +- Same application-role claim format. +- Same stable user identifier expected by current consumers. +- Lifetime no longer than the incoming Cognito token, with a short configured maximum such as 5–10 minutes. + +For the pilot, duplicating the existing application lookup, role lookup, and signing sequence is acceptable to avoid refactoring the production login path. Mark the duplication explicitly and add contract tests. Consolidate only after the pilot succeeds. + +### Operational controls + +- Client-to-audience allowlist. +- Rate limiting. +- Dedicated metrics and structured logs. +- Independent kill switch. +- No external network calls during token-service startup. +- Dev deployment before production. +- Fast rollback by disabling the v2 feature. + +## Phase 3 — Add OBO support to AgentCore connector provisioning + +Extend the existing custom OAuth connector model rather than creating a new tool or plugin protocol. + +Primary code areas: + +- `backend/src/apis/shared/oauth/models.py` +- `backend/src/apis/shared/oauth/agentcore_registrar.py` +- `backend/src/apis/app_api/admin/oauth/routes.py` +- Admin connector form/models under `frontend/ai.client/src/app/admin/connectors/` +- Existing OAuth registrar and route tests + +Add connector configuration for: + +- OAuth mode: user federation, client credentials, or OBO token exchange. +- OBO grant type: initially `TOKEN_EXCHANGE`. +- Client authentication method: initially client-secret basic. +- Optional actor-token settings only if token-service later requires them. +- OAuth discovery URL or explicit authorization-server metadata. + +For token-service, the AgentCore provider input should include the equivalent of: + +```json +{ + "customOauth2ProviderConfig": { + "oauthDiscovery": { + "authorizationServerMetadata": { + "token_endpoint": "https://token-service.example.edu/v2/oauth/token" + } + }, + "clientId": "boisestate-ai-agentcore", + "clientSecret": "", + "clientAuthenticationMethod": "CLIENT_SECRET_BASIC", + "onBehalfOfTokenExchangeConfig": { + "grantType": "TOKEN_EXCHANGE" + } + } +} +``` + +Use the exact AWS SDK shape supported by the repository's pinned boto3 version. Create and update must both send the full OBO configuration because AgentCore credential-provider updates are full replacements. + +### Phase 3 acceptance criteria + +- Admin can create a custom OBO connector for token-service. +- AgentCore stores the client secret; the platform DynamoDB record does not. +- The returned credential-provider ARN is persisted. +- Editing metadata without rotating credentials preserves the provider. +- Credential rotation requires the full client ID/secret pair, as it does today. +- Delete remains idempotent. + +## Phase 4 — Wire authenticated Directory tools end-to-end + +With Gateway JWT auth proven (Phase 1), token-service accepting OBO exchanges (Phase 2), and the AgentCore OBO connector registered (Phase 3), this phase connects the final dots: upgrade the Directory Gateway target to use OBO credentials and add the authenticated tool endpoints. + +### 4A. Upgrade Directory target to OAuth OBO + +Update the existing `campus-directory` Gateway target registration from `GATEWAY_IAM_ROLE` to OAuth token exchange: + +```text +Protocol: MCP Gateway (AgentCore) +Target name: campus-directory +Endpoint URL: +Listing mode: Default +Outbound credential: OAuth +Credential provider: +Grant type: Token Exchange +OAuth scopes: (empty — Directory uses role claims) +``` + +### 4B. Add authenticated tools to the MCP Lambda wrapper + +Extend the Directory MCP Lambda wrapper with tools that pass the OBO-exchanged Bearer token to the Directory API: + +```json +[ + { + "name": "directory_get_me", + "description": "Get the calling user's own directory entry including private fields (office location, employee ID). Requires authentication.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "directory_get_pending", + "description": "Get pending directory entry change requests. Requires IamStaff or DotNetDevelopers role.", + "inputSchema": { + "type": "object", + "properties": { + "page_size": { + "type": "integer", + "default": 25 + }, + "page": { + "type": "integer", + "default": 1 + } + } + } + } +] +``` + +The wrapper Lambda reads the Authorization header that Gateway injects (the OBO-exchanged token-service JWT) and forwards it to the Directory API. For `directory_search`, the token is optional (the endpoint is anonymous); for `directory_get_me` and `directory_get_pending`, it is required. + +### Phase 4 acceptance criteria + +- Invoking `directory_get_me` causes AgentCore to call token-service's `/v2/oauth/token` with the user's Cognito access token. +- token-service receives a validated Cognito subject token and authenticated client identity. +- The Directory API receives a token-service JWT with the expected audience and the user's employee ID. +- `directory_get_me` returns the calling user's own directory entry (proves identity propagation end-to-end). +- `directory_search` continues to work (now optionally with a token, still anonymous-capable). +- `directory_get_pending` succeeds for a user with IamStaff/DotNetDevelopers role and is denied for users without that role. +- The Directory API accepts the exchanged token without any code changes. + +## Phase 5 — Production hardening and reusable onboarding + +After the pilot succeeds: + +- Extract the duplicated token issuance sequence into a shared, tested `ApplicationTokenIssuer` used by old and new token-service flows. +- Add a standard onboarding checklist for each campus MCP target: + - token-service application/audience registration + - allowed AgentCore client-to-audience mapping + - scopes + - Gateway plugin registration + - target JWT validation + - business-authorization tests +- Add dashboards for exchange count, denials, latency, failures, and target audience. +- Add alarms for elevated token exchange failures and invalid subject tokens. +- Document client-secret rotation and signing-key rotation. +- Load-test token exchange and Gateway calls. +- Define availability behavior: fail closed when token-service is unavailable; do not fall back to service-wide credentials. + +## Phase 6 — Fine-grained per-tool RBAC + +### Initial control model + +Keep the controls already present: + +1. BoiseState.ai AppRoles determine whether a tool is visible/available. +2. User preferences determine whether an available tool is enabled. +3. token-service application roles are included in the target token. +4. The MCP server/legacy API performs final business authorization. + +This is sufficient for the first OBO pilot. + +### Gateway Policy capability + +AgentCore Policy can evaluate every MCP tool call using: + +- The inbound JWT subject and claims. +- The exact MCP tool name as the Cedar action. +- The Gateway ARN as the resource. +- Typed MCP tool arguments as `context.input`. + +This supports rules such as allowing `directory_get_pending` only for users with the IamStaff role, or restricting `directory_add_entry` to the entry owner or a DotNetDevelopers member. + +### Important claim-ordering rule + +Gateway Policy evaluates the **inbound token used to call Gateway**. It cannot inspect the target-specific token-service JWT created later by outbound OBO. + +During the first implementation, Gateway receives a Cognito token, so policy can use Cognito claims but not claims added only to the downstream token-service JWT. + +If token-service must become the source of Gateway policy claims, add a later pre-Gateway exchange: + +```text +Cognito access token + -> token-service exchange for audience=agentcore-gateway + -> short-lived token-service Gateway JWT with tool entitlement claims + -> Gateway JWT authorizer + Cedar policy + -> target-specific OBO exchange + -> campus MCP/API +``` + +That design requires token-service to support exchanging a trusted token-service Gateway token for a target-specific token, or an equivalent delegated flow. Make this a separate architecture decision after the initial OBO path is proven. + +### Directory API authorization example + +The Directory API provides a clean three-tier authorization model for the pilot: + +**Tier 1 — Public read (no policy needed):** + +`directory_search` is anonymous-capable. Gateway Policy can allow it for any authenticated user without additional claim checks: + +```cedar +permit( + principal, + action == Action::"directory_search", + resource == GatewayTarget::"campus-directory" +); +``` + +**Tier 2 — Authenticated self-lookup:** + +`directory_get_me` requires a valid identity (the employee ID from the exchanged token drives the query). No specific role is needed, but the user must be authenticated: + +```cedar +permit( + principal, + action == Action::"directory_get_me", + resource == GatewayTarget::"campus-directory" +) when { + context.subject_token_valid == true +}; +``` + +**Tier 3 — Role-gated admin operations:** + +`directory_get_pending` requires the IamStaff or DotNetDevelopers role. Gateway Policy can deny early before the request reaches the Directory API: + +```cedar +permit( + principal, + action == Action::"directory_get_pending", + resource == GatewayTarget::"campus-directory" +) when { + context.token_claims.roles.contains("IamStaff") || + context.token_claims.roles.contains("DotNetDevelopers") +}; +``` + +**Key principles:** + +- token-service governs entitlements such as `IamStaff` and `DotNetDevelopers` roles. +- Gateway Policy may deny the tool call before it reaches the target (fail-fast). +- The Directory API still performs its own role checks as the final authority — Gateway policy is an additional defense layer, not a replacement. +- Keep authorization tokens short-lived because claims are snapshots. +- For immediate revocation or highly volatile entitlement data, perform a live check in the target/API or a Gateway request interceptor rather than relying only on claims. + +### Future policy-management work + +The current admin UI stores per-tool approval flags but does not manage Cedar policies. A later feature should add: + +- Policy engine creation/attachment to the Gateway. +- Policy templates keyed to Gateway target/tool names. +- Validation against the generated Gateway Cedar schema. +- Test/simulation before activation. +- Default-deny behavior for protected write tools. +- Audit logs containing user, tool, target, decision, and non-sensitive reason. + +## Security requirements + +- Never expose Cognito or token-service tokens to the browser. +- Never log raw tokens or client secrets. +- Authenticate both the user subject token and the calling AgentCore client. +- Restrict every client to explicitly allowed target audiences. +- Use short-lived target tokens. +- Validate issuer, signature, client/audience, `token_use`, and time claims. +- Preserve the original employee identity for audit. +- Do not copy arbitrary inbound claims into target tokens. +- Keep the target API as the final authorization authority for state-changing operations. +- Deny when token-service, AgentCore Identity, policy evaluation, or entitlement checks fail. + +## Test plan + +### token-service + +- Valid Cognito access token exchanges successfully. +- ID token, expired token, wrong issuer, wrong app client, bad signature, or missing employee ID is rejected. +- Invalid client credentials are rejected. +- Client cannot request an unapproved audience. +- Issued JWT matches existing issuer/audience/role/signature contracts. +- Token expiry does not outlive the subject token or configured maximum. +- Feature-disabled route is unavailable. +- Existing authorization-code and refresh tests remain unchanged and passing. + +### AgentCore provider registration + +- Create/update payload includes OBO config. +- Custom discovery and explicit metadata both work. +- Full config is preserved on update. +- Secrets are never returned or stored in the platform table. + +### Gateway and agent + +- JWT-authorized Gateway accepts the expected Cognito access token. +- Missing, expired, or wrong-client tokens are rejected. +- User tokens cannot leak between agent instances. +- Existing target discovery and filtering still work. +- OAuth token-exchange target payload matches AWS API expectations. +- End-to-end request arrives at the pilot MCP server with the expected token-service JWT. + +### Authorization + +- AppRole without tool access cannot select the tool. +- User with tool access but no downstream role is denied by the target/API. +- Future Cedar policy denies protected tools without the required claim. +- Protected business action is still denied by the API when its live rules fail. + +## Rollout sequence + +1. Confirm Phase 0 contracts (Cognito issuer, employee-ID claim, token-service audience for Directory). +2. ~~Decide: migrate existing Gateway or add a second JWT Gateway.~~ Decided: single Gateway, migrate inbound to `CUSTOM_JWT`. +3. Deploy Gateway JWT authorizer (Phase 1A) in development. +4. Update agent Gateway client to pass bearer token (Phase 1B). +5. Build and deploy the Directory MCP Lambda wrapper with `directory_search` only (Phase 1C). +6. Validate: agent can search the directory via Gateway with JWT auth. First working tool. +7. (Optional) Observe the OBO request format via a logging endpoint (Phase 1D). +8. Implement token-service v2 exchange endpoint behind a disabled feature flag (Phase 2). +9. Deploy to token-service development environment. +10. Test direct RFC 8693 exchange with a non-production Cognito token and Directory audience. +11. Add AgentCore OBO provider support in the platform (Phase 3). +12. Register a development token-service OBO provider. +13. Upgrade Directory Gateway target to OAuth OBO credentials (Phase 4A). +14. Add `directory_get_me` and `directory_get_pending` to the MCP Lambda wrapper (Phase 4B). +15. Validate: `directory_get_me` returns the calling user's own entry (identity flows end-to-end). +16. Validate: `directory_get_pending` succeeds for IamStaff, denied for others (roles flow correctly). +17. Validate: identity, audit logs, failure behavior, and token expiry. +18. Run security review and load test. +19. Enable in production for an allowlisted group and audience. +20. Expand target-by-target; do not bulk-migrate existing integrations. + +## Rollback + +- Disable token-service `TokenExchangeV2` feature flag. +- Disable or remove the pilot Gateway target. +- Disable the token-service AgentCore credential provider. +- Restore the prior Gateway authorizer/client if the existing Gateway was migrated. +- Existing token-service login and refresh routes remain available throughout. +- Existing `mcp_external` direct-forwarding integrations remain unchanged. + +## Out of scope for the first release + +- Replacing existing token-service login flows. +- Changing all legacy APIs to trust Cognito. +- Migrating every `mcp_external` integration to Gateway. +- Building the full Cedar policy-management UI. +- Encoding volatile business state into long-lived Cognito claims. +- Removing final authorization checks from MCP servers or legacy APIs. + +## 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. +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. +5. Initial scopes and target-token lifetime? +6. Long-term Gateway policy claims: Cognito claims, pre-Gateway token-service JWT, or live interceptor lookup? + +## Definition of done for the pilot + +The pilot is complete when an allowlisted BoiseState.ai user can invoke one campus MCP tool through AgentCore Gateway, Gateway silently exchanges the user's enriched Cognito access token through token-service, the target receives a short-lived application-specific token-service JWT, the legacy API accepts it without modification, unauthorized users are denied, no token reaches the browser, and existing token-service login flows continue to operate unchanged. diff --git a/infrastructure/lib/config.ts b/infrastructure/lib/config.ts index e5705dcc5..1d74387fa 100644 --- a/infrastructure/lib/config.ts +++ b/infrastructure/lib/config.ts @@ -58,6 +58,7 @@ export interface AppConfig { artifacts: ArtifactsConfig; mcpSandbox: McpSandboxConfig; mcpIdentity: McpIdentityConfig; + gateway: GatewayConfig; appVersion: string; tags: { [key: string]: string }; } @@ -254,6 +255,30 @@ export interface McpIdentityConfig { }; } +/** + * AgentCore Gateway configuration. + * + * `inboundAuth` selects the Gateway's single inbound authorizer. AgentCore + * allows exactly one authorizer type per Gateway (`authorizerType` is a scalar: + * CUSTOM_JWT | AWS_IAM | NONE | AUTHENTICATE_ONLY) — there is no "accept either + * SigV4 or JWT" mode. Outbound credentials remain per-target, so a single + * 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. + * + * 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. + */ +export interface GatewayConfig { + inboundAuth: 'jwt' | 'iam'; +} + /** * Load and validate configuration from CDK context * @param scope The CDK construct scope @@ -481,6 +506,16 @@ 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. + inboundAuth: + (process.env.CDK_GATEWAY_INBOUND_AUTH as 'jwt' | 'iam' | undefined) + || scope.node.tryGetContext('gateway')?.inboundAuth + || 'jwt', + }, tags: { ...(scope.node.tryGetContext('tags') || {}), }, @@ -664,6 +699,15 @@ function validateConfig(config: AppConfig): void { throw new Error(`Invalid VPC CIDR format: ${config.vpcCidr}`); } + // Validate Gateway inbound auth selection. A typo here would otherwise + // silently fall through to an unintended authorizer and 401 every Gateway + // call, so fail fast at synth instead. + if (!['jwt', 'iam'].includes(config.gateway.inboundAuth)) { + throw new Error( + `gateway.inboundAuth must be 'jwt' or 'iam'. Got: ${config.gateway.inboundAuth}` + ); + } + // Validate RAG Ingestion configuration (always provisioned). // Validate Lambda memory size (128 MB to 10240 MB) if (config.ragIngestion.lambdaMemorySize < 128 || config.ragIngestion.lambdaMemorySize > 10240) { diff --git a/infrastructure/lib/constructs/gateway/agentcore-gateway-construct.ts b/infrastructure/lib/constructs/gateway/agentcore-gateway-construct.ts index 5c608ac6c..5a2ec46ee 100644 --- a/infrastructure/lib/constructs/gateway/agentcore-gateway-construct.ts +++ b/infrastructure/lib/constructs/gateway/agentcore-gateway-construct.ts @@ -1,5 +1,6 @@ import * as cdk from 'aws-cdk-lib'; import * as agentcore from 'aws-cdk-lib/aws-bedrockagentcore'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as ssm from 'aws-cdk-lib/aws-ssm'; import { Construct } from 'constructs'; @@ -8,11 +9,47 @@ import { AppConfig, getResourceName } from '../../config'; export interface AgentCoreGatewayConstructProps { config: AppConfig; + + /** + * Cognito user pool whose access tokens the Gateway trusts for inbound auth. + * + * Passed as a construct ref rather than read from SSM: the pool lives in this + * same stack, and CloudFormation resolves + * `AWS::SSM::Parameter::Value` template parameters *before* any of the + * stack's resources are created — so reading a parameter this stack publishes + * is unsatisfiable on first deploy. + * + * Required when `config.gateway.inboundAuth === 'jwt'`. + */ + userPool?: cognito.IUserPool; + + /** + * BFF app client. Its client ID is the only value allowed in the inbound + * token's `client_id` claim. + * + * Required when `config.gateway.inboundAuth === 'jwt'`. + */ + bffAppClient?: cognito.IUserPoolClient; } /** * AgentCoreGatewayConstruct — AWS Bedrock AgentCore Gateway with MCP - * protocol and AWS_IAM (SigV4) authorization. + * protocol and configurable inbound authorization. + * + * Inbound auth (`config.gateway.inboundAuth`, default `jwt`): + * - `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. + * + * 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 + * (Wikipedia, ArXiv, policy-search) and OAuth/token-exchange targets (campus + * token-service APIs). One front desk, many doors, different keys. * * Provides: * - Gateway IAM execution role with NO standing Lambda-invoke grant. The @@ -24,8 +61,7 @@ export interface AgentCoreGatewayConstructProps { * only the functions explicitly registered (no naming-convention wildcard). * See `apis/shared/tools/gateway_lambda_grant.py`. * - CloudWatch Logs publish rights for gateway-scoped log groups - * - `agentcore.CfnGateway` configured for MCP protocol with AWS_IAM - * authorizer and SEMANTIC search type + * - `agentcore.CfnGateway` configured for MCP protocol with SEMANTIC search * * SSM publications: * /{prefix}/gateway/id — gateway identifier, read at runtime by app-api's @@ -97,11 +133,51 @@ 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). + const useJwtInboundAuth = config.gateway.inboundAuth === 'jwt'; + + if (useJwtInboundAuth && (!props.userPool || !props.bffAppClient)) { + throw new Error( + "AgentCoreGatewayConstruct: config.gateway.inboundAuth is 'jwt' but " + + 'userPool and/or bffAppClient were not supplied. Both are required to ' + + 'build the CUSTOM_JWT authorizer. Pass them from PlatformStack, or set ' + + "gateway.inboundAuth='iam' to keep SigV4 inbound auth.", + ); + } + + const authorizerProps = useJwtInboundAuth + ? { + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + // Cognito's OIDC discovery document. Built from region + pool id + // rather than `userPoolProviderUrl` (which is only on the concrete + // UserPool, not IUserPool). Token-safe: userPoolId may be an + // unresolved CFN token and interpolates fine. + discoveryUrl: + `https://cognito-idp.${stack.region}.amazonaws.com/` + + `${props.userPool!.userPoolId}/.well-known/openid-configuration`, + // MUST be allowedClients, NOT allowedAudience. Cognito *access* + // tokens carry a `client_id` claim and no `aud` claim, so an + // audience check can never match and would 401 every call. (The + // travel-auth MCP server documents the same trap for its PyJWT + // check, where JWT_AUDIENCE is deliberately left unset.) + allowedClients: [props.bffAppClient!.userPoolClientId], + }, + }, + } + : { authorizerType: 'AWS_IAM' }; + this.gateway = new agentcore.CfnGateway(this, 'MCPGateway', { name: getResourceName(config, 'mcp-gateway'), description: 'MCP Gateway for external tools', roleArn: this.gatewayRole.roleArn, - authorizerType: 'AWS_IAM', + ...authorizerProps, protocolType: 'MCP', exceptionLevel: 'DEBUG', // Only DEBUG is supported protocolConfiguration: { @@ -135,7 +211,9 @@ export class AgentCoreGatewayConstruct extends Construct { new cdk.CfnOutput(this, 'GatewayUrl', { value: gatewayUrl, - description: 'AgentCore Gateway URL (requires SigV4 authentication)', + description: useJwtInboundAuth + ? 'AgentCore Gateway URL (requires a Cognito access token as a Bearer token)' + : 'AgentCore Gateway URL (requires SigV4 authentication)', exportName: getResourceName(config, 'gateway-url'), }); @@ -150,8 +228,24 @@ export class AgentCoreGatewayConstruct extends Construct { description: 'Gateway Status', }); + new cdk.CfnOutput(this, 'GatewayInboundAuth', { + value: useJwtInboundAuth ? 'CUSTOM_JWT' : 'AWS_IAM', + description: 'Gateway inbound authorizer type (config.gateway.inboundAuth)', + }); + new cdk.CfnOutput(this, 'UsageInstructions', { - value: ` + value: useJwtInboundAuth + ? ` +Gateway URL: ${gatewayUrl} +Authentication: CUSTOM_JWT (Cognito access token as Bearer) + +The agent sends the signed-in user's Cognito access token per invocation: + Authorization: Bearer + +Accepted only when the token's client_id matches the BFF app client. +Note: Cognito access tokens have no 'aud' claim — validation is on client_id. + `.trim() + : ` Gateway URL: ${gatewayUrl} Authentication: AWS_IAM (SigV4) @@ -159,7 +253,7 @@ To test Gateway connectivity: aws bedrock-agentcore invoke-gateway \\ --gateway-identifier ${gatewayId} \\ --region ${stack.region} - `.trim(), + `.trim(), description: 'Usage instructions for Gateway', }); } diff --git a/infrastructure/lib/constructs/inference-api/inference-agentcore-construct.ts b/infrastructure/lib/constructs/inference-api/inference-agentcore-construct.ts index 740ff8d23..8e3bbf2c3 100644 --- a/infrastructure/lib/constructs/inference-api/inference-agentcore-construct.ts +++ b/infrastructure/lib/constructs/inference-api/inference-agentcore-construct.ts @@ -332,6 +332,12 @@ export class InferenceAgentCoreConstruct extends Construct { AGENTCORE_CODE_INTERPRETER_ID: props.codeInterpreterId, BROWSER_ID: props.browserId, + // Gateway inbound auth mode. Sourced from the SAME config value that + // builds the Gateway's authorizer, so the agent's data-plane auth and + // the deployed authorizerType cannot drift: 'jwt' → the agent sends the + // user's Cognito access token as a Bearer token; 'iam' → SigV4. + AGENTCORE_GATEWAY_INBOUND_AUTH: config.gateway.inboundAuth, + // S3 storage S3_ASSISTANTS_VECTOR_STORE_BUCKET_NAME: vectorBucketName, S3_ASSISTANTS_VECTOR_STORE_INDEX_NAME: vectorIndexName, diff --git a/infrastructure/lib/platform-stack.ts b/infrastructure/lib/platform-stack.ts index e6a36ee79..248d16051 100644 --- a/infrastructure/lib/platform-stack.ts +++ b/infrastructure/lib/platform-stack.ts @@ -568,7 +568,13 @@ export class PlatformStack extends cdk.Stack { // /^${prefix}-mcp-/ Lambda naming convention used by the // external mcp-servers repo). No code lives here — Gateway // Targets are managed out-of-band by mcp-servers' own deploy. - new AgentCoreGatewayConstruct(this, 'AgentCoreGateway', { config }); + // Cognito refs are passed explicitly (not via SSM) because the pool is a + // sibling in this same stack — see AgentCoreGatewayConstructProps. + new AgentCoreGatewayConstruct(this, 'AgentCoreGateway', { + config, + userPool: cognitoConstruct.userPool, + bffAppClient: cognitoConstruct.bffAppClient, + }); // ============================================================ // MCP sandbox edge (always-on; bucket+dist; everything is wired diff --git a/infrastructure/test/constructs.test.ts b/infrastructure/test/constructs.test.ts index 602ae3931..b30e8e0df 100644 --- a/infrastructure/test/constructs.test.ts +++ b/infrastructure/test/constructs.test.ts @@ -8,6 +8,7 @@ import * as cdk from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { Template } from 'aws-cdk-lib/assertions'; import { createMockConfig, MOCK_ACCOUNT, MOCK_REGION } from './helpers/mock-config'; +import { mockCognitoRefs } from './helpers/mock-cognito'; import { NetworkConstruct } from '../lib/constructs/network/network-construct'; import { AlbConstruct } from '../lib/constructs/network/alb-construct'; @@ -306,7 +307,10 @@ describe('SpaBucketConstruct', () => { describe('AgentCoreGatewayConstruct', () => { it('creates Gateway + IAM role', () => { const stack = testStack(); - new AgentCoreGatewayConstruct(stack, 'GW', { config: createMockConfig() }); + new AgentCoreGatewayConstruct(stack, 'GW', { + config: createMockConfig(), + ...mockCognitoRefs(stack), + }); const t = Template.fromStack(stack); t.resourceCountIs('AWS::BedrockAgentCore::Gateway', 1); t.resourceCountIs('AWS::IAM::Role', 1); @@ -314,7 +318,10 @@ describe('AgentCoreGatewayConstruct', () => { it('publishes the gateway id SSM parameter for app-api (issue #419)', () => { const stack = testStack(); - new AgentCoreGatewayConstruct(stack, 'GW', { config: createMockConfig() }); + new AgentCoreGatewayConstruct(stack, 'GW', { + config: createMockConfig(), + ...mockCognitoRefs(stack), + }); const t = Template.fromStack(stack); t.hasResourceProperties('AWS::SSM::Parameter', { Name: '/test-project/gateway/id', diff --git a/infrastructure/test/helpers/mock-cognito.ts b/infrastructure/test/helpers/mock-cognito.ts new file mode 100644 index 000000000..57109b44d --- /dev/null +++ b/infrastructure/test/helpers/mock-cognito.ts @@ -0,0 +1,29 @@ +import * as cdk from 'aws-cdk-lib'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; + +export const MOCK_USER_POOL_ID = 'us-west-2_testpool1'; +export const MOCK_BFF_CLIENT_ID = 'testbffclientid123'; + +/** + * Cognito refs for constructs that need a user pool / app client (e.g. the + * Gateway's CUSTOM_JWT inbound authorizer). + * + * Uses `from*` imports rather than creating a real `UserPool`, so the returned + * refs add **zero resources** to the test stack. That keeps existing + * `resourceCountIs` assertions (IAM roles, etc.) meaningful in construct + * isolation tests. + */ +export function mockCognitoRefs(scope: cdk.Stack, idPrefix = 'MockCognito') { + return { + userPool: cognito.UserPool.fromUserPoolId( + scope, + `${idPrefix}UserPool`, + MOCK_USER_POOL_ID, + ), + bffAppClient: cognito.UserPoolClient.fromUserPoolClientId( + scope, + `${idPrefix}BffClient`, + MOCK_BFF_CLIENT_ID, + ), + }; +} diff --git a/infrastructure/test/helpers/mock-config.ts b/infrastructure/test/helpers/mock-config.ts index 04cbf2e06..7506da858 100644 --- a/infrastructure/test/helpers/mock-config.ts +++ b/infrastructure/test/helpers/mock-config.ts @@ -77,6 +77,9 @@ export function createMockConfig(overrides: Partial = {}): AppConfig accessTokenClaims: {}, }, }, + gateway: { + inboundAuth: 'jwt', + }, cognito: { domainPrefix: MOCK_PREFIX, passwordMinLength: 8, diff --git a/infrastructure/test/network-and-scripts.test.ts b/infrastructure/test/network-and-scripts.test.ts index 8591d3b8c..66cd91648 100644 --- a/infrastructure/test/network-and-scripts.test.ts +++ b/infrastructure/test/network-and-scripts.test.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { Template, Match } from 'aws-cdk-lib/assertions'; import { createMockConfig, MOCK_ACCOUNT, MOCK_REGION } from './helpers/mock-config'; +import { mockCognitoRefs, MOCK_BFF_CLIENT_ID } from './helpers/mock-cognito'; import { NetworkConstruct } from '../lib/constructs/network/network-construct'; import { AlbConstruct } from '../lib/constructs/network/alb-construct'; @@ -249,7 +250,10 @@ describe('AgentCoreGatewayConstruct — detailed', () => { let t: Template; beforeAll(() => { const stack = testStack(); - new AgentCoreGatewayConstruct(stack, 'GW', { config }); + new AgentCoreGatewayConstruct(stack, 'GW', { + config, + ...mockCognitoRefs(stack), + }); t = Template.fromStack(stack); }); @@ -259,10 +263,65 @@ describe('AgentCoreGatewayConstruct — detailed', () => { }); }); - it('gateway uses AWS_IAM authorizer', () => { + 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. + t.hasResourceProperties('AWS::BedrockAgentCore::Gateway', { + AuthorizerType: 'CUSTOM_JWT', + }); + }); + + 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', { + AuthorizerConfiguration: { + CustomJWTAuthorizer: { + AllowedClients: [MOCK_BFF_CLIENT_ID], + }, + }, + }); + const gw = Object.values( + t.findResources('AWS::BedrockAgentCore::Gateway'), + )[0] as any; + expect( + gw.Properties.AuthorizerConfiguration.CustomJWTAuthorizer.AllowedAudience, + ).toBeUndefined(); + }); + + it('JWT authorizer points at the Cognito OIDC discovery document', () => { + const gw = Object.values( + t.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. + 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(); + }); + + 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 }), + ).toThrow(/inboundAuth is 'jwt' but/); }); it('gateway role has NO standing lambda:Invoke* grant (per-target only)', () => { From 9c3c11f2d2b2e4bdac142976df9adfd898f6fb1e Mon Sep 17 00:00:00 2001 From: derrickfink Date: Tue, 28 Jul 2026 14:52:12 -0600 Subject: [PATCH 2/3] feat(gateway): send the signed-in user's token to the JWT Gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/src/agents/main_agent/base_agent.py | 7 +- .../src/agents/main_agent/config/constants.py | 8 ++ .../integrations/gateway_mcp_client.py | 130 +++++++++++++----- .../main_agent/tools/gateway_integration.py | 32 ++++- .../integrations/test_gateway_mcp_client.py | 115 ++++++++++++++++ 5 files changed, 252 insertions(+), 40 deletions(-) diff --git a/backend/src/agents/main_agent/base_agent.py b/backend/src/agents/main_agent/base_agent.py index 41a5e9193..4b6c05528 100644 --- a/backend/src/agents/main_agent/base_agent.py +++ b/backend/src/agents/main_agent/base_agent.py @@ -434,7 +434,12 @@ def _build_filtered_tools(self) -> List: # gateway's runtime per-tool ids (`gateway____`) # before the FilteredMCPClient can match them. gateway_tool_ids = self._expand_gateway_tool_ids(gateway_tool_ids) - gateway_client = self.gateway_integration.get_client(gateway_tool_ids) + # The JWT-authorized Gateway needs *this user's* access token per + # invocation — there is no machine-identity fallback. Passed + # explicitly (never cached globally) so tokens can't cross users. + gateway_client = self.gateway_integration.get_client( + gateway_tool_ids, auth_token=self.auth_token + ) if gateway_client: local_tools = self.gateway_integration.add_to_tool_list(local_tools) diff --git a/backend/src/agents/main_agent/config/constants.py b/backend/src/agents/main_agent/config/constants.py index 48ceeb481..51f976126 100644 --- a/backend/src/agents/main_agent/config/constants.py +++ b/backend/src/agents/main_agent/config/constants.py @@ -55,6 +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_AUTH = "AGENTCORE_GATEWAY_INBOUND_AUTH" # --- MCP Apps (host renderer initiative) --- MCP_APPS_HOST_ENABLED = "AGENTCORE_MCP_APPS_HOST_ENABLED" @@ -120,6 +126,8 @@ class Defaults: # --- Gateway --- GATEWAY_MCP_ENABLED = True + # Matches the CDK default (`config.gateway.inboundAuth`). + GATEWAY_INBOUND_AUTH = "jwt" # --- 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 1e5754d79..909f82401 100644 --- a/backend/src/agents/main_agent/integrations/gateway_mcp_client.py +++ b/backend/src/agents/main_agent/integrations/gateway_mcp_client.py @@ -10,6 +10,7 @@ from strands.tools.mcp import MCPClient from agents.main_agent.config.constants import EnvVars, Defaults from agents.main_agent.integrations.gateway_auth import get_sigv4_auth, get_gateway_region_from_url +from agents.main_agent.integrations.oauth_auth import create_oauth_bearer_auth from apis.shared.tools.gateway_identity import resolve_gateway_id, gateway_url_from_id from agents.main_agent.integrations.mcp_apps import ( UICapableMCPClient, @@ -20,6 +21,65 @@ logger = logging.getLogger(__name__) +class GatewayAuthError(Exception): + """Raised when the Gateway needs a user token and none is available. + + The JWT-authorized Gateway has no machine-identity fallback: without the + signed-in user's access token there is nothing to send and nothing for + AgentCore to exchange on the user's behalf. Fail loudly rather than + silently building a client that 401s on every tool call. + """ + + +def _gateway_inbound_auth_mode() -> str: + """Return the Gateway's inbound auth mode: 'jwt' (default) or 'iam'. + + 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. + """ + mode = os.environ.get( + EnvVars.GATEWAY_INBOUND_AUTH, Defaults.GATEWAY_INBOUND_AUTH + ).strip().lower() + if mode not in ("jwt", "iam"): + logger.warning( + "Unrecognized %s=%r; falling back to '%s'", + EnvVars.GATEWAY_INBOUND_AUTH, + mode, + Defaults.GATEWAY_INBOUND_AUTH, + ) + return Defaults.GATEWAY_INBOUND_AUTH + return mode + + +def _build_gateway_auth(region: str, auth_token: Optional[str]): + """Build the httpx auth for a Gateway data-plane connection. + + - `jwt` mode: bearer auth carrying *this user's* Cognito access token. + The token is bound per client instance (never module-level or shared), so + one user's credential can't leak into another user's Gateway client. + - `iam` mode: SigV4 signing with the task's IAM identity (legacy/rollback). + + Raises: + GatewayAuthError: JWT mode with no user token. + """ + if _gateway_inbound_auth_mode() == "iam": + return get_sigv4_auth(region=region) + + if not auth_token: + raise GatewayAuthError( + "Gateway inbound auth is 'jwt' but no user access token was " + "supplied. Gateway tools require a signed-in user; there is no " + "machine-identity fallback. (Headless/scheduled runs mint a token " + "via CognitoRefreshBearerAuth — check that the run has an active " + "headless grant.)" + ) + + # Static token: an agent instance serves exactly one user for one + # invocation, and the BFF has already refreshed it for this request. + return create_oauth_bearer_auth(token=auth_token) + + class FilteredMCPClient(MCPClient): """ MCPClient wrapper that filters tools based on enabled tool IDs. @@ -124,36 +184,30 @@ def create_gateway_mcp_client( gateway_url: Optional[str] = None, prefix: str = "gateway", tool_filters: Optional[dict] = None, - region: Optional[str] = None + region: Optional[str] = None, + auth_token: Optional[str] = None, ) -> Optional[MCPClient]: """ - Create MCP client for AgentCore Gateway with SigV4 authentication. + Create MCP client for AgentCore Gateway. + + Auth depends on the deployed Gateway's inbound authorizer + (``AGENTCORE_GATEWAY_INBOUND_AUTH``, set by CDK): + - ``jwt`` (default): bearer auth with ``auth_token`` (the signed-in + user's Cognito access token). Required — raises GatewayAuthError if absent. + - ``iam``: SigV4 signing (legacy/rollback path). Args: gateway_url: Gateway URL. If None, retrieves from SSM Parameter Store. prefix: Prefix for tool names (default: 'gateway') tool_filters: Tool filtering configuration (allowed/rejected lists) region: AWS region. If None, extracts from gateway_url or uses default. + auth_token: The current user's Cognito access token. Required in jwt mode. Returns: MCPClient instance or None if Gateway URL not available - Example: - >>> # Create client with all tools - >>> client = create_gateway_mcp_client() - >>> - >>> # Create client with tool filtering - >>> client = create_gateway_mcp_client( - ... tool_filters={"allowed": ["wikipedia_search", "arxiv_search"]} - ... ) - >>> - >>> # Use with Strands Agent (Managed approach - Experimental) - >>> agent = Agent(tools=[client]) - >>> - >>> # Or manual approach - >>> with client: - ... tools = client.list_tools_sync() - ... agent = Agent(tools=tools) + Raises: + GatewayAuthError: jwt mode with no ``auth_token``. """ # Get Gateway URL from SSM if not provided if not gateway_url: @@ -166,8 +220,7 @@ def create_gateway_mcp_client( if not region: region = get_gateway_region_from_url(gateway_url) - # Create SigV4 auth for Gateway - auth = get_sigv4_auth(region=region) + auth = _build_gateway_auth(region, auth_token) # Create MCP client with streamable HTTP transport # Note: prefix and tool_filters are no longer supported in MCPClient constructor @@ -177,12 +230,13 @@ def create_gateway_mcp_client( mcp_client = UICapableMCPClient( lambda: streamablehttp_client( gateway_url, - auth=auth # httpx Auth class for automatic SigV4 signing + auth=auth # httpx Auth: bearer (jwt mode) or SigV4 (iam mode) ) ) logger.info(f"✅ Gateway MCP client created: {gateway_url}") logger.info(f" Region: {region}") + logger.info(f" Inbound auth: {_gateway_inbound_auth_mode()}") logger.info(f" Note: Prefix '{prefix}' will be applied manually") if tool_filters: logger.info(f" Note: Filters {tool_filters} will be applied manually") @@ -192,7 +246,8 @@ def create_gateway_mcp_client( def create_filtered_gateway_client( enabled_tool_ids: List[str], - prefix: str = "gateway" + prefix: str = "gateway", + auth_token: Optional[str] = None, ) -> Optional[FilteredMCPClient]: """ Create Gateway MCP client with tool filtering based on enabled tool IDs. @@ -202,19 +257,16 @@ def create_filtered_gateway_client( Args: enabled_tool_ids: List of tool IDs that are enabled by user - e.g., ["gateway_wikipedia-search___wikipedia_search", "gateway_arxiv-search___arxiv_search"] + e.g., ["gateway_wikipedia-search___wikipedia_search"] prefix: Prefix used for Gateway tools (default: 'gateway') + auth_token: The current user's Cognito access token. Required when the + Gateway uses JWT inbound auth (the default). Returns: FilteredMCPClient with filtered tools or None if no Gateway tools enabled - Example: - >>> # User enabled only Wikipedia tools - >>> enabled = ["gateway_wikipedia-search___wikipedia_search", "gateway_wikipedia-get-article___wikipedia_get_article"] - >>> client = create_filtered_gateway_client(enabled) - >>> - >>> # Use with Agent (Managed Integration) - >>> agent = Agent(tools=[client]) + Raises: + GatewayAuthError: jwt mode with no ``auth_token``. """ # Filter to only Gateway tool IDs gateway_tool_ids = [tid for tid in enabled_tool_ids if tid.startswith(f"{prefix}_")] @@ -232,8 +284,7 @@ def create_filtered_gateway_client( # Extract region from URL region = get_gateway_region_from_url(gateway_url) - # Create SigV4 auth for Gateway - auth = get_sigv4_auth(region=region) + auth = _build_gateway_auth(region, auth_token) # Create FilteredMCPClient with tool filtering logger.info(f"Creating FilteredMCPClient with {len(gateway_tool_ids)} enabled tool IDs") @@ -241,7 +292,7 @@ def create_filtered_gateway_client( mcp_client = FilteredMCPClient( lambda: streamablehttp_client( gateway_url, - auth=auth # httpx Auth class for automatic SigV4 signing + auth=auth # httpx Auth: bearer (jwt mode) or SigV4 (iam mode) ), enabled_tool_ids=gateway_tool_ids, prefix=prefix @@ -249,6 +300,7 @@ def create_filtered_gateway_client( logger.info(f"✅ FilteredMCPClient created: {gateway_url}") logger.info(f" Region: {region}") + logger.info(f" Inbound auth: {_gateway_inbound_auth_mode()}") logger.info(f" Enabled tool IDs: {gateway_tool_ids}") return mcp_client @@ -258,22 +310,28 @@ def create_filtered_gateway_client( GATEWAY_ENABLED = os.environ.get(EnvVars.GATEWAY_MCP_ENABLED, str(Defaults.GATEWAY_MCP_ENABLED).lower()).lower() == 'true' def get_gateway_client_if_enabled( - enabled_tool_ids: Optional[List[str]] = None + enabled_tool_ids: Optional[List[str]] = None, + auth_token: Optional[str] = None, ) -> Optional[MCPClient]: """ Get Gateway MCP client if enabled via environment variable. Args: enabled_tool_ids: List of enabled tool IDs for filtering + auth_token: The current user's Cognito access token. Required when the + Gateway uses JWT inbound auth (the default). Returns: MCPClient or None if disabled or no tools enabled + + Raises: + GatewayAuthError: jwt mode with no ``auth_token``. """ if not GATEWAY_ENABLED: logger.info("Gateway MCP is disabled via AGENTCORE_GATEWAY_MCP_ENABLED=false") return None if enabled_tool_ids: - return create_filtered_gateway_client(enabled_tool_ids) + return create_filtered_gateway_client(enabled_tool_ids, auth_token=auth_token) else: - return create_gateway_mcp_client() + return create_gateway_mcp_client(auth_token=auth_token) diff --git a/backend/src/agents/main_agent/tools/gateway_integration.py b/backend/src/agents/main_agent/tools/gateway_integration.py index 368e0fb08..149e1c0fe 100644 --- a/backend/src/agents/main_agent/tools/gateway_integration.py +++ b/backend/src/agents/main_agent/tools/gateway_integration.py @@ -3,7 +3,10 @@ """ import logging from typing import List, Optional, Any -from agents.main_agent.integrations.gateway_mcp_client import get_gateway_client_if_enabled +from agents.main_agent.integrations.gateway_mcp_client import ( + GatewayAuthError, + get_gateway_client_if_enabled, +) from apis.shared.tools.scoped_ids import parse_scoped_tool_id logger = logging.getLogger(__name__) @@ -79,15 +82,27 @@ def __init__(self): """Initialize gateway integration""" self.client: Optional[Any] = None - def get_client(self, enabled_gateway_tool_ids: List[str]) -> Optional[Any]: + def get_client( + self, + enabled_gateway_tool_ids: List[str], + auth_token: Optional[str] = None, + ) -> Optional[Any]: """ Get Gateway MCP client if gateway tools are enabled Args: enabled_gateway_tool_ids: List of gateway tool IDs (e.g., ["gateway_wikipedia", "gateway_arxiv"]) + auth_token: The current user's Cognito access token, forwarded as a + Bearer token to the JWT-authorized Gateway. Required unless the + Gateway is running in legacy ``iam`` inbound-auth mode. Returns: MCPClient instance or None if not available + + Note: + The client is per-user because the bearer token is. This integration + is instantiated per agent (``BaseAgent.__init__``), so the token + never outlives the invocation it belongs to. """ if not enabled_gateway_tool_ids: logger.info("No gateway tools requested") @@ -95,7 +110,18 @@ def get_client(self, enabled_gateway_tool_ids: List[str]) -> Optional[Any]: # Get Gateway MCP client (Strands 1.16+ Managed Integration) # Store as instance variable to keep session alive during Agent lifecycle - self.client = get_gateway_client_if_enabled(enabled_tool_ids=enabled_gateway_tool_ids) + try: + self.client = get_gateway_client_if_enabled( + enabled_tool_ids=enabled_gateway_tool_ids, + auth_token=auth_token, + ) + except GatewayAuthError as e: + # Degrade gracefully: the turn proceeds without Gateway tools rather + # than failing outright. Every Gateway call would 401 anyway, and a + # user with no token cannot be served these tools at all. + logger.warning(f"⚠️ Gateway tools unavailable: {e}") + self.client = None + return None if self.client: logger.info(f"✅ Gateway MCP client created (Managed Integration with Strands 1.16+)") 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 d4c6729e6..08ef8b694 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 @@ -3,14 +3,20 @@ Requirements: 24.1–24.3 """ +import httpx import pytest from unittest.mock import patch, MagicMock from agents.main_agent.integrations.gateway_mcp_client import ( FilteredMCPClient, + GatewayAuthError, + _build_gateway_auth, + _gateway_inbound_auth_mode, get_gateway_client_if_enabled, create_filtered_gateway_client, ) +from agents.main_agent.integrations.oauth_auth import OAuthBearerAuth +from agents.main_agent.tools.gateway_integration import GatewayIntegration class TestFilteredMCPClient: @@ -81,3 +87,112 @@ def test_returns_none_with_custom_prefix_no_match(self): prefix="custom", ) assert result is None + + +class TestGatewayInboundAuthMode: + """Auth-mode resolution from the CDK-managed env var.""" + + def test_defaults_to_jwt(self, monkeypatch): + monkeypatch.delenv("AGENTCORE_GATEWAY_INBOUND_AUTH", raising=False) + 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" + + def test_unknown_value_falls_back_to_jwt(self, monkeypatch): + """An unrecognized value must not silently disable user auth.""" + monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "banana") + assert _gateway_inbound_auth_mode() == "jwt" + + +class TestBuildGatewayAuth: + """Auth handler selection — the security-critical path.""" + + def test_jwt_mode_uses_bearer_token(self, monkeypatch): + """JWT mode attaches the user's token as a Bearer credential.""" + monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "jwt") + auth = _build_gateway_auth("us-west-2", "user-token-abc") + assert isinstance(auth, OAuthBearerAuth) + + # Verify the token actually lands on the Authorization header. + request = httpx.Request("POST", "https://gateway.example.com/mcp") + next(auth.auth_flow(request)) + assert request.headers["Authorization"] == "Bearer user-token-abc" + + def test_jwt_mode_without_token_raises(self, monkeypatch): + """No user token in JWT mode must fail loudly, not build a 401 client.""" + monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "jwt") + with pytest.raises(GatewayAuthError, match="no user access token"): + _build_gateway_auth("us-west-2", None) + + def test_jwt_mode_with_empty_token_raises(self, monkeypatch): + """An empty-string token is as unusable as a missing one.""" + monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "jwt") + with pytest.raises(GatewayAuthError): + _build_gateway_auth("us-west-2", "") + + def test_iam_mode_uses_sigv4_and_ignores_token(self, monkeypatch): + """IAM rollback mode signs with SigV4 regardless of any user token.""" + monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "iam") + sentinel = MagicMock() + with patch( + "agents.main_agent.integrations.gateway_mcp_client.get_sigv4_auth", + return_value=sentinel, + ) as mock_sigv4: + assert _build_gateway_auth("us-west-2", None) is sentinel + mock_sigv4.assert_called_once_with(region="us-west-2") + + def test_tokens_are_not_shared_between_clients(self, monkeypatch): + """Two users' auth handlers must carry their own distinct tokens. + + Guards the multi-tenant invariant: a bearer credential must never be + cached at module scope or reused across agent instances. + """ + monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "jwt") + auth_a = _build_gateway_auth("us-west-2", "token-user-a") + auth_b = _build_gateway_auth("us-west-2", "token-user-b") + + req_a = httpx.Request("POST", "https://gateway.example.com/mcp") + req_b = httpx.Request("POST", "https://gateway.example.com/mcp") + next(auth_a.auth_flow(req_a)) + next(auth_b.auth_flow(req_b)) + + assert req_a.headers["Authorization"] == "Bearer token-user-a" + assert req_b.headers["Authorization"] == "Bearer token-user-b" + + +class TestGatewayIntegrationAuthDegradation: + """GatewayIntegration must degrade gracefully when no token is available.""" + + def test_missing_token_yields_no_client_instead_of_raising(self, monkeypatch): + """A tokenless turn loses Gateway tools but does not fail outright.""" + monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "jwt") + integration = GatewayIntegration() + with patch( + "agents.main_agent.tools.gateway_integration.get_gateway_client_if_enabled", + side_effect=GatewayAuthError("no token"), + ): + result = integration.get_client(["gateway_a___b"], auth_token=None) + assert result is None + assert integration.client is None + assert integration.is_available() is False + + def test_token_is_forwarded_to_client_factory(self, monkeypatch): + """The user's token must reach the client factory unchanged.""" + monkeypatch.setenv("AGENTCORE_GATEWAY_INBOUND_AUTH", "jwt") + integration = GatewayIntegration() + sentinel = MagicMock() + with patch( + "agents.main_agent.tools.gateway_integration.get_gateway_client_if_enabled", + return_value=sentinel, + ) as mock_factory: + result = integration.get_client(["gateway_a___b"], auth_token="tok-123") + assert result is sentinel + mock_factory.assert_called_once_with( + enabled_tool_ids=["gateway_a___b"], auth_token="tok-123" + ) From d44c0536477349d1dcaa526bf6a452c1b6ae3524 Mon Sep 17 00:00:00 2001 From: derrickfink Date: Tue, 28 Jul 2026 15:15:16 -0600 Subject: [PATCH 3/3] docs(gateway): warn forks about SigV4 callers before the JWT switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../content/docs/deployment/environments.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs-site/src/content/docs/deployment/environments.md b/docs-site/src/content/docs/deployment/environments.md index 30f5a8934..e9237423f 100644 --- a/docs-site/src/content/docs/deployment/environments.md +++ b/docs-site/src/content/docs/deployment/environments.md @@ -101,6 +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) | :::caution[Extra frame ancestors are a real loosening] `CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS` and `CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS` @@ -109,6 +110,40 @@ for pointing a local SPA at a shared dev stack, but every listed origin can fram 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. + +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. + +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. +::: + ## Data retention on teardown `CDK_RETAIN_DATA_ON_DELETE` controls what happens to stateful resources when the