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
JavaScript
;
/**
* 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