Infrastructure

Identity & credentials

The CredentialProvider port — one method. A tool declares what it needs; the framework resolves it before the tool runs and hands it over as ctx.credential. Machine vs user is decided per REQUEST, consent pauses by default, and a vended token never enters the trace.

Outbound auth: get a token so a tool can call a downstream service — GitHub, Slack, an internal API — on behalf of the agent or the end user.

This is distinct from Governance & policy. Governance answers is this tool allowed to run. Identity answers get me a token to call X. They are different ports, deliberately, and a deployment usually needs both.

The port

One property and one method:

interface CredentialProvider {
  readonly id: string;
  getCredential(req: CredentialRequest): Promise<CredentialResult>;
}

The requestCredentialRequest is { service, scopes?, mode?, identity?, forceReauth? }. service is the downstream service id the provider understands ('github').

The result is a two-arm union, and the second arm is the whole reason this port is not just "return a string":

ArmShapeMeaning
'issued'{ status, credential, expiresAt? }Here is a credential. The happy path — 2-legged, or 3-legged with a live refresh token
'authorization-required'{ status, authorizationUrl, sessionId }A person has to click a link before this can proceed

Narrow it with isCredentialIssued(result).

The credential carries its kind and a universal applicator, so nothing downstream ever switches on the kind:

interface Credential {
  readonly kind: string;                 // 'bearer' | 'apiKey' | 'basic' | 'headers' | <yours>
  toHeaders(): Record<string, string>;   // the way to USE it
}

Four kinds ship as factories on agentfootprint/securitybearer(token), apiKey(key, headerName?), basic(user, pass), headers(map). A custom kind is any object implementing the protocol; no library change is needed. Secret-bearing fields are defined non-enumerable, so an accidental JSON.stringify emits {"kind":"bearer"} and never the secret.

A vended token is a secret, and the port is built around that

Use it locally — headers: cred.toHeaders() inside a tool's execute. Never write it to tracked scope. Tracked writes flow to the commit log, recorders and observability exporters, which would put the token in the trace. A Credential carries a function, so structuredClone rejects it outright and it cannot enter tracked scope by accident. Implementers have a matching contract: a thrown error's message reaches the model and the agentfootprint.credential.failed event, so a provider must never echo a token or an Authorization header into one.

Declare-and-push: a tool never fetches its own token

A tool declares what it needs; the framework resolves it before invoking the tool and injects the result as ctx.credential.

import { defineTool } from 'agentfootprint';

const createIssue = defineTool({
  schema: { name: 'create_issue', description: 'File a GitHub issue', parameters: { /* … */ } },
  needs: { credential: 'github', scopes: ['repo'], mode: 'user' },
  async execute(args, ctx) {
    const res = await fetch('https://api.github.com/repos/o/r/issues', {
      method: 'POST',
      headers: { ...ctx.credential!.toHeaders(), 'content-type': 'application/json' },
      body: JSON.stringify(args),
    });
    return res.json();
  },
});

CredentialNeed is { credential, scopes?, mode? }. mode omitted means 'machine' — declare mode: 'user' explicitly for delegated access, or you will silently get a machine token.

When no provider is attached, ctx.credentials is not undefined — it is unconfiguredCredentialProvider, which throws loudly on every call. That is deliberate: there is no silent optional-chaining bypass, and a tool that needs a credential without one configured fails loud, not open. A tool that genuinely supports a degraded mode branches on ctx.hasCredentials.

Axis 1 — mode: machine or user, decided per request

Mirrors OAuth's two flows (and AgentCore's M2M vs USER_FEDERATION):

modeFlowReturnsChoose it when
'machine' (default)2-legged, client-credentialsA token directlyThe agent acts as itself — an internal service, a shared integration
'user'3-legged, user-delegatedA token, or authorization-requiredThe agent acts on behalf of a person, and the audit trail must say whose access was used

This is a property of the request, not of the provider. One provider serves both, and one agent can hold tools that declare each — which is the point: a read-only lookup can run as the service while the write runs as the user.

When a 'user' request comes back authorization-required, someone has to click a link. AgentOptions.onAuthorizationRequired decides what the run does about it:

