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
<PackageReference Include="Stratara.Identity.AspNetCore" Version="3.4.0" />
<PackageVersion Include="Stratara.Identity.AspNetCore" Version="3.4.0" />
<PackageReference Include="Stratara.Identity.AspNetCore" />
paket add Stratara.Identity.AspNetCore --version 3.4.0
#r "nuget: Stratara.Identity.AspNetCore, 3.4.0"
#:package Stratara.Identity.AspNetCore@3.4.0
#addin nuget:?package=Stratara.Identity.AspNetCore&version=3.4.0
#tool nuget:?package=Stratara.Identity.AspNetCore&version=3.4.0
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.
ApplyStrataraLockoutDefaultsruns insideAddAspNetIdentityWithSignInManageronly — the bareAddAspNetIdentityleaves ASP.NET Identity's own lockout defaults in place. If you wire sign-in yourself on top ofAddAspNetIdentity, configureIdentityOptions.Lockoutexplicitly; otherwise password attempts are not throttled the way the rest of this package assumes. |Services/AspNetSignInManager<TUser>| WrapsSignInManager<TUser>+UserManager<TUser>and producesStrataraSignInResultwith 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 inIdentityResources.resx;IdentityResources.de.resxprovides German overrides.AddAspNetIdentityWithSignInManagercallsAddLocalization()soIStringLocalizer<IdentityResources>resolves automatically. | |DependencyInjection/MembershipClaimsServiceCollectionExtensions+Services/MembershipClaims*| Sign-in tenant-claim bridge:AddMembershipTenantClaim<TUser>()(stampstratara:tenant_idat issuance) andAddMembershipTenantClaimsTransformation()(resolve live per request) | |Authorization/*+DependencyInjection/PermissionPolicyServiceCollectionExtensions|AddStrataraPermissionPolicies()— every catalog permission becomes an on-demand[Authorize("...")]policy backed byIPermissionResolver| |Authentication/ApiKey*+DependencyInjection/ApiKeyAuthenticationExtensions|AddStrataraApiKey()(X-Api-Key scheme overIApiKeyStore) andAddStrataraAuthSchemeSelector()(route API-key vs. Bearer vs. cookie by request shape) | |Authentication/Stratara{OpenIdConnect,JwtBearer}Options+DependencyInjection/OpenIdConnectAuthenticationExtensions|AddStrataraOpenIdConnect(configuration)(interactive external login) andAddStrataraJwtBearer(configuration)(API access-token validation, multi-issuer byiss) | |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 forSignInManager,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 | Versions 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. |
-
net10.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.8)
- Microsoft.AspNetCore.Authentication.OpenIdConnect (>= 10.0.8)
- Microsoft.AspNetCore.Identity.EntityFrameworkCore (>= 10.0.8)
- Microsoft.IdentityModel.JsonWebTokens (>= 8.18.0)
- Stratara.Identity.Core (>= 3.4.0)
- Stratara.Shared (>= 3.4.0)
- System.IdentityModel.Tokens.Jwt (>= 8.18.0)
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.