aws-samples / aws-samples/sample-autonomous-cloud-coding-agents
RFC: Identity propagation — composable credential delegation via AgentCore Identity Token Vault
- Dominant language
- TypeScript
- Stars
- 143
- Forks
- 46
- Avg merge
- 3d 9h
- Merged PRs (30d)
- 20
Description
# RFC #249: Identity propagation, composable credential delegation via AgentCore Identity Token Vault
Status: Draft for discussion. Drafted by Bonk, reviewed and filed by Laith Al-Saadoon.
Relates to: #245 (attribution data plane), #209 (SessionRole isolation), #215 (Bedrock cost attribution), #237 (governance planes), ADR-016 (pluggable identity/auth), PR #315.
Grounding: AgentCore Identity/Runtime/Gateway deep-dive v1.2 (2026-05-13); AWS API + boto3 references re-verified 2026-06-04; Phase 0 spike executed live 2026-06-14 (`us-east-1`).
## TL;DR
- **What:** Give ABCA's outbound calls (GitHub, Linear, and future targets) an enforceable per-task, per-user, short-lived credential issued by the AgentCore Identity Token Vault, replacing the shared Secrets-Manager PAT. Attribution becomes cryptographic instead of advisory.
- **Design stance:** Identity is **composable**, because ABCA is a reference architecture adopters deploy into their own environments, each with their own IdP and ops/IT stack. Two independent, swappable seams: a pluggable **IdP-inbound** seam (Cognito, Okta, Entra, PingOne, Auth0, raw OIDC) and a pluggable **target-outbound** seam (GitHub, GitLab, Jira, Linear, Slack, internal services). Cognito → GitHub is the first worked example, not the architecture.
- **The blocker is cleared.** Phase 2.0a parked on a `USER_FEDERATION` PAR/`request_uri` bug. A live Phase 0 spike on 2026-06-14 (`us-east-1`) shows that bug **does not reproduce** in the current service build: AgentCore keeps PAR on its own front-channel and hands GitHub plain authorization-code + PKCE. Verdict: **GO-LIKELY**.
- **Ask:** Approve proceeding to Phase 1. Stand up a vault provider behind a flag, keep the PAT fallback, bind on the inbound JWT's `(iss, sub)`. The one remaining unproven step is a human OAuth-consent click (can't run headless); the flow is proven end-to-end up to GitHub's login wall.
- **Decisions needed (Open Questions):** per-target scoping model (OAuth App vs GitHub App, generalized per target); consent UX for webhook-triggered tasks with no live user.
---
## 1. Problem
ABCA propagates *who* as data but not as a credential. The identity story has two halves.
**#245 owns the data half.** It propagates `{task_id, user_id, repo, trace_id}` through orchestrator logs, agent OTel baggage, and `TaskEvents` so any action is traceable. In flight.
**This RFC owns the credential half.** Outbound calls to GitHub, Linear, and future Jira/Slack/internal targets should carry an enforceable per-task, per-user, short-lived identity, so attribution becomes cryptographic rather than advisory. This is the delegation chain #245 lists as out-of-scope follow-up, and it covers ROADMAP's "Per-repo GitHub credentials" and "Layered credential derivation" items.
Current credential posture is the gap:
| Surface | Today | Risk |
|---|---|---|
| GitHub | Shared PAT in Secrets Manager (`agent/src/config.py:resolve_github_token`). | One token for all repos. No per-user or per-repo scoping. Tracked in SECURITY.md. |
| Linear | Per-workspace OAuth token in Secrets Manager, manual refresh (`linear-oauth-resolver.ts`). | Workspace-scoped, not user-scoped. No `act`-claim audit chain. |
| AgentCore Identity | Parked at Phase 2.0a; fell back to direct Secrets Manager. | Outbound identity collapses to a shared service credential. |
## 2. What is already wired (dormant, not missing)
The propagation path exists end-to-end on the AgentCore Runtime substrate today. Three pieces are in place:
- Orchestrator sets `runtimeUserId` on `InvokeAgentRuntimeCommand` (`agentcore-strategy.ts:56`).
- Orchestrator role holds `bedrock-agentcore:InvokeAgentRuntimeForUser` (`task-orchestrator.ts:288-289`).
- Agent reads the `WorkloadAccessToken` header and re-injects it across the pipeline thread; the per-thread ContextVar workaround is already solved (`server.py:283-310, 387-413`).
`bedrock-agentcore==1.9.1` stays pinned in `agent/pyproject.toml` so the path imports cleanly on resume. This RFC resumes and completes that path; it does not build from scratch.
## 3. Design
### 3.1 Composability is the design (load-bearing)
ABCA is a **reference architecture adopters deploy into their own environments**. The team does not operate one central instance; each adopter brings their own IdP and their own ops/IT stack. The identity plane therefore composes across **multiple IdPs on ingress and multiple integration targets on egress**, treating any single vendor pair as one worked example. Cognito → GitHub is that example; the design hard-wires neither.
Two independent, swappable seams:
- **Inbound (IdP) seam.** Pluggable across Cognito, Okta, Entra ID, PingOne, Auth0, or any raw OIDC provider the adopter runs. ABCA binds on the inbound JWT's `(iss, sub)`, which makes the binding key **IdP-agnostic by construction**: `iss` namespaces the subject, so two adopters on different IdPs never collide and the design privileges none of them.
- **Outbound (target) seam.** Pluggable across GitHub, GitLab, Jira, Linear, Slack, or an internal service. The Token Vault is the substrate that makes this composable: its `credentialProviderVendor` enum carries 25 built-in vendors (`GithubOauth2`, `SlackOauth2`, `AtlassianOauth2`, `MicrosoftOauth2`, `OktaOauth2`, and more) plus a `CustomOauth2` escape hatch for any target without a built-in vendor, including an adopter's internal OIDC/OAuth service.
Implementation keeps an **OIDC-inbound descriptor** and an **OAuth2-outbound resolver** as separate, registry-driven seams (the ADR-016 / PR #315 shape), so an adopter swaps their IdP or adds a target by configuration instead of forking the credential plane. `resolve__token()` stays one vendor-neutral seam.
### 3.2 Shared binding key
AgentCore Identity binds tokens to `(workload_identity, user_id)`. The `user_id` value must match the one #245 standardizes: the **IdP-namespaced** form derived from the inbound JWT's `(iss, sub)`, for example `cognito+`, `okta+`, or `entra+`. That form avoids `sub` collisions across IdPs and keeps the key identical no matter which IdP an adopter deploys. One identity, two planes: #245 stamps it in logs and spans, this RFC stamps it in tokens. Bind on the verified JWT (`get_workload_access_token_for_jwt`), not the opaque-userId path, so the namespace comes from the authenticated `iss` rather than a caller-supplied string.
### 3.3 Flow selection
The deep-dive's decision tree maps each integration to a flow:
| Integration | Inbound | Downstream | Flow |
|---|---|---|---|
| Write to a target as the user (commit/PR, issue update) | any IdP | different ecosystem (github.com, gitlab.com, linear.app) | `USER_FEDERATION` 3LO |
| Headless or webhook with no user | none | n/a | `M2M` client-credentials under a manual workload identity |
| Future AWS-native downstream | any IdP | same ecosystem | OBO (`ON_BEHALF_OF_TOKEN_EXCHANGE`) with `act`-claim delegation |
A webhook authenticates the tenant, not the user; that is the honest caveat. The real consent moment is when the agent calls back out. Read-only triage can use an M2M service account; write-back gates on fresh per-user consent.
### 3.4 Token Vault providers
Vault-managed providers replace direct Secrets Manager reads. A `GithubOauth2` provider issues short-lived tokens with auto-refresh, retiring the shared PAT. `AtlassianOauth2`, `SlackOauth2`, and others follow the same pattern as those integrations land; anything without a built-in vendor uses `CustomOauth2`. The agent calls `GetResourceOauth2Token`; on a first 3LO call with no cached token it surfaces the returned authorization URL and gates write-back until consent.
### 3.5 Delegation chain
Where the downstream can consume it, use OBO delegation with `actorTokenContent=M2M`. The issued token then carries an `act` claim recording `user → orchestrator → agent`, the signed actor chain in ROADMAP's "Delegation chain propagation" item. It gives per-action accountability and feeds #237's `abca.audit.v1` `correlation` block.
### 3.6 Substrate-independence (raised by @krokoko on the original thread)
The Token Vault operations are plain boto3 (`create_oauth2_credential_provider` on `bedrock-agentcore-control`; `get_workload_access_token_for_*` and `get_resource_oauth2_token` on `bedrock-agentcore`) with no dependency on AgentCore Runtime. The one substrate-specific seam is how the agent obtains the workload identity token before the vault call:
- **AgentCore Runtime.** Setting `runtimeUserId` makes the platform inject the `WorkloadAccessToken` request header (`agentcore-strategy.ts:47-59`); the agent reads the header.
- **ECS Fargate.** No runtime injects a token (`ecs-strategy.ts:44-46` accepts `userId` but marks it unused); the agent mints the token in-process via `get_workload_access_token_for_jwt(workloadName, userToken)`, then makes the identical vault call.
So the downstream call shape is one seam regardless of substrate. Binding on the Cognito/OIDC JWT keeps the key derived from `(iss, sub)` and identical across substrates, which also keeps it aligned with #245. The Phase 0 spike (§4) verified this rather than assuming it.
## 4. Phase 0 result (executed 2026-06-14, `us-east-1`, sandbox account, throwaway)
**Verdict: GO-LIKELY.** The PAR/`request_uri` incompatibility that parked Phase 2.0a (the primary no-go gate, `aws/bedrock-agentcore-sdk-python#111`) **does not reproduce in the current service build.** Run against a throwaway `GithubOauth2` provider with a real Cognito pool/user and workload identity. Everything testable without a human GitHub-consent click passed.
| Go/no-go criterion | Result | Evidence |
|---|---|---|
| PAR `ValidationException` no-go (the parked bug) | **PASS, does not occur** | First `get_resource_oauth2_token(USER_FEDERATION, forceAuthentication=True)` returned a clean `authorizationUrl`. The PAR `request_uri` sits on AgentCore's **own** front-channel (`bedrock-agentcore.us-east-1.amazonaws.com/identities/oauth2/authorize`); AgentCore then 302-redirects to `github.com/login/oauth/authorize` with plain auth-code + PKCE (`client_id`, `redirect_uri`=AgentCore callback, `response_type=code`, `scope=repo`, `state`, `code_challenge`, `code_challenge_method=S256`). **No `request_uri` reaches GitHub.** |
| GitHub accepts the request shape | **PASS** | GitHub routed the AgentCore-built authorize URL through its normal sign-in (`return_to` echoes the full OAuth params). It validates `client_id` only post-login, so a placeholder client reached that wall. |
| Production JWT binding to `(iss, sub)` | **PASS** | `get_workload_access_token_for_jwt` minted a token from a real Cognito IdToken; `iss=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_…`. The opaque-userId dev path also works. |
| Substrate independence (the @krokoko point) | **PASS** | Every control- and data-plane call ran from a plain boto3 session with **no AgentCore Runtime** in the loop, which is direct evidence the vault is an independent primitive, so the ECS path holds. |
| OBO reachable post-GA in `us-east-1` | **PASS (reachable)** | `oauth2Flow="ON_BEHALF_OF_TOKEN_EXCHANGE"` returns a *provider-type* constraint (`not supported for OAuth2 credential provider type: GithubOauth2`, since GitHub OAuth has no token exchange), not a "flow unavailable" error. The flow is live and validated. |
**Remaining for a full GO** (the one leg that can't run headless): a real GitHub OAuth App + one human authorize click within the 10-minute TTL → then `forceAuthentication=False` returns a usable `accessToken` → push a commit + open a PR. The shape is proven end-to-end up to GitHub's login wall, so this is expected to pass.
**Confirmed live, correcting earlier assumptions:** `GithubOauth2` is a first-class vendor (25-vendor enum); `githubOauth2ProviderConfig` needs only `clientId` + `clientSecret`; `create_oauth2_credential_provider` returns `status=READY` immediately and does **not** validate creds against GitHub (it stores them in Secrets Manager), which is why the PAR gate is testable with placeholder creds. All throwaway resources were torn down; nothing touched any ABCA deployment account.
## 5. Phasing
- **Phase 0, re-validate.** ✅ Done 2026-06-14 (`us-east-1`): GO-LIKELY, above.
- **Phase 1, first target via the vault, behind a flag.** Stand up the `GithubOauth2` provider; switch `resolve_github_token()` to the vault path behind a flag, keeping the PAT fallback; retire the shared PAT once green. Keep the inbound descriptor and outbound resolver vendor-neutral so a second IdP or target is config, not a fork.
- **Phase 2, Linear onto the vault.** Move per-workspace OAuth to the vault provider; delete the manual-refresh code in `linear-oauth-resolver.ts`.
- **Phase 3, delegation chain.** Add the OBO `act` claim where the downstream supports it; emit the actor chain into #237's `correlation` block.
## 6. Acceptance criteria
- [x] Phase 0 spike records whether AgentCore Identity flows work post-GA, with date + region. (GO-LIKELY, 2026-06-14, `us-east-1`.)
- [ ] The `user_id` binding key matches #245's IdP-namespaced value; log attribution and token binding do not diverge.
- [ ] Outbound to the first target uses a vault-issued, short-lived token; the shared PAT leaves the hot path (a flagged fallback may remain).
- [ ] A first-call 3LO surfaces an authorization URL and gates write-back on consent; no silent shared-credential fallback for user-attributed actions.
- [ ] The inbound IdP and outbound target are independently swappable by configuration, proven by standing up the worked path plus one alternate provider (or a documented `CustomOauth2` path) without editing the credential-plane code.
- [ ] No regression to #209 SessionRole tenant isolation; the `WorkloadAccessToken` bridge degrades gracefully if the dependency is absent (already holds).
- [ ] The Phase 3 `act` chain is joinable to #245's `trace_id` and feeds #237's `correlation`.
## 7. Open questions (decisions needed)
1. **Per-target scoping model** (worked through GitHub first). GitHub App vs per-user 3LO OAuth App: an App gives repo-scoped installation tokens matching the per-repo ROADMAP item, but installs per-org rather than per-user, and the `GithubOauth2` vault vendor cannot express it (that vendor only drives OAuth-App 3LO), so it needs a `CustomOauth2` / side-channel installation-token path. A 3LO App gives per-user attribution but coarser repo scope. **Generalize the decision:** each egress target has its own scoping primitive (GitHub App vs OAuth App, GitLab project access tokens, Atlassian scopes), so this is a per-target fork the outbound resolver must accommodate, not a one-time GitHub choice.
2. **Consent UX for webhook-triggered tasks with no live user.** Confirm the split: M2M service account for reads, consent gate for writes.
3. **Token Vault cost.** Roughly $0.010 per 1,000 `GetResourceOauth2Token` fetches; negligible at current volume. Noted for #215 forecasting.
## 8. Out of scope
- Trace/log attribution mechanics (owned by #245; this RFC consumes its envelope).
- Bedrock billing attribution (owned by #215).
- Workload-anchored credential binding via MicroVM attestation (separate ROADMAP item; complementary).
- Cedar authorization decisions (orthogonal; the delegation chain may inform them later).
---
*This issue body was consolidated 2026-06-15 to carry the full, current position in one place: the original RFC, the Phase 0 spike plan + executed GO-LIKELY result, and the composability stance. The comment thread below preserves the history. Drafted by Bonk; edited via Laith's account.*
Contributor guide
Assessment
This issue has not been assessed yet.