Skip to content

atenet: cache RFC 8693 token exchanges in a broker - #3344

Open
Max Thompson (thompsonmax) wants to merge 6 commits into
agent-substrate:mainfrom
thompsonmax:token-exchange-broker
Open

Max Thompson (thompsonmax) wants to merge 6 commits into
agent-substrate:mainfrom
thompsonmax:token-exchange-broker

Conversation

@thompsonmax

@thompsonmax Max Thompson (thompsonmax) commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #1661. Stacked on #3334 and #3335. Only "atenet: cache RFC 8693 token exchanges in a broker" is new; the other commits are from #3334 and #3335.

  • Adds tokenexchange.Broker. It mints a 300-second actor JWT bound to the exchange's audience through the gateway's actorjwt.Minter, exchanges it with the atenet: add an RFC 8693 token exchange client to the egress gateway #3335 client, and caches the access token per actor, token_url, audience, and scopes. It copies actorjwt.Minter's cache.Expiring and detached singleflight.
  • A token is reused until a third of its lifetime or 30 seconds remains, whichever comes first, and for 60 seconds when the endpoint returns no expires_in.
  • The detached exchange has its own 10-second deadline, so an endpoint that never answers can't hold its key forever. A caller that gives up gets DeadlineExceeded.
  • A mint failure wraps ErrMint, so the gateway can map it as it maps actor JWT mint failures. An endpoint's PermissionDenied needs a different status than the minter's.

Nothing calls the broker until the next PR wires it into applyEffects. It takes the minter through a one-method interface so its tests can use a fake.

Testing: synctest unit tests cover the reuse windows, collapsed concurrent exchanges, an abandoned exchange still filling the cache, the hung-endpoint deadline, the cache key, and error handling. Each fails when the code it covers is removed. A module-wide go test -race ./... passes except cmd/ateworker-microvm/internal/child, which fails on main on macOS too, and an atelet registry test that timed out under load and passes alone. hack/verify-all.sh passes.

AI use: AI-assisted (Claude Code) for code, tests, and docs. I reviewed and tested it.

  • Tests pass
  • Docs updated (none until the e2e PR)

A replace_headers entry can now set token_exchange, naming an RFC 8693
token endpoint, an audience, and scopes. The egress gateway will exchange
the actor's JWT there and inject the returned access token.

ateapi requires token_url to be https with a DNS hostname and no
userinfo, query, or fragment. It also rejects a policy unless one of its
https rules allows the token_url's host and port, so the endpoint stays
within what the policy already allows. EgressPolicyTemplate gets the same
check.

The gateway does not exchange tokens yet and denies a request that
carries a token_exchange header with a 500. Without that case the entry
would reach the credential provider with an empty URI.

Signed-off-by: Max Thompson <maxthomp@google.com>
Egress policies will let the gateway exchange an actor JWT for an access
token at an RFC 8693 endpoint. Add the client for that. It posts the token
exchange form, caps the response at 1 MiB, follows no redirects, ignores
proxy environment variables, and maps endpoint errors to gRPC codes that
the gateway can turn into a 403, 500, or 503.

Add --token-exchange-ca-file for endpoints with private certificates. The
router builds the client at startup so a bad file fails startup. Nothing
calls the client yet.

Signed-off-by: Max Thompson <maxthomp@google.com>
Add tokenexchange.Broker, which mints a 300-second actor JWT bound to the
exchange's audience, exchanges it, and caches the access token per actor,
token URL, audience, and scopes. A token is reused until a third of its
lifetime or 30 seconds remains, whichever comes first, and for 60 seconds
when the endpoint returns no expires_in.

Concurrent requests for one token share a single exchange. The exchange
outlives a caller that gives up, so a slow endpoint still fills the cache
for the next request, and it has its own 10-second deadline so an endpoint
that never answers cannot hold the exchange forever. Mint failures wrap
ErrMint so the gateway can map them like other actor JWT mint failures.

Signed-off-by: Max Thompson <maxthomp@google.com>
TestSecretLikeFieldsAreLabeled flags fields whose names look like
credentials. token_exchange holds an endpoint, an audience, and scopes,
and token_url can carry no userinfo or query, so neither holds a secret.

The token URL check now skips headers that use another source, and the
token_url doc names the DNS hostname rule. The http-rule test case now
fails on the protocol alone, and the scopes limits have test cases.

Signed-off-by: Max Thompson <maxthomp@google.com>
A malformed token response's JSON error can quote the endpoint's own
text, such as an expires_in number a megabyte long. Clip it like the
other endpoint-supplied error fields.

Exchange's doc now names each error code by outcome. New tests cover DEL
and a space in access_token and a truncated response body, and the
deadline test uses an expired context instead of waiting.

Signed-off-by: Max Thompson <maxthomp@google.com>
Each token_exchange entry costs a pass over every rule when ateapi
checks that an https rule allows its token URL. A policy with thousands
of entries could cost seconds of CPU. Reject more than 16, the same
bound as one rule's replace_headers, before running the check.

Signed-off-by: Max Thompson <maxthomp@google.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant