UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

456 lines • 26.9 kB
/** * 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 { lazyRequire } from '../../lib/lazyRequire.js'; import { apiKey, bearer } from '../../identity/kinds.js'; /** * Re-raise a failed SDK call **without its text** (9.12.0). * * Every input this adapter sends is a secret: the user's JWT goes into * `GetWorkloadAccessTokenForJWT`, a workload access token goes into * `GetResourceOauth2Token`. AWS clients report transport and validation * failures by echoing request detail into the message — which is how a live * user session ends up in a string that this library then hands to the LLM as * a tool result and emits on `agentfootprint.credential.failed`. One echo, and * it is in the conversation, the commit log, the narrative and every sink. * * So the text does not come through. What does is the part that is both safe * and actionable: which operation failed, the exception's NAME (AWS models * them — `AccessDeniedException`, `ResourceNotFoundException`, * `ThrottlingException`, `UnauthorizedException`, `ValidationException`) and * the HTTP status. That is the same trade the Vault adapter makes, and it is * the same reason. * * **The original is deliberately not attached as `cause`.** A cause travels * with the error into every serializer that walks own properties, which would * undo the whole of this in one `JSON.stringify`. * * An injected `_client` never reaches here: it owns its own mapping, and its * own secrecy with it — the same law this file already states for field names. */ function sdkFailure(command, err) { const e = err; const name = typeof e?.name === 'string' && e.name.length > 0 ? e.name : 'an unnamed failure'; const status = e?.$metadata?.httpStatusCode; return new Error(`agentCoreIdentity: ${command} failed — ${name}` + (typeof status === 'number' ? ` (HTTP ${status})` : '') + ".\n The SDK's own message is withheld: this request carried a token, and AWS clients " + 'echo request detail into failure text. Check CloudWatch for the full exception.'); } /** * Read the exchanged workload token, or refuse **by shape**. * * Shared by both exchanges so there is one refusal to read and one rule about * what may be said: field NAMES and a count, never a value. Every field of a * token-exchange response is a token, and this message travels to the model, to * `agentfootprint.credential.failed` and to every log sink attached to it. An * operator debugging a malformed answer needs to know which fields came back, * which is exactly the part that is safe to print. */ function requireWorkloadAccessToken(response, operation) { const token = response?.workloadAccessToken; if (typeof token === 'string' && token.length > 0) return token; const fields = response && typeof response === 'object' ? Object.keys(response) : []; throw new Error(`agentCoreIdentity: ${operation} returned no usable workloadAccessToken, so there is no ` + 'user-scoped workload token to vend with — refusing rather than falling back to a ' + 'workload-level one.\n' + ` The response carried ${fields.length} field(s)` + (fields.length > 0 ? `: ${fields.join(', ')}.` : '.') + '\n Values are withheld on purpose: every field of a token-exchange response is a secret.'); } /** * Map {@link AgentCoreIdentityClientLike} onto the real SDK commands. * * Two things this function owns, and nothing else does: * 1. **The command form.** `send(new Command(input))` — see the module header * for why a method on the Client is not an option. * 2. **The wire field names.** `GetResourceOauth2Token` answers with * `sessionUri`, not `sessionId`, and never reports an expiry. Reading * `sessionId` off the response (as this adapter did through 9.3.0) yields * `undefined` forever, which arrived at the consumer as an empty * `sessionId` on every consent round-trip. An injected `_client` is * somebody else's mapping and is left alone. */ function createIdentityClient(options) { let mod; if (options._sdk) { mod = options._sdk; } else { try { // Lazy peer-dep: only loaded when no _client/_sdk is injected and getCredential runs. mod = lazyRequire('@aws-sdk/client-bedrock-agentcore'); } catch { throw new Error('agentCoreIdentity requires the `@aws-sdk/client-bedrock-agentcore` peer dependency.\n' + ' Install: npm install @aws-sdk/client-bedrock-agentcore\n' + ' Or pass `_client` for a pre-built or mock client.'); } } if (!mod.BedrockAgentCoreClient) { throw new Error('agentCoreIdentity: `@aws-sdk/client-bedrock-agentcore` is installed but ' + '`BedrockAgentCoreClient` was not found. Update the SDK.'); } const sdk = new mod.BedrockAgentCoreClient({ ...(options.region && { region: options.region }) }); const send = async (Ctor, name, input) => { if (!Ctor) { throw new Error(`agentCoreIdentity: \`@aws-sdk/client-bedrock-agentcore\` is missing ${name}. ` + 'Upgrade the SDK, or pass `_client` with your own mapping.'); } try { return await sdk.send(new Ctor(input)); } catch (err) { throw sdkFailure(name, err); } }; return { async getResourceOauth2Token(input) { if (!input.workloadIdentityToken) { // `workloadIdentityToken` is REQUIRED on GetResourceOauth2TokenRequest. // Sending the call without one buys an opaque ValidationException from // AWS; saying so here names the two ways to supply it instead. throw new Error('agentCoreIdentity: GetResourceOauth2Token requires a workload identity token, and ' + 'none was available for this request.\n' + ' Inside AgentCore Runtime the container is given one — read it and pass it as ' + '`workloadIdentityToken`.\n' + ' Elsewhere, configure `workloadName` so a per-user token is resolved via ' + 'GetWorkloadAccessTokenForUserId, or pass `workloadIdentityToken` yourself.'); } const r = (await send(mod.GetResourceOauth2TokenCommand, 'GetResourceOauth2TokenCommand', input)); return { ...(r?.accessToken !== undefined && { accessToken: r.accessToken }), ...(r?.authorizationUrl !== undefined && { authorizationUrl: r.authorizationUrl }), // The service's correlation handle for a 3LO round-trip is `sessionUri`. ...(r?.sessionUri !== undefined && { sessionId: r.sessionUri }), }; }, // ── The two workload-token exchanges ────────────────────────────── // // Both answer `{ workloadAccessToken }` and nothing else, so unlike // `getResourceOauth2Token` above there is no field to rename — and both // hand the response back AS RECEIVED rather than narrowed to that one // field. That is deliberate: when the field is missing, // `requireWorkloadAccessToken` refuses by describing the response's SHAPE, // and a shim that had already thrown the other field names away would leave // it describing an empty object — the least useful message available, on // the one path where the operator has nothing else to go on. async getWorkloadAccessTokenForUserId(input) { return ((await send(mod.GetWorkloadAccessTokenForUserIdCommand, 'GetWorkloadAccessTokenForUserIdCommand', input)) ?? {}); }, async getResourceApiKey(input) { const r = (await send(mod.GetResourceApiKeyCommand, 'GetResourceApiKeyCommand', input)); return { ...(r?.apiKey !== undefined && { apiKey: r.apiKey }) }; }, async completeResourceTokenAuth(input) { // `CompleteResourceTokenAuthRequest` is `{ sessionUri, userIdentifier }`, // where userIdentifier is a UNION with exactly one member set — the // person's own token, or the id the agent asserts for them. Success is an // empty 200, so there is nothing to map back. await send(mod.CompleteResourceTokenAuthCommand, 'CompleteResourceTokenAuthCommand', { sessionUri: input.sessionId, userIdentifier: input.userToken !== undefined ? { userToken: input.userToken } : { userId: input.userId }, }); }, async getWorkloadAccessTokenForJWT(input) { // `GetWorkloadAccessTokenForJWTRequest` is `{ workloadName, userToken }`, // both required, and the response is `{ workloadAccessToken }` — verified // against a real install of @aws-sdk/client-bedrock-agentcore before this // shipped, the same way the code-interpreter shapes were. `userToken` is // the SDK's own name for the user's IdP-issued OAuth2 token, so the // adapter and the wire agree without a rename. return ((await send(mod.GetWorkloadAccessTokenForJWTCommand, 'GetWorkloadAccessTokenForJWTCommand', input)) ?? {}); }, }; } function resolveClient(options) { // An injected client is used as-is — that seam is how a custom integration // (or a test double) replaces the whole mapping. if (options._client) return options._client; return createIdentityClient(options); } const defaultUserIdFor = (identity) => identity.principal; /** Build a {@link CredentialProvider} backed by AWS Bedrock AgentCore Identity. */ export function agentCoreIdentity(options = {}) { let client; const getClient = () => (client ??= resolveClient(options)); const userIdFor = options.userIdFor ?? defaultUserIdFor; // Per-request identity forwarding (workload identity scoping) — see module // header for the ladder and why both exchanges answer the same question. // `GetResourceOauth2Token` has no user field; the user is bound at // workload-token acquisition, so a `mode: 'user'` request resolves a // USER-SCOPED workload token first and vends with that. Both exchanges // require `workloadName` (the opt-in); with neither engaged the static // `workloadIdentityToken` flows unchanged (pre-forwarding behavior). const resolveWorkloadToken = async (req) => { const delegated = req.mode === 'user'; if (req.userToken !== undefined) { if (!delegated) { // A user's signed token on a request that asks for the WORKLOAD's own // identity. Vending M2M while holding somebody's proof is the exact // silent downgrade this whole path exists to close, so it is named // rather than resolved one way or the other. throw new Error("agentCoreIdentity: a `userToken` arrived on a `mode: 'machine'` request. M2M is the " + "workload's own identity — there is no user to act on behalf of, and the token " + "would be ignored.\n Declare `mode: 'user'` to exchange it, or drop the token."); } if (!options.workloadName) { throw new Error('agentCoreIdentity: a `userToken` arrived but no `workloadName` is configured, so ' + 'there is nothing to exchange it against — and vending at workload level would ' + "hand back a token scoped to the AGENT while holding the user's proof.\n" + ' Configure `workloadName` (your AgentCore workload identity), or stop sending ' + '`userToken`.'); } const c = getClient(); if (typeof c.getWorkloadAccessTokenForJWT !== 'function') { // The SDK-backed client always implements this, so reaching here means // an injected `_client` that does not. throw new Error('agentCoreIdentity: a `userToken` arrived, but the injected `_client` has no ' + 'getWorkloadAccessTokenForJWT. Implement it, or stop sending `userToken`.'); } return requireWorkloadAccessToken(await c.getWorkloadAccessTokenForJWT({ workloadName: options.workloadName, userToken: req.userToken, }), 'GetWorkloadAccessTokenForJWT'); } if (options.requireUserToken === true && delegated) { // The deployment said every delegated request is authenticated. One that // is not is a wiring bug, and the only alternative to saying so is to // vend something weaker and let it succeed. throw new Error("agentCoreIdentity: `requireUserToken` is set, but this `mode: 'user'` request for " + `'${req.service}' carried no \`userToken\`.\n` + " Pass the caller's own IdP token as `getCredential({ …, userToken })` — inside " + 'AgentCore Runtime behind JWT inbound auth it is the bearer token the front door ' + 'validated.\n' + ' Or drop `requireUserToken` to fall back to the by-userId exchange.'); } const userId = delegated && req.identity !== undefined ? userIdFor(req.identity) : undefined; if (userId === undefined || !options.workloadName) return options.workloadIdentityToken; const c = getClient(); if (typeof c.getWorkloadAccessTokenForUserId !== 'function') { // Explicit config must not silently degrade to workload-level tokens. // The SDK-backed client always implements this, so reaching here means an // injected `_client` that does not. throw new Error('agentCoreIdentity: `workloadName` is configured for per-user workload scoping, ' + 'but the injected `_client` has no getWorkloadAccessTokenForUserId. Implement it, ' + 'or drop `workloadName` to vend with the static `workloadIdentityToken`.'); } return requireWorkloadAccessToken(await c.getWorkloadAccessTokenForUserId({ workloadName: options.workloadName, userId }), 'GetWorkloadAccessTokenForUserId'); }; const userFlow = options.userFlow ?? 'consent'; const apiKeyServices = new Set(options.apiKeyServices ?? []); const apiKeyHeader = options.apiKeyHeader ?? 'x-api-key'; return { id: options.id ?? 'agentcore-identity', async getCredential(req) { const workloadIdentityToken = await resolveWorkloadToken(req); // An API-key service never touches the OAuth path: different operation, // different credential kind, and no consent round-trip exists for it. if (apiKeyServices.has(req.service)) { const c = getClient(); if (!c.getResourceApiKey) { throw new Error(`agentCoreIdentity: '${req.service}' is listed in \`apiKeyServices\` but the ` + 'injected `_client` has no getResourceApiKey. Implement it, or drop the service ' + 'from the list to vend it as OAuth.'); } if (!workloadIdentityToken) { // Required on `GetResourceApiKeyRequest`, exactly as on the OAuth // call — verified against the real SDK's types, not assumed. Sending // without one buys an opaque ValidationException; this names the two // ways to supply it instead. throw new Error(`agentCoreIdentity: GetResourceApiKey for '${req.service}' requires a workload ` + 'identity token, and none was available for this request.\n' + ' Inside AgentCore Runtime the container is given one — pass it as ' + '`workloadIdentityToken`.\n' + ' Elsewhere, configure `workloadName` so a per-user token is resolved first.'); } const keyRes = await c.getResourceApiKey({ resourceCredentialProviderName: req.service, workloadIdentityToken, }); if (!keyRes.apiKey) { throw new Error(`agentCoreIdentity: GetResourceApiKey for '${req.service}' returned no key.`); } return { status: 'issued', credential: apiKey(keyRes.apiKey, apiKeyHeader) }; } const res = await getClient().getResourceOauth2Token({ resourceCredentialProviderName: req.service, scopes: req.scopes ?? [], oauth2Flow: req.mode === 'user' ? userFlow === 'exchange' ? 'ON_BEHALF_OF_TOKEN_EXCHANGE' : 'USER_FEDERATION' : 'M2M', forceAuthentication: req.forceReauth ?? false, ...(workloadIdentityToken && { workloadIdentityToken }), }); if (res.accessToken) { // AgentCore Identity vends OAuth access tokens → a bearer credential. return { status: 'issued', credential: bearer(res.accessToken), ...(res.expiresAt !== undefined && { expiresAt: res.expiresAt }), }; } if (res.authorizationUrl) { return { status: 'authorization-required', authorizationUrl: res.authorizationUrl, sessionId: res.sessionId ?? '', }; } throw new Error(`agentCoreIdentity: GetResourceOauth2Token for '${req.service}' returned neither ` + 'an access token nor an authorization URL.'); }, }; } /** * 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 async function completeAgentCoreAuthorization(options) { if (!options.sessionId) { throw new Error('completeAgentCoreAuthorization: `sessionId` is required — it is the `sessionId` from ' + 'the `authorization-required` result that started this consent.'); } if ((options.userToken === undefined) === (options.userId === undefined)) { // Both or neither. Naming a person twice is ambiguous; naming them zero // times completes a consent for nobody. throw new Error('completeAgentCoreAuthorization: pass exactly one of `userToken` (the person’s own JWT, ' + 'preferred) or `userId` (an id you assert for them).'); } const client = options._client ?? createIdentityClient({ ...(options.region !== undefined && { region: options.region }), ...(options._sdk !== undefined && { _sdk: options._sdk }), }); if (!client.completeResourceTokenAuth) { throw new Error('completeAgentCoreAuthorization: the injected `_client` has no ' + 'completeResourceTokenAuth. Implement it, or omit `_client` to use the SDK.'); } await client.completeResourceTokenAuth({ sessionId: options.sessionId, ...(options.userToken !== undefined && { userToken: options.userToken }), ...(options.userId !== undefined && { userId: options.userId }), }); } //# sourceMappingURL=agentcore.js.map