OpenAPI Code Generation with Corvus: Authentication
At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at streaming with SSE and NDJSON.
Now let's tackle authentication. It's the part that every API needs but that most code generators either over-abstract or ignore entirely.
The authentication philosophy
Here's a design choice we made early: Corvus doesn't have its own authentication framework. There's no IAuthenticationProvider interface to implement, no built-in token cache to configure, and no proprietary middleware pipeline to learn.
Why? Because .NET already has excellent patterns for this. IHttpClientFactory with DelegatingHandler middleware is the standard composition model. Azure.Identity handles Entra ID (Azure AD) tokens. Polly handles retry on 401. These are well-documented, well-tested, and understood by every .NET developer. Adding a generator-specific auth layer on top would just be another thing to learn.
What Corvus does generate is the information you need to configure these standard patterns correctly: the OAuth2 token URL, the authorisation URL, and - crucially - the per-operation scope constants extracted from the spec's security requirements.
Generated scope constants
When your OpenAPI spec declares OAuth2 security schemes with per-operation scopes, the generator emits typed constants:
// From the spec's securitySchemes definition
IApiPetsClient.SecuritySchemes.Oauth2TokenUrl // "https://auth.example.com/token"
IApiPetsClient.SecuritySchemes.Oauth2AuthorizationUrl // "https://auth.example.com/authorize"
IApiPetsClient.SecuritySchemes.Oauth2AvailableScopes // ["read:pets", "write:pets"]
// From the per-operation security requirements
IApiPetsClient.SecurityRequirements.ListPetsOauth2Scopes // ["read:pets"]
IApiPetsClient.SecurityRequirements.CreatePetOauth2Scopes // ["read:pets", "write:pets"]
IApiPetsClient.SecurityRequirements.AllOauth2Scopes // ["read:pets", "write:pets"]
This might seem like a small thing, but it solves a real problem. In most codebases, OAuth2 scopes are hardcoded strings scattered across configuration files and handler registrations. When the API adds a new scope or changes a requirement, you find out at runtime. Worse, you request too-broad a token because it's easier than tracking per-operation scopes.
With generated constants, the scopes come from the spec. If the spec changes and you regenerate, your code either still compiles (no breaking change) or fails to build (the scope constant was renamed or removed). Either way, you stay in sync with the contract.
Microsoft Entra ID (Azure AD)
The most common pattern for Azure-hosted services. Use Azure.Identity with a standard DelegatingHandler that acquires tokens using the generated scope constants:
services.AddSingleton<TokenCredential>(
new ClientSecretCredential(tenantId, clientId, clientSecret));
services.AddTransient<EntraTokenHandler>();
services.AddHttpClient("petstore")
.AddHttpMessageHandler<EntraTokenHandler>();
public class EntraTokenHandler(TokenCredential credential) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(
new TokenRequestContext(IApiPetsClient.SecurityRequirements.AllOauth2Scopes),
cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken);
}
}
For multi-tenant scenarios or when different operations need different privilege levels, you can request per-operation scopes:
// Read-only operation - request minimal scopes
new TokenRequestContext(IApiPetsClient.SecurityRequirements.ListPetsOauth2Scopes)
// Write operation - request elevated scopes
new TokenRequestContext(IApiPetsClient.SecurityRequirements.CreatePetOauth2Scopes)
The TokenCredential abstraction in Azure.Identity covers all the common flows - ClientSecretCredential for service-to-service, InteractiveBrowserCredential for desktop apps, DeviceCodeCredential for CLI tools, and DefaultAzureCredential for managed identity in Azure-hosted services. They all work with the same handler.
Bearer tokens (non-Entra OAuth2)
For APIs that use OAuth2 but aren't backed by Entra, the pattern is the same. Just swap the token acquisition:
public class BearerTokenHandler(ITokenService tokenService) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
string token = await tokenService.GetTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}
Your ITokenService can talk to Auth0, Keycloak, IdentityServer, or any other OAuth2 provider. The generated scope constants still apply. Pass them to your provider's token request.
API keys
For APIs secured with API keys, the setup is even simpler. If the key goes in a header:
services.AddHttpClient("petstore", client =>
{
client.DefaultRequestHeaders.Add("X-Api-Key", configuration["ApiKey"]);
});
If the key goes in a query parameter, use a DelegatingHandler that appends it to the request URI:
public class ApiKeyQueryHandler(string paramName, string apiKey) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var uriBuilder = new UriBuilder(request.RequestUri!);
string separator = string.IsNullOrEmpty(uriBuilder.Query) ? "" : "&";
uriBuilder.Query = uriBuilder.Query.TrimStart('?') + separator
+ Uri.EscapeDataString(paramName) + "=" + Uri.EscapeDataString(apiKey);
request.RequestUri = uriBuilder.Uri;
return base.SendAsync(request, cancellationToken);
}
}
Cookie authentication
Cookie parameters declared in the spec become regular method parameters on the generated client. The generated code sets the Cookie header for you:
await using CreatePetResponse response = await petsClient.CreatePetAsync(
session_token: "sess_k7j2m9x4"u8,
body: new NewPet.Source((ref NewPet.Builder b) =>
{
b.Create(name: "Luna"u8, tag: "cat"u8);
}));
// Wire format: Cookie: session_token=sess_k7j2m9x4
On the server side, if the cookie is declared required: true and missing from the request, the generated middleware returns 400 Problem Details automatically. Your handler is never called.
Composing auth with resilience
Because authentication is just a DelegatingHandler, you can compose it with Microsoft.Extensions.Http.Resilience (Polly v8) in the same pipeline:
services.AddHttpClient("petstore")
.AddHttpMessageHandler<EntraTokenHandler>()
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.BackoffType = DelayBackoffType.Exponential;
});
This gives you exponential backoff with jitter, circuit breaking, and token refresh on 401. All of it uses standard .NET middleware that any developer on your team already understands.
What's next
In the [ref slug=openapi-code-generation-with-corvus-callbacks-and-webhooks text=next post], we'll look at callbacks, webhooks, and links - generating code for both sides of event notification patterns.