Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Agent Instructions

## Documentation inventory snapshot

The documentation inventory test
(`SnippetCompilationTests.Documentation_FrozenSurface`) hashes every file it
covers into
[`tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md`](tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md).
CI fails when that snapshot is stale. Covered files are:

- the root `README.md`;
- every `*.md` under `docs/`, `src/` and `samples/` (including sample READMEs);
- `*.cs`, `*.razor` and `*.ts` under `samples/GuidedTour/`, `samples/SampleApp/`,
`samples/CapabilitySupport/` and `samples/EventSupport/`.

After changing any of these, regenerate the snapshot, review its diff and commit
it in the same change:

```bash
AAUTH_UPDATE_DOCS_INVENTORY=1 dotnet test tests/AAuth.Tests --filter "FullyQualifiedName~Documentation_FrozenSurface"
```
261 changes: 140 additions & 121 deletions docs/advanced/interaction-chaining.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ An `IAccessPolicy` is required (`UsePolicy` or a DI registration).
| `TokenPath` | `string` | `/token` | Auth token endpoint path (`auth_token_endpoint`) |
| `RevocationPath` | `string` | `/revoke` | Revocation endpoint path (`revocation_endpoint`) |
| `ConfigureRevocation` | `Action<AAuthRevocationOptions>?` | `null` | *Code-only.* Adjusts the mapped revocation endpoint |
| `DeriveAgentClaims` | `Func<string, JsonObject?>?` | `null` | *Code-only.* Baseline policy claims derived from the verified agent id (demo convention; production uses the §Claims Required push) |
| `DeriveAgentClaims` | `Func<string, JsonObject?>?` | `null` | *Code-only.* Baseline policy claims derived from the verified agent id. Use it only for facts about the agent; identity claims about the person (`roles`, `groups`, `tenant`) come from the PS through the §Claims Required push |
| `PendingPathPrefix` | `string` | `/pending` | Deferred-decision poll path prefix |
| `DefaultScope` | `string` | `""` | Scope assumed when the resource token omits one |
| `InteractionLoginPath` | `string` | `/interaction/login` | Browser entry point for interactive policies |
Expand Down
5 changes: 3 additions & 2 deletions docs/workflows/call-chaining.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,11 @@ dotnet run --project samples/AgentConsole -- http://localhost:5001/events \
--upstream-token "eyJ..."
```

Or test the full call chain through the Concierge:
Or test the full call chain through the Concierge (the trailing `/` targets
its root; without it AgentConsole appends its default `/events` path):

```bash
dotnet run --project samples/AgentConsole -- http://localhost:5200 \
dotnet run --project samples/AgentConsole -- http://localhost:5200/ \
--ap http://localhost:5301 --ps http://localhost:5100
```

Expand Down
24 changes: 17 additions & 7 deletions samples/AgentConsole/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@
agentTokenKid = result.AgentTokenKid;
agentJwksUri = result.JwksUri;
Console.WriteLine($"Enrolled successfully. Local key handle: {localKeyHandle}");
// Consent is recorded for the AP-assigned identity, not the --sub cache label.
Console.WriteLine($"Agent ID (AP-assigned): {result.AgentId}");

// Persist only metadata — key lives in the keystore, token is short-lived
Directory.CreateDirectory(Path.GetDirectoryName(enrollCacheFile)!);
Expand Down Expand Up @@ -211,9 +213,14 @@
if (upstreamToken is not null) options.UpstreamTokenProvider = () => upstreamToken;
if (resourceManaged)
{
// Resource-managed (two-party) opaque-token flow: capture/replay AAuth-Access
// and drive the resource's own consent handshake.
// Resource-managed (two-party) opaque-token flow: capture/replay AAuth-Access.
options.EnableResourceManagedAccess = true;
}
if (resourceManaged || personServer is not null)
{
// A resource may itself defer with 202 + requirement=interaction: its own
// consent (resource-managed Inbox) or a downstream hop's consent relayed
// by an intermediary (the Concierge call chain).
options.HandleInteractions = true;
options.Interaction.MinPollInterval = TimeSpan.FromMilliseconds(200);
options.Interaction.OnInteractionRequired = (interaction, ct) =>
Expand Down Expand Up @@ -264,18 +271,21 @@ async Task<AAuthAgent> JktJwtAgentAsync()
Console.WriteLine("Upstream token provided for call chaining.");
}

// If the target URL has no path (or just "/"), append the signing-mode-specific
// path. The identity-based modes target the Aria Profile server, whose paths
// describe the *outcome* the resource concludes (not the scheme name); the
// default jwt mode targets the Calendar's three-party `/events` endpoint.
// If the target URL has no path at all, append the signing-mode-specific
// path. An explicit trailing "/" (e.g. http://localhost:5200/ for the
// Concierge chain) targets the root instead. The identity-based modes target
// the Aria Profile server, whose paths describe the *outcome* the resource
// concludes (not the scheme name); the default jwt mode targets the
// Calendar's three-party `/events` endpoint.
//
// SIGNING MODE PROFILE PATH MEANING
// hwk → /pseudonymous key thumbprint only (pseudonym)
// jwks_uri → /identified named, verifiable identity
// jkt-jwt → /anchored ephemeral key anchored to a durable key
// jwt → /events three-party Calendar read (calendar.read)
var targetUrl = url;
if (url.AbsolutePath is "/" or "")
var typedPath = args[0][(args[0].IndexOf("://", StringComparison.Ordinal) + 3)..];
if (url.AbsolutePath == "/" && !typedPath.Contains('/'))
{
targetUrl = resourceManaged
? new Uri(url, "/messages") // resource-managed two-party (Inbox)
Expand Down
42 changes: 31 additions & 11 deletions samples/AgentConsole/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ dotnet run --project samples/AgentConsole -- <url> --ap <agent-provider-url> [op

## Signing-mode → path mapping

When the target URL has no path (or just `/`), AgentConsole appends the path
that routes to the matching verification pipeline. The pseudonymous and
When the target URL has no path at all (for example `http://localhost:5001`),
AgentConsole appends the path that routes to the matching verification
pipeline. An explicit trailing `/` (for example `http://localhost:5200/`)
targets the root instead. The pseudonymous and
agent-identity modes target the **Profile** server (port 5000); the default
three-party `jwt` mode targets the **Calendar** server (port 5001); the
`--resource-managed` flag targets the **Inbox** server (port 5004):
Expand Down Expand Up @@ -84,37 +86,55 @@ dotnet run --project samples/AgentConsole -- \
http://localhost:5001/events/write --ap http://localhost:5301 \
--ps http://localhost:5100 --signing-mode jwt

