agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
62 lines • 3.72 kB
TypeScript
/**
* 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`.
*/
import type { IdentityFailureClass } from '../../../hosting/errors.js';
import { type ClaimPath } from './claims.js';
/** What the person test is configured with. */
export interface PersonTestOptions {
/** The OAuth scope only this API's person tokens carry. One scope, exact. */
readonly requiredScope: string;
/** Where the scopes are: `'scp'` (Entra, AD FS) or `'scope'` (RFC 9068, Keycloak, Okta). */
readonly scopeClaim: ClaimPath;
/** Clients allowed to obtain a person token for this API, or `'any'` (the check is off). */
readonly allowedClients: readonly string[] | 'any';
/** The configured roles claim — part of check b's "roles without a scope". */
readonly rolesClaim: ClaimPath;
}
/** The failure class, or `undefined` when the token stands for a person. */
export declare function personTestFailure(payload: Readonly<Record<string, unknown>>, options: PersonTestOptions): Extract<IdentityFailureClass, 'not-a-user-token' | 'wrong-client'> | 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.
*/
export declare function rolesAreElsewhere(payload: Readonly<Record<string, unknown>>, rolesClaim: ClaimPath): boolean;
//# sourceMappingURL=personTest.d.ts.map