Federated Access (Four-Party)

July 2, 2026 · View on GitHub

Live demo | Access Mode Comparison

Overview: The resource has its own Access Server (AS) that enforces policy. The PS federates with the AS to obtain the auth token. From the agent's perspective, the flow looks identical to PS-asserted — the federation happens between PS and AS transparently.

sequenceDiagram
    participant Agent
    participant Resource
    participant PS as Person Server
    participant AS as Access Server
    Agent->>Resource: GET /data (signed, sig=jwt)
    Resource-->>Agent: 401 + resource token (aud=AS URL)
    Agent->>PS: POST /token (resource token)
    PS->>AS: POST /token (signed, forwards resource token)
    AS-->>PS: auth token (iss=AS)
    PS-->>Agent: auth token
    Agent->>Resource: GET /data (signed, auth token)
    Resource-->>Agent: 200 OK

Agent-Side Code

Identical to PS-asserted — WithChallengeHandling() handles it transparently. The only difference is the resource token's aud points to the AS URL instead of the PS URL.

using AAuth.Agent;
using AAuth.Crypto;
using AAuth;

var keyStore = FileKeyStore.Default();
var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!)
    ?? throw new InvalidOperationException("Key not found. Run enrollment first.");
var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!;

using var client = AAuthClientBuilder.Enrolled(key)
    .RefreshingFrom(apRefreshEndpoint, configuration["AAuth:LocalKeyHandle"]!)
    .WithKeyStore(keyStore)
    .WithChallengeHandling(personServer: "https://ps.example")
    .Build();

var response = await client.GetAsync("https://resource.example/data");

DI Registration

Identical to PS-asserted — the federation is transparent to the agent:

using AAuth.Agent;
using AAuth.Crypto;

var keyStore = FileKeyStore.Default();
var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!);
var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!;

builder.Services.AddAAuthAgent("federated", options =>
{
    options.Key = key!;
    options.PersonServer = "https://ps.example";
    options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, configuration["AAuth:LocalKeyHandle"]!)
        .WithKeyStore(keyStore)
        .Build();
});

See Dependency Injection for full reference.

Key Difference from PS-Asserted

The resource token aud = AS URL (not PS URL). The PS recognizes this and federates to the AS rather than issuing the auth token itself.

Person-Server-Side Code (federation)

The federation happens at the Person Server. When the PS receives POST /token, it peeks the resource token's aud: if it is the PS itself, the PS asserts access directly (three-party). If it is a trusted Access Server, the PS makes a signed server-to-server call to the AS and relays the AS-minted auth token back to the agent.

// MockPersonServer — federation branch (simplified).
var aud = PeekJwtAudience(resourceToken);

if (aud == psIssuer)
{
    // Three-party: the PS asserts access itself.
    return Results.Json(new { auth_token = MintLocally(...) });
}

if (!trustedAccessServers.Contains(aud))
{
    return Results.Json(new { error = "untrusted_access_server" }, statusCode: 403);
}

// Four-party: federate to the AS. The PS signs the call with the `jwks_uri`
// scheme so the AS can pin the caller to a trusted Person Server.
// `FederateAsync` drives the whole AS exchange — including polling any
// `202` deferred/interaction/claims requirement to completion — and returns
// the verified `aa-auth+jwt` directly.
var authToken = await accessServerClient.FederateAsync(aud, new AccessServerRequest
{
    ResourceToken    = resourceToken,
    AgentToken       = agentToken,
    ExpectedAudience = resourceUrl,   // resource token `iss`
    ExpectedAgentId  = agentId,
    AgentKey         = agentConfirmationKey,
}, ct);

return Results.Json(new { auth_token = authToken });

Both branches above — the three-party mint and the four-party federation — are packaged in the one-call host helper MapAAuthPersonServer. Federation is open by default: with TrustedAccessServers unset the PS federates to the AS named in a verified resource token's aud; set the list (or the IsTrustedAccessServer predicate) to pin specific Access Servers, or set it empty for three-party only. See Token Issuance → One-Call Person Server.

Access-Server-Side Code