# Three-party, RBAC — PS asserts roles ["calendar.owner"], groups ["demo-users"]
# Three-party, RBAC — PS asserts its demo person's roles ["calendar.owner", "wallet.payer"], groups ["demo-users"]
dotnet run --project samples/AgentConsole -- \
http://localhost:5001/events/admin --ap http://localhost:5301 \
--ps http://localhost:5100 --signing-mode jwt

# Four-party payment — scope "wallet.charge" (Access Server requires the wallet.payer role)
# Four-party payment — scope "wallet.charge" (the Access Server asks the PS for the person's roles and requires wallet.payer)
dotnet run --project samples/AgentConsole -- \
http://localhost:5003/wallet/charge --ap http://localhost:5301 \
--ps http://localhost:5100 --signing-mode jwt
```

## Granting consent

The isolated demo admin endpoint can pre-grant consent for the AP-assigned
agent, resource and scope. Replace the illustrative `agent` values below with
the assigned ID printed by enrollment, not the `--sub` local cache label. These are local demo operations,
not production authorization APIs. Normal browser consent binds authenticated
person/session/key/account context; the code alone is not approval.
`make demo` runs the PS with `RequireConsent=true`, so each new
agent/resource/scope prints an interaction URL and a PS dashboard link.
Approve either in a browser and the agent's poll completes.

To pre-grant instead, use the isolated demo admin endpoint. The PS records
consent for the exact agent, resource, scope and agent key, so copy the two
values AgentConsole prints at startup:

```text
Agent ID (AP-assigned): aauth:agent-1b98…@localhost
Public JWK thumbprint: Mhbryez6sAJLSDE-pAonOXX1KsaLjjAIinII_G2AaSU
```

Use the AP-assigned agent ID, not the `--sub` local cache label. These are
local demo operations, not production authorization APIs. Normal browser
consent binds authenticated person/session/key/account context; the code alone
is not approval.

```bash
AGENT='<Agent ID (AP-assigned)>'
KEY='<Public JWK thumbprint>'

