API Authentication¶
Cargonerds authenticates API callers in two completely separate ways:
- OpenID Connect / OAuth 2.0 via the OpenIddict-based auth server — for interactive users and first-party apps (the Next.js frontend, the Blazor admin, Swagger UI, the MVC public site). This is the primary path and produces JWT access tokens.
- API keys (
X-Api-Key/Api-Key) for machine-to-machine / integration access. This is a bespoke second authentication scheme layered onto the same pipeline, with its own permission model.
Both schemes coexist in Cargonerds.HttpApi.Host and both ultimately produce a
ClaimsPrincipal that ABP's permission system evaluates. The rest of this page explains the
OIDC server, its scopes/clients/tokens, how each client authenticates, and then the API-key
subsystem.
Two hosts, one audience
The AuthServer (src/Cargonerds.AuthServer) and the API host
(src/Cargonerds.HttpApi.Host) are different ASP.NET Core applications. The AuthServer issues
tokens; the API host validates them. Both are pinned to the single audience/resource
Cargonerds.
OAuth2 / OIDC server (OpenIddict)¶
src/Cargonerds.AuthServer is the identity provider. It is an MVC / Razor-Pages app that hosts the
OpenIddict server (via Volo.Abp.OpenIddict +
Volo.Abp.OpenIddictPro) plus the LeptonX Account UI (login, register, external logins,
impersonation). It is not the API host.
The module is CargonerdsAuthServerModule (src/Cargonerds.AuthServer/CargonerdsAuthServerModule.cs).
It composes the OpenIddict + Account modules through ABP's
[DependsOn] mechanism
(AbpAccountPublicWebOpenIddictModule, AbpAccountPublicWebImpersonationModule, …) and configures
the server in PreConfigureServices.
Server configuration¶
In PreConfigureServices, the server registers token validation for the Cargonerds audience
and, outside Development, swaps the auto-generated dev certificate for a real PFX:
PreConfigure<OpenIddictBuilder>(builder =>
{
builder.AddValidation(options =>
{
options.AddAudiences("Cargonerds");
options.UseLocalServer();
options.UseAspNetCore();
});
});
if (!hostingEnvironment.IsDevelopment())
{
PreConfigure<AbpOpenIddictAspNetCoreOptions>(options =>
{
options.AddDevelopmentEncryptionAndSigningCertificate = false;
});
PreConfigure<OpenIddictServerBuilder>(serverBuilder =>
{
serverBuilder.AddProductionEncryptionAndSigningCertificate(
"openiddict.pfx",
configuration["AuthServer:CertificatePassPhrase"]!);
serverBuilder.SetIssuer(new Uri(configuration["AuthServer:Authority"]!));
});
}
ConfigureServices then forwards ABP Identity's bearer authentication to the OpenIddict validation
scheme and turns on dynamic claims (so revoked permissions/roles take effect on the next request
rather than at token expiry):
context.Services.ForwardIdentityAuthenticationForBearer(
OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
context.Services.Configure<AbpClaimsPrincipalFactoryOptions>(options =>
{
options.IsDynamicClaimsEnabled = true;
});
Locally the authority is https://localhost:44345 (src/Cargonerds.AuthServer/appsettings.json,
key AuthServer:Authority). Identity lockout is also configured there (15-minute lockout, 5 failed
attempts, unique email required).
Transport security behind a proxy
When AuthServer:RequireHttpsMetadata is false (containers / a TLS-terminating reverse proxy),
the module sets OpenIddictServerAspNetCoreOptions.DisableTransportSecurityRequirement = true and
trusts X-Forwarded-Proto via ForwardedHeadersOptions. This is correct only behind a
trusted proxy — never expose such an instance directly.
API scope¶
There is exactly one custom first-party API scope. It is seeded (only if missing) by
OpenIddictDataSeedContributor.CreateScopesAsync()
(src/Cargonerds.Domain/OpenIddict/OpenIddictDataSeedContributor.cs):
await _scopeManager.CreateAsync(new OpenIddictScopeDescriptor
{
Name = "Cargonerds",
DisplayName = "Cargonerds API",
Resources = { "Cargonerds" },
});
| Scope | Display name | Resource (audience) |
|---|---|---|
Cargonerds |
Cargonerds API | Cargonerds |
The standard OpenIddict identity scopes — openid, offline_access (refresh tokens), address,
email, phone, profile, roles — are granted to clients as needed but are not custom scopes;
they are built into OpenIddict.
Tokens and validation (API host side)¶
The API host (CargonerdsHttpApiHostModule.ConfigureAuthentication) validates the JWTs that the
AuthServer issues, using ABP's AddAbpJwtBearer:
context
.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddAbpJwtBearer(options =>
{
options.Authority = configuration["AuthServer:Authority"];
options.RequireHttpsMetadata = configuration.GetValue<bool>("AuthServer:RequireHttpsMetadata");
options.Audience = "Cargonerds";
});
context.Services.Configure<AbpClaimsPrincipalFactoryOptions>(options =>
{
options.IsDynamicClaimsEnabled = true;
});
| Setting | Value (local) | Source |
|---|---|---|
| Authority | https://localhost:44345 |
AuthServer:Authority (host appsettings) |
| Audience | Cargonerds |
hard-coded in the module |
| Token type | JWT access token | OpenIddict default |
| Dynamic claims | enabled | IsDynamicClaimsEnabled = true + app.UseDynamicClaims() |
Token format
OpenIddict can issue either reference tokens or self-contained JWTs. This solution validates them
as JWT bearer tokens (JwtBearerDefaults.AuthenticationScheme + AddAbpJwtBearer), so access
tokens carry their claims and the audience claim aud=Cargonerds.
Seeded clients¶
Clients ("applications") are not hard-coded and are not defined in the AuthServer. They are
read from the OpenIddict:Applications configuration array and created/updated by the same data-seed
contributor (OpenIddictDataSeedContributor.CreateApplicationsAsync()), which runs from the
DbMigrator:
private Task CreateApplicationsAsync()
{
var configurationSection = _configuration.GetSection("OpenIddict:Applications");
var apps = configurationSection.Get<OpenIddictAppConfiguration[]>();
return CreateApplicationAsync(apps);
}
Clients live in the DbMigrator, not the AuthServer
The authoritative OpenIddict:Applications array is in src/Cargonerds.DbMigrator/appsettings.json
(with Aspire overlays in appsettings.aspire.json). Looking only at
Cargonerds.AuthServer/appsettings*.json will not reveal the clients. To add or change a
client you edit DbMigrator config and re-run the migrator.
The base (non-Aspire) clients defined in src/Cargonerds.DbMigrator/appsettings.json:
| Client ID | Type | Grant types | Consumer | Redirect URI (local) |
|---|---|---|---|---|
Cargonerds_App |
public | defaults (see below) | Next.js realtime frontend |
http://localhost:59082 (from ClientUri) |
admin |
public | authorization_code, refresh_token |
Blazor admin UI | https://localhost:44381/authentication/login-callback |
api |
public | authorization_code |
Swagger UI (SwaggerClientId = "api") |
https://localhost:44354/swagger/oauth2-redirect.html |
web |
confidential | authorization_code, implicit |
ASP.NET Core MVC public site | https://localhost:44332/signin-oidc |
Aspire overlays add more clients
When run under .NET Aspire, appsettings.aspire.json overlays inject additional clients (for
example realtime and bruno for API testing) and substitute service URLs through tokens such as
{auth}, {api}, {realtime}, {admin} and {redis}. The exact set depends on the active
environment overlays. See the client catalog and
Aspire integration.
Defaults and custom grant types¶
When a client entry omits Type, ConsentType, GrantTypes or Scopes, the binding POCO
OpenIddictAppConfiguration (src/Cargonerds.Domain.Shared/OpenIddictAppConfiguration.cs) supplies
the defaults — Type = public, ConsentType = implicit, and:
public List<string> GrantTypes { get; set; } =
[
OpenIddictConstants.GrantTypes.AuthorizationCode,
OpenIddictConstants.GrantTypes.Password,
OpenIddictConstants.GrantTypes.ClientCredentials,
OpenIddictConstants.GrantTypes.RefreshToken,
"LinkLogin",
"Impersonation",
];
public List<string> Scopes { get; set; } =
[
OpenIddictConstants.Permissions.Scopes.Address,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Phone,
OpenIddictConstants.Permissions.Scopes.Profile,
OpenIddictConstants.Permissions.Scopes.Roles,
"Cargonerds",
];
Two grant types are non-standard: "LinkLogin" (linking an external login to an existing
account) and "Impersonation" (admin impersonation). The seeder recognises any grant type that is
not one of OpenIddict's built-ins and persists it as a gt: permission string
(Permissions.Prefixes.GrantType + grantType), exactly as it persists the custom scope as
scp:Cargonerds (Permissions.Prefixes.Scope + scope):
if (!buildInGrantTypes.Contains(grantType))
{
application.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.GrantType + grantType);
}
// ...
application.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope);
Public vs confidential secrets
The seeder enforces OAuth client-type rules: a public client may not carry a secret, and a
confidential client must (it throws BusinessException otherwise). The web client ships
a "-" placeholder secret in dev config that is overridden per environment.
Idempotent seeding¶
CreateApplicationAsync is idempotent. For an existing client it compares the serialized redirect
URIs (HasSameRedirectUris) and the serialized permission set (HasSameScopes), and only calls
_applicationManager.UpdateAsync(...) when something drifted — so re-running the migrator does not
churn the database.
Per-client authentication¶
sequenceDiagram
participant U as User browser / SPA
participant Auth as AuthServer (OpenIddict)
participant API as HttpApi.Host
U->>Auth: GET /connect/authorize (response_type=code, PKCE, scope=Cargonerds ...)
Auth-->>U: Login UI (LeptonX Account) then redirect with ?code=...
U->>Auth: POST /connect/token (code + code_verifier)
Auth-->>U: access_token (aud=Cargonerds) + id_token + refresh_token
U->>API: GET /api/... (Authorization: Bearer <access_token>)
API->>API: AddAbpJwtBearer validates token, dynamic claims applied
API-->>U: Response
| Client | Flow | Token handling |
|---|---|---|
Cargonerds_App (Next.js realtime) |
Authorization Code + PKCE (public client, no secret) | react-oidc-context / oidc-client-ts stores the token; the useClient hook attaches Authorization: Bearer <access_token> to every call (see clients.md). |
admin (Blazor) |
Authorization Code + refresh token (public + PKCE) | Standard Blazor OIDC; refreshes silently. |
api (Swagger UI) |
Authorization Code (public + PKCE) | Swagger UI runs the OIDC dance in-browser; OAuthClientId is AuthServer:SwaggerClientId = api. |
web (MVC public site) |
Authorization Code + implicit (confidential, has secret) | Server-side signin-oidc / signout-callback-oidc callbacks. |
The browser-facing clients use Authorization Code with PKCE — the recommended public-client
flow. password and client_credentials appear only in the default grant list and are used by
overlay clients (e.g. API-testing clients), not by the seeded browser clients.
External login providers & impersonation¶
The AuthServer registers external IdPs with ABP's dynamic options pattern
(.WithDynamicOptions<TOptions, THandler>(...) with WithProperty(..., isSecret: true)), so client
IDs/secrets are stored as tenant settings rather than appsettings:
- Google, Microsoft Account, Twitter
- Microsoft Entra ID white-label schemes, one per configured tenant, via
AddEntraId(whiteLabelingOptions?.EntraLogins ?? [])(src/Cargonerds.AuthServer/AuthenticationBuilderExtensions.cs). Each registers an OIDC scheme namedOpenIdConnect.<name>againsthttps://login.microsoftonline.com/{tenantId}/v2.0with scopesopenid profile email offline_access.
Impersonation is wired through AbpAccountOptions:
context.Services.Configure<AbpAccountOptions>(options =>
{
options.TenantAdminUserName = RoleConsts.Admin; // "admin"
options.ImpersonationTenantPermission = SaasHostPermissions.Tenants.Impersonation;
options.ImpersonationUserPermission = IdentityPermissions.Users.Impersonation;
});
See Identity for the underlying user/role/claims model and authorization & permission management for how the issued claims drive access checks.
API key authentication¶
The API host adds a second authentication scheme for machine callers in
CargonerdsHttpApiHostModule.ConfigureApiKeyAuthentication. It is a hand-rolled scheme (the design
is credited in-code to the AbpApikeyManagement sample)
that coexists with the JWT bearer scheme. Its constants live in
src/Cargonerds.Domain.Shared/ApiKeys/ApiKeyAuthorizationConsts.cs:
public const string AuthenticationType = "api-key";
public const string PolicyName = "apiKeyManagement";
public const string PermissionProviderName = "AK";
Where keys are read from¶
Keys are located by an ordered list of IApiKeyResolveContributors. Headers always work; query-string
parameters are Development-only (query credentials leak into logs, referrer headers and CDN access
logs):
Configure<ApiKeyResolveOptions>(options =>
{
options.ApiKeyResolvers.Add(new HeaderApiKeyResolveContributor("X-Api-Key"));
options.ApiKeyResolvers.Add(new HeaderApiKeyResolveContributor("Api-Key"));
if (env.IsDevelopment())
{
options.ApiKeyResolvers.Add(new QueryApiKeyResolveContributor("apiKey"));
options.ApiKeyResolvers.Add(new QueryApiKeyResolveContributor("api_key"));
}
});
| Source | Name | Enabled |
|---|---|---|
| Request header | X-Api-Key |
always |
| Request header | Api-Key |
always |
| Query string | apiKey |
Development only |
| Query string | api_key |
Development only |
Storage & verification¶
API keys are stored as the ApiKey aggregate root (src/Cargonerds.Domain/ApiKeys/ApiKey.cs),
mapped to the AppApiKeys table (CargonerdsDbContext, with a unique index on Prefix). A
key is Prefix + rawKey; only a hash of the full key is stored — never the plaintext.
| Column | Notes |
|---|---|
Prefix |
Fixed-length lookup prefix (length from ApiKeyCreateOption.PrefixLength); unique index |
Hash |
IPasswordHasher<object> hash of the full key (ApiKeyManager.Hash) |
UserId |
Owning user — the key acts as this user |
IsActive |
Soft enable/disable |
ExpirationTime |
Optional expiry (DateTimeOffset?) |
TenantId |
Optional; omitted from claims for host users |
ApiKeyPrincipalProvider.GetApiKeyPrincipalOrNullAsync (src/Cargonerds.Domain/ApiKeys/ApiKeyPrincipalProvider.cs)
performs the validation, in this order:
- Split
Prefix/rawKey(ApiKeyManager.GetPrefix/GetRawKey). - Look up the key by prefix; reject if missing, inactive, or expired.
- Load the owner and reject if the owner is missing, inactive, or locked out (so a revoked/locked/deleted user cannot keep authenticating via leftover keys).
- Verify
rawKeyagainst the stored hash, caching the result keyed bySHA256(fullKey)(30-minute absolute / 5-minute sliding TTL). - On success, mint a
ClaimsIdentitywithAuthenticationType = "api-key"and claimsApiKeyId,UserId, and (only if present)TenantId.
Pinning the principal¶
ApiKeyAuthenticationHandler implements IAuthenticationRequestHandler, so it runs before the
default JWT scheme. On a hit it sets Context.User and publishes custom
IAuthenticateResultFeature + IHttpAuthenticationFeature so the later JWT AuthenticateAsync
call cannot overwrite the API-key principal:
public async Task<bool> HandleRequestAsync()
{
var result = await HandleAuthenticateAsync();
if (result?.Principal != null)
{
Context.User = result.Principal;
}
if (result?.Succeeded ?? false)
{
var authFeatures = new AuthenticationFeatures(result);
Context.Features.Set<IHttpAuthenticationFeature>(authFeatures);
Context.Features.Set<IAuthenticateResultFeature>(authFeatures);
}
return false; // let the pipeline continue
}
Permission model — a key never exceeds its owner¶
API-key permissions are enforced by ApiKeyPermissionValueProvider (provider name "AK",
registered first). It only activates for api-key principals and grants a permission only if both:
- some other value provider (i.e. the owner's roles/user grants) also grants it, and
- the key itself has been granted that permission under the
"AK"provider.
if (!anyGranted)
{
return PermissionGrantResult.Prohibited;
}
return await PermissionStore.IsGrantedAsync(context.Permission.Name, Name, apiKeyId)
? PermissionGrantResult.Granted
: PermissionGrantResult.Prohibited;
So an API key can only ever exercise the intersection of (its own "AK" grants) ∩ (the owner's
effective permissions). When a key is created, ApiKeyPermissionInheritanceService seeds it with the
owner's effective permissions (built from the owner's ClaimsPrincipal, not the caller's, to
prevent privilege escalation when an admin creates a key for someone else).
Key management itself is permission-gated. CargonerdsPermissions.ApiKeys
(src/Cargonerds.Application.Contracts/Permissions/CargonerdsPermissions.cs) defines
Create / Edit / Delete / ManagePermissions / ManageAll. The apiKeyManagement authorization policy
(ApiKeyPolicyRequirement → ApiKeyPolicyHandler) lets a user manage their own keys, while
ApiKeys.ManageAll (granted to the SuperUser role via the [GrantInRoles] convention) lets admins
manage anyone's keys.
Calling the API with a key¶
See REST API for endpoint conventions and API Clients for the typed .NET and TypeScript clients (which use OIDC tokens, not API keys).
Dynamic claims & session handling¶
Both hosts enable dynamic claims (AbpClaimsPrincipalFactoryOptions.IsDynamicClaimsEnabled = true
plus app.UseDynamicClaims() in the pipeline). This re-hydrates a principal's permission/role claims
from the store on each request, so permission changes do not wait for the token to expire.
API-key principals carry no SessionId, which would normally cause ABP Identity Pro's session
dynamic-claims contributor to drop them. The solution swaps that contributor for
ApiKeyAwareIdentitySessionDynamicClaimsContributor
(src/Cargonerds.Domain/Identity/ApiKeyAwareIdentitySessionDynamicClaimsContributor.cs) in
CargonerdsDomainModule.PostConfigureServices, which short-circuits for api-key auth and otherwise
falls through to the default behaviour.
Swagger UI authentication¶
ConfigureSwagger registers OIDC auth-code flow against the AuthServer with scope Cargonerds using
ABP's AddAbpSwaggerGenWithOidc, and the UI logs in with the api client
(OAuthClientId(AuthServer:SwaggerClientId)).
ABP 10.1.1 oidc vs oauth2 workaround
AddAbpSwaggerGenWithOidc registers the security definition as "oidc" but emits a
SecurityRequirement referencing a non-existent "oauth2" scheme. Without the manual fix in
ConfigureSwagger, Swagger UI attaches no Authorization header and every call returns
401 with no lock icons. The module re-adds a correct requirement pointing at "oidc".
Swagger JSON is served at /swagger/v1/swagger.json. More detail in REST API.
Data protection & sessions¶
Outside Development, ASP.NET Core Data Protection keys are persisted to Redis
(Cargonerds-Protection-Keys, application name Cargonerds) in both the AuthServer and the API
host, so tokens, cookies and antiforgery values remain valid across all instances. In Development the
keys live on the local filesystem. See Caching.
Gotchas¶
Committed secrets in dev appsettings
src/Cargonerds.AuthServer/appsettings.json ships a real-looking
AuthServer:CertificatePassPhrase and StringEncryption:DefaultPassPhrase; the API host
appsettings ship an encrypted SMTP password and the same StringEncryption pass phrase.
Production must use the openiddict.pfx certificate plus Redis-backed data-protection keys and
must not rely on these committed values.
- One audience only. Everything is keyed to
Cargonerds— the API host validatesAudience = "Cargonerds"and the AuthServer validation callsAddAudiences("Cargonerds"). There is no second resource. - Clients are config-seeded from the DbMigrator. Editing
Cargonerds.AuthServer/appsettings*.jsondoes nothing to the client list; editsrc/Cargonerds.DbMigrator/appsettings*.jsonand re-run the migrator. - Two custom grant types (
LinkLogin,Impersonation) are in the default grant list and are stored asgt:LinkLogin/gt:Impersonationpermission strings on each client. - API-key query-string credentials are Development-only — in production only the
X-Api-Key/Api-Keyheaders are read. - API-key permissions are capped by the owner — a key can only exercise
(its
"AK"grants) ∩ (owner's grants); anything else isProhibited. - The API-key verification cache is TTL-based, not event-invalidated. It is keyed by
SHA256(fullKey), which the entity-changed event cannot target, so revocation relies on theIsActive/owner checks plus the 30-minute TTL (documented inApiKeyPrincipalProvider). - Verbose error detail is returned to clients. The host sets
SendStackTraceToClients,SendExceptionsDetailsToClientsandSendExceptionDataToClientTypes = [typeof(Exception)]— a hardening item for production (see REST API).
Related pages¶
- REST API — endpoint conventions, Swagger, error handling
- API Clients — the seeded clients, C# proxies, and the frontend
fetchwrapper - OData filtering — the Hub query layer (all endpoints require authentication)
- Architecture overview — where the AuthServer fits among the hosts
- ABP patterns — module composition, data seeding, dynamic claims
- Caching — Redis-backed data protection keys