Agentic Auth

Authagonal ships the building blocks for delegating a user’s authority to AI agents (or any non-human workload) safely: registered agents, fine-grained authority grants, composite delegation tokens, standing user consent, just-in-time approvals, capability tickets, and a delegation-aware audit surface. The library owns the primitives and the invariant; the host application assembles them into a product (connector implementations, approval UX, notification delivery, and business policy stay host-side).

The invariant

Every delegated token obeys:

effective authority = admin ceiling ∩ user consent ∩ task request ∩ subject-token authority

Nothing downstream can widen it — each further delegation hop intersects again, so authority only ever narrows. The intersection is implemented once (AuthoritySet.Intersect) and used everywhere.

Entities

Entity Type Notes
Agent AgentProfile on a confidential OAuthClient Registering a profile is what makes a client an agent; deleting it reverts the client to plain OAuth.
Authority AuthoritySet / AuthorityGrant RFC 9396 authorization_details shape: connector type, actions, locations, constraints, per-action auto/ask/deny policies.
Ceiling AgentProfile.Ceiling The widest authority any delegation through the agent can carry. Admin-managed (/api/v1/agents).
Consent (floor) PersistedGrant type agent_consent Per (user, agent), managed at /consent/agents. Stored pre-intersected with the ceiling and re-intersected at every mint.
Delegation RFC 8693 token exchange Composite identity: sub = user, act = agent (nested per hop), authorization_details = the effective intersection. Short-lived, never refreshable.
Approval PersistedGrant type approval JIT gate for ask-policy actions; device-flow polling semantics; single-use, request-shape-bound.
Capability ticket ICapabilityTicketService Opaque single-use handle bound to a token — the BFF ws-ticket generalized, atomic over the grant store.
Audit IAuthHook OnDelegationMintedAsync, OnApprovalRequested/ResolvedAsync, OnAgentConsentChangedAsync, OnCapabilityTicketRedeemedAsync, plus the pre-mint OnTokenIssuingAsync gate.

Registering an agent

  1. Create a confidential client allowing urn:ietf:params:oauth:grant-type:token-exchange (delegated mode) and/or client_credentials (service mode).
  2. PUT /api/v1/agents/{clientId}:
{
  "mode": "delegated",
  "ceiling": [
    {
      "type": "email",
      "actions": ["send", "read"],
      "action_policies": { "send": "ask" },
      "recipient_domains": ["@acme.com", "*.partners.acme.com"]
    },
    { "type": "calendar", "actions": ["read"] }
  ],
  "maxDelegationDepth": 0,
  "maxTokenLifetimeSeconds": 300,
  "highRiskDefault": "ask"
}

Constraint members are typed by JSON shape: string/string-array → allowlist (set-intersection meet; entries support exact, *.host wildcard, and @suffix matching), number → cap (min meet), bool → gate (AND meet). Uninterpretable members are preserved verbatim and fail closed on evaluation. GET /api/v1/agents/{clientId}/effective-grant?subjectId=… previews ceiling ∩ consent for the admin UI.

Minting a delegation

The agent authenticates as itself and exchanges the user’s token:

POST /connect/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
client_id=agent&client_secret=…            (or private_key_jwt, below)
subject_token={user access token}
subject_token_type=urn:ietf:params:oauth:token-type:access_token
authorization_details=[{"type":"email","actions":["read"]}]   (the task slice; omit = everything grantable)

The mint enforces, in order: agent mode, standing consent, sub-delegation depth (every actor already in the act chain needs maxDelegationDepth budget for one more hop), the intersection, explicit-request denials (invalid_target — an agent must not believe it holds authority it lacks), the ask-gate, and the lifetime clamps (client lifetime ∩ subject-token remainder ∩ maxTokenLifetimeSeconds). The token carries act (RFC 8693; nested per hop) and authorization_details (RFC 9396); the response echoes the granted details; introspection emits both. Exchanging a delegated token further attenuates automatically because the subject token’s own claim joins the intersection.

Clients without an agent profile keep today’s exchange behavior exactly, except that an authorization_details request parameter now narrows (never widens) the exchanged token.

Approvals (ask-gate)

When the effective slice contains an ask action, the exchange parks:

{ "error": "authorization_pending", "approval_id": "…", "interval": 5 }

The host is notified via IAuthHook.OnApprovalRequestedAsync (email/push/chat delivery is host-side). The user resolves it — GET /approvals, POST /approvals/{id} { "decision": "approve" | "deny" } — while the agent retries the identical request plus approval_id, with device-flow vocabulary throughout (slow_down, access_denied, expired_token). Approvals are single-use (atomic consume), expire after ApprovalLifetimeSeconds (default 300), and are bound to the exact request shape and current policy state — an admin ceiling edit between park and poll invalidates the approval rather than minting stale authority. A consumed approval mints with its ask actions resolved to auto (asked and answered).

Service mode (client_credentials) has no user in the loop: the ceiling applies alone and ask degrades to deny.

Resource-side enforcement

Capability tickets

ICapabilityTicketService (default GrantStoreCapabilityTicketService, registered by AddAuthagonal) mints opaque single-use handles bound to a token, redeemed atomically via the grant store’s conditional delete — durable and replay-safe across pods, unlike a plain cache’s get-then-remove. The BFF’s ws-ticket keeps its existing distributed-cache contract (WsTicketKey / TryRedeemWsTicketAsync) because its redeemer is typically a separate host sharing only Redis; co-hosted brokers should prefer the capability-ticket service.

private_key_jwt

Agents are workloads; shared secrets are the weakest link in the chain. Set OAuthClient.JwksJson (inline JWKS) or JwksUri (fetched, cached ~10 min) and authenticate with an RFC 7523 client assertion (client_assertion_type=…:jwt-bearer). Enforced: signature against the registered JWKS, iss = sub = client_id, audience = issuer or token endpoint, bounded exp (≤ 10 min), and single-use jti (replay cache over IRevokedTokenStore). A present assertion never falls back to the secret path.

Compatibility