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
415 changes: 415 additions & 0 deletions .agent/plans/2026-05-28-live-interop-testing/implementation-plan.md

Large diffs are not rendered by default.

427 changes: 427 additions & 0 deletions .agent/plans/2026-05-28-live-interop-testing/research.md

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions .devcontainer/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,35 @@ else
echo "==> gh already installed: $(gh --version | head -n1)"
fi

# --- cloudflared --------------------------------------------------------------
# Installs `cloudflared` from Cloudflare's official apt repository.
# Required by the LiveWhoAmITest sample, which exposes its local agent metadata
# endpoint over a quick tunnel so the live resource server can fetch its JWKS.
# Docs: https://pkg.cloudflare.com/
if ! command -v cloudflared >/dev/null 2>&1; then
echo "==> Installing cloudflared"

sudo_cmd=""
if [[ $EUID -ne 0 ]]; then
sudo_cmd="sudo"
fi

$sudo_cmd install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| $sudo_cmd tee /etc/apt/keyrings/cloudflare-main.gpg >/dev/null
$sudo_cmd chmod go+r /etc/apt/keyrings/cloudflare-main.gpg

echo "deb [signed-by=/etc/apt/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \
| $sudo_cmd tee /etc/apt/sources.list.d/cloudflared.list >/dev/null

$sudo_cmd apt-get update
$sudo_cmd apt-get install -y cloudflared
else
echo "==> cloudflared already installed: $(cloudflared --version | head -n1)"
fi

# --- Bash: git completion + git status in prompt ------------------------------

# Idempotent: only appended once, guarded by a marker line.
BASHRC="${HOME}/.bashrc"
MARKER="# >>> aauth devcontainer bash setup >>>"
Expand Down
1 change: 1 addition & 0 deletions AAuth.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<Folder Name="/samples/">
<Project Path="samples/AgentConsole/AgentConsole.csproj" />
<Project Path="samples/GuidedTour/GuidedTour.csproj" />
<Project Path="samples/LiveWhoAmITest/LiveWhoAmITest.csproj" />
<Project Path="samples/MockAgentProvider/MockAgentProvider.csproj" />
<Project Path="samples/MockPersonServer/MockPersonServer.csproj" />
<Project Path="samples/Orchestrator/Orchestrator.csproj" />
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ TOUR_PROJECT := samples/GuidedTour/GuidedTour.csproj
AGENT_PROJECT := samples/AgentConsole/AgentConsole.csproj
SAMPLE_PROJECT := samples/SampleApp/SampleApp.csproj
ORCH_PROJECT := samples/Orchestrator/Orchestrator.csproj
LIVE_PROJECT := samples/LiveWhoAmITest/LiveWhoAmITest.csproj

WHOAMI_URL := http://localhost:5000
PS_URL := http://localhost:5100
Expand All @@ -25,7 +26,7 @@ SAMPLE_URL := http://localhost:5240

.PHONY: help build restore test test-unit test-conformance \
whoami ps ap tour agent demo \
clean format
live clean format

help: ## List available targets
@awk 'BEGIN { FS = ":.*##"; printf "Targets:\n" } \
Expand Down Expand Up @@ -71,6 +72,9 @@ orchestrator: ## Run the Orchestrator service (port 5200)
agent: ## Run AgentConsole against WhoAmI (override URL=… for a different target)
$(DOTNET) run --project $(AGENT_PROJECT) -- $(or $(URL),$(WHOAMI_URL))

live: ## Run LiveWhoAmITest against whoami.aauth.dev (needs cloudflared + network)
$(DOTNET) run --project $(LIVE_PROJECT)

demo: ## Start WhoAmI + Orchestrator + MockPersonServer + MockAgentProvider + GuidedTour in parallel
@echo "Starting five-party demo (all flows including call-chain)..."
@echo " WhoAmI: $(WHOAMI_URL)"
Expand Down
68 changes: 68 additions & 0 deletions aauth-spec/upcoming-changes-02.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Upcoming Changes in draft-hardt-oauth-aauth-protocol-02

