Onboarding guide
8. Authentication & Authorization
What this group covers. This is the security spine of the framework: how a caller proves who they
are (authentication), how the system decides what they may do (authorization), and how both survive
the jump from a single-process monolith to a fleet of extracted services. Almost every type here
serves one of nine moving parts: minting and validating JWTs
(TokenService / ITokenService,
RsaJwksProvider / IJwksProvider, and the settings that
select the algorithm and the key material: JwtSettings,
JwtSigningAlgorithm, JwksSettings); the shared login /
register / refresh workflow (AuthenticationServiceBase<TUser>,
IAuthenticationService,
AuthenticationValidators); multi-device refresh sessions
(RefreshSession, IRefreshSessionStore,
RefreshSessionSettings,
RefreshSessionSummaryResponse, and the two workflow-private
helpers IssuedSession and
SessionStampingTokenService); the contracts an app's User
aggregate exposes to those shared workflows (IAuthUser,
IPasswordChangeableUser, IUserPreferences,
IErasableUser); password material
(PasswordHasher / IPasswordHasher); brute-force and
rate-limit protection (LoginProtectionService /
ILoginProtectionService,
LoginProtectionSettings); the forgot-password token lifecycle
(PasswordResetTokenService /
IPasswordResetTokenService,
PasswordResetEntry, PasswordResetSettings);
reading the current caller's identity from claims
(CurrentUserService / ICurrentUserService,
ClaimsPrincipalExtensions,
ClaimBasedUserIdProvider, AuthClaimTypes); the
authorization model (permissions and resource ownership under
AuthorizationExtensions,
PermissionAuthorizationHandler, and
OwnerOrAdminFilter); and the HttpOnly session-cookie machinery
(SessionCookieEndpoints,
SessionCookieAuthenticationHandler,
CookieSessionRefresher) that keeps server-side-rendered Blazor pages
authenticated across a cold navigation.
The governing decisions are ADR-004
(dual-fetch login and cross-service token validation via JWKS, with RS256 as the default because it
survives extraction),
ADR-097 (hashed,
rotating, per-device refresh sessions, which supersedes the storage model of
ADR-050 while keeping its
rotation and reuse-detection policy),
ADR-029
(brute-force protection),
ADR-102 (PBKDF2-only
password hashing, superseding ADR-032),
ADR-091 (the cache-backed
forgot-password token),
ADR-020
(permission-based authorization),
ADR-033
(resource-ownership authorization),
ADR-022 (the browser
session-cookie scheme),
ADR-051 (how each render
head holds and reacquires a token), and
ADR-047 (runtime
revocation for a soft-deleted account). The rubric lenses are dominated by [Rubric §11, Security],
with supporting [Rubric §7, Microservices Readiness] and [Rubric §12, Performance & Scalability]. Auth surfaces
all of its expected failures (bad password, lockout, expired session, rejected reset token) as
Result failures, never exceptions, so reading the
Result pattern first pays off here.
Tokens: one signing switch, two validation worlds
The framework mints two credentials on every successful sign-in: a short-lived access token (a
JWT, 15 minutes by default,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSettings.cs:61) and an opaque,
random refresh token (64 bytes of RandomNumberGenerator output, Base64-encoded, valid 7 days by
default, TokenService.cs:118-121, JwtSettings.cs:64), both produced by
TokenService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:24). The access token
carries a fixed claim spine: sub, jti, iat, plus name, email, and role
(TokenService.cs:90-99), and the app adds its own claims (for example speaker_id or
customer_id) through the additionalClaims parameter (TokenService.cs:101-104). sub is the
single carrier of the user identifier: the duplicate custom claim that used to ride alongside it is
gone, because two values that can disagree is two claim names every reader has to know
(TokenService.cs:87-89). The port ITokenService
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ITokenService.cs:8)
publishes both lifetimes as default interface members pinned to the same 15-minute / 7-day baseline
(ITokenService.cs:33, ITokenService.cs:40), so a hand-written test double reports the same expiry
the production settings would, while the concrete service derives them from the bound settings
(TokenService.cs:125, TokenService.cs:128).
The load-bearing design choice is a single configuration switch,
JwtSettings.SigningAlgorithm
(TokenService.cs:64). It defaults to
JwtSigningAlgorithm.RS256
(JwtSettings.cs:30): the Identity service signs with an RSA private key and every other service
validates against the matching public key, which it fetches over JWKS. A single-host monolith opts
into HS256 explicitly, where one symmetric Base64 secret both signs and validates because issuer
and validator are the same process (TokenService.cs:64-75, TokenService.cs:180-192). Asymmetric
is the default precisely because it is the shape that survives extraction: a compromised non-Identity
service can verify tokens but cannot forge them. An issuer with no explicit public key configured
derives one from its own private-key parameters so it can still self-validate during refresh
(TokenService.cs:216-230), and the key id from
JwksSettings travels into every RS256 token's
kid header so a validator reading the published document selects the right key by name rather than
trying each in turn (TokenService.cs:209-213). Key material is materialized once in the constructor
and the owned RSA handles are disposed with the service (TokenService.cs:34-35,
TokenService.cs:174-178), so token operations never re-parse a PEM. The settings class enforces the
pairing rather than trusting the host: it implements IValidatableObject and rejects an HS256 secret
shorter than 32 characters or an RS256 configuration with no private key (JwtSettings.cs:70-85).
The public half is served by RsaJwksProvider
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:14), which lazily builds
a JsonWebKeySet from a PEM key (inline or read from a path) configured through
JwksSettings (RsaJwksProvider.cs:27-55,
RsaJwksProvider.cs:57-73), behind the IJwksProvider port
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:11). Publishing is off by
default, and when disabled or unconfigured the provider returns an empty key set
(RsaJwksProvider.cs:29-32, RsaJwksProvider.cs:35-38) so the endpoint stays queryable but a
non-issuer host advertises nothing. The cache is a Lazy<JsonWebKeySet> in PublicationOnly mode
rather than the default ExecutionAndPublication (RsaJwksProvider.cs:16-22): the default caches a
factory exception forever, so a single transient IO failure reading the PEM would brick the endpoint
(and with it cross-service auth) until the process restarted. The endpoint itself,
/.well-known/jwks.json, is mapped in the API layer by
JwksEndpointExtensions
(path constant at
MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/JwksEndpointExtensions.cs:20, mapped
anonymously at JwksEndpointExtensions.cs:33-39), paired with the OIDC discovery document from
OidcDiscoveryEndpointExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/OidcDiscoveryEndpointExtensions.cs:27);
OpenIdConnectMetadataWarmupTask
pre-fetches that document as a startup warm-up task
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/OpenIdConnectMetadataWarmupTask.cs:21) so the
first authenticated request on a cold replica does not pay the discovery round trip. Validation pins
the expected algorithm so an attacker cannot force an algorithm swap: GetPrincipalFromExpiredToken
sets ValidAlgorithms to the single configured value (TokenService.cs:150) and then re-checks the
token header after ValidateToken returns (TokenService.cs:159-163). Only the lifetime check is
skipped there (TokenService.cs:145), because the method exists to read claims out of an
already-expired token during refresh.
The shared authentication workflow
Login, registration, refresh, revocation, and device listing are not re-implemented per app. They
live once in AuthenticationServiceBase<TUser>
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:53), an abstract
base each app's Identity module seals over its concrete User aggregate. The base owns the sequence;
the sealed subclass supplies the genuinely app-specific pieces through abstract and virtual hooks:
FindUntrackedByEmailAsync and EmailExistsAsync (written against the concrete User so EF
translation is unchanged, AuthenticationServiceBase.cs:505, AuthenticationServiceBase.cs:511),
CreateUser (AuthenticationServiceBase.cs:514), CreateAccessToken
(AuthenticationServiceBase.cs:517), the two optional candidate gates
(AuthenticationServiceBase.cs:552-557), the post-commit OnUserRegisteredAsync
(AuthenticationServiceBase.cs:563), and the overridable "refresh user vanished" error
(AuthenticationServiceBase.cs:571, 401 by default because a token for a deleted user is
indistinguishable from an invalid one). Both token lifetimes are read from
ITokenService with a defensive fallback to the 15-minute / 7-day baseline for a
misconfigured host or a test double (AuthenticationServiceBase.cs:102-111).
LoginAsync (AuthenticationServiceBase.cs:121) shows the shape. It validates the request first,
then runs the ADR-029
lockout check (AuthenticationServiceBase.cs:133), then does the dual-fetch: an untracked,
no-change-tracking query to verify the password cheaply (AuthenticationServiceBase.cs:144,
AuthenticationServiceBase.cs:161), and only on success a second tracked re-fetch of the instance
the app's CreateAccessToken hook mints from, which is also what turns a race that deleted the
account between the two steps into a clean 404 (AuthenticationServiceBase.cs:169-178). The email is
normalized through the Email value object before the
query so the EF predicate compares same-typed converted values
(AuthenticationServiceBase.cs:141). Soft-deleted accounts fall out through EF global query filters
and return the same generic 401 as a wrong password (AuthenticationServiceBase.cs:145-152), so the
API never reveals whether an email exists, and a successful login clears the attempt counters
(AuthenticationServiceBase.cs:181) before handing off to the shared token-issue path
(AuthenticationServiceBase.cs:183).
RegisterAsync (AuthenticationServiceBase.cs:187) rate-limits by source IP, rejects a duplicate
email as a conflict, hashes the password, saves, and only then runs the app's post-commit hook, counts
the registration, and opens the session (AuthenticationServiceBase.cs:199-263). The up-front email
check is a check-then-act, so two concurrent registrations for the same address both pass it and the
loser only fails on the insert. The save is therefore wrapped in a deliberately broad catch that
re-checks the address and, if it now exists, returns the same conflict the serialized path would
have produced, rethrowing anything else (AuthenticationServiceBase.cs:224-252); the shared failure
factory keeps the two paths indistinguishable to the caller (AuthenticationServiceBase.cs:752). The
catch is broad because the Application layer has no EF Core dependency by layer rule and cannot name
DbUpdateException; the re-check is what narrows it, and it deliberately runs on
CancellationToken.None so a cancelled save can still be classified
(AuthenticationServiceBase.cs:246). RefreshTokenAsync (AuthenticationServiceBase.cs:267)
extracts claims from the expired access token (signature still verified, only lifetime skipped,
AuthenticationServiceBase.cs:280), reads the identifier off sub through
ClaimsPrincipalExtensions (AuthenticationServiceBase.cs:291), and
then resolves the presented refresh token to its session row. Every failure path returns a
Result rather than throwing, matching the framework-wide
Result pattern (see primer §2).
The request and response DTOs for these flows (LoginRequest,
RegisterRequest, RefreshTokenRequest,
AuthenticationResponse, ChangePasswordRequest,
OAuthCodeExchangeRequest, and the device-aware
AuthenticationRequest used by MAUI clients) are compact readonly record structs in MMCA.Common.Shared. Several of them mark boundaries worth noting: password change is
dispatched straight through its command handler at the controller layer rather than brokered by
IAuthenticationService
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:12), the same is
true of the forgot/reset pair below (ForgotPasswordRequest,
ResetPasswordRequest), and ExternalLoginAsync has a default interface
implementation that rejects the call (IAuthenticationService.cs:131-139) because OAuth account
linking stays coupled to the app's own User factory. OAuthCodeExchangeRequest carries only an
opaque single-use code
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/OAuthCodeExchangeRequest.cs:11) precisely so the
token pair never appears in the address bar, browser history, a Referer header, or an access log.
The FluentValidation rules that guard the requests are bundled into one parameter object,
AuthenticationValidators
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:16), which keeps
the app's AuthenticationService constructor below the arity ceiling; the framework ships
LoginRequestValidator and
RefreshTokenRequestValidator, both deliberately minimal
presence-and-shape checks so a rejection never reveals which field was wrong
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:11,
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:10),
while the IValidator<RegisterRequest> the bundle requires is supplied by each app
(AuthenticationValidators.cs:16-19).
Refresh sessions: one row per device
A refresh token is no longer a column on the user row. Every issue opens its own
RefreshSession
(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/RefreshSession.cs:31), so signing in on a phone
leaves a laptop signed in, and the store holds only the token's digest: Create hashes on the way in
so the plaintext never reaches a property (RefreshSession.cs:112-145), and HashToken is an
unsalted, deterministic SHA-256 rendered as 64 upper-case hex characters precisely because lookups are
by hash (RefreshSession.cs:160-164, width constant at RefreshSession.cs:34). The encoding is
part of the contract, not an implementation detail: the type's own remarks give the byte-for-byte SQL
Server equivalent a consumer's data migration has to reproduce (RefreshSession.cs:151-157). The row
is deliberately not an aggregate: no audit stamps, no soft-delete flag, no concurrency token, like
OutboxMessage and AuditTrailEntry, because rows are only ever inserted or revoked and no global
query filter may hide a revoked row from the reuse check (RefreshSession.cs:22-29). Revoke is
idempotent by refusal, so the first reason and instant recorded are the ones kept
(RefreshSession.cs:174-189), and the four reason constants (Rotated, SignedOut, ReuseDetected,
SessionCapExceeded, RefreshSession.cs:46-55) are what an operator reads afterwards.
Rotation leaves a chain, and the chain is the security mechanism. Using a session revokes it and
records the successor in ReplacedByTokenHash (RefreshSession.cs:79), so presenting an
already-rotated token lands on a revoked row rather than on nothing: that is the
ADR-050 reuse signal, and
the workflow answers it by revoking every live session the user holds
(AuthenticationServiceBase.cs:603-610, AuthenticationServiceBase.cs:705-715). The three
rejections behind the single generic error are deliberately different in what they do
(AuthenticationServiceBase.cs:575-615): an unknown hash, or one belonging to another account, is
failed alone, since revoking the family on it would let anyone holding one of this user's expired
access tokens sign them out everywhere by posting a random string; a revoked row revokes the family;
an expired row is an ordinary end of life, so that device re-authenticates while the others keep
working. Two requests presenting the same still-live token are covered by the same rule: rotation is
claimed atomically through IRefreshSessionStore.TryRotateAsync
(AuthenticationServiceBase.cs:685-699), and the request that loses the claim is answered exactly
like a replay because a caller cannot tell the two apart.
IRefreshSessionStore
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IRefreshSessionStore.cs:21) is the narrow
persistence port: add, find by hash (revoked and expired rows included, which is load-bearing for
reuse detection, IRefreshSessionStore.cs:28-37), list a user's un-revoked sessions
(IRefreshSessionStore.cs:45), find one of a user's sessions by id with the owner inside the query
so another account's id is indistinguishable from a nonexistent one (IRefreshSessionStore.cs:49-62),
save, and TryRotateAsync (IRefreshSessionStore.cs:95). Implementations must return tracked
instances, because revocation is a mutation on an instance the store handed out and a no-tracking read
would drop it at save time (IRefreshSessionStore.cs:16-19). The default TryRotateAsync body
(revoke in memory, add, save) is atomic only per instance, which is all an in-memory or test store can
offer; the shipped EF implementation
EFRefreshSessionStore overrides it with a
conditional ExecuteUpdateAsync the database arbitrates
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFRefreshSessionStore.cs:108-133).
That is the [Rubric §8, Data Architecture] half of the story, and the store is registered scoped
alongside the unit of work it shares a DbContext with, so a login and its session insert commit
together (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:157-163).
The workflow around the port is small and worth reading end to end.
IssueTokensAsync (AuthenticationServiceBase.cs:474) opens the session before it mints the access
token, because the token carries the session's id and a session only has an id once it has been
created (AuthenticationServiceBase.cs:484-498); OpenSessionAsync
(AuthenticationServiceBase.cs:623) mints the refresh token, builds the row, and first enforces the
per-user cap by revoking the oldest live sessions
(AuthenticationServiceBase.cs:645, AuthenticationServiceBase.cs:725-738), so one account cannot
grow the table without bound while a legitimate sign-in never fails. Both helpers return
IssuedSession (AuthenticationServiceBase.cs:761), the private pair of "the
plaintext token, which exists nowhere else" and "the row id". The id reaches the client as the
standard sid claim, stamped by SessionStampingTokenService
(AuthenticationServiceBase.cs:773), a pass-through ITokenService armed for the duration of the
app's CreateAccessToken call (AuthenticationServiceBase.cs:538-549,
AuthenticationServiceBase.cs:792-802). Doing it with a wrapper rather than by changing the hook's
signature is what makes the claim additive: every existing subclass keeps compiling and starts
emitting sid with no edit. GetSessionsAsync (AuthenticationServiceBase.cs:401) projects the
user's live sessions into RefreshSessionSummaryResponse
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/RefreshSessionSummaryResponse.cs:23), newest first,
flagging the caller's own device by comparing against the token's sid
(AuthenticationServiceBase.cs:409-422); the response deliberately omits the token hash and the
rotation link, since nothing a client does with a session needs anything but its id
(RefreshSessionSummaryResponse.cs:6-11). RevokeSessionByIdAsync
(AuthenticationServiceBase.cs:439) signs one device out and treats an already-revoked row as a
success that writes nothing, because a device list clicked twice is the most ordinary duplicate in the
feature (AuthenticationServiceBase.cs:452-457), while RevokeTokenAsync
(AuthenticationServiceBase.cs:336) degrades to signing every device out when the presented token does
not identify a live session of this user's (AuthenticationServiceBase.cs:349-367). Those methods
surface on IAuthenticationService (IAuthenticationService.cs:79,
IAuthenticationService.cs:96, IAuthenticationService.cs:115) and are exposed by
AuthControllerBase as revoke, my-sessions,
and a per-session route
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:144,
AuthControllerBase.cs:175, AuthControllerBase.cs:222).
RefreshSessionSettings
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:9) is where a host
tunes the model: MaxActiveSessionsPerUser (default 10, range 1 to 1000,
RefreshSessionSettings.cs:34-35), RetentionDays (default 30, RefreshSessionSettings.cs:71-72),
CleanupIntervalHours (default 6, RefreshSessionSettings.cs:79-80), the DataSourceName that says
which database carries the table (RefreshSessionSettings.cs:51-52), and Enabled
(RefreshSessionSettings.cs:25), which gates the model, not the workflow: the service that owns
identity sets it, every other service in a modular host leaves it alone, and that is what keeps the
table, its migrations, and its sweep in exactly one database. Retention is not decoration: the
settings' own remarks state that the sweep bounds reuse detection, because once a revoked row is
swept, a replay of its token reads as an unknown token and fails alone
(RefreshSessionSettings.cs:59-66). The hosted sweep
RefreshSessionCleanupService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/RefreshSessionCleanupService.cs:48)
is registered only when the flag is set, so a service with no RefreshSessions table never starts a
sweep over a table it does not have
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:165-172), and the mapping
itself is opt-in through
RefreshSessionModelBuilderExtensions.ApplyRefreshSessionConfiguration
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/RefreshSessionModelBuilderExtensions.cs:34).
This whole cluster is [Rubric §7, Microservices Readiness] as much as [Rubric §11, Security]: the
credential store is one service's table, not a column every service's user model has to carry.
What the app's User aggregate must expose
The shared workflows never see an app's User class. They see four small Domain-layer contracts, each
sized to one workflow, which is the [Rubric §1, SOLID] interface-segregation story in miniature.
IAuthUser
(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:16) is now the password surface and
nothing else: the hash and the salt whose length once selected an algorithm (IAuthUser.cs:19-23).
Refresh tokens are deliberately absent, and the interface says why: they used to live here as a single
plaintext column, which capped every account at one signed-in device and put a usable credential in the
users table (IAuthUser.cs:9-14). Profile fields, roles, and linked aggregates stay app-specific and
are reached only through the per-app hooks.
IPasswordChangeableUser
(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:11) extends it with
ChangePassword (IPasswordChangeableUser.cs:19), because both the rotation workflow and the reset
workflow have to write a new credential through the aggregate rather than around it.
IUserPreferences
(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:10) carries the stored culture
and theme plus a single UpdatePreferences mutator that always replaces both fields
(IUserPreferences.cs:13-25); the shared workflow is what preserves the other preference, passing the
stored value for any field the request left null
(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53-55),
which is the null-means-unchanged contract stated on
ChangePreferencesRequest and mirrored by
UserPreferencesResponse
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ChangePreferencesRequest.cs:10,
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/UserPreferencesResponse.cs:9).
IErasableUser
(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:30) is the subtlest of the four. It
extends IAnonymizable and redeclares Delete()
(IErasableUser.cs:37) rather than inheriting it from AuditableBaseEntity<TId>, because an app
User commonly hides the base method (public new Result Delete()) to add account-specific
behavior. A hidden method is not an override, so a shared workflow calling through the class constraint
would silently run the base implementation and skip that behavior; routing the call through this
interface makes the interface map resolve to the most derived Delete() (IErasableUser.cs:11-24).
The base entity deliberately does not implement the interface, so a consumer that forgets to add it
fails the generic constraint at compile time instead of losing behavior at run time
(IErasableUser.cs:25-28). These four contracts are consumed by the shared handler bases in group 14:
ChangePasswordHandlerBase<TUser, TCommand>
(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:25),
ChangePreferencesHandlerBase<TUser, TCommand>
(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:23),
GetUserPreferencesHandlerBase<TUser>
(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21),
ResetPasswordHandlerBase<TUser, TCommand>
(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:31),
and
DeleteUserHandlerBase<TUser, TCommand>
(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:58),
each of which constrains TUser to the matching contract.
Passwords and brute-force protection
Password material is handled by PasswordHasher
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:12), which hashes with
PBKDF2-HMAC-SHA512 at 600,000 iterations (OWASP 2023 guidance, PasswordHasher.cs:24) over a 32-byte
random salt (PasswordHasher.cs:15, PasswordHasher.cs:31) into a 64-byte output
(PasswordHasher.cs:18), and verifies in constant time via
CryptographicOperations.FixedTimeEquals (PasswordHasher.cs:53) to close the timing side channel.
PBKDF2 is now the only path: the legacy single-round HMAC branch, and with it the algorithm
selection keyed on stored salt length, is gone
(ADR-102), so one
algorithm derives and verifies every stored credential (PasswordHasher.cs:7-10,
PasswordHasher.cs:57-63). That is a compact [Rubric §11, Security] story: a modern KDF and a
constant-time compare in one small type, all behind the IPasswordHasher port
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/IPasswordHasher.cs:6) so
the algorithm can be strengthened without touching an Application handler.
LoginProtectionService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:19) adds the
ADR-029 gates on
top, backed by ICacheService rather than a database so the
counters are cheap and self-expiring. Counter keys are built from an Email-normalized identity
(LoginProtectionService.cs:34-47), so User@x.com, user@x.com, and a padded variant collapse onto
one lockout instead of handing an attacker three independent budgets. After
LoginProtectionSettings.MaxFailedAttempts consecutive failures
(default 5,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:18) it applies an
exponential-backoff lockout capped at MaxLockoutSeconds (default 300,
LoginProtectionSettings.cs:24), with a deliberately clamped shift exponent so a persistent attacker
cannot wrap the TTL back to something small (LoginProtectionService.cs:88), and it rate-limits
registrations per source IP (default 10 per 60-minute window, LoginProtectionSettings.cs:37-43,
LoginProtectionService.cs:101-136). Every setting carries a [Range] attribute, which is what makes
the clamp argument airtight: MaxLockoutSeconds cannot exceed 3600 (LoginProtectionSettings.cs:23),
and 1 << 30 already dwarfs that. The ILoginProtectionService port
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/ILoginProtectionService.cs:10) is what the
workflow depends on, and it calls the gates at exactly the right points (increment on failed login,
reset on success), so the protection is centralized rather than sprinkled through each app's
controller. One documented trade-off is stated in source: the attempt increment is a
read-modify-write rather than an atomic counter, because the native Redis INCR path wrote a key shape
IDistributedCache could not read back (LoginProtectionService.cs:66-74). Sequential guessing, which
is what a credential-stuffing run looks like, still trips the lockout.
Forgot password: a cache-backed single-use token
A user who has lost the password cannot present one, so this flow is anonymous by necessity, which
makes every one of its responses a potential account-enumeration oracle. It is also built without a
schema change: the token lives in the cache, hashed, and expires by TTL rather than being reaped by a
sweeper (ADR-091). The
port is IPasswordResetTokenService
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:10), two methods
wide: IssueAsync mints a token for an address (IPasswordResetTokenService.cs:23) and
ValidateAndConsumeAsync redeems it exactly once (IPasswordResetTokenService.cs:36). The
implementation, PasswordResetTokenService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26), rides on
ICacheService and buys four properties in a few lines each:
- One active token per email. Issuing writes the same per-address key
(
PasswordResetTokenService.cs:51,PasswordResetTokenService.cs:88), so requesting a new link retires the previous one. - Hashed at rest. Only the Base64 of the token's SHA-256 is stored
(
PasswordResetTokenService.cs:55-56,PasswordResetTokenService.cs:82-88), so a cache dump hands out no working reset links, and the comparison on redemption is constant time throughCryptographicOperations.FixedTimeEquals(PasswordResetTokenService.cs:118). - An attempt cap. A wrong token increments a counter on the record, and the record is discarded at
MaxValidationAttempts(PasswordResetTokenService.cs:132-153, default 5,MMCA.Common/Source/Core/MMCA.Common.Application/Auth/PasswordResetSettings.cs:36). The rewrite after a wrong guess uses the record's remaining lifetime rather than a fresh one (PasswordResetTokenService.cs:146-152), so guessing cannot extend the redeemable window. - A per-email request throttle. A counter carrying the window's TTL caps how often one address can
trigger an email (
PasswordResetTokenService.cs:66-77, default 3 per 60 minutes,PasswordResetSettings.cs:40,PasswordResetSettings.cs:44), and a successful redemption deletes the token and that counter (PasswordResetTokenService.cs:126-127) so a legitimate reset does not leave the user throttled out of a later one.
Keys are built from an Email-normalized identity for the same reason
LoginProtectionService does it
(PasswordResetTokenService.cs:34-53). The cached record, PasswordResetEntry
(PasswordResetTokenService.cs:171), is deliberately all JSON primitives: cache values round-trip
through System.Text.Json, so a value object or a byte[] member would not survive a distributed
backing store (PasswordResetTokenService.cs:162-166). Token material is 32 random bytes, Base64Url
encoded (PasswordResetTokenService.cs:30, PasswordResetTokenService.cs:79), redeemable for
TokenLifetimeMinutes (default 30, PasswordResetSettings.cs:29), and every rejection (unknown,
expired, mismatched, attempt-capped) collapses into one generic failure
(PasswordResetTokenService.cs:155-159). The settings bind from the PasswordReset configuration
section and the service is registered scoped in Infrastructure DI (PasswordResetSettings.cs:13,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:151-155).
The workflow around the port lives in the group-14 handler bases, and it is where the
anti-enumeration rule is enforced.
ForgotPasswordHandlerBase<TUser, TCommand>
(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:36)
resolves the account through its one abstract lookup, issues a token, and mails it through
IEmailSender (ForgotPasswordHandlerBase.cs:84-89), but a
malformed address, an address with no account, a throttled request, and a failed send all log and
return success alike (ForgotPasswordHandlerBase.cs:60-63, ForgotPasswordHandlerBase.cs:68-71,
ForgotPasswordHandlerBase.cs:75-78, ForgotPasswordHandlerBase.cs:91-97). The only 400 comes from
ForgotPasswordRequestValidator
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11),
which inspects the shape of the address and nothing else. The email carries both a prefilled link
(composed from PasswordResetSettings.ResetUrl, deliberately not required so a host that has not
configured a UI base still boots, PasswordResetSettings.cs:25,
ForgotPasswordHandlerBase.cs:145-148) and the raw token, because a client without deep linking (the
MAUI head) needs it typed into the reset page by hand (ForgotPasswordHandlerBase.cs:124).
ResetPasswordHandlerBase<TUser, TCommand>
(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:31)
consumes the token before the save on a stated trade-off (leaving it live until the write succeeds
opens a replay window; a token burned by a later invariant failure costs the user one more reset
request, ResetPasswordHandlerBase.cs:59-69), hashes through IPasswordHasher
and writes the credential through the aggregate's ChangePassword
(ResetPasswordHandlerBase.cs:80-81), then clears the login-protection counters so a user who reset
because of a lockout is not left locked out (ResetPasswordHandlerBase.cs:90).
ResetPasswordRequestValidator
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:12)
includes the same StrongPasswordRules<T> that
registration and change-password use (ResetPasswordRequestValidator.cs:23), so a reset is not a way
around the complexity policy. The endpoints are
PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43):
both actions are [AllowAnonymous] and rate-limited per IP exactly as login and register are
(PasswordResetAuthControllerBase.cs:77-78, PasswordResetAuthControllerBase.cs:101-102),
forgot-password answers 202 for any well-formed request (PasswordResetAuthControllerBase.cs:79,
PasswordResetAuthControllerBase.cs:92), and reset-password collapses every rejection into a single
401 (PasswordResetAuthControllerBase.cs:105).
Reading identity from claims
Once a request is authenticated, downstream code needs the caller's identity without re-parsing the
JWT, and it needs one answer no matter which pipeline produced the principal. That is the job of
ClaimsPrincipalExtensions
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:18): FindUserIdValue
reads the raw sub claim and falls back to the ClaimTypes.NameIdentifier form the JWT bearer
handler maps it onto (ClaimsPrincipalExtensions.cs:26-28), GetUserId parses that value through
IParsable<T> in the invariant culture so the solution-wide identifier alias can change shape without
editing any reader (ClaimsPrincipalExtensions.cs:40-44), and FindSessionId reads the sid claim,
treating absence as an ordinary "the caller's own device is unknown" rather than an error
(ClaimsPrincipalExtensions.cs:56-60). AuthClaimTypes
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthClaimTypes.cs:9) names the three claim types
this group cares about: the framework-custom "permission" (AuthClaimTypes.cs:17) used by the
authorization model below, plus the standard sub (AuthClaimTypes.cs:27) and sid
(AuthClaimTypes.cs:40), each documented with the pipeline caveat that motivates reading it through
the extensions.
CurrentUserService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:17) is the scoped
adapter over IHttpContextAccessor: it exposes the ClaimsPrincipal, the parsed user id, and the
first role claim, caching the parsed values behind a per-request Lazy<T>
(CurrentUserService.cs:19-23) and reading the identifier through the shared extensions
(CurrentUserService.cs:20). Its generic GetClaimValue<T> (CurrentUserService.cs:35) is what the
ownership filter uses to read app-specific owner claims, and it parses in the invariant culture
because claims are machine-written and the ambient request culture must not decide how a separator
reads (CurrentUserService.cs:39-41). The interface itself,
ICurrentUserService
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ICurrentUserService.cs:9),
carries the multi-role logic as default interface members: Roles reads every role claim across the
three claim-type spellings the JWT middleware may produce and falls back to the single Role property
when a hand-written double populates only that (ICurrentUserService.cs:45-64), and IsInRole does a
case-insensitive membership check over that set (ICurrentUserService.cs:88-89). A sibling adapter,
ClaimBasedUserIdProvider
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ClaimBasedUserIdProvider.cs:11), plugs
the same identifier into SignalR's IUserIdProvider so Clients.User(userId) routes hub messages to
the right connections (ClaimBasedUserIdProvider.cs:14-15).
Authorization: permissions and ownership
There is one authorization model here, and it is capabilities, not role names. The single
AddAuthorizationPolicies() extension in AuthorizationExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:12,
AuthorizationExtensions.cs:23) wires the whole mechanism: the handler, the on-demand policy
provider, and the shared registry (AuthorizationExtensions.cs:25-36). Role names still exist as
data, in RoleNames
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RoleNames.cs:12, five constants across both apps:
Organizer, Attendee, ContentEditor, Admin, Customer, RoleNames.cs:15-31), and roles get a
value-object base, RoleValue
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RoleValue.cs:25), so each app can fix its own role
set with case-insensitive, type-guarded equality (RoleValue.cs:90-96), a frozen interned lookup
(RoleValue.cs:75-84), and Result-returning validation (RoleValue.cs:42) while staying
dependency-free enough to use from Blazor WASM. What no longer exists is a shipped policy per role:
an endpoint states the capability it needs and the registry maps roles to capabilities
(ADR-020).
HasPermissionAttribute
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:13) marks a
controller or action with a permission such as "sessions:manage"; under the hood it is an
AuthorizeAttribute whose policy name is perm:sessions:manage
(PermissionPolicy.NameFor,
MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicy.cs:12,
PermissionPolicy.cs:17, applied at HasPermissionAttribute.cs:17-18). Rather than pre-registering a
named policy per permission, PermissionPolicyProvider
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:13)
materializes those policies on demand for any perm: name and falls through to the default provider
for everything else (PermissionPolicyProvider.cs:31-47). The requirement it attaches,
PermissionRequirement
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionRequirement.cs:10), is
evaluated by PermissionAuthorizationHandler
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:14),
which grants access when the principal holds the permission directly (a permission claim) or
derives it from one of its roles via IPermissionRegistry
(PermissionAuthorizationHandler.cs:30-31,
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionRegistry.cs:13), reading roles across the
same three claim-type spellings (PermissionAuthorizationHandler.cs:43-49). The registry itself
(PermissionRegistry,
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistry.cs:10) is an immutable, frozen
role-to-permission map with case-insensitive role keys and ordinal permission values
(PermissionRegistry.cs:25-28) built by
PermissionRegistryBuilder
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistryBuilder.cs:8); each module
contributes only its own grants through AddPermissions(...)
(AuthorizationExtensions.cs:47-55), duplicate grants union rather than collide
(PermissionRegistryBuilder.cs:25-42), and the shared registry is built lazily on first resolve, after
every module has registered (AuthorizationExtensions.cs:61-74). That module-local contribution is the
[Rubric §7, Microservices Readiness] touch: an extracted service carries only its own permission
grants.
The same capabilities reach inside the CQRS pipeline, not just the HTTP boundary: a command or query
that implements IRequiresPermission is checked by
AuthorizationCommandDecorator<TCommand, TResult>
and
AuthorizationQueryDecorator<TQuery, TResult>
against the same registry. Because those decorators are registered unconditionally, a host with no
Identity module and no grants would fail to activate every handler in the pipeline, which is what
UnconfiguredPermissionRegistry
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/UnconfiguredPermissionRegistry.cs:20) exists to
prevent: it is registered with TryAdd, so a host that called AddAuthorizationPolicies() keeps its
own registry and never constructs this one
(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:126). It grants nothing, so a
permission-gated request is denied rather than allowed (UnconfiguredPermissionRegistry.cs:28-42),
and it says so exactly once, in a log message naming the call that would fix it
(UnconfiguredPermissionRegistry.cs:49-60). The warning is deferred to the first real check rather
than raised at startup, because a host with no permission-gated request is correctly configured: it
simply never needs a registry (UnconfiguredPermissionRegistry.cs:44-48). Fail-closed plus a
diagnosable message is the [Rubric §11, Security] and [Rubric §13, Observability & Operability]
reading of that type.
The second style is resource ownership. OwnerOrAdminFilter
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:31) is an action
filter for endpoints that mix admin and owner access (carts, orders, bookmarks). It lets a bypass role
through (OwnerOrAdminFilter.cs:43), then compares the caller's owner claim against the resource id
taken from either the route or a bound argument (OwnerOrAdminFilter.cs:49,
OwnerOrAdminFilter.cs:93-111), returning 403 otherwise. The important property is that it denies by
default: when the owner claim is missing (OwnerOrAdminFilter.cs:51-55) or the owner parameter
cannot be resolved at all, the request is rejected rather than waved through
(OwnerOrAdminFilter.cs:57-71), because "nothing to compare" must not read as "nothing to enforce". An
action that legitimately has no owner parameter opts out explicitly with
AllowMissingOwnerAttribute
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AllowMissingOwnerAttribute.cs:21),
honored from either the action or its declaring controller via endpoint metadata
(OwnerOrAdminFilter.cs:82-85, AllowMissingOwnerAttribute.cs:20), and the attribute's own remarks
require the application site to name the guard that replaces the check
(AllowMissingOwnerAttribute.cs:15-19). The filter's vocabulary (claim type, bypass role, route
parameter) is configurable through
OwnerOrAdminFilterOptions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:11) whose
defaults preserve the original customer_id / Admin / id behavior
(OwnerOrAdminFilterOptions.cs:14-24,
ADR-033), with
OwnershipHelper
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:10) supplying both
the bypass-role check (OwnershipHelper.cs:17) and the query-scoping specification factory that
controllers use to narrow collection endpoints to the caller's own rows
(OwnershipHelper.cs:34-67).
Session cookies: keeping SSR authenticated
The final cluster solves a Blazor-specific problem: an interactive Blazor app keeps its access token in
browser memory, but a cold server-side render (a new tab, an F5, an external deep link) has no memory
to read, so an [Authorize] page would bounce to /login before the interactive phase starts. The fix
is a pair of HttpOnly cookies (mmca_auth_access, mmca_auth_refresh,
MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:17-18)
seeded and cleared from JS through SessionCookieEndpoints
(SessionCookieEndpoints.cs:15, SessionCookieEndpoints.cs:29-39, request body
SessionCookieRequest at SessionCookieEndpoints.cs:72), written with one
shared set of attributes by SessionCookieJar
(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieJar.cs:11: HttpOnly,
Secure outside Development, SameSite=Lax, Path=/, and a 7-day max age aligned to the
refresh-token lifetime, SessionCookieJar.cs:14, SessionCookieJar.cs:31-38), and read during
prerender by CookieTokenReader
(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieTokenReader.cs:10).
SessionCookieAuthenticationHandler
(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieAuthenticationHandler.cs:24)
is a custom authentication scheme that reads the cookie JWT, checks only its expiry against the
handler's injectable TimeProvider (SessionCookieAuthenticationHandler.cs:55) because the API still
performs full validation on every API call (SessionCookieAuthenticationHandler.cs:18-23), and
populates HttpContext.User so SSR authorization passes
(SessionCookieAuthenticationHandler.cs:60-63); a challenge redirects to /login with a returnUrl
(SessionCookieAuthenticationHandler.cs:72-77). It is registered through
SessionCookieAuthenticationExtensions
(SessionCookieAuthenticationHandler.cs:90).
When the access cookie has expired but the refresh cookie is still valid,
CookieSessionRefreshMiddleware
(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefreshMiddleware.cs:13,
registered by CookieSessionRefreshMiddlewareExtensions
at CookieSessionRefreshMiddleware.cs:35) runs before UseAuthentication on qualifying navigations
(GET plus an Accept header containing text/html, CookieSessionRefreshMiddleware.cs:28-31) and
delegates to CookieSessionRefresher
(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:52) through
the ICookieSessionRefresher port (CookieSessionRefresher.cs:30). The
refresher first tries to read a still-valid expiry out of the access cookie with a 30-second skew
allowance (CookieSessionRefresher.cs:60, CookieSessionRefresher.cs:155-184); failing that it
exchanges the refresh cookie at the API's auth/refresh endpoint server-to-server
(CookieSessionRefresher.cs:128-131), so the refresh token never reaches browser JS. It then writes
the rotated pair back as cookies and stashes the fresh access token on HttpContext.Items
(CookieSessionRefresher.cs:88-92) so the current request's authentication reads the new token:
CookieTokenReader checks that item before falling back to the request cookie
(CookieTokenReader.cs:17, CookieTokenReader.cs:27-33). Concurrent refreshes are collapsed into a
single flight by a KeyedSemaphoreStripe keyed on the refresh token plus a
10-second rotation-grace IMemoryCache entry keyed by the old refresh token
(CookieSessionRefresher.cs:61, CookieSessionRefresher.cs:63, CookieSessionRefresher.cs:96-112,
CookieSessionRefresher.cs:145), so a queued herd of requests cannot double-rotate. Striping rather
than one process-wide lock is deliberate and stated in source: the lock is held across an outbound HTTP
call, so a single semaphore serialized every unrelated user's cold navigation behind whichever refresh
was in flight (CookieSessionRefresher.cs:45-50); two unrelated tokens sharing a stripe is harmless
because the grace cache is re-checked per token after acquiring
(CookieSessionRefresher.cs:105-109). A transport failure is not cached and renders the request
anonymously rather than throwing a 500 out of SSR (CookieSessionRefresher.cs:118-123,
CookieSessionRefresher.cs:148-152). The same refresher backs the same-origin
POST /auth/session/token endpoint the browser polls to hydrate its in-memory token
(SessionCookieEndpoints.cs:45-60), guarded by SameSite=Lax plus a Sec-Fetch-Site cross-site
rejection (SessionCookieEndpoints.cs:48-51, SessionCookieEndpoints.cs:68-70) and returning
SessionTokenResponse (CookieSessionRefresher.cs:21), the browser-safe
projection of the internal SessionTokenResult
(CookieSessionRefresher.cs:15) that deliberately omits the refresh token. This whole cluster is
ADR-022's server half; the
client half across Blazor Server, WASM, and MAUI is
ADR-051.
Privacy: the data-subject export package
Three members of this group belong to the privacy surface that sits beside erasure.
UserDataExportDTO
(MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15) is the portable
GDPR/CCPA export envelope: a document FormatVersion consumers read before parsing
(UserDataExportDTO.cs:22), the generation instant, the subject id, an app-owned Subject snapshot
typed as object so each app decides which of its own fields are portable
(UserDataExportDTO.cs:40), and an ordered list of
UserDataExportSectionDTO envelopes (UserDataExportDTO.cs:48,
UserDataExportDTO.cs:61). Each section reports Available explicitly (UserDataExportDTO.cs:72) so
a reader can tell "this subject has no data here" apart from "this contributor could not be reached",
and an unavailable section carries only a caller-safe reason string, never an exception message or a
connection string (UserDataExportDTO.cs:88). One failing contributor therefore degrades one section
instead of denying the subject their whole export, which is the [Rubric §30, Compliance/Privacy/Data
Governance] point of the shape. PrivacyFeatures
(MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:6) holds the single flag name
Privacy.DataExport (PrivacyFeatures.cs:9) that keeps the whole surface off until a host turns it
on, applied as a [FeatureGate] on
DataExportControllerBase<TQuery>.
The composing workflow
(ExportUserDataHandlerBase<TUser, TQuery>
and the IUserDataExportSection
contributors) lives in group 14
(ADR-076).
Shared primitives and adjacent members
Several group members are general-purpose primitives that landed in this chapter because of how the
dependency grouping fell, though one of them is now load-bearing for auth.
KeyedSemaphoreStripe
(MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22) and its
Releaser handle (KeyedSemaphoreStripe.cs:78) serialize work per logical key across a
fixed set of semaphores (256 by default, KeyedSemaphoreStripe.cs:25, with an explicit-width
constructor at KeyedSemaphoreStripe.cs:37; acquisition maps the key onto one stripe at
KeyedSemaphoreStripe.cs:60-75). That is the bounded alternative to a semaphore-per-key dictionary,
which forces a choice between two defects: removing the entry on release opens a window where one
caller waits on a semaphore no longer in the table while another creates a fresh one, and never
removing it lets caller-supplied keys grow the table without bound
(KeyedSemaphoreStripe.cs:7-16). Its consumers today are
CookieSessionRefresher (above, CookieSessionRefresher.cs:63),
the IdempotencyFilter
(MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:90),
CachingQueryDecorator<TQuery, TResult>
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:249),
MemoryCacheService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:38), and the
default GetOrCreateAsync lock table on
ICacheService
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:145).
InProcessDistributedLock is the
deliberate exception: it keys on the exact key in a ConcurrentDictionary instead, because its
contract has a bounded wait, and stripe false-sharing would turn that into a spurious
"held elsewhere" answer for a key nobody holds
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/InProcessDistributedLock.cs:19-24).
Three HTTP and convention helpers sit in MMCA.Common.Shared for the same structural reason: both
ends of an exchange need them and the two packages do not reference each other.
IdempotencyHeaders
(MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:13) is a two-constant class
holding Idempotency-Key and X-Idempotent-Replay, read by the API filter and written by the UI
service bases (IdempotencyHeaders.cs:19, IdempotencyHeaders.cs:25).
ConcurrencyETag
(MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ConcurrencyETag.cs:24) translates between the EF
Core rowversion token and the HTTP entity tag that represents it: Format renders the base64 of the
raw token as a weak tag (ConcurrencyETag.cs:40-45), weak on purpose because the same row version
renders differently under a fields= projection and a strong tag would be promising byte-for-byte
equality it cannot deliver (ConcurrencyETag.cs:12-18), and the parse side tolerates the weak prefix
and the quotes while treating a blank value, the * wildcard, and a non-base64 payload alike as "no
concrete token" for the caller to classify (ConcurrencyETag.cs:27-33, ConcurrencyETag.cs:52-60).
ProblemDetailsResultReader
(MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs:58) is the client-side
inverse of the API's error contract: it turns a response body back into
Error values, synthesizing a status-derived code
(Http.404 and friends, ProblemDetailsResultReader.cs:65) when the payload carries no
machine-readable code, and it is explicit that the reverse mapping is lossy for 400, where Validation,
Invariant and Failure all collapse onto one status
(ProblemDetailsResultReader.cs:50-56). ModuleNameConventions
(MMCA.Common/Source/Core/MMCA.Common.Shared/Conventions/ModuleNameConventions.cs:10) derives a type's
owning module from its namespace under the MMCA.{App}.{Module}.{Layer} convention
(ModuleNameConventions.cs:38-45); it lives in Shared because both persistence (schema and data-source
names) and the Application-layer CQRS logging decorators need it, and Application may not reference
Infrastructure (ModuleNameConventions.cs:6-8). Its layer list deliberately omits Shared
(ModuleNameConventions.cs:17) so a framework namespace never resolves to a phantom module.
IcsEvent and IcsCalendarBuilder
(MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15,
MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:12) build RFC 5545
calendar (.ics) exports from UTC-normalized event times, with 75-octet line folding and CRLF endings
(IcsCalendarBuilder.cs:14 for the MaxLineOctets budget; the folding and the \r\n writes both live
in AppendLine, IcsCalendarBuilder.cs:83-104).
Two genuine auth members sit at the edge of the group.
ISoftDeletedUserValidator
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ISoftDeletedUserValidator.cs:7)
is the small contract the API's
SoftDeletedUserMiddleware uses to reject
an otherwise-valid token whose backing account has since been soft-deleted (BR-133,
ADR-047),
implemented by each Identity module so Common never takes a cross-module domain reference. Its fast
path is SoftDeletedUserCache
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:17), which owns both the
key shape and the 30-second marker lifetime (SoftDeletedUserCache.cs:29, SoftDeletedUserCache.cs:42)
so the module that deletes an account writes exactly the key the middleware reads; the marker only has
to outlive the window between the delete committing and the next validator query, and the 15-minute
access-token lifetime bounds the rest of the exposure (SoftDeletedUserCache.cs:22-28). The key is
formatted invariantly on purpose, because a culture-sensitive identifier would be written under one
request's culture and missed under another (SoftDeletedUserCache.cs:36-43). The controller surface
that drives everything above
(AuthControllerBase,
OAuthControllerBase,
UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>,
PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>,
ExternalAuthExtensions) and the gRPC token
forwarding (JwtForwardingClientInterceptor)
live in later groups; this chapter is the engine those endpoints call into.
AllowMissingOwnerAttribute
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AllowMissingOwnerAttribute.cs:21· Level 0 · class (sealed attribute, marker)
- What it is: a marker attribute placed on an action or a whole controller to declare that the
action legitimately has no owner parameter, exempting it from
OwnerOrAdminFilter's requirement that the request carry a resolvable owner identifier. - Depends on: nothing first-party;
System.AttributeandAttributeUsageAttribute(BCL). It is read (never constructed) byOwnerOrAdminFilter. - Concept introduced, the explicit opt-out that makes deny-by-default safe.
[Rubric §11, Security](assesses whether a guard fails closed rather than open) and[Rubric §34, Architecture Governance & Documentation](assesses whether a deliberate exemption is written down where it applies, so it can be audited later). The filter this attribute exempts from denies the request when it cannot resolve an owner parameter, because "no owner to compare" must not read as "no restriction" (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AllowMissingOwnerAttribute.cs:6-13). Deny-by-default only works if the genuinely parameter-less actions have a way to say so, and that is the entire job of this type. The doc comment names the two shapes that qualify: a collection endpoint whose rows are already narrowed to the caller by an ownership specification, and an action restricted to administrators by its own authorization policy (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AllowMissingOwnerAttribute.cs:9-12). - Walkthrough: the whole type is its
[AttributeUsage]declaration plus an empty body (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AllowMissingOwnerAttribute.cs:20-23).AttributeTargets.Class | AttributeTargets.Methodallows both controller-wide and per-action application;AllowMultiple = falsebecause a second copy would mean nothing;Inherited = trueso a controller base class can carry it. It holds no data: presence in the endpoint metadata is the entire signal, which is exactly whatOwnerOrAdminFilter.HasAllowMissingOwnerlooks for (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:84-85). - Why it's built this way: an empty marker keeps the opt-out cheap to apply and impossible to
mis-configure, but the
<remarks>block (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AllowMissingOwnerAttribute.cs:15-19) is the load-bearing half of the design: applying it is an assertion that the action is guarded elsewhere, so every application site is expected to name the replacement guard in a comment. That turns a silent hole into a reviewable claim (ADR-033 records the audit that produced the current application sites). - Where it's used: honored by
OwnerOrAdminFilterthrough endpoint metadata. In MMCA.Store it marks four actions onMMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/ShoppingCartsController.cs:98,113,142,178and five onMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/CustomersController.cs:49,61,78,110,131, both controllers naming the replacing guard in an inline comment next to the attribute (ShoppingCartsController.cs:93-97,CustomersController.cs:48). - Caveats / not-in-source: nothing enforces the assertion. No analyzer or compile-time rule checks
that an action carrying this attribute really is guarded another way; the guarantee is a review
convention. Store closes the specific gap it opens with a hand-written
RequireResolvableOwner()gate on the collection reads (ShoppingCartsController.cs:80-90), but that gate is per-controller code, not something the attribute demands.
OwnerOrAdminFilterOptions
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:11· Level 0 · class (options)
- What it is: a host-configurable options object that supplies the three vocabulary values
OwnerOrAdminFilterneeds: which claim carries the caller's owner id, which role bypasses the ownership check, and which route or argument parameter names the resource owner. - Depends on: nothing first-party.
- Concept introduced, externalizing a filter's vocabulary through the options pattern.
[Rubric §11, Security](assesses whether the ownership rule is enforced consistently; here the rule is fixed in code while its identifiers are configuration) and[Rubric §15, Best Practices & Code Quality](assesses whether a second host can reuse a component without forking it). The doc comment cites ADR-033 (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:4): the filter used to hard-code MMCA.Store'scustomer_id/Admin/idtriple, and moving those into anIOptions<T>-bound class lets an app with a different ownership vocabulary (aUserIdclaim with anOrganizerbypass keyed by auserIdroute value) reconfigure it viaservices.Configure<OwnerOrAdminFilterOptions>(...)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:5-9). - Walkthrough: three mutable auto-properties, each seeded with the original default so an unchanged
host needs no configuration:
OwnerClaimType="customer_id"(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:14),BypassRole="Admin"(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:17), andOwnerParameterName="id"(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:24). The last one's doc comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:19-23) spells out that the parameter is looked up as a route value first and a model-bound query or body argument second, which is exactly the two-step lookup the filter performs. - Why it's built this way:
get; set;(notinit) is the shape the ASP.NET Core options binder expects, so the values can arrive fromappsettingsor aConfigurecallback; defaults on every property mean adding the options type broke no existing host. - Where it's used: injected as
IOptions<OwnerOrAdminFilterOptions>intoOwnerOrAdminFilter(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:33). MMCA.ADC's Engagement module is the host that configures it rather than taking the defaults, pointing the shared filter atClaimTypes.NameIdentifier, theOrganizerbypass role, and auserIdparameter (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:45,53-55); the comment above those assignments explains whyNameIdentifierand not the rawsubclaim is the type the principal actually carries by the time the filter runs (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:47-52).
PermissionPolicy
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicy.cs:9· Level 0 · class (static)
- What it is: the naming convention that turns a permission string such as
"sessions:manage"into the ASP.NET Core policy name"perm:sessions:manage", and back. - Depends on: nothing first-party.
- Concept introduced, permission policies as prefixed policy names.
[Rubric §11, Security](assesses whether authorization is expressed as capabilities rather than hard-coded role checks) and[Rubric §2, Design Patterns](assesses deliberate use of a known pattern; here a reserved-prefix naming convention is what lets an on-demand provider recognize its own policies). Rather than pre-register one named policy per permission, the codebase encodes the permission inside the policy name behind a reserved prefix;PermissionPolicyProviderthen materializes any policy whose name starts with that prefix on demand (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicy.cs:3-8). This class owns both ends of that encoding. - Walkthrough:
Prefix="perm:"(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicy.cs:12), the reserved marker; andNameFor(string permission)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicy.cs:17), an expression-bodiedPrefix + permissionthat builds the policy name.HasPermissionAttributecallsNameForto build the[Authorize]policy string, andPermissionPolicyProviderstripsPrefixback off to recover the permission. - Why it's built this way: a single shared prefix constant means the attribute that writes the
policy name and the provider that reads it cannot disagree; both reference
PermissionPolicy.Prefix. - Where it's used: by
HasPermissionAttribute(encode) andPermissionPolicyProvider(decode).
PermissionRequirement
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionRequirement.cs:10· Level 0 · class (sealed)
- What it is: an ASP.NET Core
IAuthorizationRequirementcarrying the single permission a principal must hold for a given policy to succeed. - Depends on:
Microsoft.AspNetCore.Authorization.IAuthorizationRequirement(framework). - Concept introduced, the requirement/handler pair.
[Rubric §11, Security]and[Rubric §2, Design Patterns](assesses whether the ASP.NET Core authorization model is used as designed: it splits what is required from how it is checked). A requirement is a passive data object; a matchingAuthorizationHandler<T>decides whether it is satisfied. This type is the passive half;PermissionAuthorizationHandleris the active half, as the doc comment states (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionRequirement.cs:5-9). - Walkthrough: a
sealedclass implementingIAuthorizationRequirement(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionRequirement.cs:10). Its constructor (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionRequirement.cs:14-18) guards withArgumentException.ThrowIfNullOrWhiteSpace(permission)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionRequirement.cs:16) and stores the value into the get-onlyPermissionproperty (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionRequirement.cs:21), so a requirement can never carry an empty permission. - Why it's built this way: keeping
Permissionimmutable and non-empty means the handler can trust it without re-validating; the requirement is a value carrier with no behavior of its own. - Where it's used: attached to a policy by
PermissionPolicyProvider(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:43) and evaluated byPermissionAuthorizationHandler.
HasPermissionAttribute
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:13· Level 1 · class (sealed attribute)
- What it is: an
[Authorize]-derived attribute that requires the authenticated principal to hold a named permission, applied to a controller or an action. - Depends on:
PermissionPolicy(to build the policy name);Microsoft.AspNetCore.Authorization.AuthorizeAttribute(framework base). - Concept introduced, capability-based endpoint authorization.
[Rubric §11, Security](assesses whether endpoints depend on capabilities rather than hard-coded role names) and[Rubric §7, Microservices Readiness](assesses whether a module can be lifted out on its own; permissions travel as claims or through a per-module registry, so an extracted service authorizes without embedding the issuer's role taxonomy). The doc comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:5-11) states the intent directly: prefer[HasPermission("sessions:manage")]over role-based[Authorize(Policy = ...)]so an endpoint declares the capability it needs, with the mapping from roles to that capability living in one registry (IPermissionRegistry). - Walkthrough:
sealed class HasPermissionAttribute : AuthorizeAttribute(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:13) with[AttributeUsage(... AllowMultiple = true, Inherited = true)](MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:12) so several permission requirements can stack on one target and subclasses inherit them. The constructor (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:17-18) chains to the base withPermissionPolicy.NameFor(permission): setting the inheritedPolicyto"perm:<permission>"is what routes the check throughPermissionPolicyProvider. It also stores the barepermissionon the get-onlyPermissionproperty (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:21). - Why it's built this way: deriving from
AuthorizeAttribute(rather than inventing a filter) means the standard MVC authorization pipeline picks it up for free, and encoding the permission into the inheritedPolicystring is what removes the per-permission registration step. - Where it's used: on controllers and actions across both apps; its policy name is resolved by
PermissionPolicyProviderand satisfied byPermissionAuthorizationHandleragainst the grants each module declares throughAuthorizationExtensions.AddPermissions.
PermissionAuthorizationHandler
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:14· Level 1 · class (sealed)
- What it is: the
AuthorizationHandler<PermissionRequirement>that decides whether the current principal satisfies aPermissionRequirement, either because it carries the permission as an explicit claim or because one of its roles grants it. - Depends on:
PermissionRequirement,IPermissionRegistry(the role-to-permission map, taken as a primary constructor parameter atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:14),AuthClaimTypes(the permission claim type,MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthClaimTypes.cs:17);System.Security.Claims. - Concept introduced, resolving a permission through claim-or-role.
[Rubric §11, Security](assesses correctness of the grant decision; there are two independent grant paths, a direct permission claim and a role-derived one) and[Rubric §7, Microservices Readiness](assesses whether a service stands alone; the handler reads roles out of the token regardless of how the JWT middleware mapped the role claim type, so it survives inbound-claim mapping being on or off). - Walkthrough:
HandleRequirementAsync(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:18) null-guards both arguments (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:22-23), then short-circuits to a completed task when the principal is not authenticated (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:25-28), so an anonymous request never succeeds. It then succeeds the requirement if eithercontext.User.HasClaim(AuthClaimTypes.Permission, requirement.Permission)(a directly granted permission) orpermissionRegistry.HasPermission(GetRoles(context.User), requirement.Permission)(a role-derived grant) holds (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:30-34). The privateGetRoles(ClaimsPrincipal)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:43-49) gathers role values across three possible claim types: the standardClaimTypes.RoleURI plus the raw"role"and"roles"claims, so roles are found whether or not the JWT bearer middleware mapped them. The comment atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:39-42states the rationale and flags the deliberate duplication: this is the same predicateICurrentUserService.Rolesapplies (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ICurrentUserService.cs:45,49-55), restated here because the handler runs on the raw principal and has noICurrentUserServiceto read from. - Why it's built this way: never calling
context.Fail()(onlycontext.Succeed) is the ASP.NET Core convention that lets multiple handlers vote independently: this handler abstains rather than vetoes when it cannot grant. Reading three role claim types defensively decouples the check from the host's token-mapping configuration. - Where it's used: registered as an
IAuthorizationHandlersingleton byAuthorizationExtensions.AddAuthorizationPolicies(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:31-32); invoked by the authorization middleware for every policy that carries aPermissionRequirement.
PermissionPolicyProvider
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:13· Level 1 · class (sealed)
- What it is: an
IAuthorizationPolicyProviderthat lazily builds anAuthorizationPolicyfor any policy name starting with thePermissionPolicyprefix, attaching aPermissionRequirementfor the encoded permission, and delegates every other policy name to the default provider. - Depends on:
PermissionPolicy(the prefix),PermissionRequirement;Microsoft.AspNetCore.Authorization,Microsoft.Extensions.Options. - Concept introduced, on-demand policy materialization.
[Rubric §11, Security]and[Rubric §15, Best Practices & Code Quality](assesses whether the design scales without repetitive registration; a system with an open-ended set of permissions cannot pre-register a named policy for each, so the policy is built from its own name at resolution time). The doc comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:6-12) explains the design:"perm:*"names are materialized here, and every other name falls through to the default provider, so a policy the application registers itself is still resolved the usual way. - Walkthrough: the constructor
(
MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:19-20) wraps aDefaultAuthorizationPolicyProviderbuilt from the ambientAuthorizationOptionsand keeps it in_fallbackPolicyProvider(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:15).GetDefaultPolicyAsyncandGetFallbackPolicyAsync(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:23-28) delegate straight to that fallback.GetPolicyAsync(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:31) is the interesting one: it rejects a null or blank name outright (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:33), and if the name does not start withPermissionPolicy.Prefixit defers to the fallback (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:35-38); otherwise it slices the prefix off with the range expressionpolicyName[PermissionPolicy.Prefix.Length..](MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:40) and builds a policy that requires an authenticated user plus a freshPermissionRequirement(permission)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:41-44). Note theRequireAuthenticatedUser()atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:42: it makes anonymous requests fail with a challenge before the handler ever runs, which is why the handler can treat "not authenticated" as a plain abstain. - Why it's built this way: composing over
DefaultAuthorizationPolicyProviderrather than reimplementing it means all pre-registered policies survive; only theperm:namespace is intercepted. That is what letsHasPermissionAttributework for any permission string without a registration step. - Where it's used: installed (via
Replace) as the singleIAuthorizationPolicyProviderbyAuthorizationExtensions.AddAuthorizationPolicies(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:33-34). - Caveats / not-in-source: a policy object is built on every
GetPolicyAsynccall for aperm:name; no cache is present in this type, and whether ASP.NET Core caches the result upstream is not determinable from this source file.
AuthorizationExtensions
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:12· Level 3 · class (static, extension block)
- What it is: the DI wiring for the whole authorization model, in two calls: one that turns on ASP.NET Core authorization and installs the permission mechanism (handler, on-demand provider, registry), and one each module uses to declare its role-to-permission grants.
- Depends on:
PermissionAuthorizationHandler,PermissionPolicyProvider,IPermissionRegistryandPermissionRegistryBuilder;Microsoft.Extensions.DependencyInjectionplus itsExtensionsnamespace forTryAddEnumerable. - Concept introduced,
extension(T)DI members and lazy registry accumulation.[Rubric §6, CQRS & Event-Driven Design](assesses whether a concern is configured once for every host rather than re-wired per application) and[Rubric §7, Microservices Readiness](assesses per-module self-sufficiency: each module contributes only the permissions it owns, so an extracted module carries its own grants). Theextension(IServiceCollection services)block (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:14) is the C#extension(T)DI idiom taught in the primer: it addsAddAuthorizationPoliciesandAddPermissionsdirectly ontoIServiceCollection. The doc comment states the model plainly (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:16-21): permissions are the authorization model here, an endpoint states the capability it needs and the registry maps roles to capabilities, so no policy name has to be pre-registered per role. - Walkthrough
AddAuthorizationPolicies()(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:23): callsservices.AddAuthorization()(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:25) to bring in the framework's authorization services, then wires the permission mechanism:TryAddEnumerableaddsPermissionAuthorizationHandleras a singletonIAuthorizationHandler(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:31-32),ReplaceinstallsPermissionPolicyProvideras the transientIAuthorizationPolicyProvider(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:33-34), andEnsurePermissionRegistry(services)guarantees a registry exists (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:35). The inline comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:27-30) records why the mechanism ships here: every host that wires authentication gets it for free, and consumers only have to declare their grants.AddPermissions(Action<PermissionRegistryBuilder> configure)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:47): the per-module entry point for declaring grants. It guards the callback (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:49), fetches the shared builder viaEnsurePermissionRegistry, and invokesconfigure(builder)so the module's grants accumulate (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:51-52). The doc comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:40-46) notes it is safe to call once per module because grants union into a single registry; the union itself happens inPermissionRegistryBuilder.Grant(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistryBuilder.cs:32-39).EnsurePermissionRegistry(IServiceCollection)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:61): the idempotent core. If aPermissionRegistryBuilderis already registered as a singleton instance it returns that existing one (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:63-67); otherwise it creates one, registers it, and registersIPermissionRegistryas a singleton whose factory callsbuilder.Build()(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:69-71). Because the registry is built on first resolve, every module'sAddPermissionscall has already contributed by the time any request evaluates a permission, which is the point the comment atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:58-60makes.
- Why it's built this way:
TryAddEnumerablelets the permission handler coexist with any other authorization handlers a host registers;Replaceguarantees exactly one policy provider (the permission-aware one); and the lazybuilder.Build()factory is what makes module registration order irrelevant, since all grants are collected before the firstBuild(). - Where it's used:
AddAuthorizationPolicies()is called at the end of both framework authentication-wiring helpers, so a host that wires authentication through either gets the authorization model without an explicit call:AddForwardedJwtBearerCore(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:471, the call atWebApplicationBuilderExtensions.cs:522) andAddCommonAuthentication(WebApplicationBuilderExtensions.cs:539, the call atWebApplicationBuilderExtensions.cs:573).AddPermissions(...)is called by each module that owns permissions: in MMCA.ADC by Conference (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:41), Engagement (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:58), Identity (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:44) and Notification (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:38), and in MMCA.Store by Catalog (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/DependencyInjection.cs:41), Sales (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/DependencyInjection.cs:40) and Identity (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/DependencyInjection.cs:42). - Caveats / not-in-source: a host that never calls either method has no
IPermissionRegistryregistered at all. That case is covered elsewhere in this group byUnconfiguredPermissionRegistry, whose diagnostic message names these two methods as the fix (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/UnconfiguredPermissionRegistry.cs:59).
OwnershipHelper
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:10· Level 9 · class (static)
- What it is: static helpers a controller calls to scope a query to the current user's own data,
returning a specification that filters by owner id, or
nullwhen the caller holds the privileged bypass role and should see everything. - Depends on:
ICurrentUserService(Application layer, imported atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:1). The specification it hands back is typically aSpecification<TEntity, TIdentifierType>, though the helper itself never says so (see caveats). - Concept introduced, ownership scoping at the query level, as distinct from the filter's gate.
[Rubric §11, Security](assesses row-level data isolation: a non-admin caller can only read their own rows) and[Rubric §1, SOLID](assesses separation of responsibility: the helper produces a specification, the repository applies it). WhereOwnerOrAdminFilterblocks a request that names someone else's id, this helper narrows the result set so a list endpoint returns only the caller's rows without them passing any id at all. ADR-033 calls these the two enforcement points of one ownership axis: reject-one for single-resource routes, filter-many for collection routes. - Walkthrough
IsAdmin(ICurrentUserService, string bypassRole = "Admin")(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:17): a case-insensitive compare of the current user'sRoleagainst the bypass role (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:20). The default argument is"Admin", and hosts with a different vocabulary pass their own (MMCA.ADC passesOrganizer);OwnerOrAdminFilterreuses this same method so the gate and the scoping agree on who bypasses.GetOwnershipSpecification<TSpec, TId>(...)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:34): the general form. It returnsnullfor a bypass-role caller (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:45-48, no scoping needed); otherwise it reads the owner id from the named claim viacurrentUserService.GetClaimValue<TId>(claimType)and, when present, calls the suppliedspecFactory(id.Value)to build the scoping specification (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:50-51). Thewhere TId : struct, IParsable<TId>constraint (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:40) is what lets the claim string be parsed into a strongly-typed id.GetOwnershipSpecification<TSpec>(...)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:63): the convenience overload that fixesTIdtointand the claim to"customer_id"(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:67), matching the default vocabulary.
- Why it's built this way: returning
nullfor bypass-role callers (rather than a "match everything" specification) lets the caller skip filtering entirely on the privileged path; producing a specification rather than running the query keeps the helper in the API layer while the actual filtering runs in the query pipeline. - Where it's used: called from MMCA.Store controller query actions that must isolate a caller's
data.
ShoppingCartsControllerexposes both anIsAdminproperty and a privateGetOwnershipSpecification()over it (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/ShoppingCartsController.cs:64,66-68), andOrdersControllerdoes the same (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:62,65). ItsIsAdminmethod is also the bypass check insideOwnerOrAdminFilter(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:43). - Caveats / not-in-source: two gaps are worth knowing.
TSpecis an open generic with only aclassconstraint (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:39), so the helper does not itself require the returned type to be a specification: that contract is the caller's. And a non-admin caller whose claim is missing or unparseable also getsnull(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:51), which means no scoping, so callers must not readnullas "admin". Store closes that in the controller with aRequireResolvableOwner()gate that forbids the second case explicitly (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/ShoppingCartsController.cs:80-90); the helper itself does not distinguish them.
OwnerOrAdminFilter
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:31· Level 10 · class (sealed action filter)
What it is: an MVC async action filter that lets a request proceed only if the caller holds the bypass role or owns the resource named by the request, returning 403 Forbidden otherwise.
Depends on:
OwnershipHelper(for theIsAdmincheck),OwnerOrAdminFilterOptions(the vocabulary),AllowMissingOwnerAttribute(the opt-out it honors),ICurrentUserService(claims);Microsoft.AspNetCore.Mvc.Filters,Microsoft.Extensions.Options,System.Globalization.Concept introduced, per-request ownership enforcement as a filter, and deny-by-default.
[Rubric §11, Security](assesses whether a resource-level access gate runs before the action body and whether it fails closed) and[Rubric §17, DevOps & Deployment](assesses whether the rule is expressed once and attached, rather than re-coded in each action). This is the gate counterpart toOwnershipHelper's query scoping: the helper narrows a list, this filter blocks an attempt to read or mutate a specific id the caller does not own (ADR-033, cited atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:15). The deny-by-default half is the more important lesson, and the class doc comment spells it out (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:16-23): a gate that treats "nothing to compare" as "nothing to enforce" silently stops guarding every action whose parameter is optional, non-integer, or carried inside a bound model.Walkthrough:
OnActionExecutionAsync(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:36) null-guards its arguments and reads the currentsettings = options.Value(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:38-41), then walks four decisions in order:- Bypass role: if
OwnershipHelper.IsAdmin(currentUserService, settings.BypassRole)it callsnext()and returns (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:43-47). - Missing owner claim: it reads the caller's owner id with
currentUserService.GetClaimValue<int>(settings.OwnerClaimType)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:49) and short-circuits toForbidResultwhen the claim is absent (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:51-55). - Unresolvable owner parameter: if
TryGetOwnerParametercannot produce an int, the request is denied unless the endpoint carriesAllowMissingOwnerAttribute, in which case it falls through tonext()(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:57-71). Note the ordering: the opt-out excuses only a missing parameter, and it is checked after the claim check, so an[AllowMissingOwner]action still requires a valid owner claim. - Mismatch: a resolved parameter that does not equal the claim value yields
ForbidResult(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:73-77); only an exact match reachesawait next()(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:79).
Two private helpers back that flow.
HasAllowMissingOwner(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:84-85) readscontext.ActionDescriptor.EndpointMetadata.OfType<AllowMissingOwnerAttribute>(), which is how one lookup covers the attribute whether it sits on the action or on its declaring controller: MVC has already composed both into the metadata (comment atOwnerOrAdminFilter.cs:82-83).TryGetOwnerParameter(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:93-111) resolves the id from the route values first (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:95-100) and, failing that, from the model-bound action arguments (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:102-107), reporting failure by settingvalue = 0and returningfalse(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:109-110). Both parses passNumberStyles.IntegerandCultureInfo.InvariantCultureexplicitly (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:97,104); the comment above the method states the convention (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:90-92): an owner id off the wire is not a number the request's ambient culture formatted, so the host'sCurrentCulturemust not decide which strings are ids.- Bypass role: if
Why it's built this way: denying on an unresolvable parameter, with an explicit attribute as the only escape, converts a silent failure mode into a visible one: the action either compares an owner id or documents the guard that replaces the comparison (ADR-033's deny-by-default decision and the audit table that came with it). Reading the vocabulary from injected options keeps a single filter reusable across hosts, and checking the route before the bound arguments means a conventional
/{id}route costs one dictionary lookup.Where it's used: registered scoped by
AddAPI(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:78, next toIdempotencyFilteratDependencyInjection.cs:77, because both depend on scoped services) and applied as[ServiceFilter(typeof(OwnerOrAdminFilter))], per the remarks atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:25-30. MMCA.Store applies it at controller level onShoppingCartsController(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/ShoppingCartsController.cs:49) andCustomersController(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/CustomersController.cs:34), which covers every action on those controllers, so adoption there was an audit of the whole controller and the actions with no owner parameter carryAllowMissingOwnerAttribute. MMCA.ADC instead applies it per action, on twoBookmarksControllerendpoints (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:85,106), against the Engagement vocabulary configured atMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:45-56.Caveats / not-in-source: the owner id is parsed as
intonly (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:97,104), and the claim is read asGetClaimValue<int>(OwnerOrAdminFilter.cs:49), so a host whose owner id is aGuidor a string cannot use this filter as-is. It also assumes the owner parameter is the owning id, which holds for a cart or a customer profile but not for a resource with its own id and a foreign-key owner (ADR-033 lists orders as that case, handled with a specification or an explicit per-id check instead).
SessionCookieRequest
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:72· Level 0 · record
- What it is: the inbound body for
POST /auth/session-cookie: the access and refresh token strings the browser hands back to the server so they can be re-issued as HttpOnly cookies. - Depends on: nothing first-party. It is a two-string
public sealed recordnested insideSessionCookieEndpoints. - Concept introduced, the browser cannot set an HttpOnly cookie from JS. [Rubric §11, Security] and [Rubric §26, Front-End Security] both assess XSS-resistant token storage. After the SPA logs in against the API it holds the token pair in memory; to persist that pair as HttpOnly cookies (unreadable by script, so an injected payload cannot exfiltrate them) it POSTs the tokens once to this same-origin endpoint, which writes the cookies server-side. This is the seeding half of ADR-022's browser session-cookie scheme.
- Walkthrough: the whole type is one line,
public sealed record SessionCookieRequest(string AccessToken, string RefreshToken)(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:72). It is nested in the endpoint class it serves, so the contract sits next to its only route. - Where it's used: bound by the
POSThandler atSessionCookieEndpoints.cs:29, which passes both strings straight toSessionCookieJar(:31).
SessionTokenResponse
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:21· Level 0 · record
- What it is: the JSON body returned by
POST /auth/session/token: the access token and its UTC expiry, and nothing else. - Depends on: BCL only. Produced by
SessionCookieEndpointsfrom aSessionTokenResult. - Concept introduced, the refresh token never crosses the wire to the browser. [Rubric §9, API &
Contract Design] assesses whether a response exposes only what its client needs. This record carries
the access token, which the SPA holds in memory for its Bearer calls, and deliberately omits the
refresh token, which stays exclusively in the HttpOnly cookie. The doc comment states the rule
outright
(
MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:17-20). - Walkthrough:
public sealed record SessionTokenResponse(string AccessToken, DateTime AccessTokenExpiry)(:20). It is the serialized projection of the internalSessionTokenResult, which is why the two types carry the same two members and different visibility of intent. - Where it's used: constructed and returned by the
/auth/session/tokenhandler (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:56).
SessionTokenResult
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:15· Level 0 · record struct
- What it is: the internal carrier for a validated access token plus its UTC expiry, returned by
the refresher. A
readonly record struct, so the validate path allocates nothing to report success. - Depends on: BCL only. Returned by
ICookieSessionRefresherand projected onto the wire-facingSessionTokenResponse. - Concept: the value-type twin of
SessionTokenResponse. Same two members, but this one stays server-side and is used asSessionTokenResult?at every call site, so "no valid session" is expressed by the absence of a value rather than by a sentinel string or a thrown exception. [Rubric §12, Performance & Scalability] assesses avoidable allocation; areadonly record structis the light choice for a result produced on every qualifying navigation. - Walkthrough:
public readonly record struct SessionTokenResult(string AccessToken, DateTime AccessTokenExpiry)(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:15), with the one-line summary at:13naming its provenance ("acquired from the session cookies"). - Where it's used: the return type of
ICookieSessionRefresher.GetOrRefreshAsync(:36); constructed byCookieSessionRefresherat:71(cookie still valid) and:92(after a rotation); unwrapped bySessionCookieEndpointsatSessionCookieEndpoints.cs:56.
ICookieSessionRefresher
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:30· Level 1 · interface
- What it is: the "validate-or-refresh over the HttpOnly session cookies" port. One method returns
a currently-valid access token for the request, rotating from the refresh cookie when the access
cookie has expired, or
nullwhen there is no valid session. - Depends on:
HttpContext(ASP.NET Core) andSessionTokenResult. Its only implementation isCookieSessionRefresher. - Concept introduced, server-side refresh that browser script never sees. [Rubric §11, Security]
assesses where the long-lived credential lives. The type comment
(
MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:23-29) is the contract in prose: if the access cookie's JWT is still valid it is returned as-is; otherwise the refresh cookie is exchanged at the API'sauth/refreshendpoint server-to-server, so the refresh token never reaches browser JS; the rotated pair is written back as HttpOnly cookies; and the fresh access token is stashed onHttpContext.Itemsso the current request's SSR authentication can read it before theSet-Cookietakes effect on the next request. - Walkthrough:
Task<SessionTokenResult?> GetOrRefreshAsync(HttpContext context, CancellationToken cancellationToken = default)(:36). The nullable return is the whole vocabulary: a value means "here is a good access token",nullmeans "no session, treat this caller as anonymous". The doc comment at:31-35flags that setting fresh cookies is a side effect of the call. - Why it's built this way: one interface lets the SSR middleware and the
/auth/session/tokenendpoint share a single refresh path, so exactly one type decides validity and exactly one type rotates (ADR-022). It is also what makes both callers trivially testable against a mock. - Where it's used: injected into
CookieSessionRefreshMiddleware(which runs before authentication on navigations) and resolved by the/auth/session/tokenhandler (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:46). Registered as a singleton atMMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:172.
CookieSessionRefreshMiddleware
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefreshMiddleware.cs:13· Level 2 · class
- What it is: an ASP.NET Core middleware that runs before
UseAuthenticationon full-page navigations and, when the access cookie's JWT has expired but the refresh cookie is still good, refreshes server-side so SSR[Authorize]survives instead of bouncing to/login. - Depends on:
RequestDelegateandICookieSessionRefresher, both primary-constructor parameters (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefreshMiddleware.cs:13). Registered throughCookieSessionRefreshMiddlewareExtensions. - Concept introduced, refresh before authenticate for prerender. [Rubric §11, Security] and
[Rubric §18, UI Architecture] meet here. A Blazor Web App prerenders
[Authorize]pages on a cold GET (new tab, F5, external deep link), and authentication reads the cookie before any interactive code runs. If the access cookie has just expired, plain authentication fails and the user is redirected even though a perfectly good refresh token is sitting in the next cookie over. This middleware inserts a refresh attempt first, so the token the refresher stashes onHttpContext.Itemsis what authentication then reads. - Walkthrough:
InvokeAsync(:16-26) null-checks the context (:18), asksShouldAttempt(:20), and on a match awaitsrefresher.GetOrRefreshAsync(context, context.RequestAborted)(:22) before invoking the rest of the pipeline (:25). The refresh is a side effect only: the return value is discarded and the pipeline always continues, leaving the actual authentication decision to the downstream scheme.ShouldAttempt(:28-31) gates strictly toGETrequests whoseAcceptheader containstext/html, so it never fires on static assets, API calls, or XHR. - Why it's built this way: the narrow gate keeps a cookie read and a possible outbound HTTP call off every static-asset request, and delegating single-flight to the refresher means the middleware itself cannot double-rotate a token (ADR-022).
- Where it's used: registered on both Blazor Server hosts immediately before
UseAuthentication(),MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:141(withUseAuthentication()on the very next statement at:140) andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:178(:180). Its gating rules are pinned one test per branch inMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefreshMiddlewareTests.cs: an HTML navigation refreshes (:19), a browser-style multi-valueAcceptlist still matches (:31), a non-HTMLAccept(:45), a missingAccept(:60) and aPOST(:74) all skip, and anullrefresh result still callsnext(:90). - Caveats / not-in-source: the ordering rule (before
UseAuthentication) is enforced by the host that calls the extension, not by this class. Getting it wrong silently disables the SSR refresh rather than failing loudly.
SessionCookieEndpoints
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:15· Level 2 · class
- What it is: the minimal-API mapper for the three session-cookie routes:
POSTandDELETE /auth/session-cookie(seed and clear the HttpOnly cookies at login and logout) andPOST /auth/session/token(the same-origin validate-or-refresh the browser calls to hydrate its in-memory access token). It also owns the two cookie-name constants and the cross-site guard. - Depends on:
SessionCookieJar,ICookieSessionRefresher,SessionCookieRequest,SessionTokenResponse, and ASP.NET Core routing plusResults. The cookies it writes are read back byCookieTokenReader. - Concept introduced, the cookie names are the shared contract.
AccessTokenCookieName = "mmca_auth_access"andRefreshTokenCookieName = "mmca_auth_refresh"(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:17-18) arepublic const, and every other type in this feature (the jar, the reader, the refresher) references them instead of a string literal, so the names have exactly one definition. [Rubric §9, API & Contract Design] is the other lens: three tightly-scoped routes, all excluded from OpenAPI (ExcludeFromDescriptionat:27and:58) because they are browser plumbing, not public API surface. - Walkthrough: the mapping method lives inside an
extension(IEndpointRouteBuilder endpoints)block (:20), the C# extension-member syntax this codebase uses for fluent registration (primer §2), so hosts callapp.MapSessionCookieEndpoints(). InsideMapSessionCookieEndpoints(:22-63): a route group for/auth/session-cookieis created and excluded from description (:26-27); thePOST(:29-33) binds aSessionCookieRequest, callsSessionCookieJar.Appendand returns204; theDELETE(:35-39) callsSessionCookieJar.Deleteand returns204; bothDisableAntiforgery()because there is no antiforgery token cookie to validate on these calls. The/auth/session/tokenPOST(:45-60) first rejects an obvious cross-site request with403(:48-51), then awaitsrefresher.GetOrRefreshAsync(:53); anullresult becomes a401JSON body{ error = "no_session" }(:55), otherwise aSessionTokenResponseis serialized (:56). That route isAllowAnonymous()(:59) because it authenticates via the cookies themselves. The privateIsCrossSite(:68-70) inspects theSec-Fetch-Siterequest header and treats a missing header as allowed, which the comment at:67attributes to older browsers. - Why it's built this way: CSRF is defended in depth rather than by antiforgery tokens. The comment
at
:66-67spells it out:POST-only,SameSite=Laxon the cookies (which already blocks cross-site cookie attachment), and theSec-Fetch-Sitecheck together stop a cross-site page from driving these endpoints, which is what makes disabling antiforgery safe here (ADR-022). [Rubric §11, Security]. - Where it's used: mapped by both Blazor Server hosts,
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:160andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:194. The routes are exercised end to end byMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/SessionCookieEndpointsTests.cs:125, whoseCreateHostAsyncbuilds a real pipeline around the mapper; the cases that matter most are the cross-site403(:60), the no-session401(:91), and the assertion that a valid session returns the access token but never the refresh token (:104).
SessionCookieJar
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieJar.cs:11· Level 2 · class
- What it is: the one internal static helper that writes and clears the two HttpOnly auth cookies, so the endpoints, the server-side refresher, and the SSR middleware all emit identical cookie options.
- Depends on:
CookieOptions,HttpContextandIWebHostEnvironment(ASP.NET Core) plus the cookie-name constants onSessionCookieEndpoints. - Concept introduced, one place to build cookie options. [Rubric §11, Security] assesses cookie
hardening. Centralizing
BuildOptions(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieJar.cs:31-38) means every write isHttpOnly = true(:33),Secureoutside Development (:34,!environment.IsDevelopment(), sohttp://localhostdev still works while every deployed environment forces HTTPS),SameSite = SameSiteMode.Lax(:35), andPath = "/"(:36). Drift between the seed, refresh, and clear paths is structurally impossible because all three call this method. The conditionalSecureis the one thing an analyzer objects to, and the suppression carries its justification inline (:30and:39bracket a scoped#pragma warning disable S2092), which is the house style for an accepted deviation. - Walkthrough:
Lifetime = TimeSpan.FromDays(7)(:14) is aligned to the refresh-token lifetime by the comment at:13, so a cookie never outlives the credential it carries.Append(:16-21) builds options once and writes both cookies with that 7-dayMaxAge.Delete(:23-28) rebuilds the options withTimeSpan.Zero, which:37turns into anullMaxAge, and callsCookies.Deletefor both names. - Why it's built this way: a delete must send back the same
Path,SameSiteandSecureattributes as the original write or the browser will not match the cookie and will not clear it. SharingBuildOptionsbetweenAppendandDeleteguarantees that (ADR-022). - Where it's used:
SessionCookieEndpoints(seed atSessionCookieEndpoints.cs:31, clear at:37) andCookieSessionRefresher(rewrite after rotation,CookieSessionRefresher.cs:88). The attributes it emits are asserted directly byMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/SessionCookieJarTests.cs.
CookieSessionRefreshMiddlewareExtensions
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefreshMiddleware.cs:35· Level 3 · class
- What it is: a one-method registration helper (
UseCookieSessionRefresh) that addsCookieSessionRefreshMiddlewareto the request pipeline. - Depends on:
IApplicationBuilder(ASP.NET Core) andCookieSessionRefreshMiddleware. - Concept: the standard
UseXxx()middleware-registration idiom, written with the codebase'sextension(IApplicationBuilder app)member syntax (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefreshMiddleware.cs:37); see primer §2. Nothing new beyond putting a name and a doc comment onUseMiddleware<T>. - Walkthrough:
UseCookieSessionRefresh()(:43-47) null-guards the builder (:45) and returnsapp.UseMiddleware<CookieSessionRefreshMiddleware>()(:46). The XML comment (:39-42) states the load-bearing rule in bold: register it immediately beforeUseAuthentication()on the Blazor Server (UI.Web) host. - Where it's used: the two Blazor Server hosts
(
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:141,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:178). Both the null guard and the pipeline wiring are covered directly atMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefreshMiddlewareTests.cs:115and:123.
CookieTokenReader
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieTokenReader.cs:10· Level 3 · class
- What it is: the read side of the cookie feature. It pulls the access JWT and the refresh token
out of the request cookies (or out of the freshly-refreshed token stashed on
HttpContext.Items) for server-side token storage during SSR prerender, when JS interop and thereforelocalStorageare unreachable. - Depends on:
IHttpContextAccessor(primary constructor,MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieTokenReader.cs:10) and the cookie-name constants onSessionCookieEndpoints. Consumed bySessionCookieAuthenticationHandlerand byServerTokenStorageService. - Concept introduced, the fresh-token handoff. [Rubric §29, Resilience, Reliability & Business Continuity] covers
request-scoped state. The
internal const string FreshAccessTokenItemKey = "mmca.fresh-access-token"(:17) is the agreedHttpContext.Itemskey under whichCookieSessionRefresherparks a just-rotated access token, as the comment at:12-16explains.ReadAccessTokenchecks that key first, so on the very request that triggered a refresh, SSR authentication uses the new token instead of the still-expired one sitting in the request cookie; theSet-Cookiefrom the rotation only affects the next request. - Walkthrough:
ReadAccessToken(:19-34) returnsnullwhen there is noHttpContext(:22-25), then prefers a non-emptystringunderFreshAccessTokenItemKey(:27-31), and otherwise falls back to the access cookie (:33). Thefresh is string freshTokenpattern plus the whitespace check mean a wrong-typed or blank item silently falls through to the cookie rather than poisoning the request.ReadRefreshToken(:36-37) reads the refresh cookie directly with a null-conditional accessor and has no fresh-item fallback, because only the access token is ever swapped mid-request. - Why it's built this way: the Items-first precedence is exactly what makes the middleware's server-side refresh take effect on the request that triggered it instead of only on the next one (ADR-022).
- Where it's used: injected into
SessionCookieAuthenticationHandler(SessionCookieAuthenticationHandler.cs:28) and into the UI host's server-side token store (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:20). Registered scoped byAddServerAuthSessionCookie(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:166), and covered byMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieTokenReaderTests.cs.
CookieSessionRefresher
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:52· Level 4 · class
- What it is: the singleton implementation of
ICookieSessionRefresher. It validates the access cookie's JWT locally and, when that fails, exchanges the refresh cookie at the API'sauth/refreshendpoint server-to-server, writes the rotated pair back as cookies, and single-flights concurrent refreshes so a burst of requests rotates the token only once. - Depends on:
IHttpClientFactory,IMemoryCache,IWebHostEnvironmentandILogger<CookieSessionRefresher>(primary constructor,MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:52-56);KeyedSemaphoreStripe(:62);SessionCookieJar;CookieTokenReaderfor the Items key;SessionCookieEndpointsfor the cookie names; and theAuthenticationResponse/RefreshTokenRequestcontracts fromMMCA.Common.Shared.Auth. It reads token expiry withSystem.IdentityModel.Tokens.Jwt. - Concept introduced, single-flight refresh under a thundering herd. [Rubric §12, Performance &
Scalability] assesses behavior under concurrent load. When an access token expires, many queued
navigations can arrive at once; rotating for each would burn the refresh token repeatedly and log the
user out. The type comment (
:39-50) states the design: the lock is a stripedKeyedSemaphoreStripekeyed by refresh token rather than one process-wide semaphore, because the lock is held across an outbound HTTP call and a single semaphore would serialize every unrelated user's cold navigation behind whichever refresh happened to be in flight. Two unrelated tokens can still land on the same one of the stripe's 256 lanes (MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:25), which the comment calls out as harmless precisely because the rotation-grace cache is re-checked per token after acquiring. Alongside the lock, a 10-secondRotationGrace(:60) caches the rotated pair keyed by the OLD refresh token (:144), so a slightly-late sibling carrying the same expired pair gets the same result instead of rotating again. - Walkthrough:
GetOrRefreshAsync(:64-93) reads the access cookie (:68) and, ifTryReadValidExpirypasses, returns it untouched (:69-72). Otherwise it reads the refresh cookie and returnsnullwhen there is none (:74-78). It callsRefreshAsync(:80), treats a missing or blank access token as failure (:81-84), writes the rotated pair withSessionCookieJar.Append(:87), stashes the fresh access token oncontext.Items[CookieTokenReader.FreshAccessTokenItemKey](:91, with the reason spelled out at:89-90), and returns the newSessionTokenResult(:92).RefreshAsync(:95-111) is textbook double-checked locking: a cache hit returns immediately (:97-100), otherwise it acquires the stripe for this token (:102) and re-checks the cache before doing any work (:105-108).CallRefreshAsync(:113-152) creates the named client (:115), POSTs aRefreshTokenRequestto the relativeauth/refreshURI withCancellationToken.None(:127-130) so that once the lock is held the rotation completes and writes its cookies even if the triggering request was aborted (the reason is at:125-126), bails on a non-success status (:132-135) or an empty access token (:138-141), and caches theAuthenticationResponseunder the old refresh token forRotationGrace(:144).TryReadValidExpiry(:154-183) rejects a blank token, refuses anythingJwtSecurityTokenHandlercannot read (:162-166), treats the token as expired whenjwt.ValidTo <= DateTime.UtcNow + ClockSkew(:171, withClockSkewa 30-second margin at:59), and swallows onlyArgumentException/FormatException(:179-182).CacheKey(:189) builds themmca:session-refresh:{refreshToken}string that is both the cache key and the striping key; the comment at:185-188explains it isinternalrather thanprivateso a concurrency test can pick two refresh tokens that do not collide on a stripe, which is a nice example of a testability affordance that costs nothing at runtime. [Rubric §14, Testability]. - Concept, an SSR-safe failure mode. [Rubric §29, Resilience & Business Continuity] and [Rubric
§13, Observability & Operability] apply to the outbound call.
CallRefreshAsyncwraps the POST in atrywhose filter narrows toHttpRequestException,OperationCanceledException,JsonExceptionandNotSupportedException(:147), logs one warning through the source-generatedLogRefreshCallFailed(:149, declared with[LoggerMessage]at:191-192, which is why the class ispartialat:51), and returnsnull. The comment at:117-122gives the reasoning: this code runs during SSR, so an escaping exception would turn a signed-in user's navigation into a500instead of an anonymous render. The failure is deliberately not cached (only a successful rotation reachescache.Setat:144), so the next navigation retries, and a missingBaseAddressraisesInvalidOperationExceptionand is left to propagate because that is a host misconfiguration rather than a runtime condition. - Why it's built this way: keying the grace cache by the OLD token is what lets a slightly-late sibling find the already-rotated pair, and striping the lock keeps one user's slow refresh from blocking everyone else's cold navigation. The server-to-server call is what keeps the refresh token off browser JS (ADR-022). [Rubric §11, Security].
- Where it's used: resolved as
ICookieSessionRefresherbyCookieSessionRefreshMiddlewareand by the/auth/session/tokenendpoint. Its namedHttpClient,RefreshClientName = "SessionCookieRefreshClient"(:57), is configured with the API base address inAddServerAuthSessionCookie(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:168-169), which also registers the refresher as a singleton (:171-172) with an inline note that a shared instance across requests is what makes single-flight work at all. The validate, rotate, grace-cache and failure paths are covered byMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefresherTests.cs.
SessionCookieAuthenticationHandler
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieAuthenticationHandler.cs:24· Level 4 · class
- What it is: an ASP.NET Core
AuthenticationHandlerthat reads the JWT out of the session cookie, parses its claims, and populatesHttpContext.Userduring SSR prerender, so both Blazor's internal SSR authorization and endpoint-level[Authorize]pass on a fresh GET before the interactive phase starts. - Depends on:
AuthenticationHandler<AuthenticationSchemeOptions>and its three framework constructor arguments (IOptionsMonitor,ILoggerFactory,UrlEncoder) plusCookieTokenReader(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieAuthenticationHandler.cs:24-29), andSystem.IdentityModel.Tokens.Jwt. Registered bySessionCookieAuthenticationExtensions. - Concept introduced, a deliberately non-validating scheme. [Rubric §11, Security] assesses where
the trust decision is actually made. The
<remarks>block (:18-23) is load-bearing: this handler does not validate the JWT signature. The cookie was minted by the UI host in response to a successful login against the API, and every real API call still performs full JWT validation, so the handler exists only to extract claims for ASP.NET Core's auth system during prerender. That is what lets a deep-linked[Authorize]page render instead of flashing a redirect. Read it next to ADR-004: the API remains the single validation authority via JWKS, and the UI host deliberately does not duplicate it. - Walkthrough:
SchemeName = "SessionCookie"(:32) is the canonical scheme name, exposed as apublic constso registration never repeats a literal.HandleAuthenticateAsync(:35-69) reads the token throughCookieTokenReader(:37) and returnsAuthenticateResult.NoResult()when there is none (:38-41), which lets other schemes have their turn rather than hard-failing the request. It then rejects anything that is not a readable JWT (:46-49), and fails whenjwt.ValidTois in the past according to the base handler's injectableTimeProvider(:55-58, with the rationale at:53-54). On success it builds aClaimsIdentityfrom the JWT claims withClaimTypes.NameIdentifierandClaimTypes.Roleas the name and role claim types (:60), wraps it in aClaimsPrincipaland anAuthenticationTicketstamped with the scheme name (:61-63), and returnsSuccess. Malformed-token exceptions are narrowed toArgumentExceptionandFormatExceptionand returned asFail(:65-68).HandleChallengeAsync(:72-77) redirects to/login?returnUrl=...with the original path and query URL-escaped (:74-75), andHandleForbiddenAsync(:80-84) sets a bare403. - Why it's built this way: validating the signature here would duplicate the API's JWKS validation
and couple the UI host to the signing key. Extracting claims only, while the API stays the single
authority, keeps the trust boundary in one place
(ADR-004 and
ADR-022). Reading the
clock through the base handler's
TimeProviderrather thanDateTime.UtcNowkeeps the expiry check on the same injectable clock as the rest of the auth stack and its tests. [Rubric §14, Testability]. - Where it's used: registered as the
SessionCookiescheme on both Blazor Server hosts,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:73-74andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:111-112. Covered directly byMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/SessionCookieAuthenticationHandlerTests.cs, including the fresh-token-from-Items path (:95, which stashes the token underCookieTokenReader.FreshAccessTokenItemKeyat:102and asserts it wins over an expired cookie) and the proof that expiry is judged by the handler'sTimeProviderrather than the system clock (:112).
SessionCookieAuthenticationExtensions
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieAuthenticationHandler.cs:90· Level 5 · class
- What it is: the registration helper for
SessionCookieAuthenticationHandler: a singleAddSessionCookieAuthentication()that wires the scheme into anAuthenticationBuilder. - Depends on:
AuthenticationBuilder(ASP.NET Core) andSessionCookieAuthenticationHandler. - Concept: the same
extension(T)DI-registration idiom introduced by the other extension helpers in this group. The method is declared inside anextension(AuthenticationBuilder builder)block (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieAuthenticationHandler.cs:92) and reads at the call site as an instance method on the builder; see primer §2. - Walkthrough:
AddSessionCookieAuthentication()(:98-100) is a single expression:builder.AddScheme<AuthenticationSchemeOptions, SessionCookieAuthenticationHandler>( SessionCookieAuthenticationHandler.SchemeName, displayName: null, configureOptions: null). The scheme name comes from the handler's own constant rather than a duplicated literal, and the two explicitnullarguments are named, so the call site says what it is skipping. The doc comment (:94-97) directs callers to use it afterAddAuthentication(SessionCookieAuthenticationHandler.SchemeName). - Where it's used:
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:74andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:112, chained onto the host'sAddAuthentication(SessionCookieAuthenticationHandler.SchemeName)call on the preceding line.
IPasswordHasher
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/IPasswordHasher.cs:6· Level 0 · interface
- What it is: the password-security port. Two methods: hash a plaintext password into a separated
(byte[] Hash, byte[] Salt)pair, and verify a plaintext against a stored hash plus salt. - Depends on: nothing first-party, BCL only (
byte[]). Its Infrastructure adapter is PasswordHasher. - Concept introduced: hash and salt kept apart.
[Rubric §11, Security]assesses credential handling. Returning the hash and the salt as two distinctbyte[]members (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/IPasswordHasher.cs:11) rather than one concatenated blob keeps the storage contract explicit: the caller persists two columns, andVerifyPassword(:18) is unambiguous about what it re-derives and compares. Because the algorithm and its parameters live entirely behind this interface, they can be strengthened without touching a single Application handler (ADR-032 sets the current hashing policy, applied inside PasswordHasher). - Walkthrough:
(byte[] Hash, byte[] Salt) HashPassword(string password)(:11) returns a named value tuple the caller stores as two fields.bool VerifyPassword(string password, byte[] hash, byte[] salt)(:18) re-derives from the supplied salt and compares. The interface declares no iteration count, algorithm identifier, or format version: every one of those is the concrete's business. - Why it's built this way: a two-method port is the
[Rubric §1, SOLID]dependency-inversion story in miniature. Swapping the key-derivation function or raising the iteration count is an Infrastructure change, invisible to the register, login, and change-password use cases that only ever see this contract. - Where it's used: constructor-injected into AuthenticationServiceBase<TUser> (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:56), which callsVerifyPasswordon the login path (:159) andHashPasswordon registration (:210); into the shared ChangePasswordHandlerBase<TUser, TCommand>, which verifies the current password (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:56) before hashing the new one (:61); into ResetPasswordHandlerBase<TUser, TCommand>, which hashes the replacement after the token redeems (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:80); and into the per-app Identity services, handlers and seeders that derive from those, for example ADC's AuthenticationService (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:49), itsChangePasswordHandler(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:20) and its module seeder (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:34, which needs the hasher because seed data carries plaintext credentials).
ISoftDeletedUserValidator
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ISoftDeletedUserValidator.cs:7· Level 0 · interface
- What it is: a single-method port that answers "has this account been soft-deleted?", called after JWT authentication to reject a soft-deleted user who still holds a valid, unexpired token (BR-133, named in the type comment at
MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ISoftDeletedUserValidator.cs:4). - Depends on: BCL plus the solution-wide
UserIdentifierTypealias (:15). See primer §2 for the alias convention and ADR-005 for soft-delete versus erasure. The generic implementation is SoftDeletedUserValidator<TUser>. - Concept introduced: closing the stateless-token window.
[Rubric §11, Security]assesses whether revocation is timely. A JWT is stateless: once signed it stays valid untilexp, even if the account behind it was deleted a minute later. This port lets middleware re-ask the question on every authenticated request and fail the request when the answer is yes, with no per-handler code. The comment at:5states the second motive: the interface is declared in Application and implemented against the app's ownUseraggregate precisely so the middleware never takes a cross-module domain reference. That is the same dependency inversion as the other ports in this group, applied to a cross-module read. - Walkthrough: one member,
Task<bool> IsUserSoftDeletedAsync(UserIdentifierType userId, CancellationToken cancellationToken = default)(:15). One question, one answer, cancellable. - Where it's used: SoftDeletedUserMiddleware resolves it lazily from the request scope (
MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:75callscontext.RequestServices.GetService<ISoftDeletedUserValidator>(), so a host that registers no implementation simply skips the check; the reason is stated at:43) and queries it on a cache miss (:113-115). Both apps register the shared generic against their own user type:MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:35andMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:43, both asTryAddScoped<ISoftDeletedUserValidator, SoftDeletedUserValidator<User>>().
IssuedSession
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:761· Level 0 · record (private sealed, nested)
- What it is: the two-field result of opening or rotating a refresh session: the plaintext refresh token the client is handed, and the id of the session row it belongs to. It is a
private sealed recordnested inside AuthenticationServiceBase<TUser> (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:761), not part of the framework's public surface. - Depends on: nothing beyond the BCL (
string,Guid). It is produced and consumed entirely inside its declaring class. - Concept introduced: the plaintext token exists in exactly one place, and it is a return value.
[Rubric §11, Security]assesses how a bearer credential is stored. RefreshSession rows keep only a digest (RefreshSession.HashToken, used at:349and:593to look up by hash), so once a session is persisted the raw token cannot be recovered from the store at all. The type comment says exactly this (:753-757): the plaintext "exists nowhere else". Modelling the hand-off as a small record rather than an out-parameter or a tuple is what keeps that fact readable: every method that can produce a token returnsResult<IssuedSession>, so the compiler shows you the complete list of places raw token material is in flight.[Rubric §15, Best Practices & Code Quality]also applies: a positional record gives value equality and immutability for free, andGuid SessionIdnames what would otherwise be an anonymous second tuple element. - Walkthrough: one positional declaration,
IssuedSession(string RefreshToken, Guid SessionId)(:758).RefreshTokenis what goes back to the caller in the AuthenticationResponse;SessionIdis what the access token'ssidclaim carries, which is why the session must be created before the token is minted (:481-483). - Why it's built this way: the pairing is load-bearing rather than incidental. A caller that received only the token could not stamp
sid, and a caller that received only the id could not answer the client. Returning both together removes the ordering mistake where a token is minted for a session that does not exist yet. - Where it's used: returned by
OpenSessionAsync(:620, constructed at:645) andRotateAsync(:659, constructed at:698); unwrapped byIssueTokensAsync(:492-493) and byRefreshTokenAsync(:327-328), each of which readsSessionIdto mint the access token andRefreshTokento fill the response.
ITokenService
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ITokenService.cs:8· Level 0 · interface
- What it is: the token-minting port called by the login and refresh use cases. It builds a signed JWT access token from explicit identity facts, generates an opaque refresh token, publishes the two token lifetimes, and recovers the
ClaimsPrincipalfrom an expired-but-validly-signed access token. - Depends on:
System.Security.Claims(BCL,:1) and theUserIdentifierTypealias. Its Infrastructure adapter is TokenService, which signs with the RSA key surfaced by IJwksProvider; SessionStampingTokenService is a second, internal implementation that decorates the first. - Concept introduced: token creation as an Infrastructure detail.
[Rubric §3, Clean Architecture]assesses whether library-specific types stay out of the inner layers: the handlers call this contract and never seeSystem.IdentityModel.Tokens.Jwt.GetPrincipalFromExpiredToken(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ITokenService.cs:48) is the linchpin of the refresh flow: it validates the signature while deliberately ignoring lifetime, so an expired access token can still identify the user whose tokens are being rotated, returningnullwhen the token is invalid (:47). - Walkthrough:
GenerateAccessToken(UserIdentifierType userId, string email, string role, string fullName, IEnumerable<Claim>? additionalClaims = null)(:17-22) takes the minimum claim set as typed parameters rather than a ready-made principal, with an escape hatch for module-specific claims.GenerateRefreshToken()(:26) returns a cryptographically random base64 string. Two default interface members publish the lifetimes:AccessTokenLifetime(:33, defaulting to 15 minutes) andRefreshTokenLifetime(:40, defaulting to 7 days), both documented as the BR-205 baseline. The comments at:28-32and:35-39explain the split: the real implementation derives both from the bound JWT settings, so the expiry reported to a client matches the token's actualexp, while the defaults keep hand-written test doubles on the baseline instead of forcing every double to implement two more members. That derivation is visible in the concrete:TimeSpan.FromMinutes(_jwtSettings.AccessTokenExpirationMinutes)andTimeSpan.FromDays(_jwtSettings.RefreshTokenExpirationDays)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:125and:129).GetPrincipalFromExpiredToken(string token)(:48) closes the set. - Why it's built this way: the explicit-parameter overload is a
[Rubric §11, Security]guardrail. The token's contents are a deliberate list, not whatever claims happened to ride in on an inbound principal. Surfacing the lifetimes through the same port removes the duplication where a caller would hard-code an expiry that could drift from the signedexp. Note the consumer still guards: AuthenticationServiceBase<TUser> falls back to the same 15-minute and 7-day baselines when an implementation reports a non-positive lifetime (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:102-111). - Where it's used: injected into AuthenticationServiceBase<TUser> (
AuthenticationServiceBase.cs:55), which reads the expired principal on refresh (:278), mints refresh tokens when opening and rotating sessions (:627,:667), and re-exposes a wrapped instance to subclasses through itsTokenServiceproperty (:82). Each app's Identity service mints from that property, for exampleMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:119-120(access token plus thespeaker_idclaim). The rotated pair produced here is what CookieSessionRefresher later exchanges on the browser's behalf.
PasswordResetSettings
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/PasswordResetSettings.cs:10· Level 0 · class (sealed)
- What it is: the bound options object for the forgot-password workflow: where the reset page lives, how long a token stays redeemable, how many wrong guesses a token tolerates, and how often one address may ask for a reset (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/PasswordResetSettings.cs:6-9). - Depends on:
System.ComponentModel.DataAnnotationsfor the range attributes andSystem.Diagnostics.CodeAnalysisfor one scoped suppression (BCL,:1-2). Nothing first-party. Read by the implementation behind IPasswordResetTokenService and by the shared ForgotPasswordHandlerBase<TUser, TCommand>. - Concept: validated options whose defaults keep an unconfigured host bootable.
[Rubric §17, DevOps & Deployment]assesses whether policy knobs are configuration rather than constants buried in a handler, and[Rubric §11, Security]assesses whether the security-relevant knobs (token lifetime, attempt cap, request throttle) are bounded rather than free-form. Every numeric member carries a[Range]attribute, and the host binds the section withValidateDataAnnotations().ValidateOnStart()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:151-154), so a typo such asTokenLifetimeMinutes: 0fails the host at startup instead of silently issuing tokens that are already expired. - Walkthrough:
const string SectionName = "PasswordReset"(:13) names the configuration section the host binds.ResetUrl(:25) defaults tostring.Emptyand is deliberately not[Required]: the doc comment (:15-20) records that a host which has not configured a UI base must still boot, and an empty value degrades to a token-only email the user pastes into the reset page by hand. That degradation is visible in the caller, which emits the bare token when the URL is blank and otherwise appends?email=...&token=...(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:146-148). The property carries a scopedCA1056suppression (:21-24) explaining why it is astringand not aSystem.Uri: it is bound fromPasswordReset__ResetUrl, concatenated with a query string, and the empty default is not a validUri. The four numeric knobs follow:TokenLifetimeMinutes(:29,[Range(1, 1440)], default 30),MaxValidationAttempts(:36,[Range(1, 100)], default 5),MaxRequestsPerEmail(:40,[Range(1, 100)], default 3), andRequestWindowMinutes(:44,[Range(1, 1440)], default 60). All five members areinit-only, so the bound instance is immutable afterwards. - Why it's built this way: the defaults are a working policy on their own, so adopting the feature costs a registration call and no configuration at all, while the
[Range]bounds plusValidateOnStartmake the one genuinely dangerous class of misconfiguration (a zero or negative lifetime, an unbounded attempt cap) unreachable. The decision to keep the whole reset credential in configuration and cache rather than in schema is ADR-091. - Where it's used: bound in the framework's Infrastructure registration (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:151-154, immediately before the token service that reads it is registered at:141); consumed by PasswordResetTokenService as a snapshot field (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:32) for the request window, the throttle ceiling, the token lifetime, and the attempt cap; and exposed to the shared forgot-password handler as a protectedSettingsproperty (ForgotPasswordHandlerBase.cs:49) that states the expiry in the email body (:125) and renders the link (:145-147).
RefreshSessionSettings
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:9· Level 0 · class (sealed)
- What it is: the bound options object for multi-device refresh sessions: whether this host owns the
RefreshSessionstable, which database carries it, how many live sessions one user may hold, and how long dead session rows are retained before a sweep deletes them (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:5-8). A host that omits the section gets the defaults. - Depends on:
System.ComponentModel.DataAnnotations(BCL,:1). Nothing first-party. Read by AuthenticationServiceBase<TUser>, by EFRefreshSessionStore, by ApplicationDbContext, and by RefreshSessionCleanupService. - Concept introduced: a flag that places a table rather than switching a feature.
[Rubric §8, Data Architecture]assesses whether each table has exactly one owning database, and[Rubric §7, Microservices Readiness]assesses whether that ownership survives splitting a modular host into services. The doc comment onEnabledstates the distinction precisely (:20-23): the flag "gates the model, not the workflow". The workflow always issues, rotates and revokes sessions;Enableddecides which host maps the table, runs its migrations, and sweeps it. In a modular host the service that owns identity sets it totrueand every other service leaves itfalse, which is what keeps one table in one database instead of one per service. TheScheduler:Enabledprecedent is named in the same comment as the pattern being followed. - Walkthrough: six members, all
init-only.const string SectionName = "RefreshSessions"(:12) names the configuration section.Enabled(:25) defaults tofalse. Two places read it: the model gate in ApplicationDbContext, which enables the table only when the flag is set and the context instance's physical source name equalsDataSourceName(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:295-298), and the hosted-service registration, which starts the retention sweep only when the flag is set (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:168-172, whose comment explains that registering it unconditionally would start a sweep in every service of a modular host, all but one of which has no table to sweep).MaxActiveSessionsPerUser(:35,[Range(1, 1000)], default 10) caps live sessions per user. The comment (:28-33) records the deliberate behavior at the ceiling: signing in on device number cap + 1 revokes the oldest live session rather than refusing the login, so the table is bounded without a legitimate sign-in ever failing.DataSourceName(:52,[MinLength(1)], default"Default") names the logical data source whose database holds the table. The comment (:37-49) is worth reading in full: the value answers two questions that must agree, which context maps the table and which context the shipped IRefreshSessionStore reads and writes through, and naming a source that does not exist fails loudly on the first session query rather than reading the wrong database. It is ignored for routing when the consumer ships its own entity configuration for the session entity.RetentionDays(:72,[Range(0, 3650)], default 30) is measured from the instant a session died (its revocation, or its expiry when never revoked), so a live session is never a sweep candidate. The comment (:59-66) states the constraint that makes the number security-relevant: retention bounds reuse detection, because BR-206 catches a replayed refresh token by landing on its revoked row, and a swept row turns that replay into an unknown token that fails alone instead of revoking the family. Thirty days sits well past the seven-day refresh-token lifetime for exactly that reason.0keeps every row forever (:67-69).CleanupIntervalHours(:80,[Range(1, 168)], default 6) is how often the sweep runs, ignored whenRetentionDaysis0, and matches the outbox sweep cadence because the deadline is measured in days (:74-77).
- Why it's built this way: the same
AddOptions(...).Bind(...).ValidateDataAnnotations().ValidateOnStart()treatment as every other settings class in the framework (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:159-162) means an out-of-range cap or a negative retention window fails the host at startup, not at the first login. DefaultingEnabledtofalseis the safe direction for a multi-service host: a service that never opts in never grows a table it does not own. - Where it's used: bound at
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:159-162; injected asIOptions<RefreshSessionSettings>into AuthenticationServiceBase<TUser> (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:61, read for the cap at:115), into EFRefreshSessionStore (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFRefreshSessionStore.cs:34), and into RefreshSessionCleanupService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/RefreshSessionCleanupService.cs:51, snapshotted at:54). The design-time context helper supplies an instance sodotnet efcan build a model that includes the table (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:150-151). Each app's Identity service takes it as a required constructor dependency and passes it through, for exampleMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:55.
SessionStampingTokenService
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:773· Level 1 · class (private sealed, nested)
- What it is: a pass-through ITokenService that appends the current refresh session's
sidclaim to every access token minted while it is armed, and behaves as the plain inner service the rest of the time. It is aprivate sealed classnested inside AuthenticationServiceBase<TUser> (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:773). - Depends on: ITokenService (the contract it implements and the inner instance it wraps,
:770), AuthClaimTypes for thesidclaim name (:797), and the BCL (System.Security.Claims,System.Globalization.CultureInfo). - Concept introduced: a decorator used to make a new claim additive.
[Rubric §2, Design Patterns]assesses idiomatic pattern use, and this is the Decorator pattern applied to a very specific compatibility problem. Access tokens now need to name the session they belong to, but the claim set is produced by the app's ownCreateAccessTokenhook. The obvious fix, adding a session-id parameter to that hook, is a compile break in every consumer for a claim the app has no decision to make about, and the remarks say so (:765-769, and again at:520-525). Wrapping the token service instead means the base arms the wrapper around the hook call and the claim appears in tokens minted by subclasses that were never edited.[Rubric §15, Best Practices & Code Quality]is the payoff: an additive protocol change with a zero-line consumer diff. - Walkthrough: a primary constructor takes the
innerservice (:770).Guid? CurrentSessionId { get; set; }(:773) is the arming switch: a session id stamps,nullmints unchanged. The remarks on the caller explain why a plain mutable field is safe here (:526-530): the authentication service is resolved per request (scoped, like the unit of work it saves through) and one request issues one token at a time.AccessTokenLifetime(:776) andRefreshTokenLifetime(:779) forward straight toinner, so the lifetime the base reports is still the JWT settings' value.GenerateAccessToken(...)(:782-800) is the only member with behavior. When not armed it delegates verbatim (:789-792). When armed it copies the app'sadditionalClaimsinto a newList<Claim>(:796, so the caller's sequence is never mutated), appendsAuthClaimTypes.SessionIdformatted assessionId.ToString("D", CultureInfo.InvariantCulture)(:797), and delegates with the extended list (:799). The"D"format is not incidental: the comment (:794-795) records that it is the canonical hyphenated Guid form and the oneClaimsPrincipalExtensions.FindSessionIdparses back (ClaimsPrincipalExtensions).GenerateRefreshToken()(:803) andGetPrincipalFromExpiredToken(string token)(:806-807) are plain forwards.
- Why it's built this way: putting the stamping behind an
ITokenServicerather than inside the base's own method keeps the app hook's signature and semantics untouched while still guaranteeing the claim on the tokens the framework's own flows mint. The escape hatch is documented (:528-530): an app that mints from its own injectedITokenServicereference produces a valid token with nosid, and can restore the claim by overridingCreateAccessTokenForSession. - Where it's used: constructed once per authentication service instance (
:67), surfaced to subclasses as the protectedTokenServiceproperty (:82, whose remarks state that minting through the property is what putssidon the token), and armed and disarmed around the hook call inCreateAccessTokenForSession(:535-546, with thefinallyat:542-545guaranteeing disarm even when the hook throws).
UnconfiguredPermissionRegistry
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/UnconfiguredPermissionRegistry.cs:20· Level 1 · class (internal sealed partial)
- What it is: the fallback IPermissionRegistry for a host that wired the CQRS pipeline without declaring any role-to-permission grants. It grants nothing, so a permission-gated command or query is denied rather than allowed, and it logs one warning naming the call that would fix it (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/UnconfiguredPermissionRegistry.cs:6-11). - Depends on: IPermissionRegistry (
:21), IRequiresPermission by reference in the doc comment (:9), andMicrosoft.Extensions.Loggingfor the source-generated log message (:1). - Concept introduced: failing closed, and only where it costs nothing.
[Rubric §11, Security]assesses the direction a misconfiguration fails in: a registry that answered "no permission model, therefore allow" would silently open every gated request, so this one denies.[Rubric §13, Observability & Operability]assesses whether an operator can tell why: the log message spells out the remedy verbatim (:57-60, namingAddAuthorizationPolicies()/AddPermissions(...)and the required ordering beforeAddApplicationDecorators()). The second half of the doc comment (:12-17) is the more interesting teaching point and is an availability story rather than a security one: the two authorization decorators are registered unconditionally and take anIPermissionRegistryconstructor dependency, so without any registration the whole pipeline fails to activate and a small app with no Identity module answers 500 on every read, not only the gated ones. This type turns a total activation failure into a correct, noisy denial on the subset of requests that actually declare a permission. - Walkthrough: two fields and three methods, no configuration.
private static readonly HashSet<string> None = [](:23) is the single empty grant set every call returns, andprivate int _warned(:25) is the one-time-warning latch.GetPermissions(string role)(:28-32) warns then returnsNone.HasPermission(IEnumerable<string> roles, string permission)(:35-42) argument-guards both parameters (:37-38) before warning and returningfalse, so a caller bug still surfaces as anArgumentExceptionrather than being swallowed by the stub.WarnOnce()(:49-55) usesInterlocked.Exchange(ref _warned, 1) == 0(:51) so concurrent requests produce exactly one log line. The comment (:44-48) explains why the warning is deferred to the first check instead of emitted at startup: a host with no permission-gated request is correctly configured and simply never needs a registry, so warning at boot would cry wolf.LogNoPermissionsConfigured(:57-60) is a[LoggerMessage]source-generatedWarning, which is why the class ispartial.
- Why it's built this way: it is registered with
TryAddSingletonfrom insideAddApplicationDecorators()(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:126), and the surrounding comment (:119-123) states both halves of the rule:TryAddso a host that already declared its grants (viaAddAuthorizationPolicies()orAddPermissions(...), both of which run before this call) keeps its own registry and never constructs this type, and here rather than inAddApplication()so the registration lands exactly where the decorators that need it are wired. - Where it's used: nowhere by name outside that one registration. It is reached only through the IPermissionRegistry dependency of the authorization command and query decorators in the CQRS pipeline (see group 05).
- Caveats / not-in-source:
internal, so it is not part of the framework's public API and cannot be referenced or asserted against from a consumer's code.
ILoginProtectionService
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/ILoginProtectionService.cs:10· Level 3 · interface
What it is: the application-layer contract for brute-force and rate-limit protection on authentication endpoints: lockout checks, failed-attempt increments, successful-login resets, and registration rate-limiting per IP address.
Depends on: Result from
MMCA.Common.Shared.Abstractions(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/ILoginProtectionService.cs:1).Concept introduced: rate limiting as a first-class application concern.
[Rubric §11, Security]assesses brute-force protection on auth flows, and[Rubric §12, Performance & Scalability]assesses whether such a policy is extracted to a port so the application layer can reason about it without coupling to a specific store (the doc comment at:7-8names both a distributed and an in-memory cache as valid backers). Returning Result fromCheckLockoutAsync(:18) andCheckRegistrationRateLimitAsync(:42) makes "account is locked out" a normal control-flow branch rather than a thrown exception.Walkthrough: five async methods in two scopes.
- Email-scoped (failed-login lockout):
CheckLockoutAsync(:18) returns a failure result when the email is currently locked;IncrementFailedAttemptsAsync(:26) records a failure and, per the doc comment (:20-22), applies exponential-backoff lockout once the maximum is exceeded;ResetFailedAttemptsAsync(:33) clears the counter after a successful login. - IP-scoped (registration flood):
CheckRegistrationRateLimitAsync(:42) andIncrementRegistrationCountAsync(:49) throttle account creation per client IP. Both accept a nullableipAddressand skip the check when it is null, so a host that cannot resolve the caller IP degrades to no limit rather than blocking everyone;CheckRegistrationRateLimitAsyncreturnsResult.Success()in that case (doc comment,:36-37).
All five take a
CancellationTokenwith adefaultargument, per convention.- Email-scoped (failed-login lockout):
Why it's built this way: keeping the protection policy behind an interface lets the shared authentication workflow compose it in while the concrete cache mechanics stay in the implementation; the null-IP skip keeps the limiter from becoming an availability hazard (ADR-029).
Where it's used: injected into AuthenticationServiceBase<TUser> (constructor parameter at
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:57), which calls all five across its login and registration flows:CheckLockoutAsync(:131),IncrementFailedAttemptsAsyncon both the unknown-email and wrong-password branches (:146,:161),ResetFailedAttemptsAsyncon success (:178),CheckRegistrationRateLimitAsync(:197) andIncrementRegistrationCountAsync(:256). The concrete, cache-backed LoginProtectionService (tuned by LoginProtectionSettings, bound atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:145-148) implements it, and the framework registers that pairing at:135.
IPasswordResetTokenService
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:10· Level 3 · interface
What it is: the two-method port behind the forgot-password workflow: issue a single-use reset token for an email address, and validate-then-consume a token presented back by the user. Implementations keep the token material outside the database, hashed at rest, and enforce both the per-email request throttle and the per-token validation-attempt cap (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:5-9).Depends on: Result and its generic form from
MMCA.Common.Shared.Abstractions(:1), plus theUserIdentifierTypealias. Its Infrastructure adapter is PasswordResetTokenService, backed by ICacheService and tuned by PasswordResetSettings.Concept introduced: a single-use credential without a schema change.
[Rubric §11, Security]assesses how a secondary credential is minted, stored, and retired;[Rubric §8, Data Architecture]assesses whether short-lived state earns a place in the durable store. A reset token is not durable data: it is valid for minutes and must stop working the instant it is redeemed. Putting it in columns on the user row costs a migration in every consumer and needs a sweeper to reap expired rows, because expiry is not something a table enforces; a self-contained signed payload needs no store but then cannot be single-use, since a signed token that has not expired stays valid however many times it is presented. This port takes the third path and hides the choice: the handlers see twoResult-returning methods, and the cache substrate is entirely the implementation's business (ADR-091).The second teaching point is in the return shapes.
ValidateAndConsumeAsyncis documented to collapse unknown, expired, mismatched, and attempt-capped into one generic failure (:32-35), so the redeem endpoint cannot be used to distinguish a wrong token from an expired one from an address that was never issued a token. The issue path is throttled rather than refused loudly, for the same anti-enumeration reason the forgot-password handler answers success to every input.Walkthrough: two members.
Task<Result<string>> IssueAsync(string email, UserIdentifierType userId, CancellationToken cancellationToken = default)(:23) returns the raw token to email, or a failure when the per-email request throttle has been exceeded. The doc comment (:12-15) states the replace semantics: issuing overwrites any token already outstanding for that address, so requesting a new link immediately stops the older one from working. TheuserIdparameter is what the token resolves back to at redeem time, which is why the redeem call never has to trust an identifier supplied by the caller.Task<Result<UserIdentifierType>> ValidateAndConsumeAsync(string email, string token, CancellationToken cancellationToken = default)(:36) validates the presented token against the outstanding record and consumes it on success, so a token never redeems twice (:25-28), returning the account the token belongs to.
Why it's built this way: taking
emailon both methods, rather than treating the token as self-describing, is what lets the implementation key its records by address and enforce the per-address throttle and the one-active-token rule at the same key. ReturningResult<UserIdentifierType>rather than a boolean means the redeem handler gets the account identity from the token store itself. See PasswordResetTokenService for the mechanics the port hides: a 32-byte random token (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:30), only its digest stored, an attempt counter, and an address normalized through Email before it becomes a cache key (:34-45) soUser@x.comanduser@x.comcannot hold independent tokens for one account.Where it's used: injected into the shared ForgotPasswordHandlerBase<TUser, TCommand> (constructor parameter at
MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:38, called at:72, where a throttled issue is logged and still answered as success) and into ResetPasswordHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:34, redeemed at:61-62). Both apps' sealed subclasses take the same dependency, for example ADC's ForgotPasswordHandler and ResetPasswordHandler. The framework registers the concrete as scoped atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:155.
IRefreshSessionStore
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IRefreshSessionStore.cs:21· Level 4 · interface
What it is: persistence for RefreshSession rows, the multi-device replacement for the single plaintext refresh-token column a user aggregate used to carry (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IRefreshSessionStore.cs:5-7). Sessions are added, looked up by token hash or by id, listed per user, rotated, and saved.Depends on: RefreshSession from
MMCA.Common.Domain.Auth(:1) and theUserIdentifierTypealias. The shipped implementation is EFRefreshSessionStore; the test doubles are InMemoryRefreshSessionStore and FakeRefreshSessionStore.Concept introduced: a repository whose contract is deliberately missing an
Update.[Rubric §1, SOLID]assesses interface segregation, and[Rubric §8, Data Architecture]assesses whether the persistence contract expresses the aggregate's rules. The doc comment (:8-15) explains the shape: sessions are mutated only throughRefreshSession.Revokeon instances this store returned, so an implementation that tracks its entities persists a revocation with no update method at all. The requirement that makes that safe is stated as a contract obligation, not left implicit: implementations must return tracked instances, because a no-tracking read would accept revocations and rotations and drop them silently at save time (:16-19). This is the same trap called out for composed EF queries elsewhere in the framework, promoted here to interface documentation.The second concept is lookup by hash, never by token (
:37). The store never sees plaintext: callers hash first withRefreshSession.HashTokenand search on the digest, which is what lets the table hold only digests.[Rubric §11, Security]applies directly.Walkthrough: six members.
AddAsync(RefreshSession session, ...)(:26) stages an insert.FindByTokenHashAsync(string tokenHash, ...)(:37) finds by digest including revoked and expired rows, and the comment (:28-32) marks that as load-bearing: a rotated token that comes back is found on its revoked row, which is the BR-206 reuse signal, so a store that filtered revoked rows out would report a replay as "unknown token" and never revoke the family.GetUnrevokedByUserAsync(UserIdentifierType userId, ...)(:45-47) returns the user's un-revoked sessions oldest first, expired ones included since they still occupy a row, which is what makes both family revocation and cap eviction deterministic (:39-41).FindByIdAsync(Guid id, UserIdentifierType userId, ...)(:59-62) takes the owner as part of the query rather than as a check the caller performs afterwards. The comment (:50-53) gives the reason: a session id is a value a client hands back, so scoping the query to the owner is what makes another account's id indistinguishable from a nonexistent one. That is an authorization decision encoded in a signature.SaveChangesAsync(...)(:67) persists staged inserts and revocations.TryRotateAsync(RefreshSession presented, RefreshSession successor, DateTime revokedAt, ...)(:95-113) is the one exception to the no-update rule, and it ships a default interface implementation. It argument-guards both sessions (:101-102), revokes the presented session asRefreshSession.ReasonRotatedlinked to the successor's hash (:104), then stages and saves the successor (:109-110). Theboolreturn is the whole point (:73-79): two requests presenting the same still-live token both read an un-revoked row, so a check-then-act rotation would mint two successors from one token and the presented row could never fire reuse detection again. Returningfalsetells the caller it lost the claim, which is indistinguishable from a replay and gets the same answer. The default body is atomic only per instance, which is all an in-memory or test store can offer; the shipped EF store replaces it with a conditionalExecuteUpdateAsyncthe database arbitrates (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFRefreshSessionStore.cs:108and:126), as the comment at:80-84says.
Why it's built this way: hashed-at-rest, per-device session rows are what turn refresh-token rotation into something a user can inspect and revoke per device, and what let reuse detection revoke a whole family (ADR-050, BR-205/206). Keeping the contract in Application means the workflow that uses it is independent of EF, so an extracted Identity service can bring its own store. Making rotation a claim rather than a mutation is the difference between a race that mints two live tokens and a race one side of which is answered as a replay.
Where it's used: registered as scoped against the EF implementation at
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:163, with the comment (:143-144) noting the lifetime is deliberate: scoped, like the unit of work it shares aDbContextwith, so a login and its session insert commit together. Consumed throughout AuthenticationServiceBase<TUser> (injected atMMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:60, exposed to subclasses at:88) for single-device sign-out (:348-350), session listing (:404), targeted revocation (:441), reuse resolution (:592-594), rotation (:682-684), family revocation (:708) and cap eviction (:725).
SoftDeletedUserCache
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:17· Level 4 · class (static)
What it is: the shared cache contract for the soft-deleted user marker (BR-133): the key shape, the marker lifetime, and a one-call helper that writes it. The API middleware reads the marker on every authenticated request; the module that soft-deletes a user writes it (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:6-9).Depends on: ICacheService from
MMCA.Common.Application.Interfaces(:2), theUserIdentifierTypealias, andSystem.Globalization.CultureInfo(BCL,:1).Concept introduced: revoking a stateless credential without a per-request lookup.
[Rubric §11, Security]assesses whether a revoked principal actually loses access, and[Rubric §12, Performance & Scalability]assesses whether such a concern is factored so both ends share one definition. A JWT is a bearer credential: signature validation never asks "is this account still active?", so soft-deleting a user leaves an already-issued access token passing validation until it expires (ADR-047). The textbook fixes (a deny-list, or an account-status query on every request) reintroduce exactly the per-request state that stateless JWT was chosen to avoid. This type is the middle path: a short-lived cache marker written at deletion time and read cheaply on the hot path.The
remarks(:11-16) explain why the constants live in the Application layer rather than next to the middleware that reads them: a downstream application deleting an account has to write the exact same key the middleware reads, and a private constant in the presentation layer is unreachable from an application-layer command handler. Same reasoning as IdempotencyHeaders, applied one layer up.Walkthrough: three static members, no state.
MarkerDuration => TimeSpan.FromSeconds(30)(:29). The remarks (:22-28) justify the number rather than leaving it magic: the marker only has to cover the window between the delete committing and the next token validation, because once it expires the validator query is the source of truth again and gives the same answer. Short-lived access tokens (15 minutes, the BR-205 default on ITokenService) bound the rest of the exposure, so a longer marker would buy nothing and would keep stale entries alive for users who were never deleted.KeyFor(UserIdentifierType userId)(:42-43) buildsuser:deleted:{userId}throughstring.Create(CultureInfo.InvariantCulture, ...). The remarks (:36-41) name the bug this prevents: an identifier renders differently under some cultures (digit shapes, group separators), so a culture-sensitive key would be written under one request's culture and missed under another, silently letting a deleted user keep making requests. This is a case where the analyzer rule about culture-invariant formatting is guarding a security property, not just a formatting nicety.MarkDeletedAsync(ICacheService cache, UserIdentifierType userId, CancellationToken cancellationToken = default)(:53-61) null-guards the cache (:58) and writestrueunderKeyFor(userId)forMarkerDuration(:60). It returns the task without awaiting, so there is no extra async state machine for a one-call passthrough.
Why it's built this way: publishing the key shape and the TTL as framework API is what keeps the writer and the reader honest, and it is a precondition for the module boundary in ADR-047: Identity owns the delete, every service hosts the middleware, and the only thing they share is a cache entry rather than a database.
[Rubric §7, Microservices Readiness]applies directly: an extracted service can enforce the revocation without a reference to the Identity database.Where it's used: read by SoftDeletedUserMiddleware, which builds the key (
MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:85), short-circuits with 401 when the marker istrue(:102-105), and on a miss falls back to the validator query (:113-115) and caches that answer, deleted or not, for the sameMarkerDuration(:132). Written by the shared delete workflow itself, ahead of either app's post-commit tail:DeleteUserHandlerBase.HandleAsynccallsSoftDeletedUserCache.MarkDeletedAsyncright after the erasure commits (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:142-144) and swallows a cache fault (:146-149) so a failed marker cannot turn a successful erasure into an error the caller would retry.Caveats / not-in-source: the marker is best effort on both ends by design. The middleware fails open on a cache outage, falling through to the validator query (
:95-100) and proceeding if that is also unavailable (:118-125), and the writer logs and continues on a cache fault. The exposure that leaves is bounded by the access-token lifetime, which is the trade-off ADR-047 accepts explicitly. ADC's handler is the only writer in the source tree today; MMCA.Store soft-deletes users without writing the marker, so there the middleware's own validator-query fallback is what enforces BR-133.
AuthenticationValidators
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:16· Level 5 · class (sealed)
- What it is: a tiny parameter object that bundles the three FluentValidation validators the authentication workflow needs (login, registration, refresh) into one injectable dependency.
- Depends on: FluentValidation's
IValidator<T>(NuGet,:1) over the request DTOs LoginRequest, RegisterRequest, and RefreshTokenRequest (all inMMCA.Common.Shared.Auth,:2). - Concept introduced: the parameter object as a constructor-arity guardrail.
[Rubric §1, SOLID]assesses whether a class stays a single, cohesive responsibility rather than sprawling into a god class, and[Rubric §15, Best Practices & Code Quality]assesses whether cross-cutting dependencies are grouped so a class can grow without exploding its constructor. The doc comment (:6-11) states the exact motive: collapsing three closely-related dependencies into one keeps the app'sAuthenticationServicebelow the application-service constructor-arity ceiling (a god-class analyzer guardrail) without giving up per-request validation. Because the request DTOs already live inMMCA.Common.Shared.Auth, the bundle is app-agnostic, which is why it could be hoisted out of the apps into the framework. The pressure is real rather than theoretical: even with the bundle, ADC's subclass constructor takes nine parameters (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:46-55). - Walkthrough: a primary constructor takes the three
IValidator<T>instances (:16-19), and three get-only properties surface them by name:Login(:22),Register(:25), andRefresh(:28), each assigned from its matching constructor parameter. There is no logic here; the type exists purely to shrink the dependency footprint of its consumer. - Why it's built this way: a
sealedgrouping type with get-only properties is the cheapest way to fold three cohesive dependencies into one constructor slot, so the workflow base can validate each request shape without pushing its constructor over the arity limit; DI resolves the three underlying validators and composes them into this one object. Two of the three (LoginRequestValidator, RefreshTokenRequestValidator) come from the framework assembly, whileIValidator<RegisterRequest>is satisfied by the app's ownRegisterRequestValidator, so the bundle is the point where framework and app validation meet. - Where it's used: injected into AuthenticationServiceBase<TUser> (constructor parameter at
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:59), whoseLoginAsync,RegisterAsync, andRefreshTokenAsynccallvalidators.Login(:124),validators.Register(:190), andvalidators.Refresh(:270) respectively before doing any work. It is registered by each app's Identity module rather than by the framework, since one of its three dependencies is app-owned:MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:34andMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:42, bothTryAddScoped<AuthenticationValidators>().
IAuthenticationService
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:12· Level 5 · interface
What it is: the application-layer contract for the Identity module's authentication workflows: login, registration, token refresh, per-device and global session revocation, session listing, and external (OAuth) login.
Depends on: LoginRequest, RefreshTokenRequest, RegisterRequest, AuthenticationResponse, RefreshSessionSummaryResponse, Result, Error, and the
UserIdentifierTypealias (:1-2).Concept introduced: default interface methods for optional capabilities.
[Rubric §1, SOLID](interface segregation and dependency inversion):ExternalLoginAsync(:130-138) ships a default implementation in the interface itself that returns a not-supported Error ("Auth.ExternalLoginNotSupported",:138). An implementation that does not offer OAuth (a stub host, or a deployment with social login disabled) inherits that failure for free and need not override anything, so the interface stays one piece while the capability is opt-in (ADR-036).[Rubric §11, Security]: login, registration, and refresh all returnResult<AuthenticationResponse>, so auth outcomes flow as values and no exception leaks credential detail to the caller.The second concept the signatures teach is that a session is a device, not a user.
LoginAsync,RegisterAsyncandRefreshTokenAsyncall take optionalipAddressanduserAgent(:24-25,:32-33,:47-48) recorded on the session row, and the doc comments state the invariant each time: signing in opens a session for the calling device and leaves the user's other devices signed in (:14-15), and refreshing rotates the presenting device's session only (:43-44).Walkthrough: eight methods, all async, all ending in a
CancellationToken.LoginAsync(LoginRequest, string? ipAddress = null, string? userAgent = null, ...)returnsResult<AuthenticationResponse>(:22-26).RegisterAsync(RegisterRequest, string? ipAddress = null, string? userAgent = null, ...)(:36-40); theipAddressdoes double duty, feeding ILoginProtectionService's registration rate limit and the new session row (:32).RefreshTokenAsync(RefreshTokenRequest, ...)(:51-55) exchanges an expired access token plus a valid refresh token for a rotated pair.RevokeTokenAsync(UserIdentifierType userId, string? refreshToken = null, ...)(:66-69) signs one device out. The documented fallback is the interesting part (:58-60): passing no token, or one that does not belong to this user, revokes every session the user holds, which the comment calls the safe reading of "log me out" from a caller that cannot produce its refresh token.RevokeAllSessionsAsync(UserIdentifierType userId, ...)(:78-80) signs every device out: a password change, an admin lockout, or an explicit "sign out everywhere".GetSessionsAsync(UserIdentifierType userId, Guid? currentSessionId = null, ...)(:95-98) lists live sessions newest first with the caller's own device marked.currentSessionIdis the caller token'ssidclaim and is used only to setRefreshSessionSummaryResponse.IsCurrent; passingnullmarks no row (:88-91).RevokeSessionByIdAsync(UserIdentifierType userId, Guid sessionId, ...)(:114-117) revokes one named device. The remarks (:103-108) fix two behaviors as contract: an unknown id and another account's id both returnNotFoundand are indistinguishable, so a caller cannot probe for another user's sessions; and revoking an already-revoked session succeeds and changes nothing, because a device list a user is clicking through is exactly where a duplicate request comes from.ExternalLoginAsync(loginProvider, providerKey, email, firstName, lastName, ...)(:130-138), the default-implemented OAuth path.
The doc comment (
:6-9) also records a scope decision: password change is not on this interface. It is dispatched directly through its own command handler at the controller layer.Why it's built this way: concentrating the token-issuing and session-management workflows behind one port keeps the Identity controllers thin and lets the protection and rate-limit policy (ILoginProtectionService) compose in; the default OAuth method keeps the contract stable across hosts that do and do not enable social login. Encoding the anti-probing and idempotent-revoke rules in
remarksrather than leaving them to an implementation makes them testable expectations of every implementer.Where it's used: implemented by AuthenticationServiceBase<TUser> (which realises every member except the default
ExternalLoginAsync) and, through it, by each app's sealed AuthenticationService; consumed by the Identity API controllers.
AuthenticationServiceBase<TUser>
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:53· Level 8 · class (abstract)
What it is: the shared authentication workflow (login, registration, refresh-token rotation, per-device and global revocation, session listing) hoisted once into the framework, generic over the app's
Useraggregate. It realises IAuthenticationService and leaves the genuinely app-specific decisions to a small set ofabstractandvirtualhooks a sealed subclass overrides.Depends on: IUnitOfWork and IRepository<TEntity, TIdentifierType> (persistence, G07), ITokenService, IPasswordHasher, ILoginProtectionService, AuthenticationValidators, IRefreshSessionStore and
IOptions<RefreshSessionSettings>(all eight constructor parameters,:50-58), the IAuthUser credential contract plus AuditableAggregateRootEntity<TIdentifierType> as theTUserconstraint (:59), RefreshSession (the session aggregate it creates and revokes), Email (normalizing the login and register address), ClaimsPrincipalExtensions (principal.GetUserId(),:288), Result and Error, the request and response DTOs, and the BCLTimeProvider(injected at:55, neverDateTime.UtcNow, so the clock is testable).Concept introduced: the Template Method that de-duplicates a whole vertical slice.
[Rubric §2, Design Patterns]assesses idiomatic pattern use: this is a textbook Template Method, the invariant sequence of an operation living in the base while the variable steps are deferred to subclass hooks.[Rubric §15, Best Practices & Code Quality](DRY across services) and[Rubric §1, SOLID]also apply: the doc comment (:14-19) records that the app Identity modules previously duplicated this workflow at roughly 70 to 95 percent line-identity, so a fix to the lockout order or the rotation logic is written once.[Rubric §11, Security]: the base encodes the security posture directly, validate first, an ILoginProtectionService lockout and rate-limit gate (ADR-029), an untracked-then-tracked dual fetch (ADR-004), and refresh-token rotation with reuse detection (ADR-050, BR-205/206).[Rubric §7, Microservices Readiness]: the workflow depends only on ports, so it runs unchanged whether the Identity module is in-monolith or its own service.The session model is the other concept to absorb before reading the code (
:35-47). Refresh tokens are not a column on the user: every issue opens its own RefreshSession row, the store holds onlyRefreshSession.HashTokendigests, and rotation revokes the presented session and links it to its successor. Presenting an already-rotated token therefore lands on a revoked row, which is the reuse signal that revokes the user's whole live family (BR-206). Two requests presenting the same live token at the same instant get the same treatment, because the rotation is claimed atomically throughIRefreshSessionStore.TryRotateAsyncand the loser is answered as a replay rather than handed a second successor. An expired session is not a reuse signal and fails alone. A per-user cap evicts the oldest live session on a new sign-in so one account cannot grow the table without bound.Walkthrough (members in teaching order):
Constructor and protected accessors (
:50-92): a primary constructor takes the eight collaborators; a private field wraps the injected token service in a SessionStampingTokenService (:67); protected read-only properties re-exposeUnitOfWork(:70), the wrappedTokenService(:82, whose remarks explain that minting through this property is what putssidon the token),TimeProvider(:85),RefreshSessions(:88), and aRepositoryresolved lazily asunitOfWork.GetRepository<TUser, UserIdentifierType>()(:91-92).Lifetimes and cap (
:99-115):virtualAccessTokenLifetimeandRefreshTokenLifetimeread through to ITokenService (which derives them fromJwt:AccessTokenExpirationMinutesandJwt:RefreshTokenExpirationDays), falling back to the BR-205 defaults of 15 minutes and 7 days on a non-positive value, meaning a hand-written test double or a misconfigured host.virtual MaxActiveSessionsPerUser(:115) readsRefreshSessions:MaxActiveSessionsPerUser.LoginAsync(:118-181): validate the request (:124-128), check lockout (:131, ADR-029 and BR-212), normalize the raw email into an Email value object (:139) so the EF predicate compares same-typed converted values (an invalid address yields a null value object that simply matches no user, which is the invalid-credentials answer anyway). Step 1 is an untracked fetch via theFindUntrackedByEmailAsynchook (:143) to verify credentials without change-tracker overhead; a null result increments failed attempts and returns a generic 401 (:144-149). An app gate runs before password verification (:153, with no failed-attempt increment so the pre-hoist behavior is preserved), thenpasswordHasher.VerifyPassword(:159). Step 2 is a tracked re-fetch by id (:170). Read the comment there (:166-169): now that refresh tokens live in their own rows, this fetch is purely about the instance the app'sCreateAccessTokenhook mints from, and the second lookup is what turns a race that deleted the account between the two steps into a clean 404. ThenResetFailedAttemptsAsync(:178) andIssueTokensAsync(:180).RegisterAsync(:184-261): validate (:190-194), IP rate-limit (:197, ADR-029 and BR-213), reject a duplicate email through theEmailExistsAsynchook (:204-208), hash the password (:210), build the user through theCreateUserhook (:211),AddAsync(:219) andSaveChangesAsync(:223), run theOnUserRegisteredAsyncpost-commit hook (:253) to pick up the instance the first access token is minted from, increment the IP registration count (:256), and open the session last (:260). The final comment (:258-259) explains that ordering: the session row carries the user id, which a store-generated key only has once the insert has run.The save is wrapped in a deliberately broad
catch (Exception)(:221-249, with a scopedCA1031suppression at:225-227) whose comment is the teaching material. The email lookup above is a check-then-act: two concurrent registrations for the same address both pass it, and the loser only fails on the insert, against the unique index every consumer puts onEmail(ADC unfiltered, Store filtered onIsDeleted). Without the catch, that race surfaces as a generic 500 instead of the 409 a serialized pair would have produced. The catch cannot nameDbUpdateException, because Application has no EF Core dependency by layer rule, so the re-check is what narrows it (:243): if the address exists now, the concurrent registration is the cause and the caller gets the same conflict the serial path returns through the sharedEmailAlreadyExistsFailure()helper; anything else rethrows untouched (:248) and still reaches the exception middleware. The re-check passesCancellationToken.Noneon purpose (:241-243): it has to run even when the caller's token is what aborted the save, or a cancelled save could never be classified.RefreshTokenAsync(:264-330): validate (:270-274), pull claims from the expired JWT viatokenService.GetPrincipalFromExpiredToken(:278, signature still checked, only lifetime skipped), read the identifier withprincipal.GetUserId()(:288; the comment at:285-287notes it rides the standardsubclaim, also accepts theNameIdentifierform the bearer handler maps it to, and parses throughIParsableso the identifier alias can change shape without editing this file), load the tracked user (:295), run the refresh app gate (:302), resolve the session behind the presented token (:309-310), rotate it (:317), and answer with a token pair whose access token carries the successor'ssid(:326-329, comment at:323-325: a client's current-device marker follows the rotation instead of pointing at the session the rotation just revoked).RevokeTokenAsync(:333-371): load the user, and when a refresh token was supplied, look up its session by hash (:348-350). Only a live session belonging to this user identifies the device to sign out (:356-358); anything else (unknown token, another account's token, an already-revoked row) leaves the caller unidentifiable, so the request degrades to revoking every live session rather than reporting success for a revocation that reached nothing (:352-355, then:367).RevokeAllSessionsAsync(:374-389): the unconditional form of the same thing.GetSessionsAsync(:398-422): reads the same "un-revoked sessions for this user" query the cap and family revocation use, then drops expired rows in memory withIsActiveAt(now)(:409) and orders newest first withIdas the tie-break (:410-411). The remarks (:392-396) explain why the filtering is in memory: the store returns expired-but-unrevoked rows on purpose, and a device list must not offer a user a device that can no longer authenticate.RevokeSessionByIdAsync(:436-460): the ownership check is the store query (:441, scoped to the user), so another account's id and a nonexistent id produce the sameNotFound(:444-449); an already-revoked session returns success without writing (:451-454).IssueTokensAsync(:471-495): the shared open-and-respond used by login and registration, and reusable by an app-level external-login flow. It opens the session before minting the access token, because the token carries the session's id (:481-483), saves (:489), and returns the response.The private mechanics:
ResolveRotatableSessionAsync(:581-613) is where the three rejections differ behind one identical error, and its comment (:571-579) is the single most important paragraph in the file. An unknown hash, or one belonging to another account, is failed alone (:596-599), because revoking the family on it would let anyone holding one of this user's expired access tokens sign them out everywhere by posting a random token. A revoked row means this exact token was already rotated away or signed out and has come back, which is the BR-206 reuse signal that revokes every live session (:601-608). An expired row is an ordinary end of life and fails alone (:610-612).OpenSessionAsync(:620-646) mints a token, creates the session, enforces the cap, and stages the insert, returning an IssuedSession.RotateAsync(:659-699) mints the successor and claims the rotation throughTryRotateAsync(:682-684); losing the claim is answered exactly like a replay (:686-696).RevokeLiveSessionsAsync(:702-713) revokes without saving.EnforceSessionCapAsync(:722-735) revokes the oldest live sessions while the user is at or over the cap; the comment (:715-720) notes expired-but-unrevoked rows do not count against the cap because they authenticate nobody, and age out through the retention sweep instead.InvalidRefreshTokenError()(:741-742) andEmailAlreadyExistsFailure()(:749-751) are the two shared failures that keep distinct internal paths indistinguishable to a caller.The hooks: four are
abstract, so a subclass must supply them.FindUntrackedByEmailAsync(:502) andEmailExistsAsync(:508) are deliberately written against the app's concreteUserso EF translates the predicate byte-for-byte as before, and the second explicitly leaves the app to decide whether soft-deleted accounts count (ignoreQueryFilters: trueblocks re-registration of an erased address,:504-507);CreateUser(:511) runs the app's domain factory;CreateAccessToken(:514) mints the app's claim set (for examplespeaker_idversuscustomer_id). Fivevirtualmembers can be overridden:CreateAccessTokenForSession(:535-546) arms the stamping wrapper around the hook call;ValidateLoginCandidateAsync(:549) andValidateRefreshCandidateAsync(:553) add extra gates such as a deactivated-account check;OnUserRegisteredAsync(:560) runs the post-commit side-effect; andCreateRefreshUserMissingError(:568) defaults the vanished-user case to 401 (a token for a missing user is indistinguishable from an invalid one) while letting an app return 404 where its public contract already promises it.
Why it's built this way: the untracked-then-tracked dual fetch keeps the common credential-verification path off the change tracker (cheaper, and soft-deleted accounts fall out via EF query filters returning the generic 401) while still giving a tracked instance to mint from (ADR-004). Per-device session rows with hashed tokens, rotation, family revocation on reuse, and a per-user cap are the BR-205/206 model (ADR-050). Password material flows through IAuthUser's
PasswordHashandPasswordSalt(ADR-032), and the whole workflow depends only on abstractions, so it is identical whether the module runs in-process or as an extracted service.Where it's used: subclassed by each app's sealed AuthenticationService, for example
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:46, which bindsTUser = User, adds the Attendee default role (BR-45) and thespeaker_idclaim (BR-209, built at:275), and re-listsIAuthenticationService(:63) so it can re-implementRegisterAsyncandExternalLoginAsyncoutright: ADC raises its registration side-effects inside one transactional unit rather than through theOnUserRegisteredAsynchook, because the identity column means the id does not exist until the first save (AuthenticationService.cs:28-39). MMCA.Store supplies its own subclass with acustomer_idclaim. Consumed by the Identity API controllers via the IAuthenticationService port.Caveats / not-in-source:
ExternalLoginAsyncis intentionally not overridden here: the base inherits the interface's default not-supported failure, and OAuth account linking stays in the app subclass because it is coupled to the app'sUserfactory surface (doc comment,:33-34).
ICurrentUserService
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ICurrentUserService.cs:9· Level 8 · interface
- What it is: the Application layer's read-only window onto the authenticated caller: the raw
ClaimsPrincipal, a strongly-typedUserId, the caller's first role, the full role set, a generic typed-claim reader, and a role-membership helper. It answers "who is calling?" without any handler ever touchingHttpContext. - Depends on:
System.Security.ClaimsandIParsable<T>(BCL,:1) plus the solution-wideUserIdentifierTypealias (:15); see primer §2. It references RoleNames in documentation only (:80). Its adapter is CurrentUserService in Infrastructure. - Concept introduced: the caller-identity port with behavior on the interface.
[Rubric §3, Clean Architecture]assesses whether inner layers stay free of transport types, and[Rubric §1, SOLID](interface segregation) whether a contract exposes only what its clients need. A handler must know the caller to run ownership checks and to stamp audit fields, but it must not depend onIHttpContextAccessor, which would drag ASP.NET Core into the Application project. This interface is that inversion, and the adapter is the only place the accessor appears (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:17,:25). What makes it worth studying is the use of default interface members:Roles(:45-64) andIsInRole(:88-89) ship real implementations on the contract, so every implementer and every hand-written test double inherits correct multi-role behavior instead of re-deriving it. - Walkthrough:
ClaimsPrincipal User(:12) exposes the full principal for advanced inspection.UserIdentifierType? UserId(:15) is the typed identifier, nullable because an unauthenticated request has no user.string? Role(:22) is documented as the first role claim only, with the remarks at:18-21steering callers toRolesorIsInRolefor membership checks.Roles(:45-64) is the interesting member: it reads every role claim, accepting each claim type the JWT middleware may produce (ClaimTypes.Rolewhen inbound claim mapping is on, or the rawrole/rolesclaim when it is off,:50-53), falls back to a single-element list built fromRolewhen the principal yields nothing (:62), and null-guardsUsereven though the property is declared non-nullable (:49). The long remarks at:27-44justify both accommodations from the nature of a default interface member: it runs against every implementation, including a hand-written double or a mock that stubs onlyRole, where reading claims alone would have reported no roles and silently turned an authorization check into a denial, and dereferencing a null principal would have turned it into aNullReferenceException. Claims win when present, so a genuine multi-role principal is still read in full.T? GetClaimValue<T>(string claimType) where T : struct, IParsable<T>(:73-74) parses a named claim into any parsable value type and returnsnullwhen the claim is missing or unparseable, which is how a module reads its own claim (the doc namesspeaker_id,:68) without Common ever knowing that claim exists.IsInRole(string roleName)(:88-89) isRoles.Any(role => string.Equals(role, roleName, StringComparison.OrdinalIgnoreCase)). - Why it's built this way: the remarks at
:82-87record the reasoning behindIsInRolechecking every claim rather than comparing againstRole. Comparing against the first role alone matched only whichever role happened to be listed first, which is latent today because tokens carry a single role, and would have surfaced silently as an authorization denial the moment a second role was added. TypingUserIdas the per-app alias instead of a generic parameter keeps the interface concrete and easy to mock while staying correct for each app.[Rubric §11, Security]and[Rubric §15, Best Practices & Code Quality]both apply. - Where it's used: registered as scoped against CurrentUserService at
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:555. It supplies theRolesset the CQRS authorization decorators check permissions against (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/AuthorizationCommandDecorator.cs:30, and its query twin; see group 05); it is how audit fields get their actor, since DbContextFactory passes_currentUserService.UserIdinto every save (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:58, used at:248,:291,:330,:352and:414); and it backs the ownership check in OwnerOrAdminFilter and the framework's account controllers. - Caveats / not-in-source:
Roledeliberately reports only the first role claim; treat it as a display value and useRolesorIsInRolefor any decision.
IAuthUser
MMCA.Common.Domain ·
MMCA.Common.Domain.Auth·MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:16· Level 0 · interface
- What it is: the deliberately minimal credential surface an Identity module's
Useraggregate exposes to the shared AuthenticationServiceBase<TUser> workflow. Two properties, both password material. It is the contract that lets the framework's authentication plumbing verify and replace a password without knowing anything app-specific about the user (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:3-8). - Depends on: nothing first-party; the BCL only (
byte[]). Implemented transitively through IPasswordChangeableUser by each app'sUseraggregate (see User). - Concept introduced: the inverted user contract. Rather than the shared auth workflow depending on a concrete
Userclass,Userimplements a small interface the framework owns. Profile fields, roles, linked aggregates, and claim sources stay app-specific: the shared workflow reaches those only through per-app hooks (CreateAccessToken,CreateUser), never through this contract (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:6-8).[Rubric §1, SOLID]assesses interface segregation and dependency inversion, and this is a textbook case: the interface is exactly the credential surface and nothing more.[Rubric §11, Security]assesses credential handling, and the whole security-relevant surface of aUseraggregate is now readable in five lines. - Concept: a contract that got smaller on purpose. The
<para>block (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:9-14) is the most instructive part of the file, because it records what is deliberately absent. Refresh tokens used to be members here: one plaintextRefreshTokencolumn plus its expiry, per user. Two problems followed from that shape, and both are named in source. One user row could hold one token, so signing in on a phone signed the same account out of a laptop. And the column held a usable bearer credential in the users table, so a database read was enough to mint access tokens. Both are gone: sessions are rows in RefreshSession, hashed at rest and reached through IRefreshSessionStore, so this interface covers passwords only (ADR-097).[Rubric §15, Best Practices & Code Quality]applies to the removal itself: shrinking a framework contract is a breaking change for every consumer, and it was taken because the alternative was a security and UX defect baked into the contract shape. - Walkthrough: two members, both
byte[]and both read-only.byte[] PasswordHash(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:20), the PBKDF2 hash produced by IPasswordHasher.byte[] PasswordSalt(:23), the salt paired with it.- Both sit inside a scoped
#pragma warning disable CA1819(:18, restored on:24) that knowingly returns arrays, to mirror IPasswordHasher'sbyte[]tuple and the EF-mappedvarbinarycolumns rather than force a defensive copy on every read. The suppression's justification is written on the disable line itself, which is the convention this codebase uses everywhere it takes an analyzer exception. - There is no mutator. Writing new material is the separate capability IPasswordChangeableUser adds, so an aggregate that only ever authenticates never exposes a way to change its own password.
- Why it's built this way: keeping the contract in Domain and keeping it small is what makes the shared auth workflow reusable across Store and ADC (both
Useraggregates satisfy it) while each aggregate stays free to model everything else its own way. See ADR-032 for the password-material policy, ADR-004 for the dual-fetch auth model this contract feeds, and ADR-097 for the refresh-token move. - Where it's used: it is half the generic constraint on the shared login and registration workflow,
where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IAuthUser(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:62), which reads both properties on the login path (:159) and writes the pair on registration (:210). It is also the base of IPasswordChangeableUser, and the shape hand-written test doubles copy (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Auth/AuthenticationServiceBaseTests.cs:975). - Caveats / not-in-source: the doc comment on
PasswordSaltstill says the salt's length selects the verify algorithm (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:22). That was true while PasswordHasher also verified a legacy HMAC-SHA512 format; the current implementation has one algorithm and one salt size (SaltSize = 32,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:15, with no legacy branch) per ADR-102. The comment is stale; the code is the contract.
IJwksProvider
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:11· Level 0 · interface
- What it is: the abstraction that returns the active
JsonWebKeySetserved at/.well-known/jwks.json. Implementations materialize the public signing key(s) in the JWK format that other services consume to validate access tokens (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:5-10). - Depends on:
Microsoft.IdentityModel.Tokens.JsonWebKeySet(NuGet,:1). Implemented by RsaJwksProvider; configured by JwksSettings and served by JwksEndpointExtensions. - Concept introduced: publishing a public key instead of sharing a secret.
[Rubric §11, Security]assesses key management and blast radius, and[Rubric §7, Microservices Readiness]assesses whether a module can be lifted out without a rewrite. In an extracted-service topology, symmetric HS256 would require every service to hold the same secret, so any one compromised service can mint tokens for all of them. The asymmetric alternative (ADR-004) keeps the RSA private key inside the Identity service and publishes only the public key at a well-known URL; peers fetch it and validate signatures without ever being able to sign.IJwksProvideris how the Identity API obtains that public key set to serve. - Walkthrough: a single synchronous member,
JsonWebKeySet GetJsonWebKeySet()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:19). Synchronous is the deliberate shape because key material is resolved once and cached in-process by the implementation. The doc comment sets a contract that the implementation must honor: return an empty key set rather than throwing when no signing key is configured (:13-17), so/.well-known/jwks.jsonstays a valid, pollable URL even in a host where JWKS publishing is off. - Why it's built this way: an interface here lets tests inject a pre-built key set with no file IO, and the empty-set contract makes the endpoint safe to map unconditionally instead of behind a feature check.
- Where it's used: registered as
services.TryAddSingleton<IJwksProvider, RsaJwksProvider>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:183) immediately after theJwksSettingsoptions binding (:165-168); the JWKS minimal-API endpoint calls it, and consuming services fetch the resulting document throughAddForwardedJwtBearerat startup (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:9).
JwksSettings
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwksSettings.cs:17· Level 0 · class (sealed)
- What it is: the
Jwkssection that controls whether an Identity service publishes a JSON Web Key Set at/.well-known/jwks.json, and where its RSA public key comes from (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwksSettings.cs:5-9). - Depends on:
System.ComponentModel.DataAnnotationsfor[StringLength](BCL,:1) only. Consumed by RsaJwksProvider and TokenService throughIOptions<JwksSettings>. - Concept introduced: key distribution as configuration.
[Rubric §11, Security]assesses how trust is established between services. In a single-process monolith the issuer and the validator can share one symmetric secret. Once a module is extracted, the validator must obtain the issuer's public key without sharing anything secret, which is what a JWKS document is for (ADR-004, ADR-008).[Rubric §7, Microservices Readiness]: the framework ships the endpoint always and the key set empty, so nothing about a deployment changes until a host flipsEnabled. Thekidcontract is the subtle part:KeyIdis published as the JWKkidand must match thekidheader on tokens the issuer signs (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwksSettings.cs:13-14, restated on the property itself at:28-32), otherwise a validator holding a correct key set still cannot pick the right key. TokenService closes that loop by taking these same options and stampingKeyIdonto every RS256 token it signs (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:48-53,:57,:67). - Walkthrough:
SectionName = "Jwks"(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwksSettings.cs:20).Enabled(:26), defaulting tofalsewith the rationale spelled out inline (:22-25): existing HMAC-only deployments must not start advertising an RSA key set by accident.KeyId(:34),[StringLength(64)](:33), defaulting to"default".RsaPublicKeyPem(:41) andRsaPublicKeyPath(:47), documented as mutually exclusive (:36-40,:43-46); the path form exists for keys mounted as a secret rather than inlined in configuration.- The consuming logic, worth reading alongside: RsaJwksProvider
.BuildKeySetreturns an EMPTYJsonWebKeySetwhenEnabledis false (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:29-32) and again when neither PEM source resolves (:36-39); otherwise it imports the PEM, stampsKeyIdonto theRsaSecurityKey(:41-47) and tags the JWKuse=sig,alg=RS256(:50-51).ResolvePemprefers the inline value over the file (:58-74), and the key set is built once behind aLazy<JsonWebKeySet>inPublicationOnlymode (:21-22) so that one transient IO failure reading the PEM is retried rather than cached forever (:17-21).
- Why it's built this way: default-off plus an empty key set means the endpoint is safe to map unconditionally, and two key sources cover both "inline it in configuration" and "mount it as a secret" without a second code path in the provider.
- Where it's used: bound with
.ValidateDataAnnotations().ValidateOnStart()inAddInfrastructure(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:179-182), immediately followed by the IJwksProvider registration (:183). TokenService takes it as an optional constructor dependency and falls back tonew JwksSettings().KeyIdwhen it is absent (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:67).
JwtSigningAlgorithm
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSigningAlgorithm.cs:21· Level 0 · enum
- What it is: a two-value enum selecting how access tokens are signed and validated: symmetric HMAC or asymmetric RSA.
- Depends on: nothing. Referenced by JwtSettings, TokenService, and the API-layer authentication wiring.
- Concept introduced: the deployment shape encoded as one configuration value.
[Rubric §11, Security]assesses key management: HS256 requires every validator to hold the signing key, which is acceptable only while issuer and validators share a process. RS256 splits the pair, the issuer holds the private key and peers validate against the JWKS endpoint, so no peer ever holds the signing key (ADR-004).[Rubric §7, Microservices Readiness]: making this a configuration value rather than a compile-time choice is what lets the same binaries run both topologies, and the type's own doc says RS256 is also the right choice for a monolith that intends to extract later, because the token format does not change when it does (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSigningAlgorithm.cs:8-12). The operational consequence is stated just as plainly: switching a running deployment between the two invalidates every existing token, a hard cutover (:17-18). - Walkthrough:
HS256 = 0(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSigningAlgorithm.cs:24) andRS256 = 1(:27), both with explicit ordinals.- The default is RS256, and where that default lives is worth being precise about. The enum's zero value is HS256, so a configuration binder that saw an invalid value would land there; but a host that simply omits
Jwt:SigningAlgorithmnever has the property set at all, and JwtSettings's own initializer holds (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSettings.cs:30). The default is a property initializer, not the enum ordinal. - TokenService branches on the value once, in its constructor, and caches the resulting credentials (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:64-74), with the RSA and HMAC builders at:194and:180. Each builder throws a namedInvalidOperationExceptionwhen its key material is missing (:184,:200). - The API layer branches on the same value when configuring in-process JWT bearer validation:
BuildValidationParameterstakes the RSA path for RS256 (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:629-642) and, when the public key is absent, throws a message that points the reader atAddForwardedJwtBearerfor services that fetch the key through JWKS at runtime instead (:633-636).
- The default is RS256, and where that default lives is worth being precise about. The enum's zero value is HS256, so a configuration binder that saw an invalid value would land there; but a host that simply omits
- Why it's built this way: both members stay because they encode deployment shapes rather than a compatibility level (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSigningAlgorithm.cs:5-6). A single-process monolith that will never be split skips RSA key management entirely; everything else gets the algorithm that survives extraction. - Where it's used: JwtSettings.SigningAlgorithm (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSettings.cs:30) and its conditional validation (:72,:79), TokenService, andBuildValidationParametersin the API startup extensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:631).
LoginProtectionSettings
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:9· Level 0 · class (sealed)
- What it is: strongly typed,
[Range]-validated configuration for brute-force login lockout and registration rate limiting, bound from theLoginProtectionconfiguration section (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:5-7). - Depends on:
System.ComponentModel.DataAnnotationsfor[Range](BCL,:1). Consumed by LoginProtectionService throughIOptions<LoginProtectionSettings>. - Concept:
[Rubric §11, Security]assesses whether brute-force defenses exist and are tunable, and this settings class is where the policy numbers live rather than being hard-coded into a handler.[Rubric §15, Best Practices & Code Quality]also applies in a small way: fiveinit-only properties with defaults mean an app that configures nothing still gets a safe policy, and an app that configures one value inherits the rest. - Walkthrough:
const string SectionName = "LoginProtection"(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:12) names the bound section. Two concerns follow.- Account lockout:
MaxFailedAttempts(default 5,[Range(1, 100)],MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:17-18),MaxLockoutSeconds(default 300,[Range(1, 3600)],:23-24),FailedAttemptWindowMinutes(default 30,[Range(1, 1440)],:30-31). The window comment (:26-29) is load-bearing for understanding the service: the attempt counter resets by cache expiration, not by a sweep job. - Registration rate limiting:
MaxRegistrationsPerIpPerHour(default 10,[Range(1, 10000)],MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:36-37) andRegistrationRateLimitWindowMinutes(default 60,[Range(1, 1440)],:42-43).
- Account lockout:
- Why it's built this way:
sealedwithinit-only properties gives an immutable options object. Every property carries a[Range], and the registration wires.ValidateDataAnnotations().ValidateOnStart()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:145-148), so an obviously unsafe value such asMaxFailedAttempts = 0fails the host at startup instead of quietly disabling lockout until someone notices in production. TheMaxLockoutSecondsupper bound of 3600 is also what lets LoginProtectionService reason about its shift-clamp safely. - Where it's used: bound and validated in
AddInfrastructure(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:145-148) immediately before LoginProtectionService is registered (:135). Its sibling PasswordResetSettings is bound in exactly the same shape two lines later (:137-140), as is RefreshSessionSettings (:145-148).
PasswordResetEntry
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:171· Level 0 · record (internal sealed)
- What it is: the cached reset record behind the forgot-password flow: what PasswordResetTokenService writes into the cache when a reset token is issued, and reads back when one is redeemed. It is
internal sealed, declared as a second type at the bottom of its service's file (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:171-175). - Depends on: nothing first-party except the
UserIdentifierTypealias (the per-moduleglobal usingidentifier alias taught in the primer, ADR-048). Four positional parameters, all BCL primitives. - Concept introduced: a cache DTO is constrained by its serializer, not by your domain.
[Rubric §8, Data Architecture]assesses whether each store is given a shape it can actually round-trip, and this four-line record is a compact lesson in that. The XML comment states the rule directly (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:162-166): the cache round-trips values throughSystem.Text.Json, so every member is a JSON primitive. A value object such as Email or a rawbyte[]here would not survive a distributed backing store, which is why the token digest is carried as Base64 text (:172) and the expiry as Unix seconds (:175) rather than asbyte[]andDateTimeOffset.[Rubric §11, Security]also applies through one member name:TokenHashBase64, notToken. The record is structurally incapable of holding the secret it guards. - Walkthrough: four members, in the order they matter.
string TokenHashBase64(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:172): Base64 of the SHA-256 of the issued token, never the token itself (:167). Validation re-hashes the presented token and compares digests, so the cache never holds redeemable material.UserIdentifierType UserId(:173): the account the token redeems to (:168). Storing the id in the record is what lets redemption resolve the user without a second lookup by email.int FailedAttempts(:174): wrong tokens presented against this record so far (:169), the counter the attempt cap is enforced against.long ExpiresAtUnixSeconds(:175): when the record expires (:170). This one exists for a specific reason explained at the rewrite site: when a failed attempt bumps the counter, the record is re-cached with the remaining lifetime computed from this field, so a wrong guess cannot extend how long the token stays redeemable (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:138,:146-152).
- Why it's built this way: being a
recordgives the non-destructivewithexpression that the attempt-counter update relies on (entry with { FailedAttempts = attempts },MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:150), so the rewrite is a copy rather than a mutation. Beinginternalkeeps a cache-layout detail out of the package's public API: nothing outside the Infrastructure assembly should be able to construct or read one. See ADR-091 for why the reset lifecycle lives in the cache at all. - Where it's used: written by
IssueAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:82-88), read byValidateAndConsumeAsync(:100), and rewritten byRecordFailedAttemptAsync(:148-152). It appears nowhere else.
JwtSettings
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSettings.cs:16· Level 1 · class (sealed)
- What it is: the
Jwtsection: issuer, audience, signing algorithm, the key material for whichever algorithm is selected, and the two token lifetimes. It adds the piece attributes cannot express, algorithm-aware validation of the key material. - Depends on: JwtSigningAlgorithm (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSettings.cs:30), which is what puts it at Level 1, plusSystem.ComponentModel.DataAnnotationsfor[Required]and, critically, for theIValidatableObjectinterface (:1,:16). - Concept introduced:
IValidatableObjectfor conditional requirements. Attributes describe a property in isolation, so they cannot say "this one is required only when that one has a particular value".IValidatableObjectis the options-validation extension point for exactly that case: the type implements a singleValidatemethod that yields oneValidationResultper failure, and.ValidateDataAnnotations()runs it alongside the attribute checks. This class is the framework's canonical example, and says so in its own doc (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSettings.cs:13-14).[Rubric §11, Security]assesses credential handling. The HS256 branch does not merely check that a secret is present, it checks the length: fewer than 32 characters fails, and the message explicitly tells the operator to replace the placeholder with a real secret from user-secrets or environment variables (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSettings.cs:72-77). That is deliberate: a short or shipped-placeholder HMAC key is the failure mode that would otherwise reach production silently.[Rubric §15, Best Practices & Code Quality]assesses fail-fast posture. Registration pairs the bind with.ValidateDataAnnotations().ValidateOnStart()(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:541-544), so both the attribute checks andValidaterun at boot, not on the first token issued (ADR-070). - Walkthrough: one static field, eight
initproperties, one method.SectionName = "Jwt"(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSettings.cs:19).SigningAlgorithm(:30): defaults to JwtSigningAlgorithm.RS256, and the remarks give the reason (:24-29): asymmetric signing is what lets a validator verify a token without holding the key that mints one, so a host that never setsJwt:SigningAlgorithmgets the algorithm that survives extraction. A single-host monolith opts into HS256 explicitly.SecretForKey(:37),RsaPrivateKeyPem(:43),RsaPublicKeyPem(:50): none carries[Required], because whether it is required is decided inValidate. The docs are specific about the split: the private key is what an issuer signs with, the public key is what an in-process validator verifies with, and a service that fetches the key through JWKS at runtime leaves the public key unset (:45-49).Issuer(:54) andAudience(:58): both[Required](:53,:57), because they matter in every mode.AccessTokenExpirationMinutes(:61), default15;RefreshTokenExpirationDays(:64), default7. The short-access-plus-long-refresh split of ADR-050, expressed as defaults rather than as required configuration.Validate(ValidationContext)(:70-85): an iterator method with two independent checks. Under HS256,SecretForKey.Length < 32yields a failure namingSecretForKey(:72-77); under RS256, a null or whitespaceRsaPrivateKeyPemyields a failure namingRsaPrivateKeyPem(:79-84). Note the asymmetry: the private key is enforced here, the public key is not, because a service that only validates fetches it through JWKS.- The in-process validator enforces the other half at wiring time instead:
BuildValidationParametersthrows when RS256 is selected with noRsaPublicKeyPem, and the message points atAddForwardedJwtBearerfor services that should fetch the key at runtime (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:629-642).
- Why it's built this way: keeping the conditional rule in code next to the properties it constrains, rather than in the registration call, means every host that binds this section gets the same guarantee without repeating it. The algorithm switch is a hard cutover that invalidates every existing token (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSigningAlgorithm.cs:17-18), so failing the boot on a half-configured section is much cheaper than discovering it at the first sign or the first validation. - Where it's used: bound in
AddCommonAuthentication(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:541-544), which then re-reads the section eagerly to build the token validation parameters at wiring time (:549-552); consumed by TokenService throughIOptions<JwtSettings>, which branches on the algorithm once in the constructor and caches the credentials (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:54-75).
RsaJwksProvider
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:14· Level 1 · class (sealed)
- What it is: the production IJwksProvider. It builds a
JsonWebKeySetfrom a PEM-encoded RSA public key configured via JwksSettings, and returns an empty set when publishing is disabled (the default) or no key is configured (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:7-12). - Depends on: IJwksProvider; JwksSettings via
IOptions<JwksSettings>(:2,:4,:15);System.Security.Cryptography.RSAandMicrosoft.IdentityModel.Tokens(JsonWebKeySet,RsaSecurityKey,JsonWebKeyConverter,SecurityAlgorithms). - Concept: this reinforces the JWKS story introduced on IJwksProvider (
[Rubric §11, Security],[Rubric §7, Microservices Readiness], ADR-004) and adds one lesson of its own about caching failure. The PEM parse cost is paid once and memoized in aLazy<JsonWebKeySet>(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:21-22), but the mode isLazyThreadSafetyMode.PublicationOnly, not the defaultExecutionAndPublication. The comment above it (:17-21) explains why, and it is worth internalizing: the defaultLazy<T>caches a factory exception forever, so a single transient IO failure reading the PEM file would brick/.well-known/jwks.json(and with it cross-service auth) until the process restarts.PublicationOnlycaches only a successful result and lets a later call retry; concurrent factory runs are harmless here becauseBuildKeySetis pure and disposes its ownRSA. That is[Rubric §29, Resilience]reasoning applied to one field declaration. - Walkthrough
- Primary constructor takes
IOptions<JwksSettings> options(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:14) and theLazy<JsonWebKeySet>captures it (:22-23);GetJsonWebKeySet()is just_cachedKeySet.Value(:26). BuildKeySet(JwksSettings settings)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:27) short-circuits to an emptyJsonWebKeySetwhen!settings.Enabled(:30-33) or when the resolved PEM is blank (:36-39). Those are the two paths that satisfy the IJwksProvider never-throw contract.- With a key present it imports the PEM into a disposable
RSA(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:40-41), exports only the public parameters (ExportParameters(includePrivateParameters: false),:44) into anRsaSecurityKeytagged with the configuredKeyId(:44-47), converts it withJsonWebKeyConverter.ConvertFromRSASecurityKey(:49), marks itUse = "sig"andAlg = SecurityAlgorithms.RsaSha256(:50-51) so consumers know the key's purpose and algorithm, and adds it to a fresh key set (:53-55). ResolvePem(JwksSettings settings)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:57) prefers the inlineRsaPublicKeyPem(:60-63) and otherwise readsRsaPublicKeyPathfrom disk with a synchronousFile.ReadAllText(:70), justified in the comment because the read happens on the first request and its success is cached, while a failure is deliberately not cached (:67-69). With neither configured it returnsnull(:73), which is what landsBuildKeySeton the empty-set path.
- Primary constructor takes
- Why it's built this way: exporting only the public parameters guarantees the private key can never reach the JWKS document even by accident. The inline-PEM-or-path pair supports both secrets-manager injection (env var or config) and a volume-mounted key file, which are the two deployment shapes the framework's samples use.
sealed, and registered singleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:183) so the cache is process-wide. - Where it's used: the JWKS minimal-API endpoint calls
GetJsonWebKeySet()per request; see JwksEndpointExtensions.
PasswordHasher
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:12· Level 1 · class
- What it is: the framework's one credential hasher. It derives a hash for a new password and
verifies a candidate password against a stored
(hash, salt)pair, using PBKDF2-HMAC-SHA512 at 600,000 iterations. PBKDF2 is the only algorithm in the type: every hash it writes and every hash it verifies goes through the same derivation (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:7-11). - Depends on:
IPasswordHasher, the Application-layer port it implements (imported atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/PasswordHasher.cs:3, declared atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/IPasswordHasher.cs:6). Externals are BCL only:System.Security.Cryptography(Rfc2898DeriveBytes,RandomNumberGenerator,CryptographicOperations) andSystem.Text.Encoding. - Concept introduced: a password is stored as a deliberately slow, salted one-way derivation.
[Rubric §11, Security]assesses credential-at-rest protection, and this 64-line type is where the framework makes its whole stance on it. Three decisions are visible in source and each one answers a specific attack. A per-credential 32-byte salt drawn from a CSPRNG (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:15,:31) means two users who pick the same password get different stored hashes, so a precomputed rainbow table buys an attacker nothing. A 600,000-iteration work factor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:24, OWASP 2023 guidance for PBKDF2-HMAC-SHA512) makes each guess in an offline cracking run cost real CPU time, which is the only defense once a dump has left the building. AndFixedTimeEquals(:53) compares the full buffer regardless of where the first byte differs, so the duration of a failed login carries no information about how close the guess was.[Rubric §15, Best Practices & Code Quality]applies to the shape rather than the crypto: one algorithm, no version flag, no per-app copy, so the question "which primitive authenticated this login" has exactly one answer everywhere. - Walkthrough
- Three private constants carry the whole policy:
SaltSize = 32(256 bits,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:15),HashSize = 64(512 bits,:18), andIterations = 600_000(:24). Changing the policy is a one-line edit; reading it takes no archaeology. HashPassword(string password)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:27) rejects null, empty and whitespace-only input up front withArgumentException.ThrowIfNullOrWhiteSpace(:29), draws a fresh salt fromRandomNumberGenerator.GetBytes(SaltSize)(:31, the cryptographic RNG and notRandom), derives 64 bytes viaRfc2898DeriveBytes.Pbkdf2over the UTF-8 password bytes (:32-37), and returns the(Hash, Salt)named tuple (:39). The salt is generated here rather than accepted from the caller, so no call site can accidentally reuse one.VerifyPassword(string password, byte[] hash, byte[] salt)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:43) guards all three arguments (:45-47), recomputes the derivation over the stored salt (:49), and returnsCryptographicOperations.FixedTimeEquals(computedHash, hash)(:53). Note the third argument at:49: it deriveshash.Lengthbytes, notHashSize, so the comparison is made at whatever length the stored hash actually has instead of failing on a length mismatch.ComputePbkdf2Hash(string password, byte[] salt, int outputLength)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:57-63) is the single expression-bodied derivation both public members route through, which is what keeps the write path and the verify path from ever drifting apart.
- Three private constants carry the whole policy:
- Why it's built this way: the type used to carry a second verification branch, an HMAC-SHA512
recompute selected at verify time by reading the stored salt length, kept alive for credentials
written under an older scheme.
ADR-102 records its
removal and supersedes
ADR-032: the legacy branch verified
a single-round digest offering none of the offline-cracking resistance the rest of the design argues
for, and keying algorithm selection on a data property meant the credential row, not configuration,
decided which primitive ran. With the legacy corpus gone, deleting the branch made the stored format
and the executing primitive the same fact. The remaining shape (no per-app hasher, no algorithm
parameter on the port) is what lets
[Rubric §11, Security]be assessed once for both applications. - Where it's used: registered as
services.TryAddSingleton<IPasswordHasher, PasswordHasher>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:563); the type is stateless, so a singleton is safe. Consumers reach it through the port:AuthenticationServiceBase<TUser>takes it as a constructor parameter (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:56), verifies on login (:159, and a failure there increments the brute-force counter at:160) and hashes on registration (:210);ChangePasswordHandlerBase<TUser, TCommand>verifies the current password then re-hashes the new one (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:27,:55,:61); andResetPasswordHandlerBase<TUser, TCommand>only hashes (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:33,:79), because possession of the reset token stands in for knowledge of the old password. - Caveats / not-in-source:
VerifyPasswordreturns a plainbool(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:43), so there is no "this row was derived with a lower work factor, rehash it" signal on the successful-login path. Verification recomputes with the currentIterationsconstant (:61), so raising it invalidates previously stored hashes, and nothing in this file or its call sites migrates them.
TokenService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:24· Level 2 · class
- What it is: the JWT issuer. It mints signed access tokens carrying the user id, email, role and
display name (plus any extra claims a caller supplies), generates opaque random refresh tokens,
projects both configured lifetimes as
TimeSpans, and re-reads an already-expired access token during the refresh flow. It signs with RSA-SHA256 or HMAC-SHA256, decided once at construction from configuration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:11-23). - Depends on:
ITokenService(the port it implements) andIDisposable;JwtSettings,JwtSigningAlgorithmandJwksSettings, all bound throughIOptions<T>(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:54-57). Externals:System.IdentityModel.Tokens.Jwt(JwtSecurityToken,JwtSecurityTokenHandler),Microsoft.IdentityModel.Tokens(SigningCredentials,SecurityKey,TokenValidationParameters),System.Security.Cryptography(RSA,RandomNumberGenerator), andTimeProviderfor the clock. - Concept introduced: asymmetric issuance, and one deliberate hole in validation.
[Rubric §11, Security]assesses token issuance and algorithm-confusion defense, and two choices here are worth reading slowly. First,GetPrincipalFromExpiredTokensetsValidateLifetime = falseon purpose (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:145) under a narrowly scoped#pragma warning disable CA5404(:145, restored at:147) whose comment states why: the refresh flow's whole job is reading claims out of a token that has already expired. Every other check stays on (:142-144). Second, the algorithm is pinned twice:ValidAlgorithmslimits validation to the one algorithm this instance was built for (:151), and the token header'salgis re-compared with an ordinal string comparison after validation succeeds (:160-161). That pair is the defense against algorithm substitution, where an attacker takes the RSA public key (which is published on purpose) and presents it as an HMAC shared secret.[Rubric §7, Microservices Readiness]applies to the algorithm switch itself: RS256, the default, lets an extracted service validate a token without ever holding the issuer's private key, because it fetches the public key from the JWKS document thatRsaJwksProviderpublishes (ADR-004). HS256 remains available for a single-process monolith, where issuer and validator are the same host and a shared secret costs nothing (:16-23).[Rubric §14, Testability]shows up in the constructor: the clock is an injectedTimeProviderwith aTimeProvider.Systemfallback (:63), so a test can assertiat/nbf/expwithout waiting for wall-clock time to pass. - Walkthrough
- Fields (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:26-35): the settings snapshot, theTimeProvider, theSigningCredentialsused to sign, theSecurityKeyand algorithm string used to validate, and two nullable ownedRSAhandles. The comment at:33-34explains whyIDisposableis on the class at all:RsaSecurityKeydoes not own theRSAit wraps, so something has to. - The constructor
(
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:54-75) materializes all key material exactly once, so no token operation re-parses a PEM. ForJwtSigningAlgorithm.RS256it builds RSA credentials and pinsSecurityAlgorithms.RsaSha256(:65-70); the key id it passes isjwksSettings?.Value.KeyIdfalling back to a defaultJwksSettingsinstance (:68), which is how the samekidends up on both the token and the published JWK (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:45). Otherwise it builds HMAC credentials and pinsHmacSha256(:73-74). GenerateAccessToken(userId, email, role, fullName, additionalClaims)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:78-115) reads the clock once (:86), then builds six claims (:92-100):sub(the user id, formatted withCultureInfo.InvariantCulture),jti(a fresh GUID, so a token is individually identifiable),iatas Unix seconds, and the standard name, email and role claims. The comment at:88-91is the design note worth internalizing:subis the only carrier of the user id. A duplicate custom claim used to ride alongside it, which meant two values that could disagree and two claim names every reader had to know; readers now go throughClaimsPrincipalExtensionsinstead. Caller-supplied claims are appended (:102-105), then aJwtSecurityTokenis assembled with issuer, audience,notBeforefrom the injected clock andexpiresatnow + AccessTokenExpirationMinutes(:107-113) and serialized (:115).GenerateRefreshToken()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:118-122) returns 64 CSPRNG bytes Base64-encoded. It is not a JWT and carries no claims: it is an opaque bearer string whose only property is being unguessable, and the store it is compared against is what gives it meaning.AccessTokenLifetimeandRefreshTokenLifetime(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:125,:129) project the configured minutes and days, so callers computing an expiry timestamp never re-read settings and never disagree with the token just minted.GetPrincipalFromExpiredToken(string token)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:137-171) builds the validation parameters described above (:140-152), validates (:158), applies the post-validationalgre-check and returnsnullwhen the token is not aJwtSecurityTokenor the header disagrees (:160-164), and swallows every exception intonull(:168-171). A malformed, forged, or wrong-issuer token therefore produces a plain "no principal" answer rather than a parser exception leaking to the caller.Dispose()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:174-178) releases both ownedRSAhandles.BuildHmacCredentials(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:180-192) throwsInvalidOperationExceptionwhenSecretForKeyis missing (:183-187) and Base64-decodes it into aSymmetricSecurityKeyused for both signing and validation (:190-192).BuildRsaCredentials(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:194-249) throws whenRsaPrivateKeyPemis missing (:199-203), imports the private key (:205-208), and stamps the key id on the signing key (:214) so every RS256 token carries akidheader. The comment at:210-213gives the reason: a validator reading the published JWKS selects the key by name, and without akidit has to try every published key, which stops working the moment a rotation publishes two. The validation key prefers the configuredRsaPublicKeyPemand otherwise derives the public parameters from the private key (:223-231), so an issuer configured with only a private key still validates its own tokens during refresh; it carries the same key id (:236). Both nestedtry/catchblocks dispose the partially builtRSAbefore rethrowing (:239-243,:245-249), so a bad PEM does not leak a native handle. Because all of this runs in the constructor, missing or malformed key material fails at host startup, not on the first login.
- Fields (
- Why it's built this way:
ADR-004 records the
asymmetric-issuance rationale. The DI lifetime deserves its own read at the registration site
(
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:556-562): the comment there records that a scoped lifetime disposed the underlyingRSAat end-of-request while the staticCryptoProviderCacheinMicrosoft.IdentityModel.Tokensstill held the cachedAsymmetricSignatureProviderwrapping it, throwingObjectDisposedExceptionon the next RS256 sign. Singleton is correct because the constructor depends only on singleton options and the service is stateless afterwards. - Where it's used: registered as
services.TryAddSingleton<ITokenService, TokenService>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:562) and consumed through the port byAuthenticationServiceBase<TUser>: the lifetime feeds the response's expiry (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:102-103, with a 15-minute floor when the setting is non-positive), the refresh flow reads the expired token (:278), and refresh tokens are minted at:627and:667. That base also wraps this service in a privateSessionStampingTokenServicepass-through (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:773) which appends theAuthClaimTypes.SessionId(sid) claim through theadditionalClaimsparameter when a session is armed (:782-800, the claim added at:797), so per-device session identity is layered on without this type knowing about sessions at all. Thesubclaim it writes is whatCurrentUserServiceandClaimBasedUserIdProviderread back. - Caveats / not-in-source: nothing here rotates or reloads key material. The keys are read once in
the constructor
(
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:54-75) and the instance is a singleton, so a key change takes a host restart. Refresh-token storage, rotation and revocation are not in this file either: it only produces the random string.
IPasswordChangeableUser
MMCA.Common.Domain ·
MMCA.Common.Domain.Auth·MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:11· Level 3 · interface
- What it is: the password-rotation surface an Identity module's
Useraggregate exposes to the shared ChangePasswordHandlerBase<TUser, TCommand> workflow. It is one method on top of IAuthUser (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:5-9). - Depends on: IAuthUser (its base interface,
:11) and Result fromMMCA.Common.Shared.Abstractions(:1). - Concept: capability interfaces layered by workflow.
[Rubric §1, SOLID]assesses interface segregation, and this is the pattern applied twice over: aUserthat only ever authenticates satisfies IAuthUser; aUserwhose app offers self-service password change implements this one and getsPasswordHashandPasswordSaltalong with it, because the workflow must verify the current credential before writing the new one (the XML comment states exactly this reason,:7-9). Inheritance here encodes a real dependency between capabilities rather than a taxonomy.[Rubric §4, DDD]also applies: the method returns Result, so the aggregate can refuse the change (an invariant failure) instead of the handler assuming success. - Walkthrough: one member,
Result ChangePassword(byte[] newPasswordHash, byte[] newPasswordSalt)(:19). The aggregate receives already-hashed material, never a plaintext password: hashing is the handler's job via IPasswordHasher, so no plaintext ever reaches the Domain layer or an EF change tracker. - Why it's built this way: keeping the hash-and-salt pair as the parameter shape mirrors IAuthUser's two properties and IPasswordHasher's tuple return, so the whole chain from handler to aggregate speaks one vocabulary. See ADR-032.
- Where it's used: as the generic constraint
where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IPasswordChangeableUseron the shared change-password workflow (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:29), which verifies the current password (:55), hashes the new one (:61), and callsChangePasswordwith the result (:62). The forgot-password sibling, ResetPasswordHandlerBase<TUser, TCommand>, calls the same member on the redeem path after IPasswordResetTokenService has identified the account (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:80-81). Both apps' User aggregates declare it (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:34-35,MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Users/User.cs:29-30), which is also how they pick up IAuthUser.
IUserPreferences
MMCA.Common.Domain ·
MMCA.Common.Domain.Auth·MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:10· Level 3 · interface
- What it is: the stored UI-preference surface an Identity module's
Useraggregate exposes to the shared preference read and write workflows: preferred culture, preferred theme, and a single method that replaces both (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:5-8). - Depends on: Result from
MMCA.Common.Shared.Abstractions(:1). Nothing else; it is deliberately not tied to IAuthUser, because preferences are orthogonal to credentials. - Concept: null as "not chosen".
[Rubric §27, i18n]assesses whether locale is a first-class, persisted user choice rather than a per-session guess, and[Rubric §19, State Management]assesses where such UI state lives. Both properties are nullable, and the contract states thatnullmeans the user has not chosen that preference (:7-8), which is what lets the UI fall back to a browser or host default without needing a separate "is set" flag. See ADR-027 for the culture model and ADR-028 for the theme model. - Walkthrough:
string? PreferredCulture(for example"es",:13) andstring? PreferredTheme("light"or"dark",:16) are read-only.Result UpdatePreferences(string? preferredCulture, string? preferredTheme)(:25) replaces both at once. The subtlety is documented at:18-21: because the method is a whole-object replace, the shared workflow always passes the currently stored value for any field the request leftnull, so writing one preference never silently clears the other. That read-then-merge is visible in the caller (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53). - Why it's built this way: one replace method keeps the aggregate's invariant check in a single place, and pushing the merge into the workflow keeps the null-means-unchanged policy out of every app's
User. Returning Result lets the aggregate reject an unsupported culture or theme value. - Where it's used: the read workflow constrains
where TUser : AuditableBaseEntity<UserIdentifierType>, IUserPreferencesand projects both properties into a response (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:23,:44); the write workflow constrainswhere TUser : AuditableAggregateRootEntity<UserIdentifierType>, IUserPreferences(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:26). Both are cross-linked as GetUserPreferencesHandlerBase<TUser> and ChangePreferencesHandlerBase<TUser, TCommand>, and both apps' User aggregates implement the interface.
RefreshSession
MMCA.Common.Domain ·
MMCA.Common.Domain.Auth·MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/RefreshSession.cs:31· Level 3 · class (sealed)
- What it is: one refresh-token session, meaning a single device's right to mint access tokens for one user, held as a hash of the issued refresh token. A user has as many rows as they have signed-in devices, so signing in on a phone no longer signs the same account out of a laptop (BR-205/206,
MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/RefreshSession.cs:7-10). - Depends on: Result and Error (
:3), theUserIdentifierTypealias, and from the BCLSystem.Security.Cryptography.SHA256,System.Text.Encoding, andConvert.ToHexString(:1-2). Persisted by EFRefreshSessionStore behind IRefreshSessionStore, mapped by RefreshSessionModelBuilderExtensions, swept by RefreshSessionCleanupService, tuned by RefreshSessionSettings. - Concept introduced: a refresh token is a credential, so store its digest and make reuse detectable.
[Rubric §11, Security]assesses how a long-lived credential is stored, rotated, and revoked, and this one class carries three separate properties that are each worth understanding on their own.- Hash at rest. The plaintext refresh token exists only in the response that hands it to the client; the row keeps
TokenHash(:63-64), so a database read cannot mint tokens (:11-15). That forces one design consequence the comment calls out explicitly: because lookups are by hash, the digest must be unsalted and deterministic. A per-row salt would make the token unfindable. This is the opposite trade-off from PasswordHasher, and legitimately so: a refresh token is 64 random bytes from a CSPRNG rather than a human-chosen password, so there is no dictionary to run against it and no value in slowing the digest down. - Rotation leaves a chain. Using a session revokes it and records its successor in
ReplacedByTokenHash(:16-21,:75-79). The point is not bookkeeping: presenting an already-rotated token lands on a revoked row rather than on nothing, and that difference is the signal that a token was replayed. A stolen-and-replayed token therefore triggers revocation of the whole family instead of failing quietly (BR-206 reuse detection, ADR-050). - Framework bookkeeping, not an aggregate. The class comment (
:22-29) is explicit that this is a flat record like OutboxMessage and AuditTrailEntry: no audit stamps, no soft-delete flag, no concurrency token. The reason matters for[Rubric §8, Data Architecture]: rows are never deleted or edited except to be revoked, and no global query filter may hide a revoked row, because the reuse check depends on finding it. It is also mapped only where a consumer opts in (ApplyRefreshSessionConfiguration), since sessions belong to the Identity module's database rather than to every data source (ADR-006 database-per-service).
- Hash at rest. The plaintext refresh token exists only in the response that hands it to the client; the row keeps
- Walkthrough
- Width and reason constants (
MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/RefreshSession.cs:33-55):TokenHashLength = 64(the width of a hex-encoded SHA-256 digest,:34),IpAddressMaxLength = 45(sized to fit an IPv4-mapped IPv6 literal,:37),UserAgentMaxLength = 512(:40),ReasonRevokedMaxLength = 64(:43), and the four revocation reasonsReasonRotated(:46),ReasonSignedOut(:49),ReasonReuseDetected(:52), andReasonSessionCap(:55). Publishing the widths aspublic conston the Domain type is what lets the EF configuration derive every column width from the same numbers (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/RefreshSessionModelBuilderExtensions.cs:47,:52,:56-58) rather than repeating magic numbers in a mapping file. - State (
:57-92):Iddefaults to a freshGuid(:58);UserId,TokenHash,CreatedAtandExpiresAtarerequiredandinit-only (:61-70), so a session cannot be constructed without them and cannot be rewritten afterwards. The three mutable members carryprivate setand change only throughRevoke:RevokedAt(:73),ReplacedByTokenHash(:79),ReasonRevoked(:82).IpAddress(:89) andUserAgent(:92) are optionalinit-only capture. The comment onIpAddress(:84-88) is a good example of documenting what a field is not for: it identifies a session in a "your devices" list and gives an audit trail for a revocation, and it is never part of a validation decision, so a mobile client changing networks is not signed out. - Derived state:
IsRevoked => RevokedAt is not null(:95) andIsActiveAt(DateTime utcNow) => !IsRevoked && ExpiresAt > utcNow(:99). Passing the instant in rather than reading a clock keeps the type free of ambient time, which is what makes it directly unit-testable (see RefreshSessionTests). Create(...)(:112-145), the factory returningResult<RefreshSession>in the framework's standard shape (see the primer on factory methods and the Result pattern). Two guards: a blank token fails withRefreshSession.TokenRequired(:120-126), and an expiry at or before creation fails withRefreshSession.ExpiryInPast(:128-134), bothError.Validation. On success it hashes the token on the way in (:139), so the plaintext never reaches a property, and truncates the two optional capture fields to their column widths (:142-143). Truncating in the factory rather than trusting the caller is what keeps an oversizedUser-Agentheader from turning a login into a database error.HashToken(string refreshToken)(:160-164):Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken))), guarded byArgumentException.ThrowIfNullOrWhiteSpace(:162). The<remarks>(:151-157) is the one piece of this file to read twice: the encoding is part of the contract, not an implementation detail, because a consumer's data migration has to reproduce it exactly to carry existing tokens over. It even gives the T-SQL equivalent,CONVERT(char(64), HASHBYTES('SHA2_256', CONVERT(varchar(max), Token)), 2), and explains both halves of why it matches: style 2 emits upper-case hex with no0xprefix, and thevarcharconversion is what makes the hashed bytes UTF-8 rather than SQL Server's default UTF-16.[Rubric §8, Data Architecture]and[Rubric §34, Architecture Governance & Documentation]both apply here: a hash format that a migration must reproduce is a published contract, and it is documented as one.Revoke(DateTime revokedAt, string reason, string? replacedByTokenHash = null)(:174-189): the only mutator. It is idempotent by refusal (:166-169), returningError.Invariant("RefreshSession.AlreadyRevoked", ...)when the session is already revoked (:176-182) rather than silently overwriting, so the first reason and instant recorded are the ones kept. That matters for forensics: a session revoked by reuse detection must not have that reason overwritten by a later sign-out. On success it stamps the instant, the truncated reason, and the successor hash (:184-186).Truncate(:191-192) is the shared private helper, returning the value unchanged when it is null, empty, or already short enough.
- Width and reason constants (
- Why it's built this way: ADR-097 records the move from one plaintext refresh-token column on the user row to a session table, and ADR-050 the rotation-and-reuse-detection model the chain implements. Keeping the class free of audit stamps and soft-delete is deliberate rather than an omission, and keeping
Create/Revokeas the only ways in and out means every row in the table was validated and every revoked row carries a reason. - Where it's used: AuthenticationServiceBase<TUser> is the main consumer. It hashes a presented token to look the session up (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:352,:593), creates one per login (:628) and per rotation (:668), revokes on sign-out (:360,:367,:385,:456), revokes the live family on reuse detection (:603,:691), and evicts the oldest session withReasonSessionCapwhen a user exceedsRefreshSessions:MaxActiveSessionsPerUser(:733, documented at:111). Rotation itself is a claim rather than a plain mutation: IRefreshSessionStore.TryRotateAsyncrevokes withReasonRotatedand links the successor (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IRefreshSessionStore.cs:104; the EF implementation does it as a conditionalExecuteUpdate,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFRefreshSessionStore.cs:129,:142). The table is mapped throughApplyRefreshSessionConfiguration(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:680), and the account-deletion path deliberately does not revoke sessions (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:103-107): the refresh flow re-fetches the user through the soft-delete query filter, so an erased account's sessions stop working the moment the delete commits.
IErasableUser
MMCA.Common.Domain ·
MMCA.Common.Domain.Auth·MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:30· Level 4 · interface
- What it is: the erasure surface an Identity module's
Useraggregate exposes to the shared DeleteUserHandlerBase<TUser, TCommand> workflow: soft-delete the row, then irreversibly anonymize the personal data it still holds (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:6-9). - Depends on: IAnonymizable (its base, contributing
Result Anonymize(),:1and:30) and Result (:2). - Concept introduced: why a
Delete()that already exists on the base entity is redeclared here. This is the most instructive comment in the file and it is worth reading in full (:11-29). AuditableBaseEntity<TIdentifierType> already has aDelete(). But an app'sUsermay hide it (public new Result Delete()) to couple account-specific behavior to deletion. A hidden method is not an override, and C# member lookup on a generic type parameter prefers the members of its class constraint, so a shared workflow writinguser.Delete()would bind to the base implementation and silently skip the app's version. Because the appUserlists this interface in its own base list, the interface map resolves to the most derivedDelete()declared on the app type, so invoking it through the interface forces interface dispatch and reaches exactly the member the app intended.[Rubric §1, SOLID](Liskov: the hidden method is exactly the substitutability hazard this closes) and[Rubric §15, Best Practices & Code Quality]both apply, and this is a case where a language rule, not a style preference, dictates the design. The second paragraph (:25-28) adds the compile-time guarantee: the base entity deliberately does not implement this interface, so a consumer that forgets to declare it fails the generic constraint at compile time rather than losing behavior at run time. - Walkthrough: one declared member,
Result Delete()(:37), documented as soft-delete plus whatever the app couples to deletion (:32-34), returning a failure when the account is already deleted (:36). Inherited from IAnonymizable isResult Anonymize(), which must be idempotent. The two-step order is visible in the caller: cast once to the interface (IErasableUser erasable = user;,MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:114, with the reason spelled out at:88-92),erasable.Delete()first (:94), the app's own tail hook next (OnAfterSoftDeleteAsync,:101), thenerasable.Anonymize()(:108), each short-circuiting on failure. - Why it's built this way: soft-delete alone hides a row but retains its personal data, so it does not satisfy an erasure request; anonymize-in-place overwrites the personal fields while keeping the row so foreign keys and the audit trail survive (ADR-005). Splitting the two into separate members lets the workflow run app-specific work between them, which the handler documents as the only point where an app can both read the personal data and know the delete succeeded (
MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:41-42).[Rubric §30, Compliance, Privacy & Data Governance]assesses exactly this: an erasure path that does not destroy referential integrity. - Where it's used: the generic constraint
where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IErasableUseron the shared delete-user workflow (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:62), implemented by each app's User aggregate (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:34-35,MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Users/User.cs:29-30). - Caveats / not-in-source: the interface's own comment says an app typically hides
Delete()to revoke the refresh token (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:16,:34). That phrasing predates the move to RefreshSession rows and no longer describes either app. ADC is the only consumer that hides the method, and its version callsbase.Delete()and raises aUserDeleteddomain event (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:341-350), with the XML comment there stating outright that sessions are not touched and do not need to be. MMCA.Store does not hideDelete()at all. The load-bearing lesson (interface dispatch over a possibly-hidden base member) is unchanged; the example in the comment is stale.
LoginProtectionService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:19· Level 5 · class (sealed)
- What it is: the cache-backed brute-force and rate-limiting service: exponential-backoff account lockout after repeated login failures, plus a per-IP registration rate limit (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:9-18). - Depends on: ILoginProtectionService (the Application port); LoginProtectionSettings via
IOptions<>(:21, snapshotted at:23); ICacheService (:20); Result and Error (:4); and the Email value object, used purely as a normalizer (:5). - Concept introduced: counter keys must be normalized the same way the lookup is.
[Rubric §11, Security]assesses brute-force protection and rate limiting;[Rubric §12, Performance & Scalability]assesses whether it is one shared service rather than logic copied per endpoint. Two mechanisms in this file deserve close reading.- Key normalization (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:34-43, documented at:25-33):NormalizeIdentityruns the supplied address through Email.Createand uses the normalized value (:39-41). Without it, the counter keys are built from raw request input while the user lookup runs against the normalized value object, soUser@x.com,user@x.comand" user@x.com "resolve to one account but get independent attempt counters, and an attacker defeats the ADR-029 backoff just by varying capitalization. A malformed address (which never matches a user but still increments a counter) falls back to the same trim-and-lowercase shape (:41) so its attempts collapse onto one key too. The#pragma warning disable CA1308(:40) is scoped and justified: lowercase is the RFC 5321 normalizationEmailitself performs. PasswordResetTokenService copies this helper verbatim and cites this type as the reason (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:34-38). - The lockout curve (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:80-90):excessAttempts = newCount - MaxFailedAttempts(:82) driveslockoutSeconds = Math.Min(1 << Math.Min(excessAttempts, 30), MaxLockoutSeconds)(:88), doubling the lockout per excess failure (1s, 2s, 4s, and so on) up to the configured cap. The innerMath.Min(excessAttempts, 30)clamps the shift exponent, and the comment explains why (:84-87): C# masks anintshift count to five bits, so1 << 31is negative and1 << 32wraps back to1, which would silently shrink the lockout for a sufficiently persistent attacker. Since1 << 30already exceeds the[Range(1, 3600)]cap on LoginProtectionSettings.MaxLockoutSeconds, deep excess always lands on the cap.
- Key normalization (
- Walkthrough
- Key builders:
LockoutKeyproduceslogin:lockout:{normalized}(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:45),AttemptsKeyproduceslogin:attempts:{normalized}(:47),RegistrationKeyproducesregistration:ip:{ipAddress}(:136, and note this one is not normalized: an IP literal is already canonical). CheckLockoutAsync(string email, CancellationToken)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:50): reads the boolean lockout key (:53) and returnsError.Unauthorized("Auth.TooManyAttempts", ...)when set, otherwiseResult.Success()(:55-60). A cache miss is treated as not locked out (?? false), so a cache outage fails open on lockout rather than locking everyone out.IncrementFailedAttemptsAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:64): increments the attempts key with theFailedAttemptWindowMinutesTTL (:75-78), and once the count reachesMaxFailedAttemptswrites the lockout key with the exponential TTL (:80-90).ResetFailedAttemptsAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:94): removes both keys on a successful login (:96-97).CheckRegistrationRateLimitAsync(string? ipAddress, ...)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:101): a null or empty IP is unrestricted (:103-106); otherwise it compares the per-IP count againstMaxRegistrationsPerIpPerHourand fails withAuth.RegistrationRateLimitExceeded(:109-116).IncrementRegistrationCountAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:120): no-ops on a missing IP (:122-125) and otherwise increments the per-IP counter with theRegistrationRateLimitWindowMinutesTTL (:130-133). The comment (:127-129) notes the TTL is refreshed on every write, so the window slides rather than staying anchored to the first registration, which only ever tightens the limit.
- Key builders:
- Why it's built this way: reusing ICacheService (Redis in production, in-memory fallback) instead of a bespoke store keeps the service thin and lets counters expire naturally by TTL rather than needing a sweep job;
IOptions<>keeps every threshold configurable per environment (ADR-029). Registered scoped (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:149). - Where it's used: injected into AuthenticationServiceBase<TUser> (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:57), which calls all five members across its login and registration flows: the lockout check (:131), an increment on both the unknown-user and wrong-password branches (:146,:161), the reset on success (:178), and the registration rate-limit check and increment (:197,:256). Incrementing on the unknown-user branch as well as the wrong-password branch is what keeps the endpoint from becoming a user-enumeration oracle by timing or by lockout behavior. - Caveats / not-in-source: the increment is documented in source as not atomic on the distributed cache today (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:66-74). DistributedCacheService.IncrementAsyncis a read-modify-write, because the RedisINCRit used to issue wrote a plain string key whileIDistributedCachereads entries back as hashes, and the mismatch made the counter unreadable (WRONGTYPE). The accepted cost: genuinely parallel attempts can overwrite each other's increments, so a concurrent burst can stay underMaxFailedAttempts. Sequential guessing, which is what a credential-stuffing run against one account looks like, still trips the lockout. The comment names the two ways to close the gap (a Lua script that increments within the hash layout, or moving counters offIDistributedCache); neither is implemented today.
PasswordResetTokenService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26· Level 5 · class (sealed)
- What it is: the IPasswordResetTokenService implementation, and the whole forgot-password token lifecycle in one file: issue a single-use token for an address, throttle how often one address can ask, hash the token at rest, cap wrong guesses, and consume the token on a successful redeem (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:12-24). - Depends on: IPasswordResetTokenService (the Application port,
:5); PasswordResetSettings viaIOptions<>(:28, snapshotted at:32); ICacheService (:6,:27); PasswordResetEntry (its cached record); Result and Error (:7); the Email value object as a normalizer (:8); and from the BCLSHA256,RandomNumberGenerator,CryptographicOperations, andSystem.Buffers.Text.Base64Url(:1-3). - Concept introduced: a reset token is a bearer credential, so treat it like a password.
[Rubric §11, Security]assesses credential issuance and redemption;[Rubric §8, Data Architecture]assesses picking the right store for the right lifetime. Four properties are designed in, and the class doc lists all four up front (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:15-24).- Hashed at rest. Only
SHA256.HashData(...)of the token is stored (:55-56,:83), so a cache dump does not hand out working reset links. Like RefreshSession and unlike a password, a reset token is high-entropy (32 random bytes,:30,:79) and short-lived, which is why a plain digest is sufficient here where PasswordHasher needs 600,000 PBKDF2 iterations: there is no dictionary to run against a 256-bit random value. - One active token per email. The key is derived purely from the address (
:51), soSetAsyncoverwrites (:88) and an older link stops working the moment a newer one is requested. - Attempt cap. Wrong tokens are counted on the record and the record is discarded at
MaxValidationAttempts(:140-144), which turns the token into a credential you cannot grind at. - No schema change, no sweeper. The whole lifecycle rides ICacheService, so expiry is the cache TTL rather than a background job over a table (ADR-091). Compare LoginProtectionService, which reaches the same conclusion for lockout counters.
- Hashed at rest. Only
- Walkthrough
- Primary constructor takes
ICacheService cacheServiceandIOptions<PasswordResetSettings> settings(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26-28), snapshottingsettings.Valueinto_settings(:32).TokenByteLength = 32(:30) is the only other constant. NormalizeIdentity(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:40-49) is the same Email.Create-then-fallback shape as LoginProtectionService, and its doc comment cites that type as the reason (:34-39): keys built from raw request input would giveUser@x.comanduser@x.comindependent tokens and independent request counters while resolving to one account. Two key builders follow:TokenKeyproducespwdreset:token:{normalized}(:51) andRequestKeyproducespwdreset:req:{normalized}(:53).HashTokenis the shared SHA-256 helper (:55-56).IssueAsync(string email, UserIdentifierType userId, CancellationToken)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:59): throttle first. It increments the per-email request counter with theRequestWindowMinutesTTL (:66-69) and fails withError.Unauthorized("Auth.ResetThrottled", ...)once the count exceedsMaxRequestsPerEmail(:71-77). Only then does it mint the token: 32 CSPRNG bytes rendered withBase64Url.EncodeToString(:79, URL-safe because the token travels in a query string), builds a PasswordResetEntry holding the Base64 digest, the user id, a zero attempt count and the absolute expiry as Unix seconds (:82-86), caches it under the token key with the configured lifetime (:88), and returns the raw token to the caller to email (:90). The raw token exists only in that return value: it is never written anywhere.ValidateAndConsumeAsync(string email, string token, CancellationToken)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:94): loads the entry (:99-100) and returnsInvalidToken()when there is none (:101-104). AFormatExceptiondecoding the stored Base64 removes the unreadable record rather than leaving it to expire (:107-116). The comparison isCryptographicOperations.FixedTimeEqualsover the two digests (:118), the same timing-side-channel defense PasswordHasher uses, withtoken ?? string.Emptyso a null token hashes rather than throwing. A mismatch records a failed attempt and returns the same generic failure (:120-121). On a match it removes both the token key and the address's request counter (:126-127), so a successful reset does not leave the user throttled out of a later legitimate request (:124-125), and returns the entry'sUserId(:129).RecordFailedAttemptAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:132): computesattempts = entry.FailedAttempts + 1and the remaining lifetime fromExpiresAtUnixSeconds(:137-138). AtMaxValidationAttempts, or once the remaining lifetime is non-positive, it deletes the record (:140-144). Otherwise it rewrites the entry withentry with { FailedAttempts = attempts }and a TTL of the remaining seconds, not a fresh lifetime (:146-152), because a wrong guess must not be able to extend how long the token stays redeemable.InvalidToken()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:155-159) is the single failure factory: unknown, expired, mismatched and attempt-capped all collapse to oneAuth.InvalidResetTokenerror with one message. That uniformity is deliberate: distinct errors would make the endpoint an oracle for which addresses have an outstanding reset.
- Primary constructor takes
- Why it's built this way: ADR-091 records the decision. It extends ADR-029 (the cache-backed protection idiom reused here) and sits beside ADR-032, which decided how a password is stored but not how a user who has lost one gets a new one. Keeping the token out of the database is what makes the feature additive: no migration, no new table, and nothing to reap.
- Where it's used: registered
services.TryAddScoped<IPasswordResetTokenService, PasswordResetTokenService>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:155), directly after thePasswordResetSettingsbinding (:137-140). ForgotPasswordHandlerBase<TUser, TCommand> callsIssueAsyncand emails the resulting link (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:73); ResetPasswordHandlerBase<TUser, TCommand> callsValidateAndConsumeAsync(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:62-64) before the save, because leaving the token live until the write succeeds would open a replay window, and a token burned by a later invariant failure only costs the user one more reset request (:58-60). It is unit-tested by PasswordResetTokenServiceTests. - Caveats / not-in-source: the per-email request throttle inherits LoginProtectionService's non-atomic increment, and the source says so where it matters (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:64-65): concurrent requests can undercount, which loosens the throttle but never tightens it. The failed-attempt rewrite is a read-modify-write too, so a burst of simultaneous wrong guesses can lose increments against the attempt cap; sequential guessing still trips it.
AuthClaimTypes
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthClaimTypes.cs:9· Level 0 · class (static)
- What it is: the three claim-type name constants the framework's own token vocabulary rests on,
sitting alongside the standard
System.Security.Claims.ClaimTypesvalues (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthClaimTypes.cs:5-9). - Depends on: nothing first-party at runtime. Its doc comments point at
IPermissionRegistry for the role-derived half of the permission model
(
AuthClaimTypes.cs:14) and at ClaimsPrincipalExtensions.FindUserIdValuefor the reader that papers over claim-name mapping (AuthClaimTypes.cs:24). - Concept introduced, a claim vocabulary owned by the framework rather than by each reader.
[Rubric §11, Security]assesses how authentication and authorization are modeled and which facts a principal is allowed to carry;[Rubric §15, Best Practices & Code Quality]assesses single points of change. A claim type is just a string, so writer and reader agreeing on it is a convention with no compiler behind it. Putting all three names in oneconstholder is what makes the token issuer and every reader provably agree.Permission(AuthClaimTypes.cs:17, value"permission") carries a single granted capability. The load-bearing design note is in its doc comment (AuthClaimTypes.cs:11-16): permission claims are honored in addition to the permissions a role confers through IPermissionRegistry, and baking them into the token is explicitly optional, because role-derived permissions work without them. A token can therefore stay small (roles only) and still authorize against capabilities, which is what makes the model in ADR-020 backward compatible with plain role checks.Subject(AuthClaimTypes.cs:27, value"sub") is the single authoritative carrier of the user identifier in every token the framework mints. The doc comment states the trap that follows (AuthClaimTypes.cs:19-26): one value reaches readers under two different claim types, because the JWT bearer handler maps inboundsubontoClaimTypes.NameIdentifierwhile a handler that materializes an identity straight from a token's claims leaves the rawsubin place.SessionId(AuthClaimTypes.cs:40, value"sid") is the RFC 7519 / OpenID Connect session id: the identifier of the refresh session the access token was minted for, which is to say the device behind the token (AuthClaimTypes.cs:29-33). It is additive, never required (AuthClaimTypes.cs:34-38): rotation mints a new session and therefore a newsid, a token issued before the claim shipped simply carries none, and nothing validates it. A missing or unparsable value degrades to "no current session known", never to a rejected token.
- Walkthrough: three
public const stringfields and nothing else.constrather thanstatic readonlyso the values are usable in attribute arguments and in patterns that require compile-time constants, the same reason RoleNames usesconst. - Why it's built this way:
ADR-020 makes the
permission layer opt-in, and
ADR-097 adds the
per-device session identity that
sidnames. Keeping the claim types as shared constants means the writer and the reader cannot drift apart on a string. - Where it's used:
Permissionis read by PermissionAuthorizationHandler, which checkscontext.User.HasClaim(AuthClaimTypes.Permission, requirement.Permission)before falling back to the registry (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:30-31), and is described (without being named) in the HasPermissionAttribute doc comment as the "explicit permission claim" alternative to the role-derived path (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:5-11).Subjectis written by TokenService asJwtRegisteredClaimNames.Sub(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:93, with the one-carrier rationale at:88-91) and read through ClaimsPrincipalExtensions by CurrentUserService, ClaimBasedUserIdProvider, the IdempotencyFilter, and the rate-limit partitioner.SessionIdis stamped by the private SessionStampingTokenService decorator inside AuthenticationServiceBase<TUser> (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:800) and read back byFindSessionIdfor the "my sessions" endpoint (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:187).
- Caveats / not-in-source: no shipped token issuer in this repo writes a
Permissionclaim. Across both applications and the framework there are exactly four references to that constant (its declaration, the handler's doc comment atPermissionAuthorizationHandler.cs:10, the handler's check at:29, and one test), and the only writer in the tree is a test that hands the claim to a principal directly (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Authorization/PermissionAuthorizationHandlerTests.cs:31). The claim path is real and covered, but every deployed grant today flows through roles.
ChangePasswordRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ChangePasswordRequest.cs:8· Level 0 · record struct (readonly)
- What it is:
(string CurrentPassword, string NewPassword), the payload for an authenticated password change (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ChangePasswordRequest.cs:3-10). - Depends on: nothing first-party.
- Concept: the same
readonly record structDTO shape introduced by AuthenticationResponse.[Rubric §11, Security]: requiring the current password re-proves the caller's identity before a credential change, so a stolen session alone cannot lock the owner out. The strength rules forNewPasswordare deliberately not here; they live in each app's validator (see ChangePasswordRequestValidator), which is what lets Store and ADC differ on policy while sharing the contract. - Walkthrough: two positional parameters (
ChangePasswordRequest.cs:8-10); no body. - Where it's used: bound as the body of the shared
PUT passwordendpoint on UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:87,93), reached only through theIUserScopedCommand<ChangePasswordRequest>constraint on the app's command (UserAccountAuthControllerBase.cs:48); carried by each app's ChangePasswordCommand (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:15-16and its Store twin); validated by ADC'sMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/ChangePasswordRequestValidator.cs:11, which requires a non-emptyCurrentPassword(:15-16) and includes the shared StrongPasswordRules<T> forNewPassword(:18). - Caveats / not-in-source: nothing in this type prevents the password strings from reaching a log. That is an operational convention (PII masking plus the "never log the body" habit), not a compile-time or runtime guarantee.
ChangePreferencesRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ChangePreferencesRequest.cs:10· Level 0 · record (sealed)
- What it is:
(string? Culture, string? Theme), the payload for updating the signed-in user's stored UI preferences (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ChangePreferencesRequest.cs:3-10). - Depends on: nothing first-party. It is the write-side counterpart of UserPreferencesResponse.
- Concept introduced, null-means-unchanged partial update.
[Rubric §9, API & Contract Design]assesses how a contract expresses partial intent, and[Rubric §19, State Management]assesses where user state lives and who may overwrite it. A naive "PUT the whole preferences object" endpoint has a real bug hiding in it: the app-bar language switcher knows only the culture and the theme toggle knows only the theme, so whichever fires last would sendnullfor the other field and silently erase the user's other choice. The doc comment states the rule that removes the bug (ChangePreferencesRequest.cs:3-7): anullfield leaves that preference unchanged, so each control can persist its own field in isolation. The rule is honored in exactly one place, the shared handler'scommand.Request.Culture ?? user.PreferredCulture/command.Request.Theme ?? user.PreferredThemecoalesce (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:54-55), which is why the contract can afford to be this terse. The two preferences themselves come from ADR-027 (culture) and ADR-028 (theme). - Walkthrough: two nullable positional parameters on a
sealed record(ChangePreferencesRequest.cs:10); no body. Unlike the auth siblings this is a reference type (record, notrecord struct), which matters at the boundary: the controller binds it with[FromBody], so a completely absent body deserializes tonullrather than to a silently valid all-defaults struct. - Why it's built this way: the payload record was byte-identical in both applications' Identity
modules and was hoisted here, while the command record stayed app-side because ADC marks it
ICacheInvalidatingand Store does not. That split is spelled out in the handler base's remarks (ChangePreferencesHandlerBase.cs:18), and it is a good illustration of the framework's hoisting rule: share the shape, leave the per-app policy behind. - Where it's used: the body of the shared
PUT preferencesendpoint (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:113,119), which hands it to the app's command through the abstractCreateChangePreferencesCommandfactory (UserAccountAuthControllerBase.cs:78-80,127); the generic constraint that ties the two together iswhere TChangePreferencesCommand : IUserScopedCommand<ChangePreferencesRequest>(UserAccountAuthControllerBase.cs:49). It is consumed by ChangePreferencesHandlerBase<TUser, TCommand> and carried by each app's ChangePreferencesCommand (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:14-15). - Caveats / not-in-source: this type validates nothing. Rejecting an unknown culture such as
"xx"or an unknown theme such as"blue"is the domain's job, insideUpdatePreferenceson theUseraggregate behind IUserPreferences, which returns a Result the handler propagates. Note also that the Blazor UI does not send this exact type: ApiUserPreferenceWriter declares its own privateUserPreferencesRequest(string? Culture, string? Theme)wire record (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/ApiUserPreferenceWriter.cs:29,65), so the two shapes agree by convention rather than by a shared reference.
ForgotPasswordRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ForgotPasswordRequest.cs:8· Level 0 · record struct (readonly)
- What it is: a single-field request
(string Email)that starts a password reset (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ForgotPasswordRequest.cs:3-9). - Depends on: nothing first-party. It pairs with ResetPasswordRequest, which completes the flow this one starts.
- Concept introduced, the anti-enumeration contract.
[Rubric §11, Security]assesses whether an endpoint leaks facts an attacker can harvest, and[Rubric §9, API & Contract Design]assesses whether a contract's shape matches the answer it is allowed to give. A password-reset entry point is the classic account-enumeration oracle: if "no such user" answers differently from "email sent", an attacker can test an address list against your user base for free. The doc comment on this one-field record records the countermeasure as part of the contract (ForgotPasswordRequest.cs:3-6): the response is always accepted, so the payload carries no signal about whether the address belongs to an account. The rule is not aspirational, it is implemented in three coordinated places:- the request validator checks only the shape of the address, and its doc comment says exactly
why it stops there, because a 400 on an unknown address would be the oracle the always-accepted
response exists to close
(
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:6-9,13-16); - the handler returns
Result.Success()for a malformed address, an address with no account, a throttled request, and a failed send alike, logging the real reason instead of returning it (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:62,70,77,96,100); - the endpoint answers
202 Acceptedon every well-formed request (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:79,92).
- the request validator checks only the shape of the address, and its doc comment says exactly
why it stops there, because a 400 on an unknown address would be the oracle the always-accepted
response exists to close
(
- Walkthrough: one positional
string Email(ForgotPasswordRequest.cs:8-9); no body, no validation attributes, no normalization. Normalizing the address is the handler's job, throughEmail.Create(command.Request.Email)(ForgotPasswordHandlerBase.cs:58), which is what lets the DTO stay a raw wire shape while the Email value object owns the parsing rules. - Why it's built this way: ADR-091 records the cache-backed reset design this request opens. Keeping the payload to a single field means there is nothing else for an attacker to probe, and keeping the "always accepted" promise in the type's doc comment puts it where a reader meets it before the handler.
- Where it's used: bound as the body of the anonymous, rate-limited
POST forgot-passwordaction on PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:75,83), which turns it into the app's command through an abstract factory (:61) constrained to ICommandWithRequest<out TRequest> (:46); shape-validated by ForgotPasswordRequestValidator; handled by ForgotPasswordHandlerBase<TUser, TCommand>; posted by AuthUIService'sRequestPasswordResetAsync, deliberately over a bearer-free client so a signed-in caller does not bind the reset to the current session (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:194,202). - Caveats / not-in-source: both applications now wire this vertical. ADC has a
ForgotPasswordCommand
(
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12-13) and a derived controller (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:36); Store has the same pair (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:11,MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/PasswordResetController.cs:33).
IPermissionRegistry
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionRegistry.cs:13· Level 0 · interface
- What it is: the abstraction that maps roles to the fine-grained permissions they grant, and the
single place that knows which roles confer which capabilities
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionRegistry.cs:3-13). - Depends on: nothing first-party; its remarks reference RoleNames for the case-insensitivity rule.
- Concept introduced, permission (capability) authorization over role checks.
[Rubric §11, Security]assesses the authorization model, and[Rubric §1, SOLID]assesses dependency inversion: endpoints depend on an abstraction, not on a role name. Instead of scattering[Authorize(Roles = "Organizer")]across endpoints, code authorizes against a permission (a capability such assessions:manage) and this registry translates a principal's roles into the permissions they hold. The payoff is decoupling: adding a role or reshaping who-can-do-what is a registry change, not an edit to every endpoint (IPermissionRegistry.cs:4-7). The remarks also fix the comparison rules (IPermissionRegistry.cs:9-12): role lookups are case-insensitive, permission values are compared ordinally, and implementations are expected to be immutable and thread-safe. Those three sentences are what let the implementation be a frozen, lock-free structure. - Walkthrough: two members.
GetPermissions(string role)(IPermissionRegistry.cs:20) returns the permission set for a role, or an empty set for an unknown role, never a throw (IPermissionRegistry.cs:15-17).HasPermission(IEnumerable<string> roles, string permission)(IPermissionRegistry.cs:28) answers whether any of a principal's roles grants the permission: the hot path the authorization handler calls per request. - Why it's built this way: ADR-020 records the decision. An empty-set-on-miss contract keeps callers branchless, and pushing the who-grants-what knowledge behind one interface is the capability-security expression of the framework's habit of hiding decision logic behind an abstraction.
- Where it's used: implemented by PermissionRegistry (built via
PermissionRegistryBuilder); registered as a lazily-built singleton by
AuthorizationExtensions
(
MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:71) and injected into PermissionAuthorizationHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:14).
LoginRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/LoginRequest.cs:8· Level 0 · record struct (readonly)
- What it is: the email/password payload for authentication:
(string Email, string Password)(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/LoginRequest.cs:3-10). - Depends on: nothing first-party.
- Concept: the
readonly record structDTO introduced by AuthenticationResponse.[Rubric §11, Security]: the doc comment (LoginRequest.cs:7) records the rule that the password travels over TLS and is never logged. That convention is enforced operationally, not by this type, but the intent is documented at the source where a reader will meet it. - Walkthrough: two positional parameters (
LoginRequest.cs:8-10); no body. - Where it's used: shape-validated by LoginRequestValidator, which is
deliberately minimal (non-empty plus address shape) so that no field-level 400 hints at which half
of the credential was wrong
(
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:6-10,15-20); then handled by AuthenticationServiceBase<TUser>.LoginAsync(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:121-122), which is reached through AuthControllerBase'sPOST login(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:69,76-77).
OAuthCodeExchangeRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/OAuthCodeExchangeRequest.cs:11· Level 0 · record struct (readonly)
- What it is: a single-field request
(string Code)that exchanges a short-lived, single-use OAuth completion code for the token pair (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/OAuthCodeExchangeRequest.cs:3-12). - Depends on: nothing first-party.
- Concept reinforced, security by construction.
[Rubric §11, Security]and[Rubric §26, Front-End Security]both assess safe token handling, in particular whether credentials can leak into places that are logged or replayed. The doc comment (OAuthCodeExchangeRequest.cs:3-9) explains why the indirection exists: the server mints an opaque code after the external-provider callback succeeds and carries that in the redirect URL, so the access and refresh tokens never appear in the address bar, browser history, theRefererheader, or server access logs. The mint side is right there in the controller, with the same reasoning as a comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:127-134). ADR-036 records the decision, and ADR-043 extends the pattern to the native mobile callback. - Walkthrough: one positional
string Code(OAuthCodeExchangeRequest.cs:11-12). - Why it's built this way: the code is worthless once redeemed, which is the property that makes
putting it in a URL acceptable.
OAuthControllerBase
.ExchangeAsyncrejects a blank code (OAuthControllerBase.cs:157-160), looks the code up in ICacheService (OAuthControllerBase.cs:162-170), and then removes it so a replayed code cannot mint a second token pair (OAuthControllerBase.cs:172-173); an unknown, burned, or expired code all return the same HTTP 400 with a deliberately non-specific message (OAuthControllerBase.cs:178-181). The action is also marked[NonIdempotent]with the reason inline: replaying a stored response would defeat the burn and let a leaked code mint the same tokens again (OAuthControllerBase.cs:150). - Where it's used: the body of the OAuth
exchangeendpoint (OAuthControllerBase.cs:149,153-155), called by the UI's/auth/oauth-completepage after the provider redirect lands (OAuthControllerBase.cs:137-140,143-144).
RefreshTokenRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/RefreshTokenRequest.cs:9· Level 0 · record struct (readonly)
- What it is:
(string AccessToken, string RefreshToken); it sends the expired access token alongside the refresh token so the server can read its claims without forcing a full re-authentication (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/RefreshTokenRequest.cs:3-11). - Depends on: nothing first-party.
- Concept: the
readonly record structDTO shape from AuthenticationResponse.[Rubric §11, Security]: this is the request half of ADR-050's rotation scheme. Carrying the expired token lets the server reconstruct the principal cheaply, while the opaque refresh token is what actually gates the rotation, so possession of an expired access token alone buys nothing. - Walkthrough: two positional parameters (
RefreshTokenRequest.cs:9-11); no body. Both are required to be non-empty, and the validator's doc comment says why each is needed: the access token for claim extraction, the refresh token for rotation verification (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:6-9,14-18). - Where it's used: shape-validated by
RefreshTokenRequestValidator, handled by
AuthenticationServiceBase<TUser>
.RefreshTokenAsync(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:267-268), which rejects an unreadable token or a principal with no usable user id with anAuth.InvalidTokenfailure before it ever looks at the refresh token (AuthenticationServiceBase.cs:284-285,291-295); exposed by AuthControllerBase'sPOST refresh(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:117,122-123).
ResetPasswordRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ResetPasswordRequest.cs:9· Level 0 · record struct (readonly)
- What it is:
(string Email, string Token, string NewPassword), the payload that completes a password reset by redeeming the single-use token that ForgotPasswordRequest caused to be mailed (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ResetPasswordRequest.cs:3-12). - Depends on: nothing first-party; the
readonly record structshape from AuthenticationResponse. - Concept, the three-field redemption payload and the single collapsed failure.
[Rubric §11, Security]: the address is carried alongside the token so the server can verify that the token was issued for that address rather than trusting the token in isolation, which is what the handler'sValidateAndConsumeAsync(request.Email, request.Token, ...)call checks (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:62-64). The anti-enumeration discipline that governs the forgot half continues here in a different form: an unknown, expired, mismatched or attempt-capped token and a vanished account all collapse to oneAuth.InvalidResetToken401 with the same message, so the response distinguishes none of them (ResetPasswordHandlerBase.cs:18-23,65-69,74-78,96-100). One ordering decision is worth internalizing: the token is consumed before the save, and the comment says why (ResetPasswordHandlerBase.cs:59-61), because leaving it live until the write succeeds would open a replay window in which the same token is redeemed twice; a token burned by a later invariant failure costs the user one more reset request, which is the cheaper failure. - Walkthrough: three positional parameters (
ResetPasswordRequest.cs:9-12); no body. The doc comment repeats the never-logged rule forNewPassword(ResetPasswordRequest.cs:8), the same convention LoginRequest states. - Why it's built this way: the new password goes through the same
StrongPasswordRules<T> that registration and
change-password use, so a reset cannot become a way around the complexity policy
(
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:7-11,16-23). Reusing one rule set rather than restating it per endpoint is the reason the policy cannot drift. ADR-091 covers the token side. - Where it's used: bound as the body of the anonymous, rate-limited
POST reset-passwordaction on PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, which answers 204 on success (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:99,103,108,117); shape-validated by ResetPasswordRequestValidator; handled by ResetPasswordHandlerBase<TUser, TCommand>, which hashes the new password, lets the aggregate apply its own invariants, saves, and then clears the account's failed-attempt count so a user who reset because of a lockout is not still locked out (ResetPasswordHandlerBase.cs:80-81,87,89-90); posted by AuthUIService'sResetPasswordAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:211,221). ADC carries it in a ResetPasswordCommand markedICacheInvalidating(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:15-16); Store carries its own, without that marker (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:12).
RoleNames
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RoleNames.cs:12· Level 0 · class (static)
- What it is: the canonical role-name string constants shared across all layers and both
applications (
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RoleNames.cs:3-12). - Depends on: nothing first-party.
- Concept introduced, centralized constants over magic strings.
[Rubric §11, Security]assesses authorization correctness and[Rubric §15, Best Practices & Code Quality]assesses duplication and single points of change. Roles are stored as plain strings and emitted as JWT claims (RoleNames.cs:8), so a typo in a literal does not fail to compile: it silently fails to authorize, or worse, silently authorizes nobody while looking correct. ReferencingRoleNames.Organizerinstead of"Organizer"moves that class of bug to compile time. The remarks also record that role comparisons should be case-insensitive (RoleNames.cs:10, pointing at ICurrentUserService.IsInRole), which is the same equality contract RoleValue and PermissionRegistry implement. - Walkthrough: five
public const stringfields.Organizer(RoleNames.cs:15),Attendee(RoleNames.cs:18), andContentEditor(RoleNames.cs:25) are ADC roles;Admin(RoleNames.cs:28) andCustomer(RoleNames.cs:31) are Store roles.ContentEditoris documented as a strict subset of the Organizer's capabilities (RoleNames.cs:20-24): it curates the session catalog without rights over event structure, rooms, questions, session selection, or user administration. That subset is precisely the case ADR-020 uses to justify the permission layer, and you can see the payoff in ADC's grants, where Organizer and Admin each receive the module's whole permission set while ContentEditor receives only the content-management slice, one extra line rather than an edit to every attribute (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:43-44,50). - Why it's built this way:
constrather thanstatic readonlyso the values can appear in attribute and option initializers that require compile-time constants. Keeping both apps' roles in one shared file is a deliberate trade: a small amount of irrelevance for each consumer in exchange for one authoritative list. - Where it's used: the per-app role types
(UserRole;
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:20,23,30andMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Users/UserRole.cs:17,20), each module's permission grants (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:41-50,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:58-61,MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:44-47), and the ownership-filter bypass role in ADC's Engagement module (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:54). - Caveats / not-in-source: AuthorizationExtensions registers no named
role policies today:
AddAuthorizationPolicieswires only ASP.NET Core's authorization services and the permission mechanism (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:23-38, with the "permissions are the one authorization model" statement at:17-20). Role names therefore reach authorization only through the registry, not through a pre-registered policy per role.
AuthenticationRequest
MMCA.Common.Shared ·
MMCA.Common.Shared·MMCA.Common/Source/Core/MMCA.Common.Shared/AuthenticationRequest.cs:15· Level 0 · record struct
- What it is: a device-aware authentication request shape for mobile/MAUI clients. It carries device metadata (id, form factor, platform, model, manufacturer, name, type) alongside the user's email so that sessions and tokens can be tracked per device (
MMCA.Common/Source/Core/MMCA.Common.Shared/AuthenticationRequest.cs:3-5). - Depends on: nothing first-party. Eight positional
stringparameters and the BCL only. - Concept: an immutable
readonly record structrequest DTO. Thereadonly record structgives value-based equality and a compact, copy-by-value payload for something that crosses the wire once per login, and the eight positional parameters (MMCA.Common/Source/Core/MMCA.Common.Shared/AuthenticationRequest.cs:15-23) show the same request shape scaling from bare credentials to a credential-plus-context payload.[Rubric §11, Security]assesses credential handling and session management: capturing device identity at authentication time is the precondition for per-device session tracking, which is what the XML doc states the type exists for (MMCA.Common/Source/Core/MMCA.Common.Shared/AuthenticationRequest.cs:4-5). - Walkthrough: the whole type is one positional constructor with eight
stringmembers (MMCA.Common/Source/Core/MMCA.Common.Shared/AuthenticationRequest.cs:15-23):DeviceId,Email,DeviceFormFactor,DevicePlatform,DeviceModel,DeviceManufacturer,DeviceName,DeviceType. Note thatEmailis the plainstringhere and not the Email value object: this is a transport shape, and normalization happens further in. It is also the only type in the rootMMCA.Common.Sharednamespace; the rest of the auth request family lives underMMCA.Common.Shared.Auth. - Why it's built this way: a struct record keeps a small, short-lived login payload allocation-free while still giving structural equality and a
with-expression copy for free; every member beingstringkeeps it trivially serializable by any client transport. - Caveats / not-in-source: this type has no first-party consumer in the workspace source today. A search across all four .NET repos finds
AuthenticationRequestonly in its own declaration file, so the device fields are a published contract awaiting a caller rather than an active login path. The per-device story that did ship is RefreshSession, which captures IP and user-agent rather than these eight fields; treat the device-metadata contract as documented intent (MMCA.Common/Source/Core/MMCA.Common.Shared/AuthenticationRequest.cs:3-5), not as shipped behavior.
ClaimsPrincipalExtensions
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:18· Level 1 · class (static)
What it is: the one place the framework reads identity claims off a
ClaimsPrincipal: the raw user-id value, the parsed user id, and the refresh-session id (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:6-18).Depends on: AuthClaimTypes for the two claim names it reads (
ClaimsPrincipalExtensions.cs:27,58); the solution-wideUserIdentifierTypealias; BCLSystem.Security.ClaimsandSystem.Globalization(ClaimsPrincipalExtensions.cs:1-2).Concept introduced, one reader for a claim that arrives under two names.
[Rubric §11, Security]assesses whether identity resolution is correct and uniform, and[Rubric §15, Best Practices & Code Quality]assesses whether a fragile detail is centralized or copy-pasted. The trap is ASP.NET Core's inbound claim mapping. Tokens carry the user identifier in the standardsubclaim only, but that single value reaches readers under two different claim types depending on which pipeline produced the principal (ClaimsPrincipalExtensions.cs:8-16):- the JWT bearer handler maps inbound
subontoClaimTypes.NameIdentifier, the longhttp://schemas.xmlsoap.org/...URI; - a handler that materializes an identity straight from a token's claims leaves the raw
subin place. SessionCookieAuthenticationHandler is exactly that case: it buildsnew ClaimsIdentity(jwt.Claims, ...)from the decoded token (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieAuthenticationHandler.cs:60-61).
A reader that hard-codes either name works under one pipeline and silently returns "anonymous" under the other, and "silently anonymous" in an authorization path is the worst failure shape available. Routing every framework reader through
FindUserIdValuemakes both shapes resolve identically, and means a consumer that changes its claim mapping does not lose the current user (ClaimsPrincipalExtensions.cs:13-15). Note the deliberate use of C#'s classicthis-parameter extension methods here rather than theextension(T)blocks the codebase uses for DI registration: these are plain static helpers on a BCL type.- the JWT bearer handler maps inbound
Walkthrough: three extension methods, all null-tolerant by construction (every parameter is
ClaimsPrincipal?, so a null principal yields null rather than throwing).FindUserIdValue(this ClaimsPrincipal?)(ClaimsPrincipalExtensions.cs:26-28) is the primitive:principal?.FindFirst(AuthClaimTypes.Subject)?.Value ?? principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value. Rawsubwins, the mappedNameIdentifieris the fallback, and a principal carrying neither yieldsnull.GetUserId(this ClaimsPrincipal?)(ClaimsPrincipalExtensions.cs:40-44) parses that value into the module'sUserIdentifierTypeand returnsnullwhen the claim is absent or unparsable. It parses throughIParsable<TSelf>.TryParseinCultureInfo.InvariantCulture, which the remarks justify twice over (:34-38): it matches the writer (claims are formatted invariantly) and it stays correct if the solution-wide identifier alias changes shape, so the helper does not have to be rewritten when an app moves frominttoGuid.FindSessionId(this ClaimsPrincipal?)(ClaimsPrincipalExtensions.cs:56-60) reads thesidclaim andGuid.TryParses it. The remarks make the degradation explicit (:50-54):nullis an ordinary answer, not an error, because tokens issued beforesidshipped carry no such claim, and every reader treats its absence as "the caller's own session is unknown". Nothing authenticates on this value.
Why it's built this way: TokenService records the other half of the story in a comment (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:87-90): a duplicate custom user-id claim used to ride alongsidesub, which meant two values that could disagree and two claim names every reader had to know. Collapsing the writer to one claim (TokenService.cs:93) is only safe because one reader absorbs the mapping difference, which is this type. Thesidhalf comes from ADR-097.Where it's used: broadly, and always instead of a hand-rolled claim lookup. CurrentUserService resolves the ambient user id through
GetUserId()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:13,20); ClaimBasedUserIdProvider usesFindUserIdValue()to key SignalR connections (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ClaimBasedUserIdProvider.cs:15); the IdempotencyFilter uses it to scope an idempotency key to a caller (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:487); CurrentUserTargetingContextAccessor uses it for feature-flag targeting (MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/CurrentUserTargetingContextAccessor.cs:17,86); rate-limit partitioning uses it to build a per-user partition (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:93); AuthControllerBase usesFindSessionId()to tell the session list which row is the caller's own (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:187); AuthenticationServiceBase<TUser> usesGetUserId()on the principal recovered from an expired access token during rotation (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:288-291); and TestPrincipal writessubprecisely so test principals resolve the same way real ones do (MMCA.Common/Source/Hosting/MMCA.Common.Testing.UI/Infrastructure/TestPrincipal.cs:19,27).Caveats / not-in-source: MMCA.Store's Sales UI module defines its own unrelated
ClaimsPrincipalExtensionsin a different namespace (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Extensions/ClaimsPrincipalExtensions.cs:9); do not confuse the two when reading ausinglist. Also noteGetUserIdreturns a nullable value type, sois { } userIdpattern-matching (as ADC's Blazor pages use) is the idiomatic call shape, not a!dereference.
PermissionRegistry
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistry.cs:10· Level 1 · class (sealed)
- What it is: the immutable, thread-safe implementation of
IPermissionRegistry, backed by a frozen role-to-permissions map
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistry.cs:5-10). - Depends on: IPermissionRegistry;
System.Collections.Frozen(FrozenDictionaryandFrozenSet, BCL,PermissionRegistry.cs:1). - Concept introduced,
Frozen*collections for read-optimized immutable lookups.[Rubric §12, Performance & Scalability]assesses hot-path data-structure choices. AFrozenDictionaryorFrozenSetpays a higher one-time construction cost in exchange for faster repeated reads and no mutation support, which is exactly the authorization access pattern: built once at startup, queried on every authorized request, never written again. Immutability is also what makes the structure lock-free under concurrency, satisfying the interface's thread-safety expectation without a lock. The registry pins the interface's two comparison rules into the data structure itself: the outer dictionary usesStringComparer.OrdinalIgnoreCaseso role lookups are case-insensitive, while each permission set usesStringComparer.Ordinalso permission values must match exactly (PermissionRegistry.cs:25-28). - Walkthrough: a shared empty
FrozenSetsentinel (PermissionRegistry.cs:12) and the frozen map field (PermissionRegistry.cs:14). The constructor (PermissionRegistry.cs:21) guards its argument and freezes the supplied map with the two comparers (PermissionRegistry.cs:23-28).GetPermissions(PermissionRegistry.cs:32-35) returns the matching set, or the shared sentinel on a null or unknown role, so it never allocates and never throws: the empty-set-on-miss contract from the interface, made literal.HasPermission(PermissionRegistry.cs:38) guards its inputs (PermissionRegistry.cs:40-41), then walks the principal's roles and returns on the first role whose set contains the permission (PermissionRegistry.cs:43-53), so the common case of a matching first role costs one dictionary probe and one set probe. - Why it's built this way: freezing at construction trades a one-time build cost for fast, allocation-free, lock-free concurrent reads, which suits a startup-built structure hit on every authorized request (ADR-020).
- Where it's used: constructed by PermissionRegistryBuilder
.Buildand registered as the IPermissionRegistry singleton in AuthorizationExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:69-71); read by PermissionAuthorizationHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:14,31).
PermissionRegistryBuilder
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistryBuilder.cs:8· Level 2 · class (sealed)
- What it is: a mutable accumulator that collects role-to-permission grants and freezes them into
an immutable PermissionRegistry
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistryBuilder.cs:3-8). - Depends on: PermissionRegistry, its build target.
- Concept introduced, the builder pattern for multi-module contribution.
[Rubric §2, Design Patterns]assesses idiomatic pattern use and[Rubric §7, Microservices Readiness]assesses whether a module can declare only what it owns. The builder separates the accumulation phase (mutable, order-independent, contributed to by many modules during startup) from the finished phase (an immutable snapshot read by every request). The property that makes it work for a modular monolith is in the doc comment (PermissionRegistryBuilder.cs:5-6): multiple modules may grant permissions for the same role and the grants are unioned, so a module never needs to know what the others granted, and load order does not change the result. - Walkthrough: a case-insensitive backing
Dictionary<string, HashSet<string>>(PermissionRegistryBuilder.cs:14-15), preceded by a comment (PermissionRegistryBuilder.cs:10-12) that explains the scopedIDE0028suppression: a collection expression cannot carry theOrdinalIgnoreCasecomparer that keeps role keys case-insensitive, and the concreteDictionarytype is kept forCA1859.Grant(role, params permissions)(PermissionRegistryBuilder.cs:25) guards its inputs (:27-28), filters blank permissions (:30), then either unions into the existing set or seeds a new ordinalHashSet(:32-39), and returnsthisfor chaining (:41): additive and idempotent, so a duplicate grant from a second module is a no-op.Build()(PermissionRegistryBuilder.cs:46) projects the grants into anIReadOnlyDictionary<string, IReadOnlySet<string>>(keeping the case-insensitive comparer) and hands it to the PermissionRegistry constructor (PermissionRegistryBuilder.cs:48-53). - Why it's built this way: mutable while assembling, immutable once built is the safe way to let independent modules compose one shared authorization table at startup without any shared mutable state at runtime.
- Where it's used: AuthorizationExtensions registers exactly one
builder instance and a lazily-built singleton registry over it, so the registry is materialized on
first resolve, after every module has contributed
(
MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:58-74); modules reach it throughAddPermissions(...), which is deliberately safe to call once per module (AuthorizationExtensions.cs:40-55), as MMCA.ADC's Conference, Engagement, and Identity modules each do (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:41-50,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:58-61,MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:44-47). - Caveats / not-in-source: the lazy build means a
Grantcall made after the first IPermissionRegistry resolve would silently not take effect; the API doc says to call before the host is built (AuthorizationExtensions.cs:43), but nothing enforces it at runtime.
RoleValue
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RoleValue.cs:25· Level 3 · class (abstract)
What it is: the abstract base for a role value object. It stores one canonical string, gives it case-insensitive value equality and hashing, and offers a shared validation helper against a per-app set of known role names (
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RoleValue.cs:6-25).Depends on:
ResultandErrorfromMMCA.Common.Shared.Abstractions(RoleValue.cs:2), plusSystem.Collections.Frozenfrom the BCL (RoleValue.cs:1). Its doc comments point atRoleNamesfor the canonical strings (RoleValue.cs:9) and atICurrentUserService.IsInRolefor the comparison semantics it matches (RoleValue.cs:16). Conceptually a value object (seeValueObject) but deliberately not derived from that base, for a reason the next bullet unpacks.Concept introduced, a value object as an abstract class with type-guarded equality.
[Rubric §4, Domain-Driven Design]assesses whether identity-less concepts are modeled as value objects rather than bare primitives, and[Rubric §1, SOLID]applies to how an open hierarchy is left safe to extend. A role has no database identity: two "Organizer" values are the same role, so the type is defined by its value, which is exactly the value-object shape. Two design decisions make this base unusual, and both are written into the source:- No
IEquatable<T>on the base (RoleValue.cs:17-23). The remarks cite Sonar S4035: an unsealed type implementingIEquatable<T>breaks the equality contract, because a subclass instance compared through the base-typed interface can report an equality the derived type would reject. Instead equality is the plainobject.Equalsoverride, and it is type-guarded:GetType() == other.GetType()before the value comparison (RoleValue.cs:90-93), so a role of one concrete type is never equal to a same-valued role of another. A sealed derived type is then free to layer a strongly-typedIEquatable<TSelf>plus==/!=on top, which is what ADC'sUserRoledoes (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:17,77-83). - It does not derive from
ValueObject, and that is a fitness-rule consequence, not an oversight.ValueObjectsAreImmutableSealedInSharedrequires every concrete class whose base type starts withMMCA.Common.Shared.ValueObjects.ValueObjectto be sealed, immutable, and to live in the Shared layer (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Domain/ArchitectureRules.Immutability.cs:56-72). A concrete role type lives in its app's Domain layer (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:5), so deriving fromValueObjectwould fail that rule in every app. Keeping the base inMMCA.Common.Sharedwith its own equality also keeps it dependency-free and therefore usable from Blazor WebAssembly and UI code as well as Domain (RoleValue.cs:11-16). The same trade-off is made byEnumeration<TEnumeration>, whose own remarks nameRoleValueas the shipped precedent (MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Enumeration.cs:25-29).
[Rubric §11, Security]applies too, because role strings arrive as JWT claim values whose casing is not under this codebase's control. Every comparison here isOrdinalIgnoreCase, so an authorization check does not silently miss on"organizer"against"Organizer".- No
Walkthrough: a get-only
Value(RoleValue.cs:28) assigned by the protected constructor (RoleValue.cs:32), so an instance is immutable and only a derived type can create one. The staticValidate(role, knownRoles, source)(RoleValue.cs:42) null-guards the supplied set (RoleValue.cs:44), then returnsResult.Success()when the role is known, otherwise aResultfailure carrying anErrorof kindInvariant, codedUser.Role.Invalid, with the caller's method name assourceandroleastarget(RoleValue.cs:46-52). The membership test is the privateIsKnown(RoleValue.cs:63-65) and it is more careful than it first looks: the fast path is the supplied set's ownContains(correct and O(1) for the intendedOrdinalIgnoreCasesets, with arole ?? string.Emptycoalesce so a null role becomes a clean failure rather than aNullReferenceException), and a miss falls back to an explicit case-insensitiveAnyscan, so a set built with the default ordinal comparer still validates case-insensitively as the contract promises (RoleValue.cs:55-62). Role sets hold a handful of entries, so the fallback is negligible and only ever runs on a miss. The protected genericBuildLookup<TRole>(params TRole[] roles)(RoleValue.cs:75) freezes the supplied singletons into a case-insensitiveFrozenDictionarykeyed byValue(RoleValue.cs:80-83), so a derived type can back itsFromString/IsValidmembers with interned instances instead of re-allocating on every parse.ToStringreturns the value (RoleValue.cs:87), andGetHashCodeuses the ordinal-ignore-case hash (RoleValue.cs:96) so it stays consistent withEquals, which is the contract any dictionary orHashSetkey depends on.Why it's built this way: the abstract-class-plus-type-guard shape is how you share equality behavior across an open hierarchy of value objects without violating the equality contract, and the S4035 rationale is documented inline (
RoleValue.cs:17-23) so a future reader does not "helpfully" addIEquatable<T>to the base. The comparer-agnosticIsKnownfallback exists becauseValidateaccepts anyIReadOnlySet<string>: the type cannot see how the caller built the set, so it enforces its own promise instead of trusting the caller's comparer. That behavior is pinned by test, including the default-comparer case (MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Auth/RoleValueTests.cs:18), theOrdinalIgnoreCasecase (RoleValueTests.cs:30), a null role (RoleValueTests.cs:59), and a null role set throwing (RoleValueTests.cs:67).Where it's used: the two apps consume it differently, and the difference is instructive. ADC derives a full sealed value object,
UserRole, which fixes three roles (Organizer, Attendee, ContentEditor), interns them throughBuildLookup(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:20-33), exposesFromStringreturning aResult<UserRole>andIsValidover that frozen lookup (UserRole.cs:51-65), and adds a case-insensitiveIsOrganizerfor raw claim strings (UserRole.cs:75) plus the sealed-typeIEquatable<UserRole>and==/!=operators the base leaves to subclasses (UserRole.cs:77-89). Store'sUserRoleis a static class, not a subclass: it fixes Admin and Customer as string properties over anOrdinalIgnoreCaseset and calls the sharedRoleValue.Validatehelper for itsIsValid(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Users/UserRole.cs:14,26-30,37), so it inherits the rule set (case-insensitive membership, theUser.Role.Invalidcode) without inheriting the type. Both key their known-role sets off theRoleNamesconstants, andPermissionRegistryBuilderkeeps its role keys on the same case-insensitive comparer to match (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistryBuilder.cs:11).
RegisterRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/RegisterRequest.cs:13· Level 4 · record struct (readonly)
- What it is: the registration payload for a new account: email, password, first and last name,
and an optional postal address
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/RegisterRequest.cs:5-18). - Depends on:
AddressfromMMCA.Common.Shared.ValueObjects(RegisterRequest.cs:1), which is the only reason this otherwise Level 0-shaped DTO sits at Level 4. - Concept: the
readonly record structrequest shape introduced byLoginRequest; see that section for the value semantics.[Rubric §9, API & Contract Design]assesses whether the wire contract is explicit and evolvable. The optionalAddress? Address = nullparameter (RegisterRequest.cs:18) is the notable detail here: positional record structs support default parameter values, so a caller with no address simply omits it rather than needing a second overload or a null literal at the call site. That default is what lets one shared contract serve two apps with different profile shapes (see Where it's used). - Walkthrough: five positional parameters and no body (
RegisterRequest.cs:13-18): four strings plus the nullableAddress. The strings arrive raw, with no validation attributes and no normalization. Shape checking is the validator's job and semantic conversion is the domain factory's, which is the codebase's standing division of labor: ADC'sRegisterRequestValidatorcomposes reusable rule sets over the four strings and appliesAddressValidatoronlyWhenthe address is non-null (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:16-23), whileAuthenticationServiceBase<TUser>hands the whole request to an abstractCreateUser(RegisterRequest request, byte[] passwordHash, byte[] passwordSalt)that each app implements against its ownUseraggregate (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:514). Note thatPasswordis a plainstringon the contract and never travels past the hashing call:RegisterAsyncturns it into a hash and salt pair, and that pair, not the password, is what reachesCreateUserand the aggregate (AuthenticationServiceBase.cs:213-214).[Rubric §11, Security]. - Where it's used:
AuthenticationServiceBase<TUser>.RegisterAsync(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:187-188), theregisterendpoint onAuthControllerBase, which binds it[FromBody]on an anonymous, rate-limited, idempotent POST (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:93-102), and each app's register form. The two apps'CreateUseroverrides show why the address is optional: Store passesrequest.Addressstraight intoUser.Create(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/AuthenticationService.cs:52-60), while ADC ignores it entirely and creates the user from email, names, hash, salt, and the defaultUserRole.Attendee(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:109-116).
AuthenticationResponse
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Responses·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/AuthenticationResponse.cs:10· Level 0 · record struct (readonly)
- What it is: the success payload of authentication, carrying
AccessToken,RefreshToken, andAccessTokenExpiry, shared by the Identity API and the UI clients (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/AuthenticationResponse.cs:3-13). - Depends on: nothing first-party;
System.DateTime(BCL). - Concept introduced, the
readonly record structDTO.[Rubric §15, Best Practices & Code Quality]assesses consistent conventions and immutability, and[Rubric §9, API & Contract Design]assesses well-shaped request/response contracts. A positionalrecord structis a value type with a compiler-generated constructor, deconstruction, value equality, andToString;readonlymakes every field immutable. For small, short-lived request/response carriers this avoids a heap allocation while staying immutable, and it is this codebase's default shape for auth DTOs, reused by every sibling below. The explicitAccessTokenExpiry(AuthenticationResponse.cs:13) lets clients refresh proactively instead of waiting for a 401, which is the client-side half of ADR-051. - Walkthrough: three positional parameters and no body (
AuthenticationResponse.cs:10-13). - Why it's built this way: value semantics keep the type cheap, but they have one consequence
worth internalizing before you reuse the shape. A struct has no null, so a cache miss returns
default(AuthenticationResponse)rather thannull, and OAuthControllerBase therefore detects a missing exchange entry by testingstring.IsNullOrEmpty(response.AccessToken), with the reason written down at the call site (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:164-167). - Where it's used: produced by
AuthenticationServiceBase<TUser> from the shared
IssueTokensAsynchelper that login and registration both funnel through (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:183,263,474,494) and directly at the end of a rotation (AuthenticationServiceBase.cs:329); declared as the 200/201 response type on the three AuthControllerBase token endpoints (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:73,97,120); consumed by AuthUIService, DirectApiTokenRefresher, and CookieSessionRefresher.
RefreshSessionSummaryResponse
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Responses·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/RefreshSessionSummaryResponse.cs:23· Level 0 · record (sealed)
- What it is: one row of a user's "signed-in devices" list, describing a live refresh session in
the terms a person can recognize it by
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/RefreshSessionSummaryResponse.cs:3-5). - Depends on: nothing first-party at compile time. It is the sanitized projection of the
RefreshSession entity, and its
IsCurrentflag is computed from thesidclaim named by AuthClaimTypes.SessionId. - Concept introduced, the sanitized read model over a credential-bearing entity.
[Rubric §11, Security]assesses what a response is allowed to expose, and[Rubric §9, API & Contract Design]assesses whether a contract carries exactly the fields its consumers need. The entity behind this row holds the material the refresh-token reuse check runs on: aTokenHash(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/RefreshSession.cs:64) and the rotation linkReplacedByTokenHash(RefreshSession.cs:79). Both are deliberately absent from this response, and the doc comment states the reasoning (RefreshSessionSummaryResponse.cs:6-11): shipping either would hand every caller a queryable index of another session's credentials-at-rest for no gain, since nothing a client does with a session needs anything but its id. The rule is enforced by a reflection assertion rather than by review habit: a test asserts the type's property names contain neitherTokenHashnorReplacedByTokenHash(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Auth/RefreshSessionManagementTests.cs:199-202). This is the read-model half of the "never project a secret" discipline; the same instinct is what keeps password hashes out of every user DTO. - Walkthrough: six positional parameters on a
sealed record(RefreshSessionSummaryResponse.cs:23-29), documented one by one at:13-22.SessionId(Guid) is the value a per-device sign-out is addressed to (:13), which is why it is the only identifying field the row needs.CreatedAtandExpiresAt(DateTime, both UTC) say when this device signed in and when the session stops being usable even if never revoked (:14-15).IpAddressandUserAgentare nullable and explicitly labeled informational (:16-17): they are what a human recognizes a device by, and nothing authorizes on them.IsCurrent(bool) marks the session the calling access token was minted for. The doc comment records the degradation rule (:18-22): it is alwaysfalsefor a caller whose token predates thesidclaim, because nothing then identifies the caller's own device.
- Why it's built this way:
ADR-097 introduces the
per-device refresh session, and
ADR-050 is the rotation
scheme whose hashes this response must not leak.
IsCurrentis computed server-side rather than guessed by the client, which is what keeps the UI from having to parse a token to know which row is its own: the service compares each session id against acurrentSessionIdargument (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:421), and the interface documents that passingnullsimply marks no row as current (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:89-93). - Where it's used: produced by
AuthenticationServiceBase<TUser>
.GetSessionsAsync, which reads unrevoked sessions from IRefreshSessionStore, filters to those active at the current instant, orders newest-first, and projects each into this record (AuthenticationServiceBase.cs:401-424); returned by theGET my-sessionsendpoint on AuthControllerBase, which supplies the caller's own session viaUser.FindSessionId()(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:175-187); fetched client-side by AuthUIService.GetSessionsAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:229,236) and rendered by the Sessions page, which usesIsCurrentto disable the revoke action on the caller's own row (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor.cs:38,108-110,179). - Caveats / not-in-source:
IpAddressandUserAgentare whatever the client sent at issue time and are stored verbatim; nothing in this type or the projection validates, geolocates, or canonicalizes them, so a spoofed user-agent shows up as-is in the device list.
UserPreferencesResponse
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Responses·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/UserPreferencesResponse.cs:9· Level 0 · record (sealed)
- What it is:
(string? Culture, string? Theme), the read side of the stored UI preferences: what the server hands back when a returning user signs in (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/UserPreferencesResponse.cs:3-9). - Depends on: nothing first-party. It is the exact mirror of ChangePreferencesRequest.
- Concept: the null-means-absent convention. Where the request's
nullmeans "leave unchanged", the response'snullmeans the user has never chosen that preference (UserPreferencesResponse.cs:4-5).[Rubric §9, API & Contract Design]assesses whether the same wire shape can carry two different meanings without confusing the reader: here it can, because the two directions are separate types with separate doc comments rather than one reused DTO.[Rubric §27, i18n]and[Rubric §20, Design System & Theming]both apply, since this is the cross-device carrier for the culture and theme choices of ADR-027 and ADR-028: the browser's own storage is per device, and this response is what makes the choice roam with the account. - Walkthrough: two nullable positional parameters on a
sealed record(UserPreferencesResponse.cs:9); no body. Being arecord(not arecord struct) it also carries value equality, which the tests lean on directly, asserting whole-object equality rather than field-by-field (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/GetPreferences/GetUserPreferencesHandlerTests.cs:47,61). - Why it's built this way: like its request twin, the response record was byte-identical in both
applications' Identity modules and was hoisted into Shared, which is what let the read side become a
shared base generic parameterized only on the
Useraggregate rather than on the query and the response too (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21-22). - Where it's used: produced by
GetUserPreferencesHandlerBase<TUser>
from the aggregate's
PreferredCulture/PreferredTheme(GetUserPreferencesHandlerBase.cs:44), against a GetUserPreferencesQuery; declared as the 200 response of the sharedGET preferencesendpoint on UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:139,141,143). - Caveats / not-in-source: the handler reads through
GetReadRepository, not the write repository, and the remarks note this was a deliberate correction of a disagreement between the two app copies (ADC read, Store write), so Store gained a no-tracking read on adoption (GetUserPreferencesHandlerBase.cs:16,39). As with the request twin, the Blazor client does not deserialize into this type: ApiUserPreferenceReader readsauth/preferencesinto its own UI-sideUserPreferencesrecord and falls back to an empty one for anonymous users or any transport error (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/ApiUserPreferenceReader.cs:18,39-40,44-48).
ConcurrencyETag
MMCA.Common.Shared ·
MMCA.Common.Shared.Http·MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ConcurrencyETag.cs:24· Level 0 · class (static)
- What it is: the translator between the framework's optimistic-concurrency token (the EF Core
rowversionbyte array carried byIConcurrencyAware) and the HTTP entity tag that represents that token on the wire. It owns three header constants plus aFormat/TryParsepair (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ConcurrencyETag.cs:24-105). - Depends on: nothing first-party at runtime (the doc comment references
IConcurrencyAwareatConcurrencyETag.cs:7). BCL only:Convert.ToBase64String,Convert.TryFromBase64Chars,ReadOnlySpan<char>, and[NotNullWhen]fromSystem.Diagnostics.CodeAnalysis(ConcurrencyETag.cs:1). - Concept introduced, the HTTP conditional-request boundary for optimistic concurrency.
Optimistic concurrency itself is introduced at
IConcurrencyAware: a client echoes back the row version it last saw so a stale write is refused. This type is where that value becomes an HTTP citizen, and two decisions recorded in its own doc comment are the whole design. First, the tag is always weak (W/"...",ConcurrencyETag.cs:12-18): a strong entity tag promises byte-for-byte equality of the representation, and this one does not, because the same row version renders differently under afields=projection and says nothing about serializer formatting. Weak is the honest strength for a token that answers "is this the same version of the resource", which is exactly whatIf-Matchasks. Second, the payload is base64 of the raw token (ConcurrencyETag.cs:19-22), so the round trip is lossless and the value stays inside the quoted-string grammar RFC 9110 defines for an entity tag.[Rubric §9, API & Contract Design]assesses whether the framework speaks the standard protocol rather than inventing a private one: preconditions here travel in the headers HTTP already defines, so a generic client library can participate.[Rubric §8, Data Architecture]covers concurrency control as a deliberate persistence concern. This is also the same shared-wire-literal placement ruleIdempotencyHeadersfollows, and the doc comment says so: it lives in Shared "because both ends of the exchange need it: the API reads anIf-Matchvalue with it and the UI services write one with it" (ConcurrencyETag.cs:8-9). - Walkthrough:
- Three constants name the protocol:
IfMatchHeaderName = "If-Match"(ConcurrencyETag.cs:27),ETagHeaderName = "ETag"(:30), andWildcard = "*"(:33), theIf-Matchvalue that matches any current version. Format(byte[] rowVersion)(:40) null-guards (:42) and returnsstring.Concat("W/\"", Convert.ToBase64String(rowVersion), "\"")(:44), so a token renders as, for example,W/"AAAAAAAAB9E=".TryParse(string? value, out byte[]? rowVersion)(:62) is deliberately forgiving on the way in and strict about what counts as success. It nulls the out parameter first (:64), returnsfalsefor a blank value (:66-69), then takes only the first entry of a comma-separated list (:71-77). The remark explains why (:57-61): a conditional write here is a single-version precondition, since there is one row version to compare against, so a list beyond its first entry has no meaning. It then trims (:79), strips a case-insensitiveW/prefix (:81-84), strips the surrounding quotes when both are present (:86-89), and fails on an empty remainder (:91-94).- Decoding is allocation-conscious: it sizes a buffer from the candidate length (
:96), callsConvert.TryFromBase64Charsand rejects both a decode failure and a zero-length result (:97-100), then slices the buffer to the bytes actually written (:102). - The contract on
falseis spelled out in the doc (:52-56): a blank value, the wildcard, and anything that is not base64 all returnfalse, and the caller decides which of those is an error in its context. That is what letsSupportsIfMatchAttributetreat a wildcard as "no precondition" while treating unparsable input as a 400.
- Three constants name the protocol:
- Why it's built this way:
ADR-035 is the governing
record. It picks the header as the one transport because HTTP already defines
ETagandIf-Matchfor precisely this exchange (Website/docs-src/adr/035-optimistic-concurrency.md:23-24,147), and it states the Shared placement as a deliberate consequence: the type lives inMMCA.Common.Shared.Http"rather than in the API package precisely so both ends of the exchange can use it" (035-optimistic-concurrency.md:124-128). Keeping the wire format in one static class means the reader side and the writer side cannot disagree about weakness, base64, or list handling; pushing the error decision to the caller keeps the parser free of HTTP status opinions. - Where it's used: server side,
EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.SetConcurrencyETag(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:471) writesResponse.Headers[ConcurrencyETag.ETagHeaderName] = ConcurrencyETag.Format(rowVersion)(EntityControllerBase.cs:479) after a successful by-id read (EntityControllerBase.cs:436), and the CRUD base does the same after a create (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/CrudEntityControllerBase.cs:112);SupportsIfMatchAttributereads the request header (MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:106), compares against the wildcard (:110) and decodes withTryParse(:116). Client side,EntityServiceBase<TEntityDTO, TIdentifierType>formats the tag (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:199) and attaches it asIf-Match(EntityServiceBase.cs:394), as do ADC'sEventService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/EventService.cs:29,41),SessionQuestionUIService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/SessionQuestionUIService.cs:133,142) andLivePollUIService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LivePollUIService.cs:197,206). Framework coverage isConcurrencyETagTests(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Http/ConcurrencyETagTests.cs:16,21,33,47), and ADC's integration bases build theirIf-Matchvalues through the same helper (MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Infrastructure/ConferenceIntegrationTestBase.cs:49,59,63). - Caveats / not-in-source: the
ETagthis type renders exists to be echoed back on the next write. Nothing here implements conditional GET: no code path compares an inboundIf-None-Matchagainst it, which ADR-035 records explicitly (Website/docs-src/adr/035-optimistic-concurrency.md:182-184).
IcsEvent
MMCA.Common.Shared ·
MMCA.Common.Shared.Calendars·MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15· Level 0 · record
- What it is: one calendar entry handed to
IcsCalendarBuilder: a stableUid, aSummary, a UTC start and end, and two optional strings for description and location (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15-21). - Depends on: nothing first-party;
System.DateTimeOffset(BCL). - Concept introduced, the UTC-only calendar contract.
[Rubric §9, API & Contract Design]assesses whether a contract states its own invariants rather than leaving them to convention. The invariant here is written into the type's own doc comment: "Times are UTC by contract" (IcsEvent.cs:4). RFC 5545 lets a calendar carry local times paired with aVTIMEZONEblock that restates the zone's DST rules inside the document; getting that block right (and keeping it right as tzdata moves) is a well-known source of bugs. By declaring the two timestampsDateTimeOffsetand requiring them to already be UTC instants, this record pushes the wall-clock to UTC conversion onto the caller, which is where the zone knowledge actually lives, and lets the builder emit plainZ-suffixed timestamps with noVTIMEZONEmachinery at all (IcsEvent.cs:5-7). TheUidcarries a second contract: calendar clients de-duplicate re-imports by it, so it must be globally unique and stable across exports of the same thing (IcsEvent.cs:9). - Walkthrough: a positional
sealed recordwith six parameters and no body.Uid,Summary,StartsAtUtc,EndsAtUtcare required by position;DescriptionandLocationdefault tonull(IcsEvent.cs:16-21), which is how the builder decides to omit the corresponding lines entirely rather than emit an empty one. Arecord(reference type) rather than thereadonly record structthat the auth DTOs in this group use: entries are built into a collection and enumerated once, so there is no per-call allocation to avoid. - Why it's built this way: the framework ships no calendar NuGet dependency, so the shape of an
entry is the framework's to define. Keeping it to the six fields every calendar client honors is the
same minimal-subset judgement the builder documents at
MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:7-10. No ADR governs calendar export; the decision lives in these two files' doc comments. - Where it's used: ADC's Conference module builds entries from sessions in
CalendarExportMapper, which does the event-zone to UTC conversion the contract demands (itsToUtchelper atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:47) and composes theUidassession-{id}@atldevcon(CalendarExportMapper.cs:31-44, the id at:38). The mapped entries reachExportSessionCalendarHandler(.../ExportCalendar/ExportSessionCalendarHandler.cs:51-54) andExportEventCalendarHandler(.../ExportCalendar/ExportEventCalendarHandler.cs:46-53). - Caveats / not-in-source: nothing in the type enforces that
StartsAtUtcandEndsAtUtcreally carry a zero offset, that the end follows the start, or that theUidis unique. All three are contract-by-documentation; the only enforcement is the mapper that produces them.
IdempotencyHeaders
MMCA.Common.Shared ·
MMCA.Common.Shared.Http·MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:13· Level 0 · class (static)
- What it is: the two HTTP header names of the idempotency protocol, as
const strings:Idempotency-Key(the request header a client sends) andX-Idempotent-Replay(the response header a server appends when it served a cached body) (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:19,25). - Depends on: nothing.
- Concept introduced, the shared wire-literal.
[Rubric §15, Best Practices & Code Quality]assesses whether a fact that two components must agree on has exactly one home.[Rubric §9, API & Contract Design]assesses whether the protocol between client and server is expressed explicitly. Both ends of this protocol are first-party but live in packages that do not reference each other: the filter that reads the key ships inMMCA.Common.API, the service bases that write it ship inMMCA.Common.UI. The doc comment states the consequence plainly: "Hard-coding the string in both places is exactly the drift this constant exists to prevent" (IdempotencyHeaders.cs:8-12). Putting the literal inMMCA.Common.Shared, the one assembly both sides already depend on, is the standard placement rule for cross-layer constants in this framework, and it is the same ruleConcurrencyETagand the auth request DTOs follow. - Walkthrough: a
static classwith twoconst stringfields and nothing else (IdempotencyHeaders.cs:13-26).constrather thanstatic readonlyso the values can appear in attribute arguments and constant patterns, matchingAuthClaimTypesandRoleNamesin this group. - Why it's built this way:
ADR-017 defines the protocol: the
client supplies the key, and a server that replays a cached response adds
X-Idempotent-Replay: trueso the caller can tell a replay from a fresh execution (Website/docs-src/adr/017-request-idempotency.md:31,46). - Where it's used: server side,
IdempotencyFilterre-exports the request header name as a public property (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:73), reads it in the one helper both filter stages share (IdempotencyFilter.cs:165), and appends the replay header when it serves a cached response (IdempotencyFilter.cs:383);NotificationsControllerreads the same request header directly (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:62). Client side,EntityServiceBase<TEntityDTO, TIdentifierType>attaches a generated key on retried writes (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:386), as do ADC'sSessionQuestionUIService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/SessionQuestionUIService.cs:73) andLivePollUIService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LivePollUIService.cs:93,156).
ModuleNameConventions
MMCA.Common.Shared ·
MMCA.Common.Shared.Conventions·MMCA.Common/Source/Core/MMCA.Common.Shared/Conventions/ModuleNameConventions.cs:10· Level 0 · class (static)
- What it is: one function,
GetModuleName(Type), that reads a CLR type's namespace and returns the module that owns it, following the workspace naming conventionMMCA.{App}.{Module}.{Layer}(MMCA.Common/Source/Core/MMCA.Common.Shared/Conventions/ModuleNameConventions.cs:10-51).MMCA.Store.Sales.Domain.Ordersgives"Sales";MMCA.ADC.Conference.Application.Sessionsgives"Conference"; a type outside that shape givesnull. - Depends on: nothing first-party;
System.Array.FindIndex/Array.Existsandstring.Split(BCL). - Concept introduced, convention over configuration, with the convention hoisted to one function.
[Rubric §15, Best Practices & Code Quality]assesses whether a rule two subsystems must agree on has one implementation.[Rubric §7, Microservices Readiness]assesses whether module ownership is a first-class, machine-readable fact rather than a naming habit. Two very different subsystems need to answer "which module does this type belong to": persistence, which turns the answer into a SQL schema name and a logical data-source name, and the CQRS logging decorators, which stamp it into every log scope. The class doc names the constraint that forced the hoist: Application "may not reference Infrastructure" (ModuleNameConventions.cs:6-8), so the derivation cannot live beside the persistence code that first needed it.[Rubric §3, Clean Architecture]in miniature: the rule moved down toMMCA.Common.Shared, the assembly every layer may see, rather than up into a layer that would have inverted the dependency. The alternative (each subsystem parsing namespaces its own way) is the drift the test file names explicitly: the two callers "must never disagree" (MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Conventions/ModuleNameConventionsTests.cs:10-11). - Walkthrough:
NonDomainLayerSegmentsis a privatestatic readonly string[]holdingApplication,Infrastructure,API,UI(ModuleNameConventions.cs:17). What is absent is the load-bearing part, and the comment says so:Sharedis deliberately not in the list, so framework namespaces such asMMCA.Common.Shared.*cannot resolve to a phantom module (:13-16).GetModuleName(:38) splits the namespace, defaulting to an empty array when the type has none (:40), so a compiler-generated or global-namespace type is handled without a null check at the call site.- Rule one,
Domainat any position past the first segment (:41-46). This is the original persistence rule, and the remark records that it is kept byte-for-byte so schema and data-source names do not move (:26-28).domainIndex >= 1is the guard: a namespace that starts withDomainhas nothing preceding it to name. - Rule two, the other layer segments, only at the fourth segment or later (
:48-50). ThelayerIndex >= 3test is what makesMMCA.Common.Application.*returnnullrather than the phantom"Common": in that namespaceApplicationsits at index 2, below the threshold, while inMMCA.ADC.Conference.Applicationit sits at index 3 and yields"Conference"(:29-31). - Every comparison is
StringComparison.OrdinalIgnoreCase(:42,49), and the first matching segment wins (:22-23), so a module that happens to be named after a layer word resolves deterministically rather than by accident of ordering.
- Why it's built this way: no ADR governs the derivation; the rationale is entirely in the class
doc comment and in the test file's summary. The asymmetric threshold (any position for
Domain, index 3 or later for the rest) is not elegance, it is compatibility: relaxing theDomainrule would rename live SQL schemas, and tightening the others is what keeps framework namespaces module-less. - Where it's used: two subsystems, exactly as the doc comment claims.
LoggingQueryDecorator<TQuery, TResult>andLoggingCommandDecorator<TCommand, TResult>each resolve it into aprivate static readonly string ModuleName, falling back to"unknown"(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingQueryDecorator.cs:75,.../LoggingCommandDecorator.cs:76) and push it into the log scope (LoggingQueryDecorator.cs:26,68,LoggingCommandDecorator.cs:26,69). Because the field isstaticon a closed generic, the namespace parse happens once per query or command type rather than per execution, which the doc comment calls out (LoggingQueryDecorator.cs:70-74). On the persistence side, the internalNamespaceConventionswrapper delegates straight to it (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/NamespaceConventions.cs:20-21), and that wrapper is whatEntityTypeConfiguration<TEntity, TIdentifierType>uses for the SQL Server schema name (falling back todbo,.../Configuration/EntityTypeConfiguration/EntityTypeConfiguration.cs:66) and the Cosmos container name (EntityTypeConfiguration.cs:87), and whatEntityDataSourceRegistryuses to derive a logical database name when no[UseDatabase]attribute overrides it (.../DataSources/EntityDataSourceRegistry.cs:181). - Caveats / not-in-source: the parse keys on the type's own namespace and never on its type
arguments, so
List<SalesFakeAggregate>resolves tonullrather than"Sales"; that is pinned by test rather than by anything visible in the method (ModuleNameConventionsTests.cs:46-48). The "framework namespaces resolve to no module" behavior has no direct test in this file either: the comment records that it is pinned indirectly, by theLoggingCommandDecoratortests asserting a scope of"unknown"for their own fake command (ModuleNameConventionsTests.cs:41-44).
PrivacyFeatures
MMCA.Common.Shared ·
MMCA.Common.Shared.Privacy·MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:6· Level 0 · class (static)
- What it is: one
const stringnaming the feature flag that gates the data-subject export surface,Privacy.DataExport(MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:9). - Depends on: nothing first-party.
- Concept introduced, the feature flag as a shared constant.
[Rubric §30, Compliance / Privacy / Data Governance]assesses how the codebase handles data-subject rights and how deliberately those surfaces are turned on.[Rubric §9, API & Contract Design]assesses whether concerns like feature gating are applied uniformly rather than ad hoc. A data-subject access endpoint returns a complete dossier of one person's personal data, so it is the last endpoint that should default to reachable. Naming the flag once, in the assembly every layer can see, lets the attribute that gates the controller and the host configuration that enables it refer to the same string. The flag's own evaluation is theMicrosoft.FeatureManagement[FeatureGate]attribute, whose behavior is not this type's concern; see ADR-031. - Walkthrough: a
static classcontaining a singlepublic const string DataExport = "Privacy.DataExport";(PrivacyFeatures.cs:6-10). The dotted name is a namespace convention for the flag key, not C# syntax: it is one opaque string as far as the feature manager is concerned. - Why it's built this way:
ADR-076 makes the whole export
capability opt-in and records the gate explicitly: a host that has not turned the feature on gets a
404 from the endpoint rather than an unauthorized-looking 403
(
Website/docs-src/adr/076-data-subject-export.md:124). - Where it's used: the framework side is
DataExportControllerBase<TQuery>, which carries[FeatureGate(PrivacyFeatures.DataExport)]on the class (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:58) with the rationale in the same file's remarks (DataExportControllerBase.cs:52-53). Both apps subclass that base with a thin, route-only controller:UsersDataExportController(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersDataExportController.cs:26-35,MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/UsersDataExportController.cs:28-38), and both Identity service hosts turn the flag on in configuration (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/appsettings.json:21,MMCA.Store/Source/Services/MMCA.Store.Identity.Service/appsettings.json:18). ADC's config carries the operational warning beside it: the flag "must stay true: with the flag off the endpoint 404s and ADC has no other DSAR surface" (MMCA.ADC/.../appsettings.json:17-19). - Caveats / not-in-source: those two
appsettings.jsonfiles are the only places in the workspace that declare the flag. Both apps deploy the endpoint from their Identity service host, so the deployed path is covered, but any other host that mounted the controller would serve a 404 until it added its ownFeatureManagemententry.
Releaser
MMCA.Common.Shared ·
MMCA.Common.Shared.Concurrency·MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:78· Level 0 · record struct (readonly, nested)
- What it is: the handle
KeyedSemaphoreStripe.AcquireAsyncreturns. Disposing it releases the stripe that was taken (MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:78-86). - Depends on: nested inside
KeyedSemaphoreStripe; implementsSystem.IDisposable; wraps aSystem.Threading.SemaphoreSlim(BCL). - Concept introduced, the disposable-scope handle over a manual acquire/release pair.
[Rubric §15, Best Practices & Code Quality]assesses whether resource lifetimes are expressed so the compiler enforces them. A rawSemaphoreSlimrequiresWaitAsyncandReleaseto be paired by hand, and the pairing has to survive an exception in between; forgetting thefinallydeadlocks every later caller on that semaphore permanently. Returning a handle turns the pairing into ausingstatement, which the compiler expands to atry/finallyfor you. The caller's whole contract becomes one line, and the doc comment says so: "Await the call inside ausingstatement so the release happens even when the guarded work throws" (KeyedSemaphoreStripe.cs:53-55).[Rubric §12, Performance & Scalability]: making it areadonly record structmeans the handle costs one machine word on the stack rather than a heap allocation on the hot path of every cache read. - Walkthrough: one private field,
SemaphoreSlim? _stripe(KeyedSemaphoreStripe.cs:80), set by aninternalconstructor so only the enclosing stripe set can hand out a live handle (KeyedSemaphoreStripe.cs:82).Disposeis_stripe?.Release()(KeyedSemaphoreStripe.cs:85). The null-conditional is load-bearing rather than defensive noise: a struct always has a parameterlessdefaultform that no constructor ever ran for, sodefault(Releaser).Dispose()is reachable C# and must be a no-op instead of aNullReferenceException. The doc comment states that guarantee (KeyedSemaphoreStripe.cs:84). - Why it's built this way: synchronous
IDisposablerather thanIAsyncDisposablebecauseSemaphoreSlim.Releasedoes not block. Contrast the distributed path, whereInProcessDistributedLockreturns anIAsyncDisposable?(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/InProcessDistributedLock.cs:42), because releasing a lock held in a remote store is I/O. - Where it's used: every caller of
AcquireAsync, always inside ausing:MemoryCacheServiceatMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:101,112,132,CookieSessionRefresheratMMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:103,IdempotencyFilteratMMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:206, and theICacheServiceGetOrCreateAsyncdefault implementation atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:112.CachingQueryDecorator<TQuery, TResult>is the one caller that names the type explicitly: itsTryAcquirePopulateLockAsyncreturnsKeyedSemaphoreStripe.Releaser?(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:178, acquiring at:182or, under a budget,:189), so the nullable handle can carry "no lock was taken" as a value rather than as a separate flag.
UserDataExportSectionDTO
MMCA.Common.Shared ·
MMCA.Common.Shared.Privacy·MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:61· Level 0 · record
- What it is: one section of a data-subject export package: a
SectionName, anAvailableflag, an opaqueDatapayload, and anUnavailableReason(MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:61-89). It is the envelope around whatever one contributor holds about the subject. - Depends on: nothing first-party;
System.Runtime.Serialization.DataContractAttribute/DataMemberAttribute(BCL). It is the element type ofUserDataExportDTO.Sections. - Concept introduced, "no data" is not the same fact as "not retrieved".
[Rubric §29, Resilience & Business Continuity]assesses how a composite operation behaves when one contributor is down.[Rubric §30, Compliance / Privacy / Data Governance]assesses whether a data-subject right can be honored under partial failure. A naive export either fails whole when any peer is unreachable (denying the subject the data that is available) or silently omits the failed section (telling the subject, falsely, that nothing is held there). This envelope refuses both: a section that could not be produced is still present in the document, reportingAvailable = false, and the doc comment records the distinction the reader must draw: false "means the section is incomplete and the export can be retried later; it does not mean the subject has no data here" (UserDataExportDTO.cs:68-69). This is the shape ADR-096 calls best-effort, applied to a read. - Walkthrough: four
init-only properties, ordered explicitly with[DataMember(Order = n)](UserDataExportDTO.cs:64,71,79,87) so the serialized field order is a stated part of the contract rather than a reflection accident.SectionNameandAvailablearerequired(UserDataExportDTO.cs:65,72), so a section envelope cannot be constructed without answering both questions.Datais typedobject?for the same reasonUserDataExportDTO.Subjectis: the framework owns the envelope, the contributor owns the payload shape, andSystem.Text.Jsonserializes anobject-typed property by its runtime type (UserDataExportDTO.cs:74-78). The fourth property carries the section's most security-sensitive rule:UnavailableReasonis "a short, caller-safe explanation" that "never carries exception messages, stack traces, connection strings, or peer addresses: this string is handed to the data subject" (UserDataExportDTO.cs:82-86). - Why it's built this way:
ADR-076 settled the three
questions neither app had answered, the first of which was exactly what an export does when one
contributing source is unavailable
(
Website/docs-src/adr/076-data-subject-export.md:52-53). Degrading one section preserves the legal deadline on the rest of the document, and the ADR names the trade-off it accepts in return: an export that looks successful can be incomplete, which is what theAvailableflag exists to disclose (076-data-subject-export.md:84-88). - Where it's used: produced by
ExportUserDataHandlerBase<TUser, TQuery>on both paths of its per-sectiontry/catch: from a successfulUserDataExportSectionResult(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:177-183) and from thecatchthat degrades a throwing contributor (ExportUserDataHandlerBase.cs:185-197), where the reason is the fixed string onUserDataExportSectionDefaultsrather than anything derived from the exception (:196) and the exception detail goes to the log instead (:190). The envelopes are collected intoUserDataExportDTO.SectionsatExportUserDataHandlerBase.cs:104,116. The contributors themselves implementIUserDataExportSection(ExportUserDataHandlerBase.cs:167). - Caveats / not-in-source: nothing prevents an envelope from setting
Available = trueand a non-nullUnavailableReasonat the same time, orAvailable = falsewith a payload. The consistency is a convention the producing handler upholds, not a type invariant.
IcsCalendarBuilder
MMCA.Common.Shared ·
MMCA.Common.Shared.Calendars·MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:12· Level 1 · class (static)
- What it is: a dependency-free RFC 5545 writer. Given a product id, a collection of
IcsEvent, and a timestamp, it returns a completeVCALENDARdocument as a string (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:22-41). - Depends on:
IcsEvent;System.Text.StringBuilder,System.Text.Encoding, andSystem.Globalization.CultureInfo(BCL). No NuGet package. - Concept introduced, the deterministic pure builder.
[Rubric §14, Testability]assesses whether behavior can be asserted without a harness. This type takesdtStampas a parameter rather than reading a clock, and the doc comment states the consequence: "Deterministic by design: the caller suppliesdtStamp, so identical inputs produce identical output" (IcsCalendarBuilder.cs:9-10). That makes the whole document byte-assertable, which is exactly what the suite inMMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Calendars/IcsCalendarBuilderTests.csdoes, including a determinism test that builds twice and compares (IcsCalendarBuilderTests.cs:138-143).[Rubric §32, Dependency & Supply-Chain]assesses what the framework takes on as a dependency. Emitting an ICS file is a few hundred lines of string handling; taking a calendar library for it would add a transitive surface toMMCA.Common.Shared, the assembly every other package depends on. The type instead states its scope as "the subset every calendar app imports reliably" (IcsCalendarBuilder.cs:7-9). - Walkthrough:
MaxLineOctets = 75(IcsCalendarBuilder.cs:14) is RFC 5545's content-line limit, counted in octets rather than characters.Build(:22) guards both inputs (ThrowIfNullOrWhiteSpaceon the product id,ThrowIfNullon the events,:24-25), then writes the fixed calendar preambleVERSION:2.0, an escapedPRODID,CALSCALE:GREGORIAN, andMETHOD:PUBLISH(:28-32), loops the entries in the order given (:34-37), and closes the document (:39). Note that an empty collection is legal: it produces a valid, entry-less calendar, whichIcsCalendarBuilderTests.cs:151pins.AppendEvent(:43) writes the five mandatoryVEVENTlines:UID,DTSTAMP,DTSTART,DTEND,SUMMARY(:46-50).DESCRIPTIONandLOCATIONare emitted only when the optional field is not null or whitespace (:52-60), so an all-blank location does not leave a stray empty property in the document.FormatUtc(:65) is where the UTC-only contract shows up on the wire: it converts throughUtcDateTimeand formatsyyyyMMdd'T'HHmmss'Z'underInvariantCulture(:66). The invariant culture is not optional decoration; a non-Gregorian or non-ASCII-digit current culture would otherwise corrupt the timestamp.EscapeText(:69) implements RFC 5545 section 3.3.11 TEXT escaping. Order matters and is correct here: backslash is escaped first (:71), so the backslashes introduced by the later replacements are not double-escaped. Semicolon and comma follow (:72-73), then all three newline forms collapse to the literal escaped-nsequence (:74-76), CRLF before its parts so a Windows line break does not become two escapes.AppendLine(:83) is the subtlest method: RFC 5545 folding. It walks the string counting UTF-8 octets per character, treating a surrogate pair as one unit (:89-90), and when the next character would push the line past 75 octets it emitsCRLFplus a single space and resets the counter to1(:92-96). Two details are easy to get wrong and are handled: a fold never splits a multi-byte character (because the decision is made per character, before appending), and the continuation line's leading space counts against its own budget, which the inline comment states (:95). Every line, folded or not, ends inCRLF(:103).
- Why it's built this way: no ADR covers calendar export; the rationale is entirely in the doc
comments cited above. The minimal-subset choice is the same instinct as the
IcsEventUTC contract: avoid the parts of the specification whose correctness would need continuous maintenance. - Where it's used: ADC's Conference module only, from
ExportSessionCalendarHandlerfor a single session (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:51-54) andExportEventCalendarHandlerfor a whole event (.../ExportCalendar/ExportEventCalendarHandler.cs:46-53), both passingCalendarExportMapper'sProductIdconstant-//MMCA//AtlDevCon//EN(.../ExportCalendar/CalendarExportMapper.cs:17). - Caveats / not-in-source: both ADC handlers pass
DateTimeOffset.UtcNowfordtStamp(ExportSessionCalendarHandler.cs:54,ExportEventCalendarHandler.cs:53) rather than an injectedTimeProvider, so the determinism the builder guarantees is available to its own tests but not exercised through the handlers.
KeyedSemaphoreStripe
MMCA.Common.Shared ·
MMCA.Common.Shared.Concurrency·MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22· Level 1 · class
What it is: an in-process, per-key mutual-exclusion primitive. Callers ask to serialize on a string key; the key is hashed onto one of a fixed number of
SemaphoreSlimstripes, and the caller gets back aReleaserto dispose (MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22-86).Depends on: its own nested
Releaser;System.Threading.SemaphoreSlim(BCL).Concept introduced, lock striping.
[Rubric §12, Performance & Scalability]assesses how shared state is guarded under concurrency and what that guard costs. The naive way to lock per key is aConcurrentDictionary<string, SemaphoreSlim>, and the class doc comment lays out why that shape is a trap, in the code rather than in tribal memory (KeyedSemaphoreStripe.cs:8-15):- If you remove the entry when the last holder releases, you open a race. Caller A looks the semaphore up, then B releases and removes it, then A waits on an object no longer in the table while C creates a fresh one and takes that. A and C now both run the guarded section, which is precisely what the lock existed to prevent.
- If you never remove it, the table grows without bound, and the keys here are caller-supplied (an idempotency key, a parameterized cache key), so that is an attacker-influenced memory leak.
Striping sidesteps both by never creating or destroying anything: the table is allocated once at the declared width and every key maps into it forever. The price is stated honestly in the same comment: two unrelated keys can collide on a stripe and briefly serialize against each other. That is harmless for the double-check-locking callers this exists for, because each one re-checks its own key's state after acquiring (
KeyedSemaphoreStripe.cs:13-15).Walkthrough:
DefaultWidth = 256(:25), described as "ample concurrency without a meaningful memory cost" (:24); 256SemaphoreSliminstances is a fixed, small, one-time allocation.- The parameterless constructor chains to the width-taking one (
:30-33). The real constructor validates withArgumentOutOfRangeException.ThrowIfLessThanOrEqual(width, 0)(:39), then eagerly fills the array with binary semaphores,new SemaphoreSlim(1, 1)(:42-46). Eager fill is what removes every later allocation and every later race: after the constructor there is no mutation of the table at all, which is why the type is safe to share without any lock of its own. Widthis a get-only property (:50), exposed so tests can reason about collisions; one test computes the exact stripe index a key lands on, precisely so it "cannot flake on the one-in-DefaultWidthcollision" (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefresherTests.cs:270,291).AcquireAsync(:60) resolves the stripe, awaitsWaitAsync(cancellationToken)withConfigureAwait(false)per ADR-049 (:63), and wraps the semaphore in aReleaser(:64). The parameter doc draws a line worth remembering: the token "Cancels the wait, not the work that follows it" (:58).GetStripe(:67) does the hashing:(uint)string.GetHashCode(key, StringComparison.Ordinal) % (uint)Width(:73). Two deliberate choices, both commented (:71-72).StringComparison.Ordinalis passed explicitly rather than relying on the default, which keeps the mapping culture-independent. And the sign is folded by casting touintrather than callingMath.Abs, becauseint.MinValuehas no positive counterpart andMath.Abswould throw on it.
Why it's built this way: the class is a hoisted shared primitive rather than a private helper because five separate call sites needed the same guard. ADR-017 records its role in the idempotency filter explicitly: the striped semaphore is the fallback for a host that registers no
IDistributedLock, and the ADR reproduces the same two-defects argument (Website/docs-src/adr/017-request-idempotency.md:59-65). The scaling limit is stated there too: a process-local lock only serializes duplicates that land on the same replica (017-request-idempotency.md:91-93), which is whyIDistributedLockis preferred when present (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:34-37).Where it's used: five holders, all
staticor instance fields that live for the lifetime of their owner, matching the remark that instances are "intended to be held in a static field for the process lifetime" and that stripes are never disposed (KeyedSemaphoreStripe.cs:18-21):IdempotencyFilter(IdempotencyFilter.cs:90),CookieSessionRefresher(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:63),MemoryCacheService(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:38), theCacheKeyLocksholder behindICacheService'sGetOrCreateAsyncdefault implementation (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:142-145), and theQueryCacheKeyLocksholder behindCachingQueryDecorator<TQuery, TResult>(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:246-249).InProcessDistributedLockcites the same reasoning in its own doc comment (InProcessDistributedLock.cs:20).Caveats / not-in-source: .NET randomizes string hash codes per process, so the stripe a given key lands on differs between runs. That is invisible to correctness (any key consistently maps to one stripe within a process) but it means collision behavior cannot be reproduced across processes.
UserDataExportDTO
MMCA.Common.Shared ·
MMCA.Common.Shared.Privacy·MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15· Level 1 · record
- What it is: the whole data-subject export package: a format version, a generation timestamp, the
subject's id, an app-owned snapshot of the account itself, and a list of
UserDataExportSectionDTOenvelopes (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15-49). - Depends on:
UserDataExportSectionDTO; theUserIdentifierTypealias (ADR-085);System.Runtime.Serializationattributes (BCL). - Concept introduced, the versioned, PII-by-design document.
[Rubric §30, Compliance / Privacy / Data Governance]assesses how personal data is classified and handled. Most DTOs in this codebase carry incidental personal data; this one is personal data end to end, and the type says so in bold in its own summary: "This document is PII by design. It exists to hand a data subject everything an app holds about them, so it must only ever be produced for the account owner (or a privileged role) and must never be logged, cached, or persisted by the pipeline that serves it" (UserDataExportDTO.cs:9-11). That single comment is what makes three otherwise-invisible decisions legible: the query is notIQueryCacheable, so the caching decorator never sees it (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:43-44); the degradation path logs the exception but hands the subject a generic reason (ExportUserDataHandlerBase.cs:188-196); and the controller serializes to bytes and returns a file rather than anObjectResult(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:101-109).[Rubric §9, API & Contract Design]:FormatVersionversions "the export document shape itself (not the app's data)" (UserDataExportDTO.cs:18-19), so a consumer parsing an old file can detect an envelope change rather than guess at it. - Walkthrough: five
init-only properties under[DataContract](:14), each with an explicit[DataMember(Order = n)](UserDataExportDTO.cs:21,25,29,39,47) pinning field order into the contract.FormatVersion,GeneratedOn, andUserIdarerequired(:22,26,30), so the envelope cannot be constructed without them.Subjectisobject?(:40), and the doc comment gives the full reasoning: the framework owns the envelope, each app owns which of its own fields are portable personal data, and anobject-typed property serializes by its runtime type underSystem.Text.Json(:32-38). That last clause is the mechanism that makes the erasure of the static type harmless.nullis legal and means the app publishes no subject fields.Sectionsdefaults to an empty collection expression,= [](:48), so an export with no registered contributors is a well-formed document rather than a null-bearing one. Order is the section registration order, which the comment makes part of the contract (:42-46).
- Why it's built this way:
ADR-076 hoisted this shape out of
two near-identical app implementations. It is the export half of the data-subject obligation whose
erasure half was settled by
ADR-005, which explicitly scoped
export out and left it to consumers
(
Website/docs-src/adr/076-data-subject-export.md:21-23). - Where it's used: it is the result type of the export query all the way through the stack.
ExportUserDataHandlerBase<TUser, TQuery>implementsIQueryHandler<TQuery, Result<UserDataExportDTO>>(ExportUserDataHandlerBase.cs:53), stampsCurrentFormatVersion = "1.0"into it (ExportUserDataHandlerBase.cs:61,112), and takesGeneratedOnfrom an injectedTimeProviderrather than a static clock (ExportUserDataHandlerBase.cs:113).DataExportControllerBase<TQuery>declares it as the 200 response type (DataExportControllerBase.cs:78) and derives the download file name from the package's ownGeneratedOnso the file name and the document can never disagree (DataExportControllerBase.cs:109,124-134). Both apps subclass the handler (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:35,MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:39) and expose it through their ownUsersDataExportController(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersDataExportController.cs:27,MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/UsersDataExportController.cs:29).
ProblemDetailsResultReader
MMCA.Common.Shared ·
MMCA.Common.Shared.Http·MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs:58· Level 3 · class (static)
- What it is: the client-side inverse of the API's error edge. It reads an RFC 9457 Problem Details
body (or a non-JSON body, or no body at all) and turns it back into the
Errorlist and failedResultthe server started from (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs:58-476). - Depends on:
Error,ResultandErrorTypefromMMCA.Common.Shared.Abstractions(ProblemDetailsResultReader.cs:4); BCL only otherwise:System.Text.Json,System.Collections.Frozen,System.Net.Http.HttpResponseMessage,System.Globalization(:1-3). - Concept introduced, closing the Result round trip across an HTTP hop. The framework's error
currency is a
Resultcarrying typedErrors, and ADR-013 makes that the whole point: business failures are values, not exceptions. HTTP does not carryResults, soApiControllerBaseprojects a failure into Problem Details on the way out. Without a reader, the client half of that trip is lossy in the worst way: a Blazor page would see "500" or anHttpRequestExceptionwhere the server said "Session.AlreadyClosed, Conflict". This type is the missing half.[Rubric §9, API & Contract Design]assesses whether the contract is honored symmetrically at both ends;[Rubric §29, Resilience & Business Continuity]assesses whether a caller degrades usefully rather than crashing, which is why every unreadable-body path still yields exactly one usableError. Its placement is the same ruleIdempotencyHeadersandConcurrencyETagfollow, stated in the doc comment: the client half of the round trip isMMCA.Common.UI, "which references Shared only" (ProblemDetailsResultReader.cs:15-18), so the reader lives in Shared and uses nothing beyond the BCL. The doc comment also enumerates the four payload shapes it understands and, crucially, states its own fidelity limit in the same breath (:20-56): only the MMCA error array is lossless; the other three derive theErrorTypefrom the status code, "which is lossy for 400 Bad Request". - Walkthrough:
- Three public
const stringcodes name the synthesized failures:StatusErrorCodePrefix = "Http."(:65),EmptyResponseCode = "Http.EmptyResponse"(:71), andMalformedResponseCode = "Http.MalformedResponse"(:77). They are public precisely so tests and callers can branch on them without re-spelling the literal. - A block of private
consts holds every JSON member name it looks for (:79-88), so the wire vocabulary is declared once. StatusCodeToErrorTypeis aFrozenDictionary<int, ErrorType>(:96-106), documented as "the exact reverse ofErrorHttpMapping.ErrorTypeToStatusCode" (:91-92). That forward map really does collapse three types onto 400 (Validation,InvariantandFailure, atMMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:22,23,29), which is exactly why the reverse is lossy there and picksValidation.FromHttpStatusCode(int)(:128) is the public reverse mapping: a dictionary hit wins (:130-133), any other 4xx becomesFailure, anything else (5xx included) becomesUnexpected(:135).ParseProblemDetails(int, string?)(:153) is the pure core, and the doc says why it is separated from the HTTP surface: "no HTTP, no I/O, no allocation of anHttpResponseMessage, so it can be tested directly against captured payloads" (:139-141), which is[Rubric §14, Testability]made structural. Its flow: a blank body synthesizes one error (:155-158); aJsonExceptionis caught and does the same, with the comment naming the real-world cases ("a bare challenge, an HTML error page, a proxy response",:161-169); a root that is not a JSON object likewise (:174-177). Otherwise it resolves the effective status (:179) and the fallback type (:180), then branches on theerrorsmember: an array goes toReadErrorArray(:186-188), an object toReadValidationDictionary(:190-192). Parsed errors win only when the list is non-empty (:195-198); otherwise it falls through to a synthesized error carryingdetailortitle(:201).ResolveStatus(:419) is a small but deliberate affordance: a caller that passes a non-positive status gets the status read out of the body's ownstatusmember instead (:426-430), which is what makes the parser usable against a captured payload with no response object around it.ReadErrorArray(:319) maps each object element throughReadErrorObjectand, notably, still salvages a degraded array of plain strings (:331-336).ReadErrorObject(:342) readscode,message,type,sourceandtarget, with a three-step fallback for the message (message, thencode, then the generic default) so anErroris never constructed with an empty one (:348-353).ReadValidationDictionary(:356) handles the standard ASP.NET Core shape. The key becomesValidation.{propertyName}, or bareValidationfor an object-level rule with an empty key (:363-365), and the property name is carried separately asTarget(:366). A value may be an array of messages or a single one, and both paths funnel throughAddValidationError(:368-378), which silently ignores non-string and blank entries (:391-400).ParseErrorType(:403) is the one place an inbound string becomes an enum, and it is defensive in the right way:Enum.TryParsewithignoreCase: trueplusEnum.IsDefined(:405-406). Without the second checkTryParsewould happily accept an arbitrary numeric string and hand back an undefined enum value.ToFailureResult(:212) lifts the parsed errors into a failedResult.ReadAsync(:223) short-circuits on a 2xx toResult.Success()(:229-232) and otherwise parses the body.ReadAsync<T>(:257) is the value-returning overload: a non-success status parses errors (:269), a blank 2xx body is a failure codedEmptyResponseCode(:272-279), a body that deserializes tonullis the same failure with a different message (:284-289), and aJsonExceptionbecomesMalformedResponseCode(:292-295). The "204 is a failure here" rule is stated in the doc with its escape hatch: use the non-generic overload for endpoints that legitimately answer without a body (:241-246).ReadBodyAsync(:298) buffers the content as a string and swallows anHttpRequestExceptionback tonull(:311-316), with the comment explaining the judgement: a truncated body should still report the status-level failure rather than surface a transport exception "from a reader".TryGetProperty(:444) does case-insensitive member lookup, trying the exact name first and only then enumerating (:453-459). The doc gives the reason (:438-443): the wire form is camelCase, but a hand-assembled or differently-configured payload can be PascalCase, and a reader that understood only one "would silently drop every error field".
- Three public
- Why it's built this way:
ADR-094 is the governing
record. It states that the client dispatch "returns a
Result; it does not throw", hands the response to this reader in both service-base overloads, and records what the change replaced: the client used to pull domain wording out of the body and rethrow it as aDomainInvariantViolationExceptionbefore falling back toEnsureSuccessStatusCode, and that helper "is deleted, not deprecated" (Website/docs-src/adr/094-client-entity-data-access.md:81-92). - Where it's used: it is the single funnel for every framework-shaped HTTP read on the client.
EntityServiceBase<TEntityDTO, TIdentifierType>uses both overloads (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:339,367, documented at:304,321,347),ChildEntityServiceBaseuses all three call shapes (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:42,58,77), and the notification inbox service does the same (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:54,73,92,110). ADC's hand-written UI services call it directly rather than going through a base, for exampleUserService(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Services/UserService.cs:64,90,112,139,156),SpeakerDashboardService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerDashboardService.cs:102),CategoryItemLookupService(.../Services/CategoryItemLookupService.cs:51) andSessionQuestionUIService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/SessionQuestionUIService.cs:35,54,80). The round trip is pinned end to end byProblemDetailsRoundTripTests, which serializes a real failure through the API edge and reads it back withParseProblemDetails(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/ProblemDetailsRoundTripTests.cs:47,71,86,125). - Caveats / not-in-source:
ErrorHttpMappingisinternaltoMMCA.Common.API(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:14) and this reader lives inMMCA.Common.Shared, so the two dictionaries cannot reference each other. Nothing in the type system keeps the reverse map aligned when a newErrorTypeor status is added to the forward one; the alignment rests on the doc comment (ProblemDetailsResultReader.cs:91-94) and on the round-trip test. The[Rubric §15, Best Practices & Code Quality]reading is that this is a knowingly accepted duplication, priced against giving Shared a reference to the API package.
ForgotPasswordRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11· Level 1 · class
- What it is: the FluentValidation validator for
ForgotPasswordRequest. It checks one field,Email, for non-empty and address shape (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:13-16). - Depends on:
ForgotPasswordRequest;FluentValidation'sAbstractValidator<T>(NuGet). - Concept introduced, validation that deliberately stops short.
[Rubric §11, Security]assesses whether the system leaks facts an attacker can use, and account enumeration is the classic leak: if "forgot password" answers differently for a registered and an unregistered address, the endpoint becomes a membership oracle. The forgot-password endpoint answers202 Acceptedunconditionally (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:79,92), and this validator is the place that could quietly undo it: a rule that checked whether the address belongs to an account would turn a miss into a400, which is the same oracle by a different status code. The class doc comment names that trap and refuses it: a 400 there "would be the enumeration oracle the always-accepted response exists to close" (ForgotPasswordRequestValidator.cs:7-9), and the controller's own remarks agree that only a malformed payload may reach 400 (PasswordResetAuthControllerBase.cs:28-30).[Rubric §24, Forms / Validation / UX Safety]: shape validation still runs, so a genuinely malformed address gets a useful client-side message without costing an email send. - Walkthrough: an expression-bodied constructor with a single chained rule,
RuleFor(x => x.Email).NotEmpty().EmailAddress(), each stage carrying an explicitWithMessage(ForgotPasswordRequestValidator.cs:13-16). The messages are literal English strings rather than resource lookups, which is how every validator in this assembly is written. - Why it's built this way: the reset flow itself is ADR-091; the uniform-response posture it depends on is only as strong as its weakest responder, and a validator runs before the handler does.
- Where it's used: registered by assembly scan.
services.AddValidatorsFromAssemblyContaining<ClassReference>()(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:51) picks up every validator inMMCA.Common.Application, with the comment explaining why it must happen here rather than in the per-module scan (DependencyInjection.cs:48-50). The resolvedIValidator<ForgotPasswordRequest>is then consumed indirectly: an app's forgot-password command implementsICommandWithRequest<ForgotPasswordRequest>(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:43), andCommandRequestValidator<TCommand, TRequest>bridges the command'sRequestproperty to this validator (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommandRequestValidator.cs:37-39), auto-registered for every such command atDependencyInjection.cs:254-270.
LoginRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:11· Level 1 · class
- What it is: the validator for
LoginRequest:Emailmust be non-empty and a valid address,Passwordmust be non-empty (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:15-20). - Depends on:
LoginRequest;FluentValidation'sAbstractValidator<T>. - Concept: the same "validation that deliberately stops short" posture introduced by
ForgotPasswordRequestValidator.[Rubric §11, Security]: the doc comment is explicit that the minimalism is a security property, not laziness. Credential verification "happens in the authentication service to avoid leaking information about which field was wrong" (LoginRequestValidator.cs:7-9). Notice what is absent: noPasswordRules<T>orStrongPasswordRules<T>include. Applying the complexity policy at login would tell an attacker that a candidate password could not possibly be the stored one, and would lock out any account whose password predates the current policy. Complexity belongs on the writing paths only, which is whyResetPasswordRequestValidatorincludes it and this one does not. - Walkthrough: a block-bodied constructor with two independent
RuleForchains (LoginRequestValidator.cs:15-20), each stage given an explicitWithMessage. FluentValidation runs both rule sets and reports every failure, so a request missing both fields returns two errors rather than one. - Why it's built this way: uniform failure responses for authentication are the same discipline as
the forgot-password 202, applied to a different endpoint. The complementary defence against guessing
at scale is the per-IP rate-limit policy the login action carries,
[EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)](MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:72, with the posture stated in the class remarks at:19-21), which is ADR-029. - Where it's used: registered by the assembly scan at
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:51(which names this class in its comment,DependencyInjection.cs:48), then injected asIValidator<LoginRequest>intoAuthenticationValidators, the parameter object that bundles the three auth validators (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:17,22), which is in turn whatAuthenticationServiceBase<TUser>consumes. - Caveats / not-in-source:
AuthenticationValidatorsalso requires anIValidator<RegisterRequest>(AuthenticationValidators.cs:18,25), butMMCA.Common.Applicationships noRegisterRequestValidator: the only ones in the tree are app-level (RegisterRequestValidatoratMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:12andMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/Validation/RegisterRequestValidator.cs:13). The bundle therefore only resolves in a host whose own Application assembly has been scanned as well.
RefreshTokenRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:10· Level 1 · class
- What it is: the validator for
RefreshTokenRequest. Both fields are required and nothing more is checked (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:14-18). - Depends on:
RefreshTokenRequest;FluentValidation'sAbstractValidator<T>. - Concept: the shape is the one
ForgotPasswordRequestValidatorintroduced. What this validator teaches is why both fields are mandatory, which the doc comment states: the expired access token is needed "for claim extraction" and the refresh token "for rotation verification" (RefreshTokenRequestValidator.cs:7-8).[Rubric §11, Security]: rotation verifies the presented refresh token against the one stored for the identity carried by the access token, so a request missing either half cannot be evaluated at all. Deliberately absent: any JWT well-formedness or signature check. Parsing a token is the token service's job, and doing it here would duplicate the trust boundary in a layer that has no key material. - Walkthrough: two single-stage
RuleFor(...).NotEmpty()chains with explicit messages (RefreshTokenRequestValidator.cs:14-18). - Why it's built this way: keeping the validator to presence checks leaves exactly one place where a token's authenticity is decided, which is what makes the refresh endpoint's failure responses uniform.
- Where it's used: picked up by the same assembly scan
(
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:51, named in the comment at:47) and injected asIValidator<RefreshTokenRequest>intoAuthenticationValidators(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:19,28).
ResetPasswordRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:12· Level 1 · class
- What it is: the validator for
ResetPasswordRequest: address shape onEmail, presence onToken, and the shared strong-password policy onNewPassword(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:16-23). - Depends on:
ResetPasswordRequest;StrongPasswordRules<T>;FluentValidation'sAbstractValidator<T>and itsIncludecomposition. - Concept introduced, composing a rule set with
Include.[Rubric §11, Security]assesses whether a policy holds on every path that can change the guarded value, and[Rubric §1, SOLID]the single-responsibility split that makes that possible. A password-complexity policy is only a policy if every write path enforces it; if registration demands an uppercase letter and reset does not, reset is a documented downgrade route. FluentValidation'sIncludemerges another validator's rules for the same model type into this one, so the policy can live in exactly one class and be pulled into each writer. The doc comment states the intent: the new password goes through "the sameStrongPasswordRules<T>the registration and change-password requests use, so a reset cannot be a way around the complexity policy" (ResetPasswordRequestValidator.cs:8-10).StrongPasswordRules<T>is generic over the containing model and takes a selector expression, which is what lets one rule set attach to a differently-shaped request each time (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:188-199): it enforces non-empty, 8 to 128 characters, and one each of uppercase, lowercase, digit, and non-alphanumeric. - Walkthrough: three statements in a block-bodied constructor
(
ResetPasswordRequestValidator.cs:14-24).EmailgetsNotEmpty().EmailAddress()(:16-18), matching the forgot-password half so the two steps agree on what an address is.TokengetsNotEmpty()with a reset-specific message (:20-21); no format check, because the token's validity is a lookup, not a shape. ThenInclude(new StrongPasswordRules<ResetPasswordRequest>(x => x.NewPassword))(:23) grafts the seven policy rules onto theNewPasswordfield. Note the contrast with the weaker siblingPasswordRules<T>(CommonValidationRules.cs:174-181), which enforces length only; reset deliberately takes the strong one. - Why it's built this way: the reset flow is ADR-091, and the hashing the accepted password ends up under is ADR-102 (which supersedes ADR-032). Neither is this validator's concern, which is the point: it only decides whether the candidate is policy-compliant.
- Where it's used: registered by the assembly scan
(
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:51) and reached throughCommandRequestValidator<TCommand, TRequest>for any command implementingICommandWithRequest<ResetPasswordRequest>, the constraintResetPasswordHandlerBase<TUser, TCommand>declares (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:38). The request arrives atPasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:108), which is why a policy failure surfaces as the documented400(PasswordResetAuthControllerBase.cs:104) while a bad token collapses to401(PasswordResetAuthControllerBase.cs:97,105).
ClaimBasedUserIdProvider
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Context·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ClaimBasedUserIdProvider.cs:11· Level 8 · class
- What it is: a two-line SignalR
IUserIdProviderthat tells the hub infrastructure which user a connection belongs to, by reading the identity claim off the connection's principal (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ClaimBasedUserIdProvider.cs:6-10). - Depends on:
Microsoft.AspNetCore.SignalR(IUserIdProvider,HubConnectionContext) andClaimsPrincipalExtensionsforFindUserIdValue(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ClaimBasedUserIdProvider.cs:1-2). - Concept:
[Rubric §11, Security]assesses that identity is derived from the validated token and never from client-supplied input, and[Rubric §9, API & Contract Design]assesses whether that derivation is centralized once. SignalR keys its user-targeted sends on whatever string anIUserIdProviderreturns; the built-in provider readsClaimTypes.NameIdentifier. This framework mints the user id only intosub(seeTokenService,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:93), and whether that survives assubor arrives mapped toNameIdentifierdepends on which handler authenticated the connection. Routing through the shared extension is what makes both shapes resolve identically, so a consumer that changes its inbound claim mapping does not silently start delivering zero notifications. - Walkthrough: the entire type is
GetUserId(HubConnectionContext connection)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ClaimBasedUserIdProvider.cs:14-15), an expression body returningconnection?.User.FindUserIdValue(). Two null paths are handled without a branch: a null connection short-circuits tonull, andFindUserIdValueis an extension on a nullableClaimsPrincipalthat returnsnullwhen neither claim is present (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:26-28). An unauthenticated connection therefore has no user id, and SignalR treats it as belonging to no user rather than failing connection setup. - Why it's built this way: the value returned here is compared as a string against the string a
sender passes to
Clients.User(...), so both sides must format the identifier the same way. The raw claim value is used verbatim on this side, and the sender formats withCultureInfo.InvariantCulture(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/SignalRPushNotificationSender.cs:19), matching the invariant formatting the token writer used (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:93). Keeping the whole provider to one delegating line is what makes that three-way agreement checkable at a glance. - Where it's used: registered as
services.TryAddSingleton<IUserIdProvider, ClaimBasedUserIdProvider>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:647), in the same block that swaps the null notification implementations for the SignalR-backed ones (:630-632). SignalR's connection manager calls it on every connection, and it is what makesSignalRPushNotificationSenderreach the right sockets onNotificationHub(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/SignalRPushNotificationSender.cs:17-21for the single-user send and:25-34for the batched multi-user send).
CurrentUserService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Context·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:17· Level 9 · class
- What it is: the per-request implementation of
ICurrentUserService. It answers "who is calling" by reading claims off the current HTTP request's principal: the rawClaimsPrincipal, the typed user id, the role, and any other parsable claim by name (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:10-16). - Depends on:
ICurrentUserService(the Application port) andClaimsPrincipalExtensionsfor the identity read (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:4-5); externals areMicrosoft.AspNetCore.Http.IHttpContextAccessor,System.Security.ClaimsandSystem.Globalization. The claims it reads are the onesTokenServicewrites. - Concept introduced: a scoped identity snapshot, computed lazily and parsed invariantly.
[Rubric §3, Clean Architecture]applies first: application code needs the caller's identity but must not referenceHttpContext, so the port lives in Application and this HTTP-aware implementation lives in Infrastructure, which is the only placeIHttpContextAccessorappears.[Rubric §12, Performance & Scalability]explains theLazy<T>fields (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:19,:21): because the service is registered scoped (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:555), the claim walk happens at most once per request no matter how many handlers, filters and save operations ask.[Rubric §27, i18n]covers the trap most codebases miss: claim values are machine-written underCultureInfo.InvariantCulture, so they must be read invariantly too, or a request running under a culture with different separators misreads decimal, double andDateTimeclaims. Both parse paths say so explicitly (:40, with the comment at:38-39, and the identifier parse insideClaimsPrincipalExtensions.GetUserIdatMMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:43). - Walkthrough
- The primary constructor takes
IHttpContextAccessor httpContextAccessor(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:17) and is captured directly by the lazy initializers, so there is no field boilerplate. _userId(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:19-20) defers toClaimsPrincipalExtensions.GetUserId, which readssubfirst and falls back to the mappedClaimTypes.NameIdentifier(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:26-28) before parsing intoUserIdentifierType. That indirection is the reason a JWT-bearer request and a session-cookie request resolve to the same user._role(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:22-23) caches the firstClaimTypes.Roleclaim.User(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:26) returns the principal, substituting a fresh emptyClaimsPrincipalwhen there is no HTTP context. That fallback is what makes the service safe to resolve from a background job or hosted service: callers get an anonymous principal instead of aNullReferenceException.UserIdandRole(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:29,:31) are one-line projections of the two lazies, both nullable, bothnullwhen unauthenticated.GetClaimValue<T>(string claimType)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CurrentUserService.cs:35-42) is constrained toT : struct, IParsable<T>and calls the static abstractT.TryParse, so any parsable value type (int,Guid,DateTime) can be lifted out of a named claim without Common knowing what the claim means. It returnsnullfor an absent or unparsable claim, and unlikeUserId/Roleit is a fresh lookup on every call.
- The primary constructor takes
- Why it's built this way: scoped lifetime plus
Lazy<T>yields a stable per-request identity snapshot at minimal cost, while the empty-principal fallback keeps the same abstraction usable outside a request. Reading identity through the shared extension rather than a hand-rolledFindFirst("sub")is the load-bearing part: it is what stops a consumer's claim-mapping choice from silently emptying the current user (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:9-16). - Where it's used: registered as
services.TryAddScoped<ICurrentUserService, CurrentUserService>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:555). The highest-traffic consumer isDbContextFactory, which takes it as a constructor dependency (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:43,:57) and passesUserIdinto every save so audit fields are stamped with the acting user (:248,:291,:330,:352,:414);EFRepository<TEntity, TIdentifierType>accepts it as an optional dependency (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:26). Application handlers in both apps inject the port for ownership checks and caller-scoped queries. - Caveats / not-in-source: this class implements four members.
RolesandIsInRoleare default interface members on the port (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ICurrentUserService.cs:45,:88), not overridden here, so multi-role behavior is defined on the interface rather than in this file. Outside an HTTP requestHttpContextis null andUserIdis thereforenull, which means a save performed by a background worker stamps no acting user; nothing in this file substitutes a system identity.