Skip to content

Install verified callers in Cratis Arc

Cratis Arc can install a signed v2 browser-session caller for a receiving capability resource. The verified person becomes the principal used by Arc authorization, ICurrentPrincipalAccessor, and Chronicle identity/causation when Chronicle is installed. Arc accepts signed v2 callers only through an admitted forwarding hop: the immediate TLS peer must present a client certificate from a trusted CA with an allowed peer identity. The signature and hop admission do not grant product access.

The runnable acceptance sample in the repository’s Samples/VerifiedCallerAcceptance folder shows a complete host and product-owned authorization. This configuration fragment belongs in its Program.Build, where builder, settings, and the sample’s PersonDirectory are available:

using System.Net;
using Cratis.Arc;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using VerifiedCallerAcceptance;
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.Listen(IPAddress.Loopback, settings.CapabilityPort, listen => listen.UseHttps(settings.ServerCertificate, https =>
{
https.ClientCertificateMode = ClientCertificateMode.AllowCertificate;
https.AllowAnyClientCertificate();
}));
kestrel.Listen(IPAddress.Loopback, settings.DefaultPort);
});
builder.AddCratisArc(
options =>
{
options.EndpointGroups.Assign("capabilities", settings.CapabilityPort);
options.Tenancy.HttpHeader = "x-example-tenant";
options.ForwardedIdentity.ClientCertificate.Required = true;
options.ForwardedIdentity.ClientCertificate.TrustedCertificateAuthoritiesPath = settings.TrustedCertificateAuthoritiesPath;
options.ForwardedIdentity.ClientCertificate.AllowedDnsNames.Add(settings.AllowedPeerDnsName);
},
arc => arc.WithoutControllers().WithCapabilities().WithVerifiedCallers(callers => callers
.ForResource("https://api.example.test", "example-api", "sample")
.UsePersonDirectory<PersonDirectory>()
.UseSignedForwardedIdentityV2(v2 => v2
.WithRealmIssuer("https://identity.example.test")
.WithTenantHeader("x-example-tenant")
.AddPublicKey(settings.KeyId, settings.PublicKeyPem)
.UseProcessLocalNonceStore())));

settings.ServerCertificate is the backend’s TLS certificate with its private key; the trusted CA bundle and allowed DNS name identify the forwarding proxy’s client certificate, not the browser. The sample uses AllowCertificate and delegates peer validation to Arc through AllowAnyClientCertificate(). This lets the acceptance specs prove Arc returns HTTP 401 without dispatch when a valid signed envelope arrives without a client certificate. Arc refuses signed v2 callers without a trusted client certificate regardless of the transport mode, including when ClientCertificate.Required is false. Production hosts may additionally require the certificate at TLS with ClientCertificateMode.RequireCertificate, rejecting missing certificates before HTTP.

Call app.UseCratisArc() after building the host, as in the sample. The capability port must be reachable only through the trusted ingress. An endpoint-group assignment alone does not establish that boundary.

WithVerifiedCallers installs exactly one resource/audience/environment binding without normalizing it. It is separate from WithCapabilities, which enables the request gate. Missing binding values, a missing person directory, missing realm issuer or keys, and a second installation throw InvalidVerifiedCallerInstallation during registration, before requests are served. Capability hosting without an installation continues to fail startup.

AddPublicKey accepts public-only P-256 PEM material. Private keys, other curves, and malformed PEM throw InvalidForwardedIdentityPublicKey. Keep signing keys at the proxy, not the receiving backend. Key identifiers are exact and unique; a duplicate identifier is a configuration error.

Implement Cratis.Arc.Identity.Callers.ICallerPersonDirectory. Its Find(CallerAccountKey, CancellationToken) returns CallerPersonFound.Found(CallerPersonId) only for an existing persisted account mapping; otherwise return CallerPersonFound.NotFound(). The result type is in Cratis.Arc.Identity.Callers.

The account key consists of the exact trusted realm issuer and canonical lowercase UUID subject. idp describes the upstream connection; it is not the account-key namespace. Arc registers the selected directory in the request scope, so it can depend on scoped persistence services. Do not provision people or infer authorization from forwarded headers. The sample directory contains synthetic persisted mappings solely to keep the proof self-contained.

No nonce store is selected by default. A missing choice throws ForwardedIdentityNonceStoreNotConfigured. UseProcessLocalNonceStore() is an explicit single-process sample/development choice, not fleet replay protection.

For multiple receiving replicas, use UseNonceStore<TStore>(), where the product’s singleton, concurrency-safe store implements IForwardedIdentityNonceStore. Its existing asynchronous Reserve(aud, kid, nonce, timestamp, now, cancellationToken) contract returns Reserved, Replayed, or Unavailable. Reservations must be atomic and shared across replicas, survive the required replay window, and fail closed on outages or capacity limits. Do not replace an unavailable fleet store with a local fallback.

A valid caller is evaluated as its verified subject. Product roles, policies, and live tenant membership must still authorize that subject. Claims transformation can enrich roles, but every authenticated identity it adds must preserve that subject; a different or missing subject is not an enrichment of the same person.

[RequiresScope("content:write")] narrows an operation to callers whose verified credential scope ceiling contains that scope. Scope comparison is exact. An absent ceiling is empty, not unrestricted. Claims, roles, another authentication scheme, or a same-subject product cookie cannot widen it. Conversely, a scope cannot substitute for product authorization: an editor-only operation still refuses a non-editor with content:write.

A request already authenticated as a different subject is refused rather than merged with the verified person. A signed tenant header is only a candidate: Arc checks live ITenantMembership against the verified principal before publishing it or invoking the operation. The sample covers both missing roles and tenant non-membership and checks that neither invokes the command.

From the repository root:

Terminal window
dotnet test Samples/VerifiedCallerAcceptance.Specs/VerifiedCallerAcceptance.Specs.csproj -c Release

The specs use only public Arc APIs, a real HTTPS capability listener, synthetic signing material from the shared v2 fixture, and freshly generated test CA, server, and forwarding-peer certificates. Clients pin the test server certificate; no machine trust-store changes are needed. They compare the test signer’s framing to the shared vector bytes, then exercise command/query dispatch, scopes, roles, membership, replay, key/audience mismatch, missing people, unsigned forwarding, and listener isolation. The command returns ICurrentPrincipalAccessor’s subject, the same identity source used for causation; this sample does not append Chronicle events.

This installer supports the signed v2 browser-session flow. It does not install PAT, OAuth-grant, service-account, bearer-verifier, or on-behalf adapters. Those flows cannot be enabled by changing a header or credential kind. MCP serving, --as clients, protected-resource metadata, and OAuth registration are separate work.

For proxy-side framing, ingress admission, rotation, and replay retention requirements, see signed forwarded identity v2. For capability listener exposure, see capability hosting.