Interaction Chaining
June 9, 2026 · View on GitHub
When an intermediary resource calls a downstream resource and the downstream PS/AS requires user consent, the intermediary must propagate the interaction requirement back through the call chain to the original agent.
Spec Requirement (§Interaction Chaining)
When a resource acting as an agent receives a
202 Acceptedresponse withAAuth-Requirement: requirement=interaction, and the resource needs to propagate this interaction requirement to its caller, it MUST return a202 Acceptedresponse to the original agent with its ownAAuth-Requirementheader containingrequirement=interactionand its own interaction code. The resource MUST provide its ownLocationURL for the original agent to poll. When the user completes interaction and the resource obtains the downstream auth token, the resource completes the original request and returns the result at its pending URL.
Flow Diagram
sequenceDiagram
participant A as Agent A
participant C as Concierge (Resource B)
participant PS as Downstream PS
participant U as User
A->>C: request (auth token)
C->>PS: exchange for downstream auth token
PS-->>C: 202 + requirement=interaction
C-->>A: 202 + requirement=interaction (Concierge's own URL + code)
A->>U: open interaction URL in browser
U->>PS: complete consent
loop poll until resolved
A->>C: GET Location (pending URL)
C->>PS: poll downstream PS
PS-->>C: still pending / auth token
C-->>A: 202 (still pending)
end
Note over C,PS: Concierge obtains the downstream auth token,<br/>retries the downstream call
C-->>A: 200 (final result)
SDK Support: throw AAuthInteractionChainedException
When the downstream PS/AS requires consent, the intermediary's exchange surfaces an
onInteractionRequired callback. The intermediary cannot block and poll on the caller's
behalf — there is no user attached to the inbound request to relay the consent URL to.
Instead, the callback throws AAuthInteractionChainedException to abort the exchange
before the SDK starts its blocking poll. The endpoint catches that exception, parks the
flow, and re-emits its own 202 Accepted to the caller:
async Task<IResult> RunChainAsync(HttpContext ctx, string upstreamToken)
{
using var downstream = AAuthClientBuilder.SelfIssuing(conciergeKey)
.As(conciergeUrl, agentId)
.WithKid(conciergeKid)
.WithPersonServer(psUrl)
.WithCallChaining(upstreamToken)
.WithChallengeHandling(opts =>
{
// No user to relay to — abort the exchange and re-emit upward.
opts.OnInteractionRequired = (interaction, _) =>
throw new AAuthInteractionChainedException(interaction);
})
.Build();
var response = await downstream.GetAsync($"{downstreamUrl}/events");
var body = await response.Content.ReadFromJsonAsync<JsonNode>();
return Results.Ok(new { chain = "ok", downstream = body });
}
app.MapGet("/", async (HttpContext ctx, PendingStore pending) =>
{
var upstream = ctx.Features.Get<UpstreamAuthTokenFeature>()?.Token;
if (upstream is null) return Results.Unauthorized();
try
{
return await RunChainAsync(ctx, upstream);
}
catch (AAuthInteractionChainedException ex)
{
// Downstream needs consent. Park the upstream token + the downstream
// interaction details, then re-emit our OWN 202 to the caller.
var entry = pending.Add(upstream, ex.Interaction.Url, ex.Interaction.Code);
return ReEmitChainedInteraction(ctx, entry);
}
});
Throwing from the callback is what makes this work: the exchange wraps the callback in
try { await onInteractionRequired(...) } finally { ... } with no catch, so the
exception unwinds before DeferredPoller.PollAsync runs. There is no blocked poll and no
double-write to the response.
Re-emitting the chained 202
ReEmitChainedInteraction writes the intermediary's own 202 carrying its poll URL and
the downstream interaction's url/code (the user approves the downstream resource
directly):
IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry)
{
ctx.Response.Headers.Location = $"/pending/{entry.Id}";
ctx.Response.Headers["Retry-After"] = "1";
ctx.Response.Headers.CacheControl = "no-store";
ctx.Response.Headers[AAuthRequirementHeader.Name] =
Interaction.Format(entry.InteractionUrl, entry.InteractionCode);
return Results.Json(new { status = "interaction_required" }, statusCode: 202);
}
Resuming at the poll endpoint
When the agent polls /pending/{id}, the intermediary retries the chain. If consent has
been granted the exchange now succeeds and the final result is returned; if it is still
pending the same chained 202 is re-emitted; a denial maps to 403:
app.MapGet("/pending/{id}", async (HttpContext ctx, string id, PendingStore pending) =>
{
var entry = pending.Get(id);
if (entry is null)
return Results.Json(new { error = "unknown_pending" }, statusCode: 404);
try
{
var result = await RunChainAsync(ctx, entry.UpstreamToken);
pending.Remove(id);
return result;
}
catch (AAuthInteractionChainedException)
{
// Still waiting — re-emit (same url/code; consent is keyed by triple).
return ReEmitChainedInteraction(ctx, entry);
}
catch (AAuthInteractionDeniedException)
{
pending.Remove(id);
return Results.Json(new { error = "denied" }, statusCode: 403);
}
});
Why not write the
202from inside the callback? Returning normally fromonInteractionRequiredtells the SDK to block and poll for the downstream token. An intermediary has no user to wait on, so it would hang for the full polling budget and then try to complete a response the endpoint may have already written. ThrowingAAuthInteractionChainedExceptionis the correct, non-blocking abort.
Agent side: surfacing the chained 202
The original agent must handle two interaction points: the hop-1 PS challenge (via
WithChallengeHandling) and the hop-2 chained 202 the intermediary re-emits (a resource
202, handled by the top-level interaction pipeline via WithInteractionHandling). Wire
both so either hop can surface a consent URL:
using var client = AAuthClientBuilder.SelfIssuing(agentKey)
.As(issuer, agentId)
.WithKid(kid)
.WithPersonServer(psUrl)
.WithChallengeHandling(opts => // hop 1: PS exchange 202
{
opts.OnInteractionRequired = (interaction, _) =>
SurfaceToUser(interaction.BuildUserUrl());
})
.WithInteractionHandling(opts => // hop 2: intermediary's chained 202
{
opts.OnInteractionRequired = (userUrl, code, _) =>
SurfaceToUser(userUrl);
})
.Build();
var response = await client.GetAsync(intermediaryUrl);
ChallengeHandler only acts on 401 challenges, so the intermediary's 202 would pass
straight through unless WithInteractionHandling is also configured.
Manual Pattern (Without Builder)
For full control over the interaction-chaining flow using CallChainingHandler directly,
apply the same throw-to-abort rule inside the onInteractionRequired callback:
app.MapGet("/", async (HttpContext ctx, PendingStore pending) =>
{
var upstream = ctx.Features.Get<UpstreamAuthTokenFeature>()!;
var chainHandler = new CallChainingHandler(exchangeClient, options);
try
{
var chainedToken = await chainHandler.ExchangeForDownstreamAsync(
upstream.Token,
resourceToken,
onInteractionRequired: (interaction, _) =>
// Abort before the blocking poll; the endpoint re-emits its own 202.
throw new AAuthInteractionChainedException(interaction),
pollerOptions: new DeferredPollerOptions
{
MaxTotalWait = TimeSpan.FromMinutes(5),
PreferWaitSeconds = 45,
});
// Exchange succeeded — call downstream with the chained token.
using var client = new AAuthClientBuilder(myKey)
.UseJwt(chainedToken)
.Build();
return Results.Ok(await client.GetFromJsonAsync<JsonNode>(downstreamUrl));
}
catch (AAuthInteractionChainedException ex)
{
var entry = pending.Add(upstream.Token, ex.Interaction.Url, ex.Interaction.Code);
return ReEmitChainedInteraction(ctx, entry);
}
});
Note: With
PreferWaitSecondsset on a directly constructedTokenExchangeClient/DeferredPoller, ensure the underlyingHttpClient.Timeoutis greater thanPreferWaitSeconds(orTimeout.InfiniteTimeSpan). A defaultHttpClient(100s timeout) would abort the in-flight long-poll with aTaskCanceledException. Clients built viaAAuthClientBuilderalready useTimeout.InfiniteTimeSpan.
Pending Request Management
The intermediary must manage pending requests:
- Store: When
onInteractionRequiredfires, store the request context and downstream interaction details. - Poll endpoint: Expose a
/pending/{id}endpoint that the original agent polls. - Background completion: When user consent completes, the downstream PS issues the token. The intermediary completes the original request.
- Cleanup: Expire stale pending requests.
This is application-specific logic that the SDK intentionally does not automate, as different architectures (stateless, queue-backed, actor-based) require different implementations.
Future Enhancement: Automatic Propagation Middleware
A future SDK version may provide an InteractionPropagationMiddleware that:
- Automatically returns 202 to the caller when downstream interaction is needed
- Manages a pending-request store (pluggable: in-memory, Redis, database)
- Exposes a polling endpoint
- Completes the original request when downstream interaction resolves
This would further reduce boilerplate for common intermediary patterns. Track progress in the SDK roadmap.
See Also
- Call Chaining — overall call-chaining workflow
- Error Handling —
AAuthInteractionTimeoutExceptionandAAuthInteractionDeniedException - Deferred Consent — agent-side 202 handling