agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
304 lines (303 loc) • 17.5 kB
TypeScript
/**
* agentCoreIdentity — AWS Bedrock AgentCore Identity adapter (peer-dep
* `@aws-sdk/client-bedrock-agentcore`).
*
* import { agentCoreIdentity } from 'agentfootprint/security';
* const credentials = agentCoreIdentity({ region: 'us-east-1' });
*
* Maps the {@link CredentialProvider} port onto AgentCore Identity's
* `GetResourceOauth2Token` (the SDK's `@requires_access_token` underneath):
* - request.mode 'machine' → `M2M`; 'user' → `USER_FEDERATION`
* - request.service → the configured OAuth2 credential-provider name
* - request.identity → (per-request workload identity scoping; see below)
* - request.userToken → the user's own JWT, exchanged for a user-scoped
* workload token before vending (9.12.0; see below)
* - a returned access token → `{ status: 'issued', credential: bearer(token) }`
* - a returned auth URL → `{ status: 'authorization-required' }` (3LO consent)
*
* The token vault + refresh-token handling live in AgentCore, so repeat calls
* usually return a token directly (no consent round-trip).
*
* **Per-request identity forwarding (workload identity scoping).**
* `GetResourceOauth2Token` carries NO user/tenant field — in AgentCore the
* user identity is bound EARLIER, at workload-token acquisition. There are two
* ways to bind it, and this adapter dispatches both:
*
* `GetWorkloadAccessTokenForUserId(workloadName, userId)` — the ASSERTION.
* The agent says who the user is; AWS takes its word for it and returns a
* workload token keyed to that (workload, user).
* `GetWorkloadAccessTokenForJWT(workloadName, userToken)` — the PROOF (9.12.0).
* The agent hands over the token the user's own identity provider signed,
* and gets back "an opaque token representing the identity of both the
* workload and the user" (the service's own words for the response).
*
* Either way the answer is a `workloadAccessToken`, and it flows into
* `GetResourceOauth2Token`'s `workloadIdentityToken` unchanged — which is why
* the second one needed no new credential path, no new result branch and no
* change to `toHeaders()`. AgentCore keys its token vault + 3LO grants per
* (workload, user), so the whole point of resolving either is that the vault
* entry belongs to the person rather than to the agent.
*
* **The JWT rides the REQUEST.** `req.userToken` is per call, because the
* person calling is per call; a JWT in this provider's construction options
* would be one user's session serving everybody. Presence is what selects the
* exchange — a proof that arrived is never downgraded to an assertion — and
* `requireUserToken` turns "no JWT on a `mode: 'user'` request" from a quiet
* fallback into a refusal, for a deployment where every user IS authenticated.
*
* The ladder, in order, for one `getCredential(req)`:
* 1. `req.userToken` present → exchange it (needs `workloadName`; `mode: 'user'`).
* 2. else a userId derives from `req.identity` (default `identity.principal`;
* override via `userIdFor`) on a `mode: 'user'` request, and
* `options.workloadName` is configured → the by-userId exchange.
* 3. else the static `options.workloadIdentityToken` flows exactly as before.
*
* `tenant` has no native AgentCore field and is NOT forwarded by default —
* tenant isolation derives from the workload identity itself (per-tenant
* workloads), or encode it via `userIdFor` (e.g. `${tenant}:${principal}`).
*
* **Secrecy.** The inbound JWT and the exchanged workload token are secrets of
* equal weight, and neither appears in anything this file throws. A failed
* exchange is described by the SHAPE of the response — how many fields it
* carried and what they are called — never by its content, because every field
* of a token-exchange response is a token. A thrown message reaches the LLM as
* a tool result AND rides `agentfootprint.credential.failed`, so an error that
* quotes the response has published the user's session to both at once.
*
* ── How this talks to the SDK (9.4.0 — read this before editing) ────────────
* Through **`client.send(new SomeCommand(input))`**, never through a method on
* the client. A bare `@aws-sdk/client-*` **Client** is command-based: its
* prototype carries `send` and `destroy` and NOTHING ELSE. The per-operation
* shortcuts (`getResourceOauth2Token(...)`) live on the AGGREGATED client
* (`BedrockAgentCore`), which is a different class.
*
* Until 9.4.0 this adapter built a `BedrockAgentCoreClient` and then duck-typed
* `client.getResourceOauth2Token` — a method that is never there — so the
* documented path failed 100% of the time on the very first call, with a
* message blaming the SDK version. Every test injected `_client` and so never
* touched the shim. The sibling memory adapter in this same package had the
* command form right all along; this one now matches it, and
* `test/adapters/aws/aws-command-pin.test.ts` pins the command names for both.
*
* Pattern: Adapter (GoF) + lazy peer-dep load — the AWS SDK is required only when
* `getCredential` first runs (or never, if you inject `_client` / `_sdk`).
*/
import type { CredentialProvider } from '../../identity/types.js';
/** Raw result shape we consume from the AgentCore identity client. */
export interface AgentCoreOauthResponse {
readonly accessToken?: string;
readonly authorizationUrl?: string;
/** Correlates a 3LO consent round-trip. The real service reports this as
* `sessionUri`; the SDK shim renames it here. */
readonly sessionId?: string;
/** Unix seconds. **AgentCore does not report one** —
* `GetResourceOauth2TokenResponse` has no expiry field, so this is only ever
* populated by an injected `_client` that knows one from elsewhere. */
readonly expiresAt?: number;
}
/** The minimal, operation-semantic surface the adapter calls — `GetResourceOauth2Token`
* and (for per-user workload scoping) `GetWorkloadAccessTokenForUserId`.
* `createIdentityClient` maps the real SDK's `send(new Command(...))` onto this;
* tests and custom integrations inject a fake via `_client`. */
export interface AgentCoreIdentityClientLike {
getResourceOauth2Token(input: {
readonly resourceCredentialProviderName: string;
readonly scopes: readonly string[];
readonly oauth2Flow: 'M2M' | 'USER_FEDERATION' | 'ON_BEHALF_OF_TOKEN_EXCHANGE';
readonly forceAuthentication: boolean;
readonly workloadIdentityToken?: string;
}): Promise<AgentCoreOauthResponse>;
/** Optional — required only when `workloadName` is configured. Exchanges
* (workloadName, userId) for a USER-SCOPED workload access token; AgentCore
* keys its token vault + 3LO grants per (workload, user). */
getWorkloadAccessTokenForUserId?(input: {
readonly workloadName: string;
readonly userId: string;
}): Promise<{
readonly workloadAccessToken?: string;
}>;
/** Optional — required only when a request carries `userToken`. Exchanges the
* user's OWN IdP-issued JWT for a workload access token representing both the
* workload and the user. Same answer shape as the by-userId exchange, which
* is why both feed the same `workloadIdentityToken`. */
getWorkloadAccessTokenForJWT?(input: {
readonly workloadName: string;
readonly userToken: string;
}): Promise<{
readonly workloadAccessToken?: string;
}>;
/** Optional — required only for services named in `apiKeyServices`. AgentCore
* keeps API keys in the same vault as OAuth tokens, behind a different
* operation, so this is a sibling of `getResourceOauth2Token` rather than a
* mode of it. */
getResourceApiKey?(input: {
readonly resourceCredentialProviderName: string;
/** REQUIRED on the wire — `GetResourceApiKeyRequest` declares it non-optional. */
readonly workloadIdentityToken: string;
}): Promise<{
readonly apiKey?: string;
}>;
/** Optional — required only by {@link completeAgentCoreAuthorization}. Tells
* AgentCore that the person behind `sessionId` finished consenting, which is
* what releases the token into the vault for the next vend. */
completeResourceTokenAuth?(input: {
readonly sessionId: string;
readonly userToken?: string;
readonly userId?: string;
}): Promise<void>;
}
export interface AgentCoreIdentityOptions {
readonly region?: string;
/** The agent's workload access token (AgentCore Runtime injects one in-container;
* supply it explicitly when running elsewhere). Used as-is unless a per-user
* workload token is resolved (see `workloadName`). */
readonly workloadIdentityToken?: string;
/** The AgentCore workload identity name — the OPT-IN for per-request identity
* scoping, and the one option BOTH exchanges need. When set, a `mode: 'user'`
* request resolves a per-user workload access token before vending — from
* `req.userToken` via `GetWorkloadAccessTokenForJWT` when the user's own JWT
* arrived, otherwise from `req.identity` via `GetWorkloadAccessTokenForUserId`
* — so AgentCore's token vault + 3LO grants are keyed per (workload, user)
* instead of per workload. Omit → today's static-token behavior. */
readonly workloadName?: string;
/**
* Refuse a `mode: 'user'` request that carries no `req.userToken` (9.12.0),
* instead of falling back to the by-userId exchange or the static token.
*
* The opt-in for a deployment where the front door really does authenticate
* every person — an AgentCore Runtime behind JWT inbound auth, an API gateway
* that validates a bearer token. There, a delegated request with no proof
* attached is a wiring bug, and the failure it causes without this flag is the
* quiet kind: a token gets vended, the call succeeds, and it was scoped to the
* agent (or to a user id the agent asserted) rather than to the person.
*
* `mode: 'machine'` is never affected — M2M is the workload's own identity and
* has no user to prove.
*/
readonly requireUserToken?: boolean;
/** Map `req.identity` → the AgentCore `userId`. Default: `identity.principal`.
* `tenant` has no native AgentCore field — encode it here if you need
* tenant-scoped vault entries (e.g. ``({ tenant, principal }) =>
* tenant && principal ? `${tenant}:${principal}` : principal``). Return
* `undefined` to skip per-user scoping for that request. */
readonly userIdFor?: (identity: {
readonly principal?: string;
readonly tenant?: string;
}) => string | undefined;
/**
* How a `mode: 'user'` request gets its token (9.66.0). Default `'consent'`.
*
* `'consent'` — AgentCore's `USER_FEDERATION`. If the vault has no grant
* for this (workload, user), the result is an
* `authorization-required` with a URL to send the person to.
* They approve once; later calls return a token directly.
* `'exchange'` — AgentCore's `ON_BEHALF_OF_TOKEN_EXCHANGE`. The person's
* own login is TRADED for a scoped downstream token, with no
* consent screen at any point.
*
* `'exchange'` is not simply the nicer one. It works only where the
* downstream provider was configured for it (an on-behalf-of exchange grant
* on the credential provider) and where trading the user's session for
* downstream access is a decision your organisation has already made — the
* consent screen is what asks the person, and this flow is the deployment
* saying it does not need to. Choose it deliberately.
*
* `mode: 'machine'` is unaffected: M2M has no user to act for.
*/
readonly userFlow?: 'consent' | 'exchange';
/**
* Services whose credential is an API KEY, not an OAuth token (9.66.0).
*
* AgentCore's vault holds both kinds behind two different operations, and the
* provider NAME alone does not say which — so a request for a service named
* here is vended with `GetResourceApiKey` and comes back as an
* {@link apiKey} credential. Everything else takes the OAuth path.
*
* There is no auto-detection on purpose: guessing would mean calling one
* operation, catching a failure, and retrying with the other, which turns a
* configuration mistake into two round-trips and an ambiguous error.
*/
readonly apiKeyServices?: readonly string[];
/** Header an API-key credential is sent in. Default `'x-api-key'`. */
readonly apiKeyHeader?: string;
/** Stable provider id (default 'agentcore-identity'). */
readonly id?: string;
/** Test seam — inject a client implementing {@link AgentCoreIdentityClientLike}.
* Bypasses the SDK entirely; the field mapping below is then yours. */
readonly _client?: AgentCoreIdentityClientLike;
/** @internal Test injection — the AWS SDK module, to exercise the real shim
* (`send(new Command(...))`) with a fake SDK. */
readonly _sdk?: BedrockAgentCoreIdentitySdkModule;
}
/** The slice of `@aws-sdk/client-bedrock-agentcore` this shim touches. */
export interface BedrockAgentCoreIdentitySdkModule {
readonly BedrockAgentCoreClient?: new (config: {
region?: string;
}) => {
send(cmd: unknown): Promise<unknown>;
};
readonly GetResourceOauth2TokenCommand?: new (input: unknown) => unknown;
readonly GetWorkloadAccessTokenForUserIdCommand?: new (input: unknown) => unknown;
readonly GetWorkloadAccessTokenForJWTCommand?: new (input: unknown) => unknown;
readonly GetResourceApiKeyCommand?: new (input: unknown) => unknown;
readonly CompleteResourceTokenAuthCommand?: new (input: unknown) => unknown;
}
/** Build a {@link CredentialProvider} backed by AWS Bedrock AgentCore Identity. */
export declare function agentCoreIdentity(options?: AgentCoreIdentityOptions): CredentialProvider;
/** Connection options for {@link completeAgentCoreAuthorization}. */
export interface CompleteAgentCoreAuthorizationOptions {
/** The 3LO round-trip this completes — the `sessionId` from the
* `authorization-required` result the agent returned. */
readonly sessionId: string;
/** The person's own IdP-issued JWT, if your callback has it. Preferred: it is
* the artifact their provider signed. */
readonly userToken?: string;
/** The user id you asserted for them, when no JWT is available. Exactly one
* of `userToken` / `userId` is required. */
readonly userId?: string;
readonly region?: string;
/** Test seam — same client surface the provider uses. */
readonly _client?: AgentCoreIdentityClientLike;
/** @internal Test injection — the AWS SDK module. */
readonly _sdk?: BedrockAgentCoreIdentitySdkModule;
}
/**
* Tell AgentCore the person finished consenting, so the token lands in the vault.
*
* ── Where this runs, and why it is not a provider method ─────────────────────
* A 3LO consent has three actors and two processes. The agent asks for a
* credential and gets back `authorization-required` with a URL and a session
* id; the PERSON opens that URL in their browser and approves; AgentCore then
* redirects their browser to a callback route **your web app** owns. That route
* is where this belongs — a different process from the agent run, often a
* different service — so it takes its own connection options rather than
* pretending to be a method on a provider that route has never seen.
*
* Your route's job before calling this is the part nobody else can do: confirm
* the browser session really belongs to the user you are about to name. This
* function is the handshake, not the authentication.
*
* ── After it returns ─────────────────────────────────────────────────────────
* Nothing is handed back — success is an empty acknowledgement. The token now
* exists in the vault for that (workload, user), and the way to obtain it is to
* run the agent's request again: the next `getCredential` for that service
* returns `issued` where the last one returned `authorization-required`. In
* agentfootprint terms the consent is a PAUSE, and this is what makes the
* resume succeed.
*
* The authorization URL and its session are short-lived (AWS documents ten
* minutes) — a person who wanders off has to start the consent again, and the
* refusal you get is AgentCore's, not this adapter's.
*
* @example An Express-style callback route
* app.get('/oauth/callback', async (req, res) => {
* const user = await requireSignedInUser(req); // yours, and load-bearing
* await completeAgentCoreAuthorization({
* sessionId: String(req.query.session),
* userId: user.id,
* region: 'us-east-1',
* });
* res.send('Approved — you can return to the assistant.');
* });
*/
export declare function completeAgentCoreAuthorization(options: CompleteAgentCoreAuthorizationOptions): Promise<void>;