ModeWhat happensChoose it when
'pause' (default)The run stops at the block. agent.run() returns a pause outcome, a host answers with { awaiting }, and agent.resume(checkpoint) re-resolves the credential and runs the tool that was waiting. The model is never told; nothing is fabricated.Almost always — the work is not lost, and the answer arrives on human time
'tell-model'The model reads a bracketed refusal and may route around the block. The turn still cannot report a clean completion: it raises CredentialConsentRequiredError.The agent has other useful work it can genuinely do without that service

Setting onAuthorizationRequired without a credentials provider is refused at construction — a dial with no switch behind it.

The URL is a bearer capability, not a message

The consent URL carries a session-correlating state parameter: whoever holds it can complete the flow. So it goes to the caller and to nowhere else — on PendingAsk.pauseData under 'pause', on CredentialConsentRequiredError under 'tell-model'.

It rides pauseData under a named key rather than a free-form shape, for one reason: agentfootprint.pause.request mirrors the whole of pauseData into its payload, so the emitter has to be able to find the consent block and withhold the URL from the event stream. A shape that could not be bounded by name is how the URL leaked into every observer before 8.6.0 — along with the conversation history, stream.tool_end, agent.iteration_end, the commit log, the snapshot and any recording, because it used to be interpolated into the tool-result string.

What the model reads instead names the service, says a person is handling it, and says what to do next — the same shape as the permission refusal it already knows how to read:

[authorization required: 'github' — a person must consent before this tool can
run. The caller has been told; you cannot do it yourself. Continue with work that
does not need 'github', or say plainly that you could not finish this step.]

The model is the one party that cannot act on it.

Axis 3 — transient failure: retry the transport, not the human

withCredentialRetry(provider, options) is the credential twin of withRetry for LLM providers — same option vocabulary (maxAttempts, initialDelayMs, backoffFactor, maxDelayMs, shouldRetry, onRetry) and the same default transience policy: skip AbortError and 4xx except 429; retry 5xx, network errors and unknown shapes.

import { agentCoreIdentity, withCredentialRetry } from 'agentfootprint/security';

const credentials = withCredentialRetry(agentCoreIdentity({ region: 'us-east-1' }), {
  maxAttempts: 3,
  onRetry: (err, attempt, ms) => console.warn(`credential retry ${attempt} in ${ms}ms`, err),
});

Only thrown errors retry. Both result branches return immediately: issued is success, and authorization-required is a human flow — retrying it would hammer the IdP without anybody having authorized anything.

The adapters

AdapterDoorPeer depWhat it doesStatus
staticTokens(creds, opts?)agentfootprint/securitynoneA service → credential map. A plain string is treated as a bearer token. Always 2-legged; throws for an unknown serviceShipped — dev/test
agentCoreIdentity(opts)agentfootprint/security@aws-sdk/client-bedrock-agentcoreAWS Bedrock AgentCore Identity — workload token vault + OAuth2Shipped; contract-mapped and injection-tested. The JWT exchange (9.12.0) is contract-shaped and tested against the installed SDK's own request/response shapes; awaiting field use
vaultCredentials(opts)agentfootprint/securitynone — plain HTTPHashiCorp-Vault-compatible KV v2, read over Vault's own API. V1 is token auth, no leases; every other auth method is refused by name9.8.0 — contract-shaped and tested; awaiting field use
unconfiguredCredentialProvideragentfootprint/securitynoneThe fail-closed default when nothing is attachedShipped
withCredentialRetry(inner, opts?)agentfootprint/securitynoneDecorator — retries transient getCredential failuresShipped
// dev
import { staticTokens, apiKey } from 'agentfootprint/security';
const credentials = staticTokens({ github: 'ghp_dev_xxx', internal: apiKey('k', 'x-internal-key') });

// prod — the tool code does not change
import { agentCoreIdentity } from 'agentfootprint/security';
const credentials = agentCoreIdentity({
  region: 'us-west-2',
  workloadName: 'workflow_assistant_agent',
  userIdFor: ({ principal }) => principal,   // per-(workload, user) token vault
});

