Skip to content

API Authentication

Cargonerds authenticates API callers in two completely separate ways:

  1. 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.
  2. 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 named OpenIdConnect.<name> against https://login.microsoftonline.com/{tenantId}/v2.0 with scopes openid 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:

  1. Split Prefix / rawKey (ApiKeyManager.GetPrefix / GetRawKey).
  2. Look up the key by prefix; reject if missing, inactive, or expired.
  3. 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).
  4. Verify rawKey against the stored hash, caching the result keyed by SHA256(fullKey) (30-minute absolute / 5-minute sliding TTL).
  5. On success, mint a ClaimsIdentity with AuthenticationType = "api-key" and claims ApiKeyId, 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:

  1. some other value provider (i.e. the owner's roles/user grants) also grants it, and
  2. 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 (ApiKeyPolicyRequirementApiKeyPolicyHandler) 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

GET /api/app/shipment HTTP/1.1
Host: localhost:44354
X-Api-Key: <prefix><rawKey>

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 validates Audience = "Cargonerds" and the AuthServer validation calls AddAudiences("Cargonerds"). There is no second resource.
  • Clients are config-seeded from the DbMigrator. Editing Cargonerds.AuthServer/appsettings*.json does nothing to the client list; edit src/Cargonerds.DbMigrator/appsettings*.json and re-run the migrator.
  • Two custom grant types (LinkLogin, Impersonation) are in the default grant list and are stored as gt:LinkLogin / gt:Impersonation permission strings on each client.
  • API-key query-string credentials are Development-only — in production only the X-Api-Key / Api-Key headers are read.
  • API-key permissions are capped by the owner — a key can only exercise (its "AK" grants) ∩ (owner's grants); anything else is Prohibited.
  • 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 the IsActive/owner checks plus the 30-minute TTL (documented in ApiKeyPrincipalProvider).
  • Verbose error detail is returned to clients. The host sets SendStackTraceToClients, SendExceptionsDetailsToClients and SendExceptionDataToClientTypes = [typeof(Exception)] — a hardening item for production (see REST API).

  • REST API — endpoint conventions, Swagger, error handling
  • API Clients — the seeded clients, C# proxies, and the frontend fetch wrapper
  • 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