Skip to content

docs(agent-identity): make AS-1 grants generic and move session checks to the broker - #2575

Merged
ChrisJBurns merged 1 commit into
mainfrom
docs/generic-grants
Oct 27, 2026
Merged

ChrisJBurns merged 1 commit into
mainfrom
docs/generic-grants

Conversation

@jhrozek

@jhrozek jhrozek commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Summary

AS-1 no longer knows what a Mecatl session, schedule, or owner is. It issues generic grants bound to the broker's client and the user, and everything session-shaped moves to the broker, which already does most of it.

Why. The design had AS-1 parse a mecatl_session entry, record session owners, refuse reopened sessions, keep one refresh token per session, and return Mecatl reason codes. That put Mecatl's vocabulary into ToolHive's authorization server, and every one of those checks only compares facts inside Mecatl: they catch broker or mecak8s bugs, not a compromised broker, and AS-1 runs inside the broker anyway. The ToolHive side (stacklok/toolhive#6897, #6896) is being built as a generic, standards-shaped grant instead, so the design has to match it before more Mecatl code builds on the old model.

What changes in the flow:

  • Session open. The broker checks the owner (owner_mismatch) and that the session was never closed (session_owned) before calling AS-1, and records the owner only after AS-1 accepts the token. The request carries one authorization_details entry of ToolHive's exchange-grant type: "a refresh token that issues only ID-JAGs", with an optional expires_at that AS-1 caps and echoes. No session, no owner.
  • One grant per access-token jti, with no retry exception. A lost response or crash leaves an orphan grant, valid until its expiry and usable only with the broker's key; the client refreshes its token and opens again.
  • One live refresh token per session is the broker's rule. A later open stores the new token and revokes the old one; an exchange that gets revoked retries once with the newer token. superseded is removed. A later open asks for expires_at no later than the session's remaining hard expiry.
  • Reasons. For grants, AS-1 returns only token_used, revoked, parent_revoked, and not_issuable. The broker records why it revoked each grant and reports session_closed, signed_out, or schedule_ended itself. An invalid_request with no reason at session open means the access token was refused; the broker reports it as subject_invalid.
  • Agent binding at ID-JAG issuance. AS-1 passes the actor validator the grant's sub and sub_id instead of a session ID. SVID check 8 at AS-1 becomes "the agent's session belongs to the grant's user"; "registered to this session" stays at the broker's step (6), which is now where client, user, and agent are bound (ID-JAG §9.7). The broker records each ID-JAG jti with its session, run, and instance, so pairings can be audited.
  • Schedules. Setup asks for a grant that issues refresh tokens, capped at AS-1's maximum (at most 30 days in ToolHive). A run gets a child grant that issues ID-JAGs, checked against its parent on every use, so deleting a schedule stops running runs. AS-1 never extends a grant: renewal creates a new one while Alice is present and lets the old one run out.
  • Sign-out everywhere. The broker revokes the grants it holds; AS-1's per-user revocation time (later ToolHive work) also ends grants nobody holds.

Decisions taken, after a review against RFC 8693, 9396, 7009, 9700, ID-JAG -04, and the WIMSE drafts, and against the code on both sides:

  1. Agent binding: the broker-side check plus the owner comparison above. Passing AS-1 an opaque grant handle was rejected: ToolHive would have to expose a new identifier, and the check would run against the same broker map that picked the grant.
  2. Orphan grants are accepted. The ToolHive work list now asks for replay-revoke: a reused jti revokes the grant it created.
  3. AS-1 reasons are generic; the broker translates.
  4. The upstream re-check stays as the target. Grants record the user, not an upstream session, so AS-1 needs upstream tokens stored per user.

Issue relationship

Relates to #2451, #2492
Relates to stacklok/toolhive#6886, stacklok/toolhive#6896, stacklok/toolhive#6897, stacklok/toolhive#6898, stacklok/toolhive#6899

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

Baseline checks

  • Linting (task lint)
  • Fast offline test suite (task test)
  • Pre-review race suite (task test:race)
  • Offline demo (go run ./cmd/mecademo)
  • Markdown changed: docs generation/link checks (task docs). Not run in full; matlatl check docs/drafts/agent-identity --strict gives the same result as on main (3 links out of the directory, 0 broken anchors). No code changed.
  • User docs/user-facing behavior changed: site build (task site:build)
  • Guarded engine API affected: compatibility check (task api:check)
  • Intentional engine API change: task api:update + engine/CHANGELOG.md

Changes

File Change
docs/drafts/agent-identity/credentials-and-lifecycle.md Step (3) session open, check 8, steps (6) and (7), scheduled runs, error reasons and failure matrix, lifetimes, sign-out, configuration and later work
docs/drafts/agent-identity/implementation.md AS-1 and broker grant records, session-open, ID-JAG and schedule tests, ToolHive work list
README.md, audit.md, mcp-authorization.md, run-owner.md One-line consistency fixes

User-facing change

None. Design drafts only.

Special notes for reviewers

@ChrisJBurns, what this means for your work:

#2573 (private_key_jwt), before it merges:

  1. knownReasons in serverrefusal.go: add revoked, parent_revoked, and not_issuable, or IsConfigurationFault reports them as configuration faults. Drop superseded. owner_mismatch, session_owned, session_closed, and schedule_ended no longer come from a server; keeping them is harmless.
  2. TestRetryAfterLostResponseUsesANewAssertion: resending the same access token is now refused with token_used. The new assertion and jti per attempt still hold; a retry with a fresh access token succeeds.
  3. TestServerErrorsAreClassified and the harness's clientauth_test.go expect AS-1 to return subject_invalid; AS-1 now sends no reason. The classifier can stay generic; the session-open caller in Convert the sign-in at session open #2492 maps it.
  4. The fixture's sessionOpenForm builds a mecatl_session entry. It switches to the exchange-grant entry when the harness does.

This PR also edits rows next to the two error-reasons rows #2573 changes, so whichever lands second needs a short rebase.

#2572 (TUI sign-in) and #2544 (allowlist): nothing changes. One conversion per access token still holds, and refreshing before each session open is now the only retry path.

Harness AS-1 fake (identityharness/sessionopen.go): re-cut it to the generic grant: the exchange-grant entry with a capped, echoed expires_at; one grant per jti with no retry exception; child grants refused once their parent is revoked; the reasons token_used, revoked, parent_revoked, not_issuable, and no reason for a refused access token. About 6 of its 11 tests change. A good first commit for #2492.

#2492 (session open): the broker checks the owner and never-reopen before calling AS-1, records the owner after AS-1 accepts, checks the echo (same type and output, expires_at no later than requested), applies the later-open expires_at rule, revokes the replaced token, keeps a revocation reason per grant, and maps the reasonless invalid_request and token_used to 401. Same-jti retry, replace-on-open at AS-1, the owner echo, and owner_mismatch from AS-1 are out. The broker's existing session code (sessions.go) needs no change. I'll update the ticket text to match.

#2534 (actor validator) and #2535 (per-call grant): the validator receives sub and sub_id instead of a session ID; the broker records each ID-JAG jti, handles revoked and not_issuable, and treats a reasonless refusal as a bug or clock skew. Both still wait on ToolHive's ID-JAG issuance from a grant (stacklok/toolhive#6898) and the validator input (stacklok/toolhive#6899).

…s to the broker

AS-1 no longer knows Mecatl's sessions, schedules, or owners. It issues
generic grants through RFC 8693 token exchange, described by one
authorization_details entry of ToolHive's exchange-grant type: the grant's
single output (ID-JAGs or refresh tokens) and an optional expiry that AS-1
caps and echoes. A scheduled run's refresh token is a child of the schedule
grant, checked against its parent on every use.

The broker now does what AS-1 did for sessions:
- the owner check and the never-reopen check run at the broker before it
  calls AS-1, with the same reason codes;
- one live refresh token per session: a later open stores the new token
  and revokes the old one; superseded is removed;
- the broker records why it revoked each grant and reports Mecatl's reason
  in place of AS-1's generic token_used, revoked, parent_revoked, and
  not_issuable.

A reused access-token jti is refused even on retry, so a lost response
leaves an orphan grant usable only with the broker's key until its expiry.

At ID-JAG issuance, AS-1 passes the grant's sub and sub_id to the actor
validator, which checks that the agent's session belongs to that user. The
broker's step (6) checks are where client, user, and agent are bound
(ID-JAG section 9.7). The broker records each ID-JAG jti with its session,
run, and instance so pairings can be audited.

Schedule renewal now creates a new grant while the user is present and
lets the old one run out; AS-1 never extends a grant.

Co-Authored-By: mecatl <noreply@mecatl.dev>
@jhrozek
jhrozek force-pushed the docs/generic-grants branch from 012cf2a to 3e64e9b Compare October 27, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants