agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
306 lines • 18.2 kB
JavaScript
;
/**
* entraIdentity — the {@link CredentialProvider} port over Microsoft Entra ID
* (peer-dep `@azure/identity`).
*
* import { entraIdentity } from 'agentfootprint/security';
* const credentials = entraIdentity();
*
* ── What it is, and what it deliberately is not ─────────────────────────────
* This is the **narrow** adapter: it vends *Entra* access tokens for *Azure*
* APIs, from whatever credential the environment already has — the
* DefaultAzureCredential chain walks environment service principal, workload
* identity, managed identity, VS Code, Azure CLI, Azure PowerShell and the
* Azure Developer CLI, in that order. That is one job and it is done
* completely.
*
* It is **not** a user-delegation surface. Entra's on-behalf-of flow (and any
* 3-legged consent dance) needs a confidential client app registration that
* this adapter does not hold, so `mode: 'user'` is **refused by name** rather
* than quietly served with a machine token. A machine token returned where a
* user token was asked for is the exact silent downgrade the port exists to
* prevent: the call succeeds, the data comes back, and it was the agent's
* access rather than the person's. OBO is a later train; when it lands it will
* be its own provider, not a flag here.
*
* ── The audience split, and where it bites ──────────────────────────────────
* Azure tokens are minted for ONE audience. {@link AZURE_AI_SCOPE}
* (`https://ai.azure.com/.default`) is the data plane — every Foundry and
* Azure OpenAI inference call takes it. {@link AZURE_MANAGEMENT_SCOPE}
* (`https://management.azure.com/.default`) is the ARM control plane —
* listing deployments, creating resources. A token for one audience is a 401
* on the other, which is why BOTH are exported by name instead of leaving the
* caller to guess a string. The default here is the data-plane scope, because
* vending inference credentials is what an agent runtime does all day.
*
* ── Caching: the credential, never a token ──────────────────────────────────
* One `DefaultAzureCredential` is constructed for the life of the provider and
* every `getToken` call goes through it. MSAL — the machinery underneath
* `@azure/identity` — caches and proactively refreshes tokens internally, so
* caching a token HERE would mean owning an expiry this adapter did not
* compute and cannot see revoked. Unlike the Google adapter (where scopes are
* fixed at client construction and a different scope set needs its own
* client), Azure scopes travel per `getToken` call, so the ONE cached
* credential serves every scope set.
*
* ── Secrets ─────────────────────────────────────────────────────────────────
* The `sdkFailure` law, same as every other credential-touching adapter here:
* the library's own message never comes through, because auth libraries echo
* request detail into 401/403 text and a message thrown from a
* `CredentialProvider` reaches the LLM as a tool result AND rides
* `agentfootprint.credential.failed` to every sink. What comes through is the
* operation that failed and the error's NAME. The original is not attached as
* `cause` — a cause travels into every serializer that walks own properties,
* which would undo all of it in one `JSON.stringify`.
*
* Pattern: Adapter (GoF) + lazy peer-dep load — `@azure/identity` is required
* the first time `getCredential` runs, or never if you inject a credential.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.entraIdentity = exports.AZURE_COGNITIVE_SERVICES_SCOPE = exports.AZURE_MANAGEMENT_SCOPE = exports.AZURE_AI_SCOPE = void 0;
const lazyRequire_js_1 = require("../../lib/lazyRequire.js");
const kinds_js_1 = require("../../identity/kinds.js");
const ADAPTER = 'entraIdentity';
/**
* The data-plane scope for ALL Foundry / Azure OpenAI inference
* (`https://ai.azure.com/.default`). This is the default scope this provider
* requests.
*
* The audience split matters: a token minted for this scope does NOT work on
* the ARM control plane, and a {@link AZURE_MANAGEMENT_SCOPE} token does not
* work here — Azure validates the audience on every call. Both are exported by
* name so nobody has to remember which string is which.
*/
exports.AZURE_AI_SCOPE = 'https://ai.azure.com/.default';
/**
* The ARM control-plane scope (`https://management.azure.com/.default`) —
* listing deployments, managing resources. A DIFFERENT audience from
* {@link AZURE_AI_SCOPE}: a token for one is a 401 on the other, which is why
* both are named rather than leaving the caller to guess.
*/
exports.AZURE_MANAGEMENT_SCOPE = 'https://management.azure.com/.default';
/**
* The CLASSIC Azure OpenAI data-plane scope
* (`https://cognitiveservices.azure.com/.default`) — the audience Microsoft's
* own keyless guidance names for the older deployment-scoped route
* (`{endpoint}/openai/deployments/{d}/…`), which is the route `azureOpenai()`
* builds and therefore its default. Current resources widely accept
* {@link AZURE_AI_SCOPE} too, but an older `*.openai.azure.com` resource may
* not — and a door should default to the audience ITS route documents, not the
* one its sibling uses. (Azure Government spells this
* `https://cognitiveservices.azure.us/.default`.)
*/
exports.AZURE_COGNITIVE_SERVICES_SCOPE = 'https://cognitiveservices.azure.com/.default';
/**
* Vend Entra access tokens from whatever credential this environment has —
* the DefaultAzureCredential chain: environment service principal, workload
* identity, managed identity, VS Code, Azure CLI, Azure PowerShell, Azure
* Developer CLI. (`AZURE_TOKEN_CREDENTIALS` can restrict the chain; that is
* the SDK's own dial and this adapter does not second-guess it.)
*
* @throws when `mode: 'user'` is requested — no user-delegation surface is
* wired for Entra yet (on-behalf-of is a later train), and a machine token
* returned in its place would be a silent downgrade.
* @throws when `services` is configured and the request names another one.
*
* @example A tool that calls an Azure API with the deployment's own identity
* const agent = Agent.create({ provider, credentials: entraIdentity() })
* .tool(defineTool({
* name: 'ask_foundry',
* needs: [{ credential: 'azure-ai' }],
* execute: async (args, ctx) =>
* fetch(url, { headers: ctx.credential!.toHeaders() }).then((r) => r.text()),
* }))
* .build();
*
* @example A control-plane token, without touching the data-plane default
* entraIdentity({ scopes: [AZURE_MANAGEMENT_SCOPE] });
*/
function entraIdentity(options = {}) {
const defaultScopes = options.scopes ?? [exports.AZURE_AI_SCOPE];
const allowed = options.services === undefined ? undefined : new Set(options.services);
const cache = {};
const resolveCredential = () => {
if (options._credential)
return options._credential;
// ONE credential for the life of the provider — scopes ride on getToken
// per call (unlike Google, where they are fixed at client construction),
// so no per-scope keying is needed. MSAL underneath caches and refreshes
// tokens on its own; we cache the CREDENTIAL, never a token.
if (cache.credential)
return cache.credential;
const mod = loadIdentitySdk(options._sdk);
if (typeof mod.DefaultAzureCredential !== 'function') {
throw new Error(`${ADAPTER}: \`@azure/identity\` is installed but exports no ` +
`\`DefaultAzureCredential\`. This adapter is built against the 4.x package — ` +
`update it, or pass \`_credential\`.`);
}
cache.credential = new mod.DefaultAzureCredential();
return cache.credential;
};
return {
id: options.id ?? 'entra-identity',
async getCredential(req) {
if (req.mode === 'user') {
throw new Error(`${ADAPTER}: a \`mode: 'user'\` request arrived for '${req.service}', and this ` +
`provider cannot serve one.\n` +
` It vends the DEPLOYMENT's Entra credential (the DefaultAzureCredential ` +
`chain — environment service principal, workload identity, managed identity, ` +
`or a developer's \`az login\`). No user-delegation surface is wired for Entra ` +
`yet — the on-behalf-of flow is a later train.\n` +
` Returning a machine token here would succeed and be wrong: the call would ` +
`run with the AGENT's access rather than the person's, and nothing downstream ` +
`could tell.\n` +
` Fix: declare \`mode: 'machine'\` if the deployment's own identity is really ` +
`what you want, or vend the user's token from a provider that holds one.`);
}
if (req.userToken !== undefined) {
// A user's signed token handed to a provider that cannot exchange it.
// Named rather than ignored: without a confidential client app
// registration there is no on-behalf-of exchange to perform, and
// silently dropping somebody's proof and vending machine access is
// the same downgrade in a quieter costume.
throw new Error(`${ADAPTER}: a \`userToken\` arrived for '${req.service}', but this provider has ` +
`nothing to exchange it against — the on-behalf-of flow needs a confidential ` +
`client app registration, and this provider vends the deployment's own Entra ` +
`credential.\n` +
` Ignoring it would hand back agent-scoped access while holding the user's ` +
`proof. Drop the token, or use a provider that can exchange one.`);
}
if (allowed !== undefined && !allowed.has(req.service)) {
throw new Error(`${ADAPTER}: this provider is configured for [${[...allowed].join(', ')}] and was ` +
`asked for '${req.service}'.\n` +
` It vends ENTRA access tokens; handing one to a tool that wanted a different ` +
`service's credential would fail downstream as a puzzling 401 instead of here ` +
`as a wiring error.\n` +
` Fix: add '${req.service}' to 'services', or attach a provider that serves it.`);
}
let credential;
try {
credential = resolveCredential();
}
catch (err) {
// A refusal this adapter authored (a missing peer dependency, a
// too-old SDK) is already the right diagnosis; rewriting it through
// sdkFailure would send the reader chasing Entra sign-in logs for a
// problem `npm install` fixes. Wrong diagnoses are their own kind of
// silently-wrong.
if (isOwnRefusal(err))
throw err;
throw sdkFailure('new DefaultAzureCredential', err);
}
// A request's own non-empty scopes win over the provider's default.
// The array goes to getToken as-is — Azure scopes are per-call, and
// `.default` scopes are single-element by convention anyway.
const scopes = req.scopes !== undefined && req.scopes.length > 0 ? req.scopes : defaultScopes;
let answer;
try {
answer = await credential.getToken(scopes);
}
catch (err) {
throw sdkFailure('getToken', err);
}
const token = answer?.token;
if (typeof token !== 'string' || token.trim() === '') {
// The SCOPES are quoted; nothing else is. An audience URI is public,
// and it is the datum most often wrong here — "could not mint for the
// requested scope" is useless when the reader cannot see WHICH scope
// was requested (a tool that declares `needs` without `scopes` gets
// this provider's default, which it never typed anywhere). The token
// response's own fields stay withheld: every one of them is a secret.
// Same conclusion `entraBearerToken` reached out loud in
// src/adapters/llm/OpenAIProvider.ts — the two siblings now agree.
throw new Error(`${ADAPTER}: the credential resolved but vended no access token for ` +
`'${req.service}' at scope${scopes.length === 1 ? '' : 's'} ` +
`[${scopes.join(', ')}] — ${answer === null
? '`getToken` returned null, the SDK\'s spelling of "no token available"'
: "the response's `token` field was empty"}.\n` +
` No token value is quoted here on purpose — every field of a token response is ` +
`a secret. This usually means the chain found a credential source that could not ` +
`actually mint for THAT audience.\n` +
` Fix: check the audience first — inference is ${exports.AZURE_AI_SCOPE} and the ARM ` +
`control plane is ${exports.AZURE_MANAGEMENT_SCOPE}; name the right one in the request's ` +
`\`scopes\` or this provider's \`scopes\` option. If it is already right, sign in as ` +
`an identity that can mint for it (\`az login\`, or a managed identity with the role).`);
}
// The SDK records expiry in unix MILLISECONDS; the port reports unix
// SECONDS. Reported when known and omitted when not — an invented
// expiry is worse than none, because a caller would cache against it.
// (`answer` cannot be null past the token guard; the optional chain is
// for the compiler, which does not carry the narrowing across fields.)
const expiryMs = answer?.expiresOnTimestamp;
const expiresAt = typeof expiryMs === 'number' && Number.isFinite(expiryMs) && expiryMs > 0
? Math.floor(expiryMs / 1000)
: undefined;
return {
status: 'issued',
credential: (0, kinds_js_1.bearer)(token),
...(expiresAt !== undefined && { expiresAt }),
};
},
};
}
exports.entraIdentity = entraIdentity;
// ─── Internals ───────────────────────────────────────────────────────
function loadIdentitySdk(injected) {
if (injected)
return injected;
try {
return (0, lazyRequire_js_1.lazyRequire)('@azure/identity');
}
catch {
throw new Error(`${ADAPTER} requires the \`@azure/identity\` peer dependency.\n` +
` Install: npm install @azure/identity\n` +
` It is optional and loaded only when this provider first vends, so nothing else ` +
`in this library pays for it.`);
}
}
/**
* "There is no usable credential here" — the most common real failure, given
* the fix instead of the library's own text. `@azure/identity` names it
* `CredentialUnavailableError` (one rung) or `AggregateAuthenticationError`
* (the whole chain came up empty); both mean the same thing to the reader.
*/
function credentialsUnavailable(name) {
const failure = new Error(`${ADAPTER}: could not acquire an Entra token in this environment — ${name}.\n` +
` The underlying message is withheld: auth libraries echo request detail into ` +
`failure text, and this message reaches the model as a tool result.\n` +
` Every rung of the DefaultAzureCredential chain was tried: environment service ` +
`principal, workload identity, managed identity, VS Code, Azure CLI, Azure ` +
`PowerShell, Azure Developer CLI.\n` +
` Fix: run \`az login\`, or set AZURE_CLIENT_ID / AZURE_TENANT_ID / ` +
`AZURE_CLIENT_SECRET, or run where a managed identity exists, or pass \`_credential\`.`);
failure.name = 'AzureCredentialsUnavailableError';
return failure;
}
/** Re-raise without the library's text. See the module header for why. */
function sdkFailure(operation, err) {
const name = errorName(err);
// The no-credential names get the fix, not just the diagnosis — but ONLY
// those names. Everything else is reported as what it is.
if (name === 'CredentialUnavailableError' || name === 'AggregateAuthenticationError') {
return credentialsUnavailable(name);
}
const failure = new Error(`${ADAPTER}: ${operation} failed — ${name}.\n` +
` The underlying message is withheld: this call handles an access token, and auth ` +
`libraries echo request detail into failure text. Check the Entra sign-in logs for ` +
`the full error.`);
failure.name = 'AzureCredentialError';
return failure;
}
/**
* Did THIS adapter write this error?
*
* Every refusal authored here opens with the adapter's own name — both as the
* marker and because it is what makes the message readable ("entraIdentity:
* …", "entraIdentity requires …"). A library's own failure never does, so the
* prefix is a reliable discriminator without an error subclass per refusal.
*/
function isOwnRefusal(err) {
return err instanceof Error && err.message.startsWith(ADAPTER);
}
function errorName(err) {
const name = err?.name;
return typeof name === 'string' && name.length > 0 ? name : 'an unnamed failure';
}
//# sourceMappingURL=azure.js.map