Documentation
¶
Overview ¶
Package authgate implements the AARM R10 auth-required gate — the pause-and-resume primitive behind delegated MCP consent (#330).
It is the sibling of deferpolicy (R4c): where DEFER parks the executor until a human *approves an action*, authgate parks it until a user *completes an OAuth consent* and the platform holds a grant. Both share the same shape — a goroutine blocks on a handle, an out-of-band signal resolves it, a timeout auto-fails — but the semantics differ enough to justify a distinct engine:
- Keyed by {subject, server}, NOT taskID. A grant is per user per server; once alice consents to "atl", EVERY parked call of hers to "atl" resumes — across tasks/sessions. So one gate fans out to many waiters, and one Resolve wakes them all. (deferpolicy is one waiter per taskID.)
- The resolution is binary — granted or timed-out. There is no approver/reject: the "approver" is the OAuth flow itself, and the signal is "a grant now exists," delivered by the platform's consent callback or a bounded re-poll of the delegated resolver.
The gate never mints a token and never sees one: it only unblocks the executor so it can re-resolve through the normal delegated path, which now succeeds because the grant exists (delegation follows authorization, design-tool-registry.md §18.5). Token custody stays with the resolver.
In-process only, like deferpolicy — a restart abandons pending gates (each blocked goroutine's stack disappears). The Engine is the seam for a future persistent implementation.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Decision ¶
type Decision string
Decision is how a parked gate resolved.
const ( // DecisionGranted: the user consented and the platform now holds a // grant — the executor should re-resolve and proceed. DecisionGranted Decision = "granted" // DecisionTimeout: no consent arrived within the window — the call // fails, same as the pre-gate ErrNoToken behavior. DecisionTimeout Decision = "timeout" // DecisionCanceled: every waiter abandoned the gate (all contexts // cancelled) before consent — the gate is torn down with nothing left // to resume. DecisionCanceled Decision = "canceled" )
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine coordinates pending auth-required gates across the runtime. Thread-safe. Every gate is single-flighted per {subject, server}: the first Await creates it and is told to deliver the consent prompt; later Awaits attach silently.
func (*Engine) Await ¶
Await returns the pending gate for {subject, server}, creating it if none exists. The bool is true only for the caller that CREATED the gate — that caller (and only that caller) should deliver the consent prompt; joiners get false and must not re-deliver, or one user would get N prompts for N concurrent calls.
subject and server are required. A blank subject means no requesting user is in context, which is a caller bug (a gate can't be addressed to nobody).
func (*Engine) Peek ¶
Peek reports whether a gate is pending for {subject, server}. Used by the resume endpoint to 404 signals that target no parked call.
func (*Engine) Pending ¶
Pending returns the count of currently-parked gates. For health checks and startup logs.
func (*Engine) Resolve ¶
Resolve wakes every waiter on {subject, server} with the given decision (normally DecisionGranted, from a consent callback). Idempotent — a second Resolve, or a Resolve racing the timeout, is a no-op guarded by the gate's sync.Once. Returns an error only when no gate is pending (404-like).
type Handle ¶
type Handle struct {
// contains filtered or unexported fields
}
Handle is a pending gate. Executors obtain one from Engine.Await and block on WaitCtx; the resume signal (or the timeout) resolves it, broadcasting the Resolution to every waiter at once via a closed channel.
A Handle is shared: concurrent Await calls for the same {subject, server} return the SAME *Handle. It is opaque — the engine owns its lifecycle.
func (*Handle) WaitCtx ¶
func (h *Handle) WaitCtx(ctx context.Context) (Resolution, error)
WaitCtx blocks until the gate resolves (consent granted, timeout, or cancellation) or ctx is cancelled. On ctx cancellation it detaches this waiter — and if it was the last one holding the gate open, tears the gate down (DecisionCanceled) so an abandoned prompt doesn't linger to its timeout. Returns (Resolution{}, ctx.Err()) on cancellation.
type Resolution ¶
Resolution is the outcome delivered to every waiter on a gate.
func (Resolution) Granted ¶
func (r Resolution) Granted() bool
Granted reports whether the resolution means "proceed."
type Spec ¶
type Spec struct {
// Timeout overrides defaultTimeout when > 0.
Timeout time.Duration
// TaskID / Session identify the request that first tripped the gate,
// for audit and for routing the consent prompt back to the right
// conversation. Later joiners' task/session are intentionally dropped —
// one consent serves them all.
TaskID string
Session string
// CorrelationID is the invocation id of the request that first tripped the
// gate. It travels with the first waiter so the resume paths (the loopback
// OAuth callback, POST /mcp/consent) can attribute the completion egress +
// audit back to the still-in-flight parked invocation (#366).
CorrelationID string
}
Spec carries the delivery/audit context for a gate. None of these fields are part of the dedup key — the key is {subject, server} — they travel with the FIRST waiter so the consent prompt can be addressed and audited.