UNPKG

agentfootprint

Version:

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

88 lines • 4.58 kB
"use strict"; /** * verify/personTest — only a person's token is a person (rule 5). * * A verified signature proves who SIGNED a token, not that it stands for a * person. On Entra ID any application registered in the tenant can obtain an * app-only token for this API by the client-credentials grant — right issuer, * right audience, a valid signature and an `oid` — and Microsoft says so: * "app-only tokens can be issued without a `roles` claim. Applications that * expose APIs must implement permission checks in order to accept tokens." * A verifier that accepted it would hand a service principal a `principal`, * conversations, a model budget and the agent's tools. * * Five checks, in this order, because each names a different shape: * * c. `idtyp: "app"` — Entra's own marker, refused whenever present. * d. `oid` equal to `sub` — Microsoft's documented app-only test. * b. roles and NO scope claim at all — a daemon's token carries app roles * instead of delegated scopes. * a. the required scope in the scope claim — Microsoft: `scp` is "Only * included for user tokens". * e. the client that obtained the token is one this deployment lists — on * Okta and Keycloak a client-credentials token may carry scopes too, and * this is the check that refuses it there. * * a–d fail as `not-a-user-token`; e fails as `wrong-client`. `scp` alone is * Microsoft's test, not an IdP-neutral one; the checks together are. * * Check b is DEFENCE IN DEPTH, kept on purpose: today no token's outcome * depends on it, because check a refuses every token with no scope claim in * the same words. It stays so that a later change to a — an optional scope, a * scope read from elsewhere — cannot silently admit a daemon's roles-only * token. (A mutation that deletes it survives the suite, and that is why.) * * **The client check is load-bearing on Keycloak and Okta**, where a service * account's client-credentials token carries your API's scope: every client in * `allowedClients` must have service accounts / client credentials turned OFF, * and `'any'` is refused in production by `identityFromConfig`. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.rolesAreElsewhere = exports.personTestFailure = void 0; const claims_js_1 = require("./claims.js"); /** The failure class, or `undefined` when the token stands for a person. */ function personTestFailure(payload, options) { if (payload.idtyp === 'app') return 'not-a-user-token'; const { oid, sub } = payload; if (typeof oid === 'string' && typeof sub === 'string' && oid === sub) return 'not-a-user-token'; const scopes = (0, claims_js_1.scopesOf)((0, claims_js_1.claimAt)(payload, options.scopeClaim)); if (scopes === undefined && hasRoles(payload, options.rolesClaim)) return 'not-a-user-token'; if (scopes === undefined || !scopes.includes(options.requiredScope)) return 'not-a-user-token'; if (options.allowedClients === 'any') return undefined; const client = (0, claims_js_1.clientOf)(payload); if (client === undefined || !options.allowedClients.includes(client)) return 'wrong-client'; return undefined; } exports.personTestFailure = personTestFailure; /** Entra's app roles live in `roles`; the configured claim may be another. */ function hasRoles(payload, rolesClaim) { return payload.roles !== undefined || (0, claims_js_1.claimAt)(payload, rolesClaim) !== undefined; } /** * Unknown is not none (rule 14). When the roles claim was REPLACED by a * pointer to somewhere else — OIDC's distributed claims (`_claim_names`), which * is what Entra sends instead of `groups` above 200 groups, or Entra's * `hasgroups: true` — the person's roles are not in this token. Reading them * as "no roles" would strip a person silently; this says so instead. */ function rolesAreElsewhere(payload, rolesClaim) { // Distributed claims (`_claim_names`) name TOP-LEVEL claims, so a roles path // is elsewhere when its first name is. const top = typeof rolesClaim === 'string' ? rolesClaim : rolesClaim[0]; if (top === undefined) return false; const names = payload._claim_names; if (names !== null && typeof names === 'object' && !Array.isArray(names)) { if (Object.prototype.hasOwnProperty.call(names, top)) return true; } return top === 'groups' && payload.hasgroups === true; } exports.rolesAreElsewhere = rolesAreElsewhere; //# sourceMappingURL=personTest.js.map