Agent.create({ provider, model, credentials }).build();

Acting for a real person: the JWT exchange (9.12.0)

An agent that authenticated somebody has two things it could hand downstream, and they are not the same thing:

  • an assertionidentity.principal, a string this process wrote down. GetWorkloadAccessTokenForUserId takes the agent's word for it.
  • a proof — the token that person's identity provider signed. GetWorkloadAccessTokenForJWT exchanges it for, in the service's own words, "an opaque token representing the identity of both the workload and the user".

Until 9.12.0 only the first was reachable. Now the second is, and it changes nothing else: the exchanged token flows into the same GetResourceOauth2Token call, out through the same Credential, and onto a downstream request through the same toHeaders().

const credentials = agentCoreIdentity({
  region: 'us-west-2',
  workloadName: 'workflow_assistant_agent',   // the exchange needs this
  requireUserToken: true,                     // optional: refuse a delegated call without one
});

// Inside a tool — the JWT rides the REQUEST, because the person is per request.
const result = await ctx.credentials.getCredential({
  service: 'google',
  mode: 'user',
  userToken: callersJwt,       // what the IdP signed, as it arrived at your door
});

The whole flow, end to end:

flowchart LR
  U["User<br/>signs in at your IdP"] -->|"JWT"| D["Your front door<br/>(runtime / gateway)"]
  D -->|"the JWT, plus<br/>X-Amzn-…-Runtime-User-Id"| A["Agent<br/>(a tool's execute)"]
  A -->|"getCredential({ userToken })"| I["AgentCore Identity"]
  I -->|"GetWorkloadAccessTokenForJWT"| W["workload + user token"]
  W -->|"GetResourceOauth2Token"| C["Credential"]
  C -->|"toHeaders()"| R["Downstream resource<br/>(Google, GitHub, …)"]

Read it as one sentence: a user acts through an agent, which authenticates as a workload on that user's behalf, to reach a resource. Every arrow is a different party, and the vault entry at the end belongs to the person rather than to the agent — which is what makes revoking their access actually revoke it.

The mode rides the request, not the provider. One agentCoreIdentity serves every caller; a JWT in its construction options would be one user's live session serving all of them. So presence of userToken is what selects the exchange, and requireUserToken is the opt-in that turns its absence on a mode: 'user' call from a quiet fallback into a refusal — for a deployment where the front door really does authenticate everybody. mode: 'machine' is never affected.

Where the JWT may travel, and where it may not

The framework does not thread it for you. ctx.credentials fills in the service, the scopes and the identity — never the token — because the only routes from your door to a tool are tracked scope and the run input, and both flow to the commit log, the recorders and every observability exporter. A tool that needs it captures it at the door in its own closure, as above.

Neither the inbound JWT nor the exchanged token appears in anything this adapter throws. A failed exchange is described by the response's shape — how many fields came back and what they are called — and an SDK failure keeps its exception name and HTTP status and loses its text, because AWS clients echo request detail into failure messages and a getCredential message is read by the model, emitted on agentfootprint.credential.failed, and kept by every sink attached to it.

agentCoreIdentity — ops coverage

Three of AgentCore Identity's six data-plane operations, named honestly:

NeedDispatched commandNotes
User-delegated workload token, from a proofGetWorkloadAccessTokenForJWTCommand9.12.0. { workloadName, userToken }{ workloadAccessToken }. Engaged by req.userToken; wins over the by-userId path when both are present
User-delegated workload token, from an assertionGetWorkloadAccessTokenForUserIdCommandKeyed by userIdFor({ principal })
OAuth2 / API-key credentialGetResourceOauth2TokenCommandRequires a workload identity token first; its absence is refused by name before the call
not coveredGetWorkloadAccessTokenThe workload's own token, with no user — mode: 'machine' reaches the vault through the static workloadIdentityToken instead
not coveredGetResourceApiKeyAPI-key credential providers. Vend those with staticTokens or vaultCredentials for now
not coveredCompleteResourceTokenAuthConfirms a 3LO session server-side. This adapter surfaces the consent URL and lets the caller complete it