# Baseline / RBAC endpoints use scope "calendar.read"
curl -X POST http://localhost:5100/admin/consent \
-H 'content-type: application/json' \
-d '{"agent":"aauth:demo@ap.example","resource":"http://localhost:5001","scope":"calendar.read"}'
-d "{\"agent\":\"$AGENT\",\"resource\":\"http://localhost:5001\",\"scope\":\"calendar.read\",\"key\":\"$KEY\"}"

# The /events/write endpoint requires the elevated scope
curl -X POST http://localhost:5100/admin/consent \
-H 'content-type: application/json' \
-d '{"agent":"aauth:demo@ap.example","resource":"http://localhost:5001","scope":"calendar.write"}'
-d "{\"agent\":\"$AGENT\",\"resource\":\"http://localhost:5001\",\"scope\":\"calendar.write\",\"key\":\"$KEY\"}"
```

A cached enrollment keeps the same agent ID and key across runs. Clearing it
(`make agent-reset`) creates a new identity that needs fresh consent.

## Enrollment lifetime

AgentConsole caches the local key handle and endpoint metadata, not the token.
Expand Down
112 changes: 83 additions & 29 deletions samples/Concierge/PendingStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json.Nodes;
using AAuth.Server;
using AAuth.Server.CallChaining;
using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.Tokens;

namespace Concierge;
Expand All @@ -13,53 +14,102 @@ namespace Concierge;
/// <remarks>
/// <para>When the Concierge's downstream token exchange returns
/// <c>202 requirement=interaction</c>, the Concierge (which has no user of
/// its own) cannot relay the interaction. Instead it persists an entry here,
/// re-emits its <em>own</em> <c>202</c> to the caller with
/// <c>Location=/pending/{id}</c> and an intermediary interaction URL. That URL
/// redirects the browser to the downstream PS interaction; the caller polls
/// <c>GET /pending/{id}</c> until consent resolves.</para>
/// its own) keeps polling the downstream pending URL in the background
/// (<see cref="AAuthChainedOperation{TResult}"/>) and stores an entry here. It
/// answers the caller with its <em>own</em> <c>202</c>, <c>Location=/pending/{id}</c>
/// and an intermediary interaction URL that redirects the browser to the
/// downstream interaction. The caller's polls read the operation's state; they
/// never re-send the downstream request.</para>
/// <para>A production intermediary would persist these durably and expire them
/// on a timer; this demo store is in-memory and never GCs.</para>
/// on a timer; this demo store is in-memory.</para>
/// </remarks>
public sealed class PendingStore
{
public sealed record Entry(
string Id,
string UpstreamToken,
ChainedInteractionEntry Interaction,
string DownstreamBase,
string DownstreamPath,
string PendingPrefix)
public sealed class Entry
{
public DateTimeOffset ExpiresAt { get; } = DateTimeOffset.FromUnixTimeSeconds(
JsonNode.Parse(Base64UrlEncoder.DecodeBytes(UpstreamToken.Split('.')[1]))!["exp"]!.GetValue<long>());
private readonly object _gate = new();
private readonly HashSet<string> _codes = new(StringComparer.Ordinal);
private ChainedInteractionEntry _interaction;
private long _version;

internal Entry(string upstreamToken, ChainedInteractionEntry interaction, string pendingPrefix,
AAuthChainedOperation<IResult>? operation, long interactionVersion)
{
UpstreamToken = upstreamToken;
PendingPrefix = pendingPrefix;
Operation = operation;
_interaction = interaction;
_codes.Add(interaction.Code);
_version = interactionVersion;
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(
JsonNode.Parse(Base64UrlEncoder.DecodeBytes(upstreamToken.Split('.')[1]))!["exp"]!.GetValue<long>());
}

public string Id => _interaction.Id;
public string UpstreamToken { get; }
public string PendingPrefix { get; }

/// <summary>The background downstream work, or null when nothing is running.</summary>
public AAuthChainedOperation<IResult>? Operation { get; }

public DateTimeOffset ExpiresAt { get; }
public DeferredState Lifecycle { get; } = new();

/// <summary>
/// The Concierge's current interaction. When the downstream asked for a different
/// interaction (for example an AS step after PS consent), it is re-keyed with a new code,
/// atomically with its downstream redirect target.
/// </summary>
public ChainedInteractionEntry Interaction
{
get
{
lock (_gate)
{
if (Operation?.Interaction is { } latest && latest.Version > _version)
{
_interaction = AAuthChainedInteractions.Rekey(_interaction, latest.Downstream);
_codes.Add(_interaction.Code);
_version = latest.Version;
}
return _interaction;
}
}
}

/// <summary>Any code this entry issued stays valid and leads to the latest downstream step.</summary>
public bool MatchesCode(string? code)
{
if (string.IsNullOrEmpty(code)) return false;
lock (_gate)
foreach (var issued in _codes)
if (AAuth.Server.AAuthInteractionCode.Matches(issued, code)) return true;
return false;
}

public bool Matches(string? upstreamToken) => string.Equals(UpstreamToken, upstreamToken, StringComparison.Ordinal);
}

private readonly ConcurrentDictionary<string, Entry> _entries = new();

/// <summary>
/// Create a pending entry capturing the upstream auth token (used to
/// re-drive the chained call on each poll) and the SDK-owned chained
/// interaction. <paramref name="downstreamBase"/> +
/// <paramref name="downstreamPath"/> are the downstream resource origin and
/// path re-driven on each poll (e.g. Calendar <c>/events</c> or the
/// mission-aware Trips <c>/trips</c>); <paramref name="pendingPrefix"/>
/// is the caller-facing poll route prefix (e.g. <c>/pending</c> or
/// <c>/mission-pending</c>).
/// Create a pending entry capturing the upstream auth token (the caller must
/// re-present it on every poll), the SDK-owned chained interaction and the
/// running downstream <paramref name="operation"/>. <paramref name="pendingPrefix"/>
/// is the caller-facing poll route prefix (e.g. <c>/pending</c> or <c>/mission-pending</c>).
/// <paramref name="interactionVersion"/> is the version of the operation's interaction that
/// <paramref name="interaction"/> was parked from; a newer one re-keys the entry.
/// </summary>
public Entry Add(
string upstreamToken,
ChainedInteractionEntry interaction,
string downstreamBase = "http://localhost:5001",
string downstreamPath = "/events",
string pendingPrefix = "/pending")
string pendingPrefix = "/pending",
AAuthChainedOperation<IResult>? operation = null,
long interactionVersion = 0)
{
foreach (var pair in _entries)
if (pair.Value.ExpiresAt.AddHours(1) <= DateTimeOffset.UtcNow) _entries.TryRemove(pair.Key, out _);
var entry = new Entry(
interaction.Id, upstreamToken, interaction, downstreamBase, downstreamPath, pendingPrefix);
var entry = new Entry(upstreamToken, interaction, pendingPrefix, operation, interactionVersion);
_entries[interaction.Id] = entry;
return entry;
}
Expand All @@ -71,5 +121,9 @@ public void Remove(string id)
=> _entries.TryRemove(id, out _);

/// <summary>Drop all pending entries back to the empty baseline.</summary>
public void Clear() => _entries.Clear();
public void Clear()
{
foreach (var entry in _entries.Values) entry.Operation?.Cancel();
_entries.Clear();
}
}
Loading
Loading