The Access Server is the fourth party. The whole token-endpoint pipeline ships as a single host helper, MapAAuthAccessServer: it publishes the /.well-known/aauth-access.json metadata + JWKS, verifies the RFC 9421 request signature (pinning the caller's jwks_uri host to a trusted Person Server), verifies the agent and resource tokens, evaluates policy through a pluggable IAccessPolicy, and mints the auth token.

using AAuth.Access;

// Register the policy decision point (stub | keycloak) and the store that
// parks deferred decisions (§Claims Required / interactive consent).
builder.Services.AddSingleton<IAccessPolicy>(new StubAccessPolicy(requiredClaims));
builder.Services.AddSingleton<IAccessPendingStore, InMemoryAccessPendingStore>();

var app = builder.Build();

// One call maps /.well-known + JWKS, request-signature verification, and
// POST /token + GET|POST /pending/{id}.
app.MapAAuthAccessServer(new AAuthAccessServerOptions
{
    Issuer               = asIssuer,
    SigningKeys          = new Dictionary<string, AAuthKey> { [AsKid] = asKey },
    DefaultScope         = "wallet.read",
    TrustedPersonServers = trustedPersonServers,
});

The helper resolves IAccessPolicy and IAccessPendingStore from DI. The policy returns one of Allow / Deny / NeedsInteraction / NeedsClaims / NeedsPayment; the helper maps those to a minted auth token, 403, or a 202 that parks the decision and advertises the requirement to the PS.

TrustedPersonServers follows the same open-by-default rule: null brokers for any verifiable Person Server (the spec's "no separate registration step"); empty denies all; a non-empty set or the IsTrustedPersonServer predicate restricts. Leaving it open logs a startup Warning.

The dwk=aauth-access.json tell

The auth token's dwk (discovery well-known) claim points at /.well-known/aauth-access.jsonnot /.well-known/aauth-person.json. This is how the resource knows the token was minted by an Access Server and verifies it against the AS's JWKS rather than the PS's.

Minted bydwkiss
Person Server (three-party)aauth-person.jsonPS URL
Access Server (four-party)aauth-access.jsonAS URL

Keycloak-Backed Access Server

The reference Mock Access Server ships a pluggable IAccessPolicy. The keycloak provider makes Keycloak the policy decision point: the AS adapter performs the AAuth crypto while Keycloak handles the interactive user login and the authorization decision (UMA uma-ticket grant). The realm models the resource scopes (wallet.read, wallet.charge) and a payer role.

See the Mock Access Server README for the realm/client/resource/scope/policy setup and the claim mapping.

When the AS policy engine needs an interactive user login/consent, the AS cannot decide synchronously. It returns 202 with AAuth-Requirement: requirement=interaction and a Location URL. The PS relays that 202 back to the agent on the same challenge pipeline, and the agent surfaces the AS interaction URL and polls until the verdict resolves — structurally identical to PS-asserted deferred consent, but the consent screen is the AS's, not the Person Server's. Both AS policies exercise this path: the stub policy renders its own Approve/Deny consent page (AccessServer:RequireConsent), and the keycloak policy hands off to Keycloak's login/consent screen.

sequenceDiagram
    participant Agent
    participant Resource
    participant PS as Person Server
    participant AS as Access Server
    participant KC as Keycloak
    Agent->>Resource: GET /data (signed)
    Resource-->>Agent: 401 + resource token (aud=AS)
    Agent->>PS: POST /token (resource token)
    PS->>AS: POST /token (signed)
    AS-->>PS: 202 requirement=interaction + Location
    PS-->>Agent: 202 + interaction URL (relayed)
    Note over Agent: surface URL, begin poll loop
    Agent->>KC: user logs in / consents
    KC-->>AS: authorization decision
    Agent->>PS: poll pending URL
    PS->>AS: poll
    AS-->>PS: auth token (iss=AS)
    PS-->>Agent: auth token
    Agent->>Resource: GET /data (signed, auth token)
    Resource-->>Agent: 200 OK

Because the relay rides the existing WithChallengeHandling / OnInteractionRequired callback, the agent code is unchanged from PS-asserted deferred consent — federation (and any AS interaction) is transparent.

Try It

DemoCommandAS policy
Both UIs, no Dockermake demostub (interactive consent)
Both UIs, real Keycloakmake demo-keycloakkeycloak (interactive)

In the GuidedTour pick Federated mode; in the SampleApp open the Federated (Four-Party) page. With the Keycloak policy, log in as demo/demo (admin, full access) or guest/guest (limited).

PS-AS Collapse

When the PS and AS are the same server, the wire protocol is unchanged — it's just an internal evaluation. No code changes needed on either side.

Identity-Claims Push (requirement=claims)

When an Access Server needs identity claims it does not hold to make a policy decision, it answers the PS's token request with 202 and AAuth-Requirement: requirement=claims, listing the claim names in the body's required_claims array (AAuth §Claims Required):

HTTP/1.1 202 Accepted
Location: https://as.example/pending/xyz
AAuth-Requirement: requirement=claims
Content-Type: application/json

{ "status": "pending", "required_claims": ["email", "tenant"] }

The Person Server is the identity authority, so it supplies the requested claims — including a directed pseudonymous sub — by POSTing a signed request to the Location URL, then resumes polling that same URL for the issued auth token. In the SDK this is a single additive callback on the PS's federation request:

var fedRequest = new AccessServerRequest
{
    ResourceToken = resourceTokenJwt,
    AgentToken = agentTokenJwt,
    ExpectedAudience = resourceUrl,
    ExpectedAgentId = agentId,
    AgentKey = agentConfirmationKey,
    RequestedScope = scope,
    // The AS asked for identity claims; answer with a directed sub + claims.
    OnClaimsRequired = (requirement, ct) =>
    {
        var claims = new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
        foreach (var name in requirement.RequiredClaims)
        {
            if (heldClaims.TryGetValue(name, out var value))
            {
                claims[name] = value;
            }
        }
        return Task.FromResult(new ClaimsResponse
        {
            Subject = directedSubject,
            Claims = claims,
        });
    },
};

AccessServerClient.FederateAsync reads required_claims, invokes the callback, POSTs the returned ClaimsResponse (signed, origin-pinned to the AS) to the Location, and continues the poll loop to the 200 auth token. The Subject (directed sub) is mandatory — leaving it blank surfaces an InvalidOperationException. The issued auth token asserts the pushed claims (the AS sets sub to the directed identifier, promotes tenant to the named tenant claim, and echoes the recognized claims). Try it with the stub policy by configuring the Access Server with AccessServer:RequireClaims (e.g. AccessServer__RequireClaims__0=email); the demo Person Server releases email, tenant, and name for its bound principal.

Further Reading