Stratara.Identity.AspNetCore 3.4.0

dotnet add package Stratara.Identity.AspNetCore --version 3.4.0
                    
NuGet\Install-Package Stratara.Identity.AspNetCore -Version 3.4.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Stratara.Identity.AspNetCore" Version="3.4.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Stratara.Identity.AspNetCore" Version="3.4.0" />
                    
Directory.Packages.props
<PackageReference Include="Stratara.Identity.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Stratara.Identity.AspNetCore --version 3.4.0
                    
#r "nuget: Stratara.Identity.AspNetCore, 3.4.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Stratara.Identity.AspNetCore@3.4.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Stratara.Identity.AspNetCore&version=3.4.0
                    
Install as a Cake Addin
#tool nuget:?package=Stratara.Identity.AspNetCore&version=3.4.0
                    
Install as a Cake Tool

Stratara.Identity.AspNetCore

Derived. The behaviour described here is specified under openspec/specs/. Those specifications are the source; this page explains and illustrates them.

License: MIT.

Channel-agnostic ASP.NET Core identity wiring for the Stratara stack. Provides the AddAspNetIdentity / AddAspNetIdentityWithSignInManager extension methods and an IStrataraSignInManager wrapper around the ASP.NET Core SignInManager. Channel-specific glue (Blazor Server's AuthenticationStateProvider, MAUI session-state forwarders, etc.) is the consumer's responsibility — Stratara intentionally stops at the ASP.NET-Core-generic surface to stay application-agnostic.

What's in the box

Folder Contents
DependencyInjection/AspCoreIdentityHostBuilderExtensions AddAspNetIdentity<TUser, TIdentityDbContext>() (Stratara password/schema-v3/passkey defaults — no lockout), AddAspNetIdentityWithSignInManager<TUser, TIdentityDbContext>() (same + lockout defaults + AspNetSignInManager + localization), AddDevelopmentNoOpEmailSender<TUser>() (dev-only, throws in Production)

Lockout only ships with the sign-in manager. ApplyStrataraLockoutDefaults runs inside AddAspNetIdentityWithSignInManager only — the bare AddAspNetIdentity leaves ASP.NET Identity's own lockout defaults in place. If you wire sign-in yourself on top of AddAspNetIdentity, configure IdentityOptions.Lockout explicitly; otherwise password attempts are not throttled the way the rest of this package assumes. | Services/AspNetSignInManager<TUser> | Wraps SignInManager<TUser> + UserManager<TUser> and produces StrataraSignInResult with already-localized failure messages | | Services/IdentityNoOpEmailSender<TUser> | Development-time email sender that drops every email (Task.CompletedTask); replace in production | | Resources/IdentityResources | Resource-anchor for sign-in failure messages. English default ships in IdentityResources.resx; IdentityResources.de.resx provides German overrides. AddAspNetIdentityWithSignInManager calls AddLocalization() so IStringLocalizer<IdentityResources> resolves automatically. | | DependencyInjection/MembershipClaimsServiceCollectionExtensions + Services/MembershipClaims* | Sign-in tenant-claim bridge: AddMembershipTenantClaim<TUser>() (stamp stratara:tenant_id at issuance) and AddMembershipTenantClaimsTransformation() (resolve live per request) | | Authorization/* + DependencyInjection/PermissionPolicyServiceCollectionExtensions | AddStrataraPermissionPolicies() — every catalog permission becomes an on-demand [Authorize("...")] policy backed by IPermissionResolver | | Authentication/ApiKey* + DependencyInjection/ApiKeyAuthenticationExtensions | AddStrataraApiKey() (X-Api-Key scheme over IApiKeyStore) and AddStrataraAuthSchemeSelector() (route API-key vs. Bearer vs. cookie by request shape) | | Authentication/Stratara{OpenIdConnect,JwtBearer}Options + DependencyInjection/OpenIdConnectAuthenticationExtensions | AddStrataraOpenIdConnect(configuration) (interactive external login) and AddStrataraJwtBearer(configuration) (API access-token validation, multi-issuer by iss) | | Services/ExternalLoginProvisioningService<TUser> + DependencyInjection/ExternalLoginProvisioningExtensions | AddStrataraExternalLoginProvisioning<TUser>() — hardened JIT create/link of local accounts on first external sign-in (see below) |

Localization

AspNetSignInManager resolves its four user-facing failure messages (Identity.SignIn.Lockout, Identity.SignIn.InvalidCredentials, Identity.SignIn.InvalidTwoFactor, Identity.SignIn.InvalidRecoveryCode) via IStringLocalizer<IdentityResources>. A "not allowed" sign-in deliberately maps onto the InvalidCredentials message rather than getting its own, to avoid confirming that an account exists. Languages out of the box: English (default) and German (de). To add another culture, ship a satellite .resx (e.g. IdentityResources.fr.resx) in your own assembly and register a chained IStringLocalizer<IdentityResources> if needed. Selection follows CultureInfo.CurrentUICulture — wire up app.UseRequestLocalization(...) to map this from the request.

Quick start

// Channel-agnostic ASP.NET Core host (MVC, Razor Pages, Minimal API, ...):
builder.AddAspNetIdentityWithSignInManager<ApplicationUser, IdentityDbContext>();

// Or for a host without sign-in manager (e.g. a worker that only needs identity stores):
builder.AddAspNetIdentity<ApplicationUser, IdentityDbContext>();

For Blazor Server hosts, additionally register your own IStrataraAuthenticationStateProvider implementation (and the AuthenticationStateProvider forwarder). Stratara does not ship a Blazor-specific provider — the previous BlazorAuthenticationStateProvider lived here in 1.x but moved out in v2.0.0 to keep this package application-agnostic.

External login (OpenID Connect) + JIT provisioning

Add external identity providers as ordinary authentication schemes and provision local accounts on first sign-in:

builder.Services
    .AddAuthentication(StrataraAuthSchemeSelectorOptions.SchemeName)
    .AddCookie(IdentityConstants.ApplicationScheme)
    .AddStrataraOpenIdConnect(builder.Configuration)   // interactive "log in with <provider>"
    .AddStrataraJwtBearer(builder.Configuration)        // API access-token validation (iss-routed)
    .AddStrataraAuthSchemeSelector();                   // route Bearer vs. cookie per request

builder.Services.AddStrataraExternalLoginProvisioning<ApplicationUser>();

AddStrataraOpenIdConnect binds Identity:OpenIdConnect (Authority, ClientId, ClientSecret, Scopes) and AddStrataraJwtBearer binds Identity:JwtBearer (Authority, Audience, ValidIssuers). Both key the principal on the issuer sub, never on email — Entra, Keycloak, and generic OIDC differ only in configuration.

ExternalLoginProvisioningService<TUser> creates or links the local account on a first external sign-in with the account-takeover defenses on by default: it links on the issuer's (provider, sub); auto-links to a pre-existing account only when the email is verified by the provider (email_verified/xms_edov) and already confirmed locally — otherwise it returns RequiresInteractiveLinking and refuses to merge; honors an optional invitation gate and an AutoProvision switch; and fails closed. Call it from your sign-in callback (for example the OpenID Connect OnTicketReceived event). The Stratara.Sample.Identity sample shows the full wiring.

Dependencies

  • Stratara.Identity.Core — channel-agnostic abstractions (IStrataraSignInManager, IStrataraAuthenticationStateProvider) + shared model records.
  • Stratara.Shared — multitenancy + session-context types.
  • Microsoft.AspNetCore.App — shared framework reference for SignInManager, IEmailSender<TUser>, etc.
  • Microsoft.AspNetCore.Identity.EntityFrameworkCore — ASP.NET Identity stores.
  • Microsoft.AspNetCore.Authentication.OpenIdConnect, Microsoft.AspNetCore.Authentication.JwtBearer — external-login OIDC + API bearer-token schemes.
  • Microsoft.IdentityModel.JsonWebTokens, System.IdentityModel.Tokens.Jwt — JWT helpers for token-based flows.
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.4.0 0 8/28/2026
3.3.0 124 8/25/2026
3.2.3 85 8/22/2026
3.2.2 706 8/14/2026
3.2.1 689 8/2/2026
3.2.0 102 7/18/2026
3.1.7 122 7/1/2026
3.1.6 530 6/22/2026
3.1.5 117 6/22/2026
3.1.4 125 6/15/2026
3.1.3 120 6/10/2026
3.1.2 136 6/5/2026
3.1.1 210 6/1/2026
3.1.0 127 5/30/2026
3.0.23 115 5/28/2026

Four findings a consumer team reported from production use, three defects found in the framework's
own queue, and one found while fixing another. The thread running through them is the same: a
mechanism that reported success while doing nothing. A signature that covered everything except the
payload. A circuit breaker whose thresholds its own retry could never reach. A replay marking that
outlived the replay. Two environment guards that admitted every environment name nobody had thought
of. Each was documented as working, and each was believed.

**Read the two rollout notes before upgrading a fleet**: bus-envelope signatures change, and a
projection replay marking already stuck from an earlier version does not clear itself on upgrade.

### Added

- **`ProjectionReplayOptions`** — configures how long a projection replay's active marking and
 progress counters survive without renewal. Registered with its defaults by
 `AddProjectionReplayState()`, so an existing host is leased without configuring anything.

- **`AddTenantMembershipStoreFromContextFactory<TContext>()`,
 `AddApiKeyStoreFromContextFactory<TContext>()` and `AddSettingStoreFromContextFactory<TContext>()`**
 — register the identity-directory stores so that each operation takes a fresh database context from
 `IDbContextFactory<TContext>` instead of sharing the request's.

 The existing registrations share one context across every directory store in a request. A database
 context serves one operation at a time, so directory work issued concurrently within a scope — two
 role checks together, a lookup racing a page load — fails on whichever arrives second, and the
 failure surfaces at the call site that lost the race rather than the one that introduced the
 concurrency. Sharing also means a store's own commit commits whatever else the consumer has left
 unsaved on that context.

 Both registrations now state what they cost, because the choice runs both ways: with a context per
 operation neither of those applies, and in exchange a store write no longer takes part in a
 transaction opened on the consumer's own scoped context. Calling both registrations for the same
 store leaves whichever ran first in place; they do not compose.

 Nothing changes for a consumer who does not adopt them — the existing registrations behave exactly
 as before, and remain the default.

### Fixed

- **Appending on behalf of a subject that names no tenant now fails.** Of the five sources the store
 consults to decide which tenant an event belongs to, four rejected an absent tenant and the append
 failed rather than guessing. The fifth — the subject you supply yourself through
 `AppendOnBehalfOfAsync` — was taken as given, so passing one with an empty tenant id recorded an
 event owned by nobody and encrypted against nobody. An erasure never reaches such an event. The
 call now throws `ArgumentException` naming the stream and the event, and records nothing.
 **Breaking** where a consumer passes a subject sourced from aggregate state that a stream written
 before the field existed left empty — that call site is the defect, and the exception message
 identifies it.

- **A newly created tenant now owns itself.** The framework's tenant-creation event did not declare
 the tenant it creates as the event's data owner, so ownership fell through to the acting session:
 a tenant created by an operator was owned by the *operator's* tenant, while a caller who happened
 to point the session at the new id first got a tenant that owned itself. The same operation
 produced two different owners depending on whether the caller knew to take an undiscoverable
 detour. **Breaking** in recorded data: tenants created after adopting this version are owned by
 themselves. Streams already written are untouched, no payload changed, and no migration is
 required — the event's serialized JSON is byte-identical.

- **An abandoned projection replay no longer suppresses publication forever.** While a replay is
 active the framework suppresses publication, so replayed history does not re-trigger side effects.
 That marking was only ever cleared by the process that set it, so a kill, a container stop, an
 out-of-memory kill or a reboot left it standing with nothing to clear it — and while it stands,
 every command is recorded instead of sent while the caller receives an identifier and a success
 response, and the outbox never drains. The marking and its progress counters are now held on a
 lease that the replay renews each time it reports progress: a replay that stops without clearing
 them stops renewing them, and they lapse on their own.

 The lease defaults to 300 seconds and is configurable through the new `ProjectionReplayOptions`.
 Set it longer than your slowest stretch between two progress reports — a shorter lease lapses while
 the replay is still running and resumes publication against half-rebuilt read models. Erring long
 only delays the clearing of a marking whose replay already died.

 **One action on upgrade:** a marking already stuck from before this version was written without an
 expiry and does not gain one. Clear it once — an explicit deactivation, or let the next replay's
 own completion clear it. Upgrading alone does not free a host that is stuck today.

- **The outbox drain no longer spins on a batch it cannot deliver.** The worker read a batch, handed
 it to the dispatcher, and read again until a batch came back empty — but an entry is only removed
 once the bus has accepted it, so a batch nobody could deliver came back identical every time. Two
 ordinary conditions reached it: an unreachable broker, and a projection replay in progress. Either
 turned a drain pass into a hot loop against the database, and because the command drain never
 returned, the event drain that follows it never started. A drain pass now handles one batch and
 ends; undelivered entries stay stored and are retried on the next interval.

 Two shifts to know about. **`outbox.published` now counts what the bus accepted** rather than what
 was read from storage — it is emitted by the dispatchers, which know the answer, instead of by the
 worker, which did not. The instrument name and its `outbox.kind` tag are unchanged, but the values
 drop to the truth, so an alert threshold calibrated against the inflated numbers needs revisiting.
 And **a large stored backlog now drains at one batch per polling interval** rather than in a single
 pass — with the defaults, twenty thousand entries a minute, and both the batch size and the
 interval are configurable.



- **Two environment guards now admit Development only, instead of refusing Production only.**
 `IsProduction()` recognises exactly one name. Everything else fell through — Staging, QA, UAT,
 Preview, and, because the check is by name, `Production-EU` and `prod` as well.

 **Missing broker credentials** no longer fall back to the `guest` account outside Development.
 Previously any non-Production host silently connected as `guest`, which matters where the broker
 runs in the same container or network with a default configuration.

 **The no-op email sender** can no longer be registered outside Development. It returns success for
 every operation without an exception or a log entry, so on a staging host a registered user never
 received a confirmation link, the account stayed unconfirmed, and external-login auto-linking then
 failed for that user. What gets reported is "OIDC linking doesn't work on staging"; the cause is a
 dropped mail three steps earlier.

 **Breaking for hosts that relied on either fall-through**, which is the point of the change. Both
 escape hatches already exist and are explicit — set `RABBITMQ_USERNAME=guest` and
 `RABBITMQ_PASSWORD=guest` to use the default account deliberately, and register your own
 `IEmailSender<TUser>` to drop mail outside Development. The framework ships no no-op sender for
 that case, because a shipped no-op is indistinguishable at every call site from a working one.

- **The message-bus circuit breaker can now open.** It could not — not rarely, but never. The breaker
 required ten failures inside a sixty-second window while the retry in front of it backs off to a
 sixty-second cap, so once the backoff had grown at most one failure landed per window and the tenth
 was never counted. Measured over a simulated hour of uninterrupted failure: sixty-three attempts,
 no circuit opening at all. The window is now derived from the retry's own delay cap instead of being
 chosen independently of it, and five failures inside a ten-minute window open the circuit.

 **Nothing you can observe was broken, and nothing you rely on changes.** Retries remain unbounded,
 the backoff is unchanged, and traffic still succeeds once the broker recovers without you writing
 any retry — the retry sits in front of the breaker and retries the broken-circuit signal too. What
 changes is what a sustained outage *looks like*: an open circuit in logs and metrics, which is a
 state you can alert on. If your broker goes down for minutes, expect new breaker-state events.

 Worth saying plainly: the framework documented this protection and did not have it. An operator who
 built an alert on breaker state got an alert that could never fire, and the code comment describing
 the behaviour described something that never happened.

### Security

- **A bus-envelope signature now covers the payload, not only the identity claims.** The canonical
 form a signature was computed over was, for an event bundle, everything except the events — the
 record has three fields and one of them is the signature. That is correct for the threat it was
 built for, which is *minting* a session context. It does not answer *transplanting* an observed
 signature onto different events, which was never raised.

 The attack is not "replay the same message" — duplicates are ordinary, delivery is at-least-once
 and handlers are already idempotent. It is: observe one signed message, publish a new one carrying
 that exact session context and arbitrary events, and the canonical form is unchanged, so the
 signature verifies and strict mode accepts it. The attacker cannot reach a tenant they have never
 observed, which is a real limit. They can inject arbitrary events into any tenant they *have*
 observed one message from — the projection worker writes them into that tenant's read models, and
 the saga worker reacts to them and can issue commands as that actor. Two of the three actors this
 mechanism was designed to stop plausibly hold read access as well as publish access.

 The canonical form now covers a digest of the payload — the events for a bundle; the command body
 and the envelope id for a command, both of which were transmitted and unsigned. It is built from
 field values, never by re-serializing, so a message that has been on the wire projects to exactly
 what its publisher signed.

 **Fields are now length-prefixed.** They were joined with a separator that two attacker-controlled
 fields are allowed to contain, so content could be shifted across a field boundary without
 changing the canonical form — altering which command type is dispatched while the signature still
 verified, defeating the guard the type name is signed for.

 **Rollout — this is the migration the three modes exist for, and the first time it is needed.**
 Signatures produced by an older publisher stop verifying. Move the fleet through **permissive**
 mode: publishers upgrade first, consumers follow, then strict is safe again. Upgrading both sides
 straight into strict will reject in-flight messages.

- **The integrity start-up warning now fires outside development, not only in production.** It was
 governed by whether the host was *named* `Production`, so a host called `Production-EU` or `prod`
 ran with unsigned envelopes and no warning. It is now governed by whether the host is in
 development, which also surfaces the deviation on staging and QA.