Confirmed by spec lead (2026-05-30). Tracked here until the -02 draft is published.

## Index

| # | Change | Extends | Status |
|---|--------|---------|--------|
| 1 | Add `capabilities` as standard token endpoint parameter | §7.1.3 Agent Token Request (L830) | Pending -02 |
| 2 | Add `user_unreachable` to error table as terminal error | §Error Responses (L2006) | Pending -02 |
| 3 | Add `prompt` as standard token endpoint parameter | §7.1.3 Agent Token Request (L830) | Pending -02 |

---

## 1. `capabilities` as token endpoint body parameter

**Extends:** §7.1.3 Agent Token Request (`#ps-token-endpoint`, line 830)

**Current spec:** Token endpoint params are `resource_token`, `upstream_token`, `justification`, `login_hint`, `tenant`, `domain_hint`, `platform`, `device`. The `AAuth-Capabilities` header (§AAuth-Capabilities, L1756) is explicitly excluded from PS endpoints.

**Change:** Add `capabilities` (OPTIONAL) to the token endpoint request body. Array of strings. Values from the AAuth Capabilities registry (`interaction`, `clarification`, `payment`).

**Clarification:**
- `capabilities` in the body is the correct mechanism for mission-less agents.
- When a mission is active, the PS already has capabilities from the approval flow - the agent doesn't need to resend them but MAY include them if they've changed.
- The `AAuth-Capabilities` header remains resource-only. No conflict - headers are used where there's a pre-existing API; body is the right place for the PS token endpoint.

**SDK impact:** Current fix (sending `capabilities` in POST body) is correct and will be spec-standard.

---

## 2. `user_unreachable` as terminal error

**Extends:** §Token Endpoint Error Codes (`#error-response-format`, line 2006)

**Current spec:** Error table has `interaction_required` (403) defined as "User interaction is needed but no interaction channel is available."

**Change:** Add `user_unreachable` as a distinct terminal error. Clarify the difference:

| Error | Status | Type | Meaning |
|-------|--------|------|---------|
| `interaction_required` | 202 | Non-terminal | PS needs the agent to direct the user somewhere (URL + code). Polling continues. |
| `user_unreachable` | 400 | Terminal | PS has no channel to the user AND the agent didn't declare `interaction` capability. No way to reach the user. |

**Clarification:** These are two distinct conditions, not aliases. `interaction_required` comes with a deferred response (202) and an interaction URL. `user_unreachable` is a hard stop - nothing can be done without the agent declaring capabilities.

**SDK impact:** Error classification (Gap E) should treat `user_unreachable` as a terminal, non-retryable error distinct from `interaction_required`.

---

## 3. `prompt` as token endpoint body parameter

**Extends:** §7.1.3 Agent Token Request (`#ps-token-endpoint`, line 830)

**Current spec:** No `prompt` parameter listed.

**Change:** Add `prompt` (OPTIONAL) to the token endpoint request body. Values follow OIDC (per OpenID Core §3.1.2.1):

| Value | Meaning |
|-------|---------|
| `none` | No UI. Return error if consent/login is needed. |
| `login` | Force re-authentication. |
| `consent` | Force consent screen even if prior consent exists. |
| `select_account` | Prompt user to select an account. |

**Not included:** `provider_hint` remains a Hellospecific extension. It steers between consumer providers (email, Google, etc.) and doesn't generalize.

**SDK impact:** Should support `prompt` as a first-class option on the token exchange builder. `provider_hint` can go through the extensibility hook for PS-specific params.
5 changes: 3 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,9 @@ This is the documentation for the AAuth .NET SDK (`AAuth` NuGet package). It cov
| Type | Purpose |
|------|---------|
| `SignatureError` / `SignatureErrorCode` | Signature verification failures |
| `TokenError` / `TokenErrorCode` | Token validation failures |
| `PollingError` / `PollingErrorCode` | Deferred polling failures |
| `TokenErrorResponse` / `TokenErrorCode` | Token validation failures |
| `AAuthTokenExchangeException` | Structured PS token-endpoint errors |
| `PollingErrorException` / `PollingErrorCode` | Deferred polling failures |