expiresAt is absent on issued credentials from this adapter, and that is honest rather than missing: GetResourceOauth2TokenResponse has no expiry field, so there is nothing to report.

The bug this adapter is the worked example of (9.4.0)

Until 9.4.0 it built a BedrockAgentCoreClient and then called client.getResourceOauth2Token(...) — a method that is never there, because a bare @aws-sdk/client-* client is command-based (send / destroy only). The documented path failed 100% of the time on the very first call, with a message blaming the SDK version. Every AWS adapter now pins the SDK command constructors it dispatches in test/adapters/aws/awsCommandPin.ts, and a completeness test fails the build for any src/** file that loads an @aws-sdk/* package without a row. See AWS & Bedrock AgentCore.

Provisioning the identity resources (CreateWorkloadIdentity, CreateOauth2CredentialProvider) is control-plane work — the AWS SDK or CDK, not this library.

The caller's identity, and where else it shows up (9.11.0)

run({ identity }) is the inbound half of this page — not a token to call something with, but the tuple that says who the run is for. It already scoped three things: memory namespaces, PermissionRequest.identity, and ctx.identity inside a tool.

Since 9.11.0 it scopes a fourth: every event of the run carries meta.principal and meta.tenant, which is what turns the typed stream into an audit log — who → what → when, on one wire.

await agent.run(message, {
  identity: { tenant: 'acme', principal: 'alice@acme.test', conversationId },
});

One rule governs all four consumers, and it is the same rule the credential half runs on:

Only an identity the caller NAMED. The run's internal identity is always populated — it defaults to { conversationId: '<runId>' }, or since 9.10.0 to { conversationId: sessionId } on a session-bound run — and none of those four consumers is ever handed one of those. An anonymous run stamps nothing, and nothing is invented to fill the gap.

A session id is not a principal

sessionId is caller data: anyone who can reach the host can send any string, including somebody else's. It is carried on the meta as itself, beside runId, because that is the fact the transport delivered. Promoting it to "who did this" would produce an audit trail that looks complete and names the wrong party.

Where it lands per sink — and which sink does not carry it — is on Observability sinks.

Making the caller's identity something you PROVED (9.26.0)

Everything above scopes on an identity somebody named. At a hosted door the naming can now be checked: standingAgent({ identity: { verify } }) verifies the request's Authorization: Bearer … before the run's identity is composed, so the principal those four consumers see is one a token proved rather than one a header claimed. jwksIdentity({ jwksUrl, issuer, audience }) from agentfootprint/security is the shipped verifier — one adapter for cloud IdPs and on-prem ones alike, because JWKS is the same protocol in both — and any IdentityVerifier works in its place.

The refusal law, the failure vocabulary that never contains the token, and what roles/claims do and deliberately do not reach are all on Hosting & runtime → Verified identity.

Watching credentials fail

agentfootprint.credential.requested / .acquired / .failed bracket every resolution. Subscribe to the group:

agent.on('agentfootprint.credential.failed', (e) => {
  console.error(e.payload.service, e.payload.tool, e.payload.errorClass);
});

Those events had payloads, registry entries and live emit sites for eight minors with no dispatcher bridge and no domain wildcard, so agent.on(...) observed nothing — the silence in which an identity adapter failed 100% of its calls. Fixed in 9.4.0: a credentialRecorder is attached on every run, and CredentialFailedPayload gained tool and errorClass. Defence in depth for the payloads themselves: footprintjs RedactionPolicy.emitPatterns: [/credential\.failed/].

Status

PieceStatus
CredentialProvider port, Credential protocol, four kindsShipped
Declare-and-push (needsctx.credential), fail-closed defaultShipped
mode: 'machine' | 'user' per requestShipped
onAuthorizationRequired: 'pause' | 'tell-model' + URL withholdingShipped
staticTokens, withCredentialRetryShipped
agentCoreIdentityShipped; contract-mapped and injection-tested, command names pinned
vaultCredentials (token auth, KV v2, no leases)9.8.0 — contract-shaped and tested; awaiting field use
run({ identity })EventMeta.principal / .tenantShipped (9.11.0) — explicit identity only, never session-derived

Next

On this page