Apricot Framework

ASP.NET Core

Registration, every setting in the Authentication section, calling as the caller, reading the principal, and what is checked at startup.

builder.Services.AddJwtBearerAuthentication(builder.Configuration);
builder.Services.AddAuthorization();

That registers both directions: inbound bearer validation, and the client that obtains tokens for onward calls. A service that only calls others and serves nothing authenticated calls AddClientAuthentication(builder.Configuration) instead, which registers the client alone and does not ask for an audience.

Token exchange is not in that bundle, and the omission is deliberate. Bundling the client credentials grant is free — lazily constructed singletons, a named HttpClient, some bound options. The exchange is not: it registers IHttpContextAccessor, which a bearer-validating host does not otherwise have, and once registered the http context factory writes a static AsyncLocal on every request whether or not anything reads it. The second reason matters more. A service has an identity of its own by virtue of being configured; acting as somebody else is an architectural decision, and it should appear in the host that made it rather than arrive with bearer validation.

Calling as the caller

builder.Services.AddTokenExchangeAuthentication(builder.Configuration);

Adds an ITokenExchangeAuthenticator, which obtains a token for whoever this service is serving rather than for the service itself. It is a separate registration from IClientCredentialsAuthenticator and does not replace it, so adding it cannot change how an existing call authenticates; order between the two does not matter. Nothing in the settings changes — the same Authentication:Client credentials are used — and the subject comes from the request being served. See Token exchange.

What gets registered

ServiceImplementation
ITokenRequestHostingContextConfiguredTokenRequestHostingContext — reads the section on each call, so a reload takes effect without a restart
ITokenCacheInMemoryTokenCache
IClientCredentialsAuthenticatorClientCredentialsAuthenticator
ITokenExchangeAuthenticatorTokenExchangeAuthenticator, when the exchange is added
ISubjectTokenProviderHttpContextSubjectTokenProvider, when the exchange is added

Every one is conditional, so registering your own first keeps it. Replacing ITokenRequestHostingContext changes how token requests are sent for both grants at once, which is usually what is meant — this service is who it is either way.

Settings

Everything binds from the Authentication section.

SettingDefaultNotes
Authority—Required for inbound validation. Where signing keys are read from.
ValidAudiences—Required unless ValidateAudience is off.
ValidIssuersthe authorityFor an issuer that differs from the URL the service reaches.
ValidTokenTypes["at+jwt"]Replaces the default rather than adding to it.
ValidateAudiencetrue
ValidateTokenTypetrueTurn off for a provider that emits no typ.
SkipIssuerValidationfalseDevelopment only. Warned about at startup.
AllowInsecurefalseDevelopment only. Warned about at startup.
NameClaimType, RoleClaimTypeframework defaults
ClockSkew5 minutes
Client:Authoritythe inbound authorityWhen the provider issuing differs from the one validated.
Client:ClientId, Client:ClientSecret—
Client:Scopes, Client:ResourcesnoneUsed when a caller names none.
Client:CredentialStyleBasicPostBody for a provider that accepts only that.
Client:TokenExpirySkew30 secondsHow early a cached token stops being served.
Client:MetadataCacheDuration1 hour
Client:RequestTimeout30 secondsThe framework default of 100 seconds is too long to wait.

ValidTokenTypes cannot be emptied from configuration — the binder reads an empty JSON array as no value at all — which is why turning the check off is ValidateTokenType rather than an empty list.

Supply Client:ClientSecret through the environment as Authentication__Client__ClientSecret, or through a secrets manager. It is read from wherever the host's configuration comes from and is never written anywhere by this library.

Reading the principal

var caller = this.HttpContext.GetPrincipal();

GetPrincipal requires the request to be authenticated and nothing more. It does not require a subject, because a client credentials grant has none to give: a machine token from a provider following RFC 9068 carries client_id and no sub, while Azure AD, Auth0 and Keycloak emit both. Subject is therefore nullable, and which of it and ClientId is present is the provider's choice rather than a reliable way to tell a person from a service.

TryGetPrincipal is the same thing without the exception, for an endpoint that serves anonymous callers differently rather than refusing them.

Scopes is read from either shape a provider issues: one space-delimited scope claim, or one claim per scope.

Startup checks

A configuration that could never work fails before the host serves anything, rather than as a rejected request later.

  • No Authority, when inbound tokens are validated. There would be no signing keys.
  • No ValidAudiences while ValidateAudience is on. Every token would be rejected — which reads as a broken provider rather than as a missing setting.
  • An authority that is not an absolute http or https URL, or is not https without AllowInsecure.
  • A Client section that names a secret but no client, or has no authority to reach.

AllowInsecure and SkipIssuerValidation warn rather than fail, once each, and never stop a boot.

Going further

To configure the handler beyond these settings, configure JwtBearerOptions for the same scheme after registering. A later configuration wins, so nothing here has to be undone:

builder.Services.AddJwtBearerAuthentication(builder.Configuration);

builder.Services.Configure<JwtBearerOptions>(
    JwtBearerDefaults.AuthenticationScheme,
    options => options.Events = new JwtBearerEvents { /* ... */ });

The client's outbound requests go through the named client AuthenticationHttpClients.Token. Add a retry policy or a proxy with AddHttpClient(AuthenticationHttpClients.Token); the timeout and, where insecure transport is permitted, the handler are already set.

On this page