### `AAuth.Identifiers` — AAuth URI parsing

Expand Down
70 changes: 64 additions & 6 deletions docs/advanced/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ namespace AAuth.Errors;
public enum SignatureErrorCode
{
InvalidRequest, // Missing required headers (Signature, Signature-Input, Signature-Key)
InvalidInput, // Malformed Signature-Input structured field
InvalidInput, // Covered components don't match the required set (see required_input)
InvalidSignature, // Signature bytes don't verify against key
UnsupportedAlgorithm, // Algorithm not supported by this resource
InvalidKey, // Key material is malformed or unsupported
Expand Down Expand Up @@ -48,8 +48,26 @@ if (SignatureError.TryParse(response.Headers["Signature-Error"], out var code))
{
Console.WriteLine($"Signature rejected: {code}");
}

// Extract the components a resource demands on an invalid_input error
string[] required = SignatureError.ParseRequiredInput(
response.Headers["Signature-Error"]);
// → ["content-digest"] (empty array when no required_input is present)
```

### Adaptive Retry on `invalid_input`

When challenge handling is enabled, the agent handles `invalid_input` with a
`required_input` list automatically: it learns the additional covered
components, re-signs the request covering them, and retries once. Learned
components are cached per origin. If `content-digest` is among the required
components, the signing handler computes it (RFC 9530, `sha-256`) from the
request body before re-signing, so the retry succeeds without caller
intervention. See
[Adaptive Signature Components](../signing-modes/overview.md#adaptive-signature-components)
for how to seed components proactively from resource metadata. `ParseRequiredInput`
is exposed for callers implementing this handshake manually.

## Token Errors (PS/AS → Agent)

When a Person Server or Access Server rejects a token exchange request.
Expand All @@ -66,8 +84,9 @@ public enum TokenErrorCode
ExpiredAgentToken, // Agent token exp has passed
InvalidResourceToken, // Resource token fails validation
ExpiredResourceToken, // Resource token exp has passed
InteractionRequired, // User must approve (deferred consent)
ServerError, // Internal server error
InteractionRequired, // User must approve (deferred consent, non-terminal 202)
UserUnreachable, // No channel to the user; agent declared no interaction capability (terminal 400)
ServerError, // Internal server error (transient, retryable)
}
```

Expand All @@ -80,10 +99,48 @@ public sealed record TokenErrorResponse(TokenErrorCode Error, string? ErrorDescr
}
```

The `TokenExchangeClient` throws when it receives an error response from the PS. Check the HTTP status code and parse the body:
The `TokenExchangeClient` throws when it receives an error response from the PS.

When the PS returns a non-success status with a structured AAuth error body
(`{ "error": ..., "error_description": ... }`), the exchange throws a typed
`AAuthTokenExchangeException` carrying the parsed fields. Responses that are not
parseable AAuth error objects fall back to a plain `HttpRequestException`.

```csharp
public sealed class AAuthTokenExchangeException : Exception
{
public string ErrorCode { get; } // e.g. "invalid_resource_token"
public string? ErrorDescription { get; } // optional human-readable text
public int StatusCode { get; } // HTTP status from the token endpoint
public bool IsTerminal { get; } // false only for "server_error" (retryable)
}
```

```csharp
try
{
var authToken = await exchangeClient.ExchangeAsync(personServer, resourceToken);
}
catch (AAuthTokenExchangeException ex)
{
Console.WriteLine($"Token exchange failed: {ex.ErrorCode} (HTTP {ex.StatusCode})");
if (!ex.IsTerminal)
{
// Transient (server_error) — a later retry may succeed.
}
}
catch (HttpRequestException ex)
{
// Transport failure, or a non-success response without a parseable
// AAuth error body.
Console.WriteLine($"Transport error: {ex.Message}");
}
```

If you're calling the PS manually, parse the body yourself with
`TokenErrorResponse`:

```csharp
// If you're calling the PS manually:
var response = await signedClient.PostAsync(psTokenEndpoint, content);
if (!response.IsSuccessStatusCode)
{
Expand Down Expand Up @@ -178,9 +235,10 @@ catch (TokenVerificationException ex)
|-----------|----------|---------|
| `AAuthVerificationException` | `AAuthVerifier` | Signature bytes invalid |
| `TokenVerificationException` | `TokenVerifier` | JWT fails validation |
| `AAuthTokenExchangeException` | `TokenExchangeClient` / `ChallengeHandler` | PS token endpoint returned a structured error |
| `AAuthInteractionDeniedException` | `DeferredPoller` / `ChallengeHandler` | User denied |
| `AAuthInteractionTimeoutException` | `DeferredPoller` / `ChallengeHandler` | Polling timed out |
| `PollingErrorException` | `DeferredPoller` | PS returned terminal error |
| `PollingErrorException` | `DeferredPoller` | PS returned terminal error during polling |

## Server-Side Error Emission

Expand Down
2 changes: 2 additions & 0 deletions docs/advanced/interaction-chaining.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ app.MapGet("/", async (HttpContext ctx) =>
});
```

> **Note:** With `PreferWaitSeconds` set on a directly constructed `TokenExchangeClient`/`DeferredPoller`, ensure the underlying `HttpClient.Timeout` is greater than `PreferWaitSeconds` (or `Timeout.InfiniteTimeSpan`). A default `HttpClient` (100s timeout) would abort the in-flight long-poll with a `TaskCanceledException`. Clients built via `AAuthClientBuilder` already use `Timeout.InfiniteTimeSpan`.

## Pending Request Management

The intermediary must manage pending requests:
Expand Down
11 changes: 9 additions & 2 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,9 @@ Standard `DelegatingHandler` — no configurable options. Requires an `ISignatur

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `MetadataCacheDuration` | `TimeSpan` | 5 minutes | Metadata document cache lifetime |
| `JwksCacheDuration` | `TimeSpan` | 5 minutes | JWKS cache lifetime |
| `MetadataCacheTtl` | `TimeSpan` | 5 minutes | Metadata document cache lifetime |
| `JwksCacheTtl` | `TimeSpan` | 1 hour | JWKS cache lifetime |
| `JwksMinRefreshInterval` | `TimeSpan` | 1 minute | Minimum interval between JWKS fetches (rate limit) |

### ChallengeHandlingOptions (WithChallengeHandling)

Expand All @@ -218,6 +219,12 @@ Standard `DelegatingHandler` — no configurable options. Requires an `ISignatur
| `OnInteractionRequired` | `Func<AAuthInteraction, CancellationToken, Task>?` | null | Deferred consent callback |
| `PollingTimeout` | `TimeSpan` | 5 minutes | Max deferred polling time |
| `DefaultPollInterval` | `TimeSpan` | 5 seconds | Poll interval (overridden by Retry-After) |
| `PreferWaitSeconds` | `int?` | null | Sends `Prefer: wait=N` to long-poll |
| `MinPollInterval` | `TimeSpan` | 100 ms | Minimum delay between polls |
| `OnPoll` | `Action<HttpResponseMessage>?` | null | Per-poll callback (logging/progress) |
| `Capabilities` | `IList<string>?` | null | Capabilities sent to the PS (null = infer) |
| `Prompt` | `string?` | null | OIDC `prompt` sent to the PS |
| `AdditionalSignatureComponents` | `IReadOnlyDictionary<string, IReadOnlyList<string>>?` | null | Per-origin extra covered components to seed |

### InteractionHandlingOptions (WithInteractionHandling)

Expand Down
8 changes: 4 additions & 4 deletions docs/reference/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,8 @@ Register shared `MetadataClient` and `JwksClient` singletons with custom cache s
```csharp
builder.Services.AddAAuthDiscovery(options =>
{
options.MetadataCacheDuration = TimeSpan.FromMinutes(10);
options.JwksCacheDuration = TimeSpan.FromHours(2);
options.MetadataCacheTtl = TimeSpan.FromMinutes(10);
options.JwksCacheTtl = TimeSpan.FromHours(2);
});
```

Expand Down Expand Up @@ -320,8 +320,8 @@ app.Run();

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `MetadataCacheDuration` | `TimeSpan` | 5 min | How long to cache well-known metadata |
| `JwksCacheDuration` | `TimeSpan` | 5 min | How long to cache JWKS documents |
| `MetadataCacheTtl` | `TimeSpan` | 5 min | How long to cache well-known metadata |
| `JwksCacheTtl` | `TimeSpan` | 1 hour | How long to cache JWKS documents |

## Call Chaining (AAuthClientBuilder)

Expand Down
6 changes: 3 additions & 3 deletions docs/server/token-issuance.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ var resourceToken = new ResourceTokenBuilder
Lifetime = TimeSpan.FromMinutes(5), // default: 5 min
}.Build();

// Return as 401 challenge
context.Response.Headers["WWW-Authenticate"] = $"AAuth resource_token={resourceToken}";
return Results.Unauthorized();
// Return as 401 challenge (sets the AAuth-Requirement header:
// requirement=auth-token; resource-token="...")
return context.ChallengeAAuth(resourceToken);
```

### ResourceTokenBuilder Properties
Expand Down
6 changes: 5 additions & 1 deletion docs/server/verification-middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,13 @@ On verification failure, the middleware returns `401 Unauthorized` with a `Signa
| Error Code | Meaning |
|------------|---------|
| `invalid_request` | Missing required signature headers |
| `invalid_input` | Covered components don't match the required set (see the `required_input` parameter) |
| `invalid_signature` | Signature verification failed |
| `unsupported_algorithm` | Signature algorithm not supported |
| `invalid_key` | Signature key malformed or unusable |
| `unknown_key` | Referenced key could not be resolved |
| `invalid_jwt` | JWT parsing/issuer verification failed |
| `expired` | Token or signature timestamp expired |
| `expired_jwt` | Token JWT expired |

## OpenTelemetry Integration

Expand Down
46 changes: 46 additions & 0 deletions docs/signing-modes/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,52 @@ Signature: sig=:base64url-ed25519-signature:

The `AAuthSigningHandler` handles construction automatically.

## Adaptive Signature Components

Every signed request always covers the four base AAuth components shown above
(`@method`, `@authority`, `@path`, `signature-key`), plus `authorization` when
that header is present. A resource MAY require **additional** covered components
(for example `content-digest` for request-body integrity, or `content-type`).
The agent discovers these in one of two ways:

1. **From resource metadata.** If you know a resource publishes
`additional_signature_components`, seed them so the very first request
already covers them:

```csharp
using var client = new AAuthClientBuilder(key)
.WithTokenRefresh(refresher)
.WithChallengeHandling(ps, options =>
{
options.AdditionalSignatureComponents =
new Dictionary<string, IReadOnlyList<string>>
{
["https://resource.example"] = new[] { "content-digest" },
};
})
.Build();
```

The dictionary is keyed by origin (`scheme://host:port`).

2. **From a `401` response.** When a resource rejects a request with
`Signature-Error: invalid_input; required_input="content-digest"`, the
challenge handler learns the required components, re-signs the request
covering them, and retries **once**. Learned components are cached per
origin, so subsequent requests to the same resource cover them up front.

Additional components are always **additive** — the base components can never
be dropped or reordered. The component value is taken from the request's own
headers at signing time. When a resource requires `content-digest` (RFC 9530)
on a body-bearing request, the signing handler **computes and attaches it
automatically** (`sha-256`) before signing, so callers do not need to set it
themselves. Any required component AAuth cannot derive on its own must be
present on the request; if such a component is absent, signing fails fast with
an `InvalidOperationException` that names the resource origin.

See [Error Handling](../advanced/error-handling.md) for the
`Signature-Error` codes and `SignatureError.ParseRequiredInput`.

## Further Reading

- [Signing Mode Comparison](https://explorer.aauth.dev/signing/compare)
Expand Down
Loading
Loading