Register AAuth services in ASP.NET Core and hosted applications using the built-in DI extensions.
How your agent obtains its token depends on its deployment model:
- Hosted services (web apps, APIs, concierges with a stable URL): Self-issue agent tokens at runtime. Generate a key at startup, publish
/.well-known/aauth-agent.json, and build tokens locally. No external AP needed. - CLI / desktop / mobile agents (no stable URL): Enrol with an Agent Provider once (provisioning step), then refresh tokens from the AP at runtime.
In both cases, the agent token is short-lived (typically 1 hour) and refreshed automatically by the SDK. You never persist it.
flowchart LR
subgraph Hosted
H1["Startup: Generate key"] --> H2["Publish /.well-known/aauth-agent.json"]
H2 --> H3["Runtime: self-issue token via AgentTokenBuilder"]
end
subgraph CLI/Desktop
P["Provisioning: EnrolAsync(keyStore)"] --> C["App config: KeyHandle"]
C --> S["Startup: AddAAuthAgent (KeyHandle, AgentProvider)"]
S --> K["First use: key loaded through IKeyStore"]
K --> R["Runtime: SDK calls AP refresh before expiry"]
end
See Bootstrap & Enrollment for the CLI/desktop provisioning step, or Getting Started for the self-issued path.
AddAAuthAgent(name, …) registers a named agent and returns an AAuthAgentBuilder
(Configure, WithAgentProvider, WithGovernance). Resolve the agent's
HttpClient with IHttpClientFactory.CreateClient(name) or
IAAuthAgentFactory.Get(name), and its Person Server clients as keyed services
(see Typed Clients). The named AAuthAgentOptions are validated when
the host starts (see AAuthAgentOptions). The pipeline is
composed from the service provider when the agent is first resolved and lives as
long as the container. A KeyHandle is loaded at that point through the
registered IKeyStore, or FileKeyStore.Default() when none is registered.
No enrollment required. Generate or load a key and register:
var key = AAuthKey.Generate(); // or load from persistent storage
builder.Services.AddAAuthAgent("signing-only", options =>
{
options.Signer = key;
options.SignatureKeyProvider = new HwkSignatureKeyProvider(key);
});For a plain signing client without the agent pipeline, AddAAuthClient(name, …)
takes Signer or KeyHandle plus a SignatureKeyProvider; its options are
validated when the host starts.
No AP enrollment needed. The service generates a key and self-issues tokens:
var key = AAuthKey.Generate();
const string Kid = "svc-key-1";
var issuer = "https://my-service.example";
builder.Services.AddAAuthAgent("self-issued", options =>
{
options.Signer = key;
options.SelfIssued.Issuer = issuer;
options.SelfIssued.Subject = "aauth:my-service@my-service.example";
options.SelfIssued.KeyId = Kid;
options.PersonServer = "https://ps.example"; // also the token's ps claim
});
// Also publish agent metadata so verifiers can discover the JWKS
app.MapAAuthAgentWellKnown(options =>
{
options.Issuer = issuer;
options.SigningKeys = new AAuthSigningKeySet(Kid, key);
});Name the enrolled key by its local handle and the agent provider's refresh
endpoint. The agent token is refreshed at that endpoint, signed with the
KeyHandle key:
builder.Services.AddSingleton<IKeyStore>(FileKeyStore.Default()); // optional: the default
builder.Services.AddAAuthAgent("identity", options =>
{
options.KeyHandle = configuration["AAuth:LocalKeyHandle"]!;
options.PersonServer = "https://ps.example";
})
.WithAgentProvider(provider => provider.RefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!);WithAgentProvider also registers an AgentProviderClient keyed by the agent
name, for enrollment and explicit two-key refresh
(GetRequiredKeyedService<AgentProviderClient>("identity")). The SDK offers no
automatic two-key refresh; callers that use RefreshTwoKeyAsync rebuild the
client with the returned EphemeralKey and AgentToken together. Setting
AgentProvider:RefreshEndpoint in configuration selects the same identity. The
pipeline owns its internally created single-key refresh transport.
For an already-held token with externally managed renewal, set AgentToken:
builder.Services.AddAAuthAgent("identity", options =>
{
options.Signer = key;
options.PersonServer = "https://ps.example";
options.AgentToken = heldAgentToken;
});When the Person Server or the resource requires user approval, provide
interaction callbacks. Challenge configures the Person Server exchange;
Interaction configures a resource's own 202 responses:
using var refresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle)
.WithKeyStore(keyStore).Build();
builder.Services.AddAAuthAgent("interactive", options =>
{
options.Signer = key;
options.PersonServer = "https://ps.example";
options.TokenRefresher = refresher;
// The Person Server returning 202 + requirement=interaction surfaces here.
options.Challenge.OnInteractionRequired = ShowConsentAsync;
options.Challenge.PollingTimeout = TimeSpan.FromMinutes(3);
// A resource returning 202 + requirement=interaction surfaces here.
options.Interaction.OnInteractionRequired = async (interaction, ct) =>
{
// Present URL and code to user
logger.LogInformation("Approve at {Url} with code {Code}", interaction.BuildUserUrl(), interaction.Code);
};
options.Interaction.PollingTimeout = TimeSpan.FromMinutes(3);
});Challenge handling is on by default when PersonServer or call chaining is set;
interaction handling is on by default when an Interaction callback is set.
HandleChallenges and HandleInteractions override either default.
For long-lived agents, enable automatic token refresh before expiry:
using var refresher = AgentProviderTokenRefresher.Create("https://ap.example/refresh", localKeyHandle)
.WithKeyStore(keyStore).Build();
builder.Services.AddAAuthAgent("refreshing", options =>
{
options.Signer = key;
options.PersonServer = "https://ps.example";
options.TokenRefresher = refresher;
});Bind an agent from a configuration section; the conventional root is
AAuth:Agents (AAuthAgentServiceCollectionExtensions.ConfigurationSection).
The optional configure callback, or .Configure(...), runs after binding:
builder.Services.AddAAuthAgent("calendar", builder.Configuration.GetSection("AAuth:Agents:calendar"))
.Configure(options => options.Interaction.OnInteractionRequired = (interaction, ct) => SurfaceToUser(interaction.BuildUserUrl()));{
"AAuth": {
"Agents": {
"calendar": {
"KeyHandle": "calendar-agent",
"AgentProvider": { "RefreshEndpoint": "https://ap.example/refresh" },
"PersonServer": "https://ps.example",
"Capabilities": [ "interaction" ],
"Challenge": { "PollingTimeout": "00:03:00" }
}
}
}
}In code, use AAuthConstants.Capabilities.Interaction,
AAuthConstants.Capabilities.Clarification and
AAuthConstants.Capabilities.Payment instead of duplicating these protocol
strings.
A self-issued agent binds "SelfIssued": { "Issuer": "…", "Subject": "…" }
instead of AgentProvider. Scalars bind; delegate and instance members are
code-only: Signer, AgentTokenFactory, TokenRefresher, SignatureKeyProvider,
Mission, UpstreamTokenProvider, the Challenge and Interaction callbacks,
EgressPolicy, InnerHandler, TransportContract, AAuthAccessStore,
OnSignatureBase and TokenCache. Set them in configure or .Configure(...).
An agent acting under its own approved mission names it on person token
requests. Challenge.OnClarificationRequired answers a Person Server's
clarification questions during the exchange:
builder.Services.AddAAuthAgent("trip-planner", options =>
{
options.Signer = key;
options.AgentToken = agentToken;
options.PersonServer = "https://ps.example";
options.Mission = mission; // person tokens carry its mission_s256
options.Challenge.OnClarificationRequired = async (requirement, ct) =>
ClarificationResponse.Respond(await AskUser(WebUtility.HtmlEncode(requirement.Clarification)));
});An intermediary chains the upstream auth token of the request it is serving.
ChainFromHttpContext reads the token verified by the inbound AAuth middleware;
UpstreamTokenProvider supplies it from elsewhere. Set only one:
builder.Services.AddAAuthAgent("downstream", options =>
{
options.Signer = key;
options.AgentToken = agentToken;
options.ChainFromHttpContext = true; // or: options.UpstreamTokenProvider = () => upstreamAuthToken;
});See Call Chaining (AAuthClientBuilder) for what chaining does.
AddAAuthAgent composes each agent from AAuthClientBuilder, which remains the
primitive. Consoles and tools without a Generic Host use the builder directly
(new AAuthClientBuilder(key), AAuthClientBuilder.SelfIssuing(key),
AAuthClientBuilder.Enrolled(key)), and IAAuthAgentFactory.Create(name, signer, …)
accepts a builder callback.
builder.Services.AddAAuthResource(options =>
{
options.Issuer = "https://my-resource.example";
// Set for four-party resources. Omit for three-party PS-issued authorization.
options.AccessServer = "https://as.example";
options.SigningKeys["key-1"] = resourceKey;
});
builder.Services.AddAAuthAuthentication();
builder.Services.AddAAuthAuthorization();
var app = builder.Build();
app.MapAAuthWellKnown(); // /.well-known/aauth-resource.json + /jwks.json
app.UseRouting();
app.UseAAuth(o => o.Trust.AuthTokenIssuers.Allowed = new HashSet<string> { "https://ps.example" });
app.UseAuthentication();
app.UseAuthorization();
// Each protected endpoint declares what it needs:
app.MapGet("/data", (HttpContext ctx) => Results.Ok(ctx.GetAAuthVerification()!.Scopes))
.RequireAAuth(scope: "data:read");
Trust.AuthTokenIssuersis optional only for the default three-party PS-issued authorization mode (AccessServerunset). In that mode, leaving it unset (or assigningAAuthTrust.Anyto itsPredicate) accepts any verifiable Person Server with claims namespaced byiss; leaving it unset logs an open-trustWarningat startup. WhenAAuthResourceOptions.AccessServeris set, the SDK derives AS-only auth-token trust and pins auth-tokendwktoaauth-access.json. A direct PS-issuedaauth-person.jsonauth token is rejected. Explicit mixed deployments must setAAuthVerificationOptions.ExpectedAuthTokenDwk = nulland use anIAAuthTrustPolicythat checksAAuthTrustContext.TokenDwk.
builder.Services.AddAAuthResource(options =>
{
options.Issuer = "https://my-resource.example";
options.SigningKeys = new() { ["key-1"] = resourceKey };
options.Name = "Supply Chain Service";
options.ScopeDescriptions = new()
{
["data:read"] = "Read supply chain data",
["data:write"] = "Modify supply chain records",
};
});Advertise the resource's proactive authorization endpoint for agents to start
authorization without first receiving a resource challenge. This does not select
an Access Server: configure AAuthResourceOptions.AccessServer to set the
resource token's AS recipient and the matching AS-issued auth-token verification
default (unset means three-party PS-issued authorization).
builder.Services.AddAAuthResource(options =>
{
options.Issuer = "https://my-resource.example";
options.SigningKeys = new() { ["key-1"] = resourceKey };
options.AuthorizationEndpoint = "https://my-resource.example/authorize";
});Map the advertised endpoint with MapAAuthAuthorizationEndpoint(pattern, handler).
It requires a verified AAuth person token (.RequireAAuthPersonToken()), reads
scope and optional account from the JSON body, and rejects requests that
provide neither a scope nor an authorization claim supplied by an
IAAuthAuthorizationEndpointExtension. R3 resources opt that extension in with
AddAAuthR3AuthorizationEndpoint(), which accepts r3_operations as the
authorization claim and exposes them through request.GetR3Operations().
To map verification results into a ClaimsPrincipal and enforce per-endpoint
access, register the AAuth authentication scheme and the authorization handlers,
then declare each endpoint's requirement inline:
builder.Services.AddAAuthAuthentication(); // maps result → ClaimsPrincipal
builder.Services.AddAAuthAuthorization(); // scope handler + built-in policies
var app = builder.Build();
app.UseRouting();
app.UseAAuth(o => o.Trust.AuthTokenIssuers.Allowed = new HashSet<string> { "https://ps.example" });
app.UseAuthentication();
app.UseAuthorization();
// Per-route scope/role lives on the endpoint:
app.MapGet("/data", handler).RequireAAuth(scope: "data:read");
app.MapGet("/admin", handler).RequireAAuth(scope: "data:read", role: "admin");
app.MapGet("/me", handler).RequireAAuthSignature(identified: true); // agent identity, no tokenAddAAuthAuthorization()registers the built-inAAuth.Authenticated,AAuth.Identified, andAAuth.Authorizedpolicies plusAAuthScopeHandler..RequireAAuth(scope: "x")requires anAAuthLevel.Authorizedauth token carryingx— an agent-token-only (PoP) request cannot satisfy it. Addrole: "y"to also require a role (mapped from the token'srolesclaim to the standardClaimTypes.Role)..RequireAAuthSignature()requires only a verified HTTP signature (agent identity or resource-managed access); passidentified: trueto require at least an agent token.
See Authorization Policies for details.
Register shared MetadataClient and JwksClient singletons with custom cache settings:
builder.Services.AddAAuthDiscovery(options =>
{
options.MetadataCacheTtl = TimeSpan.FromMinutes(10);
options.JwksCacheTtl = TimeSpan.FromHours(2);
});AddAAuthResource, AddAAuthPersonServer and AddAAuthAccessServer register discovery clients if AddAAuthDiscovery has not been called. Call it explicitly to share instances and control cache behavior. An agent's challenge-handling pipeline owns its discovery clients. The agent's typed clients use the registered MetadataClient when there is one, and otherwise their own.
Additional discovery options are EgressPolicy (Production), MaxCacheEntries
(1024), MaxCacheAge (24 hours) and JwksMinRefreshInterval (1 minute).
Development loopback policies fail in Production.
AddAAuthResourceManaged(options => …)registers the resource-managed two-party interaction seams:IOpaqueTokenStore(InMemoryOpaqueTokenStore) andIInteractionPendingStore(InMemoryInteractionPendingStore). Map the poll endpoint withMapAAuthInteractionPoll()and open an interaction from a verified endpoint withHttpContext.RequireAAuthInteraction(scope, account).AddAAuthHeldInvocations()registersIAAuthHeldInvocationswith in-memoryIAAuthHeldInvocationStoreandIAAuthSingleUseGatedefaults. Use.WithHeldInvocation(operation, execute, pendingLifetime)on an endpoint and mapMapAAuthHeldInvocations()for the poll URL. The same single-use gate is the durable seam R3 per-call approvals need whenR3Enforcementreturns aSingleUseGrant.AddAAuthEvents()registers event and subscription token verifiers plus a sharedEventsProtocol; mapMapAAuthEventEndpoint(path)andMapAAuthSubscriptionEndpoint(path, options => …)after registering the appropriate event store.AddAAuthR3Documents(readerPolicy)registers R3 document reader policy and an in-memoryIR3DocumentEntitlementsdefault, thenMapR3Document(...)maps the verified document endpoint.AddR3AccessTokenEndpoint(options => …)/MapR3AccessTokenEndpoint()provide the standalone R3 AS endpoint.
public class MyAgentService(IHttpClientFactory factory)
{
private readonly HttpClient _client = factory.CreateClient("identity");
public async Task<string> FetchDataAsync()
{
var response = await _client.GetAsync("https://resource.example/data");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}AddAAuthAgent also registers IAAuthAgentFactory; AddAAuthAgentFactory()
registers it alone. Get(name) returns the registered agent. It is owned by the
factory, so disposing it does nothing. Create(...) builds an agent unknown at
startup, for example per tenant or per user. The caller owns it and disposes it.
AAuthAgent.HttpClient holds the agent's token caches, so reuse one agent
instead of creating one per request:
var agents = app.Services.GetRequiredService<IAAuthAgentFactory>();
var calendar = agents.Get("calendar"); // factory-owned
var events = await calendar.HttpClient.GetStringAsync("https://calendar.example/events");
// Validated like a registered agent's options; caller-owned.
using var tenant = agents.Create(new AAuthAgentDescriptor("tenant-a")
{
Signer = key,
AgentToken = agentToken,
PersonServer = "https://ps.example",
});
// Composed from the builder primitive; caller-owned.
using var probe = agents.Create("probe", key, agentBuilder => agentBuilder
.UseJwt(agentToken)
.WithChallengeHandling("https://ps.example"));AddAAuthAgent(name) also registers the agent's Person Server clients, keyed by
the agent name. Every client is signed as the agent, never with a person token or
auth token it obtained:
| Keyed service | Needs PersonServer |
|---|---|
TokenExchangeClient |
No. Each call names the Person Server. |
AAuthGovernanceClient |
Yes |
MissionClient, PermissionClient, AuditClient, InteractionClient |
Yes. The same instances as AAuthGovernanceClient.Mission and the other properties. |
RevocationClient |
No |
The clients share one agent-signed HttpClient and one MetadataClient. That is
the registered MetadataClient when there is one. Resolving a client that needs
a Person Server throws InvalidOperationException when
AAuthAgentOptions.PersonServer is not set. AAuthAgent exposes the same clients
as TokenExchange, Governance and Revocation:
app.MapPost("/person-token", async (
[FromKeyedServices("planner")] TokenExchangeClient exchange, CancellationToken cancellationToken)
=> await exchange.RequestPersonTokenAsync("https://ps.example", "https://calendar.example", cancellationToken));
var planner = app.Services.GetRequiredService<IAAuthAgentFactory>().Get("planner");
var missions = planner.Governance.Mission; // same instance as the keyed MissionClientAn agent from IAAuthAgentFactory.Create(...) creates its typed clients when
they are first used. It disposes them with the agent.
Challenge handling caches the person tokens and auth tokens it obtains in an
IAAuthTokenCache. A token is reused only for a request with the same agent
token, upstream token, mission, resource, account and signing key. Concurrent
requests that need the same token share one exchange. An agent that alternates
between resources keeps one entry per resource. Clear() forgets every entry,
for example when the person signs out; the next request exchanges again.
Each registered agent gets an in-memory cache, keyed by its name. It is shared by
every HttpClient resolved for the agent. To share a cache between agents, set
AAuthAgentOptions.TokenCache in code, or register your own IAAuthTokenCache
keyed by the agent name. With the builder, WithTokenCache(cache) shares one
cache between builds:
var cache = new InMemoryAAuthTokenCache();
using var reader = new AAuthClientBuilder(key).UseJwt(agentToken)
.WithChallengeHandling("https://ps.example").WithTokenCache(cache).Build();
using var writer = new AAuthClientBuilder(key).UseJwt(agentToken)
.WithChallengeHandling("https://ps.example").WithTokenCache(cache).Build();- Build once and reuse. A registered agent's pipeline, token cache and typed
clients live as long as the container, and the container disposes them.
Disposing an agent from
IAAuthAgentFactory.Get(name)does nothing. - Caller-owned. You own and dispose an agent from
IAAuthAgentFactory.Create(...)and a client fromAAuthClientBuilder.Build()orBuildGovernance(). Building one per request throws away its token cache unless you share one withWithTokenCache. - Three timeouts, each with one job.
AAuthEgressPolicy.RequestTimeoutbounds each HTTP call the SDK makes.PollingTimeout(onChallengeandInteraction) andDeferredPollerOptions.MaxTotalWaitbound the wait for a deferred consent.HttpClient.Timeoutbounds the wholeSendAsync, including every poll inside the pipeline. Agent clients set it toTimeout.InfiniteTimeSpan, so a consent that takes longer than the default 100 seconds is not cut off. If you wrap an agent's handler in your ownHttpClient, setTimeoutabovePollingTimeoutor toTimeout.InfiniteTimeSpan.
Register different clients for different resources or signing modes:
builder.Services.AddAAuthAgent("internal-api", options =>
{
options.Signer = key;
options.PersonServer = "https://ps.internal";
options.TokenRefresher = internalRefresher;
});
builder.Services.AddAAuthAgent("external-api", options =>
{
options.Signer = externalKey;
options.PersonServer = "https://ps.partner.example";
options.TokenRefresher = externalRefresher;
});An app that verifies inbound AAuth requests AND makes signed outbound requests.
See samples/Concierge for a full working implementation with call chaining.
var builder = WebApplication.CreateBuilder(args);
// Inbound: verify signatures on incoming requests
builder.Services.AddAAuthResource(options =>
{
options.Issuer = "https://my-service.example";
options.SigningKeys["rs-1"] = resourceKey;
});
builder.Services.AddAAuthAuthentication();
builder.Services.AddAAuthAuthorization();
// Outbound: sign requests to downstream resources
using var refresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle)
.WithKeyStore(keyStore).Build();
builder.Services.AddAAuthAgent("downstream", options =>
{
options.Signer = agentKey;
options.PersonServer = "https://ps.example";
options.TokenRefresher = refresher;
});
var app = builder.Build();
app.MapAAuthWellKnown();
app.UseRouting();
app.UseAAuth();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/data", async (HttpContext ctx, IHttpClientFactory factory) =>
{
// Inbound request was verified by the AAuth pipeline
var parsed = ctx.GetAAuthParsedKey()!;
// Make signed outbound request
var client = factory.CreateClient("downstream");
var downstream = await client.GetStringAsync("https://other-resource.example/api");
return Results.Ok(new { agent = parsed.Payload?["sub"]?.ToString(), downstream });
}).RequireAAuthSignature(identified: true);
app.Run();AddAAuthAgent validates the named options when the host starts
(ValidateOnStart). A bad configuration fails app.StartAsync() with an
OptionsValidationException; IAAuthAgentFactory.Create applies the same rules:
- Set exactly one of
SignerandKeyHandle. - Set exactly one identity source: an agent token (
AgentTokenorAgentTokenFactory, and/orTokenRefresher),SelfIssued,AgentProvider,JwksUri, orSignatureKeyProvider. Omitting all of them does not select HWK. SelfIssuedneedsIssuerandSubject;JwksUrineedsId,DwkandKeyId;AgentProviderneedsKeyHandle.PersonServer,HandleChallenges = true,Missionand call chaining need an agent-token identity: an agent token,SelfIssuedorAgentProvider.- A
SignatureKeyProvidercannot be combined withEnableResourceManagedAccess. - Set at most one of
UpstreamTokenProviderandChainFromHttpContext, and at most one ofEgressPolicyandDevelopmentLoopbackOrigins.
A KeyHandle that is missing from the key store fails when the agent is first
resolved. Keep an injected disposable TokenRefresher alive until all borrowing
clients stop; the using examples belong to the enclosing host lifetime, not a
short-lived registration helper. SelfIssued and AgentProvider create
refreshers the pipeline owns. Keys and stores remain caller-owned.
Scalars bind from configuration. Members marked code-only are delegates or instances, which binding ignores.
Key (exactly one)
| Property | Type | Default | Description |
|---|---|---|---|
KeyHandle |
string? |
null |
Handle of the signing key in the registered IKeyStore, loaded at first use |
Signer |
IAAuthSigner? |
null |
Code-only. The signing key (must have a private component) |
Identity sources (exactly one)
| Property | Type | Default | Description |
|---|---|---|---|
AgentToken |
string? |
null |
Already-held agent JWT; no implicit provisioning |
AgentTokenFactory |
Func<string>? |
null |
Code-only. Returns the current agent JWT for each request |
TokenRefresher |
ITokenRefresher? |
null |
Code-only. Caller-owned; refreshes the agent token before expiry, alone or with AgentToken |
TokenRefreshThreshold |
TimeSpan? |
5 minutes | Refresh when less than this remains before exp |
SelfIssued |
AAuthSelfIssuedAgentOptions |
empty | Self-issued identity: Issuer and Subject, optional KeyId (default: the key's JWK thumbprint) |
AgentProvider |
AAuthAgentProviderOptions |
empty | Enrolled identity: RefreshEndpoint, refreshed with the KeyHandle key |
JwksUri |
AAuthJwksUriIdentityOptions |
empty | Server identity (jwks_uri scheme): Id, Dwk, KeyId |
SignatureKeyProvider |
ISignatureKeyProvider? |
null |
Code-only. Generic Signature Keys signing; excludes AAuth authorization flows |
Flows
| Property | Type | Default | Description |
|---|---|---|---|
PersonServer |
string? |
the agent token's ps claim |
Person Server; turns challenge handling on |
HandleChallenges |
bool? |
null |
Overrides 401 challenge handling, on by default when PersonServer or call chaining is set |
Challenge |
ChallengeHandlingOptions |
defaults | PS interaction and clarification callbacks, Prompt, Capabilities, polling (callbacks are code-only) |
HandleInteractions |
bool? |
null |
Overrides resource 202 handling, on by default when an Interaction callback is set |
Interaction |
InteractionHandlingOptions |
defaults | Resource 202 callbacks (OnInteractionRequired with an Interaction, OnApprovalPending) and polling (callbacks are code-only) |
Capabilities |
string[]? |
null |
AAuth-Capabilities declared on every signed request |
Mission |
Mission? |
null |
Code-only. The agent's approved mission; person tokens carry its mission_s256 |
UpstreamTokenProvider |
Func<string?>? |
null |
Code-only. Returns the upstream auth token to chain |
ChainFromHttpContext |
bool |
false |
Chain the current request's verified upstream auth token |
EnableResourceManagedAccess |
bool |
false |
Capture + replay the opaque AAuth-Access token (resource-managed, two-party) |
AAuthAccessStore |
IAAuthAccessStore? |
in-memory | Code-only. Per-origin token store for the resource-managed flow |
Transport
| Property | Type | Default | Description |
|---|---|---|---|
EgressPolicy |
AAuthEgressPolicy? |
Production |
Code-only. Egress policy for the agent's requests |
DevelopmentLoopbackOrigins |
string[]? |
null |
Loopback origins a development agent may call |
InnerHandler |
HttpMessageHandler? |
null |
Code-only. Transport under the signer |
TransportContract |
AAuthTransportContract? |
null |
Code-only. What InnerHandler guarantees about egress |
OnSignatureBase |
Action<HttpRequestMessage, string>? |
null |
Code-only. Observes each RFC 9421 signature base |
TokenCache |
IAAuthTokenCache? |
the agent's keyed cache (in-memory) | Code-only. Person and auth tokens from challenge handling (see Token Cache) |
AddAAuthResource registers inbound verification and resource-token issuance.
It requires the resource issuer but does not require agent credentials.
Signing keys are required when issuing resource tokens or making signed calls;
a verification-only resource can leave SigningKeys empty. It also registers a
TokenVerifier (with the resource's EgressPolicy and TimeProvider) and
IOptions<AAuthResourceOptions>; the AddAAuthResource(IConfiguration, …)
overload binds from a section such as AAuth:Resource.
| Property | Type | Default | Description |
|---|---|---|---|
Issuer |
string |
required | Resource HTTPS URL (metadata + audience) |
EgressPolicy |
AAuthEgressPolicy |
Production |
Egress and URL-validation policy; development loopback fails in Production |
AccessServer |
string? |
null |
Four-party AS issuer; when set, resource-token challenges target this AS and auth-token verification expects aauth-access.json |
SigningKeys |
AAuthSigningKeySet |
empty | Keys published at the JWKS; resource tokens are signed with the active key |
KeyHandle |
string? |
null |
Handle in the registered IKeyStore to load the signing key from when SigningKeys is empty |
KeyId |
string? |
null |
kid for the key loaded from KeyHandle (default: its JWK thumbprint) |
MaxSignatureAge |
TimeSpan |
60 s | Maximum age for inbound signature created |
TimeProvider |
TimeProvider |
TimeProvider.System |
Clock for signatures, tokens and revocation |
EnableReplayDetection |
bool |
true |
Register IJtiStore (InMemoryJtiStore) for request replay and token inventory |
KeyResolver |
ISignatureKeyResolver? |
null |
Custom resolver; otherwise DefaultSignatureKeyResolver uses DI discovery clients and token verifiers |
Name |
string? |
null |
Human-readable name in metadata (name) |
Description |
string? |
null |
Optional resource metadata field (description) |
LogoUri |
string? |
null |
Optional resource metadata field (logo_uri) |
LogoDarkUri |
string? |
null |
Optional resource metadata field (logo_dark_uri) |
DocumentationUri |
string? |
null |
Optional resource metadata field (documentation_uri) |
TosUri |
string? |
null |
Optional resource metadata field (tos_uri) |
PolicyUri |
string? |
null |
Optional resource metadata field (policy_uri) |
ScopeDescriptions |
Dictionary<string, string>? |
null |
Scope descriptions in metadata |
SignatureWindow |
int? |
null |
Advertised signature validity (seconds) |
AdditionalSignatureComponents |
IReadOnlyList<string>? |
null |
Metadata components agents include on first signed request |
AccessMode |
string? |
null |
Advisory metadata access mode (agent-token, person-token, session-token, auth-token, or R3 per-call) |
AuthorizationEndpoint |
string? |
null |
Resource's proactive authorization endpoint URL; not the PS/AS resource-token recipient (draft-11 removed PersonServerAudience; the recipient is AccessServer or the presented token's PS) |
RevocationEndpoint |
string? |
null |
Revocation endpoint URL |
ConfigureRevocation |
Action<AAuthRevocationOptions>? |
null |
Narrows the revocation endpoint mapped by MapAAuthResourceRevocation() |
EnableResourceManagedAccess |
bool |
false |
Register a default IOpaqueTokenStore for the resource-managed (two-party) flow |
AdditionalMetadata |
Dictionary<string, JsonNode?>? |
null |
Extra top-level metadata entries, such as R3 vocabulary metadata |
| Property | Type | Default | Description |
|---|---|---|---|
MetadataCacheTtl |
TimeSpan |
5 min | How long to cache well-known metadata |
JwksCacheTtl |
TimeSpan |
1 hour | How long to cache JWKS documents |
JwksMinRefreshInterval |
TimeSpan |
1 min | Minimum time between JWKS refreshes for one issuer |
MaxCacheEntries |
int |
1024 | Maximum cached metadata/JWKS entries |
MaxCacheAge |
TimeSpan |
24 hours | Absolute maximum cache age |
EgressPolicy |
AAuthEgressPolicy |
Production |
Egress policy for discovery fetches |
For intermediary services that act as both resource and agent, AAuthClientBuilder provides call-chaining methods:
// From HttpContext (reads UpstreamAuthTokenFeature set by middleware)
using var contextClient = new AAuthClientBuilder(key)
.UseJwt(() => tokenHolder.Current)
.WithTokenRefresh(refresher)
.WithCallChaining(httpContext)
.Build();
// From a raw upstream token string
using var fixedUpstreamClient = new AAuthClientBuilder(key)
.UseJwt(() => tokenHolder.Current)
.WithTokenRefresh(refresher)
.WithCallChaining(upstreamAuthToken)
.Build();
// From a dynamic provider
using var dynamicUpstreamClient = new AAuthClientBuilder(key)
.UseJwt(() => tokenHolder.Current)
.WithTokenRefresh(refresher)
.WithCallChaining(() => GetUpstreamToken())
.Build();WithCallChaining automatically:
- Routes downstream token requests to the PS the upstream token names via
CallChainingRouter - Passes
upstream_tokenon the downstream person token and auth token requests - Inserts
MissionForwardingHandlerto attach the upstream token to downstream requests (the PS carries itsmission_s256forward) - Handles the person-token and auth-token challenges and retries
The mission governance client is built from AAuthClientBuilder, which wires the
signed channel for you. The client is bound to one Person Server, so the agent
must have an agent-token identity and an explicit PersonServer. Every registered
agent registers an AAuthGovernanceClient keyed by the agent name, signed as that
agent (see Typed Clients). Call .WithGovernance(options) to set
its default GovernanceOptions:
builder.Services.AddAAuthAgent("planner", options =>
{
options.Signer = agentKey;
options.AgentToken = agentToken;
options.PersonServer = "https://ps.example";
})
.WithGovernance(governanceOptions); // optional: default GovernanceOptions
var app = builder.Build();
var planner = app.Services.GetRequiredKeyedService<AAuthGovernanceClient>("planner");In a minimal API or controller, inject it with
[FromKeyedServices("planner")] AAuthGovernanceClient governance. To build one
inline instead of via DI, call BuildGovernance() on a configured builder:
using var governance = new AAuthClientBuilder(agentKey)
.UseJwt(agentToken)
.WithPersonServer("https://ps.example")
.BuildGovernance(); // AAuthGovernanceClientBuildGovernance() requires a token/signing configuration and an explicit Person
Server (WithPersonServer). It also supports SelfIssuing and Enrolled:
the returned facade owns its signed/discovery clients and must be disposed.
Constructor/Create callers retain ownership of injected clients. See
Mission Governance Clients.
AddAAuthGovernance() registers the in-memory mission storage seams as
singletons. It uses TryAdd, so register durable implementations first to
override them. The defaults are intentionally simple: DefaultMissionApprover
approves proposed missions, DefaultPermissionDecider grants only pre-approved
tools and otherwise prompts, DefaultAuditSink appends to the mission log, and
DefaultInteractionRelay has no user channel so it returns unavailable (or not
accepted for completion). It also registers the default IMissionTokenConsent,
IMissionPersonTokenIssuer and the route registry that MapAAuthPersonServer()
uses to attach the PS minting surface.
builder.Services.AddAAuthGovernance(); // InMemoryMissionStore + InMemoryMissionLog
builder.Services.AddSingleton<IPermissionDecider>(permissionDecider);
builder.Services.AddSingleton<IAuditSink>(missionAuditSink);
builder.Services.AddSingleton<IInteractionRelay>(interactionRelay);The user channel can also be supplied as a lambda instead of a full class, via
AddAAuthInteractionRelay(...) (backed by DelegateInteractionRelay). It removes
any previously registered relay (including the fail-closed default) and registers the
delegate-backed one:
builder.Services.AddAAuthInteractionRelay((request, ct) =>
Task.FromResult(request.Type == InteractionType.Question
? new InteractionRelayResult { Answer = "Approved." }
: new InteractionRelayResult { Pending = true }));Relay answers map to an answered response, Pending maps to a deferred 202,
and Unavailable maps to 424 interaction_unavailable.
See Mission Governance (Server) for the seams and the decision model.
A Person Server registered with AddAAuthPersonServer can call .WithGovernance()
on its builder instead; it calls AddAAuthGovernance() for you, declares the
governance paths on the Person Server registration, and lets
MapAAuthPersonServer() map them. See Person Server and Access Server
Registration.
Register a Person Server (PS) or Access Server (AS) in DI, then map it by name.
AddAAuthPersonServer / AddAAuthAccessServer return a builder for the role's
seams; MapAAuthPersonServer() / MapAAuthAccessServer() map the endpoints.
The default instance names are AAuthPersonServerBuilder.DefaultName
("PersonServer") and AAuthAccessServerBuilder.DefaultName ("AccessServer").
builder.Services.AddAAuthPersonServer(configure: options =>
{
options.Issuer = psIssuer;
options.SigningKeys = new AAuthSigningKeySet(PsKid, psKey);
options.DefaultScope = "calendar.read";
})
// Unset ⇒ federate to verified aud; empty ⇒ three-party only.
.WithTrust(trust => trust.AccessServers.Allowed = trustedAccessServers)
.UseClaimsAsserter(new DefaultIdentityClaimsAsserter("user-42")) // swap in a real asserter
.WithFederation() // PS→AS four-party client, signed as this PS
.WithGovernance(); // declares mission/permission/audit/interaction endpoints
var app = builder.Build();
app.MapAAuthPersonServer();An Access Server has no default access policy, so it needs UsePolicy or an
IAccessPolicy registered in DI:
builder.Services.AddAAuthAccessServer(configure: options =>
{
options.Issuer = asIssuer;
options.SigningKeys = new AAuthSigningKeySet(AsKid, asKey);
})
.WithTrust(trust => trust.PersonServers.Allowed = trustedPersonServers)
.UsePolicy(accessPolicy);
var app = builder.Build();
app.MapAAuthAccessServer();| Helper | Person Server | Access Server |
|---|---|---|
Configure(options => …) |
✓ | ✓ |
WithTrust(trust => …): configures options.Trust |
✓ | ✓ |
UsePendingStore<T>() / (instance) / (sp => …) |
✓ | ✓ |
UseTokenVerifier(verifier) |
✓ | ✓ |
UseTokenInventory<T>() / (instance): the issuer's IJtiStore |
✓ | ✓ |
UseClaimsAsserter<T>() / (instance) / (sp => …) |
✓ | — |
UseAgentPersonBindingStore<T>() / (instance) / (sp => …) |
✓ | — |
UsePersonResourceEnrollmentStore<T>() / (instance) / (sp => …) |
✓ | — |
UsePersonSubjectDeriver<T>() / (instance) / (sp => …) |
✓ | — |
UsePaymentSettler<T>() / (instance) / (sp => …) |
✓ | — |
UseBillingRelationshipCache<T>() / (instance) / (sp => …) |
✓ | — |
UseCollocatedAccessServer(resourceIssuer, accessServerName, expectedIssuer) |
✓ | — |
UseCollocatedAccessServer(policy) / (sp => …) |
✓ | — |
WithFederation(): the PS→AS AccessServerClient |
✓ | — |
WithGovernance(): calls AddAAuthGovernance() |
✓ | — |
UsePolicy<T>() / (instance) / (sp => …): required |
— | ✓ |
WithFederation() signs PS→AS token requests as the PS (jwks_uri scheme,
active key) through the named AAuthPersonServerBuilder.FederationHttpClientName
("aauth-federation") client. Tests can redirect that client in process with
AddHttpClient(AAuthPersonServerBuilder.FederationHttpClientName) plus
AAuthFederationOptions.TransportContract.
Each seam is a keyed singleton under the instance name. It resolves in this order:
- The builder's
Use*helper. - An unkeyed DI registration of the same service type, such as
services.AddSingleton<IPersonPendingStore>(…). - The SDK default.
| Seam | Default |
|---|---|
IPersonPendingStore |
InMemoryPersonPendingStore |
IIdentityClaimsAsserter |
DefaultIdentityClaimsAsserter |
IAgentPersonBindingStore |
InMemoryAgentPersonBindingStore |
IPersonResourceEnrollmentStore |
InMemoryPersonResourceEnrollmentStore |
IPersonSubjectDeriver |
HmacPersonSubjectDeriver (requires durable PairwiseSubjectSecrets in Production) |
IAAuthCollapsedFederationPolicy |
ConfiguredCollapsedFederationPolicy over AAuthPersonServerOptions.CollapsedFederation |
IAAuthPaymentSettler |
none: AS 402 payment challenges are declined unless a billing cache entry already exists |
IAAuthBillingRelationshipCache |
InMemoryAAuthBillingRelationshipCache |
IAccessPendingStore |
InMemoryAccessPendingStore |
IAccessPolicy |
none: startup fails without one |
TokenVerifier |
A verifier with the role's EgressPolicy and TimeProvider |
IJtiStore (token inventory) |
InMemoryJtiStore |
AddAAuthPersonServer also registers AddAAuthDiscovery() and in-memory
IMissionStore / IMissionLog defaults (TryAdd). To use a seam in your own
endpoints, resolve it by instance name:
var pending = app.Services.GetRequiredKeyedService<IPersonPendingStore>(AAuthPersonServerBuilder.DefaultName);
app.MapGet("/admin/pending/{id}", (string id,
[FromKeyedServices(AAuthPersonServerBuilder.DefaultName)] IPersonPendingStore store) =>
store.Get(id) is { } entry ? Results.Ok(entry.Status.ToString()) : Results.NotFound());Outside the Development environment, mapping a role that still uses an
in-memory default (a pending store, the token inventory, the agent/person binding
store, the person/resource enrollment store, or the mission store or log) logs a
warning. A Person Server with no durable pairwise subject secret also logs a
warning in non-production and fails validation in Production. That state is lost
on restart and isn't shared across instances, so register durable implementations
in production.
The role options are validated at startup instead of through required members.
A bad configuration fails app.StartAsync() with an OptionsValidationException.
MapAAuthPersonServer() / MapAAuthAccessServer() fail the same way because they
read the options. The exception joins every failure with "; ":
Issuermust be an absolute https URL (loopback http is allowed for development).- The role needs a signing key: add one to
SigningKeysor setKeyHandle. - PS
TokenPath,PersonTokenPath,RevocationPath,InteractionPath, governance paths and ASTokenPath,RevocationPath,InteractionLoginPathmust be derived paths without a query or fragment. - Each
Trust.AccessServers(PS) /Trust.PersonServers(AS)Allowedentry must be an absolute https URL. - Production Person Servers must configure
PairwiseSubjectSecrets, andActivePairwiseSubjectKeyIdmust name one of them when set. - Collocated federation declarations must name absolute resource and expected AS issuer URLs and a linked Access Server role name.
- An Access Server must resolve an
IAccessPolicy.
Each role has an IConfiguration overload. The conventional sections are
AAuth:PersonServer, AAuth:AccessServer and AAuth:Resource, exposed as the
ConfigurationSection constant on each extension class. The optional
configure callback runs after binding:
builder.Services.AddSingleton<IKeyStore>(FileKeyStore.Default());
builder.Services.AddAAuthPersonServer(
builder.Configuration.GetSection(AAuthPersonServerServiceCollectionExtensions.ConfigurationSection));
builder.Services.AddAAuthResource(
builder.Configuration.GetSection(AAuthResourceServiceCollectionExtensions.ConfigurationSection),
options => options.Name = "Calendar");{
"AAuth": {
"PersonServer": {
"Issuer": "https://ps.example",
"KeyHandle": "ps-signing-key",
"KeyId": "ps-1",
"MissionPath": "/mission",
"Trust": { "AccessServers": { "Allowed": [ "https://as.example" ] } }
}
}
}When SigningKeys is empty, KeyHandle loads the signing key from the
registered IKeyStore. The key is published under KeyId, or under its JWK
thumbprint when KeyId is unset. AAuthResourceOptions has the same KeyHandle
/ KeyId pair.
By default a mapped role serves every request that reaches the app. When several
roles or instances share one host, set MatchIssuerHost = true on each. Each
instance then serves only requests whose Host matches its issuer's authority:
builder.Services.AddAAuthPersonServer("tenant-a", options =>
{
options.Issuer = "https://ps-a.example";
options.SigningKeys = new AAuthSigningKeySet("ps-a", psKey);
options.MatchIssuerHost = true;
});
builder.Services.AddAAuthPersonServer("tenant-b", options =>
{
options.Issuer = "https://ps-b.example";
options.SigningKeys = new AAuthSigningKeySet("ps-b", psSigningKey);
options.MatchIssuerHost = true;
}).UseClaimsAsserter(identityAsserter);
var app = builder.Build();
app.MapAAuthPersonServer("tenant-a");
app.MapAAuthPersonServer("tenant-b");Each instance registers an AAuth.Server.IAAuthServerIdentity keyed by its name.
It holds the Issuer, the well-known document name (Dwk), the SigningKeys
and the EgressPolicy. Url(path) builds an absolute URL on the server.
CreateSignedClient() returns an HttpClient that signs as the server
(jwks_uri scheme, active key) without exposing the key:
var ps = app.Services.GetRequiredKeyedService<IAAuthServerIdentity>(AAuthPersonServerBuilder.DefaultName);
var consentUrl = ps.Url("/interaction"); // {Issuer}/interaction
using var signedAsPs = ps.CreateSignedClient();
var ownTokens = tokenVerifier.WithLocalIssuer(ps.Issuer, ps.SigningKeys);To route the signed client through a custom transport, pass the inner handler
and its AAuthTransportContract together:
CreateSignedClient(innerHandler, AAuthTransportContract.InProcessOnly).
See Token Issuance → One-Call Person Server and Federated authorization.