All insights

Cognito now does the token exchange we had to build

TL;DR. On 12 November AWS announced that Amazon Cognito now implements OAuth 2.0 token exchange, RFC 8693, at its token endpoint and in a new GetDerivedToken API. When we wrote post 05 of our AgentCore series it did not, so we built the exchange AgentCore Identity needs ourselves, a front door on API Gateway and Lambda, a second user pool and four Lambda triggers. We had the native exchange running behind AgentCore Identity the next day, in both of its modes. It does the job of the front door, the exchange pool and the triggers. The token comes from the customer's own pool, and in delegation mode it names the agent as well as the customer. Four things caught us out, and they are below.

What post 05 had to build

Post 05 gave the series' order support agent a token for the order service, minted for the one customer it was serving, through AgentCore Identity's on-behalf-of exchange. That exchange is RFC 8693, and at the time Cognito's token endpoint offered only the authorisation code, refresh token and client credentials grants.

So we followed AWS's own sample and built the missing piece. An endpoint on API Gateway and Lambda spoke RFC 8693 to AgentCore Identity. A second Cognito pool minted the token through its custom authentication flow, and four Lambda triggers verified the customer's token and wrote the customer_id claim that the gateway's Cedar policy checks. That came to about 450 lines of Terraform and 700 of Python before the tests. Post 05 called it a teaching component and said that in production the exchange belongs to the identity provider.

Post 07 then showed what it cost at run time. The first exchange of a turn failed after 6.1 seconds while the front door and the triggers started cold, and a retry covered for it.

What Cognito does now

An app client that is allowed to exchange tokens presents a customer's access token and gets back a new access token for an audience it names. AWS's guide calls it "well suited to service-to-service and agent-based scenarios". There are two modes. Impersonation issues a token that stands for the customer alone. Delegation adds an actor token, here the agent's own client credentials token, and the issued token carries an act claim that names the agent. Cognito does not evaluate may_act, so which agents may act for a customer is still decided by which app clients you allow to exchange, and by your policy.

Cognito now does the checking our front door and triggers did, and on scopes it is stricter. The issued token can only carry scopes the customer's token already held and the client may issue, a pre token generation trigger cannot add one, and asking for any other scope is refused. The audience must be a resource server registered in the pool. The customer must be enabled and confirmed, and the subject token's signature, expiry and revocation are checked. No refresh token is issued. If you still want a claim of your own, the pre token generation trigger runs for an exchange too.

This is the token AgentCore Identity brought back in our test, in delegation mode, with the ids shortened.

{
  "username": "c-1000",
  "client_id": "<agent client>",
  "aud": "orders",
  "act": {
    "sub": "<agent client>",
    "client_id": "<agent client>",
    "iss": "https://cognito-idp.us-east-1.amazonaws.com/<customer pool>",
    "token_type": "urn:ietf:params:oauth:token-type:access_token"
  }
}

What it removes from post 05

Post 05 With Cognito's exchange
Exchange endpointAPI Gateway and a Lambda front doorThe customer pool's token endpoint
Pool that mints the tokenA second pool, only for the exchangeThe customer pool
Lambda triggersDefine, create and verify challenge, and pre token generationNone
Credential providerPoints at the front doorPoints at the customer pool
Gateway trustsThe exchange pool, with the orders client as audienceThe customer pool, with the orders resource server as audience
Cedar comparescustomer_id, which a trigger copied from usernameusername itself
Token namesThe customerThe customer, and in delegation the agent
Lambdas to start coldFiveNone

AgentCore's side keeps its shape. The runtime's inbound auth, the workload identity and the token vault do the same jobs. The credential provider stays a custom OAuth2 provider, now on the customer pool's discovery URL, with the agent's app client and its secret, the token exchange grant, and actor token content NONE for impersonation or M2M for delegation. That setting, not the agent's code, chooses the mode. For delegation the app client also needs the client credentials grant and a scope of its own, orders/read here, which AgentCore asks for to get the actor token. The gateway's authorizer and the Cedar policy move to the customer pool and its claims.

In the agent, the request names the audience, asks for no scopes and says what kind of token the customer's is. The token came back with no scope claim, as the customer's only scope is not one the agent's client may issue. The API reference lists at least one scope as required, but the service accepted an empty list.

result = client.get_resource_oauth2_token(
    workloadIdentityToken=workload_token,
    resourceCredentialProviderName=os.environ["OBO_PROVIDER_NAME"],
    oauth2Flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
    scopes=[],
    audiences=["orders"],
    customParameters={
        "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
    },
)

What caught us out

  1. The token endpoint wanted managed login. Exchanges returned token_exchange_requires_managed_login until the pool's domain used managed login version 2. Switching an existing domain over had not taken effect after five minutes, and creating the domain on version 2 worked at once. The guide asks only for a domain, so this may change. GetDerivedToken needs no domain, but AgentCore Identity uses the token endpoint.
  2. The subject must be typed as an access token. Cognito accepts urn:ietf:params:oauth:token-type:access_token and refuses the generic JWT type as unsupported. AgentCore Identity's exchange failed with HTTP 400 until we passed the type in customParameters, as above.
  3. Scopes cannot be added, and that is the safer rule. Post 05's trigger put orders/read into the minted token, a scope the customer's own token never carried. Nothing in post 05 checked it, so it did no harm there, but our exchange was granting the agent something the customer had not been given. For an agent acting for a customer, the exchange should only narrow what the customer's token allows. Cognito enforces that, so asking for orders/read was refused. Here the agent asks for none and the audience and Cedar do the work. To use the scope, the customer has to be granted it when they sign in, through Cognito's OAuth flow rather than its sign in API.
  4. The tooling is behind the service. The current boto3, 1.44.2, does not model GetDerivedToken, so we signed that call by hand, though it did pass the new app client flows, ALLOW_TOKEN_EXCHANGE_IMPERSONATION and ALLOW_TOKEN_EXCHANGE_DELEGATION, through to the service. The Terraform AWS provider, at 6.73.0, refuses those two flows, so for now set them through the API. The token endpoint's reference page still lists three grants. The pool must be on the Essentials or Plus plan, as post 05's exchange pool already was, and the guide lists the feature being enabled for your account as a prerequisite. Ours already had it.

One more thing to check rather than a trap. Post 05 minted five minute tokens. Ours lived just under an hour, as long as the customer's token had left, which was also the agent client's default access token lifetime. Look at the lifetime you get before you rely on it.

Where that leaves the series

This is how we would build post 05 today. The exchange would live in the customer's own pool, in delegation mode, so the token the gateway checks names both the customer and the agent acting for them. We ran the exchange end to end through AgentCore Identity in both modes. We have not yet run the gateway and Cedar against the new token. The gateway would check the customer pool's signature and the orders audience, which should also keep refusing the customer's own token, as it carries no aud. Cedar would compare username, the claim post 05's trigger copied into customer_id. That is the next thing we run.

If you followed post 05, the front door and the exchange pool are on their way out. If you are about to build on-behalf-of access for an agent on AWS, start with Cognito's exchange.

Sources

Talk to us about this

Contact

Talk to us before your next AI investment.

We run this work in our own business every day. If you are planning to run it in yours, the first conversation is diagnostic, not a pitch.